Track every outstanding write commit, not just one per thread - #2009
Conversation
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>
There was a problem hiding this comment.
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.
|
Reviewed; no blockers found. |
…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>
- 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>
|
Replying to @kriszyp's comment on Consolidated it — 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) |
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
mainand 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 pastmaxTransactionQueueTime(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:
Two consequences, both meaning a wedge could be entirely invisible:
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 oncommitResolution; 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:
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
Setbecause 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
recordCommitLatencycomment 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 assertscontext.transaction.nextexists 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:resources1342 passing,test:unit:main3966 passing,test:integration:all1619 tests / 1593 pass / 0 fail, oxlint and prettier clean.Known gaps, not addressed here
delay(...)with nothing outstanding, so a lost continuation there (the harper#1785 shape) is invisible to this mechanism. Needs its own treatment.resources/LMDBTransaction.tshas no equivalent protection at all — its own separate sentinel, no start stamp, nocheckOverloaded(). Pre-existing; LMDB is deprecated.unitTests/resources/txn-tracking.test.jslooks mis-scoped. Its fixture comment claims two tables indatabase: 'test'"span two databases (writes to the second live on the transaction'snextchain)", 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 thenextchain 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