carlos / platform Public

zdd-5 task 2: good build and serving build registry columns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a44e05e75df114bcf28ac3bb20b9d5696f381f3e parent 5bf25d5
3 files changed, +213 −11
  • internal/registry/coldhandover.go +1 −1
  • internal/registry/registry.go +104 −10
  • internal/registry/registry_test.go +108 −0
diff --git a/internal/registry/coldhandover.go b/internal/registry/coldhandover.go
index 6e832076a..b41a2382d 100644
--- a/internal/registry/coldhandover.go
+++ b/internal/registry/coldhandover.go
@@ -102,7 +102,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}
+ 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}
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 417f82818..64d1cee5a 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -75,6 +75,21 @@ type Route struct {
DBPath string
Version string
PrevVersion string
+ // GoodVersion, GoodDigest and GoodDemand are the good build (zero-downtime
+ // spec §8.5): the last build that served for this instance after passing
+ // its health check, the sha256 of its staged executable, and the
+ // release's own `swap` demand as verified then. Separate from
+ // PrevVersion, which records install history whether or not the build
+ // ever ran. Written only by SetGoodBuild, with an expected-current
+ // condition; Upsert inserts them on a new row and never changes them on
+ // an existing one.
+ GoodVersion string
+ GoodDigest string
+ GoodDemand string
+ // ServingVersion is the build of the generation that last took new
+ // connections (spec §3), written by the agent for status and the CLI.
+ // It is not cleared when the instance sleeps.
+ ServingVersion string
App string // which app's releases this route runs
Pool string // placement pool ("" = shared)
@@ -547,6 +562,10 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN replicas TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN replica INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE routes ADD COLUMN hold_op TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN good_version TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN good_digest TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN good_demand TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN serving_version TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -793,7 +812,7 @@ func (r *Registry) Accounts() ([]Account, error) {
}
// routeCols is the column list every route read and the one route write
-// share — 47 names, in this exact order. scanRoute's destinations, Upsert's
+// 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
@@ -803,7 +822,8 @@ 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`
+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`
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
@@ -814,7 +834,8 @@ func scanRoute(scan func(...any) error) (Route, error) {
&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.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.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.Hibernate = hib != 0
t.Canary = can != 0
t.StatusNamed = sn != 0
@@ -999,6 +1020,11 @@ func (r *Registry) Delete(host string) error {
// steering housekeeper duty (Task 6) is armed would silently clear a route's
// Route53 latency records — a real incident this repo caught in review before
// it shipped, not a hypothetical.
+// `good_version`, `good_digest`, `good_demand` and `serving_version`
+// (zero-downtime spec §8.5) are absent from the ON CONFLICT clause on
+// purpose: an existing row keeps them whatever the incoming Route carries.
+// They have one writer each (SetGoodBuild, SetServingVersion), and no
+// full-row caller knows what an instance last served.
func (r *Registry) Upsert(t Route) error {
if t.CreatedAt == 0 {
t.CreatedAt = time.Now().Unix()
@@ -1035,7 +1061,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,
@@ -1073,7 +1099,8 @@ AND routes.claim_owner=excluded.claim_owner AND routes.claim_row_id=excluded.cla
t.CertExpiresAt, t.Account, t.Environment, t.Caps, t.Tunnel,
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.Replicas, b2i(t.Replica), t.ClaimIncarnation, t.ClaimStamp, t.ClaimOwner, t.ClaimRowID, t.HoldOp,
+ t.GoodVersion, t.GoodDigest, t.GoodDemand, t.ServingVersion)
if err != nil {
return err
}
@@ -1993,6 +2020,55 @@ func (r *Registry) SetVersions(host, ver, prev string) error {
return err
}
+// GoodBuild is the good-build triple of zero-downtime spec §8.5.
+type GoodBuild struct{ Version, Digest, Demand string }
+
+// Good returns the route's good-build triple.
+func (t Route) Good() GoodBuild {
+ return GoodBuild{Version: t.GoodVersion, Digest: t.GoodDigest, Demand: t.GoodDemand}
+}
+
+// ErrGoodBuildChanged is SetGoodBuild's answer when the row no longer holds
+// the triple the caller expected to replace.
+var ErrGoodBuildChanged = errors.New("registry: good build changed underneath this write")
+
+// SetGoodBuild replaces host's good build with next, but only if the row
+// still holds expect (spec §8.5: "written with an expected-current
+// condition"). Two writers that each saw a different generation become
+// current cannot both win; the loser learns what the row holds now.
+func (r *Registry) SetGoodBuild(host string, expect, next GoodBuild) error {
+ res, err := r.sql.Exec(`UPDATE routes SET good_version = ?, good_digest = ?, good_demand = ?
+WHERE host = ? AND good_version = ? AND good_digest = ? AND good_demand = ?`,
+ next.Version, next.Digest, next.Demand, host, expect.Version, expect.Digest, expect.Demand)
+ if err != nil {
+ return err
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if n == 1 {
+ return nil
+ }
+ var cur GoodBuild
+ serr := r.sql.QueryRow(`SELECT good_version, good_digest, good_demand FROM routes WHERE host = ?`, host).
+ Scan(&cur.Version, &cur.Digest, &cur.Demand)
+ if serr == sql.ErrNoRows {
+ return fmt.Errorf("no route for host %q", host)
+ }
+ if serr != nil {
+ return serr
+ }
+ return fmt.Errorf("%w: %s now holds %+v", ErrGoodBuildChanged, host, cur)
+}
+
+// SetServingVersion records the build of the generation that just became
+// current (spec §3), as a narrow UPDATE.
+func (r *Registry) SetServingVersion(host, ver string) error {
+ _, err := r.sql.Exec(`UPDATE routes SET serving_version = ? WHERE host = ?`, ver, host)
+ return err
+}
+
// SetHibernate flips the per-host hibernation opt-in as a narrow UPDATE.
// Turning hibernation OFF also clears `replication`, and that is the point
// of doing it here rather than in each caller. The replication class is read
@@ -2665,9 +2741,9 @@ FROM route_paths ORDER BY host, prefix`)
// 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
+// "Every column" has exactly three exceptions, snapshotChurnCols: the
+// scheduling stamps (and the serving_version rollout stamp) 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
@@ -2691,8 +2767,9 @@ FROM route_paths ORDER BY host, prefix`)
// 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)
+ "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)
}
// snapshotClaimCols are the ownership columns, hashed only when non-default.
@@ -2703,6 +2780,17 @@ var snapshotClaimCols = map[string]bool{
"claim_row_id": true,
}
+// snapshotWhenSetCols are the good-build columns (zero-downtime spec §8.5).
+// A restore has to reproduce them, so they are hashed, but only when set and
+// tagged, on the hold_op rule: a registry that has never recorded a good
+// build digests exactly as it did before the columns existed, so upgrading
+// does not force a snapshot on every box.
+var snapshotWhenSetCols = map[string]bool{
+ "good_version": true,
+ "good_digest": true,
+ "good_demand": true,
+}
+
func (r *Registry) SnapshotFingerprint() (string, error) {
db, err := sql.Open("sqlite", r.path)
if err != nil {
@@ -2753,6 +2841,12 @@ func (r *Registry) SnapshotFingerprint() (string, error) {
}
continue
}
+ if snapshotWhenSetCols[cols[i]] {
+ if v.Valid && v.String != "" {
+ fmt.Fprintf(h, "%s=%q\x1f", cols[i], v.String)
+ }
+ 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 b479ca6e1..baaa3a116 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -2831,3 +2831,111 @@ func TestSetInstancePathsIsACompareAndSwapOnBothColumns(t *testing.T) {
t.Fatalf("absent host = %v, want a no-route error", err)
}
}
+
+// The good build (zero-downtime spec §8.5) has exactly one writer, the agent,
+// and every write states what it expects to replace. A full-row Upsert from a
+// caller that knows nothing about rollouts (carlos add, a re-mint) must never
+// clear it: a lost good build is an instance with nothing to fall back to.
+func TestGoodBuildIsWrittenOnlyByItsOwnDoor(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ const host = "a.example.com"
+ if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "blog", Version: "v1"}); err != nil {
+ t.Fatal(err)
+ }
+ g1 := GoodBuild{Version: "v1", Digest: "aa11"}
+ if err := reg.SetGoodBuild(host, GoodBuild{}, g1); err != nil {
+ t.Fatalf("first write: %v", err)
+ }
+ err = reg.SetGoodBuild(host, GoodBuild{}, GoodBuild{Version: "v2", Digest: "bb22"})
+ if !errors.Is(err, ErrGoodBuildChanged) {
+ t.Fatalf("stale expectation: err = %v, want ErrGoodBuildChanged", err)
+ }
+ if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "blog", Version: "v2"}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get(host)
+ if err != nil || got == nil {
+ t.Fatalf("Get: %v, %v", got, err)
+ }
+ if got.Good() != g1 {
+ t.Fatalf("Upsert changed the good build to %+v, want %+v", got.Good(), g1)
+ }
+ if got.Version != "v2" {
+ t.Fatalf("Version = %q, want v2", got.Version)
+ }
+ g2 := GoodBuild{Version: "v2", Digest: "bb22", Demand: "hold"}
+ if err := reg.SetGoodBuild(host, g1, g2); err != nil {
+ t.Fatal(err)
+ }
+ routes, err := reg.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if routes[0].Good() != g2 {
+ t.Fatalf("List reads %+v, want %+v", routes[0].Good(), g2)
+ }
+ if err := reg.SetGoodBuild("nope.example.com", GoodBuild{}, g1); err == nil {
+ t.Fatal("a missing row must be an error, not a silent no-op")
+ }
+}
+
+// serving_version moves on every wake and swap, with no operator change
+// behind it, so it must not trigger a registry snapshot (#207's rule).
+func TestServingVersionIsChurnForTheSnapshotGate(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ const host = "a.example.com"
+ if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "blog"}); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetServingVersion(host, "v9"); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before != after {
+ t.Fatal("SetServingVersion moved the snapshot fingerprint")
+ }
+ got, _ := reg.Get(host)
+ if got.ServingVersion != "v9" {
+ t.Fatalf("ServingVersion = %q, want v9", got.ServingVersion)
+ }
+}
+
+// The good build is restore state, so a change to it must move the snapshot
+// fingerprint, even though a registry that never set one hashes as before.
+func TestGoodBuildMovesTheSnapshotFingerprint(t *testing.T) {
+ reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ const host = "a.example.com"
+ if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "blog"}); err != nil {
+ t.Fatal(err)
+ }
+ before, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetGoodBuild(host, GoodBuild{}, GoodBuild{Version: "v1", Digest: "aa11"}); err != nil {
+ t.Fatal(err)
+ }
+ after, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if before == after {
+ t.Fatal("a new good build did not move the snapshot fingerprint")
+ }
+}