Skip to content

Exclusive record locks: table.lock(id) serialized across worker threads (Phase 0 of #483) - #2462

Merged
kriszyp merged 66 commits into
mainfrom
feat/record-lock-phase0
Sep 4, 2026
Merged

kriszyp merged 66 commits into
mainfrom
feat/record-lock-phase0

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 2, 2026 •

Copy link
Copy Markdown
Member

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

  1. Advisory scope: this phase serializes only 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.
  2. Holder precedence and replication ordering: holder writes use the acquisition-time version so a later ordinary write wins under LWW. The alternative is commit-time precedence. Please explicitly rule on the consequence that a holder mutation committed later can carry a transaction-log timestamp older than a replica's cursor; a “no” requires separating replication ordering from record conflict ordering before merge.
  3. Immediate scoped lifetime: a scoped lock outside transaction() survives sequential saves until unlock() or lease expiry. Requiring an explicit transaction or releasing after one save would reduce lifecycle states but break the tested sequential-save behavior.
  4. Hold-mode API: { hold: true } returns a resource-attached handle that outlives its acquiring transaction and is explicitly released with unlock(). Restricting the API to transaction scope would be simpler but removes the intended long-lived use case and is easiest to decide before shipping.
  5. Platform scope: Phase 0 is in-process and RocksDB-only; LMDB returns 501 and no protocol authorization surface is added. Engine parity, cluster coordination, and protocol policy are deferred extensions rather than hidden fallbacks.
  6. Known lifecycle boundaries: overlapping un-awaited saves on one handle, scoped locks sharing an ImmediateTransaction with unrelated instance writes, and sourced/caching-table reload semantics remain unproved. The documented and tested use is awaited saves on local RocksDB tables; expanding those guarantees should be a deliberate follow-up rather than inferred from this phase.

Verification

  • npm run build; npm run typecheck; npm run test:types; and npm run lint:required all pass. Full npm run lint reaches 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).
  • Independent review ran five rounds on the fix sequence with Claude, Gemini, Cursor/Grok, and Harper storage/data adjudication. It closed the staged-write abort blocker, tail-only upgrade detachment, failed-floor advancement, default-path bookkeeping, and timestamp-minting findings; the replication-ordering decision above remains intentionally open for human review.

Complexity: complicated

Review-Coverage: authored=codex; ran=claude,gemini; declined=cursor-grok,cursor-composer,domain; rounds=5 @ 553cfb6

Human-Review-Need: 3 @ 553cfb6

kriszyp and others added 6 commits September 1, 2026 17:44
…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
@kriszyp kriszyp added this to the v5.3 milestone Sep 2, 2026
… ungated rewrite

Refs #483

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFmSH1vMM9FnEdJqHAhZfN

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread integrationTests/resources/record-lock-concurrency.test.ts Outdated
kriszyp and others added 2 commits September 1, 2026 19:16
…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
@kriszyp

kriszyp commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

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 record

The mixed-version story here covers the LOCK/UNLOCK control entries (LOCAL_ONLY, peer never sees action 9/10 — good). But holder writes and every preserved-bit rewrite are ordinary PUT/PATCH entries whose encoded record carries LOCKED (0x4) + lockVersion + lockExpiresAt in the metadata prefix, and DESIGN.md makes that deliberate: "the record itself replicates as usual." Replication and base copy forward encoded entries — the send path tests metadataFlags precisely so it never re-encodes. Two consequences:

  1. Silent value corruption on pre-Phase-0 peers. DESIGN.md already states the mechanism — "an older Harper decodes a LOCKED record's value from the wrong offset" — but scopes it to local downgrade. The same bytes travel the wire: during a rolling upgrade (or to a v5→v4 bridge route, or a clone target), any replicated write to a locked record hands an older peer a layout it decodes 16 bytes short. That's the silent-corruption class we've spent months eliminating, and "don't downgrade this node" doesn't cover it.

  2. Phantom locks on same-version peers. A peer applies a holder's write with the bit and live lockExpiresAt baked in, and its own local writers then hit the gateOnLock path — but the origin's UNLOCK never arrives (LOCAL_ONLY), so the peer stays write-gated for the full residual lease after the origin released. Base-copy targets inherit the same phantom state. That contradicts the PR's own scope statement ("one node, every worker thread") in the deployment mode that matters most.

Suggested fix: strip the descriptor from the audit/outbound encoding

Dual-encode locked-record data writes at the recordUpdater choke point: the store copy keeps LOCKED + descriptor; the audit-log copy (which is what replication and copy read) drops the bit and both fields. Costs one extra encode only on writes to locked records — same cost class as the lock transitions themselves.

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 _writeUnlockReplay doesn't depend on the descriptor being present in data entries. Worth double-checking the resequencing/out-of-order history walk paths that reload entries from the audit log, since store and log encodings would no longer be byte-identical for locked records.

Phase 1 then replaces the strip with capability-gated propagation (the recordLocks capability in the harper-pro protocol-capability registry, harper-pro#440), rather than introducing lock state to the wire accidentally now and having to define its semantics retroactively.

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

kriszyp and others added 16 commits September 1, 2026 21:54
… 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>
kriszyp and others added 2 commits September 3, 2026 11:53
…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>
Comment thread resources/defineTable.ts
kriszyp and others added 6 commits September 3, 2026 13:43
…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
Comment thread resources/ResourceInterface.ts
Comment thread resources/Table.ts Outdated
kriszyp and others added 3 commits September 3, 2026 19:33
…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>
Comment thread resources/Table.ts Outdated
kriszyp and others added 4 commits September 3, 2026 23:45
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>
@kriszyp
kriszyp marked this pull request as draft September 4, 2026 06:43
@claude

claude Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp marked this pull request as ready for review September 4, 2026 12:58
@kriszyp
kriszyp merged commit 0373b29 into main Sep 4, 2026
61 of 67 checks passed
@kriszyp
kriszyp deleted the feat/record-lock-phase0 branch September 4, 2026 15:19
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.

2 participants