Repository navigation
Refuse an engine-only restore over a database that still has blobs - #2646
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a safety check to prevent accidental in-place restores of engine-only backups (backups created with exclude_blobs) over databases that still contain blob files. It adds the blobRootsHaveFiles and assertEngineOnlyRestoreAllowed helper functions in dataLayer/blobBackup.ts to detect existing blobs and enforce this restriction unless the operator explicitly opts in by passing allow_engine_only=true. The online and offline restore paths in dataLayer/rocksdbBackup.ts and bin/backup.ts have been updated to validate and handle this new option, and comprehensive unit tests have been added to verify the behavior. There are no review comments to address, and the changes conform to the repository's style guidelines.
ba6faa1 to
4564c09
Compare
b48edde to
83b6434
Compare
4564c09 to
2648289
Compare
83b6434 to
f2e043d
Compare
2648289 to
689ce2a
Compare
f2e043d to
8a90d1a
Compare
689ce2a to
0cabed0
Compare
8a90d1a to
a707050
Compare
0cabed0 to
c81b4ab
Compare
a707050 to
7384fa3
Compare
a97f765 to
8405bb2
Compare
3204c62 to
ef745d9
Compare
8315e89 to
b951da6
Compare
ef745d9 to
a3dc599
Compare
b951da6 to
750ffa7
Compare
a3dc599 to
0fb92ce
Compare
53ea575 to
bdd847d
Compare
c4b37c1 to
c942563
Compare
c942563 to
7984559
Compare
bdd847d to
6114583
Compare
44f7a91 to
30f0640
Compare
kriszyp
left a comment
There was a problem hiding this comment.
A good safety measure.
🤖 Reviewed with Codex
…re-blob-guard # Conflicts: # server/itc/serverHandlers.js # server/jobs/jobOwnership.ts # server/loadRootComponents.js # server/threads/itc.js # server/threads/manageThreads.js # unitTests/server/jobs/jobOwnership.test.js # unitTests/utility/signalling.test.js
… empty root Three findings from kriszyp's review, all real: 1. The fence was a database-wide boolean. Two restores of the same database can overlap -- a restore releases its lock before awaiting its reload broadcast -- so the first one's late reload lifted the second one's fence and saves were admitted during its post-close check and purge. The fence is now keyed by a per-restore token carried on both phases of the schema event; a worker releases only the token it was given, and the database stays fenced while any token is outstanding. 2. The fence covered saves but not deletions. A pendingReclamation entry is a timer that never consults the database, so one queued before the restore would unlink the bytes the restore had just written at that same path; a dispatched unlink could likewise land after the walk. Reclamation now skips a fenced database, the fence waits out unlinks already dispatched, and when the last token is released that database's queued entries are discarded rather than resumed -- they condemn paths belonging to the generation the restore just replaced. 3. An empty destination root was treated as proof that an engine-only restore was safe. It is not: getNextFileId re-seeds the per-database id counter by scanning the roots, so empty roots re-seed at 1, which is the id space the restored records already reference -- the next blob written lands on a path one of them points at. A brand-new target is not exempt, for exactly the same reason. The guard now refuses any blob-excluding backup without allow_engine_only; the destination is still inspected, but only to say which of the two hazards applies. Point 3 is a behavior change: an engine-only restore now always requires the opt-in, where before it passed silently into an empty or new database. The durable alternative is to persist the blob-id high-water mark with the engine so the counter cannot fall behind the references it has handed out; that is noted in the code as what would let the opt-in be dropped. Both barrier fixes carry the regressions that were asked for, and the reclamation one was verified by removing the fix and watching it fail.
…rop the walk The cross-model planning review returned better-alternative-exists and was right on the central point. **A token set makes a failed restore unrecoverable.** A restore that dies after destruction never broadcasts reload, so its token is never released; a recovery restore then succeeds and releases only its own, leaving the database fenced for the life of the process -- contradicting the rerun the error tells the operator to perform. I had argued this was acceptable in a review reply; it is not, and it was a regression against the boolean it replaced. The fence is now a single current owner per database: a later close takes ownership without ever unfencing, a superseded restore's late reload is a no-op, and the rerun's reload clears it. **The fence did not cover every blob-root mutation.** cleanup_orphan_blobs is dispatched fire-and-forget and its verdict -- nothing references this path -- is reached against the generation being replaced, so it now stops when it finds the database fenced. The save-failure cleanup unlink is registered like any other deletion so the fence drains it. Both were paths I had explicitly deferred; they are the same defect as the reclamation timer. **Hot-path accounting.** Tracking unlinks as a set of promises allocated on every deletion even with no restore in sight. It is a counter with waiters now, so the no-restore path allocates nothing beyond the lookup. **The guard no longer touches the filesystem.** With refusal unconditional, walking the destination only to word the error could turn a deterministic 400 into a slow traversal or a raw EACCES. Both hazards are named in one message instead, and blobRootsHaveFiles is gone with its last caller. That also makes the post-fence recheck redundant -- the decision reads the manifest and the opt-in, never the destination -- so each path checks once. **Compatibility.** The generated backup README still told operators to restore into a new database, which this change makes a 400. It now explains why that is not an escape. Tests: a failed-restore-then-rerun case for the ownership fix, and the reclamation regression rewritten to assert both halves deterministically (fence held past the deadline, then discarded) rather than racing a timer. The restore-fence tests move after the orphan assertions, because lifting a fence discards that database's queued reclamations by design and would otherwise strand files the orphan sweep is asserted not to find.
…rd' into fix/engine-only-restore-blob-guard
Chris narrowed this to "blob saves and deferred reclamation" in fc70819, which was accurate at the time: orphan cleanup and the save-failure unlink were outside the fence. This commit's predecessor brought both in, so the broader claim holds again -- stated as the three concrete paths rather than "every blob-root mutation", since naming them is checkable and the sweeping version was what went stale.
**Blocker.** Orphan cleanup checked whether the database was fenced *right now*. A sweep awaits an unlink between files, and an entire restore can start and finish inside that await -- leaving the fence clear again by the next iteration, so the sweep resumes deleting on a verdict reached against the generation that restore replaced. It now captures a per-database restore generation before its first deletion and abandons the pass if anything has taken the fence since, rather than asking a question whose answer it can miss entirely. **Aborting the sweep leaked its locks.** Repair-temp locks are acquired for every candidate up front, so the paths the loop never reached kept theirs; nothing releases them until the process restarts. The abort path now unlocks what it skipped. **A restore that destroyed nothing must not discard reclamation.** The fence release dropped a database's queued reclamations unconditionally, but a restore that fails its admission checks leaves the generation intact -- those entries still condemn the files they were queued against, and dropping them stranded every superseded blob the database had pending. The reload phase now carries whether the generation was actually replaced, and only then are they discarded. **A corrupt manifest no longer waves the guard past.** The blobs field is on-disk data, so it is tested strictly rather than for truthiness. **The offline opt-in is validated, not coerced.** restoreBackupOffline compared with === true, so a malformed allow_engine_only was silently read as "no" while the online path rejects it. Both now go through requireBooleanOption. Two Gemini findings were disproved rather than adopted, each by a line: storageInfo is declared at resources/blob.ts:1588 as pending.fileInfo (the value the finding proposed switching to), and requireBooleanOption returns false for undefined, so omitting the flag does not break existing clients. The CLI JSON-parses key=value, so a real boolean does arrive -- but a typo did not, which is what the offline validation above fixes. Also trims the comment narration both lenses flagged.
…rd' into fix/engine-only-restore-blob-guard
A restore that fails after destruction left its fence held for the life of the process. The suggestion was to give the token a lease so it expires on its own. Declined, and fixed the other way. A lease is the wrong shape for this barrier. Its whole job is to hold until the restore says it may stop; a deadline that elapses during a slow restore -- a large database, a loaded disk -- would silently unfence mid-purge and re-admit the blob saves the fence exists to exclude. That trades a loud, already-signposted failure for a quiet correctness hole. And the failure it was protecting against is mostly already covered: a database with an abandoned restore marker refuses to load at all (resources/databases.ts:2355-2363), with a 409 that names the rerun. An operator never reaches the 503 the suggestion describes. What is genuinely uncovered is the operator who gives up on the rerun and drops/recreates the name instead. There is no marker then, and the recreated database would inherit a fence no restore owns. So the failure path now broadcasts its reload on the way out, which releases the fence deterministically at the moment the restore actually ends. A broadcast failure there is logged rather than allowed to mask the restore error, since the fence is worker-local state that a process restart clears anyway. The release reports the generation as replaced, because destruction may have begun: forgoing a deletion only leaks a file for the orphan sweep to collect, while performing a stale one destroys restored bytes.
…its deletions Three findings from the delta review, all in the fence code added on this branch. The generation check was captured after the scan that decides which paths are orphans, so a restore landing during the scan itself left the epoch unchanged and the sweep deleted on a verdict that restore had already invalidated. It is captured at entry now, before the scan. Abandoning the sweep copied the whole candidate set to find the locks it still had to release. This function is built to run for hours over very large roots, so duplicating that set is a heap risk precisely when the sweep is biggest. It walks the original instead, releasing locks as it passes and counting what it skipped. Lifting a fence did not wake the reclamation queue. A drain that ran while the fence was up skipped those entries without recording a next deadline, so an aborted restore kept its entries -- correctly -- and then nothing ever came back for them. The release now schedules a run when anything is pending. The kept-reclamation test drew the fence long enough for the drain to skip the entry first, so it now fails without the wakeup rather than passing on timing.
Opting job workers into the restore close broadcast fenced them, but the reload broadcast fell through to the ordinary path, which excludes them. So every job thread took the fence and never got it back: blob saves on that database refused for the life of the process, which is the same stuck-fence class fixed a commit ago for the main path -- introduced by the close-phase inclusion itself. Both phases now reach job workers. The release stays best-effort rather than strict: the restore has already done its work by then, and must not be reported as failed because one worker was slow to take its fence back off.
Both the JSDoc and the design note still said the fence lifts "when the last token is released" and stays held "while any token is outstanding". That is the token-set design, which was abandoned precisely because a restore that dies after destruction never reloads and its token would fence the database for the life of the process. The code stores one current owner, so there is never more than one token outstanding. Left as stale prose this is worse than no comment: it describes the shape whose flaw the code was changed to avoid, and a reader reconciling the two would most likely believe the note.
restoreBlobSnapshotleaves the live blob roots untouched and logs a warning when a backup has no blob snapshot. That is right in isolation — purging them would strip blobs the restored records still reference — but it means restoring anexclude_blobsbackup silently produces a mixed generation: rolled-back records addressing whichever blobs are on disk now. The operator got a log line and a successful restore.That restore is now refused unless the caller passes
allow_engine_only, and the restore itself runs behind a fence that stops every blob-root mutation while it purges and rewrites.For the human reviewer
restore_backupagainst anexclude_blobsbackup now fails untilallow_engine_onlyis added. Naming a freshtarget_databaseis not an escape, which is the part that changed late: blob ids are a per-database counter thatgetNextFileIdre-seeds by scanning the roots, so empty roots re-seed at 1 — the id space the restored records already reference — and the next blob written lands on a path one of them points at. An empty root is not evidence that a backup is blob-free.closeandreloadphases. A save is an async file pipeline that outlives the handle it started from; a reclamation is a timer that never consults the database; an orphan sweep is dispatched fire-and-forget. All three are fenced.reloadbroadcast. A flag lets the first one's late reload unfence the second. A set is worse in the case that matters: a restore that dies after destruction never reloads, so its token would keep the database fenced for the life of the process — defeating the rerun the error tells the operator to perform. Ownership transfers instead.Changes
dataLayer/blobBackup.ts—assertEngineOnlyRestoreAllowedrefuses any blob-excluding backup without the opt-in, decided from the manifest alone, naming both hazards in one message.dataLayer/rocksdbBackup.ts— both restore paths check once before anything destructive; the restore mints an owner token carried on both schema phases, and thereloadphase reports whether the generation was actually replaced;validateRestoreBackupand the offline path share one boolean validation; the generated backup README explains why a new target is not an escape.resources/blob.ts— the owner fence, the restore generation, and fencing for saves, deferred reclamation, save-failure cleanup and orphan cleanup. Unlink tracking is a counter with waiters, so the no-restore path allocates nothing beyond a map lookup.server/itc/serverHandlers.js,server/threads/itc.js,utility/signalling.ts— the close broadcast is strict, bounded at 30s, and includes job workers, because they write blobs too; a close-barrier failure fails the restore with a retryable 409 rather than proceeding or hanging.dataLayer/DESIGN.md— the barrier and owner-token protocol.Verification
unitTests/resources/blob.test.js(129 passing) covers the save drain, owner replacement, rerun recovery after a restore that died mid-flight, the reclamation discard, the aborted-restore case that must not discard, the generation change, and the fenced orphan sweep.unitTests/dataLayer/(74 passing) covers the guard directly and through real offline restores.unitTests/server/itc/,unitTests/utility/signalling.test.jsandunitTests/server/threads/stuckWorkerDiagnostics.test.js(67 passing) cover the strict broadcast and handler ordering.Fails-on-base performed for the reclamation fence: removing the fence skip and the generation discard makes
a reclamation queued before a restore does not unlink what the restore wrotefail, and restoring them makes it pass.Known gaps, stated rather than implied:
signalSchemaChangeusesPromise.allover the local close and the bounded peer broadcast, so a peer timeout can reject while a local close handler is still draining. Not addressed.cleanup_orphan_blobsis still dispatched without a rejection observer (pre-existing).Complexity: complicated
Framing-Verdict: chosen-approach-sound
The planning review first returned
better-alternative-existsand was right: it identified that a token set makes a failed restore unrecoverable without a process restart, and that the fence did not cover orphan cleanup or save-failure deletion. Both were adopted — the fence became a current owner and the deletion paths were brought in — and the re-run cleared.Fence release, not a fence lease. A review suggestion asked for an expiry on the restore fence so a failed restore could not leave a database fenced for the life of the process. Declined: a deadline is the wrong shape for this barrier, because one elapsing during a slow restore would silently unfence mid-purge and re-admit the saves the fence exists to exclude. Most of the harm was already covered — an abandoned restore marker refuses to load the database at all (409, naming the rerun). The uncovered case is an operator who drops/recreates the name instead of rerunning, so the destructive-failure path now broadcasts its reload on the way out, releasing the fence deterministically when the restore ends. This reverses what I told @kriszyp earlier on that thread, and is called out there.
Cross-model review. codex
gpt-6-astra, Gemini 3.1 Pro and cursor-grok, planning and implementation rounds againstmain. The receipt is re-pinned to the current head after amainmerge; this PR's own files are byte-identical to the reviewed sha (8539c00), so the reviewed artifact and the head artifact are the same change. The Harper-domain adjudication leg was SIGKILLed in every round, so findings were triaged by the author rather than adjudicated — weaker, and the reasonHuman-Review-Needis not lower. In the final round the codex leg also hung and was killed after 720s idle; it delivered on a fresh retry, so that round's coverage is three outside legs rather than a clean single pass.Adopted from the code review: the orphan sweep's stale-generation blocker, the repair locks its abort leaked, the unconditional reclamation discard that stranded work from an intact generation, strict manifest checking, and offline/online opt-in validation parity.
Round 4 confirmed the release routing correct by code trace and raised no blocker. Its one adopted item was documentation: the JSDoc and design note still described the token set the fence no longer uses — "the last token is released", "while any token is outstanding" — which is the shape whose flaw the code was changed to avoid. Corrected.
Human-Review-Needtherefore pins one commit behind head: the only delta since is that comment text, with no executable change (verified by diff).Adopted from the final-artifact round: the restore release now reaches job workers. Opting them into the close broadcast fenced them, but the reload fell through to the ordinary path that excludes job workers, so every job thread took the fence and never got it back — the same stuck-fence class fixed a commit earlier, introduced by the close-phase inclusion itself.
Adopted from the delta round before it: the generation is captured before the sweep's scan rather than before its deletions (a restore landing during the scan already invalidates the verdict); abandoning the sweep no longer duplicates the candidate set to find its held locks, which is a heap risk on exactly the hours-long sweeps this path supports; and lifting a fence now wakes the reclamation queue, since a drain that ran while fenced skipped entries without recording a next deadline and nothing came back for them.
Not adopted, each disproved by a line rather than by judgement:
storageInfoas undefined atresources/blob.ts:1654. It is declared atresources/blob.ts:1588aspending.fileInfo— the exact value the finding proposed switching to.requireBooleanOptionbreaking clients that omit the flag. It returnsfalseforundefinedand only throws on a non-boolean (dataLayer/rocksdbBackup.ts:169-174).buildRequestrunsJSON.parseon everykey=valuewhose field is not inRAW_STRING_FIELDS, and that set is{'ref'}(bin/cliOperations.ts:630-646), so a real boolean arrives. A malformed value did not, which is what the shared validation now fixes.Review-Coverage: authored=claude; ran=gemini,codex,cursor-grok; blocked=domain(exit--1); declined=cursor-composer,cursor-kimi,cursor-muse; rounds=4; full=3 @ cb30d47
Human-Review-Need: 4 @ 35b6777