Repository navigation
fix(backup): clean up .part files and bound skip tolerance (#555, #556) - #557
Merged
Merged
Conversation
Two follow-ups split out of #322 / PR #554. #555 -- a failed backup write left an orphaned "<path>.part" staging file in backup storage. LocalBackend.WriteReader preserves it deliberately so the file-replication puller can resume from the last committed byte, but backup has no resume path: a retried backup starts over under a fresh backup ID. The staging file was therefore unreferenced garbage -- never read, never listed as a backup (no manifest.json), and holding disk equal to the bytes transferred before the failure. streamBackupFile now removes it on write failure. Cleanup is best-effort: the write already failed, so the delete very likely fails too, and a cleanup error must not mask or replace the original write error. #556 -- skipping a file exists to tolerate one narrow race: a file removed by compaction or retention between the listing and the copy, which touches a handful of files at the tail of a run. A large fraction of the backup failing to read is a different event (throttling, credential expiry, a storage outage), and returning a fraction of the data as a successful backup is how an operator discovers the gap at restore time instead of at backup time. Backups now fail when more than maxSkipRatio (10%) of files are unreadable. The skip count is still recorded on the failed run. The ratio check subsumes the previous all-skipped guard -- 100% exceeds any ratio -- so total-loss behavior is unchanged; a test pins it explicitly. maxSkipRatio is a constant rather than a config key: no operator has asked to tune it, and a knob nobody sets is a knob nobody tests.
6 tasks done
pull Bot
pushed a commit
to Mu-L/arc
that referenced
this pull request
Jul 30, 2026
Merges main (23 commits) into the Iceberg branch and fixes the issues a review of PR Basekick-Labs#533 turned up. The Iceberg subsystem itself is sound -- the config guardrails, the compaction-race handling, and the fingerprint cache all hold up. These are the gaps. BLOCKER: iceberg.catalog_db_path at a non-default value silently excluded the Iceberg SQL catalog from backup. main.go hardcoded cfg.Auth.DBPath when constructing the backup manager while Iceberg read cfg.Iceberg.CatalogDBPath. Both default to ./data/arc.db, so they are the same file at the default and diverge the moment an operator sets the key -- the Basekick-Labs#534 shape, in the package Basekick-Labs#534 was filed against. A restore then brought back Parquet and warehouse metadata whose tables no longer resolved, contradicting the "backup-aware" claim. The catalog is now backed up as metadata/iceberg-catalog.db when it is a separate file, and restored alongside; older backups without it are skipped, not an error. BLOCKER: a failed version-hint.text write was never retried. The scheduler short-circuits on an unchanged fingerprint before reaching ReconcileMeasurement, and cached the fingerprint regardless of whether the best-effort discovery-file writes succeeded. Once the file set went quiet -- the steady state -- the hint stayed stale forever and directory-based readers (DuckDB iceberg_scan, Spark hadoop-format) resolved the wrong version indefinitely. Publishing now reports success, a converged pass republishes, and the scheduler declines to cache when it failed. BLOCKER: merging main produced a silently broken backup path. backup.go auto-merged with NO conflict, combining the PR's second copyDataFiles call (Iceberg metadata) with main's skip-ratio guard from Basekick-Labs#557. Two defects, both reproduced with tests before fixing: SkippedFiles used Store rather than Add, so the second call erased the first call's skips and the manifest claimed a complete backup; and the ratio was evaluated per call, so one stale entry in a 3-file metadata set (33%) aborted a backup whose data files had all copied. Skips now accumulate and the ratio is evaluated once over every file group. HIGH: pruneOldVersionFiles kept exactly `retain` v<N>.metadata.json copies, so a directory reader that resolved version-hint.text just before a commit could find its version already deleted -- continuous at retain_snapshots=1. Keeps retain+1 now, bounding the race to one reconcile interval for a few KB. MEDIUM: retain_snapshots=0 silently meant "keep every snapshot forever". Rejected at config load, consistent with the other Iceberg guardrails. MEDIUM: a warehouse outside the storage root disabled discovery-file publishing permanently and logged only at Debug. Now warns once per table and names what is degraded (directory readers) and what is not (catalog readers). LOW: the exporter opened a seventh SQLite handle on the shared file, reintroducing what Basekick-Labs#329/Basekick-Labs#562 consolidated. It borrows the auth manager's handle when the catalog lives in the shared database, and owns one only when the operator points the key elsewhere. Verified against the running binary with catalog_db_path and retain_snapshots both non-default: separate catalog file created, table reconciled, backup contains metadata/iceberg-catalog.db with has_iceberg_catalog=true, version-hint published, zero errors, clean shutdown with no database-is-closed cascade from the borrowed handle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Closes #555. Closes #556.
Two follow-ups split out of #322 / PR #554, now rebased on
mainwith #554 merged.#555 — orphaned
.partfilesLocalBackend.WriteReaderwrites to a deterministic<path>.partstaging file and deliberately leaves it on failure so the file-replication puller can resume from the last committed byte (local.go:151-156).Backup has no resume path — a retried backup starts over under a fresh
backupID— so that staging file was unreferenced garbage: never read, never listed as a backup (nomanifest.json, soListBackupswon't surface it), holding disk equal to the bytes transferred before the failure.streamBackupFilenow removes it on write failure. Best-effort by design: the write already failed, so the delete very likely fails too (unwritable volume, storage unreachable). A cleanup failure must not mask the original write error — there's a test for exactly that.#556 — bounded skip tolerance
Skipping exists to tolerate one narrow race: a file removed by compaction or retention between the listing and the copy. That touches a handful of files at the tail of a run.
A large fraction of the backup failing to read is a different event — throttling, credential expiry, a storage outage — and returning a fraction of the data as a successful backup is how an operator discovers the gap at restore time instead of at backup time. #554 caught only the total-loss case; 4,000 of 10,000 files skipped still reported success.
Backups now fail above
maxSkipRatio(10%) withsource storage may be degraded. The skip count is still recorded on the failed run.Two decisions worth naming:
maxSkipRatiois a constant, not a config key. No operator has asked to tune it, and per CLAUDE.md every new config key needsv.SetDefaultplus testing at a non-default value — real work for a knob nobody sets. Easy to promote later if someone asks.Configuration matrix
Both changes live in
internal/backup, reachable only via the backup API. No new config keys, no new pointer dereferences, no startup wiring touched.Managernever constructedskipped == 0early-returns (backup.go:260)backupStoragealwaysLocalBackend(manager.go:53, sole assignment)Deletereturns nil for a missing path (local.go:476-479), so no spurious cleanup noiselen(files) > 0guaranteed by theskipped > 0preconditioncleanupPartialWritebackupStoragenon-nil per above; cleanup error swallowed at debug, original error preserveddataStoragesizeonly ever reachesLocalBackend.WriteReader, which ignores itTest plan
TestStreamBackupFile_CleansUpPartFileOnWriteFailure(new) — verified it fails with the cleanup call removed:orphaned .part file left in backup storage: …/a.parquet.partTestStreamBackupFile_CleanupFailureDoesNotMaskWriteError(new) — cleanup failure preserves the original error and stays fatal (not reclassified as skippable)TestCopyDataFiles_SkipRatioExceeded(new) — verified it fails against fix(backup): stream files instead of buffering them in memory (#322) #554's all-skipped-only guard:expected failure when the skip ratio is exceeded, got nil (partial backup would report success)TestCopyDataFiles_AllFilesSkippedIsFatal— total loss still fails (now via the ratio path)TestCopyDataFiles_SkipsFailedFiles/_PartialSkipRecordsCount— rewritten to 21-file sets so one skip sits under the ratio; both assertSkippedFilesCreateBackup: 20 files →files=20 skipped=0, zero.partfiles anywhere under the backup rootgo build ./cmd/... ./internal/...cleango test ./internal/backup/...— 17/17 passgo test -race ./internal/backup/...cleango vet+gofmt -lcleanNote on the two rewritten tests: they previously used 2-file sets where a single skip is 50%, which now correctly trips the ratio. Enlarging them was the right fix — a 1-in-2 skip should fail under this policy.
🤖 Generated with Claude Code