carlos / platform Public

Add the sidecar wake generation object and its box-side pass

Paul Campbell pushed by paul@keymail.dev 3436588cbc1428aa0865c93351f53dc17419126b parent ab0f4e3
4 files changed, +425 −0
  • internal/host/sidecarwake.go +109 −0
  • internal/host/sidecarwake_test.go +223 −0
  • internal/release/sidecarwake.go +45 −0
  • internal/release/sidecarwake_test.go +48 −0
diff --git a/internal/host/sidecarwake.go b/internal/host/sidecarwake.go
new file mode 100644
index 000000000..cbff12df9
--- /dev/null
+++ b/internal/host/sidecarwake.go
@@ -0,0 +1,109 @@
+// The box half of a sidecar wake request: when a sidecar's wake object
+// (release.SidecarWakeKey) MOVES, wake exactly that sidecar. Pull-based like
+// the restart signal it is modelled on (restart.go) — no push channel, no
+// box access, and the box need not even be reachable from anywhere for this
+// to work.
+//
+// It exists because a sidecar has no socket and nothing dials it: an
+// instance wakes on its next inbound request, but a hibernating sidecar has
+// no "next request" to wake on, so waking one from outside the box needs an
+// explicit signal a pull-based box can notice on its own.
+package host
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// sidecarsServed maps every sidecar route this box serves to the (account,
+// app, name) triple its wake object lives at — name comes from Slug, the
+// field a sidecar route carries its short name on (registry.Route's own
+// doc comment; provision's reconciler stamps it from the sidecar record).
+// A sidecar route with no Slug cannot be resolved to a wake key and is
+// skipped: it has nothing this pass can act on.
+func sidecarsServed(routes []registry.Route) map[[3]string]registry.Route {
+ out := make(map[[3]string]registry.Route)
+ for _, rt := range routes {
+ if !rt.IsSidecar() || rt.Slug == "" {
+ continue
+ }
+ out[[3]string{release.RouteAccount(rt), rt.App, rt.Slug}] = rt
+ }
+ return out
+}
+
+// sidecarWakePass is sidecar-wake's counterpart to restartGenPass
+// (restart.go): read the wake-object etag of every sidecar this box serves
+// and, for each one that MOVED, call wake with that sidecar's route.
+//
+// - First sight of a sidecar records silently and wakes nothing. A box
+// (or an agent process) that has just started must not wake every
+// sidecar it serves because it has never seen these etags before — that
+// is the seed-don't-replay rule the serial watcher already applies
+// (agent.go's fastTick), and it is the whole safety property of this
+// feature. Without it, an agent restart would replay every wake this
+// app's sidecars have ever received — a thundering herd on boot, and
+// for a hibernating fleet a real cost, not a cosmetic one.
+// - An unchanged etag does nothing.
+// - Absent that stays absent does nothing, forever: a sidecar nobody has
+// ever woken this way has no object and never will until someone asks.
+// - Any movement wakes: the etag changed, or absent -> present (the
+// sidecar's first ever wake request).
+// - A wake object naming a sidecar this box does not serve is never even
+// read: the pass only ever builds keys from sidecarsServed, which comes
+// from THIS box's own route table. Such an object, however it moves in
+// the bucket, produces neither a wake nor an error here — it is simply
+// never asked about.
+// - A read error for one sidecar leaves its cache entry untouched and the
+// rest of the pass keeps going; the error comes back for the caller to
+// aggregate into its own log line, same discipline as restartGenPass.
+// - A sidecar route that vanishes from the route set loses its cache
+// entry: a re-added sidecar is a first sight again, which is right,
+// because a route that has just been (re-)minted is starting fresh
+// anyway.
+//
+// The cache is mutated in place and is expected to live for the process
+// lifetime, exactly like restartGenPass's — there is nowhere else to
+// remember what "changed" means between two requests that may be days
+// apart. wake may be nil (no activator armed on this box): a working no-op.
+func sidecarWakePass(ctx context.Context, routes []registry.Route, store release.Store, cache map[[3]string]string, wake func(rt registry.Route) error) (woken []string, readErrs []error) {
+ served := sidecarsServed(routes)
+ live := make(map[[3]string]bool, len(served))
+ for k, rt := range served {
+ live[k] = true
+ _, etag, err := store.Get(ctx, release.SidecarWakeKey(k[0], k[1], k[2]))
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ etag = genAbsent
+ case err != nil:
+ readErrs = append(readErrs, fmt.Errorf("%s/%s/%s: %w", k[0], k[1], k[2], err))
+ continue // cache entry untouched: unknown this tick, not changed
+ }
+ prev, seen := cache[k]
+ cache[k] = etag
+ if !seen || prev == etag {
+ continue
+ }
+ if wake == nil {
+ continue
+ }
+ if err := wake(rt); err != nil {
+ log.Printf("agent: sidecar wake requested: wake %s: %v", rt.Host, err)
+ continue
+ }
+ woken = append(woken, rt.Host)
+ log.Printf("agent: sidecar wake requested: woke %s", rt.Host)
+ }
+ for k := range cache {
+ if !live[k] {
+ delete(cache, k)
+ }
+ }
+ return woken, readErrs
+}
diff --git a/internal/host/sidecarwake_test.go b/internal/host/sidecarwake_test.go
new file mode 100644
index 000000000..efae9b48a
--- /dev/null
+++ b/internal/host/sidecarwake_test.go
@@ -0,0 +1,223 @@
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "slices"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// wakeSidecarRoute is sidecarRoute (restart_test.go) with Slug set — the
+// field a sidecar route carries its short name on (registry.Route.Slug's
+// doc comment; provision's reconciler stamps it from the sidecar record).
+// sidecarWakePass keys entirely off it, so a fixture that omits it is
+// useless here even though restart_test.go's sidecarRoute never needed it.
+func wakeSidecarRoute(host, acct, app, slug string) registry.Route {
+ rt := sidecarRoute(host, acct, app)
+ rt.Slug = slug
+ return rt
+}
+
+// touchSidecarWake writes a sidecar-wake object directly, standing in for
+// the console door Task 12 has not built yet on this branch — exactly the
+// role racingRestartPutStore's raw JSON plays in restartgen_test.go. Every
+// call produces a BYTE-DIFFERENT body (an incrementing generation), because
+// DirStore's etag is content-derived and a byte-identical rewrite would not
+// move it.
+func touchSidecarWake(t *testing.T, ctx context.Context, s release.Store, acct, app, name string, gen int) {
+ t.Helper()
+ body, err := json.Marshal(release.SidecarWake{
+ Schema: 1, Generation: gen, At: "2026-08-17T23:05:00Z",
+ By: "member:bdm", Reason: "test wake",
+ })
+ if err != nil {
+ t.Fatalf("marshal sidecar wake: %v", err)
+ }
+ key := release.SidecarWakeKey(acct, app, name)
+ _, etag, err := s.Get(ctx, key)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("read sidecar wake %s: %v", key, err)
+ }
+ if errors.Is(err, os.ErrNotExist) {
+ etag = ""
+ }
+ if _, err := s.Put(ctx, key, body, etag); err != nil {
+ t.Fatalf("put sidecar wake %s: %v", key, err)
+ }
+}
+
+func TestSidecarWakePassEtagMoveWakesExactlyThatSidecar(t *testing.T) {
+ ctx, s := genKit(t)
+ routes := []registry.Route{
+ wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"),
+ wakeSidecarRoute("other.eleven.sidecar.internal", "bdm", "eleven", "other"),
+ }
+ cache := map[[3]string]string{}
+ // Seed: first sight of both, silently.
+ sidecarWakePass(ctx, routes, s, cache, nil)
+
+ touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot", 1)
+ var woken []string
+ wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil }
+ got, errs := sidecarWakePass(ctx, routes, s, cache, wake)
+ if len(errs) != 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+ if !slices.Equal(got, []string{"bot.eleven.sidecar.internal"}) {
+ t.Fatalf("sidecarWakePass returned %v, want exactly the moved sidecar", got)
+ }
+ if !slices.Equal(woken, []string{"bot.eleven.sidecar.internal"}) {
+ t.Fatalf("woken = %v, want exactly the named sidecar and nothing else", woken)
+ }
+}
+
+func TestSidecarWakePassUnchangedEtagWakesNothing(t *testing.T) {
+ ctx, s := genKit(t)
+ routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
+ touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot", 1)
+ cache := map[[3]string]string{}
+ sidecarWakePass(ctx, routes, s, cache, nil) // seed
+
+ var woken []string
+ wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil }
+ got, errs := sidecarWakePass(ctx, routes, s, cache, wake)
+ if len(errs) != 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+ if len(got) != 0 || len(woken) != 0 {
+ t.Fatalf("unchanged etag woke %v (returned %v), want none", woken, got)
+ }
+}
+
+// TestSidecarWakePassFirstSightDoesNotReplay is the no-replay-on-absence
+// rule: an unseeded cache must not treat "I have never looked at this
+// before" as movement, or every agent restart would wake every sidecar
+// this app has ever asked to wake — a thundering herd on boot.
+func TestSidecarWakePassFirstSightDoesNotReplay(t *testing.T) {
+ ctx, s := genKit(t)
+ // Wake object already exists BEFORE this box ever looks — the shape a
+ // long-lived wake request or a box that just (re)started sees.
+ touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot", 1)
+ routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
+ cache := map[[3]string]string{}
+
+ var woken []string
+ wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil }
+ got, errs := sidecarWakePass(ctx, routes, s, cache, wake)
+ if len(errs) != 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+ if len(got) != 0 || len(woken) != 0 {
+ t.Fatalf("first sight woke %v (returned %v) — a box that just started must not replay every past wake", woken, got)
+ }
+ want := touchedEtag(t, ctx, s, "bdm", "eleven", "bot")
+ if got := cache[[3]string{"bdm", "eleven", "bot"}]; got != want {
+ t.Fatalf("cache etag = %q, want the store's %q", got, want)
+ }
+
+ // A SECOND wake request after the seed IS movement.
+ touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot", 2)
+ got, errs = sidecarWakePass(ctx, routes, s, cache, wake)
+ if len(errs) != 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+ if !slices.Equal(got, []string{"bot.eleven.sidecar.internal"}) || !slices.Equal(woken, []string{"bot.eleven.sidecar.internal"}) {
+ t.Fatalf("second request returned %v, woken %v, want the sidecar woken exactly once", got, woken)
+ }
+}
+
+func touchedEtag(t *testing.T, ctx context.Context, s release.Store, acct, app, name string) string {
+ t.Helper()
+ _, etag, err := s.Get(ctx, release.SidecarWakeKey(acct, app, name))
+ if err != nil {
+ t.Fatalf("read sidecar wake: %v", err)
+ }
+ return etag
+}
+
+// TestSidecarWakePassReadErrorDoesNotSuppressOthers: a transient read
+// failure against one sidecar's wake object must not blind the pass to a
+// real move on a different one in the same tick.
+func TestSidecarWakePassReadErrorDoesNotSuppressOthers(t *testing.T) {
+ ctx, s0 := genKit(t)
+ routes := []registry.Route{
+ wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"),
+ wakeSidecarRoute("worker.titogo.sidecar.internal", "bdm", "titogo", "worker"),
+ }
+ cache := map[[3]string]string{}
+ sidecarWakePass(ctx, routes, s0, cache, nil) // seed both, absent
+
+ touchSidecarWake(t, ctx, s0, "bdm", "eleven", "bot", 1)
+ touchSidecarWake(t, ctx, s0, "bdm", "titogo", "worker", 1)
+ failing := &failingKeyStore{Store: s0, failKey: release.SidecarWakeKey("bdm", "eleven", "bot"), err: errors.New("bucket blip")}
+
+ var woken []string
+ wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil }
+ got, errs := sidecarWakePass(ctx, routes, failing, cache, wake)
+ if len(errs) != 1 {
+ t.Fatalf("errs = %v, want exactly one (the failing key)", errs)
+ }
+ if !slices.Equal(got, []string{"worker.titogo.sidecar.internal"}) || !slices.Equal(woken, []string{"worker.titogo.sidecar.internal"}) {
+ t.Fatalf("returned %v, woken %v, want only the unaffected sidecar moved", got, woken)
+ }
+}
+
+// TestSidecarWakePassIgnoresUnservedSidecar: a wake object naming a sidecar
+// this box does not serve is never even read, let alone acted on or
+// reported as an error — the pass only ever asks about routes this box
+// actually has.
+func TestSidecarWakePassIgnoresUnservedSidecar(t *testing.T) {
+ ctx, s := genKit(t)
+ // A wake request for a sidecar this box has no route for at all.
+ touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost", 1)
+ routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
+ cache := map[[3]string]string{}
+ sidecarWakePass(ctx, routes, s, cache, nil) // seed
+
+ touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost", 2) // "ghost" moves again
+ var woken []string
+ wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil }
+ got, errs := sidecarWakePass(ctx, routes, s, cache, wake)
+ if len(errs) != 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+ if len(got) != 0 || len(woken) != 0 {
+ t.Fatalf("an unserved sidecar's wake object produced %v (woken %v), want none", got, woken)
+ }
+ if _, ok := cache[[3]string{"bdm", "eleven", "ghost"}]; ok {
+ t.Fatal("a sidecar this box does not serve must never appear in its cache")
+ }
+}
+
+// TestSidecarWakePassIgnoresNonSidecarRouteWithMatchingSlug: Slug is not a
+// sidecar-only field (registry.Route.Slug's doc comment — it is the
+// app-account short name usable on a root domain, stamped on ordinary
+// routes too), so an ordinary instance route that happens to carry the same
+// Slug as a real sidecar name must never be mistaken for that sidecar and
+// must never trigger a read of, or a wake from, its wake object.
+func TestSidecarWakePassIgnoresNonSidecarRouteWithMatchingSlug(t *testing.T) {
+ ctx, s := genKit(t)
+ touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot", 1)
+ notSidecar := execRoute("app.example.com", "bdm", "eleven")
+ notSidecar.Slug = "bot" // same name a sidecar could carry, wrong Kind
+ routes := []registry.Route{notSidecar}
+ cache := map[[3]string]string{}
+
+ var woken []string
+ wake := func(rt registry.Route) error { woken = append(woken, rt.Host); return nil }
+ got, errs := sidecarWakePass(ctx, routes, s, cache, wake)
+ if len(errs) != 0 {
+ t.Fatalf("unexpected read errors: %v", errs)
+ }
+ if len(got) != 0 || len(woken) != 0 {
+ t.Fatalf("a non-sidecar route with a matching Slug produced %v (woken %v), want none", got, woken)
+ }
+ if len(cache) != 0 {
+ t.Fatalf("cache = %v, want empty — a non-sidecar route must never seed a sidecar cache entry", cache)
+ }
+}
diff --git a/internal/release/sidecarwake.go b/internal/release/sidecarwake.go
new file mode 100644
index 000000000..5a5cabdd6
--- /dev/null
+++ b/internal/release/sidecarwake.go
@@ -0,0 +1,45 @@
+// The wake signal for a hibernating sidecar: one tiny CAS-guarded object per
+// sidecar, watched by etag, exactly one door over from restartgen.go's
+// restart signal and appconfig's config-gen object — the same mechanism a
+// third time, because "tell a box to act, with no push channel and no box
+// access" is one problem no matter what the box is being told to do.
+//
+// Bucket layout addition:
+//
+// apps/<acct>/<app>/sidecar-wake/<name>.json CAS-guarded, schema-1
+//
+// It is a SIBLING prefix under the app, deliberately not a key under
+// sidecars/: provision.ParseSidecarKey treats anything but exactly one
+// non-empty segment after "sidecars/" as not-a-record, and a wake object
+// living inside that prefix would either have to be taught to that parser
+// (widening what it accepts) or silently misparse as a sidecar name of
+// "<name>.json" — either way the family's "exactly one segment" rule stops
+// meaning what it says. A sibling prefix costs nothing and keeps that rule
+// exactly as strict as it reads.
+package release
+
+// SidecarWake is the object a wake request touches. Change detection is by
+// ETAG, never by Generation: a monotonic counter that resets — a bucket
+// restored from backup, an object rewritten by a script that does not know
+// the current value — must not wedge a box's cache into believing every
+// future request lands on a value it has "already seen". Generation, At, By
+// and Reason exist so a human debugging "why did this sidecar wake?" has an
+// answer; a box reading this object for its own purposes reads only its
+// etag and never unmarshals the body at all.
+//
+// It carries no authority: the worst a forged touch can do is wake a
+// sidecar that was going to run under its own signed release anyway. Waking
+// is not deciding what runs, the same way a restart-generation touch isn't.
+type SidecarWake struct {
+ Schema int `json:"schema"` // 1
+ Generation int `json:"generation"`
+ At string `json:"at"`
+ By string `json:"by"`
+ Reason string `json:"reason"`
+}
+
+// SidecarWakeKey is the bucket key of name's wake object within app, derived
+// from AppPrefix like every other per-app key.
+func SidecarWakeKey(acct, app, name string) string {
+ return AppPrefix(acct, app) + "sidecar-wake/" + name + ".json"
+}
diff --git a/internal/release/sidecarwake_test.go b/internal/release/sidecarwake_test.go
new file mode 100644
index 000000000..1a3abfadb
--- /dev/null
+++ b/internal/release/sidecarwake_test.go
@@ -0,0 +1,48 @@
+package release
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+// TestSidecarWakeKeyIsAnAppSibling pins the key's shape: a SIBLING prefix
+// under the app's own account-scoped prefix, deliberately not a key under
+// sidecars/ — provision.ParseSidecarKey's "exactly one non-empty segment
+// after sidecars/" rule stays exactly as strict as it reads only if nothing
+// else ever lands under that prefix.
+func TestSidecarWakeKeyIsAnAppSibling(t *testing.T) {
+ got, want := SidecarWakeKey("bdm", "eleven", "bot"), "apps/bdm/eleven/sidecar-wake/bot.json"
+ if got != want {
+ t.Fatalf("SidecarWakeKey = %q, want %q", got, want)
+ }
+}
+
+// TestSidecarWakeJSONShape pins the wire shape against the exact example the
+// design settled on, so a field rename or a json-tag typo fails loudly
+// instead of silently breaking every box's etag comparison (which never
+// parses the body at all) while still breaking a human reading the object
+// during an incident.
+func TestSidecarWakeJSONShape(t *testing.T) {
+ w := SidecarWake{
+ Schema: 1,
+ Generation: 3,
+ At: "2026-08-17T23:05:00Z",
+ By: "member:bdm",
+ Reason: "operator wake",
+ }
+ raw, err := json.Marshal(w)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ const want = `{"schema":1,"generation":3,"at":"2026-08-17T23:05:00Z","by":"member:bdm","reason":"operator wake"}`
+ if string(raw) != want {
+ t.Fatalf("SidecarWake JSON = %s, want %s", raw, want)
+ }
+ var back SidecarWake
+ if err := json.Unmarshal(raw, &back); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if back != w {
+ t.Fatalf("round trip = %+v, want %+v", back, w)
+ }
+}