feat: fast-adopt canary channels every few seconds, not hourly
Splits two axes that were conflated: bake window (canary/* already bypasses this) and poll latency (it didn't -- a canary release still sat unnoticed for up to an hour, waiting on the same hourly+jitter cycle as stable). Adds canaryAdoptLoop, a second loop scoped strictly to routes following a canary/* channel, sweeping every ~2s instead of hourly. Deliberately NOT fleet-wide: canary channels are individually created and inherently low-cardinality (one deliberate rollout at a time), so this can't turn into a poll storm the way fast-polling every route would. Stable/beta stay on the existing hourly loop -- bake-gated for a day or three regardless, so faster polling buys nothing there. release.Pass is now a thin wrapper around a new release.PassFilter(..., extra) -- zero signature change for Pass's 9 existing call sites (agent.go, cli.go's `carlos adopt`, and 7 test call sites), the filter is purely additive. Solves the real use case that surfaced it: pushing a fix to one Tito customer's instance via canary, and having it land in seconds instead of up to an hour.
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
4 files changed,
+121
−9
internal/host/agent.go+51 −7internal/host/host_test.go+20 −0internal/release/adopt.go+19 −2internal/release/adopt_test.go+31 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 0277c1d7a..abdc6a214 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -1,11 +1,13 @@ |
| // The `carlos agent` verb — the single-box role: edge (TLS + host routing) + |
| -// hibernation activator + hourly release adoption + the fleet status page's |
| -// 5-minute self-report composed in one process. The composition is new |
| -// here, but each part is a port: the edge from Eleven's router.go, the |
| -// activator from lchat activator.go, the adopt loop is release.Pass on the |
| -// cadence of Eleven's updater timer, and the status loop is |
| -// internal/status's CollectBox/WriteBox/BuildFleet/PublishFleet run on a |
| -// timer with one publisher elected via internal/lease. |
| +// hibernation activator + hourly release adoption (plus a canary/*-only |
| +// fast-adopt sweep every few seconds, canaryAdoptLoop) + the fleet status |
| +// page's 5-minute self-report composed in one process. The composition is |
| +// new here, but each part is a port: the edge from Eleven's router.go, the |
| +// activator from lchat activator.go, the adopt loop is release.Pass (or, |
| +// for canaries, release.PassFilter) on the cadence of Eleven's updater |
| +// timer, and the status loop is internal/status's |
| +// CollectBox/WriteBox/BuildFleet/PublishFleet run on a timer with one |
| +// publisher elected via internal/lease. |
| // |
| // Fleet status page config surface (every env var any part of this file |
| // reads for it, gathered in one place since no single function owns the |
| @@ -133,6 +135,7 @@ func RunAgent(args []string) { |
| } |
| go adoptLoop(reg) |
| + go canaryAdoptLoop(reg) |
| go statusLoop(reg) |
| go domainsLoop(reg, peekAcmeCache(args)) |
| @@ -197,6 +200,47 @@ func adoptLoop(reg *registry.Registry) { |
| } |
| } |
| +// canaryAdoptLoop mirrors adoptLoop but sweeps only routes following a |
| +// canary/* channel, every few seconds instead of hourly — "I promoted a |
| +// canary, why is my box still on the old version for the next 40 minutes" |
| +// with a real fix instead of an operator forcing `carlos adopt` by hand. |
| +// Bounded deliberately to canary/*: those channels are individually |
| +// created and inherently low-cardinality (one deliberate rollout at a |
| +// time, never the whole fleet — see release.PassFilter's doc comment and |
| +// bake.go's "canary/* has no window"), so this can't grow into a |
| +// fleet-wide poll storm the way sweeping every route this fast would. |
| +// Stable/beta routes stay on adoptLoop's hourly cadence: they're |
| +// bake-gated for a day or three regardless, so faster polling would buy |
| +// nothing there. |
| +func canaryAdoptLoop(reg *registry.Registry) { |
| + ctx := context.Background() |
| + store, err := openDeployStore(ctx) |
| + if err != nil { |
| + return // adoptLoop already logged this |
| + } |
| + pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| + if err != nil { |
| + return // ditto |
| + } |
| + optDir := release.OptDir() |
| + log.Printf("agent: canary adopt loop armed (2s + jitter, canary/* routes only)") |
| + for { |
| + time.Sleep(2*time.Second + rand.N(time.Second)) |
| + if err := release.PassFilter(ctx, reg, store, pub, optDir, restartRoute, isCanaryRoute); err != nil { |
| + log.Printf("agent: canary adopt pass: %v", err) |
| + } |
| + } |
| +} |
| + |
| +// isCanaryRoute reports whether rt follows a canary/* channel — the same |
| +// two-segment convention bake.go's DefaultHolds wildcard matches (a bare |
| +// "canary" with no slash is NOT covered, same as bake.go: it falls through |
| +// to the unknown-channel default there, so it shouldn't fast-poll here |
| +// either). |
| +func isCanaryRoute(rt registry.Route) bool { |
| + return strings.HasPrefix(rt.Channel, "canary/") |
| +} |
| + |
| // peekAcmeCache pre-parses the agent args for -acme-cache the same way |
| // peekRegistryPath peeks -registry: the edge does the authoritative parse, |
| // this just lets the domains loop stamp cert expiry from the SAME cache dir |
diff --git a/internal/host/host_test.go b/internal/host/host_test.go| index 44b880ede..ea0a3b211 100644 |
| --- a/internal/host/host_test.go |
| +++ b/internal/host/host_test.go |
| @@ -365,3 +365,23 @@ func TestBootstrapIdempotent(t *testing.T) { |
| } |
| } |
| } |
| + |
| +func TestIsCanaryRoute(t *testing.T) { |
| + cases := []struct { |
| + channel string |
| + want bool |
| + }{ |
| + {"canary/x", true}, |
| + {"canary/pr-142", true}, |
| + {"stable", false}, |
| + {"beta", false}, |
| + {"canary", false}, // no slash — same convention as bake.go's DefaultHolds wildcard |
| + {"", false}, |
| + } |
| + for _, c := range cases { |
| + rt := registry.Route{Channel: c.channel} |
| + if got := isCanaryRoute(rt); got != c.want { |
| + t.Errorf("isCanaryRoute(Channel=%q) = %v, want %v", c.channel, got, c.want) |
| + } |
| + } |
| +} |
diff --git a/internal/release/adopt.go b/internal/release/adopt.go| index 87d0baf5c..c8dc86c86 100644 |
| --- a/internal/release/adopt.go |
| +++ b/internal/release/adopt.go |
| @@ -39,17 +39,34 @@ var adoptPlatform = runtime.GOOS + "-" + runtime.GOARCH |
| // hosts are processed AFTER every awake host, so an awake host discovers a |
| // boot-broken build (and poisons it) before any sleeper is moved onto it. |
| func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir string, restart func(host string) error) error { |
| + return PassFilter(ctx, reg, s, pub, optDir, restart, nil) |
| +} |
| + |
| +// PassFilter is Pass narrowed to the routes extra additionally accepts |
| +// (nil extra makes it identical to Pass) — the seam a faster, narrower |
| +// sweep composes on top of the full one without duplicating the group |
| +// resolution, bake-clock, staging, or restart logic. Built for the |
| +// canary-only fast-adopt loop (internal/host/agent.go): canary channels |
| +// are individually created and inherently low-cardinality (one deliberate |
| +// rollout at a time, never the whole fleet — see bake.go's "canary/* has |
| +// no window"), so sweeping just them every few seconds instead of hourly |
| +// can't turn into a fleet-wide poll storm the way sweeping every route |
| +// that fast would. |
| +func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir string, restart func(host string) error, extra func(registry.Route) bool) error { |
| routes, err := reg.List() |
| if err != nil { |
| return err |
| } |
| + adopts := func(rt registry.Route) bool { |
| + return adoptable(rt) && (extra == nil || extra(rt)) |
| + } |
| // Resolve every distinct (App, Channel) once. Verification failures kill |
| // the group (nil entry), not the pass. |
| type groupKey struct{ app, channel string } |
| pointers := map[groupKey]*ChannelPointer{} |
| for _, rt := range routes { |
| - if !adoptable(rt) { |
| + if !adopts(rt) { |
| continue |
| } |
| k := groupKey{rt.App, routeChannel(rt)} |
| @@ -96,7 +113,7 @@ func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.Publ |
| // sleeper is moved onto it. |
| var hibernating []registry.Route |
| for _, rt := range routes { |
| - if !adoptable(rt) { |
| + if !adopts(rt) { |
| continue |
| } |
| if rt.Hibernate { |
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go| index ad98a5847..c8b3940ed 100644 |
| --- a/internal/release/adopt_test.go |
| +++ b/internal/release/adopt_test.go |
| @@ -240,3 +240,34 @@ func TestPassMissingPlatformArtifactSkips(t *testing.T) { |
| t.Fatalf("live = %q, want unmoved (no artifact for this platform)", v) |
| } |
| } |
| + |
| +func TestPassFilterOnlyTouchesRoutesExtraAccepts(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + opt := t.TempDir() |
| + reg := testRegistry(t) |
| + |
| + shipRelease(t, s, "myapp", "v1", adoptPlatform, []byte("build one")) |
| + if err := Promote(ctx, s, priv, "myapp", "canary/x", "v1"); err != nil { |
| + t.Fatalf("Promote canary/x: %v", err) |
| + } |
| + if err := Promote(ctx, s, priv, "myapp", "stable", "v1"); err != nil { |
| + t.Fatalf("Promote stable: %v", err) |
| + } |
| + addRoute(t, reg, "canary.example.com", "myapp", "canary/x", false) |
| + addRoute(t, reg, "stable.example.com", "myapp", "stable", false) |
| + |
| + canaryOnly := func(rt registry.Route) bool { return rt.Channel == "canary/x" } |
| + restart := func(string) error { return nil } |
| + if err := PassFilter(ctx, reg, s, pub, opt, restart, canaryOnly); err != nil { |
| + t.Fatalf("PassFilter: %v", err) |
| + } |
| + |
| + if v, _ := LiveVersion(opt, "canary.example.com"); v != "v1" { |
| + t.Errorf("canary.example.com live = %q, want v1 (extra accepts it)", v) |
| + } |
| + if v, _ := LiveVersion(opt, "stable.example.com"); v != "" { |
| + t.Errorf("stable.example.com live = %q, want unmoved — PassFilter must not touch a route extra rejects", v) |
| + } |
| +} |