Skip to content

fix(cli): claim the backup destination before dumping, not after - #273

Merged
jouwdan merged 1 commit into
mainfrom
claude/backup-claim-destination-first
Aug 27, 2026
Merged

fix(cli): claim the backup destination before dumping, not after#273
jouwdan merged 1 commit into
mainfrom
claude/backup-claim-destination-first

Conversation

@jouwdan

@jouwdan jouwdan commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes MEI-149 — the follow-up I flagged on #271 rather than folding into it, since it is a behaviour change and that PR was a doc correction.

The problem

backupCommand did the expensive work first and only then found out whether it could write the answer anywhere:

await run('pg_dump', [])                  // minutes, on a real board
const { uploads } = await stageUploads()  // more, on s3/blob
await claimBackupDestination(out)          // ← the first test of --out

MEI-104 made that failure legible. It did not make it early. An operator following the upgrade runbook has just been told to take a backup because migrations are forward-only, and docker volume rm docker_pgdata is two lines below — so a late failure there invites re-running under sudo (producing exactly the root-owned bundle MEI-104 fixed) or skipping the backup.

Measured, before and after

Same command both times: an occupied --out and a deliberately unreachable database, which distinguishes which check runs first.

Before — never looks at --out; goes straight for the dump:

Dumping the database…
pg_dump: error: connection to server at "127.0.0.1", port 59999 failed: Connection refused

After — refuses before connecting to anything, and never prints "Dumping the database…":

backup will not write over /tmp/mei149/occupied.tar.gz: something is already there. Move it
aside or pass a different --out. A previous run killed part-way through can leave an empty or
truncated bundle at the path it had claimed; that file is not a backup and is safe to delete.

The occupying file was still 0 bytes afterwards — a refusal does not touch it.

The change

Three lines move; the rest is the consequences.

  • claimBackupDestination(out) and its destinationCreated = true move to the top of the try. The existing finally already removes a reserved-but-unwritten destination, so an ordinary mid-run failure still leaves nothing behind. A failed claim leaves destinationCreated false, so the cleanup never deletes a file that was already there — asserted by a test.
  • Free win kept: two backup --out <same path> runs (a cron job and an operator) previously both dumped before one lost. The second is now refused immediately.
  • EEXIST is translated. Claiming earlier widens the window in which a SIGKILL leaves an empty file at the claimed path, so that path stops being a bare errno and says what is there and what to do. reserveBackupDestination keeps its untranslated contract — the existing test still pins { code: 'EEXIST' } — with the translation in claimBackupDestination beside MEI-104's permission one.

Docs

#271's own prose is what this falsifies, so both places are corrected in the same commit:

File Was Now
operating.md §Backup "it ends there after the database dump, with no bundle to show for it" "It ends there before dumping anything" — plus the concurrent-run and never-overwrite behaviour
upgrading.md "fails with EACCES after the dump, two lines before this runbook destroys the volume" refuses immediately, "so you find out here rather than two lines further down"

I grepped for any other doc still describing the late failure; there were none.

Tests

  • refuses an occupied path with the remedy, not a bare errno (MEI-149) — asserts the message names the path, says what is there, mentions --out, and does not leak EEXIST.
  • leaves the occupying file alone — refusing is not deleting somebody's bundle — writes real content to the path, asserts the refusal, then asserts the content is byte-identical. This is the one that would catch a careless finally.
  • MEI-104's permission translation and reserveBackupDestination's raw-EEXIST contract both still pass unchanged.

Validation

  • pnpm verify passes (exit 0) — 468 test files, 8197 tests, lint, guards, all three typechecks.
  • The before/after contrast above was run against this branch and against main via git stash.
  • pnpm comments:check, pnpm docs:links:check, pnpm site:docs:check pass. Formatted only the files touched.
  • The Backup and restore round-trip CI job exercises the real community backup happy path; unchanged by this, since the claim succeeds and the ordering is invisible on success.

One thing left as-is, deliberately

ENOENT — an --out inside a directory that does not exist — stays a raw errno. It now surfaces immediately like the others, and the message already names the full path, so a third translation looked like noise rather than help. Easy to add if you disagree.


Generated by Claude Code

backupCommand ran pg_dump and staged the uploads before it first tested
whether --out could be written at all. MEI-104 made that failure legible;
it was still late. An operator following the upgrade runbook — where the
next instruction destroys the pgdata volume — waited out a full production
dump to learn their path was wrong, which invites re-running under sudo
(producing the root-owned bundle MEI-104 fixed) or skipping the backup.

Claiming the destination first also makes two backups aimed at one path
safe: the second is refused immediately rather than dumping and then
losing the race.

The cost is a wider window in which a killed run leaves an empty file at
the claimed path, so EEXIST stops being a raw errno from open(…, 'wx') and
says what is there and what to do about it. reserveBackupDestination keeps
its untranslated contract; the translation sits in claimBackupDestination
beside MEI-104's, and a refusal never touches the occupying file.

Both documents that described the failure as arriving after the dump now
describe it as arriving before one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVdrZfcwVhvJpZWUKWxYs9
@jouwdan
jouwdan merged commit 0db94f4 into main Aug 27, 2026
15 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.

2 participants