d8c2634472
Each quiesce window now backs up ONLY the first due tier that starts and resumes the apps at its snapshotted; the upload finishes with the apps running. Any other due tier stays due and a later cycle (the next poll) takes it in its own short window - never straight after the first. This reverses R-82's 'ONE quiesce window for both due tiers'. A manual press (TriggerNow) backs up the primary (local) tier only, picked by the agent's Primary flag; the off-site tier follows at the next scheduled night run. If no available tier is flagged primary, the first available tier is backed up rather than nothing. Unchanged: marker written before any stop, one unquiesce per window, the max-quiesce bound, BUSY/start-error handling (the next tier is still tried in the same window when a tier does not start), breaker and contention. Tests: TestBothTiersDue_ExactlyOneQuiesceWindow -> TestBothTiersDue_FirstCycleRunsOnlyFirstTier; TestNonLastTierSnapshot_ DoesNotResumeApp -> TestFirstTierSnapshot_ResumesAppWhileUploadContinues (red on old code); new TestManualPress_RunsOnlyLocalTier (red on old code), TestLeftoverTier_RunsInNextCycleInItsOwnWindow, TestManualRunTiers_NoPrimaryAvailable_BacksUpFirstAvailable; TestNotify_BothFailingTiersAreReported now runs two cycles. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
553 lines
22 KiB
Go
553 lines
22 KiB
Go
package quiesce
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// R-82 Slice B — two tiers; since `09` §3 decision 156 (R-518 option A), one stop per tier.
|
|
//
|
|
// Two properties are load-bearing and neither is provable by "no error was returned":
|
|
// 1. Each window backs up ONE tier and resumes the apps at its snapshot; another due tier gets its
|
|
// own window in a later cycle (decision 156 reverses R-82's one window for both tiers).
|
|
// 2. A new controller against an OLD agent still TAKES A BACKUP. The hollow version of that test
|
|
// asserts "no error" while silently skipping the backup — the exact failure it exists to catch.
|
|
|
|
// ---- fakes -------------------------------------------------------------------------------
|
|
|
|
// NOTE: fakeStacks comes from quiesce_test.go (same package) — reused rather than duplicated.
|
|
// Counts come from len(stoppedNames()) / len(startedNames()).
|
|
|
|
// tierBackend is a multi-tier fake agent. phases[target] is the phase sequence returned by
|
|
// successive BackupStatusFor calls for that tier.
|
|
type tierBackend struct {
|
|
mu sync.Mutex
|
|
tiers []BackupTier
|
|
tiersErr error
|
|
dueSet map[string]bool
|
|
phases map[string][]string
|
|
phaseIdx map[string]int
|
|
started []string // targets StartBackupFor/StartBackup was called with, in order
|
|
untargetedDue bool
|
|
startErrOn string
|
|
// stacks (optional) lets a start sample how many restarts have happened SO FAR — the direct
|
|
// way to assert "the app had not resumed when this tier started".
|
|
stacks *fakeStacks
|
|
startsAtStart []int
|
|
// stopsAtStart samples the stop count when each tier starts — a new window shows as a higher
|
|
// stop count than the previous tier saw (R-518, decision 156).
|
|
stopsAtStart []int
|
|
// statusRestarts[target] samples the restart count at EACH status poll of that tier, so a test can
|
|
// say "the apps were already started when the Nth poll answered" (R-518, decision 156).
|
|
statusRestarts map[string][]int
|
|
}
|
|
|
|
func newTierBackend() *tierBackend {
|
|
return &tierBackend{
|
|
dueSet: map[string]bool{}, phases: map[string][]string{}, phaseIdx: map[string]int{},
|
|
statusRestarts: map[string][]int{},
|
|
}
|
|
}
|
|
|
|
func (b *tierBackend) Tiers(context.Context) ([]BackupTier, error) {
|
|
if b.tiersErr != nil {
|
|
return nil, b.tiersErr
|
|
}
|
|
return b.tiers, nil
|
|
}
|
|
|
|
// DueFor returns a nil age with an EMPTY age_state — i.e. the pre-v0.105.0 (legacy) shape, which
|
|
// keeps every suite written before R-88 Part 2 asserting exactly the behaviour it always did.
|
|
func (b *tierBackend) DueFor(_ context.Context, target string) (bool, *int64, string, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.dueSet[target], nil, "", nil
|
|
}
|
|
func (b *tierBackend) StartBackupFor(_ context.Context, target string) (string, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.startErrOn == target {
|
|
return "", fmt.Errorf("simulated start failure on %s", target)
|
|
}
|
|
b.started = append(b.started, target)
|
|
if b.stacks != nil {
|
|
b.startsAtStart = append(b.startsAtStart, len(b.stacks.startedNames()))
|
|
b.stopsAtStart = append(b.stopsAtStart, len(b.stacks.stoppedNames()))
|
|
}
|
|
return "job-" + target, nil
|
|
}
|
|
func (b *tierBackend) BackupStatusFor(_ context.Context, target string) (string, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.stacks != nil {
|
|
b.statusRestarts[target] = append(b.statusRestarts[target], len(b.stacks.startedNames()))
|
|
}
|
|
seq := b.phases[target]
|
|
i := b.phaseIdx[target]
|
|
if i < len(seq) {
|
|
b.phaseIdx[target]++
|
|
return seq[i], nil
|
|
}
|
|
if len(seq) > 0 {
|
|
return seq[len(seq)-1], nil
|
|
}
|
|
return phaseDone, nil
|
|
}
|
|
|
|
// The untargeted (pre-R-82) surface.
|
|
func (b *tierBackend) Due(context.Context) (bool, *int64, error) {
|
|
return b.untargetedDue, nil, nil
|
|
}
|
|
func (b *tierBackend) StartBackup(context.Context) (string, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.started = append(b.started, "(untargeted)")
|
|
return "job-untargeted", nil
|
|
}
|
|
func (b *tierBackend) BackupStatus(context.Context) (string, error) { return phaseDone, nil }
|
|
|
|
func (b *tierBackend) restartsWhenEachTierStarted() []int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return append([]int(nil), b.startsAtStart...)
|
|
}
|
|
|
|
func (b *tierBackend) stopsWhenEachTierStarted() []int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return append([]int(nil), b.stopsAtStart...)
|
|
}
|
|
|
|
func (b *tierBackend) restartsAtEachPoll(target string) []int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return append([]int(nil), b.statusRestarts[target]...)
|
|
}
|
|
|
|
func (b *tierBackend) setDue(target string, due bool) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.dueSet[target] = due
|
|
}
|
|
|
|
func (b *tierBackend) startedTargets() []string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return append([]string(nil), b.started...)
|
|
}
|
|
|
|
func newTierLoop(t *testing.T, be Backend, st *fakeStacks, logTo io.Writer) *Loop {
|
|
t.Helper()
|
|
if logTo == nil {
|
|
logTo = io.Discard
|
|
}
|
|
return New(Options{
|
|
Backend: be,
|
|
Stacks: st,
|
|
MarkerPath: filepath.Join(t.TempDir(), "quiesce-state.json"),
|
|
StatusPoll: time.Millisecond,
|
|
MaxQuiesce: 30 * time.Second,
|
|
Logger: log.New(logTo, "", 0),
|
|
})
|
|
}
|
|
|
|
// ---- R-518 option A — one stop per backup tier (`09` §3 decision 156) --------------------
|
|
//
|
|
// Decision 156 REVERSES R-82's „ONE quiesce window for both due tiers (never two app outages for one
|
|
// night)". The one long window kept every app down for the whole local upload (5 min 47 s measured on
|
|
// demo-hp, 9 apps). Now each window runs ONLY the first due tier and resumes the apps at its
|
|
// `snapshotted`; another due tier waits for a LATER cycle and gets its own short window. Every copy is
|
|
// still taken with the apps stopped, so each stays app-consistent.
|
|
|
|
// Both tiers due → the first cycle stops once, backs up ONLY the first (primary) tier, starts once.
|
|
// The other tier is not started in this cycle — never straight after tier 1 (the next poll picks it
|
|
// up, pinned by TestLeftoverTier_RunsInNextCycleInItsOwnWindow).
|
|
//
|
|
// Was TestBothTiersDue_ExactlyOneQuiesceWindow (R-82), which pinned both tiers inside one window.
|
|
func TestBothTiersDue_FirstCycleRunsOnlyFirstTier(t *testing.T) {
|
|
be := newTierBackend()
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
|
|
be.dueSet["local"] = true
|
|
be.dueSet["felhom-pbs"] = true
|
|
be.phases["local"] = []string{phaseSnapshotted, phaseDone}
|
|
be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}
|
|
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
l := newTierLoop(t, be, st, nil)
|
|
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatalf("runOnce: %v", err)
|
|
}
|
|
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
|
|
t.Fatalf("one window: want EXACTLY 1 stop and 1 start, got stops=%d starts=%d (stopped=%v started=%v)",
|
|
stops, starts, st.stoppedNames(), st.startedNames())
|
|
}
|
|
if got := be.startedTargets(); len(got) != 1 || got[0] != "local" {
|
|
t.Fatalf("decision 156: a window backs up ONLY the first due tier (primary); got %v", got)
|
|
}
|
|
}
|
|
|
|
// THE red test of R-518: the apps must run while the local copy still uploads. Local reports
|
|
// `snapshotted` three times, then `done`; the stacks must already have been STARTED when the SECOND
|
|
// `snapshotted` poll answered. Under R-82 the app stayed down until the LAST tier snapshotted, so the
|
|
// whole local upload ran with every app stopped.
|
|
//
|
|
// Was TestNonLastTierSnapshot_DoesNotResumeApp (R-82), which pinned the opposite (decision 156
|
|
// reverses R-82's one window for both tiers).
|
|
func TestFirstTierSnapshot_ResumesAppWhileUploadContinues(t *testing.T) {
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
be := newTierBackend()
|
|
be.stacks = st
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
|
|
be.dueSet["local"] = true
|
|
be.dueSet["felhom-pbs"] = true
|
|
be.phases["local"] = []string{phaseSnapshotted, phaseSnapshotted, phaseSnapshotted, phaseDone}
|
|
be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}
|
|
|
|
l := newTierLoop(t, be, st, nil)
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
polls := be.restartsAtEachPoll("local")
|
|
if len(polls) < 2 {
|
|
t.Fatalf("the local tier must be polled past its first snapshot; restarts per poll = %v", polls)
|
|
}
|
|
if polls[1] < 1 {
|
|
t.Fatalf("the apps were still STOPPED when the second `snapshotted` poll answered (restarts per poll = %v) — they must resume at the first tier's snapshot (decision 156)", polls)
|
|
}
|
|
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
|
|
t.Fatalf("want exactly one stop/start pair; got %d/%d", stops, starts)
|
|
}
|
|
}
|
|
|
|
// The tier left over by a cycle IS run by the NEXT cycle, in its OWN window (a second stop that comes
|
|
// after the first window's start). Without this, decision 156 would silently drop the off-site copy.
|
|
func TestLeftoverTier_RunsInNextCycleInItsOwnWindow(t *testing.T) {
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
be := newTierBackend()
|
|
be.stacks = st
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
|
|
be.dueSet["local"] = true
|
|
be.dueSet["felhom-pbs"] = true
|
|
be.phases["local"] = []string{phaseSnapshotted, phaseDone}
|
|
be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}
|
|
l := newTierLoop(t, be, st, nil)
|
|
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatalf("cycle 1: %v", err)
|
|
}
|
|
if got := be.startedTargets(); len(got) != 1 || got[0] != "local" {
|
|
t.Fatalf("cycle 1 must run only the local tier; got %v", got)
|
|
}
|
|
// The agent now reports local as backed up; the off-site tier is still due.
|
|
be.setDue("local", false)
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatalf("cycle 2: %v", err)
|
|
}
|
|
got := be.startedTargets()
|
|
if len(got) != 2 || got[1] != "felhom-pbs" {
|
|
t.Fatalf("cycle 2 must run the left-over off-site tier; got %v", got)
|
|
}
|
|
// Its own window: when it started, the first window had already resumed (1 start) and a NEW stop
|
|
// had happened (2 stops).
|
|
stopsAt, startsAt := be.stopsWhenEachTierStarted(), be.restartsWhenEachTierStarted()
|
|
if stopsAt[1] != 2 || startsAt[1] != 1 {
|
|
t.Fatalf("the off-site tier must start in its OWN window; at its start stops=%d starts=%d (want 2/1)", stopsAt[1], startsAt[1])
|
|
}
|
|
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 2 || starts != 2 {
|
|
t.Fatalf("two cycles, two windows: want 2 stops / 2 starts, got %d/%d", stops, starts)
|
|
}
|
|
// A third cycle with nothing due touches nothing.
|
|
be.setDue("felhom-pbs", false)
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stops := len(st.stoppedNames()); stops != 2 {
|
|
t.Fatalf("nothing due must not stop the apps again; stops=%d", stops)
|
|
}
|
|
}
|
|
|
|
// A manual press („Mentés most") makes the LOCAL copy only, in one short stop; the off-site tier is
|
|
// NOT requested — it follows at the next scheduled night run (decision 156).
|
|
func TestManualPress_RunsOnlyLocalTier(t *testing.T) {
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
be := newTierBackend()
|
|
be.stacks = st
|
|
// Off-site listed FIRST on purpose: the press must pick the tier the agent flags Primary, not
|
|
// whichever comes first.
|
|
be.tiers = []BackupTier{{Target: "felhom-pbs"}, {Target: "local", Primary: true}}
|
|
be.phases["local"] = []string{phaseSnapshotted, phaseDone}
|
|
be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}
|
|
l := newTierLoop(t, be, st, nil)
|
|
|
|
if err := l.TriggerNow(); err != nil {
|
|
t.Fatalf("TriggerNow: %v", err)
|
|
}
|
|
// TriggerNow holds l.mu for the whole async cycle; taking it waits for the cycle to finish.
|
|
l.mu.Lock()
|
|
l.mu.Unlock() //nolint:staticcheck // deliberate: wait for the async cycle
|
|
|
|
if got := be.startedTargets(); len(got) != 1 || got[0] != "local" {
|
|
t.Fatalf("a manual press must back up ONLY the local (primary) tier; started=%v", got)
|
|
}
|
|
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
|
|
t.Fatalf("a manual press is one short stop; got %d/%d", stops, starts)
|
|
}
|
|
}
|
|
|
|
// One tier due → one quiesce, that tier only.
|
|
func TestOnlyPBSDue_OneQuiesceThatTierOnly(t *testing.T) {
|
|
be := newTierBackend()
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
|
|
be.dueSet["felhom-pbs"] = true
|
|
be.phases["felhom-pbs"] = []string{phaseDone}
|
|
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
l := newTierLoop(t, be, st, nil)
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stops, starts := len(st.stoppedNames()), len(st.startedNames())
|
|
if stops != 1 || starts != 1 {
|
|
t.Fatalf("want 1/1, got stops=%d starts=%d", stops, starts)
|
|
}
|
|
if got := be.startedTargets(); len(got) != 1 || got[0] != "felhom-pbs" {
|
|
t.Fatalf("only the due tier may back up; got %v", got)
|
|
}
|
|
}
|
|
|
|
// Neither due → no quiesce at all. The app must not be touched.
|
|
func TestNoTierDue_NoQuiesce(t *testing.T) {
|
|
be := newTierBackend()
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
l := newTierLoop(t, be, st, nil)
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 0 || starts != 0 {
|
|
t.Fatalf("no tier due must not touch the app; got stops=%d starts=%d", stops, starts)
|
|
}
|
|
if got := be.startedTargets(); len(got) != 0 {
|
|
t.Fatalf("no backup may start; got %v", got)
|
|
}
|
|
}
|
|
|
|
// ---- RED-PROOF 2 — new controller ↔ OLD agent --------------------------------------------
|
|
|
|
// The agent 404s /backup/tiers. The controller MUST degrade to the untargeted tier, LOG it, and
|
|
// STILL TAKE A BACKUP.
|
|
//
|
|
// The hollow version of this test asserts only "runOnce returned nil" — which passes against an
|
|
// implementation that silently skips the backup entirely. The assertion that matters is that a
|
|
// backup actually started.
|
|
//
|
|
// COMPANION RED-PROOF (observed): make resolveDueTiers return (nil, false, nil) on
|
|
// ErrTiersUnsupported — i.e. treat "no tier support" as "nothing due" → this test fails with
|
|
// "OLD AGENT: a backup MUST still be taken; got started=[]". Restored.
|
|
func TestOldAgent_DegradesToUntargetedAndStillBacksUp(t *testing.T) {
|
|
be := newTierBackend()
|
|
be.tiersErr = ErrTiersUnsupported
|
|
be.untargetedDue = true
|
|
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
var logbuf strings.Builder
|
|
l := newTierLoop(t, be, st, &logbuf)
|
|
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatalf("runOnce against an old agent must not error: %v", err)
|
|
}
|
|
got := be.startedTargets()
|
|
if len(got) != 1 || got[0] != "(untargeted)" {
|
|
t.Fatalf("OLD AGENT: a backup MUST still be taken via the untargeted path; got started=%v", got)
|
|
}
|
|
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
|
|
t.Fatalf("old-agent path must still be one quiesce window; got %d/%d", stops, starts)
|
|
}
|
|
if !strings.Contains(logbuf.String(), "predates R-82") {
|
|
t.Fatalf("the degrade must be LOGGED — a silent degrade is indistinguishable from multi-tier working; log:\n%s", logbuf.String())
|
|
}
|
|
}
|
|
|
|
// The degrade line is logged ONCE, not every poll (it is a steady state during a rollout).
|
|
func TestOldAgent_DegradeLoggedOnce(t *testing.T) {
|
|
be := newTierBackend()
|
|
be.tiersErr = ErrTiersUnsupported
|
|
be.untargetedDue = false // not due → cheap repeated polls
|
|
|
|
var logbuf strings.Builder
|
|
l := newTierLoop(t, be, &fakeStacks{}, &logbuf)
|
|
for i := 0; i < 5; i++ {
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if n := strings.Count(logbuf.String(), "predates R-82"); n != 1 {
|
|
t.Fatalf("degrade must log exactly once across polls, got %d:\n%s", n, logbuf.String())
|
|
}
|
|
}
|
|
|
|
// A backend that does not implement TieredBackend at all (an older controller build path) uses the
|
|
// untargeted route with no probe and no degrade log.
|
|
func TestPlainBackend_UsesUntargetedPath(t *testing.T) {
|
|
be := &plainBackend{due: true}
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
l := newTierLoop(t, be, st, nil)
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if be.started != 1 {
|
|
t.Fatalf("a plain Backend must still back up; started=%d", be.started)
|
|
}
|
|
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
|
|
t.Fatalf("want 1/1, got %d/%d", stops, starts)
|
|
}
|
|
}
|
|
|
|
type plainBackend struct {
|
|
due bool
|
|
started int
|
|
}
|
|
|
|
func (p *plainBackend) Due(context.Context) (bool, *int64, error) { return p.due, nil, nil }
|
|
func (p *plainBackend) StartBackup(context.Context) (string, error) {
|
|
p.started++
|
|
return "job", nil
|
|
}
|
|
func (p *plainBackend) BackupStatus(context.Context) (string, error) { return phaseDone, nil }
|
|
|
|
// ---- resilience --------------------------------------------------------------------------
|
|
|
|
// One tier failing to START must not prevent the other tier's backup, and the app must still resume
|
|
// exactly once.
|
|
func TestOneTierFailsToStart_OtherStillRunsAndAppResumes(t *testing.T) {
|
|
be := newTierBackend()
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
|
|
be.dueSet["local"] = true
|
|
be.dueSet["felhom-pbs"] = true
|
|
be.startErrOn = "local"
|
|
be.phases["felhom-pbs"] = []string{phaseDone}
|
|
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
l := newTierLoop(t, be, st, nil)
|
|
err := l.runOnce(context.Background())
|
|
if err == nil {
|
|
t.Fatal("a tier start failure must be reported, not swallowed")
|
|
}
|
|
if got := be.startedTargets(); len(got) != 1 || got[0] != "felhom-pbs" {
|
|
t.Fatalf("the surviving tier must still back up; got %v", got)
|
|
}
|
|
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
|
|
t.Fatalf("app must resume exactly once even when a tier fails; got %d/%d", stops, starts)
|
|
}
|
|
}
|
|
|
|
// A tier whose due-check errors must not drop the OTHER tier's backup.
|
|
func TestDueCheckErrorOnOneTier_OtherTierStillEvaluated(t *testing.T) {
|
|
be := &dueErrBackend{tierBackend: newTierBackend(), errOn: "local"}
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
|
|
be.dueSet["felhom-pbs"] = true
|
|
be.phases["felhom-pbs"] = []string{phaseDone}
|
|
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
l := newTierLoop(t, be, st, nil)
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatalf("a single tier's due-check failure must not fail the cycle: %v", err)
|
|
}
|
|
if got := be.startedTargets(); len(got) != 1 || got[0] != "felhom-pbs" {
|
|
t.Fatalf("the healthy tier must still back up; got %v", got)
|
|
}
|
|
}
|
|
|
|
type dueErrBackend struct {
|
|
*tierBackend
|
|
errOn string
|
|
}
|
|
|
|
func (d *dueErrBackend) DueFor(ctx context.Context, target string) (bool, *int64, string, error) {
|
|
if target == d.errOn {
|
|
return false, nil, "", fmt.Errorf("simulated due-check failure")
|
|
}
|
|
return d.tierBackend.DueFor(ctx, target)
|
|
}
|
|
|
|
// An agent advertising ZERO tiers must fall back to the untargeted path, not do nothing.
|
|
func TestZeroTiersAdvertised_FallsBackNotSilent(t *testing.T) {
|
|
be := newTierBackend()
|
|
be.tiers = nil // advertised, but empty
|
|
be.untargetedDue = true
|
|
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
l := newTierLoop(t, be, st, nil)
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := be.startedTargets(); len(got) != 1 || got[0] != "(untargeted)" {
|
|
t.Fatalf("zero advertised tiers must fall back, never skip; got %v", got)
|
|
}
|
|
}
|
|
|
|
// oldestAge drives the window gate's safety valve: a NEVER-backed-up tier (nil age) must win over
|
|
// a fresher sibling, or a stale DR tier could be starved by a healthy local one.
|
|
func TestOldestAge(t *testing.T) {
|
|
i := func(v int64) *int64 { return &v }
|
|
if got := oldestAge([]dueTier{{ageSecs: i(10)}, {ageSecs: i(99)}}); got == nil || *got != 99 {
|
|
t.Fatalf("want 99, got %v", got)
|
|
}
|
|
if got := oldestAge([]dueTier{{ageSecs: i(10)}, {ageSecs: nil}}); got != nil {
|
|
t.Fatalf("a never-backed-up tier (nil) must win; got %v", *got)
|
|
}
|
|
if got := oldestAge(nil); got != nil {
|
|
t.Fatalf("empty → nil, got %v", *got)
|
|
}
|
|
}
|
|
|
|
// Operator ruling 2026-07-26: "let the first backup run as long as needed; other backups shouldn't
|
|
// start until finished." A first FULL offsite snapshot legitimately runs for hours — far past the
|
|
// quiesce bound. When that bound elapses the app resumes (correct), but the remaining tiers must
|
|
// NOT start: 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 that leaves the tier permanently due.
|
|
//
|
|
// COMPANION RED-PROOF (observed): drop the `break` on stillRunning → the second tier starts while
|
|
// the first is still going and this fails with
|
|
// "the second tier MUST NOT start while the first is still running; started=[local felhom-pbs]".
|
|
// Restored.
|
|
func TestTierOverrunsQuiesceBound_RemainingTiersDeferred(t *testing.T) {
|
|
be := newTierBackend()
|
|
be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
|
|
be.dueSet["local"] = true
|
|
be.dueSet["felhom-pbs"] = true
|
|
// The local tier never reaches a terminal phase — it just keeps running, like a long upload.
|
|
be.phases["local"] = []string{phaseRunningForever}
|
|
|
|
st := &fakeStacks{running: []string{"immich"}}
|
|
l := New(Options{
|
|
Backend: be, Stacks: st,
|
|
MarkerPath: filepath.Join(t.TempDir(), "q.json"),
|
|
StatusPoll: time.Millisecond,
|
|
MaxQuiesce: 20 * time.Millisecond, // bound elapses almost immediately
|
|
Logger: log.New(io.Discard, "", 0),
|
|
})
|
|
if err := l.runOnce(context.Background()); err != nil {
|
|
t.Fatalf("an overrun is not an error: %v", err)
|
|
}
|
|
got := be.startedTargets()
|
|
if len(got) != 1 || got[0] != "local" {
|
|
t.Fatalf("the second tier MUST NOT start while the first is still running; started=%v", got)
|
|
}
|
|
// The app must still have come back exactly once (the max-quiesce guard).
|
|
if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
|
|
t.Fatalf("the app must resume exactly once on the quiesce bound; got %d/%d", stops, starts)
|
|
}
|
|
}
|
|
|
|
// phaseRunningForever is a non-terminal phase the fake returns indefinitely.
|
|
const phaseRunningForever = "running"
|