carlos / platform Public

provision+registry: the steering field — additive, 4-place, converge-owned

Adds Record.Steering ("" or "latency", ValidSteering-gated) and its
registry mirror, Route.Steering — the 36th routeCols column, touching
the four hand-maintained lists (ALTER, routeCols, scanRoute, Upsert)
the same way #152's addrs/health columns did.

SetSteering is a narrow single-column UPDATE. routeFor mints the
column straight from the record; shapeMismatch refuses adoption on a
disagreement (steering has no out-of-band writer, so unlike
Channel/Environment it isn't excluded from the adoption gate);
convergeOwned converges it unconditionally through SetSteering, never
a full-row Upsert.

Fingerprint's hand-typed column subset is extended explicitly to hash
steering (a reload signal, not a consumer); SnapshotFingerprint rides
routeCols automatically per its existing contract. Both are pinned by
dedicated tests per the plan's own demand, not assumed.

Single-box inert: nothing reads this column yet. The console door
(Task 3), the desired-state builder and the Route53 converger (Tasks
4-6) are what give a non-empty value any effect, gated further behind
CARLOS_STEERING_ZONE_ID.

Wire-additivity proven by a hand-typed pre-plan JSON fixture (current
shape fields present, no "steering" key) that parses with Steering ==
"" and round-trips without sprouting the key.
Paul Campbell pushed by paul@keymail.dev 76c4482668be041de8bc8ddd19b2bf7cb92dd26f parent 07a41eb
6 files changed, +363 −18
  • internal/provision/provision.go +37 −0
  • internal/provision/provision_test.go +57 −1
  • internal/provision/reconcile.go +26 −6
  • internal/provision/reconcile_test.go +78 −0
  • internal/registry/registry.go +79 −11
  • internal/registry/registry_test.go +86 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 0081145c1..2108074b1 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -96,6 +96,25 @@ type Record struct {
// instanceListRecord.Health) that a deployed CLI parses — that surface
// is shipped and keeps the name, this field is new and yields it.
Health *Health `json:"probe,omitempty"`
+
+ // --- steering fields (2026-08-23, multi-region ingress design Part 4) --
+
+ // Steering is this route's DNS steering opt-in: "" (default — today's
+ // static per-host answer, and what every record written before this
+ // field existed reads as) or "latency" (a Route53 latency record per
+ // armed edge, converged by the steering housekeeper duty — Tasks 4-6 of
+ // the multi-region-steering plan). A STRING, deliberately, not a bool:
+ // Part 4 names other steering policies as future work, and a string
+ // leaves room for a new value to land without another wire-shape break.
+ // ValidSteering is the door.
+ //
+ // SINGLE-BOX INERT as of this field's introduction (plan Task 2):
+ // nothing reads it yet. It reaches the registry row (routeFor,
+ // convergeOwned) and nothing further — the console door (Task 3) is
+ // what lets a member set it, and the desired-state builder and Route53
+ // converger (Tasks 4-6) are what give a non-empty value any effect, and
+ // even then only once a deployment arms CARLOS_STEERING_ZONE_ID.
+ Steering string `json:"steering,omitempty"`
}
// Health is the per-route probe config (HA spec Part II). Zero value =
@@ -323,6 +342,21 @@ func ValidKind(kind string) bool {
return false
}
+// ValidSteering pins the record's DNS-steering vocabulary (multi-region
+// ingress design Part 4): "" (no opinion — a route's host resolves exactly
+// as it does today) or "latency" (opt into a Route53 latency record per
+// armed edge). Case-sensitive and closed, the same discipline ValidKind
+// applies to its own vocabulary — "Latency" or "geo" are refused at this
+// door rather than discovered as a steering record the Route53 converger
+// (Tasks 4-6) silently ignores.
+func ValidSteering(s string) bool {
+ switch s {
+ case "", "latency":
+ return true
+ }
+ return false
+}
+
// MaxUpstreams caps the Addrs set one record may name. The prober probes a
// host's upstreams SEQUENTIALLY once per interval (internal/edge/prober.go),
// and every scale figure the multi-upstream design rests on — probe traffic,
@@ -341,6 +375,9 @@ func ValidateShape(rec Record) error {
if !ValidKind(rec.Kind) {
return fmt.Errorf("kind %q: want instance, service or static (empty means instance)", rec.Kind)
}
+ if !ValidSteering(rec.Steering) {
+ return fmt.Errorf("steering %q: want \"\" or \"latency\"", rec.Steering)
+ }
if rec.Addr != "" {
if err := validAddr(rec.Addr); err != nil {
return err
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go
index 8a3c6942d..c92266f39 100644
--- a/internal/provision/provision_test.go
+++ b/internal/provision/provision_test.go
@@ -71,13 +71,54 @@ func TestRecordMarshalOmitsAbsentShapeFields(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named"} {
+ for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named", "steering"} {
if strings.Contains(string(body), `"`+key+`"`) {
t.Errorf("marshalled record carries %q when it was never set: %s", key, body)
}
}
}
+// TestRecordSteeringPrePlanFixtureParses is the plan's own wire-additivity
+// demand: a record HAND-TYPED as a binary that predates the steering field
+// would have written it — a current production-shaped record (schema-1 plus
+// the shape and multi-upstream fields, the vocabulary provision's own test
+// fixtures already exercise), verbatim bytes, never round-tripped through
+// this build's Record first. It must still parse, with Steering == "" — the
+// only value that reproduces what routeFor already mints for every record
+// that predates this plan: unsteered, today's static per-host answer.
+func TestRecordSteeringPrePlanFixtureParses(t *testing.T) {
+ const preplan = `{"schema":1,"host":"ha.go.tito.io","slug":"ha","app":"tito",` +
+ `"account":"bab","pool":"tito","region":"ie","channel":"stable",` +
+ `"created_at":"2026-08-12T09:00:00Z","kind":"service",` +
+ `"addrs":["127.0.0.1:7001","127.0.0.1:7002"],` +
+ `"probe":{"path":"/up","status":200},"environment":"demo",` +
+ `"status_named":true}`
+ var rec Record
+ if err := json.Unmarshal([]byte(preplan), &rec); err != nil {
+ t.Fatal(err)
+ }
+ if rec.Steering != "" {
+ t.Errorf("Steering = %q, want empty — a pre-plan record names no opinion", rec.Steering)
+ }
+ if !ValidSteering(rec.Steering) {
+ t.Error("the zero value must itself be valid — every record ever written must pass this door unchanged")
+ }
+ // Confirm nothing ELSE about the record's own established shape was
+ // disturbed by adding the new field.
+ if rec.Host != "ha.go.tito.io" || rec.Pool != "tito" || len(rec.Addrs) != 2 || rec.Environment != "demo" {
+ t.Fatalf("pre-plan fixture's own fields mis-parsed: %+v", rec)
+ }
+ // Round trip: re-marshaling must not sprout a "steering" key for a
+ // record that never carried one.
+ body, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(body), `"steering"`) {
+ t.Errorf("re-marshaled pre-plan record carries steering: %s", body)
+ }
+}
+
func TestValidHost(t *testing.T) {
cases := []struct {
host, domain string
@@ -557,6 +598,19 @@ func TestValidKind(t *testing.T) {
}
}
+func TestValidSteering(t *testing.T) {
+ for _, s := range []string{"", "latency"} {
+ if !ValidSteering(s) {
+ t.Errorf("ValidSteering(%q) = false, want true", s)
+ }
+ }
+ for _, s := range []string{"Latency", "geo", "LATENCY", " latency", "latency "} {
+ if ValidSteering(s) {
+ t.Errorf("ValidSteering(%q) = true, want false", s)
+ }
+ }
+}
+
func TestValidateShape(t *testing.T) {
yes := true
base := Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito",
@@ -576,6 +630,7 @@ func TestValidateShape(t *testing.T) {
{"service with addr", with(func(r *Record) { r.Kind = "service"; r.Addr = "127.0.0.1:8090" })},
{"static", with(func(r *Record) { r.Kind = "static" })},
{"explicitly non-hibernating instance", with(func(r *Record) { r.Hibernate = new(bool) })},
+ {"steered instance", with(func(r *Record) { r.Steering = "latency" })},
}
for _, c := range ok {
if err := ValidateShape(c.rec); err != nil {
@@ -592,6 +647,7 @@ func TestValidateShape(t *testing.T) {
{"addr with garbage port", with(func(r *Record) { r.Addr = "127.0.0.1:abc" })},
{"unit typo", with(func(r *Record) { r.Unit = "console" })},
{"bad environment name", with(func(r *Record) { r.Environment = "Demo" })},
+ {"bad steering value", with(func(r *Record) { r.Steering = "geo" })},
{"service with no addr", with(func(r *Record) { r.Kind = "service" })},
{"static with addr", with(func(r *Record) { r.Kind = "static"; r.Addr = "127.0.0.1:8090" })},
{"static with unit", with(func(r *Record) { r.Kind = "static"; r.Unit = "console.service" })},
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index 8b532196a..3a47a42dd 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -243,6 +243,7 @@ func routeFor(rec Record, dataDir, seed string) registry.Route {
App: rec.App, Account: rec.Account, Pool: rec.Pool, Region: rec.Region,
Channel: rec.Channel, Environment: rec.Environment,
Hibernate: rec.HibernateOrDefault(), StatusNamed: rec.StatusNamedOrDefault(),
+ Steering: rec.Steering,
Provisioned: true, Seed: seed, Status: "ready",
}
if rec.DerivesPaths() {
@@ -387,6 +388,8 @@ func shapeMismatch(existing, want registry.Route) string {
return fmt.Sprintf("addrs %q/%q", existing.Addrs, want.Addrs)
case existing.Health != want.Health:
return fmt.Sprintf("health %q/%q", existing.Health, want.Health)
+ case existing.Steering != want.Steering:
+ return fmt.Sprintf("steering %q/%q", existing.Steering, want.Steering)
case existing.Unit != want.Unit:
return fmt.Sprintf("unit %q/%q", existing.Unit, want.Unit)
case existing.Slug != want.Slug:
@@ -404,12 +407,13 @@ func shapeMismatch(existing, want registry.Route) string {
}
}
-// convergeOwned applies the record's Channel, Environment, StatusNamed and
-// UPSTREAM SET onto a route it owns — the columns the record is authoritative
-// for once it owns the row, whether ownership was just granted by adoption or
-// was already held by an earlier pass. Both the owned-route converge branch and
-// the adoption branch call this, so there is exactly one place these columns get
-// written from a record, never two copies drifting apart.
+// convergeOwned applies the record's Channel, Environment, StatusNamed,
+// UPSTREAM SET and Steering onto a route it owns — the columns the record is
+// authoritative for once it owns the row, whether ownership was just granted
+// by adoption or was already held by an earlier pass. Both the owned-route
+// converge branch and the adoption branch call this, so there is exactly one
+// place these columns get written from a record, never two copies drifting
+// apart.
//
// Addrs/Health converge UNCONDITIONALLY on divergence — no "only when the
// record states one" guard of the kind Unit carries — and that difference is
@@ -422,6 +426,13 @@ func shapeMismatch(existing, want registry.Route) string {
// derivation the mint uses, so a route minted from a record cannot be
// "converged" onto anything but what it already carries.
//
+// Steering converges the same way, for the same reason: it has no supported
+// out-of-band writer either (Task 3's console door and CLI both write the
+// RECORD, never the row directly), so "" is as much an answer as "latency" —
+// a record that clears its steering opt-in must reach the row, or the
+// console's PATCH door would move the bucket and nothing else, the exact
+// silent-stomp shape the upstream-set paragraph above already documents.
+//
// StatusNamed converges only when the record STATES it (design §3, the same
// tri-state Hibernate carries): absent means "this record does not speak to
// the status page", and stamping a derived false over an operator's `carlos
@@ -487,6 +498,15 @@ func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record,
rec.Host, existing.Addr, addr, existing.Addrs, addrs, existing.Health, health)
}
}
+ if existing.Steering != rec.Steering {
+ if dryRun {
+ log.Printf("provision: dry-run: would converge %s steering %q -> %q", rec.Host, existing.Steering, rec.Steering)
+ } else if err := reg.SetSteering(rec.Host, rec.Steering); err != nil {
+ log.Printf("provision: converge steering on %s: %v", rec.Host, err)
+ } else {
+ log.Printf("provision: converged %s steering %q -> %q", rec.Host, existing.Steering, rec.Steering)
+ }
+ }
}
// Reconcile materializes bucket instance records into registry routes for
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index 039ba4b19..2e0478cd8 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -489,6 +489,56 @@ func TestReconcileConvergesEnvironment(t *testing.T) {
}
}
+// TestReconcileConvergesSteering: a record's steering opt-in changing after
+// mint converges onto the owned row within one pass, exactly as Channel and
+// Environment do — the console's PATCH door (Task 3) writes the record, and
+// this is the pass that has to notice.
+func TestReconcileConvergesSteering(t *testing.T) {
+ s, sqid := setup(t)
+ rec := acmeRecord(sqid)
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ reg := regWith(t)
+ cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"}
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ before := *mustGet(t, reg, "acme.go.tito.io")
+ if before.Steering != "" {
+ t.Fatalf("minted Steering = %q, want empty", before.Steering)
+ }
+
+ rec.Steering = "latency"
+ putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec))
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ after := *mustGet(t, reg, "acme.go.tito.io")
+ if after.Steering != "latency" {
+ t.Errorf("steering = %q, want latency", after.Steering)
+ }
+ // The self-review question this test also answers: an edited record
+ // converges ONLY its steering column, through SetSteering's narrow
+ // UPDATE — not a full-row Upsert that could disturb anything else.
+ after.Steering = before.Steering
+ if after != before {
+ t.Errorf("converging steering touched another column:\n before %+v\n after %+v", before, after)
+ }
+
+ // Clearing must reach the row too — same shape as Environment's clear:
+ // Upsert never runs on the owned branch, so only the narrow setter can
+ // move this column back to "".
+ rec.Steering = ""
+ putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec))
+ if err := Reconcile(ctx, s, reg, cfg); err != nil {
+ t.Fatal(err)
+ }
+ if got := mustGet(t, reg, "acme.go.tito.io").Steering; got != "" {
+ t.Errorf("steering = %q, want it cleared", got)
+ }
+}
+
// An explicit Hibernate converges; an absent one never does. Absent means
// "the record does not speak to this", and a pass that stamped its
// derivation over an operator's deliberate SetHibernate would be the silent
@@ -2184,3 +2234,31 @@ func TestShapeMismatchComparesAddrsAndHealth(t *testing.T) {
t.Fatal("equal shapes must match")
}
}
+
+// TestRouteForCarriesSteering: the mint renders the record's steering opt-in
+// onto the route, byte for byte — no derivation, unlike Hibernate/StatusNamed.
+func TestRouteForCarriesSteering(t *testing.T) {
+ rec := Record{Host: "m.example.com", App: "web", Account: "bab", Channel: "stable", Steering: "latency"}
+ if got := routeFor(rec, "/data", "seed").Steering; got != "latency" {
+ t.Fatalf("Steering = %q, want latency", got)
+ }
+ unsteered := Record{Host: "s.example.com", App: "web", Account: "bab", Channel: "stable"}
+ if got := routeFor(unsteered, "/data", "seed").Steering; got != "" {
+ t.Fatalf("Steering = %q, want empty for a record that never mentioned it", got)
+ }
+}
+
+// TestShapeMismatchComparesSteering is the adopt-side proof for Steering, the
+// same guard TestShapeMismatchComparesAddrsAndHealth already applies to the
+// upstream columns: a hand-added route whose steering disagrees with what
+// the record describes must not be silently adopted.
+func TestShapeMismatchComparesSteering(t *testing.T) {
+ a := registry.Route{Kind: "instance", Steering: "latency"}
+ b := registry.Route{Kind: "instance", Steering: ""}
+ if shapeMismatch(a, b) == "" {
+ t.Fatal("differing steering adopted silently")
+ }
+ if shapeMismatch(a, registry.Route{Kind: "instance", Steering: "latency"}) != "" {
+ t.Fatal("equal steering must match")
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 14b7be821..218d89c63 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -204,6 +204,20 @@ type Route struct {
// the built-in "ops" account.
Account string
+ // Steering is this route's DNS steering opt-in (multi-region ingress
+ // design Part 4): "" (default — today's static per-host answer) or
+ // "latency" (a Route53 latency record per armed edge, converged by the
+ // steering housekeeper duty). A route's own value, set from
+ // provision.Record.Steering via routeFor's mint and SetSteering's
+ // converge — provision.ValidSteering is the rail; this column validates
+ // nothing, the same discipline every other provision-owned column
+ // follows (SetCaps's rule: the rails live at the door).
+ //
+ // SINGLE-BOX INERT as of this column's introduction: nothing reads it
+ // yet. See provision.Record.Steering's doc comment for the task-by-task
+ // rollout that gives it effect.
+ Steering string
+
CreatedAt int64
}
@@ -362,6 +376,7 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN fleet_box TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN addrs TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN health TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN steering TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -557,7 +572,7 @@ func (r *Registry) Accounts() ([]Account, error) {
}
// routeCols is the column list every route read and the one route write
-// share — 35 names, in this exact order. scanRoute's destinations, Upsert's
+// share — 36 names, in this exact order. scanRoute's destinations, Upsert's
// placeholder row and Upsert's argument list all count from here: adding a
// column means adding it in FOUR places, and a miscount is a silent column
// shift rather than an error (TestUpsertRoundTripsAllColumns is what catches
@@ -566,7 +581,7 @@ const routeCols = `host, slug, kind, socket, addr, status, last_error, db_path,
version, prev_version, app, pool, region, channel, hibernate, canary,
replication, next_due, backing, status_named, platform, dns_status,
dns_checked_at, cert_expires_at, account, environment, caps, tunnel,
-provisioned, seed, unit, fleet_box, addrs, health, created_at`
+provisioned, seed, unit, fleet_box, addrs, health, steering, created_at`
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
@@ -576,7 +591,7 @@ func scanRoute(scan func(...any) error) (Route, error) {
&t.Region, &t.Channel, &hib, &can, &t.Replication, &t.NextDue, &t.Backing,
&sn, &plat, &t.DNSStatus, &t.DNSCheckedAt, &t.CertExpiresAt, &t.Account,
&t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.Unit, &t.FleetBox,
- &t.Addrs, &t.Health, &t.CreatedAt)
+ &t.Addrs, &t.Health, &t.Steering, &t.CreatedAt)
t.Hibernate = hib != 0
t.Canary = can != 0
t.StatusNamed = sn != 0
@@ -694,6 +709,14 @@ func (r *Registry) Delete(host string) error {
// own) are copied onto the literal before this call, the same
// preserve-what-you-don't-manage shape as the CASE columns above, just done
// by the caller instead of the ON CONFLICT clause.
+//
+// `steering` (multi-region ingress design Part 4) joins `addrs`/`health` as a
+// PLAIN overwrite, for the identical reason: provision.routeFor now carries
+// Record.Steering onto every route it mints, the owned-route branch converges
+// it through SetSteering rather than a full-row Upsert, and `carlos add`
+// carries no `-steering` flag at all — there is no caller that could reach
+// this Upsert with an unrelated Route literal and a stale zero value for a
+// column an operator meant to keep.
func (r *Registry) Upsert(t Route) error {
if t.CreatedAt == 0 {
t.CreatedAt = time.Now().Unix()
@@ -712,7 +735,7 @@ func (r *Registry) Upsert(t Route) error {
}
_, err := r.sql.Exec(`
INSERT INTO routes (`+routeCols+`)
-VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(host) DO UPDATE SET
slug=excluded.slug, kind=excluded.kind, socket=excluded.socket,
addr=excluded.addr, status=excluded.status, last_error=excluded.last_error,
@@ -732,13 +755,14 @@ ON CONFLICT(host) DO UPDATE SET
seed=CASE WHEN excluded.seed = '' THEN routes.seed ELSE excluded.seed END,
unit=CASE WHEN excluded.unit = '' THEN routes.unit ELSE excluded.unit END,
fleet_box=CASE WHEN excluded.fleet_box = '' THEN routes.fleet_box ELSE excluded.fleet_box END,
- addrs=excluded.addrs, health=excluded.health`,
+ addrs=excluded.addrs, health=excluded.health, steering=excluded.steering`,
t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError,
t.DBPath, t.Version, t.PrevVersion, t.App, t.Pool, t.Region, t.Channel,
b2i(t.Hibernate), b2i(t.Canary), t.Replication, t.NextDue, t.Backing,
b2i(t.StatusNamed), b2i(t.Platform), t.DNSStatus, t.DNSCheckedAt,
t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel,
- b2i(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, t.CreatedAt)
+ b2i(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health,
+ t.Steering, t.CreatedAt)
return err
}
@@ -992,6 +1016,38 @@ func (r *Registry) SetUpstreams(host, addr, addrs, health string) error {
return nil
}
+// SetSteering repoints a route's DNS steering opt-in ("" or "latency") as a
+// narrow UPDATE — same rationale as SetUpstreams: a read-modify-write Upsert
+// racing a deploy's SetVersions or the activator's SetNextDue would clobber
+// other columns, and this is how the provision reconcile pass converges
+// steering independently of everything else a full-row Upsert would touch.
+// Unlike SetUpstreams this column is single — steering is one fact, not
+// three columns describing one — so there is nothing here for a second
+// argument to move together with.
+//
+// Value validation is NOT this door's job: the rail is
+// provision.ValidSteering, run before a value ever reaches a record, the
+// same discipline SetUpstreams follows for its own columns (the rails live
+// at the provision pass's door, per SetCaps's rule).
+//
+// Errors on an unmatched host, the SetChannel/SetUpstreams contract: a
+// reconcile pass that has just read the row must notice it vanishing
+// underneath, not silently do nothing.
+func (r *Registry) SetSteering(host, steering string) error {
+ res, err := r.sql.Exec(`UPDATE routes SET steering = ? WHERE host = ?`, steering, host)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 0 {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ return nil
+}
+
// SetChannels repoints every host in hosts to channel as ONE transaction —
// every row moves or none do. `carlos route -app` uses this for its bulk
// form: the caller runs every route's safety-rail check to completion FIRST
@@ -1524,6 +1580,18 @@ func (r *Registry) DeleteWildcardDomain(host string) error {
// "box-a"/"box-b"), which is precisely the blind spot hashing the values
// closes.
//
+// `steering` is hashed too (multi-region ingress design Part 4, plan Task 2):
+// it is a route's own member-facing routing opt-in, set through the same
+// console PATCH door channel/environment go through, and it must move the
+// digest for the identical reason those two do — a value that reaches the
+// bucket and the registry row but never trips the edge's own reload poll
+// stays invisible until an unrelated column happens to change too, exactly
+// the failure class db_path (#69) and addrs/health (#152) above were added
+// to close. Task 2 wires no edge consumer of this column yet — the field is
+// SINGLE-BOX INERT until Tasks 3-6 — but the reload signal is cheap to carry
+// now rather than teach Fingerprint about a column after something depends
+// on it moving.
+//
// The fleets and fleet_boxes tables are hashed into the SAME digest, each
// under its own section marker so a value can never migrate across sections
// and cancel a change out. That is what lets the edge's Watch poll
@@ -1561,7 +1629,7 @@ func (r *Registry) Fingerprint() (string, error) {
}
rows, err := db.Query(`
SELECT rowid, status, socket, addr, version, hibernate, channel, backing, tunnel,
- db_path, region, fleet_box, addrs, health
+ db_path, region, fleet_box, addrs, health, steering
FROM routes ORDER BY rowid`)
if err != nil {
return "", err
@@ -1571,14 +1639,14 @@ FROM routes ORDER BY rowid`)
n := 0
for rows.Next() {
var rowid, hibernate int64
- var status, socket, addr, version, channel, backing, tunnel, dbPath, region, fleetBox, addrs, health string
+ var status, socket, addr, version, channel, backing, tunnel, dbPath, region, fleetBox, addrs, health, steering string
if err := rows.Scan(&rowid, &status, &socket, &addr, &version, &hibernate,
- &channel, &backing, &tunnel, &dbPath, &region, &fleetBox, &addrs, &health); err != nil {
+ &channel, &backing, &tunnel, &dbPath, &region, &fleetBox, &addrs, &health, &steering); err != nil {
return "", err
}
- fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e",
+ fmt.Fprintf(h, "%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%d\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1e",
rowid, status, socket, addr, version, hibernate, channel, backing, tunnel,
- dbPath, region, fleetBox, addrs, health)
+ dbPath, region, fleetBox, addrs, health, steering)
n++
}
if err := rows.Err(); err != nil {
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index fdef66c1a..dbf68f355 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -76,6 +76,7 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) {
Environment: "demo", Caps: "host-role,port:25",
Hibernate: true, Canary: true, Replication: "batched",
NextDue: 4200, Backing: BackingUnit, Unit: "console.service",
+ Steering: "latency",
CreatedAt: 1700000000,
}
if err := reg.Upsert(want); err != nil {
@@ -205,6 +206,91 @@ func TestSetUpstreamsErrorsOnUnmatchedHost(t *testing.T) {
}
}
+// TestSetSteeringRoundTrips: the setter writes exactly the value given and
+// nothing else — a single-column UPDATE, unlike SetUpstreams' three.
+func TestSetSteeringRoundTrips(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "m.example.com", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetSteering("m.example.com", "latency"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("m.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Steering != "latency" {
+ t.Fatalf("Steering = %q, want latency", got.Steering)
+ }
+ // And clearing: "" is a legitimate write, the column's only "off" value.
+ if err := reg.SetSteering("m.example.com", ""); err != nil {
+ t.Fatal(err)
+ }
+ if got, err = reg.Get("m.example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if got.Steering != "" {
+ t.Fatalf("Steering = %q after clearing, want empty", got.Steering)
+ }
+}
+
+func TestSetSteeringErrorsOnUnmatchedHost(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.SetSteering("nope.example.com", "latency"); err == nil {
+ t.Fatal("SetSteering on an unmatched host: want error, got nil")
+ }
+}
+
+// TestFingerprintSeesSteeringChange and TestSnapshotFingerprintSeesSteeringChange
+// are the plan's own demand: "Fingerprint AND SnapshotFingerprint change when
+// steering changes (pin, don't assume routeCols coverage)". SnapshotFingerprint
+// hashes routeCols generically (its own doc comment: "a column added to
+// routeCols is covered here automatically"), but Fingerprint's SELECT is a
+// hand-typed subset — nothing enforces that steering ever lands in it besides
+// this test.
+func TestFingerprintSeesSteeringChange(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "m.example.com", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ fp1, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetSteering("m.example.com", "latency"); err != nil {
+ t.Fatal(err)
+ }
+ fp2, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fp1 == fp2 {
+ t.Fatal("steering change invisible to Fingerprint — the edge would never notice a steering opt-in move")
+ }
+}
+
+func TestSnapshotFingerprintSeesSteeringChange(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "m.example.com", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ fp1, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetSteering("m.example.com", "latency"); err != nil {
+ t.Fatal(err)
+ }
+ fp2, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fp1 == fp2 {
+ t.Fatal("steering change invisible to SnapshotFingerprint — a cold-standby restore would silently drop it")
+ }
+}
+
func TestGetMissingIsNilNil(t *testing.T) {
reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
if err != nil {