fix(txn): make writes after a mid-scope commit atomic with the scope that owns them - #2239
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements post-commit atomicity for the RocksDB storage path, aligning its behavior with LMDB. When a transaction is committed mid-handler, the transaction scope rotates to a fresh, open, and snapshot-free generation. This ensures that subsequent writes within the same scope are staged and committed or rolled back together upon request completion, rather than committing individually. Additionally, a shared RELEASED_TRANSACTION placeholder is introduced to safely handle completed transactions on released contexts. I have no feedback to provide as the code changes are well-implemented and the reviewed comment was found to be incorrect.
…that owns them A handler that commits its own transaction mid-scope — the documented `await getContext().transaction.commit()` — got non-atomic writes for the rest of that scope: each later write was serviced by its own transaction() scope and committed immediately, so a handler that failed halfway left the earlier half durable and unrollbackable. Measured on the base: two writes across two databases after a mid-scope commit, handler throws, both still present. `open` was standing in for two things — whether a physical generation is open, and whether some scope is still responsible for committing it. An explicit commit closes the generation while the scope keeps running, and both dispatchers read `open` for the second question. A successful commit that is NOT the scope's final one now rotates the owning transaction to a fresh OPEN generation. Every dispatch path keeps its plain `open === OPEN` check, so CLOSED never gains a second meaning and the default path is untouched. The new generation is snapshot-free: committing mid-scope is how a handler asks to stop reading a pinned snapshot, and re-pinning one would take that back. Ownership is a constructor flag on a #private field, so application code cannot mark a transaction no scope owns. Rotation is refused when the scope is finished, when a timeout poisoned it, when a commit FAILED, and while read iterators still hold the native handle. This ends an engine divergence rather than adding behavior: harperdb 4.7 ships lmdb and no RocksDB at all, and LMDBTransaction leaves a non-final commit OPEN, so post-commit writes have always staged and rolled back there. 8 of the 12 unit cases here pass on LMDB unchanged. The 4.7 -> 5.x move onto RocksDB is what dropped the property, which is why this targets 5.2.x. Rebased onto #2232, whose attachOwnedTransaction() seeds the per-handle bookkeeping at the source and whose supervisedWriteRoots brings these transactions under the long-transaction monitor — the gap that was this change's one objection to a patch release. An earlier doneReadTxn() state check enforcing the same invariant is dropped in favour of it; keeping it also declined the release abort() performs. Closes #2238. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ags together Post-rebase review, four real findings. A failed CHAINED-store commit did not surrender ownership — only the head's own error branch did. A handler that writes databases A and B, commits mid-scope inside a try/catch (the shape people write precisely because a mid-scope commit can conflict), and sees B fail could then commit again: that second commit rotated with ownership still set, and completeMidScopeCommit dropped the failed link before its abort could run cleanupUnusedBlobs. Both commit paths now clear ownership when a completion rejects, as the head-failure branch already did. `snapshotFree` was cleared on abort but not on a final commit, so a context that outlives its scope — a final commit deferred by an undrained iterator leaves this instance in the slot — kept opening snapshot-free handles, tearing later multi-key reads across concurrent writers. Both flags now leave together through endScopeOwnership(), so no exit can clear one and keep the other. The blind-write options ternary coupled `coordinatedRetry` to the snapshot switch, which moved blind writes onto the RETRY_NOW ladder after a mid-scope commit and the ERR_BUSY ladder before it. Only the snapshot switch belongs to a rotated generation. Two coverage corrections. The ownership-gate test proved the wrong path: after the scope it went through the released placeholder, so txnForContext built an ImmediateTransaction and never consulted ownership. It now uses an unowned OPEN DatabaseTransaction — the replayLogs.ts shape, where a regression to "anything rotates" would stage replay's post-commit writes into a generation its loop never commits. And the integration file had lost its original guard, since an ordinary mid-handler commit now rotates rather than leaving the slot closed; a new undrained-iterator route keeps the handle held, blocks the rotation, and pins that a genuinely closed slot still reads latest committed state rather than empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the chained failure Round-2 review caught the same asymmetry in the fix that introduced the helper: the two new completion-rejection exits set `#scopeOwned` directly, so they surrendered ownership while leaving `snapshotFree` set. There is now exactly one direct write to that field (inside the helper), and every exit goes through it. Adds the test that was missing for the chained-database failure itself: the head commits, the chained store's commit is forced to fail terminally, the handler catches it, and the scope must not be rotated — a later write commits itself instead of staging on a half-landed multi-store commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-3 review: `this.next.commit()` throwing synchronously reached neither the async chained rejection handler nor the outer one, so after the head had already committed the scope stayed owned and a later commit could rotate on top of a half-landed multi-store commit. The chained commit is now wrapped, and a sync throw surrenders ownership the same way the async rejection does. Two smaller review points: `snapshotFree` is a real initialized field rather than `declare`, so rotation no longer adds a hidden-class transition to every DatabaseTransaction; and the rotated blind-write read options are one shared frozen object instead of an allocation per native transaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… real Two test-honesty gaps from round 4. The chained-failure case only rejected a promise, so the synchronous-throw cleanup the previous commit added — which leaves the chained commit through different code — was untested. It is now parameterised over both. The ambient operation-handler test was titled as verifying resumption but its body never committed mid-scope, so it exercised no rotation at all. It now commits mid-scope under processLocalTransaction, writes after it, and throws — the central-manager shape — asserting the pre-commit write stays and the post-commit write rolls back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9b89d1b to
0358191
Compare
| /** | ||
| * A successful commit that is NOT the scope's final one leaves the scope still running and still | ||
| * responsible for a commit. Rotate to a fresh OPEN generation so the rest of the scope's writes | ||
| * stage into it and are committed — or rolled back — as one unit, instead of each committing itself | ||
| * the moment it is made. Every dispatch path keeps its plain `open === OPEN` check; CLOSED never | ||
| * gains a second meaning. | ||
| * | ||
| * Deliberately not rotated when: the scope is finished (`doneWriting`), nothing owns this instance, | ||
| * a timeout poisoned it, or a commit failed — a failed or uncertain commit must never be followed by | ||
| * a resumed segment that can commit on its own. Nor when read iterators still hold the native | ||
| * handle: that handle belongs to them until they drain, so there is nothing to rotate into and those | ||
| * writes keep today's immediate-commit path. | ||
| */ | ||
| /** | ||
| * Finish a commit: the chain goes with it, then the scope may rotate. A link left attached and CLOSED | ||
| * would be reused by txnForContext for the next write to that database and commit itself, surviving a | ||
| * rollback of the rotated head — the cross-store leftover this rotation exists to prevent. Every | ||
| * commit path must run this, and none may do one half without the other. | ||
| */ | ||
| /** Both scope flags leave together, so no exit can clear one and keep the other. */ | ||
| private endScopeOwnership(): void { | ||
| this.#scopeOwned = false; | ||
| this.snapshotFree = false; | ||
| } |
There was a problem hiding this comment.
Suggestion (non-blocking): the two large JSDoc blocks here (A successful commit that is NOT the scope's final one... and Finish a commit: the chain goes with it...) describe rotateAfterMidScopeCommit and completeMidScopeCommit respectively, but both sit stacked above endScopeOwnership instead of above the methods they document (which are defined a few lines below). Only the last comment block (Both scope flags leave together...) is actually attached to endScopeOwnership by JS/TS doc-comment convention, so an editor hovering rotateAfterMidScopeCommit/completeMidScopeCommit won't surface their reasoning at all. Move each block to sit directly above its own method.
|
Reviewed; no blockers found. One non-blocking suggestion posted inline (misplaced JSDoc blocks in |
DavidCockerill
left a comment
There was a problem hiding this comment.
Approving at this head. No findings in the change itself — the separation of "a generation is physically open" from "a scope is still responsible for committing it" is the right cut, and the rotation falls out of it cleanly.
Two things I want to credit specifically:
- A failed commit deliberately does not rotate. "A commit that FAILED leaves this generation finished and its durability unknown. Rotating after it would let the rest of the scope stage into a generation whose predecessor may not have landed." That is the one case where rotating would be actively harmful, and it is excluded explicitly rather than by omission.
- The engine difference is stated rather than smoothed over — a rotated RocksDB generation is snapshot-free, while LMDB cannot open a snapshot-free read transaction so the scope keeps its snapshot. Two behaviours under one API, documented as two.
The release-line argument is also made the right way round: targeting 5.2.x rather than 5.3 because every 4.7 → 5.2.x upgrade would otherwise carry the regression for that line's whole life, evidenced by 8 of 12 unit cases passing unchanged on LMDB rather than asserted.
One thing worth surfacing for central-manager, since it is not obvious from here. CM main is pinned to harper-pro 5.1.23 because on 5.2.x a second context.transaction.commit() on the same context threw TypeError: Cannot read properties of null (reading 'commit') — 19 sites across 9 files. Tracing that against this head:
context.transactionis no longer nulled on release;DatabaseTransaction.ts:550/:616set it toRELEASED_TRANSACTION, andRELEASED_TRANSACTION_SURFACE.commit()returns{ txnTime: 0 }— a no-op that succeeds. That sentinel is already onmainand is not part of this PR.- The sentinel alone only stops the crash, though:
addWrite()on a released transaction throws "Cannot write to a transaction that has already completed", so a commit-then-write-then-commit handler would fail at the intervening write instead of the second commit. - This PR is what makes that pattern work, by rotating to a fresh OPEN generation so the post-commit writes stage normally — and atomically, which is the actual point.
So the upstream fix for the CM breakage is the sentinel plus this, and the useful next step on our side is identifying which harper-pro release carries both rather than guarding the remaining CM sites on the assumption they are still needed. Noting it here mostly so the connection is on the record — the 5.2.x targeting decision above looks even better in that light.
Coverage, so the approve is honest: I read the rotation path and its failed-commit exclusion, the open/responsibility split, the engine read difference, and the transaction-slot assignments. I did not read closely the two integration fixtures or the wider Table.ts / ResourceInterface.ts edges. Also worth a re-read once this retargets main — a rebase onto a different #2230 is exactly where the rotation guards could shift.
— DAIvid (Claude Opus 5)
Closes #2238. Stacked on #2230 (base is
kris/txn-after-commit); retarget tomainonce that merges. Also depends on #2232 — see below.Targeted at 5.2.x, not 5.3. This is not a new behavior:
harperdb 4.7.36shipslmdband no RocksDB dependency at all, and on LMDB post-commit writes have always staged and rolled back with the scope (8 of the 12 unit cases here pass on LMDB unchanged, including both two-database rollback cases). So the 4.7 → 5.x upgrade, which moves a database onto RocksDB, silently dropped an atomicity property — and that drop is the mechanism behind a failed cluster delete leaving a cluster marked TERMINATED with its instances still RUNNING. Shipping this only in 5.3 would leave every 4.7 → 5.2.x upgrade carrying the regression for that line's whole life.A handler that commits its own transaction mid-scope — the documented
await getContext().transaction.commit()— got non-atomic writes for the rest of that scope. Each later write was serviced by its owntransaction()scope and committed immediately, so a handler that failed halfway left the earlier half durable and unrollbackable. That is the mechanism behind a failed cluster delete leaving a cluster marked TERMINATED with its instances still RUNNING.openwas standing in for two different things: whether a physical transaction generation is open, and whether some scope is still responsible for committing it. An explicit commit closes the generation while the scope keeps running, and the dispatchers readopenfor the second question.So a successful commit that is not the scope's final one now rotates the owning transaction to a fresh OPEN generation. Every dispatch path keeps its plain
open === OPENcheck — CLOSED never gains a second meaning and the default path is untouched. The new generation is snapshot-free, because committing mid-scope is how a handler asks to stop reading a pinned snapshot.This is not a new behavior so much as the end of an engine divergence. LMDB has always done it (
LMDBTransaction.commitsetsopen = OPENon any non-final commit); only the RocksDB path committed each later write on its own. 8 of the 12 unit cases pass on LMDB unchanged, including every atomicity case.For the human reviewer
The step-6 framing verdict was
better-alternative-exists, and I adopted it. My spike taught the two dispatch sites a "resumable-CLOSED" state; the planning review argued for rotating to a real OPEN generation instead, which keeps the hot paths unchanged, makestxnForContextcreate later chain links OPEN for free, and separates a retained iterator's old handle from the new generation. It was right on all three. The implementation here is the reviewer's design, not mine.Post-commit writes stage instead of committing one at a time.
await Table.put(x, context)after a mid-scope commit resolves staged, not durable. Consistent with every write before the commit in the same scope, and with LMDB — but observable, and it means a single mid-scope commit no longer bounds how much a long handler holds in memory. A handler streaming a large volume should keep checkpointing; the checkpoint-loop case is tested.The rotated generation reads snapshot-free for the rest of the scope. Required, not incidental: with a snapshot, a
search()after a mid-scope commit stopped seeing another writer's row with no intervening write at all — I measured that against base before changing it. The cost is no repeatable read after a mid-scope commit, which is what committing mid-scope asks for. Carried concern I could not fully close: review argued this widens the lost-update window for a post-commit read-modify-write from one write to the rest of the handler. I measured both arms — snapshot-bearing and snapshot-free — in a read-modify-write race against a concurrent writer and they behave identically, so the concern does not distinguish them in that shape. It is one shape, not a proof.Ownership is a constructor flag on a
#privatefield. It went WeakSet → static setter → constructor option across rounds, each time because the previous shape was reachable from application code. The constructor option is still callable by anything that can import the class (it is not a package export), so what it actually guarantees is that no accidental path turns it on for a running transaction. I stopped there rather than iterate again.Two carve-outs where the guarantee deliberately does not apply, both tested and stated on
Context.transaction: a commit made while a read iterator still holds the native handle (that handle is the iterator's until it drains), and a commit that failed — a generation whose durability is unknown must never be followed by a segment that commits as though it landed.The monitor gap is closed by Correct blind-write transaction bookkeeping and bring those transactions under the long-transaction monitor #2232, not here — and that is what makes 5.2.x defensible. A rotated generation whose handle comes from
save()would be outsidetrackedTxns, so the long-transaction monitor could not reap it while it holds write intents (the harper#2001 shape). That was my one objection to a patch release. Correct blind-write transaction bookkeeping and bring those transactions under the long-transaction monitor #2232 already solves it, and properly: a separatesupervisedWriteRootsset keyed on the chain root, plus anattachOwnedTransaction()helper that seeds the per-handle bookkeeping insave()'s adopt branch. I verified the stack rather than assuming: withorigin/mainand Correct blind-write transaction bookkeeping and bring those transactions under the long-transaction monitor #2232's branch both merged in, this branch's suites pass (resume 12/12, transaction 48/48, lingeringWriteCommit 3/3).doneReadTxn()guard added here. It enforces "never abort a handle holding writes still awaiting a commit" via a state check; Correct blind-write transaction bookkeeping and bring those transactions under the long-transaction monitor #2232 enforces the same invariant at the source by seeding the base reference, which is the better fix. Verified: removing the guard with Correct blind-write transaction bookkeeping and bring those transactions under the long-transaction monitor #2232 merged keeps all of this branch's tests green. The guard is only load-bearing while this lands without Correct blind-write transaction bookkeeping and bring those transactions under the long-transaction monitor #2232.abort()handle release this branch added is already onmainas4fbe87d95, and is dropped in the merge.Unrelated collision worth flagging: Correct blind-write transaction bookkeeping and bring those transactions under the long-transaction monitor #2232 conflicts with fix(txn): keep context.transaction usable after its transaction completes #2230. Three cases in
adoptedHandleBookkeeping.test.jsassertcontext.transaction === nullafter a completed transaction (e.g.:112), which fix(txn): keep context.transaction usable after its transaction completes #2230 changes to the shared released placeholder. Whichever merges second needs those three assertions updated — the same edit fix(txn): keep context.transaction usable after its transaction completes #2230 already made to the other suites. Not caused by this PR; found while verifying the stack.The engines still differ on reads, and the interface says so: a rotated RocksDB generation is snapshot-free; LMDB cannot open a snapshot-free read transaction and keeps its snapshot.
Verification
Base behavior measured first: two writes across two stores after a mid-scope commit, handler throws, both still present. Then, with the fix, both roll back.
Three things the tests caught that I would otherwise have shipped:
search()after a mid-scope commit stopped seeing another writer's row. Compared against base rather than assumed.context.transaction, socommit → read → write → throwleft the write durable. Since committing in order to re-read is the reason the pattern exists, the guarantee would have been absent in exactly the shape people use.Also corrected against a control rather than assumed: an index scan does not see this scope's uncommitted staged writes (true in a normal scope too), and a static write with no explicit context resolves to the ambient context — so a test using it as "another writer" was measuring nothing.
Coverage: 12 unit cases in
unitTests/resources/resumeAfterMidScopeCommit.test.js— two real databases through thetransaction.nextchain (rollback and success), failed-commit poisoning through a forced terminal native failure, intervening reads, other-writer visibility, the iterator carve-out, no-owning-scope (the harper#1591 hazard), the checkpoint loop, and the ambient operation-handler path. Plus transport-level proof in the existingtransaction-context-readsfixture: a handler that commits then writes twice then throws returns 5xx and neither record exists; the success route leaves both durable. Review had shown my earlier "not observable end-to-end" claim was false.Gates:
test:unit:resources1606 passing / 0 failing on RocksDB, 1400 / 0 withHARPER_STORAGE_ENGINE=lmdb(4 cases pending there, each with its engine reason stated).test:integration -- transaction-context-reads8/8. Lint and prettier clean.test:unit:mainandtest:integration:allcannot run in this checkout — other worktrees' Harper processes hold~/harper/database/system/LOCK— so they run in CI.Complexity: complicated
Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=5 @ 9b89d1b
Human-Review-Need: 4 @ 9b89d1b