Collect orphaned snapshots by name: the sidecar lives in tmpfs
CI / shell (shellcheck + syntax) (push) Successful in 10s
CI / python 3.11 (push) Successful in 13s
CI / python 3.12 (push) Successful in 16s
CI / python 3.13 (push) Successful in 16s

A reboot mid-backup orphaned the entire tree, permanently. The sidecar is the record
of which snapshots a run pinned -- and /run is tmpfs. A reboot or crash between the
recursive snapshot and its cleanup destroyed that record, leaving one snapshot per
descendant dataset (250+ on a real pool) with nothing pointing at them. Nothing would
ever have found them.

gc_stale_snapshots() identifies leftovers by NAME, so it works when the record is
gone. It runs after the sidecar reclaim -- the recorded path stays authoritative and
the collector only mops up what the record lost.

It deletes data on a name match, which is a weaker claim than a recorded fact, so the
selection is a pure function with the harshest tests here. A snapshot is collected
only if the name is exactly <dataset>@<task>-<YYYYMMDDHHMMSS>, it is not the current
run's, NOTHING IS MOUNTED FROM IT (this, not the age guard, is what protects a
concurrent backup), and it is over an hour old.

Checked against the real pool: of 4728 snapshots including 2341 periodic ones, it
selects exactly the orphans of the task being run and nothing else.
This commit is contained in:
2026-07-13 19:56:32 +00:00
parent 2b8ef107f7
commit 0d04c2cd1c
4 changed files with 360 additions and 1 deletions
+29 -1
View File
@@ -207,7 +207,35 @@ worse than no alert, because one day it carries a security fix.
warning, not a refusal: declining to install over a string we failed to read would
be a worse failure than the one being prevented.
- **A stable release may not leave work stranded under `## Unreleased`.** Either it
- **A stable release may not leave work stranded under `## Unreleased
### Fixed
- **A reboot mid-backup orphaned the entire snapshot tree, permanently.** The sidecar
is the record of which snapshots a run pinned — and it lives in `/run`, which is
**tmpfs**. A reboot (or a crash) between taking the recursive snapshot and cleaning
it up destroyed that record, leaving one snapshot per descendant dataset — **250+ on
a real pool** — with nothing left pointing at them. Nothing would ever have found
them again.
`gc_stale_snapshots()` is the backstop: it identifies leftovers **by name**, so it
works when the record is gone. It runs at the start of every backup, after the
sidecar reclaim — the recorded path stays authoritative, and the collector only ever
mops up what the record lost.
Because it deletes data on a *name match* — a weaker claim than a recorded fact — the
selection is a **pure function** with the harshest tests in the suite. A snapshot is
collected only if **all** of these hold:
| | |
| --- | --- |
| name is exactly `<dataset>@<task>-<YYYYMMDDHHMMSS>` | so `cloud_backup-5` never matches `cloud_backup-50`, an `auto-*` periodic snapshot, or anything a human made |
| it is not the current run's | parent *and* children are excluded |
| **nothing is mounted from it** | an in-flight run pins its own snapshots — this, not the age guard, is what protects a concurrent backup |
| it is **over an hour old** | covers the seconds-long window where a live run has snapshotted but not yet mounted |
Verified against the real pool: of **4,728** snapshots — including **2,341** periodic
ones — it selects exactly the orphans of the task being run, and nothing else.`.** Either it
is finished and belongs in the release, or the release is premature. Candidates
are exempt: an rc may legitimately have work queued behind it.