fix: release the context's transaction back-reference on completion - #2030
fix: release the context's transaction back-reference on completion#2030kriszyp wants to merge 7 commits into
Conversation
MQTT subscription contexts (and any other long-lived context) stay reachable for the life of a suspended delivery loop long after their transaction() call has returned and committed, and kept pointing at the completed DatabaseTransaction — pinning it (and whatever it exclusively retains) in memory for that entire time. A production heap snapshot under a ~127k connection MQTT load test found ~157,893 committed-but-retained DatabaseTransaction objects on a single worker, all reachable via context.transaction, ~14.87 per connection. DatabaseTransaction now releases the context's back-reference once a transaction is truly done: on the wrapper's own final commit (doneWriting: true) or on abort(), identity-guarded so a context already re-pointed at a different (e.g. reused) transaction is never clobbered. An in-callback explicit context.transaction.commit() (the documented "commit in the middle, reuse and recommit" pattern) is not final and does not release. A final commit with outstanding read iterators still streaming through the transaction defers the release until the last one drains (doneReadTxn()/releaseReadTxn()), since those iterators still depend on the same instance. Updates two existing tests whose second, redundant explicit commit() call relied on context.transaction never going null, and one whose mechanism-level assertion this supersedes with a stronger guarantee (see inline comments). Expected win: ~2.2 KB/connection of directly-freed shallow size (~2.8% of the ~79 KB/connection measured total), plus whatever each transaction exclusively retained — a clean, low-risk win, not the headline fix for per-connection memory (that's the async-iterator closure chain and socket write buffers, handled separately). Co-Authored-By: Claude Opus <noreply@anthropic.com>
…1591 instance check Independent pre-push review (codex/gemini/grok + Harper-domain adjudication) caught a real correctness break in the first version of this change: - abort() releasing the context unconditionally disarmed the #1411 over-time atomicity guarantee. Resource.ts's dispatcher deliberately keeps joining a `timedOut` transaction (context?.transaction?.timedOut) so the rest of a logical operation fails atomically after the long-transaction monitor poisons it, instead of silently starting a fresh transaction for a write made after the timeout fired. releaseContext() in abort() now only fires when the transaction is not timedOut; a poisoned transaction stays attached as a deliberate tombstone. Verified against both integrationTests/resources/txn-overtime-atomicity.test.ts and overtime-multi-write-atomicity.test.ts, plus a new focused unit test. - The operationContextTransactionLeak.test.js mechanism-level rewrite in the prior commit dropped the #1591 dispatcher-fix's actual discriminating power (distinct transaction instances per write) in favor of a null check that passes whether or not the #1591 fix is even in place. Restored the instance-identity assertion (captured via a temporary setContext() hook, since the old leftover-reference read no longer survives this PR's own release) alongside the new null check. Co-Authored-By: Claude Opus <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request addresses a transaction-context memory leak where long-lived contexts (such as MQTT subscription contexts) kept pointing to completed DatabaseTransaction instances, pinning them in memory. It introduces a releaseContext mechanism to drop the transaction's back-reference from its context upon a final commit or abort, while deferring the release if there are outstanding read iterators still using the transaction. It also updates and adds corresponding unit tests to verify this behavior, including handling of timeout-poisoned transactions and context reuse. There are no review comments, and I have no additional feedback to provide.
|
Reviewed; no blockers found. |
Guards the failure mode the release could plausibly have introduced: a write made with a context after its transaction completed landing on a fresh transaction instance that never commits. It can't, and the reason is worth recording in a test: transaction() only reuses a context's transaction while it is still OPEN, so a retained CLOSED one was never reused for a later write to begin with — a fresh DatabaseTransaction was minted either way. Nulling the reference changes only what stays reachable, not how the next write is serviced. Verified A/B against origin/main with a throwaway probe before writing this: both durably commit every post-completion write and leave nothing staged; the only difference is that main leaves the last finished transaction attached. Co-Authored-By: Claude Opus <noreply@anthropic.com>
|
Reviewed Traced the release across every path — final wrapper commit ( Note: unit tests were not executed in-environment (worktree deps/native rocksdb build not installed); this is a code-trace review, relying on the reported — |
- releaseContext() now runs on the terminal (non-conflict) native commit rejection path too, not just fulfillment: transaction.ts's onComplete() has no rejection handler, so a context whose commit failed this way was left permanently pinning the CLOSED wrapper. - Delete the context's `.transaction` slot on release instead of assigning null: Context.transaction is typed `DatabaseTransaction | undefined`, and null was silently expanding that. Deleting also restores the context to its original shape rather than adding a third transaction state. - Rewrite the ambient-context mechanism-level test to observe the real context synchronously instead of stubbing DatabaseTransaction.prototype.setContext, a global production-method hook the repo's test policy disallows for new tests (and one that would also count unrelated background transactions started while it's installed). - Extend the existing forced terminal-commit-failure test (lingeringWriteCommit.test.js) to assert the context no longer retains the wrapper once the outstanding iterator drains. Addresses #2030 review threads. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ts type An advisory review leg flagged that delete on a hot, long-lived context (the exact MQTT-subscription case this PR targets) repeatedly forces V8 into dictionary-mode property storage. Keep the identity-guarded release but assign null and widen Context.transaction to `DatabaseTransaction | null | undefined`, documenting null as "attached, now released" — satisfying the original review ask (no silently-expanded, undocumented API) without the perf risk of delete. Also corrects an overclaim in operationContextTransactionLeak.test.js's mechanism-test comment: once release always clears the slot, that particular flow can no longer discriminate a #1591 dispatcher revert (both old and new dispatcher code see the same falsy value) — that coverage lives in the adjacent search()-iterator test, which exercises the LINGERING window. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Re-reviewed Incremental re-review of the new terminal-commit-failure release site plus the
Test changes are consistent (lingeringWriteCommit captures the wrapper before the drain that nulls — |
CI caught what local runs didn't: peeking at contextStorage.getStore() synchronously right after issuing (but before awaiting) each write assumed no async work ever happens between the call and transaction.ts's context.transaction assignment. That's not guaranteed (authorization, resource resolution, component loading can all be async), and it intermittently lost the race in CI, observing null instead of the write's transaction. Hook LeakTable.prototype.put instead — the instance method Resource.ts's dispatcher only ever calls from inside the callback handed to transaction(), which by construction runs after context.transaction is already assigned. Still scoped to this one test table's own prototype, not a shared production hook. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Picks up b852a72 (widen atomicWriteFile's Windows rename-retry budget, harper#2036) which fixes the EPERM rename flake on the Configuration integration test suite that was failing "Integration Tests 6/6 (Windows, Node.js v24)" on this PR — unrelated to this PR's own changes.
|
Re-reviewed — |
What / why
A long-lived context — notably an MQTT subscription context, which stays reachable for the
whole life of a suspended delivery loop (
server/DurableSubscriptionsSession.tsstarts thedelivery loop without awaiting it, so
transaction()runs itsonCompleteand commitsimmediately) — kept its
context.transactionreference pointing at the completedDatabaseTransactionforever, pinning it (and whatever it exclusively retained) in memory.A V8 heap snapshot from a production node running
harper-pro5.1.26 under a ~127k-connectionMQTT load test found 157,893 live
DatabaseTransactionobjects on a single worker (vs. 10,620connections — ~14.87 per connection), 157,886 of them retained by a plain
object Objectthrough a property literally named
transaction:resources/transaction.ts:40'scontext.transaction = transaction, never cleared. 22.9 MB shallow on one of 16 workers.DatabaseTransactionnow releases the context's back-reference once a transaction is trulydone:
resources/transaction.ts's{ doneWriting: true },the commit issued once the caller's callback has fully returned) or on
abort().this.#context?.transaction === this) so a context already re-pointed at adifferent (e.g. reused) transaction is never clobbered.
context.transaction.commit()— the documented "thistransaction be reused and committed again" pattern intentionally keeps recommitting and
adding writes to the same instance, so releasing there would strand later writes with
nothing to join.
transaction (the existing "commit now, keep the handle open for iterators" replay path) —
those iterators still depend on the same instance, so the release completes once the last
one drains (
doneReadTxn()/releaseReadTxn()).abortDueToTimeout()(this.timedOut) — Resource.ts'sdispatcher deliberately keeps joining a timed-out transaction so the rest of that logical
operation fails atomically (Abort over-time write transactions instead of force-committing (#1407) #1411), instead of silently starting fresh after a partial
rollback. Releasing there would have disarmed that guarantee (caught by independent review
and verified against
integrationTests/resources/txn-overtime-atomicity.test.ts).Scoped to the RocksDB path.
LMDBTransactionfully overridescommit()/abort()anddoesn't call into this cleanup — under
HARPER_STORAGE_ENGINE=lmdbthis retention isunaffected either way (flagged by independent review; left out of scope for this PR).
Expected win (be honest about the size): ~2.2 KB/connection of directly-freed shallow size
(22.9 MB / 10,620 connections), roughly 2.8% of the ~79 KB/connection measured total, plus
whatever each transaction exclusively retained. This is a clean, root-caused, low-risk win —
not the headline fix for per-connection memory. The larger costs (the per-subscription
async-iterator closure chain, ~27%; socket write-path buffers, ~20%; subscriptions-per-connection,
application-side) are being handled separately and are out of scope here.
Implementation note
The literal fix (release unconditionally at the three existing cleanup points, mirroring the
resourceCacheclearing that used to happen at the same three points before23298663dremoved it as vestigial) broke two existing tests exercising the "commit in the middle"
pattern, because
context.transactionwould go null the instant any interim write's ownshort-lived transaction completed. Fixed by threading the wrapper's own
doneWritingflagthrough as a
finalgate, plus deferring the release when read iterators are stilloutstanding. See the comments on
releaseContext()/completeDeferredContextRelease()inresources/DatabaseTransaction.tsfor the full reasoning.Test plan
unitTests/resources/transaction.test.js("Releasing the contextback-reference on transaction completion"): commit releases, abort releases, a re-pointed
context is not clobbered, and a context is safely reused for a second
transaction()callafter the first commits.
unitTests/resources/operationContextTransactionLeak.test.js's mechanism-level test(Audit records lack user attribution for writes from registered operations (no ambient operation context) #1591/Audit records now attribute registered-operation writes to the authenticated user #1592 regression coverage) to assert the new, stronger invariant this change provides.
unitTests/resources/lingeringWriteCommit.test.jsto capture the transactionreference before it can be released, and added an assertion that the context's own reference
is released once the outstanding iterator drains.
npm run test:unit:resources(1346 passing) andnpm run test:unit:main(4133 passing, 11pre-existing/unrelated failures verified against unmodified
main— worktree-sandboxedcomponent-loading fixtures and one config-validator path-length test, both untouched by this
diff) both green relative to this change.
integrationTests/resources/txn-overtime-atomicity.test.tsandovertime-multi-write-atomicity.test.tsboth pass (the Abort over-time write transactions instead of force-committing (#1407) #1411 atomicity guarantee this PRcould have disarmed).
came back CHANGES with a confirmed blocker (the timeout-poison interaction above) and a
confirmed test-coverage regression (a rewritten test lost its original discriminating power);
both fixed and re-verified. Round 2 re-check: the graded/independent leg timed out on infra
grounds; the advisory legs that did complete raised nothing new that held up under inspection.
Scope
Out of scope per the source investigation, not attempted here: the MQTT/subscription code
(
server/DurableSubscriptionsSession.ts), the async-iterator closure chain, socket buffers, andreducing subscriptions-per-connection. A v5.1 backport is likely wanted (affected clusters run
5.1.x) but is left to release management to decide.
🤖 Generated with Claude Code