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 4 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.
| isPrimary ? { start, transaction, versions: true } : { start, transaction } | ||
| )) { | ||
| try { | ||
| start = key; |
There was a problem hiding this comment.
If value is null or undefined (e.g., due to a corrupt entry or unexpected state), accessing value.length at line 437 will throw a TypeError and crash the database copy process. Adding a defensive guard to skip null/undefined values ensures robustness.
| start = key; | |
| start = key; | |
| if (value == null) { | |
| skippedRecord++; | |
| continue; | |
| } |
References
- When iterating over array elements that could potentially be null or undefined, include a null-entry guard (e.g.,
if (!entry) continue;) to prevent runtime TypeErrors.
| } from '../resources/databases.ts'; | ||
| import { open, asBinary } from 'lmdb'; | ||
| import { join } from 'path'; | ||
| import { isAbsolute, join, relative } from 'path'; |
There was a problem hiding this comment.
According to the repository style guide, Node builtins must use the node: prefix. Please import from node:path instead of path.
| import { isAbsolute, join, relative } from 'path'; | |
| import { isAbsolute, join, relative } from 'node:path'; |
References
- Node builtins use the node: prefix. These are constraints, not style choices. (link)
|
Reviewed; no blockers found. |
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>
80280e2 to
ee2b9bc
Compare
Human-Review-Need: 4 @ ee2b9bc
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.