Fix copy-db producing a silently corrupt, non-restorable database copy - #2098
Fix copy-db producing a silently corrupt, non-restorable database copy#2098kriszyp wants to merge 9 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request enhances the database copy and compaction processes for LMDB environments to ensure data integrity and prevent silent degradation. Key improvements include requiring a blob disposition strategy to handle file-backed blobs, verifying that shared-structures dictionaries are successfully copied, preserving duplicate keys in dupSort indexes, and correctly routing audit logs to the target environment. Additionally, delete tombstones are now retained if they fall within the audit-retention window. Review feedback recommends adhering to the repository style guide by using the node: prefix for the path import and suggests adding a defensive null check for entry values during iteration.
|
Reviewed; no blockers found. |
80280e2 to
ee2b9bc
Compare
copy-db needs the same per-root, hard-link-else-copy, staged-then-renamed
blob copy that managed backups do, but into a standalone directory beside a
database copy rather than a backup repository.
Extract that core from snapshotBlobs as copyBlobRootsByIndex(destDir, roots)
and add a third blobsReadmeContent variant ('copy') so all blob-layout
documentation stays in one place. snapshotBlobs now stages at
<snapshotDir>.tmp instead of blobs/.tmp-<id>; both are same-filesystem
siblings, so the rename stays atomic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
copyDb()/copyDbi() — behind the `copy-db` CLI verb and storage.compactOnStart — degraded the copy five independent ways and still logged "copied N entries" and exited 0. 1. A `value.length < 14` test treated the primary DBI's shared-structures dictionary as a delete tombstone whenever it was small (short attribute names), so every record in the copy decoded as null. Symbol-keyed entries are no longer classified at all, a tombstone is now identified by decoding with the table's own record decoder (a length can distinguish neither a dictionary nor a small record from a tombstone, and a real delete carrying node-id metadata runs to 17 bytes anyway), and a primary DBI's copy now fails loudly if its dictionary did not land. `decode` returning null is not enough on its own — it also returns null for a record whose shared structure is missing on this node — so the classifier requires the metadata-bearing decode only a real tombstone produces and keeps anything it cannot prove. 2. `getKeys()` + one `getEntry()` per key yielded a single entry per unique key, collapsing every dupSort secondary index to one entry per value. Both primary and index DBIs are now walked with one `getRange` pass, which yields every duplicate (and drops the second lookup per key). 3. The blob store was never copied. Blob files live outside the environment and are addressed by database *name*, so the copy was unreadable anywhere but its origin. `blobs` is now a required argument, since both answers are silently destructive when wrong: `'copy'` copies each root to <target>-blobs/<rootIndex>/ with a README documenting the restore mapping, `'preserve-source-roots'` leaves them in place and is only sound when the copy replaces the source in place — which compactOnStart now enforces by skipping any database whose tables span more than one environment. 4. The audit store was copied into the *source* environment (`rootStore`, not `targetEnv`) through a fresh handle the write-guard never covered, and the call was not awaited. In practice every entry failed to re-encode and was swallowed, so the copy got no audit log at all. Both handles are now opened on the correct environment, raw, and the copy is awaited. 5. Every recognised tombstone was dropped regardless of age. The runtime only removes one past `auditRetention`; dropping a live tombstone loses the delete, letting a peer that missed it resurrect the record. The copy now uses the same retention cutoff, fixed once per copy. Silent-success paths that made all of the above exit 0 now fail: a per-record copy error, an exhausted resume, an unresumable key type, and a pre-existing target (which was opened and merged into) all throw, the retry bound drops from 10 million to 1000, a partial copy is removed, and compactOnStart treats a failed backup as fatal instead of overwriting the only good copy. The regression tests never ran: the suite gated on a config value that is unset under mocha, so it skipped in both engine runs. Fixed, which also surfaced two pre-existing failures. The 85%-compaction assertions were measuring the audit log being dropped, and are replaced with copy-not-larger-than-source plus record-readability checks. Fixes #2048 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…edges - compactOnStart rolled back every database it had *started*, using a fixed backup path per database. A retained backup from an earlier run could therefore be moved over a database whose compaction failed before taking its own backup, replacing healthy data with a stale snapshot. Rollback now only restores a backup this run created. - copyDbi's resume advanced the cursor on an iteration error (a string key bumped to `<prefix>z`), skipping every key in between and then reporting success — the same silent degradation this change exists to remove. It now retries from the last key read, and a key it cannot get past fails the copy. - A record whose put fails now stops that DBI immediately instead of logging once per remaining record. - Failure cleanup removed `<target>-blobs` even in preserve-source-roots mode, where the copy never created it; a pre-existing blob companion is now rejected up front and only removed when this call wrote it. - Source writes are no longer no-op'd before the validation throws, so a caller that catches a rejected copy keeps working stores. - Adds the end-to-end proof the suite was missing: restore the copy plus its blob directory under a different database name and read the attachment back byte-exact. Comment volume pruned to the invariants the code cannot state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md forbids `node:assert/strict` and new sinon usage in tests. The sandbox only stubbed `updateConfigValue`, which this suite never reaches (it drives copyDb, not compactOnStart), so sinon goes entirely; retention now moves through its setter rather than the exported binding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Refuse compact-on-start when its staging path is occupied by the registered <database>-copy environment, preventing an untracked deletion of user data. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Treat any existing staging path as operator-owned or recoverable state instead of deleting it, including restore-blocked and failed-to-load databases. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
…igured blob roots
isWithin() used a raw startsWith('..') check on the relative path, so a real child
directory whose name happens to start with those two characters (e.g. a copy target
literally named `..copy.mdb`) was misclassified as outside the source blob root. That
let copyDb() place a copy's blob destination inside the very blob root it walks,
letting the walker discover its own output and recurse. Compare only the first path
segment instead.
Also warn (rather than silently proceed) when a configured blob root does not exist at
copy time, since that's ambiguous between "never had blobs" and "an unmounted/missing
root", and the latter would otherwise produce a copy quietly missing blobs.
Found by the pre-push review CLI (codex) while rebasing PR #2098 onto main.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…arify compaction skip message The overlap guard only checked the final blob destination against each blob root, but a failed copy also removes targetDatabasePath, its -lock sibling, and the blob staging directory's .tmp sibling — any of those landing inside a live blob root is just as destructive as the final destination doing so. Check all of them up front. Also note in the occupied-compaction-target skip message that this run already disabled storage.compactOnStart, since the prior wording only said to remove the leftover file and retry, which silently never re-runs without also re-enabling the flag. Found by the pre-push review CLI (harper-domain adjudication) while rebasing PR #2098. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
b633732 to
162cc49
Compare
| }); | ||
| await CollisionSourceTable.put({ id: 'source' }); | ||
| await CollisionTable.put({ id: 'preserved', value: 'must survive compaction' }); | ||
| assert.ok(getDatabases()['collision-source-copy'], 'the colliding database should be registered by name'); |
There was a problem hiding this comment.
Suggestion (non-blocking): This test asserts the colliding database is registered (getDatabases()['collision-source-copy']) before compacting. Both the old copyDatabaseRootStores/relative(...) === '' check and the new existsSync(copyDest) check protect a registered database whose path matches exactly, so this test would still pass with faf06aafb ("Fail closed on occupied compaction targets") reverted — it doesn't exercise the gap that fix actually closed (an unregistered occupant at the target path: a stray leftover, a failed-to-load, or restore-blocked database, none of which appear in getDatabases()). Consider adding/adjusting a case that writes an arbitrary file or directory at copyDest without registering a database there, to prove the new existsSync-based check (not just the old registration-based one) is what's guarding the path.
| hdbLogger.warn(message); | ||
| console.warn(message); | ||
| } | ||
| if (populatedRoots.length === 0) return; |
There was a problem hiding this comment.
Suggestion (non-blocking): When every configured blob root is missing, this only warns and then returns (copy reports success with no blobs). Per the PR's own framing, this is ambiguous between "never had blobs" and "an unmounted/misconfigured root" — the latter produces exactly the kind of silently-degraded, non-restorable copy this PR set out to fix. Consider failing closed by default here (throw) with an explicit opt-in for the "I know this database never had blobs" case, rather than a warning an unattended/scripted copy-db run won't see.
Fixes #2048.
copyDb()/copyDbi()inbin/copyDb.ts— behind thecopy-dbCLI verb andstorage.compactOnStart— degraded the copy five independent ways while loggingcopied N entriesand exiting 0. Each is a separate defect in the same ~145-line function; each is fixed on its own terms rather than by patching the one heuristic they shared.value.length < 14tombstone test also matched the primary DBI's shared-structures dictionary when it was small (short attribute names), so every record in the copy decoded asnullgetKeys()+ onegetEntry()per key yields one entry per unique key, collapsing every dupSort secondary indexgetRangepass per DBI, which yields every duplicate (and drops the second lookup per key)blobsis now a required argument:'copy'writes each root to<target>-blobs/<rootIndex>/with a README;'preserve-source-roots'is for the in-place replacementcompactOnStartdoesrootStore, nottargetEnv), un-awaitedauditRetentioncutoff the runtime uses, fixed once per copyChannel 5 is not in the issue; I found it while fixing channel 1. Dropping a live tombstone loses the delete, so a peer that missed it can resurrect the record.
Two things worth knowing about the classifier: a length cannot decide this question in either direction (a real delete carries node-id metadata and runs to 17 bytes, past the 14 the old test used), and
RecordEncoder.decodereturningnullis not sufficient either — it also returnsnullfor a record whose shared structure is missing on this node (resources/RecordEncoder.ts:470). So the classifier requires the metadata-bearing decode only a real tombstone produces and keeps anything it cannot prove.Silent-success paths. A fix for "produces a corrupt copy and exits 0" cannot keep the paths that made that possible, so these now fail the copy: a per-record error, an exhausted resume, a target that already exists (it was opened and merged into), and a failed compaction backup (it overwrote the only good copy). The retry bound drops from 10,000,000 to 1,000, resume no longer advances past the key it failed on — it used to bump a string key to
<prefix>z, skipping everything in between and then reporting success — and a partial copy is removed.compactOnStartnow skips a RocksDB database, skips one whose tables span multiple environments (it would relocate tables and strand blobs), and rolls back only backups it created this run.Where to look
bin/copyDb.ts:394isDeletedRecord— the classifier, and the one place a bug still silently deletes data. It runs only for values whose trailing byte is msgpack nil.bin/copyDb.ts:476the resume path. Retrying from the same key relies on a re-put of identical bytes being a no-op (and a dupSort pair being a set). A permanently unreadable key now fails the copy after 1,000 attempts instead of skipping a key range.bin/copyDb.ts:206useRawBytes. This mutates the handleopenDBreturned. It is safe because lmdb-js constructs a newLMDBStoreperopenDBcall (node_modules/lmdb/open.js:415, no instance cache) — the pre-existing code depended on the same fact. Both outside reviewers' first pass flagged this as mutating the live store; it does not, and the liveprimaryStorethe classifier decodes with keeps its decoder.dataLayer/blobBackup.ts—snapshotBlobs's copy core is extracted ascopyBlobRootsByIndexand its staging directory moves fromblobs/.tmp-<id>to<snapshotDir>.tmp(same filesystem either way, so the rename stays atomic).blobsReadmeContent'sarchiveboolean becomes avariant.unitTests/bin/copyDB.test.js:26— this suite never ran: it gated on a config value that is unset under mocha, so it skipped in both engine runs. That is why none of this was caught. Fixing the gate surfaced two pre-existing failures, and the85%-compaction assertions turned out to be measuring the audit log being dropped — they are replaced with copy-not-larger-than-source plus record readability.Verification
Route (b), new integration-grade regression coverage in the unit suite (it drives real LMDB environments, real tables and real blob files), plus (a) the repaired existing suite.
unitTests/bin/copyDbIntegrity.test.js(10 cases) —HARPER_STORAGE_ENGINE=lmdb npm run test:unit:bin→ 178 passing; default engine → 156 passing, 7 pending;npm run test:unit:backup→ 77 passing;npm run test:unit:dataLayer→ 245 passing.Reads go to the copy at its own path, opened with the same
OpenDBIObjectthe runtime uses. Swapping the copy over the source path proves nothing: lmdb-js returns the already-open environment for a path, and the live stores answer point reads from cache — an early version of these tests passed for that reason.Fails-on-base (same tests against
origin/main, quantified):-blobs)Illegal extended typewhile being written into the sourcenpm run test:unit:maincannot run on this machine (a local Harper instance holds the RocksDB system lock; it fails at module load, before any test) — relying on CI for it.Open items
AGENTS.mdforbids new sinon/stub-based tests, and I could not reach those paths through the real modules without stubbing, so I left it rather than take the shortcut. The paths are small and the review verified them by reading.compactOnStartcan now fail a startup where it previously produced a bad copy and continued. That is intended, and the config flag is cleared before the work, so it will not retry-loop.Generated by Claude Opus 5. Reviewed pre-push by Codex (graded) + Gemini + a Harper-domain adjudication pass; two Gemini blockers were verified false and dropped (documented above), and every kept production finding is fixed in
4ecb258.Rebase note (2026-08-26)
Rebased onto latest
main(was 214 commits behind;mainhad independently landed thesame blob-classification feature this PR also touches in
dataLayer/blobBackup.ts).Conflicts were in
dataLayer/blobBackup.tsonly (thecopyBlobRootsByIndexextractionthis PR's first commit adds vs.
main's own classify/substitute-marker feature landing inthe same function) — resolved by keeping both: the reusable
copyBlobRootsByIndex(destDir, blobRoots)signature this PR needs forcopy-db, generalized so its warning message nolonger references a
backupIdthat's out of scope for a non-backup caller.While rebasing, the pre-push review CLI (codex + gemini + cursor-grok + a Harper-domain
adjudication pass) surfaced two more, unrelated real bugs on top of the rebase, both fixed
and covered by a regression test:
isWithin()'sstartsWith('..')containment check was character-based, notsegment-based, so a real child directory whose name happens to start with
..(e.g. acopy target literally named
..copy.mdb) was misclassified as outside the source blobroot — letting a copy's blob destination land inside the very root it walks. Fixed to
compare only the first path segment; regression test added
(
unitTests/bin/copyDbIntegrity.test.js: "rejects a blob-copy target inside the sourceblob root even when its name starts with '..'").
paths a failed copy actually removes (
targetDatabasePath, its-locksibling, theblob staging directory's
.tmpsibling) — widened to check all of them.For the human reviewer
The domain-adjudicated review left these open (not fixed here — out of this rebase's
scope, and some are pre-existing / design tradeoffs the PR author should weigh in on):
and returns success rather than failing closed (
bin/copyDb.tscopyDatabaseBlobs).Ambiguous against the legitimate "no blobs yet" case, so I only added a warning rather
than unilaterally deciding to fail closed.
non-atomic
existsSynccheck, not an atomic reservation or stage-then-rename; twoconcurrent
copy-dbinvocations to the same target can race.realpath),so a symlinked path component could in theory alias into a blob root.
(
sourceAuditStoreends up falsy for it), so its copy silently ships with no audit log.directory; a crash between the copy and the two renames leaves a
<db>-copy.mdbthatthe boot scanner loads as a live database.
verifyStructuresCopiedawaiting aDBI-shared
writtenpromise as possibly stale across DBI boundaries(
bin/copyDb.tsaround thecopyDbi/verifyStructuresCopiedclosure). I traced thecontrol flow and believe it's correct —
writtenis only read synchronously rightafter its owning
copyDbicall resolves, before anything else can reassign it — butflagging for a second pair of eyes since domain adjudication didn't run on this delta
round to confirm.
Pre-existing (not introduced by this rebase, reproduced on
main-tip base too):unitTests/bin/copyDbIntegrity.test.jshas 3 failing tests around tombstoneversion/localTime preservation and audit-log-into-target — same 3 failures on the
unrebased branch tip against its old base.
Independent review: codex (graded) + gemini + cursor-grok + harper-domain, round 1 full;
codex + gemini, round 2 delta. Receipt at
162cc49b2609.Review-Coverage: authored=claude; ran=codex,gemini; declined=cursor-grok,cursor-composer,domain; rounds=2 @ 162cc49
Human-Review-Need: 4 @ 162cc49