R-899: a daytime press never cancels the night's whole-guest backup (operator ruling 2026-10-08, option A); press sends trigger=manual
gates / gates (push) Successful in 1m3s
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
This commit is contained in:
@@ -64,6 +64,21 @@ func targetQuery(target string) string {
|
||||
return "?target=" + url.QueryEscape(target)
|
||||
}
|
||||
|
||||
// backupStartQuery is targetQuery plus `trigger=manual` for a household press (R-899).
|
||||
func backupStartQuery(target string, manual bool) string {
|
||||
q := url.Values{}
|
||||
if target != "" {
|
||||
q.Set("target", target)
|
||||
}
|
||||
if manual {
|
||||
q.Set("trigger", "manual")
|
||||
}
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "?" + q.Encode()
|
||||
}
|
||||
|
||||
// BackupDueFor reports whether THIS TIER is due. A fresh backup on another tier must not satisfy it
|
||||
// — that filtering happens agent-side (latestSuccessfulBackupForTarget); this just asks per tier.
|
||||
func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse, error) {
|
||||
@@ -80,8 +95,15 @@ func (c *Client) BackupDueFor(ctx context.Context, target string) (DueResponse,
|
||||
|
||||
// StartBackupFor enqueues a backup of this guest ON THE GIVEN TIER.
|
||||
func (c *Client) StartBackupFor(ctx context.Context, target string) (BackupResponse, error) {
|
||||
return c.StartBackupForTrigger(ctx, target, false)
|
||||
}
|
||||
|
||||
// StartBackupForTrigger is StartBackupFor that also says whether this is a household press (R-899): a press
|
||||
// carries `trigger=manual`, and an agent that knows it does not start the night's OS leg after it. An older
|
||||
// agent ignores the parameter (it reads only `target`), so the request is safe against any agent.
|
||||
func (c *Client) StartBackupForTrigger(ctx context.Context, target string, manual bool) (BackupResponse, error) {
|
||||
var out BackupResponse
|
||||
body, err := c.post(ctx, "/backup"+targetQuery(target), struct{}{})
|
||||
body, err := c.post(ctx, "/backup"+backupStartQuery(target, manual), struct{}{})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -272,15 +272,7 @@ func (c *Client) BackupDue(ctx context.Context) (DueResponse, error) {
|
||||
|
||||
// StartBackup enqueues a backup of this guest (the agent vzdump) and returns the job to poll.
|
||||
func (c *Client) StartBackup(ctx context.Context) (BackupResponse, error) {
|
||||
var out BackupResponse
|
||||
body, err := c.post(ctx, "/backup", struct{}{})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode POST /backup: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
return c.StartBackupForTrigger(ctx, "", false)
|
||||
}
|
||||
|
||||
// BackupStatus reports the current/last backup job phase for this guest.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-899: a household press tells the agent so (`trigger=manual`), and nothing else does — the agent runs the
|
||||
// night's OS leg only after a backup that is not a press. The request the agent RECEIVES is asserted.
|
||||
func TestR899_PressCarriesTriggerManual(t *testing.T) {
|
||||
var got []string
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /backup", func(w http.ResponseWriter, r *http.Request) {
|
||||
got = append(got, r.URL.RawQuery)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"job_id":"backup-9201-2","phase":"running"}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := c.StartBackup(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.StartBackupFor(ctx, "local"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.StartBackupForTrigger(ctx, "local", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.StartBackupForTrigger(ctx, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"", "target=local", "target=local&trigger=manual", "trigger=manual"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("queries = %q, want %q", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("query %d = %q, want %q (all: %q)", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package quiesce
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backupwindow"
|
||||
)
|
||||
|
||||
// R-899 (operator ruling 2026-10-08, option A): a daytime whole-guest backup never moves the night's backup.
|
||||
// Every night takes its own.
|
||||
//
|
||||
// The agent answers /backup/due from the newest archive on the tier's storage, and a household's „Mentés most"
|
||||
// press makes an archive like any other. So a press at 08:49 made the 24 h tier due again at 08:49 the next day —
|
||||
// after the gate window [W+2h, W+6h) had closed — and that night had no whole-guest backup, no OS leg and no
|
||||
// kernel step (measured on demo-hp, 2026-10-07 → 08, `audits/kernel-night-2026-10-07/readback/`).
|
||||
//
|
||||
// The rule, kept here and nowhere else: a press keeps its own record (it is a real copy, and the agent counts it
|
||||
// for everything else), but it does not count for „has tonight's backup run". A tier is OWED tonight when
|
||||
//
|
||||
// a press succeeded on it after its last SCHEDULED success, and
|
||||
// that last scheduled success is older than the opening of the current gate window (W+2h).
|
||||
//
|
||||
// An owed tier is due on the scheduled path even when the agent says it is not. The window gate still decides
|
||||
// WHEN (its age is the press's, so the safety valve never fires for it): outside the window it waits, inside it
|
||||
// runs. A scheduled success inside tonight's window ends the debt, so a press inside the window after tonight's
|
||||
// backup forces nothing. A failed scheduled run does not end it (the breaker still spaces the retries).
|
||||
//
|
||||
// Without a window function (the pre-v0.168.0 shape) nothing is owed: there is no night to protect.
|
||||
//
|
||||
// The ledger is durable (beside the quiesce marker) so a controller restart between the press and the night
|
||||
// does not forget the press. It is an attempt-free record of SUCCESSES only, and it is read only to decide
|
||||
// due-ness — never as evidence that a backup exists (the agent's storage answers that).
|
||||
//
|
||||
// Pinned by TestR899_* (nightowed_test.go).
|
||||
|
||||
// wholeGuestLedger is the per-tier record of the last successful press and the last successful scheduled run.
|
||||
type wholeGuestLedger struct {
|
||||
Tiers map[string]ledgerTier `json:"tiers"`
|
||||
}
|
||||
|
||||
type ledgerTier struct {
|
||||
PressOK time.Time `json:"press_ok,omitempty"`
|
||||
ScheduledOK time.Time `json:"scheduled_ok,omitempty"`
|
||||
}
|
||||
|
||||
// manualCtxKey marks the context of a household press, so the agent adapter can tell the agent (R-899: the agent
|
||||
// runs the night's OS leg only after a scheduled backup).
|
||||
type manualCtxKey struct{}
|
||||
|
||||
// WithManualTrigger marks ctx as a household press.
|
||||
func WithManualTrigger(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, manualCtxKey{}, true)
|
||||
}
|
||||
|
||||
// IsManualTrigger reports whether ctx belongs to a household press.
|
||||
func IsManualTrigger(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(manualCtxKey{}).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func (l *Loop) ledgerPath() string {
|
||||
if l.markerPath == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(filepath.Dir(l.markerPath), "whole-guest-ledger.json")
|
||||
}
|
||||
|
||||
// loadLedger reads the ledger (in memory when there is no marker path). A missing or unreadable file is an
|
||||
// empty ledger: nothing owed, the agent's own answer stands — the pre-R-899 behaviour, never a skipped backup.
|
||||
func (l *Loop) loadLedger() wholeGuestLedger {
|
||||
l.ledgerMu.Lock()
|
||||
defer l.ledgerMu.Unlock()
|
||||
return l.loadLedgerLocked()
|
||||
}
|
||||
|
||||
func (l *Loop) loadLedgerLocked() wholeGuestLedger {
|
||||
led := wholeGuestLedger{Tiers: map[string]ledgerTier{}}
|
||||
p := l.ledgerPath()
|
||||
if p == "" {
|
||||
for k, v := range l.memLedger {
|
||||
led.Tiers[k] = v
|
||||
}
|
||||
return led
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
l.logger.Printf("[WARN] [quiesce] whole-guest ledger unreadable (%v) — no night is owed by a press this poll (R-899)", err)
|
||||
}
|
||||
return led
|
||||
}
|
||||
if err := json.Unmarshal(data, &led); err != nil {
|
||||
l.logger.Printf("[WARN] [quiesce] whole-guest ledger corrupt (%v) — starting a new one (R-899)", err)
|
||||
return wholeGuestLedger{Tiers: map[string]ledgerTier{}}
|
||||
}
|
||||
if led.Tiers == nil {
|
||||
led.Tiers = map[string]ledgerTier{}
|
||||
}
|
||||
return led
|
||||
}
|
||||
|
||||
// recordWholeGuestSuccess notes a successful backup on a tier, as a press or as a scheduled run.
|
||||
func (l *Loop) recordWholeGuestSuccess(target string, manual bool) {
|
||||
l.ledgerMu.Lock()
|
||||
defer l.ledgerMu.Unlock()
|
||||
led := l.loadLedgerLocked()
|
||||
t := led.Tiers[target]
|
||||
if manual {
|
||||
t.PressOK = l.now()
|
||||
} else {
|
||||
t.ScheduledOK = l.now()
|
||||
}
|
||||
led.Tiers[target] = t
|
||||
p := l.ledgerPath()
|
||||
if p == "" {
|
||||
if l.memLedger == nil {
|
||||
l.memLedger = map[string]ledgerTier{}
|
||||
}
|
||||
l.memLedger[target] = t
|
||||
return
|
||||
}
|
||||
data, err := json.MarshalIndent(led, "", " ")
|
||||
if err == nil {
|
||||
tmp := p + ".tmp"
|
||||
if err = os.WriteFile(tmp, data, 0o600); err == nil {
|
||||
err = os.Rename(tmp, p)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
l.logger.Printf("[WARN] [quiesce] could not save the whole-guest ledger (%v) — a press may still move the next night (R-899)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// pressOwesNight reports whether a press made this tier's agent answer „not due" while tonight's scheduled
|
||||
// backup has not run (the rule at the top of this file).
|
||||
func (l *Loop) pressOwesNight(led wholeGuestLedger, target string) bool {
|
||||
if l.windowStartFn == nil {
|
||||
return false
|
||||
}
|
||||
t, ok := led.Tiers[target]
|
||||
if !ok || t.PressOK.IsZero() || !t.PressOK.After(t.ScheduledOK) {
|
||||
return false
|
||||
}
|
||||
open, ok := lastGateOpen(l.now(), l.windowStartFn())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return t.ScheduledOK.Before(open)
|
||||
}
|
||||
|
||||
// lastGateOpen returns the most recent opening of the gate window (W+2h, Budapest wall clock) at or before now.
|
||||
func lastGateOpen(now time.Time, windowStart string) (time.Time, bool) {
|
||||
startMin, err := backupwindow.ParseHHMM(windowStart)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
openMin := mod1440(startMin + gateOpenOffsetMin)
|
||||
loc := budapestLocation()
|
||||
n := now.In(loc)
|
||||
open := time.Date(n.Year(), n.Month(), n.Day(), openMin/60, openMin%60, 0, 0, loc)
|
||||
if open.After(n) {
|
||||
open = open.AddDate(0, 0, -1)
|
||||
}
|
||||
return open, true
|
||||
}
|
||||
|
||||
// withoutOwed drops the R-899 owed tiers: they never license the window gate's safety valve.
|
||||
func withoutOwed(tiers []dueTier) []dueTier {
|
||||
out := make([]dueTier, 0, len(tiers))
|
||||
for _, t := range tiers {
|
||||
if !t.owed {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gateAge is the age the window gate judges: the oldest of the tiers that are due by the agent. When every due tier
|
||||
// is owed by a press, it is zero — „just backed up" — so outside the window the gate waits and inside it runs.
|
||||
func gateAge(tiers []dueTier) *int64 {
|
||||
agentDue := withoutOwed(tiers)
|
||||
if len(agentDue) == 0 {
|
||||
zero := int64(0)
|
||||
return &zero
|
||||
}
|
||||
return oldestAge(agentDue)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,10 @@ type Loop struct {
|
||||
// restartFailed (F-CRIT-1) is the set of stacks this loop stopped and could NOT restart. Guarded
|
||||
// by suppressMu — same concern, same lock. See suppress.go.
|
||||
restartFailed map[string]struct{}
|
||||
// R-899: the whole-guest ledger (nightowed.go) — the last successful press and scheduled run per tier.
|
||||
// ledgerMu guards the file and memLedger (the in-memory copy used when there is no marker path).
|
||||
ledgerMu sync.Mutex
|
||||
memLedger map[string]ledgerTier
|
||||
}
|
||||
|
||||
// SetTierNotifier wires the hub-event seam. INIT-ONLY — call once at startup, before Run.
|
||||
@@ -256,7 +260,7 @@ func (l *Loop) runOnce(ctx context.Context) error {
|
||||
// cadence+24h" — cannot be suppressed by a fresher sibling tier.
|
||||
if l.windowStartFn != nil {
|
||||
window := l.windowStartFn()
|
||||
if !scheduledRunAllowed(l.now().In(budapestLocation()), window, oldestAge(dueTiers), valveLicensed(dueTiers), l.cadence) {
|
||||
if !scheduledRunAllowed(l.now().In(budapestLocation()), window, gateAge(dueTiers), valveLicensed(withoutOwed(dueTiers)), l.cadence) {
|
||||
from, to := gateBounds(window)
|
||||
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
|
||||
@@ -432,6 +436,9 @@ func (l *Loop) TriggerNow() error {
|
||||
// The page says so in both languages (templates/backups.html, keys
|
||||
// backups.a_mentes_alatt_az_alkalmazasok + backups.elinditod_a_teljes_rendszermentest_most,
|
||||
// pinned by TestR518_BackupButtonStatesTheShortLocalOnlyStop).
|
||||
// R-899: the press is marked, so its success is recorded as a press (it does not count for
|
||||
// tonight's backup) and the agent is told not to start the night's OS leg after it.
|
||||
ctx = WithManualTrigger(ctx)
|
||||
if err := l.quiesceAndPollTiers(ctx, l.manualRunTiers(ctx)); err != nil {
|
||||
l.logger.Printf("[ERROR] [quiesce] manual backup cycle error: %v", err)
|
||||
}
|
||||
@@ -641,6 +648,7 @@ func (l *Loop) quiesceAndPollTiers(ctx context.Context, tiers []dueTier) error {
|
||||
// must NOT count as a failure, or a slow-but-healthy tier would back itself off.
|
||||
default:
|
||||
l.noteTierSuccess(t.target, label)
|
||||
l.recordWholeGuestSuccess(t.target, IsManualTrigger(ctx)) // R-899 (nightowed.go)
|
||||
}
|
||||
if stillRunning {
|
||||
// The max-quiesce guard fired while THIS tier's backup is still going (a first full
|
||||
|
||||
@@ -137,6 +137,9 @@ type dueTier struct {
|
||||
// primary is the agent's Primary flag (the local tier). Set on the manual path, which backs up
|
||||
// only the primary (`09` §3 decision 156).
|
||||
primary bool
|
||||
// owed (R-899) marks a tier the agent called „not due" only because of a household press. It runs inside the
|
||||
// window and never fires the safety valve (nightowed.go).
|
||||
owed bool
|
||||
}
|
||||
|
||||
// resolveDueTiers answers "what must this cycle back up?" — the dedup rule above, in one place.
|
||||
@@ -175,6 +178,7 @@ func (l *Loop) resolveDueTiers(ctx context.Context) (due []dueTier, degraded boo
|
||||
l.logger.Printf("[WARN] [quiesce] agent advertised ZERO backup tiers — falling back to the untargeted path")
|
||||
return l.resolveUntargeted(ctx)
|
||||
}
|
||||
led := l.loadLedger() // R-899: read once per cycle
|
||||
for _, t := range tiers {
|
||||
isDue, age, wireState, derr := tb.DueFor(ctx, t.Target)
|
||||
if derr != nil {
|
||||
@@ -190,6 +194,12 @@ func (l *Loop) resolveDueTiers(ctx context.Context) (due []dueTier, degraded boo
|
||||
l.logAgeStateDegradeOnce()
|
||||
}
|
||||
due = append(due, dueTier{target: t.Target, ageSecs: age, state: st})
|
||||
continue
|
||||
}
|
||||
if l.pressOwesNight(led, t.Target) {
|
||||
// R-899: the agent says „not due" only because of a household press — tonight still takes its own.
|
||||
l.logger.Printf("[INFO] [quiesce] tier %s: not due by the agent, but its newest copy is a manual press and tonight's scheduled backup has not run — due tonight (R-899)", tierLabel(t.Target))
|
||||
due = append(due, dueTier{target: t.Target, ageSecs: age, state: ageStateFromWire(wireState), owed: true})
|
||||
}
|
||||
}
|
||||
return due, false, nil
|
||||
@@ -202,6 +212,10 @@ func (l *Loop) resolveUntargeted(ctx context.Context) ([]dueTier, bool, error) {
|
||||
return nil, true, err
|
||||
}
|
||||
if !isDue {
|
||||
if l.pressOwesNight(l.loadLedger(), "") {
|
||||
l.logger.Printf("[INFO] [quiesce] not due by the agent, but its newest copy is a manual press and tonight's scheduled backup has not run — due tonight (R-899)")
|
||||
return []dueTier{{target: "", ageSecs: age, owed: true}}, true, nil
|
||||
}
|
||||
return nil, true, nil
|
||||
}
|
||||
return []dueTier{{target: "", ageSecs: age}}, true, nil
|
||||
|
||||
@@ -47,6 +47,8 @@ type tierBackend struct {
|
||||
// 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
|
||||
// manualStarts records, per start, whether the context carried the R-899 press mark.
|
||||
manualStarts []bool
|
||||
}
|
||||
|
||||
func newTierBackend() *tierBackend {
|
||||
@@ -70,13 +72,14 @@ func (b *tierBackend) DueFor(_ context.Context, target string) (bool, *int64, st
|
||||
defer b.mu.Unlock()
|
||||
return b.dueSet[target], nil, "", nil
|
||||
}
|
||||
func (b *tierBackend) StartBackupFor(_ context.Context, target string) (string, error) {
|
||||
func (b *tierBackend) StartBackupFor(ctx 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)
|
||||
b.manualStarts = append(b.manualStarts, IsManualTrigger(ctx))
|
||||
if b.stacks != nil {
|
||||
b.startsAtStart = append(b.startsAtStart, len(b.stacks.startedNames()))
|
||||
b.stopsAtStart = append(b.stopsAtStart, len(b.stacks.stoppedNames()))
|
||||
|
||||
Reference in New Issue
Block a user