fix(transaction): bound the coordinated-retry park with a descriptor-owned timeout - #744
Conversation
There was a problem hiding this comment.
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.
📊 Benchmark Resultsget-sync.bench.tsgetSync() > random keys - small key size (100 records)
getSync() > sequential keys - small key size (100 records)
ranges.bench.tsgetRange() > small range (100 records, 50 range)
realistic-load.bench.tsRealistic write load with workers > write variable records with transaction log
transaction-log.bench.tsTransaction log > read 100 iterators while write log with 100 byte records
Transaction log > read one entry from random position from log with 1000 100 byte records
worker-put-sync.bench.tsputSync() > random keys - small key size (100 records, 10 workers)
worker-transaction-log.bench.tsTransaction log with workers > write log with 100 byte records
Results from commit e99b6f4 |
…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
…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
1e915d5 to
bf650a2
Compare
…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
bf650a2 to
5b1a801
Compare
kriszyp
left a comment
There was a problem hiding this comment.
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.
|
Reviewed Re-review scope: Both of my remaining Low findings are addressed:
Re-verified from the prior round, since a later commit could have reintroduced it:
Test results at this head: — |
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>
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>
6515310 to
dc1bacf
Compare
|
Reviewed Re-review scope — derived with
Genuine new work: 1 commit, 2 files, +13/−4 — That commit is sound. Guarding Prior findings re-checked against this head, in the artifact rather than from thread state:
Testing. Local build at One detection note for anyone repeating the join mutation: the linked Generated by Barber AI |
…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>
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>
`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>
Summary
Fixes #741 by bounding every coordinated-retry park. A commit blocked behind a holder that never releases now resolves
RETRY_NOWafterROCKSDB_JS_PARK_TIMEOUT_MS(default 5000 ms) instead of leaving the write path hung indefinitely.DBDescriptorandDBRegistry: 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).ROCKSDB_JS_PARK_TIMEOUT_MSonce per process behind a function-local static, matchingcommitThreadMode()/commitDelayMs()in the same file: a park runs on whichever env's JS thread owns the transaction, and::getenvis not safe against aprocess.envwrite on another thread (parkTimeoutMs()).ParkTimeoutRegistry::resolveand the no-timeout-thread degrade) onnapi_call_threadsafe_function's status before releasing: anapi_closingresult 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.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.ERR_TRANSACTION_ABANDONEDat roughlymaxRetries xthe timeout, and that0/negative/malformed values select the 5000 ms default rather than disabling the bound (README retry section).ParkTimeoutRegistry::scheduledoes so before the entry publishesfired, and thefireOncewake 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).elapsedassertion 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 asSIGTERMwith the fixture's diagnostic never thrown. 20 s stays under vitest's 30 stestTimeoutso the kill path still dumps the child's stderr (backstop).origin/main(tests).For the human reviewer
The implementation deliberately preserves the task's policy choices:
maxRetries × timeout; operators can raise the timeout but cannot disable the bound with0. This is now stated in the README rather than only in the code, so it is a documented contract from here on.LockTrackerhas 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 retriesa hot key produces. Atransaction:parkTimeoutglobal event (thetransactionLog:warningshape) 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 misconfiguredROCKSDB_JS_PARK_TIMEOUT_MSis still silent at runtime, but the README now says which values are ignored, so an operator who sets0can find out why it had no effect.db.transaction()orchestration is not covered end-to-end.test/fixtures/fork-park-timeout.mtsdrivesTransaction.commit()directly and asserts the nativeRETRY_NOW. I tracedsrc/database.ts:1014-1027and 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'ssetIntervalkeep-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 aroundParkTimeoutRegistry(// 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 theParkTimeoutRegistryclass 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 VTwriterMutex_is held, so a wake can wait behindrunLoop()/shutdown()draining a batch of due parks. Everything done under that mutex is non-blocking —napi_call_threadsafe_functionisnapi_tsfn_nonblocking, andshutdown()'sjoin()is deliberately outside the lock — and N is bounded by one descriptor's concurrent coordinated-retry commits. The suggestedtry_lockwould silently drop a real early wake and fall back to the full timeout, which is worse than the latency it avoids.The first
::getenvinparkTimeoutMs()can still race aprocess.envwrite. True, and exactly the exposurecommitThreadMode()/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_allocbetween the ref transfer and the park registration would terminate the process. Pre-existing: before this change the same window ended inaddWakeCallback()pushing onto astd::vector<std::function>, which can throw identically, and no N-API callback in this codebase is exception-guarded. Worth notingParkTimeoutRegistry::schedule()does not leak on a partial insert — an orphaned deadline whose park is missing is erased byrunLoop()'s existingparks.find() == end()branch.retry: 1oncommit-teardown.test.tscan 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); askipIfwould remove the crash guard on that platform outright rather than weaken it.retryNowFinalizedeletes its refs without anenv == nullptrguard — checked and declined as a false positive. The NULL-envconvention is documented fornapi_threadsafe_function_call_jsonly; the finalize callback runs on the JS thread with a live env when the thread count hits zero, which is the contractevent_emitter.cpp:216-219already 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()inParkTimeoutRegistry::shutdown()leaves the file 12/12 green, becauseshutdown()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,
getenvthread-safety, comment accuracy, the wall-clock assertion above, and — in round 8, rebasing onto latestmain— thenapi_closinguse-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 (confirmingschedulereturns 0 only before it movesfiredinto an entry, and thatfireOnceis 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-reviewrefuses any diff that touches agent instructions, and this one editsAGENTS.md.Rebase note (rounds 8–9): rebased onto latest
origin/main(da0ef135) with no semantic conflicts — the only overlap was an independent upstreamAGENTS.mdinvariant 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 thenapi_closingfix above.Verification
pnpm buildpnpm check— clean (type-check, oxlint, oxfmt)pnpm test:native— 151 passedpnpm test— 58 files passed, 805 tests passed, 3 skippednode --expose-gc ./node_modules/vitest/vitest.mjs test/lock-tracker.test.ts test/commit-teardown.test.ts— 14 passed after the final delta65153100(Linux, Node 26.2.0, RocksDB 11.8.1):pnpm checkclean,pnpm test:native151 passed,pnpm test58 files / 805 passed / 3 skipped, and the two park files 14 passed.mainand thenapi_closingfix, atdc1bacfd(Linux, Node 26.2.0, RocksDB 11.8.1): fullpnpm build,pnpm test:native151 passed, andlock-tracker.test.ts+commit-teardown.test.ts+transaction-orphan-gc.test.ts21 passed.d2587a27(Linux, Node 26.2.0, RocksDB 11.8.1):pnpm build:binding+pnpm build:bundle,pnpm checkclean,pnpm test59 files / 819 passed / 3 skipped (the one initial failure wastransaction-log-crash-recovery.test.tsneeding adistbundle that did not exist in the fresh worktree; it passes once built), andlock-tracker.test.ts12/12 in 2.93 s with the raised backstop.origin/main, where the child exceeded its 4-second guard and was terminated; the branch completes in under 500 ms.elapsed >= 40, so both halves of the parse rule (the child'sROCKSDB_JS_PARK_TIMEOUT_MS=1taking 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