Skip to content

fix(backup): clean up .part files and bound skip tolerance (#555, #556) - #557

Merged
xe-nvdk merged 1 commit into
mainfrom
fix/backup-followups-555-556
Jul 28, 2026
Merged

xe-nvdk merged 1 commit into
mainfrom
fix/backup-followups-555-556

Conversation

@xe-nvdk

@xe-nvdk xe-nvdk commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes #555. Closes #556.

Two follow-ups split out of #322 / PR #554, now rebased on main with #554 merged.

#555 — orphaned .part files

LocalBackend.WriteReader writes to a deterministic <path>.part staging 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 (no manifest.json, so ListBackups won't surface it), holding disk equal to the bytes transferred before the failure.

streamBackupFile now 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%) with source storage may be degraded. The skip count is still recorded on the failed run.

Two decisions worth naming:

  • The ratio check subsumes the old all-skipped guard (100% exceeds any ratio), so total-loss behavior is unchanged. A test pins it explicitly rather than relying on that implication.
  • maxSkipRatio is a constant, not a config key. No operator has asked to tune it, and per CLAUDE.md every new config key needs v.SetDefault plus 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.

Configuration Reaches new code? Preconditions established?
OSS, backup API unused No — Manager never constructed n/a
OSS + backup, healthy storage Partial — ratio check runs, skipped == 0 early-returns (backup.go:260) backupStorage always LocalBackend (manager.go:53, sole assignment)
Backup, isolated file removed mid-run (compaction race) Yes — skip counted, under ratio, backup succeeds Delete returns nil for a missing path (local.go:476-479), so no spurious cleanup noise
Backup, >10% of sources unreadable (storage outage) Yes — new failure path len(files) > 0 guaranteed by the skipped > 0 precondition
Backup, write fails mid-transfer Yes — cleanupPartialWrite backupStorage non-nil per above; cleanup error swallowed at debug, original error preserved
Backup + S3/Azure dataStorage Yes Unchanged from #554 — size only ever reaches LocalBackend.WriteReader, which ignores it

Test plan

  • TestStreamBackupFile_CleansUpPartFileOnWriteFailure (new) — verified it fails with the cleanup call removed: orphaned .part file left in backup storage: …/a.parquet.part
  • TestStreamBackupFile_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 assert SkippedFiles
  • End-to-end through the real CreateBackup: 20 files → files=20 skipped=0, zero .part files anywhere under the backup root
  • go build ./cmd/... ./internal/... clean
  • go test ./internal/backup/... — 17/17 pass
  • go test -race ./internal/backup/... clean
  • go vet + gofmt -l clean

Note 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

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.
@xe-nvdk
xe-nvdk merged commit ae9bdcc into main Jul 28, 2026
4 checks passed
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>
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.

medium(backup): partial-skip storm still reports success (skip ratio threshold) low(backup): .part files leak into backup storage on write failure

1 participant