carlos / platform Public

edge asset cache task 4: record fields, route columns and convergence

Record gains Assets (*bool) and AssetGen with AssetsOn and NewAssetGen.
The registry gains assets_off and asset_gen route columns, SetAssets, and
Fingerprint coverage (folded in only when set). RouteFor carries both and
convergeOwned converges them as one pair.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev bcb0800fb1caa13c11b8d023eb65c7403bd92c89 parent 38819c5
7 files changed, +443 −10
  • internal/provision/provision.go +33 −0
  • internal/provision/provision_test.go +52 −1
  • internal/provision/reconcile.go +16 −0
  • internal/provision/reconcile_test.go +98 −0
  • internal/registry/registry.go +93 −9
  • internal/registry/registry_test.go +147 −0
  • internal/registry/remote_dispatch.go +4 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index a5657b0cb..7fa7a23be 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -8,6 +8,7 @@ package provision
import (
"context"
+ "crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -156,6 +157,20 @@ type Record struct {
// even then only once a deployment arms CARLOS_STEERING_ZONE_ID.
Steering string `json:"steering,omitempty"`
+ // --- asset cache fields (2026-10-24, edge asset cache design 3.2, 6) ---
+
+ // Assets is the instance's asset-cache opt-out: absent means "follow
+ // the release", false means "catch nothing for this instance". A
+ // POINTER for Hibernate's reason: this column is CONVERGED, and absent
+ // must not read as an explicit false. AssetsOn is the accessor.
+ Assets *bool `json:"assets,omitempty"`
+ // AssetGen is the instance's asset generation: 16 lowercase hex from
+ // NewAssetGen, drawn at create and redrawn by a purge. Every edge cache
+ // key carries it, so a new one orphans the old entries at once. Empty
+ // (a record from before the field) publishes nothing. Converged onto
+ // the route row beside Assets.
+ AssetGen string `json:"asset_gen,omitempty"`
+
// Replica is NOT a separate record field: Role carries the role
// vocabulary alone (ValidRole's door), and every reader derives the
// boolean from it. A second spelling of the same fact is two chances
@@ -462,6 +477,24 @@ func (r Record) StatusNamedOrDefault() bool {
return false
}
+// AssetsOn reports whether this instance follows the release's asset
+// declaration: true unless Assets is present and false.
+func (r Record) AssetsOn() bool {
+ return r.Assets == nil || *r.Assets
+}
+
+// NewAssetGen draws an asset generation: 64 random bits as 16 lowercase hex.
+// Random rather than a counter, so a hostname deleted and recreated can never
+// land back on a generation an edge still holds entries for (design section
+// 6).
+func NewAssetGen() (string, error) {
+ var b [8]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return "", fmt.Errorf("draw asset generation: %w", err)
+ }
+ return hex.EncodeToString(b[:]), nil
+}
+
// ValidKind pins the record's route-class vocabulary to the set `carlos
// add` documents. "status" is deliberately absent: the fleet status page's
// route is the platform's own, not an app's, and nothing should be able to
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go
index 596506be4..f3e8300d2 100644
--- a/internal/provision/provision_test.go
+++ b/internal/provision/provision_test.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"os"
+ "regexp"
"strings"
"testing"
"time"
@@ -49,6 +50,34 @@ func TestRecordWireShapeIsAdditive(t *testing.T) {
if rec.Hibernate != nil {
t.Errorf("Hibernate = %v, want nil — absent and false are different facts here", rec.Hibernate)
}
+ if rec.Assets != nil {
+ t.Errorf("Assets = %v, want nil — absent and false are different facts here", rec.Assets)
+ }
+ if rec.AssetGen != "" {
+ t.Errorf("AssetGen = %q, want empty — a record from before the field names no generation", rec.AssetGen)
+ }
+ if !rec.AssetsOn() {
+ t.Error("AssetsOn() = false, want true — absent means follow the release")
+ }
+ off := false
+ if (Record{Assets: &off}).AssetsOn() {
+ t.Error("AssetsOn() = true for an explicit false, want false")
+ }
+ on := true
+ if !(Record{Assets: &on}).AssetsOn() {
+ t.Error("AssetsOn() = false for an explicit true, want true")
+ }
+ // A legacy record round-tripped through the new struct marshals without
+ // either new key.
+ body, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, key := range []string{"assets", "asset_gen"} {
+ if strings.Contains(string(body), `"`+key+`"`) {
+ t.Errorf("legacy record marshals %q when it was never set: %s", key, body)
+ }
+ }
if got := rec.KindOrDefault(); got != "instance" {
t.Errorf("KindOrDefault() = %q, want instance", got)
}
@@ -72,7 +101,7 @@ func TestRecordMarshalOmitsAbsentShapeFields(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named", "steering"} {
+ for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named", "steering", "assets", "asset_gen"} {
if strings.Contains(string(body), `"`+key+`"`) {
t.Errorf("marshalled record carries %q when it was never set: %s", key, body)
}
@@ -1420,3 +1449,25 @@ func TestListRecordsStrictFailsOnAnyReadFailure(t *testing.T) {
t.Fatalf("ListRecordsStrict over a failed listing = %+v, %v, %v; want an error", recs, unreadable, err)
}
}
+
+// TestNewAssetGenShape pins the asset generation token (edge asset cache
+// design section 6): 64 random bits as 16 lowercase hex, a fresh one per call.
+func TestNewAssetGenShape(t *testing.T) {
+ re := regexp.MustCompile(`^[0-9a-f]{16}$`)
+ a, err := NewAssetGen()
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := NewAssetGen()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, g := range []string{a, b} {
+ if !re.MatchString(g) {
+ t.Errorf("NewAssetGen() = %q, want 16 lowercase hex", g)
+ }
+ }
+ if a == b {
+ t.Errorf("two NewAssetGen() calls returned %q", a)
+ }
+}
diff --git a/internal/provision/reconcile.go b/internal/provision/reconcile.go
index 31945adc1..35d871fac 100644
--- a/internal/provision/reconcile.go
+++ b/internal/provision/reconcile.go
@@ -440,6 +440,8 @@ func RouteFor(rec Record, dataDir, seed string, nested bool, policy *UpdatePolic
Channel: rec.Channel, Environment: rec.Environment,
Hibernate: rec.HibernateOrDefault(), StatusNamed: rec.StatusNamedOrDefault(),
Steering: rec.Steering,
+ AssetsOff: !rec.AssetsOn(),
+ AssetGen: rec.AssetGen,
Replicas: replicaSet(rec.Replicas),
Replica: rec.Role == "replica",
Gate: rec.Gate,
@@ -760,6 +762,20 @@ func convergeOwned(reg *registry.Registry, existing registry.Route, rec Record,
log.Printf("provision: converged %s steering %q -> %q", rec.Host, existing.Steering, rec.Steering)
}
}
+ // The asset opt-out and generation converge as one pair, both ways,
+ // from the record: SetAssets writes the two columns in one UPDATE, so
+ // the row never carries a new generation under the old opt-out.
+ if want := !rec.AssetsOn(); existing.AssetsOff != want || existing.AssetGen != rec.AssetGen {
+ if dryRun {
+ log.Printf("provision: dry-run: would converge %s assets off %v -> %v, generation %q -> %q",
+ rec.Host, existing.AssetsOff, want, existing.AssetGen, rec.AssetGen)
+ } else if err := reg.SetAssets(rec.Host, want, rec.AssetGen); err != nil {
+ log.Printf("provision: converge assets on %s: %v", rec.Host, err)
+ } else {
+ log.Printf("provision: converged %s assets off %v -> %v, generation %q -> %q",
+ rec.Host, existing.AssetsOff, want, existing.AssetGen, rec.AssetGen)
+ }
+ }
// Replicas CONVERGES, where steering merely repoints: the bucket's value
// is authoritative in BOTH directions, including down to none — a set
// that shrank must actually shrink on the row, or the edges file goes on
diff --git a/internal/provision/reconcile_test.go b/internal/provision/reconcile_test.go
index 6c148437d..53e01a789 100644
--- a/internal/provision/reconcile_test.go
+++ b/internal/provision/reconcile_test.go
@@ -3680,3 +3680,101 @@ func TestListAllRecordsStrictKeepsFailingClosed(t *testing.T) {
t.Fatalf("a record that will not Get = %+v, %v, %v; want an error and nothing else", recs, unreadable, err)
}
}
+
+// TestRouteForCarriesAssets: the mint renders the record's opt-out and
+// generation onto the route. An absent Assets follows the release (AssetsOff
+// false); only an explicit false switches the route off.
+func TestRouteForCarriesAssets(t *testing.T) {
+ off := false
+ on := true
+ rec := Record{Host: "shop.example.com", App: "web", Account: "abc", Channel: "stable", Assets: &off, AssetGen: "0123456789abcdef"}
+ got := RouteFor(rec, "/data", "seed", false, nil)
+ if !got.AssetsOff || got.AssetGen != "0123456789abcdef" {
+ t.Fatalf("AssetsOff = %v, AssetGen = %q, want true and the record's token", got.AssetsOff, got.AssetGen)
+ }
+ for name, a := range map[string]*bool{"absent": nil, "true": &on} {
+ rec := Record{Host: "shop.example.com", App: "web", Account: "abc", Channel: "stable", Assets: a}
+ got := RouteFor(rec, "/data", "seed", false, nil)
+ if got.AssetsOff || got.AssetGen != "" {
+ t.Errorf("%s: AssetsOff = %v, AssetGen = %q, want false and empty", name, got.AssetsOff, got.AssetGen)
+ }
+ }
+}
+
+// TestReconcileConvergesAssets: a record whose AssetGen or opt-out changes
+// after mint converges onto the owned row within one pass through
+// convergeOwned, touching only the two asset columns, in both directions.
+func TestReconcileConvergesAssets(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.AssetsOff || before.AssetGen != "" {
+ t.Fatalf("minted AssetsOff = %v, AssetGen = %q, want false and empty", before.AssetsOff, before.AssetGen)
+ }
+
+ rec.AssetGen = "0123456789abcdef"
+ 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.AssetGen != "0123456789abcdef" || after.AssetsOff {
+ t.Errorf("AssetGen = %q, AssetsOff = %v, want the new token and false", after.AssetGen, after.AssetsOff)
+ }
+ after.AssetGen = before.AssetGen
+ if after != before {
+ t.Errorf("converging the asset generation touched another column:\n before %+v\n after %+v", before, after)
+ }
+
+ // A purge: a fresh token replaces the old one.
+ rec.AssetGen = "fedcba9876543210"
+ 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").AssetGen; got != "fedcba9876543210" {
+ t.Errorf("AssetGen = %q after a purge, want the fresh token", got)
+ }
+
+ // The opt-out reaches the row, and clearing it reaches the row too.
+ off := false
+ rec.Assets = &off
+ 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"); !got.AssetsOff || got.AssetGen != "fedcba9876543210" {
+ t.Errorf("AssetsOff = %v, AssetGen = %q, want true and the token kept", got.AssetsOff, got.AssetGen)
+ }
+ rec.Assets = nil
+ rec.AssetGen = ""
+ 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"); got.AssetsOff || got.AssetGen != "" {
+ t.Errorf("AssetsOff = %v, AssetGen = %q, want both cleared", got.AssetsOff, got.AssetGen)
+ }
+}
+
+// TestShapeMismatchIgnoresAssets: the asset columns are record-owned, so a
+// hand-added route that disagrees on them is still adopted, and
+// convergeOwned stamps the record's values on afterwards.
+func TestShapeMismatchIgnoresAssets(t *testing.T) {
+ a := registry.Route{Kind: "instance", AssetsOff: true, AssetGen: "0123456789abcdef"}
+ b := registry.Route{Kind: "instance"}
+ if shapeMismatch(a, b) != "" {
+ t.Fatalf("differing asset columns blocked adoption: %q, want a match", shapeMismatch(a, b))
+ }
+ if shapeMismatch(b, a) != "" {
+ t.Fatalf("differing asset columns blocked adoption: %q, want a match", shapeMismatch(b, a))
+ }
+}
diff --git a/internal/registry/registry.go b/internal/registry/registry.go
index 4447cc982..ffa2cf304 100644
--- a/internal/registry/registry.go
+++ b/internal/registry/registry.go
@@ -87,6 +87,23 @@ type Route struct {
// In Fingerprint (the edge must reload on a change), folded only when
// non-empty.
ImportGate string
+ // AssetsOff is the instance's asset-cache opt-out (edge asset cache
+ // design section 3.2): true means the home box publishes no asset
+ // prefixes for this host. Inverted from the record's Assets *bool so
+ // the zero value is the default, "follow the release". Converged from
+ // provision.Record.Assets by RouteFor and convergeOwned, together with
+ // AssetGen through SetAssets. IN Fingerprint (unlike Steering): it
+ // changes what the home box publishes, so the edge must reload on it,
+ // folded in only when set so a registry that never used it digests as
+ // it did before the column existed.
+ AssetsOff bool
+ // AssetGen is the instance's asset generation, 16 lowercase hex
+ // (provision.NewAssetGen), "" for a record from before the field. A
+ // purge draws a fresh one; every cache key under the old one stops
+ // matching. Empty publishes nothing. Converged from
+ // provision.Record.AssetGen. IN Fingerprint, folded in only when set,
+ // for AssetsOff's reason.
+ AssetGen string
LastError string
DBPath string
@@ -609,6 +626,8 @@ CREATE TABLE IF NOT EXISTS routes (
`ALTER TABLE routes ADD COLUMN build_state TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN seed_source TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE routes ADD COLUMN import_gate TEXT NOT NULL DEFAULT ''`,
+ `ALTER TABLE routes ADD COLUMN assets_off INTEGER NOT NULL DEFAULT 0`,
+ `ALTER TABLE routes ADD COLUMN asset_gen TEXT NOT NULL DEFAULT ''`,
} {
if _, err := r.sql.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
@@ -897,10 +916,10 @@ 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, build_state,
-seed_source, import_gate`
+seed_source, import_gate, assets_off, asset_gen`
// routeColCount is the number of names in routeCols.
-const routeColCount = 55
+const routeColCount = 57
// placeholders is n comma-joined bind parameters.
func placeholders(n int) string {
@@ -909,7 +928,7 @@ func placeholders(n int) string {
func scanRoute(scan func(...any) error) (Route, error) {
var t Route
- var hib, can, sn, plat, prov, rep int
+ var hib, can, sn, plat, prov, rep, assetsOff int
err := scan(&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, &hib, &can, &t.Replication, &t.NextDue, &t.Backing,
@@ -918,7 +937,8 @@ func scanRoute(scan func(...any) error) (Route, error) {
&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.BuildState,
- &t.SeedSource, &t.ImportGate)
+ &t.SeedSource, &t.ImportGate, &assetsOff, &t.AssetGen)
+ t.AssetsOff = assetsOff != 0
t.Hibernate = hib != 0
t.Canary = can != 0
t.StatusNamed = sn != 0
@@ -1118,6 +1138,10 @@ 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.
+// `assets_off` and `asset_gen` travel as one pair: an incoming literal that
+// names a generation replaces both, one that names none (a bare re-add that
+// knows nothing of assets) keeps both, so it cannot silently switch an
+// opted-out instance back on. SetAssets is the door that can clear them.
// `update_settings` and `build_state` keep an existing value only while the
// row's tenant (effective account, app) stays the same. An Upsert that moves
// the hostname to another tenant takes the incoming values, empty included:
@@ -1208,7 +1232,9 @@ ON CONFLICT(host) DO UPDATE SET
WHEN excluded.update_settings = '' THEN routes.update_settings ELSE excluded.update_settings END,
build_state=CASE WHEN `+tenantChangedSQL+` THEN excluded.build_state
WHEN excluded.build_state = '' THEN routes.build_state ELSE excluded.build_state END,
- seed_source=CASE WHEN excluded.seed_source = '' THEN routes.seed_source ELSE excluded.seed_source END
+ seed_source=CASE WHEN excluded.seed_source = '' THEN routes.seed_source ELSE excluded.seed_source END,
+ assets_off=CASE WHEN excluded.asset_gen = '' THEN routes.assets_off ELSE excluded.assets_off END,
+ asset_gen=CASE WHEN excluded.asset_gen = '' THEN routes.asset_gen ELSE excluded.asset_gen 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`,
routeArgs(normalizeRoute(t))...)
@@ -1269,7 +1295,7 @@ func routeArgs(t Route) []any {
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,
- t.SeedSource, t.ImportGate,
+ t.SeedSource, t.ImportGate, b2iDB(t.AssetsOff), t.AssetGen,
}
}
@@ -1969,6 +1995,32 @@ func (r *Registry) SetSteering(host, steering string) error {
return nil
}
+// SetAssets converges a route's asset-cache columns as one narrow UPDATE:
+// off is the instance opt-out (true = publish no prefixes), gen the asset
+// generation ("" for none). They are one fact from one record, so they move
+// together, in both directions, including down to false and "". Value
+// validation lives at the provision door (NewAssetGen mints the only shape).
+// Errors on an unmatched host, like SetSteering.
+func (r *Registry) SetAssets(host string, off bool, gen string) error {
+ if r.remote != nil {
+ _, err := r.remote.call(nil, remoteRequest{Method: "SetAssets", Host: host, AssetsOff: off, AssetGen: gen}, nil)
+ return err
+ }
+
+ res, err := r.sql.Exec(`UPDATE routes SET assets_off = ?, asset_gen = ? WHERE host = ?`, b2iDB(off), gen, 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
+}
+
// SetUpdateSettings repoints a route's effective update settings as a narrow
// UPDATE: reconcile converges it from the record every pass, in both
// directions, including down to "". Errors on an unmatched host, like
@@ -3009,6 +3061,11 @@ func (r *Registry) DeletePath(host, prefix string) error {
// "box-a"/"box-b"), which is precisely the blind spot hashing the values
// closes.
//
+// `assets_off` and `asset_gen` (edge asset cache design sections 3.2 and 6)
+// ARE hashed, folded in only when set: unlike steering they change what the
+// home box publishes to the edge, and an unset registry's digest is
+// byte-identical to the one before the columns existed.
+//
// `steering` (multi-region ingress design Part 4) is deliberately NOT hashed
// here — see Route.Steering's own doc comment for the exclusion, stated in
// the same place `unit`'s exclusion is (registry.go:93's rule, restated):
@@ -3069,7 +3126,8 @@ 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, gate, hold_op, import_gate,
- claim_incarnation, claim_stamp, claim_owner, claim_row_id
+ claim_incarnation, claim_stamp, claim_owner, claim_row_id,
+ assets_off, asset_gen
FROM routes ORDER BY rowid`)
if err != nil {
return "", err
@@ -3078,12 +3136,14 @@ FROM routes ORDER BY rowid`)
h := sha256.New()
n := 0
for rows.Next() {
- var rowid, hibernate int64
+ var rowid, hibernate, assetsOff int64
var claim ClaimOwnership
+ var assetGen string
var status, socket, addr, version, channel, backing, tunnel, dbPath, region, fleetBox, addrs, health, gate, holdOp, importGate string
if err := rows.Scan(&rowid, &status, &socket, &addr, &version, &hibernate,
&channel, &backing, &tunnel, &dbPath, &region, &fleetBox, &addrs, &health, &gate, &holdOp, &importGate,
- &claim.ClaimIncarnation, &claim.ClaimStamp, &claim.ClaimOwner, &claim.ClaimRowID); err != nil {
+ &claim.ClaimIncarnation, &claim.ClaimStamp, &claim.ClaimOwner, &claim.ClaimRowID,
+ &assetsOff, &assetGen); 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\x1f%s",
@@ -3102,6 +3162,15 @@ FROM routes ORDER BY rowid`)
if importGate != "" {
fmt.Fprintf(h, "\x1fimport_gate=%q", importGate)
}
+ // The asset columns follow the same rule: the edge must reload when
+ // the opt-out or the generation changes, and a registry that never
+ // set either digests as it did before the columns existed.
+ if assetsOff != 0 {
+ fmt.Fprint(h, "\x1fassets_off=1")
+ }
+ if assetGen != "" {
+ fmt.Fprintf(h, "\x1fasset_gen=%q", assetGen)
+ }
// Ownership follows the same rule: the all-default value is omitted,
// and each non-default field is tagged and quoted so a partial value
// cannot alias another field's.
@@ -3374,6 +3443,21 @@ func (r *Registry) SnapshotFingerprint() (string, error) {
}
continue
}
+ // assets_off and asset_gen follow the same rule: a registry that
+ // never set either digests exactly as it did before the columns
+ // existed, so upgrading does not force a snapshot on every box.
+ if cols[i] == "assets_off" {
+ if v.Valid && v.String != "" && v.String != "0" {
+ fmt.Fprintf(h, "assets_off=%s\x1f", v.String)
+ }
+ continue
+ }
+ if cols[i] == "asset_gen" {
+ if v.Valid && v.String != "" {
+ fmt.Fprintf(h, "asset_gen=%q\x1f", v.String)
+ }
+ continue
+ }
// Claim ownership columns follow the hold_op rule: default
// values are omitted, anything else is tagged and quoted.
if snapshotClaimCols[cols[i]] {
diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go
index ab3b7b1fd..5ec2d1103 100644
--- a/internal/registry/registry_test.go
+++ b/internal/registry/registry_test.go
@@ -87,6 +87,8 @@ func TestUpsertRoundTripsAllColumns(t *testing.T) {
BuildState: `{"known":true,"state":{"schema":1,"last_executed":"bs345"}}`,
SeedSource: `{"id":"sd_aaaaaaaaaaaaaaaaaaaaaaaaaa","kind":"replica","state":"ready"}`,
ImportGate: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
+ AssetsOff: true,
+ AssetGen: "0123456789abcdef",
}
if err := reg.Upsert(want); err != nil {
t.Fatal(err)
@@ -2949,3 +2951,148 @@ func TestGoodBuildMovesTheSnapshotFingerprint(t *testing.T) {
t.Fatal("a new good build did not move the snapshot fingerprint")
}
}
+
+func TestSetAssetsRoundTrips(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetAssets("shop.example.com", true, "0123456789abcdef"); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("shop.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.AssetsOff || got.AssetGen != "0123456789abcdef" {
+ t.Fatalf("AssetsOff = %v, AssetGen = %q, want true and the token", got.AssetsOff, got.AssetGen)
+ }
+ // Both columns move together and clear together.
+ if err := reg.SetAssets("shop.example.com", false, ""); err != nil {
+ t.Fatal(err)
+ }
+ if got, err = reg.Get("shop.example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if got.AssetsOff || got.AssetGen != "" {
+ t.Fatalf("AssetsOff = %v, AssetGen = %q after clearing, want false and empty", got.AssetsOff, got.AssetGen)
+ }
+ if err := reg.SetAssets("nope.example.com", true, "x"); err == nil {
+ t.Fatal("SetAssets on an unmatched host: want error, got nil")
+ }
+}
+
+// TestUpsertKeepsAssetColumnsWhenIncomingNamesNone: an Upsert whose literal
+// carries no asset generation (a bare `carlos add` over an existing host)
+// must not clear the opt-out or the generation.
+func TestUpsertKeepsAssetColumnsWhenIncomingNamesNone(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready", AssetsOff: true, AssetGen: "0123456789abcdef"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := reg.Get("shop.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.AssetsOff || got.AssetGen != "0123456789abcdef" {
+ t.Fatalf("AssetsOff = %v, AssetGen = %q, want the stored values kept", got.AssetsOff, got.AssetGen)
+ }
+ // A literal that does name a generation replaces both.
+ if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready", AssetGen: "fedcba9876543210"}); err != nil {
+ t.Fatal(err)
+ }
+ if got, err = reg.Get("shop.example.com"); err != nil {
+ t.Fatal(err)
+ }
+ if got.AssetsOff || got.AssetGen != "fedcba9876543210" {
+ t.Fatalf("AssetsOff = %v, AssetGen = %q, want false and the new token", got.AssetsOff, got.AssetGen)
+ }
+}
+
+// TestFingerprintSeesAssetColumns: unlike steering, the asset opt-out and
+// generation change what the edge publishes and serves, so each moves the
+// reload fingerprint. A registry that never set either digests exactly as it
+// did before the columns existed.
+func TestFingerprintSeesAssetColumns(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ fp := func() string {
+ t.Helper()
+ v, err := reg.Fingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return v
+ }
+ base := fp()
+ if err := reg.SetAssets("shop.example.com", false, "0123456789abcdef"); err != nil {
+ t.Fatal(err)
+ }
+ withGen := fp()
+ if withGen == base {
+ t.Fatal("Fingerprint did not move on an asset generation change")
+ }
+ if err := reg.SetAssets("shop.example.com", false, "fedcba9876543210"); err != nil {
+ t.Fatal(err)
+ }
+ withOther := fp()
+ if withOther == withGen {
+ t.Fatal("Fingerprint did not move on a changed asset generation")
+ }
+ if err := reg.SetAssets("shop.example.com", true, "fedcba9876543210"); err != nil {
+ t.Fatal(err)
+ }
+ if fp() == withOther {
+ t.Fatal("Fingerprint did not move on an opt-out change")
+ }
+ if err := reg.SetAssets("shop.example.com", false, ""); err != nil {
+ t.Fatal(err)
+ }
+ if got := fp(); got != base {
+ t.Fatalf("Fingerprint after clearing = %s, want the original %s: unset columns must not change the digest", got, base)
+ }
+}
+
+// TestSnapshotFingerprintSeesAssetColumns: the restore surface reproduces the
+// asset columns, and a registry that never set them digests as before.
+func TestSnapshotFingerprintSeesAssetColumns(t *testing.T) {
+ reg := openTestRegistry(t)
+ if err := reg.Upsert(Route{Host: "shop.example.com", Kind: "instance", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ base, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetAssets("shop.example.com", true, ""); err != nil {
+ t.Fatal(err)
+ }
+ off, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if off == base {
+ t.Fatal("SnapshotFingerprint did not move on an opt-out")
+ }
+ if err := reg.SetAssets("shop.example.com", false, "0123456789abcdef"); err != nil {
+ t.Fatal(err)
+ }
+ gen, err := reg.SnapshotFingerprint()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gen == base || gen == off {
+ t.Fatal("SnapshotFingerprint did not move on an asset generation")
+ }
+ if err := reg.SetAssets("shop.example.com", false, ""); err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := reg.SnapshotFingerprint(); got != base {
+ t.Fatal("SnapshotFingerprint after clearing differs from the original")
+ }
+}
diff --git a/internal/registry/remote_dispatch.go b/internal/registry/remote_dispatch.go
index 23adccb5e..4750f4254 100644
--- a/internal/registry/remote_dispatch.go
+++ b/internal/registry/remote_dispatch.go
@@ -55,6 +55,8 @@ type remoteRequest struct {
Addrs string
Health string
Steering string
+ AssetsOff bool
+ AssetGen string
UpdateSettings string
BuildState string
Replicas string
@@ -241,6 +243,8 @@ func (r *Registry) dispatchRemote(ctx context.Context, q remoteRequest, callback
err = r.SetUpstreams(q.Host, q.Addr, q.Addrs, q.Health)
case "SetSteering":
err = r.SetSteering(q.Host, q.Steering)
+ case "SetAssets":
+ err = r.SetAssets(q.Host, q.AssetsOff, q.AssetGen)
case "SetUpdateSettings":
err = r.SetUpdateSettings(q.Host, q.UpdateSettings)
case "SetBuildState":