v0.335.0: no app stop for a tier the agent will refuse because its host is busy (host_busy pre-check, R-921)
gates / gates (push) Successful in 53s

This commit is contained in:
2026-10-19 20:05:52 +02:00
parent db980ff2cc
commit 15ea002458
9 changed files with 282 additions and 13 deletions
+16 -1
View File
@@ -132,7 +132,7 @@ func (c *Client) BackupStatusFor(ctx context.Context, target string) (StatusResp
// tier is the agent's first reason to refuse POST /backup (HTTP 409), so the controller asks this BEFORE it
// stops any app. It reads only GET /backup/tiers and GET /backup/status?target= (both agent >= v0.97.0); a
// pre-R-82 agent (no /backup/tiers) answers nil, nil. Any other error is returned — the caller fails toward
// backing up. It does NOT see the agent's host-wide heavy-operation gate: no endpoint serves it.
// backing up. It does NOT see the agent's host-wide heavy-operation gate; HostBusy does (newer agents).
func (c *Client) BackupJobsInFlight(ctx context.Context) ([]string, error) {
tiers, err := c.BackupTiers(ctx)
if errors.Is(err, ErrTiersUnsupported) {
@@ -154,6 +154,21 @@ func (c *Client) BackupJobsInFlight(ctx context.Context) ([]string, error) {
return out, nil
}
// HostBusy (R-921) reads GET /backup/status's host_busy: whether the agent's host-wide heavy-operation gate is held
// right now (another guest's backup, the restore test, a night OS step …) — the agent's second reason to refuse
// POST /backup. known=false: the agent does not serve the field (older than the agent release that adds it) — no
// information, never „free". An error is returned; the caller fails toward backing up.
func (c *Client) HostBusy(ctx context.Context) (busy bool, what string, known bool, err error) {
st, err := c.BackupStatusFor(ctx, "")
if err != nil {
return false, "", false, err
}
if st.HostBusy == nil {
return false, "", false, nil
}
return st.HostBusy.Busy, st.HostBusy.What, true, nil
}
// SetBackupTargetResponse mirrors POST /backup/target (agent >= v0.113.0).
type SetBackupTargetResponse struct {
Target string `json:"target"`
+9
View File
@@ -202,6 +202,15 @@ type StatusResponse struct {
// Tiers (agent >= v0.131.0, R-517) — per tier: newest success, last attempt, storage presence.
// Absent on an older agent; the page then falls back to the single latest record.
Tiers []TierBackupState `json:"tiers,omitempty"`
// HostBusy (R-921, agent Unreleased 2026-10-19) — whether the agent's host-wide heavy-operation gate is held
// right now (then POST /backup answers 409). nil = an older agent that does not serve it.
HostBusy *HostBusyState `json:"host_busy,omitempty"`
}
// HostBusyState mirrors the agent's localapi.HostBusyState.
type HostBusyState struct {
Busy bool `json:"busy"`
What string `json:"what,omitempty"`
}
// TierBackupState mirrors the agent's localapi.TierBackupState.
@@ -73,3 +73,35 @@ func TestBackupJobsInFlight_StatusErrorIsReturned(t *testing.T) {
t.Fatal("a failed status read was swallowed — the caller could not tell it from 'nothing in flight'")
}
}
// R-921 part 2: HostBusy reads the agent's real wire shape (untargeted GET /backup/status, `host_busy`); an agent
// without the field is „not known" — never „free".
func TestHostBusy_ReadsTheAgentsGate(t *testing.T) {
for _, tc := range []struct {
name, body string
busy, known bool
what string
}{
{"held", `{"ok":true,"data":{"vmid":9201,"phase":"idle","host_busy":{"busy":true,"what":"os-pve-step"}}}`, true, true, "os-pve-step"},
{"free", `{"ok":true,"data":{"vmid":9201,"phase":"idle","host_busy":{"busy":false}}}`, false, true, ""},
{"older agent", `{"ok":true,"data":{"vmid":9201,"phase":"idle"}}`, false, false, ""},
} {
t.Run(tc.name, func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("GET /backup/status", func(w http.ResponseWriter, r *http.Request) {
if r.URL.RawQuery != "" {
http.Error(w, "want the untargeted status", http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(tc.body))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
busy, what, known, err := c.HostBusy(context.Background())
if err != nil || busy != tc.busy || known != tc.known || what != tc.what {
t.Fatalf("HostBusy = %v %q known=%v err=%v, want %v %q known=%v", busy, what, known, err, tc.busy, tc.what, tc.known)
}
})
}
}
+66 -9
View File
@@ -14,11 +14,19 @@ import "context"
// (localapi otherTierInFlight) — that IS readable, per tier, from GET /backup/status?target=…. (2) Its
// host-wide heavy-operation gate is held (backup.InFlight: the OS step after the night's local copy, a
// restore-test, fstrim) — that is served by NO endpoint (InFlight.Busy() exists and nothing exposes it).
// So the pre-check below covers reason (1) only. Reason (2) — the measured instance — is met by the
// So the pre-check below covered reason (1) only. Reason (2) — the measured instance — was met by the
// BUSY path in quiesceAndPollTiers, which resumes the apps at once (pinned by
// TestR921_BusyRefusalResumesAtOnce); closing it before the stop needs the agent to serve its gate.
// TestR921_BusyRefusalResumesAtOnce).
//
// The race (free at the check, busy at the start) needs nothing new: it is the BUSY path again.
// UPDATED 2026-10-19 (controller 0.335.0, operator answer „add one field"): the agent now serves its gate —
// GET /backup/status `host_busy` (felhom-agent, Unreleased on 2026-10-19; it reaches a box with the next agent
// release). HostBusyProber reads it; a held gate is handled exactly like a 409 (contention: the tier stays due,
// is skipped for contentionRetryAfter, alarms after contentionAlarmAfter) — but with NO app stopped. An agent
// that does not serve the field keeps the old order (stop, ask, resume at once on 409). Pinned by
// TestR921_NoStopWhileTheHostIsBusy.
//
// The race (free at the check, busy at the start) needs nothing new: it is the BUSY path again — one short stop,
// resumed at once, as before. The check is a read, never a reservation.
// InFlightProber is the OPTIONAL pre-check surface (R-921). An adapter that does not implement it keeps
// the pre-R-921 behaviour exactly: stop, ask, and on refusal resume at once.
@@ -28,31 +36,80 @@ type InFlightProber interface {
BackupJobsInFlight(ctx context.Context) ([]string, error)
}
// HostBusyProber is the OPTIONAL read of the agent's host-wide heavy-operation gate (R-921, agent with
// `host_busy`). known=false: the agent does not serve it — no information.
type HostBusyProber interface {
HostBusy(ctx context.Context) (busy bool, what string, known bool, err error)
}
// precheckSkips is the whole check-first rule: true = the agent would refuse this window, so no app is stopped.
func (l *Loop) precheckSkips(ctx context.Context, tiers []dueTier) bool {
first := tiers[0].target
skip, ownInFlight := l.anotherTierInFlight(ctx, first)
if skip {
return true
}
if ownInFlight || first == "" {
// The window's own tier in flight: the agent answers its start with the running job (202) whatever its
// gate says (the gate may be held by that very job) — attach as before. An untargeted window is a
// pre-R-82 agent, which serves no gate read.
return false
}
return l.hostBusyRefuses(ctx, tiers)
}
// hostBusyRefuses: the agent says its host-wide gate is held, so every tier of this window would be refused (409).
// Recorded as CONTENTION for each tier — the same verdict a 409 gets (retry after contentionRetryAfter, one alarm
// after contentionAlarmAfter), so a gate that never lets go still pages the operator — and no app is stopped.
// Fail toward backing up: an error, an older agent or an adapter without the surface → false.
func (l *Loop) hostBusyRefuses(ctx context.Context, tiers []dueTier) bool {
p, ok := l.backend.(HostBusyProber)
if !ok {
return false
}
busy, what, known, err := p.HostBusy(ctx)
if err != nil {
l.logger.Printf("[WARN] [quiesce] pre-check: could not ask the agent whether its host is busy (%v) — stopping the apps and asking as before (R-921)", err)
return false
}
if !known || !busy {
return false
}
l.logger.Printf("[INFO] [quiesce] tier %s is due, but the agent's host is busy (%s) — it would refuse the backup, so no app is stopped (R-921)",
tierLabel(tiers[0].target), what)
for _, t := range tiers {
l.noteTierContention(t.target, tierLabel(t.target))
}
return true
}
// anotherTierInFlight reports whether the agent would refuse the window's first tier because a job of a
// DIFFERENT tier is in flight — so the window must not stop any app. The window's OWN tier in flight is
// not a refusal (the agent answers its start with the running job, 202), and that path is left as it was.
//
// Fail toward backing up: an unanswerable pre-check, an untargeted window (pre-R-82 agent) or an adapter
// without the surface all return false — the window runs as before.
func (l *Loop) anotherTierInFlight(ctx context.Context, first string) bool {
// ownInFlight: the window's own tier has a job in flight (the agent will answer its start with that job).
func (l *Loop) anotherTierInFlight(ctx context.Context, first string) (skip, ownInFlight bool) {
if first == "" {
return false
return false, false
}
p, ok := l.backend.(InFlightProber)
if !ok {
return false
return false, false
}
busy, err := p.BackupJobsInFlight(ctx)
if err != nil {
l.logger.Printf("[WARN] [quiesce] pre-check: could not ask the agent which backup jobs are in flight (%v) — stopping the apps and asking as before (R-921)", err)
return false
return false, false
}
for _, t := range busy {
if t != first {
l.logger.Printf("[INFO] [quiesce] tier %s is due, but the agent still holds a backup job on tier %s — no app is stopped; the tier stays due and is asked again at the next poll (R-921)",
tierLabel(first), tierLabel(t))
return true
return true, false
}
ownInFlight = true
}
return false
return false, ownInFlight
}
+4 -3
View File
@@ -556,9 +556,10 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
if len(tiers) == 0 {
return nil
}
// R-921: check first, stop second — a tier the agent will refuse (another tier's job in flight) stops
// no app. Pinned by TestR921_NoStopWhileAnotherTiersJobIsInFlight (precheck.go says what it cannot see).
if l.anotherTierInFlight(ctx, tiers[0].target) {
// R-921: check first, stop second — a tier the agent will refuse (another tier's job in flight, or its
// host-wide gate held) stops no app. Pinned by TestR921_NoStopWhileAnotherTiersJobIsInFlight and
// TestR921_NoStopWhileTheHostIsBusy (precheck.go).
if l.precheckSkips(ctx, tiers) {
return nil
}
running := l.stacks.RunningAppStacks()
@@ -34,6 +34,10 @@ type r921Backend struct {
probeErr error
busyOn map[string]bool // StartBackupFor answers ErrTierBusy for these targets
starts []string
// R-921 part 2: the agent's host_busy answer (hostKnown=false = an older agent without the field).
hostKnown, hostBusy bool
hostWhat string
hostErr error
}
func (b *r921Backend) log(e string) {
@@ -68,6 +72,13 @@ func (b *r921Backend) BackupJobsInFlight(context.Context) ([]string, error) {
}
return append([]string(nil), b.inFlight...), nil
}
func (b *r921Backend) HostBusy(context.Context) (bool, string, bool, error) {
b.log("hostbusy")
if b.hostErr != nil {
return false, "", false, b.hostErr
}
return b.hostBusy, b.hostWhat, b.hostKnown, nil
}
func (b *r921Backend) Due(context.Context) (bool, *int64, error) { return false, nil, nil }
func (b *r921Backend) StartBackup(context.Context) (string, error) { return "", errors.New("unused") }
func (b *r921Backend) BackupStatus(context.Context) (string, error) {
@@ -284,3 +295,114 @@ func (n *noProbeBackend) StartBackup(ctx context.Context) (string, error) {
func (n *noProbeBackend) BackupStatus(ctx context.Context) (string, error) {
return n.b.BackupStatus(ctx)
}
// R-921 part 2 — THE MEASURED CASE, now seen before the stop. The agent's host-wide gate is held (the night OS step
// after the local copy: `os-pve-step`), the off-site tier is due. With the agent's host_busy answer the loop stops NO
// app and asks for NO backup, and records contention for the tier (skipped for contentionRetryAfter, alarmed after
// contentionAlarmAfter) — the verdict a 409 gets today, minus the stop.
//
// COMPANION RED-PROOF (observed 2026-10-19): with precheckSkips back to the pre-fix shape (the host_busy branch
// removed), it fails with
//
// r921_precheck_test.go: R-921: 3 app(s) stopped while the agent says its host is busy (os-pve-step); events=[probe stop:immich … start:felhom-pbs=BUSY startstack:immich …]
//
// Restored.
func TestR921_NoStopWhileTheHostIsBusy(t *testing.T) {
be, _, l, events, logs := r921Setup(t)
be.due["felhom-pbs"] = true
be.busyOn["felhom-pbs"] = true // what the real agent answers while its gate is held
be.hostKnown, be.hostBusy, be.hostWhat = true, true, "os-pve-step"
if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("runOnce: %v", err)
}
if n := countPrefix(*events, "stop:"); n != 0 {
t.Fatalf("R-921: %d app(s) stopped while the agent says its host is busy (os-pve-step); events=%v", n, *events)
}
if len(be.starts) != 0 {
t.Fatalf("a backup was requested while the host is busy: %v", be.starts)
}
if _, blocked := l.contention.blocked("felhom-pbs", l.now()); !blocked {
t.Errorf("the busy tier was not recorded as contention — the next poll would ask again at once, and a stuck gate would never alarm")
}
if got := l.breaker.failuresFor("felhom-pbs"); got != 0 {
t.Errorf("a busy host armed the failure breaker (%d) — nothing failed", got)
}
if !strings.Contains(logs.String(), "os-pve-step") || !strings.Contains(logs.String(), "R-921") {
t.Errorf("the skip does not name the holder and R-921: %q", logs.String())
}
// The gate lets go and the skip window passes → the next poll backs the tier up as normal.
be.hostBusy, be.hostWhat, be.busyOn["felhom-pbs"] = false, "", false
l.contention.clear("felhom-pbs")
if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("runOnce 2: %v", err)
}
if len(be.starts) != 1 || be.starts[0] != "felhom-pbs" {
t.Fatalf("with the host free the tier was not backed up: starts=%v", be.starts)
}
}
// A gate that never lets go must still page the operator — the skip path must not be a silence path.
func TestR921_HostBusySkipsStillAlarmAfterTheCeiling(t *testing.T) {
be, _, l, events, _ := r921Setup(t)
n := &recordingNotifier{}
l.SetTierNotifier(n)
base := time.Now()
now := base
l.now = func() time.Time { return now }
be.due["felhom-pbs"] = true
be.hostKnown, be.hostBusy, be.hostWhat = true, true, "restore-test"
for _, step := range []time.Duration{0, time.Hour, 2 * time.Hour, contentionAlarmAfter + time.Minute} {
now = base.Add(step)
if st, ok := l.contention.states["felhom-pbs"]; ok {
st.until = time.Time{} // let each poll through the skip window; the run (since, alarmed) is kept
l.contention.states["felhom-pbs"] = st
}
if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("runOnce at +%s: %v", step, err)
}
}
if countPrefix(*events, "stop:") != 0 {
t.Fatalf("an app was stopped while the host was busy: %v", *events)
}
if len(n.failed) != 1 || !contains(n.failed[0], "BLOCKED") {
t.Fatalf("after %s of a busy host: alarms %v, want exactly one BLOCKED alarm", contentionAlarmAfter, n.failed)
}
}
// The other answers keep today's order: a FREE host backs up; an OLDER agent (no host_busy), or a host_busy read
// that fails, stops the apps and asks as before (fail toward backing up); the window's OWN tier in flight attaches
// to its job whatever the gate says (the gate may be held by that very job).
func TestR921_HostBusyOtherAnswersKeepTheOldOrder(t *testing.T) {
for _, tc := range []struct {
name string
known, busy bool
err error
ownInFlight bool
wantStarts, wantStp bool
}{
{"host free", true, false, nil, false, true, true},
{"older agent (no host_busy)", false, false, nil, false, true, true},
{"host_busy read failed", false, false, errors.New("agentapi: GET /backup/status: connection refused"), false, true, true},
{"own tier in flight, gate held by it", true, true, nil, true, true, false},
} {
t.Run(tc.name, func(t *testing.T) {
be, _, l, events, _ := r921Setup(t)
be.due["felhom-pbs"] = true
be.hostKnown, be.hostBusy, be.hostErr = tc.known, tc.busy, tc.err
if tc.ownInFlight {
be.inFlight = []string{"felhom-pbs"}
}
if err := l.runOnce(context.Background()); err != nil {
t.Fatalf("runOnce: %v", err)
}
if got := len(be.starts) == 1; got != tc.wantStarts {
t.Fatalf("starts=%v, want a start: %v; events=%v", be.starts, tc.wantStarts, *events)
}
if tc.wantStp && countPrefix(*events, "stop:") == 0 {
t.Fatalf("no app stopped — the old order (stop, then ask) was not kept; events=%v", *events)
}
})
}
}