zdd-6 task 14: rollout events use the rolloutwords wording
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+132
−35
internal/activator/wakeplan.go+8 −6internal/activator/wakeplan_test.go+23 −2internal/rollout/events.go+19 −12internal/rollout/events_words_test.go+59 −0internal/rollout/manager_test.go+19 −12internal/rollout/wakeplan_test.go+4 −3
diff --git a/internal/activator/wakeplan.go b/internal/activator/wakeplan.go| index 25dae3664..bd255fe02 100644 |
| --- a/internal/activator/wakeplan.go |
| +++ b/internal/activator/wakeplan.go |
| @@ -15,6 +15,7 @@ import ( |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rolloutwords" |
| ) |
| // WakePlan is the scheduler's answer to "which build does this wake boot" |
| @@ -271,15 +272,16 @@ func (a *Service) BounceFor(host string, cause Cause) bool { |
| return true |
| } |
| -// bounceText is the one place BounceFor words its event. Draft wording: |
| -// plan 6 rewrites it through copy review. The flag-off literal in |
| -// bounceLocked is never edited. |
| +// bounceText is the one place BounceFor words its event. The words are |
| +// internal/rolloutwords, drafts pending copy review. The flag-off literal in |
| +// bounceLocked is never edited; restart's cause phrase keeps the two |
| +// identical. |
| func bounceText(worker bool, cause Cause) string { |
| - noun := "instance" |
| + format := rolloutwords.EventBounceInstance |
| if worker { |
| - noun = "sidecar" |
| + format = rolloutwords.EventBounceSidecar |
| } |
| - return noun + " restarting (" + string(cause) + ")" |
| + return fmt.Sprintf(format, rolloutwords.Cause(string(cause))) |
| } |
| // killFailed ends a failed first-wake build outright (spec §8.2 step 1), |
diff --git a/internal/activator/wakeplan_test.go b/internal/activator/wakeplan_test.go| index 4a96ffb25..ead4ea146 100644 |
| --- a/internal/activator/wakeplan_test.go |
| +++ b/internal/activator/wakeplan_test.go |
| @@ -22,6 +22,7 @@ import ( |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rolloutwords" |
| ) |
| // planGenBackend is plan 3's SwapBackend (startPinnedAt's staged spawn) plus |
| @@ -1182,8 +1183,28 @@ func TestBounceForNamesTheCause(t *testing.T) { |
| } |
| sink.mu.Lock() |
| defer sink.mu.Unlock() |
| - if len(sink.msgs) != 1 || sink.msgs[0] != "instance restarting (config)" { |
| - t.Fatalf("events = %v", sink.msgs) |
| + want := fmt.Sprintf(rolloutwords.EventBounceInstance, rolloutwords.Cause(string(CauseConfig))) |
| + if len(sink.msgs) != 1 || sink.msgs[0] != want { |
| + t.Fatalf("events = %v, want %q", sink.msgs, want) |
| + } |
| +} |
| + |
| +// bounceText words both nouns through rolloutwords, and restart's cause |
| +// phrase leaves the flag-off literals in bounceLocked unchanged. |
| +func TestBounceTextIsTheApprovedWording(t *testing.T) { |
| + for _, cause := range []Cause{CauseRelease, CauseConfig, CauseRestart} { |
| + if got, want := bounceText(false, cause), fmt.Sprintf(rolloutwords.EventBounceInstance, rolloutwords.Cause(string(cause))); got != want { |
| + t.Errorf("instance, %s: %q, want %q", cause, got, want) |
| + } |
| + if got, want := bounceText(true, cause), fmt.Sprintf(rolloutwords.EventBounceSidecar, rolloutwords.Cause(string(cause))); got != want { |
| + t.Errorf("sidecar, %s: %q, want %q", cause, got, want) |
| + } |
| + } |
| + if got := bounceText(false, CauseRestart); got != "instance restarting (carlos restart)" { |
| + t.Errorf("restart bounce %q drifted from the flag-off literal", got) |
| + } |
| + if got := bounceText(true, CauseRestart); got != "sidecar restarting (carlos restart)" { |
| + t.Errorf("restart sidecar bounce %q drifted from the flag-off literal", got) |
| } |
| } |
diff --git a/internal/rollout/events.go b/internal/rollout/events.go| index 110873927..c331d0719 100644 |
| --- a/internal/rollout/events.go |
| +++ b/internal/rollout/events.go |
| @@ -1,17 +1,20 @@ |
| package rollout |
| import ( |
| + "fmt" |
| "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/rolloutwords" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| // EventCode is a rollout event's stable internal name (spec §9.4). The codes |
| // are what the journal and tests key on. The member-facing texts below are |
| -// DRAFTS: plan 6 rewrites every one of them through copy review, and the |
| -// CARLOS_SWAP flag stays off outside dev until it has. |
| +// worded by internal/rolloutwords, and are drafts pending copy review (the |
| +// TODO-COPY markers live there); the CARLOS_SWAP flag stays off outside dev |
| +// until they are reviewed. |
| type EventCode string |
| const ( |
| @@ -48,38 +51,42 @@ func reasonWord(text string) string { |
| } |
| func textSwapping(build string, cause activator.Cause) string { |
| - return "swapping to build " + build + " (" + string(cause) + ")" |
| + return fmt.Sprintf(rolloutwords.EventSwapping, build, rolloutwords.Cause(string(cause))) |
| } |
| -// textSwapped carries the retiring generation's drain time (spec §9.4). |
| +// textSwapped carries the retiring generation's drain time (spec §9.4), |
| +// printed to a tenth of a second. |
| func textSwapped(build string, drain time.Duration) string { |
| - return "swapped to build " + build + ". the previous build drained in " + drain.Round(100*time.Millisecond).String() |
| + return fmt.Sprintf(rolloutwords.EventSwapped, build, drain.Round(100*time.Millisecond).String()) |
| } |
| -// textSwapFailed's reason is a reason word (reasonWord), never raw text. |
| +// textSwapFailed's reason is a reason word (reasonWord at the emit site), |
| +// never raw text. It is worded as it is given: mapping it again would fold |
| +// the closed words into one another (reasonWord("exit") is a health |
| +// failure). |
| func textSwapFailed(build, serving, reason string) string { |
| - return "swap to build " + build + " failed: " + reason + ". still serving build " + serving |
| + return fmt.Sprintf(rolloutwords.EventSwapFailed, build, rolloutwords.Reason(reason), serving) |
| } |
| func textSwapDown(build string) string { |
| - return "build " + build + " failed in hold mode. the instance stays down until it is rolled back with carlos rollback" |
| + return fmt.Sprintf(rolloutwords.EventHoldDown, build) |
| } |
| // textCohortHalted names the box (spec §9.4); the event sink already |
| // carries the account, the app and the host. reason is a reason word. |
| func textCohortHalted(build, box, reason string) string { |
| - return "rollout of build " + build + " halted on box " + box + ": " + reason |
| + return fmt.Sprintf(rolloutwords.EventCohortHalted, build, box, rolloutwords.Reason(reason)) |
| } |
| func textFallback(failed, good string) string { |
| - return "build " + failed + " failed to start. served build " + good + " instead" |
| + return fmt.Sprintf(rolloutwords.EventFallbackServed, failed, good) |
| } |
| // textConfigPending's reason is a reason word. |
| func textConfigPending(reason string, wait time.Duration) string { |
| - return "config delivered, not active: " + reason + ". trying again in " + wait.String() |
| + return fmt.Sprintf(rolloutwords.EventConfigPending, rolloutwords.Reason(reason), wait.String()) |
| } |
| func textStagingRefused(build string) string { |
| - return "build " + build + " is not staged correctly on this box. asked for it to be staged again" |
| + return fmt.Sprintf(rolloutwords.EventStagingRefused, build) |
| } |
diff --git a/internal/rollout/events_words_test.go b/internal/rollout/events_words_test.go| new file mode 100644 |
| index 000000000..fdc71b78d |
| --- /dev/null |
| +++ b/internal/rollout/events_words_test.go |
| @@ -0,0 +1,59 @@ |
| +package rollout |
| + |
| +import ( |
| + "fmt" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/rolloutwords" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// textHead is the event text up to its first argument the caller does not |
| +// know: sentinel is that argument, as the text function was given it. It |
| +// keeps the other tests' pins on the wording itself (rolloutwords), so a |
| +// copy change re-keys once. |
| +func textHead(text, sentinel string) string { |
| + head, _, _ := strings.Cut(text, sentinel) |
| + return head |
| +} |
| + |
| +// The text functions take reason WORDS (status.Reason*), as the emit sites |
| +// give them after reasonWord; each is worded through rolloutwords.Reason and |
| +// is not mapped again (reasonWord("exit") would read as a health failure). |
| +func TestEventTextsAreTheApprovedOnes(t *testing.T) { |
| + cases := map[string][2]string{ |
| + "swapping": {textSwapping("v2", activator.CauseRelease), fmt.Sprintf(rolloutwords.EventSwapping, "v2", rolloutwords.Cause("release"))}, |
| + "swapped": {textSwapped("v2", 1234*time.Millisecond), fmt.Sprintf(rolloutwords.EventSwapped, "v2", "1.2s")}, |
| + "swap failed": {textSwapFailed("v2", "v1", status.ReasonHealth), fmt.Sprintf(rolloutwords.EventSwapFailed, "v2", rolloutwords.Reason(status.ReasonHealth), "v1")}, |
| + "down": {textSwapDown("v2"), fmt.Sprintf(rolloutwords.EventHoldDown, "v2")}, |
| + "halted": {textCohortHalted("v2", "box-7", status.ReasonSettle), fmt.Sprintf(rolloutwords.EventCohortHalted, "v2", "box-7", rolloutwords.Reason(status.ReasonSettle))}, |
| + "fallback": {textFallback("v2", "v1"), fmt.Sprintf(rolloutwords.EventFallbackServed, "v2", "v1")}, |
| + "config pending": {textConfigPending(status.ReasonExit, 2*time.Minute), fmt.Sprintf(rolloutwords.EventConfigPending, rolloutwords.Reason(status.ReasonExit), (2 * time.Minute).String())}, |
| + "staging refused": {textStagingRefused("v2"), fmt.Sprintf(rolloutwords.EventStagingRefused, "v2")}, |
| + } |
| + for name, c := range cases { |
| + if c[0] != c[1] { |
| + t.Errorf("%s: %q, want the approved %q", name, c[0], c[1]) |
| + } |
| + } |
| +} |
| + |
| +// Every closed reason word is worded as itself: none is folded into another |
| +// by a second mapping. |
| +func TestEventTextsKeepEachReasonWord(t *testing.T) { |
| + for _, w := range []string{status.ReasonHealth, status.ReasonSettle, status.ReasonExit, status.ReasonStaging, status.ReasonReused} { |
| + want := rolloutwords.Reason(w) |
| + for name, s := range map[string]string{ |
| + "swap failed": textSwapFailed("v2", "v1", w), |
| + "halted": textCohortHalted("v2", "box-7", w), |
| + "config pending": textConfigPending(w, time.Minute), |
| + } { |
| + if !strings.Contains(s, want) { |
| + t.Errorf("%s with reason word %q: %q does not say %q", name, w, s, want) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/rollout/manager_test.go b/internal/rollout/manager_test.go| index 183231c24..78b4d9f26 100644 |
| --- a/internal/rollout/manager_test.go |
| +++ b/internal/rollout/manager_test.go |
| @@ -11,6 +11,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/rolloutwords" |
| "github.com/carlosframework/platform/internal/status" |
| ) |
| @@ -32,7 +33,7 @@ func TestAReleaseRollsOutCanaryFirstThenPaced(t *testing.T) { |
| t.Fatalf("%s row = %+v, want good and serving v2", host, r) |
| } |
| } |
| - if !h.events.has("swapped to build v2") { |
| + if !h.events.has(textHead(textSwapped("v2", time.Hour), time.Hour.String())) { |
| t.Fatal("no swapped event") |
| } |
| } |
| @@ -87,7 +88,7 @@ func TestAFailedCanaryHaltsTheCohort(t *testing.T) { |
| if r := h.rows.get("a"); r.GoodVersion != "v1" { |
| t.Fatalf("good build moved to %q on a failure", r.GoodVersion) |
| } |
| - if !h.events.has("rollout of build v2 halted") { |
| + if !h.events.has(textHead(textCohortHalted("v2", "\x00", status.ReasonHealth), "\x00")) { |
| t.Fatal("no cohort-halted event") |
| } |
| } |
| @@ -277,7 +278,7 @@ func TestStagingRefusalWritesNoFailureRecord(t *testing.T) { |
| if g, _ := h.act.Current("a"); g.Build != "v1" { |
| t.Fatal("the instance left its serving build") |
| } |
| - if !h.events.has("not staged correctly") { |
| + if !h.events.has(textStagingRefused("v2")) { |
| t.Fatal("no staging event") |
| } |
| } |
| @@ -416,7 +417,7 @@ func TestAFailedConfigAttemptRetriesWithBackoff(t *testing.T) { |
| h.pass() |
| h.m.ConfigDelivered("a", "sha256:c1") |
| waitFor(t, "the first config attempt", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 }) |
| - waitFor(t, "the pending event", func() bool { return h.events.has("config delivered, not active") }) |
| + waitFor(t, "the pending event", func() bool { return h.events.has(configPendingHead) }) |
| h.advance(59 * time.Second) |
| h.pass() |
| time.Sleep(50 * time.Millisecond) |
| @@ -628,7 +629,7 @@ func TestAFailedHoldIsHeldDownBeforeItsSlotFrees(t *testing.T) { |
| h.install("v2", "a") |
| h.pass() |
| waitFor(t, "the held-down record", func() bool { return h.book.HeldDown("bab", "blog", "v2", "a") }) |
| - waitFor(t, "the down event", func() bool { return h.events.has("failed in hold mode") }) |
| + waitFor(t, "the down event", func() bool { return h.events.has(textSwapDown("v2")) }) |
| if bad.Load() != 0 || seen.Load() == 0 { |
| t.Fatalf("%d of %d look(s) with the slot free found the host not held down", bad.Load(), seen.Load()) |
| } |
| @@ -767,7 +768,7 @@ func TestAnOlderActivationKeepsTheConfigFailureCount(t *testing.T) { |
| } |
| h.pass() |
| h.m.ConfigDelivered("a", "sha256:c2") |
| - waitFor(t, "the pending event", func() bool { return h.events.has("config delivered, not active") }) |
| + waitFor(t, "the pending event", func() bool { return h.events.has(configPendingHead) }) |
| h.m.configActivated("a", "sha256:c1") |
| h.m.mu.Lock() |
| fails := h.m.cfg["a"].fails |
| @@ -800,7 +801,7 @@ func TestASleepersPendingConfigIsNoAttemptAfterItWakes(t *testing.T) { |
| }) |
| h.m.ConfigDelivered("a", "sha256:c1") |
| ack(appconfig.ConfigAck{Host: "a", Restarted: true}) |
| - waitFor(t, "the failed config attempt", func() bool { return h.events.has("config delivered, not active") }) |
| + waitFor(t, "the failed config attempt", func() bool { return h.events.has(configPendingHead) }) |
| h.act.mu.Lock() |
| delete(h.act.gens, "a") // hibernated inside its backoff |
| h.act.mu.Unlock() |
| @@ -835,7 +836,7 @@ func TestAConfigDeliveredToASleeperClearsTheOlderPendingAttempt(t *testing.T) { |
| } |
| h.pass() |
| h.m.ConfigDelivered("a", "sha256:c1") |
| - waitFor(t, "the failed c1 attempt", func() bool { return h.events.has("config delivered, not active") }) |
| + waitFor(t, "the failed c1 attempt", func() bool { return h.events.has(configPendingHead) }) |
| s := h.m.schedulerOf("a") |
| waitFor(t, "the slot to free with c1 pending", func() bool { |
| s.mu.Lock() |
| @@ -919,15 +920,17 @@ func TestMemberEventsCarryOnlyTheReasonWord(t *testing.T) { |
| h.act.outcome = func(activator.Attempt) activator.SwapOutcome { return activator.SwapFailed } |
| h.install("v2", "a") |
| h.pass() |
| - waitFor(t, "the halt event", func() bool { return h.events.has("rollout of build v2 halted") }) |
| - waitFor(t, "the failed event", func() bool { return h.events.has("swap to build v2 failed") }) |
| + waitFor(t, "the halt event", func() bool { |
| + return h.events.has(textHead(textCohortHalted("v2", "\x00", status.ReasonHealth), "\x00")) |
| + }) |
| + waitFor(t, "the failed event", func() bool { return h.events.has(textHead(textSwapFailed("v2", "\x00", status.ReasonHealth), "\x00")) }) |
| for _, raw := range []string{"permission denied", "fork/exec", "blog:"} { |
| if h.events.has(raw) { |
| t.Fatalf("a member event carries the raw reason %q", raw) |
| } |
| } |
| - if !h.events.has("swap to build v2 failed: health. still serving build v1") || |
| - !h.events.has("halted on box box-1: health") { |
| + if !h.events.has(textSwapFailed("v2", "v1", status.ReasonHealth)) || |
| + !h.events.has(textCohortHalted("v2", "box-1", status.ReasonHealth)) { |
| t.Fatal("the events do not carry the reason word") |
| } |
| } |
| @@ -947,3 +950,7 @@ func TestReasonWords(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// configPendingHead is the config-pending event up to its reason: the tests |
| +// that wait for it do not know the reason or the backoff. |
| +var configPendingHead = textHead(rolloutwords.EventConfigPending, "%s") |
diff --git a/internal/rollout/wakeplan_test.go b/internal/rollout/wakeplan_test.go| index 6c68657ce..be1a6577f 100644 |
| --- a/internal/rollout/wakeplan_test.go |
| +++ b/internal/rollout/wakeplan_test.go |
| @@ -12,6 +12,7 @@ import ( |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/status" |
| ) |
| func wakeRoute(installed, good string) registry.Route { |
| @@ -111,7 +112,7 @@ func TestAFellBackWakeRecordsTheFailureAndKeepsTheGoodBuild(t *testing.T) { |
| 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") { |
| + if !h.events.has(textFallback("v2", "v1")) { |
| t.Fatal("no fallback event") |
| } |
| } |
| @@ -249,7 +250,7 @@ func TestAFailedHoldFirstWakeHoldsTheInstanceDown(t *testing.T) { |
| 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") { |
| + if !h.events.has(textCohortHalted("v2", "box-1", status.ReasonHealth)) { |
| t.Fatal("no cohort-halted event with the reason word") |
| } |
| } |
| @@ -428,7 +429,7 @@ func TestAPlannedWakeReadsTheDemandAsAnAttemptDoes(t *testing.T) { |
| 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") { |
| + if h.touched.Load() != 1 || !h.events.has(textStagingRefused("v2")) { |
| t.Fatal("a staging refusal must ask root to stage again and say so") |
| } |
| } |