Skip to content

Add cross-DAO atomic locking to LockedContext - #179

Open
iamamberkulkarni wants to merge 1 commit into
santanusinha:masterfrom
iamamberkulkarni:multi-row-atomic-update
Open

Add cross-DAO atomic locking to LockedContext#179
iamamberkulkarni wants to merge 1 commit into
santanusinha:masterfrom
iamamberkulkarni:multi-row-atomic-update

Conversation

@iamamberkulkarni

Copy link
Copy Markdown

Cross-DAO Atomic Locking in LockedContext

Summary

Adds two chaining methods to LockedContext that lock and mutate rows of other DAOs within the context's existing transaction. This makes it possible to lock and update multiple rows across multiple tables atomically — a single commit, a single rollback — as long as all rows reside on the same shard.

  • lockAndMutateEach — lock N rows of a RelationalDao (each via SELECT FOR UPDATE NOWAIT)
  • lockAndMutate — lock a LookupDao entity by key (via SELECT FOR UPDATE / PESSIMISTIC_WRITE)

Both join the transaction already opened by the LockedContext, so they participate in the same commit/rollback as the entry-point entity and any chained mutate / save calls.


Motivation

The existing LockedContext API locks only the entry-point entity. Any other row a transaction needs to mutate — whether additional rows of the same table or rows of a different DAO — was read/updated without a lock, leaving a window for a concurrent transaction to mutate the same row (lost update).

A representative case: an operation that must lock several rows of a sharded table and one related lookup entity, then write additional related rows — all atomically. Concretely:

  1. Lock several rows of a RelationalDao (updating a field on each)
  2. Lock and update a LookupDao entity on the same shard
  3. Save one or more related rows

Without a shared lock point across these, two concurrent operations on overlapping rows could interleave. The fix is to take SELECT FOR UPDATE on every row the transaction will mutate (not just the first), so concurrent transactions touching the same rows fail fast instead of racing.


What's added

1. lockAndMutateEach — lock multiple RelationalDao rows

public <U> LockedContext<T> lockAndMutateEach(
        RelationalDao<U> relationalDao,
        List<DetachedCriteria> criteriaList,
        UnaryOperator<U> mutator)
  • Each DetachedCriteria must match exactly one row (point lock by unique key — no gap locking). Zero matches → not-found exception; more than one → NonUniqueResultException. Either rolls back the whole transaction.
  • Uses LockMode.UPGRADE_NOWAIT — fails immediately on contention (no blocking, no deadlocks).
  • A single UnaryOperator<U> is applied once per locked row; each invocation returns the entity to persist.

2. lockAndMutate — lock a LookupDao entity

public <U> LockedContext<T> lockAndMutate(
        LookupDao<U> lookupDao,
        String key,
        Mutator<U> mutator)
  • Uses PESSIMISTIC_WRITE on the keyed entity.
  • Mutator is applied in place; the entity is updated in the same session.

Combined usage

// All entities below are sharded on the same key, so they share one transaction.
parentDao.lockAndGetExecutor(shardKey, firstCriteria)          // opens txn, locks first row
    .filter(row -> row.isAvailable(), NOT_AVAILABLE)
    .mutate(row -> row.setFlag(true))
    .lockAndMutateEach(parentDao, remainingCriteria, row -> {   // lock additional rows of same table
        row.setFlag(true);
        return row;
    })
    .lockAndMutate(otherLookupDao, lookupKey, entity -> {       // lock a LookupDao entity (same shard)
        entity.setCounter(entity.getCounter() + 1);
    })
    .save(childDaoA, parent -> buildChildA(parent))
    .save(childDaoB, parent -> buildChildB(parent))
    .execute();   // single COMMIT — all locks released together

Lock-mode semantics

The lock modes match each DAO's existing getLockedForWrite behavior — no new locking semantics are introduced:

DAO Method Lock mode Contention behavior
RelationalDao getLockedForWrite(DetachedCriteria) UPGRADE_NOWAIT fails immediately
LookupDao getLockedForWrite(key) PESSIMISTIC_WRITE waits for lock

Constraints & guards

  • Same-shard only. All locked rows must map to the same shard as the LockedContext. Cross-shard locking cannot be atomic (separate databases, separate transactions). For lockAndMutate, this is enforced: if the key hashes to a different shard than the context, an IllegalArgumentException is thrown before any lock is taken (rather than silently querying the wrong shard and reporting a misleading "entity not found"). For lockAndMutateEach, rows are addressed by arbitrary criteria with no shard key to hash, so co-sharding remains a documented caller contract.
  • Exactly one row per criteria in lockAndMutateEach (see above).
  • Input validation: unknown tenant and null/empty criteria list are rejected with clear errors.
  • No API or behavior changes to existing methods, and no changes to transaction machinery, OpType, OpContext, or related types. The change is purely additive.

Tests

8 new cases in LockTest:

Test Verifies
testLockAndMutateEachUpdatesMultipleRelationalEntities Happy path: multiple relational rows updated atomically
testLockAndMutateEachThrowsWhenEntityNotFound Missing row → exception → rollback
testLockAndMutateUpdatesLookupEntityWithinRelationalContext Happy path: LookupDao entity locked + mutated in same txn
testLockAndMutateThrowsWhenLookupEntityNotFound Missing lookup entity → exception → rollback
testLockAndMutateThrowsWhenLookupKeyMapsToDifferentShard Cross-shard key rejected with IllegalArgumentException; rollback
testLockAndMutateEachProtectsEachRowFromConcurrentAccess T1 holds locks on two rows; T2 fails immediately (NOWAIT)
testLockAndMutateEachFailsWhenRowAlreadyLockedByAnotherTransaction Pre-existing row lock blocks lockAndMutateEach
testSingleTableLockAndMutateEachProtectsRowsFromConcurrentAccess / ...FailsWhenRowAlreadyLocked Same guarantees in the single-table scenario

All LockTest cases pass against the current master.

@iamamberkulkarni iamamberkulkarni changed the title feat: add cross-DAO atomic locking to LockedContext Add cross-DAO atomic locking to LockedContext Jun 23, 2026
@iamamberkulkarni
iamamberkulkarni force-pushed the multi-row-atomic-update branch from f3e0c3a to f339510 Compare June 23, 2026 18:35
Add lockAndMutateEach (lock N RelationalDao rows via SELECT FOR UPDATE
NOWAIT) and lockAndMutate (lock a LookupDao entity by key) as chaining
methods on LockedContext. Both join the context's transaction so rows
across multiple tables on the same shard are locked and updated
atomically — single commit, single rollback.

Includes same-shard enforcement and input validation, plus 8 LockTest
cases covering happy paths, rollback, cross-shard rejection, and
concurrent NOWAIT contention.

Co-authored-by: amber.kulkarni <amber.kulkarni@phonepe.com>
@iamamberkulkarni
iamamberkulkarni force-pushed the multi-row-atomic-update branch from f339510 to e8c56f7 Compare June 23, 2026 19:01
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant