Skip to content

Verify a backup can be read before restore_backup replaces the database - #3121

Draft
cb1kenobi wants to merge 16 commits into
mainfrom
fix/restore-verify-before-purge-2965
Draft

cb1kenobi wants to merge 16 commits into
mainfrom
fix/restore-verify-before-purge-2965

Conversation

@cb1kenobi

@cb1kenobi cb1kenobi commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

⊙ Problem

restore_backup purged the destination database before anything had checked that this build could read the backup (#2965). A backup with a corrupt file, a table format_version from a newer binding, or a transaction log with an unsupported header destroyed the very database it was meant to restore. Reproduced on main: one corrupted file in a managed backup makes backups.restore(… purgeAllFiles) fail on its checksum after the purge, leaving the database without CURRENT. Both the online operation and the offline CLI took that path.

❓ Your call: Is a full stage-then-swap warranted rather than a cheaper preflight? I think yes. backups.verify before the purge would catch only the checksum case; an unsupported table format or log header passes it, and a mid-copy ENOSPC would still destroy the database. The cost is disk for one extra engine copy during a restore. Two of the calls below follow from that cost.

💡 Solution

Restore now stages, proves, then publishes:

  1. Check there is room for the copy (assertRoomToStage), then restore the engine into `restore`/<key>.staging, beside the database (stageRestore). Online, this happens while the database is still serving.
  2. Validate every staged transaction-log store, then open and stamp the staged copy with this build.
  3. Only then replace the database with two renames: the database moves to <key>.replaced, and staging moves into its place.

The restore marker's one-way door moves from the purge to the first rename. Any refusal or failure before it — an unreadable backup, too little space, a symlinked or mount-point database directory, or a .replaced that no marker accounts for — leaves the database untouched, and the error says so (… was not modified). If a restore is interrupted between the two renames, .replaced holds the database and survives reruns until a new copy publishes. If the second rename fails, the database is moved back. The online and offline paths share one implementation.

⚠️ Look hardest: the recovery rules. A rerun treats an existing .replaced as proof that the database path holds only a disposable candidate. That holds only because a finished restore renames .replaced away while its marker still stands. What counts as "nothing destroyed" — the callers' marker branch — rests on publishStagedRestore setting destroyed correctly.

⚖️ Alternatives

  • backups.verify(…, { verifyWithChecksum: true }) before the existing purge. Rejected: it can't detect a checksum-valid table in an unsupported format, which takes an open, and it doesn't protect against a mid-copy failure after the purge.
  • Restore into scratch, open it, delete it, then purge and restore as before. Rejected: it proves the same thing but copies every restore twice, inside the downtime window, and reads the source a second time after the proof. It would avoid the space refusal below, at the cost of reopening the post-purge failure window.
  • Gating on rocksdb_js_version in the archive manifest. Rejected: Identify a get_backup archive with a capability manifest as its first entry #2648 deliberately declined version comparison, and a version gate can't catch corruption or backups written before the manifest carried producer information.
  • A non-destructive restore mode in rocksdb-js. Rejected: the binding doesn't own the marker, the blob fence or the generation stamp, so Harper would still have to sequence the swap.

❓ Your call: An online restore now stages while the database keeps serving, so the close comes only after a copy that can take minutes. Writes made during staging succeed and are then replaced by the restore. Before, the database closed first and those writes failed with DatabaseClosingError. Staging after the close instead turns the whole copy into downtime; reordering is a few lines.

❓ Your call: The marker is still written when the restore claims the database, before staging. A crash or restart during a long staging copy therefore leaves a database that was never touched refused at startup as an incomplete restore until someone reruns the restore; the rerun's refusal now says the database may be incomplete rather than that it is. Before, that window was only the close and the 3 s closure poll. Recording a phase in the marker (staging, then publishing) would let startup clear a staging-phase marker that has no .replaced; I left that for a follow-up rather than widen the marker format here.

❓ Your call: A staging copy that would not leave headroom — the larger of 256 MiB and a tenth of the copy — is refused with a 507 before it starts. This holds offline too, so a database larger than about half its volume can no longer be restored in place without freeing space first. The check is also per restore: concurrent restores of different databases on one filesystem are not reserved against each other. An operator override is possible later.

❓ Your call: The proof is a bounded open. With the default table cache, RocksDB loads only part of a large database's tables at open, so an unsupported table outside that set surfaces later, on a cold read. Opening every table (maxOpenFiles: -1) costs one file descriptor per table, which a large database can exhaust and so turn into a refused restore. DESIGN.md states the bound. Tightening it later, for example with a footer scan, would be additive.

❓ Your call: Transaction logs are validated for readability, not strictly. A torn tail passes, because open-time recovery truncates it and the operator has no override to restore past a refusal. Making it strict is one flag.

❓ Your call: A database directory that is itself a symlink or a mount point is now refused before staging, because the swap renames the directory itself. Restores of those layouts that worked before now fail until the configured path points at a real directory on its parent's filesystem. A same-device bind mount still passes the check and fails only at the rename, with nothing lost.

❓ Your call: Blob roots are not staged. They can span filesystems, and the archive's capability tokens already gate their encodings. A blob-copy failure after publication still requires a rerun, as it does today.

🔧 Changes

Product and architecture tour

Where the point of no return moved

What does a failed restore leave behind now?

Before After
backups.restore(… purgeAllFiles) empties the database directory, then copies. A corrupt or unreadable backup fails after the purge, so the database is gone and the marker demands a rerun that hits the same backup. The backup is restored into staging and opened by this build first. Only after that does the database move aside. A backup that fails to stage, validate or open, or that will not fit, leaves the database as it was, with a fresh marker cleared.

The database directory is not touched until a staged copy of the backup has opened in this build and every staged transaction-log store has validated.
Once anything is published, the marker stays on every later failure until a rerun succeeds.
.replaced exists only while a publication is unfinished.

Restore states and what a failure leaves

Failures before the first rename clear a fresh marker. Failures after it keep the marker, and .replaced keeps the pre-restore database until a later attempt publishes.

stateDiagram-v2
    [*] --> Marked: lock and marker
    Marked --> Staged: space check, restore into staging
    Staged --> Proven: validate logs, open and stamp
    Proven --> Closed: close broadcast (online) or LOCK probe (offline)
    Closed --> Displaced: database to .replaced
    Displaced --> Published: staging to database path
    Published --> Done: blobs, .replaced to .discarded, clear marker
    Marked --> Untouched: any refusal or failure
    Staged --> NeedsRerun: crash during the copy (database untouched)
    Staged --> Untouched: any failure
    Proven --> Untouched: any failure
    Displaced --> Untouched: second rename fails, rollback fsynced
    Displaced --> NeedsRerun: crash or rollback fails
    Published --> NeedsRerun: blob, fsync or retirement failure
    Untouched --> [*]
    NeedsRerun --> Marked: rerun keeps .replaced, drops the candidate
    Done --> [*]
Loading
  • restoreBlocksLoad — a rescan during online staging keeps the database this thread is serving loaded, so the close broadcast can still close it
  • prepareRestoreStaging — refuses symlink and mount-point layouts; staging and .discarded are always disposable; under .replaced the database path is a candidate
  • publishStagedRestore — moves the database aside, publishes staging, and marks destruction once anything is published
  • rollBackPublication — "nothing destroyed" only after both directories are fsynced
  • discardReplaced — retires .replaced atomically while the marker still stands

What operators see

Which outcomes does each kind of bad backup or interruption produce?

Example: a backup written by a newer binding

The backup's newest table declares a format_version this build cannot read. Restore copies it into staging; the staged open fails with Corrupt or unsupported format_version.

Outcome: The restore fails with Backup <id> could not be staged and verified, so <dir> was not modified: …, and the database stays loaded and writable, with its subscriptions running.

Example: the process dies between the two renames

The database path is empty and .replaced holds the database; the marker blocks loading. A rerun with an unreadable backup fails and keeps both, and a rerun with a good backup publishes and then retires .replaced.

Outcome: The only copy of the database is never deleted before a replacement has been published.

What this does not prove

Where does the guarantee stop?

The proof is an ordinary bounded open, so a large database's tables beyond RocksDB's initial load set are not read until later. Transaction logs are checked for readability, not completeness: a torn tail passes. Blob roots are not staged, so a blob-copy failure after publication still needs a rerun, as before. The space check is per restore. Each of these is a ❓ Your call above.

Files:

✅ Verification

End-to-end route: a new integration test, plus unit tests that drive the real engine with real damaged backups.

  • unitTests/dataLayer/rocksdbBackup.test.js adds a "restore staging" suite (21 tests): npx mocha unitTests/dataLayer/rocksdbBackup.test.js -g "restore staging" → 21 passing.
  • Guards checked by deleting them from dist/: the rollback, keeping .replaced on rerun, marking destruction after publication, the mode from .replaced, the mount-point and space refusals, dropping the candidate, retiring .replaced by rename, the closure check before a rerun drops its candidate, the preexisting-marker gate on that check, and the refusal of an unaccounted .replaced. Each deletion fails exactly the test that covers it; the rescan test failed before its fix.
  • integrationTests/database/restore-backup-staging.test.ts (new, 3 workers) → 3/3 passing at this head; on main, 3/3 fail. It covers a refused online Operations API job, a refused offline CLI restore that exits non-zero, and an offline CLI restore through the swap that survives a restart. The success path runs offline because of a separate, pre-existing bug found here: #3120. A runtime schema change leaks RocksDB handles, so an online restore_backup of a database whose schema changed at runtime is always refused while the server runs. The unit suite covers online success.
  • A probe on this branch confirmed that a restore refused before the close leaves subscriptions running: the listener received a write made after the refusal.
  • Full gates on the final code, before the merge from main:
    • test:unit:backup: 176 passing.
    • restoreGeneration: 3 passing.
    • test:unit:main, with applicationSpawn excluded because it wedges on main too: 6788 passing and 17 failing. That is the same 17 failing tests as with main's restore sources, in unrelated suites (module tracking, preload resolution).
    • test:unit:resources: 4058 passing and 4 failing. Three are a flush-timing premise test that also fails with main's sources. The fourth, sourceTxnStreams, timed out under machine load and passes 11/11 when run alone.
    • lint, format:check, typecheck and check:design-docs are clean.
  • After merging main: test:unit:backup, restoreGeneration and the integration test pass again, and lint, format and typecheck are clean. The review-coverage action tests pass 143/143 locally after the framing_paths addition.
  • unitTests/resources/databases.test.js: the startup-scan test for an empty restoring marker now closes its database first, as a real startup has nothing open; with the database still open, restoreBlocksLoad keeps it loaded by design. 36 passing.
  • After the latest review rounds: test:unit:backup 179 passing; test:unit:resources 4071 passing, 0 failing; lint, format, typecheck and check:design-docs clean. test:unit:main was not rerun after them.
  • Not run: the TypeStrip test mode, which is broken locally on main.

Docs: HarperFast/documentation#721 Document that restore_backup checks a backup before replacing the database covers staging, the space requirement, the symlink and mount-point refusals, and the 5.3 release note.

🤖 Generated by Anthropic Claude (Opus); posted via @cb1kenobi.

Related PRs: #2649 overlaps (this narrows its scan-level restore skip for a root this thread already has open), 2 others independent
Complexity: complicated

Review-Coverage: authored=claude; ran=gemini,codex,cursor-composer,cursor-muse,cursor-grok; adjudicated=domain; declined=cursor-kimi; rounds=12; full=6 @ bf1e140

Review-Attention: deep ~60m (critical: restoreMarker.ts, rocksdbBackup.ts +1; sensitive: review-coverage.yml; decisions: do-less-alternative, stage-while-serving, keep-loaded-under-any-marker, refuse-symlink-and-mount-point, space-headroom-policy; raised: degraded review) @ bf1e140

cb1kenobi and others added 3 commits October 8, 2026 12:58
restore_backup purged the destination (backups.restore with purgeAllFiles) before anything
established that this build could read the backup, so a corrupt backup, a table format_version
from a newer binding, or an unsupported transaction-log header destroyed the database it was
meant to restore.

Restore now stages the engine beside the database, validates every staged transaction-log store,
opens and stamps the staged copy with this build, and only then publishes it by two renames. The
marker protocol's one-way door moves from the purge to the first rename: any staging failure
leaves the destination untouched. A publication interrupted between the renames keeps the
displaced database as `.replaced` until a later attempt publishes; a failed second rename moves
it back. Both the online operation and the offline CLI share the path.

Fixes #2965

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very

Review round 1 on #2965:
- a restore into a destination that did not exist (a new target_database) reported nothing
  destroyed after publishing, so a blob-restore failure cleared its marker; publication now
  always counts as destruction
- a rerun after a crash between the publication renames takes the staging mode from `.replaced`
- DESIGN.md states the bound on the open's SST coverage instead of claiming all of it
- rename injections re-sync the ESM builtin exports so they also hold under TypeStrip; stale
  purge wording, a misleading test title and a lenient CLI exit check are corrected

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 2 on #2965: a live holder the PID check missed now fails fast instead of after a
full staging copy; the probe before publication stays. The generated backup README says the
staged copy is opened rather than claiming it is verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cb1kenobi cb1kenobi added this to the v5.3 milestone Oct 8, 2026
@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

Release cherry-pick v5.3: failed

The cherry-pick job for v5.3 did not complete. It is unclear whether v5.3 was updated — check before re-running.

Run: https://github.com/HarperFast/harper/actions/runs/37890866919

Re-run once the cause is addressed (workflow_dispatch, pr_number=3121).

gemini-code-assist[bot]

This comment was marked as resolved.

cb1kenobi and others added 6 commits October 8, 2026 14:07
Raised by the companion docs review: staging lives beside the database directory, so when that
directory is itself a mount point the staged copy lands on the parent filesystem and publication's
rename fails only after a full copy. Refuse it up front, as the symlink case already is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 4 on #2965: staging writes a second engine copy, online while every database on that
filesystem keeps serving, where the purge it replaced freed the space first. A copy that would
not leave headroom (the larger of 256 MiB and a tenth of the copy) is now refused with a 507
before it starts, leaving the database untouched. The symlink refusal under an interrupted
earlier restore now says to rerun offline before the next start, since repointing the path moves
the restore metadata away from the marker guarding the half-restored directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 5 on #2965: a rerun after a failure past publication kept both `.replaced` and the
published candidate, so the space check asked for a third copy and could refuse the only way out
of a marked database. Under a preexisting marker with `.replaced` present the database path holds
only a disposable candidate, which publication already removed; it is now removed in preparation.
DESIGN.md names the space check's limits: in-place restores need two copies even offline, and
concurrent restores on one filesystem are not reserved against each other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 6 on #2965: a rerun reads an existing `.replaced` as proof that the database path holds
only a publication's candidate, but a finished restore could leave `.replaced` behind when its
removal failed, so a later interrupted restore's rerun could delete the live database. A finished
restore now renames `.replaced` to `.discarded` inside its protected section, so a failure keeps
the marker and `.replaced` never outlives its restore; only removing `.discarded` may fail quietly,
and preparation always clears it. Dropping a candidate in preparation now counts as destruction,
and a refusal over a preexisting marker no longer claims the database was not modified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…online success

Review round 7 on #2965: the "rerun restore_backup to recover" wrapper now carries the original
status code (a 507 refusal on a rerun used to lose it), and a unit test restores a valid backup
through the online path, which the integration test cannot reach until #3120.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cb1kenobi and others added 7 commits October 8, 2026 15:34
…e binding

The review-coverage policy test requires every production module that imports a storage binding
to be listed in framing_paths; dataLayer/restoreStaging.ts imports @harperfast/rocksdb-js.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gemini review on #3121: format a thrown non-Error with String() and read its code with optional
chaining, at the staging, rerun-wrapper and symlink-probe sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A rerun of an unfinished publication removes the candidate at the database
path while preparing staging, which runs before the close broadcast and the
closure check. A component holding that candidate would have its files
unlinked underneath it. The online path now verifies nothing holds the
database before preparing such a rerun; offline already probes first.

DESIGN.md also narrows the mount-point refusal to what it detects: a
same-device bind mount passes and fails at the first rename.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Online staging holds the restore marker for the whole copy, and a schema
rescan in that window skipped the marked database and then dropped it from
the map without closing its root store. The restore's close broadcast could
no longer reach it, so verifyDatabaseClosed refused with a misleading 409
and the database stopped serving for the rest of the copy. The scan now
keeps a marked root this thread already has open; one it has not opened is
still skipped. The startup-scan test now closes its database first, as a
real startup has nothing open.

A .replaced that no restore marker accounts for is now refused (409)
instead of being trusted as an unfinished publication, which would have
dropped the live database before the backup was verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The online closure pre-check ran whenever .replaced existed, so a stray one
under a fresh marker waited out the close deadline and refused with "held
open by a loaded component", sending the operator offline for the wrong
reason. It now runs only for a rerun under a preexisting marker, leaving the
refusal that names .replaced to answer the stray case; the test matches that
message. Also tidies comments the review flagged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rving

A rerun under a preexisting marker no longer claims the database is still
incomplete: a crash during staging leaves that marker over an untouched
database, so the refusal now says it may be incomplete. The rescan's
"in progress; not loading it" warning is skipped for a database this thread
keeps serving during online staging.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant