Skip to content

feat: add db.setCompression() for live per-CF compression changes - #742

Draft
kriszyp wants to merge 6 commits into
mainfrom
feat/live-compression-setoptions
Draft

feat: add db.setCompression() for live per-CF compression changes#742
kriszyp wants to merge 6 commits into
mainfrom
feat/live-compression-setoptions

Conversation

@kriszyp

@kriszyp kriszyp commented Aug 1, 2026

Copy link
Copy Markdown
Member

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-global DBDescriptor. 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 by rocksdb::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) clean
  • pnpm test — 720 passed, 2 pre-existing skips (unrelated)
  • Native binding (pnpm build:binding) and TS bundle (pnpm build:bundle) both rebuild clean
  • New tests: live getter effect, level round-trip/reset, on-disk-size proof of live algorithm change (SST + blob threshold), cold-reopen durability, already-open-CF conflict-check interplay, read-only rejection, validation
  • README.md Compression section updated with the new API's semantics and verified caveats
  • Independent cross-model review: round 1 (pre-hardening) completed with real findings, all addressed in the second commit; round 2 (post-hardening) could not complete after 5 attempts due to the review CLI's background process being killed externally each time (machine under heavy concurrent multi-agent load) — see the PR's self-review for the full account. The hardening commit mirrors exact patterns (ACQUIRE_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

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

@github-actions

github-actions Bot commented Aug 1, 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 23.32K ops/sec 42.89 41.50 496.985 0.112 116,584
🥈 rocksdb 2 10.86K ops/sec 92.06 87.18 31,419.513 1.24 54,312

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 28.54K ops/sec 35.04 33.82 570.501 0.107 142,689
🥈 rocksdb 2 11.44K ops/sec 87.40 85.40 581.469 0.052 57,206

ranges.bench.ts

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

Implementation Rank Operations/sec Mean (ms) Min (ms) Max (ms) RME (%) Samples
🥇 lmdb 1 24.81K ops/sec 40.31 36.50 1,977.29 0.304 124,031
🥈 rocksdb 2 16.54K ops/sec 60.46 52.37 1,105.232 0.122 82,706

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 374.82 ops/sec 2,667.975 117.459 28,069.363 9.22 750
🥈 lmdb 2 26.40 ops/sec 37,876.857 422.169 1,191,651.657 136.548 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 38.65K ops/sec 25.87 11.72 451.168 0.242 193,258
🥈 lmdb 2 443.41 ops/sec 2,255.269 103.262 8,116.097 1.14 2,218

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 728.60K ops/sec 1.37 1.20 5,231.168 0.219 3,642,978
🥈 lmdb 2 447.78K ops/sec 2.23 1.10 5,732.14 0.815 2,238,909

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 821.19 ops/sec 1,217.75 1,045.153 3,124.784 0.384 1,643
🥈 lmdb 2 1.16 ops/sec 860,945.334 801,249.067 954,056.92 4.21 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 22.73K ops/sec 44.00 30.52 525.706 0.575 45,460
🥈 lmdb 2 809.73 ops/sec 1,234.976 197.946 13,277.574 5.34 1,620

Results from commit 27e2b6e

@kriszyp

kriszyp commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Parking this for future consideration rather than merging it now — moving to draft.

Opened #743 as the narrower alternative: compressionForAllColumnFamilies on the open call, which applies the requested codec to every column family instead of only the target. That covers the case actually blocking Harper today (a database upgraded from 5.1, where every family is persisted at none and the per-table reconcile open cannot move them), without live mutation of an already-open family.

The distinction is which model we want:

  • One codec per deployment — the codec is known from config before any open, so it can just be supplied at open time. Add compressionForAllColumnFamilies so a database can adopt one codec #743 is enough, and there is no SetOptions() cost or new mutable native state.
  • A codec per table — you cannot know a table's codec until you have read the catalog, which is itself opened by that same DB::Open, so the only way to act on it afterwards is to change an open family's codec. That is this PR, and nothing else substitutes for it.

Harper's per-table dimension today is only enabled-vs-disabled, derived from one global storage.compression value at table-creation time, so the deployment-global model fits what exists. If we later want genuine per-table codecs (a @table(compression:) directive, say), this PR is the thing that unblocks it.

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.

kriszyp added a commit that referenced this pull request Aug 1, 2026
…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
kriszyp added a commit that referenced this pull request Aug 2, 2026
…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
@kriszyp
kriszyp force-pushed the feat/live-compression-setoptions branch from 0b0af1d to 8fdb03a Compare August 2, 2026 22:40
kriszyp added a commit that referenced this pull request Aug 19, 2026
…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
@kriszyp
kriszyp force-pushed the feat/live-compression-setoptions branch from 8fdb03a to 0f0085e Compare August 19, 2026 02:57
kriszyp and others added 6 commits August 21, 2026 11:19
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>
@kriszyp
kriszyp force-pushed the feat/live-compression-setoptions branch from 0f0085e to 4d4194e Compare August 21, 2026 17:31
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.

1 participant