Stop reads from extending the open-transaction limit while uncommitted writes are held - #2052
Stop reads from extending the open-transaction limit while uncommitted writes are held#2052kriszyp wants to merge 4 commits into
Conversation
…txn holding uncommitted writes getReadTxn() re-armed the limit on every read, so a handler that kept reading while holding staged writes was immortal to the long-transaction monitor. Those staged writes hold verification-table write intents that other writers' coordinated-retry commits park on, so an orphaned reader — a long-poll whose client had already disconnected, in harper#2001 — held them indefinitely and wedged the thread's write path. The limit now runs from the first write rather than the last read. Committed transactions still re-arm: their intents were released by the commit, and the monitor enforces their retained read snapshot separately (releaseReadTxn). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…in arm, fast-path the hot read Codex found the new assertions run unconditionally in the LMDB suite, where LMDBTransaction.getReadTxn() still re-arms unconditionally (and that engine has no verification-table park to wedge) — guarded. Gemini asked for the multi-store case: a write on the next chain with reads only on the head, which pins the chain walk re-arming would otherwise miss; both new write arms fail on origin/main. Both models flagged hasPendingWrites() on the read hot path, so the dominant never-written single-store case now short-circuits on two field reads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request modifies 'DatabaseTransaction.ts' to prevent read operations from extending the transaction timeout limit once uncommitted writes exist. This ensures that write intents are not held indefinitely by ongoing reads. Additionally, three new unit tests are added to 'txn-tracking.test.js' to verify this behavior under various conditions, skipping them when the LMDB storage engine is used. There are no review comments, so I have no feedback to provide.
|
Found a blocker in the latest push: |
Review (claude bot) found the next-chain test proved nothing: the write ran first and so became the head, leaving the read loop on the tail — which correctly re-arms. Chasing that surfaced a second problem: the `next` chain is per-database and a second `database:` in this suite resolves to the same store, so no chain forms through the resource API at all (the pre-existing multi-store test has the same gap). The chain walk is now pinned by constructing the links directly, with a control that re-arms once the chain is drained. QA-176 Q1 asserted status 200, which encoded the re-arm behavior being removed: the transaction it was written to drive the monitor against was immortal, so the monitor never fired on it. It now follows Q2's existing convention — assert index consistency, tolerate either status — and the abort leaves the index consistent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Guided tourThe production change is one condition. Most of the diff is tests, and one of them is a correction to an existing anchor. The change — if ((this.writes.length === 0 && !this.next) || this.open !== TRANSACTION_STATE.OPEN || !this.hasPendingWrites()) {
this.timeout = txnExpiration;
}Previously this line was unconditional, so every read reset the clock. Read the three disjuncts as "re-arm when it is safe to":
The tests — Three arms at identical duration and read cadence, which is the point: a write-holding reader is aborted, a read-only reader at the same cadence still lives. Without the control arm this change is indistinguishable from "shorten everything." The chain-walk arm builds its The anchor correction — QA-176 Q1 asserted The behavior change, stated plainlyA transaction holding uncommitted writes is now capped at Where to look hardest
Review coverageDegraded, and disclosed: gemini contributed; the codex leg exited rc=-1 after writing its findings (recovered from its log and addressed — the LMDB-guard finding was real and would have broken CI); grok was version-gated; the same-family fallback failed auth. The CI bots then caught the chain-walk test flaw on the pushed branch. Posted by Claude Fable 5 on behalf of @kriszyp. |
…rm it, reads don't while writes are pending Kris caught that the previous shape was wrong: it capped any write-holding transaction at maxTransactionOpenTime regardless of activity, so a job that keeps writing would be aborted mid-flight. The limit is meant to reap IDLE transactions, not working ones. Writes now re-arm the limit (addWrite), reads still don't while uncommitted writes are held, and a multi-store chain counts as active while any link has been written recently — so a head that only reads database A isn't aborted out from under continuing writes to database B. The harper#2001 orphan is still reaped: it wrote once and then only read. This also restores QA-176 Q1 to its original assertions — that anchor was right, and the previous commit's edit to it was the tell that the change was too aggressive. It passes unmodified now (status 200, index consistent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correction: the limit is now an idle limit (writes re-arm it)Kris caught a real defect in the earlier revision, and it changes the semantics enough to be worth calling out separately from the tour above. What was wrong. The first shape capped any write-holding transaction at The tell was in the diff. Making that version green required editing QA-176's Q1 anchor (8 indexed writes 350ms apart against a 1s limit) from "expect 200" to "tolerate either status". Rewriting an existing anchor to match new behavior is exactly the signal to re-examine the change rather than the test. That edit is now reverted, and Q1 passes unmodified — status 200, index consistent — because writes re-arm. The rule now:
So: continuous writers live, long read-only scans live, and the harper#2001 shape — wrote once, then only reads while the client is gone — is reaped at 30s from its last write. That last row is the entire behavior change. The multi-store clause matters because writes re-arm only the link that receives them; without it a head that only reads database A would be aborted out from under continuing writes to database B ( Tests are now a four-way discrimination at identical duration and cadence: write-then-idle-reads → aborted; continuous writer → survives and commits; read-only → survives; plus the direct-construction chain check. Two arms fail on Posted by Claude Fable 5 on behalf of @kriszyp. |
| /** | ||
| * True when a link other than `txn` in the same multi-store chain still has limit remaining — | ||
| * i.e. that link received a write recently enough to re-arm itself. Writes re-arm only the link | ||
| * that receives them, so a chain writing database B while its head only reads A would otherwise | ||
| * be aborted by the head's own decay. | ||
| */ | ||
| function chainStillActive(txn: DatabaseTransaction): boolean { | ||
| for (let link: DatabaseTransaction = txn.next; link; link = link.next) { | ||
| if (link.timeout > 0) return true; | ||
| } | ||
| return false; | ||
| } |
There was a problem hiding this comment.
chainStillActive masks read-only activity as write activity, reintroducing the exact immortal-transaction bug on a 2-link chain
File: resources/DatabaseTransaction.ts:991-1002 (used at line 1022)
What: chainStillActive treats any link with timeout > 0 as evidence the chain is "still active" from writes, but timeout is also reset by reads on a link that itself holds no pending writes and has no .next — the fast path at line 293 (this.writes.length === 0 && !this.next) re-arms unconditionally on every read of that link, regardless of activity elsewhere in the chain.
Concretely, for a 2-link chain — write on table A (head), then repeated reads on table B (.next, created once via txnForContext in Table.ts:5505 and reused on every subsequent touch of B) — B's own getReadTxn() hits the fast path and re-arms B on every read. When A's own timeout decays to ≤0, the monitor's hasPendingWrites() is true (A's own write) and chainStillActive(A) walks to B and sees B.timeout > 0 (kept alive purely by B's unrelated reads) — so A gets re-armed forever (line 1027), even though nothing is writing to A and B holds no pending write at all.
This is exactly the harper#2001 shape the PR sets out to fix — "stage a write, then loop reading" — just shifted from one store to two. The PR body states the intent as "active while any link has been written recently," but the implementation checks generic timeout > 0, which doesn't distinguish a recent write from a recent unrelated read.
Why it matters: for any transaction that writes one store and then continues reading a second store in a loop (a very plausible long-poll/orphan shape, and the literal inverse of the multi-store test order this PR added), the write-holding link never expires — the idle-limit fix this PR implements is defeated for the multi-store case it explicitly claims to handle. There is no test exercising chainStillActive at all: the existing "multi-store path" test doesn't form a real .next link (both tables resolve to the same database, as the code comment at line ~90 of the test file now acknowledges), and the new direct-construction test only exercises getReadTxn()/hasPendingWrites(), never the monitor's chainStillActive branch.
Suggested fix: track write recency separately from read recency (e.g., a lastWriteTimeout set only in addWrite, checked by chainStillActive instead of the shared timeout field), and add a direct-construction test that pins the head-writes/tail-reads-only ordering to confirm the head is not kept alive by the tail's unrelated reads.
| }` | ||
| ); | ||
| txn.releaseReadTxn(); | ||
| } else if (txn.hasPendingWrites() && chainStillActive(txn)) { |
There was a problem hiding this comment.
Medium: chainStillActive can keep an idle write-holding chain immortal, reintroducing the harper#2001 leak in the multi-store blind-write shape.
this.timeout decays only for transactions in trackedTxns, and a txn is added there only by getReadTxn (line 319). A next link that receives writes but is never read is therefore never tracked, so its timeout is armed once by addWrite and never decays. chainStillActive treats link.timeout > 0 as "written recently," but for such a link it is permanently positive — so this branch re-arms the head on every tick and the monitor never reaps it.
This is reachable with the common read-one-DB / blind-write-another pattern: the head does A.get(id) (tracked, decays), then B.put(newId, …) stages via addWrite and resolves the existing entry at commit through the write txn — it never calls getReadTxn on B's link, so B is untracked. If the handler then goes idle while holding B's uncommitted write intents (the harper#2001 orphaned long-poll), the head is re-armed forever and B's intents are held indefinitely, so other writers' coordinated-retry commits park on them. Pre-PR that same idle transaction hit the abort branch and was reaped, so this is a regression inside the PR's own target scenario.
Suggested fix: ensure downstream write-holding links actually decay. Simplest option is to have the monitor decay the links chainStillActive inspects that aren't independently tracked (e.g. if (!trackedTxns.has(link)) link.timeout -= txnExpiration; before the > 0 test), so a written-once idle link eventually reaches 0 and the head is reaped. The direct-construction chain test sets timeout manually and does not exercise monitor decay, so a regression test driving a real read-A/blind-write-B txn to idle would pin this.
—
Generated by Barber AI
Why
getReadTxn()re-armedthis.timeout = txnExpirationon every read. A handler that keeps reading while holding staged writes therefore resets its own clock forever and is immortal to the #1407/#1411 long-transaction monitor — while those staged writes hold verification-table write intents that other writers'coordinatedRetrycommits park on.That is the mechanism behind the remaining half of harper#2001: an orphaned handler whose client had already disconnected (the pre-dispatch#12 heartbeat: stage
lastSeen, then scan queues on every tick of a 25s long-poll) held its intents for hours while the monitor never fired. Proven interventionally on a test cluster — a handler staging one write and then sitting idle for 90s was aborted at the limit, while the same handler reading every 2s held its intents for 120s (4× the limit) and committed successfully with zero monitor engagement.What
The limit becomes a true idle limit:
addWrite) — a transaction that keeps writing stays alive as long as work keeps coming, which is the behavior a long-running write job needs;getReadTxn) — so a handler that wrote once and then only reads cannot keep its write intents alive by reading;The harper#2001 orphan is still reaped: it stages
lastSeenonce and thereafter only reads, so nothing re-arms it.Deliberately narrow:
releaseReadTxn(). (this.writesis not cleared on commit, so gating onhasPendingWrites()alone would have changed behavior for committed-then-streaming transactions that hold no intents at all.)search()/export holds no intents and no one parks on it; the regression pair pins this half explicitly.next.Hot-path note: the dominant case (single-store, never written) short-circuits on two field reads before
hasPendingWrites().Relationship to the other #2001 work
Complementary, not overlapping — this bounds any write-holding transaction that outlives the limit, including orphans with no disconnect signal (hung upstream, keep-alive socket that never closes):
abandonWrites) release the intents a committed transaction retains for its outstanding iterators — a different holder class this change does not touch.Testing
unitTests/resources/txn-tracking.test.js, four arms at identical duration and cadence, which is what makes them a discrimination rather than a single assertion:nextholds the writes does not re-arm, with a control proving it re-arms again once the chain is drained.Two arms fail on
origin/main; the two "still alive" arms pass on both, which is the guard against this change simply shortening every transaction's life.integrationTests/database/longtxn-secondary-index.test.ts(QA-176) is unmodified and passes: Q1 drives 8 indexed writes 350ms apart against a 1s limit and still completes 200 with a consistent index, because writes re-arm. RocksDB-only:LMDBTransaction.getReadTxn()re-arms unconditionally too, but that engine has no verification-table park to wedge and is deprecated — flagging the asymmetry rather than changing a deprecated engine's timeout semantics without a driving case.test:unit:resources: 1354 passing.test:unit:maincould not be run locally (shared~/harper/databaseLOCK across worktrees) — relying on CI.Review coverage
Degraded, disclosed per the cross-model rule: Gemini contributed findings; the Codex leg exited rc=-1 after producing findings (recovered from its log and addressed); grok was version-gated; the same-family fallback failed auth. So one clean outside lens plus a partial second, not the usual two.
Findings addressed: the new assertions ran unconditionally in the LMDB suite (Codex, real — guarded); no multi-store coverage (Gemini — added, and it fails on base);
hasPendingWrites()on the read hot path (both — fast-pathed). Gemini also raised a blocker thathasPendingWrites()might only check the head — dismissed: it walks thenextchain, and the new next-chain test pins that.Generated with Claude Fable 5.