carlos / platform Public

zdd-6 task 14: rollout events use the rolloutwords wording

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev cf0b8eb956e405a857a34d856b21605e6cfce2e9 parent a67bc75
6 files changed, +132 −35
  • internal/activator/wakeplan.go +8 −6
  • internal/activator/wakeplan_test.go +23 −2
  • internal/rollout/events.go +19 −12
  • internal/rollout/events_words_test.go +59 −0
  • internal/rollout/manager_test.go +19 −12
  • internal/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")
}
}