R-330: stop the backup alarming about the apps it is holding down (v0.224.0)
gates / gates (push) Successful in 11s
gates / gates (push) Successful in 11s
Measured live on demo-hp 2026-08-30 (controller 0.223.0): the nightly db-dump
and offbox-backup legs stop each stack ~13s to tar its volumes while the
deadapp-check job scans every 30s, so the scan caught whichever stack was
mid-cycle and pushed app_start_failed to the customer. 61 e-mails about apps
that were never broken.
The defect is not a missing mechanism. quiesce/suppress.go solved exactly this
in v0.179.0 and works -- but classifyRunStates read only the quiesce loop's set,
and that loop covers the WHOLE-GUEST backup. The per-app legs stop stacks
through Manager.DumpAppVolumesSafe, which registered with nothing. Two
mechanisms stop apps on purpose; only one told the alarm. Fifth instance of the
"seam built but never wired" class, and the first where the unwired half was a
consumer.
The suppression now rides AppStopGuard, which already brackets every deliberate
stop in the product (Begin before the stop, End after a successful restart) at
all three call sites, and which main.go hands as ONE object to the backup
manager and the exporter. scanDeployedAppRunStates takes the union of both sets.
All three per-app stop paths are covered, not only the reported nightly one.
It cannot latch -- End() runs only on a restart that SUCCEEDED, so unlike the
quiesce loop an open-ended hold is a real hazard here:
1. ReleaseFailed drops the entry IMMEDIATELY on a restart that broke, wired at
every failure path, so the app alarms on the next scan;
2. Begin REPLACES the set (one marker file = one operation);
3. appStopMaxHold (6h) caps a hold nothing released, logged at WARN.
Grace is 180s, deliberately quiesce's own constant and derivation. Suppression
is NOT persisted: after a crash the guard holds nothing and a down app must
alarm. ReleaseFailed keeps the durable crash marker; a test pins that.
Three companion red-proofs, each printing the pre-fix value (REPORT.md section 5):
- drop markStopped from Begin -> "suppressed at stop = map[]"
- drop ReleaseFailed from the dump -> "map[bookstack:true] after a restart that FAILED"
- pass nil instead of appStopGuard -> the AST wiring test fails
The third is load-bearing: the component was never the broken part, so a suite
that only injected it would have been green against the shipped defect.
Green gate clean: go build + go vet + go test ./... -- 28 packages, rc 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LB8FmJaGd2cyjvy6dbEjpM
This commit is contained in:
@@ -1,3 +1,86 @@
|
||||
## v0.224.0 — the backup alarmed about the apps it was holding down (2026-08-30, R-330)
|
||||
**MinAgent: 0.129.0** (unchanged — no new agent coupling)
|
||||
|
||||
### R-330 — 61 e-mails about apps that were never broken
|
||||
|
||||
**Measured live on `demo-hp` 2026-08-30, controller 0.223.0.** Every night both demo boxes e-mailed
|
||||
the customer `app_start_failed — "Telepitett alkalmazas nem fut: <App>"` about healthy apps. Both
|
||||
bursts were the box's own backup:
|
||||
|
||||
| leg (UTC) | window | events |
|
||||
|---|---|---|
|
||||
| `db-dump` 00:30 | W (02:30 CEST) | Docmost, Paperless-ngx, RomM |
|
||||
| `offbox-backup` 02:15 | W+105m (04:15 CEST) | Docmost, Paperless-ngx |
|
||||
|
||||
`DumpAppVolumesSafe` stops a stack (`docker compose down`), tars its volumes and starts it again —
|
||||
**~13 s per stack, measured** — while `deadapp-check` scans every **30 s**. The scan caught whichever
|
||||
stack was mid-cycle. Every other scan that day logged `0 currently down`, and the same night's
|
||||
`[offbox] backup OK: 8 app(s) backed up, 67 snapshot(s)` proves the backup itself was healthy.
|
||||
|
||||
**The defect is not a missing mechanism — it is a mechanism that was never consulted.**
|
||||
`quiesce/suppress.go` solved exactly this in v0.179.0 (R-97b) and works. But `classifyRunStates` read
|
||||
only `Loop.SuppressedStacks()`, and the quiesce loop covers the **whole-guest** (vzdump/PBS) backup.
|
||||
The **per-app** legs stop stacks through `Manager.DumpAppVolumesSafe`, which registered with nothing.
|
||||
Two mechanisms in this product stop a customer's app on purpose; only one told the alarm. That is the
|
||||
**"seam built but never wired"** class, and this is its fifth instance — the first where the unwired
|
||||
half was a *consumer* rather than a producer.
|
||||
|
||||
**The fix sits on `AppStopGuard`, not in a fourth registry.** The guard already brackets every
|
||||
deliberate stop in the product — `Begin` before the stop, `End` after a successful restart — at all
|
||||
three call sites (volume dump, off-site reconstitute, `.fab` export), and `main.go` hands the SAME
|
||||
guard object to the backup manager and the exporter. The fact the alarm needs already existed there
|
||||
with exactly one writer. `scanDeployedAppRunStates` now takes the union of both suppression sets.
|
||||
|
||||
**All three per-app stop paths are covered by the one change**, not just the nightly one that was
|
||||
reported. A `.fab` export and an off-site restore stop an app the same way and would alarm the same
|
||||
way; fixing only the observed leg would have left two loaded guns.
|
||||
|
||||
### It must never latch — the harder half
|
||||
|
||||
Permanent suppression trades a loud false alarm for a silent real one, which is F-CRIT-1 and R-88
|
||||
Scenario D over again. `AppStopGuard.End()` runs **only on a restart that succeeded**, so an
|
||||
open-ended hold is a real hazard here in a way it is not for the quiesce loop, which always releases.
|
||||
Three independent things stop the window latching:
|
||||
|
||||
1. **`ReleaseFailed`** — a restart that was ATTEMPTED AND BROKE drops the entry **immediately**, so
|
||||
the app alarms on the very next scan with no delay at all. Wired at every failure path: the volume
|
||||
dump, the off-site reconstitution's `restartStack`, and the exporter's restart defer (the seam
|
||||
interface grew the method rather than the exporter keeping its own bookkeeping).
|
||||
2. **`Begin` REPLACES the set.** The marker file holds one operation, so a new `Begin` proves the
|
||||
previous one is over; a set stranded by an operation that died mid-window cannot survive into a
|
||||
later one.
|
||||
3. **`appStopMaxHold` (6 h)** — a backstop for a hold nothing ever released, logged at WARN when it
|
||||
fires. Longer than any real hold (13 s dump, minutes for an export, hours at the outside for a
|
||||
multi-gigabyte reconstitution) and far shorter than "forever". Exceeding it means something is
|
||||
wrong, and the right answer when something is wrong is to let the alarm through.
|
||||
|
||||
The grace after a successful restart is **180 s, deliberately the same constant as
|
||||
`quiesce.quiesceAlarmGrace`** and by the same derivation (120 s deploy health timeout; Mealie's 60 s
|
||||
`start_period` plus check intervals). Two suppression windows over one alarm that disagreed on how
|
||||
long a restart takes would be a bug waiting to be found on whichever path used the shorter one.
|
||||
|
||||
**The suppression is deliberately NOT persisted.** After a crash the guard holds nothing: `Recover()`
|
||||
either brings the apps back or leaves them genuinely down, and a down app must alarm. Reviving a
|
||||
suppression across a restart would silence the exact case the alarm exists for. The durable crash
|
||||
marker is untouched by all of this and stays the recovery record — `ReleaseFailed` drops the
|
||||
suppression and **keeps** the marker, and a test pins that.
|
||||
|
||||
### Tests, and what each red-proof actually printed
|
||||
|
||||
`internal/backup/appstop_suppress_test.go` drives the **real** `DumpAppVolumesSafe` and asserts the
|
||||
suppression set the dead-app scanner actually reads — the consequence, not a log line. Three
|
||||
red-proofs were run and are recorded in `REPORT.md`:
|
||||
|
||||
- deleting `markStopped` from `Begin` → `suppressed at stop = map[]`, the exact pre-fix shape;
|
||||
- deleting `ReleaseFailed` from the volume dump → `suppressed = map[bookstack:true] after a restart
|
||||
that FAILED`;
|
||||
- passing `nil` instead of `appStopGuard` in `main.go` → the AST wiring test fails.
|
||||
|
||||
That third one is the point: the component was never the broken part, so a test that only injects it
|
||||
directly would have passed against the shipped defect. `cmd/controller/r330_backup_suppression_test.go`
|
||||
walks main.go's AST rather than matching a string, because a commented-out call satisfies
|
||||
`strings.Contains` — a sibling test in that package records paying for exactly that.
|
||||
|
||||
## v0.223.0 — the alarm we had just built reached nobody, and the stop nobody heard (2026-08-23, R-329 + R-386)
|
||||
**MinAgent: 0.129.0** (unchanged — no new agent coupling)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user