Repository navigation
Abort a request-scoped transaction promptly when its client disconnects - #2047
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces immediate transaction aborts upon client disconnect to prevent orphaned write-bearing transactions from holding native write intents. It adds a disconnected poison flag to DatabaseTransaction, registers an 'abort' listener on context.signal during transaction execution, propagates poison flags across multi-store transaction chains, and wires up real abort signals for uWS WebSocket connections. Comprehensive unit tests have been added to verify these behaviors. I have no feedback to provide as there are no review comments.
|
Reviewed; no blockers found. |
aabed52 to
60261b4
Compare
60261b4 to
e83577f
Compare
b022d8a to
4dcfef6
Compare
b462e1f to
dbdca81
Compare
|
Reviewed Finding 6 is fixed, not merely mitigated — and this time I could prove it. The last round could only confirm it structurally: the forced-failure probe never reached the patched native The root cause is in I instrumented
At head the chained store also discards its staged writes and neither store's row is visible afterwards. The test earns its keep: dropping just the returned-chain catch ( Double-abort was the thing I most wanted to rule out, since the inner failure branch and the new outer handler can both fire. It is safe: Everything fixed in the previous round stayed fixed — the delta is 1 commit and 2 files, and Full One CI note: — |
e4846ce to
206354f
Compare
206354f to
732b734
Compare
A client that disconnects mid-request left its request-scoped transaction open, holding staged writes and native RocksDB write intents, until the handler's promise settled or the long-transaction monitor swept it 30-60 s later (harper#2001). transaction() now listens on context.signal for the lifetime of the scope and poisons/aborts through the same abortAndPoison path the monitor uses, with the same sourceApply/isReplay exclusions. Supporting boundaries: native submission (not entry into commit()) is the point of no destructive return; transaction-owned read iterators are tracked and closed when they become unreachable; both engines funnel commit-chain failures through the wrapper-level abort; the LMDB monitor gains its missing CLOSED branch; uWS WebSocket connections get a real disconnect signal. Squashed from the review history of PR #2047 ahead of a rebase onto main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPLiusCBMeLbZJyWwtxdxP
732b734 to
613785c
Compare
cb1kenobi
left a comment
There was a problem hiding this comment.
On LMDB, a disconnect after the head store has submitted still aborts a chained store waiting in before-hooks, which splits a multi-store commit. Guard those resume throws with poisonedMidCommit the same way RocksDB does, so the unsubmitted store rides the cascade.
—
Reviewed 613785c
cb1kenobi
left a comment
There was a problem hiding this comment.
The latest commit only changes how the long-transaction monitors log reclaiming abandoned iterators, so every snapshot release is visible. No new blocking defects showed up on this head. The already-raised LMDB pre-commit resume still throws on disconnect without a poisonedMidCommit guard; wrap those throws like RocksDB so a disconnect after the head submits cannot split a multi-store commit.
—
Reviewed 732a3d3
|
@cb1kenobi re: LMDB pre-commit resume throws on disconnect Good catch — confirmed and fixed in 05b9429. The whole-chain spare set New regression in — Claude Opus 5.5 |
cb1kenobi
left a comment
There was a problem hiding this comment.
The LMDB pre-commit resume now honors the chain-wide submission boundary and avoids splitting the commit after a disconnect. The regression test covers the previously reported failure. No new blocking defects were found on changed lines.
—
Reviewed 05b9429
cb1kenobi
left a comment
There was a problem hiding this comment.
The last blocking split is closed: after a disconnect, an LMDB child parked in before-hooks now rides the cascade once any store has submitted, instead of throwing 499 and dropping its writes. A regression test covers that sequence. Nothing else on the changed lines is a confirmed remaining blocker.
—
Reviewed 05b9429
A client that disconnects mid-request left its request-scoped transaction open, holding staged writes and native RocksDB write intents, until the handler's promise settled or the long-transaction monitor swept it 30-60 s later (harper#2001). transaction() now listens on context.signal for the lifetime of the scope and poisons/aborts through the same abortAndPoison path the monitor uses, with the same sourceApply/isReplay exclusions. Supporting boundaries: native submission (not entry into commit()) is the point of no destructive return; transaction-owned read iterators are tracked and closed when they become unreachable; both engines funnel commit-chain failures through the wrapper-level abort; the LMDB monitor gains its missing CLOSED branch; uWS WebSocket connections get a real disconnect signal. Squashed from the review history of PR #2047 ahead of a rebase onto main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPLiusCBMeLbZJyWwtxdxP
05b9429 to
5137f20
Compare
A client that disconnects mid-request left its request-scoped transaction open, holding staged writes and native RocksDB write intents, until the handler's promise settled or the long-transaction monitor swept it 30-60 s later (harper#2001). transaction() now listens on context.signal for the lifetime of the scope and poisons/aborts through the same abortAndPoison path the monitor uses, with the same sourceApply/isReplay exclusions. Supporting boundaries: native submission (not entry into commit()) is the point of no destructive return; transaction-owned read iterators are tracked and closed when they become unreachable; both engines funnel commit-chain failures through the wrapper-level abort; the LMDB monitor gains its missing CLOSED branch; uWS WebSocket connections get a real disconnect signal. Squashed from the review history of PR #2047 ahead of a rebase onto main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPLiusCBMeLbZJyWwtxdxP
A disconnect used to poison only the transaction open when the 'abort' event fired. A later transaction() on the same context shared the already-aborted signal but never saw an event, so it committed -- while the direct Resource/Table spelling of the same write joined the poisoned transaction and was rejected. That made post-disconnect writes succeed or fail depending on how they were spelled. Cancellation is now a property of the request: a scope opened on an already-aborted signal starts with disconnectPending, so reads complete but any write is refused with 499, and a listener armed on an aborted signal runs immediately. Work that must outlive the client runs on a context without the signal, as deploymentRecorder already does. The analytics flush is now armed outside the request's AsyncLocalStorage context: it was armed by whichever request recorded the first sample of a period, so the flush and the scheduled tasks it starts ran under that request's context -- as its user, and, once its client disconnected, with every write refused. Also: move a commit-phase test off id 2071, which main's staggered-cascade test now uses on the same table; expect main's ReadSnapshotExpiredError when a late consumer drains a reclaimed RocksDB iterator; port the design notes into resources/DESIGN.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPLiusCBMeLbZJyWwtxdxP
…d chain whole Round-10 review findings: - A retained instance could write after its scope completed and the client disconnected: txnForContext hands it a per-context self-committing transaction that never passes through transaction(). The cancellation record now lives where every write is admitted -- each transaction created for a request carries its signal as requestSignal on the chain root, and rejectIfRequestCancelled() in both engines' addWrite and in save()'s deferred-save admission refuses once it has aborted. disconnectPending is gone; the listener now only makes the release of a write-bearing transaction prompt. - A disconnect landing while the head store's native commit was in flight aborted the still-unsubmitted chained store, leaving the head durable and the chained store discarded -- a split multi-store commit that main never produced. For a disconnect, native submission is now the point of no return for the whole chain; the monitor still aborts an unsubmitted store whose pre-commit work outlived its grace. - The wrapper closed owned iterators after a commit failure too, where the callback had completed and may have handed one out; main's read-range guard exposed it. The close now runs only when the callback threw. - The "second database" disconnect tests never formed a chain (test and test2 alias one path); they now use a separate database, and a new engine-driven test proves both stores land when the disconnect follows submission. - Make the analytics context test independent of suite order, adapt main's @decide cancellation test to the refused write, and fix two misplaced or stale comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPLiusCBMeLbZJyWwtxdxP
Adopts the second planning round's better-alternative-exists verdict. The abort listener was armed per transaction() scope, so a retained instance writing after its scope ended -- on a per-context self-committing transaction -- had none: a write parked in its pre-commit work kept its staged intents after a disconnect until that work finished. And every read-only request paid a listener add/remove for nothing. admitRequestWrite() now both refuses a write on an aborted request and, on the first admitted write, subscribes the chain root to the signal; the subscription is released when the chain's writes and commit attempts settle or the root aborts. transaction() only records the request's signal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPLiusCBMeLbZJyWwtxdxP
Round-11 review: LMDB's addWrite admitted the write against the lingering chain root and then gave it to a throwaway ImmediateTransaction with no request signal, so a pre-submit write parked there was not cancelled on disconnect, and the settled root kept a subscription it would never release. The throwaway now carries the request's signal and its own admission refuses and subscribes; the root is admitted only on the staging path. LMDB's ImmediateTransaction.save admits a deferred save the way the RocksDB class does. Also refresh comments left describing the old scope-armed listener. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPLiusCBMeLbZJyWwtxdxP
main's QA-716 integration anchor (new to this PR's base) asserts that the long-transaction monitor names the abandoned iterator whose snapshot it reclaims. With iterator ownership, the monitor reclaims it by closing the owned iterator, and that path returned before logging -- so the reclaim an operator needs to see became silent. Both monitors now log whichever mechanism does the reclaim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPLiusCBMeLbZJyWwtxdxP
Review on 613785c: for a disconnect, native submission of any store is the point of no return for the whole chain, so abortAndPoison excuses every link (poisonedMidCommit) instead of aborting the unsubmitted ones. RocksDB's post-pre-commit resume honors that; LMDB's still threw requestAbortedError on `disconnected`, so an LMDB child store parked in blob/before work when the head had already submitted was discarded while the head landed -- the split the rule exists to prevent. Guard the resume the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Dispatch-Task: fix-kriszyp_harper_2047-6c1b1388
resources/DESIGN.md:504 read "drives the uWS WebSocket upgrade's from the adapter's close" — missing the noun the possessive was pointing at. Dispatch-Task: pr-maint-6c2520a0c60c651c74a639056dfc8b35 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The rebase (main commits, plus this branch's own disconnect section) pushed the file over build-tools/check-design-docs.mjs's hard 1000-line ceiling per file, twice in a row as main kept moving during this task (each time by another unrelated PR adding a line or two to this same file) — so this leaves a few lines of real headroom rather than landing exactly at 1000 again. Consolidated the "Over-time transactions", "A client disconnect cancels the request's writes", and blob-cleanup sections into fewer, denser bullets — kept the disconnect section as two bullets (not one merged paragraph) per review feedback, so it stays scannable. No invariant dropped. Dispatch-Task: pr-maint-6c2520a0c60c651c74a639056dfc8b35 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
e2c2107 to
e04ef3a
Compare
A client that disconnects mid-request used to leave its request-scoped transaction open — holding staged writes and native RocksDB write intents — until the handler's promise settled or the 30–60 s long-transaction monitor swept it. harper#2001 traced a live incident to exactly this: an orphan held past a disconnect wedged a later, conflicting commit 27 s afterwards, and with it that worker's write path until restart. With this PR, once a request's
signalaborts, the request cannot write any more — in whatever transaction, through whatever API spelling — and the writes it had staged are released at once.How it works
signalon the chain root — set intransaction()and intxnForContext's per-context self-committing transaction, the two places a transaction is created for a request context.admitRequestWrite()runs in both engines'addWriteand at every deferred-save admission: an aborted signal poisons the chain and the write is refused withrequestAbortedError(499). So a static-API write in acatch— which joins the poisoned context transaction — a freshtransaction(context, …), and a retained instance writing after its scope ended are all refused alike, as theContext.signalcontract now states.sourceApplynever carries the signal; it has no resume path (harper-pro#348).abortAndPoison()the monitor'sabortDueToTimeout()uses.commit(), is the point of no destructive return. A poison landing while a submitted attempt is in flight flags the chain but aborts nothing, so the attempt reaches its native outcome; aborting would race cleanup against an unknown outcome and unlink blob files a successful commit references (Over-time transaction abort deletes the pre-saved deploy payload blob, leaving later deploys referencing a destroyed blob (regression from #1411, 5.1.21) #2062). For a disconnect that covers the whole chain: once any store has submitted, the chain's unsubmitted stores ride the cascade instead of being aborted, so a multi-store commit never splits (Force-committing an over-time transaction leaves orphaned secondary-index entries (atomicity violation) #1407) — and both engines' resume after pre-commit work honors that, so a chained LMDB store parked in a blob save still lands. The submission marker is set immediately before each native commit, after main's database-closing assert so a closing database cannot leave the root marked submitted.Table.search()registers each result set with the transaction owning its read reference;closeOwnedReadIterators()runs theironDoneacross the chain. The wrapper closes them only when the callback threw, since then nothing was returned to consume them; both monitors'CLOSEDarm is the backstop — and the LMDB monitor had noCLOSEDarm at all, so a poisoned link pinned an LMDB read snapshot for the life of the process.AsyncLocalStoragecontext: attributed to its user, and under request-lifetime cancellation, refused once that one client had gone.abort(). Request signals accept any number of listeners, and the uWS fallback signal is created once. uWS WebSocket connections never had a working disconnect signal; the upgrade's controller is now driven by the adapter's'close'. Both engines funnel commit-chain failures through one wrapper-level abort, LMDB's abort cascade skips a child whose native commit is outstanding, and a lingering LMDB write's throwaway transaction owns its own cancellation.The invariants are recorded in
resources/DESIGN.md— A client disconnect cancels the request's writes, indexed from the rootDESIGN.md.For the human reviewer
Framing-Verdict: better-alternative-exists (114e2b20e2d7)
Both planning verdicts were non-clearing, and both were resolved by adopting the alternative. The first planning recheck held that cancellation should belong to the request lifetime rather than to the one transaction open when the event fired; you ruled to adopt it. The second, run after that implementation, kept that framing and proposed a write-owned subscription in place of a per-scope listener, on two facts: a retained instance's self-committing write had no listener at all, and every read-only request paid for one. I adopted it. The change a caller will notice: every write on a request after its client disconnected is refused with 499 — including in a
catch/finally, a freshtransaction(context, …), and a retained instance. Work that must outlive the client needs a context without the signal, as deployment tracking already builds.Decisions taken here, and where to push back:
removeEntry), the caching source-fill (its own signal-freesourceContext). Anything else armed inside a request that writes through the static API after its client left now gets 499 where it used to write as that request's user.COMMIT_PHASE_GRACE(~10 min) — deliberately, since that store's source has stalled.lingeringWriteCommittest depends on that).@decidehooks still run on an already-aborted request before its write is refused; a decider that ignores the signal can make a paid model call for a dead request. An early check before the hooks is easy to add.ws.send(write); ws.close()without waiting for the ack is not durable.requestAbortedErrorisServerErrorwith a non-standard 499; the class name is visible in logs and metrics.setMaxListeners(0)on every request signal also silences leak warnings for application listeners such asgenerateStream({ signal }); with one listener per write-bearing chain it could be relaxed.DurableSubscriptionsSession.createContext()copies nosignal.MAX_DEFERRED_POISON_TICKS = 2is a constant, not derived fromtimeoutBudget.nativeCommitSubmitted(and hencedeferForCommitInFlight's "outcome unknown" branch) stays true untilcommit()'s whole returned promise settles — which includes waiting onconfirmReplicationfor a peer that is slow or down (resources/DatabaseTransaction.ts:2914,resources/DatabaseTransaction.ts:2090-2099). If that wait outlivesMAX_DEFERRED_POISON_TICKS * txnExpiration,poisonAfterStalledSubmittedCommit()setstimedOut = trueon the chain root even though the native write is already durable — andclearAttemptState()never clearstimedOut, so a mid-scope explicitcommit()that was going to rotate and keep taking writes instead gets a permanent 503 on its next write, for a commit that actually succeeded. Surfaced by round 18's independent review, not reproduced with a test; the fix suggested there is ending the "submitted" state when the native commit itself resolves, before the post-commit completions (confirmation, flush) are awaited — worth a look before merge, but it predates this rebase and is not something this pass changed.performCommit; worth a write-throughput benchmark before merge.AbortController; a transport that stopped aborting its signal would pass them all.ReadSnapshotExpiredErrorinstead of ending early; LMDB still drains todone. Two tests encode that split.review / reviewbot cannot finish on this diff. One04ef3a4, attempt 1 hit the workflow's 30-minute timeout and attempt 2 endedPrompt is too long, as on five earlier heads: the 19-thread context snapshot (~33K tokens) sits outside the bot's readable directory, and the session runs out of context. Its last completed verdict, one2c2107c1, was "no blockers found"; this head's own diff is identical to that one outsideresources/DESIGN.md. The check is not required. The submission-boundary and iterator-ownership work is separable in principle, but the cancellation is unsafe without it.Verification
test:unit:resourceson the pushed head: LMDB 2758 passing / 0 failing; RocksDB 3551 / 0. CI Unit Test passed on Node 22/24/26 and Windows at the previous head; the last commit changes only two monitor log statements. Both engines were also green at each intermediate commit of this round.txn-tracking.test.jsDisconnect abort,transactionCommitBoundary.test.js,resumeAfterMidScopeCommit.test.js,Request.test.jsanddeploymentRecorder.test.js.transaction()on the same request refused; a static-API write after a read-only scope; a retained instance's write after its scope ended; a self-committing write parked in blob pre-commit work, aborted on disconnect; the chain subscribing only while it owns writes; a lingering LMDB write's throwaway owning its cancellation; a real two-database commit landing whole when the disconnect follows the head's native submission, and its LMDB counterpart with the chained store parked in abeforehook; the analytics flush running outside the arming request's context (also run in full-suite order, sincecrud.test.jsleaves analytics disabled).mainadapted, with the reason in each:@decide's cancellation test (the decider still sees the signal; the write is now refused), two iterator tests (main's read-range guard names an expired snapshot on RocksDB; LMDB still drains todone), and a commit-phase test moved off id 2071, which main's staggered-cascade test now uses on the same table.integrationTests/resources/qa716-lingering-write-commit.test.ts(RocksDB) passes locally, 4/4. It landed onmainafter this PR's old base, and its first CI run here caught that the monitor reclaimed an abandoned iterator by closing it silently; both monitors now log every such reclaim.npm run build,lint:required,prettier --checkandcheck:design-docsclean.main(c87f4b174). The 38-commit review history was squashed to one commit before the rebase; conflicts inDatabaseTransaction.ts,LMDBTransaction.ts,Table.ts,Request.tsandDESIGN.mdresolved keeping both sides, with main's database-closing assert placed before the native-submission marker.mainkept moving during PR maintenance (0304f2120, then794cb5dbc). The only real conflict was splicing main's newmonitorCommitforce-commit-wait feature (a fresh top-levelcommit()now awaits the monitor's own in-flight force-commit instead of racing it) into this PR's restructuredcommit()/performCommit(); round 17's independent review confirmed the splice sound by code trace.resources/DESIGN.mdalso needed pruning back undercheck:design-docs's 1000-line budget twice, as unrelatedmaincommits kept nudging the same file over — condensed bullets, no invariant dropped, kept the disconnect section as two bullets (not one) per review feedback on scanability.test:unit:maindoes not run in this environment, so CI is its gate.Complexity: complicated
Origin — the dispatch brief this PR was written from
Abort a request-scoped transaction promptly when its client disconnects
Maintain #2047 (Abort a request-scoped transaction promptly when its client disconnects) on branch fix/abort-request-transaction-on-client-disconnect. Read the current PR head, mergeability, and latest check runs before acting; the dispatch observation may be stale. First read the remote head for refs/heads/fix/abort-request-transaction-on-client-disconnect without updating local refs and require it to equal this task's observed head 05b9429; if it differs, stop with needs-input because remote history changed after dispatch. Then fetch only refs/heads/main from origin into refs/remotes/origin/main. If Git reports a paused rebase for this task's exact generation branch, require that rebase's recorded original head to equal 05b9429 and its recorded onto commit to equal the fetched origin/main tip; stop with needs-input on either mismatch. Only then resume it without repeating the local-HEAD ancestry check or starting another rebase. Otherwise record the remote SHA, verify it is an ancestor of local HEAD, and rebase onto the fetched base. If Context carries companion instructions, follow them exactly for every named gitlink; they override ordinary conflict handling. Preserve both sides' intent for every other conflict. Stop with needs-input on semantic conflicts. Force-with-lease is authorized only for this rebase. Immediately before pushing, re-read the PR's base ref and body plus every companion PR head/state named in Context. The base must still be main, and the declarations and submodule bindings must still match Context. If an open companion advanced, update its named gitlink to the new exact head and rerun relevant tests. If it merged, point the named gitlink at the companion repository's current default-branch tip after verifying that tip contains the merge, then rerun relevant tests. If it closed without merging, stop with needs-input and do not publish its abandoned head. If the base, declaration, binding or any other companion state changed ambiguously, stop with needs-input. Then push the rebased head with
git push --force-with-lease=refs/heads/fix/abort-request-transaction-on-client-disconnect:EXPECTED_HEAD_SHA origin HEAD:refs/heads/fix/abort-request-transaction-on-client-disconnect, substituting the recorded SHA. If the remote head changes, stop with needs-input; never overwrite intervening remote work. Do this before evaluating CI. Then inspect CI for the resulting current PR head, not old-head failures. Wait for relevant pending checks, diagnose remaining failures, fix them, run relevant tests, and push in THIS task. Do not create a separate CI-fix task. Stop with needs-input for judgment calls or an unavailable CI result; report exactly what was verified. No merge is authorized.Dispatch: task
pr-maint-6c2520a0c60c651c74a639056dfc8b35· queued by automation · ran by claude/sonnet/xhigh · worker kzyp-xps-1Review-Coverage: authored=claude; ran=gemini,codex,cursor-kimi,cursor-composer; adjudicated=domain; declined=cursor-grok,cursor-muse; rounds=18; full=3 @ e04ef3a
Human-Review-Need: 4 (decisions: disconnect-spares-whole-submitted-chain, signal-is-request-lifetime, request-aborted-error-class, stalled-submit-poisons-follow-up, mqtt-out-of-scope) @ e04ef3a