registry: fleet placement and box restamp move the fingerprint
Found by the two-box e2e (next commit): Route.FleetBox is written by an UNATTENDED failover — ReconcileFleetBindings stamps the surviving box — but neither fleet_box nor region appears in Fingerprint's routes aggregate, so the write left the fingerprint byte-identical, the edge's 2s Watch poll never fired, and its fleet proxy kept the DEAD box's label captured. Rule 1 then dispatched straight back to that box the moment it re-dialed, serving from the one box that does NOT hold the lease. Adding LENGTH(fleet_box) would not have fixed it: box labels are overwhelmingly the same length as each other (box-a/box-b, pi-1/pi-2), which is exactly the same-length blind spot the token_hash rev column exists for. So the fingerprint gains a fourth component carrying the VALUES — rowid=region>fleet_box per row — restricted to fleet-relevant rows, which keeps it empty on every non-fleet deployment today and bounded by the fleet's own route count on the rest. Every transition is visible: entering placement, leaving it, and a restamp between two same-length labels.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+125
−0
internal/registry/fleets_test.go+95 −0internal/registry/registry.go+30 −0
diff --git a/internal/registry/fleets_test.go b/internal/registry/fleets_test.go| index 486b972df..6c3012419 100644 |
| --- a/internal/registry/fleets_test.go |
| +++ b/internal/registry/fleets_test.go |
| @@ -382,6 +382,101 @@ func TestFingerprintChangesOnTokenRotation(t *testing.T) { |
| } |
| } |
| +// TestFingerprintChangesOnFleetBoxRestamp is the Task 10 regression pin — |
| +// the defect the two-box e2e (internal/edge/fleet_e2e_test.go) surfaced. A |
| +// failover restamps routes.fleet_box (ReconcileFleetBindings -> SetFleetBox) |
| +// between two labels of the SAME length, which is the common case for fleet |
| +// box labels ("box-a"/"box-b", "pi-1"/"pi-2"). Before the fleet-placement |
| +// projection was added to Fingerprint, that write changed nothing the |
| +// aggregate looked at — fleet_box is not in the routes weighted sum at all — |
| +// so the edge never reloaded, kept the dead box's label captured in its |
| +// fleet proxy, and dispatched straight back to it the moment it re-dialed. |
| +// Unlike an operator's route edit, a restamp is unattended: there is nobody |
| +// there to SIGHUP. |
| +func TestFingerprintChangesOnFleetBoxRestamp(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "app.example", Kind: "instance", Region: PlacementTarget("pi"), FleetBox: "box-a"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetFleetBox("app.example", "box-b"); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if before == after { |
| + t.Fatalf("fingerprint unchanged across a same-length fleet_box restamp: %q — the edge would keep routing to the box that died", after) |
| + } |
| +} |
| + |
| +// TestFingerprintChangesOnPlacement: the same projection covers the OTHER |
| +// column fleet dispatch reads — Region. Placing an existing route on a fleet |
| +// (`carlos apps -place`, a different PROCESS from the edge, so the |
| +// fingerprint poll is the only path in) must be visible too. |
| +func TestFingerprintChangesOnPlacement(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "app.example", Kind: "instance"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetPlacement("app.example", PlacementTarget("pi")); err != nil { |
| + t.Fatal(err) |
| + } |
| + mid, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if before == mid { |
| + t.Fatalf("fingerprint unchanged when a route was placed on a fleet: %q", mid) |
| + } |
| + // ...and clearing it again is equally visible (the row drops out of the |
| + // projection rather than lingering as a stale entry). |
| + if err := reg.SetPlacement("app.example", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after == mid { |
| + t.Fatalf("fingerprint unchanged when a route left fleet placement: %q", after) |
| + } |
| +} |
| + |
| +// A registry with no fleet-placed routes at all — every deployment in the |
| +// fleet today — must produce a STABLE fingerprint across repeated polls: the |
| +// projection is a GROUP_CONCAT, and one that flapped (or that grew with |
| +// unrelated routes) would make the edge reload every 2s forever. |
| +func TestFingerprintStableWithoutFleetRoutes(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + for _, h := range []string{"one.example", "two.example", "three.example"} { |
| + if err := reg.Upsert(Route{Host: h, Kind: "service", Addr: "127.0.0.1:1", Region: "eu"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + first, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for i := 0; i < 5; i++ { |
| + again, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if again != first { |
| + t.Fatalf("fingerprint flapped with no writes at all: %q then %q", first, again) |
| + } |
| + } |
| +} |
| + |
| // TestFleetPlacement pins the "fleet/<name>" grammar Route.Region carries |
| // when a route is bound to a customer fleet: the prefix alone is not enough, |
| // the name half must also pass ValidTunnelName, and a bare region code (a |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index c7095cc10..1c136de22 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -1087,6 +1087,30 @@ WHERE kind = 'instance' AND next_due > 0 AND next_due <= ? ORDER BY host`, now) |
| // rev is what makes a fixed-length-value rewrite visible, the same job |
| // COUNT/MAX(rowid) do for inserts/deletes and LENGTH does for |
| // variable-length rewrites. |
| +// |
| +// The FOURTH component is the fleet-placement projection, and it is a |
| +// verbatim value list rather than a weighted length sum for a reason the |
| +// two-box e2e (internal/edge/fleet_e2e_test.go) found the hard way: the |
| +// routes aggregate above covers neither `region` nor `fleet_box`, so a |
| +// failover restamp (ReconcileFleetBindings -> SetFleetBox, box-a -> box-b) |
| +// left the fingerprint byte-identical and the edge kept dispatching to the |
| +// box that had died — rule 1 pointing at a corpse the moment it re-dialed, |
| +// with no operator around to SIGHUP, because a restamp is UNATTENDED where |
| +// an operator's `carlos route` edit is not. Adding LENGTH(fleet_box) would |
| +// not have fixed it either: fleet box labels are overwhelmingly the same |
| +// length as each other ("pi-1"/"pi-2", "box-a"/"box-b"), which is exactly |
| +// the blind spot the paragraph above documents. So this component carries |
| +// the VALUES — rowid=region>fleet_box per row — and is restricted to |
| +// fleet-relevant rows (a fleet placement target, or a stamped box), which |
| +// keeps it empty on every non-fleet deployment and bounded by the fleet's |
| +// own route count on the rest. Every transition is visible: a route |
| +// entering fleet placement appears, one leaving drops out, a restamp |
| +// changes its entry. |
| +// |
| +// The ORDER BY rowid in the subquery is what makes GROUP_CONCAT's result |
| +// deterministic across polls; a hypothetical reordering would only cost a |
| +// redundant reload (idempotent), never a missed change — the failure mode |
| +// worth engineering against is the false NEGATIVE. |
| func (r *Registry) Fingerprint() (string, error) { |
| db, err := sql.Open("sqlite", r.path) |
| if err != nil { |
| @@ -1113,6 +1137,12 @@ SELECT |
| COALESCE(SUM(rowid*31 + LENGTH(fleet)*37 + LENGTH(label)*41 |
| + LENGTH(token_hash)*43 + LENGTH(status)*47 + rev*59),0) |
| FROM fleet_boxes) |
| + || ':' || |
| + (SELECT COALESCE(GROUP_CONCAT(v, ','), '') |
| + FROM (SELECT rowid || '=' || region || '>' || fleet_box AS v |
| + FROM routes |
| + WHERE fleet_box <> '' OR region LIKE 'fleet/%' |
| + ORDER BY rowid)) |
| `).Scan(&fp) |
| return fp, err |
| } |