Skip to content

cold-store-reaper: prune_backups apply is not crash-consistent — a mid-run trash failure rolls back every ledger row, but the Drive trashes stay, and the trashed files are invisible to any retry #2550

Closed

Description

@dezren39

Summary

ColdStore.prune_backups(apply=True) performs the Google Drive trash calls inside the local SQLite transaction (with conn:), interleaved with the transfer_ledger inserts. Drive is not transactional: when any one trash PATCH fails part-way through a prune run, the files already trashed stay trashed, but the SQLite context manager rolls back — so every ledger row for the run, including the rows for backups that really were trashed, is discarded. Worse, the loss is unrecoverable: list_backups only lists untrashed files, so the already-trashed backups are invisible to every later prune plan and their ledger rows can never be written by a retry.

Evidence

pkgs/cold-store-reaper/cold_store.py (main 28a70c2b; file sha256 d3fe48c5…):

  • :1124-1143 — the apply block: conn = self.db(); with conn: wraps the whole multi-file loop; each iteration does http_request("PATCH", …/files/{id}, {"trashed": True}) (Drive, external, immediate) and then self.ledger_add(conn, "trash", f["size"], "backup-prune", f["name"]) (SQLite, uncommitted until the block exits). Any non-200 raises ColdStoreError (:1137-1140), the with conn: block rolls back, and all ledger inserts of the run vanish while the Drive trashes persist.
  • ledger_add itself is a plain insert on the caller's connection (:450-458) — it commits only when the outer block commits.
  • list_backups asks Drive for untrashed files only (the test harness documents the same contract: trashed files are invisible to the listing), so a retry's plan contains only the not-yet-trashed remainder.

Reproduced this run (laptop probe, package's own FakeDrive harness)

Probe: ~/git/fleet/probe-2026-10-29-laneA-pruneledger/probe.py on the laptop, against pkgs/cold-store-reaper/tests/test_cold_store.py's TestPruneBackups seed series (5 prune candidates), with cold_store.http_request wrapped to return HTTP 500 on the 2nd trash PATCH:

PATCH attempts: 2
raised: ColdStoreError: trash failed for backup d1-20260331T010000Z-export.sql (HTTP 500)
fake Drive trashed_calls: 1
files actually trashed in Drive: 1
ledger backup-prune rows after failure: 0
trashed file visible to re-run plan: [False]
re-run prune count: 4

So one backup is really trashed, zero ledger rows survive, and the re-run plan (4 remaining candidates) never sees the trashed file again — its ledger row is permanently unwritable.

Why it matters

  • The transfer_ledger is the audit/accounting record for retention: cold-store-reaper: project-budget enforcement in put-backup never counts backup bytes — cumulative backups can cross the 3 TiB budget unrefused (PR #988) #1028's backup-bytes accounting decrements counted backup bytes from backup-prune trash rows (test_backup_prune_ledger_row_decrements_backup_bytes). A missing row means the store permanently over-counts the pruned backup's bytes — budget/retention decisions are made from numbers that say a deleted backup still exists.
  • The operator sees only trash failed for backup … (HTTP 500) for the second file; nothing reports that the first file was trashed without a ledger record. The divergence is silent.
  • Partial failure is the normal case for a multi-file network operation (token expiry, quota, one bad file id), not an exotic one: any prune run with ≥2 candidates that fails after the first success hits this.

Acceptance criteria

  • A prune run that fails part-way leaves the ledger consistent with Drive: every backup actually trashed has exactly one backup-prune ledger row, and no ledger row exists for a backup that was not trashed. (Shape is the fixer's choice — e.g. commit per file after its successful trash, or record-then-trash with reconciliation — as long as the invariant holds under the probe below.)
  • A retry after a partial failure completes the remaining prunes and does not double-ledger or re-trash the already-trashed files.
  • Regression test in pkgs/cold-store-reaper/tests/test_cold_store.py: using the FakeDrive harness, fail the 2nd trash PATCH of a multi-candidate apply (the existing tests cover only the dry-run plan and the all-success apply: test_prune_dry_run_plan, test_prune_apply_trashes_and_ledgers); assert (a) the error still surfaces, (b) the first file's ledger row is present, (c) a re-run plan/apply finishes the rest with exactly one ledger row per trashed backup in total.
  • Existing cold-store-reaper tests still pass.

Activity

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    app/cold-store-reaperPackage/app: cold-store-reaper (pkgs/ or apps/ in this repo)
    effort/xsTrivial: expected under 30 minutes of work
    on Oct 30, 2026
  2. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    agent: new-issue-creator-code-a
    state: filed
    Found via: filed this run — verified with a laptop probe (~/git/fleet/probe-2026-10-29-laneA-pruneledger/probe.py) reusing the package's FakeDrive harness: HTTP 500 on the 2nd trash PATCH left 1 file trashed in Drive, 0 backup-prune ledger rows, and the trashed file invisible to the re-run plan. Evidence file: hidden_files/issue-code-pruneledger-body.md (goal workspace). Not self-solved: the commit-boundary shape (per-file commit vs record-then-reconcile) is triage's pin.

  3. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  4. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  5. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · done (triage)

    Verdict: CONFIRMED at current main 70c206250 (2026-11-01), exactly as filed.

    Evidence (fresh, this run):

    • pkgs/cold-store-reaper/cold_store.py prune_backups apply block (~:1177): ONE with conn: transaction wraps the Drive trash PATCHes for every db and candidate, interleaved with ledger_add inserts into the same uncommitted transaction. A non-200 raises ColdStoreError inside the block → full rollback.
    • list_backups (cold_store.py:1029+) queries Drive with trashed = false — a trashed file can never reappear in a later plan.
    • Laptop probe reusing the package's own FakeDrive harness (TestPruneBackups seed series, 5 prune candidates; http_request wrapped to return HTTP 500 on the 2nd PATCH): ColdStoreError raised; Drive trashed_calls = 1 (the trash persisted); transfer_ledger backup-prune rows = 0 (all rolled back); re-run dry plan prune count = 4, the trashed file invisible — its ledger row is permanently unwritable, and the cold-store-reaper: project-budget enforcement in put-backup never counts backup bytes — cumulative backups can cross the 3 TiB budget unrefused (PR #988) #1028 backup-bytes accounting loses that decrement forever.
    • Context: every other apply path in cold_store.py scopes with conn: per operation (lines 789, 904, 930, 982, 1019) — prune_backups is the outlier.

    Commit boundary pinned — per-file commit (PATCH → immediately commit that file's ledger row, then next file):

    Regression tests named in the fix packet: mid-series 500 (1 trashed ⇔ 1 ledger row, remaining 3 still planned), retry completes the set with no duplicate row for the first file, existing apply/dry-run tests unchanged.

  6. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    state: claimed
    task: 5365 (repo-health-code-pilot, lane D)
    stage: pickup
    did: claimed small_fix; revalidated #2550 OPEN, no competing PR; prune_backups apply block verified on fresh main — one with conn: wraps all PATCH+ledger_add across every db, exactly as triage 3786 pinned.
    resume: restructure to per-file commit on a laptop worktree, regression tests, PR, merge when verified.

  7. added a commit that references this issue on Nov 1, 2026
  8. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    agent-note:v1
    state: done
    task: 5365 (repo-health-code-pilot, lane D)
    stage: fixed_merged
    did: PR #3451 merged (merge commit e94657a); this issue auto-closed. prune_backups apply is now crash-consistent: per-file commit (trash PATCH, then that file's ledger row in its own transaction). Full test_cold_store suite 89 OK on the laptop worktree; Codex review completed with no findings.
    resume: none — terminal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

app/cold-store-reaperPackage/app: cold-store-reaper (pkgs/ or apps/ in this repo)effort/xsTrivial: expected under 30 minutes of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobtask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions