Repository navigation
Conversation
…t-effort An explicit transaction() has one commit clock for every write it makes, and lock() only pinned that clock to the acquisition when no write had been staged yet. With a write already staged the clock was left alone, so a plain write that landed between the clock and the acquisition outranked a write made under the lock: the caller read the fresh record through #reloadLocked, wrote it back, and its write vanished under LWW with no error. What is enforced now is that ordering, not an absolute clock floor: a write made under a lock is never ordered behind one made before the lock. lock() moves the clock up to handle.acquiredAt at every fresh acquisition, the second lock in a scope included — a clock left at the first acquisition lets a plain write made between the two outrank the second lock's own write. The native transaction decides whether the clock can still move: rocksdb-js freezes it (ERR_TIMESTAMP_FROZEN) once a write or transaction-log entry is staged, because the producer has already encoded the old value into that write's record bytes, so this.timestamp follows setTimestamp() rather than leading it and the record version, the native transaction and the log batch key stay on one value. A clock the caller put on the context is not moved either; that version is their own ordering choice. Either case leaves a window between the clock and the acquisition, so the record's committed version is read there and the lock is refused 409 when something already landed inside it. With nothing there the lock is still taken, below its own acquisition but above every version visible at it. That narrowing is deliberate: put, patch, create and delete all stage natively at once in an explicit scope, so refusing every frozen clock would reject every caller that writes before it locks while refusing nothing that is unsafe. nativeWritesStaged answers "has a write reached this handle" in constant time, so neither the frozen path nor the snapshot decision below scans the write set or pays for a thrown native error. Every exit from the check releases the native key and any cluster admission before it throws, and it runs before registerRecordLock: releaseRecordLocks() deliberately skips hold handles, so a registered-then-refused hold would have held the key for its whole lease. A scoped lock also now releases the read snapshot whenever no write has reached the native handle, rather than only when the transaction has staged nothing at all. The snapshot is also the commit basis, so a stale one made the scope's own conditional put refuse against a record #reloadLocked had just re-read fresh — the same write loss by another route. Fixes #2712 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request implements mixed-transaction lock ordering (harper#2712) to guarantee that a write made under a lock is never ordered behind a write made before the lock. It introduces conflict detection in DatabaseTransaction and Table that raises the transaction clock to the lock's acquisition time or throws a 409 error if a write has already occurred within the window. Additionally, it updates documentation and adds extensive unit and integration tests to verify these ordering behaviors. I have no feedback to provide as there are no review comments.
This was referenced Oct 5, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A record lock taken inside an explicit
transaction()now enforces its ordering instead of taking it best-effort: a write made under the lock is never ordered behind a write that was made before the lock, and where that cannot be arranged the acquisition is refused409rather than committed silently behind the writer that beat it.Until now
lock()pinned the transaction's commit clock to the acquisition only when no write had been staged yet. With a write already staged the clock was left alone, so a plain write landing between the clock and the acquisition outranked the lock's own write under last-write-wins — the caller read the fresh record through#reloadLocked, wrote it back, and the write vanished with no error. Closes #2712.For the human reviewer
Framing-Verdict: better-alternative-existsand the alternative was adopted in full: instead of pinning only a clock that is still zero and carrying a provenance field to exempt a clock an earlier lock had pinned, the native transaction is asked on every fresh acquisition whether the clock can still move. The reviewer's counterexample for the exemption was decisive and is now a test — lock A att1, plain write to B att2, lock B att3: leaving the clock att1let thet2write, made before B was locked at all, outrank B's own locked write. Implementation evidence then forced a second change the planning round could not see:put,patch,createanddeleteall stage natively at once inside an explicit scope (Table.putcalls_writeUpdateand thensave()), so a clock frozen below the acquisition is the ordinary state of any transaction that writes before it locks, not an edge case. Refusing all of them would409every such caller — including two existing tests that encode the pattern — while refusing nothing that is actually unsafe, and would leave harper#2707's enlistment API no better off than its current workaround. So what is enforced is the ordering statement, notcommit timestamp >= acquiredAt: a frozen transaction still commits its locked write below its own acquisition, and is refused only when something actually landed in the window that leaves open. The graded leg raised that narrowing as a blocker on every round and asks for a human contract decision; it is kept, with that disqualifier, and the cost is asserted directly rather than implied. If you want the stronger absolute contract, it is one branch — return the conflict unconditionally when the clock cannot move — and the price is the compatibility break above.operation.lockStamp, as theImmediateTransactionpath already does — was rejected on a storage-format fact rather than taste: it would give a locally-originated write a record version newer than the transaction it is filed under, andgetAppliedWriteVersiondeliberately gates that dual-version representation tosourceApply || isReplay(harper#2412). Nothing in flight supersedes it. It is a prerequisite for relaxing harper#2707's "no writes in scope" enlistment workaround, which can now become "try, and handle the 409" — that follow-up is not in this PR.minor-3) is rewritten rather than deleted, and the limitation is stated in the design note.nativeWritesStagedwas originally also a fast path for the clock decision, until the final round found that_writeDelete's lost-delete guard returns without staging and without marking itselfskipped, so the flag could read true for a clock that was still movable and produce a false409. The nativesetTimestampprobe is now the only authority for the clock, which costs one thrownERR_TIMESTAMP_FROZENper acquisition in a transaction that already writes — against a native key acquisition, a possible cluster round and a store read. The flag survives only where being conservatively wrong costs nothing: it gates the read-snapshot release.#reloadLockedhad just re-read fresh — the same write loss by a different route, and without it the clock fix produces no user-visible change on that path. It can be split out if you would rather review it separately.holdlock, and any scoped lock taken while read iterators still hold the handle, keep the stale snapshot and can still have a write refused at commit with no error reaching the caller — pre-existing, strictly narrowed by this change, raised by the Gemini leg and recorded under "Not enforced" in the design note. And a concurrent delete inside the window is caught only on a table that keeps a versioned tombstone; measured on a table with auditing off, the delete leaves no entry at all, which is equally why it leaves nothing for the locked write to lose to.SIGKILLed on every round on this machine (exit--1, empty output, sandbox on or off), so there is no adjudicated decision ledger andHuman-Review-Needfloors at 4 by deterministic policy rather than by judgement. The outside coverage in the footer is real and unaffected — codex, Gemini and a Cursor lens all ran — but the dispositions in this section are the author's, taken against the graded leg's findings.lock()scope comes from the table's declaration", PR Resolve a defaulted lock() scope from the table's replicate declaration, so enabling cluster record locks cannot re-scope a plugin's node-local lock #2903) changeslock()'s scope resolution —resolveLockOptions, arescope()helper, the coordinator and transport checks around the native wait — plusresources/DESIGN.mdand the root index. This change touches only the clock decision that runs after those checks, a new method pair onDatabaseTransaction, and the Phase 0 half ofresources/record-locks.mdwhile Resolve a defaulted lock() scope from the table's replicate declaration, so enabling cluster record locks cannot re-scope a plugin's node-local lock #2903 edits its Phase 1 scope section; the two are disjoint hunks in every shared file and are expected to merge cleanly in either order. The design note also stays out ofresources/DESIGN.mdentirely, so it does not contend with harper#2711's prune of that file (PR Preserve a write's local-only marking on every mutation and recovery path #2902).save(record, true)on a scoped-locked instance whose write was staged atlock()time silently stages nothing — thefullUpdateflag and the record argument are dropped and the commit handler early-returns with no changes. It behaves identically onmain, so it is out of this diff, but it deserves its own issue.Product and architecture tour
What a lock now promises
What can an application rely on when it calls
table.lock()inside its owntransaction()?409. Nothing is taken silently.The weaker form is what makes the feature usable.
put,patch,createanddeleteeach stage natively the moment they are called inside an explicit scope, so a frozen clock is the ordinary state of any transaction that writes before it locks — not an edge case. Refusing all of them would answer409to every such caller while refusing nothing that is actually unsafe, and would leave the enlistment API in harper#2707 no better off than the workaround it has today.The decision at acquisition
What decides whether an acquisition is admitted, and what has to be given back when it is not?
How an acquisition decides
Every fresh acquisition asks the native transaction whether its timestamp can still move, and only falls back to a conflict check when it cannot.
flowchart TD A[Native key acquired] --> B{Clock already at or above acquisition?} B -->|yes| OK[Register the lock] B -->|no| C{Clock set by the caller?} C -->|yes| E[Read committed version] C -->|no| D{Native setTimestamp accepted?} D -->|yes| OK D -->|frozen| E E --> F{Version inside the window?} F -->|no| OK F -->|yes| G[Release key, throw 409]The probe is made rather than inferred. An earlier revision used a staged-write flag as a fast path here, until the final review round found that
_writeDelete's lost-delete guard returns without staging and without marking itself skipped — a flag reading true there would have refused a lock whose clock was still movable. The flag survives only where being conservatively wrong costs nothing, gating the read-snapshot release.Refusal happens before the handle is registered on the transaction, and every exceptional exit releases the native key and any cluster admission first:
releaseRecordLocks()deliberately skipsholdhandles, so a registered-then-refused hold would have held the key for its whole lease.Example: transfer under two locks
A transaction locks account A at
t1, and a plain writer updates account B att2. The transaction then locks B att3and saves both.Outcome: the clock advances from
t1tot3, so B's locked write outranks thet2write that preceded its lock. Before this change the clock stayed att1and thet2write silently won. The cost is stated in the ledger: A's write now also commits att3, so a plain write to A between the two acquisitions loses to it.Why the read snapshot moves too
Why does a clock fix alone leave the commonest case unchanged?
Raising the clock alone changes nothing a caller can see on the commonest path. A scoped lock's transaction keeps a read snapshot, and that snapshot is also the basis its own conditional put commits against — so with a deferred write present, a stale basis made the scope's write refuse against the very record
#reloadLockedhad just re-read fresh. The same write loss, by a different route.The snapshot was already dropped for a scoped lock, but only when the transaction had staged nothing at all. The precise condition is "no write has reached the native handle", which is the same fact the clock decision needs, so it costs one field. A
holdlock, and a scoped lock taken while read iterators still hold the handle, keep the stale basis and are recorded as not enforced.Changes
The ordering decision itself is two new methods on
resources/DatabaseTransaction.ts:lockOrderingConflict()raises the commit clock to the acquisition, leaves a clock the caller set on the context alone, and otherwise returns the committed version that would outrank a write made under the lock. Its private helper asks the native transaction rather than inferring, because a commit handler can return without staging and without marking itself skipped, and a wrong answer there is a false409.releaseReadSnapshotForLock()gives up the read snapshot — and with it the stale commit basis — unless the handle may already carry a write or iterators still hold it. That "may already carry a write" is the newnativeWritesStagedfield, set from the write's outcome insave()and cleared with the native handle it belongs to, on attach and on detach.resources/Table.tscalls that decision fromlock()'s acquisition continuation, beforeregisterRecordLockand releasing the handle on every exceptional exit as well as on the refusal, then replaces the old inline snapshot rule with the helper. It imports the new error fromresources/recordLock.ts, which builds the caller-visible409naming the transaction's commit timestamp, the acquisition and the version that beat it.resources/record-locks.mdreplaces the best-effort description with the enforced statement, its mechanism and the deliberate narrowing; the Phase 0 contract paragraph, the holder-version paragraph and the Phase 1 §10 limitation are corrected to match, and the limits that remain — a caller-supplied future timestamp, both writes of a two-lock transaction sharing the later acquisition, a table that keeps no tombstone, and thehold/open-iterator snapshot cases — are recorded rather than implied. The note stays inrecord-locks.md, so neitherresources/DESIGN.mdnor the root index is touched.Verification
Route (a): the existing record-lock integration test is extended, plus unit-level coverage through the real
Table/DatabaseTransactionon RocksDB for the timestamp assertions, which have no HTTP surface.unitTests/resources/recordLock.test.jsadds amixed-transaction lock ordering (harper#2712)suite: the deferred-write clock raise and the two-lock interleave prove the ordering itself; the frozen-clock refusal, itsholdvariant (which also proves the native key is not leaked by a refusal) and the uncaught-refusal rollback prove the409and its cleanup; the caller-pinned refusal and the untouched future timestamp cover the context clock; the concurrent delete and the scoped→hold upgrade followed by a fresh lock close the two cases review raised; the ImmediateTransaction case pins that it is out of scope. The accepted cost is asserted directly — a frozen clock commits below its own acquisition, above every version visible at it — rather than left implied.minor-3previously asserted that a second lock did not move the clock; it now asserts the opposite, which is the behaviour change in item 3 above.integrationTests/resources/record-lock-concurrency.test.tsadds an HTTP case that a write-then-lock request is admitted when nothing intervenes and returns409to the client when a plain write beat it, driving a newMixedTransactionLockfixture endpoint whose handler runs in the request's own explicit transaction.Executed on this machine at
2a7baf9f:npm run build— clean.npx mocha unitTests/resources/recordLock.test.js unitTests/resources/recordLockCluster.test.js unitTests/resources/sameKeySaveOrder.test.js unitTests/resources/immediateTransactionConflictRetry.test.js— 146 passing, 1 pending.npm run test:unit:resources— 3495 passing, 53 pending, 4 failing. All four reproduce on a clean80f6d1c3checkout against the same dependency tree: threesource-apply conflict retry converges instead of spinningcases, andNativeFullTextDerivedIndexLifecyclefailing with@harperfast/fulltext/native reported incompatible runtime capabilities, which this diff does not touch.HARPER_STORAGE_ENGINE=lmdb npm run test:unit:resources— 2701 passing, 566 pending, 1 failing (the same full-text native failure). Record locks are501on LMDB, so the new cases skip there.npm run lint:requiredandnpm run check:design-docs(therunLinterCI job) — both clean;npx prettier --check .clean.80f6d1c3and run there — 76 passing, 8 failing, each on its own assertion rather than a setup error:clock advanced to the second acquisition,the clock sits at the acquisition,the second lock write outranks the plain write before it,lock() rejected rather than taking a backdated lock, and four more. All 84 pass on the branch.Not run here, with the reason:
npm run test:unit:maindoes not complete on this machine — it stalls inunitTests/components/applicationSpawn.test.jsand was killed after 4h35m with no output past 527 tests; it stalls on a clean base too, and this diff is confined toresources/(which that script excludes) plus test files.npm run test:integration:allcannot run here either:startHarperallocates per-instance loopback addresses from 127.0.0.3 up and only 127.0.0.1 is configured on this host. CI runs both.Documentation
The
409is a new caller-visible outcome on a public API, so HarperFast/documentation's record-lock page needs a sentence: acquiring a record lock inside an explicit transaction that has already written can fail with409when another writer changed the record first, and the caller should take the lock before writing or retry. No companion PR is opened from here.Complexity: complicated
Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; blocked=domain(exit--1); declined=cursor-grok,cursor-kimi,cursor-muse; rounds=4; full=4 @ 2a7baf9
Human-Review-Need: 4 @ 2a7baf9