Capture only whole blobs in a backup, substituting a marker for one still being written - #2265
Open
kriszyp wants to merge 2 commits into
Open
Capture only whole blobs in a backup, substituting a marker for one still being written#2265kriszyp wants to merge 2 commits into
kriszyp wants to merge 2 commits into
Conversation
…till being written A blob is written in place at its final path and only carries its real size once the write ends, so the file exists and grows for the whole write. The snapshot walk hard-linked whatever it found, so a backup could share an inode whose bytes then kept changing -- or ended truncated if the write aborted. The module's own doc comment asserted the opposite. Classify each entry instead. A complete blob is linked as before. One that is not is written into the snapshot as a PENDING marker rather than dropped: dropping it would free its file id, and getNextFileId recovers the id counter by scanning the directory, so a restored record could have its blob id reissued to a different record. A marker also keeps the reader classification the replication layer depends on -- a retryable 503 rather than a 404 it reads as "cleanly gone" (harper-pro#481). Existing abort markers are stable, so they are linked as-is. The get_backup tar walk gets the same treatment; it was archiving truncated in-flight blobs too. Completeness comes from isBlobFileCompleteAtPath, extracted from the existing isBlobFileComplete so compressed bodies are verified by streamed inflate rather than skipped. The gate is snapshot-only: restore must replace exactly what a snapshot holds, or backups taken before this change would silently lose files. The fix is in the consumer, not the writer. Staging writes to a temporary path and renaming looks like the obvious fix and breaks two things: readers stream blobs from the final path while they are still arriving (checkIfIsBeingWritten and the watcher behind it), and getNextFileId's directory scan parses a suffixed temp to NaN, so a crashed write would stop reserving its id. Closes #2262 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to safely capture blob files during live backups without freezing writes. Instead of capturing incomplete or growing blobs, the backup process now classifies each blob file and substitutes incomplete or deleted blobs with a PENDING or GONE marker. This reserves the file ID and allows for subsequent repair after a restore. The changes span blobBackup.ts, rocksdbBackup.ts, and blob.ts, along with corresponding unit tests. Feedback on the changes suggests explicitly destroying the inflate stream in inflatesToExactly to prevent potential zlib resource leaks during stream failures.
Contributor
|
Reviewed; no blockers found. |
pipe() does not tear down the destination when the source errors, so the inflate's native zlib handle stayed allocated until GC. Pre-existing, but this path now runs once per blob on every backup and archive walk rather than only in the periodic repair sweep, so the exposure is much broader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
kriszyp
marked this pull request as ready for review
August 22, 2026 12:15
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.
A blob is written in place at its final path and only carries its real size once the write ends, so the file exists and grows for the whole write. The backup walk hard-linked whatever it found, with no check, so a snapshot could share an inode whose bytes then kept changing — or ended truncated if the write aborted. The module's own doc comment asserted the opposite.
Every entry is now classified by one shared rule (
classifyBlobFileForCapture, inresources/blob.tswhere the on-disk format lives). A complete blob and a stable abort marker are linked as before. Anything else gets a marker written into the snapshot rather than being dropped — retryable (PENDING) if it was not complete yet, terminal (ERROR) if its bytes are gone for good. Dropping is what must not happen:getNextFileIdrecovers the id counter by scanning the directory, so an absent file lets a restored record's blob id be reissued to a different record. Theget_backuptar walk goes through the same rule; it was archiving truncated in-flight blobs too, and had drifted to turning anERRORstub intoPENDING.Fixes #2262.
For the human reviewer
A marker instead of the writer's final bytes (
in-flight-blob-stub-vs-live-inode). On the same filesystem the old hard link self-healed: the snapshot shared the inode, so a write that went on to succeed left the snapshot holding the complete blob. This rule gives that up — a 4 GB blob streaming during a nightly backup that finishes 200 ms later used to restore intact, and now restores as a stub. It's chosen because the old behavior was correct only when the write succeeded, and silently truncated when it aborted, with no way to tell the two apart at restore. If you'd rather keep the win, the reviewer-suggested middle path is real: refuse to overwrite a live complete blob during restore. Every backup taken meanwhile bakes the choice in — reversing later doesn't repair snapshots already written.A mid-walk delete becomes terminal, not retryable (
gone-as-terminal-error-type). Those bytes are unrecoverable, andcleanupOrphanswill never reclaim the marker because the checkpointed record still references the id — soPENDINGwould promise a retry that can never succeed and hold each read forblobReadTimeoutfirst.ERRORsays so honestly. Cost: a peer that does hold the bytes can no longer heal it, because a 500 is replicated as-is where a 503 is held as a gap.A systemic errno fails the whole backup (
systemic-errno-fails-whole-backup).EIO/EMFILE/ENOSPC/EROFSpropagate rather than degrading that file to a marker. This is a policy call about whether a partial backup beats no backup, and note the blast radius:finalizeBackupresponds to a throw by deleting the engine backup too. Non-systemic failures deliberately fall back to capturing the file unverified, becauselink()needs no read permission on the source and a read failure is not evidence the bytes are bad.Classification at capture time, not quiescing writes (
classify-in-both-walks-vs-quiesce-blob-writes). The root-cause fix is for backup to coordinate with the blob write lock, or to capture only ids present in the checkpoint, excluding in-flight blobs by construction — that would delete this whole classification path. Not done here because it needs the store handle in the walk and a lossless path→fileId mapping, neither of which exists today. Expensive to retrofit once the marker format is in shipped backups.Substitution is invisible in the manifest (
substitution-invisible-in-manifest). It's a log line at backup time. An operator restoring months later gets success and then 503s, with no record of which ids need repair. A count in the manifest is the fix; it's a format change restore would have to read, so it's not bundled here — and adding it later doesn't annotate existing backups.Blob internals exported to
dataLayer(capture-surface-in-resources-blob).classifyBlobFileForCapture,createCaptureMarker, andisSystemicIoErrorare exported rather than hidden behind one façade, because the two consumers need different capture mechanics (hard link vspack.entry) and share only the classification. harper-pro sees this surface too.Every compressed blob is fully inflated during capture (surfaced by the graded leg). A deflate header records the uncompressed length, so a growing compressed body is invisible to the size check and has to be read — which makes backup cost scale with logical rather than stored size. I tried gating this on an mtime window and removed it: a stalled deflate write carries a final-looking header from its first byte, so the byte-copying capture paths (
get_backup's stream, the cross-filesystemcopyFilefallback) would have truncated it. Accepted becausecompressis opt-in percreateBloband nothing in core passes it, so an ordinary corpus reads nothing — but it is a latent cost for anyone who does opt in, and a cheap deflate-terminator check is the obvious future out.One claim a core-only reviewer cannot check. The
capturearm for an existing abort marker is safe only if nothing rewrites a published blob path in place. Two comments inresources/blob.tspreviously asserted the opposite ("the re-stream overwrites this stub,createWriteStreamflags'w'"); both are corrected here. I verified it against harper-pro —replication/replicationConnection.ts:6524-6531builds a fresh blob viacreateBlob(stream, remoteBlob)and takes a new file id, so the stub is orphaned rather than truncated, andrepairBlobFileis the only same-id writer (via.repair+ rename). That stale sentence caused three separate wrong readings of this bug, two of them mine. A pro-side inode assertion is the right follow-up to make it durable; nothing tests it today.Verification
Fails-on-base, against
git merge-base HEAD mainsources with a forced clean rebuild (rm -rf dist) and the built artifact grepped to confirm base code was actually under test:unitTests/dataLayer/blobBackup.test.js— 5 failing on baseunitTests/dataLayer/rocksdbBackup.test.js"packs a PENDING marker…" — failing on baseunitTests/resources/blob.test.js— 3 failing on base, including the end-to-end case: a realcreateBlobwrite streaming whilesnapshotBlobswalks the root, asserting a different inode, a marker, and that snapshot bytes do not change after the write completesReader-level classification is asserted through the real read path, not by inspecting a header byte: a restored retryable marker reads 503, a terminal one reads 500.
Two tests pass with and without the fix and are forward regression guards, not bug proofs: "preserves an existing PENDING marker" and "captures a complete compressed blob".
Suites:
test:unit:backup82 passing;unitTests/resources/blob.test.js106 passing;tsc --noEmit,lint:required, prettier clean. Not run locally: thetest:unit:main/test:unit:resourcesfull gates (shared-lock contention on this machine) — relying on CI.Complexity: medium
Review-Coverage: authored=claude; ran=gemini,codex; adjudicated=domain; declined=cursor-grok,cursor-composer; rounds=12 @ 4a9931b
Human-Review-Need: 4 (decisions: stub-in-flight-vs-wait-for-completion, gone-marker-terminal-vs-retryable, classifier-lives-in-blob-ts, systemic-error-list-aborts-the-backup, repair-temps-now-omitted-from-the-tar) @ 4a9931b