Skip to content

Support atomic conditional updates with affected-row counts #65

Description

@bazer

Summary

Add a first-class DataLinq API for atomic conditional updates that executes as a single SQL UPDATE ... WHERE ...
statement and returns the number of affected rows.

This enables compare-and-set and claim-once workflows without dropping down to provider-specific command creation
and raw SQL.

Use case

An application stores a one-time callback token with a nullable consumption timestamp. A callback may proceed only
when it is the first caller to consume the token:

UPDATE callback_sessions
SET consumed_at = @consumedAt
WHERE relay_state = @relayState
  AND consumed_at IS NULL;

The application authenticates only when the affected-row count is exactly one.

The normal immutable-model flow cannot safely express this as a read followed by a mutation:

var session = CallbackSession.GetRelayState(relayState);
if (session.ConsumedAt is not null)
    return false;

var mutation = session.Mutate();
mutation.ConsumedAt = now;
mutation.Save();

Two concurrent callers can both observe ConsumedAt == null before either write completes. Transactions do not fix
that pattern unless the read obtains an appropriate database lock and the entire operation stays on the same
connection and transaction. A single conditional update is both simpler and the actual concurrency boundary.

The same primitive is useful for:

  • consuming one-time bearer tokens;
  • claiming queued jobs or callbacks;
  • optimistic state transitions such as Pending -> Processing;
  • lease acquisition and renewal;
  • compare-and-set updates using a version or current-status predicate.

Desired API characteristics

The exact API shape is open for design. Conceptually, it should allow a model/table update with separate set and
predicate expressions, for example:

var affectedRows = database
    .Update<CallbackSession>()
    .Set(x => x.ConsumedAt, now)
    .Where(x => x.RelayState == relayState && x.ConsumedAt == null)
    .Execute();

or an equivalent generated/table-scoped API.

Required semantics:

  • Execute one SQL update statement; do not materialize matching rows before the update.
  • Parameterize both assigned values and predicate values.
  • Return the provider's affected-row count.
  • Support execution inside an existing DataLinq transaction as well as directly through a database instance.
  • Define cache behavior explicitly. Cached rows affected by the update must be invalidated or updated, or the API
    must expose a safe and documented consistency contract.
  • Preserve DataLinq's provider-independent expression translation and diagnostics where possible.

Useful follow-up capabilities, if they fit the design, include multiple assignments and expressions based on the
current column value, such as incrementing an attempt counter. They are not required for the initial compare-and-set
primitive.

Acceptance criteria

  • A conditional model update is emitted as one UPDATE ... SET ... WHERE ... statement.
  • The API returns the affected-row count without requiring a second query.
  • Concurrent attempts to change the same row from the same expected state result in exactly one successful claim.
  • Predicate and assignment values are parameterized.
  • SQLite, MySQL, and MariaDB provider tests cover the same behavior.
  • Transactional and non-transactional execution are covered.
  • Cache invalidation or update behavior is documented and tested.
  • Unsupported assignment or predicate expressions fail with a focused DataLinq diagnostic rather than silently
    falling back to client-side evaluation.

Non-goals

  • This does not require bulk materialization or returning updated models.
  • This should not silently turn arbitrary LINQ queries into client-side read-modify-write operations.
  • A database-specific locking API is not a substitute for the single-statement compare-and-set contract.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions