Skip to content

Track every outstanding write commit, not just one per thread - #2009

Merged
kriszyp merged 9 commits into
mainfrom
kris/outstanding-commit-arming
Aug 3, 2026
Merged

Track every outstanding write commit, not just one per thread#2009
kriszyp merged 9 commits into
mainfrom
kris/outstanding-commit-arming

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 30, 2026

Copy link
Copy Markdown
Member

Follow-up to the harper#2001 investigation, and the "arming-race gap" that PR #2007 documents in-code but deliberately does not fix. Independent of #2007 — this branch is cut from main and the two overlap textually at the arming site, so whichever lands second rebases.

What was wrong

checkOverloaded() rejects new application writes with a 503 once a commit has been outstanding past maxTransactionQueueTime (45s default). It is the only protection against a native rocksdb-js commit promise that never settles, which is the harper#2001 symptom: a worker thread that 503s every write until restarted.

It watched one module-level slot per worker thread, armed by whichever commit found it free:

if (!outstandingCommit) { outstandingCommit = commitResolution; ... }

Two consequences, both meaning a wedge could be entirely invisible:

  1. Concurrent commits were sampled, not tracked. Any commit submitted while another held the slot was never watched, and got no second chance when the holder settled. If one of those wedged, no 503 was ever raised, the write queue grew unbounded, and nothing was logged.
  2. Retries and chained commits could never arm. A coordinated retry round (this.commit({...options, transaction})) and a chained second database's commit (this.next.commit(...)) are both issued from inside the preceding commit's resolve handler. That handler is a direct reaction on commitResolution; the slot was released by a .catch(() => {}).finally(...) chain hanging two further reactions off it. Reactions run in registration order, so the resolve handler always ran first and observed the slot still held by the already-settled promise.

Confirmed by reducing the promise topology to a standalone repro:

ARM
REENTER (outstandingCommit is set -> NOT armed)
CLEAR

The change

Track every submitted commit in a linked list ordered oldest-first; checkOverloaded() tests the head. That removes the single-slot condition rather than reordering around it — with unconditional tracking the microtask ordering stops mattering at all, so the fix does not depend on it.

A linked list rather than a Set because the write path reads the oldest on every write and must not allocate an iterator to do it, and because commits settle out of order and have to unlink in constant time.

Each attempt is timed from its own submission, so the overload window stays per-attempt rather than accumulating across a retry ladder — which is the semantics the recordCommitLatency comment already documented and the old code failed to deliver.

Also included: the outstanding-iterators replay commit now counts against the write-queue depth. It is a real native commit, and omitting it left write-transaction-queue-depth — per the harper#2001 investigation, the only metric that can observe a wedge — reading zero for exactly that path.

Behavior change worth a reviewer's attention

Strictly more commits are now evaluated against the threshold. Under a genuine stall lasting >45s, a worker will now shed application writes reliably where before it did so only if the stalled commit happened to be the sampled one. That is the documented intent of maxTransactionQueueTime ("Max write queue time before rejecting"), but it is a real change in when 503s appear, and it couples across databases on a thread. Commits under the threshold are unaffected.

Source-applied writes are unaffected: both checkOverloaded() call sites are gated on !context.source, so replication and caching-source applies never reach it and their uncapped retry policy is untouched.

Review

Two Codex passes (design, then final artifact). The design pass corrected my characterization of the retry ladder — only retries 1–2 re-enter immediately and hit the race; retry 3+ goes through delay() and already armed. The artifact pass found no defect in the list itself (double-untrack, out-of-order settlement, already-settled and double-tracked promises all check out) and confirmed the enter/leave accounting is balanced, but caught that my "multi-table chained" test was not chained — chaining forms only across databases, and only when a read initializes the head first. Verified directly and rewritten; the test now asserts context.transaction.next exists so it cannot silently stop covering that path.

Gemini's leg was unavailable this run (socket error), so outside coverage here is Codex-only.

Tests

unitTests/resources/outstandingCommitTracking.test.js — 8 tests. The concurrency test was verified to fail against the old semantics (tracked 1, expected 8) before being kept. Coverage includes head/middle/tail unlink in controlled order, rejection, already-settled and double-tracked promises, and the real cross-database chain.

test:unit:resources 1342 passing, test:unit:main 3966 passing, test:integration:all 1619 tests / 1593 pass / 0 fail, oxlint and prettier clean.

Known gaps, not addressed here

  • The retry backoff window is still unmonitored. Retry 3+ waits in delay(...) with nothing outstanding, so a lost continuation there (the harper#1785 shape) is invisible to this mechanism. Needs its own treatment.
  • resources/LMDBTransaction.ts has no equivalent protection at all — its own separate sentinel, no start stamp, no checkOverloaded(). Pre-existing; LMDB is deprecated.
  • A pre-existing test in unitTests/resources/txn-tracking.test.js looks mis-scoped. Its fixture comment claims two tables in database: 'test' "span two databases (writes to the second live on the transaction's next chain)", and the test at :131 is titled "aborts a multi-store txn whose write lives on the next chain". Same-database tables share one transaction — I measured it — so that test appears not to exercise the next chain it names. Not touched here; flagging for whoever owns it.

None of this root-causes why a native commit promise stops settling. That still needs evidence from a live occurrence.

🤖 Generated with Claude Code

checkOverloaded() sheds application writes with a 503 when a commit has been
outstanding past maxTransactionQueueTime — the only protection against a native
commit promise that never settles (harper#2001). It watched a single module-level
slot, claimed by whichever commit found it free, so every other commit in flight
at that moment was invisible: if one of those wedged, no 503 was ever raised and
the write queue grew unbounded with nothing logged.

The slot also could not re-arm for a coordinated retry round or a chained second
database's commit. Both are issued from inside the preceding commit's own resolve
handler, which runs a microtask before the .catch().finally() that released the
slot, so they always observed it still held and skipped arming entirely.

Track each submitted commit in a linked list ordered oldest-first and test the
oldest, which removes the single-slot condition rather than reordering around it.
Each attempt is timed from its own submission, so the window stays per-attempt
over a retry ladder.

Also count the outstanding-iterators replay commit against the write queue depth.
It is a real native commit, and omitting it left write-transaction-queue-depth —
the one metric that can observe a commit that never settles — reading zero for
exactly that path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@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 replaces the single-slot outstanding commit tracking with a linked-list-based tracking mechanism (OutstandingCommit) in DatabaseTransaction.ts to properly track concurrent commits and detect unsettled promises. It also adds comprehensive unit tests to verify various commit tracking and untracking scenarios. The review feedback suggests adding defensive checks to ensure commitResolution is a valid thenable before tracking or calling .then() on it, and introducing a guard flag to prevent double-untracking of commits.

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

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Reviewed; no blockers found.

@kriszyp
kriszyp requested review from cb1kenobi and removed request for sleekmountaincat July 30, 2026 21:54
…commit

test_export_terminology_test.json is unrelated to this PR's transaction-tracking
change, isn't referenced by any test, and was clearly swept in by an unscoped
`git add`. It fails Format Check (not prettier-formatted) and has no reason to
be tracked at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review July 31, 2026 02:35
Comment thread resources/DatabaseTransaction.ts Outdated
kriszyp and others added 7 commits July 30, 2026 21:10
- trackOutstandingCommit: guard against a non-thenable commitResolution
  (matches the repo's typeof value?.then === 'function' convention) and
  add a double-untrack guard, so a future misuse can't wedge the list or
  drive the count negative. Addresses gemini-code-assist and cb1kenobi's
  review comments.
- Guard the replay-path commitResolution.then() call the same way as the
  ordinary commit path, for consistency (gemini-code-assist).
- Strengthen the chained-commit test: the existing test only proved the
  final count reaches zero, which also passes if the second (chained)
  link's tracking were silently omitted. Add a test that holds the second
  native commit open via a scoped Transaction.prototype.commit patch and
  asserts the outstanding count while only that link is pending (kriszyp).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… fix test defects

Per kriszyp's review comment and codex's independent pre-push review: writeTxnQueueDepth
and outstandingCommitCount tracked exactly the same set of native commits through two
separate mechanisms that had already drifted once (the replay-path write-queue omission
this PR fixes). trackOutstandingCommit now owns both the linked-list and the write-queue
high-water mark; getTransactionQueueDepths() derives writeDepth from outstandingCommitCount
directly. Removes enterWriteQueue/leaveWriteQueue and their two duplicated call sites.

Also fixes two defects codex's review found in the new chained-commit test: the head
transaction only read TrackA (no write), so it never armed a real native commit and the
fixture didn't actually exercise the re-entrant tracking path it claimed to cover; and an
assertion failure before releaseHold() would leave the held commit (and its list node)
permanently pending, poisoning later tests. Head now performs a real put, and cleanup
unconditionally releases the hold and awaits/catches the outer commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… stuck-commit diagnostics

Per codex's independent pre-push review: main landed #2007 (Log once when checkOverloaded()
first rejects writes, harper#2001) while this branch was in flight, adding per-commit
identity (store/startedFrom/native-transaction-id) and one-time logging to the single-slot
`outstandingCommit`/`outstandingCommitStart` this PR replaces. A plain rebase/merge would
have silently dropped that diagnostics feature.

Reconciled by moving the identity fields and a `logged` flag onto each OutstandingCommit
list node (populated at trackOutstandingCommit() call time) instead of a single module-level
slot, and having checkOverloaded() read them off `oldestOutstandingCommit`. This also
strictly improves on #2007: its single slot only ever armed for the FIRST commit attempt
(a documented known gap — a chained second-store commit or most retries were invisible to
both the overload check and the log), whereas every node here carries its own identity, so
a chained or retried commit that wedges is now named just as precisely as the first one, and
a second still-stuck commit logs on its own once it becomes the oldest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per gemini's independent pre-push review: per-node `logged` dedup (added in the previous
commit to close #2007's known single-slot gap) means that under sustained overload, each
distinct commit that individually ages past MAX_OUTSTANDING_TXN_DURATION logs once as it
becomes the oldest — which is correct in isolation, but a large pileup where many commits
settle in succession could turn one overload episode into a growing stream of ERROR lines.

Cap the log to once per second across the whole thread regardless of how many commits cross
the threshold. A commit skipped by the cooldown is not marked `logged`, so it still logs
later if it's still the oldest once the cooldown clears, rather than going silent forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per codex's independent pre-push review: the polling loop started checking
getOutstandingCommits() immediately after kicking off the chained transaction, and TrackA's
own native commit is also briefly tracked (it settles and untracks itself before
this.next.commit() runs, but that's still a real count>0 window). The loop could latch onto
THAT transient state and stop before TrackB's held commit was ever submitted, letting the
assertions pass without actually exercising the re-entrant path this test exists to cover.

Signal from inside the patched Transaction.prototype.commit the moment TrackB's commit is
invoked, and await that signal before asserting. Verified by temporarily disabling
trackOutstandingCommit's call site and confirming this test (and only this test) now fails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…uite

Per codex's independent pre-push review: an unbounded await on secondCommitStarted would
hang forever if a regression ever prevented TrackB's commit from being invoked (or done
rejected before reaching it). Mocha's own test timeout does not cancel a still-running async
test function, so the try/finally's cleanup — restoring the monkeypatched
Transaction.prototype.commit and releasing the held promise — would never run, breaking
every later RocksDB-touching test in the process. Race the signal against a bounded timeout
so the finally block always gets a chance to run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Promise.race() doesn't cancel the losing branch, so the 10s timeout added in the previous
commit stayed referenced even after secondCommitStarted won, keeping a targeted run of this
file alone alive for ~10s past the actual result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@kriszyp

kriszyp commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

Replying to @kriszyp's comment on resources/DatabaseTransaction.ts about consolidating trackOutstandingCommit's list with the writeTxnQueueDepth lifecycle (couldn't post as a threaded reply — a pending review on the PR is blocking the GitHub API's reply endpoint):

Consolidated it — trackOutstandingCommit() now owns both the linked list and the write-queue high-water mark directly, and enterWriteQueue/leaveWriteQueue plus the separate writeTxnQueueDepth counter are gone. getTransactionQueueDepths() derives writeDepth straight from outstandingCommitCount.

Codex's independent pre-push review flagged the exact same duplication before I'd pushed it, with a concrete argument for why it mattered beyond code cleanliness: the replay-path write-queue omission this PR fixes was an instance of the two mechanisms drifting.

— Claude (Sonnet 5)

@kriszyp
kriszyp merged commit 4690a33 into main Aug 3, 2026
73 of 76 checks passed
@kriszyp
kriszyp deleted the kris/outstanding-commit-arming branch August 3, 2026 12:14
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