Skip to content

Refuse an engine-only restore over a database that still has blobs - #2646

Merged
cb1kenobi merged 25 commits into
mainfrom
fix/engine-only-restore-blob-guard
Sep 29, 2026
Merged

cb1kenobi merged 25 commits into
mainfrom
fix/engine-only-restore-blob-guard

Conversation

@cb1kenobi

@cb1kenobi cb1kenobi commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

restoreBlobSnapshot leaves 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 an exclude_blobs backup 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

  1. This breaks existing scripted engine-only restores, including into a new database. Anything running restore_backup against an exclude_blobs backup now fails until allow_engine_only is added. Naming a fresh target_database is not an escape, which is the part that changed late: blob ids are a per-database counter that getNextFileId re-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.
  2. The guard reads the manifest and the opt-in, and nothing else. No filesystem access, so it cannot be slow or fail on an unreadable root, and each path checks once because no concurrent writer can change a manifest-only answer.
  3. The durable fix for hazard 1 is not here. Persisting the blob-id high-water mark with the engine, so the counter cannot fall behind the references it has handed out, would let the opt-in be dropped for the empty-root case — and would also fix the same latent hazard outside restore, where a cold start after deletions can reissue a live id. That is a change to the blob write path rather than a guard, so it wants its own PR. The reasoning is recorded in the JSDoc.
  4. Closing a database is not a write barrier, so restore has explicit close and reload phases. 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.
  5. The fence is a current owner, not a flag or a token set. Restores of one database can overlap, because a restore releases its lock before awaiting its reload broadcast. 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.
  6. Long-running work compares generations, not "is it fenced now." An orphan sweep can read the fence as clear, have an entire restore happen while it awaits an unlink, and resume deleting on a verdict reached against the replaced generation. It captures a restore generation instead.

Changes

  • dataLayer/blobBackup.ts — assertEngineOnlyRestoreAllowed refuses 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 the reload phase reports whether the generation was actually replaced; validateRestoreBackup and 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.js and unitTests/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 wrote fail, and restoring them makes it pass.

Known gaps, stated rather than implied:

  • No test drives a real online restore end to end through worker broadcasts and snapshot replacement. Coverage is helper-level and offline.
  • signalSchemaChange uses Promise.all over the local close and the bounded peer broadcast, so a peer timeout can reject while a local close handler is still draining. Not addressed.
  • Two findings about the thread fabric are open and not addressed here: job cleanup can satisfy the strict barrier without draining blob mutations, and a worker registered after the broadcast's recipient snapshot can miss the fence entirely.
  • A failed save can settle before its asynchronous ERROR-stub write, so that write can land after the restore. Open.
  • cleanup_orphan_blobs is still dispatched without a rejection observer (pre-existing).

Complexity: complicated


Framing-Verdict: chosen-approach-sound

The planning review first returned better-alternative-exists and 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 against main. The receipt is re-pinned to the current head after a main merge; 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 reason Human-Review-Need is 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-Need therefore 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:

  • Gemini reported storageInfo as undefined at resources/blob.ts:1654. It is declared at resources/blob.ts:1588 as pending.fileInfo — the exact value the finding proposed switching to.
  • Gemini reported requireBooleanOption breaking clients that omit the flag. It returns false for undefined and only throws on a non-boolean (dataLayer/rocksdbBackup.ts:169-174).
  • Both lenses questioned CLI boolean coercion. buildRequest runs JSON.parse on every key=value whose field is not in RAW_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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@cb1kenobi cb1kenobi added this to the v5.3 milestone Sep 16, 2026
@cb1kenobi
cb1kenobi force-pushed the fix/engine-only-restore-blob-guard branch from ba6faa1 to 4564c09 Compare September 16, 2026 09:09
@cb1kenobi
cb1kenobi force-pushed the fix/job-boot-reconciliation branch from b48edde to 83b6434 Compare September 16, 2026 21:22
@cb1kenobi
cb1kenobi force-pushed the fix/engine-only-restore-blob-guard branch from 4564c09 to 2648289 Compare September 16, 2026 21:22
@cb1kenobi
cb1kenobi force-pushed the fix/job-boot-reconciliation branch from 83b6434 to f2e043d Compare September 17, 2026 19:28
@cb1kenobi
cb1kenobi force-pushed the fix/engine-only-restore-blob-guard branch from 2648289 to 689ce2a Compare September 17, 2026 19:28
@cb1kenobi
cb1kenobi force-pushed the fix/job-boot-reconciliation branch from f2e043d to 8a90d1a Compare September 17, 2026 21:15
@cb1kenobi
cb1kenobi force-pushed the fix/engine-only-restore-blob-guard branch from 689ce2a to 0cabed0 Compare September 17, 2026 21:15
@cb1kenobi
cb1kenobi force-pushed the fix/job-boot-reconciliation branch from 8a90d1a to a707050 Compare September 18, 2026 00:09
@cb1kenobi
cb1kenobi force-pushed the fix/engine-only-restore-blob-guard branch from 0cabed0 to c81b4ab Compare September 18, 2026 00:09
@cb1kenobi
cb1kenobi force-pushed the fix/job-boot-reconciliation branch from a707050 to 7384fa3 Compare September 18, 2026 04:01
@cb1kenobi
cb1kenobi force-pushed the fix/engine-only-restore-blob-guard branch 2 times, most recently from a97f765 to 8405bb2 Compare September 18, 2026 04:03
@cb1kenobi
cb1kenobi force-pushed the fix/job-boot-reconciliation branch 2 times, most recently from 3204c62 to ef745d9 Compare September 18, 2026 19:22
@cb1kenobi
cb1kenobi force-pushed the fix/engine-only-restore-blob-guard branch 2 times, most recently from 8315e89 to b951da6 Compare September 18, 2026 21:31
@cb1kenobi
cb1kenobi force-pushed the fix/job-boot-reconciliation branch from ef745d9 to a3dc599 Compare September 18, 2026 21:31
@cb1kenobi
cb1kenobi force-pushed the fix/engine-only-restore-blob-guard branch from b951da6 to 750ffa7 Compare September 21, 2026 03:13
@cb1kenobi
cb1kenobi force-pushed the fix/job-boot-reconciliation branch from a3dc599 to 0fb92ce Compare September 21, 2026 03:13
@cb1kenobi
cb1kenobi force-pushed the fix/engine-only-restore-blob-guard branch 2 times, most recently from 53ea575 to bdd847d Compare September 21, 2026 14:09
@cb1kenobi
cb1kenobi force-pushed the fix/job-boot-reconciliation branch from c4b37c1 to c942563 Compare September 21, 2026 14:09
@cb1kenobi
cb1kenobi marked this pull request as ready for review September 21, 2026 14:21
@cb1kenobi
cb1kenobi force-pushed the fix/job-boot-reconciliation branch from c942563 to 7984559 Compare September 21, 2026 14:24
@cb1kenobi
cb1kenobi force-pushed the fix/engine-only-restore-blob-guard branch from bdd847d to 6114583 Compare September 21, 2026 14:24
@cb1kenobi
cb1kenobi force-pushed the fix/job-boot-reconciliation branch from 44f7a91 to 30f0640 Compare September 24, 2026 02:27

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A good safety measure.

🤖 Reviewed with Codex

Comment thread dataLayer/blobBackup.ts Outdated
Comment thread dataLayer/rocksdbBackup.ts Outdated
Comment thread resources/blob.ts Outdated
Base automatically changed from fix/job-boot-reconciliation to main September 28, 2026 18:29
…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
Comment thread dataLayer/blobBackup.ts Outdated
Comment thread dataLayer/rocksdbBackup.ts Outdated
Comment thread resources/blob.ts Outdated
… 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.
Comment thread resources/blob.ts Outdated
Comment thread dataLayer/rocksdbBackup.ts Outdated
…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.
Comment thread dataLayer/rocksdbBackup.ts
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.
Comment thread resources/blob.ts Outdated
**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.
Comment thread resources/blob.ts
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.
@cb1kenobi
cb1kenobi merged commit ed37656 into main Sep 29, 2026
52 checks passed
@cb1kenobi
cb1kenobi deleted the fix/engine-only-restore-blob-guard branch September 29, 2026 15:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants