carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package activator
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "slices"
9 "strings"
10 "sync"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/hostclaim"
15 "github.com/carlosframework/platform/internal/hostwrite"
16 "github.com/carlosframework/platform/internal/registry"
17 "github.com/carlosframework/platform/internal/release"
18)
19
20// A hold is stop-then-start through the barrier (spec §7.8). A request that
21// arrives during the stop is held, not refused, and is served by the new
22// generation, which execs the attempt's staged build while the live link
23// names something else. This is the witness for mutation M3.
24func TestSwapWitnessHoldExecsTheStagedBuild(t *testing.T) {
25 t.Parallel()
26 f := newSwapFixture(t)
27 f.b.execVerFn = func() string { return "v3" }
28 gate := make(chan struct{})
29 f.b.lastProc().setStopGate(gate)
30 results := make(chan SwapResult, 1)
31 go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest) }()
32 waitFor(t, "the hold's stop", func() bool {
33 f.a.mu.Lock()
34 defer f.a.mu.Unlock()
35 e := f.a.entries[f.host]
36 return e != nil && e.state == actStopping
37 })
38 stopSeen := time.Now() // the old generation's stop began before this
39 served := make(chan error, 1)
40 waiting := &doneAskedCtx{Context: context.Background(), asked: make(chan struct{})}
41 go func() {
42 release, err := f.a.beginRequest(waiting, testRoute(f.host))
43 if err == nil {
44 release()
45 }
46 served <- err
47 }()
48 // The request has reached its wait on the stopping entry: begin asks
49 // its context's Done only there (the first select it reaches).
50 <-waiting.asked
51 select {
52 case err := <-served:
53 t.Fatalf("the request was answered (%v) while the old generation was still stopping", err)
54 default:
55 }
56 gateOpen := time.Now()
57 close(gate)
58 var res SwapResult
59 select {
60 case res = <-results:
61 case <-time.After(10 * time.Second):
62 t.Fatal("the hold never finished")
63 }
64 if res.Outcome != SwapDone || res.Gen.Build != "v2" || res.Gen.StagedPath != f.staged {
65 t.Fatalf("Swap = %+v", res)
66 }
67 if held := gateOpen.Sub(stopSeen); res.Drain < held {
68 t.Fatalf("Drain = %v, want at least the old generation's gated stop, %v (spec §9.4)", res.Drain, held)
69 }
70 if err := <-served; err != nil {
71 t.Fatalf("the held request was refused: %v", err)
72 }
73 // #231: one live-link Start (the original wake). The hold's wake went
74 // through StartGen alone.
75 if got := f.b.startCount(); got != 1 {
76 t.Fatalf("Start (live link) calls = %d, want 1", got)
77 }
78 if gens, _, _ := f.b.calls(); gens != 1 {
79 t.Fatalf("StartGen calls = %d, want 1", gens)
80 }
81 if got := f.appSocket(t); got != res.Gen.Socket {
82 t.Fatalf("AppSocket = %q, want the new generation's %q", got, res.Gen.Socket)
83 }
84}
85
86// doneAskedCtx is a context that signals the first time its Done channel
87// is asked for: begin() asks only when it reaches a wait, so the signal is
88// the request held on the entry.
89type doneAskedCtx struct {
90 context.Context
91 once sync.Once
92 asked chan struct{}
93}
94
95func (c *doneAskedCtx) Done() <-chan struct{} {
96 c.once.Do(func() { close(c.asked) })
97 return c.Context.Done()
98}
99
100// A hold canary is not done when it is healthy: it must survive the 10 s
101// settle (spec §6.2, §8.5). Without the settle, swapHold answers SwapDone
102// the moment the pinned wake is ready, and a crash a second later neither
103// fails the attempt nor halts the rollout.
104func TestSwapHoldCanarySettles(t *testing.T) {
105 old := swapSettle
106 t.Cleanup(func() { swapSettle = old })
107 start := func(t *testing.T, f *swapFixture) (chan SwapResult, Gen, *fakeProc) {
108 t.Helper()
109 at := f.attempt(ModeHold)
110 at.Canary = true
111 results := make(chan SwapResult, 1)
112 go func() { results <- f.a.Swap(context.Background(), at, f.newest) }()
113 var g Gen
114 var p *fakeProc
115 waitFor(t, "the pinned generation awake", func() bool {
116 g, p = f.b.lastGen()
117 f.a.mu.Lock()
118 defer f.a.mu.Unlock()
119 e := f.a.entries[f.host]
120 return p != nil && e != nil && e.state == actAwake && e.gen.N == g.N
121 })
122 return results, g, p
123 }
124 t.Run("an exit in the window is down, with no fallback", func(t *testing.T) {
125 swapSettle = 5 * time.Second
126 f := newSwapFixture(t)
127 results, _, p := start(t, f)
128 select {
129 case res := <-results:
130 t.Fatalf("Swap answered %+v before the settle ended", res)
131 case <-time.After(50 * time.Millisecond):
132 }
133 p.exit()
134 res := <-results
135 if res.Outcome != SwapDown || !strings.Contains(res.Reason, errSettleExit.Error()) {
136 t.Fatalf("Swap = %+v, want down: %v", res, errSettleExit)
137 }
138 if gens, _, _ := f.b.calls(); gens != 1 || f.b.startCount() != 1 {
139 t.Fatalf("a failed hold canary fell back: StartGen %d, Start %d", gens, f.b.startCount())
140 }
141 })
142 t.Run("surviving the window is done", func(t *testing.T) {
143 swapSettle = 100 * time.Millisecond
144 f := newSwapFixture(t)
145 began := time.Now()
146 results, g, _ := start(t, f)
147 res := <-results
148 if res.Outcome != SwapDone || res.Gen.N != g.N {
149 t.Fatalf("Swap = %+v", res)
150 }
151 if took := time.Since(began); took < swapSettle {
152 t.Fatalf("done after %s, inside the %s settle", took, swapSettle)
153 }
154 })
155 t.Run("an intentional stop in the window is not a failure", func(t *testing.T) {
156 swapSettle = 5 * time.Second
157 f := newSwapFixture(t)
158 results, _, _ := start(t, f)
159 f.a.Bounce(f.host)
160 res := <-results
161 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapStopped.Error()) {
162 t.Fatalf("Swap = %+v, want abandoned: %v", res, errSwapStopped)
163 }
164 })
165}
166
167// A hold build that fails its health check leaves nothing serving and does
168// not fall back (spec §8.3).
169func TestSwapHoldFailureIsDown(t *testing.T) {
170 f := newSwapFixture(t)
171 f.a.genHealth = func(context.Context, Gen) error { return errors.New("unhealthy: HTTP 500") }
172 res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
173 if res.Outcome != SwapDown {
174 t.Fatalf("Swap = %+v, want down", res)
175 }
176 if f.a.entryCount() != 0 {
177 t.Fatal("a failed hold left an entry behind")
178 }
179 if _, p := f.b.lastGen(); p == nil || !p.isStopped() {
180 t.Fatal("the failed hold generation was left running")
181 }
182 if got := f.b.startCount(); got != 1 {
183 t.Fatalf("Start (live link) calls = %d: a failed hold fell back", got)
184 }
185}
186
187// A pinned wake that fails before it reaches the exec ran nothing of the
188// build: the hold is abandoned, never down (index P5/P6, spec §6.4: only a
189// hold build that executed is held down). Reported down, plan 5 records a
190// failure of a build that never ran and halts its cohort.
191func TestSwapHoldRefusedBeforeTheExecIsAbandoned(t *testing.T) {
192 t.Parallel()
193 for _, tc := range []struct {
194 name string
195 arrange func(f *swapFixture)
196 want string
197 }{
198 {"the lease is held elsewhere after the stop", func(f *swapFixture) {
199 f.b.mu.Lock()
200 f.b.acquireErr = &errLeaseHeld{holder: "box-b", retryAfter: time.Minute}
201 f.b.mu.Unlock()
202 }, "instance lease held"},
203 {"the restore fails", func(f *swapFixture) {
204 f.b.mu.Lock()
205 f.b.restoreErr = errors.New("replica unreadable")
206 f.b.mu.Unlock()
207 }, "replica unreadable"},
208 {"the staged build changed after the stop", func(f *swapFixture) {
209 f.b.mu.Lock()
210 f.b.restoreErr = nil
211 f.b.mu.Unlock()
212 f.a.backend = &acquireHookBackend{fakeSwapBackend: f.b, hook: func() {
213 if err := os.WriteFile(f.staged, []byte("not build v2"), 0o755); err != nil {
214 panic(err)
215 }
216 }}
217 }, "staging"},
218 } {
219 t.Run(tc.name, func(t *testing.T) {
220 f := newSwapFixture(t)
221 tc.arrange(f)
222 res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
223 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, tc.want) {
224 t.Fatalf("Swap = %+v, want abandoned: %s", res, tc.want)
225 }
226 if gens, _, _ := f.b.calls(); gens != 0 {
227 t.Fatalf("StartGen calls = %d, want 0", gens)
228 }
229 })
230 }
231}
232
233// lockedSink is an EventSink safe for the wake goroutine and the test.
234type lockedSink struct {
235 mu sync.Mutex
236 events []eventRec
237}
238
239func (s *lockedSink) App(string, string, string, string) {}
240func (s *lockedSink) Event(acct, app, host, level, msg string) {
241 s.mu.Lock()
242 s.events = append(s.events, eventRec{acct, app, host, level, msg})
243 s.mu.Unlock()
244}
245
246func (s *lockedSink) errors() []string {
247 s.mu.Lock()
248 defer s.mu.Unlock()
249 var out []string
250 for _, e := range s.events {
251 if e.level == "error" {
252 out = append(out, e.msg)
253 }
254 }
255 return out
256}
257
258// A pinned wake's refusal is the attempt's business, not a member's: an
259// abandoned hold emits no "wake failed" event, and no swap text reaches the
260// member's timeline (plan 5's EvSwapDown carries a failed hold's line). A
261// pinned build that ran and failed is reported in the wording any failed
262// wake already uses.
263func TestSwapHoldWakeEventsCarryNoSwapText(t *testing.T) {
264 t.Parallel()
265 t.Run("superseded before the exec emits nothing", func(t *testing.T) {
266 f := newSwapFixture(t)
267 sink := &lockedSink{}
268 f.a.Logs = sink
269 var once sync.Once
270 f.a.backend = &acquireHookBackend{fakeSwapBackend: f.b, hook: func() { once.Do(func() { f.rev.Add(1) }) }}
271 if res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest); res.Outcome != SwapAbandoned {
272 t.Fatalf("Swap = %+v, want abandoned", res)
273 }
274 if got := sink.errors(); len(got) != 0 {
275 t.Fatalf("member error events %q, want none for an abandoned hold", got)
276 }
277 })
278 t.Run("a mismatched spawn's text stays in the log", func(t *testing.T) {
279 f := newSwapFixture(t)
280 sink := &lockedSink{}
281 f.a.Logs = sink
282 f.a.backend = &mismatchSwapBackend{fakeSwapBackend: f.b}
283 if res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest); res.Outcome != SwapDown {
284 t.Fatalf("Swap = %+v, want down: the mismatched process ran", res)
285 }
286 for _, msg := range sink.errors() {
287 if strings.Contains(msg, "the hold chose") || strings.Contains(msg, "swap:") {
288 t.Fatalf("member event %q carries swap text", msg)
289 }
290 }
291 })
292 t.Run("a pinned build that failed its health uses the existing wording", func(t *testing.T) {
293 f := newSwapFixture(t)
294 sink := &lockedSink{}
295 f.a.Logs = sink
296 f.a.genHealth = func(context.Context, Gen) error { return errors.New("unhealthy: HTTP 500") }
297 if res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest); res.Outcome != SwapDown {
298 t.Fatalf("Swap = %+v, want down", res)
299 }
300 got := sink.errors()
301 if len(got) != 1 || !strings.HasPrefix(got[0], "wake failed: ") || strings.Contains(got[0], "swap:") {
302 t.Fatalf("member error events %q, want one existing \"wake failed: …\" line", got)
303 }
304 })
305}
306
307// mismatchSwapBackend runs a different build from the one it was asked for.
308type mismatchSwapBackend struct {
309 *fakeSwapBackend
310}
311
312func (b *mismatchSwapBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error) {
313 ran, p, err := b.fakeSwapBackend.StartStaged(ctx, rt, g)
314 ran.Build = "v9"
315 return ran, p, err
316}
317
318// acquireHookBackend runs hook inside Acquire: the window between a hold's
319// re-check at its stop and its pinned wake's exec.
320type acquireHookBackend struct {
321 *fakeSwapBackend
322 hook func()
323}
324
325func (b *acquireHookBackend) Acquire(ctx context.Context, rt registry.Route) (Lease, error) {
326 b.hook()
327 return b.fakeSwapBackend.Acquire(ctx, rt)
328}
329
330// Spec §6.3, §7.4, §8.1: a hold's attempt checks run again at the pinned
331// wake's exec and once more before its result counts. Checked only when
332// the stop finished, a newer attempt (or a halted cohort, which bumps the
333// revision) landing during the wake still exec'd the superseded build and
334// answered SwapDone, which records it good.
335func TestSwapHoldReChecksAtTheExecAndBeforeTheResult(t *testing.T) {
336 t.Run("superseded before the exec", func(t *testing.T) {
337 f := newSwapFixture(t)
338 var once sync.Once
339 f.a.backend = &acquireHookBackend{fakeSwapBackend: f.b, hook: func() { once.Do(func() { f.rev.Add(1) }) }}
340 res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
341 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) {
342 t.Fatalf("Swap = %+v, want abandoned: superseded", res)
343 }
344 if gens, _, _ := f.b.calls(); gens != 0 {
345 t.Fatalf("the superseded hold build was exec'd (%d)", gens)
346 }
347 })
348 t.Run("superseded before the result", func(t *testing.T) {
349 f := newSwapFixture(t)
350 f.a.genHealth = func(context.Context, Gen) error { f.rev.Add(1); return nil }
351 res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
352 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapSuperseded.Error()) {
353 t.Fatalf("Swap = %+v, want abandoned: a superseded result is never done", res)
354 }
355 })
356}
357
358// killingSwapBackend is fakeSwapBackend plus plan 2's KillGen. fail decides,
359// per generation, whether the child is still occupied.
360type killingSwapBackend struct {
361 *fakeSwapBackend
362 kmu sync.Mutex
363 fail func(n uint64) bool
364}
365
366func (k *killingSwapBackend) KillGen(_ context.Context, _ registry.Route, n uint64) error {
367 k.kmu.Lock()
368 defer k.kmu.Unlock()
369 if k.fail != nil && k.fail(n) {
370 return errors.New("the child still holds a process")
371 }
372 return nil
373}
374
375func (k *killingSwapBackend) setFail(f func(uint64) bool) {
376 k.kmu.Lock()
377 k.fail = f
378 k.kmu.Unlock()
379}
380
381// A failed wake gives its lease back only once its generation's child is
382// proven empty (spec §7.4). Until then the host takes no new wake, and the
383// sweep retries the proof. cleanupFailedWake that accepts the leader's exit
384// releases the lease over a surviving descendant and lets a new wake start
385// beside it.
386func TestSwapHoldFailedWakeKeepsTheLeaseUntilItsChildIsEmpty(t *testing.T) {
387 t.Parallel()
388 f := newSwapFixture(t)
389 kb := &killingSwapBackend{fakeSwapBackend: f.b}
390 kb.setFail(func(n uint64) bool { return n != f.incumbent.N }) // the hold's new generation only
391 f.a.backend = kb
392 f.a.genHealth = func(context.Context, Gen) error { return errors.New("unhealthy: HTTP 500") }
393 before := f.b.releaseCount()
394 if res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest); res.Outcome != SwapDown {
395 t.Fatalf("Swap = %+v, want down", res)
396 }
397 if got := f.b.releaseCount(); got != before+1 {
398 t.Fatalf("lease releases %d -> %d: want the old generation's only, the failed wake's kept", before, got)
399 }
400 if _, err := f.a.beginRequest(context.Background(), testRoute(f.host)); err == nil || !strings.Contains(err.Error(), errWakeUnproven.Error()) {
401 t.Fatalf("a new wake = %v, want refused while the failed generation is unproven", err)
402 }
403 kb.setFail(nil)
404 f.a.sweepOnce()
405 waitFor(t, "the failed wake's lease given back", func() bool { return f.b.releaseCount() == before+2 })
406}
407
408// The same when the failed wake's leader itself never stops: an unproven
409// stop (ErrStillRunning) is no termination, so the lease is kept until the
410// sweep's retried stop and proof hold. Retaining only when the leader had
411// exited released the lease over a live process.
412func TestSwapHoldFailedWakeWhoseLeaderNeverStopsKeepsTheLease(t *testing.T) {
413 t.Parallel()
414 f := newSwapFixture(t)
415 f.b.gmu.Lock()
416 f.b.genStuck = true
417 f.b.gmu.Unlock()
418 f.a.genHealth = func(context.Context, Gen) error { return errors.New("unhealthy: HTTP 500") }
419 before := f.b.releaseCount()
420 if res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest); res.Outcome != SwapDown {
421 t.Fatalf("Swap = %+v, want down", res)
422 }
423 if got := f.b.releaseCount(); got != before+1 {
424 t.Fatalf("lease releases %d -> %d: the failed wake's lease was given back over a live leader", before, got)
425 }
426 _, p := f.b.lastGen()
427 p.exit()
428 f.a.sweepOnce()
429 waitFor(t, "the failed wake's lease given back", func() bool { return f.b.releaseCount() == before+2 })
430}
431
432// The re-check before the spawn happens when the stop finishes. A fence, a
433// newer attempt or the agent's shutdown abandons the hold, and nothing is
434// spawned.
435func TestSwapHoldRefusedAtTheStop(t *testing.T) {
436 t.Parallel()
437 for _, tc := range []struct {
438 name string
439 act func(t *testing.T, f *swapFixture)
440 want error
441 }{
442 {"fenced", func(_ *testing.T, f *swapFixture) {
443 f.a.mu.Lock()
444 if f.a.fences == nil {
445 f.a.fences = map[string]uint64{}
446 }
447 f.a.fences[f.host] = 5
448 f.a.mu.Unlock()
449 }, errSwapFenced},
450 {"superseded", func(_ *testing.T, f *swapFixture) { f.rev.Add(1) }, errSwapSuperseded},
451 {"shutdown", func(t *testing.T, f *swapFixture) {
452 go f.a.Shutdown(context.Background())
453 // The shutdown is announced before the old generation's stop is
454 // let go, so the outcome does not hang on goroutine scheduling.
455 waitFor(t, "the shutdown announced", func() bool {
456 f.a.mu.Lock()
457 defer f.a.mu.Unlock()
458 return f.a.shuttingDown
459 })
460 }, errSwapShutdown},
461 } {
462 t.Run(tc.name, func(t *testing.T) {
463 f := newSwapFixture(t)
464 gate := make(chan struct{})
465 f.b.lastProc().setStopGate(gate)
466 results := make(chan SwapResult, 1)
467 go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest) }()
468 waitFor(t, "the hold's stop", func() bool {
469 f.a.mu.Lock()
470 defer f.a.mu.Unlock()
471 e := f.a.entries[f.host]
472 return e != nil && e.state == actStopping
473 })
474 tc.act(t, f)
475 close(gate)
476 res := <-results
477 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, tc.want.Error()) {
478 t.Fatalf("Swap = %+v, want abandoned: %v", res, tc.want)
479 }
480 if gens, _, _ := f.b.calls(); gens != 0 {
481 t.Fatal("a refused hold spawned a generation")
482 }
483 })
484 }
485}
486
487// Claims on: a hold needs a claim runtime that can start a pinned
488// generation of its own. Without one it is refused BEFORE anything stops.
489func TestSwapHoldClaimsOnNeedsPins(t *testing.T) {
490 f := newSwapFixture(t)
491 f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: &swapSelector{work: &swapWork{}}}
492 res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
493 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errClaimsNoGenerations.Error()) {
494 t.Fatalf("Swap = %+v", res)
495 }
496 if f.b.lastProc().isStopped() {
497 t.Fatal("a refused hold stopped the incumbent")
498 }
499}
500
501// A row that cannot run a staged generation is refused BEFORE anything
502// stops (spec §7.8 is stop-then-start: a start that cannot happen must not
503// cost the instance its running process). Refused after the stop, the
504// pinned wake failed and the hold answered SwapDown for a build that never
505// ran. Swaps off refuses a hold as it refuses an overlap.
506func TestSwapHoldRefusesARowThatCannotRunAStagedBuild(t *testing.T) {
507 t.Parallel()
508 for _, tc := range []struct {
509 name string
510 arrange func(f *swapFixture)
511 want error
512 }{
513 {"swap off", func(f *swapFixture) { f.a.gens = false }, errSwapOff},
514 {"unit-backed", func(f *swapFixture) {
515 f.a.mu.Lock()
516 f.a.entries[f.host].route.Backing = registry.BackingUnit
517 f.a.mu.Unlock()
518 }, errSwapKind},
519 {"fenced layout", func(f *swapFixture) {
520 f.b.layoutFn = func(registry.Route) (Layout, string) { return LayoutFenced, "a migration journal" }
521 }, errSwapLayout},
522 {"worker", func(f *swapFixture) {
523 f.a.mu.Lock()
524 f.a.entries[f.host].worker = true
525 f.a.mu.Unlock()
526 }, errSwapKind},
527 } {
528 t.Run(tc.name, func(t *testing.T) {
529 f := newSwapFixture(t)
530 tc.arrange(f)
531 res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
532 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, tc.want.Error()) {
533 t.Fatalf("Swap = %+v, want abandoned: %v", res, tc.want)
534 }
535 if f.b.lastProc().isStopped() {
536 t.Fatal("a refused hold stopped the incumbent")
537 }
538 if gens, _, _ := f.b.calls(); gens != 0 || f.b.startCount() != 1 {
539 t.Fatalf("a refused hold spawned: StartGen %d, Start %d", gens, f.b.startCount())
540 }
541 f.a.mu.Lock()
542 e := f.a.entries[f.host]
543 held := e != nil && e.holdNext != nil
544 f.a.mu.Unlock()
545 if held {
546 t.Fatal("a refused hold left its spec on the entry")
547 }
548 })
549 }
550}
551
552// The caller is released when the old generation's stop outlasts
553// wakeWaitBound, the bound §7.8 holds requests for, or when the caller's own
554// context ends. The hold is withdrawn under the entry lock, so the stop's
555// eventual finish installs no pinned wake and the next request wakes the
556// ordinary way. Waiting on the stop alone held the caller for as long as an
557// unproven stop stayed pending.
558func TestSwapHoldReleasesItsCallerWhileTheStopIsPending(t *testing.T) {
559 for _, tc := range []struct {
560 name string
561 run func(f *swapFixture) SwapResult
562 want error
563 }{
564 {"the stop outlasts wakeWaitBound", func(f *swapFixture) SwapResult {
565 return f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
566 }, errHoldStopSlow},
567 {"the caller's context ends", func(f *swapFixture) SwapResult {
568 ctx, cancel := context.WithCancel(context.Background())
569 defer cancel()
570 go func() {
571 // Cancel once the hold's stop has begun (bounded: the test's
572 // own assertion reports a hold that never got there).
573 for deadline := time.Now().Add(5 * time.Second); time.Now().Before(deadline); time.Sleep(time.Millisecond) {
574 f.a.mu.Lock()
575 e := f.a.entries[f.host]
576 stopping := e != nil && e.state == actStopping
577 f.a.mu.Unlock()
578 if stopping {
579 break
580 }
581 }
582 cancel()
583 }()
584 return f.a.Swap(ctx, f.attempt(ModeHold), f.newest)
585 }, errSwapStopped},
586 } {
587 t.Run(tc.name, func(t *testing.T) {
588 old := wakeWaitBound
589 t.Cleanup(func() { wakeWaitBound = old })
590 f := newSwapFixture(t)
591 if errors.Is(tc.want, errHoldStopSlow) {
592 wakeWaitBound = 100 * time.Millisecond
593 }
594 gate := make(chan struct{})
595 incumbent := f.b.lastProc()
596 incumbent.setStopGate(gate)
597 res := tc.run(f)
598 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, tc.want.Error()) {
599 close(gate)
600 t.Fatalf("Swap = %+v, want abandoned: %v", res, tc.want)
601 }
602 f.a.mu.Lock()
603 prev := f.a.entries[f.host]
604 if prev == nil || prev.state != actStopping || prev.holdNext != nil {
605 f.a.mu.Unlock()
606 close(gate)
607 t.Fatal("want the old entry still stopping, with its hold withdrawn")
608 }
609 stopped := prev.stopped
610 f.a.mu.Unlock()
611 close(gate)
612 <-stopped
613 // finishStop would install the pinned wake in the critical section
614 // that closes stopped, so its absence now is final.
615 f.a.mu.Lock()
616 next := f.a.entries[f.host]
617 f.a.mu.Unlock()
618 if next != nil {
619 t.Fatalf("a withdrawn hold installed an entry (state %v)", next.state)
620 }
621 if gens, _, _ := f.b.calls(); gens != 0 {
622 t.Fatalf("a withdrawn hold exec'd its build (%d)", gens)
623 }
624 })
625 }
626}
627
628// A claims-on pinned wake runs the hold's attempt gate before anything is
629// admitted: a superseded hold reaches neither the prerequisites nor the
630// admission.
631func TestDoClaimWakeRunsThePinGateFirst(t *testing.T) {
632 obs := hostclaim.Observed{Host: "x.example", ETag: "one", Claim: &hostclaim.Claim{
633 Holder: &hostclaim.Holder{Location: "instances/x.example/inc", Session: 1, Generation: "one", WriterBox: "box"},
634 Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"},
635 }}
636 admitted := false
637 a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box", Pins: true,
638 Read: func(context.Context, string) (hostclaim.Observed, error) { return obs, nil },
639 StopForeign: func(context.Context, string) error { return nil },
640 FailedAdmission: func(context.Context, hostclaim.Observed, error) error { return nil },
641 Prerequisites: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) error {
642 admitted = true
643 return nil
644 },
645 Admit: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (hostclaim.Observed, error) {
646 admitted = true
647 return obs, nil
648 },
649 Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) {
650 admitted = true
651 return ClaimStart{}, nil
652 }}}}
653 e := &actEntry{host: obs.Host, route: registry.Route{Host: obs.Host, Kind: "instance"}, pin: &Gen{Build: "v2"},
654 pinCheck: func() error { return errSwapSuperseded }}
655 if _, err := a.doClaimWake(context.Background(), e); !errors.Is(err, errSwapSuperseded) || admitted {
656 t.Fatalf("doClaimWake = %v (admitted %v), want the pin gate's refusal before admission", err, admitted)
657 }
658}
659
660// A claim runtime that prepared a different build from the pinned one is
661// refused before Restore touches the database.
662func TestStartAdmittedRefusesAPinMismatch(t *testing.T) {
663 obs := hostclaim.Observed{Host: "x.example", ETag: "one", Claim: &hostclaim.Claim{
664 Holder: &hostclaim.Holder{Location: "instances/x.example/inc", Session: 1, Generation: "one"},
665 Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"},
666 }}
667 work, _ := (hostwrite.Config{}).Select(context.Background(), obs.Host, "spawn")
668 stop := errors.New("stop at restore")
669 for _, tc := range []struct {
670 version string
671 restored bool
672 }{{"v1", false}, {"v2", true}} {
673 restored := false
674 a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box",
675 Read: func(context.Context, string) (hostclaim.Observed, error) { return obs, nil },
676 Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) {
677 s := claimedStartFor(obs, work, tc.version)
678 s.Restore = func(context.Context) error { restored = true; return stop }
679 return s, nil
680 }}}}
681 policy := startPolicy{MaxStarts: 1, Window: time.Second, Pin: &Gen{Build: "v2"}}
682 err := a.startAdmitted(context.Background(), &actEntry{host: obs.Host}, obs, policy)
683 if restored != tc.restored {
684 t.Fatalf("prepared %s: restored = %v, want %v (err %v)", tc.version, restored, tc.restored, err)
685 }
686 if !tc.restored && !errors.Is(err, hostwrite.ErrUnavailable) {
687 t.Fatalf("prepared %s: %v, want hostwrite.ErrUnavailable", tc.version, err)
688 }
689 if tc.restored && !errors.Is(err, stop) {
690 t.Fatalf("prepared %s: %v, want the restore's own error", tc.version, err)
691 }
692 }
693}
694
695// A foreign row keeps its canonical socket, so an overlap attempt on it
696// runs as a hold (plan 1's reading): the old process stops, then the staged
697// build starts on the same socket. Refusing it (errSwapLayout) left a
698// foreign row unable to take any release with the flag on.
699func TestSwapOverlapOnAForeignRowRunsAsAHold(t *testing.T) {
700 f := newSwapFixture(t)
701 f.b.layoutFn = func(registry.Route) (Layout, string) { return LayoutForeign, "hand-set socket" }
702 incumbent := f.b.lastProc()
703 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
704 if res.Outcome != SwapDone || res.Gen.StagedPath != f.staged || res.Mode != ModeHold {
705 t.Fatalf("Swap = %+v, want done through the hold, reported as a hold (Mode)", res)
706 }
707 ev := f.b.events()
708 if i, j := slices.Index(ev, "stop:instance"), indexPrefix(ev, "startGen:"+f.staged); i < 0 || j < 0 || i > j {
709 t.Fatalf("order %v: a foreign row stops before its replacement starts", ev)
710 }
711 if indexPrefix(ev, "raise") >= 0 {
712 t.Fatalf("events %v: a foreign row never raises an overlap parent", ev)
713 }
714 if !incumbent.isStopped() {
715 t.Fatal("the old process outlived the hold")
716 }
717}
718
719// Task 5, plan review #2: the scheduler writes an attempt's intent in the
720// mode Swap will run it in, which EffectiveMode answers with Swap's own
721// override: an overlap on a foreign row is a hold, anything else is as given.
722func TestEffectiveModeIsTheModeSwapRuns(t *testing.T) {
723 f := newSwapFixture(t)
724 host := f.attempt(ModeOverlap).Host
725 if m := f.a.EffectiveMode(host, ModeOverlap); m != ModeOverlap {
726 t.Fatalf("flat row: %s, want overlap", m)
727 }
728 f.b.layoutFn = func(registry.Route) (Layout, string) { return LayoutForeign, "hand-set socket" }
729 if m := f.a.EffectiveMode(host, ModeOverlap); m != ModeHold {
730 t.Fatalf("foreign row: %s, want hold", m)
731 }
732 if m := f.a.EffectiveMode(host, ModeHold); m != ModeHold {
733 t.Fatalf("foreign row, hold: %s", m)
734 }
735}
736
737// A nested row can hold-swap now that root mounts the staged build (Task 9):
738// the refusal Task 6 made for it is gone, and the hold runs to SwapDone.
739func TestSwapHoldAcceptsANestedRow(t *testing.T) {
740 f := newSwapFixture(t)
741 f.b.layoutFn = func(registry.Route) (Layout, string) { return LayoutNested, "" }
742 res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
743 if res.Outcome != SwapDone || res.Gen.StagedPath != f.staged {
744 t.Fatalf("Swap = %+v, want a done hold on the staged build", res)
745 }
746}
747
748// zdd-readiness Task 13 (Astra R1 T12, R3 §5 item 9): a hold stops its
749// incumbent before its own build execs, so the build has to be in the
750// generations file before that stop begins. Published only at the pinned
751// wake's exec, root could sweep or restage the staged build in the window
752// where nothing serves at all.
753
754// The generations file names the hold's build when the incumbent's stop
755// begins. The fake records what the file said at that moment, before the
756// stop's own gate.
757func TestSwapHoldPublishesItsBuildBeforeTheIncumbentStops(t *testing.T) {
758 f := newSwapFixture(t)
759 f.a.GenerationsFile = release.GenerationsPath(t.TempDir())
760 incumbent := f.b.lastProc()
761 incumbent.mu.Lock()
762 incumbent.onStopEnter = func() {
763 f.b.record(fmt.Sprintf("stop-begins:file=%s", strings.Join(hostsOf(f.a.GenerationsFile)[f.host], ",")))
764 }
765 incumbent.mu.Unlock()
766 res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
767 if res.Outcome != SwapDone || res.Gen.StagedPath != f.staged {
768 t.Fatalf("Swap = %+v, want a done hold on the staged build", res)
769 }
770 evs := f.b.events()
771 began := slices.IndexFunc(evs, func(ev string) bool { return strings.HasPrefix(ev, "stop-begins:") })
772 if began < 0 {
773 t.Fatalf("events %q: the incumbent's stop never began", evs)
774 }
775 if evs[began] != "stop-begins:file=v1,v2" {
776 t.Fatalf("when the incumbent's stop began the file named %q, want the hold's build v2 beside v1", evs[began])
777 }
778 if i := slices.Index(evs, "startGen:"+f.staged); i < began {
779 t.Fatalf("events %q: the hold's exec at %d, before its incumbent's stop at %d", evs, i, began)
780 }
781 // The pending registration went with the swap: the file names what runs.
782 waitForGenerations(t, f.a.GenerationsFile, map[string][]string{f.host: {"v2"}})
783 f.a.mu.Lock()
784 pending := len(f.a.genPending)
785 f.a.mu.Unlock()
786 if pending != 0 {
787 t.Fatalf("%d pending generations left after the hold finished", pending)
788 }
789}
790
791// A hold whose build cannot be published stops nothing: the incumbent keeps
792// serving and the attempt is abandoned with nothing executed.
793func TestSwapHoldUnpublishedLeavesTheIncumbentServing(t *testing.T) {
794 f := newSwapFixture(t)
795 f.a.GenerationsFile = unwritableGenerationsFile(t)
796 incumbent := f.b.lastProc()
797 res := f.a.Swap(context.Background(), f.attempt(ModeHold), f.newest)
798 if res.Outcome != SwapAbandoned || res.Executed || !strings.Contains(res.Reason, errStagedBuildNotPublished.Error()) {
799 t.Fatalf("Swap = %+v, want abandoned, not executed: unpublished", res)
800 }
801 if n := incumbent.stopEntered(); n != 0 || incumbent.isStopped() {
802 t.Fatalf("the incumbent's stop was entered %d times for a hold whose build was never published", n)
803 }
804 f.a.mu.Lock()
805 e := f.a.entries[f.host]
806 awake := e != nil && e.state == actAwake && e.gen.N == f.incumbent.N && e.holdNext == nil
807 pending := len(f.a.genPending)
808 f.a.mu.Unlock()
809 if !awake {
810 t.Fatal("want the incumbent's entry still awake on its generation, with no hold on it")
811 }
812 if pending != 0 {
813 t.Fatalf("%d pending generations left by an abandoned hold", pending)
814 }
815 if gens, _, _ := f.b.calls(); gens != 0 {
816 t.Fatalf("StartGen calls = %d, want 0", gens)
817 }
818}
819
820// The publication runs off a.mu, so the admission is asked again once it is
821// written. An attempt superseded while the file was written withdraws its
822// pending build and stops nothing.
823func TestSwapHoldReadmitsAfterThePublication(t *testing.T) {
824 f := newSwapFixture(t)
825 f.a.GenerationsFile = release.GenerationsPath(t.TempDir())
826 incumbent := f.b.lastProc()
827 at := f.attempt(ModeHold)
828 // newest is asked under a.mu at each admission. The newer attempt
829 // arrives exactly when the hold's build is in the file: after the
830 // first admission, before the second.
831 newest := func(host string) uint64 {
832 if slices.Contains(hostsOf(f.a.GenerationsFile)[host], "v2") {
833 return at.SchedRev + 1
834 }
835 return at.SchedRev
836 }
837 res := f.a.Swap(context.Background(), at, newest)
838 if res.Outcome != SwapAbandoned || res.Executed || !strings.Contains(res.Reason, errSwapSuperseded.Error()) {
839 t.Fatalf("Swap = %+v, want abandoned, not executed: superseded", res)
840 }
841 if n := incumbent.stopEntered(); n != 0 {
842 t.Fatalf("the incumbent's stop was entered %d times for a superseded hold", n)
843 }
844 f.a.mu.Lock()
845 e := f.a.entries[f.host]
846 awake := e != nil && e.state == actAwake && e.holdNext == nil
847 pending := len(f.a.genPending)
848 f.a.mu.Unlock()
849 if !awake || pending != 0 {
850 t.Fatalf("awake with no hold = %v, pending generations = %d", awake, pending)
851 }
852 // The withdrawn build is no longer published.
853 waitForGenerations(t, f.a.GenerationsFile, map[string][]string{f.host: {"v1"}})
854 if gens, _, _ := f.b.calls(); gens != 0 {
855 t.Fatalf("StartGen calls = %d, want 0", gens)
856 }
857}
858