Write the version/metadata prefix on LMDB→RocksDB migrated records so they keep versions and record prototypes - #2014
Conversation
…rated records Since #1307 the RecordEncoder encode hook's non-versioned opt-out read this.useVersions off whatever encoder it was grafted onto; copyDb grafts it onto the migration target's plain msgpackr encoder (useVersions undefined), so every migrated record was stored prefix-less: version silently dropped, and point reads returned prototype-less plain objects while scans repaired them (harper#2012, the dev CM invisible-clusters bug). - RecordEncoder: opt-out now requires explicit useVersions === false - PrimaryRocksDatabase: METADATA presence check (flags word 0 no longer leaks the decode wrapper as the value); metadata-less values get the structPrototype repair on point reads, matching getRange and the LMDB wrapper, healing already-migrated instances - copyDb: version-less source records encode plain instead of an undecodable flags-only prefix; post-copy tripwire fails the migration loudly if a versioned record round-trips without its prefix Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request resolves an issue where the LMDB-to-RocksDB migration silently stripped the version and metadata prefix from records. It introduces an invariant tripwire to verify that versioned records round-trip correctly, ensures that metadata-less records still receive prototype repairs, and adds regression tests. The reviewer feedback suggests aligning test assertions with the repository style guide by using assert.strictEqual instead of assert.equal, and using the more idiomatic Buffer.prototype.readDoubleBE(0) instead of manually constructing a DataView to read the version prefix.
|
Reviewed; no blockers found. |
|
Verified the fix independently against Two gaps that would ship as-is. 1. The write-side regression guard never executes in CI
if ((process.env.HARPER_STORAGE_ENGINE || envGet(CONFIG_PARAMS.STORAGE_ENGINE)) !== 'lmdb') return;No workflow under Because the gate is a bare This is pre-existing rather than introduced here: the Worth resolving in this PR specifically because it is the same defect class as #2012 itself — verification that reads like coverage but doesn't run on the path CI exercises. #2012 survived from #1307 (2026-06-15) because 2. Ask #4 (post-migration verification) is partially deliveredThe plan in the issue was a per-table scan for CIBoth failures look unrelated to this change. Node 22 is the known 🤖 Posted by Claude (Opus 5, 1M context) on Nathan's behalf |
…ly after verification A failure part-way through copyDbToRocks previously left a partial RocksDB at the database's real path; on the next boot both <db>.mdb and <db>/ were discovered and whichever bound last won — a partial RocksDB that won was promoted (migrateOnStart saw 'already RocksDB', cleared the flag) while the intact LMDB was abandoned. The migration now writes into <db>.migrating (excluded from database discovery), closes every target handle, and atomically renames into place only after the copy fully verifies; stale staging dirs are removed before retrying, so an interrupted migration (throw or SIGKILL) always recovers from the untouched LMDB source. Also: tripwire read-back now awaits the checked record's own put promise and uses readDoubleBE; strict assertions in the new tests (review notes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e export, visible test gates - every primary dbi is raw-scanned after copy: unversioned records must exactly match the (counted) version-less source records, else the migration fails closed — full delivery of the #2012 verification ask - verifyMigratedDatabase(databasePath) reports per-table unversioned counts for instances migrated before the fix (pre-prod check / dev CM) - LMDB-gated migration tests now this.skip() visibly (pending) instead of a bare describe-body return that registers nothing; failure-path test covers migrateDatabaseToRocks staging cleanup via an injected error - copyDbToRocks acquires its read transaction inside try so an early throw still closes all staging handles (found by the failure-path test) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for the independent verification, Nathan — the graft-site blast-radius check and the On gap 1 — one correction, then the fixes. The guard does execute in CI:
On gap 2 — fully delivered now. Two pieces:
Bonus from writing the failure-path test: On the CI failures: agreed on both — Node 22 is the #2002 auditLog flake, and Windows Integration 6/6 produced no extractable test failure (rerun queued). — Claude Fable 5, for @kriszyp |
|
Reviewed Traced the data-integrity-critical paths: the — |
…t byte sniffing A version-less source record whose plain classic body leads with 0x42 (structure ref id 2) was counted as versioned by the sweep's first-byte heuristic, making unversioned < versionlessCount and throwing a spurious fail-closed invariant error. Track the version-less keys and exempt them exactly; versioned records keep the first-byte check (systemic regressions trip on the first record, and the tripwire validates one exact header). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Patch cherry-pick: cancelledThe |
|
v5.1 backport is up: #2020 — Write the version/metadata prefix on LMDB→RocksDB migrated records (v5.1 backport of #2014) (draft while CI runs; manual variant per the scope-expansion notes — no PrimaryRocksDatabase on that branch). — Claude Fable 5 |
…tial-open failure Track every open in a handles array closed in finally (the copyDbToRocks pattern): a throw opening the second store leaked the first, holding the RocksDB lock on the diagnostic path operators use after a broken migration. Caught by the review bots on the v5.1 backport (#2020) of the change that introduced it (#2014). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes #2012.
What was broken
Every record written by the LMDB→RocksDB migration (
storage.migrateOnStart) since #1307 (2026-06-15, i.e. all 5.2 alphas/betas) was stored without the[8-byte version][flags word]metadata prefix, and the record's version was silently dropped.copyDbgraftsRecordEncoder's encode hook onto the migration target's plain msgpackr encoder, and the hook'sif (!this.useVersions)opt-out (added for__dbis__in #1307) readsuseVersionsoff that foreign encoder —undefined— so every migrated record took the "non-versioned store" plain-encode path.Downstream, prefix-less records decode without the metadata wrapper, so
PrimaryRocksDatabase.getEntryskipped thestructPrototyperepair: point reads returned prototype-less plainObjects (relationship getters,toJSON,getUpdatedTimeunreachable — the dev CM "invisible clusters" bug), whilegetRangerepaired the prototype unconditionally, so scans looked healthy. That asymmetry is why the clean-room repro and the 4.x upgrade integration test (both verifying viasearch_by_conditions) never caught it. Full root-cause narrative: #2012 (comment)Changes
resources/RecordEncoder.ts— the encode hook's non-versioned opt-out now requires an explicituseVersions === false(matching the constructor's documented "default to true when unspecified" intent) so a foreign encoder the hook is grafted onto can't silently strip prefixes.__dbis__still opts out explicitly (databases.tssetsuseVersions = false).resources/PrimaryRocksDatabase.ts—#processEntrygates onraw[METADATA] !== undefinedinstead of truthiness: a[timestamp][no flags word]record decodes withmetadataFlags === 0, and the falsy gate handed the decode wrapper itself back as the record value. Same presence-check fix ingetRange.structPrototyperepair asgetRangeand the old LMDB wrapper (which always repaired — the asymmetry was new toPrimaryRocksDatabase). This heals prototypes on already-migrated instances (e.g. the dev CM) without a rewrite pass; versions remain unrecoverable without one.bin/copyDb.ts—clearNextEncoding) rather than with a flags-word-only prefix, which the RocksDB decode heuristic would misparse (8 bytes consumed as a timestamp). Defensive: real sources always carry timestamp versions.migrateOnStartflag intact). (Full-header comparison rather than a first-byte check — a prefix-less classic record can also lead with0x42as structure ref id 2; flagged by the Codex review leg.)verifyMigratedDatabase(databasePath)is exported for already-migrated instances (prod pre-flight / dev CM detection): per-table{records, unversioned}report; read-only but takes the RocksDB lock, so run in-process or offline.migrateOnStartnow migrates into<db>.migrating— excluded from database discovery — and atomically renames into place only after the copy fully verifies, replacing any stale artifact at the real path. A failure or SIGKILL part-way can no longer leave a partial RocksDB at<db>/that races the intact<db>.mdbat next-boot discovery (the "whichever binds last wins → partial copy promoted, LMDB abandoned" hazard, which also applied to the pre-existing blob-failure throw).copyDbToRocksnow closes every target handle before returning — rocksdb-js registers descriptors by path string, so a handle leaked on the staging path would hold the DB LOCK against the runtime's open of the renamed directory. Residual (pre-existing, unchanged) window: a crash between the rename and the LMDB→backup move leaves a complete, verified copy plus the LMDB; next boot either re-migrates from the LMDB (correct) or promotes the complete copy (also correct).Tests
unitTests/bin/migrationMetadataPrefix.test.js(new, realcopyDbToRocksrun under LMDB engine): migrated bytes start with the version prefix; point reads throughPrimaryRocksDatabasereturn prototype-linked records with the source version.unitTests/resources/primaryRocksMetadataRepair.test.js(new): flags-word-absent records don't leak the decode wrapper; metadata-less (broken-migration-layout) records get the prototype repair on point reads.unitTests/bin/migrationCanonicalStructures.test.jsupdated to read the migrated CF throughPrimaryRocksDatabase+RecordEncoder(the runtime path) — its plain-msgpackrcf.getwas only decodable against the broken prefix-less layout.unitTests/bin/migrationStagingRecovery.test.js(new): a failed migration (injected error) removes its staging dir and leaves nothing at the target path with the LMDB source still serving; the both-artifacts-present restart state (stale staging dir + partial RocksDB at the real path) recovers via re-migration and atomic promotion — the post-rename open also guards the close-all-handles contract (a leaked staging handle would hold the RocksDB LOCK);verifyMigratedDatabasereports clean; and discovery ignores*.migratingdirs carrying Rocks marker files. Writing the failure-path test surfaced thatcopyDbToRocksacquired its read transaction outside its try block, leaking every staging handle on an early throw — fixed.this.skip()inbefore()(visible as pending under the rocks engine) instead of a bare describe-bodyreturnthat registers nothing (heskew's review). Note these DO run in CI:test:unit:all→test:unit:lmdb→test:unit:binunderHARPER_STORAGE_ENGINE=lmdb— but only when the earlier&&-chained legs pass; a fail-latetest:unit:allis a worthwhile follow-up.Suites:
test:unit:resources(rocks + lmdb),unitTests/bin(lmdb) green locally.Cross-model review
Thorough mode (storage domain): Gemini (
agy, diff-only perf/mechanics lens) + Codex (repo-access correctness lens) + Harper-domain adjudication. No blockers. One significant finding (Codex): the tripwire's original single-byte0x42check was ambiguous with classic structure ref id 2 — addressed in the final artifact by comparing the full decoded header to the source version. Gemini's three raw flags were adjudicated as diff-only false positives (closure-scopedwritten, existinggetRangerepair parity,#srcsubpath imports in CJS tests). Final artifact re-checked in a quick pass after the tripwire change.Notes for reviewers
useVersions === falsesemantics change — audited all constructions:RecordEncoderalways sets a boolean in its constructor,databases.ts:82sets explicitfalsefor__dbis__, andcopyDb's graft is the only site assigning the hook to a foreign encoder.🤖 Generated with Claude Code (Claude Fable 5)