Bound a request-path commit's conflict retries to its queue-time budget - #2459
Merged
Conversation
rocksdb-js 2.8 returns control from a commit parked on a conflicting write intent every ROCKSDB_JS_PARK_TIMEOUT_MS even when the holder never releases, so Harper sees a stream of transient conflicts rather than one hung commit. The 40-attempt cap alone then keeps the initiating request pending for roughly 40 park timeouts - minutes past the queue limit an operator configured, which is what left a control-plane node rejecting every write for 12 minutes. Carry one elapsed clock per logical commit on its chain root, stamped at the first native submission, and read it at both retry decisions. Past max(storage.maxTransactionQueueTime, timeoutBudget) the commit takes the existing retry-exhaustion abort path, logs the stuck-commit identity through the same rate limiter checkOverloaded() uses, and throws a 503 TRANSACTION_COMMIT_CONFLICT_TIMEOUT. Only a chain root that has not rotated through a mid-scope commit reports retryable: anywhere else an earlier store already landed durable audit entries a replayed request would repeat. Outstanding-commit tracking stays per native attempt - it measures native liveness and drives thread-wide load shedding, so back-dating it would let one uncapped source-apply retry shed every unrelated request on the thread. Source-applied writes stay exempt from the deadline as they are from the cap. Refs #2450 Co-Authored-By: Claude Opus <noreply@anthropic.com>
- Give the abandonment log its own cooldown slot. It shared checkOverloaded()'s, and that one fires on every bystander write during a wedge, so the line naming the abandoned transaction - the only server-side record of why a request was failed - lost the slot to a line naming a different commit. - Add a test that observes production arming the clock at the first native submission and releasing it at settle. Every other case plants the clock, so the one line that arms it could have been deleted with the suite still green. - Pin the snapshotFree half of the retryable decision: a scope that landed an earlier mid-handler segment must report retryable: false. - Trim two comments that restated the adjacent code. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Pre-existing drift from #2437 under the current prettier 3.9.x; formatting only, no behavior change. Format Check fails identically on main without it. Co-Authored-By: Claude Opus <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to track and enforce deadlines for transaction commit conflict retries, preventing requests from hanging indefinitely on locked write intents. It adds the TransactionCommitConflictTimeoutError (503) and implements budget checks on logical commits. Review feedback recommends aligning null-or-undefined checks with the repository style guide by using loose equality (== null), using strict assertions (assert.strictEqual and assert.deepStrictEqual) in tests to avoid type-coercion bugs, and replacing condition-polling helpers with deterministic watchdog timers in tests.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp
marked this pull request as ready for review
September 2, 2026 17:15
github-actions
Bot
requested review from
Ethan-Arrowood,
cb1kenobi and
heskew
September 2, 2026 17:16
kriszyp
added a commit
that referenced
this pull request
Sep 3, 2026
…inks Rebased onto main (#2459 landed the `describeCommitIdentity`/`allowStuckCommitLog` refactor under the same log line), then applied the open review feedback: - Holder candidates now enumerate every registry entry, ranking the stuck commit's own database first and labelling a foreign one with its path. The verification table is one process-global slot array whose hash mixes in the database id, so a holder in `system` or `oauth` parks a `data` commit at the same rate another `data` key would; filtering to the commit's own database printed nothing at all for that shape. - The enumeration guards `database.path` before `resolve()`. Unguarded, one pathless entry anywhere in the registry threw inside the predicate and zeroed the candidate list for every database, silently. - `describeHolderCandidates()` honors the documented disable value, like the other two surfaces. - The same suffix is appended to `abandonCommitAfterDeadline()`'s log, the other place a commit parked on someone else's write intent is reported. - Changing `storage.longTransactionReportThreshold` clears the accrued backoff, so a handle already under observation is re-measured. `nextReportAgeMs` was pinned at first observation, so lowering the threshold mid-incident did not bring a report forward and raising it did not quiet one — contradicting the live-reload the module promises. - Attribution walks the whole `.next` chain, reporting each link that holds its own native handle under that link's own id, with that link's own staged-write count. A link the monitor reaches only through the root was named under the root's id, which the registry sweep's line cannot be joined to. - The attribution prune runs at most once a minute rather than once per over-threshold transaction per tick (O(N^2) in exactly the leak it reports on). - A handle whose database has no path logs `?` rather than `undefined`; strict assertion in the new txn-tracking test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QDdAwWFsWANGNs7ccja1H
github-actions Bot
pushed a commit
that referenced
this pull request
Sep 3, 2026
…inks Rebased onto main (#2459 landed the `describeCommitIdentity`/`allowStuckCommitLog` refactor under the same log line), then applied the open review feedback: - Holder candidates now enumerate every registry entry, ranking the stuck commit's own database first and labelling a foreign one with its path. The verification table is one process-global slot array whose hash mixes in the database id, so a holder in `system` or `oauth` parks a `data` commit at the same rate another `data` key would; filtering to the commit's own database printed nothing at all for that shape. - The enumeration guards `database.path` before `resolve()`. Unguarded, one pathless entry anywhere in the registry threw inside the predicate and zeroed the candidate list for every database, silently. - `describeHolderCandidates()` honors the documented disable value, like the other two surfaces. - The same suffix is appended to `abandonCommitAfterDeadline()`'s log, the other place a commit parked on someone else's write intent is reported. - Changing `storage.longTransactionReportThreshold` clears the accrued backoff, so a handle already under observation is re-measured. `nextReportAgeMs` was pinned at first observation, so lowering the threshold mid-incident did not bring a report forward and raising it did not quiet one — contradicting the live-reload the module promises. - Attribution walks the whole `.next` chain, reporting each link that holds its own native handle under that link's own id, with that link's own staged-write count. A link the monitor reaches only through the root was named under the root's id, which the registry sweep's line cannot be joined to. - The attribution prune runs at most once a minute rather than once per over-threshold transaction per tick (O(N^2) in exactly the leak it reports on). - A handle whose database has no path logs `?` rather than `undefined`; strict assertion in the new txn-tracking test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QDdAwWFsWANGNs7ccja1H
kriszyp
added a commit
that referenced
this pull request
Sep 4, 2026
…inks Rebased onto main (#2459 landed the `describeCommitIdentity`/`allowStuckCommitLog` refactor under the same log line), then applied the open review feedback: - Holder candidates now enumerate every registry entry, ranking the stuck commit's own database first and labelling a foreign one with its path. The verification table is one process-global slot array whose hash mixes in the database id, so a holder in `system` or `oauth` parks a `data` commit at the same rate another `data` key would; filtering to the commit's own database printed nothing at all for that shape. - The enumeration guards `database.path` before `resolve()`. Unguarded, one pathless entry anywhere in the registry threw inside the predicate and zeroed the candidate list for every database, silently. - `describeHolderCandidates()` honors the documented disable value, like the other two surfaces. - The same suffix is appended to `abandonCommitAfterDeadline()`'s log, the other place a commit parked on someone else's write intent is reported. - Changing `storage.longTransactionReportThreshold` clears the accrued backoff, so a handle already under observation is re-measured. `nextReportAgeMs` was pinned at first observation, so lowering the threshold mid-incident did not bring a report forward and raising it did not quiet one — contradicting the live-reload the module promises. - Attribution walks the whole `.next` chain, reporting each link that holds its own native handle under that link's own id, with that link's own staged-write count. A link the monitor reaches only through the root was named under the root's id, which the registry sweep's line cannot be joined to. - The attribution prune runs at most once a minute rather than once per over-threshold transaction per tick (O(N^2) in exactly the leak it reports on). - A handle whose database has no path logs `?` rather than `undefined`; strict assertion in the new txn-tracking test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QDdAwWFsWANGNs7ccja1H
github-actions Bot
pushed a commit
that referenced
this pull request
Sep 4, 2026
…inks Rebased onto main (#2459 landed the `describeCommitIdentity`/`allowStuckCommitLog` refactor under the same log line), then applied the open review feedback: - Holder candidates now enumerate every registry entry, ranking the stuck commit's own database first and labelling a foreign one with its path. The verification table is one process-global slot array whose hash mixes in the database id, so a holder in `system` or `oauth` parks a `data` commit at the same rate another `data` key would; filtering to the commit's own database printed nothing at all for that shape. - The enumeration guards `database.path` before `resolve()`. Unguarded, one pathless entry anywhere in the registry threw inside the predicate and zeroed the candidate list for every database, silently. - `describeHolderCandidates()` honors the documented disable value, like the other two surfaces. - The same suffix is appended to `abandonCommitAfterDeadline()`'s log, the other place a commit parked on someone else's write intent is reported. - Changing `storage.longTransactionReportThreshold` clears the accrued backoff, so a handle already under observation is re-measured. `nextReportAgeMs` was pinned at first observation, so lowering the threshold mid-incident did not bring a report forward and raising it did not quiet one — contradicting the live-reload the module promises. - Attribution walks the whole `.next` chain, reporting each link that holds its own native handle under that link's own id, with that link's own staged-write count. A link the monitor reaches only through the root was named under the root's id, which the registry sweep's line cannot be joined to. - The attribution prune runs at most once a minute rather than once per over-threshold transaction per tick (O(N^2) in exactly the leak it reports on). - A handle whose database has no path logs `?` rather than `undefined`; strict assertion in the new txn-tracking test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QDdAwWFsWANGNs7ccja1H
kriszyp
added a commit
that referenced
this pull request
Sep 5, 2026
…inks Rebased onto main (#2459 landed the `describeCommitIdentity`/`allowStuckCommitLog` refactor under the same log line), then applied the open review feedback: - Holder candidates now enumerate every registry entry, ranking the stuck commit's own database first and labelling a foreign one with its path. The verification table is one process-global slot array whose hash mixes in the database id, so a holder in `system` or `oauth` parks a `data` commit at the same rate another `data` key would; filtering to the commit's own database printed nothing at all for that shape. - The enumeration guards `database.path` before `resolve()`. Unguarded, one pathless entry anywhere in the registry threw inside the predicate and zeroed the candidate list for every database, silently. - `describeHolderCandidates()` honors the documented disable value, like the other two surfaces. - The same suffix is appended to `abandonCommitAfterDeadline()`'s log, the other place a commit parked on someone else's write intent is reported. - Changing `storage.longTransactionReportThreshold` clears the accrued backoff, so a handle already under observation is re-measured. `nextReportAgeMs` was pinned at first observation, so lowering the threshold mid-incident did not bring a report forward and raising it did not quiet one — contradicting the live-reload the module promises. - Attribution walks the whole `.next` chain, reporting each link that holds its own native handle under that link's own id, with that link's own staged-write count. A link the monitor reaches only through the root was named under the root's id, which the registry sweep's line cannot be joined to. - The attribution prune runs at most once a minute rather than once per over-threshold transaction per tick (O(N^2) in exactly the leak it reports on). - A handle whose database has no path logs `?` rather than `undefined`; strict assertion in the new txn-tracking test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QDdAwWFsWANGNs7ccja1H
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A request-path RocksDB commit that keeps losing write-intent conflicts now stops at its queue-time budget instead of running the full 40-attempt cap.
rocksdb-js 2.8 wakes a commit parked on another transaction's write intent after
ROCKSDB_JS_PARK_TIMEOUT_MS(5s) and returnsRETRY_NOW_VALUEeven when the holder never releases. That turned the "commit that never settles" of #2450 into a stream of transient conflicts — but the attempt cap alone still keeps the initiating request pending for roughly 40 park timeouts, several times the 45sstorage.maxTransactionQueueTimean operator configured. This adds the missing bound.One elapsed clock per logical commit, stamped on the chain root at its first native submission, is read at both retry decisions (the coordinated
RETRY_NOW_VALUEresolve path and theERR_BUSY/ERR_TRY_AGAINrejection path). PastMath.max(storage.maxTransactionQueueTime, timeoutBudget)the commit takes the existingabortChainAfterRetries()cleanup, logs the stuck-commit identity, and throws a 503TransactionCommitConflictTimeoutErrorcarryingcode: 'TRANSACTION_COMMIT_CONFLICT_TIMEOUT'. The 40-attempt cap stays as the second bound.Three properties are load-bearing:
trackOutstandingCommit()is untouched — it measures native liveness and drivescheckOverloaded()'s thread-wide load shedding, so back-dating it would let one uncappedsourceApplyretry eventually 503 every unrelated request on the thread. The new clock is stamped only when unset, so retry rounds and chained per-store commits inherit it; the budget comparison itself is wheresourceApplyis exempted.retryableis true only when nothing landed durably. A chained link commits solely from its predecessor's success handler, and a head that rotated through a mid-scope commit already landed a segment; in both cases replaying the request would repeat durable audit entries and hooks, so the flag is false — pinned for the chained case and for the mid-scope case.checkOverloaded()'s 1s limiter, but that one fires on every bystander write during a wedge and would have starved the only server-side line naming the abandoned transaction; each site now has its own slot.Source-applied writes are exempt from the deadline exactly as they are from the attempt cap: there is no resubscribe/sequence-resume path, so dropping one permanently diverges the node (harper-pro#348).
For the human reviewer
The step-6 planning gate never returned
chosen-approach-sound. Two rounds returnedFraming-Verdict: better-alternative-exists: the first rejected a ladder-local 45s clock in favour of reusing the outstanding-commit tracker; the second rejected that because back-dating the tracker would make one long source-apply retry shed every unrelated request on the thread. @kriszyp adjudicated and directed the third framing — deadline only on the chain root, tracker per attempt, abandonment-site diagnostic, retryability conditioned on whether the chain half-landed — which is what is implemented here. The design note is not in the diff; its "Approaches considered" set is summarised above.Closesmay overstate what this does. #2450 asks for three things. This delivers the deadline (ask 1) and a distinct error code separating "wedged" from "queue overloaded" (part of ask 3). It does not deliver the holder/key diagnostic (ask 2) — rocksdb-js 2.8 exposes neither the waited-on key nor the intent holder through its publicTransactionAPI — nor cancellation of an already-submitted native commit, nor scopingcheckOverloaded()'s rejection to what actually conflicts (the rest of ask 3). If you want those tracked, downgrade this toRefsand split them out.Declined review findings, all raised and adjudicated pre-push:
save()'s immediate-commit reuse branch and reverted that — I could not reach the branch from a test, becauseResource.tsstarts a fresh transaction for a static op whose context transaction is closed. The residual is bounded: a thread with a permanently wedged commit is already 503ing every application write throughcheckOverloaded(), so the extra spurious 503 is not a new failure mode. Worth a follow-up if you disagree.unhandledRejection. Per commit, not per record, in a path that already builds several promises.Open decisions, unchanged from the design and worth a second opinion:
Math.max(maxTransactionQueueTime, timeoutBudget)means a caller can only ever raise its budget. A request with a 2s client deadline still burns the full configured queue time in retries.retryableis false. Clients that retry on status code alone will replay a half-landed multi-store request; the flag is in the response body (serverHelpers.jssendscode/retryablewheneverretryable !== undefined), not the status.Verification
npm run build,npm run format:write,npm run lint:required— clean. The last commit reformatsunitTests/resources/query-array-scoping.test.js, which is unrelated pre-existing drift from Pin element-scoping semantics of queries over array-valued properties #2437 under the current prettier 3.9.x — Format Check fails on it identically onmain, so it had to be fixed here to get a green run. Formatting only.npm run test:unit:resources— 1929 passing, 0 failing.npm run test:unit:main— 5188 passing, 2 failing; both (gitCredentials,configValidatordomain-socket path length) reproduce unchanged onorigin/mainin this worktree and are environmental.npm run test:integration:all, run in four chunks to fit the runner: 2006 passing, 0 failing. The only failures were theOllamaBackend against a real Ollama instancecases, which need a local Ollama daemon.resources/DatabaseTransaction.tsandutility/errors/hdbError.tsreverted toorigin/mainanddistrebuilt, the three behavioural cases in the new suite fail (they get the generic 500 exhaustion error after 41 attempts instead of the 503).New coverage in
unitTests/resources/commitConflictDeadline.test.js: production arming/release of the clock (so the one line that arms it cannot be deleted with the suite still green), abandonment on both conflict paths, one budget shared across a cross-database chain withretryable: falseand the head's write durable, the mid-scope-rotation half of the retryable decision,sourceApplyretrying to convergence past the deadline, and the clock being released so the next transaction starts fresh. Error shape inunitTests/utility/errors/hdbError.test.js.Closes #2450
Complexity: moderate
🤖 Generated with Claude Code — reviewed by Claude Opus 5
Review-Coverage: authored=claude; ran=codex; blocked=gemini(auth); declined=cursor-grok,cursor-composer,domain; rounds=5 @ d8d49de
Human-Review-Need: 3 @ d8d49de