host: registry snapshot pass — current + sidecar + pruned generations under control/
2 files changed,
+252
−0
internal/host/snapshot_loop.go+118 −0internal/host/snapshot_loop_test.go+134 −0
diff --git a/internal/host/snapshot_loop.go b/internal/host/snapshot_loop.go| new file mode 100644 |
| index 000000000..0d199f0ba |
| --- /dev/null |
| +++ b/internal/host/snapshot_loop.go |
| @@ -0,0 +1,118 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The cold-standby spec (2026-08-12 HA posture, Part I rung 1): the current |
| +// snapshot at a fixed key a runbook can name, a sidecar describing it, and a |
| +// short tail of prior generations. All under control/ beside control/serial; |
| +// platform boxes write direct S3, so no store-door ACL is involved. |
| +const ( |
| + snapshotKey = "control/registry-snapshot.db" |
| + snapshotMetaKey = "control/registry-snapshot.json" |
| + snapshotGenPrefix = "control/registry-snapshots/" |
| + snapshotKeep = 8 |
| + snapshotStamp = "20060102T150405Z" |
| +) |
| + |
| +type snapshotMeta struct { |
| + V int `json:"v"` |
| + Box string `json:"box"` |
| + Fingerprint string `json:"fingerprint"` |
| + CreatedAt time.Time `json:"created_at"` |
| + Bytes int `json:"bytes"` |
| +} |
| + |
| +// registrySnapshotPass snapshots the registry into the bucket when its |
| +// fingerprint moved since *lastFP. One pass; the loop owns cadence. *lastFP |
| +// is updated only after every write (including pruning) succeeded, so a |
| +// partial failure is retried whole next tick. |
| +func registrySnapshotPass(ctx context.Context, s release.Store, reg *registry.Registry, box string, now time.Time, lastFP *string) error { |
| + fp, err := reg.Fingerprint() |
| + if err != nil { |
| + return fmt.Errorf("snapshot: fingerprint: %w", err) |
| + } |
| + if fp == *lastFP { |
| + return nil |
| + } |
| + |
| + dir, err := os.MkdirTemp("", "carlos-registry-snapshot") |
| + if err != nil { |
| + return err |
| + } |
| + defer os.RemoveAll(dir) |
| + file := filepath.Join(dir, "registry.db") |
| + if err := reg.SnapshotTo(file); err != nil { |
| + return fmt.Errorf("snapshot: vacuum: %w", err) |
| + } |
| + body, err := os.ReadFile(file) |
| + if err != nil { |
| + return err |
| + } |
| + |
| + // Generation first (create-only — a stamp collision is a real conflict), |
| + // then current + sidecar (replace with the CAS-retry recipe |
| + // usage.UpsertEdgeDay uses), then prune. |
| + genKey := snapshotGenPrefix + now.UTC().Format(snapshotStamp) + ".db" |
| + if _, err := s.Put(ctx, genKey, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) { |
| + return fmt.Errorf("snapshot: put %s: %w", genKey, err) |
| + } |
| + if err := putReplacing(ctx, s, snapshotKey, body); err != nil { |
| + return err |
| + } |
| + meta := snapshotMeta{V: 1, Box: box, Fingerprint: fp, CreatedAt: now.UTC(), Bytes: len(body)} |
| + metaRaw, err := json.Marshal(meta) |
| + if err != nil { |
| + return err |
| + } |
| + if err := putReplacing(ctx, s, snapshotMetaKey, metaRaw); err != nil { |
| + return err |
| + } |
| + |
| + gens, err := s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + return fmt.Errorf("snapshot: list generations: %w", err) |
| + } |
| + sort.Strings(gens) // stamps are lexically chronological |
| + for len(gens) > snapshotKeep { |
| + if err := s.Delete(ctx, gens[0]); err != nil { |
| + return fmt.Errorf("snapshot: prune %s: %w", gens[0], err) |
| + } |
| + gens = gens[1:] |
| + } |
| + |
| + *lastFP = fp |
| + return nil |
| +} |
| + |
| +// 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. |
| +func putReplacing(ctx context.Context, s release.Store, key string, body []byte) error { |
| + for attempt := 0; attempt < 3; attempt++ { |
| + _, etag, err := s.Get(ctx, key) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + etag = "" |
| + case err != nil: |
| + return fmt.Errorf("snapshot: get %s: %w", key, err) |
| + } |
| + if _, err := s.Put(ctx, key, body, etag); err == nil { |
| + return nil |
| + } else if !errors.Is(err, release.ErrCASConflict) { |
| + return fmt.Errorf("snapshot: put %s: %w", key, err) |
| + } |
| + } |
| + return fmt.Errorf("snapshot: %s: lost CAS race three times", key) |
| +} |
diff --git a/internal/host/snapshot_loop_test.go b/internal/host/snapshot_loop_test.go| new file mode 100644 |
| index 000000000..91764e35e |
| --- /dev/null |
| +++ b/internal/host/snapshot_loop_test.go |
| @@ -0,0 +1,134 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func snapTestRegistry(t *testing.T) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "service", Addr: "10.0.0.1:3000", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + return reg |
| +} |
| + |
| +func TestSnapshotPassWritesCurrentSidecarAndGeneration(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) |
| + |
| + var lastFP string |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at, &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + raw, _, err := s.Get(ctx, snapshotKey) |
| + if err != nil { |
| + t.Fatalf("current snapshot missing: %v", err) |
| + } |
| + if len(raw) == 0 { |
| + t.Fatal("current snapshot is empty") |
| + } |
| + |
| + metaRaw, _, err := s.Get(ctx, snapshotMetaKey) |
| + if err != nil { |
| + t.Fatalf("sidecar missing: %v", err) |
| + } |
| + var meta snapshotMeta |
| + if err := json.Unmarshal(metaRaw, &meta); err != nil { |
| + t.Fatal(err) |
| + } |
| + if meta.V != 1 || meta.Box != "box1" || meta.Fingerprint == "" || meta.Bytes != len(raw) { |
| + t.Fatalf("sidecar = %+v (snapshot %d bytes)", meta, len(raw)) |
| + } |
| + if lastFP != meta.Fingerprint { |
| + t.Fatalf("lastFP %q not updated to %q", lastFP, meta.Fingerprint) |
| + } |
| + |
| + gens, err := s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(gens) != 1 || gens[0] != snapshotGenPrefix+"20260812T030000Z.db" { |
| + t.Fatalf("generations = %v", gens) |
| + } |
| +} |
| + |
| +func TestSnapshotPassSkipsWhenFingerprintUnchanged(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) |
| + |
| + var lastFP string |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at, &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + 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("unchanged registry grew generations: %v", gens) |
| + } |
| + |
| + // A registry change makes the next pass write again. |
| + if err := reg.Upsert(registry.Route{Host: "b.example.com", Kind: "service", Addr: "10.0.0.2:3000", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at.Add(10*time.Minute), &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + gens, err = s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(gens) != 2 { |
| + t.Fatalf("changed registry did not write a new generation: %v", gens) |
| + } |
| +} |
| + |
| +func TestSnapshotPassPrunesGenerationsBeyondKeep(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + reg := snapTestRegistry(t) |
| + at := time.Date(2026, 8, 12, 0, 0, 0, 0, time.UTC) |
| + |
| + var lastFP string |
| + for i := 0; i < snapshotKeep+3; i++ { |
| + // Change the registry each round so no pass skips. |
| + if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Version: string(rune('a' + i))}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := registrySnapshotPass(ctx, s, reg, "box1", at.Add(time.Duration(i)*5*time.Minute), &lastFP); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + gens, err := s.List(ctx, snapshotGenPrefix) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(gens) != snapshotKeep { |
| + t.Fatalf("kept %d generations, want %d: %v", len(gens), snapshotKeep, gens) |
| + } |
| + // The survivors must be the newest ones: the earliest kept stamp is round 3's. |
| + if gens[0] != snapshotGenPrefix+"20260812T001500Z.db" { |
| + t.Fatalf("oldest kept generation = %q, want the round-3 stamp", gens[0]) |
| + } |
| +} |