Skip to content

fix(txn): make writes after a mid-scope commit atomic with the scope that owns them - #2239

Merged
kriszyp merged 5 commits into
mainfrom
kris/txn-resume-after-commit
Aug 20, 2026
Merged

fix(txn): make writes after a mid-scope commit atomic with the scope that owns them#2239
kriszyp merged 5 commits into
mainfrom
kris/txn-resume-after-commit

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 20, 2026

Copy link
Copy Markdown
Member

Closes #2238. Stacked on #2230 (base is kris/txn-after-commit); retarget to main once 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.36 ships lmdb and 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 own transaction() 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.

open was 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 read open for 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 === OPEN check — 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.commit sets open = OPEN on 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, makes txnForContext create 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.

  1. 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.

  2. 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.

  3. Ownership is a constructor flag on a #private field. 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.

  4. 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.

  5. 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 outside trackedTxns, 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 separate supervisedWriteRoots set keyed on the chain root, plus an attachOwnedTransaction() helper that seeds the per-handle bookkeeping in save()'s adopt branch. I verified the stack rather than assuming: with origin/main and 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).

  6. 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.js assert context.transaction === null after 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.

  7. 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:

  • Joining reads to a snapshot-bearing resumed transaction was a read-visibility regression — a search() after a mid-scope commit stopped seeing another writer's row. Compared against base rather than assumed.
  • The guarantee was conditional on not reading: a nested read scope replaced context.transaction, so commit → read → write → throw left 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.
  • The synchronous commit path rotated the head but left the chained link attached and CLOSED, so a second database's write committed itself and survived the rollback — the exact cross-store leftover this change exists to prevent.

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 the transaction.next chain (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 existing transaction-context-reads fixture: 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:resources 1606 passing / 0 failing on RocksDB, 1400 / 0 with HARPER_STORAGE_ENGINE=lmdb (4 cases pending there, each with its engine reason stated). test:integration -- transaction-context-reads 8/8. Lint and prettier clean. test:unit:main and test:integration:all cannot 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

@kriszyp kriszyp added this to the v5.3 milestone Aug 20, 2026
@kriszyp
kriszyp requested a review from heskew August 20, 2026 05:38

@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 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.

@kriszyp kriszyp modified the milestones: v5.3, v5.2 Aug 20, 2026
Base automatically changed from kris/txn-after-commit to main August 20, 2026 12:03
kriszyp and others added 5 commits August 20, 2026 08:14
…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>
@kriszyp
kriszyp force-pushed the kris/txn-resume-after-commit branch from 9b89d1b to 0358191 Compare August 20, 2026 15:11
Comment on lines +1186 to +1209
/**
* 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;
}

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.

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.

@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found. One non-blocking suggestion posted inline (misplaced JSDoc blocks in DatabaseTransaction.ts).

@kriszyp
kriszyp marked this pull request as ready for review August 20, 2026 15:25

@DavidCockerill DavidCockerill left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.transaction is no longer nulled on release; DatabaseTransaction.ts:550/:616 set it to RELEASED_TRANSACTION, and RELEASED_TRANSACTION_SURFACE.commit() returns { txnTime: 0 } — a no-op that succeeds. That sentinel is already on main and 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)

@kriszyp
kriszyp merged commit 2907260 into main Aug 20, 2026
47 of 48 checks passed
@kriszyp
kriszyp deleted the kris/txn-resume-after-commit branch August 20, 2026 22:08
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.

Writes made after a mid-scope transaction.commit() are not atomic with the scope that owns them

2 participants