Repository navigation
Stop reads from extending the open-transaction limit while uncommitted writes are held #2052
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d47e055
f5b4380
4a04679
1905ee7
9f57310
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -248,6 +248,12 @@ export class DatabaseTransaction implements Transaction { | |
| readTxnRefCount: number; | ||
| readTxnsUsed: number; | ||
| timeout: number; | ||
| // Write recency, tracked separately from `timeout`: set only by addWrite, never by a read. `timeout` | ||
| // is re-armed by reads too (on a link with no pending writes of its own, see the fast path in | ||
| // getReadTxn), so chainStillActive can't use it to mean "this link was written recently" — a `.next` | ||
| // link with no writes that is being read in a loop would otherwise masquerade as write activity and | ||
| // keep a write-holding head immortal. | ||
| declare writeTimeout: number; | ||
| validated = 0; | ||
| timestamp = 0; | ||
| retries = 0; | ||
|
|
@@ -281,7 +287,18 @@ export class DatabaseTransaction implements Transaction { | |
|
|
||
| getReadTxn(disableSnapshot?: boolean): ReadTransaction { | ||
| this.readTxnRefCount = (this.readTxnRefCount || 0) + 1; | ||
| this.timeout = txnExpiration; // reset the timeout | ||
| // The limit is an IDLE limit. Writes always re-arm it (see addWrite), but reads only do so | ||
| // while no uncommitted writes are held: staged writes hold write intents that other writers' | ||
| // coordinated-retry commits park on, so a handler that wrote once and then only reads — an | ||
| // orphaned long-poll whose client had already gone, in harper#2001 — must not keep those | ||
| // intents alive by reading. A transaction that keeps writing stays alive; so does a purely | ||
| // read-only one. A committed transaction re-arms too: its intents went with the commit, and | ||
| // the monitor bounds its retained read snapshot separately. | ||
| // `writes`/`next` are checked inline first: this is a hot path and the dominant case is a | ||
| // single-store transaction that has never written. | ||
| if ((this.writes.length === 0 && !this.next) || this.open !== TRANSACTION_STATE.OPEN || !this.hasPendingWrites()) { | ||
| this.timeout = txnExpiration; | ||
| } | ||
| if (this.transaction) { | ||
| if ((this.transaction as any).openTimer) (this.transaction as any).openTimer = 0; | ||
| return this.transaction; | ||
|
|
@@ -410,6 +427,13 @@ export class DatabaseTransaction implements Transaction { | |
|
|
||
| addWrite(operation: TransactionWrite) { | ||
| if (this.timedOut) throw transactionOpenTooLongError(); | ||
| // A write is activity: it re-arms the idle limit on this link even though the reads it | ||
| // performs no longer do (see getReadTxn), so a transaction that keeps writing stays alive | ||
| // and only an idle one holding write intents is reaped. | ||
| this.timeout = txnExpiration; | ||
| // Independent write-recency signal for chainStillActive (see the field comment) — reads never | ||
| // touch this, only writes do. | ||
| this.writeTimeout = txnExpiration; | ||
| this.linkWrite(operation); | ||
| this.writes.push(operation); | ||
| if (!operation.deferSave) { | ||
|
|
@@ -973,9 +997,31 @@ export class ImmediateTransaction extends DatabaseTransaction { | |
|
|
||
| let timer; | ||
|
|
||
| /** | ||
| * True when a link other than `txn` in the same multi-store chain was written recently enough to | ||
| * still be active — i.e. its `writeTimeout` (set only by addWrite, see the field comment) hasn't | ||
| * decayed to zero. 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) { | ||
| // A write-only link (e.g. a blind write to a second database, never itself read) never calls | ||
| // getReadTxn, so it's never added to trackedTxns and the main loop below never decays it. | ||
| // Decay it here instead, so an idle write-only link eventually expires rather than keeping the | ||
| // whole chain immortal (harper#2001's blind-write shape). | ||
| if (!trackedTxns.has(link) && link.writeTimeout > 0) link.writeTimeout -= txnExpiration; | ||
| if (link.writeTimeout > 0) return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| function startMonitoringTxns() { | ||
| timer = setInterval(function () { | ||
| for (const txn of trackedTxns) { | ||
| // Decay write recency once per tick for every tracked link, independent of the `timeout` | ||
| // branches below — a tracked link that keeps its own idle limit alive by reading must not | ||
| // thereby keep chainStillActive believing it was written recently too. | ||
| if (txn.writeTimeout > 0) txn.writeTimeout -= txnExpiration; | ||
| if (txn.timeout <= 0) { | ||
| const url = (txn.getContext() as any)?.url; | ||
| if (txn.open === TRANSACTION_STATE.CLOSED) { | ||
|
|
@@ -991,6 +1037,12 @@ function startMonitoringTxns() { | |
| }` | ||
| ); | ||
| txn.releaseReadTxn(); | ||
| } else if (txn.hasPendingWrites() && chainStillActive(txn)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium:
This is reachable with the common read-one-DB / blind-write-another pattern: the head does Suggested fix: ensure downstream write-holding links actually decay. Simplest option is to have the monitor decay the links — |
||
| // A later link in the chain was written recently (writes re-arm only the link that | ||
| // receives them, and a multi-store transaction can be writing database B while this | ||
| // head only reads A). The logical transaction is still active, so re-arm this link | ||
| // rather than aborting the whole chain out from under it. | ||
| txn.timeout = txnExpiration; | ||
| } else if (txn.hasPendingWrites() && !txn.sourceApply && !txn.isReplay) { | ||
| // Abort and surface an error rather than force-committing a partial write set: silently | ||
| // committing on the application's behalf breaks atomicity and can leave orphaned | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
chainStillActivemasks read-only activity as write activity, reintroducing the exact immortal-transaction bug on a 2-link chainFile: resources/DatabaseTransaction.ts:991-1002 (used at line 1022)
What:
chainStillActivetreats any link withtimeout > 0as evidence the chain is "still active" from writes, buttimeoutis 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 viatxnForContextin Table.ts:5505 and reused on every subsequent touch of B) — B's owngetReadTxn()hits the fast path and re-arms B on every read. When A's owntimeoutdecays to ≤0, the monitor'shasPendingWrites()is true (A's own write) andchainStillActive(A)walks to B and seesB.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
chainStillActiveat all: the existing "multi-store path" test doesn't form a real.nextlink (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 exercisesgetReadTxn()/hasPendingWrites(), never the monitor'schainStillActivebranch.Suggested fix: track write recency separately from read recency (e.g., a
lastWriteTimeoutset only inaddWrite, checked bychainStillActiveinstead of the sharedtimeoutfield), 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.