Skip to content

fix(txn): keep context.transaction usable after its transaction completes - #2230

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

fix(txn): keep context.transaction usable after its transaction completes#2230
kriszyp merged 7 commits into
mainfrom
kris/txn-after-commit

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 19, 2026

Copy link
Copy Markdown
Member

Closes #2229.

releaseContext() (added in #2030, to stop a long-lived context pinning a completed transaction) set context.transaction = null. That introduced a third state for a published slot, and application code cannot interpret it — the next touch throws Cannot 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 calling transaction.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, since getUserPermissions() commits the caller's transaction mid-request.

A completed transaction now leaves RELEASED_TRANSACTION in 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), and addWrite()/setContext() throw.

Two more callers were broken by the same null and 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

  1. The slot is now permanently truthy after first attach, rather than null. The alternative was keeping null and changing the docs plus every caller to context.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 downstream if (context.transaction) flips meaning — I audited every core reader (transaction.ts, Resource.ts, Table.ts's two stale writebacks and txnForContext, HierarchicalNavigableSmallWorld.ts) and none is truthiness-sensitive, and harper-pro reads context.transaction nowhere outside core/. 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.
  2. One shared instance rather than a per-context lightweight closed one. Sharing is what keeps the memory win. It also means the instance must own no mutable state — hence the small frozen surface rather than a DatabaseTransaction subclass, whose inherited writes array stayed mutable through context.transaction.writes.push(...) and whose setContext() could rebind the shared private context. Anything off that surface now fails loudly rather than inheriting behavior that writes through to every other context.
  3. isReleasedTransaction() is checked at four call sites rather than being a state on the transaction. A future adoption site that forgets gets a TypeError from 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 because Resource.create keeps its own reference to the argument and reads context.transaction.startedFrom back inside the callback, so a funnel-only guard leaves that reference pointing at the frozen object.
  4. "Absent" is standardized as "use the ambient context", not "a fresh empty context". Every route resolves the placeholder to undefined and falls through to contextStorage.getStore(). Review flagged that this changes Resource.create's shifted two-argument form, whose isolation on origin/main would 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, because DatabaseTransaction has a getContext() and create calls it. The private {} only ever appeared on 5.2.1–5.2.3, from context = record || {} with null; 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 what Table.create(record) with no second argument already does.
  5. context.transaction.commit() stays callable and transaction.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 the null, 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.
  6. LMDB is not at parity and the interface comment says so. LMDBTransaction overrides commit()/abort() and never calls releaseContext(), 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.
  7. Not in scope, worth knowing: writes made with a context whose transaction has completed are each committed immediately and individually — no atomicity, no rollback. Identical on 5.2.0, so not part of this regression, but it is why a failed deleteCluster can 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: 0 from the placeholder's commit() 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.transaction after a nested Resource-API call, then commit(). 5.2.0 leaves the completed CLOSED transaction and the call is a no-op; 5.2.3 throws the production TypeError; this branch matches 5.2.0. getReadTxn()'s open !== OPEN early return and save()'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/main for the right reasons — the two restored commit in the middle calls and the ambient handler case with Cannot read properties of null (reading 'commit'), the checkpoint loop and the bare-transaction/create routes with No active transaction is available to commit.

Added: the ambient delivery path (processLocalTransaction, the function serverHandlers.js calls 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 the transaction.commit()/abort() behavior on absent versus released slots.

Gates, locally: test:unit:resources 1593 passing, 0 failing on RocksDB and 1392 passing, 0 failing with HARPER_STORAGE_ENGINE=lmdb. npm run lint:required and prettier clean. test:unit:main and test:integration:all could not run in this checkout — long-running Harper processes from other worktrees hold ~/harper/database/system/LOCK, so both abort on Resource temporarily unavailable before 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 browsers download 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

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

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp marked this pull request as ready for review August 19, 2026 23:08
kriszyp and others added 7 commits August 19, 2026 17:14
…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>
@kriszyp
kriszyp force-pushed the kris/txn-after-commit branch from 3817e90 to d746a30 Compare August 19, 2026 23:16
@kriszyp

kriszyp commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

The red Integration Tests 5/6 (uWS HTTP) is #1827 (Flaky: "Harper produced no startup output" — recurring integration-test startup hang), not this PR. Evidence:

  • record-caching-ttl-eviction.test.ts failed with HarperStartupError: Harper produced no startup output for 150000ms — Harper never started; no assertion failed.
  • The identical failure mode occurs on main (e.g. run 32182927071, same 150000ms startup hang).
  • This same SHA passed all six shards, including 5/6 uWS, on the first run of this PR.

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

@kriszyp

kriszyp commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Follow-up filed: #2241 — collapse the ImmediateTransaction / RELEASED_TRANSACTION surface.

Kris asked how the placeholder this PR adds differs from the ImmediateTransaction that txnForContext already mints, since both mean "no owning transaction scope". Fair question — they differ in three load-bearing properties (open OPEN vs CLOSED, store-bound vs store-less, per-context vs per-process and frozen), but a store-less immutable ImmediateTransaction would be the placeholder, so the redundancy is real.

Not folded into this PR: the released marker started as a frozen DatabaseTransaction subclass here and was replaced in review for concrete reasons (shallow freeze left writes mutable, #private fields unaffected so setContext() could rebind the shared context, un-overridden inherited mutators threw opaque TypeErrors). Collapsing the surface properly means touching every site that recognizes the marker — the argument normalizer, Resource.create, transaction(), txnForContext — which is more than a patch should carry. #2241 has the constraints written down.

— Claude Opus 5

kriszyp added a commit that referenced this pull request Aug 20, 2026
#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>
kriszyp added a commit that referenced this pull request Aug 20, 2026
…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>
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.

context.transaction is null after its transaction completes, breaking the documented mid-handler commit pattern

1 participant