carlos / platform Public

registry: content-accurate Fingerprint — same-length rewrites reload the edge (closes #15)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell f7cdb645ac8a40e016315d3713e5ca9f5f0c87c6 parent f93666a
2 files changed, +81 −19
  • internal/registry/registry.go +39 −19
  • internal/registry/registry_test.go +42 −0
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 797b08494..24192db6d 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -10,6 +10,7 @@
package registry
import (
+ "crypto/sha256"
"database/sql"
"fmt"
"regexp"
@@ -984,18 +985,22 @@ WHERE kind = 'instance' AND next_due > 0 AND next_due <= ? ORDER BY host`, now)
}
// Fingerprint summarizes the route table over a fresh connection, for the
-// edge's cheap change poll. It must change on any row insert/delete and on
-// the column writes reloads care about; it must see other processes' commits
-// (fresh connection, same WAL-snapshot quirk as List).
+// edge's cheap change poll. It is content-accurate over the reload-relevant
+// columns (status, socket, addr, version, hibernate, channel, backing,
+// tunnel): it hashes the actual values, not a length-weighted aggregate, so
+// two DIFFERENT values of the SAME length (channel canary/1 -> canary/2, a
+// git short-sha version bump) change the string exactly like any other
+// write. `grants` stays deliberately excluded (registry.go:93's rule: grants
+// change unit shape, not routing). Row count is folded into the result so an
+// empty table and a never-opened one still differ from a populated one.
+// Still a fresh connection per poll — it must see other processes' commits
+// (same WAL-snapshot quirk as List).
//
-// Text columns contribute their LENGTH under a per-column weight, so a rewrite
-// to a DIFFERENT value of the SAME length (channel canary/1 -> canary/2,
-// tunnel pi-box -> hq-box) is invisible to this poll. That is a deliberate
-// cost of keeping it a single cheap aggregate: an operator's `carlos route`
-// runs beside an edge they can SIGHUP, and every same-length rewrite reaches
-// the fleet on the next reload for any other reason. Weights (3,5,7,11,13…)
-// keep DIFFERENT columns from cancelling each other out, which is the failure
-// that would actually hide a change forever.
+// Closes #15: the edge's X-Carlos-Version response header reads Version off
+// the route the fingerprint gated in, and git short shas are same-length by
+// construction — the old length-weighted aggregate could not see a version
+// bump land, so the header would serve stale until an unrelated column
+// happened to change too.
func (r *Registry) Fingerprint() (string, error) {
db, err := sql.Open("sqlite", r.path)
if err != nil {
@@ -1005,12 +1010,27 @@ func (r *Registry) Fingerprint() (string, error) {
if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
return "", err
}
- var fp string
- err = db.QueryRow(`
-SELECT COUNT(*) || ':' || COALESCE(MAX(rowid),0) || ':' ||
- COALESCE(SUM(rowid*17 + LENGTH(status)*3 + LENGTH(socket) + LENGTH(addr)
- + LENGTH(version)*5 + hibernate*7 + LENGTH(channel)
- + LENGTH(backing)*11 + LENGTH(tunnel)*13),0)
-FROM routes`).Scan(&fp)
- return fp, err
+ rows, err := db.Query(`
+SELECT rowid, status, socket, addr, version, hibernate, channel, backing, tunnel
+FROM routes ORDER BY rowid`)
+ if err != nil {
+ return "", err
+ }
+ defer rows.Close()
+ h := sha256.New()
+ n := 0
+ for rows.Next() {
+ var rowid, hibernate int64
+ var status, socket, addr, version, channel, backing, tunnel string
+ if err := rows.Scan(&rowid, &status, &socket, &addr, &version, &hibernate, &channel, &backing, &tunnel); err != nil {
+ return "", err
+ }
+ fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1e",
+ rowid, status, socket, addr, version, hibernate, channel, backing, tunnel)
+ n++
+ }
+ if err := rows.Err(); err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%d:%x", n, h.Sum(nil)), nil
}
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index b7f120211..82ff11a3a 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -1241,6 +1241,48 @@ func TestFingerprintIgnoresAGrant(t *testing.T) {
}
}
+// TestFingerprintSeesSameLengthVersionSwap pins issue #15: a same-length
+// version rewrite (git short shas are always the same length) must change
+// the fingerprint, or the edge's X-Carlos-Version header goes stale.
+func TestFingerprintSeesSameLengthVersionSwap(t *testing.T) {
+ reg := openTestRegistry(t)
+ rt := Route{Host: "fp.example.test", Kind: "instance", App: "echo", Status: "ready", Version: "abc1234"}
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetVersions("fp.example.test", "def5678", "abc1234"); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatal("same-length version swap invisible to Fingerprint (issue #15)")
+ }
+}
+
+func TestFingerprintSeesSameLengthChannelSwap(t *testing.T) {
+ reg := openTestRegistry(t)
+ rt := Route{Host: "fp2.example.test", Kind: "instance", App: "echo", Status: "ready", Channel: "canary/1"}
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := reg.Fingerprint()
+ rt.Channel = "canary/2"
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ after, _ := reg.Fingerprint()
+ if before == after {
+ t.Fatal("same-length channel swap invisible to Fingerprint (issue #15)")
+ }
+}
+
func TestTunnelColumnRoundTrips(t *testing.T) {
reg := openTestRegistry(t)
if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil {