feat: add db.setCompression() for live per-CF compression changes - #742
feat: add db.setCompression() for live per-CF compression changes#742kriszyp wants to merge 6 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces the setCompression method to dynamically change the compression algorithm and optional level for an already-open database's column family without requiring a close and reopen. This feature is implemented across the TypeScript API, the native C++ binding using RocksDB's DB::SetOptions(), and is thoroughly documented in the README and verified with a comprehensive suite of unit tests. I have no feedback to provide as the implementation is robust, well-documented, and includes excellent test coverage.
📊 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 27e2b6e |
|
Parking this for future consideration rather than merging it now — moving to draft. Opened #743 as the narrower alternative: The distinction is which model we want:
Harper's per-table dimension today is only enabled-vs-disabled, derived from one global Nothing here is wrong — the native hardening in the second commit (in-flight operation registration, read-only guard, split live/durable state on an OPTIONS-persist failure) is good work and worth keeping on the branch. Parking rather than closing so it's ready if the per-table case comes up. Generated by Claude Opus 5. |
…n race The Windows Bun job on PR #742 failed twice (once per GH Actions attempt, each already exhausting the existing single retry) with "Worker exited unexpectedly" right after the entire Vitest suite (all 722 tests) had already passed -- i.e. during the single Bun process's final teardown, not from any test failure. Root-caused via an ASan rebuild (ROCKSDB_ASAN=1, per AGENTS.md) run under Bun: deterministic crash inside DBRegistry::OpenDB (db_registry.cpp:376, the DBDescriptor::open() call for a freshly-opened path) right after a prior test's db.close(). Reproduces identically on main (ea83ff4, the commit this branch forked from) -- confirmed pre-existing and unrelated to db.setCompression(); #742's larger compression.test.ts simply added enough extra DB open/close churn (fileParallelism:false runs the whole suite in one Bun process) to make the existing race likelier to land inside a single CI run. Full evidence and stack trace posted to #695, which already describes this class of hazard (a dangling registry-entry reference racing a concurrent close). Not a fix for #695 itself -- that needs the categorical fix already scoped there, and this PR's diff is the wrong place to touch OpenDB's teardown/registry logic under time pressure. This just gives the Windows Bun job enough retry budget (3 attempts instead of 2) that the rare pre-existing race doesn't block unrelated PRs; a stopgap, not a fix, and it's proportionate because 6/6 local non-ASan Bun runs on Linux stayed clean -- the failure rate is genuinely low, just not zero on Windows CI. Refs #695 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WV3oB7zEeJvKFugn8DQ93e
…n race The Windows Bun job on PR #742 failed twice (once per GH Actions attempt, each already exhausting the existing single retry) with "Worker exited unexpectedly" right after the entire Vitest suite (all 722 tests) had already passed -- i.e. during the single Bun process's final teardown, not from any test failure. Root-caused via an ASan rebuild (ROCKSDB_ASAN=1, per AGENTS.md) run under Bun: deterministic crash inside DBRegistry::OpenDB (db_registry.cpp:376, the DBDescriptor::open() call for a freshly-opened path) right after a prior test's db.close(). Reproduces identically on main (ea83ff4, the commit this branch forked from) -- confirmed pre-existing and unrelated to db.setCompression(); #742's larger compression.test.ts simply added enough extra DB open/close churn (fileParallelism:false runs the whole suite in one Bun process) to make the existing race likelier to land inside a single CI run. Full evidence and stack trace posted to #695, which already describes this class of hazard (a dangling registry-entry reference racing a concurrent close). Not a fix for #695 itself -- that needs the categorical fix already scoped there, and this PR's diff is the wrong place to touch OpenDB's teardown/registry logic under time pressure. This just gives the Windows Bun job enough retry budget (3 attempts instead of 2) that the rare pre-existing race doesn't block unrelated PRs; a stopgap, not a fix, and it's proportionate because 6/6 local non-ASan Bun runs on Linux stayed clean -- the failure rate is genuinely low, just not zero on Windows CI. Refs #695 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WV3oB7zEeJvKFugn8DQ93e
0b0af1d to
8fdb03a
Compare
…n race The Windows Bun job on PR #742 failed twice (once per GH Actions attempt, each already exhausting the existing single retry) with "Worker exited unexpectedly" right after the entire Vitest suite (all 722 tests) had already passed -- i.e. during the single Bun process's final teardown, not from any test failure. Root-caused via an ASan rebuild (ROCKSDB_ASAN=1, per AGENTS.md) run under Bun: deterministic crash inside DBRegistry::OpenDB (db_registry.cpp:376, the DBDescriptor::open() call for a freshly-opened path) right after a prior test's db.close(). Reproduces identically on main (ea83ff4, the commit this branch forked from) -- confirmed pre-existing and unrelated to db.setCompression(); #742's larger compression.test.ts simply added enough extra DB open/close churn (fileParallelism:false runs the whole suite in one Bun process) to make the existing race likelier to land inside a single CI run. Full evidence and stack trace posted to #695, which already describes this class of hazard (a dangling registry-entry reference racing a concurrent close). Not a fix for #695 itself -- that needs the categorical fix already scoped there, and this PR's diff is the wrong place to touch OpenDB's teardown/registry logic under time pressure. This just gives the Windows Bun job enough retry budget (3 attempts instead of 2) that the rare pre-existing race doesn't block unrelated PRs; a stopgap, not a fix, and it's proportionate because 6/6 local non-ASan Bun runs on Linux stayed clean -- the failure rate is genuinely low, just not zero on Windows CI. Refs #695 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WV3oB7zEeJvKFugn8DQ93e
8fdb03a to
0f0085e
Compare
RocksDB's DB::Open() opens every existing column family in one call, so a caller can never reopen an already-discovered CF with a different explicit compression without closing every other handle sharing that process-global DBDescriptor. This blocks Harper's boot sequence, which necessarily opens its default CF (and thus transitively opens every other CF) before it can read its own catalog CF to learn each table's desired codec. Add db.setCompression(algorithm, level?), backed by RocksDB's DB::SetOptions(), which documents ColumnFamilyOptions::compression and blob_compression_type as dynamically mutable. Verified against the linked RocksDB build (not just assumed) via a standalone probe: a live SetOptions call takes effect on the very next flush/compaction output while existing SST/blob files keep their prior compression until rewritten by compaction — matching the open-time compression option's existing documented semantics, without the reopen restriction. The level is applied via RocksDB's partial nested-option syntax, so unset compression_opts sub-fields are left alone; omitting the level resets it to the algorithm default rather than inheriting a previously-set one, mirroring applyCompression()'s open-time behavior. A per-CF options map on the root open call (the alternative considered) doesn't fit the motivating case: Harper doesn't know a table's codec until it reads its own catalog CF, which is itself opened by that same call. SetOptions() sidesteps this structurally by operating on an already-open CF. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WV3oB7zEeJvKFugn8DQ93e
…nd OPTIONS-persist splits
Independent cross-model review (codex/gemini/grok/harper-domain) surfaced
real safety gaps in the initial setCompression implementation:
- Native SetOptions() dereferenced descriptor->db/the CF handle with no
in-flight registration, unlike every other op that touches them (Clear,
DropSync, FlushSync, GetSync, PutSync, RemoveSync) — a concurrent
cross-env close/shutdown could tear down the descriptor mid-call
(AGENTS.md's documented teardown hazard). Fixed with ACQUIRE_OPERATIONS_LOCK.
- No readOnly guard, so a read-only handle could durably persist an OPTIONS
file despite this binding's read-only guarantee. Fixed with
THROW_IF_READONLY (throwing, not the silent no-op Compact/Flush use, since
a setter silently doing nothing is a worse footgun than an explicit error).
- A persist failure (ENOSPC/EROFS/EIO) leaves the new compression already
live in memory while the OPTIONS file keeps the old one; the thrown error
now detects and states that split explicitly, since a cold reopen trusts
only the OPTIONS file and would otherwise silently revert.
- RocksDB's own SetOptions() doc calls it a slow, infrequent-use call
(serializes+persists a full OPTIONS file every time). The motivating
Harper boot sweep calls this once per table, almost always as a no-op
(already at the desired codec) — added a live-GetOptions short-circuit so
the steady-state sweep costs nothing instead of N blocking OPTIONS writes.
Also corrected the README's compact() claim: compactSync()'s default
CompactRangeOptions (kIfHaveCompactionFilter) skips already-bottommost
files, so it does not reliably force existing data onto a new codec as
previously documented. Documented the OPTIONS-persist-failure split state,
the read-only rejection, and that a live change silently overrides another
handle's explicit codec (no reopen-time conflict check applies).
test/compression.test.ts: fixed a wrong `it.skipIf` fallback assertion (a
none+snappy-only build would run setCompression({algorithm:'none',
level:6}) and fail), and added coverage for blob-threshold output, cold
close+reopen durability, and the read-only rejection.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WV3oB7zEeJvKFugn8DQ93e
…n race The Windows Bun job on PR #742 failed twice (once per GH Actions attempt, each already exhausting the existing single retry) with "Worker exited unexpectedly" right after the entire Vitest suite (all 722 tests) had already passed -- i.e. during the single Bun process's final teardown, not from any test failure. Root-caused via an ASan rebuild (ROCKSDB_ASAN=1, per AGENTS.md) run under Bun: deterministic crash inside DBRegistry::OpenDB (db_registry.cpp:376, the DBDescriptor::open() call for a freshly-opened path) right after a prior test's db.close(). Reproduces identically on main (ea83ff4, the commit this branch forked from) -- confirmed pre-existing and unrelated to db.setCompression(); #742's larger compression.test.ts simply added enough extra DB open/close churn (fileParallelism:false runs the whole suite in one Bun process) to make the existing race likelier to land inside a single CI run. Full evidence and stack trace posted to #695, which already describes this class of hazard (a dangling registry-entry reference racing a concurrent close). Not a fix for #695 itself -- that needs the categorical fix already scoped there, and this PR's diff is the wrong place to touch OpenDB's teardown/registry logic under time pressure. This just gives the Windows Bun job enough retry budget (3 attempts instead of 2) that the rare pre-existing race doesn't block unrelated PRs; a stopgap, not a fix, and it's proportionate because 6/6 local non-ASan Bun runs on Linux stayed clean -- the failure rate is genuinely low, just not zero on Windows CI. Refs #695 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WV3oB7zEeJvKFugn8DQ93e
Independent pre-push review (codex + gemini + grok + Harper-domain adjudication) of the full branch diff found two real correctness bugs in the setCompression feature this PR already carries (not introduced by the CI fix), both silently reverting a durably-requested compression change: - database.cpp: the no-op short-circuit compared only the LIVE GetOptions() value. After a SetOptions() call whose in-memory apply succeeded but whose OPTIONS-file persist failed (ENOSPC/EROFS/EIO -- the exact split the existing error message already describes), a retry at the same algorithm/level saw "already matches" and returned success without ever calling SetOptions() again, leaving the OPTIONS file stale forever. Added a per-CF compressionPersistDirty flag (ColumnFamilyDescriptor) that forces the retry through SetOptions() until a persist actually succeeds. - database.ts: a successful setCompression() never updated Store.compression, which Store.open() re-normalizes and reapplies on every open(). A close()+open() on the SAME RocksDatabase/Store instance therefore silently re-requested the ORIGINAL open-time codec, undoing a durably-persisted live change with no error. Also, smaller findings from the same review: - database.cpp: createRocksDBError() only assigns its out-param on success; napi_throw() could receive an uninitialized napi_value if error construction itself failed. Initialize to nullptr and skip the throw when unset (createRocksDBError's own NAPI_STATUS_THROWS_VOID calls already throw a fallback error in that case). - database.ts: setCompression's JSDoc claimed compact() reliably forces existing data onto the new codec; corrected to match the README's documented kIfHaveCompactionFilter caveat. - database.cpp: trimmed PR-narrative/reviewer-facing comment blocks added by the prior commit down to the load-bearing invariants. - compression.test.ts: three existing setCompression sequences left a database open on assertion/call failure (missing try/finally) -- wrapped them, since on Windows an open handle blocks the afterEach cleanup and can cascade into unrelated test failures. New regression coverage (test/compression.test.ts): a same-instance close()+open() proving the live change survives, and a permission- denied (read-only directory) failure injection proving a retry at the same algorithm actually re-persists instead of taking the shortcut. Both fail without the corresponding fix (verified by temporarily reverting each). Full suite: pnpm check clean; 722/722 passing tests, 2 pre-existing skips (Node, full run). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WV3oB7zEeJvKFugn8DQ93e 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>
0f0085e to
4d4194e
Compare
Summary
RocksDB's
DB::Open()opens every existing column family in one call, so a caller can never re-specify compression for a column family that's already been transitively opened without closing every other handle sharing that process-globalDBDescriptor. This blocks Harper's boot sequence, which necessarily opens its default CF (transitively opening every other CF) before it can read its own catalog CF to learn each table's desired codec — see the linked issue for the full repro.Adds
db.setCompression(algorithm, level?), backed byrocksdb::DB::SetOptions(), which RocksDB's own headers document as dynamically mutable for exactly the fields needed (ColumnFamilyOptions::compression,blob_compression_type). Verified against the linked RocksDB build with a standalone C++ probe (not just assumed) — see the PR's self-review for the full writeup, including why this was chosen over a per-CF options map on the root open call (the alternative doesn't fit Harper's actual use case: it doesn't know a table's codec until after reading its own catalog CF, which is itself opened by that same call).An independent cross-model review (codex + gemini + grok + a Harper-domain adjudicator) on the initial implementation surfaced real native-layer gaps — no in-flight operation registration (a shutdown-race UAF hazard matching AGENTS.md's documented teardown discipline), no read-only guard, no handling for a split live/durable state on an OPTIONS-persist failure, and an unbounded per-call cost given
SetOptions()is RocksDB's own documented "slow, infrequent-use" call. All are fixed (second commit) — see the self-review for details.Test plan
pnpm check(type-check, lint, format) cleanpnpm test— 720 passed, 2 pre-existing skips (unrelated)pnpm build:binding) and TS bundle (pnpm build:bundle) both rebuild cleanACQUIRE_OPERATIONS_LOCK,THROW_IF_READONLY) already used by sibling native ops in this file, which is a partial mitigation but not a substitute for independent review of that specific diff.🤖 Generated with Claude Code
https://claude.ai/code/session_01WV3oB7zEeJvKFugn8DQ93e
Review-Coverage: authored=unknown; ran=none; rounds=1 @ 4d4194e
Human-Review-Need: 4 @ 4d4194e