32200c7b5f
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.
139 lines
6.0 KiB
Go
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
|
|
}
|