Skip to content

fix: release the context's transaction back-reference on completion - #2030

Open
kriszyp wants to merge 7 commits into
mainfrom
fix/release-context-transaction-backref
Open

fix: release the context's transaction back-reference on completion#2030
kriszyp wants to merge 7 commits into
mainfrom
fix/release-context-transaction-backref

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 31, 2026

Copy link
Copy Markdown
Member

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.ts starts the
delivery loop without awaiting it, so transaction() runs its onComplete and commits
immediately) — kept its context.transaction reference pointing at the completed
DatabaseTransaction forever, pinning it (and whatever it exclusively retained) in memory.

A V8 heap snapshot from a production node running harper-pro 5.1.26 under a ~127k-connection
MQTT load test found 157,893 live DatabaseTransaction objects on a single worker (vs. 10,620
connections — ~14.87 per connection), 157,886 of them retained by a plain object Object
through a property literally named transaction: resources/transaction.ts:40's
context.transaction = transaction, never cleared. 22.9 MB shallow on one of 16 workers.

DatabaseTransaction now releases the context's back-reference once a transaction is truly
done:

  • On the wrapper's own final commit (resources/transaction.ts's { doneWriting: true },
    the commit issued once the caller's callback has fully returned) or on abort().
  • Identity-guarded (this.#context?.transaction === this) so a context already re-pointed at a
    different (e.g. reused) transaction is never clobbered.
  • Not on an in-callback explicit context.transaction.commit() — the documented "this
    transaction 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.
  • Deferred when a final commit still has outstanding read iterators streaming through the
    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()).
  • Not on a transaction poisoned by abortDueToTimeout() (this.timedOut) — Resource.ts's
    dispatcher 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. LMDBTransaction fully overrides commit()/abort() and
doesn't call into this cleanup — under HARPER_STORAGE_ENGINE=lmdb this retention is
unaffected 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
resourceCache clearing that used to happen at the same three points before 23298663d
removed it as vestigial) broke two existing tests exercising the "commit in the middle"
pattern, because context.transaction would go null the instant any interim write's own
short-lived transaction completed. Fixed by threading the wrapper's own doneWriting flag
through as a final gate, plus deferring the release when read iterators are still
outstanding. See the comments on releaseContext() / completeDeferredContextRelease() in
resources/DatabaseTransaction.ts for the full reasoning.

Test plan

  • New unit tests in unitTests/resources/transaction.test.js ("Releasing the context
    back-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() call
    after the first commits.
  • Updated 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.
  • Updated unitTests/resources/lingeringWriteCommit.test.js to capture the transaction
    reference before it can be released, and added an assertion that the context's own reference
    is released once the outstanding iterator drains.
  • New unit test asserting a timeout-poisoned transaction stays attached to its context.
  • npm run test:unit:resources (1346 passing) and npm run test:unit:main (4133 passing, 11
    pre-existing/unrelated failures verified against unmodified main — worktree-sandboxed
    component-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.ts and
    overtime-multi-write-atomicity.test.ts both pass (the Abort over-time write transactions instead of force-committing (#1407) #1411 atomicity guarantee this PR
    could have disarmed).
  • Independent pre-push review (codex + gemini + grok + Harper-domain adjudication): round 1
    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, and
reducing 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

kriszyp and others added 2 commits July 31, 2026 13:55
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>
@kriszyp
kriszyp requested a review from cb1kenobi July 31, 2026 20:30

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

@kriszyp
kriszyp requested a review from harper-joseph July 31, 2026 20:33
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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>
@kriszyp
kriszyp marked this pull request as ready for review July 31, 2026 21:11
@cb1kenobi

Copy link
Copy Markdown
Member

Reviewed 65d6ed92a — no issues found. This PR looks good, nice job!

Traced the release across every path — final wrapper commit (doneWriting: true), abort, the deferred-until-iterators-drain path (doneReadTxn()/releaseReadTxn()), retry recursions, and the multi-store next chain — and the identity guard (#context?.transaction === this) plus the timedOut tombstone exclusion hold up. Confirmed the dispatcher semantics are unchanged: a released (now null) reference takes the same start-fresh branch a CLOSED one did, so #1591 and the #1411 over-time atomicity guarantee are both preserved. Test coverage is thorough.

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 test:unit:resources/test:unit:main results.


Generated by Barber AI

kriszyp and others added 2 commits July 31, 2026 22:59
- 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>
@cb1kenobi

Copy link
Copy Markdown
Member

Re-reviewed 4542a8e1 (2 new commits since 65d6ed92) — no issues found. This PR looks good, nice job!

Incremental re-review of the new terminal-commit-failure release site plus the null/type-widening change:

  • The new releaseContext(!!options.doneWriting) on the terminal (non-conflict) native-commit rejection path fills a real gap — that branch does a native-only transaction.abort(), so the wrapper abort()releaseContext() release never ran there and onComplete() has no rejection handler. Correctly deferral-safe (readTxnsUsedpendingContextReleasedoneReadTxn()/completeDeferredContextRelease()), identity-guarded, and touches only the JS back-reference (no native-handle lifetime change, no use-after-free).
  • Verified no double-release: the 861 failure site and the 872 success site are mutually exclusive branches of the same native commit; releaseContext is idempotent under the identity guard regardless.
  • The retry-exhaustion give-up path (abortChainAfterRetries → wrapper abort()releaseContext at 913) already releases the context, so it is not a companion leak.
  • Abort over-time write transactions instead of force-committing (#1407) #1411 timeout tombstone preserved: commit() throws transactionOpenTooLongError() synchronously at the top for a timedOut transaction, so it can never reach the new release site — the doneWriting gate there (vs !this.timedOut in abort()) is safe.
  • Context.transaction: DatabaseTransaction | null | undefined is a net behavioral no-op — the slot already held null at the prior SHA; no consumer distinguishes null from undefined (dispatcher keys on ?.open === OPEN).

Test changes are consistent (lingeringWriteCommit captures the wrapper before the drain that nulls context.transaction; the mechanism test drops the disallowed prototype.setContext stub for synchronous observation). Unit tests not executed in-environment (native rocksdb build not installed) — code-trace review.


Generated by Barber AI

kriszyp and others added 2 commits July 31, 2026 23:35
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.
@cb1kenobi

Copy link
Copy Markdown
Member

Re-reviewed daae9ccd — no issues found. The update since the last review is a merge of main (RocksDB managed-backup feature, v5.2.0 release, Windows atomicWriteFile retry-budget fix); this PR's own files (DatabaseTransaction.ts, ResourceInterface.ts, tests) are unchanged and the transaction back-reference release remains correct. This PR looks good, nice job!


Generated by Barber AI

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.

2 participants