settings-4 task 3: the registry's build_state column
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+200
−12
internal/registry/buildstate_test.go+146 −0internal/registry/coldhandover.go+1 −1internal/registry/registry.go+49 −11internal/registry/registry_test.go+1 −0internal/registry/remote_dispatch.go+3 −0
diff --git a/internal/registry/buildstate_test.go b/internal/registry/buildstate_test.go| new file mode 100644 |
| index 000000000..819644036 |
| --- /dev/null |
| +++ b/internal/registry/buildstate_test.go |
| @@ -0,0 +1,146 @@ |
| +package registry |
| + |
| +import ( |
| + "context" |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +const buildStateCol = `{"known":true,"replica":true,"state":{"schema":1,"host":"a.example.com","last_executed":"v1","at":"2026-10-07T12:00:00Z"},"read_at":"2026-10-07T12:00:00Z"}` |
| + |
| +func TestBuildStateColumn(t *testing.T) { |
| + reg, err := Open(filepath.Join(t.TempDir(), "r.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt, _ := reg.Get("a.example.com") |
| + if rt.BuildState != "" { |
| + t.Fatalf("a new row has build state %q", rt.BuildState) |
| + } |
| + before, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + col := buildStateCol |
| + if err := reg.SetBuildState("a.example.com", col); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ = reg.Get("a.example.com"); rt.BuildState != col { |
| + t.Fatalf("column = %q", rt.BuildState) |
| + } |
| + after, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after != before { |
| + t.Fatal("build state changed the edge's fingerprint") |
| + } |
| + // A full-row Upsert that does not carry the column keeps it. |
| + if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ = reg.Get("a.example.com"); rt.BuildState != col { |
| + t.Fatalf("Upsert cleared the column: %q", rt.BuildState) |
| + } |
| + if err := reg.SetBuildState("a.example.com", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rt, _ = reg.Get("a.example.com"); rt.BuildState != "" { |
| + t.Fatal("the narrow setter could not clear the column") |
| + } |
| + if err := reg.SetBuildState("nobody.example.com", col); err == nil { |
| + t.Fatal("an unknown host was not an error") |
| + } |
| +} |
| + |
| +// build_state is rewritten (ReadAt) on every reconcile refresh with nothing an |
| +// operator did behind it, so it must not trigger a registry snapshot (#207). |
| +func TestBuildStateIsChurnForTheSnapshotGate(t *testing.T) { |
| + reg, err := Open(filepath.Join(t.TempDir(), "r.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + const host = "a.example.com" |
| + if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "km"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetBuildState(host, buildStateCol); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, err := reg.SnapshotFingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if before != after { |
| + t.Fatal("SetBuildState moved the snapshot fingerprint") |
| + } |
| +} |
| + |
| +// A cold handover INSERTs the whole row positionally: the column must be in |
| +// that list or every handover fails on column count, and its value must |
| +// survive. |
| +func TestBuildStateSurvivesColdHandover(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + ctx := context.Background() |
| + reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + oldOwner := ClaimOwnership{"inc", 7, "installation-7", "route-7"} |
| + newOwner := ClaimOwnership{"inc", 8, "installation-8", "route-8"} |
| + route := Route{Host: "shop.example", App: "app", ClaimOwnership: oldOwner} |
| + if err = reg.Upsert(route); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = reg.SetBuildState(route.Host, buildStateCol); err != nil { |
| + t.Fatal(err) |
| + } |
| + current, err := reg.Get(route.Host) |
| + if err != nil || current == nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + old := ColdRows{Route: *current} |
| + next := ColdRows{Route: *current} |
| + next.Route.ClaimOwnership = newOwner |
| + targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: oldOwner}} |
| + if err = reg.RetireClaimRows(ctx, "ret", targets, func([]ClaimRowArchive) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { |
| + t.Fatalf("handover refused: %v", err) |
| + } |
| + got, err := reg.Get(route.Host) |
| + if err != nil || got == nil { |
| + t.Fatalf("Get after handover: %v", err) |
| + } |
| + if got.BuildState != buildStateCol { |
| + t.Fatalf("build state after handover = %q", got.BuildState) |
| + } |
| +} |
| + |
| +// The host's registry child carries the writer across the transport. |
| +func TestRemoteSetBuildState(t *testing.T) { |
| + r, _ := remoteTestRegistry(t) |
| + const host = "remote.example.com" |
| + if err := r.Upsert(Route{Host: host, Kind: "instance", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := r.SetBuildState(host, buildStateCol); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := r.Get(host) |
| + if err != nil || got == nil || got.BuildState != buildStateCol { |
| + t.Fatalf("route = %+v, %v", got, err) |
| + } |
| + if err := r.SetBuildState("nobody.example.com", buildStateCol); err == nil { |
| + t.Fatal("an unknown host was not an error") |
| + } |
| +} |
diff --git a/internal/registry/coldhandover.go b/internal/registry/coldhandover.go| index 4d82e992d..d3514a109 100644 |
| --- a/internal/registry/coldhandover.go |
| +++ b/internal/registry/coldhandover.go |
| @@ -135,7 +135,7 @@ func (r *Registry) RestoreClaimRows(ctx context.Context, id, retirement string, |
| return ErrClaimOwnership |
| } |
| t := next.Route |
| - values := []any{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, b2iDB(t.Hibernate), b2iDB(t.Canary), t.Replication, t.NextDue, t.Backing, b2iDB(t.StatusNamed), b2iDB(t.Platform), t.DNSStatus, t.DNSCheckedAt, t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel, b2iDB(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, t.Replicas, b2iDB(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp, t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings} |
| + values := []any{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, b2iDB(t.Hibernate), b2iDB(t.Canary), t.Replication, t.NextDue, t.Backing, b2iDB(t.StatusNamed), b2iDB(t.Platform), t.DNSStatus, t.DNSCheckedAt, t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel, b2iDB(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, t.Replicas, b2iDB(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp, t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings, t.BuildState} |
| if _, err = tx.ExecContext(ctx, `INSERT INTO routes (`+routeCols+`) VALUES (`+strings.TrimSuffix(strings.Repeat("?,", len(values)), ",")+`)`, values...); err != nil { |
| return err |
| } |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index fe3457f65..0ac7f9d62 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -299,6 +299,14 @@ type Route struct { |
| // changes what root installs, not what the edge serves. |
| UpdateSettings string |
| + // BuildState is the agent's copy of the build-state object (JSON of |
| + // release.BuildStateCopy; "" = never read), refreshed at reconcile and |
| + // before every wake. Root reads it. Deliberately NOT in Fingerprint(): |
| + // it changes what root decides, not what the edge serves. The registry |
| + // holds it as an opaque string (release imports registry, not the other |
| + // way round). |
| + BuildState string |
| + |
| // Replicas is the account's replica placements, comma-joined placement |
| // codes in canonical (sorted, deduplicated) order — the registry half of |
| // #162. "" on every row that predates the column = no replicas = |
| @@ -582,6 +590,7 @@ CREATE TABLE IF NOT EXISTS routes ( |
| `ALTER TABLE routes ADD COLUMN good_demand TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN serving_version TEXT NOT NULL DEFAULT ''`, |
| `ALTER TABLE routes ADD COLUMN update_settings TEXT NOT NULL DEFAULT ''`, |
| + `ALTER TABLE routes ADD COLUMN build_state TEXT NOT NULL DEFAULT ''`, |
| } { |
| if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -848,18 +857,22 @@ func (r *Registry) Accounts() ([]Account, error) { |
| } |
| // routeCols is the column list every route read and the one route write |
| -// share — 51 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 |
| -// one). |
| +// share, 53 names, in this exact order. scanRoute's destinations, Upsert's |
| +// placeholder row and Upsert's argument list all count from here, as does |
| +// coldhandover.go's positional values list; adding a column means adding it |
| +// in SIX places: the Route struct, the migration list, routeCols with |
| +// scanRoute, Upsert (placeholders, CASE arm and arguments), |
| +// coldhandover.go's values, and remote_dispatch.go's remoteRequest and |
| +// dispatch case for its narrow setter. A miscount is a silent column shift |
| +// rather than an error (TestUpsertRoundTripsAllColumns is what catches one; |
| +// a missing cold-handover value fails the INSERT on column count). |
| 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, steering, gate, created_at, |
| args, idle_seconds, max_run_seconds, replicas, replica, claim_incarnation, claim_stamp, claim_owner, claim_row_id, hold_op, |
| -good_version, good_digest, good_demand, serving_version, update_settings` |
| +good_version, good_digest, good_demand, serving_version, update_settings, build_state` |
| func scanRoute(scan func(...any) error) (Route, error) { |
| var t Route |
| @@ -871,7 +884,7 @@ func scanRoute(scan func(...any) error) (Route, error) { |
| &t.Environment, &t.Caps, &t.Tunnel, &prov, &t.Seed, &t.Unit, &t.FleetBox, |
| &t.Addrs, &t.Health, &t.Steering, &t.Gate, &t.CreatedAt, |
| &t.Args, &t.IdleSeconds, &t.MaxRunSeconds, &t.Replicas, &rep, &t.ClaimIncarnation, &t.ClaimStamp, &t.ClaimOwner, &t.ClaimRowID, &t.HoldOp, |
| - &t.GoodVersion, &t.GoodDigest, &t.GoodDemand, &t.ServingVersion, &t.UpdateSettings) |
| + &t.GoodVersion, &t.GoodDigest, &t.GoodDemand, &t.ServingVersion, &t.UpdateSettings, &t.BuildState) |
| t.Hibernate = hib != 0 |
| t.Canary = can != 0 |
| t.StatusNamed = sn != 0 |
| @@ -1028,7 +1041,7 @@ func (r *Registry) Delete(host string) error { |
| // SetEnvironment (`carlos route -environment -`), SetCaps (`carlos route |
| // -revoke`), SetTunnel (`carlos route -tunnel -`), SetUnit (`carlos route |
| // -unit -`), SetReplication (`carlos route -replication -`), SetSeed, |
| -// SetFleetBox, SetArgs, SetWorkerLimits, SetUpdateSettings, and for |
| +// SetFleetBox, SetArgs, SetWorkerLimits, SetUpdateSettings, SetBuildState, and for |
| // `provisioned` the row's own deletion. |
| // |
| // `replication` is the one column with a SECOND clearing door, and it is not |
| @@ -1117,7 +1130,7 @@ func (r *Registry) Upsert(t Route) error { |
| } |
| res, 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, |
| @@ -1146,7 +1159,8 @@ ON CONFLICT(host) DO UPDATE SET |
| args=CASE WHEN excluded.args = '' THEN routes.args ELSE excluded.args END, |
| idle_seconds=CASE WHEN excluded.idle_seconds = 0 THEN routes.idle_seconds ELSE excluded.idle_seconds END, |
| max_run_seconds=CASE WHEN excluded.max_run_seconds = 0 THEN routes.max_run_seconds ELSE excluded.max_run_seconds END, |
| - update_settings=CASE WHEN excluded.update_settings = '' THEN routes.update_settings ELSE excluded.update_settings END |
| + update_settings=CASE WHEN excluded.update_settings = '' THEN routes.update_settings ELSE excluded.update_settings END, |
| + build_state=CASE WHEN excluded.build_state = '' THEN routes.build_state ELSE excluded.build_state END |
| WHERE routes.claim_incarnation=excluded.claim_incarnation AND routes.claim_stamp=excluded.claim_stamp |
| AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.claim_row_id`, |
| t.Host, t.Slug, t.Kind, t.Socket, t.Addr, t.Status, t.LastError, |
| @@ -1157,7 +1171,7 @@ AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.cla |
| b2i(t.Provisioned), t.Seed, t.Unit, t.FleetBox, t.Addrs, t.Health, |
| t.Steering, t.Gate, t.CreatedAt, t.Args, t.IdleSeconds, t.MaxRunSeconds, |
| t.Replicas, b2i(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp, |
| - t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings) |
| + t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion, t.UpdateSettings, t.BuildState) |
| if err != nil { |
| return err |
| } |
| @@ -1869,6 +1883,29 @@ func (r *Registry) SetUpdateSettings(host, col string) error { |
| return nil |
| } |
| +// SetBuildState stores the agent's copy of a route's build-state object as a |
| +// narrow UPDATE: reconcile and the wake path refresh it, in both directions, |
| +// including down to "". Errors on an unmatched host, like SetUpdateSettings. |
| +func (r *Registry) SetBuildState(host, col string) error { |
| + if r.remote != nil { |
| + _, err := r.remote.call(nil, remoteRequest{Method: "SetBuildState", Host: host, BuildState: col}, nil) |
| + return err |
| + } |
| + |
| + res, err := r.sql.Exec(`UPDATE routes SET build_state = ? WHERE host = ?`, col, 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 |
| +} |
| + |
| // SetReplicas repoints a route's replica placement set (#162) as a narrow |
| // UPDATE: the provision pass converges this column every tick from the |
| // record, and the record's value is authoritative in BOTH directions — |
| @@ -3131,6 +3168,7 @@ var snapshotChurnCols = map[string]bool{ |
| "next_due": true, // activator scheduling (SetNextDue, every pass) |
| "dns_checked_at": true, // DNS sweep stamp (SetDNSStatus, every sweep) |
| "serving_version": true, // rollout stamp (SetServingVersion, every wake and swap) |
| + "build_state": true, // the agent's build-state copy (SetBuildState, ReadAt moves every refresh) |
| } |
| // snapshotClaimCols are the ownership columns, hashed only when non-default. |
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go| index ca70219d2..089a512d4 100644 |
| --- a/internal/registry/registry_test.go |
| +++ b/internal/registry/registry_test.go |
| @@ -84,6 +84,7 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) { |
| GoodVersion: "good789", GoodDigest: "sha256:good", GoodDemand: "swap-demand", |
| ServingVersion: "serve012", |
| UpdateSettings: `{"updates":"manual","channel":"stable"}`, |
| + BuildState: `{"known":true,"state":{"schema":1,"last_executed":"bs345"}}`, |
| } |
| if err := reg.Upsert(want); err != nil { |
| t.Fatal(err) |
diff --git a/internal/registry/remote_dispatch.go b/internal/registry/remote_dispatch.go| index 9d84778b3..dcfdfa556 100644 |
| --- a/internal/registry/remote_dispatch.go |
| +++ b/internal/registry/remote_dispatch.go |
| @@ -53,6 +53,7 @@ type remoteRequest struct { |
| Health string |
| Steering string |
| UpdateSettings string |
| + BuildState string |
| Replicas string |
| Replica bool |
| Hash string |
| @@ -212,6 +213,8 @@ func (r *Registry) dispatchRemote(ctx context.Context, q remoteRequest, callback |
| err = r.SetSteering(q.Host, q.Steering) |
| case "SetUpdateSettings": |
| err = r.SetUpdateSettings(q.Host, q.UpdateSettings) |
| + case "SetBuildState": |
| + err = r.SetBuildState(q.Host, q.BuildState) |
| case "SetReplicas": |
| err = r.SetReplicas(q.Host, q.Replicas) |
| case "SetReplica": |