Skip to content

Abort a request-scoped transaction promptly when its client disconnects - #2047

Merged
kriszyp merged 9 commits into
mainfrom
fix/abort-request-transaction-on-client-disconnect
Sep 30, 2026
Merged

kriszyp merged 9 commits into
mainfrom
fix/abort-request-transaction-on-client-disconnect

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 1, 2026 •

Copy link
Copy Markdown
Member

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 signal aborts, 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

The invariants are recorded in resources/DESIGN.md — A client disconnect cancels the request's writes, indexed from the root DESIGN.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 fresh transaction(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:

  • Cancellation is on by default, and the storage layer is coupled to request lifetime. Existing apps' post-disconnect writes change behavior at once; the opt-out is a signal-free context. Write admission is the only placement that also covers writes made outside any scope.
  • ALS-inherited background work is audited, not proven clean. A timer armed inside a request inherits that request's context. I checked the plausible lazily-armed writers — analytics (fixed above), the scheduler (armed at startup and component load), expiration cleanup (writes through removeEntry), the caching source-fill (its own signal-free sourceContext). 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.
  • The monitor can still split a chain. A disconnect after submission spares the whole chain, but the monitor's own timeout path still aborts an unsubmitted store whose pre-commit work stalled past COMMIT_PHASE_GRACE (~10 min) — deliberately, since that store's source has stalled.
  • After a failed final commit, iterators the callback opened but did not return are left to the monitor, which can pin a read snapshot for up to the open-transaction limit — in exchange for not closing an iterator the callback may have handed out (main's lingeringWriteCommit test depends on that).
  • @decide hooks 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.
  • A WebSocket close aborts writes still in flight on that connection, clean close or not; ws.send(write); ws.close() without waiting for the ack is not durable.
  • requestAbortedError is ServerError with 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 as generateStream({ signal }); with one listener per write-bearing chain it could be relaxed.
  • MQTT is not covered: DurableSubscriptionsSession.createContext() copies no signal.
  • MAX_DEFERRED_POISON_TICKS = 2 is a constant, not derived from timeoutBudget.
  • A slow post-submit completion can trip the stalled-commit escalation on a write that already landed. nativeCommitSubmitted (and hence deferForCommitInFlight's "outcome unknown" branch) stays true until commit()'s whole returned promise settles — which includes waiting on confirmReplication for a peer that is slow or down (resources/DatabaseTransaction.ts:2914, resources/DatabaseTransaction.ts:2090-2099). If that wait outlives MAX_DEFERRED_POISON_TICKS * txnExpiration, poisonAfterStalledSubmittedCommit() sets timedOut = true on the chain root even though the native write is already durable — and clearAttemptState() never clears timedOut, so a mid-scope explicit commit() 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.
  • Default-path cost is unmeasured. Every async commit adds one wrapper promise and two closures; each write-bearing request cycle adds and removes one listener, and a context with no scope pays that per write. Reviewers proposed folding the settle bookkeeping into performCommit; worth a write-throughput benchmark before merge.
  • No end-to-end socket test. Every disconnect test drives a synthetic AbortController; a transport that stopped aborting its signal would pass them all.
  • Main's read-range guard changes what a late consumer sees. Draining a RocksDB iterator whose snapshot this PR reclaimed now throws ReadSnapshotExpiredError instead of ending early; LMDB still drains to done. Two tests encode that split.
  • Size: the review / review bot cannot finish on this diff. On e04ef3a4, attempt 1 hit the workflow's 30-minute timeout and attempt 2 ended Prompt 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, on e2c2107c1, was "no blockers found"; this head's own diff is identical to that one outside resources/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:resources on 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.
  • Suites: txn-tracking.test.js Disconnect abort, transactionCommitBoundary.test.js, resumeAfterMidScopeCommit.test.js, Request.test.js and deploymentRecorder.test.js.
  • Every new or reversed test was run against the code with its fix removed, and failed: a scope opened on an aborted signal; a synchronously staged write on one; write A rolled back, then a follow-up 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 a before hook; the analytics flush running outside the arming request's context (also run in full-suite order, since crud.test.js leaves analytics disabled).
  • Tests from main adapted, 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 to done), 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 on main after 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 --check and check:design-docs clean.
  • Rebased onto current main (c87f4b174). The 38-commit review history was squashed to one commit before the rebase; conflicts in DatabaseTransaction.ts, LMDBTransaction.ts, Table.ts, Request.ts and DESIGN.md resolved keeping both sides, with main's database-closing assert placed before the native-submission marker.
  • Two further mechanical rebases as main kept moving during PR maintenance (0304f2120, then 794cb5dbc). The only real conflict was splicing main's new monitorCommit force-commit-wait feature (a fresh top-level commit() now awaits the monitor's own in-flight force-commit instead of racing it) into this PR's restructured commit()/performCommit(); round 17's independent review confirmed the splice sound by code trace. resources/DESIGN.md also needed pruning back under check:design-docs's 1000-line budget twice, as unrelated main commits 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.
  • Not run: no end-to-end socket test; test:unit:main does 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-1

Review-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

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

@claude

claude Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

Comment thread resources/transaction.ts Outdated
@kriszyp
kriszyp force-pushed the fix/abort-request-transaction-on-client-disconnect branch from aabed52 to 60261b4 Compare August 5, 2026 18:14
@kriszyp
kriszyp force-pushed the fix/abort-request-transaction-on-client-disconnect branch from 60261b4 to e83577f Compare August 6, 2026 11:19
Comment thread DESIGN.md Outdated
Comment thread resources/DatabaseTransaction.ts Outdated
Comment thread resources/DatabaseTransaction.ts Outdated
Comment thread resources/LMDBTransaction.ts Outdated
Comment thread resources/transaction.ts Outdated
Comment thread resources/DatabaseTransaction.ts Outdated
Comment thread resources/DatabaseTransaction.ts Outdated
Comment thread resources/DatabaseTransaction.ts
@kriszyp
kriszyp force-pushed the fix/abort-request-transaction-on-client-disconnect branch from b022d8a to 4dcfef6 Compare August 19, 2026 03:25
Comment thread resources/DatabaseTransaction.ts Outdated
Comment thread resources/DatabaseTransaction.ts
Comment thread resources/LMDBTransaction.ts Outdated
Comment thread resources/transaction.ts Outdated
Comment thread DESIGN.md Outdated
Comment thread resources/transaction.ts Outdated
Comment thread resources/transaction.ts Outdated
Comment thread resources/DatabaseTransaction.ts Outdated
Comment thread resources/DatabaseTransaction.ts Outdated
@cb1kenobi

Copy link
Copy Markdown
Member

Reviewed e4846cef — no open issues. This PR looks good, nice job!

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 commit(), so the recommendation was to rescore it Low. This push makes a real forced failure reachable, so here is the measurement instead.

The root cause is in utility/when.ts:4-13: when(value, callback, reject) only wires reject when value is a promise, and even then it guards value's rejection, not the promise the callback returns. With completions empty, value is null, so callback() runs synchronously and its rejected promise is handed back untouched — abortAfterCommitError was unreachable. The new try/catch plus the explicit .catch() on the returned chain covers both the synchronous throw and the returned rejection, and still covers a Promise.all(completions) rejection as before.

I instrumented abortAfterCommitError with a marker, rebuilt dist/ each time (one isolated match), and drove the new test's forced ERR_CORRUPTION rejection through the stubbed native Transaction.prototype.commit:

reaches abortAfterCommitError chained store open test
5eba8aee (prior head, same test copied in) 0 OPEN fails
e4846cef (this head) 1 CLOSED passes

At head the chained store also discards its staged writes and neither store's row is visible afterwards. abortAfterCommitError is typed : never and rethrows, so commit() still rejects — the .catch() does not swallow the failure.

The test earns its keep: dropping just the returned-chain catch (return commitResult in place of return commitResult.catch(...), rebuilt, one isolated marker in dist/) takes the file from 16 passing / 0 failing to 15 passing / 1 failing, and the failure is precisely "the failed commit must abort its chained store".

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: releaseReadTxn() goes through detachOwnedTransaction() so the handle is nulled before a second pass, drainCompletions() early-returns on an empty list, and abort() ends with clearWrites(). No path double-frees a native handle.

Everything fixed in the previous round stayed fixed — the delta is 1 commit and 2 files, and git diff confirms nothing outside performCommit's tail moved. Spot-checked anyway: the getReadTxn bootstrap block is still gone (debugLongTransactionsReads.test.js 4 passing on both engines at head and at base 29072607), the LMDB monitor's CLOSED branch is intact at LMDBTransaction.ts:431, and transaction.ts:147 still passes abort(true) alongside closeOwnedReadIterators().

Full unitTests/resources against the true merge base, both engines: RocksDB 1684 passing / 0 failing at head vs 1658 / 0 at base; LMDB 1451 / 0 vs 1425 / 0. No failures on either side, and neither known flake band came up this run. prettier --check, oxlint, and tsc --noEmit are clean on both changed files.

One CI note: Integration Tests 2/6 (Windows, Node.js v24) is red, but it fails on readiness timeouts (Probe /SeoPageCache/ did not become ready within 120000ms, ECONNREFUSED after restart_service), it also failed at the previous head 5eba8aee, and the same shard is failing on main in four of the last eight commits. Not attributable to this change.

—
Generated by Barber AI

@kriszyp
kriszyp force-pushed the fix/abort-request-transaction-on-client-disconnect branch from e4846ce to 206354f Compare August 26, 2026 03:43
@kriszyp
kriszyp force-pushed the fix/abort-request-transaction-on-client-disconnect branch from 206354f to 732b734 Compare September 9, 2026 15:00
kriszyp added a commit that referenced this pull request Sep 28, 2026
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
@kriszyp
kriszyp force-pushed the fix/abort-request-transaction-on-client-disconnect branch from 732b734 to 613785c Compare September 28, 2026 18:39

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread resources/LMDBTransaction.ts Outdated

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@kriszyp

kriszyp commented Sep 28, 2026

Copy link
Copy Markdown
Member Author

@cb1kenobi re: LMDB pre-commit resume throws on disconnect

Good catch — confirmed and fixed in 05b9429. The whole-chain spare set poisonedMidCommit on the LMDB child, and the cascade admitted its continuation, but the resume after its before hooks still threw on disconnected, so the head landed and the child was discarded. The resume now skips those two throws under poisonedMidCommit, matching DatabaseTransaction.performCommit; a disconnect before any store submits still throws there.

New regression in transactionCommitBoundary.test.js ("lets an LMDB child parked in pre-commit work land when a disconnect follows the head submission") drives exactly your sequence and failed with Transaction was aborted because the client disconnected before the fix. I also checked the other poison throw sites in both engines: they are fresh-write admission or already guarded. Full test:unit:resources is green on both engines.

— Claude Opus 5.5

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

kriszyp added a commit that referenced this pull request Sep 29, 2026
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
@kriszyp
kriszyp force-pushed the fix/abort-request-transaction-on-client-disconnect branch from 05b9429 to 5137f20 Compare September 29, 2026 21:22

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit only repairs a missing word in the disconnect-cancellation design note. It introduces no confirmed blocking issue on a changed line.

—
Reviewed 5137f20

kriszyp and others added 9 commits September 29, 2026 15:55
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>

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest commit only condenses the design notes to satisfy their line budget. No confirmed blocking defect was introduced on changed lines.

—
Reviewed e2c2107

@kriszyp
kriszyp force-pushed the fix/abort-request-transaction-on-client-disconnect branch from e2c2107 to e04ef3a Compare September 29, 2026 22:09

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest changes only condense existing design documentation. No confirmed blocking defect remains on changed lines.

—
Reviewed e04ef3a

@kriszyp
kriszyp merged commit b09a78a into main Sep 30, 2026
54 of 56 checks passed
@kriszyp
kriszyp deleted the fix/abort-request-transaction-on-client-disconnect branch September 30, 2026 01:11
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