6d07ca2be4
gates / gates (push) Successful in 1m3s
Unreleased; ships with tomorrow's release. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
193 lines
6.9 KiB
Go
193 lines
6.9 KiB
Go
package quiesce
|
|
|
|
import (
|
|
"bytes"
|
|
"log"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// R-899 (operator ruling 2026-10-08, option A): a daytime whole-guest backup never moves the night's backup.
|
|
// These assert the CONSEQUENCE — is tonight's local backup started? — not the ledger's mechanism.
|
|
|
|
func budapest(t *testing.T, y int, m time.Month, d, hh, mm int) time.Time {
|
|
t.Helper()
|
|
return time.Date(y, m, d, hh, mm, 0, 0, budapestLocation())
|
|
}
|
|
|
|
// r899Loop is a tiered loop with the window gate on (W = 02:30 → gate [04:30, 08:30)) and a settable clock.
|
|
func r899Loop(t *testing.T, be *tierBackend, st *fakeStacks, markerPath string, now *time.Time, logs *bytes.Buffer) *Loop {
|
|
t.Helper()
|
|
l := New(Options{
|
|
Backend: be, Stacks: st, MarkerPath: markerPath,
|
|
StatusPoll: time.Millisecond, MaxQuiesce: 30 * time.Second,
|
|
Logger: log.New(logs, "", 0),
|
|
})
|
|
l.windowStartFn = func() string { return "02:30" }
|
|
l.now = func() time.Time { return *now }
|
|
return l
|
|
}
|
|
|
|
func press(t *testing.T, l *Loop) {
|
|
t.Helper()
|
|
if err := l.TriggerNow(); err != nil {
|
|
t.Fatalf("TriggerNow: %v", err)
|
|
}
|
|
l.mu.Lock()
|
|
l.mu.Unlock() //nolint:staticcheck // wait for the async cycle
|
|
}
|
|
|
|
// The 2026-10-07 case, replayed: last night's backup at 04:35, a press at 08:49, and the agent then answers
|
|
// „not due" at 04:35 the next night (its newest archive is the press, 19.7 h old). Tonight must still back up.
|
|
// Before R-899 the cycle started nothing and the night had no OS leg and no kernel step.
|
|
func TestR899_DaytimePressDoesNotCancelTheNight(t *testing.T) {
|
|
st := &fakeStacks{running: []string{"opengist"}}
|
|
be := newTierBackend()
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
|
|
var logs bytes.Buffer
|
|
now := budapest(t, 2026, 10, 7, 4, 35)
|
|
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
|
|
|
|
be.setDue("local", true)
|
|
if err := l.runOnce(t.Context()); err != nil {
|
|
t.Fatalf("night 1: %v", err)
|
|
}
|
|
be.setDue("local", false)
|
|
|
|
now = budapest(t, 2026, 10, 7, 8, 49)
|
|
press(t, l)
|
|
|
|
now = budapest(t, 2026, 10, 8, 4, 35) // agent: local NOT due (the press is 19.7 h old)
|
|
if err := l.runOnce(t.Context()); err != nil {
|
|
t.Fatalf("night 2: %v", err)
|
|
}
|
|
got := be.startedTargets()
|
|
if len(got) != 3 || got[2] != "local" {
|
|
t.Fatalf("night 2 must take its own local backup despite the morning press; started=%v\nlogs:\n%s", got, logs.String())
|
|
}
|
|
if !strings.Contains(logs.String(), "R-899") {
|
|
t.Fatalf("the forced night must say why in the log; logs:\n%s", logs.String())
|
|
}
|
|
// The press was marked as a press; the two scheduled runs were not.
|
|
if m := be.manualStarts; len(m) != 3 || m[0] || !m[1] || m[2] {
|
|
t.Fatalf("press mark per start = %v, want [false true false]", m)
|
|
}
|
|
|
|
// And once tonight's backup ran, the debt is paid: a later poll in the same window starts nothing.
|
|
now = budapest(t, 2026, 10, 8, 4, 45)
|
|
if err := l.runOnce(t.Context()); err != nil {
|
|
t.Fatalf("night 2, later poll: %v", err)
|
|
}
|
|
if got := be.startedTargets(); len(got) != 3 {
|
|
t.Fatalf("tonight's backup already ran — no second one; started=%v", got)
|
|
}
|
|
}
|
|
|
|
// Outside the window the owed night waits for the window (the press's own age never fires the valve).
|
|
func TestR899_OwedNightWaitsForTheWindow(t *testing.T) {
|
|
st := &fakeStacks{running: []string{"opengist"}}
|
|
be := newTierBackend()
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}}
|
|
var logs bytes.Buffer
|
|
now := budapest(t, 2026, 10, 7, 8, 49)
|
|
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
|
|
press(t, l)
|
|
|
|
now = budapest(t, 2026, 10, 7, 15, 0)
|
|
if err := l.runOnce(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := be.startedTargets(); len(got) != 1 {
|
|
t.Fatalf("outside the window nothing may start; started=%v", got)
|
|
}
|
|
now = budapest(t, 2026, 10, 8, 4, 31)
|
|
if err := l.runOnce(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := be.startedTargets(); len(got) != 2 || got[1] != "local" {
|
|
t.Fatalf("inside the window the owed night runs; started=%v", got)
|
|
}
|
|
}
|
|
|
|
// A press INSIDE the window after tonight's backup forces nothing more tonight.
|
|
func TestR899_PressAfterTonightsBackupForcesNothing(t *testing.T) {
|
|
st := &fakeStacks{running: []string{"opengist"}}
|
|
be := newTierBackend()
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}}
|
|
var logs bytes.Buffer
|
|
now := budapest(t, 2026, 10, 8, 4, 35)
|
|
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
|
|
be.setDue("local", true)
|
|
if err := l.runOnce(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
be.setDue("local", false)
|
|
now = budapest(t, 2026, 10, 8, 5, 0)
|
|
press(t, l)
|
|
now = budapest(t, 2026, 10, 8, 5, 10)
|
|
if err := l.runOnce(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := be.startedTargets(); len(got) != 2 {
|
|
t.Fatalf("tonight's scheduled backup already ran before the press — no third backup; started=%v", got)
|
|
}
|
|
}
|
|
|
|
// Without a press, an agent „not due" stands (no extra backups for a box nobody pressed).
|
|
func TestR899_NoPressNoExtraBackup(t *testing.T) {
|
|
st := &fakeStacks{running: []string{"opengist"}}
|
|
be := newTierBackend()
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}}
|
|
var logs bytes.Buffer
|
|
now := budapest(t, 2026, 10, 8, 4, 35)
|
|
l := r899Loop(t, be, st, filepath.Join(t.TempDir(), "quiesce-state.json"), &now, &logs)
|
|
if err := l.runOnce(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := be.startedTargets(); len(got) != 0 {
|
|
t.Fatalf("no press, agent not due → nothing; started=%v", got)
|
|
}
|
|
}
|
|
|
|
// The press is remembered across a controller restart (the ledger is on disk beside the marker).
|
|
func TestR899_PressSurvivesARestart(t *testing.T) {
|
|
st := &fakeStacks{running: []string{"opengist"}}
|
|
be := newTierBackend()
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}}
|
|
var logs bytes.Buffer
|
|
marker := filepath.Join(t.TempDir(), "quiesce-state.json")
|
|
now := budapest(t, 2026, 10, 7, 8, 49)
|
|
press(t, r899Loop(t, be, st, marker, &now, &logs))
|
|
|
|
now = budapest(t, 2026, 10, 8, 4, 35)
|
|
l2 := r899Loop(t, be, st, marker, &now, &logs) // a new process
|
|
if err := l2.runOnce(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := be.startedTargets(); len(got) != 2 || got[1] != "local" {
|
|
t.Fatalf("after a restart the press still does not count for tonight; started=%v", got)
|
|
}
|
|
}
|
|
|
|
// The gate-open instant, across midnight and a DST change.
|
|
func TestR899_LastGateOpen(t *testing.T) {
|
|
cases := []struct {
|
|
now time.Time
|
|
win string
|
|
want time.Time
|
|
}{
|
|
{budapest(t, 2026, 10, 8, 4, 35), "02:30", budapest(t, 2026, 10, 8, 4, 30)},
|
|
{budapest(t, 2026, 10, 8, 4, 29), "02:30", budapest(t, 2026, 10, 7, 4, 30)},
|
|
{budapest(t, 2026, 10, 8, 1, 0), "23:30", budapest(t, 2026, 10, 8, 1, 30).AddDate(0, 0, -1)},
|
|
{budapest(t, 2026, 10, 25, 5, 0), "02:30", budapest(t, 2026, 10, 25, 4, 30)}, // DST ends that night
|
|
}
|
|
for _, c := range cases {
|
|
got, ok := lastGateOpen(c.now, c.win)
|
|
if !ok || !got.Equal(c.want) {
|
|
t.Errorf("lastGateOpen(%s, %s) = %s, want %s", c.now, c.win, got, c.want)
|
|
}
|
|
}
|
|
}
|