carlos / platform Public

host: arm registry snapshot loop (5min, platform boxes only)

Paul Campbell 53c55aafcd6592f5857fd236930f066b700223a4 parent 89229f2
3 files changed, +107 −4
  • internal/host/agent.go +1 −0
  • internal/host/snapshot_loop.go +61 −4
  • internal/host/snapshot_loop_test.go +45 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 23fb23f62..e90377c6e 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -300,6 +300,7 @@ func RunAgent(args []string) {
go domainsLoop(reg, peekAcmeCache(args))
go instancesLoop(reg)
go retentionLoop(reg)
+ go registrySnapshotLoop(reg)
// The sweep reads the registry only through this closure — the stamped
// bespoke units to sample beside the carlos- ones, re-read each tick.
go metricsLoop(cgm.Root(), func() []string { return stampedUnitNames(reg) })
diff --git a/internal/host/snapshot_loop.go b/internal/host/snapshot_loop.go
index 0d199f0ba..486ca01df 100644
--- a/internal/host/snapshot_loop.go
+++ b/internal/host/snapshot_loop.go
@@ -5,6 +5,8 @@ import (
"encoding/json"
"errors"
"fmt"
+ "log"
+ "math/rand/v2"
"os"
"path/filepath"
"sort"
@@ -49,7 +51,7 @@ func registrySnapshotPass(ctx context.Context, s release.Store, reg *registry.Re
dir, err := os.MkdirTemp("", "carlos-registry-snapshot")
if err != nil {
- return err
+ return fmt.Errorf("snapshot: %w", err)
}
defer os.RemoveAll(dir)
file := filepath.Join(dir, "registry.db")
@@ -58,10 +60,13 @@ func registrySnapshotPass(ctx context.Context, s release.Store, reg *registry.Re
}
body, err := os.ReadFile(file)
if err != nil {
- return err
+ return fmt.Errorf("snapshot: %w", err)
}
- // Generation first (create-only — a stamp collision is a real conflict),
+ // Generation first (create-only — a same-second re-run or a clock
+ // step-back can collide on the stamp, in which case Put's ErrCASConflict
+ // is deliberately swallowed: the existing generation for that second is
+ // as good as the one we'd have written, so there is nothing to retry),
// then current + sidecar (replace with the CAS-retry recipe
// usage.UpsertEdgeDay uses), then prune.
genKey := snapshotGenPrefix + now.UTC().Format(snapshotStamp) + ".db"
@@ -74,7 +79,7 @@ func registrySnapshotPass(ctx context.Context, s release.Store, reg *registry.Re
meta := snapshotMeta{V: 1, Box: box, Fingerprint: fp, CreatedAt: now.UTC(), Bytes: len(body)}
metaRaw, err := json.Marshal(meta)
if err != nil {
- return err
+ return fmt.Errorf("snapshot: %w", err)
}
if err := putReplacing(ctx, s, snapshotMetaKey, metaRaw); err != nil {
return err
@@ -96,6 +101,58 @@ func registrySnapshotPass(ctx context.Context, s release.Store, reg *registry.Re
return nil
}
+// seedSnapshotFingerprint reads the sidecar a previous run (or a previous
+// process on this same box) left at snapshotMetaKey and returns its
+// fingerprint, so a freshly started loop's in-memory lastFP starts where the
+// bucket already is rather than empty. Without this, a crash-looping agent
+// would re-snapshot an unchanged registry on every restart and burn through
+// all snapshotKeep generations with identical copies. Any read or parse
+// failure — no sidecar yet, a corrupt object, a store that is down — returns
+// "": the loop then snapshots on its first tick, the same behavior a box
+// that has never snapshotted before gets. This must never fail the loop.
+func seedSnapshotFingerprint(ctx context.Context, s release.Store) string {
+ raw, _, err := s.Get(ctx, snapshotMetaKey)
+ if err != nil {
+ return ""
+ }
+ var meta snapshotMeta
+ if err := json.Unmarshal(raw, &meta); err != nil {
+ return ""
+ }
+ return meta.Fingerprint
+}
+
+// registrySnapshotLoop snapshots the registry to the deployment bucket every
+// 5 minutes + jitter when it changed — rung 1 of the HA ladder (cold
+// standby restores this file). Platform-box job, retentionLoop's rules:
+// skipped on fleet boxes, disabled with a log line when there is no bucket.
+// The fixed control/ keys this writes assume one platform box per deployment
+// bucket (true for every deployment today); a second concurrent writer would
+// interleave current/sidecar writes from two registries.
+func registrySnapshotLoop(reg *registry.Registry) {
+ ctx := context.Background()
+ if shouldSkipPublisherElection(os.Getenv) {
+ log.Printf("agent: registry snapshot loop is a platform-box job — skipped on a fleet box")
+ return
+ }
+ store, err := openDeployStore(ctx)
+ if err != nil {
+ log.Printf("agent: registry snapshot loop disabled: %v", err)
+ return
+ }
+ box := metricsBoxLabel(os.Getenv, readMachineID())
+ log.Printf("agent: registry snapshot loop armed (5min + jitter, %s)", snapshotKey)
+ lastFP := seedSnapshotFingerprint(ctx, store)
+ delay := time.Minute
+ for {
+ time.Sleep(delay)
+ delay = 5*time.Minute + rand.N(time.Minute)
+ if err := registrySnapshotPass(ctx, store, reg, box, time.Now(), &lastFP); err != nil {
+ log.Printf("agent: registry snapshot pass: %v", err)
+ }
+ }
+}
+
// putReplacing writes key regardless of prior state: create-only when
// absent, etag-replace when present, re-reading on a lost race. Three
// attempts, like usage.UpsertEdgeDay.
diff --git a/internal/host/snapshot_loop_test.go b/internal/host/snapshot_loop_test.go
index 91764e35e..e212e0bd9 100644
--- a/internal/host/snapshot_loop_test.go
+++ b/internal/host/snapshot_loop_test.go
@@ -132,3 +132,48 @@ func TestSnapshotPassPrunesGenerationsBeyondKeep(t *testing.T) {
t.Fatalf("oldest kept generation = %q, want the round-3 stamp", gens[0])
}
}
+
+func TestSeedSnapshotFingerprintEmptyWhenNoSidecar(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ if got := seedSnapshotFingerprint(context.Background(), s); got != "" {
+ t.Fatalf("seedSnapshotFingerprint = %q, want empty with no sidecar written yet", got)
+ }
+}
+
+// TestSeedSnapshotFingerprintMatchingSidecarSkipsFirstPass proves the
+// registrySnapshotLoop wiring this backs: reading lastFP from the bucket
+// sidecar at loop start (rather than starting it empty in memory) makes the
+// very first pass after a restart a no-op when the registry has not changed
+// since the last snapshot — the crash-loop case addition 1 of the Task 2
+// review exists to fix.
+func TestSeedSnapshotFingerprintMatchingSidecarSkipsFirstPass(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ reg := snapTestRegistry(t)
+ at := time.Date(2026, 8, 12, 3, 0, 0, 0, time.UTC)
+
+ // A prior process ran a pass and wrote the sidecar, then exited (or
+ // crashed) — its in-memory lastFP is gone.
+ var priorFP string
+ if err := registrySnapshotPass(ctx, s, reg, "box1", at, &priorFP); err != nil {
+ t.Fatal(err)
+ }
+
+ // A fresh process seeds lastFP from the bucket instead of starting empty.
+ seeded := seedSnapshotFingerprint(ctx, s)
+ if seeded == "" || seeded != priorFP {
+ t.Fatalf("seeded fingerprint = %q, want %q", seeded, priorFP)
+ }
+
+ lastFP := seeded
+ if err := registrySnapshotPass(ctx, s, reg, "box1", at.Add(5*time.Minute), &lastFP); err != nil {
+ t.Fatal(err)
+ }
+ gens, err := s.List(ctx, snapshotGenPrefix)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gens) != 1 {
+ t.Fatalf("seeded lastFP did not skip the unchanged registry: generations = %v (want 1, no new write)", gens)
+ }
+}