controller v0.271.0: automatic app updates — the update leg after the off-site copy, the backup gate waits, the switch (09 6.4 part 7; R-680, R-678, R-643)
gates / gates (push) Successful in 24s
gates / gates (push) Successful in 24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package quiesce
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow"
|
||||
)
|
||||
|
||||
// v0.271.0 — `09` §3 decision 20: on an update night the full-system backup WAITS for the automatic
|
||||
// update leg, inside its own window, until W+5h; after W+5h only for a step already in flight, and never
|
||||
// at or past W+5h30m. Window 02:30 → gate [04:30, 08:30), leg stop 07:30, step grace until 08:00.
|
||||
|
||||
// TestD20_GateWaitsForTheLeg — the CONSEQUENCE, through runOnce: while the leg runs at 04:45 the due
|
||||
// backup is not started and no app is stopped; the same poll at 07:30 (W+5h) runs it.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): drop the updateLegDefers block from runOnce — this test fails at
|
||||
// "the backup started while the update leg was running (04:45, before W+5h)".
|
||||
func TestD20_GateWaitsForTheLeg(t *testing.T) {
|
||||
legRunning := func() (bool, bool) { return true, false }
|
||||
|
||||
be := &fakeBackend{due: true, dueAge: i64(20 * 3600), phases: []string{"done"}}
|
||||
st := &fakeStacks{running: []string{"nextcloud"}}
|
||||
l := windowLoop(t, be, st, "02:30", atBudapest(4, 45))
|
||||
l.updateLegFn = legRunning
|
||||
if err := l.runOnce(context.Background()); err != nil {
|
||||
t.Fatalf("runOnce: %v", err)
|
||||
}
|
||||
if be.startCalls != 0 || len(st.stoppedNames()) != 0 {
|
||||
t.Fatalf("the backup started while the update leg was running (04:45, before W+5h): start=%d stopped=%v", be.startCalls, st.stoppedNames())
|
||||
}
|
||||
|
||||
be2 := &fakeBackend{due: true, dueAge: i64(20 * 3600), phases: []string{"done"}}
|
||||
st2 := &fakeStacks{running: []string{"nextcloud"}}
|
||||
l2 := windowLoop(t, be2, st2, "02:30", atBudapest(7, 30))
|
||||
l2.updateLegFn = legRunning
|
||||
if err := l2.runOnce(context.Background()); err != nil {
|
||||
t.Fatalf("runOnce: %v", err)
|
||||
}
|
||||
if be2.startCalls != 1 {
|
||||
t.Fatalf("at W+5h (07:30) the backup must run even though the leg still says active; start=%d", be2.startCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestD20_UpdateLegDefers — the truth table of the pure predicate.
|
||||
func TestD20_UpdateLegDefers(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
active, step bool
|
||||
h, m int
|
||||
window string
|
||||
want bool
|
||||
}{
|
||||
{"no leg", false, false, 5, 0, "02:30", false},
|
||||
{"leg running inside the gate", true, false, 4, 45, "02:30", true},
|
||||
{"leg running one minute before W+5h", true, false, 7, 29, "02:30", true},
|
||||
{"leg running AT W+5h", true, false, 7, 30, "02:30", false},
|
||||
{"step in flight at W+5h+10m", true, true, 7, 40, "02:30", true},
|
||||
{"step in flight at W+5h+30m (cap)", true, true, 8, 0, "02:30", false},
|
||||
{"leg running across midnight (W 23:00, now 03:00)", true, false, 3, 0, "23:00", true},
|
||||
{"leg running across midnight at W+5h (04:00)", true, false, 4, 0, "23:00", false},
|
||||
{"unreadable window never blocks", true, true, 5, 0, "nonsense", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
fn := func() (bool, bool) { return c.active, c.step }
|
||||
if got, _ := updateLegDefers(fn, atBudapest(c.h, c.m), c.window); got != c.want {
|
||||
t.Errorf("%s: updateLegDefers = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
if got, _ := updateLegDefers(nil, atBudapest(5, 0), "02:30"); got {
|
||||
t.Error("an unwired leg must never defer the backup")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegDeadlineAndGateShareW5h — the gate and the leg read the SAME offset (stacks.LegDeadline uses
|
||||
// backupwindow.UpdateLegStopOffsetMin too). If either moves alone, the backup would wait for a leg that
|
||||
// has stopped starting steps, or the leg would start steps the backup no longer waits for.
|
||||
func TestLegDeadlineAndGateShareW5h(t *testing.T) {
|
||||
if backupwindow.UpdateLegStopOffsetMin != 300 {
|
||||
t.Fatalf("decision 20 says W+5h; the shared constant is %d min", backupwindow.UpdateLegStopOffsetMin)
|
||||
}
|
||||
if backupwindow.UpdateLegStopOffsetMin >= 360 || backupwindow.UpdateLegStopOffsetMin+legStepGraceMin >= 360 {
|
||||
t.Fatal("the leg's stop (plus the in-flight grace) must leave the full-system backup part of its [W+2h, W+6h) window")
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,11 @@ type Options struct {
|
||||
// Cadence is the agent's backup cadence, used only by the gate's safety valve (run regardless of
|
||||
// the window once the last successful backup is older than Cadence+24h). Defaults to 24h.
|
||||
Cadence time.Duration
|
||||
// UpdateLegFn (v0.271.0, `09` §3 decision 20) reports whether the automatic update leg is running
|
||||
// and whether one of its steps is in flight. A SCHEDULED cycle defers while the leg runs, until
|
||||
// W+5h; after W+5h it waits only for a step already in flight, and never past W+5h30m. nil = no
|
||||
// interlock (pre-v0.271.0 behaviour). Read with WindowStartFn; without a window it is not consulted.
|
||||
UpdateLegFn func() (active, stepRunning bool)
|
||||
}
|
||||
|
||||
// Loop is the quiesce background loop.
|
||||
@@ -94,6 +99,7 @@ type Loop struct {
|
||||
// windowStartFn (nil = gate disabled) + cadence drive the scheduled-cycle window gate (Part 3).
|
||||
windowStartFn func() string
|
||||
cadence time.Duration
|
||||
updateLegFn func() (active, stepRunning bool)
|
||||
// mu single-flights the quiesce cycle across the scheduled loop AND the manual trigger, so the
|
||||
// two can never stop the same stacks concurrently (the persisted marker covers crash-safety across
|
||||
// restarts; this covers concurrency within the process — which a manual trigger introduces).
|
||||
@@ -152,7 +158,7 @@ func New(o Options) *Loop {
|
||||
backend: o.Backend, stacks: o.Stacks, markerPath: o.MarkerPath,
|
||||
poll: o.Poll, statusPoll: o.StatusPoll, maxQuiesce: o.MaxQuiesce,
|
||||
logger: o.Logger, now: time.Now,
|
||||
windowStartFn: o.WindowStartFn, cadence: o.Cadence,
|
||||
windowStartFn: o.WindowStartFn, cadence: o.Cadence, updateLegFn: o.UpdateLegFn,
|
||||
breaker: newFailureBreaker(),
|
||||
contention: newContentionTracker(),
|
||||
}
|
||||
@@ -242,6 +248,13 @@ func (l *Loop) runOnce(ctx context.Context) error {
|
||||
l.logger.Printf("[DEBUG] [quiesce] scheduled backup due but outside the backup window [%s–%s) — deferring to the next poll inside it", from, to)
|
||||
return nil
|
||||
}
|
||||
// Decision 20 (v0.271.0): on an update night the full-system backup WAITS for the update leg,
|
||||
// inside its own window — the leg starts no step at W+5h, so this backup keeps an hour.
|
||||
// COMPANION RED-PROOF (REPORT.md): drop this block — TestD20_GateWaitsForTheLeg fails.
|
||||
if wait, why := updateLegDefers(l.updateLegFn, l.now().In(budapestLocation()), window); wait {
|
||||
l.logger.Printf("[INFO] [quiesce] full-system backup due and inside its window, but %s — deferring to the next poll (`09` decision 20)", why)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return l.quiesceAndPollTiers(ctx, dueTiers)
|
||||
@@ -785,3 +798,35 @@ func (l *Loop) clearMarker() error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// updateLegDefers is decision 20's interlock, pure: the full-system backup waits while the automatic
|
||||
// update leg runs and it is before W+5h; from W+5h it waits only for a step already in flight, and never
|
||||
// at or past W+5h30m (a step is bounded by its own health timeouts; the cap keeps a stuck flag from
|
||||
// eating the backup's hour). The offsets come from backupwindow, the constant the leg's own deadline
|
||||
// reads, so the two stop at the same minute (TestLegDeadlineAndGateShareW5h).
|
||||
func updateLegDefers(fn func() (bool, bool), now time.Time, windowStart string) (bool, string) {
|
||||
if fn == nil {
|
||||
return false, ""
|
||||
}
|
||||
active, stepRunning := fn()
|
||||
if !active && !stepRunning {
|
||||
return false, ""
|
||||
}
|
||||
startMin, err := backupwindow.ParseHHMM(windowStart)
|
||||
if err != nil {
|
||||
return false, "" // an unreadable window never blocks the backup
|
||||
}
|
||||
nowMin := now.Hour()*60 + now.Minute()
|
||||
stopMin := backupwindow.UpdateLegStopOffsetMin
|
||||
if active && within(nowMin, startMin, stopMin) {
|
||||
return true, fmt.Sprintf("the automatic update leg is running (it starts no step after %s)", backupwindow.FmtHHMM(mod1440(startMin+stopMin)))
|
||||
}
|
||||
if stepRunning && within(nowMin, startMin, stopMin+legStepGraceMin) {
|
||||
return true, fmt.Sprintf("an automatic update step started before %s is still running (waiting at most until %s)",
|
||||
backupwindow.FmtHHMM(mod1440(startMin+stopMin)), backupwindow.FmtHHMM(mod1440(startMin+stopMin+legStepGraceMin)))
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// legStepGraceMin bounds how long past W+5h the gate waits for a step already in flight.
|
||||
const legStepGraceMin = 30
|
||||
|
||||
Reference in New Issue
Block a user