Repository navigation
Conversation
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>
Contributor
Release cherry-pick
|
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>
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
⊙ Problem
restore_backuppurged the destination database before anything had checked that this build could read the backup (#2965). A backup with a corrupt file, a tableformat_versionfrom a newer binding, or a transaction log with an unsupported header destroyed the very database it was meant to restore. Reproduced onmain: one corrupted file in a managed backup makesbackups.restore(… purgeAllFiles)fail on its checksum after the purge, leaving the database withoutCURRENT. Both the online operation and the offline CLI took that path.💡 Solution
Restore now stages, proves, then publishes:
assertRoomToStage), then restore the engine into`restore`/<key>.staging, beside the database (stageRestore). Online, this happens while the database is still serving.<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
.replacedthat 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,.replacedholds 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.⚖️ 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.rocksdb_js_versionin 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.🔧 Changes
Product and architecture tour
Where the point of no return moved
What does a failed restore leave behind now?
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.Restore states and what a failure leaves
Failures before the first rename clear a fresh marker. Failures after it keep the marker, and
.replacedkeeps 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 --> [*].discardedare always disposable; under.replacedthe database path is a candidate.replacedatomically while the marker still standsWhat 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_versionthis build cannot read. Restore copies it into staging; the staged open fails withCorrupt 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
.replacedholds 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 callabove.Files:
dataLayer/restoreStaging.ts(new) holds the protocol: preparation, including the symlink and mount-point refusals the refusal of a.replacedno marker accounts for, and clearing.discarded; the space check and its headroom; staging with the mode taken from the database or, during recovery, from.replaced; the refusal message, which only claims "not modified" when that is true; publication; rollback; and cleanup of.replacedand staging.dataLayer/rocksdbBackup.tsroutes onlinerestoreBackupand offlinerestoreBackupOfflinethrough it:.replacedinside the protected section, keep the marker whenever publication happened, and drop staging on failure.assertNotOpenElsewhereand runs before staging and again before publication.dataLayer/restoreMarker.tsadds the staging, replaced and discarded paths inside the existing reserved metadata directory, which the startup scan already skips, and documents them in the module header.resources/databases.ts: online staging holds the marker for the whole copy, and a schema rescan in that window used to unload the marked database without closing its handle, which left the close broadcast nothing to close and made the closure check refuse with a misleading 409. Both scan loops now go throughrestoreBlocksLoad(main directory, configured paths), which keeps a marked root this thread already has open; one it has not opened is still skipped. The "in progress; not loading it" warning is skipped for a database that is still serving.server/itc/serverHandlers.jsupdates a comment that described the purge..github/workflows/review-coverage.ymllists the new module inframing_paths, as the review-coverage policy test requires for every production module that imports a storage binding.dataLayer/DESIGN.mdrecords the invariant and recovery rules, the bound on the proof, the space check and its limits, the symlink and mount-point refusals, the rescan rule during staging, and why.replacedis retired by rename. It also updates the bullets that described the purge: stamping, closure, drop and the offline probe.✅ 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.jsadds a "restore staging" suite (21 tests):npx mocha unitTests/dataLayer/rocksdbBackup.test.js -g "restore staging"→ 21 passing.format_version 99, and a transaction log with an unsupported header version, with the destination intact and no marker. These fail onmain, where the database is destroyed or the unreadable log is restored.SIGKILLbetween the renames, followed by a failed rerun (.replacedand the marker are kept) and a good rerun (.replacedis retired, mode kept)..replacedno marker accounts for is refused with the message that names it, online and offline.target_databasefails after publication and when.replacedcannot be retired. A rerun after a failed publication drops the candidate before the space check. The directory mode survives the swap.dist/: the rollback, keeping.replacedon rerun, marking destruction after publication, the mode from.replaced, the mount-point and space refusals, dropping the candidate, retiring.replacedby 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; onmain, 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 onlinerestore_backupof a database whose schema changed at runtime is always refused while the server runs. The unit suite covers online success.main:test:unit:backup: 176 passing.restoreGeneration: 3 passing.test:unit:main, withapplicationSpawnexcluded because it wedges onmaintoo: 6788 passing and 17 failing. That is the same 17 failing tests as withmain'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 withmain's sources. The fourth,sourceTxnStreams, timed out under machine load and passes 11/11 when run alone.lint,format:check,typecheckandcheck:design-docsare clean.main:test:unit:backup,restoreGenerationand the integration test pass again, and lint, format and typecheck are clean. The review-coverage action tests pass 143/143 locally after theframing_pathsaddition.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,restoreBlocksLoadkeeps it loaded by design. 36 passing.test:unit:backup179 passing;test:unit:resources4071 passing, 0 failing; lint, format, typecheck andcheck:design-docsclean.test:unit:mainwas not rerun after them.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