agent v0.105.0 — R-88 Part 2: /backup/due gains age_state
newestArchiveOn's (time.Time, bool) signature could not express the 'unknown' its own doc comment promised: a read error and a genuine not-found both returned (zero,false), so /backup/due emitted a POSITIVE 'no successful backup recorded yet' with a nil age, and the controller fired its window-gate valve on an unreadable storage. Three states now: known / absent / unknown, carried as a STRING enum so the zero value unambiguously means 'legacy agent' rather than masquerading as an answer. Fail-safe direction unchanged — unknown is still DUE; only the window-gate bypass narrows to ABSENT. A service with NO lister deliberately stays ABSENT: calling it unknown would stop a genuinely new box on a pre-R-84 build from ever backing up outside its window. An unparseable timestamp becomes unknown — a backup happened, we cannot date it.
This commit is contained in:
+81
-12
@@ -886,6 +886,31 @@ func (s *Server) jobSnapshot(key backupJobKey) (backupJob, bool) {
|
||||
// recorded OR the newest successful one is older than the agent-local cadence. A successful
|
||||
// POST /backup flips this to false for the window, so the controller won't re-quiesce in a loop.
|
||||
// The hub-served policy is slice 10.
|
||||
// BackupAgeState (R-88 Part 2) says WHY AgeSecs is what it is — the distinction the type system
|
||||
// could not previously express.
|
||||
//
|
||||
// Before this, a storage read ERROR and a genuine never-backed-up both produced a nil AgeSecs with
|
||||
// the same `Reason`, byte-identical on the wire. The controller therefore fired its window-gate
|
||||
// safety valve ("no backup yet — never withhold the first one") on an unreadable storage, quiescing
|
||||
// customer app stacks OUTSIDE the backup window. Absence of a signal, read as a specific value —
|
||||
// the fourth instance of that class in this codebase.
|
||||
//
|
||||
// A STRING enum, not a bool: the zero value must mean "legacy agent, no information", and "" says
|
||||
// that unambiguously where `false` would silently masquerade as a real answer.
|
||||
type BackupAgeState string
|
||||
|
||||
const (
|
||||
// AgeStateKnown — AgeSecs is set and meaningful.
|
||||
AgeStateKnown BackupAgeState = "known"
|
||||
// AgeStateAbsent — a POSITIVE determination that no backup has ever landed for this tier. This is
|
||||
// the only state that may fire the controller's safety valve.
|
||||
AgeStateAbsent BackupAgeState = "absent"
|
||||
// AgeStateUnknown — the agent could not determine the age (storage unreadable, timestamp
|
||||
// unparseable). Still DUE (an unreadable storage must never suppress a backup), but the window
|
||||
// gate must NOT be bypassed on it.
|
||||
AgeStateUnknown BackupAgeState = "unknown"
|
||||
)
|
||||
|
||||
type BackupDueResponse struct {
|
||||
VMID int `json:"vmid"`
|
||||
Due bool `json:"due"`
|
||||
@@ -894,8 +919,24 @@ type BackupDueResponse struct {
|
||||
// Target (R-82) echoes the tier this verdict is about. EMPTY (and omitted) for an untargeted
|
||||
// request, which is what keeps the pre-R-82 response bytes identical for old controllers.
|
||||
Target string `json:"target,omitempty"`
|
||||
// AgeState (R-88 Part 2) disambiguates a nil AgeSecs. Additive: an OLD controller ignores it and
|
||||
// behaves exactly as before. An EMPTY value on the wire means the agent is pre-v0.105.0 — the
|
||||
// controller must treat that as "legacy, no information", never as AgeStateUnknown.
|
||||
AgeState BackupAgeState `json:"age_state,omitempty"`
|
||||
}
|
||||
|
||||
// archiveLookup is the three-state result of asking a tier's storage when a backup last landed.
|
||||
// It exists because the old (time.Time, bool) signature could not distinguish "nothing there" from
|
||||
// "I could not look" — the doc comment on newestArchiveOn promised that distinction for months while
|
||||
// the type made it impossible.
|
||||
type archiveLookup int
|
||||
|
||||
const (
|
||||
archiveFound archiveLookup = iota // a backup exists; the time is valid
|
||||
archiveAbsent // read succeeded, no backup for this guest on this tier
|
||||
archiveUnknown // could not read (error, or the service has no lister)
|
||||
)
|
||||
|
||||
func (s *Server) handleBackupDue(w http.ResponseWriter, r *http.Request, vmid int) {
|
||||
tier, echo, ok := s.tierFromRequest(w, r)
|
||||
if !ok {
|
||||
@@ -927,26 +968,40 @@ func (s *Server) handleBackupDue(w http.ResponseWriter, r *http.Request, vmid in
|
||||
unparseable = true
|
||||
}
|
||||
}
|
||||
if t, ok3 := s.newestArchiveOn(r.Context(), tier, vmid); ok3 && (!haveNewest || t.After(newest)) {
|
||||
t, lookup := s.newestArchiveOn(r.Context(), tier, vmid)
|
||||
if lookup == archiveFound && (!haveNewest || t.After(newest)) {
|
||||
newest, haveNewest = t, true
|
||||
unparseable = false // ground truth supersedes an unreadable in-memory timestamp
|
||||
}
|
||||
if !haveNewest {
|
||||
if unparseable {
|
||||
// Fail safe toward "due" so a backup still happens.
|
||||
writeOK(w, BackupDueResponse{VMID: vmid, Due: true, Reason: "last backup time unparseable — treating as due", Target: echo})
|
||||
return
|
||||
// R-88 Part 2: THREE distinct reasons for a nil age, each with its own state. Only ABSENT is a
|
||||
// positive claim of "never backed up"; only that one may license the controller to bypass its
|
||||
// backup window. All three stay DUE — an agent that cannot tell must never suppress a backup.
|
||||
switch {
|
||||
case unparseable:
|
||||
writeOK(w, BackupDueResponse{VMID: vmid, Due: true, AgeState: AgeStateUnknown,
|
||||
Reason: "last backup time unparseable — treating as due", Target: echo})
|
||||
case lookup == archiveUnknown:
|
||||
// The storage could not be read AND this process holds no record. Previously this emitted
|
||||
// "no successful backup recorded yet" — a positive claim built out of two absences, which
|
||||
// is what fired the window-gate valve during the 2026-07-27 PBS outage.
|
||||
writeOK(w, BackupDueResponse{VMID: vmid, Due: true, AgeState: AgeStateUnknown,
|
||||
Reason: "backup storage unreadable and no in-memory record — age UNKNOWN, treating as due", Target: echo})
|
||||
default:
|
||||
writeOK(w, BackupDueResponse{VMID: vmid, Due: true, AgeState: AgeStateAbsent,
|
||||
Reason: "no successful backup recorded yet", Target: echo})
|
||||
}
|
||||
writeOK(w, BackupDueResponse{VMID: vmid, Due: true, Reason: "no successful backup recorded yet", Target: echo})
|
||||
return
|
||||
}
|
||||
age := s.now().Sub(newest)
|
||||
ageSecs := int64(age.Seconds())
|
||||
if age >= tier.Cadence {
|
||||
writeOK(w, BackupDueResponse{VMID: vmid, Due: true, Reason: "older than cadence", AgeSecs: &ageSecs, Target: echo})
|
||||
writeOK(w, BackupDueResponse{VMID: vmid, Due: true, AgeSecs: &ageSecs, AgeState: AgeStateKnown,
|
||||
Reason: "older than cadence", Target: echo})
|
||||
return
|
||||
}
|
||||
writeOK(w, BackupDueResponse{VMID: vmid, Due: false, Reason: "within cadence window", AgeSecs: &ageSecs, Target: echo})
|
||||
writeOK(w, BackupDueResponse{VMID: vmid, Due: false, AgeSecs: &ageSecs, AgeState: AgeStateKnown,
|
||||
Reason: "within cadence window", Target: echo})
|
||||
}
|
||||
|
||||
// BackupTiersResponse is GET /backup/tiers (R-82): the tiers this agent serves, primary first.
|
||||
@@ -1096,18 +1151,32 @@ func (s *Server) pickLatestBackup(ctx context.Context, vmid int, successOnly boo
|
||||
// unsupported services degrade to "unknown", never to "no backup" — an unreadable storage must not
|
||||
// make the tier look freshly backed up, and it must not suppress a backup either: the caller falls
|
||||
// back to the in-memory record, whose absence means DUE.
|
||||
func (s *Server) newestArchiveOn(ctx context.Context, tier BackupTier, vmid int) (time.Time, bool) {
|
||||
func (s *Server) newestArchiveOn(ctx context.Context, tier BackupTier, vmid int) (time.Time, archiveLookup) {
|
||||
lister, ok := tier.Service.(BackupArchiveLister)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
// NO LISTER = the pre-R-84 world, and it must stay ABSENT — not unknown.
|
||||
//
|
||||
// "Unknown" is the tempting answer (we cannot consult storage, so we do not know) and it is
|
||||
// WRONG here, because it would regress Scenario D: the controller fires its first-backup
|
||||
// safety valve only on ABSENT, so a genuinely new box on a no-lister build would never take
|
||||
// its first backup outside the window, and nobody would notice for weeks. A loud bug traded
|
||||
// for a silent one.
|
||||
//
|
||||
// The honest reading: on this path the in-memory record is the ONLY registry that exists, so
|
||||
// its absence means "no backup recorded" in the only terms available — exactly the claim this
|
||||
// path has always made. UNKNOWN is reserved for a lister that was asked and could not answer.
|
||||
return time.Time{}, archiveAbsent
|
||||
}
|
||||
t, found, err := lister.NewestArchiveTime(ctx, vmid)
|
||||
if err != nil {
|
||||
s.logger.Warn("local-api: could not read the backup storage for the due-check — falling back to the in-memory record",
|
||||
"vmid", vmid, "target", tier.TargetID, "err", err)
|
||||
return time.Time{}, false
|
||||
return time.Time{}, archiveUnknown
|
||||
}
|
||||
return t, found
|
||||
if !found {
|
||||
return time.Time{}, archiveAbsent
|
||||
}
|
||||
return t, archiveFound
|
||||
}
|
||||
|
||||
// backupAge2 parses an RFC3339 backup start time, returning it as a time.
|
||||
|
||||
Reference in New Issue
Block a user