| new file mode 100644 |
| index 000000000..ab2f50b92 |
| --- /dev/null |
| +++ b/internal/rollout/wakeplan_test.go |
| @@ -0,0 +1,530 @@ |
| +package rollout |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func wakeRoute(installed, good string) registry.Route { |
| + return registry.Route{Host: "a", Kind: "instance", App: "blog", Account: "bab", Status: "ready", Hibernate: true, |
| + Version: installed, GoodVersion: good, GoodDigest: "sha-" + good} |
| +} |
| + |
| +func inputs(rt registry.Route, b *FailureBook, p AppPolicy) wakeInputs { |
| + return wakeInputs{Route: rt, OptDir: "/opt/carlos", Policy: p, Book: b, |
| + Demand: func(ver, _ string) (string, string, error) { return "", "sha-" + ver, nil }} |
| +} |
| + |
| +func TestDecideWake(t *testing.T) { |
| + overlap := DefaultPolicy() |
| + held := &FailureBook{} |
| + held.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "x", At: t0}) |
| + down := &FailureBook{} |
| + down.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a", HeldDown: []string{"a"}, At: t0}) |
| + |
| + if p, _ := decideWake(inputs(wakeRoute("", ""), &FailureBook{}, overlap)); p.Boot.StagedPath != "" || p.Refuse == "" { |
| + t.Fatal("nothing stamped: today's wake, unplanned") |
| + } |
| + p, _ := decideWake(inputs(wakeRoute("v1", "v1"), &FailureBook{}, overlap)) |
| + if p.Boot.Version != "v1" || p.FirstWake || p.Boot.Digest != "sha-v1" { |
| + t.Fatalf("installed is the good build: recovery by the retained identity, got %+v", p) |
| + } |
| + p, _ = decideWake(inputs(wakeRoute("v2", "v1"), &FailureBook{}, overlap)) |
| + if p.Boot.Version != "v2" || !p.FirstWake || p.Fallback == nil || p.Fallback.Version != "v1" || p.Mode != activator.ModeOverlap { |
| + t.Fatalf("a new build: a first wake with the good build behind it, got %+v", p) |
| + } |
| + p, _ = decideWake(inputs(wakeRoute("v2", "v1"), &FailureBook{}, AppPolicy{Swap: activator.ModeHold, Concurrency: 1})) |
| + if p.Mode != activator.ModeHold || p.Fallback != nil { |
| + t.Fatalf("hold: no fallback, got %+v", p) |
| + } |
| + in := inputs(wakeRoute("v2", "v1"), &FailureBook{}, overlap) |
| + in.Demand = func(ver, _ string) (string, string, error) { return "hold", "sha-" + ver, nil } |
| + if p, _ := decideWake(in); p.Mode != activator.ModeHold || p.Fallback != nil { |
| + t.Fatalf("the release demands hold: no fallback, got %+v", p) |
| + } |
| + if p, _ := decideWake(inputs(wakeRoute("v2", "v1"), held, overlap)); p.Boot.Version != "v1" || p.FirstWake { |
| + t.Fatalf("installed build held by a record: boot the good build, got %+v", p) |
| + } |
| + if p, _ := decideWake(inputs(wakeRoute("v2", ""), held, overlap)); p.Refuse == "" { |
| + t.Fatal("held, with no good build: nothing to boot") |
| + } |
| + if p, _ := decideWake(inputs(wakeRoute("v2", "v1"), down, overlap)); p.Refuse == "" { |
| + t.Fatal("held down after a hold failure: no automatic fallback (spec §6.4)") |
| + } |
| + in = inputs(wakeRoute("v2", "v1"), &FailureBook{}, overlap) |
| + in.Demand = func(string, string) (string, string, error) { return "", "", errors.New("digest mismatch") } |
| + p, err := decideWake(in) |
| + if err == nil || p.Boot.Version != "v1" || p.FirstWake { |
| + t.Fatalf("a staging refusal with a good build boots the good build, got %+v, %v", p, err) |
| + } |
| +} |
| + |
| +// Review focus 3. |
| +func TestPlanBootsTheGoodBuildForAHeldInstallation(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.install("v2", "a") |
| + h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "b", At: t0}) |
| + p := h.m.Plan(context.Background(), h.rows.get("a")) |
| + if p.Boot.Version != "v1" || p.FirstWake { |
| + t.Fatalf("plan = %+v, want the good v1 without a repoint", p) |
| + } |
| +} |
| + |
| +// registerThenInstall makes the scheduler know host (the pass) while every |
| +// host still wants v1, and only then stamps v2 on host's row. The pass sees |
| +// nothing to adopt, so no swap onto v2 runs beside the wake outcome a test |
| +// hands OnGen: these tests are about the wake, and a swap racing it would |
| +// change what the activator serves under them. |
| +func (h *harness) registerThenInstall(ver, host string) { |
| + h.pass() |
| + h.install(ver, host) |
| +} |
| + |
| +func TestAFellBackWakeRecordsTheFailureAndKeepsTheGoodBuild(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.registerThenInstall("v2", "a") |
| + rt := h.rows.get("a") |
| + h.act.serve("a", "v1") // the good build the fallback left serving |
| + served, _ := h.act.Current("a") |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenFellBack, Route: rt, FirstWake: true, Mode: activator.ModeOverlap, |
| + Gen: served, Failed: release.StagedIdentity{Version: "v2"}, Reason: "health check failed"}) |
| + if _, ok := h.book.Lookup("bab", "blog", "v2"); !ok { |
| + t.Fatal("no failure record") |
| + } |
| + if r := h.rows.get("a"); r.GoodVersion != "v1" || r.ServingVersion != "v1" { |
| + t.Fatalf("row = %+v", r) |
| + } |
| + if !h.events.has("served build v1 instead") { |
| + t.Fatal("no fallback event") |
| + } |
| +} |
| + |
| +// Spec §8.1: a fallback publishes the failure hold before the serving build |
| +// and the active config. Writing the serving build first showed the good |
| +// build serving with no hold on the failed one, a window in which a wake or |
| +// an admission could select the failed build again. |
| +func TestAFallbackHoldsTheFailedBuildBeforeItPublishesServing(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.registerThenInstall("v2", "a") |
| + h.act.serve("a", "v1") |
| + served, _ := h.act.Current("a") |
| + var early atomic.Int32 |
| + h.rows.mu.Lock() |
| + h.rows.setServingHook = func(string) { |
| + if _, ok := h.book.Lookup("bab", "blog", "v2"); !ok { |
| + early.Add(1) |
| + } |
| + } |
| + h.rows.mu.Unlock() |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenFellBack, Route: h.rows.get("a"), FirstWake: true, Mode: activator.ModeOverlap, |
| + Gen: served, Failed: release.StagedIdentity{Version: "v2"}, Reason: "health check failed"}) |
| + if n := early.Load(); n != 0 { |
| + t.Fatal("the serving build was published before the failed build was held") |
| + } |
| + if r := h.rows.get("a"); r.ServingVersion != "v1" { |
| + t.Fatalf("serving = %q, want the good v1", r.ServingVersion) |
| + } |
| +} |
| + |
| +func TestOnlyASettledFirstWakeBecomesTheGoodBuild(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.registerThenInstall("v2", "a") |
| + rt := h.rows.get("a") |
| + h.act.serve("a", "v2") // the first wake's generation, which the activator serves |
| + g, _ := h.act.Current("a") |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: rt, Gen: g, FirstWake: true}) |
| + if h.rows.get("a").GoodVersion != "v1" { |
| + t.Fatal("a first wake became the good build before its 10 s (spec §8.5)") |
| + } |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: rt, Gen: g, FirstWake: true}) |
| + if r := h.rows.get("a"); r.GoodVersion != "v2" || r.ServingVersion != "v2" { |
| + t.Fatalf("row = %+v: the settled first wake must be the good and the serving build", r) |
| + } |
| +} |
| + |
| +// Task 9's verdict floor: a first wake left too little health window boots |
| +// the good build untried and reports an ordinary healthy wake of it |
| +// (FirstWake false). The scheduler records the good build as serving and |
| +// records nothing against the untried build, which stays installed for a |
| +// later wake. |
| +func TestAnUntriedFirstWakeIsAnOrdinaryWakeOfTheGoodBuild(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.registerThenInstall("v2", "a") |
| + h.act.serve("a", "v1") |
| + g, _ := h.act.Current("a") |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: h.rows.get("a"), Gen: g, Mode: activator.ModeOverlap}) |
| + if _, ok := h.book.Lookup("bab", "blog", "v2"); ok { |
| + t.Fatal("the untried build was recorded as failed") |
| + } |
| + if r := h.rows.get("a"); r.ServingVersion != "v1" || r.GoodVersion != "v1" || r.Version != "v2" { |
| + t.Fatalf("row = %+v: want v1 serving and good, v2 still installed", r) |
| + } |
| + if p := h.m.Plan(context.Background(), h.rows.get("a")); !p.FirstWake || p.Boot.Version != "v2" { |
| + t.Fatalf("plan %+v: the untried build must stay a candidate for the next wake", p) |
| + } |
| +} |
| + |
| +// Spec §6.4: an intentional stop during the 10 s observation records |
| +// nothing. GenCancelled only closes the observation. |
| +func TestACancelledObservationRecordsNothing(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.registerThenInstall("v2", "a") |
| + h.act.serve("a", "v2") |
| + g, _ := h.act.Current("a") |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("a"), Gen: g, FirstWake: true}) |
| + if _, ok := h.book.Lookup("bab", "blog", "v2"); ok { |
| + t.Fatal("a cancelled observation recorded a failure") |
| + } |
| + if r := h.rows.get("a"); r.GoodVersion != "v1" || r.ServingVersion != "" { |
| + t.Fatalf("row = %+v: a cancelled observation published a build", r) |
| + } |
| +} |
| + |
| +// Spec §7.9: a worker's serving build has one writer, the activator |
| +// (ordered by incarnation). The scheduler records a worker's good build but |
| +// leaves its ServingVersion alone, so its write can never interleave with |
| +// the activator's and restore an older value. |
| +func TestTheSchedulerLeavesAWorkersServingVersionAlone(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.act.serve("a", "v2") |
| + g, _ := h.act.Current("a") |
| + g.Worker = true |
| + h.m.served("a", g) |
| + if r := h.rows.get("a"); r.ServingVersion != "" || r.GoodVersion != "v2" { |
| + t.Fatalf("row = %+v: want the good build recorded and the serving build left to the activator", r) |
| + } |
| +} |
| + |
| +// Spec §8.5: a good build is written expecting the current one. A late |
| +// success of an older generation (a completion delayed past a newer swap, a |
| +// stale wake event) is not the generation the activator serves, so it never |
| +// overwrites the newer good or serving build. Writing whatever arrived last |
| +// regressed both to the older build. |
| +func TestAStaleSuccessNeverOverwritesANewerBuild(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.act.serve("a", "v2") |
| + older, _ := h.act.Current("a") |
| + h.act.serve("a", "v3") // a newer generation took over |
| + newer, _ := h.act.Current("a") |
| + h.m.served("a", newer) |
| + h.m.served("a", older) // the older one's completion arrives late |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: h.rows.get("a"), Gen: older, FirstWake: true}) |
| + if r := h.rows.get("a"); r.GoodVersion != "v3" || r.ServingVersion != "v3" { |
| + t.Fatalf("row = %+v: an older generation overwrote the newer good and serving build", r) |
| + } |
| +} |
| + |
| +func TestAFailedHoldFirstWakeHoldsTheInstanceDown(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.registerThenInstall("v2", "a") |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenFailed, Route: h.rows.get("a"), FirstWake: true, Mode: activator.ModeHold, |
| + Failed: release.StagedIdentity{Version: "v2"}, Reason: "health check failed"}) |
| + if p := h.m.Plan(context.Background(), h.rows.get("a")); p.Refuse == "" { |
| + t.Fatalf("plan = %+v, want a refusal: recovery after a hold failure is explicit", p) |
| + } |
| + // Task 4's rule: a record never carries a zero PromotedAt. No pointer was |
| + // read for this wake, so the failure's own time stands in. |
| + if rec, ok := h.book.Lookup("bab", "blog", "v2"); !ok || !rec.PromotedAt.Equal(t0) || !rec.At.Equal(t0) { |
| + t.Fatalf("record %+v %v: want PromotedAt = At = the failure's time", rec, ok) |
| + } |
| + if !h.events.has("rollout of build v2 halted on box box-1: health") { |
| + t.Fatal("no cohort-halted event with the reason word") |
| + } |
| +} |
| + |
| +func TestAWakeActivatesTheConfigItStartedWith(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.pass() |
| + h.act.mu.Lock() |
| + delete(h.act.gens, "a") |
| + h.act.owned["a"] = true // waking: a process, not yet current |
| + h.act.mu.Unlock() |
| + var acks int |
| + ack := h.m.DeferAck(func(appconfig.ConfigAck) { acks++ }) |
| + h.m.ConfigDelivered("a", "sha256:c1") |
| + ack(appconfig.ConfigAck{Host: "a"}) |
| + if acks != 0 { |
| + t.Fatal("acknowledged before any generation started with the config") |
| + } |
| + rt := h.rows.get("a") |
| + h.m.Plan(context.Background(), rt) |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: rt, Gen: activator.Gen{Build: "v1", Digest: "sha-v1"}}) |
| + if acks != 1 { |
| + t.Fatalf("acks = %d after the wake, want 1", acks) |
| + } |
| +} |
| + |
| +// Spec §8.1: a sleeping instance needs no config attempt. Its next wake |
| +// starts with the delivered file, and config sync's ack goes out at |
| +// delivery, as today, marked Hibernating. |
| +func TestASleepersConfigNeedsNoAttempt(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.pass() |
| + h.act.mu.Lock() |
| + delete(h.act.gens, "a") // asleep: no process at all |
| + h.act.mu.Unlock() |
| + var acks []appconfig.ConfigAck |
| + ack := h.m.DeferAck(func(a appconfig.ConfigAck) { acks = append(acks, a) }) |
| + h.m.ConfigDelivered("a", "sha256:c1") |
| + ack(appconfig.ConfigAck{Host: "a", Restarted: true}) |
| + if len(acks) != 1 || !acks[0].Hibernating || acks[0].Restarted { |
| + t.Fatalf("acks = %+v, want one at delivery, Hibernating", acks) |
| + } |
| + h.pass() |
| + time.Sleep(50 * time.Millisecond) |
| + if calls, _, _ := h.act.snapshot(); len(calls) != 0 { |
| + t.Fatalf("attempts %+v: a sleeper got a config attempt", calls) |
| + } |
| +} |
| + |
| +// Spec §8.1: a failed first wake is published with no blocking read in |
| +// between. The failing pointer's PromotedAt is read when the wake is |
| +// planned and travels on the event. buildFailed read it again after the |
| +// failure, before the record, leaving every sibling's wake free to plan the |
| +// failed build meanwhile. |
| +func TestAFailedFirstWakeIsRecordedWithNoPointerRead(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.install("v2", "a") |
| + promoted := t0.Add(-time.Hour) |
| + h.pointer("stable", "v2", promoted) |
| + p := h.m.Plan(context.Background(), h.rows.get("a")) |
| + if !p.FirstWake || !p.FailedUnder.Equal(promoted) { |
| + t.Fatalf("plan %+v: want a first wake carrying the pointer's PromotedAt", p) |
| + } |
| + var reads atomic.Int32 |
| + h.mu.Lock() |
| + h.pointerHook = func() { reads.Add(1) } |
| + h.mu.Unlock() |
| + h.m.OnGen(activator.GenEvent{Kind: activator.GenFailed, Route: h.rows.get("a"), FirstWake: true, Mode: activator.ModeOverlap, |
| + Failed: p.Boot, Reason: "GET / answered 500", FailedUnder: p.FailedUnder}) |
| + if n := reads.Load(); n != 0 { |
| + t.Fatalf("%d pointer read(s) between the failure and its record", n) |
| + } |
| + rec, ok := h.book.Lookup("bab", "blog", "v2") |
| + if !ok || !rec.PromotedAt.Equal(promoted) { |
| + t.Fatalf("record %+v %v: want one carrying the planned PromotedAt", rec, ok) |
| + } |
| +} |
| + |
| +// Spec §6.4, §8.4: a cold managed host (nothing installed yet) has its build |
| +// installed and stamped before the wake is planned, so the plan boots that |
| +// identity, as a first wake, and respects its failure records. An empty |
| +// plan sent the wake down the live-link path, which materialized the |
| +// channel head and ran it unplanned, a held build included. |
| +func TestAColdManagedHostIsInstalledBeforeItIsPlanned(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + cold := func() { |
| + r := h.rows.get("a") |
| + r.Version = "" |
| + h.rows.put(r) |
| + } |
| + cold() |
| + installs := 0 |
| + h.m.d.Materialize = func(_ context.Context, host string) error { |
| + installs++ |
| + r := h.rows.get(host) |
| + r.Version = "v2" |
| + h.rows.put(r) |
| + return nil |
| + } |
| + if p := h.m.Plan(context.Background(), h.rows.get("a")); installs != 1 || p.Boot.Version != "v2" || !p.FirstWake { |
| + t.Fatalf("installs %d, plan %+v: want v2 installed, then planned as a first wake", installs, p) |
| + } |
| + if _, err := h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a", At: t0}); err != nil { |
| + t.Fatal(err) |
| + } |
| + cold() |
| + if p := h.m.Plan(context.Background(), h.rows.get("a")); p.Boot.Version == "v2" { |
| + t.Fatalf("plan %+v: a cold host was booted onto a build its app holds", p) |
| + } |
| + h.m.d.Materialize = nil |
| + cold() |
| + if p := h.m.Plan(context.Background(), h.rows.get("a")); p.Refuse == "" || p.Boot.StagedPath != "" { |
| + t.Fatalf("plan %+v: with nothing installed and no way to install, the wake must be refused", p) |
| + } |
| +} |
| + |
| +// A refusal's text reaches the member's "wake failed" event (Task 9). A |
| +// failed install's error, which can name box paths, goes to the operator log |
| +// only. |
| +func TestAFailedColdInstallRefusesWithoutItsErrorText(t *testing.T) { |
| + h := newHarness(t, "a") |
| + r := h.rows.get("a") |
| + r.Version = "" |
| + h.rows.put(r) |
| + h.m.d.Materialize = func(context.Context, string) error { |
| + return errors.New("stage /opt/carlos/versions/bab/v2/blog: no space left on device") |
| + } |
| + p := h.m.Plan(context.Background(), h.rows.get("a")) |
| + if p.Refuse == "" || p.Boot.StagedPath != "" { |
| + t.Fatalf("plan %+v: a failed install must refuse the wake", p) |
| + } |
| + if strings.Contains(p.Refuse, "/opt") || strings.Contains(p.Refuse, "no space") { |
| + t.Fatalf("refusal %q carries the raw error", p.Refuse) |
| + } |
| +} |
| + |
| +// Spec §4.2 (Task 3's carry, as resolve applies it to an attempt): a first |
| +// wake whose canonical demand cannot be read or compared boots in hold, on |
| +// the staged bytes hashed here, with no fallback. Missing or unverifiable |
| +// data never permits an overlap. A staged file that is not the release's |
| +// artifact is a staging refusal: the good build boots and root is asked to |
| +// stage again. |
| +func TestAPlannedWakeReadsTheDemandAsAnAttemptDoes(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.install("v2", "a") |
| + sum, err := release.FileSHA256(release.VersionPath(h.opt, "bab", "v2", "blog")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + h.mu.Lock() |
| + h.demandErr["v2"] = errors.New("manifest unreadable") |
| + h.mu.Unlock() |
| + if p := h.m.Plan(context.Background(), h.rows.get("a")); !p.FirstWake || p.Mode != activator.ModeHold || p.Fallback != nil || p.Boot.Digest != sum { |
| + t.Fatalf("plan %+v: an unreadable demand must boot v2 in hold on its hashed bytes", p) |
| + } |
| + |
| + h.mu.Lock() |
| + delete(h.demandErr, "v2") |
| + h.noDigest["v2"] = true |
| + h.mu.Unlock() |
| + if p := h.m.Plan(context.Background(), h.rows.get("a")); !p.FirstWake || p.Mode != activator.ModeHold || p.Fallback != nil || p.Boot.Digest != sum { |
| + t.Fatalf("plan %+v: a demand nothing compared must boot v2 in hold on its hashed bytes", p) |
| + } |
| + |
| + h.mu.Lock() |
| + delete(h.noDigest, "v2") |
| + h.mismatch["v2"] = true |
| + h.mu.Unlock() |
| + p := h.m.Plan(context.Background(), h.rows.get("a")) |
| + if p.FirstWake || p.Boot.Version != "v1" { |
| + t.Fatalf("plan %+v: a mis-staged v2 must boot the good v1", p) |
| + } |
| + if h.touched.Load() != 1 || !h.events.has("build v2 is not staged correctly on this box") { |
| + t.Fatal("a staging refusal must ask root to stage again and say so") |
| + } |
| +} |
| + |
| +// Spec §7.4, §8.1–8.4 (P5 review finding 3): a plan carries its selection |
| +// gate. A sibling's failure recorded after the plan was made refuses the |
| +// planned build at the exec; the good build still passes. |
| +func TestAPlansCheckRefusesABuildHeldSinceThePlan(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.install("v2", "a") |
| + p := h.m.Plan(context.Background(), h.rows.get("a")) |
| + if !p.FirstWake || p.Boot.Version != "v2" || p.Check == nil { |
| + t.Fatalf("plan %+v: want a first wake onto v2 with its check", p) |
| + } |
| + if err := p.Check("v2"); err != nil { |
| + t.Fatalf("nothing holds v2 yet: %v", err) |
| + } |
| + if _, err := h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "b", At: t0}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.Check("v2"); err == nil { |
| + t.Fatal("v2 failed on a sibling after the plan, and the check still let it boot") |
| + } |
| + if err := p.Check("v1"); err != nil { |
| + t.Fatalf("the good build was refused: %v", err) |
| + } |
| +} |
| + |
| +// Spec §2.9 (rev 17), §6.4, §8.3: an overlap attempt on a foreign-layout row |
| +// runs as a hold, and the activator says so (SwapResult.Mode). Its failure |
| +// is a hold build's failure: held down, so no wake boots the good build in |
| +// its place. And while an attempt runs for an instance, no wake of it is |
| +// planned, so a hold that went down is recorded before anything wakes it. |
| +// Recording under the dispatched ModeOverlap left it un-held, and the next |
| +// wake booted the good build against the database the hold build ran on. |
| +func TestAForeignRowsFailedHoldIsHeldDown(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.act.outcome = func(activator.Attempt) activator.SwapOutcome { return activator.SwapDown } |
| + h.act.mode = activator.ModeHold // the effective mode, as plan 3's Swap reports it |
| + gate := h.act.hold("a") |
| + h.install("v2", "a") |
| + h.pass() |
| + waitFor(t, "the attempt to start", func() bool { _, started, _ := h.act.snapshot(); return len(started) == 1 }) |
| + if p := h.m.Plan(context.Background(), h.rows.get("a")); p.Refuse == "" { |
| + t.Fatalf("a wake was planned while the instance's attempt ran: %+v", p) |
| + } |
| + close(gate) |
| + waitFor(t, "the hold-down record", func() bool { return h.book.HeldDown("bab", "blog", "v2", "a") }) |
| + if p := h.m.Plan(context.Background(), h.rows.get("a")); p.Refuse == "" || p.Boot.StagedPath != "" { |
| + t.Fatalf("after the hold went down the wake planned %+v, want refused (held down)", p) |
| + } |
| +} |
| + |
| +// The activator's sweep calls OnDrift (Nudge) on a goroutine of its own for |
| +// every drifted entry on every sweep, with no dedupe of its own (Task 9). |
| +// Nudges for one host collapse: while one is reading the row, the others |
| +// return at once and leave it one more look, so nothing piles up behind a |
| +// slow read and nothing a later nudge was for is lost. And a nudge for the |
| +// build an attempt is already running onto neither queues it again nor |
| +// supersedes it. |
| +func TestNudgesForOneHostCollapse(t *testing.T) { |
| + h := newHarness(t, "a") |
| + h.stage("v2") |
| + h.pass() |
| + release1 := make(chan struct{}) |
| + var gets atomic.Int32 |
| + h.rows.mu.Lock() |
| + h.rows.getHook = func(host string) { |
| + if host == "a" && gets.Add(1) == 1 { |
| + <-release1 |
| + } |
| + } |
| + h.rows.mu.Unlock() |
| + first := make(chan struct{}) |
| + go func() { h.m.Nudge("a"); close(first) }() |
| + waitFor(t, "the first nudge to read the row", func() bool { return gets.Load() == 1 }) |
| + for range 20 { |
| + h.m.Nudge("a") // returns at once: the first nudge owns this host |
| + } |
| + if n := gets.Load(); n != 1 { |
| + t.Fatalf("%d row reads while one nudge was in flight, want 1", n) |
| + } |
| + close(release1) |
| + <-first |
| + if n := gets.Load(); n != 2 { |
| + t.Fatalf("%d row reads, want 2: the in-flight nudge looks once more for the ones it absorbed", n) |
| + } |
| + |
| + h.rows.mu.Lock() |
| + h.rows.getHook = nil |
| + h.rows.mu.Unlock() |
| + gate := h.act.hold("a") |
| + h.install("v2", "a") |
| + h.m.Nudge("a") |
| + waitFor(t, "the attempt onto v2 to start", func() bool { _, started, _ := h.act.snapshot(); return len(started) == 1 }) |
| + for range 5 { |
| + h.m.Nudge("a") |
| + } |
| + close(gate) |
| + waitFor(t, "the attempt to finish", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 }) |
| + calls, started, _ := h.act.snapshot() |
| + if calls[0].outcome != activator.SwapDone || len(started) != 1 { |
| + t.Fatalf("calls %+v started %v: repeated nudges re-queued or superseded the running attempt", calls, started) |
| + } |
| +} |