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

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 false — pinned 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
kriszyp added a commit that referenced this pull request Sep 8, 2026
* Report the long-lived transaction holder behind a wedged commit

A RocksDB transaction that holds staged writes — and with them verification-table
write intents — can stay open indefinitely with nothing in core naming it. On 5.2.7
such a holder parked other writers forever; on 5.2.8 it costs ~5s per colliding write
and can block version reclamation until restart. Two field incidents held for 4.5h and
9h with no log line identifying the holder.

Nothing reported it because the monitor's `sourceApply`/`isReplay` branch force-commits
silently, its commit-phase sparing carries no native transaction id, `checkOverloaded()`
names only the victim commit, and a handle opened outside a supervised transaction is in
neither monitor registry at all.

Three reporting surfaces, joined by the native transaction id:

- `resources/longLivedTransactions.ts` sweeps rocksdb-js's registry from the main thread
  — it is process-global, so one sweep sees every worker's handles, including ones core
  never tracked — and names any open past `storage.longTransactionReportThreshold`
  (default 5m, 0 disables).
- The per-thread monitor adds the attribution the sweep cannot: thread, database/table,
  `startedFrom`, staged write count, and which state kept it from being reaped.
- `checkOverloaded()` appends the live handles on the stuck commit's own database as
  holder candidates.

Reporting only: no reap predicate, timeout transition, commit or abort order changes.
Suppression is keyed on (database path, native id) because ids are per-descriptor and
restart on reopen, and candidates are ranked by age but never filtered by it — a
coordinated retry can park on a transaction younger than itself.

Refs #2471

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016a9iHeo3Zz4vjtak9mFpoj

* Address pre-push review findings

- Count staged writes only when a line is actually due. The count walks every
  chain link's whole `writes` array, and the backoff that suppresses the log
  lives downstream, so a replay or canonical-source apply holding ~10^5 writes
  for hours paid that walk on every 30s tick to produce a number it logs about
  five times. It is a thunk now, evaluated after the due check.
- Reject a threshold that is neither string nor number. `convertToMS` returns 0
  for a boolean or object — what YAML hands us for `longTransactionReportThreshold:
  yes` — which is indistinguishable from the documented 0 that disables reporting,
  so a typo silently switched off all three surfaces with no warning.
- Report `?` rather than the table name when a store has no `rootStore.databaseName`,
  matching the stuck-commit line the attribution line is read beside.
- Add `setMaxOutstandingTxnDuration`, so a test can reach `checkOverloaded()`'s
  stuck-commit log without wedging a commit for the real 45s limit, and cover the
  surface an operator actually reads during an outage: the candidate suffix reaches
  the message, the wedged commit is excluded from its own candidate list, and a
  registry that throws still yields the 503 rather than a 500.
- Assert a real table store's `rootStore.path` is a path `registryStatus()` reports,
  which is the join the candidate lookup depends on and the mocked-registry tests
  cannot catch.
- Trim comments that restated the code rather than explaining it.

Refs #2471

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016a9iHeo3Zz4vjtak9mFpoj

* Address round-2 review nits

- Assert the staged-write walk is skipped on a suppressed report, which is the
  regression guard for the lazy count itself.
- Say which degraded case happened: a registry entry with no transactionDetails
  is not the same as no entry for the path, and only the latter means the
  store-path join has drifted.
- Drop a comment that narrated the call below it.

Refs #2471

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016a9iHeo3Zz4vjtak9mFpoj

* Address PR review: cross-database candidates, live threshold, chain links

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

* Address pre-push review: skip sub-threshold state, drop a false `active`

- The sweep no longer builds a key, a report state and two container entries for every live
  handle in the process on every pass. `nextReportAgeMs` is seeded at the threshold and only
  grows, so a sub-threshold handle can never be due and its state would be pruned on the same
  pass; a healthy node's minute now costs nothing rather than ~4 allocations per live handle.
- A chain link reported through the root no longer claims `state: active`. `timeout` is armed
  by `addWrite` and decremented once per tick only for the link the tick entered on, so a link
  reached through `.next` kept an armed `timeout` forever and told the operator the application
  was still writing when the link had been idle for hours — the inverse of the diagnosis, on
  exactly the shape the chain walk exists for.
- Two comments that restated the callee's own docstring are gone.

The chain-link test now matches on the link's table rather than its native id alone: ids are
allocated per database descriptor, so the root and the second-database link both held id 4 and
an id-only match asserted against the root's line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QDdAwWFsWANGNs7ccja1H

* Address re-review: read the clock that is actually decayed for a chain link

`active` now reads whichever recency clock the monitor maintains for the link being reported:
`timeout` for the link the tick entered on, which is decremented once per tick beside the reap
branches, and `writeTimeout` for a link reached only through the chain, which is what
`chainStillActive` decays. The previous round dropped `active` for chain links entirely, which
traded a permanent false positive for hiding a link the application is still writing to.

Both directions are pinned: a freshly-written chain link must report `active`, and one whose
write recency has decayed must not. Each is a single-phase test — the reporting window closes
when the logical transaction settles, so a multi-phase assertion raced the force-commit — and the
child's native id is captured before the wait rather than read after it.

Also corrects the sweep comment: without the new skip, a sub-threshold handle's state was added to
`seen` and survived pruning. The reason the skip is safe is that `nextReportAgeMs` is seeded at the
threshold and only grows, so that state could never change an outcome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QDdAwWFsWANGNs7ccja1H

* Correct a docstring the previous commit made stale

reportLongLivedLink no longer infers the chain case from `timeout`; it reads whichever recency
clock the monitor maintains for that link. The docstring still described the old rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QDdAwWFsWANGNs7ccja1H

* Keep both reporting surfaces inside the guards that promise they cannot throw

Both functions document that they never take down their caller — the sweep runs from a bare
`setInterval` on the main thread, and `describeHolderCandidates` builds a log message on the way
to a 503 that must not become a 500. In both, the config read sat outside the `try` that makes
that true. Scope moves only; no behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QDdAwWFsWANGNs7ccja1H

* Give the cross-database holder a slot the cap cannot take away

Ranking the stuck commit's own database first and then capping at three could bury the one
candidate the cross-database search was added to surface: a busy database routinely has three
live handles of its own, so a `system`/`oauth` holder parking a `data` commit would only ever
appear inside the "and N more" count. The oldest foreign candidate now takes the last slot when
this database would otherwise fill every one, and a test pins it with four same-database handles
against one foreign.

Found by the cursor-grok leg, which reached a verdict for the first time this session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QDdAwWFsWANGNs7ccja1H

* Read the clock once per monitor tick, not once per link

`reportLongLivedLink` sampled `performance.now()` before the threshold check, so a worker paid one
clock read per link holding a live handle on every tick — the cost scales with tracked transactions,
which is exactly the leak this reports on. The tick now samples once and passes it down; the
threshold it feeds is minutes, so per-link precision buys nothing.

Also strengthens the reserved-slot test: a second, younger foreign handle now sits between, so the
fixture proves the OLDEST foreign candidate takes the slot rather than merely some foreign one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QDdAwWFsWANGNs7ccja1H

* Give CI jobs enough time to complete

Co-Authored-By: GPT-5 Codex <noreply@openai.com>

* Bound long-lived transaction attribution reporting

Co-Authored-By: GPT-5 Codex <noreply@openai.com>

* Refine long-lived transaction diagnostics

Co-Authored-By: GPT-5 Codex <noreply@openai.com>

* Reset transaction report backoff on threshold changes

Co-Authored-By: GPT-5 Codex <noreply@openai.com>

* Restore transaction test seam documentation

Co-Authored-By: GPT-5 Codex <noreply@openai.com>

* Stabilize long-lived transaction reporting tests

Co-Authored-By: GPT-5 Codex <noreply@openai.com>

* Harden long-lived transaction diagnostics tests

Co-Authored-By: GPT-5 Codex <noreply@openai.com>

---------

Co-authored-by: Kris Zyp <kris@harperdb.io>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: GPT-5 Codex <noreply@openai.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.

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