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 "slices"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/registry"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// cmdSwapBackend is the swap fixture's backend with the run capability, on
17// the nested layout (merge ruling M2).
18type cmdSwapBackend struct {
19 *fakeSwapBackend
20 cmu sync.Mutex
21 unit *fakeCmdUnit
22 specs []CmdSpec
23 gate chan struct{} // when set, StartCmd blocks until it closes
24 entered chan struct{} // when set, closed as StartCmd is entered
25 onStart func() // when set, runs inside StartCmd, before the unit is handed back
26}
27
28func (b *cmdSwapBackend) StartCmd(_ context.Context, _ registry.Route, spec CmdSpec) (CmdUnit, error) {
29 b.cmu.Lock()
30 gate, entered, onStart := b.gate, b.entered, b.onStart
31 b.entered = nil
32 b.cmu.Unlock()
33 if entered != nil {
34 close(entered)
35 }
36 if onStart != nil {
37 onStart()
38 }
39 if gate != nil {
40 <-gate
41 }
42 b.cmu.Lock()
43 defer b.cmu.Unlock()
44 b.specs = append(b.specs, spec)
45 return b.unit, nil
46}
47
48func (b *cmdSwapBackend) started() []CmdSpec {
49 b.cmu.Lock()
50 defer b.cmu.Unlock()
51 return slices.Clone(b.specs)
52}
53
54func newCmdSwapFixture(t *testing.T) (*swapFixture, *cmdSwapBackend) {
55 t.Helper()
56 var cb *cmdSwapBackend
57 f := newSwapFixtureOn(t, func(b *fakeSwapBackend) Backend {
58 cb = &cmdSwapBackend{fakeSwapBackend: b, unit: &fakeCmdUnit{}}
59 return cb
60 })
61 f.b.layoutFn = func(registry.Route) (Layout, string) { return LayoutNested, "" }
62 return f, cb
63}
64
65func (a *Service) runGenOf(h CmdHandle) Gen {
66 a.mu.Lock()
67 defer a.mu.Unlock()
68 return h.(*cmdRun).h.gen
69}
70
71// A run whose unit is starting refuses an overlap swap's admission: the
72// attempt is abandoned with nothing spawned, never admitted to retire the
73// generation that run is starting against. Once the start has returned,
74// the same attempt is admitted (merge ruling M2).
75func TestARunStartInFlightRefusesAnOverlapSwap(t *testing.T) {
76 f, cb := newCmdSwapFixture(t)
77 cb.gate, cb.entered = make(chan struct{}), make(chan struct{})
78 entered := cb.entered
79 type ran struct {
80 h CmdHandle
81 err error
82 }
83 done := make(chan ran, 1)
84 go func() {
85 h, err := f.a.RunCmd(context.Background(), testRoute(f.host), CmdSpec{RunID: "run_a"})
86 done <- ran{h, err}
87 }()
88 <-entered
89 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
90 if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapBusy.Error()) || res.Executed {
91 t.Fatalf("Swap during the run's start = %+v, want abandoned as busy with nothing run", res)
92 }
93 if gens, raises, _ := f.b.calls(); gens != 0 || raises != 0 {
94 t.Fatalf("the refused attempt spawned %d generations and raised %d times", gens, raises)
95 }
96 close(cb.gate)
97 r := <-done
98 if r.err != nil {
99 t.Fatal(r.err)
100 }
101 if g := f.a.runGenOf(r.h); g.N != f.incumbent.N {
102 t.Fatalf("the run was admitted against generation %d, want the incumbent %d", g.N, f.incumbent.N)
103 }
104 f.rev.Add(1)
105 if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapDone {
106 t.Fatalf("Swap after the start = %+v", res)
107 }
108}
109
110// A run that arrives while an overlap successor is spawning starts no unit
111// against the incumbent, which is about to retire: it waits for the switch
112// and then runs the successor's build, admitted against the successor's
113// generation (merge ruling M2).
114func TestARunWaitsForAPendingSwitch(t *testing.T) {
115 t.Parallel()
116 f, cb := newCmdSwapFixture(t)
117 f.b.gmu.Lock()
118 f.b.genGate = make(chan struct{})
119 f.b.gmu.Unlock()
120 swapped := make(chan SwapResult, 1)
121 go func() { swapped <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }()
122 waitFor(t, "the successor in the operation set", func() bool {
123 _, members := f.a.genStateForTest(f.host)
124 return len(members) == 1
125 })
126 type ran struct {
127 h CmdHandle
128 err error
129 }
130 done := make(chan ran, 1)
131 go func() {
132 h, err := f.a.RunCmd(context.Background(), testRoute(f.host), CmdSpec{RunID: "run_a"})
133 done <- ran{h, err}
134 }()
135 time.Sleep(3 * cmdSwitchPoll)
136 if n := len(cb.started()); n != 0 {
137 t.Fatalf("%d units started while a successor was pending", n)
138 }
139 f.b.gmu.Lock()
140 close(f.b.genGate)
141 f.b.gmu.Unlock()
142 res := <-swapped
143 if res.Outcome != SwapDone {
144 t.Fatalf("Swap = %+v", res)
145 }
146 r := <-done
147 if r.err != nil {
148 t.Fatal(r.err)
149 }
150 defer r.h.Release()
151 if g := f.a.runGenOf(r.h); g.N != res.Gen.N {
152 t.Fatalf("the run was admitted against generation %d, want the successor %d", g.N, res.Gen.N)
153 }
154 specs := cb.started()
155 if len(specs) != 1 || specs[0].serving == nil || specs[0].serving.Path != f.staged {
156 t.Fatalf("the run started %+v, want one unit on the successor's build %s", specs, f.staged)
157 }
158 if n, _ := cb.unit.stopped(); n != 0 {
159 t.Fatal("a run on the current generation was interrupted")
160 }
161}
162
163// A running command on a generation a swap retires is interrupted and
164// settled, and the run reads why: a new release for a release attempt, a
165// restart for a scheduled restart or config change (exit 125, RUN-C75;
166// merge ruling M2).
167func TestARunOnARetiredGenerationIsInterrupted(t *testing.T) {
168 t.Parallel()
169 for _, c := range []struct {
170 cause Cause
171 why string
172 }{{CauseRelease, StopRelease}, {CauseRestart, StopRestart}, {CauseConfig, StopRestart}} {
173 t.Run(string(c.cause), func(t *testing.T) {
174 f, cb := newCmdSwapFixture(t)
175 h, err := f.a.RunCmd(context.Background(), testRoute(f.host), CmdSpec{RunID: "run_a"})
176 if err != nil {
177 t.Fatal(err)
178 }
179 if g := f.a.runGenOf(h); g.N != f.incumbent.N || g.StagedPath != f.incumbent.StagedPath {
180 t.Fatalf("the run was admitted against %+v, want the incumbent", g)
181 }
182 at := f.attempt(ModeOverlap)
183 at.Cause = c.cause
184 if res := f.a.Swap(context.Background(), at, f.newest); res.Outcome != SwapDone {
185 t.Fatalf("Swap = %+v", res)
186 }
187 waitFor(t, "the run interrupted and settled", func() bool {
188 n, _ := cb.unit.stopped()
189 return n == 1 && h.(*cmdRun).h.provenSettled()
190 })
191 if why, at := h.Interrupted(); why != c.why || at.IsZero() {
192 t.Fatalf("Interrupted() = %q at %v, want %q", why, at, c.why)
193 }
194 if f.a.entryCount() != 1 {
195 t.Fatal("interrupting the run stopped the instance")
196 }
197 h.Release()
198 })
199 }
200}
201
202// A run on a retired generation keeps that generation's build in the
203// generations file until the run is proven settled, so root neither
204// restages nor sweeps the bytes the command runs; the settle that proves
205// it releases the pin (merge ruling M2).
206func TestARetiredRunKeepsItsBuildPinnedUntilItSettles(t *testing.T) {
207 t.Parallel()
208 f, cb := newCmdSwapFixture(t)
209 path := release.GenerationsPath(t.TempDir())
210 f.a.GenerationsFile = path
211 cb.unit.setSettleErr(errors.New("processes remained"))
212 h, err := f.a.RunCmd(context.Background(), testRoute(f.host), CmdSpec{RunID: "run_a"})
213 if err != nil {
214 t.Fatal(err)
215 }
216 res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest)
217 if res.Outcome != SwapDone {
218 t.Fatalf("Swap = %+v", res)
219 }
220 waitFor(t, "the interrupt's failed settle", func() bool { n, _ := cb.unit.stopped(); return n >= 1 })
221 pinned := func() bool { return slices.Contains(readPinPaths(t, path, f.host), f.incumbent.StagedPath) }
222 if err := f.a.persistGenerations(); err != nil {
223 t.Fatal(err)
224 }
225 _, members := f.a.genStateForTest(f.host)
226 if len(members) != 0 {
227 t.Fatalf("the retiring generation is still a member: %v", members)
228 }
229 if !pinned() {
230 t.Fatal("the retired build went from the generations file while its run was unsettled")
231 }
232 cb.unit.setSettleErr(nil)
233 if err := h.Stop(context.Background()); err != nil {
234 t.Fatalf("Stop: %v", err)
235 }
236 waitFor(t, "the pin released", func() bool { return !pinned() })
237 h.Release()
238}
239
240// A run waiting for a switch carries its own deadline and cancellation:
241// cancelled or expired while the successor is held, it starts nothing,
242// before or after the switch, and returns its context's error. A Recheck
243// that refuses after the wait starts nothing either (re-review 5).
244func TestARunEndedDuringASwitchWaitNeverStarts(t *testing.T) {
245 t.Parallel()
246 for _, c := range []struct {
247 name string
248 ctx func() (context.Context, context.CancelFunc)
249 cancel bool
250 recheck error
251 want error
252 }{
253 {name: "cancelled", ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) }, cancel: true, want: context.Canceled},
254 {name: "expired", ctx: func() (context.Context, context.CancelFunc) {
255 return context.WithTimeout(context.Background(), 3*cmdSwitchPoll)
256 }, want: context.DeadlineExceeded},
257 {name: "recheck refuses", ctx: func() (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) },
258 recheck: errors.New("the record no longer names the run"), want: nil},
259 } {
260 t.Run(c.name, func(t *testing.T) {
261 f, cb := newCmdSwapFixture(t)
262 f.b.gmu.Lock()
263 f.b.genGate = make(chan struct{})
264 f.b.gmu.Unlock()
265 swapped := make(chan SwapResult, 1)
266 go func() { swapped <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }()
267 waitFor(t, "the successor in the operation set", func() bool {
268 _, members := f.a.genStateForTest(f.host)
269 return len(members) == 1
270 })
271 ctx, cancel := c.ctx()
272 defer cancel()
273 var rechecks int
274 spec := CmdSpec{RunID: "run_a"}
275 if c.recheck != nil {
276 spec.Recheck = func(context.Context) error { rechecks++; return c.recheck }
277 }
278 done := make(chan error, 1)
279 go func() {
280 _, err := f.a.RunCmd(ctx, testRoute(f.host), spec)
281 done <- err
282 }()
283 waitFor(t, "the run waiting", func() bool {
284 f.a.mu.Lock()
285 defer f.a.mu.Unlock()
286 e := f.a.entries[f.host]
287 return e != nil && e.cmd != nil
288 })
289 time.Sleep(cmdSwitchPoll)
290 if c.cancel {
291 cancel()
292 }
293 if c.want != nil {
294 if err := <-done; !errors.Is(err, c.want) {
295 t.Fatalf("RunCmd = %v, want %v", err, c.want)
296 }
297 }
298 f.b.gmu.Lock()
299 close(f.b.genGate)
300 f.b.gmu.Unlock()
301 if res := <-swapped; res.Outcome != SwapDone {
302 t.Fatalf("Swap = %+v", res)
303 }
304 if c.want == nil {
305 if err := <-done; !errors.Is(err, c.recheck) || rechecks != 1 {
306 t.Fatalf("RunCmd = %v after %d rechecks, want the recheck's refusal after one", err, rechecks)
307 }
308 }
309 time.Sleep(2 * cmdSwitchPoll)
310 if n := len(cb.started()); n != 0 {
311 t.Fatalf("%d units started for a run that ended before its launch", n)
312 }
313 f.a.mu.Lock()
314 attached := f.a.entries[f.host].cmd != nil
315 f.a.mu.Unlock()
316 if attached {
317 t.Fatal("the ended run is still attached to the instance")
318 }
319 })
320 }
321}
322
323// A run on a generation a swap retires, whose interruption's stop fails
324// once while the instance stays awake: the sweeps alone retry the settle,
325// actStopTimeout apart, prove it, drop the retired build's pin, and the
326// next run is admitted. The run's handle and the pin stay until then
327// (re-review 6).
328func TestARetiredRunsFailedStopIsSettledBySweeps(t *testing.T) {
329 t.Parallel()
330 for _, released := range []bool{false, true} {
331 t.Run(map[bool]string{false: "attached", true: "released"}[released], func(t *testing.T) {
332 f, cb := newCmdSwapFixture(t)
333 path := release.GenerationsPath(t.TempDir())
334 f.a.GenerationsFile = path
335 cb.unit.setSettleErr(errors.New("instance control: 502 mid-roll"))
336 h, err := f.a.RunCmd(context.Background(), testRoute(f.host), CmdSpec{RunID: "run_a"})
337 if err != nil {
338 t.Fatal(err)
339 }
340 if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapDone {
341 t.Fatalf("Swap = %+v", res)
342 }
343 waitFor(t, "the interruption's failed stop", func() bool {
344 n, _ := cb.unit.stopped()
345 return n == 1 && h.(*cmdRun).h.settlingDone()
346 })
347 if why, _ := h.Interrupted(); why != StopRelease {
348 t.Fatalf("Interrupted() = %q, want %q", why, StopRelease)
349 }
350 if released {
351 h.Release() // the worker wrote 125 and let go, its own Stop failed too
352 }
353 if _, err := f.a.RunCmd(context.Background(), testRoute(f.host), CmdSpec{RunID: "run_b"}); !errors.Is(err, ErrCmdBusy) {
354 t.Fatalf("a run while the retired one is unproven: %v, want ErrCmdBusy", err)
355 }
356 pinned := func() bool {
357 if err := f.a.persistGenerations(); err != nil {
358 t.Fatal(err)
359 }
360 return slices.Contains(readPinPaths(t, path, f.host), f.incumbent.StagedPath)
361 }
362 if !pinned() {
363 t.Fatal("the retired build lost its pin while its run was unproven")
364 }
365 cb.unit.setSettleErr(nil)
366 f.a.sweepOnce() // within actStopTimeout of the failure: not yet
367 time.Sleep(50 * time.Millisecond)
368 if n, _ := cb.unit.stopped(); n != 1 {
369 t.Fatalf("the sweep retried %d times inside actStopTimeout", n-1)
370 }
371 f.clk.advance(actStopTimeout)
372 f.a.sweepOnce()
373 waitFor(t, "the sweep's retry proving the run stopped", func() bool { return h.(*cmdRun).h.provenSettled() })
374 if n, _ := cb.unit.stopped(); n != 2 {
375 t.Fatalf("stops %d, want the failed one and the sweep's", n)
376 }
377 if pinned() {
378 t.Fatal("the retired build is still pinned after its run was proven stopped")
379 }
380 if !released {
381 h.Release()
382 }
383 h2, err := f.a.RunCmd(context.Background(), testRoute(f.host), CmdSpec{RunID: "run_b"})
384 if err != nil {
385 t.Fatalf("the next run: %v", err)
386 }
387 h2.Release()
388 if f.a.entryCount() != 1 {
389 t.Fatal("the instance stopped")
390 }
391 })
392 }
393}
394
395// settlingDone reports whether no settle attempt is in flight.
396func (h *cmdHold) settlingDone() bool {
397 h.mu.Lock()
398 defer h.mu.Unlock()
399 return h.settling == nil
400}
401
402// readPinPaths is host's staged paths in the generations file. A read
403// that meets a write in progress (another goroutine republishing) is
404// tried again, as root would on its next pass.
405func readPinPaths(t *testing.T, path, host string) []string {
406 t.Helper()
407 deadline := time.Now().Add(5 * time.Second)
408 for {
409 g, err := release.ReadGenerations(path)
410 if err == nil && g != nil {
411 return g.Paths[host]
412 }
413 if time.Now().After(deadline) {
414 t.Fatalf("ReadGenerations: %v", err)
415 }
416 time.Sleep(10 * time.Millisecond)
417 }
418}
419
420// buildRecorder is a Service.BeforeExec that keeps every record in order:
421// its last entry for a host is that host's last_executed.
422type buildRecorder struct {
423 mu sync.Mutex
424 builds []string
425 err error
426 hook func() // runs inside the write, when set
427}
428
429func (r *buildRecorder) record(_ context.Context, _, build string) error {
430 r.mu.Lock()
431 hook, err := r.hook, r.err
432 if err == nil {
433 r.builds = append(r.builds, build)
434 }
435 r.mu.Unlock()
436 if hook != nil {
437 hook()
438 }
439 return err
440}
441
442func (r *buildRecorder) last() string {
443 r.mu.Lock()
444 defer r.mu.Unlock()
445 if len(r.builds) == 0 {
446 return ""
447 }
448 return r.builds[len(r.builds)-1]
449}
450
451// A successor B records its build and then fails its health check, so the
452// incumbent A goes on serving under a build state that names B. A command
453// then runs A: its build is recorded before its unit starts, so the build
454// state names A again, as it does for the sidecar (settings spec §4.2;
455// second merge review, finding 1).
456func TestARunRecordsItsServingBuildBeforeItStarts(t *testing.T) {
457 f, cb := newCmdSwapFixture(t)
458 rec := &buildRecorder{}
459 f.a.BeforeExec = rec.record
460 f.a.genHealth = func(context.Context, Gen) error { return errors.New("502 from the successor") }
461 if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapFailed || !res.Executed {
462 t.Fatalf("Swap = %+v, want the successor to have run and failed", res)
463 }
464 if got := rec.last(); got != "v2" {
465 t.Fatalf("after the failed successor the build state names %q, want its v2", got)
466 }
467 if cur, _ := f.a.genStateForTest(f.host); cur.N != f.incumbent.N {
468 t.Fatalf("generation %d is serving, want the incumbent", cur.N)
469 }
470 // What the build state names at the moment the unit starts.
471 var atStart string
472 cb.cmu.Lock()
473 cb.onStart = func() { atStart = rec.last() }
474 cb.cmu.Unlock()
475 h, err := f.a.RunCmd(context.Background(), testRoute(f.host), CmdSpec{RunID: "run_a"})
476 if err != nil {
477 t.Fatal(err)
478 }
479 defer h.Release()
480 if got := rec.last(); got != f.incumbent.Build {
481 t.Fatalf("after the command the build state names %q, want the serving %q", got, f.incumbent.Build)
482 }
483 if specs := cb.started(); len(specs) != 1 || specs[0].serving == nil || specs[0].serving.Path != f.incumbent.StagedPath {
484 t.Fatalf("the command started %+v, want one unit on the incumbent's build", specs)
485 }
486 if atStart != f.incumbent.Build {
487 t.Fatalf("the unit started with the build state naming %q: the record must come first", atStart)
488 }
489}
490
491// A build state that cannot be written launches no command (settings spec
492// §4.2): the run is refused, the hold is released, and the instance and its
493// next run are unaffected. A stop, or the run's own cancel, that lands
494// during the write starts nothing either.
495func TestARunWhoseBuildRecordFailsOrIsOvertakenNeverStarts(t *testing.T) {
496 t.Parallel()
497 t.Run("the write fails", func(t *testing.T) {
498 f, cb := newCmdSwapFixture(t)
499 rec := &buildRecorder{err: errors.New("bucket: 503")}
500 f.a.BeforeExec = rec.record
501 if _, err := f.a.RunCmd(context.Background(), testRoute(f.host), CmdSpec{RunID: "run_a"}); !errors.Is(err, errBuildStateUnwritten) {
502 t.Fatalf("RunCmd = %v, want errBuildStateUnwritten", err)
503 }
504 if n := len(cb.started()); n != 0 {
505 t.Fatalf("%d units started over an unwritten build state", n)
506 }
507 rec.mu.Lock()
508 rec.err = nil
509 rec.mu.Unlock()
510 h, err := f.a.RunCmd(context.Background(), testRoute(f.host), CmdSpec{RunID: "run_b"})
511 if err != nil {
512 t.Fatalf("the next run: %v (the refused one must leave no hold)", err)
513 }
514 h.Release()
515 })
516 t.Run("a stop lands during the write", func(t *testing.T) {
517 f, cb := newCmdSwapFixture(t)
518 rec := &buildRecorder{}
519 rec.hook = func() { f.a.Bounce(f.host) }
520 f.a.BeforeExec = rec.record
521 _, err := f.a.RunCmd(context.Background(), testRoute(f.host), CmdSpec{RunID: "run_a"})
522 var se *CmdStoppedError
523 if !errors.As(err, &se) || se.Reason != StopRestart {
524 t.Fatalf("RunCmd = %v, want CmdStoppedError{%q}", err, StopRestart)
525 }
526 if n := len(cb.started()); n != 0 {
527 t.Fatalf("%d units started under a stopping instance", n)
528 }
529 rec.mu.Lock()
530 rec.hook = nil
531 rec.mu.Unlock()
532 waitFor(t, "the instance stopped", func() bool { return f.a.entryCount() == 0 })
533 })
534 t.Run("the run is cancelled during the write", func(t *testing.T) {
535 f, cb := newCmdSwapFixture(t)
536 ctx, cancel := context.WithCancel(context.Background())
537 defer cancel()
538 rec := &buildRecorder{hook: cancel}
539 f.a.BeforeExec = rec.record
540 if _, err := f.a.RunCmd(ctx, testRoute(f.host), CmdSpec{RunID: "run_a"}); !errors.Is(err, context.Canceled) {
541 t.Fatalf("RunCmd = %v, want context.Canceled", err)
542 }
543 if n := len(cb.started()); n != 0 {
544 t.Fatalf("%d units started for a cancelled run", n)
545 }
546 f.a.mu.Lock()
547 attached := f.a.entries[f.host].cmd != nil
548 f.a.mu.Unlock()
549 if attached {
550 t.Fatal("the cancelled run is still attached")
551 }
552 })
553}
554