| index 3c082d2cc..16933a8d0 100644 |
| --- a/internal/host/sidecarwake_test.go |
| +++ b/internal/host/sidecarwake_test.go |
| @@ -43,11 +43,11 @@ func TestSidecarWakeChangesEtagMoveReportsExactlyThatSidecar(t *testing.T) { |
| wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"), |
| wakeSidecarRoute("other.eleven.sidecar.internal", "bdm", "eleven", "other"), |
| } |
| - cache := map[[3]string]string{} |
| - sidecarWakeChanges(ctx, routes, s, cache) // seed: first sight of both, silently |
| + mem := newSidecarWakeMemory() |
| + sidecarWakeChanges(ctx, routes, s, mem) // seed: first sight of both, silently |
| |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| - got, errs := sidecarWakeChanges(ctx, routes, s, cache) |
| + got, errs := sidecarWakeChanges(ctx, routes, s, mem) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| @@ -60,10 +60,10 @@ func TestSidecarWakeChangesUnchangedEtagReportsNothing(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") |
| - cache := map[[3]string]string{} |
| - sidecarWakeChanges(ctx, routes, s, cache) // seed |
| + mem := newSidecarWakeMemory() |
| + sidecarWakeChanges(ctx, routes, s, mem) // seed |
| |
| - got, errs := sidecarWakeChanges(ctx, routes, s, cache) |
| + got, errs := sidecarWakeChanges(ctx, routes, s, mem) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| @@ -72,19 +72,22 @@ func TestSidecarWakeChangesUnchangedEtagReportsNothing(t *testing.T) { |
| } |
| } |
| |
| -// TestSidecarWakeChangesFirstSightDoesNotReplay 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 replay every wake this |
| -// sidecar has ever received — a thundering herd on boot. |
| -func TestSidecarWakeChangesFirstSightDoesNotReplay(t *testing.T) { |
| +// TestSidecarWakeChangesBaselinePassDoesNotReplay is the no-replay rule at |
| +// the point it applies: the BASELINE pass — arming, i.e. an agent restart — |
| +// must not treat "I have never looked at this before" as movement, or every |
| +// restart would replay every wake this sidecar has ever received, a |
| +// thundering herd on boot. (First sight AFTER the baseline is a different |
| +// fact entirely — a route minted under a running agent — and is woken; see |
| +// TestSidecarWakeChangesRouteMintedAfterTheBaselineIsWoken.) |
| +func TestSidecarWakeChangesBaselinePassDoesNotReplay(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") |
| routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| - cache := map[[3]string]string{} |
| + mem := newSidecarWakeMemory() |
| |
| - got, errs := sidecarWakeChanges(ctx, routes, s, cache) |
| + got, errs := sidecarWakeChanges(ctx, routes, s, mem) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| @@ -92,13 +95,13 @@ func TestSidecarWakeChangesFirstSightDoesNotReplay(t *testing.T) { |
| t.Fatalf("first sight reported %v — a box that just started must not replay every past wake", got) |
| } |
| want := sidecarWakeStoreEtag(t, ctx, s, "bdm", "eleven", "bot") |
| - if got := cache[[3]string{"bdm", "eleven", "bot"}]; got != want { |
| + if got := mem.etags[[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") |
| - got, errs = sidecarWakeChanges(ctx, routes, s, cache) |
| + got, errs = sidecarWakeChanges(ctx, routes, s, mem) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| @@ -107,6 +110,78 @@ func TestSidecarWakeChangesFirstSightDoesNotReplay(t *testing.T) { |
| } |
| } |
| |
| +// TestSidecarWakeChangesRouteMintedAfterTheBaselineIsWoken is the |
| +// create-then-wake flow, which is the feature's PRIMARY one: an operator |
| +// wake is a hibernating sidecar's only first-run mechanism, and |
| +// `carlos sidecars create` + `carlos sidecars wake` routinely land in the |
| +// same tick — the tick that mints the route. Seeding that route's etag |
| +// silently (the baseline rule, over-applied) loses the wake outright while |
| +// the CLI reports success: a bot that never runs. |
| +func TestSidecarWakeChangesRouteMintedAfterTheBaselineIsWoken(t *testing.T) { |
| + ctx, s := genKit(t) |
| + existing := wakeSidecarRoute("old.eleven.sidecar.internal", "bdm", "eleven", "old") |
| + mem := newSidecarWakeMemory() |
| + sidecarWakeChanges(ctx, []registry.Route{existing}, s, mem) // arming: this box's baseline |
| + |
| + // The console creates a sidecar and wakes it; this box's next tick mints |
| + // the route AND sees the wake object for the first time, together. |
| + minted := wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot") |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| + |
| + got, errs := sidecarWakeChanges(ctx, []registry.Route{existing, minted}, s, mem) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if len(got) != 1 || got[0].Host != "bot.eleven.sidecar.internal" { |
| + t.Fatalf("reported %v, want the just-minted sidecar — a wake standing at a route this pass minted is a wake somebody already asked for", got) |
| + } |
| +} |
| + |
| +// A route minted after the baseline with NO wake object is silent: only a |
| +// PRESENT object makes a first sight a wake, so ordinary provisioning never |
| +// wakes a hibernating sidecar nobody asked to run. |
| +func TestSidecarWakeChangesRouteMintedWithNoWakeObjectIsSilent(t *testing.T) { |
| + ctx, s := genKit(t) |
| + mem := newSidecarWakeMemory() |
| + sidecarWakeChanges(ctx, nil, s, mem) // arming on a box with no sidecars |
| + |
| + minted := wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot") |
| + got, errs := sidecarWakeChanges(ctx, []registry.Route{minted}, s, mem) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if len(got) != 0 { |
| + t.Fatalf("reported %v — a newly minted sidecar nobody has ever asked to wake must stay asleep", got) |
| + } |
| + |
| + // …and it behaves as an ordinary member from then on: the first real |
| + // request moves the etag and wakes it. |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| + got, _ = sidecarWakeChanges(ctx, []registry.Route{minted}, s, mem) |
| + if len(got) != 1 || got[0].Host != "bot.eleven.sidecar.internal" { |
| + t.Fatalf("reported %v, want the sidecar on its first real wake request", got) |
| + } |
| +} |
| + |
| +// An arming pass whose route LIST failed must not leave the box thinking |
| +// every sidecar it serves is brand new: the memory stays unseeded, so the |
| +// first leg pass establishes the baseline itself instead of replaying every |
| +// historical wake at once. |
| +func TestSidecarWakeSeedListFailureLeavesTheBaselineToTheFirstPass(t *testing.T) { |
| + ctx, s := genKit(t) |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") // history, from before this agent |
| + routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| + mem := newSidecarWakeMemory() // arming never completed: no List, nothing seeded |
| + |
| + got, errs := sidecarWakeChanges(ctx, routes, s, mem) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if len(got) != 0 { |
| + t.Fatalf("reported %v — with no baseline established, the first pass IS the baseline, not a replay of every past wake", got) |
| + } |
| +} |
| + |
| func sidecarWakeStoreEtag(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)) |
| @@ -125,14 +200,14 @@ func TestSidecarWakeChangesReadErrorDoesNotSuppressOthers(t *testing.T) { |
| wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"), |
| wakeSidecarRoute("worker.titogo.sidecar.internal", "bdm", "titogo", "worker"), |
| } |
| - cache := map[[3]string]string{} |
| - sidecarWakeChanges(ctx, routes, s0, cache) // seed both, absent |
| + mem := newSidecarWakeMemory() |
| + sidecarWakeChanges(ctx, routes, s0, mem) // seed both, absent |
| |
| touchSidecarWake(t, ctx, s0, "bdm", "eleven", "bot") |
| touchSidecarWake(t, ctx, s0, "bdm", "titogo", "worker") |
| failing := &failingKeyStore{Store: s0, failKey: release.SidecarWakeKey("bdm", "eleven", "bot"), err: errors.New("bucket blip")} |
| |
| - got, errs := sidecarWakeChanges(ctx, routes, failing, cache) |
| + got, errs := sidecarWakeChanges(ctx, routes, failing, mem) |
| if len(errs) != 1 { |
| t.Fatalf("errs = %v, want exactly one (the failing key)", errs) |
| } |
| @@ -148,18 +223,18 @@ func TestSidecarWakeChangesIgnoresUnservedSidecar(t *testing.T) { |
| ctx, s := genKit(t) |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost") // no route for "ghost" at all |
| routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| - cache := map[[3]string]string{} |
| - sidecarWakeChanges(ctx, routes, s, cache) // seed |
| + mem := newSidecarWakeMemory() |
| + sidecarWakeChanges(ctx, routes, s, mem) // seed |
| |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost") // "ghost" moves again |
| - got, errs := sidecarWakeChanges(ctx, routes, s, cache) |
| + got, errs := sidecarWakeChanges(ctx, routes, s, mem) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| if len(got) != 0 { |
| t.Fatalf("an unserved sidecar's wake object was reported: %v, want none", got) |
| } |
| - if _, ok := cache[[3]string{"bdm", "eleven", "ghost"}]; ok { |
| + if _, ok := mem.etags[[3]string{"bdm", "eleven", "ghost"}]; ok { |
| t.Fatal("a sidecar this box does not serve must never appear in its cache") |
| } |
| } |
| @@ -176,17 +251,17 @@ func TestSidecarWakeChangesIgnoresNonSidecarRouteWithMatchingSlug(t *testing.T) |
| 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{} |
| + mem := newSidecarWakeMemory() |
| |
| - got, errs := sidecarWakeChanges(ctx, routes, s, cache) |
| + got, errs := sidecarWakeChanges(ctx, routes, s, mem) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| if len(got) != 0 { |
| t.Fatalf("a non-sidecar route with a matching Slug was reported: %v, want none", got) |
| } |
| - if len(cache) != 0 { |
| - t.Fatalf("cache = %v, want empty — a non-sidecar route must never seed a sidecar cache entry", cache) |
| + if len(mem.etags) != 0 { |
| + t.Fatalf("cache = %v, want empty — a non-sidecar route must never seed a sidecar cache entry", mem.etags) |
| } |
| } |
| |
| @@ -342,8 +417,8 @@ func TestSidecarWakeLegActsOnBumpedGeneration(t *testing.T) { |
| t.Fatalf("upsert %s: %v", rt.Host, err) |
| } |
| } |
| - cache := map[[3]string]string{} |
| - sidecarWakeSeed(ctx, reg, s, cache) |
| + mem := newSidecarWakeMemory() |
| + sidecarWakeSeed(ctx, reg, s, mem) |
| |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| var mu sync.Mutex |
| @@ -354,7 +429,7 @@ func TestSidecarWakeLegActsOnBumpedGeneration(t *testing.T) { |
| mu.Unlock() |
| return nil |
| } |
| - moved, errs := sidecarWakeLeg(ctx, routes, s, cache, wake) |
| + moved, errs := sidecarWakeLeg(ctx, routes, s, mem, wake) |
| if len(errs) != 0 { |
| t.Fatalf("unexpected read errors: %v", errs) |
| } |
| @@ -368,14 +443,99 @@ func TestSidecarWakeLegActsOnBumpedGeneration(t *testing.T) { |
| }) |
| } |
| |
| +// TestSidecarWakeLegCreateThenWakeInOneTickStillWakesIt is the blocker as a |
| +// member would meet it, through the leg fastConvergeLoop actually calls: the |
| +// agent arms with no sidecars, then one serial move carries BOTH the record |
| +// (so this tick mints the route) and the wake object. The wake must land. |
| +func TestSidecarWakeLegCreateThenWakeInOneTickStillWakesIt(t *testing.T) { |
| + ctx, s := genKit(t) |
| + reg := restartTestRegistry(t) |
| + mem := newSidecarWakeMemory() |
| + sidecarWakeSeed(ctx, reg, s, mem) // arming: no sidecar routes yet |
| + |
| + // create + wake, both before this box's next tick. |
| + minted := wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot") |
| + if err := reg.Upsert(minted); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| + |
| + var mu sync.Mutex |
| + var woken []string |
| + wake := func(rt registry.Route) error { |
| + mu.Lock() |
| + woken = append(woken, rt.Host) |
| + mu.Unlock() |
| + return nil |
| + } |
| + moved, errs := sidecarWakeLeg(ctx, []registry.Route{minted}, s, mem, wake) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if len(moved) != 1 || moved[0].Host != "bot.eleven.sidecar.internal" { |
| + t.Fatalf("leg reported %v, want the just-created sidecar — the CLI already told the member this wake was accepted", moved) |
| + } |
| + waitForCond(t, "the just-created sidecar to be woken", func() bool { |
| + mu.Lock() |
| + defer mu.Unlock() |
| + return slices.Equal(woken, []string{"bot.eleven.sidecar.internal"}) |
| + }) |
| +} |
| + |
| +// The other side of the same coin, end to end: an agent RESTART on a box |
| +// whose sidecars have been woken many times before wakes nothing at all. |
| +func TestSidecarWakeLegAfterAnAgentRestartReplaysNothing(t *testing.T) { |
| + ctx, s := genKit(t) |
| + reg := restartTestRegistry(t) |
| + routes := []registry.Route{ |
| + wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"), |
| + wakeSidecarRoute("other.eleven.sidecar.internal", "bdm", "eleven", "other"), |
| + } |
| + for _, rt := range routes { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert %s: %v", rt.Host, err) |
| + } |
| + } |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") // history |
| + touchSidecarWake(t, ctx, s, "bdm", "eleven", "other") // history |
| + |
| + mem := newSidecarWakeMemory() // a fresh process |
| + sidecarWakeSeed(ctx, reg, s, mem) |
| + if len(mem.etags) != 2 { |
| + t.Fatalf("the arming pass recorded %d etags, want 2 — a baseline that records nothing is no baseline at all", len(mem.etags)) |
| + } |
| + |
| + var mu sync.Mutex |
| + var woken []string |
| + wake := func(rt registry.Route) error { |
| + mu.Lock() |
| + woken = append(woken, rt.Host) |
| + mu.Unlock() |
| + return nil |
| + } |
| + moved, errs := sidecarWakeLeg(ctx, routes, s, mem, wake) |
| + if len(errs) != 0 { |
| + t.Fatalf("unexpected read errors: %v", errs) |
| + } |
| + if len(moved) != 0 { |
| + t.Fatalf("an agent restart replayed %v — every wake these sidecars ever received would fire at once", moved) |
| + } |
| + time.Sleep(50 * time.Millisecond) // the dispatch is asynchronous; give it a chance to be wrong |
| + mu.Lock() |
| + defer mu.Unlock() |
| + if len(woken) != 0 { |
| + t.Fatalf("woke %v on an agent restart, want none", woken) |
| + } |
| +} |
| + |
| // TestSidecarWakeLegReturnsBeforeASlowWakeCompletes: the glued leg itself |
| // must not block on WakeWorker either — the property dispatchSidecarWakes |
| // provides must survive being called from inside sidecarWakeLeg. |
| func TestSidecarWakeLegReturnsBeforeASlowWakeCompletes(t *testing.T) { |
| ctx, s := genKit(t) |
| routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")} |
| - cache := map[[3]string]string{} |
| - sidecarWakeChanges(ctx, routes, s, cache) // seed |
| + mem := newSidecarWakeMemory() |
| + sidecarWakeChanges(ctx, routes, s, mem) // seed |
| touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") |
| |
| slow := make(chan struct{}) |
| @@ -388,7 +548,7 @@ func TestSidecarWakeLegReturnsBeforeASlowWakeCompletes(t *testing.T) { |
| var moved []registry.Route |
| done := make(chan struct{}) |
| go func() { |
| - moved, _ = sidecarWakeLeg(ctx, routes, s, cache, wake) |
| + moved, _ = sidecarWakeLeg(ctx, routes, s, mem, wake) |
| close(done) |
| }() |
| select { |