Skip to content

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

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

cb1kenobi wants to merge 36 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 database before anything checked that this build could read the backup (#2965: restore_backup purges the destination before anything verifies the backup can be read). A corrupt file, a table in a newer format_version, or an unreadable transaction log destroyed the database it was meant to restore.

💡 Solution

Verify the backup in place, in its repository, before the purge, with no staging copy and no extra disk. Online, the check runs after the database is closed, so no write is acknowledged and then lost. The restoring marker is now published only immediately before the purge, so a crash during the check leaves the database loadable.

⚖️ Alternatives

  • Stage, prove, then swap (this PR's first design). Rejected in review (Kris): it needs a third copy on disk, so a restore on a tight disk is always refused. It also proved less than it seemed, since opening a database leaves tables outside the cache unread.
  • Verify while the database still serves. Rejected: writes acknowledged during a minutes-long checksum read would be lost to the purge. Before this PR they failed instead.
  • Publish the marker at the claim, as before. Rejected: a crash during the check would leave an intact database marked incomplete, and with an unreadable backup the only way out is restoring older data over it.
  • Re-check the backup's identity after an unlocked verify. Rejected for holding the repository lock from selection to pin. Timestamp, size and file count do not identify a backup.

❓ Your call: verify_backup keeps its existing checks and does not open tables, so it can report a backup as valid that a restore then refuses for its format. I kept it narrow to avoid changing its cost and results for every caller.

🔧 Changes

✅ Verification

End-to-end route: the new integration test, through the Operations API and the CLI.

Docs: HarperFast/documentation#721: Document that restore_backup checks a backup before replacing the database.

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

Related PRs: #2648 independent, #2649 independent, #3107 independent, #2155 independent
Complexity: complicated
Release target: v5.4 — a behavior change to restore, milestoned for the next minor

Framing-Verdict: chosen-approach-sound (round roll-up)

Review-Coverage: authored=claude; ran=gemini,codex,cursor-composer,cursor-muse,cursor-grok; adjudicated=domain; declined=cursor-kimi; rounds=17; full=8 @ dd51b03

Review-Attention: deep ~40m (critical: backupRepository.ts, restoreMarker.ts +1; decisions: markerless-claim, online-verify-after-close, verify-backup-skips-tables, checksum-on-every-restore, do-less-alternative, release-target-vs-evidence) @ dd51b03

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: cancelled

Cherry-pick branch cherry-pick/v5.3/pr-3121 was deleted — this PR no longer targets v5.3 (milestone is now v5.4).

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 8 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>
@cb1kenobi
cb1kenobi marked this pull request as ready for review October 9, 2026 18:33
@github-actions
github-actions Bot requested a review from kriszyp October 9, 2026 18:33

@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.

I'm skeptical of this approach, as it significantly increases the disk space required to do a restore. If the remaining free space is less than the database size, it will now fail. Before this PR, there is some slight chance that this would detect a failure ahead of time, but if there is too little disk space, there is now a 100% chance of failure. Reporting the failure loudly ahead of time seems like a poor consolation in the midst of an emergency restore, if the reporting was the actual cause of the failure. I would think most of the likely failures could be checked in place, and others could still be remedied after the main database was deleted in a failed restore (if you are doing a restore, you are probably pretty committed to replacing the main database).

🤖 Reviewed with Codex

Comment thread dataLayer/restoreStaging.ts Outdated
Comment thread dataLayer/restoreStaging.ts Outdated
@kriszyp kriszyp modified the milestones: v5.3, v5.4 Oct 11, 2026
@cb1kenobi
cb1kenobi marked this pull request as draft October 13, 2026 06:45
@cb1kenobi
cb1kenobi requested a review from kriszyp October 13, 2026 07:24
cb1kenobi and others added 14 commits October 13, 2026 10:52
Staging needed disk for a third copy (database, repository, staging), so
a restore on a tight disk was always refused. Both restore paths now run
backups.verify with checksums and verifyTables against the repository
before the purge, which catches the same unreadable backups (corrupt
file, unsupported log header, unsupported table format or compression)
with no extra disk. The staging, .replaced recovery, space check and
symlink/mount-point refusals go away with it. verify_backup checks
tables too, so it agrees with what restore will accept.

Requires the @harperfast/rocksdb-js release that adds verifyTables
(HarperFast/rocksdb-js#925).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A full checksum read can take minutes. Holding the restoring marker
across it meant a crash left an intact database flagged incomplete and
unloadable, and the rerun's refusal told the operator to restore over
newer data. Verification now runs before the lock, marker and pin; the
claim's findBackup re-checks under the repository lock that the backup
is still there. With no marker held while the database serves, the
rescan exemption in databases.ts is no longer needed and is reverted.

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

Verification runs unpinned, and deleting the newest backup frees its id
for the next create_backup, so the claim now checks it is the same
backup (timestamp, size, file count, metadata), not just the same id.
A refusal over an interrupted restore says the database stays
unloadable instead of "was not modified". The restore JSDoc now lists
verification first, matching the code.

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

Holding the repository lock from verification through the claim keeps a
delete plus create_backup from swapping a different backup in under the
same id, so the identity comparison is no longer needed. The lock is
already held this long behind a create_backup. Verification still runs
before the marker, so a crash mid-verify leaves the database loadable.

Restore now shares verify_backup's file checks, including the missing
blob snapshot a manifest declares, which restoreBlobSnapshot would
otherwise only warn about after the purge. A failure reading the marker
while reporting a refusal no longer masks the verification error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The manifest read, the blob-root check and the engine-only decision ran
before the lock, so a delete plus create_backup reusing the id could
leave them describing a different backup than the one verified and
pinned. All of selection now runs inside the lock with verification and
the claim. A refusal says this restore did not modify the database,
which stays true even when the database's restore state is unknown.

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

Restore opens every table; verify_backup keeps its existing default
rather than changing cost and results for every caller. A refusal
caused by a client error (a missing declared blob snapshot) keeps its
status instead of surfacing as a server error, and a non-Error rejection
can no longer replace the diagnosis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ackup's blob snapshot missing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Verifying while the database still served let it acknowledge writes for
the length of a full checksum read that the purge then discarded; before
this PR the close came first and those writes failed instead. Online
verification now runs after the close and the closure check, under the
claim, with the pin protecting the selected backup. Offline, with no
live writers, keeps verifying under the repository lock before the
claim, so a crash mid-verify leaves the database loadable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Verification can run for minutes after verifyDatabaseClosed. The marker
keeps Harper from reopening the database, but a component opening the
path directly is not held off by it, so the closure check runs again
right before the purge. The offline comment no longer claims it mirrors
online, and the design note states the crash-mid-verify cost plainly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fresh restore claim now takes the lock and the pin but writes no
marker until immediately before its first destructive step
(markRestoreDestructive). The held lock alone already keeps every open
off the database, so a crash while claimed — online, that is the whole
verification read — leaves the intact database loadable instead of
marked incomplete, where a rerun of an unreadable backup could only be
escaped by restoring older data over it.

Pins count while their restore holds the lock, not only while a marker
exists, so the source stays protected before the marker. An offline
create_backup treats a held claim as an active restore. Clearing a
claim that never marked is a no-op on the marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cb1kenobi
cb1kenobi marked this pull request as ready for review October 13, 2026 21:46
…tore

Online verification now runs after the closure check, so the insert that
added `note` (a redeclare, which leaks handles until harper#3120 lands)
made the online refusal a closure 409 instead of a verification refusal.
Declaring the attribute at create_table avoids the redeclare, which also
lets the success path run online.

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

Copy link
Copy Markdown
Member Author

Thanks Kris. That was the open "your call" on disk cost, and your point about the emergency restore on a tight disk carries it. Staging is gone; the restore now checks the backup in place and needs no extra disk.

Before anything is deleted, restore_backup now runs backups.verify against the repository with checksums and the new verifyTables (rocksdb-js 2.12.0, which opens every SST in the backup one at a time), validates the transaction logs, and checks that a declared blob snapshot is present. A backup that fails is refused with the database untouched.

Ordering, since it is the subtle part:

  • Selection and every manifest-derived check run under the backup repository lock, and the backup is pinned before that lock is released, so the backup that gets verified and restored is the one that was selected.
  • Online, the check runs after the close and the closure check, so no write is acknowledged and then lost to the purge; the closure check runs again right before the purge.
  • The restoring marker is now published only immediately before the purge. Until then the held restore lock alone keeps every open off the database, so a crash while the backup is still being checked leaves the database loadable instead of marked incomplete.

The cost is time rather than disk: the check reads the whole backup once, and online that read is inside the downtime.

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

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.

2 participants