v0.174.0 — R-82 Slice B: one quiesce window, two backup tiers

MinAgent UNCHANGED — degrades gracefully against ANY older agent.

The agent gained per-target tiers in v0.97.0. The controller owns quiescing,
so the multi-tier schedule is reconciled here: every due tier is collected up
front and run inside ONE quiesce window (one stop, N sequential backups, one
resume). Two cycles on the weekly night would mean two app outages for one
night's work.

Dedup rule: local-only -> one quiesce; PBS-only -> one quiesce; BOTH due ->
ONE window with both backups inside; neither -> no quiesce.

- quiesce.TieredBackend + BackupTier + ErrTiersUnsupported (optional extension)
- agentapi: BackupTiers/BackupDueFor/StartBackupFor/BackupStatusFor;
  targetQuery("") yields an EMPTY suffix so untargeted hits the pre-R-82 route
  byte-for-byte
- Loop.resolveDueTiers = the dedup rule in one place, agent order preserved
- quiesceAndPollTiers + pollTier: app stays quiesced until the LAST tier
  snapshots (resuming earlier loses app-consistency on the DR tier). Consequence
  stated in the docs: both-due-night downtime = first tier's full backup + last
  tier's snapshot, which is why tiers run fast-first.
- Manual 'Mentes most' covers EVERY tier, due-ness ignored.
- Window-gate safety valve now uses the OLDEST due tier, so a stale DR tier
  cannot be starved by a fresher local one.

Capability detection: /backup/tiers 404 = pre-R-82 agent (the documented
route-probe mechanism). Not a featureProbes row on purpose — the loop needs the
tier LIST, not a yes/no. Degrade logged exactly once per process.

Tests +11, full suite green. Red-proofs #2 and #3 observed and restored.
This commit is contained in:
Claude Code
2026-07-26 14:40:44 +02:00
parent 47fda06ba1
commit de96efc0c5
8 changed files with 924 additions and 42 deletions
+35
View File
@@ -1704,6 +1704,41 @@ func (b quiesceBackend) BackupStatus(ctx context.Context) (string, error) {
return r.Phase, err
}
// ---- R-82: the tiered surface (quiesce.TieredBackend) ------------------------------------
//
// quiesceBackend satisfies quiesce.TieredBackend as well, so the loop schedules per tier when the
// agent supports it. Against a PRE-R-82 agent, Tiers returns quiesce.ErrTiersUnsupported and the
// loop degrades to the untargeted methods above — still taking a backup, never skipping one.
func (b quiesceBackend) Tiers(ctx context.Context) ([]quiesce.BackupTier, error) {
r, err := b.c.BackupTiers(ctx)
if errors.Is(err, agentapi.ErrTiersUnsupported) {
// Translate the transport-layer probe into the loop's vocabulary; the loop keys on this.
return nil, quiesce.ErrTiersUnsupported
}
if err != nil {
return nil, err
}
out := make([]quiesce.BackupTier, 0, len(r.Tiers))
for _, t := range r.Tiers {
out = append(out, quiesce.BackupTier{Target: t.Target, Primary: t.Primary})
}
return out, nil
}
func (b quiesceBackend) DueFor(ctx context.Context, target string) (bool, *int64, error) {
r, err := b.c.BackupDueFor(ctx, target)
return r.Due, r.AgeSecs, err
}
func (b quiesceBackend) StartBackupFor(ctx context.Context, target string) (string, error) {
r, err := b.c.StartBackupFor(ctx, target)
return r.JobID, err
}
func (b quiesceBackend) BackupStatusFor(ctx context.Context, target string) (string, error) {
r, err := b.c.BackupStatusFor(ctx, target)
return r.Phase, err
}
// startQuiesceLoop wires + starts the slice-8B quiesce loop when the local API is configured and
// quiesce is enabled. It Recovers (restarts stacks left stopped by a mid-quiesce crash) before
// starting the loop goroutine. Non-fatal: any misconfig disables the loop with a log line.