Skip to content

Serialize database destruction with concurrent opens - #787

Open
kriszyp wants to merge 35 commits into
mainfrom
kris/serialize-destroy-open
Open

Serialize database destruction with concurrent opens#787
kriszyp wants to merge 35 commits into
mainfrom
kris/serialize-destroy-open

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 15, 2026

Copy link
Copy Markdown
Member

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.
  • A failed native close quarantines the path and emits database:closeFailed. A failed post-destroy directory cleanup is visible in registryStatus() and can only be retried by the explicit destructive verbs, destroy() or shutdown(); open() remains non-destructive.
  • Close-time flush/compaction errors are reported after native teardown completes. This can surface an error from close() in a finally, but silently ignoring a failed flush would hide possible data loss.
  • Destroy waits for registered in-flight backups/checkpoints before teardown. Streaming backups poll the closing state so their JS backpressure handshake cannot deadlock the synchronous destroy.
  • The registry and path gates are process-global. Raw path spellings are not canonicalized, matching the existing registry key behavior.
  • Cross-thread handle close can still race an owner-thread close on the same 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 .open lock should remain out of the released path once a package containing this change is available.

Verification

  • node_modules/.bin/node-gyp build
  • node_modules/.bin/tsc --noEmit
  • node_modules/.bin/oxlint
  • node_modules/.bin/oxfmt --check
  • Full JavaScript suite: 768 passed, 1 skipped
  • Native suite: 101 passed, 3 expected macOS MADV_COLD skips
  • Focused lifecycle suite: 69 passed

Review coverage

  • Full and incremental reviews by Claude, Gemini, Cursor Grok, Cursor Composer, and Harper storage-domain adjudication.
  • Review-found blockers fixed: shutdown no longer abandons healthy databases; destroy-cleanup tombstones are explicit and recoverable; directory backups participate in teardown accounting; cancelled queued operations release their in-flight claims; close-failure events cover every registry teardown path.
  • Human decisions remain around force-destroy semantics, synchronous wait behavior, and the pre-existing concurrent close race described above.

— 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

@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 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.

@github-actions

github-actions Bot commented Aug 15, 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.56K ops/sec 40.71 39.34 552.334 0.112 122,806
🥈 rocksdb 2 10.75K ops/sec 93.06 88.17 31,256.477 1.23 53,729

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.66K ops/sec 34.90 33.77 543.043 0.104 143,285
🥈 rocksdb 2 11.59K ops/sec 86.26 83.60 561.476 0.050 57,962

ranges.bench.ts

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 25.46K ops/sec 39.28 35.79 2,175.193 0.296 127,305
🥈 rocksdb 2 13.74K ops/sec 72.78 64.48 1,097.221 0.120 68,702

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 384.22 ops/sec 2,602.65 90.82 26,997.777 9.99 774
🥈 lmdb 2 26.52 ops/sec 37,709.36 419.636 1,199,078.613 137.466 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.63K ops/sec 25.24 10.63 14,124.195 0.608 198,137
🥈 lmdb 2 441.86 ops/sec 2,263.137 107.709 8,485.188 1.19 2,210

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 732.96K ops/sec 1.36 1.20 3,827.199 0.163 3,664,784
🥈 lmdb 2 457.62K ops/sec 2.19 1.11 756.4 0.266 2,288,098

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 843.30 ops/sec 1,185.814 1,026.087 2,013.439 0.316 1,687
🥈 lmdb 2 1.16 ops/sec 858,400.415 801,924.5 950,927.475 3.70 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 23.66K ops/sec 42.27 29.58 548.545 0.573 47,318
🥈 lmdb 2 834.52 ops/sec 1,198.294 55.82 12,875.158 5.40 1,670

Results from commit a972fec

@kriszyp
kriszyp marked this pull request as ready for review August 15, 2026 11:52
Comment thread src/binding/database/db_registry.cpp Outdated
Comment thread src/binding/binding.cpp Outdated
Comment thread src/binding/database/db_registry.cpp
Comment thread src/binding/core/test_seam.h Outdated
Comment thread src/binding/database/db_handle.cpp
Comment thread src/binding/database/db_registry.cpp Outdated
kriszyp and others added 2 commits August 15, 2026 11:27
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Comment thread src/binding/database/db_handle.cpp
kriszyp and others added 2 commits August 16, 2026 23:09
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Comment thread src/binding/database/db_registry.cpp Outdated
Comment thread src/binding/database/database.cpp
kriszyp and others added 4 commits August 17, 2026 01:09
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>
Comment thread src/binding/iterator/db_iterator.cpp Outdated
Comment thread AGENTS.md Outdated
Comment thread benchmark/setup.ts
Comment thread test/destroy.test.ts
- 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
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.

2 participants