Files
felhom-agent/internal/backup/inflight.go
T
admin a18b18e5de docs: correct inflight.go's DEFERS claim (F-A1)
The gate's behaviour is correct and unchanged. The comment said 'a caller that
cannot acquire DEFERS to its next cadence' — true of the restore-test caller,
NOT of the backup caller, and it did not say so. The controller recorded the
refusal as a tier failure and emailed the operator; fixed controller-side in
v0.179.0. Comment only, no behaviour change.
2026-07-28 08:50:23 +02:00

70 lines
2.9 KiB
Go

package backup
import "sync"
// InFlight is the host-wide "one heavy guest operation at a time" gate.
//
// R-85 (Scenario F). The operator's R-82 ruling was "one backup at a time per guest"; a restore-test
// must JOIN that single-flight rather than sit outside it. It is not a lock-contention concern —
// a restore-test uses a scratch VMID, so it never touches the live guest's vzdump lock. It is a
// LINK concern: an offsite restore PULLS a multi-GB archive while an offsite backup PUSHES one, over
// the same WireGuard tunnel. On the demo fleet that link runs at ~33 MB/min upstream; running both
// at once makes each slower and pushes both toward their timeouts, which is how a healthy tier ends
// up recorded as failed.
//
// It is deliberately host-wide and coarse rather than per-guest: these boxes carry one customer
// guest, and the resource being protected (the uplink) is shared by everything on the host anyway.
//
// The gate is ADVISORY in one direction only — it never cancels anything already running. A caller
// that cannot acquire DEFERS to its next cadence. Deferring a restore-test costs a few hours of
// coverage; cancelling a running backup costs the backup.
//
// CORRECTED 2026-07-28 (F-A1). That "DEFERS" was true of the restore-test caller and NOT of the
// backup caller, and the comment did not say so. The controller's start path had no 409 branch, so
// a refusal here was recorded as a tier FAILURE: the R-88 breaker armed and the operator was
// emailed "Whole-guest backup FAILED" about a backup that was merely waiting its turn. Campaign 8
// observed it on both demo boxes in the same minute.
//
// Fixed on the CONTROLLER side (v0.179.0), which is where the misreading lived — this gate's
// behaviour was correct throughout and is unchanged. The controller now maps HTTP 409 to a
// contention path: it defers the tier, keeps it DUE, and alarms only if contention outlives the
// agent's own restore-test ceiling. Nothing here needs to change; the claim above is simply now
// true of both callers.
type InFlight struct {
mu sync.Mutex
what string // "" = idle
}
// TryAcquire claims the gate for `what`. ok=false means something else holds it, and `busy` names
// it — the name matters, because "deferred" with no reason is indistinguishable from "broken".
func (g *InFlight) TryAcquire(what string) (release func(), busy string, ok bool) {
if g == nil {
// Not wired (older call sites, tests) → no gating, previous behaviour.
return func() {}, "", true
}
g.mu.Lock()
defer g.mu.Unlock()
if g.what != "" {
return nil, g.what, false
}
g.what = what
var once sync.Once
return func() {
once.Do(func() {
g.mu.Lock()
g.what = ""
g.mu.Unlock()
})
}, "", true
}
// Busy reports what currently holds the gate ("" = idle).
func (g *InFlight) Busy() string {
if g == nil {
return ""
}
g.mu.Lock()
defer g.mu.Unlock()
return g.what
}