Skip to content

Bound a request-path commit's conflict retries to its queue-time budget - #2459

Merged
kriszyp merged 5 commits into
mainfrom
fix/bound-stuck-commit-retries
Sep 2, 2026
Merged

Bound a request-path commit's conflict retries to its queue-time budget#2459
kriszyp merged 5 commits into
mainfrom
fix/bound-stuck-commit-retries

Conversation

@kriszyp

@kriszyp kriszyp commented Sep 2, 2026

Copy link
Copy Markdown
Member

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 returns RETRY_NOW_VALUE even 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 45s storage.maxTransactionQueueTime an 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_VALUE resolve path and the ERR_BUSY/ERR_TRY_AGAIN rejection path). Past Math.max(storage.maxTransactionQueueTime, timeoutBudget) the commit takes the existing abortChainAfterRetries() cleanup, logs the stuck-commit identity, and throws a 503 TransactionCommitConflictTimeoutError carrying code: 'TRANSACTION_COMMIT_CONFLICT_TIMEOUT'. The 40-attempt cap stays as the second bound.

Three properties are load-bearing:

  • The clock is per logical commit, not per attempt. trackOutstandingCommit() is untouched — it measures native liveness and drives checkOverloaded()'s thread-wide load shedding, so back-dating it would let one uncapped sourceApply retry 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 where sourceApply is exempted.
  • retryable is 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 falsepinned for the chained case and for the mid-scope case.
  • The abandonment log gets its own cooldown slot. It initially shared 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 returned Framing-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.

Closes may 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 public Transaction API — nor cancellation of an already-submitted native commit, nor scoping checkOverloaded()'s rejection to what actually conflicts (the rest of ask 3). If you want those tracked, downgrade this to Refs and split them out.

Declined review findings, all raised and adjudicated pre-push:

  • A wedged commit leaves the clock set on a reusable instance (minor). Real in principle: the release runs when the commit settles, and a commit that never settles never releases. I tried to fix it by clearing on save()'s immediate-commit reuse branch and reverted that — I could not reach the branch from a test, because Resource.ts starts 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 through checkOverloaded(), so the extra spurious 503 is not a new failure mode. Worth a follow-up if you disagree.
  • The release wrapper costs one promise and two closures per commit (nit). Kept deliberately: it is one place to get right instead of five terminal branches, and it is on the returned promise rather than a second subscriber so a dropped commit rejection still surfaces as an 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.
  • The abandonment is a 503 even when retryable is false. Clients that retry on status code alone will replay a half-landed multi-store request; the flag is in the response body (serverHelpers.js sends code/retryable whenever retryable !== undefined), not the status.
  • The budget is measured from the first native submission, not from scope entry — so it is 45s of commit retry on top of whatever the request already spent staging and in pre-commit blob I/O.

Verification

  • npm run build, npm run format:write, npm run lint:required — clean. The last commit reformats unitTests/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 on main, 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, configValidator domain-socket path length) reproduce unchanged on origin/main in 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 the OllamaBackend against a real Ollama instance cases, which need a local Ollama daemon.
  • Fails-on-base check: with resources/DatabaseTransaction.ts and utility/errors/hdbError.ts reverted to origin/main and dist rebuilt, the three behavioural cases in the new suite fail (they get the generic 500 exhaustion error after 41 attempts instead of the 503).
  • End-to-end route: resource-layer tests driving real Harper table transactions with only the native commit result substituted. A native park deadline is covered in rocksdb-js and is not reproducible end-to-end here without deliberately leaking a native intent.

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 with retryable: false and the head's write durable, the mid-scope-rotation half of the retryable decision, sourceApply retrying to convergence past the deadline, and the clock being released so the next transaction starts fresh. Error shape in unitTests/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

Kris Zyp and others added 3 commits September 1, 2026 17:51
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>
@kriszyp kriszyp added this to the v5.3 milestone Sep 2, 2026
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>

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

Comment thread resources/DatabaseTransaction.ts Outdated
Comment thread resources/DatabaseTransaction.ts Outdated
Comment thread unitTests/resources/commitConflictDeadline.test.js Outdated
Comment thread unitTests/resources/commitConflictDeadline.test.js
Comment thread unitTests/utility/errors/hdbError.test.js Outdated
Co-Authored-By: Claude Opus <noreply@anthropic.com>
@kriszyp
kriszyp marked this pull request as ready for review September 2, 2026 17:15
@kriszyp
kriszyp merged commit bec217a into main Sep 2, 2026
50 checks passed
@kriszyp
kriszyp deleted the fix/bound-stuck-commit-retries branch 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
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.

Outstanding-commit wedge recurs on 5.2.6/5.2.7: native commits never settle and checkOverloaded 503s every application write on the thread until restart

1 participant