Skip to content

fix(transaction): bound the coordinated-retry park with a descriptor-owned timeout - #744

Merged
kriszyp merged 19 commits into
mainfrom
fix/park-timeout-coordinated-retry
Aug 25, 2026
Merged

fix(transaction): bound the coordinated-retry park with a descriptor-owned timeout#744
kriszyp merged 19 commits into
mainfrom
fix/park-timeout-coordinated-retry

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 1, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #741 by bounding every coordinated-retry park. A commit blocked behind a holder that never releases now resolves RETRY_NOW after ROCKSDB_JS_PARK_TIMEOUT_MS (default 5000 ms) instead of leaving the write path hung indefinitely.

  • Adds a descriptor-owned timeout registry with monotonic deadline ordering and a lazily started thread, joined during descriptor close.
  • Keeps the VT wake callback off DBDescriptor and DBRegistry: it captures only weak timeout-registry and exactly-once state, avoiding registry re-entry and descriptor reference-count perturbation while the process-global VT writer mutex is held (wake path).
  • Cancels timeout TSFNs during worker-environment cleanup while snapshotting only independently owned registries, so cleanup cannot make a concurrent descriptor purge skip (env cleanup).
  • Handles timeout-thread and N-API/TSFN setup failures with a bounded immediate retry or terminal promise rejection; no setup failure can silently leave the commit promise pending (failure path).
  • Reads ROCKSDB_JS_PARK_TIMEOUT_MS once per process behind a function-local static, matching commitThreadMode()/commitDelayMs() in the same file: a park runs on whichever env's JS thread owns the transaction, and ::getenv is not safe against a process.env write on another thread (parkTimeoutMs()).
  • Guards both TSFN resolution paths (ParkTimeoutRegistry::resolve and the no-timeout-thread degrade) on napi_call_threadsafe_function's status before releasing: a napi_closing result means the tsfn may already be mid-teardown on Node's side, so releasing it again is a use-after-close (ParkTimeoutRegistry::resolve) — found by the pre-push review during this rebase, not present in earlier rounds.
  • Measures every elapsed-time assertion on the monotonic clock, including the conflict-wake bound in the concurrent-transactions test, which was still reading Date.now() on both ends (lock-tracker.test.ts) — an NTP step between the two reads was enough to push it past the 3000 ms bound with nothing wrong in the wake path.
  • Documents the bound where the behavior is specified. The README described the coordinated-retry wait as lasting "until the conflicting transaction has committed and released its write intent", which this change makes untrue; it now states the timeout, that a timeout consumes a retry attempt, the resulting ERR_TRANSACTION_ABANDONED at roughly maxRetries x the timeout, and that 0/negative/malformed values select the 5000 ms default rather than disabling the bound (README retry section).
  • Drops the exactly-once gate on the no-timeout-thread degrade. Every path that returns 0 from ParkTimeoutRegistry::schedule does so before the entry publishes fired, and the fireOnce wake closure is not built until after this branch breaks out, so that side owns the park outright and the compare-exchange could never fail (degrade path).
  • Raises the fork fixture's parent-side kill timer from 4 s to 20 s. The child's own elapsed assertion is the deadline check; the parent's timer additionally covers node boot, type-stripping, addon load and the seed writes, so at the same 4 s budget a slow runner and a real regression both surfaced as SIGTERM with the fixture's diagnostic never thrown. 20 s stays under vitest's 30 s testTimeout so the kill path still dumps the child's stderr (backstop).
  • Adds deterministic timeout and close-lifecycle regressions, including an isolated process that proves the test fails against origin/main (tests).

For the human reviewer

The implementation deliberately preserves the task's policy choices:

  • A timeout consumes one of the finite coordinated-retry attempts. A legitimately slow holder can therefore fail after roughly maxRetries × timeout; operators can raise the timeout but cannot disable the bound with 0. This is now stated in the README rather than only in the code, so it is a documented contract from here on.
  • The knob is process-wide (env var, read once at the first park) and the implementation uses one lazy timeout thread per descriptor. Moving this to a per-database open option or a process-global timer would be a separate API/architecture change.
  • LockTracker has no callback-removal API. Timed-out callbacks become inert immediately, but registrations remain until the holder wakes; adding cancellation is deferred because it changes the VT concurrency surface. Note the shape of the residual: registrations now accumulate per retry rather than per incident, so a long-lived abandoned holder under heavy contention grows one tracker's callback vector for as long as the incident lasts.

Declined, with reasons — each is a real observation, none is required to make the fix correct:

  • Park timeouts are operationally silent. A synthetic wake is indistinguishable from a real one, so an exhausted retry reports the same generic Transaction did not commit after N coordinated retries a hot key produces. A transaction:parkTimeout global event (the transactionLog:warning shape) would make the abandoned-holder incident diagnosable — new API surface, so it belongs in its own change. Partially mitigated this round on the configuration half: a misconfigured ROCKSDB_JS_PARK_TIMEOUT_MS is still silent at runtime, but the README now says which values are ignored, so an operator who sets 0 can find out why it had no effect.

  • db.transaction() orchestration is not covered end-to-end. test/fixtures/fork-park-timeout.mts drives Transaction.commit() directly and asserts the native RETRY_NOW. I traced src/database.ts:1014-1027 and the loop does terminate correctly, but nothing pins it.

  • A parked commit does not hold the event loop (napi_unref_threadsafe_function, pre-existing — the fixture's setInterval keep-alive exists for this). Ref-ing for the park's duration is only safe now that the wait is bounded, but it is a behavior change beyond this fix.

  • Comment density in db_descriptor.cpp. All three lenses flag the inline narration around ParkTimeoutRegistry (// Fire while still holding the mutex, // Notify + join outside the lock) as saying what the next line says. I trimmed only the two blocks this round touched; rewriting comments from earlier rounds under a nit that has been adjudicated as a nit five times running seemed like the wrong kind of churn for a review-feedback pass. The two blocks that do carry invariants — AGENTS.md note 12 and the ParkTimeoutRegistry class header — are the reason the Async DB ops (backup, createCheckpoint) defer registry purge when closed before settling #672 purge-skip does not come back, and the domain lens explicitly asked that they stay.

  • fire() takes the park registry's mutex while the global VT writerMutex_ is held, so a wake can wait behind runLoop()/shutdown() draining a batch of due parks. Everything done under that mutex is non-blocking — napi_call_threadsafe_function is napi_tsfn_nonblocking, and shutdown()'s join() is deliberately outside the lock — and N is bounded by one descriptor's concurrent coordinated-retry commits. The suggested try_lock would silently drop a real early wake and fall back to the full timeout, which is worse than the latency it avoids.

  • The first ::getenv in parkTimeoutMs() can still race a process.env write. True, and exactly the exposure commitThreadMode()/commitDelayMs() already carry three lines below — the previous round asked for this shape specifically. Closing it properly means capturing all three at module init, which is a separate change to code this PR does not otherwise touch.

  • std::bad_alloc between the ref transfer and the park registration would terminate the process. Pre-existing: before this change the same window ended in addWakeCallback() pushing onto a std::vector<std::function>, which can throw identically, and no N-API callback in this codebase is exception-guarded. Worth noting ParkTimeoutRegistry::schedule() does not leak on a partial insert — an orphaned deadline whose park is missing is erased by runLoop()'s existing parks.find() == end() branch.

  • retry: 1 on commit-teardown.test.ts can let a probabilistic native abort pass. It is the narrowing the previous round asked for (Deno-on-macOS only, one test, rather than re-rolling the whole suite); a skipIf would remove the crash guard on that platform outright rather than weaken it.

  • retryNowFinalize deletes its refs without an env == nullptr guard — checked and declined as a false positive. The NULL-env convention is documented for napi_threadsafe_function_call_js only; the finalize callback runs on the JS thread with a live env when the thread count hits zero, which is the contract event_emitter.cpp:216-219 already states and relies on. The function is also unchanged by this PR.

  • Join-at-close is still not covered by a test. I re-ran the mutation myself: join()detach() in ParkTimeoutRegistry::shutdown() leaves the file 12/12 green, because shutdown() resolves pending parks under its mutex before joining. The close test's comment no longer claims otherwise. Real coverage needs the ASan lane, not another deadline assertion.

Cross-model review used independent Codex and Gemini lenses plus Harper-domain adjudication across nine rounds. It found and drove fixes for the original wake-path deadlock, transient descriptor pins, deadline lookup, thread-creation failure, promise-settlement failures, getenv thread-safety, comment accuracy, the wall-clock assertion above, and — in round 8, rebasing onto latest main — the napi_closing use-after-close noted above. Round 9 (delta, after that fix) confirmed no new defect and that the other findings carry forward unchanged. Rounds 10-12 covered this review-feedback round: round 10 verified the CAS removal is sound (confirming schedule returns 0 only before it moves fired into an entry, and that fireOnce is constructed afterwards) and raised one new finding caused by the README addition itself - documenting the knob as operator-facing while every misconfiguration of it stays silent - which round 11 closed by stating the parsing rules; round 12 covered the comment trim. No round found a defect in the delta. The Cursor lens has never run on this branch: cursor-review refuses any diff that touches agent instructions, and this one edits AGENTS.md.

Rebase note (rounds 8–9): rebased onto latest origin/main (da0ef135) with no semantic conflicts — the only overlap was an independent upstream AGENTS.md invariant renumbering (main inserted its own invariant 13; this PR's invariant 13 became 14), resolved by keeping both. pnpm build, pnpm test:native (151 passed), and the park/lock-tracker/commit-teardown/orphan-gc Vitest suites (21 passed) were re-run clean against the rebased tip before and after the napi_closing fix above.

Verification

  • pnpm build
  • pnpm check — clean (type-check, oxlint, oxfmt)
  • pnpm test:native — 151 passed
  • pnpm test — 58 files passed, 805 tests passed, 3 skipped
  • node --expose-gc ./node_modules/vitest/vitest.mjs test/lock-tracker.test.ts test/commit-teardown.test.ts — 14 passed after the final delta
  • Re-verified from a clean worktree at 65153100 (Linux, Node 26.2.0, RocksDB 11.8.1): pnpm check clean, pnpm test:native 151 passed, pnpm test 58 files / 805 passed / 3 skipped, and the two park files 14 passed.
  • Re-verified again after the rebase onto main and the napi_closing fix, at dc1bacfd (Linux, Node 26.2.0, RocksDB 11.8.1): full pnpm build, pnpm test:native 151 passed, and lock-tracker.test.ts + commit-teardown.test.ts + transaction-orphan-gc.test.ts 21 passed.
  • Re-verified after this review-feedback round at d2587a27 (Linux, Node 26.2.0, RocksDB 11.8.1): pnpm build:binding + pnpm build:bundle, pnpm check clean, pnpm test 59 files / 819 passed / 3 skipped (the one initial failure was transaction-log-crash-recovery.test.ts needing a dist bundle that did not exist in the fresh worktree; it passes once built), and lock-tracker.test.ts 12/12 in 2.93 s with the raised backstop.
  • Fails-on-base proof: the isolated timeout regression was applied to origin/main, where the child exceeded its 4-second guard and was terminated; the branch completes in under 500 ms.
  • Env-read regression check: with the value read once per process, the fork fixture still completes in 369 ms and still asserts elapsed >= 40, so both halves of the parse rule (the child's ROCKSDB_JS_PARK_TIMEOUT_MS=1 taking effect, and the 50 ms clamp) stay covered.

Refs #741

Review-Coverage: authored=claude; ran=codex,gemini; adjudicated=domain; blocked=cursor-grok(no-receipt); declined=cursor-composer; rounds=12 @ d2587a2

Human-Review-Need: 4 (decisions: timeout-consumes-a-retry-attempt, default-5000ms-and-no-opt-out, bound-at-the-park-vs-reap-the-holder, env-var-only-configuration, plain-std-thread-per-descriptor) @ d2587a2

@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 bounded park timeout (defaulting to 5000ms) for coordinated-retry commits waiting on conflicting locks, preventing indefinite hangs caused by leaked or abandoned transactions. It implements a descriptor-owned timeout thread, ensures safe cleanup of thread-safe functions during environment teardown, and adds regression tests. The feedback suggests using performance.now() instead of Date.now() in the tests to provide monotonic, high-resolution timing and avoid potential flakiness from system clock adjustments.

Comment thread test/lock-tracker.test.ts Outdated
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

get-sync.bench.ts

getSync() > random keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 24.69K ops/sec 40.49 39.13 518.705 0.107 123,473
🥈 rocksdb 2 10.93K ops/sec 91.46 87.79 31,065.105 1.22 54,669

getSync() > sequential keys - small key size (100 records)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.74K ops/sec 34.79 33.59 521.914 0.103 143,711
🥈 rocksdb 2 10.49K ops/sec 95.34 92.69 2,879.657 0.122 52,444

ranges.bench.ts

getRange() > small range (100 records, 50 range)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 24.09K ops/sec 41.50 36.45 1,775.753 0.301 120,475
🥈 rocksdb 2 16.16K ops/sec 61.87 53.28 1,079.226 0.121 80,822

realistic-load.bench.ts

Realistic write load with workers > write variable records with transaction log

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 377.99 ops/sec 2,645.579 74.58 75,424.304 14.41 756
🥈 lmdb 2 26.10 ops/sec 38,321.361 419.198 1,200,938.239 135.699 64.00

transaction-log.bench.ts

Transaction log > read 100 iterators while write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 39.80K ops/sec 25.13 11.49 20,788.79 0.847 198,993
🥈 lmdb 2 440.77 ops/sec 2,268.76 77.76 16,531.036 1.24 2,204

Transaction log > read one entry from random position from log with 1000 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 710.65K ops/sec 1.41 1.21 446.263 0.062 3,553,254
🥈 lmdb 2 448.40K ops/sec 2.23 1.15 8,118.139 0.540 2,242,008

worker-put-sync.bench.ts

putSync() > random keys - small key size (100 records, 10 workers)

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 828.90 ops/sec 1,206.419 1,026.614 5,127.862 0.828 1,658
🥈 lmdb 2 1.13 ops/sec 886,201.565 830,478.393 986,428.821 4.60 10.00

worker-transaction-log.bench.ts

Transaction log with workers > write log with 100 byte records

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 rocksdb 1 21.89K ops/sec 45.68 29.97 20,524.644 2.09 43,786
🥈 lmdb 2 837.47 ops/sec 1,194.074 92.22 9,297.709 4.98 1,675

Results from commit e99b6f4

kriszyp added a commit that referenced this pull request Aug 1, 2026
…n race

commit-teardown.test.ts crashes with SIGABRT (mutex lock failed: Invalid
argument) inside the shared commit-thread teardown fixture on Deno macOS CI.
Confirmed pre-existing and unrelated to this PR's diff by finding the
identical crash on two other unrelated PRs in the last 24h (a dependabot
bump and fix/dropped-cf-write-poisons-env), hitting both commit-thread
modes. Filed #746 to track the root cause and added a
retry for macos-latest, mirroring the existing windows-latest retry
(#695) so this pre-existing race doesn't block PR #744.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012F3qheoCwRvpjgjgu5Vc1K
kriszyp added a commit that referenced this pull request Aug 6, 2026
…n race

commit-teardown.test.ts crashes with SIGABRT (mutex lock failed: Invalid
argument) inside the shared commit-thread teardown fixture on Deno macOS CI.
Confirmed pre-existing and unrelated to this PR's diff by finding the
identical crash on two other unrelated PRs in the last 24h (a dependabot
bump and fix/dropped-cf-write-poisons-env), hitting both commit-thread
modes. Filed #746 to track the root cause and added a
retry for macos-latest, mirroring the existing windows-latest retry
(#695) so this pre-existing race doesn't block PR #744.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012F3qheoCwRvpjgjgu5Vc1K
@kriszyp
kriszyp force-pushed the fix/park-timeout-coordinated-retry branch from 1e915d5 to bf650a2 Compare August 6, 2026 23:03
kriszyp added a commit that referenced this pull request Aug 19, 2026
…n race

commit-teardown.test.ts crashes with SIGABRT (mutex lock failed: Invalid
argument) inside the shared commit-thread teardown fixture on Deno macOS CI.
Confirmed pre-existing and unrelated to this PR's diff by finding the
identical crash on two other unrelated PRs in the last 24h (a dependabot
bump and fix/dropped-cf-write-poisons-env), hitting both commit-thread
modes. Filed #746 to track the root cause and added a
retry for macos-latest, mirroring the existing windows-latest retry
(#695) so this pre-existing race doesn't block PR #744.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012F3qheoCwRvpjgjgu5Vc1K
@kriszyp
kriszyp force-pushed the fix/park-timeout-coordinated-retry branch from bf650a2 to 5b1a801 Compare August 19, 2026 02:10
@kriszyp
kriszyp marked this pull request as ready for review August 20, 2026 04:12
@kriszyp
kriszyp requested a review from cb1kenobi as a code owner August 20, 2026 04:12
Comment thread src/binding/transaction/transaction.cpp Outdated
Comment thread src/binding/transaction/transaction.cpp Outdated
Comment thread src/binding/transaction/transaction.cpp
Comment thread src/binding/database/db_descriptor.cpp Outdated
Comment thread test/lock-tracker.test.ts Outdated
Comment thread .github/workflows/pr.yml Outdated

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed all seven unresolved review threads. The PR body now records the verification results, remaining coverage gap, and explicit timeout-policy decisions for human review.

Comment thread test/lock-tracker.test.ts Outdated
Comment thread src/binding/transaction/transaction.cpp Outdated
Comment thread src/binding/transaction/transaction.cpp Outdated
Comment thread src/binding/transaction/transaction.cpp
Comment thread src/binding/database/db_descriptor.cpp Outdated
Comment thread test/lock-tracker.test.ts Outdated
Comment thread .github/workflows/pr.yml Outdated
Comment thread src/binding/transaction/transaction.cpp Outdated
Comment thread test/lock-tracker.test.ts Outdated
@cb1kenobi

Copy link
Copy Markdown
Member

Reviewed a45e8493 — no issues found in the incremental diff. This PR looks good, nice job!

Re-review scope: 88580c00..a45e8493 is a fast-forward (both heads share merge base 23f21726, verified with git range-diff — 11 commits identical, 3 new). Genuine new work: 3 commits / 3 files, +53/-38.

Both of my remaining Low findings are addressed:

  • getenv thread-safety48f985cf restores the function-local static, matching commitThreadMode()/commitDelayMs(), with the 50 ms clamp and zero-is-malformed rule inside the initializer. Confirmed the only consumer of the env var is a spawned child process (test/lock-tracker.test.ts:195), so nothing depended on the per-park read; lock-tracker still runs 12/12 in 3.32s.
  • Coverage claim87e7378c + a45e8493 correct it rather than overstating. I re-ran the mutation at this head to check: toJoin.join()toJoin.detach() in ParkTimeoutRegistry::shutdown(), rebuilt, mutation confirmed in the artifact (nm -u db_descriptor.o shows std::__1::thread::detach(), absent from the clean build), still 12/12 green. The corrected comment matches that result.

Re-verified from the prior round, since a later commit could have reintroduced it:

  • The PurgeIfUnreferenced-under-writerMutex_ self-deadlock is still structurally impossible here. nm -u transaction.o shows 42 undefined rocksdb_js:: symbols and zero DBRegistry::; PurgeIfUnreferenced is absent. Validated with a negative control — re-injecting the call into fireOnce makes the symbol appear (42 → 43) and a marker string land in both the .o and the .node; reverting removes both. Note this is symbol-level and structural evidence, not a dynamic repro.
  • Lock ordering is unchanged: txnsMutex → writerMutex_ already exists at the merge base (cancelForDB under txnsLock), and ParkTimeoutRegistry's mutex remains a leaf, so there is no ordering constraint against Serialize database destruction with concurrent opens #787 in either direction.

Test results at this head: lock-tracker 12/12 (3.32s), full suite 807 passed / 1 skipped, CI 24 pass / 2 skipping. Against the merge base, the fork-based timeout regression fails (child SIGTERM'd at its 4s guard) while the close-path test passes — so the coverage split is exactly what the corrected comment now describes.


Generated by Barber AI

kriszyp and others added 14 commits August 24, 2026 23:35
A commit that loses a conflict under coordinatedRetry parks on the
conflicting holder's VT LockTracker and only resolved RETRY_NOW when that
lock's last holder released. If the holder never releases (a leaked/
abandoned transaction, or a wake lost to the #741 double-release
corruption), the commit promise never settled -- in production
(harper#2001) a worker's write path was disabled for 5+ hours.

Add a bounded wait (ROCKSDB_JS_PARK_TIMEOUT_MS, default 3000ms) that
resolves RETRY_NOW even if the holder never releases. The timeout and the
wake callback race through a shared atomic flag (independent of the
per-park heap state so a late loser never touches memory the winner may
have already freed), guaranteeing exactly-once resolve regardless of
which fires first.

Refs #741

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Addresses BLOCK findings from cross-model pre-push review of cc3c205
(codex+gemini+grok+harper-domain, independent=true):

- blocker: a detached std::thread per park was an unbounded resource
  cliff on exactly the contention/abandoned-holder path parks are dense
  on. Replaced with one park-timeout thread per DBDescriptor (lazily
  started, joined at finishClose() like commitWorker) tracking every
  outstanding deadline -- DBDescriptor::scheduleParkTimeout /
  runParkTimeoutLoop / fireParkTimeout in db_descriptor.{h,cpp}.

- blocker: the detached thread's tsfn call could race Node freeing a
  torn-down worker env's threadsafe functions (AGENTS.md: "a per-commit
  tsfn acquire is NOT sufficient -- env teardown does not honor tsfn
  acquire counts"). Each park is now tracked by env in the descriptor
  and releaseParkTimeoutsByEnv cancels a dying env's pending parks from
  the same module env-cleanup hook that already scrubs commit
  completions (DBRegistry::ReleaseParkTimeoutsByEnv, wired in
  binding.cpp), releasing the tsfn without calling it.

- major (unchecked std::thread ctor throw -> process abort): moot with
  a single lazily-started thread per descriptor instead of one per park.

- minor: ROCKSDB_JS_PARK_TIMEOUT_MS now parsed with strtoul + explicit
  negative/overflow rejection instead of atoi, so a malformed value
  falls back to the safe default instead of silently reintroducing an
  unbounded hang (negative wrapping through unsigned) or an immediate-
  fire spin (non-numeric -> 0).

- major (test couldn't distinguish the timeout branch from the
  already-existing !parked fast path): added a lower bound
  (elapsed >= 2500ms) alongside the upper bound.

- nit: trimmed comments that narrated mechanics/addressed the reviewer.

Not fixed in this pass (documented in AGENTS.md as a known gap):
LockTracker::wakeCallbacks has no removal API, so a permanently-
abandoned holder's wake registrations still accumulate across retries
for the life of the incident -- deferred rather than risk an
unreviewed change to verification_table.cpp's concurrency invariants.
Also not fixed: a timeout-triggered RETRY_NOW consumes a maxRetries
attempt like any other, so a legitimately slow (not abandoned) holder
can exhaust the retry budget before it would have woken naturally --
an accepted tradeoff per the original task ("a spurious early RETRY_NOW
is harmless"), flagged for follow-up in the PR description.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Round-3 response to the second BLOCK verdict (codex+gemini+grok+
harper-domain, independent=true) on the descriptor-owned redesign:

- blocker: the LockTracker wake closure captured a raw DBDescriptor*.
  A park can end up registered on a tracker installed by a *different*
  database on a colliding VT slot (lockSlotForWrite joins an existing
  tracker without retagging its dbId), so that lock's eventual release
  wakes a park whose own descriptor may have already closed and been
  destroyed -- cancelForDB only wakes trackers tagged with its own
  vtEpoch, so it can't be relied on to have resolved a foreign-dbId
  park first. Now captures a std::weak_ptr<DBDescriptor> and .lock()s
  it; a failed lock means shutdownParkTimeouts (below) already
  resolved the park.

- major: retryNowCallJs had no `env == nullptr` guard, unlike
  commitCompletionCallJs -- Node drains a tearing-down env's tsfn
  queue by invoking call_js_cb with a null env, and this change adds a
  second producer onto that tsfn's queue. Added the same guard.

- major: a park's identity was its ParkTimeout*. Since
  LockTracker::wakeCallbacks has no removal API, a stale closure can
  outlive its entry, and the freed heap address could be reused by an
  unrelated later park -- an ABA hazard that could resolve the wrong
  commit's promise. Identity is now a monotonic uint64_t id.

- major: shutdownParkTimeouts only stopped and joined the thread; any
  park still pending (notably one on a foreign-dbId tracker per the
  blocker above) was neither fired nor released -- an unresolved
  promise, or a leaked tsfn, exactly the class of bug this PR exists
  to fix, now happening at DB-close time instead. It now drains
  (call+release) everything remaining after the join, and both
  finishClose() and the destructor call it (idempotently), matching
  commitWorker's own belt-and-suspenders shutdown discipline.

- major: the default (3000ms) plus maxRetries (default 3) capped a
  *coordinated* wait at ~9s, and every timeout consumed a retry
  attempt indistinguishably from a real wake -- a holder that
  legitimately holds a VT write intent for a few seconds (large batch
  commit, slow fsync under compaction backpressure) could exhaust the
  retry budget and abandon a write that would previously have parked
  and succeeded. Raised the default to 5000ms, the top of this task's
  requested 2-5s range, for maximum headroom within scope; not fully
  solved (would need the native/JS layers to distinguish a timeout-
  triggered RETRY_NOW from a genuine wake so it doesn't consume
  maxRetries budget -- flagged as a follow-up in the PR).

- minor: `ROCKSDB_JS_PARK_TIMEOUT_MS=" -1"` (leading whitespace) bypassed
  the negative check and could wrap to ~49 days on a 32-bit build.
  Skips leading whitespace before checking for '-'. Also folded in
  the "explicit 0 spins" minor from round 1/2 (parsed == 0 now falls
  back to the default too, alongside non-numeric/negative/overflow).

Also updated the AGENTS.md note and the test's timing bounds
(>=4500ms, <10000ms) for the 5000ms default.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Round-4 response to the third review (codex+gemini+grok+harper-domain,
independent=true) -- no confirmed blocker this round; two majors:

- The inline-resolve guard was `if (descriptor && parkId == 0)`.
  DBHandle::close() can reset `descriptor` to null concurrently while
  another handle keeps the DB open; in that case parkId is 0 but the
  guard was false, so the code fell through to registering a wake
  callback with no timeout thread behind it -- an unbounded park,
  exactly the harper#2001 class this PR exists to close. Changed to
  `if (parkId == 0)`: the inline-resolve path only needs tsfn/fired,
  not descriptor.

- shutdownParkTimeouts() moved pending entries out of parkTimeouts and
  called their tsfns *after* releasing parkTimeoutMutex, defeating the
  barrier releaseParkTimeoutsByEnv depends on for env-teardown safety
  (runParkTimeoutLoop fires under the lock; the drain didn't). A
  concurrent releaseParkTimeoutsByEnv for a dying env could find
  nothing to cancel (already pulled out) while shutdown is mid-call on
  that exact env's tsfn, racing Node freeing it. Now drains under the
  same lock it takes to stop the thread, then joins outside it (joining
  while holding the lock would deadlock the loop's cv.wait_until).

Also: check napi_create_threadsafe_function's status instead of using
an uninitialized handle on failure (pre-existing gap, widened by this
change storing the handle for a background thread to call later rather
than using it immediately); corrected a stale comment claiming pending
parks at descriptor-close "need not fire" (they do, for the
foreign-dbId case the weak_ptr fix addresses); trimmed several
narrating comments flagged across all three review rounds.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Round-5 response to the fourth review (codex+gemini+grok+harper-domain,
independent=true). No blocker survived that round's domain adjudication
either -- three raw "blocker" findings were downgraded to major/minor
after tracing the actual mechanism -- but it found real new issues:

- The LockTracker wake closure's weakDescriptor.lock() is a transient
  extra ref on the descriptor, exactly the shape that can make a
  racing close()'s PurgeIfUnreferenced observe use_count() > 1 and
  skip the purge (#672's exact class of bug --
  already fixed for backup/checkpoint state, missed here). Now retries
  PurgeIfUnreferenced after releasing the ref, matching AsyncBackupState.

- runParkTimeoutLoop's wait_until bound a const reference into a
  ParkTimeout the mutex-releasing wait could let another thread erase
  (a real wake racing the timeout) -- a freed-memory read once the
  wait re-checked the deadline. Copies the deadline into a local first.

- fireParkTimeout's O(N) vector scan+erase runs inside LockTracker::
  wake(), which holds the *process-global* VT writerMutex_ -- under a
  contention burst (exactly what abandoned holders cause), every other
  database's write-intent registration serializes behind this one's
  O(N) work. parkTimeouts is now an unordered_map<id, ParkTimeout>,
  making fireParkTimeout's lookup O(1); the deadline scan (unaffected
  by the global lock) stays a linear pass, which is fine at realistic N.

- scheduleParkTimeout set parkTimeoutThreadStarted before constructing
  the thread; a throwing ctor (thread/resource exhaustion) both
  unwinds through an N-API callback with no catch (process abort) and,
  if caught, permanently disables ever starting a timeout thread again
  (silent regression to an unbounded park). Now wraps construction in
  try/catch, sets the flag only on success, and returns 0 (caller
  resolves inline) on failure so the next park retries.

Also: added a wall-clock upper bound to an existing wake-path test
(Coordinated retry Phase 3) so a broken LockTracker::wake() can't hide
behind the #741 timeout and still pass; updated the AGENTS.md note on
wakeCallbacks growth (now per-retry, not per-incident, since each
timeout causes a re-park); further comment trims flagged across all
four review rounds, including one describing removed (atoi) code.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
…n race

commit-teardown.test.ts crashes with SIGABRT (mutex lock failed: Invalid
argument) inside the shared commit-thread teardown fixture on Deno macOS CI.
Confirmed pre-existing and unrelated to this PR's diff by finding the
identical crash on two other unrelated PRs in the last 24h (a dependabot
bump and fix/dropped-cf-write-poisons-env), hitting both commit-thread
modes. Filed #746 to track the root cause and added a
retry for macos-latest, mirroring the existing windows-latest retry
(#695) so this pre-existing race doesn't block PR #744.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012F3qheoCwRvpjgjgu5Vc1K
…stry

A LockTracker wake callback is invoked inline by LockTracker::wake() with the
process-global VT writerMutex_ held, so DBRegistry::PurgeIfUnreferenced from
that callback could claim the purge and run finishClose() -> cancelForDB() ->
a second lock of the same non-recursive mutex, wedging every database's
write-intent path -- the harper#2001 symptom this bound exists to fix. Move
the park bookkeeping into a standalone ParkTimeoutRegistry owned by the
descriptor through a shared_ptr, and have the wake closure hold a weak_ptr to
that instead of to the descriptor: fire() touches one mutex and one map, the
transient .lock() no longer perturbs the use_count PurgeIfUnreferenced decides
on, and the purge retry (and its databasesMutex acquisition under the VT lock)
is gone.

Also from review:
- index parks by deadline as well as id, so the timeout thread stops
  re-scanning the whole map on every wakeup while fire() waits on that mutex
  under the global VT lock, and only notify when the new park is the earliest
- read ROCKSDB_JS_PARK_TIMEOUT_MS per park instead of once per process, and
  clamp positive values up to a 50ms floor (0 still falls back to the default)
- cover db.close() with a commit still parked
- use performance.now() for the elapsed-time assertions
- gate the macOS Deno retry on rocksdb-js#746's crash signature so an
  unrelated macOS regression still fails on the first run

Refs #741

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Run the bounded-park regression in an isolated child with the timeout set before the addon loads, covering the 50ms floor without adding five seconds to every suite. Keep the close-with-live-park coverage and scope the pre-existing Deno/macOS retry to commit-teardown instead of re-running the full Deno suite.\n\nRefs #741\n\nCo-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
::getenv is not safe against a concurrent ::setenv, and a park runs on
whichever env's JS thread owns the transaction while a `process.env` write
on the main thread goes through uv_os_setenv -> setenv(3), which may
reallocate `environ`. The per-park read introduced in 2a5ae5b put that
race on every coordinated-retry commit.

It bought nothing in exchange: worker-thread `process.env` writes never
reach ::getenv (core/test_seam.h), so the only way to vary the value is a
child process started with it already set -- which is what the #741
regression fixture does. Restores the function-local static used by
commitThreadMode()/commitDelayMs() in the same file, keeping the 50 ms
clamp and the zero-is-malformed rule inside the initializer.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
The comment claimed the close-with-a-live-park test covered the timeout
thread being joined. It does not: ParkTimeoutRegistry::shutdown() resolves
every pending park under its mutex before joining, so the promise settles
on time whether the thread is joined or detached -- confirmed locally by
mutating join() to detach(), rebuilding and re-running the file (12/12
green). A detached runLoop() costs a freed-registry touch, which is a
teardown/ASan-shaped failure a deadline assertion cannot see. The test also
passes at the merge base, so it is close-path smoke coverage rather than a
regression test for this change.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Independent review flagged both new blocks as narration: the parkTimeoutMs
docblock restated rationale AGENTS.md already carries, and the close test's
comment read as a mutation-testing changelog aimed at a reviewer rather than
the next reader. Keeps what the code cannot say -- why the env read is
once-per-process, and which two close-path behaviours a deadline assertion
cannot discriminate.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Kris Zyp and others added 2 commits August 24, 2026 23:37
The reply resolving the review thread on this file said the elapsed-time
assertions had moved to performance.now(), but the wall-clock bound added
to the concurrent-conflict test still read Date.now() on both ends. An NTP
step between the two reads is enough to push `elapsed` past the 3000ms
bound (or negative) with nothing wrong in the wake path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ParkTimeoutRegistry::resolve() and the no-timeout-thread degrade path
in transaction.cpp called napi_release_threadsafe_function
unconditionally after napi_call_threadsafe_function, discarding the
status. If the call returns napi_closing (env teardown racing this
resolve), the tsfn may already be mid-teardown on Node's side, and
releasing it again is a use-after-close. Guard the release on
status == napi_ok, matching the pattern already used elsewhere in
this file (dispatchCommitCompletion/releaseCommitCompletionsByEnv).

Found by the pre-push cross-model review during the #744 rebase.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
@kriszyp
kriszyp force-pushed the fix/park-timeout-coordinated-retry branch from 6515310 to dc1bacf Compare August 25, 2026 06:09
Comment thread test/lock-tracker.test.ts Outdated
Comment thread src/binding/transaction/transaction.cpp Outdated
Comment thread AGENTS.md
@cb1kenobi

cb1kenobi commented Aug 25, 2026

Copy link
Copy Markdown
Member

Reviewed dc1bacfd — no issues found in the incremental diff. This PR looks good, nice job!

Re-review scope — derived with git range-diff, not the compare API. Merge bases computed separately per head: merge-base 65153100 origin/main = 23f21726, merge-base dc1bacfd origin/main = da0ef135. They differ, so this was a rebase onto 14 new main commits, not a fast-forward — which means the delta had to be checked for content that came from neither side. Three independent methods agree it is clean:

  1. git range-diff 23f21726..65153100 da0ef135..dc1bacfd — 15 commits map 1:1, 1 appended. The four marked ! (15bbcb77, 15088793, d09d9ff7, 94906e72) differ only in AGENTS.md/transaction.cpp context lines that main moved.
  2. git merge-tree --write-tree 65153100 da0ef135 conflicts in AGENTS.md and transaction.cpp. Diffing that auto-merge tree against dc1bacfd^ shows the resolution keeps main's wrapperCollectedclose() block and this PR's relocation of the RetryNowContext transfer — both derivable from the two sides, nothing foreign introduced.
  3. Comparing the PR's own net patch (added/removed lines only) between the two ranges differs by exactly 4 lines, all of them the AGENTS.md invariant renumber (12 → 13, 13 → 14) forced by main inserting its own invariant 12.

Genuine new work: 1 commit, 2 files, +13/−4dc1bacfd "don't release a closing park timeout tsfn twice".

That commit is sound. Guarding napi_release_threadsafe_function on the preceding napi_call_threadsafe_function status is the pattern already used at db_descriptor.cpp:1132-1136 and :1172-1176; the == napi_ok form is behaviourally identical to != napi_closing here because the park tsfn is created with max_queue_size = 0 (unlimited), so napi_queue_full is unreachable, and the exactly-once fired CAS means the thread count cannot reach zero before this call — so napi_closing at this point really only means env teardown, where Node destroys the tsfn itself and the skipped release cannot leak. (Small nit on the commit message only: dispatchCommitCompletion/releaseCommitCompletionsByEnv don't actually implement this pattern — the first never releases, the second releases without calling. lockReleaseByKey is the real precedent.)

Prior findings re-checked against this head, in the artifact rather than from thread state:

  • Critical (wake-path re-entry) — still fixed. fireOnce still captures only weak_ptr<ParkTimeoutRegistry> + weak_ptr<std::atomic<bool>> + parkId and calls fire(id). nm -u build/Release/obj.target/rocksdb-js/src/binding/transaction/transaction.o | c++filt: 175 undefined / 43 rocksdb_js:: / 0 DBRegistry::. The only registry symbols this TU references are ParkTimeoutRegistry::schedule and ::fire. The 174→175 / 42→43 drift from the prior head is exactly one symbol, TransactionHandle::close(), which is main's code arriving via the rebase. Negative control re-run through a real make BUILDTYPE=Release (not node-gyp build, which can silently no-op): re-injecting a DBRegistry::PurgeIfUnreferenced call with a unique marker takes the counts to 176/44/1 and puts the marker in both the .o and the .node; reverting restores a byte-identical symbol set and removes the marker from both. This is source + symbol-graph evidence with a validated detector — not a dynamic repro; forcing the interleaving would require mutating the code under review.
  • Low (join-at-close uncovered) — still open, and now conclusively so. toJoin.join()toJoin.detach() in ParkTimeoutRegistry::shutdown(), rebuilt and confirmed in the artifact (nm -u db_descriptor.o gains std::__1::thread::detach(), absent from the clean build): lock-tracker 12/12 green, and this time I also ran the whole suite against the mutated build — 60 files, 814 passed / 1 skipped, all green. So nothing anywhere in the suite kills it. The test comment at test/lock-tracker.test.ts:219-227 says exactly this, so the claim matches the measurement; the gap is documented, not misstated.
  • Medium (a timeout-driven RETRY_NOW spends a coordinatedRetry attempt) — unchanged by this push. src/database.ts and src/transaction.ts remain untouched by the entire PR at this head, parkTimeoutMs() is still the function-local static with the 5000 ms default, and no opt-out, backoff, or change to the retry accounting was added. It stays where it was: a documented policy decision for the human reviewer, not something for you to action.

Testing. Local build at dc1bacfd: lock-tracker 12/12 in 3.11s, lock-tracker + commit-teardown 14/14, full suite 60 files / 814 passed / 1 skipped. Merge base da0ef135 built from scratch for comparison (nm confirms zero ParkTimeoutRegistry symbols in its .node): the same two files give 12/12. The PR's own test files are byte-identical between 65153100 and dc1bacfd — the test/ delta between those heads is all main's (transaction-orphan-gc.test.ts, stats.test.ts, a fixture), which is also what moves the suite total from 808 to 815 — so the earlier fails-on-base result for the fork-based timeout regression carries forward unchanged. CI at this head: 24 pass / 2 skipping — the full matrix is green.

One detection note for anyone repeating the join mutation: the linked .node carries an undefined std::__1::thread::detach() even on a clean build — it comes from deps/rocksdb/lib/librocksdb.a, not from this project (no project object references it, and there is no .detach() anywhere in src/). Only the nm -u on db_descriptor.o discriminates. That joins the two artifacts already known here: the residual join() traced to commit_worker.h:110, and the DEBUG_LOG marker being a release-build no-op (debug.h:39).

Generated by Barber AI

kriszyp and others added 3 commits August 25, 2026 00:46
…e docs

Addresses the three open review threads on #744.

The `parkId == 0` branch owned `fired` outright: every path that returns 0
from `ParkTimeoutRegistry::schedule` does so before the entry is published,
and the LockTracker wake callback is not built until after this branch
breaks out, so the compare-exchange could never fail and its comment
described a race that cannot occur.

The fixture's own `elapsed` assertion is the deadline check; the parent's
kill timer only needs to catch a true hang, and at 4000 ms it also had to
cover node boot, type-stripping, addon load and the seed writes -- so a slow
runner and a regression both surfaced as SIGTERM with the fixture's
diagnostic never thrown. Raised to 20 s, under vitest's 30 s testTimeout so
the kill path still dumps the child's stderr.

README documented the coordinated-retry wait as unbounded, which the park
timeout made wrong: the wait now ends after ROCKSDB_JS_PARK_TIMEOUT_MS and
consumes a retry attempt, so a holder blocked longer than roughly
maxRetries x that timeout abandons rather than waiting.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
Documenting the knob as operator-facing without its parsing rules left `0` --
the value an operator reaches for first to restore the old blocking wait --
silently running at the 5000ms default.

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

@cb1kenobi cb1kenobi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested. Looks good!

@kriszyp
kriszyp merged commit fd4e001 into main Aug 25, 2026
26 checks passed
@kriszyp
kriszyp deleted the fix/park-timeout-coordinated-retry branch August 25, 2026 12:06
kriszyp added a commit that referenced this pull request Aug 25, 2026
Rebasing this branch onto main resolved the db_descriptor.cpp conflict by
taking the branch side wholesale, which deleted every hunk main's #744
(fd4e001) had added to that file while leaving the class declaration in
db_descriptor.h and its seven uses in transaction/transaction.cpp. The
binding then had four unresolved symbols: dlopen fails outright on
Linux/Windows (every Linux, Windows, Bun, Deno, benchmark and stress job
on the last CI run), and macOS links under -undefined dynamic_lookup and
crashes on close() instead.

Re-applies main's four hunks: the <system_error> include, the
ParkTimeoutRegistry method definitions, both parkTimeouts->shutdown()
calls (the destructor safety net and the finishClose() one that exists
because a park can sit on a foreign-dbId tracker cancelForDB() will not
wake), and the comment recording why finishClose()'s flush() keeps the
waiting default. No tiered-storage change is touched; flush(bool
allowWriteStall) was already restored by a2965c0.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
kriszyp added a commit that referenced this pull request Aug 28, 2026
`Check` was red on `oxfmt --check AGENTS.md`, and nothing in this branch's
commits could fix it: GitHub builds a PR from the merge of the branch into its
base, `main` gained its own invariant 17 in #780, and the merged list read
17, 18, 17. The renumbering that clears it can only be written where both
texts exist, so it has to be part of an integration.

Integrating by merge rather than rebase. The 3-way merge is clean in all seven
overlapping files, where replaying 22 commits across `db_descriptor.cpp`,
`db_registry.cpp`, `store.ts` and `load-binding.ts` is the operation that
already dropped #744's hunks from this branch once (66cc2ee). #780 itself
merged `main` into its branch three times, so this is the prevailing shape here.

AGENTS.md keeps main's invariant at 17 and moves this branch's two to 18 and
19, rather than taking the order git's auto-merge produced (ours first, main's
renumbered to 19). That is what a rebase would produce and what the review
thread asked for, so the file's content no longer depends on which integration
path this branch eventually takes. The result is a pure insertion over main --
`git diff origin/main -- AGENTS.md` has zero deletions -- and oxfmt 0.64.0
accepts it.

Verified the merge did not repeat the dropped-hunk failure: `CloseTransactionsByEnv`,
`closeTransactionsByEnv`, `ParkTimeoutRegistry`, `onWrapperCollected` and
`wrapperCollected` all match their `origin/main` occurrence counts;
`src/binding/transaction/`, `binding.cpp` and `napi/async.h` are identical to
main; `db_descriptor.h`, `db_registry.h` and `load-binding.ts` are pure
additions; and all 35 deletions in `git diff origin/main HEAD` are this
feature's own (the `PersistedCompression` -> `PersistedCFOptions` widening,
blob defaults moving into `buildColumnFamilyOptions`, `DestroyDB` taking a real
layout, and doc rewrites).

Refs #767

Co-Authored-By: Claude Opus <noreply@anthropic.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.

Worker-env teardown destroys transactions on the shared DBDescriptor, corrupting the heap under concurrent commits

2 participants