| new file mode 100644 |
| index 000000000..55ea11355 |
| --- /dev/null |
| +++ b/internal/rollout/admit_test.go |
| @@ -0,0 +1,237 @@ |
| +package rollout |
| + |
| +import ( |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| +) |
| + |
| +// currentView is every slot as the activator would report it. |
| +func currentView(q *appQueue) map[string]string { |
| + v := map[string]string{} |
| + for host, h := range q.hosts { |
| + v[host] = h.serving |
| + } |
| + return v |
| +} |
| + |
| +func releaseAll(q *appQueue, build string, hosts ...string) { |
| + for _, h := range hosts { |
| + q.wantBuild(h, activator.CauseRelease, build, t0) |
| + } |
| +} |
| + |
| +func TestTheCanaryRunsAloneUntilItSettles(t *testing.T) { |
| + q := awakeQueue(noBlocks, "a", "b", "c") |
| + q.concurrency = 2 |
| + releaseAll(q, "v2", "a", "b", "c") |
| + first := q.admit(currentView(q), t0) |
| + if len(first) != 1 || !first[0].Canary { |
| + t.Fatalf("admitted %+v, want exactly one canary", first) |
| + } |
| + if more := q.admit(currentView(q), t0); len(more) != 0 { |
| + t.Fatalf("admitted %+v while the canary has not settled", more) |
| + } |
| + q.finish(first[0], activator.SwapDone) |
| + rest := q.admit(currentView(q), t0) |
| + if len(rest) != 2 || rest[0].Canary || rest[1].Canary { |
| + t.Fatalf("after the settle admitted %+v, want the other two and no canary", rest) |
| + } |
| +} |
| + |
| +func TestSwapConcurrencyIsNeverExceeded(t *testing.T) { |
| + q := awakeQueue(noBlocks, "a", "b", "c", "d") |
| + q.concurrency = 1 |
| + releaseAll(q, "v2", "a", "b", "c", "d") |
| + for done := 0; done < 4; { |
| + ds := q.admit(currentView(q), t0) |
| + if q.running > q.concurrency || len(ds) > 1 { |
| + t.Fatalf("running %d, admitted %d, with concurrency %d", q.running, len(ds), q.concurrency) |
| + } |
| + for _, d := range ds { |
| + q.finish(d, activator.SwapDone) |
| + done++ |
| + } |
| + } |
| +} |
| + |
| +func TestAnAbandonedCanaryIsReplaced(t *testing.T) { |
| + q := awakeQueue(noBlocks, "a", "b") |
| + releaseAll(q, "v2", "a", "b") |
| + first := q.admit(currentView(q), t0) |
| + q.finish(first[0], activator.SwapAbandoned) |
| + next := q.admit(currentView(q), t0) |
| + if len(next) != 1 || !next[0].Canary { |
| + t.Fatalf("after an abandoned canary admitted %+v, want a new canary", next) |
| + } |
| +} |
| + |
| +func TestHaltOnTheFirstFailureSparesInstancesServingTheBuild(t *testing.T) { |
| + b := &FailureBook{} |
| + q := newAppQueue("bab", "blog", func(build, host string, served bool) bool { |
| + return b.Blocks("bab", "blog", build, host, served) |
| + }) |
| + for _, h := range []string{"a", "b", "c"} { |
| + q.observe(h, "v1", "v1", t0) |
| + } |
| + q.observe("d", "v2", "v2", t0) // d served v2 before it failed elsewhere |
| + q.concurrency = 3 |
| + releaseAll(q, "v2", "a", "b", "c", "d") |
| + canary := q.admit(currentView(q), t0) |
| + if len(canary) != 1 { |
| + t.Fatalf("admitted %+v", canary) |
| + } |
| + if !q.finish(canary[0], activator.SwapFailed) { |
| + t.Fatal("a failed release attempt goes through the cohort-failure transition") |
| + } |
| + if _, _, err := failCohort(b, q, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: canary[0].ID.Host, At: t0}); err != nil { |
| + t.Fatal(err) |
| + } |
| + q.wantRestart("d", "r1", t0) |
| + ds := q.admit(currentView(q), t0) |
| + for _, d := range ds { |
| + if d.ID.Build == "v2" && d.Class == classAdopt { |
| + t.Fatalf("admitted %+v after the halt", d.ID) |
| + } |
| + } |
| + if len(ds) != 1 || ds[0].ID.Host != "d" || ds[0].ID.Cause != activator.CauseRestart { |
| + t.Fatalf("admitted %+v, want only d's restart onto the v2 it serves (spec §8.4)", ds) |
| + } |
| +} |
| + |
| +// orderBook watches the transition's order: when the record is written, the |
| +// attempts past spawn must not yet have been superseded. |
| +type orderBook struct { |
| + FailureBook |
| + q *appQueue |
| + host string |
| + atRecord uint64 |
| +} |
| + |
| +func (o *orderBook) Record(r FailureRecord) (bool, error) { |
| + o.atRecord = o.q.newest(o.host) |
| + return o.FailureBook.Record(r) |
| +} |
| + |
| +func TestTheRecordComesBeforeTheSupersession(t *testing.T) { |
| + q := awakeQueue(noBlocks, "a", "b") |
| + q.concurrency = 2 |
| + releaseAll(q, "v2", "a", "b") |
| + canary := q.admit(currentView(q), t0) |
| + q.finish(canary[0], activator.SwapDone) |
| + running := q.admit(currentView(q), t0) // b, now past spawn |
| + if len(running) != 1 { |
| + t.Fatalf("admitted %+v", running) |
| + } |
| + before := q.newest(running[0].ID.Host) |
| + ob := &orderBook{q: q, host: running[0].ID.Host} |
| + _, superseded, _ := failCohort(ob, q, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "c", At: t0}) |
| + if ob.atRecord != before { |
| + t.Fatal("attempts were superseded before the failure record was written (spec §8.1 order)") |
| + } |
| + if len(superseded) != 1 || q.newest(running[0].ID.Host) == before { |
| + t.Fatalf("superseded %v: an attempt past spawn onto the failed build must be abandoned at its re-check", superseded) |
| + } |
| +} |
| + |
| +func TestAWakingInstanceKeepsItsPlaceAndASleeperLosesItsRestart(t *testing.T) { |
| + q := awakeQueue(noBlocks, "a", "b") |
| + q.wantRestart("a", "r1", t0) |
| + q.wantRestart("b", "r1", t0) |
| + v := currentView(q) |
| + delete(v, "a") // waking |
| + v["b"] = "" // asleep |
| + if ds := q.admit(v, t0); len(ds) != 0 { |
| + t.Fatalf("admitted %+v", ds) |
| + } |
| + if q.hosts["a"].wantRestart != "r1" { |
| + t.Fatal("a waking instance keeps its place (spec §6.2)") |
| + } |
| + if q.hosts["b"].wantRestart != "" { |
| + t.Fatal("a restart never wakes a sleeper") |
| + } |
| +} |
| + |
| +func TestCohortsAreKeyedByDesiredBuild(t *testing.T) { |
| + q := awakeQueue(noBlocks, "a", "b") |
| + q.concurrency = 2 |
| + q.wantBuild("a", activator.CauseRelease, "v2", t0) // one channel |
| + q.wantBuild("b", activator.CauseRelease, "v3", t0) // another |
| + ds := q.admit(currentView(q), t0) |
| + if len(ds) != 2 || !ds[0].Canary || !ds[1].Canary { |
| + t.Fatalf("admitted %+v, want each cohort's own canary", ds) |
| + } |
| +} |
| + |
| +func TestARetriedInstanceRunsAsACanaryThroughAHalt(t *testing.T) { |
| + q := awakeQueue(noBlocks, "a", "b") |
| + releaseAll(q, "v2", "a", "b") |
| + q.halt("v2", "health check failed") |
| + if ds := q.admit(currentView(q), t0); len(ds) != 0 { |
| + t.Fatalf("admitted %+v from a halted cohort", ds) |
| + } |
| + q.retryHost("b") |
| + ds := q.admit(currentView(q), t0) |
| + if len(ds) != 1 || ds[0].ID.Host != "b" || !ds[0].Canary { |
| + t.Fatalf("admitted %+v, want b alone, as a canary", ds) |
| + } |
| + q.unhalt("v2") |
| + q.finish(ds[0], activator.SwapDone) |
| + if ds := q.admit(currentView(q), t0); len(ds) != 1 || ds[0].ID.Host != "a" { |
| + t.Fatalf("after the app retry admitted %+v", ds) |
| + } |
| +} |
| + |
| +func TestAFailedConfigAttemptWaitsOutItsBackoff(t *testing.T) { |
| + q := awakeQueue(noBlocks, "a") |
| + q.wantConfig("a", "c1", t0) |
| + q.configFailed("a", t0.Add(time.Minute)) |
| + if ds := q.admit(currentView(q), t0); len(ds) != 0 { |
| + t.Fatal("admitted a config attempt inside its backoff") |
| + } |
| + if got := q.nextDue(t0); !got.Equal(t0.Add(time.Minute)) { |
| + t.Fatalf("nextDue = %v, want the backoff's end", got) |
| + } |
| + if ds := q.admit(currentView(q), t0.Add(time.Minute)); len(ds) != 1 { |
| + t.Fatal("the config attempt did not come back after its backoff") |
| + } |
| +} |
| + |
| +// Task 5's pending() does not read notBefore, so admission must: a staging |
| +// refusal's wait holds every attempt for the host, not only config ones. |
| +func TestAStagingRefusalWaitsOutItsDeferral(t *testing.T) { |
| + q := awakeQueue(noBlocks, "a", "b") |
| + q.concurrency = 2 |
| + releaseAll(q, "v2", "a", "b") |
| + q.deferHost("a", t0.Add(30*time.Second)) |
| + ds := q.admit(currentView(q), t0) |
| + if len(ds) != 1 || ds[0].ID.Host != "b" { |
| + t.Fatalf("admitted %+v, want only b: a is deferred", ds) |
| + } |
| + if got := q.nextDue(t0); !got.Equal(t0.Add(30 * time.Second)) { |
| + t.Fatalf("nextDue = %v, want the deferral's end", got) |
| + } |
| + q.finish(ds[0], activator.SwapDone) |
| + if ds := q.admit(currentView(q), t0.Add(29*time.Second)); len(ds) != 0 { |
| + t.Fatalf("admitted %+v inside the deferral", ds) |
| + } |
| + if ds := q.admit(currentView(q), t0.Add(30*time.Second)); len(ds) != 1 || ds[0].ID.Host != "a" { |
| + t.Fatalf("after the deferral admitted %+v, want a", ds) |
| + } |
| + if got := q.nextDue(t0.Add(30 * time.Second)); !got.IsZero() { |
| + t.Fatalf("nextDue = %v, want zero once nothing waits", got) |
| + } |
| +} |
| + |
| +// A deferred host never holds the cohort's canary slot: the next host in the |
| +// queue takes it, so one refusal does not stall the whole rollout. |
| +func TestADeferredHostDoesNotHoldTheCanarySlot(t *testing.T) { |
| + q := awakeQueue(noBlocks, "a", "b") |
| + releaseAll(q, "v2", "a", "b") |
| + q.deferHost("a", t0.Add(time.Minute)) |
| + ds := q.admit(currentView(q), t0) |
| + if len(ds) != 1 || ds[0].ID.Host != "b" || !ds[0].Canary { |
| + t.Fatalf("admitted %+v, want b as the canary", ds) |
| + } |
| +} |