Repository navigation
Abort over-time write transactions instead of force-committing (#1407) - #1411
Conversation
822b62d to
ea8ed50
Compare
There was a problem hiding this comment.
Code Review
This pull request addresses issue #1407 by aborting and poisoning write-bearing transactions that exceed the open-transaction limit, rather than silently force-committing them. This change preserves atomicity and prevents secondary-index corruption. Key updates include introducing a timedOut state, throwing a transactionOpenTooLongError on subsequent writes or commits, and adding robust unit tests. Feedback on these changes suggests wrapping txn.commit() in LMDBTransaction.ts with proper error handling to avoid unhandled rejections, refactoring abortDueToTimeout to ensure all transactions in a chain are poisoned even if an individual abort throws an error, and changing the HTTP status code for transaction timeouts from 503 to 408 to discourage inappropriate client retries.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
Reviewed; no blockers found. |
The long-transaction monitor force-committed a still-open, in-use transaction and reused it. That committed a partial write set the application never asked to commit (atomicity violation) and could desync secondary-index maintenance from the record, leaving orphaned index entries only a full rebuild repairs. Write-bearing transactions that exceed the open-transaction limit are now aborted and poisoned: any subsequent addWrite/commit throws an actionable error so the request rolls back cleanly. The whole multi-store `next` chain is checked (so a read-A/write-B transaction is not misclassified read-only) and poisoned together. Read-only long transactions, and canonical-source apply / crash-recovery replay transactions (which have no resubscribe/resume path), keep their prior force-commit behavior. Applied symmetrically to both the RocksDB DatabaseTransaction and LMDBTransaction monitors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ea8ed50 to
21ed179
Compare
| } | ||
|
|
||
| addWrite(operation: TransactionWrite) { | ||
| if (this.timedOut) throw transactionOpenTooLongError(); |
There was a problem hiding this comment.
This is weird ergonomics to throw a function. Instead I would recommend declaring a TransactionOpenTooLongError class that extends ServerError, then throw a new instance.
There was a problem hiding this comment.
Agreed — the factory-function pattern is unusual. Will refactor to a TransactionOpenTooLongError class extending ServerError, then throw new TransactionOpenTooLongError() at the call sites in a follow-up commit.
— KrAIs (claude-sonnet-4-6)
| // Closing first makes the release discard (abort) the uncommitted writes instead, which is the intent. | ||
| txn.open = TRANSACTION_STATE.CLOSED; | ||
| } | ||
| for (let txn: DatabaseTransaction = this; txn; txn = txn.next) { |
There was a problem hiding this comment.
Why the second loop? Could the first loop do the abort?
There was a problem hiding this comment.
The separation is intentional: the first loop poisons every link in the chain (timedOut=true, open=CLOSED) before any abort() call. If abort() throws at link N in a combined loop, links N+1…M remain un-poisoned — their timedOut is still false, making them eligible to force-commit on the next monitor pass. Splitting the loops guarantees the poison-all-first invariant even if any abort() throws.
— KrAIs (claude-sonnet-4-6)
|
Flagging a reproduction this PR should resolve. In an exploratory pass (qa-explorer, main
This PR's abort-instead-of-force-commit is exactly the right direction (the client should get an error / the writes should roll back atomically, rather than a silent 200 + partial drop). One thing worth confirming before merge: that the abort path covers the custom-resource handler force-commit (not just the replication/apply path), and that the caller receives a non-2xx. Repro: — filed by KrAIs (Claude) on Kris's behalf |
…or (#1407) Ports the qa-scratch qa314 repro flagged in review: a custom Resource handler that writes a row, awaits past storage.maxTransactionOpenTime with no intervening write, then writes a second row. Confirms the abort-and-poison fix covers the custom-resource-handler path (not just replication/apply): the request now surfaces a non-2xx error and neither write is durably committed. Fails on pre-#1407 behavior (200, both writes land via a force-commit + fresh-txn split) and passes on this branch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Follow-up on the custom-resource-handler repro flagged above — the promised regression test has landed: Confirmed: the abort-and-poison fix covers the custom-resource-handler path, not just replication/apply. The test drives a custom Resource whose handler writes a row, awaits past
Also ran the PR's own One incidental note, unrelated to this PR's diff: — filed by KrAIs (Claude) on Kris's behalf |
…tly restarting #1720 fixed the ambient-transaction-leak bug by requiring context.transaction.open === OPEN before joining it, otherwise starting a fresh transaction. That treats a committed and an aborted ambient transaction the same way, but they need opposite handling: a committed transaction is safe to silently replace, while a transaction aborted by the long-transaction monitor (#1411, DatabaseTransaction.abortDueToTimeout(), marked via the `timedOut` poison flag) must not be silently superseded — the independent write that follows it needs to fail too, so the whole logical operation rolls back atomically instead of quietly landing on a brand-new transaction while the earlier write was discarded. Widen the join condition to also join a `timedOut` transaction: addWrite()/commit() already throw transactionOpenTooLongError() once a transaction is poisoned, so joining it here correctly propagates the abort to the caller. Restores integrationTests/resources/txn-overtime-atomicity.test.ts (broken by #1720) without regressing unitTests/resources/operationContextTransactionLeak.test.js (#1720's own regression test).
…1720) * fix: only join an ambient transaction if it is genuinely still OPEN resources/Resource.ts's static-API dispatcher (backing Table.get/put/patch/ delete) decides whether to join an existing transaction on the ambient context or start a fresh one purely via `if (context?.transaction)` truthiness. This is safe only when ambient contexts are fresh, one-shot objects per call — which was always true before #1591/#1592, since `context = contextStorage.getStore() ?? {}` fell back to a brand-new `{}` whenever nothing had populated contextStorage for the current call chain. #1591/#1592 (establish ambient user context for operation handlers so audit records carry attribution) changed that: processLocalTransaction now wraps an entire operation handler invocation in one `contextStorage.run({ user: hdbUser }, handler)` call, installing a single, long-lived, mutable context object as the ambient store for the whole handler. resources/transaction.ts's transaction() helper mutates that shared object in place (`context.transaction = new DatabaseTransaction()`) as a side effect of servicing the first static Resource API write made with no explicit context. Because the object is shared, that `.transaction` reference is then visible to any later, logically-independent static Resource API call in the same handler — and Resource.ts wrongly joins it instead of starting its own, even though the first call's transaction may already be closing/lingering. The two writes get folded into one transaction whose commit lifecycle was only ever driven by the first (or second) call, and the other write can be silently dropped. Confirmed via a live 2-node cluster-formation regression: harper-pro's `add_node`/`add_node_back` operation handlers (replication/ subscriptionManager.ts's ensureNode(), and security/certificate.ts's signCertificate()) each make 2+ sequential static Resource API calls with no explicit context under one ambient hdb_user-carrying operation. Bisected against harper-pro's integrationTests/cluster/replicationTopology.test.mjs (swapping only the core submodule pointer, harder-pro code held constant) to core commit af64622 as first-bad: peer nodes' CA certs silently failed to persist into hdb_nodes, producing SELF_SIGNED_CERT_IN_CHAIN on subsequent mesh TLS connections and "Timed out waiting for cluster to connect". Instrumented tracing showed two sequential ensureNode() calls (self-record, then peer-record) sharing the identical DatabaseTransaction instance. Fix: only treat an ambient `.transaction` as reusable when it is still TRANSACTION_STATE.OPEN — the exact same self-consistency check resources/transaction.ts's own transaction() helper already applies before deciding to reuse vs. create a transaction. This restores the pre-#1591 isolation for independent calls while leaving genuine nested/explicit transaction chaining (a caller holding the same still-open Context/txn across multiple calls) unaffected, since `.open` only transitions away from OPEN once the transaction's owning transaction() call has fully completed. Verified against harper-pro's replicationTopology.test.mjs "connect nodes" test (4-node star topology): fails consistently (timeout, 0/3 runs) with core at af64622 and this fix absent; passes consistently and at pre-regression speed (3/3 runs, ~3.2s each, matching a pre-af646222d core baseline) with this fix applied — even without any harper-pro-side change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * style: prettier formatting * test: assert transaction-instance identity, not just persistence, in leak regression cb1kenobi (PR #1720 review) found that the existing regression test doesn't actually discriminate buggy vs. fixed code: reverting Resource.ts's fix back to bare `if (context?.transaction)` truthiness and re-running still passes 3/3, because DatabaseTransaction's addWrite()/save() takes an immediate-commit path when a write joins an already-closed transaction, so persistence succeeds regardless in this isolated unit setup. Add a test that asserts the mechanism directly: each independent, no-explicit-context write inside one ambient operation handler must be serviced by its own DatabaseTransaction instance (captured via contextStorage.getStore()?.transaction after each write). Verified this invariant fails on the pre-fix bare-truthiness check and holds with the `.open === TRANSACTION_STATE.OPEN` fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: propagate a timeout-aborted ambient transaction instead of silently restarting #1720 fixed the ambient-transaction-leak bug by requiring context.transaction.open === OPEN before joining it, otherwise starting a fresh transaction. That treats a committed and an aborted ambient transaction the same way, but they need opposite handling: a committed transaction is safe to silently replace, while a transaction aborted by the long-transaction monitor (#1411, DatabaseTransaction.abortDueToTimeout(), marked via the `timedOut` poison flag) must not be silently superseded — the independent write that follows it needs to fail too, so the whole logical operation rolls back atomically instead of quietly landing on a brand-new transaction while the earlier write was discarded. Widen the join condition to also join a `timedOut` transaction: addWrite()/commit() already throw transactionOpenTooLongError() once a transaction is poisoned, so joining it here correctly propagates the abort to the caller. Restores integrationTests/resources/txn-overtime-atomicity.test.ts (broken by #1720) without regressing unitTests/resources/operationContextTransactionLeak.test.js (#1720's own regression test). --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Abort over-time write transactions instead of force-committing (#1407)
…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>
…tting transaction (#2325) txnForContext installs an ImmediateTransaction in a context slot that is empty or holds the released placeholder. It reports open === OPEN, but its save() *is* the commit and nothing owns a final commit or abort for it. Both join sites gated on OPEN alone, so transaction() and the Resource dispatcher joined it, ran the callback, and returned without ever reaching commit({ doneWriting: true }) — every write self-committed, a throw partway left the earlier ones durable, and onError's abort never ran, silently, with the handler returning success. Gate both join sites on whether the transaction stages its writes for a later commit (isJoinableScope) rather than on OPEN. Ownership is deliberately not the test: a context pre-seeded with an externally driven DatabaseTransaction (replayLogs.ts, Table.ts) is not scopeOwned yet still owns the writes the static API gives it, and its own commit/abort still governs them. The same invariant reaches three more places: - A link chained onto a self-committing head (a second database) is now another ImmediateTransaction, transitively down the chain. A staging link there was committed only if the head's own database was written again and cascaded the chain, so a handler writing the second database last lost that write. Such a link is never born CLOSED, and a spent one is dropped from the chain rather than handed back, since either sends its next write down a commit path whose promise is discarded (#2323). A timeout-poisoned link is kept: reusing it is what makes the rest of the operation fail atomically (#1411). - A deferred write (deferSave, i.e. every _writeUpdate) is only triggered by resource.save(); it lives in the `writes` of whichever transaction addWrite put it in (operation.stagedIn), so save() can run after the context has moved on — which a scope opening in between now makes routine. A joinable scope takes the write over, moved rather than shared (detachWrite from the holder, addWrite onto the scope, priorWrite cleared): every commit path decides what to stage, what to replay onto a fresh handle for outstanding iterators, what to roll back and whose blobs to reclaim from `writes` alone, and the per-key basis chain belongs to the holder. Not from a sourceApply or isReplay holder, whose never-drop-on-conflict policy would not travel with the write (harper-pro#348), and only where addWrite runs the write, which LMDBTransaction's never does. - clearWrites() drops `stagedIn`, so a write whose holder has finished is not revived on a transaction that already reclaimed its blobs. validate() now receives the transaction committing the write rather than closing over the one that staged it, so overload accounting, the replay marker and a no-op write's removal follow a takeover. Introduced by #2030 (5.2.1+) for released slots, but the same hole was already reachable on a context that never had a transaction. Fixes #2292 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…tting transaction (#2325) (#2327) txnForContext installs an ImmediateTransaction in a context slot that is empty or holds the released placeholder. It reports open === OPEN, but its save() *is* the commit and nothing owns a final commit or abort for it. Both join sites gated on OPEN alone, so transaction() and the Resource dispatcher joined it, ran the callback, and returned without ever reaching commit({ doneWriting: true }) — every write self-committed, a throw partway left the earlier ones durable, and onError's abort never ran, silently, with the handler returning success. Gate both join sites on whether the transaction stages its writes for a later commit (isJoinableScope) rather than on OPEN. Ownership is deliberately not the test: a context pre-seeded with an externally driven DatabaseTransaction (replayLogs.ts, Table.ts) is not scopeOwned yet still owns the writes the static API gives it, and its own commit/abort still governs them. The same invariant reaches three more places: - A link chained onto a self-committing head (a second database) is now another ImmediateTransaction, transitively down the chain. A staging link there was committed only if the head's own database was written again and cascaded the chain, so a handler writing the second database last lost that write. Such a link is never born CLOSED, and a spent one is dropped from the chain rather than handed back, since either sends its next write down a commit path whose promise is discarded (#2323). A timeout-poisoned link is kept: reusing it is what makes the rest of the operation fail atomically (#1411). - A deferred write (deferSave, i.e. every _writeUpdate) is only triggered by resource.save(); it lives in the `writes` of whichever transaction addWrite put it in (operation.stagedIn), so save() can run after the context has moved on — which a scope opening in between now makes routine. A joinable scope takes the write over, moved rather than shared (detachWrite from the holder, addWrite onto the scope, priorWrite cleared): every commit path decides what to stage, what to replay onto a fresh handle for outstanding iterators, what to roll back and whose blobs to reclaim from `writes` alone, and the per-key basis chain belongs to the holder. Not from a sourceApply or isReplay holder, whose never-drop-on-conflict policy would not travel with the write (harper-pro#348), and only where addWrite runs the write, which LMDBTransaction's never does. - clearWrites() drops `stagedIn`, so a write whose holder has finished is not revived on a transaction that already reclaimed its blobs. validate() now receives the transaction committing the write rather than closing over the one that staged it, so overload accounting, the replay marker and a no-op write's removal follow a takeover. Introduced by #2030 (5.2.1+) for released slots, but the same hole was already reachable on a context that never had a transaction. Fixes #2292 Co-authored-by: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit 5259da8)
Fixes #1407.
Summary
The long-transaction monitor force-
commit()d a still-open, in-use transaction and reused it. That (a) committed a partial write set the application never asked to commit (atomicity violation) and (b) could desync secondary-index maintenance from the on-disk record, leaving orphaned index entries that only a full index rebuild repairs. Observed in production on v4.7.19; the same force-commit-and-reuse design is present in v5.This makes the monitor abort and poison a write-bearing transaction that exceeds the open-transaction limit instead of force-committing it. Once poisoned (
timedOut), any subsequentaddWrite/committhrows an actionabletransactionOpenTooLongError(503), so the request rolls back cleanly and the application owns how it splits long-running work. Core keeps the consistency guarantee; it no longer silently mutates committed state to paper over app-side slowness.Per the issue's "design recommendation", this is the abort-and-surface option (confirmed with the team), applied symmetrically to both transaction engines: the RocksDB
DatabaseTransactionmonitor and theLMDBTransactionmonitor.What changed
DatabaseTransaction/LMDBTransactionmonitors: a timed-out write-bearing txn is aborted viaabortDueToTimeout()(setstimedOut, forcesopen = CLOSEDbefore releasing the read snapshot so the discard path is taken, thenabort()). Guards inaddWrite/committhrowtransactionOpenTooLongErroronce poisoned.hasPendingWrites()walks thenextchain, so a transaction that reads database A (head, no writes of its own) and writes database B (next) is correctly treated as write-bearing rather than misclassified read-only and force-committed.abortDueToTimeout()poisons + aborts the whole chain.sourceApplyandisReplaytransactions keep the prior force-commit behavior — they have no resubscribe/resume path, so aborting a write would drop it while the resume cursor advances past it (a harper-pro#348-class permanent divergence).unitTests/resources/txn-tracking.test.js): write-bearing abort leaves no record/orphaned index entry; multi-store write on thenextchain is not force-committed; source-apply txn is preserved. Pass on both RocksDB and LMDB.Where to look / open items
transactionOpenTooLongErrorstatus code — now 422 (was 503; cross-model review and the Gemini bot both flagged 503 as inviting auto-retry of the same doomed transaction). 422 signals the request itself must change. Swap to 408/400 if you'd prefer.commit()is already in flight (awaitingbeforehooks), the resumed continuation does a second--readTxnsUsed(→ -1) and a doubletransaction.abort()that the pre-existing try/catch swallows. Cross-model review confirmed the data is still correctly rolled back and the request errors out — the only artifact is an inert negative counter on a dead txn object. I left the hot commit-continuation untouched rather than add a guard under a race that's hard to unit-test; flagging it for a reviewer's call.Reviewed cross-model (Gemini + Harper-domain deep-review); the two findings above are the only items not fully resolved in the diff. Generated with Claude Opus 4.8 (1M context).