Serialize database destruction with concurrent opens - #787
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces robust database lifecycle management for RocksDB JS bindings. It implements a timed-wait mechanism (lifecycleWaitSeconds) for open, destroy, and shutdown operations to prevent concurrent lifecycle conflicts. It also introduces a "quarantine" state for database paths when a native close, flush, compaction, or physical directory cleanup fails, preventing subsequent opens until the cleanup is retried via destroy() or shutdown(). Additionally, it ensures that in-flight operations (like backups and checkpoints) are safely awaited before destruction, and that thread-affine N-API references are cleaned up safely. There are no review comments, so I have no feedback to provide.
📊 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 35e8d64 |
|
Reviewed Re-review of the one new commit since
Also checked and cleared: the dropped Verification: full suite 55 files, 773 passed / 1 skipped / 0 failed; targeted destroy + ranges 64/64 including the new One merge-ordering note, not a defect in this PR: — |
…GetCount against concurrent close finishClose() cleared compactCancelRequested right after the operationsInFlight drain, but an async compact() releases its OperationGuard at setup handoff and is not awaited until the closables sweep — so it can still be running after the drain returns, and clearing the token there left it able to stall teardown (and every concurrent open on the path) indefinitely. Keep the token armed for finishClose()'s whole duration instead, and have the close-time compact-on-close pass opt out via a new compactRange() `cancellable` param rather than relying on the shared flag being cleared. Transaction::GetCount now takes an OperationGuard and checks isClosing() before scanning: without it, finishClose()'s drain can return immediately and the closables sweep can roll back the transaction while the count scan is parked between rows, reading freed memory. Carries the in-progress PR #787 lifecycle repair plan describing the fuller atomic-admission fix these two changes are a first slice of. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CvCKcGKG7vyhRts1Mv4Wh
f24ef7a to
5b459e4
Compare
…GetCount against concurrent close finishClose() cleared compactCancelRequested right after the operationsInFlight drain, but an async compact() releases its OperationGuard at setup handoff and is not awaited until the closables sweep — so it can still be running after the drain returns, and clearing the token there left it able to stall teardown (and every concurrent open on the path) indefinitely. Keep the token armed for finishClose()'s whole duration instead, and have the close-time compact-on-close pass opt out via a new compactRange() `cancellable` param rather than relying on the shared flag being cleared. Transaction::GetCount now takes an OperationGuard and checks isClosing() before scanning: without it, finishClose()'s drain can return immediately and the closables sweep can roll back the transaction while the count scan is parked between rows, reading freed memory. Carries the in-progress PR #787 lifecycle repair plan describing the fuller atomic-admission fix these two changes are a first slice of. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CvCKcGKG7vyhRts1Mv4Wh
5b459e4 to
542b058
Compare
|
Reviewed Re-review of the one new commit since
Also re-verified at object-code level:
— |
…GetCount against concurrent close finishClose() cleared compactCancelRequested right after the operationsInFlight drain, but an async compact() releases its OperationGuard at setup handoff and is not awaited until the closables sweep — so it can still be running after the drain returns, and clearing the token there left it able to stall teardown (and every concurrent open on the path) indefinitely. Keep the token armed for finishClose()'s whole duration instead, and have the close-time compact-on-close pass opt out via a new compactRange() `cancellable` param rather than relying on the shared flag being cleared. Transaction::GetCount now takes an OperationGuard and checks isClosing() before scanning: without it, finishClose()'s drain can return immediately and the closables sweep can roll back the transaction while the count scan is parked between rows, reading freed memory. Carries the in-progress PR #787 lifecycle repair plan describing the fuller atomic-admission fix these two changes are a first slice of. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CvCKcGKG7vyhRts1Mv4Wh
542b058 to
3cdd9f9
Compare
…GetCount against concurrent close finishClose() cleared compactCancelRequested right after the operationsInFlight drain, but an async compact() releases its OperationGuard at setup handoff and is not awaited until the closables sweep — so it can still be running after the drain returns, and clearing the token there left it able to stall teardown (and every concurrent open on the path) indefinitely. Keep the token armed for finishClose()'s whole duration instead, and have the close-time compact-on-close pass opt out via a new compactRange() `cancellable` param rather than relying on the shared flag being cleared. Transaction::GetCount now takes an OperationGuard and checks isClosing() before scanning: without it, finishClose()'s drain can return immediately and the closables sweep can roll back the transaction while the count scan is parked between rows, reading freed memory. Carries the in-progress PR #787 lifecycle repair plan describing the fuller atomic-admission fix these two changes are a first slice of. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CvCKcGKG7vyhRts1Mv4Wh
107b216 to
ab5cd96
Compare
cb1kenobi
left a comment
There was a problem hiding this comment.
Please rebase with main and resolve the merge conflicts.
Co-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>
Co-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>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
- shutdown() no longer permanently throws once a destroy-cleanup tombstone exists; it stays non-destructive (per AGENTS.md) and skips the entry instead of poisoning every later call - binding.cpp always releases global listener threadsafe functions, even when DBRegistry::Shutdown() throws - compactSync() cancels its manual compaction when finishClose() is draining in-flight operations, instead of blocking the untimed drain (and cascading OpenDB timeouts) for the compaction's full duration - Iterator Return()/Throw() are idempotent again on an already-closed iterator, matching close() elsewhere, instead of throwing over a clean loop exit or the caller's real error - narrow the AGENTS.md VT fast-path claim to what's actually true - log the retained path on a benchmark teardown failure instead of leaking it silently - add a deterministic test for iteratorMutex serializing Next() against a foreign forced close, plus a return()/throw() idempotency unit test
- DBIterator::Next() no longer pays a getenv() scan per row for the ROCKSDB_JS_ITERATOR_NEXT_DELAY_MS seam; it is snapshotted once in initializeTestSeams() alongside the close-failure flags. Next() returns one row per call, so this was ~a quarter of the per-row getRange cost for a seam that is unset in production. - Extract closeClaimedDescriptors() in db_registry.cpp: the finishClose() -> erase-or-quarantine -> notify -> emit tail was copied four times (PurgeIfUnreferenced, DestroyDB, PurgeAll, Shutdown), each handling closeError/closeRetrying slightly differently. Only the claim predicate genuinely differs per caller, so that is all that is left at the call sites. The completed-but-errored policy that had drifted is now one named option: fatal for shutdown()/PurgeAll() because dropping a failed close-time flush would hide possible data loss, non-fatal for destroy(), whose caller asked for the data to be deleted anyway. - getKeysCount() was the remaining unbounded OperationGuard holder that finishClose()'s untimed drain could not cancel. The scan now polls isClosing() per row and reports the abort instead of a partial count, on both the database and transaction paths, so a foreign destroy() is no longer blocked for the length of the range (and concurrent OpenDB() calls for that path no longer time out behind it). The comment claiming compaction was the only unbounded in-flight op is corrected. Co-Authored-By: Claude Opus <noreply@anthropic.com>
…GetCount against concurrent close finishClose() cleared compactCancelRequested right after the operationsInFlight drain, but an async compact() releases its OperationGuard at setup handoff and is not awaited until the closables sweep — so it can still be running after the drain returns, and clearing the token there left it able to stall teardown (and every concurrent open on the path) indefinitely. Keep the token armed for finishClose()'s whole duration instead, and have the close-time compact-on-close pass opt out via a new compactRange() `cancellable` param rather than relying on the shared flag being cleared. Transaction::GetCount now takes an OperationGuard and checks isClosing() before scanning: without it, finishClose()'s drain can return immediately and the closables sweep can roll back the transaction while the count scan is parked between rows, reading freed memory. Carries the in-progress PR #787 lifecycle repair plan describing the fuller atomic-admission fix these two changes are a first slice of. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013CvCKcGKG7vyhRts1Mv4Wh
…nbounded for drain AsyncWorkHandle::registerAsyncWork() unconditionally incremented its counter with no serialization against cancelAllAsyncWork(), and waitForAsyncWorkCompletion() gave up after a hardcoded 5s even if work remained. Since Flush/Compact/Clear/async Get in database.cpp only hold the descriptor's operationsInFlight guard through synchronous setup (not through the queued execute callback), the 5s bound let DBDescriptor::finishClose() reach this->db.reset() while a slow flush() (legitimately waiting out a write stall per AGENTS.md invariant 16, which has no bound) was still executing against it — a genuine use-after-free, not a theoretical one. registerAsyncWork()/cancelAllAsyncWork() now share waitMutex so admission and cancellation can never interleave: a registration either fully lands before cancellation publishes, or is refused. waitForAsyncWorkCompletion() is now unbounded, matching the existing unbounded operationsInFlight wait pattern elsewhere in db_descriptor.cpp. Every registerAsyncWork() call site (database.cpp Clear/Compact/Flush/Get, backup.cpp's shared queueBackupWork, checkpoint.cpp, transaction.cpp's two Commit() paths) is wired through a new admitAsyncWorkOrReject() helper that rejects the already-constructed promise and tears down cleanly on refusal instead of proceeding into a closing handle. ScopedAsyncWorkRegistration (transaction_handle.cpp, used for cross-column-family transactional reads) now tracks admission via ok() so its destructor can't underflow the count on a refused registration, and both of its call sites in TransactionHandle::get() check ok() explicitly rather than relying on the (currently-true but unenforced) correlation with isCancelled(). backup_stream.cpp's registration is left unchecked, with an explanatory comment: its operationsInFlight claim is held through the whole async execution already, so it can't hit refusal in practice. Corrected the README's lifecycleWaitSeconds doc: it said "Total maximum time," which contradicted the existing note that destroy()/shutdown()'s wait for in-flight backups/checkpoints is intentionally unbounded — reworded to clarify it only bounds the wait for a conflicting lifecycle op on the same path. Deleted .pr787-lifecycle-repair-plan.md (superseded by this commit and AGENTS.md invariant 17, which documents the fix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019nt6hrpR4pnotKkZ8AUVv5
Gemini + Harper-domain pre-push review (round 1) found that every admitAsyncWorkOrReject() call site followed a successful admission with a bare NAPI_STATUS_THROWS(::napi_queue_async_work(...)). On the rare napi_queue_async_work() failure that macro throws and returns immediately, leaking `state` with its AsyncWorkHandle registration still counted. Since waitForAsyncWorkCompletion() is now unbounded (this branch's whole point), that stuck count blocks the handle's close forever, which blocks every later OpenDB() for its path -- worse than the leak it fixed. Added queueAsyncWorkOrReject() alongside admitAsyncWorkOrReject() in async.h: releases the admitted claim via signalExecuteCompleted(), deletes the async work object, rejects the promise, deletes state. Takes an `admitted` flag for backup.cpp's queueBackupWork(), whose registration is conditional on its registerWork/state->handle parameters -- unregistering an admission that never happened would underflow the count the same way. backup_stream.cpp's AsyncBackupStreamState is refcounted (acquire()/ release(), shared with an N-API tsfn) rather than a plain heap object, so the generic helper's `delete state` would double-free against tsfnFinalize()'s later release(). Wrote the queue-failure cleanup by hand there instead, mirroring backupStreamComplete()'s existing teardown sequence (delete async work, release the tsfn, drop the descriptor pin, reject, release the constructor's own ref). Also asserted the invariant its "(void)registerAsyncWork()" comment already claimed (registration there is guaranteed by the operationsInFlight claim held for the whole stream) -- the review's other nit, that the claim was load-bearing but unenforced. transaction_handle.cpp's async Get fallback was checked and left alone: its queue call runs before a still-in-scope PendingAsyncState RAII guard is released, so a queue failure already unwinds and deletes state correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019nt6hrpR4pnotKkZ8AUVv5
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
ba0e1c0 to
e5e55bc
Compare
| claimed.reserve(instance->databases.size()); | ||
| alreadyClosing.reserve(instance->databases.size()); | ||
| for (auto& [key, entry] : instance->databases) { | ||
| if (key.path != path || !entry.descriptor) { |
There was a problem hiding this comment.
High — Raw path aliases bypass destruction ownership
Registry identity compares the caller’s raw path string, so equivalent .., relative, or symlink paths can create separate entries and bypass destroyingPaths. An alias can open the same physical database while this loop closes only exact-string entries and then removes their shared files, producing live-handle I/O failures or a failed/corrupt destroy. Canonicalize the path once at every registry boundary and use that canonical identity for DBKey and destroyingPaths.
—
Generated by Barber AI
There was a problem hiding this comment.
Real, but out of scope for this PR — leaving open for a human decision rather than resolving.
Registry identity has always been the raw path string; destroyingPaths inherits that, it does not introduce it. The practical blast radius is also narrower than it reads: RocksDB takes its own directory LOCK, so a second read-write open of the same physical database through an alias fails at the RocksDB layer regardless of what our map key says. What genuinely survives is a read-only alias (no write lock) that DestroyDB will not close before rocksdb::DestroyDB deletes the files.
Canonicalizing is the right fix and it is not a one-liner: DBKey identity is user-visible in registryStatus().path and in every lifecycle error message, TransactionLogStoreRegistry keys off descriptor->path, and the per-path lock/backup files derive from it — so it needs its own change with its own tests, plus a decision on weakly_canonical failure modes on a path whose tail does not exist yet. Doing it inside a PR that is already reworking teardown ordering would make both harder to review.
Filed as a follow-up for triage; carried in the PR body under ## For the human reviewer.
— Claude Opus
| // by the same happens-before edge, already forced state to Aborted) — | ||
| // undo the completion registration just above and reject rather than | ||
| // dispatching into a transaction that is being torn down. | ||
| if (!(*txnHandle)->registerAsyncWork()) { |
There was a problem hiding this comment.
High — Quarantined descriptors still admit transaction commits
After a close-time flush failure, the descriptor is closing and its commit workers are stopped, but pending transaction handles are not cancelled yet. During shutdown() retry this registration can therefore succeed and the fallback async commit can run after the retry’s flush, violating close durability—especially with WAL disabled. Admit commits under a descriptor OperationGuard, reject when descriptor->isClosing(), and ensure close drains the admission before stopping workers and flushing.
—
Generated by Barber AI
There was a problem hiding this comment.
Leaving open — the analysis holds but the suggested fix collides with a documented invariant, so this is a human call rather than something to change under bot feedback.
Confirming the window: after a quarantining close, finishClose() has already set commitCompletionsClosed, so Transaction::Commit falls through to the legacy libuv path, which admits on the transaction handle. The closables sweep never ran (the flush threw first), so those handles are not cancelled and admission succeeds. Two corrections to the framing, though: the same window exists on the first close pass too — finishClose() flushes and only then sweeps closables — so it is not specific to quarantine or to the shutdown() retry; and there is no durable flush for the write to land after in the quarantined case, since that flush is precisely what failed.
The suggested fix — reject admission when descriptor->isClosing() — is what AGENTS.md invariant 18 exists to prevent. db.transaction() awaits its callback before committing, so a legitimate commit is routinely one microtask behind a db.close(); rejecting there is what stranded commit promises under Deno and is what test/txn-close-commit-uaf.test.ts guards. (Today that overlap is protected by refcount, not by admission policy: a live transaction pins the descriptor so PurgeIfUnreferenced skips the claim.)
The fix that would actually close it without rejecting anything is to run the closables sweep before the flush in finishClose() — every transaction handle is then closed and drained, so nothing can commit after the flush. That is a material reordering of the most delicate path in this PR and wants its own change and its own review.
Carried in the PR body under ## For the human reviewer.
— Claude Opus
A close-time flush failure quarantines the descriptor so shutdown()/destroy() can retry it. At process exit there is no later retry, and the registry singleton is a namespace-scope static: whatever it still holds is destroyed from an atexit handler. Closing a RocksDB database there runs CancelAllBackgroundWork() -> PeriodicTaskScheduler::Unregister() after RocksDB's own function-local statics are gone, so port::Mutex::Lock() gets EINVAL and RocksDB's PthreadCall aborts: rocksdb-js database registry cleanup failed: Failed to flush database during close: IO error: ... 000010.log: Permission denied pthread lock: Invalid argument That is the full-suite exit failure reported on this PR. It reproduces from test/background-error.test.ts alone, whose fixtures leave a database with a sticky RocksDB background error, so close() keeps failing its flush. DBRegistry::Teardown() now releases the remaining entries from the module's env-cleanup hook, while RocksDB is still usable; the failure is still reported, the process just exits cleanly. Those fixtures also tear down with destroy() so they stop leaving a broken database (and a leaked temp dir) behind. Also in this commit: - Regression coverage for the manual-compaction cancellation contract, per the review thread on compactCancelRequested. A new startup-snapshotted seam (ROCKSDB_JS_COMPACT_DELAY_MS) parks a cancellable compactRange() until the close claim arms the token, so the sync and async fixtures assert prompt destroy + a cancelled compaction without depending on host I/O timing. Verified against three mutations: unwiring options.canceled fails both fixtures, moving the arm past the in-flight drain fails the sync one, and removing Teardown() aborts/hangs the quarantined-exit fixture. - The contract itself is now written down on the member declaration and in AGENTS.md: one arming site, never cleared, never aliased onto `closing` (RocksDB writes through that pointer). - DestroyDB()/Shutdown() give each wait its own lifecycleWaitSeconds budget instead of sharing one deadline with the gate/lock acquisition, which could make a post-claim wait time out immediately and leave the database on disk. - A close/open cycle on the same RocksDatabase instance is now covered: open() clears the handle's async-work cancellation, so async work is admitted again. Refs #787 Co-Authored-By: Claude Opus <noreply@anthropic.com>
db.close() cancels async work and then waits for it with no timeout, but the
only cancellation RocksDB honours for a manual compaction is
CompactRangeOptions::canceled — and that token was armed exclusively by
DBDescriptor::beginClose(), which DBRegistry::CloseDB does not reach until
after DBHandle::close() has already returned from its drain. An async
compact() therefore ran to completion with canceled == false, parking the JS
thread (and the path gate, so every concurrent open of that path times out)
for the compaction's full duration: minutes to hours on a large column family,
where the pre-change bounded drain gave up after 5s.
Arming the descriptor token from CloseDB is not the fix — it is never cleared,
so one handle closing would permanently kill manual compaction for every other
handle sharing the process-global descriptor. Instead the token to hand RocksDB
is now chosen by the drain that awaits the caller:
- synchronous compactSync()/clearSync() hold an OperationGuard for the whole
compaction and are awaited by finishClose()'s operationsInFlight wait, so
they keep the descriptor token that beginClose() arms ahead of it;
- async compact()/clear() released their guard at setup handoff and are
awaited by DBHandle::close()'s async-work drain, so they get a new
per-handle token that close() arms immediately before that drain.
compactRange() takes the token as a parameter rather than deciding for itself;
close-initiated compaction passes nullptr as before. The foreign-destroy path
reaches the per-handle token through finishClose()'s closables sweep, so
fork-compact-cancel-async.mts still holds. fork-compact-cancel-close.mts is the
new self-close case: with the async compaction parked in the
ROCKSDB_JS_COMPACT_DELAY_MS seam, close() returns in ~6ms with the fix and
blocks 9760ms without it.
Also from the same review round:
- DBHandle::open() clears cancellation only after adopting the new descriptor.
OpenDB() blocks while a foreign destroy owns the old path, and that destroy's
closables sweep force-closes this still-attached handle mid-wait, re-arming
cancellation over the newly opened descriptor — leaving a handle whose sync
methods work while every async admission rejects as "Database is closing" for
the rest of its life. Written up as AGENTS.md invariant 20.
- ROCKSDB_JS_DESTROY_FAILURE is snapshotted in initializeTestSeams() like the
other fault flags instead of re-reading getenv on a teardown thread, and the
five previously undocumented seams are listed in AGENTS.md.
- Destroy on a never-opened handle said "Database path is required for destroy"
to a caller who did pass a path; it now names the real condition.
- README no longer claims close reports a compactOnClose failure — only the
WaitForCompact status is captured, and a skipped compaction loses no data, so
failing the close (and quarantining the path) for it would be wrong.
- Two comments cited AGENTS invariant 15 for the flush-stall rule, which is 16.
- runDestroyFixture removes the database directory it generated, unless the
fixture failed or KEEP_FILES is set.
Verification: pnpm build, pnpm check, pnpm test:native (159/159), and pnpm test
(855 passed / 3 skipped, exit 0) all pass.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
…locks
DBHandle::compactCancelRequested was armed only by that handle's own close(),
which on a foreign teardown (destroy()/shutdown(), or the last-handle purge from
another env) is reached by finishClose()'s closables sweep -- the LAST step of
teardown. Three steps before it can each block on the very compaction the sweep
would cancel:
- the optional compactOnClose pass takes DBDescriptor::compactMutex, which the
running manual compaction holds for its whole duration;
- WaitForCompact() does not return while a manual compaction is running;
- the sweep's own untimed async-work drain then waits it out.
The closer holds the path gate throughout, so every concurrent open of that path
times out after lifecycleWaitSeconds while it waits -- minutes to hours on a
large column family. finishClose() therefore now publishes cancellation on every
attached closable up front, before its first blocking step, through a new
Closable::cancelBlockingWork() hook (default no-op; only DBHandle overrides it).
Arming another thread's handle is safe precisely because this token is
per-handle and IS cleared, by DBHandle::open() after it adopts the new
descriptor (invariant 20) -- the descriptor-wide token, which is never cleared,
could not be used this way.
fork-compact-cancel-destroy.mts pins the ordering: with compactOnClose enabled
and an async compact() parked in the ROCKSDB_JS_COMPACT_DELAY_MS seam, a foreign
destroy completes in ~490ms with the fix and takes 7770ms with the arming loop
removed. fork-compact-cancel-async.mts cannot see this -- with compactOnClose
off, nothing between the in-flight wait and the sweep touches the compaction.
Also:
- clear() reports a cancelled leading compaction as "Database closed during
clear operation" rather than RocksDB's "Manual compaction paused", which left
the caller unable to tell whether the clear had partially applied. Nothing is
deleted when the compaction is cancelled: DeleteFilesInRange runs after it.
- runDestroyFixture's cleanup rmSync runs after the Promise executor returned,
so a Windows EBUSY past maxRetries would have killed the worker instead of
failing the test; a leftover directory is not worth that.
Verification: pnpm build, pnpm check, pnpm test:native, pnpm test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0114M7CN7LeYsih4iXsnX7Pa
Review nits on 1926048, all of them staleness the previous commit introduced: - DBHandle::compactCancelRequested still cited fork-compact-cancel-{close,async} as its coverage, when fork-compact-cancel-destroy is what actually proves the foreign-close arm happens before finishClose() can block. Its 26-line restatement of the two-token split is now one pointer to AGENTS.md invariant 6, which carries that contract. - DBDescriptor::compactCancelRequested announced "three parts" over four, and its part 3 described the per-handle token as armed only by a self-close. - Invariant 6 pointed at the member declarations for the fixture contract while they pointed back at it; it now states what each of the four fixtures does and does not pin down, and the members point at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114M7CN7LeYsih4iXsnX7Pa
The invariant-6 rewrite claimed the four fork-compact-cancel fixtures fail if `options.canceled` stops reaching RocksDB, which is true but reads as if they exercise RocksDB abandoning a live compaction. They do not, on purpose: ROCKSDB_JS_COMPACT_DELAY_MS parks *before* CompactRange so a fixture never depends on how long a real compaction runs, and unwiring the token is caught because the compaction then succeeds rather than returning Incomplete. What the fixtures pin down is our half of the contract -- which token is armed, how early, and that it is handed over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0114M7CN7LeYsih4iXsnX7Pa
Co-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>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Summary
Harper currently works around a rocksdb-js lifecycle race by locking root opens in JavaScript. This moves that invariant into the native registry: physical destruction owns a database path across read-write/read-only descriptors, concurrent opens wait, and shutdown is serialized with both operations.
The change also makes teardown failures observable and recoverable, prevents destroy/shutdown from releasing the native database beneath backups or checkpoints, and makes unbounded async work drain before its native owner is released. This is the rocksdb-js root-cause fix for Harper PR #2169.
The reported shutdown-cleanup failure is fixed and the suite is green. It was a genuine crash introduced by this PR's quarantine behaviour, and the root cause is a process-exit ordering rule that is now written down as AGENTS.md invariant 19: no
rocksdb::DBmay outlive the module's env-cleanup hook. A close-time flush failure deliberately quarantines the descriptor soshutdown()/destroy()can retry it — but at process exit there is no later retry, andDBRegistry::instanceis a namespace-scopestatic, so the surviving entry was destroyed from anatexithandler. Closing a RocksDB database there runsCancelAllBackgroundWork()→PeriodicTaskScheduler::Unregister()after RocksDB's own function-local statics are gone, soport::Mutex::Lock()getsEINVALand RocksDB'sPthreadCallaborts withpthread lock: Invalid argument.DBRegistry::Teardown()now releases whateverShutdown()left behind, from the cleanup hook while RocksDB is still usable; the failure is still reported, the process just exits cleanly. It reproduces fromtest/background-error.test.tsalone, which chmods a database directory read-only so RocksDB records a sticky background error — after which every close-time flush fails. That file's fixtures now tear down withdestroy(), so they stop leaving a broken database and a leaked temp dir behind.Manual compaction cancellation is now complete on both close paths, which took three rounds to get right and is the part worth reading closely. RocksDB abandons a manual compaction only through the
CompactRangeOptions::canceledpointer it was handed, so a token armed after teardown has already blocked on that compaction does nothing. SynccompactSync()/clearSync()hold anOperationGuardand are awaited byfinishClose()'s in-flight wait, so they use the descriptor token armed bybeginClose(). Asynccompact()/clear()released that guard at setup handoff and are awaited byDBHandle::close()'s async-work drain, so they get a per-handle token — which has two arming sites, one per closer, and the second is the fix in the newest commit: a foreigndestroy()/shutdown()reaches a handle only through the closables sweep, the last step of teardown, and three earlier steps each block on that compaction (compactOnClosetakescompactMutex,WaitForCompact()does not return under a manual compaction, then the sweep's own drain).finishClose()therefore publishes cancellation on every attached closable up front, before its first blocking step. Arming another thread's handle is safe precisely because the per-handle token is cleared byDBRegistry::OpenDB()after it adopts the new descriptor (invariant 20); the descriptor token, which is never cleared, could not be used this way. The whole contract is invariant 6.The final open/attach race is fixed.
DBRegistry::OpenDB()now receives the handle and publishes its descriptor-backed state and attaches it whiledatabasesMutexstill owns the lifecycle gate. A forced destroy therefore either precedes the open or sees the fully adopted handle in the descriptor's closables sweep; there is no return/adopt/attach gap in which RocksDB can be deleted beneath an invisible column-family handle. The worker-thread regression fixture asserts both handles are attached before destroy, verifies the racing handle is force-closed, then reopens that same instance and completes async I/O. Failed opens still retain their requested path, and the quarantine fault-injection fixture recovers by callingdestroy()on that same failed-open handle.For the human reviewer
Look hardest at four places, all teardown ordering:
DBRegistry::Teardown()'s placement in the cleanup hook, the per-wait deadline split inDestroyDB/Shutdown, the up-front cancellation pass at the top offinishClose(), and the new registry-locked handle publication inOpenDB().closing: RocksDB writes through the pointer it is given (DisableManualCompaction()sets the caller's atomic), andclosingmeans the registry has an owner committed to runningfinishClose(), which RocksDB must not be able to publish. Honest limit on the coverage: the three fixtures fail ifoptions.canceledis unwired, the sync one additionally fails if arming moves past the in-flight drain, andfork-compact-cancel-destroy.mtsfails if the foreign-close arm moves back to the sweep — but none distinguishes arming inbeginClose()from arming at the top offinishClose(), because for a single descriptor those are equivalent.beginClose()is still the right home for the descriptor token (one mutation site tied to the transition), but that is a design argument, not a tested one.databasesMutexis released. The deterministic fixture proves the pre-fix layout fails: moving attachment back after the seam produced a native SIGSEGV, while the fixed build reports exactly two closables and force-closes the racing handle.waitForAsyncWorkCompletion()now waits without a timeout, rather than letting teardown free a native database still used by a slow flush. The alternative is a bounded wait that leaks or needs new ownership machinery; changing this after release would alter availability semantics. Every operation that can run unboundedly under that wait now has a cancellation path — which is what items 1 and the count-scan abort exist for.shutdown()/destroy()recovery rather than erased or automatically deleted. This preserves data and exposes the failure, but an unrecovered path remains unavailable in-process.Teardown()is the terminal owner of that quarantine at process exit. The same failure also makesshutdown()throw, rather than reporting only throughdatabase:closeFailed; used directly as a Node'exit'listener, that can stop later exit listeners, so the failure-delivery contract needs an explicit human ruling before release.../symlink aliases can bypass the destroy/open gate. Not introduced by this PR, and narrower than it reads — RocksDB's own directoryLOCKalready blocks a second read-write open through an alias, leaving a read-only alias during destroy as the live hazard. Canonicalizing changes a user-visible identity contract (registryStatus().path, every lifecycle error message,TransactionLogStoreRegistrykeys, per-path lock/backup files) and wants its own PR and tests.commitCompletionsClosedsendsTransaction::Commitdown the legacy libuv path, and the closables sweep never ran, so pending transaction handles still admit. Two corrections to how it was reported: the same window exists on the first close pass (finishClose()flushes, then sweeps closables), so it is not specific to quarantine; and in the quarantined case there is no durable flush for the write to land after, since that flush is what failed. Rejecting ondescriptor->isClosing()is what AGENTS.md invariant 18 andtest/txn-close-commit-uaf.test.tsexist to prevent. The fix that closes it without rejecting anything is to run the closables sweep before the flush infinishClose()— a material reordering of the most delicate path here, which wants its own change.CloseDBdetaches before it closes).DBRegistry::CloseDBcallsdescriptor->detach(handle)beforehandle->close(), so for the duration of that close the handle is in no descriptor'sclosables. A concurrentDestroyDB/Shutdown/PurgeAllclaim then runsfinishClose()whose in-flight wait returns immediately (an async flush/compact/get released itsOperationGuardat setup, invariant 17) and whose closables sweep — the only thing that would block on this handle'scloseMutexand drain its async work — does not see it, reachingthis->db.reset()while a libuv worker is still insidedescriptor->flush(). That is a use-after-free on therocksdb::DB. It is pre-existing (origin/mainhas the same ordering) and the same shape as theNativeIteratorfinalizer, which also detaches beforeclose(); this PR does widen the window, by making the drain untimed and by havingdestroy()force-close foreign handles at all. The fix is small — close first, then detach, holding a localshared_ptracross it and releasing it beforePurgeIfUnreferencedso the refcount check still sees an unreferenced descriptor — but it changes the serialization of the most delicate path in a PR that already reworks it, and it needs its own fixtures. Filed for triage rather than folded in.DBHandle::close()currently treats a matchingstd::thread::idas proof that it owns the handle's N-API environment. Thread IDs may be reused after a worker exits, so a later worker could pass that guard and delete references against a dead environment. Releasing log references bynapi_envfrom the environment cleanup hook would avoid that ambiguity, but it adds new cross-environment cleanup ownership and needs a dedicated worker-reuse regression rather than a mechanical edit here.Also carried, unchanged from the previous round:
getRange()pays astd::mutexper row for a window that only opens under a concurrent foreign forced close (deliberate, recorded rather than treated as free), and the last-env cleanup still keys off an unsynchronized--moduleRefCount == 0.Verification
lifecycleWaitSecondsis tested, but the new lifecycle timeout branches do not yet have a fault-injected timeout-and-retry fixture proving each gate is released after the throw.pnpm build/pnpm check— passed.pnpm test:native— 159 passed.pnpm test— 857 passed, 3 skipped, exit 0. The previously reported exit-1 during process cleanup (.../000010.log: Permission deniedfollowed bypthread lock: Invalid argument) no longer occurs.d082b425:pnpm checkpassed, andnode --expose-gc ./node_modules/vitest/vitest.mjs test/destroy.test.ts --runpassed all 25 tests after replacing the scheduler-sensitive fixed sleep with a boundedclosables === 2poll.Teardown()call →fork-quarantined-exit.mtshangs inWaitForFlushMemTables, and the chmod repro aborts withpthread lock: Invalid argument(gdb backtrace through~unique_ptr<DBRegistry>→DBDeleter→PeriodicTaskScheduler::Unregister);CompactRangeOptions::canceled→ the compaction fixtures fail;fork-compact-cancel-sync.mtsfails (the compaction runs the full seam, then succeeds);finishClose()→fork-compact-cancel-destroy.mtsfails, with the foreign destroy taking 7770ms instead of ~490ms.d082b425: Codex confirmed the scheduler-independent poll fixes the final fixture-barrier finding and introduced no new findings. The preceding85caaac7round also completed Harper-domain adjudication; Gemini remained unavailable because the local CLI is unauthenticated, while Cursor was policy-pruned because this diff editsAGENTS.md.Complexity: complicated
Refs #787
— Claude Opus
Review-Coverage: authored=claude; ran=codex; blocked=gemini(auth); declined=cursor-grok,cursor-composer,domain; rounds=14 @ d082b42
Human-Review-Need: 4 @ d082b42