Skip to content

fix(txn): don't drop writes staged while read iterators defer the commit#1860

Open
kriszyp wants to merge 9 commits into
mainfrom
kris/lingering-write-drop
Open

fix(txn): don't drop writes staged while read iterators defer the commit#1860
kriszyp wants to merge 9 commits into
mainfrom
kris/lingering-write-drop

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 18, 2026

Copy link
Copy Markdown
Member

Summary

A commit issued while a search iterator still holds the read transaction cannot commit the native handle — the iterator's native cursor lives inside it (GetIterator wraps the transaction's write batch + snapshot, and RocksDB invalidates it on commit). The old code deferred the entire native commit to doneReadTxn() (the LINGERING state), and the LINGERING fall-through cleared this.writes, so the deferred commit found an empty write set and aborted the native transaction with the staged writes in it — every write silently dropped after the caller was told the commit succeeded.

Found while root-causing the #1785 follow-up; empirically confirmed on main — the new test fails without the fix.

Approach (reworked after review discussion)

The first version of this PR preserved this.writes across the deferral so the deferred commit worked. Review (cb1kenobi) showed the deferral itself is the hazard: any path that keeps the last iterator from finishing cleanly — the long-transaction monitor's timeout abort, a hung stream — still dropped acknowledged writes. So this now eliminates the deferred commit entirely on the RocksDB path:

  • commit() with outstanding iterators replays the staged writes onto a fresh transaction and commits them immediately, ERR_TRY_AGAIN-replay style: entries reload through the new transaction, commit handlers re-base on them (CAS re-resolves against current state), and conflicts surface through the normal retry ladder. The replay is part of the awaited commit chain, so the caller's acknowledgment now covers actual durability — and a terminal commit failure rejects the awaited chain instead of being loggable-only.
  • The replay deliberately does not carry isRetry: audit/txn-log entries batch natively on the transaction they were staged into and are only written by its commit attempt (the ERR_TRY_AGAIN replay relies on the original's failed commit having already written its batch; here the original never attempts one). The replayed stagings re-append their entries into the replay transaction's own batch — the test asserts exactly one audit entry (no loss, no duplication).
  • The original native handle stays open solely for the iterators and is aborted, with no remaining responsibilities, when the last one finishes. TRANSACTION_STATE.LINGERING is now LMDB-only (LMDB overrides all the relevant methods; its path is unchanged).
  • save() no longer stages writes into a retained non-OPEN handle (they would be discarded by that abort) — internal txnForContext writers now commit immediately on a fresh transaction.
  • The long-transaction monitor never aborts a CLOSED (already-acknowledged) transaction's writes: for an overdue CLOSED-but-tracked transaction it releases just the read snapshot (releaseReadTxn()), preserving the open-time bound for hung iterators without re-introducing write loss (the cb1kenobi finding).
  • The commit's base read reference is now consumed exactly once per read handle (baseReadRefConsumed), so retry rounds, immediate-commit re-entries, and a second top-level commit() can't steal an iterator's reference and abort the handle under it.
  • Multi-store: this.next.commit() no longer inherits a retry/replay round's head-store options.transaction (pre-existing bug; the next store must commit through its own transaction).

Where to look

  • The outstanding-iterators branch in DatabaseTransaction.commit() (the replay) and the simplified doneReadTxn().
  • The monitor's new CLOSED branch + releaseReadTxn().
  • unitTests/resources/lingeringWriteCommit.test.js — asserts immediate durability while the iterator is still open, exactly-one audit entry, independent post-close writes, and that a terminally failing replay commit rejects the awaited chain while the iterator survives.
  • A renewAfterCommit/CommitAndTryCreateSnapshot + iterator-reseek primitive in rocksdb-js would let the original handle commit directly (the old TODO's intent) and replace the replay; noted as follow-up work.

Likely a v5.1 patch candidate — the bug exists on the release branch.

Cross-model reviewed (Codex traced repo paths; Gemini via direct API on the diff): Codex's audit-entry blocker, read-reference theft, and next-chain transaction leak are fixed in the final commits; coverage: codex ✓ / gemini ✗ (agy hung) → api ✓.

Generated by an LLM (Claude Fable 5 + Claude Sonnet 5), supervised by Kris.

🤖 Generated with Claude Code

@kriszyp
kriszyp requested a review from cb1kenobi July 18, 2026 13:55

@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 fixes an issue where lingering transactions (transactions committed while read iterators are still open) would silently drop staged writes because this.writes was cleared prematurely. It also ensures that failures in deferred commits are caught, logged, and aborted to prevent unhandled promise rejections and clean up resources. Additionally, a comprehensive test suite has been added to verify these behaviors. Feedback suggests wrapping the commit call in a try-catch block to handle synchronous errors and improving the promise detection check to avoid potential false positives.

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

This comment has been minimized.

Comment thread resources/DatabaseTransaction.ts Outdated
@kriszyp
kriszyp marked this pull request as ready for review July 18, 2026 14:23
Comment thread resources/DatabaseTransaction.ts Outdated
kriszyp added a commit that referenced this pull request Jul 22, 2026
… iterators retain the handle

Replaces the LINGERING deferral (commit deferred to doneReadTxn() when the
last iterator finishes) on the RocksDB path: a commit with outstanding read
iterators now replays its staged writes onto a fresh transaction
(ERR_TRY_AGAIN-replay style: entries reload through the new transaction,
isRetry suppresses re-appending audit/txn-log entries, conflicts re-resolve
through the normal retry ladder) and commits them before the awaited commit
chain resolves. The original native handle stays open solely for the
iterators and is aborted empty when they finish.

This closes the whole class of deferred-commit loss windows: the
long-transaction monitor can no longer abort already-acknowledged writes
(it now just releases the read snapshot of an overdue CLOSED transaction
via releaseReadTxn()), a hung stream can no longer strand acknowledged
writes, and a terminal commit failure now rejects the awaited chain instead
of being loggable-only. save() no longer stages writes into a retained
(non-OPEN) handle - internal txnForContext writers commit immediately on a
fresh transaction instead of silently riding a handle that gets aborted.

Per review discussion with Kris on PR #1860 (cb1kenobi's monitor finding).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tp9CaX9wmJTwbNRkDWATQC
(txn.db as any)?.name + (url ? ' path: ' + url : '')
}`
);
txn.releaseReadTxn();

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.

Blocker: new monitor branch (the fix for cb1kenobi's write-loss finding) has no test coverage.

This CLOSED branch is the direct fix for the bug cb1kenobi found during this PR's review (a long-transaction monitor that could abort an already-acknowledged transaction's writes when a read iterator outlived STORAGE_MAXTRANSACTIONOPENTIME). The fix is structurally sound — releasing only the read snapshot via releaseReadTxn() instead of touching writes — but neither unitTests/resources/lingeringWriteCommit.test.js nor unitTests/resources/txn-tracking.test.js exercises this exact scenario: a transaction that committed via the replay path (outstanding iterators) while its native read handle is then held open past the monitor's timeout.

txn-tracking.test.js already has the pattern to extend (setTxnExpiration(20) + delay(150), see "aborts a write-bearing txn open too long..."). Given this branch exists specifically because a real regression was found in this exact area during this PR's own review cycle, it's worth pinning down with a test that: starts a search iterator, writes behind it (triggering the replay-commit), lowers txnExpiration, holds the iterator open past the limit, and asserts the write survives and the snapshot is released via releaseReadTxn() rather than force-aborted.

Suggested fix: add a test case alongside lingeringWriteCommit.test.js's existing iterator scenarios that drives the monitor tick while an iterator from a replay-committed transaction is still open.

transaction = replayTransaction;
}
// with options.transaction set this is a retry round — the save loop above already
// re-staged the writes into it

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.

Suggestion (non-blocking): this replay-commit transaction.commit() submits real work to the storage engine, same as the "no more reads" branch below (line ~464), but only that other branch calls enterWriteQueue()/leaveWriteQueue() around its commit() (lines ~479-484). Writes committed through this outstanding-iterators replay path — a real, if less common, write source — won't be reflected in the write-transaction-queue-depth analytics gauge, understating the backlog during workloads that mix long-held iterators with writes. Consider wrapping this commitResolution the same way for consistent accounting.

kriszyp added a commit that referenced this pull request Jul 22, 2026
RocksDB's clear() is an unsynchronized native range-delete on the
libuv threadpool — unlike LMDB's clear, which goes through the same
single-writer instruction queue as put/remove, it has no coordination
with in-flight transaction commits. setupTestApp()'s reseed path fired
tables.X.clear() without awaiting it, so the delete-all range op could
still be in flight when the following sequential PUT loop reinserted
records, racing the stale delete against the fresh insert and losing
the row.

This surfaced as CI-only failures on PR #1860 (Unit Test Node.js
v22/v26): "check headers on get" 404s on a record written moments
earlier, and FourProp coming back empty from search_by_value/SQL —
consistent with the reseeded rows being wiped by a late-arriving clear
from the previous test file's setup. Awaiting the clears serializes
them before the reseed starts, closing the race.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
kriszyp and others added 8 commits July 22, 2026 12:32
A commit issued while a search iterator still holds the read transaction
goes LINGERING: the native commit is deferred until the last iterator
finishes (doneReadTxn). But the LINGERING fall-through in commit() cleared
this.writes, so the deferred commit found an empty write set and aborted
the native transaction — silently dropping every staged write after the
caller was already told the commit succeeded.

Keep the writes across the deferral, and log a deferred-commit failure
loudly instead of leaking it as an unhandled rejection (the awaited chain
resolved when the transaction went LINGERING, so a later failure has no
caller left to reject).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review)

Codex review caught that the post-lingering case never exercised the retained
transaction: the transactional wrapper only reuses an OPEN context transaction,
so the late write runs on a fresh transaction. Assert that real behavior —
independent immediate commit that leaves the lingering deferral intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l review)

Gemini review: a deferred lingering commit that fails terminally has no error
middleware left to clean up — without an abort, this.writes stays populated
and savedBlobs temp files leak. Abort in the catch so cleanupUnusedBlobs runs;
assert the cleanup in the failure test. Also drain an event-loop turn before
the unhandled-rejection assertions (delivery is a later macrotask).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…here)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ndle on terminal commit failure (bot review)

With no async completions pending, commit() runs synchronously inside
doneReadTxn's LINGERING branch, so a sync throw bypassed the .catch() and
propagated into the iterator consumer. Wrap in try/catch routing to the same
log+abort handler. Also abort the native transaction on a terminal
non-conflict commit failure (mirrors the retry-exhaustion give-up pattern) so
the handle doesn't leak.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… iterators retain the handle

Replaces the LINGERING deferral (commit deferred to doneReadTxn() when the
last iterator finishes) on the RocksDB path: a commit with outstanding read
iterators now replays its staged writes onto a fresh transaction
(ERR_TRY_AGAIN-replay style: entries reload through the new transaction,
isRetry suppresses re-appending audit/txn-log entries, conflicts re-resolve
through the normal retry ladder) and commits them before the awaited commit
chain resolves. The original native handle stays open solely for the
iterators and is aborted empty when they finish.

This closes the whole class of deferred-commit loss windows: the
long-transaction monitor can no longer abort already-acknowledged writes
(it now just releases the read snapshot of an overdue CLOSED transaction
via releaseReadTxn()), a hung stream can no longer strand acknowledged
writes, and a terminal commit failure now rejects the awaited chain instead
of being loggable-only. save() no longer stages writes into a retained
(non-OPEN) handle - internal txnForContext writers commit immediately on a
fresh transaction instead of silently riding a handle that gets aborted.

Per review discussion with Kris on PR #1860 (cb1kenobi's monitor finding).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tp9CaX9wmJTwbNRkDWATQC
… state; don't forward head txn to next chain (cross-model review)

Three fixes from the cross-model review of the replay rework:

- Audit-entry loss (Codex blocker): audit/txn-log entries batch natively on
  the transaction they were staged into and are written only by its commit
  attempt; the original handle here never attempts one, so its batch dies
  with the doneReadTxn() abort. The replay transaction must therefore NOT be
  marked isRetry (and must not inherit the original's onCommit) so the
  replayed stagings re-append their entries into its own batch. Test asserts
  exactly one audit entry after a lingering replay commit.

- Read-reference theft (Codex): consume the commit's base read reference
  exactly once per read handle via an explicit baseReadRefConsumed flag
  (reset when getReadTxn creates a handle), instead of inferring rounds from
  options.transaction - a second top-level commit() (wrapper commit after an
  explicit in-handler commit) no longer steals an iterator's reference.

- Multi-store cascade (Codex, pre-existing): never forward options.transaction
  (a retry/replay round's head-store handle) into this.next.commit() - the
  next store must commit its own writes through its own transaction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tp9CaX9wmJTwbNRkDWATQC
RocksDB's clear() is an unsynchronized native range-delete on the
libuv threadpool — unlike LMDB's clear, which goes through the same
single-writer instruction queue as put/remove, it has no coordination
with in-flight transaction commits. setupTestApp()'s reseed path fired
tables.X.clear() without awaiting it, so the delete-all range op could
still be in flight when the following sequential PUT loop reinserted
records, racing the stale delete against the fresh insert and losing
the row.

This surfaced as CI-only failures on PR #1860 (Unit Test Node.js
v22/v26): "check headers on get" 404s on a record written moments
earlier, and FourProp coming back empty from search_by_value/SQL —
consistent with the reseeded rows being wiped by a late-arriving clear
from the previous test file's setup. Awaiting the clears serializes
them before the reseed starts, closing the race.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the kris/lingering-write-drop branch from 7a5e3fc to 55e246a Compare July 22, 2026 18:34
const history = await LingerTable.getHistoryOfRecord('linger-write');
assert.equal(history.length, 1, 'the replayed write must carry exactly one audit entry (not zero, not two)');
assert.deepEqual(unhandled, [], 'the replay commit must not leak an unhandled rejection');
});

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.

Low (test coverage): no regression test exercises the monitor's new CLOSED branch (releaseReadTxn).

These tests pin the commit-time replay and the iterator-drain release, but not the path the prior Medium was actually about: the long-transaction monitor firing on a committed (open === CLOSED) transaction whose iterator still holds the read snapshot past STORAGE_MAXTRANSACTIONOPENTIME. That path now runs the new releaseReadTxn() branch in startMonitoringTxns() — the code added specifically to neutralize the finding — and it has no direct coverage.

The risk is low because the write is already durable at ack time (the replay commit is part of the awaited chain, as the first test asserts), so the monitor can't drop an acknowledged write even untested. But a regression test would lock in the branch that exists to prevent exactly the prior bug. txn-tracking.test.js already uses setTxnExpiration(20) to drive the monitor; a case there (or here) that holds an iterator open across a committed write, lets the monitor tick, then asserts the committed record survives, the snapshot is released (context.transaction.transaction === null), and nothing floats an unhandled rejection would close the gap.


Generated by Barber AI

Resolves the DIRTY/CONFLICTING merge state against main (34 commits ahead
since the merge-base, 7 touching resources/DatabaseTransaction.ts).

- unitTests/apiTests/setupTestApp.mjs: both sides independently added the
  same `await Promise.all([...clear()])` fix with only differing comments;
  kept a merged comment covering both the general async-clear hazard and
  the RocksDB-specific race.
- resources/DatabaseTransaction.ts: auto-merged cleanly line-by-line, but
  main's ERR_TRY_AGAIN retry rework (dcd44e4) had changed the `transaction`
  local in commit() from `let` to `const` (that rework never reassigns it —
  it resets the same native handle in place). This PR's outstanding-iterators
  replay path reassigns `transaction = replayTransaction`, which the
  line-based merge didn't detect as a conflict. Reverted to `let` so the
  replay assignment is valid; not a build error 3-way merge would catch mid-
  merge since main and the PR branch never touch that exact line.

Build passes; unitTests/resources/**/*.js: 1254 passing / 3 pre-existing
failures (confirmed present on origin/main alone, in sourceApplyConflictRetry.test.js,
unrelated to this PR).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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