carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package rollout
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "runtime"
8 "strings"
9 "sync"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "github.com/carlosframework/platform/internal/activator"
15 "github.com/carlosframework/platform/internal/appconfig"
16 "github.com/carlosframework/platform/internal/registry"
17 "github.com/carlosframework/platform/internal/release"
18 "github.com/carlosframework/platform/internal/rolloutwords"
19 "github.com/carlosframework/platform/internal/status"
20)
21
22func TestAReleaseRollsOutCanaryFirstThenPaced(t *testing.T) {
23 h := newHarness(t, "a", "b", "c")
24 h.stage("v2")
25 h.install("v2", "a", "b", "c")
26 h.pass()
27 waitFor(t, "every host on v2", func() bool { return h.servingAll("v2", "a", "b", "c") })
28 calls, _, maxFlight := h.act.snapshot()
29 if len(calls) != 3 || !calls[0].a.Canary || calls[1].a.Canary {
30 t.Fatalf("calls = %+v, want the canary first and then two plain swaps", calls)
31 }
32 if maxFlight > 1 {
33 t.Fatalf("%d swaps ran at once with swap_concurrency 1", maxFlight)
34 }
35 for _, host := range []string{"a", "b", "c"} {
36 if r := h.rows.get(host); r.GoodVersion != "v2" || r.GoodDigest != "sha-v2" || r.ServingVersion != "v2" {
37 t.Fatalf("%s row = %+v, want good and serving v2", host, r)
38 }
39 }
40 if !h.events.has(textHead(textSwapped("v2", time.Hour), time.Hour.String())) {
41 t.Fatal("no swapped event")
42 }
43}
44
45// Review focus 1, the index's cross-plan item.
46func TestTwoReleasesTwentySecondsApartNeverSwitchToTheFirst(t *testing.T) {
47 h := newHarness(t, "a", "b", "c")
48 h.stage("v2", "v3")
49 gate := h.act.hold("a") // the canary onto v2 sits in its settle
50 h.install("v2", "a", "b", "c")
51 h.pass()
52 waitFor(t, "the v2 canary to start", func() bool { _, started, _ := h.act.snapshot(); return len(started) == 1 })
53 h.advance(20 * time.Second)
54 h.install("v3", "a", "b", "c")
55 h.pass()
56 close(gate)
57 waitFor(t, "every host on v3", func() bool { return h.servingAll("v3", "a", "b", "c") })
58 calls, _, maxFlight := h.act.snapshot()
59 for _, c := range calls {
60 if c.a.Build == "v2" && c.outcome == activator.SwapDone {
61 t.Fatalf("switched %s to the superseded v2", c.a.Host)
62 }
63 }
64 if calls[0].a.Build != "v2" || calls[0].outcome != activator.SwapAbandoned {
65 t.Fatalf("first call = %+v, want the v2 canary abandoned at its re-check", calls[0])
66 }
67 if len(h.book.List()) != 0 {
68 t.Fatal("an abandoned attempt is not a build failure")
69 }
70 if maxFlight > 1 {
71 t.Fatal("swap_concurrency 1 exceeded")
72 }
73}
74
75func TestAFailedCanaryHaltsTheCohort(t *testing.T) {
76 h := newHarness(t, "a", "b", "c")
77 h.stage("v2")
78 h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
79 if a.Build == "v2" {
80 return activator.SwapFailed
81 }
82 return activator.SwapDone
83 }
84 h.install("v2", "a", "b", "c")
85 h.pass()
86 waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
87 h.pass()
88 time.Sleep(50 * time.Millisecond)
89 if calls, _, _ := h.act.snapshot(); len(calls) != 1 {
90 t.Fatalf("%d attempts onto v2, want the halt at the first failure", len(calls))
91 }
92 if r := h.rows.get("a"); r.GoodVersion != "v1" {
93 t.Fatalf("good build moved to %q on a failure", r.GoodVersion)
94 }
95 if !h.events.has(textHead(textCohortHalted("v2", "\x00", status.ReasonHealth), "\x00")) {
96 t.Fatal("no cohort-halted event")
97 }
98}
99
100// Spec §8.1: the failure transition (the record and the halt) happens in
101// the same step that frees the failed attempt's slot. The hook looks at the
102// queue at every moment done() reads a row after the failure: whenever the
103// failed attempt's slot is free, the record must already be in the book
104// (which wake plans read) and admission must start nothing onto the build.
105// Halting in that step but writing the record after the row read let a
106// wake select the failed build in between.
107func TestAFailureIsRecordedAndHaltedInTheStepThatFreesItsSlot(t *testing.T) {
108 h := newHarness(t, "a", "b")
109 h.stage("v2")
110 var failed atomic.Bool
111 h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
112 if a.Build != "v2" {
113 return activator.SwapDone
114 }
115 failed.Store(true)
116 return activator.SwapFailed
117 }
118 var bad atomic.Int32
119 h.rows.mu.Lock()
120 h.rows.getHook = func(host string) {
121 if !failed.Load() {
122 return
123 }
124 s := h.m.schedulerOf(host)
125 s.mu.Lock()
126 hs := s.q.hosts[host]
127 free := hs != nil && hs.running == nil
128 var ds []dispatch
129 if free {
130 ds = s.q.admit(currentView(s.q), h.clock())
131 }
132 s.mu.Unlock()
133 if !free {
134 return
135 }
136 if _, ok := h.book.Lookup("bab", "blog", "v2"); !ok {
137 bad.Add(1)
138 }
139 for _, d := range ds {
140 if d.ID.Build == "v2" {
141 bad.Add(1)
142 }
143 }
144 }
145 h.rows.mu.Unlock()
146 h.install("v2", "a", "b")
147 h.pass()
148 waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
149 h.pass() // reads the rows again, with the slot free
150 if n := bad.Load(); n != 0 {
151 t.Fatalf("%d moment(s) with the slot free and the record missing or a v2 attempt admissible", n)
152 }
153}
154
155// Spec §8.5: a successful attempt keeps its host's slot until its state
156// (serving and good build) is published, so no newer attempt can be
157// admitted and complete first and then be overwritten by this one. Freeing
158// the slot before the write let that happen.
159func TestASuccessKeepsItsSlotUntilItIsPublished(t *testing.T) {
160 h := newHarness(t, "a")
161 h.stage("v2")
162 var freed atomic.Int32
163 h.rows.mu.Lock()
164 h.rows.setGoodHook = func(host string) {
165 s := h.m.schedulerOf(host)
166 s.mu.Lock()
167 if hs := s.q.hosts[host]; hs != nil && hs.running == nil {
168 freed.Add(1)
169 }
170 s.mu.Unlock()
171 }
172 h.rows.mu.Unlock()
173 h.install("v2", "a")
174 h.pass()
175 waitFor(t, "the good build moved to v2", func() bool { return h.rows.get("a").GoodVersion == "v2" })
176 if n := freed.Load(); n != 0 {
177 t.Fatalf("%d good-build write(s) made after the attempt's slot was freed", n)
178 }
179}
180
181// A failure is published with no blocking read between the failure and the
182// record (spec §8.1): the pointer the build failed under is read before the
183// attempt runs. Reading it in done(), before the record, left every sibling's
184// wake free to plan the failed build for as long as that read took.
185func TestNoPointerReadSitsBetweenAFailureAndItsRecord(t *testing.T) {
186 h := newHarness(t, "a")
187 h.stage("v2")
188 var failed, late atomic.Bool
189 h.act.outcome = func(activator.Attempt) activator.SwapOutcome {
190 failed.Store(true)
191 return activator.SwapFailed
192 }
193 h.mu.Lock()
194 h.pointerHook = func() {
195 if failed.Load() {
196 if _, ok := h.book.Lookup("bab", "blog", "v2"); !ok {
197 late.Store(true)
198 }
199 }
200 }
201 h.mu.Unlock()
202 h.install("v2", "a")
203 h.pass()
204 waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
205 if late.Load() {
206 t.Fatal("a pointer read ran after the failure and before its record was published")
207 }
208}
209
210// Spec §6.3: a build installed while the entry is unstable (a hold
211// draining, a wake running) still supersedes the attempt in flight, so its
212// gates refuse the older build. Skipping wantBuild for such a host left the
213// revision on the older build, and a hold's pinned wake exec'd it. Nothing
214// is admitted onto the host until it is stable again.
215func TestAnInstallDuringAnUnstableEntryStillSupersedes(t *testing.T) {
216 h := newHarness(t, "a")
217 h.pass()
218 s := h.m.schedulerOf("a")
219 h.act.mu.Lock()
220 delete(h.act.gens, "a")
221 h.act.owned["a"] = true // stopping or waking: a process, no current generation
222 h.act.mu.Unlock()
223 s.mu.Lock()
224 before := s.q.newest("a")
225 s.mu.Unlock()
226 h.stage("v2")
227 h.install("v2", "a")
228 h.pass()
229 s.mu.Lock()
230 after := s.q.newest("a")
231 s.mu.Unlock()
232 if after == before {
233 t.Fatal("an install during an unstable entry left the attempt revision unchanged")
234 }
235 time.Sleep(50 * time.Millisecond)
236 if calls, _, _ := h.act.snapshot(); len(calls) != 0 {
237 t.Fatalf("attempts %+v admitted onto an unstable entry", calls)
238 }
239}
240
241// A row that cannot be read at the failure still gets its record: the
242// failure is the build's, not the row's (spec §8.1). Skipping the write on
243// a failed registry read left the failed build selectable by every wake.
244func TestAFailureIsRecordedWhenItsRowIsUnreadable(t *testing.T) {
245 h := newHarness(t, "a")
246 h.stage("v2")
247 var failed atomic.Bool
248 h.act.outcome = func(activator.Attempt) activator.SwapOutcome {
249 failed.Store(true)
250 return activator.SwapFailed
251 }
252 h.rows.mu.Lock()
253 h.rows.getErr = func(string) error {
254 if failed.Load() {
255 return errors.New("registry: database is locked")
256 }
257 return nil
258 }
259 h.rows.mu.Unlock()
260 h.install("v2", "a")
261 h.pass()
262 waitFor(t, "a failure record despite the unreadable row", func() bool {
263 _, ok := h.book.Lookup("bab", "blog", "v2")
264 return ok
265 })
266}
267
268// Review focus 4.
269func TestStagingRefusalWritesNoFailureRecord(t *testing.T) {
270 h := newHarness(t, "a")
271 h.stage("v2")
272 h.mismatch["v2"] = true
273 h.install("v2", "a")
274 h.pass()
275 waitFor(t, "the stage relay touch", func() bool { return h.touched.Load() > 0 })
276 if len(h.book.List()) != 0 {
277 t.Fatal("a staging error wrote a failure record against the build")
278 }
279 if calls, _, _ := h.act.snapshot(); len(calls) != 0 {
280 t.Fatal("a refused attempt reached Swap")
281 }
282 if g, _ := h.act.Current("a"); g.Build != "v1" {
283 t.Fatal("the instance left its serving build")
284 }
285 if !h.events.has(textStagingRefused("v2")) {
286 t.Fatal("no staging event")
287 }
288}
289
290func TestAnUnstagedBuildIsAStagingRefusal(t *testing.T) {
291 h := newHarness(t, "a")
292 h.install("v9", "a") // never staged on this box
293 h.pass()
294 waitFor(t, "the stage relay touch", func() bool { return h.touched.Load() > 0 })
295 if len(h.book.List()) != 0 {
296 t.Fatal("a missing staged file wrote a failure record")
297 }
298}
299
300func TestRestartGoesAheadForAnInstanceServingAFailedBuild(t *testing.T) {
301 h := newHarness(t, "d")
302 h.stage("v2")
303 h.act.serve("d", "v2")
304 r := h.rows.get("d")
305 r.Version, r.GoodVersion, r.GoodDigest = "v2", "v2", "sha-v2"
306 h.rows.put(r)
307 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "e", At: t0})
308 h.pass()
309 if !h.m.Restart("d", "gen:etag-1") {
310 t.Fatal("the restart was not queued")
311 }
312 waitFor(t, "the restart", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 })
313 calls, _, _ := h.act.snapshot()
314 if c := calls[0].a; c.Cause != activator.CauseRestart || c.Build != "v2" || c.RestartID != "gen:etag-1" {
315 t.Fatalf("attempt = %+v", c)
316 }
317}
318
319func TestRecoveryUsesTheRetainedIdentityAndTheCurrentPolicy(t *testing.T) {
320 h := newHarness(t, "a")
321 h.swap = "hold" // switched to hold after v1 passed under overlap
322 h.pass()
323 h.m.Restart("a", "gen:etag-1")
324 waitFor(t, "the restart", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 })
325 calls, _, _ := h.act.snapshot()
326 g, _ := h.act.Current("a")
327 if c := calls[0].a; c.Mode != activator.ModeHold || c.StagedPath != "/opt/v1" || c.Digest != "sha-v1" {
328 t.Fatalf("attempt = %+v, want hold, by the generation's captured path and digest (current gen %+v)", c, g)
329 }
330}
331
332func TestAReleaseOntoTheServingBuildMakesNoSwap(t *testing.T) {
333 h := newHarness(t, "a")
334 h.pass()
335 time.Sleep(50 * time.Millisecond)
336 if calls, _, _ := h.act.snapshot(); len(calls) != 0 {
337 t.Fatalf("calls = %+v", calls)
338 }
339}
340
341// Review focus 5.
342func TestConfigDeliveredMidSwapIsAckedOnlyWhenActive(t *testing.T) {
343 h := newHarness(t, "a")
344 h.stage("v2")
345 gate := h.act.hold("a")
346 h.install("v2", "a")
347 h.pass()
348 waitFor(t, "the release swap to start", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 })
349 // The ack is written from the scheduler's goroutine: counted atomically.
350 var acks atomic.Int32
351 ack := h.m.DeferAck(func(context.Context, appconfig.ConfigAck) { acks.Add(1) })
352 h.m.ConfigDelivered("a", "sha256:c1")
353 ack(appconfig.ConfigAck{Host: "a"})
354 if acks.Load() != 0 {
355 t.Fatal("acknowledged a config no generation has started with")
356 }
357 close(gate)
358 waitFor(t, "the config attempt", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 2 })
359 calls, _, _ := h.act.snapshot()
360 if c := calls[1].a; c.Cause != activator.CauseConfig || c.ConfigRev != "sha256:c1" || c.Build != "v2" {
361 t.Fatalf("second attempt = %+v, want a config attempt onto v2", c)
362 }
363 waitFor(t, "the ack", func() bool { return acks.Load() == 1 })
364}
365
366// Spec §8.1: a successful config attempt's activation is published before
367// its slot frees, and with no registry read in front of it. Published after
368// the slot and the read, a newer config attempt could complete first and be
369// overwritten (active went back to the older revision), and an unreadable
370// row lost the activation, leaving delivered and active apart for good. The
371// held ack is released (handed to the writer, its order fixed) in the same
372// step; the write itself is the writer's, after (Task 11).
373func TestConfigActivationIsPublishedBeforeTheSlotFrees(t *testing.T) {
374 h := newHarness(t, "a")
375 h.pass()
376 var acks, released, freed atomic.Int32
377 h.m.acks.onSubmit = func(host string) {
378 released.Add(1)
379 s := h.m.schedulerOf("a")
380 s.mu.Lock()
381 if hs := s.q.hosts["a"]; hs != nil && hs.running == nil {
382 freed.Add(1)
383 }
384 s.mu.Unlock()
385 }
386 ack := h.m.DeferAck(func(context.Context, appconfig.ConfigAck) { acks.Add(1) })
387 var swapped atomic.Bool
388 h.act.outcome = func(activator.Attempt) activator.SwapOutcome {
389 swapped.Store(true)
390 return activator.SwapDone
391 }
392 h.rows.mu.Lock()
393 h.rows.getErr = func(string) error {
394 if swapped.Load() {
395 return errors.New("registry: database is locked")
396 }
397 return nil
398 }
399 h.rows.mu.Unlock()
400 // The config attempt waits at the gate until the ack is held, so the ack
401 // is released by the activation and not written at once by a config sync
402 // call that came after it.
403 gate := h.act.hold("a")
404 h.m.ConfigDelivered("a", "sha256:c1")
405 ack(appconfig.ConfigAck{Host: "a"})
406 if acks.Load() != 0 {
407 t.Fatal("acknowledged a config no generation has started with")
408 }
409 close(gate)
410 waitFor(t, "the config activated and acknowledged", func() bool { return acks.Load() == 1 })
411 if released.Load() != 1 {
412 t.Fatalf("%d acks released, want 1", released.Load())
413 }
414 if n := freed.Load(); n != 0 {
415 t.Fatal("the activation was published after the attempt's slot was freed")
416 }
417}
418
419func TestAFailedConfigAttemptRetriesWithBackoff(t *testing.T) {
420 h := newHarness(t, "a")
421 h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
422 if a.Cause == activator.CauseConfig {
423 return activator.SwapFailed
424 }
425 return activator.SwapDone
426 }
427 h.pass()
428 h.m.ConfigDelivered("a", "sha256:c1")
429 waitFor(t, "the first config attempt", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 })
430 waitFor(t, "the pending event", func() bool { return h.events.has(configPendingHead) })
431 h.advance(59 * time.Second)
432 h.pass()
433 time.Sleep(50 * time.Millisecond)
434 if calls, _, _ := h.act.snapshot(); len(calls) != 1 {
435 t.Fatal("retried inside the one-minute backoff")
436 }
437 h.advance(2 * time.Second)
438 h.pass()
439 waitFor(t, "the retry after the backoff", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 2 })
440 if len(h.book.List()) != 0 {
441 t.Fatal("a failed config attempt wrote a failure record")
442 }
443}
444
445func TestBootstrapRecordsAwakeInstancesOnly(t *testing.T) {
446 h := newHarness(t, "awake", "asleep")
447 for _, host := range []string{"awake", "asleep"} {
448 r := h.rows.get(host)
449 r.GoodVersion, r.GoodDigest = "", ""
450 h.rows.put(r)
451 }
452 h.act.mu.Lock()
453 delete(h.act.gens, "asleep")
454 h.act.mu.Unlock()
455 rs, _ := h.rows.List()
456 h.m.Bootstrap(rs)
457 if r := h.rows.get("awake"); r.Good() != (registry.GoodBuild{Version: "v1", Digest: "sha-v1"}) {
458 t.Fatalf("awake row good = %+v", r.Good())
459 }
460 if r := h.rows.get("asleep"); r.GoodVersion != "" {
461 t.Fatal("nothing may be inferred for a sleeping instance (spec §8.5)")
462 }
463}
464
465// `carlos promote --retry` re-signs the channel pointer with a fresh
466// PromotedAt. The record keeps the PromotedAt it failed under, survives that
467// same pointer being read again, and clears on the re-signed one.
468func TestAReSignedPointerClearsTheRecord(t *testing.T) {
469 h := newHarness(t, "a", "b")
470 h.stage("v2")
471 h.failing.Store(true)
472 h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
473 if a.Build == "v2" && h.failing.Load() {
474 return activator.SwapFailed
475 }
476 return activator.SwapDone
477 }
478 h.pointer("stable", "v2", promotedAt)
479 h.install("v2", "a", "b")
480 h.pass()
481 waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
482 if r, _ := h.book.Lookup("bab", "blog", "v2"); !r.PromotedAt.Equal(promotedAt) {
483 t.Fatalf("record keeps PromotedAt %v, want the pointer's %v", r.PromotedAt, promotedAt)
484 }
485 h.advance(pointerReadEvery)
486 h.pass()
487 if _, ok := h.book.Lookup("bab", "blog", "v2"); !ok {
488 t.Fatal("the pointer the build failed under cleared its record")
489 }
490 h.failing.Store(false)
491 h.pointer("stable", "v2", promotedAt.Add(time.Minute))
492 h.advance(pointerReadEvery)
493 h.pass()
494 waitFor(t, "every host on v2", func() bool { return h.servingAll("v2", "a", "b") })
495 calls, _, _ := h.act.snapshot()
496 if c := calls[1].a; c.Cause != activator.CauseRetry || !c.Canary {
497 t.Fatalf("first attempt after the retry = %+v, want a retry canary", c)
498 }
499}
500
501// `carlos instances retry <host>`: that host alone, as a canary.
502func TestRetryHostClearsOneInstance(t *testing.T) {
503 h := newHarness(t, "a", "b")
504 h.stage("v2")
505 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "c", PromotedAt: promotedAt, At: t0})
506 h.install("v2", "a", "b")
507 h.pass()
508 if !h.retry("b", "v2") {
509 t.Fatal("RetryHost did not clear b")
510 }
511 waitFor(t, "b on v2", func() bool { return h.servingAll("v2", "b") })
512 if h.servingAll("v2", "a") {
513 t.Fatal("a host retry moved another host")
514 }
515}
516
517// Carried from Task 3: a canonical manifest that cannot be read, or a store
518// that cannot compare digests, must reach AttemptMode as a hold demand, never
519// as "". Only release.ErrStagedDigestMismatch is a staging refusal.
520func TestAnUnreadableCanonicalManifestRunsInHold(t *testing.T) {
521 for name, set := range map[string]func(h *harness){
522 "demand error": func(h *harness) { h.demandErr["v2"] = errors.New("store: GetObject: connection reset") },
523 "no digest": func(h *harness) { h.noDigest["v2"] = true },
524 } {
525 t.Run(name, func(t *testing.T) {
526 h := newHarness(t, "a")
527 h.stage("v2")
528 set(h)
529 h.install("v2", "a")
530 h.pass()
531 waitFor(t, "the attempt", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 })
532 want, err := release.FileSHA256(release.VersionPath(h.opt, "bab", "v2", "blog"))
533 if err != nil {
534 t.Fatal(err)
535 }
536 calls, _, _ := h.act.snapshot()
537 if c := calls[0].a; c.Mode != activator.ModeHold || c.Demand != release.DemandHold || c.Digest != want {
538 t.Fatalf("attempt = %+v, want hold on the staged file's own digest %s", c, want)
539 }
540 if h.touched.Load() != 0 || len(h.book.List()) != 0 {
541 t.Fatal("an unreadable manifest is neither a staging refusal nor a build failure")
542 }
543 })
544 }
545}
546
547// Carried from Task 4: a record keeps a real PromotedAt. With no pointer to
548// read, the failure's own time stands in, never a zero time.
549func TestAFailureWithNoReadablePointerKeepsItsOwnTime(t *testing.T) {
550 h := newHarness(t, "a")
551 h.stage("v2")
552 h.act.outcome = func(activator.Attempt) activator.SwapOutcome { return activator.SwapFailed }
553 h.install("v2", "a")
554 h.pass()
555 waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
556 r, _ := h.book.Lookup("bab", "blog", "v2")
557 if r.PromotedAt.IsZero() || !r.PromotedAt.Equal(r.At) {
558 t.Fatalf("record %+v: want the failure's own time as PromotedAt", r)
559 }
560}
561
562// Carried from Task 5: a second `carlos promote --retry` of the same build,
563// after the retry failed too, clears the record again and the next attempt is
564// a canary again. Nothing may rest on wantBuild reporting a change.
565func TestASecondRetryOfTheSameBuildIsACanaryAgain(t *testing.T) {
566 h := newHarness(t, "a", "b")
567 h.stage("v2")
568 h.failing.Store(true)
569 h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
570 if a.Build == "v2" && h.failing.Load() {
571 return activator.SwapFailed
572 }
573 return activator.SwapDone
574 }
575 h.pointer("stable", "v2", promotedAt)
576 h.install("v2", "a", "b")
577 h.pass()
578 waitFor(t, "the first failure", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 })
579 waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
580
581 h.pointer("stable", "v2", promotedAt.Add(time.Minute))
582 h.advance(pointerReadEvery)
583 h.pass()
584 waitFor(t, "the retry to fail too", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 2 })
585 waitFor(t, "the record again", func() bool {
586 r, ok := h.book.Lookup("bab", "blog", "v2")
587 return ok && r.PromotedAt.Equal(promotedAt.Add(time.Minute))
588 })
589
590 h.failing.Store(false)
591 h.pointer("stable", "v2", promotedAt.Add(2*time.Minute))
592 h.advance(pointerReadEvery)
593 h.pass()
594 waitFor(t, "every host on v2", func() bool { return h.servingAll("v2", "a", "b") })
595 calls, _, _ := h.act.snapshot()
596 for i, c := range calls[:3] {
597 if c.a.Host != "a" || !c.a.Canary {
598 t.Fatalf("call %d = %+v, want a canary on a", i, c.a)
599 }
600 }
601 if c := calls[2].a; c.Cause != activator.CauseRetry {
602 t.Fatalf("second retry's attempt = %+v, want cause retry", c)
603 }
604}
605
606// Carried from Task 6 and plan 3 (O7): a hold attempt that fails is held
607// down in the book before its slot frees, so no wake planned in between can
608// boot the failed hold build. The mode is the one Swap ran (a foreign-layout
609// overlap runs as a hold, SwapResult.Mode), and the record's build comes from
610// the dispatch: a failed hold returns no generation.
611func TestAFailedHoldIsHeldDownBeforeItsSlotFrees(t *testing.T) {
612 h := newHarness(t, "a")
613 h.stage("v2")
614 h.act.mode = activator.ModeHold
615 var failed atomic.Bool
616 h.act.outcome = func(activator.Attempt) activator.SwapOutcome {
617 failed.Store(true)
618 return activator.SwapDown
619 }
620 var bad, seen atomic.Int32
621 h.rows.mu.Lock()
622 h.rows.getHook = func(host string) {
623 if !failed.Load() {
624 return
625 }
626 s := h.m.schedulerOf(host)
627 s.mu.Lock()
628 free := s.q.hosts[host] != nil && s.q.hosts[host].running == nil
629 s.mu.Unlock()
630 if !free {
631 return
632 }
633 seen.Add(1)
634 if !h.book.HeldDown("bab", "blog", "v2", host) {
635 bad.Add(1)
636 }
637 }
638 h.rows.mu.Unlock()
639 h.install("v2", "a")
640 h.pass()
641 waitFor(t, "the held-down record", func() bool { return h.book.HeldDown("bab", "blog", "v2", "a") })
642 waitFor(t, "the down event", func() bool { return h.events.has(textSwapDown("v2")) })
643 if bad.Load() != 0 || seen.Load() == 0 {
644 t.Fatalf("%d of %d look(s) with the slot free found the host not held down", bad.Load(), seen.Load())
645 }
646 r, _ := h.book.Lookup("bab", "blog", "v2")
647 if r.Mode != string(activator.ModeHold) || r.Build != "v2" || r.Host != "a" {
648 t.Fatalf("record = %+v, want build v2 held down in the mode Swap ran", r)
649 }
650}
651
652// Carried from Task 4: every Pass prunes, a held-down instance whose route
653// still names the failed build keeps the record, and a record no route
654// installs any more goes.
655func TestPassPrunesOnlyRecordsNoRouteInstalls(t *testing.T) {
656 h := newHarness(t, "a", "b")
657 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a", HeldDown: []string{"a"}, PromotedAt: promotedAt, At: t0})
658 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v0", Host: "b", PromotedAt: promotedAt, At: t0})
659 h.install("v2", "a")
660 h.pass()
661 if _, ok := h.book.Lookup("bab", "blog", "v0"); ok {
662 t.Fatal("a record no route installs survived the pass")
663 }
664 if !h.book.HeldDown("bab", "blog", "v2", "a") {
665 t.Fatal("the pass pruned a held-down instance's record while its route still names the build")
666 }
667 h.install("v1", "a") // rolled back by hand
668 h.pass()
669 if len(h.book.List()) != 0 {
670 t.Fatalf("records = %+v after the last route left v2", h.book.List())
671 }
672}
673
674// Carried from Task 6, spec §8.2/§8.4: an install by hand that clears the
675// record lifts the halt with it. A halt is in memory and outlived its record,
676// so installing the build again later was refused for good.
677func TestAnInstallByHandThatClearsTheRecordLiftsTheHalt(t *testing.T) {
678 h := newHarness(t, "a", "b")
679 h.stage("v2")
680 h.failing.Store(true)
681 h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
682 if a.Build == "v2" && h.failing.Load() {
683 return activator.SwapFailed
684 }
685 return activator.SwapDone
686 }
687 h.install("v2", "a", "b")
688 h.pass()
689 waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
690 h.install("v1", "a", "b")
691 h.pass()
692 if len(h.book.List()) != 0 {
693 t.Fatal("the record outlived every route installing the build")
694 }
695 h.failing.Store(false)
696 h.install("v2", "a", "b")
697 h.pass()
698 waitFor(t, "every host on v2", func() bool { return h.servingAll("v2", "a", "b") })
699 calls, _, _ := h.act.snapshot()
700 if c := calls[1].a; !c.Canary {
701 t.Fatalf("first attempt after the hold lifted = %+v, want a fresh canary", c)
702 }
703}
704
705// Carried from Task 6: `carlos instances retry` runs that host as a canary
706// through its cohort's halt, and only that host.
707func TestRetryHostRunsACanaryThroughAHalt(t *testing.T) {
708 h := newHarness(t, "a", "b")
709 h.stage("v2")
710 h.failing.Store(true)
711 h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
712 if a.Build == "v2" && h.failing.Load() {
713 return activator.SwapFailed
714 }
715 return activator.SwapDone
716 }
717 h.install("v2", "a", "b")
718 h.pass()
719 waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
720 h.failing.Store(false)
721 if !h.retry("b", "v2") {
722 t.Fatal("RetryHost did not clear b")
723 }
724 waitFor(t, "b on v2", func() bool { return h.servingAll("v2", "b") })
725 calls, _, _ := h.act.snapshot()
726 if c := calls[1].a; c.Host != "b" || !c.Canary || c.Cause != activator.CauseRetry {
727 t.Fatalf("attempt after the host retry = %+v, want b's retry canary", c)
728 }
729 h.pass()
730 time.Sleep(50 * time.Millisecond)
731 if h.servingAll("v2", "a") {
732 t.Fatal("a host retry lifted the halt for another host")
733 }
734}
735
736// Carried from Task 6: a failed config attempt backs off even when its row
737// cannot be read afterwards; the backoff is set in the step that frees the
738// slot, so nothing can re-admit the attempt before it. The row is unreadable
739// only for the read that follows the failure: the next attempt's own read
740// succeeds, so only the backoff can keep it from running.
741func TestAFailedConfigAttemptBacksOffWithItsRowUnreadable(t *testing.T) {
742 h := newHarness(t, "a")
743 var failed, refused atomic.Bool
744 h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
745 if a.Cause == activator.CauseConfig {
746 failed.Store(true)
747 return activator.SwapFailed
748 }
749 return activator.SwapDone
750 }
751 h.rows.mu.Lock()
752 h.rows.getErr = func(string) error {
753 if failed.Load() && refused.CompareAndSwap(false, true) {
754 return errors.New("registry: database is locked")
755 }
756 return nil
757 }
758 h.rows.mu.Unlock()
759 h.pass()
760 h.m.ConfigDelivered("a", "sha256:c1")
761 waitFor(t, "the first config attempt", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) >= 1 })
762 h.pass()
763 time.Sleep(50 * time.Millisecond)
764 if calls, _, _ := h.act.snapshot(); len(calls) != 1 {
765 t.Fatalf("%d config attempts with no backoff after an unreadable row", len(calls))
766 }
767}
768
769// Carried from Task 7: activating an older revision than the delivered one
770// leaves the delivered revision's failure count, and so its backoff, alone.
771func TestAnOlderActivationKeepsTheConfigFailureCount(t *testing.T) {
772 h := newHarness(t, "a")
773 h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
774 if a.Cause == activator.CauseConfig {
775 return activator.SwapFailed
776 }
777 return activator.SwapDone
778 }
779 h.pass()
780 h.m.ConfigDelivered("a", "sha256:c2")
781 waitFor(t, "the pending event", func() bool { return h.events.has(configPendingHead) })
782 h.m.configActivated("a", "sha256:c1")
783 h.m.mu.Lock()
784 fails := h.m.cfg["a"].fails
785 h.m.mu.Unlock()
786 if fails != 1 {
787 t.Fatalf("fails = %d after an older revision activated, want 1", fails)
788 }
789}
790
791// Carried from Task 6, spec §8.1: an instance that goes to sleep with a
792// config attempt pending needs no attempt any more. Its next wake starts with
793// the delivered file, so the revision is active, and the held ack goes out as
794// a sleeper's does, marked Hibernating. After it wakes, no config attempt
795// restarts a generation that already runs the revision.
796func TestASleepersPendingConfigIsNoAttemptAfterItWakes(t *testing.T) {
797 h := newHarness(t, "a")
798 h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
799 if a.Cause == activator.CauseConfig {
800 return activator.SwapFailed
801 }
802 return activator.SwapDone
803 }
804 h.pass()
805 var mu sync.Mutex
806 var acks []appconfig.ConfigAck
807 ack := h.m.DeferAck(func(_ context.Context, a appconfig.ConfigAck) {
808 mu.Lock()
809 defer mu.Unlock()
810 acks = append(acks, a)
811 })
812 h.m.ConfigDelivered("a", "sha256:c1")
813 ack(appconfig.ConfigAck{Host: "a", Restarted: true})
814 waitFor(t, "the failed config attempt", func() bool { return h.events.has(configPendingHead) })
815 h.act.mu.Lock()
816 delete(h.act.gens, "a") // hibernated inside its backoff
817 h.act.mu.Unlock()
818 h.pass()
819 waitFor(t, "the held ack", func() bool { mu.Lock(); defer mu.Unlock(); return len(acks) == 1 })
820 mu.Lock()
821 if !acks[0].Hibernating || acks[0].Restarted {
822 t.Fatalf("ack = %+v, want a sleeper's ack, Hibernating", acks[0])
823 }
824 mu.Unlock()
825 h.act.serve("a", "v1") // woke, with the delivered file
826 h.advance(2 * time.Minute)
827 h.pass()
828 time.Sleep(50 * time.Millisecond)
829 if calls, _, _ := h.act.snapshot(); len(calls) != 1 {
830 t.Fatalf("calls = %+v: a woken sleeper ran its old pending config attempt", calls)
831 }
832}
833
834// Spec §8.1, fix round 1: a config attempt pending for c1 while the instance
835// was awake is no attempt any more once c2 is delivered to it asleep, even
836// if it wakes before the scheduler ever saw it asleep (configSlept never
837// fires). The wake starts with c2. The stale c1 attempt would restart a
838// generation already running c2 and then mark c1 active.
839func TestAConfigDeliveredToASleeperClearsTheOlderPendingAttempt(t *testing.T) {
840 h := newHarness(t, "a")
841 h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
842 if a.Cause == activator.CauseConfig {
843 return activator.SwapFailed
844 }
845 return activator.SwapDone
846 }
847 h.pass()
848 h.m.ConfigDelivered("a", "sha256:c1")
849 waitFor(t, "the failed c1 attempt", func() bool { return h.events.has(configPendingHead) })
850 s := h.m.schedulerOf("a")
851 waitFor(t, "the slot to free with c1 pending", func() bool {
852 s.mu.Lock()
853 defer s.mu.Unlock()
854 hs := s.q.hosts["a"]
855 return hs != nil && hs.running == nil && hs.wantConfig == "sha256:c1"
856 })
857 h.act.mu.Lock()
858 delete(h.act.gens, "a") // hibernated inside the backoff; no scheduler iteration runs
859 h.act.mu.Unlock()
860 h.m.ConfigDelivered("a", "sha256:c2")
861 h.act.serve("a", "v1") // woke at once, with c2 in the file
862 h.m.configActivated("a", "sha256:c2")
863 h.advance(2 * time.Minute)
864 h.pass()
865 time.Sleep(50 * time.Millisecond)
866 if calls, _, _ := h.act.snapshot(); len(calls) != 1 {
867 t.Fatalf("calls = %+v: a stale c1 attempt ran against a generation already on c2", calls)
868 }
869 h.m.mu.Lock()
870 tr := h.m.track("a")
871 delivered, active := tr.delivered, tr.active
872 h.m.mu.Unlock()
873 if delivered != "sha256:c2" || active != "sha256:c2" {
874 t.Fatalf("delivered %q, active %q: want both c2", delivered, active)
875 }
876}
877
878// Spec §8.4: a record stays while any instance is on the failed build. A
879// held-down instance whose row is "draining" during a Pass is not managed
880// for that Pass, but it still names the build: the record and the halt stay.
881func TestPruneKeepsTheRecordOfAHeldDownHostWhoseRowLeavesReady(t *testing.T) {
882 h := newHarness(t, "a")
883 h.stage("v2")
884 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a", HeldDown: []string{"a"}, PromotedAt: promotedAt, At: t0})
885 r := h.rows.get("a")
886 r.Version, r.Status = "v2", "draining"
887 h.rows.put(r)
888 h.pass()
889 if !h.book.HeldDown("bab", "blog", "v2", "a") {
890 t.Fatal("a Pass dropped the record of a held-down host whose row names the build")
891 }
892 r.Status = "ready"
893 h.rows.put(r)
894 h.pass()
895 if !h.book.HeldDown("bab", "blog", "v2", "a") {
896 t.Fatal("the held-down host lost its record when its row returned to ready")
897 }
898}
899
900// Plan 3's deferral (O5): an attempt abandoned while it is still the host's
901// newest (a hold whose stop withdrew, a lost re-check) waits before it runs
902// again, rather than looping stop cycles. One abandoned by a newer desire
903// does not wait (TestTwoReleasesTwentySecondsApartNeverSwitchToTheFirst).
904func TestAnAbandonedAttemptStillNewestWaitsBeforeItRunsAgain(t *testing.T) {
905 h := newHarness(t, "a")
906 h.stage("v2")
907 h.act.outcome = func(activator.Attempt) activator.SwapOutcome { return activator.SwapAbandoned }
908 h.install("v2", "a")
909 h.pass()
910 waitFor(t, "the first attempt", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) >= 1 })
911 h.pass()
912 time.Sleep(50 * time.Millisecond)
913 if calls, _, _ := h.act.snapshot(); len(calls) != 1 {
914 t.Fatalf("%d attempts: an abandoned attempt ran again at once", len(calls))
915 }
916 h.advance(abandonDeferral + time.Second)
917 h.pass()
918 waitFor(t, "the attempt after the wait", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 2 })
919 if len(h.book.List()) != 0 {
920 t.Fatal("an abandoned attempt wrote a failure record")
921 }
922}
923
924// PF5 (plan 3's O8): member-facing events carry the reason word, never the
925// swap's diagnostic text, which can name paths and internals.
926func TestMemberEventsCarryOnlyTheReasonWord(t *testing.T) {
927 h := newHarness(t, "a")
928 h.stage("v2")
929 h.act.reason = "spawn: fork/exec /opt/carlos/versions/bab/v2/blog: permission denied"
930 h.act.outcome = func(activator.Attempt) activator.SwapOutcome { return activator.SwapFailed }
931 h.install("v2", "a")
932 h.pass()
933 waitFor(t, "the halt event", func() bool {
934 return h.events.has(textHead(textCohortHalted("v2", "\x00", status.ReasonHealth), "\x00"))
935 })
936 waitFor(t, "the failed event", func() bool { return h.events.has(textHead(textSwapFailed("v2", "\x00", status.ReasonHealth), "\x00")) })
937 for _, raw := range []string{"permission denied", "fork/exec", "blog:"} {
938 if h.events.has(raw) {
939 t.Fatalf("a member event carries the raw reason %q", raw)
940 }
941 }
942 if !h.events.has(textSwapFailed("v2", "v1", status.ReasonHealth)) ||
943 !h.events.has(textCohortHalted("v2", "box-1", status.ReasonHealth)) {
944 t.Fatal("the events do not carry the reason word")
945 }
946}
947
948// Astra R3 §5 item 4 (execution admission): an attempt is admitted from the
949// pass's view of a row, and the row may change before the scheduler runs it.
950// execute revalidates the row it reads: a row that has become a named-unit
951// route, or that now belongs to another app, is no longer this scheduler's,
952// so the attempt ends abandoned with nothing run (no Swap, no ReplaceWorker,
953// no record). Before the fix the attempt went ahead on the changed row.
954func TestARowThatLeavesTheSchedulerBeforeDispatchIsAbandoned(t *testing.T) {
955 for _, worker := range []bool{false, true} {
956 for name, change := range map[string]func(*registry.Route){
957 "a named-unit route": func(r *registry.Route) { r.Unit = "blog.service" },
958 "another app": func(r *registry.Route) { r.App = "shop" },
959 } {
960 kind := "socket instance"
961 if worker {
962 kind = "worker"
963 }
964 t.Run(kind+"/"+name, func(t *testing.T) {
965 var h *harness
966 if worker {
967 h = workerHarness(t, "h")
968 } else {
969 h = newHarness(t, "h")
970 }
971 var (
972 mu sync.Mutex
973 abandoned []string
974 )
975 h.m.d.Logf = func(format string, args ...any) {
976 if line := fmt.Sprintf(format, args...); strings.Contains(line, "abandoned") {
977 mu.Lock()
978 abandoned = append(abandoned, line)
979 mu.Unlock()
980 }
981 }
982 h.stage("v2")
983 h.install("v2", "h")
984 // The row changes between the pass that queued the attempt and
985 // the scheduler's read of it.
986 h.rows.mu.Lock()
987 h.rows.getHook = func(host string) {
988 r := h.rows.get(host)
989 change(&r)
990 h.rows.put(r)
991 }
992 h.rows.mu.Unlock()
993 moves := func() int {
994 calls, _, _ := h.act.snapshot()
995 return len(calls) + len(h.act.replacements())
996 }
997 h.pass()
998 waitFor(t, "the attempt to end", func() bool {
999 mu.Lock()
1000 n := len(abandoned)
1001 mu.Unlock()
1002 return (n > 0 || moves() > 0) && !h.m.attemptRunning("h")
1003 })
1004 if n := moves(); n != 0 {
1005 t.Fatalf("%d Swap/ReplaceWorker calls on a row that left the scheduler, want none", n)
1006 }
1007 mu.Lock()
1008 got := abandoned[0]
1009 mu.Unlock()
1010 if !strings.Contains(got, "route no longer managed") {
1011 t.Fatalf("abandoned as %q, want the route no longer managed", got)
1012 }
1013 if recs := h.book.List(); len(recs) != 0 {
1014 t.Fatalf("records %+v for an attempt that never ran", recs)
1015 }
1016 })
1017 }
1018 }
1019}
1020
1021func TestReasonWords(t *testing.T) {
1022 for text, want := range map[string]string{
1023 "health check failed": status.ReasonHealth,
1024 "swap: successor exited during its settle": status.ReasonSettle,
1025 "exited within 10s of becoming healthy": status.ReasonExit,
1026 "staging: digest mismatch": status.ReasonStaging,
1027 "version name reused with different content, ship under a new name": status.ReasonReused,
1028 "GET / answered 500": status.ReasonHealth,
1029 "": status.ReasonHealth,
1030 } {
1031 if got := reasonWord(text); got != want {
1032 t.Errorf("reasonWord(%q) = %q, want %q", text, got, want)
1033 }
1034 }
1035}
1036
1037// configPendingHead is the config-pending event up to its reason: the tests
1038// that wait for it do not know the reason or the backoff.
1039var configPendingHead = textHead(rolloutwords.EventConfigPending, "%s")
1040
1041// Task 11 rule 3 (Astra R1 Flag triage T8): the held ack an activation
1042// releases is handed to the manager's writer, not written by done(). A store
1043// write that never returns cannot keep the attempt's slot, so the host's
1044// next attempt is admitted while the writer is still blocked.
1045func TestABlockedAckWriterDoesNotHoldTheSlot(t *testing.T) {
1046 h := newHarness(t, "a")
1047 h.pass()
1048 h.stage("v2")
1049 entered, block := make(chan struct{}, 4), make(chan struct{})
1050 t.Cleanup(func() { close(block) })
1051 ack := h.m.DeferAck(func(context.Context, appconfig.ConfigAck) {
1052 entered <- struct{}{}
1053 <-block
1054 })
1055 h.m.ConfigDelivered("a", "sha256:c1")
1056 ack(appconfig.ConfigAck{Host: "a"})
1057 select {
1058 case <-entered:
1059 case <-time.After(5 * time.Second):
1060 t.Fatal("the config attempt's ack was never written")
1061 }
1062 h.install("v2", "a")
1063 h.pass()
1064 waitFor(t, "the release attempt, admitted while the ack writer blocks", func() bool { return h.servingAll("v2", "a") })
1065}
1066
1067// Task 11 rule 2 (Astra R1 Flag triage T10): the failing pointer a wake's
1068// plan reads runs under the wake's context. A store read that never answers
1069// ends when the wake is cancelled, and the plan goes on with no PromotedAt
1070// (the failure's own time stands in, the safe direction).
1071func TestABlockedPointerReadEndsWithTheWake(t *testing.T) {
1072 h := newHarness(t, "a")
1073 h.stage("v2")
1074 h.install("v2", "a")
1075 reading := make(chan struct{})
1076 var once sync.Once
1077 h.m.d.Pointer = func(ctx context.Context, _, _, _ string) (*release.ChannelPointer, error) {
1078 once.Do(func() { close(reading) })
1079 <-ctx.Done()
1080 return nil, ctx.Err()
1081 }
1082 ctx, cancel := context.WithCancel(context.Background())
1083 defer cancel()
1084 plans := make(chan activator.WakePlan, 1)
1085 go func() { plans <- h.m.Plan(ctx, h.rows.get("a")) }()
1086 <-reading
1087 cancel()
1088 select {
1089 case p := <-plans:
1090 if !p.FirstWake || !p.FailedUnder.IsZero() {
1091 t.Fatalf("plan %+v: want the first wake of v2 with no PromotedAt", p)
1092 }
1093 case <-time.After(2 * time.Second):
1094 t.Fatal("the plan's pointer read outlived the wake's context")
1095 }
1096}
1097
1098// Task 11 Corrections (Task 5 review; spec §8.6): a planned agent restart
1099// stops the activator first, which cancels a swap mid-flight, and then joins
1100// the scheduler. Close returns only once that attempt's done() has written
1101// its outcome (here the hold-ran marker of the hold build that ran) and
1102// ended its intent, so the restart reconciles nothing. After Close no
1103// attempt is admitted.
1104func TestCloseJoinsAnAttemptCancelledByShutdown(t *testing.T) {
1105 h := newHarness(t, "a")
1106 dir := h.onDisk()
1107 h.swap = "hold"
1108 h.stage("v2", "v3")
1109 gate := h.act.hold("a")
1110 h.act.outcome = func(activator.Attempt) activator.SwapOutcome {
1111 time.Sleep(200 * time.Millisecond) // the swap winding down after its entry stopped
1112 return activator.SwapAbandoned
1113 }
1114 h.act.executed = func(activator.Attempt, activator.SwapOutcome) bool { return true } // the hold build ran
1115 h.install("v2", "a")
1116 h.pass()
1117 waitFor(t, "the hold swap to start", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 })
1118 if n := len(fileOf(t, dir).Intents); n != 1 {
1119 t.Fatalf("%d intents on disk while the swap runs, want 1", n)
1120 }
1121 close(gate) // svc.Shutdown stops the entry under the swap
1122 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1123 defer cancel()
1124 h.m.Close(ctx)
1125 f := fileOf(t, dir)
1126 if len(f.Intents) != 0 {
1127 t.Fatalf("intents %+v on disk after Close: a restart would reconcile the attempt as a failure", f.Intents)
1128 }
1129 if mk, ok := f.HoldRan["a"]; !ok || mk.Build != "v2" {
1130 t.Fatalf("hold-ran markers %+v after Close, want a's for v2: the attempt's outcome", f.HoldRan)
1131 }
1132 h.advance(time.Hour)
1133 h.install("v3", "a")
1134 h.pass()
1135 time.Sleep(100 * time.Millisecond)
1136 if _, s, _ := h.act.snapshot(); len(s) != 1 {
1137 t.Fatalf("swaps started %v: an attempt was admitted after Close", s)
1138 }
1139}
1140
1141// Task 10 review minor (1), restored in Task 11: the pointer pass requeues a
1142// cleared build at once, before it reads the app's next channel, as the
1143// armed pass did before the pointer work was shared (onCleared). Both
1144// channels re-sign v2, so the second read always follows a clear.
1145//
1146// Only the pass's reads are counted (fix wave 1, item 6). The requeued
1147// retry's attempt runs on the scheduler's goroutine and reads the pointer
1148// too, for its FailedUnder (scheduler.execute, failedUnder); counted, it
1149// made a third read about once in a hundred runs.
1150func TestAPointerClearRequeuesBeforeTheNextChannelIsRead(t *testing.T) {
1151 h := newHarness(t, "a", "b")
1152 h.stage("v2")
1153 rb := h.rows.get("b")
1154 rb.Channel = "beta"
1155 h.rows.put(rb)
1156 h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "c", PromotedAt: promotedAt, At: t0})
1157 h.install("v2", "a", "b")
1158 h.pass()
1159 h.pointer("stable", "v2", promotedAt.Add(time.Minute))
1160 h.pointer("beta", "v2", promotedAt.Add(time.Minute))
1161 var reads atomic.Int32
1162 var requeuedLate atomic.Bool
1163 h.mu.Lock()
1164 h.pointerHook = func() {
1165 if !calledFrom("(*Manager).applyPointers") {
1166 return
1167 }
1168 if reads.Add(1) != 2 {
1169 return
1170 }
1171 h.m.mu.Lock()
1172 n := len(h.m.retried)
1173 h.m.mu.Unlock()
1174 if n == 0 {
1175 requeuedLate.Store(true)
1176 }
1177 }
1178 h.mu.Unlock()
1179 h.advance(pointerReadEvery)
1180 h.pass()
1181 if reads.Load() != 2 {
1182 t.Fatalf("%d pointer reads, want 2 (stable and beta)", reads.Load())
1183 }
1184 if requeuedLate.Load() {
1185 t.Fatal("the cleared build was requeued only after every channel was read")
1186 }
1187 waitFor(t, "every host on v2", func() bool { return h.servingAll("v2", "a", "b") })
1188}
1189
1190// Task 11 Corrections (plan review #10): acks are ordered at the transition.
1191// The writer keeps one pending ack per host, the newest by sequence; an older
1192// one is dropped, never written after a newer one; hosts are written in the
1193// order they were first queued; each write is bounded by ackWriteBound.
1194func TestAcksAreWrittenNewestLastPerHost(t *testing.T) {
1195 ctx, cancel := context.WithCancel(context.Background())
1196 defer cancel()
1197 w := newAckWriter(ctx, func(string, ...any) {})
1198 var mu sync.Mutex
1199 var got []string
1200 write := func(tag string) func(context.Context) {
1201 return func(ctx context.Context) {
1202 if dl, ok := ctx.Deadline(); !ok || time.Until(dl) > ackWriteBound {
1203 t.Errorf("ack %s written with deadline %v (%v), want at most ackWriteBound", tag, dl, ok)
1204 }
1205 mu.Lock()
1206 got = append(got, tag)
1207 mu.Unlock()
1208 }
1209 }
1210 entered, block := make(chan struct{}), make(chan struct{})
1211 first := write("a1")
1212 w.submit("a", ackJob{seq: 1, write: func(ctx context.Context) { close(entered); <-block; first(ctx) }})
1213 <-entered
1214 w.submit("a", ackJob{seq: 3, write: write("a3")})
1215 w.submit("a", ackJob{seq: 2, write: write("a2")}) // older than the pending a3
1216 w.submit("b", ackJob{seq: 4, write: write("b4")})
1217 w.submit("a", ackJob{seq: 5, write: write("a5")}) // replaces a3, keeps a's place
1218 close(block)
1219 waitFor(t, "three writes", func() bool { mu.Lock(); defer mu.Unlock(); return len(got) == 3 })
1220 w.submit("a", ackJob{seq: 2, write: write("a2-late")}) // older than one written
1221 time.Sleep(50 * time.Millisecond)
1222 mu.Lock()
1223 defer mu.Unlock()
1224 if want := []string{"a1", "a5", "b4"}; strings.Join(got, ",") != strings.Join(want, ",") {
1225 t.Fatalf("written %v, want %v", got, want)
1226 }
1227}
1228
1229// Task 11 Corrections (spec §8.6): Close also joins a wake event under way.
1230// A terminal OnGen writes the wake's outcome and ends its intent; Close does
1231// not return while it is still running.
1232func TestCloseWaitsForAnOnGenUnderWay(t *testing.T) {
1233 h := newHarness(t, "a")
1234 h.pass()
1235 entered, release := make(chan struct{}), make(chan struct{})
1236 var once sync.Once
1237 h.rows.mu.Lock()
1238 h.rows.setServingHook = func(string) {
1239 once.Do(func() { close(entered); <-release })
1240 }
1241 h.rows.mu.Unlock()
1242 g, _ := h.act.Current("a")
1243 go h.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: h.rows.get("a"), Gen: g, FirstWake: true})
1244 <-entered
1245 closed := make(chan struct{})
1246 go func() {
1247 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1248 defer cancel()
1249 h.m.Close(ctx)
1250 close(closed)
1251 }()
1252 select {
1253 case <-closed:
1254 t.Fatal("Close returned while a wake event was still being recorded")
1255 case <-time.After(100 * time.Millisecond):
1256 }
1257 close(release)
1258 select {
1259 case <-closed:
1260 case <-time.After(5 * time.Second):
1261 t.Fatal("Close did not return once the wake event finished")
1262 }
1263}
1264
1265// Task 11 Corrections: closing the writer writes what is pending, each
1266// write still bounded, and an ack submitted after it is dropped.
1267func TestClosingTheAckWriterWritesWhatIsPending(t *testing.T) {
1268 ctx, cancel := context.WithCancel(context.Background())
1269 defer cancel()
1270 w := newAckWriter(ctx, func(string, ...any) {})
1271 var mu sync.Mutex
1272 var got []string
1273 write := func(tag string) func(context.Context) {
1274 return func(context.Context) { mu.Lock(); got = append(got, tag); mu.Unlock() }
1275 }
1276 entered, block := make(chan struct{}), make(chan struct{})
1277 w.submit("a", ackJob{seq: 1, write: func(context.Context) { close(entered); <-block }})
1278 <-entered
1279 w.submit("b", ackJob{seq: 2, write: write("b2")})
1280 w.submit("c", ackJob{seq: 3, write: write("c3")})
1281 go func() { time.Sleep(50 * time.Millisecond); close(block) }()
1282 cctx, ccancel := context.WithTimeout(context.Background(), 5*time.Second)
1283 defer ccancel()
1284 w.close(cctx)
1285 w.submit("d", ackJob{seq: 4, write: write("d4")})
1286 time.Sleep(50 * time.Millisecond)
1287 mu.Lock()
1288 defer mu.Unlock()
1289 if strings.Join(got, ",") != "b2,c3" {
1290 t.Fatalf("written %v, want b2 and c3 written before close returned, and nothing after", got)
1291 }
1292}
1293
1294// joinCount is Close's count of operations under way (test-only).
1295func (m *Manager) joinCount() int {
1296 m.joinMu.Lock()
1297 defer m.joinMu.Unlock()
1298 return m.inflight
1299}
1300
1301// Task 11 fix round 1, review finding 1 (spec §8.6): a wake already past
1302// doWake's context check when the activator's Shutdown begins can still plan
1303// and write its intent. Its terminal event (afterWake's, here a refusal at
1304// its lease: nothing ran) reaches OnGen only after svc.Shutdown has
1305// returned, because Shutdown waits for e.ready and afterWake runs after it.
1306// Close waits for that event, so the wake's End is on disk when Close
1307// returns, and the next agent reconciles nothing.
1308func TestCloseJoinsAPlannedWakeWhoseEventArrivesAfterShutdown(t *testing.T) {
1309 h := newHarness(t, "a")
1310 dir := h.onDisk()
1311 h.stage("v2")
1312 h.registerThenInstall("v2", "a")
1313 h.wakeHost("a")
1314 p := h.m.Plan(context.Background(), h.rows.get("a"))
1315 if !p.FirstWake || p.Boot.Version != "v2" {
1316 t.Fatalf("plan %+v: want a first wake of v2", p)
1317 }
1318 if _, ok := fileOf(t, dir).Intents["a"]; !ok {
1319 t.Fatal("no intent on disk after the plan")
1320 }
1321 rt := h.rows.get("a")
1322 go func() {
1323 time.Sleep(200 * time.Millisecond) // afterWake, after Shutdown saw e.ready close
1324 h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: rt, Mode: p.Mode, FirstWake: true,
1325 Token: p.Token, ConfigRev: p.ConfigRev})
1326 }()
1327 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1328 defer cancel()
1329 h.m.Close(ctx)
1330 if f := fileOf(t, dir); len(f.Intents) != 0 {
1331 t.Fatalf("intents %+v on disk after Close: a restart would reconcile the wake as a failure", f.Intents)
1332 }
1333}
1334
1335// Task 11 fix round 1, review finding 1: a planned wake is counted from the
1336// intent its plan wrote until the one event that ends that intent, whatever
1337// its kind; a plan that wrote no intent is never counted; and an event
1338// delivered twice, or one for a wake that wrote nothing, never takes the
1339// count below what is still under way.
1340func TestAPlannedWakeIsCountedUntilItsIntentEnds(t *testing.T) {
1341 ctx := context.Background()
1342 setup := func(t *testing.T) (*harness, string, activator.WakePlan) {
1343 h := newHarness(t, "a")
1344 dir := h.onDisk()
1345 h.stage("v2")
1346 h.registerThenInstall("v2", "a")
1347 h.wakeHost("a")
1348 p := h.m.Plan(ctx, h.rows.get("a"))
1349 if !p.FirstWake || p.Boot.Version != "v2" {
1350 t.Fatalf("plan %+v: want a first wake of v2", p)
1351 }
1352 if n := h.m.joinCount(); n != 1 {
1353 t.Fatalf("count %d after a plan that wrote its intent, want 1", n)
1354 }
1355 return h, dir, p
1356 }
1357 ev := func(p activator.WakePlan, h *harness, kind activator.GenEventKind, first, ran bool) activator.GenEvent {
1358 e := activator.GenEvent{Kind: kind, Route: h.rows.get("a"), Gen: activator.Gen{N: 3, Build: "v2", Digest: "sha-v2"},
1359 Mode: p.Mode, FirstWake: first, Token: p.Token, ConfigRev: p.ConfigRev, Executed: ran}
1360 if kind == activator.GenFailed || kind == activator.GenFellBack {
1361 e.Failed = p.Boot
1362 }
1363 if kind == activator.GenFellBack {
1364 e.Gen = activator.Gen{N: 4, Build: "v1", Digest: "sha-v1"}
1365 }
1366 return e
1367 }
1368 type step struct {
1369 kind activator.GenEventKind
1370 first bool
1371 ran bool
1372 count int // the count once the event has returned
1373 }
1374 for _, tc := range []struct {
1375 name string
1376 steps []step
1377 }{
1378 {"settled", []step{{activator.GenHealthy, true, false, 1}, {activator.GenSettled, true, false, 0}}},
1379 {"failed", []step{{activator.GenFailed, true, false, 0}}},
1380 {"fell back", []step{{activator.GenFellBack, true, false, 0}}},
1381 {"refused at its lease", []step{{activator.GenCancelled, true, false, 0}}},
1382 {"stopped in its observation", []step{{activator.GenHealthy, true, false, 1}, {activator.GenCancelled, true, true, 0}}},
1383 {"the good build booted untried", []step{{activator.GenHealthy, false, false, 0}}},
1384 {"its terminal event twice", []step{{activator.GenCancelled, true, false, 0}, {activator.GenCancelled, true, false, 0}}},
1385 } {
1386 t.Run(tc.name, func(t *testing.T) {
1387 h, dir, p := setup(t)
1388 for i, s := range tc.steps {
1389 h.m.OnGen(ev(p, h, s.kind, s.first, s.ran))
1390 if n := h.m.joinCount(); n != s.count {
1391 t.Fatalf("after event %d (%s): count %d, want %d", i, s.kind, n, s.count)
1392 }
1393 }
1394 if _, stands := fileOf(t, dir).Intents["a"]; stands {
1395 t.Fatal("the intent stands after its terminal event")
1396 }
1397 })
1398 }
1399 t.Run("a replaced wake and its replacement each end once", func(t *testing.T) {
1400 h, _, old := setup(t)
1401 cur := h.m.Plan(ctx, h.rows.get("a"))
1402 if n := h.m.joinCount(); n != 2 {
1403 t.Fatalf("count %d with two planned wakes, want 2", n)
1404 }
1405 h.m.OnGen(ev(old, h, activator.GenCancelled, true, false))
1406 if n := h.m.joinCount(); n != 1 {
1407 t.Fatalf("count %d after the replaced wake's event, want 1", n)
1408 }
1409 h.m.OnGen(ev(cur, h, activator.GenCancelled, true, false))
1410 if n := h.m.joinCount(); n != 0 {
1411 t.Fatalf("count %d after both events, want 0", n)
1412 }
1413 })
1414 t.Run("a plan whose intent could not be written", func(t *testing.T) {
1415 h := newHarness(t, "a")
1416 dir := h.onDisk()
1417 h.stage("v2")
1418 h.registerThenInstall("v2", "a")
1419 h.wakeHost("a")
1420 readOnly(t, dir)
1421 p := h.m.Plan(ctx, h.rows.get("a"))
1422 if p.Refuse != BookUnreadableRefusal {
1423 t.Fatalf("plan %+v: want the book refusal", p)
1424 }
1425 if n := h.m.joinCount(); n != 0 {
1426 t.Fatalf("count %d after a plan that wrote nothing, want 0", n)
1427 }
1428 h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("a"), FirstWake: true, Token: p.Token})
1429 if n := h.m.joinCount(); n != 0 {
1430 t.Fatalf("count %d after an event for a wake that wrote nothing, want 0", n)
1431 }
1432 })
1433 t.Run("a plan refused while the attempt's intent stands", func(t *testing.T) {
1434 h := newHarness(t, "a")
1435 h.onDisk()
1436 h.stage("v2")
1437 h.registerThenInstall("v2", "a")
1438 h.wakeHost("a")
1439 if err := h.book.Begin("a", Intent{Account: "bab", App: "blog", Build: "v3", Mode: "hold", ID: "a1"}); err != nil {
1440 t.Fatal(err)
1441 }
1442 if p := h.m.Plan(ctx, h.rows.get("a")); p.Refuse == "" {
1443 t.Fatalf("plan %+v: want a refusal", p)
1444 }
1445 if n := h.m.joinCount(); n != 0 {
1446 t.Fatalf("count %d after a refused plan, want 0", n)
1447 }
1448 })
1449 t.Run("a plan under way", func(t *testing.T) {
1450 h := newHarness(t, "a")
1451 h.onDisk()
1452 h.stage("v2")
1453 h.registerThenInstall("v2", "a")
1454 h.wakeHost("a")
1455 entered, release := make(chan struct{}), make(chan struct{})
1456 var once sync.Once
1457 h.mu.Lock()
1458 h.demandHook = func(ver string) {
1459 if ver == "v2" {
1460 once.Do(func() { close(entered); <-release })
1461 }
1462 }
1463 h.mu.Unlock()
1464 planned := make(chan activator.WakePlan, 1)
1465 go func() { planned <- h.m.Plan(ctx, h.rows.get("a")) }()
1466 <-entered
1467 if n := h.m.joinCount(); n != 1 {
1468 t.Fatalf("count %d while a plan reads the demand, want 1", n)
1469 }
1470 closed := make(chan struct{})
1471 go func() {
1472 cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
1473 defer cancel()
1474 h.m.Close(cctx)
1475 close(closed)
1476 }()
1477 select {
1478 case <-closed:
1479 t.Fatal("Close returned while a plan was under way")
1480 case <-time.After(100 * time.Millisecond):
1481 }
1482 close(release)
1483 p := <-planned
1484 if n := h.m.joinCount(); n != 1 || !p.FirstWake {
1485 t.Fatalf("count %d once the plan (%+v) returned, want 1: its wake's", n, p)
1486 }
1487 h.m.OnGen(ev(p, h, activator.GenCancelled, true, false))
1488 select {
1489 case <-closed:
1490 case <-time.After(5 * time.Second):
1491 t.Fatal("Close did not return once the wake ended")
1492 }
1493 })
1494}
1495
1496// calledFrom reports whether a function whose name ends in fn is on the
1497// calling goroutine's stack.
1498func calledFrom(fn string) bool {
1499 pc := make([]uintptr, 64)
1500 frames := runtime.CallersFrames(pc[:runtime.Callers(2, pc)])
1501 for {
1502 f, more := frames.Next()
1503 if strings.HasSuffix(f.Function, fn) {
1504 return true
1505 }
1506 if !more {
1507 return false
1508 }
1509 }
1510}
1511