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 c68d8a2 |
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>
| continue; | ||
| } | ||
| if (!entry.descriptor) { | ||
| if (!entry.closeError.empty() && destroyCleanupError.empty()) { |
There was a problem hiding this comment.
Medium: a failed destroy cleanup now poisons shutdown() permanently
This round removed Shutdown()'s destroy-cleanup retry loop, so a tombstone entry (descriptor == nullptr with a non-empty closeError) is no longer repaired here — it is only reported, by throwing. Nothing else in the registry ever clears one: PurgeAll() skips every entry with a closeError (line 682), OpenDB() refuses the path (line 460), and DestroyDB() is the sole remover. So once a single destroy() fails its physical cleanup, every later shutdown() call re-scans the map, finds the tombstone, and throws — forever, even after the underlying cause is gone.
test/destroy.test.ts:162-166 pins exactly that: it restores the directory permissions with chmodSync(lockedDirectory, 0o700) first and still asserts shutdown() throws. The cleanup is now recoverable only through destroy() on that specific path, which needs a previously-opened writable instance the caller may no longer hold.
Two consequences beyond the throw itself:
- The README recommends
shutdown()from aprocessexit listener. That listener now throws on every exit for the rest of the process's life. binding.cpp'sShutdown()returns on the throw beforeGlobalEvents::Shutdown()(already flagged separately, still open at this SHA), so an unremovable directory also leaks every global listener threadsafe function at exit — andPurgeAll()at the end ofDBRegistry::Shutdown()never runs either.
Suggested fix: keep the report, drop the throw. The tombstone is already visible through registryStatus().destroyCleanupPending and was already emitted as database:closeFailed when the destroy failed, so shutdown() can surface it without aborting its own flush + PurgeAll() + GlobalEvents::Shutdown(). If a hard signal is wanted, restore the destroyPhysicalPath() retry that lived here at 6bba0884 and throw only when the retry itself fails.
Minor, same function: the destroyCleanupError.empty() term in the loop-exit condition at line 1012 is dead — line 1008 already threw on every path that could make it non-empty.
—
Generated by Barber AI
| napi_value Database::CompactSync(napi_env env, napi_callback_info info) { | ||
| NAPI_METHOD_ARGV(3); | ||
| UNWRAP_DB_HANDLE_AND_OPEN(); | ||
| ACQUIRE_OPERATIONS_LOCK(); |
There was a problem hiding this comment.
Low: compactSync() now holds an OperationGuard across an unbounded compaction, stalling an untimed teardown drain
Adding the guard here is the right call — it is what stops finishClose() from resetting columnDescriptor/db underneath the compaction. But DBDescriptor::finishClose() drains with a bare, untimed atomic wait (db_descriptor.cpp:305-307: while ((current = this->operationsInFlight.load()) != 0) this->operationsInFlight.wait(current);), and CompactSync never checks isClosing() once it starts. A manual compaction of a large column family therefore blocks destroy()/shutdown() for its full duration with no deadline.
That matters because the blocked thread is holding the new path gate: destroyingPaths / shutdownInProgress stay set, so every concurrent OpenDB() for the path waits on lifecycleCondition against its own getLifecycleWaitSeconds() deadline and fails with Timed out opening database ... destruction is still in progress. One slow compactSync() turns into cascading open failures across the process — the wedge class this PR set out to remove.
The other entry points that gained the guard in this round are all short; CompactSync and GetCount are the two that can run arbitrarily long.
Suggested fix: make the compaction cancellable so the guard is released promptly — set rocksdb::CompactRangeOptions::canceled from the closing path (or poll descriptor->isClosing() between column families) so a teardown can interrupt it, rather than leaving the drain unbounded.
—
Generated by Barber AI
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>
|
|
||
| DEBUG_LOG("%p DBIterator::Return Closing iterator handle\n", (*itHandle).get()); | ||
| (*itHandle)->close(); | ||
| if (!(*itHandle)->closeIfOpen()) { |
There was a problem hiding this comment.
Medium: Return()/Throw() now throw when the iterator is already closed, which is exactly the forced-teardown state this commit creates
closeIfOpen() returns false when iterator == nullptr, and both Return (here) and Throw (line 390) turn that into a thrown Iterator not initialized. Before this commit close() was idempotent and neither method could throw.
The iterator reaches iterator == nullptr through the very path this commit adds serialization for: finishClose()'s closables sweep (db_descriptor.cpp:393-406) calls DBIteratorHandle::close() on the destroying thread. After that, on the owner thread:
for (const x of store.getRange(...)) { ... break; }— thebreakruns IteratorClose →DBIterator.return()(src/dbi-iterator.ts:92) → this throw. A clean loop exit becomes an exception.DBIterator.throw()(src/dbi-iterator.ts:97) callsthis.iterator.throw?.(err)beforethrow err, so the native throw replaces the caller's real error withThrow failed: Iterator not initialized.
It is also reachable without any concurrency: src/dbi-iterator.ts:56 already calls this.iterator.return?.() when #limit is reached, so any later explicit .return() (legal on a done iterator, and a common finally pattern) is a second call.
There is no upside to the signal — dbi-iterator.ts discards the return value in both places, so the bool only ever materializes as a spurious error. Next is the only method that genuinely needs the null check, and it already has one under the mutex (line 283).
Suggested fix: revert Return/Throw to the idempotent close and keep closeIfOpen() for internal callers that care:
| if (!(*itHandle)->closeIfOpen()) { | |
| (*itHandle)->close(); |
(and the same at line 390).
—
Generated by Barber AI
| immediately after `UNWRAP_DB_HANDLE_AND_OPEN()`; `finishClose()` can reset the column-family pointer | ||
| from another env after the in-flight count drains. The VT-only `verifyVersion` / `populateVersion` | ||
| fast paths are the exception: `DBHandle::open()` snapshots their immutable per-open VT epoch and | ||
| column-family ID so they do not touch teardown-owned native state or register an in-flight operation. |
There was a problem hiding this comment.
Low: the VT fast-path exception is overstated — those paths still dereference teardown-owned state
verifyVersion / populateVersion (database.cpp:1294, :1330) both still start with UNWRAP_DB_HANDLE_AND_OPEN(), which reads descriptor, descriptor->isClosing() and — via opened() — the descriptor object itself. What the cached verificationTableDbId / verificationTableColumnFamilyId actually removed is the getColumnFamilyHandle() dereference and the OperationGuard, not the descriptor access. A future agent reading "do not touch teardown-owned native state" could reasonably add a new VT fast path with no descriptor gate at all.
The same paragraph is also now the only statement of an invariant that the code applies inconsistently: database.cpp:1815 (PutSync), database.cpp:1884 (RemoveSync) and transaction_handle.cpp:211 still compute the VT address as descriptor->vtEpoch + getColumnFamilyHandle()->GetID() rather than reading the new cached fields, so there are two spellings of the same address computation that must stay in agreement.
Suggested fix: narrow the claim to what is true — the fast paths avoid the column-family dereference and the in-flight registration, but still gate on UNWRAP_DB_HANDLE_AND_OPEN() — and either migrate the three remaining sites to dbHandle->verificationTableDbId / verificationTableColumnFamilyId or note why they keep the descriptor form.
—
Generated by Barber AI
| const teardownError = workerState.find((state) => state.teardownError)?.teardownError; | ||
| if (!teardownError) { | ||
| try { | ||
| rmSync(dbPath, { force: true, recursive: true, maxRetries: 3 }); |
There was a problem hiding this comment.
Low: a teardown failure now leaks the benchmark database directory with no diagnostic
This round moved the rmSync from the worker (workerInit, which ran it unconditionally) to the parent, and gated it on !teardownError. Since the worker no longer deletes anything, a benchmark whose worker fails teardown leaves benchmark/data/rocksdb-benchmark-<random>/ behind permanently — and dbPath is freshly randomized per run (line 385), so repeated failures accumulate directories rather than reusing one.
The thrown teardownError says nothing about the retained path, so the leak is invisible until someone looks at benchmark/data/.
If retaining the data for post-mortem inspection is the intent, that is reasonable — but it should say so. Suggested fix: log the retained path before rethrowing, e.g. console.warn(\Benchmark teardown failed; retaining ${dbPath} for inspection`)in theif (teardownError)branch, or delete it anyway since a failedclose()` already left the directory in an undefined state.
—
Generated by Barber AI
| }); | ||
| }, 15_000); | ||
|
|
||
| it('closes an iterator safely when destroy races its construction', async () => { |
There was a problem hiding this comment.
Low: this test covers the constructor gate, not the per-iterator mutex that is the substance of the commit
The timing here is deterministic and does exercise something real: the worker posts destroying and waits 50ms (destroy-open-worker.mts:12) while the parent sits in the 250ms ROCKSDB_JS_ITERATOR_SETUP_DELAY_MS sleep, so db_iterator.cpp:154's isClosing() check reliably fires. That half is covered.
What is not covered is the other half — iteratorMutex serializing DBIterator::Next against DBIteratorHandle::closeIfOpen() running from finishClose()'s closables sweep. The only delay seam is in the constructor, so no test can position a foreign forced close during a Next() call. If the mutex were removed the suite would still pass.
Separately, the fixture's assertion is broad: test/fixtures/fork-destroy-open.mts:34-44 accepts success, Database not open, and Database is closing alike, so it is a crash guard rather than a behavior assertion.
Suggested fix: add a testDelayMs("ROCKSDB_JS_ITERATOR_NEXT_DELAY_MS") seam inside DBIterator::Next after the lock is taken, and a fixture variant that starts a getRange walk, then triggers the destroy mid-next() — that is the case the mutex exists for.
—
Generated by Barber AI
Summary
Harper currently works around a rocksdb-js lifecycle race by locking root opens in JavaScript. This moves the invariant into the native registry: physical destruction now 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, and prevents destroy/shutdown from releasing the native database beneath directory backups, streaming backups, or checkpoints.
For the human reviewer
destroy()intentionally changes from refusing while peer handles exist to closing every in-process handle for the physical path before removing it. This is what allows Harper schema propagation to race safely with a database drop. Cross-process coordination remains RocksDB's lock responsibility.database:closeFailed. A failed post-destroy directory cleanup is visible inregistryStatus()and can only be retried by the explicit destructive verbs,destroy()orshutdown();open()remains non-destructive.close()in afinally, but silently ignoring a failed flush would hide possible data loss.DBHandle; shutdown already exercised that path before this change. N-API reference deletion is now owner-thread-only, and worker lifecycle fixtures pass, but the remaining shared-handle synchronization is a follow-up decision for the storage maintainer.This is the rocksdb-js root-cause fix for Harper PR #2169, "Prevent job wedges on runtime database opens". Harper's JavaScript
.openlock should remain out of the released path once a package containing this change is available.Verification
node_modules/.bin/node-gyp buildnode_modules/.bin/tsc --noEmitnode_modules/.bin/oxlintnode_modules/.bin/oxfmt --checkMADV_COLDskipsReview coverage
— GPT-5 Codex
Human-Review-Need: 4 (decisions: close-throws-vs-reports, quarantine-blocks-reopen, global-shutdown-as-the-retry-api, shutdown-throws-while-tombstone-exists, destroy-forces-foreign-teardown, unbounded-inflight-wait-outside-the-deadline, one-global-lifecycle-timeout, retry-skips-the-inflight-drain, iterator-serialized-by-mutex-not-lifetime-guard, destroy-readonly-flag-crosses-the-js-boundary) @ e9bc308