Files
felhom-controller/controller/internal/quiesce/breaker.go
T
admin 32200c7b5f R-88 Part 1: a failing backup stops re-quiescing (v0.176.0)
internal/quiesce had no failure counter, no backoff and no breaker, and the driver
is a plain 5-minute ticker — so a tier that was due and kept failing stopped and
restarted every customer app stack every 5 minutes indefinitely. Live on
demo-felhom 2026-07-27: three cycles in eleven minutes against an unreachable PBS
tier; it ended only because PBS recovered.

The breaker gates the QUIESCE, not the backup — the harm was the outage taken to
attempt it, so backed-off tiers are dropped before any stack is stopped. Per
target (a broken offsite tier must not suppress a healthy local one), 15m→30m→
1h→2h→4h capped, reset on success, never permanent, never applied to TriggerNow,
and stillRunning is not a failure. State is in-memory on purpose: forgetting a
backoff costs one attempt; persisting one could outlive the fix.

Part 3 invariant recorded on scheduledRunAllowed — a missing value means UNKNOWN,
and only a positive 'never' may fire the safety valve. Fourth instance of the
class (hub v0.12.0, v0.73.0, R-81, R-88).

Part 2 (unknown != never) is NOT in this commit: the agent returns byte-identical
responses for 'read errored' and 'never backed up', so the controller cannot tell
them apart. That needs an agent wire change and is tracked separately.
2026-07-27 16:21:58 +02:00

139 lines
6.0 KiB
Go

package quiesce
import (
"sync"
"time"
)
// R-88 — the failure breaker.
//
// THE BUG THIS EXISTS TO KILL: before this, `internal/quiesce` had no consecutive-failure counter,
// no backoff and no circuit breaker of any kind. The driver is a plain 5-minute ticker, so a tier
// that was due and kept failing was re-quiesced every five minutes FOREVER — and a quiesce cycle
// stops and restarts every customer app stack. Observed live on demo-felhom 2026-07-27: three full
// stop/start cycles across eleven minutes (09:02:57, 09:07:58, 09:12:57 Budapest) against a PBS tier
// that could not possibly succeed. It stopped after three only because PBS came back, not because
// anything gave up.
//
// The harm is NOT the failing backup — it is the app outage taken to attempt it. So the breaker gates
// the QUIESCE, not the backup: a tier in backoff is dropped from the due set before any stack is
// stopped.
//
// ── WHAT THIS DELIBERATELY IS NOT ────────────────────────────────────────────────────────────
//
// It is NOT permanent, and it must never become permanent. The cap bounds the retry INTERVAL; it
// never stops retrying. A breaker that latches open is a silent backup outage, which is strictly
// worse than the loop it replaces — the loop at least announced itself by stopping the apps.
//
// It is NOT global. State is per TARGET (`dueTier.target`), so a broken offsite tier cannot suppress
// a healthy local one. Halting all backups because one tier is down would trade a narrow fault for a
// total one.
//
// It does NOT gate the manual path. `TriggerNow` bypasses due-ness and the window gate, and it
// bypasses this too — a human pressing „Mentés most" has made an explicit decision and must not be
// deferred by a breaker built for the scheduler. Manual runs still RECORD their outcome (a manual
// success clears the backoff, which is exactly what an operator fixing the tier expects).
//
// ── CRASH SAFETY: in-memory, ON PURPOSE ──────────────────────────────────────────────────────
//
// This state is deliberately NOT persisted. A controller restart clears it, so the next cycle
// attempts the backup immediately. That is the direction this should fail in: forgetting a backoff
// costs one extra attempt, whereas persisting it could carry a stale "this tier is broken" verdict
// across a restart that actually fixed the tier. Do not "fix" this into persistence without deciding
// which way you want it to fail — the cheap failure is the one we chose.
const (
// breakerBaseDelay is the first backoff. It must exceed the poll interval (5m) by enough that the
// thrash stops immediately: at 15m the very first failure already skips two ticks.
breakerBaseDelay = 15 * time.Minute
// breakerMaxDelay caps the interval. 4h is picked against two real constants rather than taste:
// it sits well inside the SHORTEST tier cadence (local = 24h), so a tier that recovers still gets
// several attempts within its own cadence; and it equals the width of the backup window gate
// [W+2h, W+6h), so a tier at maximum backoff still gets at least one attempt inside any given
// night's window instead of stepping over it entirely.
breakerMaxDelay = 4 * time.Hour
// breakerMaxShift bounds the doubling so a long-broken tier cannot overflow the shift. 15m << 5
// is already past the cap, so this is a guard, not a policy.
breakerMaxShift = 5
)
// breakerState is one tier's consecutive-failure record.
type breakerState struct {
failures int
until time.Time
}
// failureBreaker tracks consecutive backup failures per target and defers the quiesce accordingly.
// The zero value is not usable — build it with newFailureBreaker.
type failureBreaker struct {
mu sync.Mutex
states map[string]breakerState
}
func newFailureBreaker() *failureBreaker {
return &failureBreaker{states: map[string]breakerState{}}
}
// backoffFor is the delay after n consecutive failures: 15m, 30m, 1h, 2h, 4h, then 4h forever.
// PURE, so the schedule is a unit-testable contract rather than an emergent property of the loop.
func backoffFor(failures int) time.Duration {
if failures <= 0 {
return 0
}
shift := failures - 1
if shift > breakerMaxShift {
shift = breakerMaxShift
}
d := breakerBaseDelay << uint(shift)
if d > breakerMaxDelay {
return breakerMaxDelay
}
return d
}
// blocked reports whether target is currently deferred, and until when.
func (b *failureBreaker) blocked(target string, now time.Time) (time.Time, bool) {
b.mu.Lock()
defer b.mu.Unlock()
st, ok := b.states[target]
if !ok || st.until.IsZero() || !now.Before(st.until) {
return time.Time{}, false
}
return st.until, true
}
// recordFailure increments the tier's consecutive-failure count and arms the next backoff. Returns
// the new count and delay so the caller can log the deferral ONCE, at the moment it is armed — a
// 5-minute loop that logged on every skipped tick would bury the signal it exists to raise.
func (b *failureBreaker) recordFailure(target string, now time.Time) (int, time.Duration) {
b.mu.Lock()
defer b.mu.Unlock()
st := b.states[target]
st.failures++
d := backoffFor(st.failures)
st.until = now.Add(d)
b.states[target] = st
return st.failures, d
}
// recordSuccess clears the tier's backoff. Returns true if there was one to clear, so the caller can
// log the recovery without narrating every healthy backup. Scenario B: normal cadence resumes on the
// very next cycle — a box that recovers carries no lingering penalty.
func (b *failureBreaker) recordSuccess(target string) bool {
b.mu.Lock()
defer b.mu.Unlock()
if _, ok := b.states[target]; !ok {
return false
}
delete(b.states, target)
return true
}
// failuresFor exposes the consecutive-failure count (tests + diagnosis).
func (b *failureBreaker) failuresFor(target string) int {
b.mu.Lock()
defer b.mu.Unlock()
return b.states[target].failures
}