fix(txn): keep context.transaction usable after its transaction completes - #2230
Conversation
There was a problem hiding this comment.
Code Review
This pull request replaces the use of null with a shared, process-wide placeholder instance RELEASED_TRANSACTION to represent a completed and released database transaction. This placeholder safely implements no-op commit() and abort() methods, allowing subsequent calls on a completed transaction context to succeed gracefully while preventing writes or context attachments. The changes span transaction management, resource normalization, and table context resolution, and are accompanied by comprehensive unit tests. I have no feedback to provide as there are no review comments.
|
Reviewed; no blockers found. |
…etes Releasing the context's transaction back-reference (harper#2030, to stop a long-lived context pinning a completed DatabaseTransaction) left `null` in `context.transaction`. That broke a documented public pattern: committing the current transaction mid-handler and then continuing to use the context — `await getContext().transaction.commit()` in the v5-migration guide, and "transactions can now be reused after calling transaction.commit()" in the 4.5.0 notes. The next touch of the slot threw `Cannot read properties of null (reading 'commit')`. That is what broke central-manager on 5.2.3: getUserPermissions() commits the caller's transaction mid-request, and fabric connect, create cluster and delete cluster all 500'd on the released slot. Verified as a 5.2.0 -> 5.2.3 regression by running the same case on both: 5.2.0 left the completed, CLOSED transaction in the slot and those calls were harmless no-ops. releaseContext() now leaves RELEASED_TRANSACTION there instead: one frozen, process-wide completed transaction, so a released context still retains nothing measurable while the slot stays safe to use. It behaves as a CLOSED transaction always did — commit/abort are no-ops, reads see the latest committed state with no snapshot pinned — and, because it is shared, txnForContext() refuses to claim it and addWrite() throws rather than staging onto process-wide state. Restores the two commit-in-the-middle tests that were softened to `?.commit()` when the release landed (both fail with the production TypeError without this fix), and adds coverage for the ambient/operation-handler shape that broke central-manager plus for the placeholder never being claimed or written to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review findings on the released-slot placeholder:
- `transaction.commit(contextSource)` / `transaction.abort(contextSource)` exist
to assert the caller still owns a live transaction. The placeholder is truthy,
so they silently no-op'd where an absent transaction threw, masking exactly
the context-lifecycle bug they are there to surface. They now treat the
placeholder as absent. `context.transaction.commit()` stays callable — that
one is the documented post-completion pattern.
- Narrow the `Context.transaction` contract to the engine it holds for:
LMDBTransaction overrides commit()/abort() and never releases, so there the
completed transaction itself stays in the slot.
- Add the delivery-path coverage that was missing: an operation handler under
processLocalTransaction's ambient context committing its own context after a
static read completed that context's transaction — the fabric-connect /
create+delete-cluster shape, rather than only the bare-`{}` unit shape.
- Trim the placeholder and txnForContext comments to the facts the code cannot
state, and correct the txnForContext one: on a frozen instance the claim
branch throws, it cannot silently take the shared instance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… surface Round-2 review findings. The placeholder was a frozen DatabaseTransaction subclass, and `Object.freeze` is a weaker guarantee than that implied for one instance shared by every released context: `writes` was a mutable array reachable as `context.transaction.writes.push(...)`, `setContext()` could rebind the shared private context (freeze does not cover private fields), and every mutator that wasn't overridden threw an opaque TypeError from somewhere inside the base class. It is now a small frozen object with a deliberately closed surface — exactly the members core reads off `context.transaction` — a frozen `writes`, and explicit throws for `addWrite()`/`setContext()`. Anything outside that surface fails loudly instead of inheriting behavior that would write through to every other context. `transaction.commit(context)` / `transaction.abort(context)` now check transaction STATE rather than the placeholder's identity. The invariant they enforce is "the caller still owns a live transaction", and a transaction stops being live in three ways: released (the placeholder), completed but still in the slot while an iterator drains (the deferred release), and completed on LMDB, which never releases. Identity caught only the first, so the same call threw or silently no-op'd depending on iterator timing. A timeout-poisoned transaction is passed through so its own commit() reports the poison instead. Adds the missing direct coverage for that diagnostic (absent, released, and completed-but-present slots all throw; an OPEN one still commits) and for every route into the shared placeholder's state, and trims the comments that narrated the 5.2.3 incident rather than the contract that holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-3 review flagged that the state-based guard from the previous commit
throws inside a still-live scope. Checking it on `origin/main` showed the
pattern was already broken there, by the same regression this PR fixes:
await transaction(context, async () => {
for (const row of rows) { await T.put(row.id, row, context);
if (++n % 1000 === 0) await transaction.commit(context); }
});
Each checkpoint commit closes the transaction; the next `put` is then serviced
by its own transaction() scope, whose final commit releases the slot — so the
second checkpoint hits an empty slot. On 5.2.3 that is `No active transaction is
available to commit`; on 5.2.0 the slot held a completed transaction and the
call was a no-op. Preserving 5.2.3's throw would have kept half the regression,
so the guard goes back to "nothing was ever attached", and the two documented
forms agree again: whatever `context.transaction.commit()` does on a completed
transaction, `transaction.commit(context)` does too.
Also from round 3:
- `Table.delete(id, context.transaction)` is a supported bare-transaction form,
and on a released context that argument is the placeholder. The argument
normalizer adopted it as the context, and `transaction()` then assigned onto a
frozen object — `Cannot add property transaction, object is not extensible`,
naming nothing the caller can act on. It now reads as absent, as `null` did.
- The identity check is an exported `isReleasedTransaction()` predicate, so the
two readers that must recognize the placeholder share one.
- Corrected a claim I had made in the docblock and had repeated from review:
`txnTime: 0` is NOT a fidelity loss. Re-committing the real completed
transaction returns `{ txnTime: 0 }` as well, because the commit that
completed it reset its timestamp — measured, both paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-4 review found `Resource.create` still exposed: it shifts its own arguments and never reaches `transactional()`'s normalizer, so `Table.create(record, context.transaction)` on a released context landed the frozen placeholder in `context` and `transaction()` then assigned onto it — `Cannot add property transaction, object is not extensible`, an error naming nothing the caller can act on. On 5.2.0 the same call got `null`, which became a fresh context, and the write succeeded. Guarded at both levels, because neither alone is enough: `transaction()` is the funnel every adopter reaches, which covers `transaction(context.transaction, cb)` called directly, but a caller that keeps its own reference to the argument (as `Resource.create` does, reading `context.transaction.startedFrom` back inside the callback) needs the scrub where it adopts. Covered by extending the bare-transaction test to all three routes. Also deletes a comment that the previous commit made false — it described the state-based helper semantics that commit removed, directly above the tests asserting the opposite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round-5 review: `transaction()`'s scrub ran AFTER `ctx ?? getStore() ?? {}`, and
the placeholder is not nullish, so it short-circuited that chain and landed on a
bare `{}`. `transaction(context.transaction, cb)` inside an operation handler
therefore ran its callback under `contextStorage.run({}, …)` — no `user` (audit
entries lose the originating username, and anything checking `context.user` sees
none), no `timestamp`, no `sourceApply`, no `replicatedConfirmation`. On 5.2.0
the same call passed `null` and inherited the ambient context.
Normalized before the fallback chain, to `undefined`, so all three routes agree
that the placeholder is exactly an absent argument and an absent argument means
the ambient context.
Covered by asserting the ambient user survives the released-slot route under
processLocalTransaction — the previous test ran on a bare `{}` with no ambient
store, so it could not see this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…elease Round-6 coverage gaps. A released slot must also be a clean start for a multi-store transaction — the `next` chain the previous one built went with its commit — and the `Resource.create` route through a released slot was asserted to not throw without asserting the record landed. Also ran the resources suite on the LMDB engine (`HARPER_STORAGE_ENGINE=lmdb`), the other half of the contract documented on `Context.transaction`: 1392 passing, 0 failing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3817e90 to
d746a30
Compare
|
The red
Re-run in flight. Every other check is green: 42 pass / 2 skipped, covering all six integration shards on Node 24, Windows, Bun and uWS, plus unit tests on Node 22/24/26. — Claude Opus 5 |
|
Follow-up filed: #2241 — collapse the Kris asked how the placeholder this PR adds differs from the Not folded into this PR: the released marker started as a frozen — Claude Opus 5 |
#2230 stopped `releaseContext()` leaving `null` in `context.transaction` — it broke the documented mid-handler `getContext().transaction.commit()` pattern — and leaves the shared `RELEASED_TRANSACTION` there instead. Four assertions here pinned the old marker; their intent, that a completed wrapper is not retained, is unchanged. - `adoptedHandleBookkeeping.test.js` — the abort case and the two wrapper-cleanup cases - `sourceApplyConflictRetry.test.js` — the chain-cleanup case Without this, merging turns `test:unit:resources` red on main: 4 failing before, 0 after (1621 passing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ons under the long-transaction monitor (#2232) * Release the native handle when a blind write's transaction aborts getReadTxn()'s fresh-handle branch established the bookkeeping every consumer of this.transaction assumes — readTxnsUsed = 1 (the base reference commit() consumes exactly once) and baseReadRefConsumed — while save()'s adopt branch, taken by every write with no preceding read (invalidate, publish, replay, reload markers), established none of it. undefined++ is NaN, and every NaN > 0 test is false, so abort()'s only release path never ran: the RocksDB transaction, its write batch and its write intents survived until GC. Caught firing from the long-transaction monitor's abortDueToTimeout(), which believed it had aborted the transaction. Ownership is now taken in one place and given up in one place, and every terminal path finalizes a handle the wrapper no longer references, so a throwing abort or a failed synchronous commit cannot strand it: doneReadTxn() detaches first and debug-logs an abort that throws (it runs before abort() marks the wrapper CLOSED, clears writes and releases the context); directCommitSync() detaches on success, and on failure releases through the wrapper's own abort() — which also reclaims blobs a replayed write staged — before rethrowing the original commit error, since neither replayLogs caller aborts afterwards. disregardReadTxn() will no longer release a handle that carries staged writes. commit() skips re-staging a write it has marked saved, so aborting the batch there drops the write, and the base reference seeded above is what brings a blind write's handle within reach of that release. Its reference count is clamped too: every getReadTxn() increments it but only this releases, so an unpaired call after a detach could drive it negative and cancel out a later handle's references. Monitor membership is deliberately unchanged: trackedTxns is the monitor's root set and the read-queue-depth metric's source, so registering write-only transactions would make each an independent timeout root and redefine a published metric. Tracked separately in #2231. Refs #2224 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Release the native handle when a blind write's transaction aborts getReadTxn()'s fresh-handle branch established the bookkeeping every consumer of this.transaction assumes — readTxnsUsed = 1 (the base reference commit() consumes exactly once) and baseReadRefConsumed — while save()'s adopt branch, taken by every write with no preceding read (invalidate, publish, replay, reload markers), established none of it. undefined++ is NaN, and every NaN > 0 test is false, so abort()'s only release path never ran: the RocksDB transaction, its write batch and its write intents survived until GC. Caught firing from the long-transaction monitor's abortDueToTimeout(), which believed it had aborted the transaction. Ownership is now taken in one place and given up in one place, and every terminal path finalizes a handle the wrapper no longer references, so a throwing abort or a failed synchronous commit cannot strand it: doneReadTxn() detaches first and debug-logs an abort that throws (it runs before abort() marks the wrapper CLOSED, clears writes and releases the context); directCommitSync() detaches on success, and on failure releases through the wrapper's own abort() — which also reclaims blobs a replayed write staged — before rethrowing the original commit error, since neither replayLogs caller aborts afterwards. disregardReadTxn() will no longer release a handle that carries staged writes. commit() skips re-staging a write it has marked saved, so aborting the batch there drops the write, and the base reference seeded above is what brings a blind write's handle within reach of that release. Its reference count is clamped too: every getReadTxn() increments it but only this releases, so an unpaired call after a detach could drive it negative and cancel out a later handle's references. Monitor membership is deliberately unchanged: trackedTxns is the monitor's root set and the read-queue-depth metric's source, so registering write-only transactions would make each an independent timeout root and redefine a published metric. Tracked separately in #2231. Refs #2224 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Abort retained read handle after retry exhaustion Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Supervise blind-write transactions in the long-transaction monitor trackedTxns.add runs only in getReadTxn()'s fresh-handle branch, and a blind write adopts its handle in save(); getReadTxn() then returns that already-adopted handle before reaching the add, so a transaction that never reads was invisible to the monitor for its whole life. #1407's abort-on-over-time did not apply to it: it held write intents with nothing to reap it, and peers' coordinated-retry commits parked on them. Supervision lives in its own registry rather than in trackedTxns, because the two have different units and different consumers. trackedTxns is per-link, bounds a read snapshot, and is what the read-queue-depth metric counts and what setTxnExpiration() hands tests; this holds one entry per logical transaction, so its membership and meaning are unchanged and the metric needed no counter to stay that way. The entry is the chain root, never the link that received the write — links get a back-pointer where the chain is built. The monitor iterates its members independently while chainStillActive() only looks downstream, so supervising a link would make it its own timeout root, able to be force-committed or poisoned while its head was still active, which is the multi-store atomicity #1407 exists to protect. Every branch in the monitor keeps operating on the unit it always did; the root simply becomes visible where it was not. Crash-recovery replay is deliberately left unsupervised. It is synchronous, already bounded by its own stall counter and wall-clock budget, and commits at timestamp boundaries through directCommitSync() that a monitor-driven commit could split. Fixes #2231 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Finalize wrapper state when abort cleanup fails Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Keep chained write supervision until ownership ends Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Supervise blind-write transactions in the long-transaction monitor trackedTxns.add runs only in getReadTxn()'s fresh-handle branch, and a blind write adopts its handle in save(); getReadTxn() then returns that already-adopted handle before reaching the add, so a transaction that never reads was invisible to the monitor for its whole life. #1407's abort-on-over-time did not apply to it: it held write intents with nothing to reap it, and peers' coordinated-retry commits parked on them. Supervision lives in its own registry rather than in trackedTxns, because the two have different units and different consumers. trackedTxns is per-link, bounds a read snapshot, and is what the read-queue-depth metric counts and what setTxnExpiration() hands tests; this holds one entry per logical transaction, so its membership and meaning are unchanged and the metric needed no counter to stay that way. The entry is the chain root, never the link that received the write — links get a back-pointer where the chain is built. The monitor iterates its members independently while chainStillActive() only looks downstream, so supervising a link would make it its own timeout root, able to be force-committed or poisoned while its head was still active, which is the multi-store atomicity #1407 exists to protect. Every branch in the monitor keeps operating on the unit it always did; the root simply becomes visible where it was not. Crash-recovery replay is deliberately left unsupervised. It is synchronous, already bounded by its own stall counter and wall-clock budget, and commits at timestamp boundaries through directCommitSync() that a monitor-driven commit could split. Fixes #2231 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Abort every handle in a failed transaction chain Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Avoid allocation when releasing read references Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Supervise blind-write transactions in the long-transaction monitor trackedTxns.add runs only in getReadTxn()'s fresh-handle branch, and a blind write adopts its handle in save(); getReadTxn() then returns that already-adopted handle before reaching the add, so a transaction that never reads was invisible to the monitor for its whole life. #1407's abort-on-over-time did not apply to it: it held write intents with nothing to reap it, and peers' coordinated-retry commits parked on them. Supervision lives in its own registry rather than in trackedTxns, because the two have different units and different consumers. trackedTxns is per-link, bounds a read snapshot, and is what the read-queue-depth metric counts and what setTxnExpiration() hands tests; this holds one entry per logical transaction, so its membership and meaning are unchanged and the metric needed no counter to stay that way. The entry is the chain root, never the link that received the write — links get a back-pointer where the chain is built. The monitor iterates its members independently while chainStillActive() only looks downstream, so supervising a link would make it its own timeout root, able to be force-committed or poisoned while its head was still active, which is the multi-store atomicity #1407 exists to protect. Every branch in the monitor keeps operating on the unit it always did; the root simply becomes visible where it was not. Crash-recovery replay is deliberately left unsupervised. It is synchronous, already bounded by its own stall counter and wall-clock budget, and commits at timestamp boundaries through directCommitSync() that a monitor-driven commit could split. Fixes #2231 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Make staged-write release guard constant time Co-Authored-By: GPT-5 Codex <noreply@openai.com> * Release a chained link's handle on an ordinary abort abortDueToTimeout() and abortChainAfterRetries() walk the chain, but an ordinary application-error abort cleaned only its own link, so a link that took a blind write kept its native handle, its write intents and its supervision claim until GC. abort() now walks the chain; every step in it is idempotent, so the callers that already walk can re-enter it harmlessly. Also corrects a native contract both comments stated backwards, measured against the bundled rocksdb-js: abort-after-abort is tolerated, and abort-after-COMMIT is what throws. Detaching before the abort is still right — it keeps abort()'s doneReadTxn loop off a nulled handle — but that is the actual reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Give write supervision an unconditional exit Membership otherwise ends only when a claiming link detaches. A chained commit that throws synchronously inside when()'s success callback never reaches that detach, so a CLOSED, handle-less root whose claim is held by a link stayed enrolled — the monitor revisiting it every tick with a misleading "read iterators held a committed transaction's snapshot" warning, and the registry pinning the chain past GC. The monitor now drops such a member outright. Also removes the chain walk the previous commit added to abort(): the finally block already aborts the chain recursively and contained, so the walk was redundant, and being outside that try it let a child's blob-cleanup throw pre-empt the head's own cleanup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Release the chain's handles when supervision is dropped The unconditional exit cleared the bookkeeping but left any handle its links still held untouched. In the case it exists for — a chained commit throwing synchronously before the link reaches its own detach — that handle is in neither registry once the root is removed, so nothing can ever abort it: a pinned snapshot blocking compaction, and staged writes and blobs never cleaned, for the life of the process. Dropping supervision now releases each link's handle first. Snapshots only, matching the CLOSED branch that calls it: staged writes may be riding an in-flight replay commit and are not the monitor's to drop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: expect the released placeholder, not null, after #2230 #2230 stopped `releaseContext()` leaving `null` in `context.transaction` — it broke the documented mid-handler `getContext().transaction.commit()` pattern — and leaves the shared `RELEASED_TRANSACTION` there instead. Four assertions here pinned the old marker; their intent, that a completed wrapper is not retained, is unchanged. - `adoptedHandleBookkeeping.test.js` — the abort case and the two wrapper-cleanup cases - `sourceApplyConflictRetry.test.js` — the chain-cleanup case Without this, merging turns `test:unit:resources` red on main: 4 failing before, 0 after (1621 passing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: GPT-5 Codex <noreply@openai.com>
Closes #2229.
releaseContext()(added in #2030, to stop a long-lived context pinning a completed transaction) setcontext.transaction = null. That introduced a third state for a published slot, and application code cannot interpret it — the next touch throwsCannot read properties of null (reading 'commit').Committing the current transaction mid-handler and then continuing to use the context is documented in two places:
release-notes/v5-lincoln/v5-migration.md(await getContext().transaction.commit()) and the 4.5.0 notes ("transactions can now be reused after callingtransaction.commit()"). On 5.2.1+ that pattern 500s. It is what took out central-manager's fabric connect and cluster create/delete on 5.2.3 for any non-super-user, sincegetUserPermissions()commits the caller's transaction mid-request.A completed transaction now leaves
RELEASED_TRANSACTIONin the slot: one frozen, process-wide completed transaction.commit()/abort()are no-ops and reads through it see the latest committed state — what the slot did before #2030 — while retention stays O(1) per process rather than O(live contexts), so #2030's measurement is kept. Because the instance is shared, every route that adopts a caller-supplied transaction or context refuses it (txnForContext,transactional()'s argument normalizer,Resource.create's own argument shift,transaction()itself), andaddWrite()/setContext()throw.Two more callers were broken by the same
nulland are fixed with it:transaction.commit(context)(a checkpointing loop that commits every Nth row fails on its second checkpoint on 5.2.3 — the intervening write is serviced by its own scope, whose final commit releases the slot), and passing a transaction where a context is expected (Table.delete(id, context.transaction)).For the human reviewer
null. The alternative was keepingnulland changing the docs plus every caller tocontext.transaction?.commit(). That loses: the pattern is published, so the break reaches every app that followed our own docs, and it shipped in a patch. The cost of this direction is that any downstreamif (context.transaction)flips meaning — I audited every core reader (transaction.ts,Resource.ts,Table.ts's twostalewritebacks andtxnForContext,HierarchicalNavigableSmallWorld.ts) and none is truthiness-sensitive, and harper-pro readscontext.transactionnowhere outsidecore/. Downstream apps and plugins are the residual exposure:if (context.transaction) context.transaction.addWrite(op)now enters the branch and throws instead of silently skipping. Hardest thing to rule on, and the one I most want a second opinion on.DatabaseTransactionsubclass, whose inheritedwritesarray stayed mutable throughcontext.transaction.writes.push(...)and whosesetContext()could rebind the shared private context. Anything off that surface now fails loudly rather than inheriting behavior that writes through to every other context.isReleasedTransaction()is checked at four call sites rather than being a state on the transaction. A future adoption site that forgets gets aTypeErrorfrom the frozen instance — loud, but still a crash. The alternative (brand it, or funnel every adopter through one place) is cheap now and invasive later; I went with the predicate becauseResource.createkeeps its own reference to the argument and readscontext.transaction.startedFromback inside the callback, so a funnel-only guard leaves that reference pointing at the frozen object.undefinedand falls through tocontextStorage.getStore(). Review flagged that this changesResource.create's shifted two-argument form, whose isolation onorigin/mainwould be lost — so I measured what that form actually did with a real completed transaction in the slot, which is what 5.2.0 had. It resolved to the caller's own context, becauseDatabaseTransactionhas agetContext()andcreatecalls it. The private{}only ever appeared on 5.2.1–5.2.3, fromcontext = record || {}withnull; it is an artifact of the bug, not behavior to preserve. A shared placeholder cannot carry a back-reference to the context that released it — that is the point of releasing — so the ambient context is the nearest faithful restoration, and it is also whatTable.create(record)with no second argument already does.context.transaction.commit()stays callable andtransaction.commit(context)stays a no-op on a completed transaction. Review pushed twice for the helper to throw instead (a silent no-op can mask a context-lifecycle bug). It threw on 5.2.3 only as a side effect of thenull, and on 5.2.0 it was a no-op — so keeping the throw would have preserved half the regression, which is how the checkpoint-loop break above was found. Both documented forms now agree.LMDBTransactionoverridescommit()/abort()and never callsreleaseContext(), so there the completed transaction stays in the slot — callable, but retained. That gap predates this change; documented rather than closed, since LMDB is deprecated and its LINGERING semantics are load-bearing.deleteClustercan leave a cluster TERMINATED with its instances RUNNING, and it is the real content of central-manager#705. A follow-up makes those writes atomic while the owning scope's commit is still pending.One claim I made early and had to retract:
txnTime: 0from the placeholder'scommit()is not a fidelity loss. Measured on both paths — re-committing the real completed transaction also returns{ txnTime: 0 }, because the commit that completed it reset its timestamp.Verification
Regression range established by running one case on three checkouts —
v5.2.0,v5.2.3(origin/main) and this branch:context.transactionafter a nested Resource-API call, thencommit(). 5.2.0 leaves the completed CLOSED transaction and the call is a no-op; 5.2.3 throws the productionTypeError; this branch matches 5.2.0.getReadTxn()'sopen !== OPENearly return andsave()'s post-commit immediate-commit path, which the original triage blamed, are byte-identical between 5.2.0 and 5.2.3.Fails-on-base: the tests here fail on unfixed
origin/mainfor the right reasons — the two restoredcommit in the middlecalls and the ambient handler case withCannot read properties of null (reading 'commit'), the checkpoint loop and the bare-transaction/createroutes withNo active transaction is available to commit.Added: the ambient delivery path (
processLocalTransaction, the functionserverHandlers.jscalls for every operation, so this is the real operations entry point — only the HTTP transport is absent), the bare-context regression shape, all four adoption routes plus ambient-identity preservation through them, a fresh multi-store chain after release, every route into the shared placeholder's state, and thetransaction.commit()/abort()behavior on absent versus released slots.Gates, locally:
test:unit:resources1593 passing, 0 failing on RocksDB and 1392 passing, 0 failing withHARPER_STORAGE_ENGINE=lmdb.npm run lint:requiredand prettier clean.test:unit:mainandtest:integration:allcould not run in this checkout — long-running Harper processes from other worktrees hold~/harper/database/system/LOCK, so both abort onResource temporarily unavailablebefore any test executes.Those two ran in CI and passed, which closes the one real gap in the local verification: 42 checks pass, 2 skipped — all six integration shards on Node 24, Windows, Bun and uWS, plus unit tests on Node 22/24/26. One Next.js adapter leg was cancelled on a 30-minute
Install Playwright browsersdownload timeout, before reaching any test; it passed on re-run.Not verified: MQTT long-lived-context reattach (the scenario motivating #2030 — untested before this change too), and an operations-HTTP or central-manager flow end to end.
Complexity: complicated
Review-Coverage: authored=unknown; ran=none; rounds=1 @ d746a30
Human-Review-Need: 4 @ d746a30