Repository navigation
Exclusive record locks: table.lock(id) serialized across worker threads (Phase 0 of #483) - #2462
Conversation
…ds (Phase 0 of #483) Adds table.lock(id, { lease, timeout, hold }) and unlock() for exclusive per-record locks on one node, across every worker thread. The durable, version-conditional LOCK write is the only authority: it sets a LOCKED metadata bit with a generation (lockVersion, the LOCK audit entry's version) and a lease deadline; UNLOCK clears them. Both are version-changing, header-only audit entries (types 9/10) stamped LOCAL_ONLY so no peer ever sees them. Non-holder local writes wait for the release in DatabaseTransaction (staging nothing while they wait) and re-stage past the released version; an abandoned lock expires at its lease with the record intact; transaction-scoped locks release at commit or abort, held locks by unlock() or lease. Refs #483 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFmSH1vMM9FnEdJqHAhZfN
… replay UNLOCK, and close review gaps From the round-1 cross-model review: a message to a locked record now waits like any other local write (it rewrites the version, which would order a holder's later write below it); a delete under a live generation leaves a locked tombstone whatever the audit settings; an UNLOCK the flush had not reached is replayed conditionally after a crash; lock transitions carry additionalAuditRefs forward; a waiter re-checks the record once registered so a release between its read and registration cannot be slept through; waiters are grouped by doorbell slot; a terminal commit failure releases the scope's locks; LMDB gates falsy keys and re-checks inside its exclusive fallback; a copied store drops the lock bit; the lease has a 100ms floor and an expired-at-return generation is retaken; the unlocked path no longer reads the clock. Refs #483 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFmSH1vMM9FnEdJqHAhZfN
…ard the staged round before parking A holder's write is based on the record it locked, so the gate now re-reads a locked record snapshot-free before the write stages; if an ungated rewrite (a source fill, a replicated apply) moved the record past the transaction's timestamp, commit() re-stages the transaction with a fresh one instead of letting the holder's write land as the older version. A commit that must wait for another party's lock discards its staged native handle before parking, so no sibling write's intent sits in the verification table while the holder it waits for is committing. LMDB mirrors both in its gate loop and exclusive fallback. Refs #483 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFmSH1vMM9FnEdJqHAhZfN
…ve the re-stage test's source apply lands Refs #483 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFmSH1vMM9FnEdJqHAhZfN
…lder on an unlocked record Refs #483 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFmSH1vMM9FnEdJqHAhZfN
… ungated rewrite Refs #483 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFmSH1vMM9FnEdJqHAhZfN
There was a problem hiding this comment.
Code Review
This pull request implements Phase 0 of exclusive per-record locks across worker threads on a single node. It introduces a conditional LOCK write on the record itself as the single authority of ownership, accompanied by advisory wait/wake doorbell machinery, transaction-scoped lock management, lease expiration, and crash-recovery replay. The feedback suggests renaming a local variable in the integration tests to avoid shadowing the imported after hook from node:test.
…m main; fails the format check) Refs #483 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFmSH1vMM9FnEdJqHAhZfN
…sed time; terminate test workers from the main thread The Node 24 unit job saw the 200 ms lease end before the gated put even began on a slow runner, so the "waited at least N ms" assertions are now "landed no earlier than the lease end". The worker threads are terminated by the test instead of calling process.exit (which the worker guard intercepts and logs). Refs #483 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFmSH1vMM9FnEdJqHAhZfN
|
Strong Phase 0 — the single-durable-authority model, the stage-nothing gate, and the conditional UNLOCK replay are exactly the right shape. One concern worth resolving before this merges, because it's cheapest to fix at the choke point this PR already owns: The lock descriptor leaves the node on every data write to a locked recordThe mixed-version story here covers the LOCK/UNLOCK control entries (
Suggested fix: strip the descriptor from the audit/outbound encodingDual-encode locked-record data writes at the Notably, this is consistent with the crash-recovery stance this PR already takes: "A LOCK is not replayed: its holder died with the process." Replay restoring the stripped (unlocked) form is the same semantics, lease-bounded, and the conditional Phase 1 then replaces the strip with capability-gated propagation (the If the dual encoding is judged too invasive for this PR, the minimum bar is widening the documented limitation from "no downgrade" to "no replication/clone of lock-bearing tables in a mixed-version cluster, and expect residual-lease write-gating on same-version peers" plus a release-note warning — but the strip closes both holes for real and keeps the PR's central invariant ("what makes a caller the holder is that its conditional write committed — nothing else, and no one else sees it") true on the wire, not just on the node. — Claude (Fable 5), reviewing on behalf of Kris |
… authority The Phase 0 exclusive record lock now uses rocksdb-js's process-wide key lock (`store.tryLock` / `store.unlock`) exclusively. lock() and unlock() write nothing to the store or audit log; the record's version and stored bytes are unchanged. recordLock.ts: new primitives — lockAttemptKey, makeKeyLockHandle (with lease timer + expired flag), acquireRecordKey (async tryLock loop, re-entrant). Removes all durable lock helpers (isLockedLive, waitForRecordUnlock, notifyRecordUnlocked, serializeLockAttempt, doorbell types). DatabaseTransaction.ts: gateLockedWrite() calls tryLock synchronously at staging. On success, a no-lease gate handle is registered and the write proceeds. On failure, the write is marked gated with a pendingWake promise (resolved by the onUnlocked callback). Sibling writes are not discarded. waitForPendingKeys() (called from commit() on gated writes) awaits each pendingWake, acquires via acquireRecordKey, bumps the transaction timestamp past the holder's version, and recursively commits. releaseRecordLocks() is now synchronous. Added LMDB guard (typeof store.tryLock !== 'function' → skip gating). LMDBTransaction.ts: removes the gate-write path (gatedWrites, recommitAfter, gatedInExclusive) since RocksDB now owns all gating. transaction.ts: restores the onError call to match origin/main (no return). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d audit Table.ts: lock() throws 501 on LMDB; acquires via acquireRecordKey (no durable write, no timestamp bump); re-entrancy checks use handle.expired instead of expiresAt comparison; unlock() is synchronous (handle.release() only). Removes writeLockTransition, clearExpiredLock, acquireRecordLock, makeLockHandle, currentTimestamp, _writeUnlockReplay. Removes isLockedLive guard from eviction and delete-tombstone paths. Removes clearLock sweep from the cleanup loop. Adds lockKey: lockAttemptKey(tableId, id) to all five gateOnLock write objects. RecordEncoder.ts: reverts to origin/main — LOCKED bit (0x4), lockVersion, and lockExpiresAt fields are gone from the record format. auditStore.ts: removes audit event types 9 (lock) and 10 (unlock); the comment now reads "leaving 9-15 free". replayLogs.ts: removes the 'lock' and 'unlock' replay cases (lock was a no-op; unlock called _writeUnlockReplay, which is gone). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unit tests (recordLock.test.js): drops the entire 'durable lock state' describe block (no LOCKED bit, no lockVersion, no audit entries to inspect). Removes 'must be acquired before writes', 'locked tombstone', and 'audit refs' tests. Adds 'pure in-memory: no store writes' section verifying lock/unlock don't change the record version, metadataFlags, or audit count; LMDB throws 501; options validate correctly; TTL is preserved. Updates all remaining tests to remove LOCKED/lockVersion/lockExpiresAt checks. Worker thread tests drop LOCKED assertions. 19 passing, 1 pending (LMDB skip). Integration tests: removes lockVersion and lockExpiresAt from the LockHold response; replaces lock version checks with elapsed-time check (write landed after lease ms); renames local 'after' to 'recordAfter' to fix Gemini nit (shadowing node:test's after import). DESIGN.md: rewrites the Record locks section to describe the native key lock authority, no durable writes, per-transaction re-entrancy map, gate mechanics (tryLock at staging → gated/pendingWake → waitForPendingKeys on commit), lease timer, crash semantics (process crash releases; thread death relies on lease), LMDB unsupported, and Phase 1 direction (distributed fencing via token). resources/DESIGN.md: updates the recordLock.ts and section rows in the module table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…, gate-to-hold)
A (bug): waitForPendingKeys called discardStagedRound() before the async wait so no
already-staged siblings survive into the acquire phase with a stale timestamp baked in;
restageAfter replaces the old setTimestamp+write.saved=false approach.
B (thread death): empirically confirmed rocksdb-js ~DBHandle() calls lockReleaseByOwner on
worker termination; unit test added ("a terminated worker thread releases its held lock").
DESIGN.md updated to reflect the confirmed behavior.
C (cleanups): removed dead lockWait field and its two assignments and ?.cancel() call;
dropped unused LOCK_VERSION_STEP, ResolvedRecordLockOptions, LOCAL_ONLY imports from
Table.ts; removed stale "a locked record leaves a tombstone" comment; dropped the
Promise.race setTimeout from waitForPendingKeys (subsumed by discardStagedRound fix).
D (gate-to-hold): lock({hold:true}) when a gate handle already exists now neutralizes the
gate handle (released=true to prevent double-unlock) and creates a fresh hold handle via
makeKeyLockHandle with the requested lease timer rather than flipping hold=true on the
Infinity-expires gate handle.
E (DESIGN.md): corrected "eliminates the Phase 0 blocker" wording to name the real blockers
(LOCAL_ONLY flag and version-skew dup-drop on peers); added store.unlock ownerless/released
flag explanation; replaced thread-death claim with confirmed behavior + Phase 1 follow-up
(native LockHandle deadline); replaced Phase 1 direction with Ricart-Agrawala description.
F (perf): single thread 657 ns/op, 8 workers on distinct keys avg 13 363 ns/op.
G (tests): unit 20 passing 1 pending (LMDB); integration 2/3 pass (hold-wait and 423-timeout).
The third integration assertion ("more than one worker thread") fails on macOS because
SO_REUSEPORT concentrates 64 simultaneous connections on one worker — the CONTROL run
exhibits the same load-balancing collapse, confirming it is not a lock regression. The
serialization correctness (all 64 status 200, count=64) is verified by the two passing
assertions and by the unit test suite.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BLOCKER — lock leak: a 423 or 409 escaping commit() now calls releaseRecordLocks() before rethrowing, so gate handles on other keys in the same transaction are not stranded. waitForPendingKeys and gateLockedWrite both call it on every error path. MAJOR 1 — pendingWake has no deadline: replaced the bare `await write.pendingWake` with a Promise race between the wake and a deadline timer. abort() cancels the pending wait via pendingWakeReject before draining completions. MAJOR 2 — re-entrancy trusts operation.lockHandle without comparing keyId: gateLockedWrite now checks `operation.lockHandle?.keyId === keyId` before re-using the handle. lock() now compares writeKeyId(id) against writeKeyId(this.getId()) in #reloadLocked and returns a fresh instance (not a mutated `this`) when they differ. MINOR 1 — lease timer unlock unguarded: wrapped in try/catch with warn-once per store via warnedLeaseTimerStores WeakSet. MINOR 2 — version + LOCK_VERSION_STEP mints without advancing monotonic clock: replaced with getNextMonotonicTime() loop in restageAfter. MINOR 3 — gate predicates diverge across 5 write types: extracted a single gateLocalWrite helper; all five call-sites use it. MINOR 4 — validated sticky on gated writes: waitForPendingKeys resets write.validated = false before re-staging. MINOR 5 — hold handles not in transaction's recordLocks: acquireRecordKey success path now calls link.registerRecordLock(handle) for hold=true as well, and the gate-to-hold upgrade path registers the fresh hold handle. releaseRecordLocks() skips hold handles (they outlive the transaction). MINOR 6 — snapshot comment inaccurate: updated Table.ts and DESIGN.md to note that setTimestamp (readTxnsUsed > 1) re-pins rather than drops the snapshot; plain reads in that scope keep the pre-lock snapshot. MINOR 7 — two filter passes on every commit: added hasGatedWrites flag; the gated-write collection in commit() is skipped when the flag is unset. MINOR 8 — ImmediateTransaction.save doesn't clear isCommitting on rejection: added the clear in the rejection path. MINOR 9 — narrating comments: removed step-by-step narrating comments from TransactionWrite field block, gateLockedWrite, abort(), and Table.ts lock(); replaced with terse explanatory notes where the why is non-obvious. Removed byte-identical duplicate test "an overrun holder write fails with 409 after the lease fires". Added unit test for BLOCKER: transaction writes A+B, B held by another party; 423 fires; fresh write to A succeeds immediately with no leak. Added setLockedWriteWaitMs / LOCKED_WRITE_WAIT_MS exports for test deadline override. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BLOCKER 1 — busy-wait in restageAfter: added advanceMonotonicTime(floor)
to commonUtility.ts that sets lastTime = max(lastTime, floor) + tick in
O(1) and returns the result, sharing the same lastTime state as
getNextMonotonicTime. restageAfter now calls it instead of the
while-loop that could spin ~10M iterations when a replicated peer
version is far ahead of wall clock. Removed the db.getMonotonicTimestamp
mix (different generator, could hand out a value ≤ the restaged version).
BLOCKER 2 — no re-check after awaits in waitForPendingKeys: after both
the pendingWake await and the acquireRecordKey await, the transaction's
open/timedOut state is re-checked. On closed, the just-acquired handle
is released before throwing, preventing key stranding. The rejection
handler now calls this.abort() (not just releaseRecordLocks) so no empty
write set is committed after an abort landing during the park.
MAJOR — 503 from restageHolderWrites did not release gate handles: now
calls this.abort() before throwing, matching the other terminal-error paths.
MAJOR — terminal lock errors left the transaction OPEN: the waitForPending
Keys rejection handler calls abort(), which marks CLOSED, clears the write
set, and releases blobs, so a handler that catches 423/503 and writes again
on the same context sees a closed transaction.
MAJOR — releaseRecordLocks not in finally on the success path: moved to
immediately after native-commit success (before this.next.commit() and the
blob-cleanup loop) so a synchronous throw from either cannot skip it.
Same fix for the no-native-commit path. The redundant per-site calls
removed now that abort() covers all error paths.
MINOR 1 — hasGatedWrites set on every sync gate acquire: the flag is now
set only when a write is actually marked gated (park) or restage. A
synchronous acquire no longer sets it, so a transaction with no parked
or restage writes skips the filter pass in commit().
MINOR 2 — validate() read stale lexical entry for createdTime: save()
now passes operation as a third argument to validate(). The _writeUpdate
callback reads operation?.entry ?? entry so re-validation sees the
refreshed entry. restageHolderWrites also resets write.validated = false
so the refreshed entry is actually read.
MINOR 3 — #reloadLocked cleared this.#lockHandle for a scoped lock:
null (scoped) no longer overwrites an existing hold handle. unlock() now
checks held.released and returns false immediately; it also calls
link.unregisterRecordLock(held) so a second defensive unlock() in a
finally block finds nothing in the map and returns false instead of 400.
MINOR 4 — releaseRecordLocks dropped the whole map at first commit:
changed to iterate and delete only non-hold handles. Hold handles remain
in the map for the life of the link so a static verb from the same
request after a mid-scope commit does not self-block on its own lock.
MINOR 5 — test improvements: await both puts in the 423 regression test;
assert that pre-423 writes did not land (transaction aborted); removed
the reviewer-addressed BLOCKER comment.
MINOR 6 — comment fixes: corrected the misstatement in the invalidate
test ("holder invalidate is not gated" → "re-entrant via recordLocks");
removed narrating field-block comments for the lock fields in
TransactionWrite and the link-level hasGatedWrites / pendingWakeReject
declarations.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…onotonicTime Add test for BLOCKER 2 (abort during pendingWake cancels park, strands no handle): holds a key with a lease, stages a put in a transaction scope to park it, captures the DatabaseTransaction before the commit, aborts it after one tick, and asserts the commit rejects 500, then verifies that a fresh put on a third context succeeds immediately (no stranded handle). The 503 restage-deadline scenario (a holder's record repeatedly rewritten by an ungated source/replicated apply until the deadline) is impractical to drive deterministically in single-threaded tests: the holder's commit has no async gap between the first save loop and the native commit on an uncontested record, so there is no scheduling slot for the injected writes. It is covered by the 503 path in restageHolderWrites and the abort() call added in that fix. advanceMonotonicTime: trim doc comment to the invariant only; add Date.now() to the max so the returned value is never below wall clock even when both lastTime and floor are stale (next getNextMonotonicTime would already recover it, but the native RocksDB commit timestamp should not be needlessly old). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BLOCKER A — wrap commit()'s save loop in a try/catch so a synchronous 409 thrown from gateLockedWrite during a retry round calls abort() (and releases any gate handles already registered in the same pass) before the error propagates. The when() rejection handler only covers the async side; the pre-when save loop was unprotected. Add regression test. BLOCKER B — ImmediateTransaction.save() on a #lockWritable record looped forever when set() produced no effective change: the dropped operation left #changes set, and the when() callback re-entered save() which staged again endlessly. Fix by checking #savingOperation.dropped in the callback and clearing #changes before returning. Confirm with a test that was verified to hang on the unfixed code. MAJOR — restageAfter now uses db.getMonotonicTimestamp() (native rocksdb-js process-wide generator) instead of the JS per-thread advanceMonotonicTime. The two sequences are independent; mixing them could interleave timestamps. Remove advanceMonotonicTime from commonUtility.ts entirely. Document the residual floor-nudge exposure and the Phase 1 follow-up in DESIGN.md. MINOR — #reloadLocked cross-id branch no longer clears this.#lockHandle when assigning the hold to the fresh instance. The original instance retains its own hold on its own id independently. DOCUMENT — DESIGN.md: add notes on restage timestamp generator rationale, hold handle re-entrancy scope after the acquiring transaction commits, and the two scenarios that cannot be exercised in single-threaded unit tests (abort during the acquireRecordKey await window; 503 restage deadline). NIT — removed the reviewer-directed narrating comment from the abort-during-pendingWake test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
BLOCKER (commit abort guard): when() has no try/catch on its sync path, so a synchronous throw from the callback (e.g. the 423 thrown by waitForPendingKeys' entry-check when lockWaitDeadline has already elapsed) propagates without calling reject or abort(), stranding all gate handles acquired earlier in the same transaction. Wrap the when() call in its own try/catch so every synchronous throw also aborts the transaction before re-throwing. The existing .catch on the Promise result continues to cover the async (completions > 0) path. BLOCKER (re-validation after restage): the save-loop condition `!operation.saved && !operation.validated` skipped validate() once saved=true, so resetting validated in restageHolderWrites was inert and holder writes committed with updatedTime from the pre-restage txnTime. Changed to `!operation.validated` alone. Moved the validated/restage resets into restageAfter (the single shared path) so both waitForPendingKeys and restageHolderWrites benefit. MAJOR (allocation on the default path): carry lockTableId+lockId on the write instead of building the lockKey array at addWrite time. gateLockedWrite builds the key lazily only when tryLock is actually called. Also replace recordLocks Map<Map> with a flat RecordLockHandle[] (linear scan; typical transactions hold 1-3 handles). DOCUMENT: lock() is reachable from the REST/operations API via Resource.static.lock = transactional(...); noted in DESIGN.md that REST-acquired hold handles can only be released by the lease timer or process exit, making REST hold coordination a Phase 1 concern. NIT: trim remaining execution-narrating comments in DatabaseTransaction.ts restageAfter and the Table.ts when() callback. Tests: add "a 423 from an expired deadline releases gate handles without waiting for a park" (exercises the sync commit abort guard) and "a holder write restaged past an ungated rewrite carries the restaged timestamp as updatedTime" (exercises the re-validation fix using sourceApply for the ungated write so it is not blocked by the held lock). 25 tests passing (1 pending LMDB skip). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ers 501 outside its verb switch) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… and associated tests MAJOR 1 (deadlock): releaseRecordLocks() before parking in waitForPendingKeys lets cross-key transactions yield their gate handles, breaking the A-waits-B / B-waits-A deadlock cycle. Hold handles survive; only gate handles (hold=false) are released. MAJOR 2 (stale replay): both waitForPendingKeys and restageHolderWrites now abort and clear options.transaction before discardStagedRound(), preventing a replayed staging round from carrying stale write intents across a park-and-restage boundary. MAJOR 3 (double-commit): save()'s when() callback guards against re-entering save() when op.saved is already true (ImmediateTransaction._writeUpdate commits inline, setting op.saved before the callback fires), preventing a second empty native commit. MINOR (recordLockFor / gateLockedWrite): recordLockFor now prefers live handles over expired ones when both coexist in the same transaction's recordLocks (re-lock after lease expiry). Explicitly-released handles (pruned by releaseRecordLocks filter) are removed eagerly; expired-by-timer handles are kept so the 409 path in gateLockedWrite can fire when needed. gateLockedWrite throws 409 immediately when operation.lockHandle itself is expired (lease fired on an in-flight holder write) rather than falling through to tryLock and parking. NIT: inline narrating comments removed from TransactionWrite lock fields. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cleanups MAJOR: sort gated writes by (lockTableId, encoded keyId) before the acquire loop in waitForPendingKeys. Releasing gate handles (MAJOR-1) removes the staging-phase deadlock, but two transactions competing for the same key set in opposite program order can still deadlock inside the acquire loop: T1 acquires A then waits for B while T2 acquires B then waits for A. A canonical sort guarantees both iterate keys in the same order and serialize. The deadlock test now uses a third-party holder for both keys so T1 and T2 are guaranteed to be in waitForPendingKeys simultaneously with both keys pending; both transactions commit. MINOR: DESIGN.md §Re-entrancy updated from nested-Map registry to flat RecordLockHandle[] with a note on expired-handle retention, prune-on-release, and the canonical acquire order. MINOR: "exactly one native commit" test renamed to "writes the correct value and does not loop" — the version gap between two ImmediateTransaction commits is not reliably observable at test level; the remaining assertions (value landed, version advanced, version stable after resolve) correctly capture the no-infinite-loop invariant the fix enforces. MINOR: deadlock-prevention and successive-parks test descriptions now state "single-thread cooperative scheduling" to avoid implying they force cross-thread concurrent interleaving. MINOR: trimmed multi-line narrating comments in gateLockedWrite (~533), waitForPendingKeys, and restageHolderWrites to single-line invariant statements. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…yLock fast path, and NITs
MAJOR: abort options.transaction in all three error exits of commit() — save-loop sync throw,
when()-callback sync throw, and async rejection — so a replay handle created by restageAfter
for an iterators-open restage round never leaks staged write intents when a subsequent error
(validate() schema error, 409 from gateLockedWrite, deadline 423) fires before the native
transaction.commit() call. All three exits already called this.abort(); options.transaction
cleanup is now symmetric.
BLOCKER-resolved: {hold:true} reads are fresh. #reloadLocked calls primaryStore.getEntry(id)
with no transaction (no snapshot), so the reloaded value reflects the latest committed state
at lock-acquisition time regardless of any prior snapshot the transaction was holding. A test
confirms this: read X into snapshot (n=0), bump X via sourceApply (n=10), lock({hold:true}),
set n+1, save → asserts n=11. DESIGN.md updated with one sentence explaining why hold does
not need the snapshot drop that scoped locks apply.
MAJOR (perf): gateLockedWrite calls store.tryLock(key) without a closure on the uncontended
path; the wake callback closure is only allocated on the second call, which is reached only
under contention. makeKeyLockHandle converted to a class (KeyLockHandle) with prototype
methods — gate handles (no lease) carry no per-instance closures; the lease timer closure
is only allocated when a lease is given.
NIT: RecordLockHandle.keyId comment updated from nested-Map to flat-array description.
NIT: deadlock test comments trimmed; the delay(50) heuristic is now described honestly.
NIT: successive-parks test comment refined to clarify deterministic cooperative-scheduler
sequencing without implying observable interleaving guarantees.
DESIGN.md: added hold-snapshot and replay-handle-lifetime notes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…per, and test/comment NITs MAJOR: waitForPendingKeys now acquires ALL gate-eligible writes in canonical order after parking, not just the previously-gated subset. The subset-only strategy causes livelock when two transactions have overlapping key sets in opposite program order: W1 acquires A (its failed key), W2 acquires B (its failed key), both re-stage, the sync gate in each grabs the other's first key again and fails the second → infinite ping-pong until max-retries → 503. Acquiring the full set (writes with gateOnLock=true, no hold handle, non-null lockKey, store supports tryLock) sorted canonically guarantees the re-stage's synchronous gateLockedWrite finds every key re-entrant and no new gating occurs. The uncontended first round is unchanged. NIT: extracted discardReplay() — the abort-options.transaction + discardStagedRound() sequence was duplicated in waitForPendingKeys and restageHolderWrites; one private method now owns it. NIT: successive-parks test renamed and comments rewritten; the observable proof (holderB acquisition succeeds immediately) is now stated explicitly. Deadlock test updated to name the livelock manifestation (503) and clarify that single-thread scheduling cannot force symmetric interleaving. DESIGN.md updated with the acquire-all invariant and livelock explanation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lesced follower whose transaction closed The lock-write branch in DatabaseTransaction.save() pinned the whole link's clock to the holder version. Any other write staged on that context before the commit reset it — an off-key write through the locked instance, a concurrent write in the caller's own Promise.all, the next operation in a retry or replay save loop — then carried the lock's acquisition time and was silently dropped by LWW against a newer record version. The stamp now stays on the operation (lockStamp) and save() consumes it locally, which is what DESIGN.md already described. A coalesced lock() follower whose enclosing transaction closed while it was parked retried through lock(), which re-resolves the context. That no longer points at the aborted link, so the handle landed on a fresh transaction that no commit or abort ever releases — the native key stayed locked until the lease expired. It now throws 500, matching the leader's own post-acquisition guard. Co-Authored-By: Claude Opus <noreply@anthropic.com>
…leader's timeout The leader's 3000ms wait was the whole runtime of the test. 1000ms proves the same thing (the follower must fail at its own 150ms deadline) in a third of the time, which matters in a unit-test job with a hard 10-minute cap. Co-Authored-By: Claude Opus <noreply@anthropic.com>
…advertises
`TypedVerbs.lock(id, options?, context?)` (defineTable.ts) types the same trailing
context every other static verb takes, but `Table.static lock` took only two
parameters and resolved `contextStorage.getStore()` itself, so the third argument
was silently dropped. A caller with no ambient context — a background job, a timer,
a subscription callback — then landed on a bare `{}`, whose ImmediateTransaction
releases no record locks: the native key stayed locked for the whole lease and every
other lock() on that record failed 423 until it expired. With a *differing* ambient
context the handle registered on the wrong link instead, released by a transaction
the caller never wrote to.
The static now resolves the passed context first, normalized by `contextArgument()`
exactly as `transactional()` does (anything carrying a context yields it; a bare
DatabaseTransaction becomes the context slot holding it), and falls back to the
ambient store as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016cQhcRnN4McDZErnbX4ny5
…izedData alias Round-9 pre-push review (codex). `lock()` aligns the native transaction's clock instead of dropping the read snapshot when iterators still pin it (`readTxnsUsed > 1`), but the clock is pinned only when no writes were staged yet. `update()` stages a DEFERRED write — its `save()`, which is what sets `this.timestamp`, runs later — so a scope that calls `update()`, opens a search iterator, then takes a scoped lock reached `setTimestamp(0)`, and rocksdb-js rejects that outright: `lock()` threw `Invalid timestamp, expected positive number`. Guarded like `DatabaseTransaction`'s own two `setTimestamp` call sites; nothing to align means leave the snapshot alone, which is the documented best-effort rule for a lock taken after a staged write. Regression test fails without the guard. `const authorizedData = data;` in `transactional()` was left over from the gating revision and is now pure indirection — reverted, which takes `resources/Resource.ts` back to matching main. Also trimmed the narration in this branch's newest comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016cQhcRnN4McDZErnbX4ny5
…s, and tighten comments `const transaction = args[0]` is the staged write, not a transaction — commit() re-enters save() with it. Two review rounds read that name literally and reported the lock stamp and expired-handle guard as dead code for ImmediateTransaction writes; they are not (commit() calls back into save() with isCommitting set, which reaches super.save()). Renamed the local. Trimmed the narration in this branch's newest comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016cQhcRnN4McDZErnbX4ny5
…expired All three lock guards threw "Record lock lease expired" for a handle that was either expired OR released, and a scoped handle released by its transaction's commit is the common case — a caller then debugs lease timeouts instead of transaction scope. `lockNotHeldError()` names the actual cause and is shared by the three sites. No status change: still 409, and no test asserted the string. Dropping the ClientError use from DatabaseTransaction.ts also takes its import line back to matching main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016cQhcRnN4McDZErnbX4ny5
…e transaction A write staged through a lock but not yet saved is saved by commit(), so its 409 is thrown from inside commit(). A review leg read that as silent data loss — 409 caught, write dropped, rest of the transaction committed. It is not: retries are keyed to the native conflict codes (RETRY_NOW / ERR_BUSY / ERR_TRY_AGAIN), never to a ClientError, so the throw leaves commit() and the scope's other writes roll back with it. Nothing pinned that, so this does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016cQhcRnN4McDZErnbX4ny5
…uns the write once
Two items from the round-13 review.
`lock()` is declared to return a Promise, but neither the static nor the
instance was `async`, so the 501 check, `resolveLockOptions` and `checkValidId`
threw past a caller's `.catch()`. The test file had already grown async wrappers
to work around it. Both are `async` now; the body still runs to completion
synchronously, which is what keeps concurrent lock() calls on one key coalescing
rather than racing to tryLock. The options test drops its wrapper, so it fails
without the change.
A review leg reported the deferred-write fallthrough in Table.save() as a second
`super.save()` for every ImmediateTransaction lock write — "corrupted entries,
race conditions, or process crashes". It is the first save, not a second:
addWrite deferred it, which is exactly what `op.saved === false` means. One hold
save produces one audit entry and one version bump; that is now a test.
Also tightened a lease-expiry test that swallowed every scope rejection with
`.catch(() => {})` — anything other than the expected 409 now fails it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016cQhcRnN4McDZErnbX4ny5
…rites via priorStagedWrite 1. Read-your-writes via priorStagedWrite (harper#1968) — #reloadLocked no longer hand-scans link.writes or synthesizes an entry. It reads the committed entry (snapshot-free, for freshness) and, for the instance's visible value, looks up the tail TransactionWrite for the key (link.writesByKey) and — via priorStagedWrite when the tail itself hasn't landed a value — takes the record from there. The removed comment claimed RocksDB transactions don't surface their own writes; the real reason a bare getEntry can miss a same-transaction write is that Harper defers an explicit transaction's writes until the writing call actually runs them, not a RocksDB limitation. 2. Scoped lock() stages exactly like update() — #reloadLocked calls _writeUpdate(id, this.#changes, false) at lock time for a scoped (non-hold) acquisition, the same way update() does, so a TransactionWrite exists immediately and save() takes the ordinary #savingOperation path. Hold keeps its deferred staging (save()'s #lockWritable branch, now explicitly gated on this.#lockHandle.hold) since the acquiring transaction may commit before the holder ever writes. The expired/released-handle 409 stays solely in the write path (DatabaseTransaction.save()'s guard on operation.lockHandle, plus the hold branch's own liveness check) — the duplicate check in Table.save() for scoped is gone since its eagerly-staged write already carries lockHandle into that same guard. Upgrading a scoped handle to hold now also detaches the scoped phase's eagerly-staged, unsaved TransactionWrite (detachScopedUpgradeWrite): hold staging is deferred and explicit-save-only, so a dangling scoped write left in place would otherwise auto-commit at the transaction's sweep and clobber whatever the hold write lands. The detached write is marked .dropped so a later explicit save() on the instance that owns it falls through to the hold branch instead of resolving a dead reference. Found and fixed along the way: Table.save()'s ordinary #savingOperation path did not await operation.innerCommit the way the lock-writable hold branch already did, so a second sequential save() on the same (already-closed) ImmediateTransaction context could resolve before its nested immediateCommit's real native commit settled — a narrow, pre-existing race that scoped locks' new eager staging newly made reachable for an ordinary resource. Fixed generally in the same fashion as the hold branch. Tests updated: item-4/rule-C and CLAIM 1's scoped-in-txn portion now call update() between sequential save() cycles on the same instance (no more lock-writable auto-restaging for scoped, matching ordinary update()+save() semantics). DESIGN.md: documented the staging model (scoped = update()-style eager staging; hold = deferred), read-your-writes via priorStagedWrite, the corrected scoped→hold upgrade mechanics (detachScopedUpgradeWrite), and the general innerCommit-chaining gap found in Table.save(). Mac: 47/47 recordLock.test.js (20x stable + 40x on the specific flaky combination that surfaced the innerCommit race), 2045 test:unit:resources, apiTests multi-threaded 3/3, 0 lint errors. Linux: 47/47 recordLock.test.js (5x stable), record-lock-concurrency 3/3, deleteUpdateRace 7/7. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ite through a commit-released scoped lock reports the release Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the coalesced-follower upgrade detaches it too; ordinary save() keeps its return value Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Track every committed same-key write in a lock-owning transaction so a surviving hold cannot generate an older version after commit. Export the public lock types and cover direct, static, and mixed transaction paths. Refs #483 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Preserve prior staged writes when acquiring a lock, detach scoped writes through intervening same-key operations, and advance holder version floors only after successful commits. Keep ordinary unlocked writes off the tracking path. Refs #483 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Read only the caller-provided timestamp when priming an immediate holder floor so ordinary writes on the same context retain their own current timestamp. Refs #483 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Route the regression through an instance write so it shares the ImmediateTransaction whose timestamp must remain unprimed. Refs #483 Co-Authored-By: GPT-5 Codex <noreply@openai.com>
|
Reviewed; no blockers found. |
Adds process-wide, per-record RocksDB locks that serialize
table.lock(id)across worker threads on one node while leaving ordinary writes ungated. This review-feedback update exports the public lock types and makes surviving hold versions monotonic only after a real commit—including static same-key writes—without adding a record-lock scan or key lookup to ordinary transactions that own no locks.The update also closes transaction-integrity edges found during review: acquiring a scoped lock no longer aborts a native handle containing earlier writes, scoped-to-hold upgrade detaches the correct staged write through an intervening same-key operation, synchronous sweep failures abort and release sibling locks, failed/skipped writes do not advance the holder floor, and lock acquisition no longer mints a stale ImmediateTransaction timestamp. One unresolved concern needs human judgment: acquisition-time record versions are also transaction-log timestamps, so a delayed holder mutation may fall behind a replication cursor that has already passed that time.
For the human reviewer
lock()participants inside one RocksDB process; ordinary writes and other nodes remain ungated. Gating ordinary writes was rejected because it added a native lock round-trip to every write and introduced deadlock/livelock shapes. Reversing this after release would be a breaking behavior change.transaction()survives sequential saves untilunlock()or lease expiry. Requiring an explicit transaction or releasing after one save would reduce lifecycle states but break the tested sequential-save behavior.{ hold: true }returns a resource-attached handle that outlives its acquiring transaction and is explicitly released withunlock(). Restricting the API to transaction scope would be simpler but removes the intended long-lived use case and is easiest to decide before shipping.Verification
npm run build;npm run typecheck;npm run test:types; andnpm run lint:requiredall pass. Fullnpm run lintreaches only 13 pre-existing warnings in untouched files.npx mocha unitTests/resources/recordLock.test.js: 71 passing, 1 pending after the transaction/version fixes. Targeted reruns also pass the different-key staged-write preservation, static same-key floor, and owning-link timestamp isolation cases. The static same-key regression fails on the pre-fix parent as expected.npm run test:unit:resources: 2068 passing, 29 pending.HARPER_STORAGE_ENGINE=lmdb npx mocha unitTests/resources/recordLock.test.js: 2 passing, 65 pending, confirming the LMDB 501 boundary.npm run test:integration -- "integrationTests/resources/record-lock-concurrency.test.ts": 3 passing across four workers. The full integration run completed with only six tests requiring an unavailable live Ollama service failing; Harper's local/in-process suites passed.npm run test:unit:main: 5286 passing, 196 pending, with five unrelated environment-sensitive failures (application-spawn cleanup, deploy socket observation, harness Git environment, missing RSA fixture, and the long-worktree Unix socket warning).Complexity: complicated
Review-Coverage: authored=codex; ran=claude,gemini; declined=cursor-grok,cursor-composer,domain; rounds=5 @ 553cfb6
Human-Review-Need: 3 @ 553cfb6