Skip to content

fix(state): never offer a half-written backup as a restore point - #43

Merged
JasonZQH merged 1 commit into
devfrom
fix/backup-partial-file
Jul 28, 2026
Merged

fix(state): never offer a half-written backup as a restore point#43
JasonZQH merged 1 commit into
devfrom
fix/backup-partial-file

Conversation

@JasonZQH

Copy link
Copy Markdown
Owner

Found by reviewing #42. A real defect in that change, on the disaster path the feature exists to protect.

The bug

sqlite3.connect creates the destination file before connection.backup() runs, and backup_ledger had no cleanup on the failure path. A backup interrupted by a full disk, an I/O error, or a killed process therefore left a 0-byte file carrying the real …-pre-migration.sqlite name.

That file is the newest, so latest_pre_migration_backup selected it and curator doctor printed it as the ledger to restore. Following that advice copies an empty database over a live ledger and destroys it.

Reproduced before fixing — a good 290KB backup on disk, then one failed attempt:

archive contains: curator-...-2-pre-migration.sqlite       0 bytes   <- failed attempt
archive contains: curator-...-pre-migration.sqlite    290816 bytes   <- the good backup
latest_pre_migration_backup -> curator-...-2-pre-migration.sqlite  0 bytes
is it the GOOD one? False

The fix

Write to a .partial name the restore glob does not match, and rename into place only once the copy completes. Rename is atomic, so an incomplete backup can never carry the real name — which also covers the killed-process case that cleanup in an except block would miss. The partial is removed on failure.

Plus one guard on the advice path: a zero-byte file is never a usable ledger, so it is skipped when choosing what to advertise. That is deliberate belt-and-braces on a code path whose output is "copy this over your live ledger".

Verification

  • Two new regression tests, both confirmed to fail against the merged code and pass here (checked by stashing backup.py).
  • 383 passing (381 before), ruff clean.

The other four review dimensions — CLAUDE.md adherence, git history, prior PR comments, code-comment invariants — returned no findings.

🤖 Generated with Claude Code

Found reviewing #42. sqlite3.connect creates the destination file before
connection.backup() runs, and backup_ledger had no cleanup on the failure
path — so a backup interrupted by a full disk, an I/O error, or a killed
process left a 0-byte file carrying the real ...-pre-migration.sqlite name.

That file is the newest, so latest_pre_migration_backup selected it and
curator doctor printed it as the ledger to restore. Following that advice
copies an empty database over a live ledger and destroys it — on exactly the
disaster path this code exists to protect.

Reproduced before fixing: with a good 290KB backup already on disk, one
failed attempt left a 0-byte sibling and latest_pre_migration_backup returned
the 0-byte one.

Backups are now written to a .partial name the restore glob does not match and
renamed into place only after the copy completes, so an incomplete backup can
never carry the real name — this covers the killed-process case too, which
cleanup in an except block would not. The partial is removed on failure, and
an unusable file is skipped when choosing what to advertise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JasonZQH
JasonZQH merged commit dd1d8f7 into dev Jul 28, 2026
5 checks passed
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.

1 participant