F-CRIT-1 + F-A1: one alarm that never fired, one that fired wrongly (v0.179.0)
F-CRIT-1 — an app that failed to restart after a quiesce never alarmed, for two
independent reasons, either of which alone kept it dead:
1. restartAll returned nothing, so the failure was logged and dropped and no
caller could learn a customer's app had not come back. It now returns the
stacks that failed; both call sites record the outcome.
2. classifyRunStates whitelists StateStopped on invariant I1 ('StateStopped
means the user stopped it'). The quiesce loop stops stacks by the same
compose-down path, so a failed restart is also StateStopped and was
whitelisted into silence. Loop.FailedRestarts() is now the only thing that
lifts the whitelist, so genuine user stops stay silent (v0.164.0 pinned).
F-A1 — HTTP 409 is the agent's single-flight gate refusing while a restore-test
holds it, not a failure. agentapi now returns a typed *StatusError on POST, the
adapter maps 409 -> quiesce.ErrTierBusy, and the loop defers: no breaker, no
event, no operator email, tier stays DUE.
Two traps avoided. Silence: contention outliving contentionAlarmAfter (3h, set
by the agent's own 120m PBS restore-test ceiling) raises its own BLOCKED signal.
App thrash: removing the failure treatment also removes the breaker's deferral,
so a contended tier is dropped BEFORE anything stops (contentionRetryAfter 15m,
against a 12m01s longest observed restore-test).
Three comments corrected; the invariant rule added to both CLAUDE.md copies.
Six red-proofs, all observed failing.
This commit is contained in:
@@ -105,6 +105,10 @@ type Loop struct {
|
||||
// breaker (R-88) defers the QUIESCE for a tier whose backups keep failing, so a broken target
|
||||
// cannot stop the customer's apps every 5 minutes forever. Scheduled path only — see breaker.go.
|
||||
breaker *failureBreaker
|
||||
// contention (F-A1) tracks per-tier HTTP 409 runs. A refusal is NOT a failure, so it must never
|
||||
// touch the breaker — but it still has to defer the quiesce (else the apps are stopped every
|
||||
// poll for the whole restore-test) and it must alarm if it never ends. See contention.go.
|
||||
contention *contentionTracker
|
||||
// tierNotify (R-97a) reports a tier's backup outcome to the hub. nil = not wired (pre-provisioning).
|
||||
// Init-only: set once at startup via SetTierNotifier, before Run.
|
||||
tierNotify TierNotifier
|
||||
@@ -112,6 +116,9 @@ type Loop struct {
|
||||
// SuppressedStacks so an app WE stopped is not reported to the customer as broken.
|
||||
suppressMu sync.Mutex
|
||||
suppressed map[string]time.Time
|
||||
// restartFailed (F-CRIT-1) is the set of stacks this loop stopped and could NOT restart. Guarded
|
||||
// by suppressMu — same concern, same lock. See suppress.go.
|
||||
restartFailed map[string]struct{}
|
||||
}
|
||||
|
||||
// SetTierNotifier wires the hub-event seam. INIT-ONLY — call once at startup, before Run.
|
||||
@@ -143,7 +150,8 @@ func New(o Options) *Loop {
|
||||
poll: o.Poll, statusPoll: o.StatusPoll, maxQuiesce: o.MaxQuiesce,
|
||||
logger: o.Logger, now: time.Now,
|
||||
windowStartFn: o.WindowStartFn, cadence: o.Cadence,
|
||||
breaker: newFailureBreaker(),
|
||||
breaker: newFailureBreaker(),
|
||||
contention: newContentionTracker(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +165,7 @@ func (l *Loop) Recover() {
|
||||
}
|
||||
l.logger.Printf("[WARN] [quiesce] crash recovery: a quiesce was in progress (job %q, %d stack(s) stopped) — restarting them",
|
||||
m.JobID, len(m.StoppedStacks))
|
||||
l.restartAll(m.StoppedStacks)
|
||||
l.noteRestartOutcome(m.StoppedStacks, l.restartAll(m.StoppedStacks))
|
||||
if err := l.clearMarker(); err != nil {
|
||||
l.logger.Printf("[ERROR] [quiesce] crash recovery: clear marker: %v", err)
|
||||
}
|
||||
@@ -256,9 +264,38 @@ func (l *Loop) noteTierFailure(target, label, errMsg string) {
|
||||
}
|
||||
}
|
||||
|
||||
// noteTierContention records a 409 refusal: log it as contention, defer the tier, and — only if
|
||||
// contention has run past contentionAlarmAfter — raise it as a fault.
|
||||
//
|
||||
// The headline deliberately says BLOCKED, not FAILED. The operator's action for "a concurrent
|
||||
// operation has held the agent for three hours" is entirely different from "the backup errored",
|
||||
// and the whole point of F-A1 is that the two stopped being distinguishable.
|
||||
//
|
||||
// It reuses the existing `whole_guest_backup_failed` event type rather than inventing one: a new
|
||||
// type would need the hub's allowedEventTypes + customerMessages pair changed, i.e. a wire change,
|
||||
// which this fix deliberately does not make.
|
||||
func (l *Loop) noteTierContention(target, label string) {
|
||||
elapsed, alarmNow := l.contention.note(target, l.now())
|
||||
l.logger.Printf("[INFO] [quiesce] tier %s is BUSY — the agent refused the backup because a concurrent heavy operation holds it. This is contention, NOT a failure: the tier stays due and retries in %s (contended for %s)",
|
||||
label, contentionRetryAfter, elapsed.Round(time.Second))
|
||||
if !alarmNow {
|
||||
return
|
||||
}
|
||||
l.logger.Printf("[ERROR] [quiesce] tier %s has been BLOCKED by a concurrent operation for over %s — that exceeds the agent's own restore-test ceiling, so the gate is stuck, not busy",
|
||||
label, contentionAlarmAfter)
|
||||
if l.tierNotify != nil {
|
||||
l.tierNotify.BackupFailed(label,
|
||||
fmt.Sprintf("Whole-guest backup BLOCKED on the %s tier — a concurrent operation has held the agent for over %s and the backup has still not run", label, contentionAlarmAfter),
|
||||
fmt.Sprintf("contention (HTTP 409) unresolved for %s; exceeds the %s restore-test ceiling", elapsed.Round(time.Second), contentionAlarmAfter))
|
||||
}
|
||||
}
|
||||
|
||||
// noteTierSuccess clears any backoff. Quiet unless there was something to clear — a line per healthy
|
||||
// backup would be noise, but a recovery is worth one.
|
||||
func (l *Loop) noteTierSuccess(target, label string) {
|
||||
if l.contention.clear(target) {
|
||||
l.logger.Printf("[INFO] [quiesce] tier %s is no longer contended — the concurrent operation released the agent", label)
|
||||
}
|
||||
// recordSuccess's bool is the edge: true only when there WAS a backoff to clear. That is exactly
|
||||
// the recovery edge — an operator told a tier broke must also be told it healed, and a line (or
|
||||
// an event) per healthy backup would be noise.
|
||||
@@ -285,6 +322,15 @@ func (l *Loop) dropBackedOffTiers(tiers []dueTier) []dueTier {
|
||||
tierLabel(t.target), l.breaker.failuresFor(t.target), until.Format(time.RFC3339))
|
||||
continue
|
||||
}
|
||||
// F-A1: a CONTENDED tier is dropped here too, for the same reason R-88 drops a failing one —
|
||||
// before any stack is stopped. Without this, removing the (wrong) failure treatment would
|
||||
// leave the loop re-quiescing every 5 minutes for the whole restore-test, stopping the
|
||||
// customer's apps each time: worse than the bug being fixed.
|
||||
if until, busy := l.contention.blocked(t.target, now); busy {
|
||||
l.logger.Printf("[DEBUG] [quiesce] tier %s is BUSY (a concurrent heavy operation holds the agent) — not quiescing until %s; the tier stays due",
|
||||
tierLabel(t.target), until.Format(time.RFC3339))
|
||||
continue
|
||||
}
|
||||
kept = append(kept, t)
|
||||
}
|
||||
return kept
|
||||
@@ -423,7 +469,9 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
|
||||
}
|
||||
unquiesced = true
|
||||
l.logger.Printf("[INFO] [quiesce] unquiescing (%s): restarting %d stack(s)", reason, len(running))
|
||||
l.restartAll(running)
|
||||
// F-CRIT-1: record which stacks came back and which did not, so the app-down classifier can
|
||||
// tell "the user stopped this" from "we stopped it and could not restart it".
|
||||
l.noteRestartOutcome(running, l.restartAll(running))
|
||||
// R-97b: start the grace clock AFTER the restart call, so the window measures time the app
|
||||
// has actually had to come up rather than time it spent stopped.
|
||||
l.markUnquiesced(running)
|
||||
@@ -453,8 +501,20 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
|
||||
last := i == len(tiers)-1
|
||||
|
||||
jobID, err := l.startBackupOn(ctx, t.target)
|
||||
if errors.Is(err, ErrTierBusy) {
|
||||
// F-A1: the agent's single-flight gate refused (HTTP 409). Nothing is broken — a
|
||||
// restore-test or another backup holds it. This is CONTENTION, not failure: no breaker,
|
||||
// no whole_guest_backup_failed, no operator email. The tier stays DUE and retries.
|
||||
l.noteTierContention(t.target, label)
|
||||
if last {
|
||||
unquiesce("last tier is busy — deferring to a later cycle")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
l.logger.Printf("[ERROR] [quiesce] start backup on tier %s: %v", label, err)
|
||||
// A REAL error ends any contention run: whatever the gate was doing, this is a fault now.
|
||||
l.contention.clear(t.target)
|
||||
l.noteTierFailure(t.target, label, err.Error())
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("start backup on %s: %w", label, err)
|
||||
@@ -465,6 +525,9 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
|
||||
}
|
||||
continue
|
||||
}
|
||||
// The tier started, so it is not contended. Clear before the poll so a long backup cannot be
|
||||
// mistaken for a stuck gate.
|
||||
l.contention.clear(t.target)
|
||||
marker.JobID = jobID
|
||||
_ = l.writeMarker(marker) // best-effort: record the CURRENT tier's job id for diagnosis
|
||||
l.logger.Printf("[INFO] [quiesce] tier %s: backup job %s started — polling", label, jobID)
|
||||
@@ -488,8 +551,14 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
|
||||
// offsite snapshot legitimately runs for hours). The app is already back up. We must
|
||||
// NOT start the next tier: ONE BACKUP AT A TIME PER GUEST (operator ruling
|
||||
// 2026-07-26) — vzdump still holds the guest lock, so a second start would be refused
|
||||
// by the agent (409) or fail on the lock and record a spurious failure. The remaining
|
||||
// tiers simply run on a later poll, once this one has finished.
|
||||
// by the agent (409) or fail on the lock. The remaining tiers simply run on a later
|
||||
// poll, once this one has finished.
|
||||
//
|
||||
// CORRECTED v0.179.0 (F-A1): this used to say the 409 would "record a spurious
|
||||
// failure". Until v0.179.0 that was not a hypothetical the comment was guarding
|
||||
// against — it was what the start path ACTUALLY did, on every 409, on both boxes.
|
||||
// A 409 is now handled as CONTENTION (see contention.go): no breaker, no operator
|
||||
// email, the tier stays due. So the outcome this comment feared no longer exists.
|
||||
l.logger.Printf("[INFO] [quiesce] tier %s still running past the quiesce bound — deferring %d remaining tier(s) to a later cycle",
|
||||
label, len(tiers)-i-1)
|
||||
break
|
||||
@@ -648,12 +717,21 @@ func within(p, start, span int) bool {
|
||||
return mod1440(p-start) < span
|
||||
}
|
||||
|
||||
func (l *Loop) restartAll(stacks []string) {
|
||||
// restartAll restarts the given stacks and RETURNS the ones that failed.
|
||||
//
|
||||
// F-CRIT-1 cause 1: this used to return nothing. The error was logged and dropped on the spot, so
|
||||
// no caller could learn that a customer's app had not come back — and the classifier downstream
|
||||
// therefore had nothing to key on. A restart failure is the single most important fact this loop
|
||||
// produces; it must leave the function.
|
||||
func (l *Loop) restartAll(stacks []string) []string {
|
||||
var failed []string
|
||||
for _, s := range stacks {
|
||||
if err := l.stacks.StartStack(s); err != nil {
|
||||
l.logger.Printf("[ERROR] [quiesce] restart %s: %v", s, err)
|
||||
failed = append(failed, s)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// ---- marker persistence (atomic, 0600) --------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user