carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "slices"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/registry"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// wakeSidecarRoute is sidecarRoute (restart_test.go) with Slug set — the
17// field a sidecar route carries its short name on (registry.Route.Slug's
18// doc comment; provision's reconciler stamps it from the sidecar record).
19// sidecarWakeChanges keys entirely off it, so a fixture that omits it is
20// useless here even though restart_test.go's sidecarRoute never needed it.
21func wakeSidecarRoute(host, acct, app, slug string) registry.Route {
22 rt := sidecarRoute(host, acct, app)
23 rt.Slug = slug
24 return rt
25}
26
27// touchSidecarWake bumps name's wake generation through the real writer
28// (release.TouchSidecarWake) — the same door the console's wake handler
29// (Task 12) will call, so these tests exercise the actual signal a box
30// sees rather than a hand-rolled stand-in for it.
31func touchSidecarWake(t *testing.T, ctx context.Context, s release.Store, acct, app, name string) {
32 t.Helper()
33 if err := release.TouchSidecarWake(ctx, s, acct, app, name, "member:bdm", "test wake"); err != nil {
34 t.Fatalf("TouchSidecarWake %s/%s/%s: %v", acct, app, name, err)
35 }
36}
37
38// ---- sidecarWakeChanges: the synchronous etag comparison ------------------
39
40func TestSidecarWakeChangesEtagMoveReportsExactlyThatSidecar(t *testing.T) {
41 ctx, s := genKit(t)
42 routes := []registry.Route{
43 wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"),
44 wakeSidecarRoute("other.eleven.sidecar.internal", "bdm", "eleven", "other"),
45 }
46 mem := newSidecarWakeMemory(false)
47 sidecarWakeChanges(ctx, routes, s, mem) // seed: first sight of both, silently
48
49 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
50 got, errs := sidecarWakeChanges(ctx, routes, s, mem)
51 if len(errs) != 0 {
52 t.Fatalf("unexpected read errors: %v", errs)
53 }
54 if len(got) != 1 || got[0].Host != "bot.eleven.sidecar.internal" {
55 t.Fatalf("sidecarWakeChanges = %v, want exactly the moved sidecar's route", got)
56 }
57}
58
59func TestSidecarWakeChangesUnchangedEtagReportsNothing(t *testing.T) {
60 ctx, s := genKit(t)
61 routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
62 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
63 mem := newSidecarWakeMemory(false)
64 sidecarWakeChanges(ctx, routes, s, mem) // seed
65
66 got, errs := sidecarWakeChanges(ctx, routes, s, mem)
67 if len(errs) != 0 {
68 t.Fatalf("unexpected read errors: %v", errs)
69 }
70 if len(got) != 0 {
71 t.Fatalf("unchanged etag reported %v, want none", got)
72 }
73}
74
75// TestSidecarWakeChangesBaselinePassDoesNotReplay is the no-replay rule at
76// the point it applies: the BASELINE pass — arming, i.e. an agent restart —
77// must not treat "I have never looked at this before" as movement, or every
78// restart would replay every wake this sidecar has ever received, a
79// thundering herd on boot. (First sight AFTER the baseline is a different
80// fact entirely — a route minted under a running agent — and is woken; see
81// TestSidecarWakeChangesRouteMintedAfterTheBaselineIsWoken.)
82func TestSidecarWakeChangesBaselinePassDoesNotReplay(t *testing.T) {
83 ctx, s := genKit(t)
84 // Wake object already exists BEFORE this box ever looks — the shape a
85 // long-lived wake request or a box that just (re)started sees.
86 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
87 routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
88 mem := newSidecarWakeMemory(false)
89
90 got, errs := sidecarWakeChanges(ctx, routes, s, mem)
91 if len(errs) != 0 {
92 t.Fatalf("unexpected read errors: %v", errs)
93 }
94 if len(got) != 0 {
95 t.Fatalf("first sight reported %v — a box that just started must not replay every past wake", got)
96 }
97 want := sidecarWakeStoreEtag(t, ctx, s, "bdm", "eleven", "bot")
98 if got := mem.etags[[3]string{"bdm", "eleven", "bot"}]; got != want {
99 t.Fatalf("cache etag = %q, want the store's %q", got, want)
100 }
101
102 // A SECOND wake request after the seed IS movement.
103 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
104 got, errs = sidecarWakeChanges(ctx, routes, s, mem)
105 if len(errs) != 0 {
106 t.Fatalf("unexpected read errors: %v", errs)
107 }
108 if len(got) != 1 || got[0].Host != "bot.eleven.sidecar.internal" {
109 t.Fatalf("second request reported %v, want exactly the sidecar", got)
110 }
111}
112
113// TestSidecarWakeChangesRouteMintedAfterTheBaselineIsWoken is the
114// create-then-wake flow, which is the feature's PRIMARY one: an operator
115// wake is a hibernating sidecar's only first-run mechanism, and
116// `carlos sidecars create` + `carlos sidecars wake` routinely land in the
117// same tick — the tick that mints the route. Seeding that route's etag
118// silently (the baseline rule, over-applied) loses the wake outright while
119// the CLI reports success: a bot that never runs.
120func TestSidecarWakeChangesRouteMintedAfterTheBaselineIsWoken(t *testing.T) {
121 ctx, s := genKit(t)
122 existing := wakeSidecarRoute("old.eleven.sidecar.internal", "bdm", "eleven", "old")
123 mem := newSidecarWakeMemory(false)
124 sidecarWakeChanges(ctx, []registry.Route{existing}, s, mem) // arming: this box's baseline
125
126 // The console creates a sidecar and wakes it; this box's next tick mints
127 // the route AND sees the wake object for the first time, together.
128 minted := wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")
129 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
130
131 got, errs := sidecarWakeChanges(ctx, []registry.Route{existing, minted}, s, mem)
132 if len(errs) != 0 {
133 t.Fatalf("unexpected read errors: %v", errs)
134 }
135 if len(got) != 1 || got[0].Host != "bot.eleven.sidecar.internal" {
136 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)
137 }
138}
139
140// A route minted after the baseline with NO wake object is silent: only a
141// PRESENT object makes a first sight a wake, so ordinary provisioning never
142// wakes a hibernating sidecar nobody asked to run.
143func TestSidecarWakeChangesRouteMintedWithNoWakeObjectIsSilent(t *testing.T) {
144 ctx, s := genKit(t)
145 mem := newSidecarWakeMemory(false)
146 sidecarWakeChanges(ctx, nil, s, mem) // arming on a box with no sidecars
147
148 minted := wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")
149 got, errs := sidecarWakeChanges(ctx, []registry.Route{minted}, s, mem)
150 if len(errs) != 0 {
151 t.Fatalf("unexpected read errors: %v", errs)
152 }
153 if len(got) != 0 {
154 t.Fatalf("reported %v — a newly minted sidecar nobody has ever asked to wake must stay asleep", got)
155 }
156
157 // …and it behaves as an ordinary member from then on: the first real
158 // request moves the etag and wakes it.
159 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
160 got, _ = sidecarWakeChanges(ctx, []registry.Route{minted}, s, mem)
161 if len(got) != 1 || got[0].Host != "bot.eleven.sidecar.internal" {
162 t.Fatalf("reported %v, want the sidecar on its first real wake request", got)
163 }
164}
165
166// An arming pass whose route LIST failed must not leave the box thinking
167// every sidecar it serves is brand new: the memory stays unseeded, so the
168// first leg pass establishes the baseline itself instead of replaying every
169// historical wake at once.
170func TestSidecarWakeSeedListFailureLeavesTheBaselineToTheFirstPass(t *testing.T) {
171 ctx, s := genKit(t)
172 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") // history, from before this agent
173 routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
174 mem := newSidecarWakeMemory(false) // arming never completed: no List, nothing seeded
175
176 got, errs := sidecarWakeChanges(ctx, routes, s, mem)
177 if len(errs) != 0 {
178 t.Fatalf("unexpected read errors: %v", errs)
179 }
180 if len(got) != 0 {
181 t.Fatalf("reported %v — with no baseline established, the first pass IS the baseline, not a replay of every past wake", got)
182 }
183}
184
185func sidecarWakeStoreEtag(t *testing.T, ctx context.Context, s release.Store, acct, app, name string) string {
186 t.Helper()
187 _, etag, err := s.Get(ctx, release.SidecarWakeKey(acct, app, name))
188 if err != nil {
189 t.Fatalf("read sidecar wake: %v", err)
190 }
191 return etag
192}
193
194// TestSidecarWakeChangesReadErrorDoesNotSuppressOthers: a transient read
195// failure against one sidecar's wake object must not blind the pass to a
196// real move on a different one in the same tick.
197func TestSidecarWakeChangesReadErrorDoesNotSuppressOthers(t *testing.T) {
198 ctx, s0 := genKit(t)
199 routes := []registry.Route{
200 wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"),
201 wakeSidecarRoute("worker.titogo.sidecar.internal", "bdm", "titogo", "worker"),
202 }
203 mem := newSidecarWakeMemory(false)
204 sidecarWakeChanges(ctx, routes, s0, mem) // seed both, absent
205
206 touchSidecarWake(t, ctx, s0, "bdm", "eleven", "bot")
207 touchSidecarWake(t, ctx, s0, "bdm", "titogo", "worker")
208 failing := &failingKeyStore{Store: s0, failKey: release.SidecarWakeKey("bdm", "eleven", "bot"), err: errors.New("bucket blip")}
209
210 got, errs := sidecarWakeChanges(ctx, routes, failing, mem)
211 if len(errs) != 1 {
212 t.Fatalf("errs = %v, want exactly one (the failing key)", errs)
213 }
214 if len(got) != 1 || got[0].Host != "worker.titogo.sidecar.internal" {
215 t.Fatalf("reported %v, want only the unaffected sidecar moved", got)
216 }
217}
218
219// TestSidecarWakeChangesIgnoresUnservedSidecar: a wake object naming a
220// sidecar this box does not serve is never even read, let alone reported —
221// the pass only ever asks about routes this box actually has.
222func TestSidecarWakeChangesIgnoresUnservedSidecar(t *testing.T) {
223 ctx, s := genKit(t)
224 touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost") // no route for "ghost" at all
225 routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
226 mem := newSidecarWakeMemory(false)
227 sidecarWakeChanges(ctx, routes, s, mem) // seed
228
229 touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost") // "ghost" moves again
230 got, errs := sidecarWakeChanges(ctx, routes, s, mem)
231 if len(errs) != 0 {
232 t.Fatalf("unexpected read errors: %v", errs)
233 }
234 if len(got) != 0 {
235 t.Fatalf("an unserved sidecar's wake object was reported: %v, want none", got)
236 }
237 if _, ok := mem.etags[[3]string{"bdm", "eleven", "ghost"}]; ok {
238 t.Fatal("a sidecar this box does not serve must never appear in its cache")
239 }
240}
241
242// TestSidecarWakeChangesIgnoresNonSidecarRouteWithMatchingSlug: Slug is not
243// a sidecar-only field (registry.Route.Slug's doc comment — it is the
244// app-account short name usable on a root domain, stamped on ordinary
245// routes too), so an ordinary instance route that happens to carry the
246// same Slug as a real sidecar name must never be mistaken for that
247// sidecar.
248func TestSidecarWakeChangesIgnoresNonSidecarRouteWithMatchingSlug(t *testing.T) {
249 ctx, s := genKit(t)
250 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
251 notSidecar := execRoute("app.example.com", "bdm", "eleven")
252 notSidecar.Slug = "bot" // same name a sidecar could carry, wrong Kind
253 routes := []registry.Route{notSidecar}
254 mem := newSidecarWakeMemory(false)
255
256 got, errs := sidecarWakeChanges(ctx, routes, s, mem)
257 if len(errs) != 0 {
258 t.Fatalf("unexpected read errors: %v", errs)
259 }
260 if len(got) != 0 {
261 t.Fatalf("a non-sidecar route with a matching Slug was reported: %v, want none", got)
262 }
263 if len(mem.etags) != 0 {
264 t.Fatalf("cache = %v, want empty — a non-sidecar route must never seed a sidecar cache entry", mem.etags)
265 }
266}
267
268// ---- dispatchSidecarWakes: the bounded-concurrency, non-blocking half ----
269
270// TestDispatchSidecarWakesReturnsBeforeASlowWakeCompletes pins fix round 1's
271// finding directly: WakeWorker can block up to 30s per sidecar (the settle
272// window), and dispatchSidecarWakes' only caller (sidecarWakeLeg) runs
273// synchronously on the box's single per-tick convergence goroutine — so
274// this function must never wait on a wake, or one slow sidecar delays every
275// other app's turn on that tick. Asserted against a deliberately slow fake
276// wake, the same elapsed-time shape Task 8's boot pass and Task 11's
277// TestCycleWorkersReturnsBeforeASlowWakeCompletes both use.
278func TestDispatchSidecarWakesReturnsBeforeASlowWakeCompletes(t *testing.T) {
279 moved := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
280 slow := make(chan struct{})
281 wake := func(rt registry.Route) error { <-slow; return nil }
282
283 // Run under a deadline rather than measuring elapsed time on the test's
284 // own goroutine (fix round 3): under a REGRESSION dispatchSidecarWakes
285 // itself never returns, so a synchronous call plus an elapsed-time
286 // check never fails — it hangs until Go's 10-minute default test
287 // timeout. Racing a goroutine against time.After (the same shape
288 // TestDispatchSidecarWakesBoundsConcurrency already uses) makes a
289 // regression here fail cleanly and promptly, in this test alone.
290 done := make(chan struct{})
291 go func() {
292 dispatchSidecarWakes(moved, wake)
293 close(done)
294 }()
295 select {
296 case <-done:
297 close(slow) // release the parked wake goroutine so the test can exit cleanly
298 case <-time.After(time.Second):
299 close(slow)
300 t.Fatal("dispatchSidecarWakes did not return within 1s with a wake blocked indefinitely — wakes must be dispatched asynchronously, not awaited")
301 }
302}
303
304// TestDispatchSidecarWakesWakesEveryMovedSidecarEventually: dispatching
305// asynchronously must not mean dropping any of them — every moved sidecar
306// still gets woken, just not before this function returns.
307func TestDispatchSidecarWakesWakesEveryMovedSidecarEventually(t *testing.T) {
308 moved := []registry.Route{
309 wakeSidecarRoute("bot.a.sidecar.internal", "bdm", "eleven", "a"),
310 wakeSidecarRoute("bot.b.sidecar.internal", "bdm", "eleven", "b"),
311 }
312 var mu sync.Mutex
313 var woken []string
314 wake := func(rt registry.Route) error {
315 mu.Lock()
316 woken = append(woken, rt.Host)
317 mu.Unlock()
318 return nil
319 }
320 dispatchSidecarWakes(moved, wake)
321 waitForCond(t, "both sidecars to be woken", func() bool {
322 mu.Lock()
323 defer mu.Unlock()
324 slices.Sort(woken)
325 return slices.Equal(woken, []string{"bot.a.sidecar.internal", "bot.b.sidecar.internal"})
326 })
327}
328
329// TestDispatchSidecarWakesBoundsConcurrency pins the carried requirement
330// that wakes must not fan out unbounded: N sidecars strictly more than
331// workerBootConcurrency drive more in-flight wakes than the bound and prove
332// it is actually reached (not stuck at 1, which a serial dispatch would
333// show) and never exceeded (which an unbounded one would show) — the same
334// shape TestWorkerBootPassWakesConcurrently pins for Task 8's boot pass.
335func TestDispatchSidecarWakesBoundsConcurrency(t *testing.T) {
336 t.Parallel()
337 n := workerBootConcurrency + 3
338 moved := make([]registry.Route, n)
339 for i := range moved {
340 moved[i] = wakeSidecarRoute("s"+string(rune('a'+i))+".x.sidecar.internal", "bdm", "eleven", "s"+string(rune('a'+i)))
341 }
342 var mu sync.Mutex
343 inFlight, maxInFlight := 0, 0
344 release := make(chan struct{})
345 wake := func(registry.Route) error {
346 mu.Lock()
347 inFlight++
348 if inFlight > maxInFlight {
349 maxInFlight = inFlight
350 }
351 mu.Unlock()
352 <-release
353 mu.Lock()
354 inFlight--
355 mu.Unlock()
356 return nil
357 }
358
359 // dispatchSidecarWakes' own dispatch loop blocks on sem once the bound
360 // saturates (it is a bounded fan-out, not a fire-and-forget launcher) —
361 // exactly runWorkerBootPass's own shape, and its test
362 // (TestWorkerBootPassWakesConcurrently) runs the call in a goroutine for
363 // the same reason: the test needs to observe the saturated state and
364 // then release it, which it cannot do if the call under test is itself
365 // the thing blocking the test's only goroutine.
366 done := make(chan struct{})
367 go func() {
368 dispatchSidecarWakes(moved, wake)
369 close(done)
370 }()
371
372 waitForCond(t, "wakes to saturate the concurrency bound", func() bool {
373 mu.Lock()
374 defer mu.Unlock()
375 return inFlight == workerBootConcurrency
376 })
377 // A beat with the bound already saturated: an unbounded implementation
378 // would keep climbing past it here rather than holding still.
379 time.Sleep(50 * time.Millisecond)
380 mu.Lock()
381 got := maxInFlight
382 mu.Unlock()
383 if got != workerBootConcurrency {
384 t.Fatalf("max concurrent wakes = %d, want exactly the bound %d", got, workerBootConcurrency)
385 }
386
387 close(release)
388 select {
389 case <-done:
390 case <-time.After(5 * time.Second):
391 t.Fatal("dispatch loop did not finish after its wakes were released")
392 }
393}
394
395// TestDispatchSidecarWakesWithoutAnActivatorIsANoop: a box with no
396// activator armed gets nil for wake — a working no-op, not a panic.
397func TestDispatchSidecarWakesWithoutAnActivatorIsANoop(t *testing.T) {
398 moved := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
399 dispatchSidecarWakes(moved, nil) // must not panic
400}
401
402// ---- sidecarWakeLeg: the wired combination fastConvergeLoop calls --------
403
404// TestSidecarWakeLegActsOnBumpedGeneration is the "wired into the
405// convergence path" pin: a real TouchSidecarWake write, run through the
406// seed-then-leg shape fastConvergeLoop actually uses (sidecarWakeSeed at
407// arming, sidecarWakeLeg on every serial move), must reach WakeWorker for
408// exactly the sidecar the console asked for.
409func TestSidecarWakeLegActsOnBumpedGeneration(t *testing.T) {
410 t.Parallel()
411 ctx, s := genKit(t)
412 routes := []registry.Route{
413 wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"),
414 wakeSidecarRoute("other.eleven.sidecar.internal", "bdm", "eleven", "other"),
415 }
416 reg := restartTestRegistry(t)
417 for _, rt := range routes {
418 if err := reg.Upsert(rt); err != nil {
419 t.Fatalf("upsert %s: %v", rt.Host, err)
420 }
421 }
422 mem := newSidecarWakeMemory(false)
423 sidecarWakeSeed(ctx, reg, s, mem)
424
425 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
426 var mu sync.Mutex
427 var woken []string
428 wake := func(rt registry.Route) error {
429 mu.Lock()
430 woken = append(woken, rt.Host)
431 mu.Unlock()
432 return nil
433 }
434 moved, errs := sidecarWakeLeg(ctx, routes, s, mem, wake)
435 if len(errs) != 0 {
436 t.Fatalf("unexpected read errors: %v", errs)
437 }
438 if len(moved) != 1 || moved[0].Host != "bot.eleven.sidecar.internal" {
439 t.Fatalf("sidecarWakeLeg reported %v, want exactly the bumped sidecar", moved)
440 }
441 waitForCond(t, "the bumped sidecar to be woken", func() bool {
442 mu.Lock()
443 defer mu.Unlock()
444 return slices.Equal(woken, []string{"bot.eleven.sidecar.internal"})
445 })
446}
447
448// TestSidecarWakeLegCreateThenWakeInOneTickStillWakesIt is the blocker as a
449// member would meet it, through the leg fastConvergeLoop actually calls: the
450// agent arms with no sidecars, then one serial move carries BOTH the record
451// (so this tick mints the route) and the wake object. The wake must land.
452func TestSidecarWakeLegCreateThenWakeInOneTickStillWakesIt(t *testing.T) {
453 t.Parallel()
454 ctx, s := genKit(t)
455 reg := restartTestRegistry(t)
456 mem := newSidecarWakeMemory(false)
457 sidecarWakeSeed(ctx, reg, s, mem) // arming: no sidecar routes yet
458
459 // create + wake, both before this box's next tick.
460 minted := wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")
461 if err := reg.Upsert(minted); err != nil {
462 t.Fatalf("upsert: %v", err)
463 }
464 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
465
466 var mu sync.Mutex
467 var woken []string
468 wake := func(rt registry.Route) error {
469 mu.Lock()
470 woken = append(woken, rt.Host)
471 mu.Unlock()
472 return nil
473 }
474 moved, errs := sidecarWakeLeg(ctx, []registry.Route{minted}, s, mem, wake)
475 if len(errs) != 0 {
476 t.Fatalf("unexpected read errors: %v", errs)
477 }
478 if len(moved) != 1 || moved[0].Host != "bot.eleven.sidecar.internal" {
479 t.Fatalf("leg reported %v, want the just-created sidecar — the CLI already told the member this wake was accepted", moved)
480 }
481 waitForCond(t, "the just-created sidecar to be woken", func() bool {
482 mu.Lock()
483 defer mu.Unlock()
484 return slices.Equal(woken, []string{"bot.eleven.sidecar.internal"})
485 })
486}
487
488// The other side of the same coin, end to end: an agent RESTART on a box
489// whose sidecars have been woken many times before wakes nothing at all.
490func TestSidecarWakeLegAfterAnAgentRestartReplaysNothing(t *testing.T) {
491 t.Parallel()
492 ctx, s := genKit(t)
493 reg := restartTestRegistry(t)
494 routes := []registry.Route{
495 wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"),
496 wakeSidecarRoute("other.eleven.sidecar.internal", "bdm", "eleven", "other"),
497 }
498 for _, rt := range routes {
499 if err := reg.Upsert(rt); err != nil {
500 t.Fatalf("upsert %s: %v", rt.Host, err)
501 }
502 }
503 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") // history
504 touchSidecarWake(t, ctx, s, "bdm", "eleven", "other") // history
505
506 mem := newSidecarWakeMemory(false) // a fresh process
507 sidecarWakeSeed(ctx, reg, s, mem)
508 if len(mem.etags) != 2 {
509 t.Fatalf("the arming pass recorded %d etags, want 2 — a baseline that records nothing is no baseline at all", len(mem.etags))
510 }
511
512 var mu sync.Mutex
513 var woken []string
514 wake := func(rt registry.Route) error {
515 mu.Lock()
516 woken = append(woken, rt.Host)
517 mu.Unlock()
518 return nil
519 }
520 moved, errs := sidecarWakeLeg(ctx, routes, s, mem, wake)
521 if len(errs) != 0 {
522 t.Fatalf("unexpected read errors: %v", errs)
523 }
524 if len(moved) != 0 {
525 t.Fatalf("an agent restart replayed %v — every wake these sidecars ever received would fire at once", moved)
526 }
527 time.Sleep(50 * time.Millisecond) // the dispatch is asynchronous; give it a chance to be wrong
528 mu.Lock()
529 defer mu.Unlock()
530 if len(woken) != 0 {
531 t.Fatalf("woke %v on an agent restart, want none", woken)
532 }
533}
534
535// TestSidecarWakeLegReturnsBeforeASlowWakeCompletes: the glued leg itself
536// must not block on WakeWorker either — the property dispatchSidecarWakes
537// provides must survive being called from inside sidecarWakeLeg.
538func TestSidecarWakeLegReturnsBeforeASlowWakeCompletes(t *testing.T) {
539 ctx, s := genKit(t)
540 routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
541 mem := newSidecarWakeMemory(false)
542 sidecarWakeChanges(ctx, routes, s, mem) // seed
543 touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
544
545 slow := make(chan struct{})
546 wake := func(rt registry.Route) error { <-slow; return nil }
547
548 // Deadline-vs-elapsed-time correction, same as
549 // TestDispatchSidecarWakesReturnsBeforeASlowWakeCompletes (fix round 3):
550 // a synchronous call on the test's own goroutine never fails under a
551 // regression, it hangs.
552 var moved []registry.Route
553 done := make(chan struct{})
554 go func() {
555 moved, _ = sidecarWakeLeg(ctx, routes, s, mem, wake)
556 close(done)
557 }()
558 select {
559 case <-done:
560 close(slow)
561 case <-time.After(time.Second):
562 close(slow)
563 t.Fatal("sidecarWakeLeg did not return within 1s with a wake blocked indefinitely — the wake must be dispatched asynchronously")
564 }
565
566 if len(moved) != 1 {
567 t.Fatalf("moved = %v, want exactly one (detection is synchronous)", moved)
568 }
569}
570
571// ---- wired into fastTick: rides the same serial move as the restart leg --
572
573// TestFastTickCallsWakeLegOnSerialChange pins the wiring itself: fastTick
574// must call fastDeps.wakeLeg exactly when it calls restartLeg — on a serial
575// move, after fullPass — or `carlos sidecars wake` writes a generation
576// object no box ever reads. Same shape as TestFastTickActsOnSerialChangeNotBoot,
577// with a call-counting wakeLeg spy standing in for the production closure.
578func TestFastTickCallsWakeLegOnSerialChange(t *testing.T) {
579 env := newFastFixture(t)
580 var calls int
581 restart := func(string) error { return nil }
582 d := &fastDeps{
583 reg: env.reg, store: env.store, pub: env.pub, optDir: env.optDir,
584 restart: restart,
585 passOpts: fastPassOpts(env.dataDir, func(string, string, error) {}, restart, func(string) error { return nil }),
586 fullPass: func(context.Context) {},
587 wakeLeg: func(context.Context) { calls++ },
588 }
589 now := time.Unix(1754640000, 0)
590 fastTick(context.Background(), d, now) // no serial object at all: seeds at zero, runs nothing
591 if calls != 0 {
592 t.Fatalf("wakeLeg ran on the boot seed, want 0 calls, got %d", calls)
593 }
594 if err := release.BumpSerial(context.Background(), env.store, now); err != nil {
595 t.Fatalf("BumpSerial: %v", err)
596 }
597 fastTick(context.Background(), d, now.Add(2*time.Second))
598 if calls != 1 {
599 t.Fatalf("wakeLeg calls = %d, want exactly 1 on a serial move", calls)
600 }
601 fastTick(context.Background(), d, now.Add(10*time.Second)) // past clamp, unchanged serial
602 if calls != 1 {
603 t.Fatalf("wakeLeg calls = %d after an unchanged serial, want still 1", calls)
604 }
605}
606
607// TestFastTickToleratesANilWakeLeg: a box built before this feature (or a
608// test fixture that never sets one) must not panic — wakeLeg is optional
609// exactly like restartLeg.
610func TestFastTickToleratesANilWakeLeg(t *testing.T) {
611 env := newFastFixture(t)
612 d := env.deps(nil) // deps() never sets wakeLeg
613 now := time.Unix(1754640000, 0)
614 if err := release.BumpSerial(context.Background(), env.store, now); err != nil {
615 t.Fatalf("BumpSerial: %v", err)
616 }
617 fastTick(context.Background(), d, now) // must not panic
618}
619
620// TestDispatchSidecarWakesReturnsPromptlyWithManySlowWakesQueued is
621// dispatchSidecarWakes' half of review round 2's regression pin (see
622// restart_test.go's TestCycleWorkersReturnsPromptlyWithManySlowWakesQueued
623// for the full shape of the bug this catches): strictly more than
624// workerBootConcurrency sidecars, every wake deliberately parked, must
625// still return this function immediately — the whole bounded fan-out runs
626// inside dispatchBoundedWakes' own goroutine now, not just each individual
627// wake dispatched from a loop that can itself stall once the bound fills.
628func TestDispatchSidecarWakesReturnsPromptlyWithManySlowWakesQueued(t *testing.T) {
629 n := workerBootConcurrency + 5
630 moved := make([]registry.Route, n)
631 for i := range moved {
632 moved[i] = wakeSidecarRoute(fmt.Sprintf("s%d.x.sidecar.internal", i), "bdm", "eleven", fmt.Sprintf("s%d", i))
633 }
634 slow := make(chan struct{})
635 var mu sync.Mutex
636 woken := map[string]bool{}
637 wake := func(rt registry.Route) error {
638 <-slow
639 mu.Lock()
640 woken[rt.Host] = true
641 mu.Unlock()
642 return nil
643 }
644
645 // Same deadline-vs-elapsed-time correction as
646 // TestDispatchSidecarWakesReturnsBeforeASlowWakeCompletes (fix round 3).
647 done := make(chan struct{})
648 go func() {
649 dispatchSidecarWakes(moved, wake)
650 close(done)
651 }()
652 select {
653 case <-done:
654 case <-time.After(time.Second):
655 close(slow)
656 t.Fatalf("dispatchSidecarWakes did not return within 1s with %d wakes blocked (bound %d) — the whole bounded fan-out must run off the caller's goroutine", n, workerBootConcurrency)
657 }
658
659 close(slow)
660 waitForCond(t, "every queued sidecar to be woken eventually", func() bool {
661 mu.Lock()
662 defer mu.Unlock()
663 return len(woken) == n
664 })
665}
666