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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user