Skip to content

Abort over-time write transactions instead of force-committing (#1407) - #1411

Merged
kriszyp merged 4 commits into
mainfrom
kris/abort-timed-out-txn-1407
Jul 8, 2026
Merged

kriszyp merged 4 commits into
mainfrom
kris/abort-timed-out-txn-1407

Conversation

@kriszyp

@kriszyp kriszyp commented Jun 19, 2026 •

Copy link
Copy Markdown
Member

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 subsequent addWrite/commit throws an actionable transactionOpenTooLongError (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 DatabaseTransaction monitor and the LMDBTransaction monitor.

What changed

  • DatabaseTransaction / LMDBTransaction monitors: a timed-out write-bearing txn is aborted via abortDueToTimeout() (sets timedOut, forces open = CLOSED before releasing the read snapshot so the discard path is taken, then abort()). Guards in addWrite/commit throw transactionOpenTooLongError once poisoned.
  • Read-only long transactions keep their prior behavior (no atomicity/index risk — e.g. a large scan/export), so ongoing reads aren't poisoned.
  • Multi-store: hasPendingWrites() walks the next chain, 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.
  • Canonical-source apply / replay excluded: sourceApply and isReplay transactions 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).
  • Tests (unitTests/resources/txn-tracking.test.js): write-bearing abort leaves no record/orphaned index entry; multi-store write on the next chain is not force-committed; source-apply txn is preserved. Pass on both RocksDB and LMDB.

Where to look / open items

  • transactionOpenTooLongError status 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.
  • In-flight-commit race (low severity, not fixed here). If the monitor aborts a write txn whose async commit() is already in flight (awaiting before hooks), the resumed continuation does a second --readTxnsUsed (→ -1) and a double transaction.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).

@kriszyp
kriszyp force-pushed the kris/abort-timed-out-txn-1407 branch from 822b62d to ea8ed50 Compare June 19, 2026 18:08

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

Comment thread resources/LMDBTransaction.ts
Comment thread resources/DatabaseTransaction.ts
Comment thread resources/DatabaseTransaction.ts
@claude

claude Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

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>
@kriszyp
kriszyp force-pushed the kris/abort-timed-out-txn-1407 branch from ea8ed50 to 21ed179 Compare June 19, 2026 18:21
@kriszyp
kriszyp marked this pull request as ready for review June 19, 2026 18:43
}

addWrite(operation: TransactionWrite) {
if (this.timedOut) throw transactionOpenTooLongError();

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.

This is weird ergonomics to throw a function. Instead I would recommend declaring a TransactionOpenTooLongError class that extends ServerError, then throw a new instance.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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) {

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.

Why the second loop? Could the first loop do the abort?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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)

@kriszyp

kriszyp commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

Flagging a reproduction this PR should resolve. In an exploratory pass (qa-explorer, main 28db4fde4) we hit the silent-partial-drop variant of #1407 from the custom-resource handler side:

  • A custom resource handler whose transaction exceeds storage.maxTransactionOpenTime (default 30s) gets force-committed mid-handler by the long-transaction monitor. Writes made before the fire point are silently lost; writes after it land on a fresh implicit txn and survive; the request still returns HTTP 200 ("count: 6" but only 1/6 rows durable, reproduced 2/2).
  • Narrow trigger: write(s) → idle async suspend with no intervening write (a bare await/slow external I/O > maxTransactionOpenTime) → write(s). Bulk writes do not trigger it (each put() resets txn.timeout at DatabaseTransaction.ts:115). Natural shape: a handler that writes a pending row, awaits a slow external API (>30s), then writes the result — the pre-await row is the casualty.

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: integrationTests/qa-scratch/qa314-overtime-atomicity.test.ts (happy to port it into a regression test for this PR).

— 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>
@kriszyp

kriszyp commented Jul 8, 2026

Copy link
Copy Markdown
Member Author

Follow-up on the custom-resource-handler repro flagged above — the promised regression test has landed: integrationTests/resources/txn-overtime-atomicity.test.ts (+ fixture in the sibling txn-overtime-atomicity/ dir), pushed to this branch.

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 storage.maxTransactionOpenTime with no intervening write (the exact narrow trigger described above), then writes a second row:

Also ran the PR's own unitTests/resources/txn-tracking.test.js and the full test:unit:resources suite (852 passing) against this branch — no regressions.

One incidental note, unrelated to this PR's diff: npm run build on this branch currently fails tsc type-checking at resources/DatabaseTransaction.ts:305 (Promise<number | void> not assignable to MaybePromise<void>) because the branch predates a rocksdb-js@2.2.0 type-widening compat cast that's already on main (transaction.commit() as Promise<void>). Doesn't block dist emission (noEmitOnError is off) or this test, but worth a rebase before merge to pick up a clean tsc pass.

— filed by KrAIs (Claude) on Kris's behalf

@kriszyp
kriszyp merged commit 3249fb5 into main Jul 8, 2026
81 of 82 checks passed
@kriszyp
kriszyp deleted the kris/abort-timed-out-txn-1407 branch July 8, 2026 17:33
kriszyp added a commit that referenced this pull request Jul 8, 2026
…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).
ldt1996 pushed a commit that referenced this pull request Jul 9, 2026
…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>
kriszyp added a commit that referenced this pull request Jul 16, 2026
Abort over-time write transactions instead of force-committing (#1407)
kriszyp added a commit that referenced this pull request Aug 7, 2026
…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>
kriszyp added a commit that referenced this pull request Aug 25, 2026
…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>
kriszyp added a commit that referenced this pull request Aug 25, 2026
…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)
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.

Force-committing an over-time transaction leaves orphaned secondary-index entries (atomicity violation)

2 participants