Honor storage.compression on upgraded databases, and let a deployment select the codec - #2044
Honor storage.compression on upgraded databases, and let a deployment select the codec#2044kriszyp wants to merge 8 commits into
Conversation
…c benchmark rocksdb-js 2.6.1 (harper#2036) brought the codecs and made lz4 the effective default, but nothing lets a deployment choose a different one. Per-table metadata only carries the LMDB-era boolean. HARPER_STORAGE_ROCKS_COMPRESSION selects the codec for every column family the process opens, validated against the build's supportedCompression so an unavailable name fails with a named error rather than a partial startup failure at the first open. It is an environment variable, not a config key, and resolved once at module load. RocksDB fixes a column family's codec while it is open and rejects a reopen that disagrees, and worker threads share one process-wide column-family registry, so every thread has to agree. A config value does not achieve that: the main thread loads the storage layer during install, before the value is readable, while workers load it after. Measured directly — same pid, thread 0 resolves undefined, threads 1 and 2 resolve zstd — after which __dbis__ is opened at the build default, reopened as the configured codec, and Harper dies with "The system database failed to load". copyDb's openRocksDb now resolves the codec the same way. DESIGN.md already called openRocksDatabase the single chokepoint; migration creates the column families the runtime reopens, so creating them under the build default while the runtime asks for a configured one wedges the next open. The benchmark compares codecs on identical data and workload. On 1M document-shaped records the data files come out at 51% (snappy), 54% (lz4) and 41% (zstd) of uncompressed, and point reads get faster rather than slower — compressed blocks hold more records per block-cache slot and that outweighs decompression. Sizes are as the workload left them, not a compacted steady state: nothing outside the process can force a RocksDB compaction, and storage.compactOnStart is an LMDB copy-compact that does nothing for RocksDB stores. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new RocksDB compression comparison benchmark and updates the database initialization logic to support selecting a RocksDB compression codec process-wide via the HARPER_STORAGE_ROCKS_COMPRESSION environment variable. The migration path in bin/copyDb.ts is also updated to resolve the codec consistently with the runtime. A high-severity issue was identified in the benchmark runner (benchmarks/compression/run.mts), where concurrent updates to scanBytesRead using += await create a race condition that leads to undercounting.
|
Reviewed the latest push (d88ccd4, resolving one codec per deployment via compressionForAllColumnFamilies). One blocker: the new default-codec fallback and compressionForAllColumnFamilies -- the mechanism the commit credits with the fix -- has no test coverage; see inline comment on resources/databases.ts. One non-blocking suggestion on a now-stale comment in bin/copyDb.ts. |
…pt a new default codec Measured across separate processes rather than assumed. RocksDB persists the codec per column family and a reopen without an explicit request inherits it, so a database created before the prebuild carried any codecs stays uncompressed however new the binary is — every 5.1 instance upgrading to 5.2 gets nothing from the lz4 default. Setting the codec explicitly compresses subsequent writes only; db.compact() does not convert the existing data, because rocksdb-js calls CompactRange with default CompactRangeOptions, whose bottommost_level_compaction is kIfHaveCompactionFilter and Harper installs no compaction filter, so the bottommost level is skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gnoring it storage.compression defaults to true (defaultConfig.yaml), so essentially every existing table's metadata records "compression enabled". toRocksCompression mapped that to unset, on the assumption that unset means "use the build default (lz4)". It does not, for a column family that already exists: RocksDB persists the codec per family and a reopen requesting nothing inherits what the family has, applying the build default only when creating it. So a new 5.2 database got lz4 while every database upgraded from 5.1 kept writing uncompressed indefinitely — both having asked for exactly the same thing. An enabled setting now resolves to an explicit codec. Measured on the real metadata path: a family created uncompressed reports lz4 after the upgrade open, and the next 60k records cost 2.3MB where the first 60k cost 31.6MB. Existing SSTs are left alone; converting them needs a forced bottommost compaction, which is expensive enough to deserve an explicit decision rather than happening during an upgrade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nment-only control Replaces the env-only selector with `storage.rocks.compression`, registered as a real config param, and fixes the bootstrap sequencing rather than routing around it. install() calls mountHdb() — which creates the system column families — several steps before createConfigFile() writes the config file, so the main thread resolved nothing while workers resolved the configured codec and __dbis__ could not be reopened. The installer now stages the value into the in-memory config before mountHdb(), mirroring the STORAGE_ENGINE line beside it, and resolution is frozen at the first open so every thread in the process agrees. Verified: config-only selection now boots clean on both a fresh install and a restart, where both previously failed. The copyDb rationale in DESIGN.md was wrong and is corrected: handles are closed before the staging rename and rocksdb-js allows a codec change across close/reopen, so the runtime would open the migrated database either way. The reason to thread the codec through migration is that it writes the whole dataset at once, and those files then keep their codec until traffic rewrites them. Benchmark: fixed the scanBytesRead race both bots flagged (my earlier edit silently no-op'd against reformatted source and I did not verify it); disk measurement now requires a clean exit, since killHarper's 5s grace lets SIGKILL truncate the final flush and a truncated flush reads as the codec being smaller; mutating requests are held to zero failures, with the tolerance kept only for reads and scans, because a dropped insert shrinks measured size while logicalBytes still counts it; warmup errors are no longer discarded. Tests now reach real RocksDB opens through the production resolution and assert the codec the engine applied, using zstd so they discriminate against the build default. The previous env-var block destructured an export that no longer exists and passed vacuously — removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All five review points addressed in 1. Config surface instead of an environment-only control — agreed, and the sequencing was fixable. The ordering problem was Mounted YAML and 2. 3. Zero tolerance for failed writes — agreed; size is the primary result. Mutating requests (warmup, insert, update) are now held to zero failures; the tolerance survives only for reads and scans, which can't change what's stored. Warmup errors are retained rather than discarded, since warmup rows land in the same table and count toward 4. Tests that reach a real open — You were also right that the previous block was worthless — it destructured an export that no longer existed and passed vacuously. Removed. I did not get multi-thread coverage into a unit test; the fresh-install-with-workers case is verified by the reproduction above but isn't automated. Happy to add it as an integration test if you want it gated in CI. 5. Also fixed the Not marking Ready. |
…aded databases with a stale codec Two independent failures on this branch's CI: 1. unitTests/resources/rocksCompressionOpen.test.js assumed it was the first thing in the process to call getRocksCompression() (which resolves-and-freezes on first call). True in isolation, false under the full `test:unit:resources` glob — several earlier-alphabetical files open a RocksDatabase first and freeze it at `undefined` before this file runs, so its setProperty() calls have no effect. Added resetRocksCompression() (test-only un-freeze) and use it in before()/after() hooks, restoring both the singleton and the config property so this file's mutations don't leak into the rest of the suite. 2. Integration Tests (cross-version-upgrade-visibility): booting the current build over a database created by 5.1.x crashed with "Column family already open with compression none; cannot reopen it with lz4". Traced with temporary diagnostic logging against the real 5.1.22 fixture: RocksDB's DB::Open opens every existing column family transitively in one call (there is no lazy per-CF open), so by the time initStores()'s per-table reconcile tries to apply the metadata-implied codec to an existing family, that family is already open at whatever was already persisted for it. Confirmed this isn't fixable from the caller's side today: explicitly requesting a codec on the root/default-CF open does not propagate to sibling CFs opened transitively alongside it. openExistingRocksPrimaryStore() now catches that specific conflict for an existing family's reconcile-open and falls back to the family's already-open codec with a warning, instead of a fatal boot failure. An explicit storage.rocks.compression override still fails loudly (the fallback retry re-resolves through getRocksCompression(), which takes precedence regardless). Filed HarperFast/rocksdb-js task rocksdb-js-cf-compression-api for the real fix (an API to change an already-open column family's codec without a full close) — this is the boot-safety net until that lands. Verified: test:unit:resources (RocksDB) 1357 passing, test:unit:resources (LMDB) 1203 passing, test:unit:main 4221 passing, all 0 failing. cross-version-upgrade-visibility integration test passes end-to-end against the real 5.1.22 fixture (24/24 rows visible on every surface, pre- and post-upgrade, across two restarts). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TUbqfkBvfRnC6RyvgBdfX
…not just the primary store The pre-push review (codex + gemini + grok + harper-domain) confirmed my first fix was incomplete: openExistingRocksPrimaryStore only wrapped the per-table primary-store open in initStores()'s reconcile loop. The domain reviewer verified directly against the real rocksdb-js binding that __dbis__ and every index column family hit the identical conflict — they're opened with `compression: undefined` in their dbiInit, which resolves through getRocksCompression() same as any other open, and are just as likely to already be transitively open at a different codec. Setting storage.rocks.compression on any pre-existing database (not just a cross-version upgrade) was still a hard boot failure. Generalized the fallback into openExistingRocksColumnFamily() and applied it at every reconcile-time open that can hit an already-open family: __dbis__ (both in initStores() and in table()'s two call sites), the primary store (both initStores()'s reconcile and table()'s create-branch, which can reopen a physical remnant left by an interrupted drop), index opens in openIndex(), and the drop-remnant open in completeInterruptedDrop(). Left the bare root/default-CF opens (readRocksMetaDb, database()) alone — those only ever run once per path per thread, before anything else has touched that path in this process, so they can't hit this conflict. Also fixed two things the review flagged in the fallback itself: the warning now only logs after the retry actually succeeds (previously it logged "keeping existing codec" and then, for an explicit misconfigured override, the retry would fail right after — a false recovery message), and it's deduped per column family instead of firing on every worker thread's independent reconcile of the same table. Second major finding: utility/install/installer.ts's stageRocksCompression() only read the JSON-blob env vars (HARPER_DEFAULT_CONFIG/HARPER_CONFIG/HARPER_SET_CONFIG). The individual STORAGE_ROCKS_COMPRESSION env var, the --STORAGE_ROCKS_COMPRESSION CLI flag, and an --HDB_CONFIG file all resolve normally through createConfigFile() later, so any of those forms would recreate the exact fresh-install race this function exists to prevent: install succeeds (main thread creates __dbis__ at the build default), and the very next boot dies once workers resolve the now-written config's codec against a family already fixed at a different one. stageRocksCompression() now checks all three sources in the same precedence createConfigFile() itself resolves them in. Verified: test:unit:resources (RocksDB) 1357 passing, test:unit:main 4221 passing, both 0 failing. cross-version-upgrade-visibility integration test still passes end-to-end against the real 5.1.22 fixture. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TUbqfkBvfRnC6RyvgBdfX
Unused destructured binding and an unwrapped line prettier wanted split. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011TUbqfkBvfRnC6RyvgBdfX
Replaces the boot-time fallback with the thing it was working around. RocksDB opens every column family of a database in one DB::Open, and rocksdb-js gives each family the caller did not name its persisted algorithm, applying the request only to the target. So a family Harper never names individually keeps whatever it was created with — which is why an upgraded 5.1 database stayed uncompressed however new the binary was, and why reconciling a table afterwards hit "already open with compression none; cannot reopen it with lz4". The previous commit caught that and kept the old codec, which meant the upgrade fix could never actually take effect. Opens now pass compressionForAllColumnFamilies (HarperFast/rocksdb-js#743) with a single codec resolved from configuration: storage.rocks.compression if it names one, else storage.compression decides enabled-or-not and the build default supplies the algorithm. Every family adopts it, so nothing conflicts and the fallback is gone along with its warning. The trade-off is deliberate: per-table compression metadata no longer selects a codec. A table persisted as disabled inside a deployment that enables compression would need its own, and there is nowhere to apply it — Harper cannot know a table's codec before the open that already opened its catalog. Verified end to end: 20k records written with compression off (9.6M SST), then the same data directory rebooted with it on — boots clean with no conflict, and compaction re-encodes to 1.6M. test:unit:resources 1357 passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reshaped around rocksdb-js #743 (
What changedThe fallback added in The root cause is that rocksdb-js gives every family the caller did not name its persisted algorithm and applies the request only to the target — so a family Harper never names individually keeps its original codec forever. #743 adds the opt-in that applies the codec to all of them. Opens now pass that flag with a single codec resolved from configuration:
Every family adopts it, nothing conflicts, and Deliberate trade-offPer-table compression metadata no longer selects a codec. A table persisted as In practice per-table values came from the global Verified20k records written with Boots clean, no conflict, no fallback — and compaction re-encoded the existing data. Generated by Claude Opus 5. |
| const databaseCodec = getRocksCompression(); | ||
| legacyOptions.compression = databaseCodec; | ||
| if (databaseCodec) (options as { compressionForAllColumnFamilies?: boolean }).compressionForAllColumnFamilies = true; |
There was a problem hiding this comment.
No test exercises compressionForAllColumnFamilies or the new default-codec fallback
What: This commit makes getRocksCompression() resolve to a real codec (via the new readDatabaseCodec()) even when storage.rocks.compression is unset — falling back to getDefaultCompression() — and, whenever that resolves truthy, sets compressionForAllColumnFamilies = true on every open. That flag is the entire mechanism the commit message credits with fixing the bug ("every family adopts it, so nothing conflicts"), replacing the deleted openExistingRocksColumnFamily fallback.
Neither the flag nor the new default-fallback branch has any direct test coverage. unitTests/resources/rocksCompressionOpen.test.js (unchanged by this commit) still opens RocksDatabase directly via a hand-rolled resolveCompression = getRocksCompression() ?? toRocksCompression(metadata) helper — the old openRocksDatabase() logic — and never calls openRocksDatabase() itself or passes compressionForAllColumnFamilies. Its own docstring's claim to test "the same resolution openRocksDatabase() uses" is now stale.
Why it matters: This is exactly the upgraded-database scenario the whole PR chain exists to fix (a pre-existing family, never individually named, that must adopt a newly-resolved codec without conflicting). Without a test that opens a family through the real openRocksDatabase(), closes it, reopens the same directory under a different resolved codec, and asserts the untouched sibling family also converts, a regression here (or the upstream compressionForAllColumnFamilies behaving differently than assumed) would silently reintroduce the "already open with compression X; cannot reopen it with Y" boot failure with no test catching it.
Suggested fix: Add a case to rocksCompressionOpen.test.js that goes through openRocksDatabase() (not a hand-resolved compression option), opens two named families in one directory under one codec, closes, reopens under a different codec, and asserts both the named and the previously-unnamed family report the new codec.
| // Migration writes the column families the runtime will later reopen, so it has to resolve the | ||
| // codec exactly as openRocksDatabase does. Creating them under the build default while the | ||
| // runtime asks for the configured one leaves the next open unable to reopen them. | ||
| const legacyOptions = options as { compression?: unknown }; | ||
| legacyOptions.compression = getRocksCompression() ?? toRocksCompression(legacyOptions.compression); |
There was a problem hiding this comment.
Suggestion (non-blocking): This comment ("resolve the codec exactly as openRocksDatabase does") and the getRocksCompression() ?? toRocksCompression(...) fallback it describes are now stale — the sibling openRocksDatabase() in resources/databases.ts was changed by this same PR to drop the ?? toRocksCompression(...) fallback and to additionally set compressionForAllColumnFamilies whenever the resolved codec is truthy. Migration is safe today only because copyDbToRocks always wipes and recreates the staging directory (no pre-existing family to reconcile), so the missing flag isn't a live bug — but update the comment (and confirm the two chokepoints intentionally diverge) so a future reader doesn't assume they still match.
rocksdb-js2.6.1 (#2036) brought the codecs in and made lz4 the effective default. This fixes the case that default silently misses, and adds a way to choose a different codec.The bug: upgraded databases ignore
storage.compressionstorage.compressiondefaults totrue(config/defaultConfig.yaml), so essentially every existing table's metadata records "compression enabled".toRocksCompressionmapped that to unset, on the documented assumption that unset means "use the build default (lz4)".It doesn't — not for a column family that already exists. RocksDB persists the codec per family, and a reopen that requests nothing inherits what the family already has; the build default applies only when the family is being created. Measured across separate processes:
So a brand-new 5.2 database gets lz4 while every database upgraded from 5.1 keeps writing uncompressed indefinitely — both having asked for exactly the same thing. New databases only worked by accident, because unset on a non-existent family does fall through to the build default.
An enabled setting now resolves to an explicit codec. On the real metadata path (
{ startingOffset, threshold }, whatgetDefaultCompression()returns whenstorage.compressionis true):Existing data is deliberately left alone. An explicit codec governs newly written files only, and
db.compact()won't convert the rest: rocksdb-js callsCompactRangewith defaultCompactRangeOptions, whosebottommost_level_compactioniskIfHaveCompactionFilter, and Harper installs no compaction filter — the bottommost level, holding the bulk of the data, is skipped. Converting in place needs a forced bottommost compaction, which is expensive enough to deserve an explicit operator decision rather than happening during an upgrade.Choosing a codec
HARPER_STORAGE_ROCKS_COMPRESSIONselects the codec for every column family the process opens, validated against the build'ssupportedCompressionso an unavailable name fails with a named error instead of a partial startup failure at the first open.It's an env var and not a config key, and resolved once at module load, because RocksDB rejects reopening a family under a disagreeing codec and Harper's worker threads share one process-wide family registry — so every thread must agree. A config value doesn't achieve that; the main thread loads the storage layer during install, before the value is readable, while workers load it after:
__dbis__is then opened at the build default and reopened as the configured codec, and Harper dies withThe system database failed to load. Reproduced on a fresh install with the value in config only; it fails on every boot. Making it a config key needs the value resolvable before the first family opens — a boot-ordering change, deliberately not in this PR, and the constraint is recorded inDESIGN.mdso it doesn't get "improved" into one.copyDb'sopenRocksDbnow resolves the codec the same way.DESIGN.mdalready calledopenRocksDatabasethe single chokepoint and migration sat outside it — it creates the families the runtime later reopens.Benchmark
benchmarks/compression/compares codecs on identical data and workload, one instance per codec. On 1M document-shaped records:Point reads get faster with compression — compressed blocks hold more records per block-cache slot and that outweighs decompression (lz4 nearly doubles point-read throughput at the storage layer). Sequential scans pay decompression with no cache-density offset and get slower.
Two traps the harness guards, both of which produce confidently wrong numbers:
@harperfast/integration-testinginstalls underos.tmpdir(), which is tmpfs on most Linux hosts (every "on-disk" byte becomes a memory measurement); and Harper answers 503 once commits back up paststorage.maxTransactionQueueTime— one run shed ~99% of its writes and still produced a throughput figure and a 67 MB "compressed" size, which reads as a spectacular win. Runs over an error-rate threshold are markedINVALID.Open concerns for the reviewer
toRocksCompressionchange alters behavior for every existing table, which is the point, but it's the line to scrutinize.false/''still map to'none'; an explicit{ algorithm }still passes through untouched; only "enabled, unspecified" changes meaning.db.compact()agrees within 3 points.openRocksDbis a behavior change on the migration path — right side of the stated invariant, but worth a second pair of eyes.Follow-up worth doing: expose forced bottommost compaction in rocksdb-js so an existing database can be converted on an explicit operator decision.
Cross-model review (Codex + Gemini + Grok + Harper-domain) ran pre-push; its findings on a no-op compaction step, a
scanBytesReadrace, thecopyDbbypass, missing validation, committed result JSONs and missing docs are all addressed.Generated by Claude Opus 5.
🤖 Generated with Claude Code