Files
felhom-controller/REPORT.md
T
admin b8598361b8 docs: REPORT for R-88 Part 1 (v0.176.0)
Includes the Phase 0 findings: the nil is agent-side (case a, a type boundary
that cannot represent 'unknown'), the four restarts were deliberate rather than
self-update, and the loop produced zero backup_failed events because quiesce
never imports notify (filed R-97).
2026-07-27 16:27:21 +02:00

106 lines
7.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# REPORT — R-88 Part 1: the quiesce failure breaker (v0.176.0) (2026-07-27)
**Overwritten** per the standing rule. Controller **v0.175.0 → v0.176.0**. **MinAgent UNCHANGED**
this is controller-internal scheduling; no agent surface is touched.
## What was broken
`internal/quiesce` had **no consecutive-failure counter, no backoff and no circuit breaker of any
kind**, and the loop driver is a plain 5-minute `time.Ticker`. A tier that was due and kept failing
was therefore re-quiesced every five minutes — and a quiesce cycle stops and restarts **every
customer app stack**.
Observed live on demo-felhom 2026-07-27: three full cycles at 09:02:57, 09:07:58 and 09:12:57
Budapest, each stopping and restarting `bookstack calibre-web docmost immich` (~19 s down per cycle,
~50 s per full cycle) against a PBS tier that could not succeed. **It stopped after three only
because PBS came back**, not because anything gave up. Had the outage lasted, so would the loop.
## Phase 0 (read-only) — the three answers
**P0.1 — case (a): agent-side.** The storage read **errored**; it was never an empty success. The
defect is a type boundary: `newestArchiveOn` (`localapi/server.go:1095-1111`) promises in its own
comment that *"errors … degrade to unknown, never to 'no backup'"*, but its `(time.Time, bool)`
signature **cannot represent unknown** — an error and a genuinely-empty storage both become
`(zero, false)`. `handleBackupDue` (`server.go:934-941`) then emits a *positive* claim:
`Due: true, Reason: "no successful backup recorded yet", AgeSecs: nil`. The fail-safe that does
exist (`targetStoragePresent` — "a storage-view error must never read as 'not there'") answers a
different question and behaved correctly.
**Decisive for scoping: the errored path and the genuine-never path are byte-identical on the wire.**
Same `Due`, same `Reason`, same nil `AgeSecs` — the controller has nothing to discriminate on, so
Part 2 cannot be done here. It needs an agent wire change plus a compat rule both ways → own task.
**P0.2 — deliberate restarts, not self-update.** Four on 2026-07-27 (07:36:39, 07:54:06, 08:50:16,
11:31:52 CEST), all clean `Stopping → Stopped → Started`, `NRestarts=0`, `Result=success`, zero
self-update lines. No automatic mechanism arms the trigger — but ordinary operator/config work armed
it four times in one day, making the trigger **more** frequent than "only when ep0 is down", not less.
**P0.3 — the loop alerted nobody.** Zero `backup_failed` events, and not because of the allowlist
(the hub carries that type). **`internal/quiesce` does not import `internal/notify` at all.** The
only trace was `07:13:27 info app_start_failed "Telepített alkalmazás nem fut: BookStack"` — a
customer-tier, Hungarian, info-severity *symptom* of the third cycle catching BookStack mid-restart.
Filed as **R-97**; out of scope here.
## What shipped
`internal/quiesce/breaker.go` — consecutive failures per **target**; a tier inside its backoff is
dropped from the due set **before any stack is stopped**. The gate is on the QUIESCE, not the backup,
because the harm was never the failing backup but the outage taken to attempt it.
**Backoff `15m → 30m → 1h → 2h → 4h`, then 4h forever.** The cap is chosen against two real
constants: 4 h sits well inside the shortest tier cadence (local 24 h), so a recovered tier still
gets several attempts within its own cadence; and it equals the width of the window gate
`[W+2h, W+6h)`, so a tier at maximum backoff still gets at least one attempt inside any given
night's window rather than stepping over it.
Bounded four ways, each with a test: **never permanent** (the cap bounds the interval, never stops
retrying — a latched breaker is a silent backup outage, worse than the loop); **never global**;
**never gates `TriggerNow`** (manual runs still record their outcome, so a manual success clears the
backoff); **`stillRunning` is not a failure** (a first full offsite snapshot legitimately runs for
hours). State is **in-memory on purpose** — a restart forgets the backoff and re-attempts, the cheap
direction to fail.
**Part 3** — the invariant is written at the head of `scheduledRunAllowed`: a missing value means
UNKNOWN, and only a *positive* "never backed up" may fire the safety valve. Fourth instance of the
class (hub v0.12.0, v0.73.0, R-81, R-88), all four named in the comment.
## Tests — 27 packages ok, 0 failed; `build` / `vet` / `test` each rc=0, run separately
11 new: 7 breaker + 4 contract. Three red-proofs, each observed and restored:
| Scenario | Mutation | Observed failure |
|---|---|---|
| **A** | remove the `dropBackedOffTiers` call | `R-88: 3 failing ticks stopped the apps 3 time(s); want 1 — the breaker did not defer anything` |
| **D** | flip the nil branch to `return false` | `CONTRACT VIOLATED: a never-backed-up box outside its window did NOT back up (0 stack stop(s)) — the safety valve was removed; a box only ever powered on outside its window would starve` |
| **F** | make the breaker global (any blocked tier halts all) | `R-88 Scenario F: the healthy local tier was backed up 2 time(s) across 4 ticks; want >= 3 — a broken tier must not halt a working one` |
**Scenario A asserts a COUNT, not a log line** — the harm is the stop/start pairs, so the test counts
them, at the exact incident cadence: 3 failing ticks → **3 pairs before the fix, 1 after**.
(Deleting the D branch outright panics on the nil deref two lines down; `return false` is the
mutation a real over-correction would produce, so that is the one recorded.)
**How Scenario D is preserved:** the nil branch of `scheduledRunAllowed` is untouched and still fires
the valve, so a genuinely never-backed-up box powered on only outside its window still backs up.
`TestContract_NeverBackedUp_RunsOutsideTheWindow` pins it;
`TestContract_RecentBackup_DefersOutsideTheWindow` is the control (without it the first test would
also pass against a gate that was simply disabled); and
`TestContract_BreakerNeverStarvesAFirstBackup` proves the breaker cannot starve a first backup either.
## Deployed
`gitea.dooplex.hu/admin/felhom-controller:0.176.0`**demo-hp first, then demo-felhom**, both
`Up … (healthy)`, both logging `[quiesce] loop started (poll 5m0s, max-quiesce 30m0s)` with **no**
backoff or deferral lines across two full poll intervals.
## NOT yet live-validated
- **The loop was reproduced in UNIT TESTS ONLY.** Reproducing it live needs PBS unavailable, and ep0
must not be taken down for it — a live DR tier on a box with no swap that already OOM'd once today
(R-90). No non-production controller was stood up.
- **No backoff has armed on real hardware** — both boxes are healthy, so what is verified live is the
negative: it does not fire spuriously.
- **Scenario C is not fixed and not testable here** → R-88 Part 2 (agent).
- demo-felhom's local tier becomes due ~2026-07-27 14:53 UTC; with a real age (~24 h, under
cadence+24 h) it should **defer** to the 02:3006:30 UTC window rather than run — correct
behaviour, and it means the first live in-window backup under v0.176.0 lands tomorrow.