carlos / platform Public

Merge pull request #298 from carlosframework/snapshot-fingerprint-churn

registry: SnapshotFingerprint ignores the box's own scheduling stamps (#207)
Paul Campbell pushed by paul@keymail.dev cbb6560789d1c98072a287d0bb87d3e7fc251ed2 parent 3e8a675parent e28e501
2 files changed, +80 −1
  • internal/registry/registry.go +26 −1
  • internal/registry/registry_test.go +54 −0
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 0b79bd5b5..0c9a5fafa 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -1794,6 +1794,20 @@ FROM fleet_boxes ORDER BY fleet, label`)
// values are scanned generically and written in column order, which is why
// this needs no per-column typing the way Fingerprint does.
//
+// "Every column" has exactly two exceptions, snapshotChurnCols: the
+// scheduling stamps the box rewrites on its own clock with no operator
+// change behind them. Hashing those made the gate never hold (#207 — both
+// production boxes wrote a generation on essentially every 5-minute tick,
+// so the 8-generation history was ~40 minutes deep, not 8 changes deep).
+// They are still IN the snapshot; only the decision to take one ignores
+// them. A restore therefore loses up to one tick of those stamps, which the
+// activator and DNS sweeps re-derive on their first pass anyway.
+//
+// route_aliases and wildcard_domains are not hashed either: both are
+// pass-owned materializations of domain claims (domains.Reconcile rebuilds
+// every row from the claim records), so a restore recovers them by
+// running.
+//
// Hashing the VACUUM INTO output instead was rejected: SQLite's page image
// is not byte-deterministic across runs of identical content (freelist and
// page-ordering effects), so it would report a change on every pass and
@@ -1802,6 +1816,14 @@ FROM fleet_boxes ORDER BY fleet, label`)
// Fresh connection, same reason as Fingerprint and List: a long-lived
// modernc connection can serve a stale WAL snapshot of another process's
// commits.
+// snapshotChurnCols are the routes columns SnapshotFingerprint skips: stamps
+// the box's own sweeps rewrite with nothing behind them an operator did.
+// Keep this to columns that are re-derived within one sweep of a restore.
+var snapshotChurnCols = map[string]bool{
+ "next_due": true, // activator scheduling (SetNextDue, every pass)
+ "dns_checked_at": true, // DNS sweep stamp (SetDNSStatus, every sweep)
+}
+
func (r *Registry) SnapshotFingerprint() (string, error) {
db, err := sql.Open("sqlite", r.path)
if err != nil {
@@ -1832,7 +1854,10 @@ func (r *Registry) SnapshotFingerprint() (string, error) {
if err := rows.Scan(dest...); err != nil {
return "", err
}
- for _, v := range vals {
+ for i, v := range vals {
+ if snapshotChurnCols[cols[i]] {
+ continue
+ }
// NULL and "" must not hash alike: a column that has never been
// written is a different restore than one explicitly cleared.
if v.Valid {
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index 8a48955e3..196a1ad45 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -2470,3 +2470,57 @@ func TestWildcardDomains(t *testing.T) {
t.Fatalf("after delete: %+v", got)
}
}
+
+// #207: the snapshot gate must hold across the stamps the box rewrites on
+// its own clock — otherwise every 5-minute tick is a "change" and the
+// 8-generation history is 40 minutes deep instead of 8 changes deep. A
+// real DNS status flip, by contrast, still moves it.
+func TestSnapshotFingerprintIgnoresSchedulingChurn(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{
+ Host: "app.example.com", Kind: "instance", App: "app",
+ Addr: "127.0.0.1:8091", Status: "ready",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetDNSStatus("app.example.com", "ok", 1000); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetNextDue("app.example.com", 4242); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetDNSStatus("app.example.com", "ok", 2000); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if after != before {
+ t.Fatalf("SnapshotFingerprint moved on next_due/dns_checked_at churn (%s -> %s) — the snapshot pass would write every tick", before, after)
+ }
+ if err := reg.SetDNSStatus("app.example.com", "mismatch", 3000); err != nil {
+ t.Fatal(err)
+ }
+ flipped, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if flipped == before {
+ t.Fatal("a dns_status change is invisible to SnapshotFingerprint")
+ }
+}
+
+// The skip list is by column NAME, so a churn column must actually exist in
+// routeCols — a rename would silently stop excluding it.
+func TestSnapshotChurnColsExist(t *testing.T) {
+ for col := range snapshotChurnCols {
+ if !strings.Contains(routeCols, col) {
+ t.Errorf("snapshotChurnCols names %q, which is not in routeCols", col)
+ }
+ }
+}