carlos / platform Public

zdd-5 task 8: the manager and the per-app scheduler

The rollout Manager and one scheduler goroutine per app: feeds (Pass,
Nudge, Adopted, Restart, ConfigDelivered, DeferAck), attempt resolution,
outcomes, good builds, the cohort-failure transition, config activation,
pointer retries and RetryHost. Tested against fakeAct until Tasks 9 and 11
give the activator Current and ReplaceWorker.

Carried rulings: member events carry reason words, never raw reasons
(reasonWord moves forward from Task 13); a demand error other than a
digest mismatch, or no digest, runs the attempt in hold; an abandoned
attempt still newest waits before it runs again; a hold's SwapDown is held
down in the step that frees its slot; a failed config attempt's backoff is
set in that step too, with no registry read in front; RetryHost clears the
book and binds the retry flag under one scheduler lock; a halt is lifted
once its record is gone; a sleeper's pending config is active on its next
wake and its held ack goes out marked Hibernating.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev e51bd9bec9d6b8a0210de21b8d58650b287b2ea2 parent a12b53c
5 files changed, +2469 −0
  • internal/rollout/events.go +94 −0
  • internal/rollout/fake_test.go +427 −0
  • internal/rollout/manager.go +808 −0
  • internal/rollout/manager_test.go +882 −0
  • internal/rollout/scheduler.go +258 −0
diff --git a/internal/rollout/events.go b/internal/rollout/events.go
new file mode 100644
index 000000000..82a4bca82
--- /dev/null
+++ b/internal/rollout/events.go
@@ -0,0 +1,94 @@
+package rollout
+
+import (
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+)
+
+// 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.
+type EventCode string
+
+const (
+ EvSwapping EventCode = "swap.started"
+ EvSwapped EventCode = "swap.done"
+ EvSwapFailed EventCode = "swap.failed"
+ EvSwapDown EventCode = "swap.down"
+ EvCohortHalted EventCode = "cohort.halted"
+ EvFallbackServed EventCode = "fallback.served"
+ EvConfigPending EventCode = "config.pending"
+ EvStagingRefused EventCode = "staging.refused"
+)
+
+// The reason words: the closed vocabulary a member-facing event or status
+// line may give for a failure. Task 13 moves them into the status package
+// (status.Reason*) and points these at them.
+const (
+ reasonHealth = "health"
+ reasonSettle = "settle"
+ reasonExit = "exit"
+ reasonStaging = "staging"
+ reasonReused = "reused"
+)
+
+// reasonWord turns an attempt's diagnostic text (SwapResult.Reason, an error)
+// into one of the reason words, so no free text, which can name paths and
+// internals, reaches a member-facing surface. The raw text goes to the
+// operator log instead. Anything unrecognised is a failed health check: the
+// swap's checks are the health check, the settle and the exit watch, and the
+// health check is the one a spawn or start error also fails.
+func reasonWord(text string) string {
+ t := strings.ToLower(text)
+ switch {
+ case strings.Contains(t, "reused"):
+ return reasonReused
+ case strings.Contains(t, "staging"), strings.Contains(t, "staged"), strings.Contains(t, "digest"):
+ return reasonStaging
+ case strings.Contains(t, "settle"):
+ return reasonSettle
+ case strings.Contains(t, "exited"):
+ return reasonExit
+ }
+ return reasonHealth
+}
+
+func textSwapping(build string, cause activator.Cause) string {
+ return "swapping to build " + build + " (" + string(cause) + ")"
+}
+
+// textSwapped carries the retiring generation's drain time (spec §9.4).
+func textSwapped(build string, drain time.Duration) string {
+ return "swapped to build " + build + ". the previous build drained in " + drain.Round(100*time.Millisecond).String()
+}
+
+// textSwapFailed's reason is a reason word (reasonWord), never raw text.
+func textSwapFailed(build, serving, reason string) string {
+ return "swap to build " + build + " failed: " + reason + ". still serving build " + 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"
+}
+
+// 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
+}
+
+func textFallback(failed, good string) string {
+ return "build " + failed + " failed to start. served build " + good + " instead"
+}
+
+// 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()
+}
+
+func textStagingRefused(build string) string {
+ return "build " + build + " is not staged correctly on this box. asked for it to be staged again"
+}
diff --git a/internal/rollout/fake_test.go b/internal/rollout/fake_test.go
new file mode 100644
index 000000000..686ababe8
--- /dev/null
+++ b/internal/rollout/fake_test.go
@@ -0,0 +1,427 @@
+package rollout
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+ "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"
+)
+
+type swapCall struct {
+ a activator.Attempt
+ outcome activator.SwapOutcome
+}
+
+type replaceCall struct {
+ a activator.Attempt
+ fallback *release.StagedIdentity
+}
+
+// fakeAct models plan 3's Swap faithfully on the one point this plan relies
+// on: the re-check before the switch. A superseded attempt never switches.
+type fakeAct struct {
+ mu sync.Mutex
+ gens map[string]activator.Gen
+ owned map[string]bool
+ n uint64
+ gates map[string]chan struct{}
+ outcome func(a activator.Attempt) activator.SwapOutcome
+ started []string
+ calls []swapCall
+ inFlight int
+ maxFlight int
+ replaced []replaceCall
+ worker func(a activator.Attempt, fallback *release.StagedIdentity) activator.SwapResult
+ // mode, when set, is the effective mode Swap reports (SwapResult.Mode):
+ // plan 3 runs an overlap attempt on a foreign-layout row as a hold.
+ mode activator.SwapMode
+ // reason, when set, is a failed Swap's diagnostic text in place of
+ // "health check failed".
+ reason string
+}
+
+func newFakeAct() *fakeAct {
+ return &fakeAct{gens: map[string]activator.Gen{}, owned: map[string]bool{}, gates: map[string]chan struct{}{}}
+}
+
+func (f *fakeAct) Current(host string) (activator.Gen, bool) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ g, ok := f.gens[host]
+ return g, ok
+}
+
+func (f *fakeAct) Owns(host string) bool {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ _, ok := f.gens[host]
+ return ok || f.owned[host]
+}
+
+func (f *fakeAct) serve(host, build string) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.n++
+ f.gens[host] = activator.Gen{N: f.n, Build: build, StagedPath: "/opt/" + build, Digest: "sha-" + build}
+}
+
+func (f *fakeAct) hold(host string) chan struct{} {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ ch := make(chan struct{})
+ f.gates[host] = ch
+ return ch
+}
+
+func (f *fakeAct) Swap(_ context.Context, a activator.Attempt, newest func(string) uint64) activator.SwapResult {
+ f.mu.Lock()
+ f.inFlight++
+ f.maxFlight = max(f.maxFlight, f.inFlight)
+ f.started = append(f.started, a.Host+":"+a.Build)
+ gate := f.gates[a.Host]
+ delete(f.gates, a.Host)
+ f.mu.Unlock()
+ if gate != nil {
+ <-gate
+ }
+ out := activator.SwapDone
+ if newest(a.Host) != a.SchedRev {
+ out = activator.SwapAbandoned
+ } else if f.outcome != nil {
+ out = f.outcome(a)
+ }
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.inFlight--
+ f.calls = append(f.calls, swapCall{a: a, outcome: out})
+ if out != activator.SwapDone {
+ reason := "health check failed"
+ if f.reason != "" {
+ reason = f.reason
+ }
+ return activator.SwapResult{Outcome: out, Reason: reason, Mode: f.mode}
+ }
+ f.n++
+ g := activator.Gen{N: f.n, Build: a.Build, StagedPath: a.StagedPath, Digest: a.Digest, Demand: a.Demand}
+ f.gens[a.Host] = g
+ return activator.SwapResult{Outcome: out, Gen: g}
+}
+
+// ReplaceWorker models plan 5's same-entry socketless attempt (Task 11):
+// worker decides the outcome, and every call is recorded with the fallback
+// it was handed. The default is a clean replacement onto the attempt's build.
+func (f *fakeAct) ReplaceWorker(_ context.Context, a activator.Attempt, fallback *release.StagedIdentity, newest func(string) uint64) activator.SwapResult {
+ f.mu.Lock()
+ f.replaced = append(f.replaced, replaceCall{a: a, fallback: fallback})
+ decide := f.worker
+ f.mu.Unlock()
+ if newest(a.Host) != a.SchedRev {
+ return activator.SwapResult{Outcome: activator.SwapAbandoned, Reason: "superseded"}
+ }
+ res := activator.SwapResult{Outcome: activator.SwapDone}
+ if decide != nil {
+ res = decide(a, fallback)
+ }
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ switch {
+ case res.Outcome == activator.SwapDone:
+ f.n++
+ res.Gen = activator.Gen{N: f.n, Build: a.Build, StagedPath: a.StagedPath, Digest: a.Digest, Demand: a.Demand}
+ f.gens[a.Host] = res.Gen
+ case res.Outcome == activator.SwapFailed && fallback != nil:
+ f.n++
+ res.Gen = activator.Gen{N: f.n, Build: fallback.Version, StagedPath: fallback.StagedPath, Digest: fallback.Digest, Demand: fallback.Demand}
+ f.gens[a.Host] = res.Gen
+ case res.Outcome == activator.SwapDown:
+ delete(f.gens, a.Host)
+ }
+ return res
+}
+
+func (f *fakeAct) replacements() []replaceCall {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]replaceCall(nil), f.replaced...)
+}
+
+func (f *fakeAct) snapshot() ([]swapCall, []string, int) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]swapCall(nil), f.calls...), append([]string(nil), f.started...), f.maxFlight
+}
+
+type fakeRoutes struct {
+ mu sync.Mutex
+ rows map[string]registry.Route
+ // getHook, when set, runs at the start of every Get, with no lock held:
+ // a test's window into the moment the scheduler reads a row. getErr,
+ // when set and answering an error, fails that Get.
+ getHook func(host string)
+ getErr func(host string) error
+ // setGoodHook, when set, runs at the start of every SetGoodBuild, with
+ // no lock held. setServingHook does the same for SetServingVersion.
+ setGoodHook func(host string)
+ setServingHook func(host string)
+}
+
+func (f *fakeRoutes) Get(host string) (*registry.Route, error) {
+ f.mu.Lock()
+ hook, fail := f.getHook, f.getErr
+ f.mu.Unlock()
+ if hook != nil {
+ hook(host)
+ }
+ if fail != nil {
+ if err := fail(host); err != nil {
+ return nil, err
+ }
+ }
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ r, ok := f.rows[host]
+ if !ok {
+ return nil, nil
+ }
+ return &r, nil
+}
+
+func (f *fakeRoutes) List() ([]registry.Route, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ out := make([]registry.Route, 0, len(f.rows))
+ for _, r := range f.rows {
+ out = append(out, r)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Host < out[j].Host })
+ return out, nil
+}
+
+func (f *fakeRoutes) SetGoodBuild(host string, expect, next registry.GoodBuild) error {
+ f.mu.Lock()
+ hook := f.setGoodHook
+ f.mu.Unlock()
+ if hook != nil {
+ hook(host)
+ }
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ r := f.rows[host]
+ if r.Good() != expect {
+ return registry.ErrGoodBuildChanged
+ }
+ r.GoodVersion, r.GoodDigest, r.GoodDemand = next.Version, next.Digest, next.Demand
+ f.rows[host] = r
+ return nil
+}
+
+func (f *fakeRoutes) SetServingVersion(host, ver string) error {
+ f.mu.Lock()
+ hook := f.setServingHook
+ f.mu.Unlock()
+ if hook != nil {
+ hook(host)
+ }
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ r := f.rows[host]
+ r.ServingVersion = ver
+ f.rows[host] = r
+ return nil
+}
+
+func (f *fakeRoutes) put(r registry.Route) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.rows[r.Host] = r
+}
+
+func (f *fakeRoutes) get(host string) registry.Route {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return f.rows[host]
+}
+
+type eventLog struct {
+ mu sync.Mutex
+ lines []string
+}
+
+func (e *eventLog) App(string, string, string, string) {}
+
+func (e *eventLog) Event(_, _, host, _, msg string) {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.lines = append(e.lines, host+": "+msg)
+}
+
+func (e *eventLog) has(substr string) bool {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ for _, l := range e.lines {
+ if strings.Contains(l, substr) {
+ return true
+ }
+ }
+ return false
+}
+
+// harness is a manager over the fakes, for app "blog" of account "bab",
+// every host awake on v1, its good build.
+type harness struct {
+ t *testing.T
+ act *fakeAct
+ rows *fakeRoutes
+ book *FailureBook
+ m *Manager
+ opt, data string
+ mu sync.Mutex
+ now time.Time
+ concurrency int
+ swap string
+ demands map[string]string
+ mismatch map[string]bool
+ demandErr map[string]error
+ noDigest map[string]bool
+ pointers map[string]release.ChannelPointer // channel -> its signed pointer
+ failing atomic.Bool
+ touched atomic.Int32
+ events *eventLog
+ // pointerHook, when set, runs at the start of every pointer read.
+ pointerHook func()
+}
+
+func newHarness(t *testing.T, hosts ...string) *harness {
+ t.Helper()
+ h := &harness{t: t, act: newFakeAct(), rows: &fakeRoutes{rows: map[string]registry.Route{}}, book: &FailureBook{},
+ opt: t.TempDir(), data: t.TempDir(), now: t0, concurrency: 1,
+ demands: map[string]string{}, mismatch: map[string]bool{}, demandErr: map[string]error{}, noDigest: map[string]bool{},
+ pointers: map[string]release.ChannelPointer{},
+ events: &eventLog{}}
+ h.stage("v1")
+ for _, host := range hosts {
+ h.rows.put(registry.Route{Host: host, Kind: "instance", App: "blog", Account: "bab", Status: "ready",
+ Hibernate: true, Version: "v1", GoodVersion: "v1", GoodDigest: "sha-v1"})
+ h.act.serve(host, "v1")
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ h.m = NewManager(ctx, Deps{
+ Act: h.act, Routes: h.rows, Book: h.book, OptDir: h.opt, DataDir: h.data, Box: "box-1",
+ Demand: h.demand, Policy: h.policy, Pointer: h.pointerFor, Events: h.events,
+ Touch: func() { h.touched.Add(1) }, Now: h.clock, Logf: func(string, ...any) {},
+ })
+ return h
+}
+
+// pointer signs channel's pointer onto ver at promoted, as `carlos promote`
+// (with or without --retry) would.
+func (h *harness) pointer(channel, ver string, promoted time.Time) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.pointers[channel] = release.ChannelPointer{Schema: 1, Account: "bab", App: "blog", Channel: channel, Version: ver, PromotedAt: promoted}
+}
+
+func (h *harness) pointerFor(_ context.Context, _, _, channel string) (*release.ChannelPointer, error) {
+ h.mu.Lock()
+ hook := h.pointerHook
+ h.mu.Unlock()
+ if hook != nil {
+ hook()
+ }
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ p, ok := h.pointers[channel]
+ if !ok {
+ return nil, nil
+ }
+ return &p, nil
+}
+
+func (h *harness) clock() time.Time {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.now
+}
+
+func (h *harness) advance(d time.Duration) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.now = h.now.Add(d)
+}
+
+func (h *harness) stage(vers ...string) {
+ for _, v := range vers {
+ if err := release.StageArtifact(h.opt, "bab", v, "blog", []byte(v)); err != nil {
+ h.t.Fatal(err)
+ }
+ }
+}
+
+// demand is release.CanonicalDemand's contract: a mismatch wraps
+// release.ErrStagedDigestMismatch (a staging refusal); demandErr is any other
+// error, and noDigest is a store that could not compare, both of which must
+// end as a hold attempt.
+func (h *harness) demand(_ context.Context, _, _, ver, _ string) (string, string, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.mismatch[ver] {
+ return "", "", fmt.Errorf("%w: the staged executable's digest differs from the canonical manifest", release.ErrStagedDigestMismatch)
+ }
+ if err := h.demandErr[ver]; err != nil {
+ return "", "", err
+ }
+ if h.noDigest[ver] {
+ return "", "", nil
+ }
+ return h.demands[ver], "sha-" + ver, nil
+}
+
+func (h *harness) policy(context.Context, string, string) (*appconfig.Manifest, error) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return &appconfig.Manifest{Schema: 1, App: "blog", Swap: h.swap, SwapConcurrency: h.concurrency}, nil
+}
+
+func (h *harness) install(ver string, hosts ...string) {
+ for _, host := range hosts {
+ r := h.rows.get(host)
+ r.Version = ver
+ h.rows.put(r)
+ }
+}
+
+func (h *harness) pass() {
+ rs, _ := h.rows.List()
+ h.m.Pass(rs)
+}
+
+func (h *harness) servingAll(build string, hosts ...string) bool {
+ for _, host := range hosts {
+ if g, ok := h.act.Current(host); !ok || g.Build != build {
+ return false
+ }
+ }
+ return true
+}
+
+func waitFor(t *testing.T, what string, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatalf("timed out waiting for %s", what)
+}
diff --git a/internal/rollout/manager.go b/internal/rollout/manager.go
new file mode 100644
index 000000000..11b4165ba
--- /dev/null
+++ b/internal/rollout/manager.go
@@ -0,0 +1,808 @@
+package rollout
+
+import (
+ "context"
+ "errors"
+ "log"
+ "os"
+ "strings"
+ "sync"
+ "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"
+)
+
+// Activator is the part of *activator.Service the scheduler drives. Swap is
+// plan 3's, for instances; ReplaceWorker is Task 11's same-entry
+// stop-then-start for workers and native sidecar members (spec §7.9).
+type Activator interface {
+ Current(host string) (activator.Gen, bool)
+ Owns(host string) bool
+ Swap(ctx context.Context, a activator.Attempt, newest func(host string) uint64) activator.SwapResult
+ ReplaceWorker(ctx context.Context, a activator.Attempt, fallback *release.StagedIdentity, newest func(host string) uint64) activator.SwapResult
+}
+
+// Routes is the part of *registry.Registry the scheduler reads and writes.
+type Routes interface {
+ Get(host string) (*registry.Route, error)
+ SetGoodBuild(host string, expect, next registry.GoodBuild) error
+ SetServingVersion(host, ver string) error
+}
+
+// Deps is everything the manager needs from the box. Its funcs are always
+// called without the manager's locks held.
+type Deps struct {
+ Act Activator
+ Routes Routes
+ Book *FailureBook
+ OptDir string
+ DataDir string
+ // Box is this box's label (host.BoxLabel), named by the cohort-halted
+ // event (spec §9.4: app, box, build, reason).
+ Box string
+ // Demand is release.CanonicalDemand bound to the deploy store and this
+ // box's platform (spec §4.2).
+ Demand func(ctx context.Context, acct, app, version, stagedPath string) (demand, digest string, err error)
+ // Policy reads the app's default config bundle (spec §4.1).
+ Policy func(ctx context.Context, acct, app string) (*appconfig.Manifest, error)
+ // Pointer reads a channel's signed pointer, verified (release.Channel).
+ // It is the retry signal (spec rev 14 §8.4) and the PromotedAt a failure
+ // record keeps. Nil: records clear only by a newer installed build. It
+ // is the raw signed read, not the per-box digest ratchet: only its
+ // Version and PromotedAt are used, to clear failure records, and root's
+ // install, which the ratchet guards, decides which build is installed.
+ Pointer func(ctx context.Context, acct, app, channel string) (*release.ChannelPointer, error)
+ Events activator.EventSink
+ // Touch asks root for a stage pass (the stage relay, spec §7.7).
+ Touch func()
+ // Materialize installs and stamps a cold managed host's build (the exec
+ // backend's Materialize hook) so a wake is planned against it (Task 10,
+ // spec §6.4, §8.4). Nil: a cold managed host's wake is refused.
+ Materialize func(ctx context.Context, host string) error
+ Now func() time.Time
+ Logf func(format string, args ...any)
+}
+
+// pointerReadEvery bounds how often an app with a standing failure record
+// has its channel pointers read for a retry. An app with no record costs no
+// read at all.
+const pointerReadEvery = 30 * time.Second
+
+// stagingDeferral is how long an attempt refused as a staging error waits:
+// one stage-relay window, for root to restage (spec §4.2, §7.7).
+const stagingDeferral = 15 * time.Second
+
+// abandonDeferral is how long a host waits after an attempt was abandoned
+// while it was still the host's newest: a hold whose stop withdrew, a lost
+// lease, a re-check that refused. Running the same attempt again at once
+// would only repeat the stop cycle (plan 3's deferral to this plan). An
+// attempt abandoned because a newer desire superseded it does not wait: the
+// newer attempt goes as soon as it is admitted.
+const abandonDeferral = 15 * time.Second
+
+// Manager is the box's rollout scheduler: one goroutine per (account, app),
+// fed by the agent pass, the adopt pass, `carlos restart` and config sync.
+//
+// Lock order: activator.Service.mu, then Manager.mu, then scheduler.mu, then
+// FailureBook.mu. The activator (Current, Owns, Swap, ReplaceWorker) is never
+// called while Manager.mu or a scheduler.mu is held.
+type Manager struct {
+ d Deps
+ ctx context.Context
+ policies *policyCache
+
+ mu sync.Mutex
+ apps map[appKey]*scheduler
+ hostApp map[string]appKey
+ cfg map[string]*configTrack
+ retried map[string]string // host -> build a retry cleared: its next attempt's cause is retry
+ pointerRead map[appKey]time.Time
+
+ // goodMu serialises every serving- and good-build write with its
+ // acknowledgement check (served, publishServing, writeGood), so an older
+ // generation's write can never land after a newer one's (spec §8.5).
+ goodMu sync.Mutex
+}
+
+// NewManager builds the manager. Its schedulers run until ctx ends.
+func NewManager(ctx context.Context, d Deps) *Manager {
+ if d.Now == nil {
+ d.Now = time.Now
+ }
+ if d.Logf == nil {
+ d.Logf = log.Printf
+ }
+ m := &Manager{d: d, ctx: ctx,
+ apps: map[appKey]*scheduler{}, hostApp: map[string]appKey{}, cfg: map[string]*configTrack{},
+ retried: map[string]string{}, pointerRead: map[appKey]time.Time{}}
+ m.policies = &policyCache{load: d.Policy, ttl: 30 * time.Second, now: d.Now, m: map[appKey]policyEntry{}}
+ return m
+}
+
+func (m *Manager) scheduler(k appKey) *scheduler {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ s := m.apps[k]
+ if s == nil {
+ book := m.d.Book
+ s = &scheduler{m: m, key: k, kick: make(chan struct{}, 1),
+ q: newAppQueue(k.acct, k.app, func(build, host string, served bool) bool {
+ return book.Blocks(k.acct, k.app, build, host, served)
+ })}
+ m.apps[k] = s
+ go s.loop(m.ctx)
+ }
+ return s
+}
+
+func (m *Manager) schedulerOf(host string) *scheduler {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ k, ok := m.hostApp[host]
+ if !ok {
+ return nil
+ }
+ return m.apps[k]
+}
+
+// track is host's config track; the caller holds m.mu.
+func (m *Manager) track(host string) *configTrack {
+ t := m.cfg[host]
+ if t == nil {
+ t = &configTrack{}
+ m.cfg[host] = t
+ }
+ return t
+}
+
+// Pass is the agent pass of spec §6.1: each managed route's installed build,
+// stamped by root, against the build its current generation serves. routes
+// must be the box's whole list, because Pass also prunes failure records no
+// route installs any more, and lifts the halts those records held.
+func (m *Manager) Pass(routes []registry.Route) {
+ keys := m.observe(routes)
+ m.applyPointers(routes, keys)
+ m.prune(routes)
+ m.liftClearedHalts()
+}
+
+// Nudge re-reads one host and wakes its scheduler. The activator sweep's
+// drift arm calls it (spec §10).
+func (m *Manager) Nudge(host string) {
+ rt, err := m.d.Routes.Get(host)
+ if err != nil || rt == nil {
+ return
+ }
+ m.observe([]registry.Route{*rt})
+}
+
+// Adopted replaces BounceAdopted as the adopt pass's restart callback when
+// the flag is on: the pass has repointed the live symlink, and the scheduler
+// takes the move from here, paced. It reports whether an awake instance will
+// be attempted, so makeRestartRoute does not fall through to systemctl.
+func (m *Manager) Adopted(host string) bool {
+ if _, awake := m.d.Act.Current(host); !awake {
+ return false
+ }
+ m.Nudge(host)
+ return true
+}
+
+func (m *Manager) observe(routes []registry.Route) []appKey {
+ type seen struct {
+ rt registry.Route
+ serving string
+ known bool // false while waking or stopping: the slot is left alone
+ }
+ byApp := map[appKey][]seen{}
+ for _, rt := range routes {
+ if !Managed(rt) {
+ continue
+ }
+ s := seen{rt: rt, known: true}
+ if g, ok := m.d.Act.Current(rt.Host); ok && g.Build != "" {
+ s.serving = g.Build
+ } else if m.d.Act.Owns(rt.Host) {
+ s.known = false
+ }
+ k := appKey{release.RouteAccount(rt), rt.App}
+ byApp[k] = append(byApp[k], s)
+ }
+ now := m.d.Now()
+ keys := make([]appKey, 0, len(byApp))
+ for k, list := range byApp {
+ keys = append(keys, k)
+ policy := m.policies.get(m.ctx, k.acct, k.app)
+ s := m.scheduler(k)
+ retried := map[string]string{}
+ m.mu.Lock()
+ for _, x := range list {
+ m.hostApp[x.rt.Host] = k
+ if b, ok := m.retried[x.rt.Host]; ok {
+ retried[x.rt.Host] = b
+ }
+ }
+ m.mu.Unlock()
+ s.mu.Lock()
+ s.q.concurrency = policy.Concurrency
+ for _, x := range list {
+ if x.known {
+ // Serving state only from a stable entry. The desired build
+ // and its revision are updated either way (spec §6.3): a
+ // build root installs while a hold drains or a wake runs
+ // supersedes the attempt's, so its gates (pinCheck, the
+ // result check) refuse the older build. Admission still
+ // waits for a stable entry (admit's view leaves a waking or
+ // stopping host out).
+ s.q.observe(x.rt.Host, x.serving, x.rt.GoodVersion, now)
+ }
+ cause := activator.CauseRelease
+ if retried[x.rt.Host] == x.rt.Version {
+ cause = activator.CauseRetry
+ }
+ s.q.wantBuild(x.rt.Host, cause, x.rt.Version, now)
+ }
+ s.mu.Unlock()
+ s.poke()
+ }
+ return keys
+}
+
+// routeChannel is the channel a route follows: "" means stable, as
+// release's own (unexported) routeChannel reads it.
+func routeChannel(rt registry.Route) string {
+ if rt.Channel == "" {
+ return "stable"
+ }
+ return rt.Channel
+}
+
+// applyPointers is the retry signal of spec rev 14 §8.4. For each app with a
+// standing failure record, at most every pointerReadEvery, it reads the
+// signed pointer of every channel the app's managed routes on this box
+// follow, and clears a record when a pointer names its build with a newer
+// PromotedAt (`carlos promote --retry`). The pointer the build failed under,
+// read again, clears nothing. A second retry of the same build is the same
+// rule again: the retry's own failure recorded the newer PromotedAt, so only
+// a pointer signed after it clears the record.
+func (m *Manager) applyPointers(routes []registry.Route, keys []appKey) {
+ if m.d.Pointer == nil {
+ return
+ }
+ now := m.d.Now()
+ for _, k := range keys {
+ recs := m.d.Book.ForApp(k.acct, k.app)
+ if len(recs) == 0 {
+ continue
+ }
+ m.mu.Lock()
+ due := now.Sub(m.pointerRead[k]) >= pointerReadEvery
+ if due {
+ m.pointerRead[k] = now
+ }
+ m.mu.Unlock()
+ if !due {
+ continue
+ }
+ channels := map[string]bool{}
+ for _, rt := range routes {
+ if Managed(rt) && release.RouteAccount(rt) == k.acct && rt.App == k.app {
+ channels[routeChannel(rt)] = true
+ }
+ }
+ for ch := range channels {
+ ptr, err := m.d.Pointer(m.ctx, k.acct, k.app, ch)
+ if err != nil {
+ m.d.Logf("rollout: %s/%s channel %s: %v (retry not checked this pass)", k.acct, k.app, ch, err)
+ continue
+ }
+ if ptr == nil {
+ continue
+ }
+ for _, r := range recs {
+ if ptr.Version != r.Build {
+ continue
+ }
+ ok, err := m.d.Book.ClearOnPointer(k.acct, k.app, r.Build, ptr.PromotedAt)
+ if err != nil {
+ m.d.Logf("rollout: %s/%s clear record for %s: %v (cleared in memory)", k.acct, k.app, r.Build, err)
+ }
+ if ok {
+ m.retryBuild(k, r.Build, "")
+ }
+ }
+ }
+ }
+}
+
+// retryBuild follows a cleared record: the next attempt onto build carries
+// cause retry and runs as a fresh canary. host == "" is an app-wide retry,
+// which reopens the halted cohort: its next attempt is a canary whatever
+// wantBuild reports, because unhalt starts the cohort again. A host names
+// `carlos instances retry`, whose attempt is that host's own canary;
+// RetryHost has already bound the host's retry flag, in the step that
+// cleared the book, and the flag is not set again here, where a forced
+// attempt may already have started and spent it.
+func (m *Manager) retryBuild(k appKey, build, host string) {
+ s := m.scheduler(k)
+ now := m.d.Now()
+ s.mu.Lock()
+ var hosts []string
+ for name, h := range s.q.hosts {
+ if h.wantBuild == build && (host == "" || name == host) {
+ hosts = append(hosts, name)
+ s.q.wantBuild(name, activator.CauseRetry, build, now)
+ }
+ }
+ if host == "" {
+ s.q.unhalt(build)
+ }
+ s.mu.Unlock()
+ m.mu.Lock()
+ for _, name := range hosts {
+ m.retried[name] = build
+ }
+ m.mu.Unlock()
+ m.d.Logf("rollout: %s/%s build %s retried (%v)", k.acct, k.app, build, hosts)
+ s.poke()
+}
+
+// RetryHost is the box half of `carlos instances retry <host>` (spec §8.4):
+// the record holding host's installed build is cleared for host alone, and
+// its next attempt is a canary. How the request reaches the box is plan 6's.
+//
+// The book entry is cleared and the host's retry flag bound in one hold of
+// the scheduler's lock (scheduler.mu before FailureBook.mu), the book first.
+// Admission cannot run between the two, so the adoption onto the cleared
+// build is the attempt the flag lets through, as a canary, and a restart or
+// config attempt cannot start in between and spend the flag.
+func (m *Manager) RetryHost(host string) bool {
+ m.mu.Lock()
+ k, ok := m.hostApp[host]
+ m.mu.Unlock()
+ if !ok {
+ return false
+ }
+ s := m.scheduler(k)
+ s.mu.Lock()
+ build := ""
+ if h := s.q.hosts[host]; h != nil {
+ build = h.wantBuild
+ }
+ var (
+ cleared bool
+ err error
+ )
+ if build != "" {
+ cleared, err = m.d.Book.RetryHost(k.acct, k.app, build, host)
+ }
+ if cleared {
+ s.q.wantBuild(host, activator.CauseRetry, build, m.d.Now())
+ s.q.retryHost(host)
+ }
+ s.mu.Unlock()
+ if err != nil {
+ m.d.Logf("rollout: retry %s on %s: %v (cleared in memory)", build, host, err)
+ }
+ if cleared {
+ m.retryBuild(k, build, host)
+ }
+ return cleared
+}
+
+// failedUnder is the PromotedAt a failure record keeps (spec rev 14 §8.4):
+// the pointer that named the build when it failed. If that pointer cannot be
+// read, or already names another build, it is zero, and the failure's own
+// time stands in when the record is written: only a pointer signed after the
+// failure can then clear the record, which is the safe direction.
+func (m *Manager) failedUnder(rt registry.Route, build string) time.Time {
+ if m.d.Pointer != nil {
+ ptr, err := m.d.Pointer(m.ctx, release.RouteAccount(rt), rt.App, routeChannel(rt))
+ if err == nil && ptr != nil && ptr.Version == build {
+ return ptr.PromotedAt
+ }
+ }
+ return time.Time{}
+}
+
+// prune drops failure records for builds no managed route installs. A
+// held-down instance's route still names its failed build, so its record
+// stays. An empty route list is never authority to drop anything:
+// registry.Open creates an empty database on a wrong path. The installed
+// callback runs under the book's lock and reads only this map.
+func (m *Manager) prune(routes []registry.Route) {
+ if len(routes) == 0 {
+ return
+ }
+ installed := map[[3]string]bool{}
+ for _, rt := range routes {
+ if Managed(rt) && rt.Version != "" {
+ installed[[3]string{release.RouteAccount(rt), rt.App, rt.Version}] = true
+ }
+ }
+ if err := m.d.Book.Prune(func(acct, app, build string) bool { return installed[[3]string{acct, app, build}] }); err != nil {
+ m.d.Logf("rollout: prune failure records: %v", err)
+ }
+}
+
+// liftClearedHalts reopens every halted adoption cohort whose failure record
+// no longer stands (spec §8.2, §8.4: a newer build, a retry or an install by
+// hand lifts the hold). A halt lives in memory beside its record, so a record
+// pruned after an install by hand would otherwise leave the build refused for
+// good if it were installed again. failCohort writes the record and the halt
+// under the same scheduler lock this reads them under.
+func (m *Manager) liftClearedHalts() {
+ m.mu.Lock()
+ scheds := make([]*scheduler, 0, len(m.apps))
+ for _, s := range m.apps {
+ scheds = append(scheds, s)
+ }
+ m.mu.Unlock()
+ for _, s := range scheds {
+ var lifted []string
+ s.mu.Lock()
+ for key, c := range s.q.cohorts {
+ build, adopt := strings.CutPrefix(key, "adopt:")
+ if !adopt || !c.halted {
+ continue
+ }
+ if _, held := m.d.Book.Lookup(s.key.acct, s.key.app, build); !held {
+ s.q.unhalt(build)
+ lifted = append(lifted, build)
+ }
+ }
+ s.mu.Unlock()
+ if len(lifted) > 0 {
+ m.d.Logf("rollout: %s/%s: halt lifted for %v, their failure records are gone", s.key.acct, s.key.app, lifted)
+ s.poke()
+ }
+ }
+}
+
+// Bootstrap is spec §8.5's first start: every awake instance records the
+// build it serves as good. Nothing is inferred from Version or PrevVersion.
+func (m *Manager) Bootstrap(routes []registry.Route) {
+ for _, rt := range routes {
+ if !Managed(rt) || rt.GoodVersion != "" {
+ continue
+ }
+ if g, ok := m.d.Act.Current(rt.Host); ok && g.Build != "" {
+ m.writeGood(rt.Host, g)
+ }
+ }
+}
+
+// Restart is a `carlos restart` request for host (spec §10). id is the
+// request's identity: the restart generation's etag, or a narrowed request's
+// id. A sleeping instance is never woken. One that is waking, stopping or
+// inside its first-wake observation keeps the request, which runs once it
+// is stable (spec §6.2).
+func (m *Manager) Restart(host, id string) bool {
+ s := m.schedulerOf(host)
+ if s == nil {
+ return false
+ }
+ if !m.d.Act.Owns(host) {
+ return false
+ }
+ s.mu.Lock()
+ changed := s.q.wantRestart(host, id, m.d.Now())
+ s.mu.Unlock()
+ if changed {
+ s.poke()
+ }
+ return changed
+}
+
+// ConfigDelivered is config sync's report that host's config file now holds
+// revision rev (spec §8.1). An instance with a process (awake, waking or
+// stopping) gets a pending config attempt; if its wake turns out to have read
+// rev, the wake's activation clears the attempt before it runs. A sleeping
+// one needs none, because its next wake starts with the file: rev counts as
+// what that wake will run, and config sync's ack goes out at once with
+// today's hibernating acknowledgement (configTrack.deferAck). A host this
+// manager does not manage is not tracked, so its ack goes out as before.
+func (m *Manager) ConfigDelivered(host, rev string) {
+ s := m.schedulerOf(host)
+ if s == nil {
+ return
+ }
+ // Asked before m.mu: the activator is never called under the manager's
+ // lock (the lock order on Manager).
+ awake := m.d.Act.Owns(host)
+ m.mu.Lock()
+ t := m.track(host)
+ t.delivered, t.fails, t.lastErr = rev, 0, ""
+ t.sleeping = !awake
+ if !awake {
+ t.active = rev
+ }
+ m.mu.Unlock()
+ if !awake {
+ return
+ }
+ s.mu.Lock()
+ changed := s.q.wantConfig(host, rev, m.d.Now())
+ s.mu.Unlock()
+ if changed {
+ s.poke()
+ }
+}
+
+// DeferAck wraps config sync's ack writer: a revision is acknowledged only
+// once it is active (spec §8.1).
+func (m *Manager) DeferAck(write func(appconfig.ConfigAck)) func(appconfig.ConfigAck) {
+ return func(a appconfig.ConfigAck) {
+ m.mu.Lock()
+ now := m.track(a.Host).deferAck(a, write)
+ m.mu.Unlock()
+ if now != nil {
+ now()
+ }
+ }
+}
+
+// resolve turns a dispatch into the Attempt Swap runs: its artifact and mode.
+// Recovery onto the serving build uses the generation's captured identity,
+// and a move back onto the good build uses the retained one; neither is
+// checked against the canonical manifest (spec §4.2). An adoption checks the
+// staged file exists and then the canonical demand. A missing staged file,
+// or a staged file that is not the release's canonical artifact
+// (release.ErrStagedDigestMismatch), is a staging refusal, never a build
+// failure. Any other trouble reading the demand runs the attempt in hold:
+// missing or unverifiable policy data never permits an overlap.
+func (m *Manager) resolve(ctx context.Context, d dispatch, rt registry.Route) (activator.Attempt, error) {
+ acct, app := release.RouteAccount(rt), rt.App
+ policy := m.policies.get(ctx, acct, app)
+ a := activator.Attempt{Host: d.ID.Host, Cause: d.ID.Cause, Build: d.ID.Build, ConfigRev: d.ID.ConfigRev,
+ RestartID: d.ID.RestartID, SchedRev: d.SchedRev, Canary: d.Canary}
+ if g, ok := m.d.Act.Current(rt.Host); ok && g.Build == d.ID.Build {
+ a.StagedPath, a.Digest, a.Demand = g.StagedPath, g.Digest, g.Demand
+ a.Mode = RecoveryMode(policy, g.Demand)
+ return a, nil
+ }
+ path := release.VersionPath(m.d.OptDir, acct, d.ID.Build, app)
+ if d.ID.Build == rt.GoodVersion && rt.GoodDigest != "" {
+ a.StagedPath, a.Digest, a.Demand = path, rt.GoodDigest, rt.GoodDemand
+ a.Mode = RecoveryMode(policy, rt.GoodDemand)
+ return a, nil
+ }
+ if _, err := os.Stat(path); err != nil {
+ return a, err
+ }
+ demand, digest, err := m.d.Demand(ctx, acct, app, d.ID.Build, path)
+ if errors.Is(err, release.ErrStagedDigestMismatch) {
+ return a, err
+ }
+ if err != nil {
+ // The canonical manifest could not be read or compared: hold, on
+ // the bytes hashed below.
+ m.d.Logf("rollout: %s: canonical demand of %s: %v (attempt runs in hold)", rt.Host, d.ID.Build, err)
+ demand, digest = release.DemandHold, ""
+ }
+ if digest == "" {
+ // Nothing compared the staged bytes with the manifest (plan 4:
+ // DemandHold with no digest), so the demand read describes nothing
+ // verified: hold. The attempt still runs only on the bytes hashed
+ // here, because plan 3's verifyStaged refuses an empty digest.
+ demand = release.DemandHold
+ if digest, err = release.FileSHA256(path); err != nil {
+ return a, err
+ }
+ }
+ a.StagedPath, a.Digest, a.Demand = path, digest, demand
+ a.Mode = AttemptMode(policy, demand)
+ return a, nil
+}
+
+// served records a generation that became current after its checks: the
+// serving build, and the good build (spec §8.5). Both only while g is the
+// generation the activator serves (acknowledged).
+func (m *Manager) served(host string, g activator.Gen) {
+ if g.Build == "" {
+ return
+ }
+ m.goodMu.Lock()
+ defer m.goodMu.Unlock()
+ if !m.acknowledged(host, g) {
+ m.d.Logf("rollout: %s: generation %d (%s) is no longer served; not recording it", host, g.N, g.Build)
+ return
+ }
+ m.setServingLocked(host, g)
+ m.writeGoodLocked(host, g)
+}
+
+// setServingLocked writes an instance's serving build. A worker's is the
+// activator's alone (Task 11, publishWorkerServing, ordered by incarnation),
+// so two writers never race on one row. The caller holds m.goodMu.
+func (m *Manager) setServingLocked(host string, g activator.Gen) {
+ if g.Worker {
+ return
+ }
+ if err := m.d.Routes.SetServingVersion(host, g.Build); err != nil {
+ m.d.Logf("rollout: %s: record serving build %s: %v", host, g.Build, err)
+ }
+}
+
+// publishServing records the serving build alone (a healthy first wake, a
+// fallback), on the same terms as served.
+func (m *Manager) publishServing(host string, g activator.Gen) {
+ if g.Build == "" {
+ return
+ }
+ m.goodMu.Lock()
+ defer m.goodMu.Unlock()
+ if !m.acknowledged(host, g) {
+ return
+ }
+ m.setServingLocked(host, g)
+}
+
+// acknowledged reports whether g is the generation the activator serves
+// for host right now (spec §8.5: the good build is written expecting the
+// current one). A late completion or event of an older generation is not,
+// so it can never overwrite what a newer one wrote. Generation numbers
+// decide when both sides have one; a worker's (no number) is compared by
+// its staged artifact.
+func (m *Manager) acknowledged(host string, g activator.Gen) bool {
+ cur, ok := m.d.Act.Current(host)
+ if !ok {
+ return false
+ }
+ if cur.N != 0 && g.N != 0 {
+ return cur.N == g.N
+ }
+ return cur.StagedPath == g.StagedPath && cur.Digest == g.Digest
+}
+
+// writeGood is writeGoodLocked for a caller outside served: a settled first
+// wake, a healthy planned wake. Same terms: g must be acknowledged.
+func (m *Manager) writeGood(host string, g activator.Gen) {
+ m.goodMu.Lock()
+ defer m.goodMu.Unlock()
+ if !m.acknowledged(host, g) {
+ return
+ }
+ m.writeGoodLocked(host, g)
+}
+
+// writeGoodLocked replaces host's good build with g's identity,
+// conditionally on the row still holding what was just read. goodMu
+// serialises every writer and each has just proven g current, so a retry
+// only covers a write from outside this manager; a second loss is logged
+// and the next serve tries again. The caller holds m.goodMu.
+func (m *Manager) writeGoodLocked(host string, g activator.Gen) {
+ if g.Build == "" || g.Digest == "" {
+ return
+ }
+ next := registry.GoodBuild{Version: g.Build, Digest: g.Digest, Demand: g.Demand}
+ for range 2 {
+ cur, err := m.d.Routes.Get(host)
+ if err != nil || cur == nil {
+ return
+ }
+ if cur.Good() == next {
+ m.observeGood(host, next.Version)
+ return
+ }
+ err = m.d.Routes.SetGoodBuild(host, cur.Good(), next)
+ if err == nil {
+ m.observeGood(host, next.Version)
+ return
+ }
+ if !errors.Is(err, registry.ErrGoodBuildChanged) {
+ m.d.Logf("rollout: %s: record good build %s: %v", host, g.Build, err)
+ return
+ }
+ }
+ m.d.Logf("rollout: %s: good build moved twice under this write, leaving it", host)
+}
+
+func (m *Manager) observeGood(host, ver string) {
+ s := m.schedulerOf(host)
+ if s == nil {
+ return
+ }
+ s.mu.Lock()
+ if h := s.q.hosts[host]; h != nil {
+ h.good = ver
+ }
+ s.mu.Unlock()
+}
+
+func (m *Manager) configActivated(host, rev string) {
+ if rev == "" {
+ return
+ }
+ m.mu.Lock()
+ write, _ := m.track(host).activate(rev)
+ m.mu.Unlock()
+ if s := m.schedulerOf(host); s != nil {
+ s.mu.Lock()
+ s.q.configActive(host, rev)
+ s.mu.Unlock()
+ }
+ if write != nil {
+ write()
+ }
+}
+
+// configSlept is configActivated for an instance seen asleep with rev
+// pending (spec §8.1): its next wake starts with the delivered file, so rev
+// is what that wake runs and no config attempt is left to make. A held ack
+// goes out as a sleeper's does at delivery, marked Hibernating. rev was read
+// before the activator reported the instance asleep, so the file already
+// held it when the instance stopped: this can only under-claim.
+func (m *Manager) configSlept(host, rev string) {
+ m.mu.Lock()
+ t := m.track(host)
+ if t.ack != nil && t.ackRev == rev {
+ t.ack.Hibernating, t.ack.Restarted = true, false
+ }
+ write, _ := t.activate(rev)
+ m.mu.Unlock()
+ if s := m.schedulerOf(host); s != nil {
+ s.mu.Lock()
+ s.q.configActive(host, rev)
+ s.mu.Unlock()
+ }
+ if write != nil {
+ write()
+ }
+}
+
+// configFailure counts a failed attempt that carried config revision rev
+// and returns its backoff. Only a failure of the delivered revision counts:
+// one of an older revision says nothing about the newer one's attempts. The
+// caller sets the queue's backoff (appQueue.configFailed) in the step that
+// frees the attempt's slot.
+func (m *Manager) configFailure(host, rev, reason string) (time.Duration, bool) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ t := m.track(host)
+ if rev == "" || rev != t.delivered {
+ return 0, false
+ }
+ t.fails++
+ t.lastErr = reason
+ return configBackoff(t.fails), true
+}
+
+// cohortFailed runs the cohort-failure transition for s's app.
+func (m *Manager) cohortFailed(s *scheduler, rt registry.Route, rec FailureRecord) {
+ s.mu.Lock()
+ added, superseded, err := failCohort(m.d.Book, s.q, rec)
+ s.mu.Unlock()
+ if err != nil {
+ m.d.Logf("rollout: write failure record %s/%s %s: %v (held in memory)", rec.Account, rec.App, rec.Build, err)
+ }
+ if len(superseded) > 0 {
+ m.d.Logf("rollout: %s/%s build %s failed: superseding attempts past spawn on %v", rec.Account, rec.App, rec.Build, superseded)
+ }
+ if added {
+ m.d.Logf("rollout: %s/%s build %s failed on %s: %s", rec.Account, rec.App, rec.Build, rec.Host, rec.Reason)
+ m.emit(rt, EvCohortHalted, "warn", textCohortHalted(rec.Build, m.d.Box, reasonWord(rec.Reason)))
+ }
+ s.poke()
+}
+
+func (m *Manager) stagingRefused(rt registry.Route, build string, err error) {
+ m.d.Logf("rollout: %s: build %s refused as a staging error: %v", rt.Host, build, err)
+ m.emit(rt, EvStagingRefused, "warn", textStagingRefused(build))
+ if m.d.Touch != nil {
+ m.d.Touch()
+ }
+}
+
+// emit logs an event and publishes it to the member's log. text is built
+// from build names and reason words only; RedactPaths stays as a second
+// guard against a box path in a build name.
+func (m *Manager) emit(rt registry.Route, code EventCode, level, text string) {
+ m.d.Logf("rollout: %s %s: %s", code, rt.Host, text)
+ if m.d.Events != nil {
+ m.d.Events.Event(release.RouteAccount(rt), rt.App, rt.Host, level, activator.RedactPaths(text))
+ }
+}
diff --git a/internal/rollout/manager_test.go b/internal/rollout/manager_test.go
new file mode 100644
index 000000000..6d4c4a166
--- /dev/null
+++ b/internal/rollout/manager_test.go
@@ -0,0 +1,882 @@
+package rollout
+
+import (
+ "errors"
+ "sync"
+ "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 TestAReleaseRollsOutCanaryFirstThenPaced(t *testing.T) {
+ h := newHarness(t, "a", "b", "c")
+ h.stage("v2")
+ h.install("v2", "a", "b", "c")
+ h.pass()
+ waitFor(t, "every host on v2", func() bool { return h.servingAll("v2", "a", "b", "c") })
+ calls, _, maxFlight := h.act.snapshot()
+ if len(calls) != 3 || !calls[0].a.Canary || calls[1].a.Canary {
+ t.Fatalf("calls = %+v, want the canary first and then two plain swaps", calls)
+ }
+ if maxFlight > 1 {
+ t.Fatalf("%d swaps ran at once with swap_concurrency 1", maxFlight)
+ }
+ for _, host := range []string{"a", "b", "c"} {
+ if r := h.rows.get(host); r.GoodVersion != "v2" || r.GoodDigest != "sha-v2" || r.ServingVersion != "v2" {
+ t.Fatalf("%s row = %+v, want good and serving v2", host, r)
+ }
+ }
+ if !h.events.has("swapped to build v2") {
+ t.Fatal("no swapped event")
+ }
+}
+
+// Review focus 1, the index's cross-plan item.
+func TestTwoReleasesTwentySecondsApartNeverSwitchToTheFirst(t *testing.T) {
+ h := newHarness(t, "a", "b", "c")
+ h.stage("v2", "v3")
+ gate := h.act.hold("a") // the canary onto v2 sits in its settle
+ h.install("v2", "a", "b", "c")
+ h.pass()
+ waitFor(t, "the v2 canary to start", func() bool { _, started, _ := h.act.snapshot(); return len(started) == 1 })
+ h.advance(20 * time.Second)
+ h.install("v3", "a", "b", "c")
+ h.pass()
+ close(gate)
+ waitFor(t, "every host on v3", func() bool { return h.servingAll("v3", "a", "b", "c") })
+ calls, _, maxFlight := h.act.snapshot()
+ for _, c := range calls {
+ if c.a.Build == "v2" && c.outcome == activator.SwapDone {
+ t.Fatalf("switched %s to the superseded v2", c.a.Host)
+ }
+ }
+ if calls[0].a.Build != "v2" || calls[0].outcome != activator.SwapAbandoned {
+ t.Fatalf("first call = %+v, want the v2 canary abandoned at its re-check", calls[0])
+ }
+ if len(h.book.List()) != 0 {
+ t.Fatal("an abandoned attempt is not a build failure")
+ }
+ if maxFlight > 1 {
+ t.Fatal("swap_concurrency 1 exceeded")
+ }
+}
+
+func TestAFailedCanaryHaltsTheCohort(t *testing.T) {
+ h := newHarness(t, "a", "b", "c")
+ h.stage("v2")
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Build == "v2" {
+ return activator.SwapFailed
+ }
+ return activator.SwapDone
+ }
+ h.install("v2", "a", "b", "c")
+ h.pass()
+ waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
+ h.pass()
+ time.Sleep(50 * time.Millisecond)
+ if calls, _, _ := h.act.snapshot(); len(calls) != 1 {
+ t.Fatalf("%d attempts onto v2, want the halt at the first failure", len(calls))
+ }
+ 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") {
+ t.Fatal("no cohort-halted event")
+ }
+}
+
+// Spec §8.1: the failure transition (the record and the halt) happens in
+// the same step that frees the failed attempt's slot. The hook looks at the
+// queue at every moment done() reads a row after the failure: whenever the
+// failed attempt's slot is free, the record must already be in the book
+// (which wake plans read) and admission must start nothing onto the build.
+// Halting in that step but writing the record after the row read let a
+// wake select the failed build in between.
+func TestAFailureIsRecordedAndHaltedInTheStepThatFreesItsSlot(t *testing.T) {
+ h := newHarness(t, "a", "b")
+ h.stage("v2")
+ var failed atomic.Bool
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Build != "v2" {
+ return activator.SwapDone
+ }
+ failed.Store(true)
+ return activator.SwapFailed
+ }
+ var bad atomic.Int32
+ h.rows.mu.Lock()
+ h.rows.getHook = func(host string) {
+ if !failed.Load() {
+ return
+ }
+ s := h.m.schedulerOf(host)
+ s.mu.Lock()
+ hs := s.q.hosts[host]
+ free := hs != nil && hs.running == nil
+ var ds []dispatch
+ if free {
+ ds = s.q.admit(currentView(s.q), h.clock())
+ }
+ s.mu.Unlock()
+ if !free {
+ return
+ }
+ if _, ok := h.book.Lookup("bab", "blog", "v2"); !ok {
+ bad.Add(1)
+ }
+ for _, d := range ds {
+ if d.ID.Build == "v2" {
+ bad.Add(1)
+ }
+ }
+ }
+ h.rows.mu.Unlock()
+ h.install("v2", "a", "b")
+ h.pass()
+ waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
+ h.pass() // reads the rows again, with the slot free
+ if n := bad.Load(); n != 0 {
+ t.Fatalf("%d moment(s) with the slot free and the record missing or a v2 attempt admissible", n)
+ }
+}
+
+// Spec §8.5: a successful attempt keeps its host's slot until its state
+// (serving and good build) is published, so no newer attempt can be
+// admitted and complete first and then be overwritten by this one. Freeing
+// the slot before the write let that happen.
+func TestASuccessKeepsItsSlotUntilItIsPublished(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ var freed atomic.Int32
+ h.rows.mu.Lock()
+ h.rows.setGoodHook = func(host string) {
+ s := h.m.schedulerOf(host)
+ s.mu.Lock()
+ if hs := s.q.hosts[host]; hs != nil && hs.running == nil {
+ freed.Add(1)
+ }
+ s.mu.Unlock()
+ }
+ h.rows.mu.Unlock()
+ h.install("v2", "a")
+ h.pass()
+ waitFor(t, "the good build moved to v2", func() bool { return h.rows.get("a").GoodVersion == "v2" })
+ if n := freed.Load(); n != 0 {
+ t.Fatalf("%d good-build write(s) made after the attempt's slot was freed", n)
+ }
+}
+
+// A failure is published with no blocking read between the failure and the
+// record (spec §8.1): the pointer the build failed under is read before the
+// attempt runs. Reading it in done(), before the record, left every sibling's
+// wake free to plan the failed build for as long as that read took.
+func TestNoPointerReadSitsBetweenAFailureAndItsRecord(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ var failed, late atomic.Bool
+ h.act.outcome = func(activator.Attempt) activator.SwapOutcome {
+ failed.Store(true)
+ return activator.SwapFailed
+ }
+ h.mu.Lock()
+ h.pointerHook = func() {
+ if failed.Load() {
+ if _, ok := h.book.Lookup("bab", "blog", "v2"); !ok {
+ late.Store(true)
+ }
+ }
+ }
+ h.mu.Unlock()
+ h.install("v2", "a")
+ h.pass()
+ waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
+ if late.Load() {
+ t.Fatal("a pointer read ran after the failure and before its record was published")
+ }
+}
+
+// Spec §6.3: a build installed while the entry is unstable (a hold
+// draining, a wake running) still supersedes the attempt in flight, so its
+// gates refuse the older build. Skipping wantBuild for such a host left the
+// revision on the older build, and a hold's pinned wake exec'd it. Nothing
+// is admitted onto the host until it is stable again.
+func TestAnInstallDuringAnUnstableEntryStillSupersedes(t *testing.T) {
+ h := newHarness(t, "a")
+ h.pass()
+ s := h.m.schedulerOf("a")
+ h.act.mu.Lock()
+ delete(h.act.gens, "a")
+ h.act.owned["a"] = true // stopping or waking: a process, no current generation
+ h.act.mu.Unlock()
+ s.mu.Lock()
+ before := s.q.newest("a")
+ s.mu.Unlock()
+ h.stage("v2")
+ h.install("v2", "a")
+ h.pass()
+ s.mu.Lock()
+ after := s.q.newest("a")
+ s.mu.Unlock()
+ if after == before {
+ t.Fatal("an install during an unstable entry left the attempt revision unchanged")
+ }
+ time.Sleep(50 * time.Millisecond)
+ if calls, _, _ := h.act.snapshot(); len(calls) != 0 {
+ t.Fatalf("attempts %+v admitted onto an unstable entry", calls)
+ }
+}
+
+// A row that cannot be read at the failure still gets its record: the
+// failure is the build's, not the row's (spec §8.1). Skipping the write on
+// a failed registry read left the failed build selectable by every wake.
+func TestAFailureIsRecordedWhenItsRowIsUnreadable(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ var failed atomic.Bool
+ h.act.outcome = func(activator.Attempt) activator.SwapOutcome {
+ failed.Store(true)
+ return activator.SwapFailed
+ }
+ h.rows.mu.Lock()
+ h.rows.getErr = func(string) error {
+ if failed.Load() {
+ return errors.New("registry: database is locked")
+ }
+ return nil
+ }
+ h.rows.mu.Unlock()
+ h.install("v2", "a")
+ h.pass()
+ waitFor(t, "a failure record despite the unreadable row", func() bool {
+ _, ok := h.book.Lookup("bab", "blog", "v2")
+ return ok
+ })
+}
+
+// Review focus 4.
+func TestStagingRefusalWritesNoFailureRecord(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ h.mismatch["v2"] = true
+ h.install("v2", "a")
+ h.pass()
+ waitFor(t, "the stage relay touch", func() bool { return h.touched.Load() > 0 })
+ if len(h.book.List()) != 0 {
+ t.Fatal("a staging error wrote a failure record against the build")
+ }
+ if calls, _, _ := h.act.snapshot(); len(calls) != 0 {
+ t.Fatal("a refused attempt reached Swap")
+ }
+ if g, _ := h.act.Current("a"); g.Build != "v1" {
+ t.Fatal("the instance left its serving build")
+ }
+ if !h.events.has("not staged correctly") {
+ t.Fatal("no staging event")
+ }
+}
+
+func TestAnUnstagedBuildIsAStagingRefusal(t *testing.T) {
+ h := newHarness(t, "a")
+ h.install("v9", "a") // never staged on this box
+ h.pass()
+ waitFor(t, "the stage relay touch", func() bool { return h.touched.Load() > 0 })
+ if len(h.book.List()) != 0 {
+ t.Fatal("a missing staged file wrote a failure record")
+ }
+}
+
+func TestRestartGoesAheadForAnInstanceServingAFailedBuild(t *testing.T) {
+ h := newHarness(t, "d")
+ h.stage("v2")
+ h.act.serve("d", "v2")
+ r := h.rows.get("d")
+ r.Version, r.GoodVersion, r.GoodDigest = "v2", "v2", "sha-v2"
+ h.rows.put(r)
+ h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "e", At: t0})
+ h.pass()
+ if !h.m.Restart("d", "gen:etag-1") {
+ t.Fatal("the restart was not queued")
+ }
+ waitFor(t, "the restart", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 })
+ calls, _, _ := h.act.snapshot()
+ if c := calls[0].a; c.Cause != activator.CauseRestart || c.Build != "v2" || c.RestartID != "gen:etag-1" {
+ t.Fatalf("attempt = %+v", c)
+ }
+}
+
+func TestRecoveryUsesTheRetainedIdentityAndTheCurrentPolicy(t *testing.T) {
+ h := newHarness(t, "a")
+ h.swap = "hold" // switched to hold after v1 passed under overlap
+ h.pass()
+ h.m.Restart("a", "gen:etag-1")
+ waitFor(t, "the restart", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 })
+ calls, _, _ := h.act.snapshot()
+ g, _ := h.act.Current("a")
+ if c := calls[0].a; c.Mode != activator.ModeHold || c.StagedPath != "/opt/v1" || c.Digest != "sha-v1" {
+ t.Fatalf("attempt = %+v, want hold, by the generation's captured path and digest (current gen %+v)", c, g)
+ }
+}
+
+func TestAReleaseOntoTheServingBuildMakesNoSwap(t *testing.T) {
+ h := newHarness(t, "a")
+ h.pass()
+ time.Sleep(50 * time.Millisecond)
+ if calls, _, _ := h.act.snapshot(); len(calls) != 0 {
+ t.Fatalf("calls = %+v", calls)
+ }
+}
+
+// Review focus 5.
+func TestConfigDeliveredMidSwapIsAckedOnlyWhenActive(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ gate := h.act.hold("a")
+ h.install("v2", "a")
+ h.pass()
+ waitFor(t, "the release swap to start", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 })
+ // The ack is written from the scheduler's goroutine: counted atomically.
+ var acks atomic.Int32
+ ack := h.m.DeferAck(func(appconfig.ConfigAck) { acks.Add(1) })
+ h.m.ConfigDelivered("a", "sha256:c1")
+ ack(appconfig.ConfigAck{Host: "a"})
+ if acks.Load() != 0 {
+ t.Fatal("acknowledged a config no generation has started with")
+ }
+ close(gate)
+ waitFor(t, "the config attempt", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 2 })
+ calls, _, _ := h.act.snapshot()
+ if c := calls[1].a; c.Cause != activator.CauseConfig || c.ConfigRev != "sha256:c1" || c.Build != "v2" {
+ t.Fatalf("second attempt = %+v, want a config attempt onto v2", c)
+ }
+ waitFor(t, "the ack", func() bool { return acks.Load() == 1 })
+}
+
+// Spec §8.1: a successful config attempt's activation is published before
+// its slot frees, and with no registry read in front of it. Published after
+// the slot and the read, a newer config attempt could complete first and be
+// overwritten (active went back to the older revision), and an unreadable
+// row lost the activation, leaving delivered and active apart for good.
+func TestConfigActivationIsPublishedBeforeTheSlotFrees(t *testing.T) {
+ h := newHarness(t, "a")
+ h.pass()
+ var acks, freed atomic.Int32
+ ack := h.m.DeferAck(func(appconfig.ConfigAck) {
+ acks.Add(1)
+ s := h.m.schedulerOf("a")
+ s.mu.Lock()
+ if hs := s.q.hosts["a"]; hs != nil && hs.running == nil {
+ freed.Add(1)
+ }
+ s.mu.Unlock()
+ })
+ var swapped atomic.Bool
+ h.act.outcome = func(activator.Attempt) activator.SwapOutcome {
+ swapped.Store(true)
+ return activator.SwapDone
+ }
+ h.rows.mu.Lock()
+ h.rows.getErr = func(string) error {
+ if swapped.Load() {
+ return errors.New("registry: database is locked")
+ }
+ return nil
+ }
+ h.rows.mu.Unlock()
+ // The config attempt waits at the gate until the ack is held, so the ack
+ // is released by the activation and not written at once by a config sync
+ // call that came after it.
+ gate := h.act.hold("a")
+ h.m.ConfigDelivered("a", "sha256:c1")
+ ack(appconfig.ConfigAck{Host: "a"})
+ if acks.Load() != 0 {
+ t.Fatal("acknowledged a config no generation has started with")
+ }
+ close(gate)
+ waitFor(t, "the config activated and acknowledged", func() bool { return acks.Load() == 1 })
+ if n := freed.Load(); n != 0 {
+ t.Fatal("the activation was published after the attempt's slot was freed")
+ }
+}
+
+func TestAFailedConfigAttemptRetriesWithBackoff(t *testing.T) {
+ h := newHarness(t, "a")
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Cause == activator.CauseConfig {
+ return activator.SwapFailed
+ }
+ return activator.SwapDone
+ }
+ 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") })
+ h.advance(59 * time.Second)
+ h.pass()
+ time.Sleep(50 * time.Millisecond)
+ if calls, _, _ := h.act.snapshot(); len(calls) != 1 {
+ t.Fatal("retried inside the one-minute backoff")
+ }
+ h.advance(2 * time.Second)
+ h.pass()
+ waitFor(t, "the retry after the backoff", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 2 })
+ if len(h.book.List()) != 0 {
+ t.Fatal("a failed config attempt wrote a failure record")
+ }
+}
+
+func TestBootstrapRecordsAwakeInstancesOnly(t *testing.T) {
+ h := newHarness(t, "awake", "asleep")
+ for _, host := range []string{"awake", "asleep"} {
+ r := h.rows.get(host)
+ r.GoodVersion, r.GoodDigest = "", ""
+ h.rows.put(r)
+ }
+ h.act.mu.Lock()
+ delete(h.act.gens, "asleep")
+ h.act.mu.Unlock()
+ rs, _ := h.rows.List()
+ h.m.Bootstrap(rs)
+ if r := h.rows.get("awake"); r.Good() != (registry.GoodBuild{Version: "v1", Digest: "sha-v1"}) {
+ t.Fatalf("awake row good = %+v", r.Good())
+ }
+ if r := h.rows.get("asleep"); r.GoodVersion != "" {
+ t.Fatal("nothing may be inferred for a sleeping instance (spec §8.5)")
+ }
+}
+
+// `carlos promote --retry` re-signs the channel pointer with a fresh
+// PromotedAt. The record keeps the PromotedAt it failed under, survives that
+// same pointer being read again, and clears on the re-signed one.
+func TestAReSignedPointerClearsTheRecord(t *testing.T) {
+ h := newHarness(t, "a", "b")
+ h.stage("v2")
+ h.failing.Store(true)
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Build == "v2" && h.failing.Load() {
+ return activator.SwapFailed
+ }
+ return activator.SwapDone
+ }
+ h.pointer("stable", "v2", promotedAt)
+ h.install("v2", "a", "b")
+ h.pass()
+ waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
+ if r, _ := h.book.Lookup("bab", "blog", "v2"); !r.PromotedAt.Equal(promotedAt) {
+ t.Fatalf("record keeps PromotedAt %v, want the pointer's %v", r.PromotedAt, promotedAt)
+ }
+ h.advance(pointerReadEvery)
+ h.pass()
+ if _, ok := h.book.Lookup("bab", "blog", "v2"); !ok {
+ t.Fatal("the pointer the build failed under cleared its record")
+ }
+ h.failing.Store(false)
+ h.pointer("stable", "v2", promotedAt.Add(time.Minute))
+ h.advance(pointerReadEvery)
+ h.pass()
+ waitFor(t, "every host on v2", func() bool { return h.servingAll("v2", "a", "b") })
+ calls, _, _ := h.act.snapshot()
+ if c := calls[1].a; c.Cause != activator.CauseRetry || !c.Canary {
+ t.Fatalf("first attempt after the retry = %+v, want a retry canary", c)
+ }
+}
+
+// `carlos instances retry <host>`: that host alone, as a canary.
+func TestRetryHostClearsOneInstance(t *testing.T) {
+ h := newHarness(t, "a", "b")
+ h.stage("v2")
+ h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "c", PromotedAt: promotedAt, At: t0})
+ h.install("v2", "a", "b")
+ h.pass()
+ if !h.m.RetryHost("b") {
+ t.Fatal("RetryHost did not clear b")
+ }
+ waitFor(t, "b on v2", func() bool { return h.servingAll("v2", "b") })
+ if h.servingAll("v2", "a") {
+ t.Fatal("a host retry moved another host")
+ }
+}
+
+// Carried from Task 3: a canonical manifest that cannot be read, or a store
+// that cannot compare digests, must reach AttemptMode as a hold demand, never
+// as "". Only release.ErrStagedDigestMismatch is a staging refusal.
+func TestAnUnreadableCanonicalManifestRunsInHold(t *testing.T) {
+ for name, set := range map[string]func(h *harness){
+ "demand error": func(h *harness) { h.demandErr["v2"] = errors.New("store: GetObject: connection reset") },
+ "no digest": func(h *harness) { h.noDigest["v2"] = true },
+ } {
+ t.Run(name, func(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ set(h)
+ h.install("v2", "a")
+ h.pass()
+ waitFor(t, "the attempt", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 })
+ want, err := release.FileSHA256(release.VersionPath(h.opt, "bab", "v2", "blog"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ calls, _, _ := h.act.snapshot()
+ if c := calls[0].a; c.Mode != activator.ModeHold || c.Demand != release.DemandHold || c.Digest != want {
+ t.Fatalf("attempt = %+v, want hold on the staged file's own digest %s", c, want)
+ }
+ if h.touched.Load() != 0 || len(h.book.List()) != 0 {
+ t.Fatal("an unreadable manifest is neither a staging refusal nor a build failure")
+ }
+ })
+ }
+}
+
+// Carried from Task 4: a record keeps a real PromotedAt. With no pointer to
+// read, the failure's own time stands in, never a zero time.
+func TestAFailureWithNoReadablePointerKeepsItsOwnTime(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ h.act.outcome = func(activator.Attempt) activator.SwapOutcome { return activator.SwapFailed }
+ h.install("v2", "a")
+ h.pass()
+ waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
+ r, _ := h.book.Lookup("bab", "blog", "v2")
+ if r.PromotedAt.IsZero() || !r.PromotedAt.Equal(r.At) {
+ t.Fatalf("record %+v: want the failure's own time as PromotedAt", r)
+ }
+}
+
+// Carried from Task 5: a second `carlos promote --retry` of the same build,
+// after the retry failed too, clears the record again and the next attempt is
+// a canary again. Nothing may rest on wantBuild reporting a change.
+func TestASecondRetryOfTheSameBuildIsACanaryAgain(t *testing.T) {
+ h := newHarness(t, "a", "b")
+ h.stage("v2")
+ h.failing.Store(true)
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Build == "v2" && h.failing.Load() {
+ return activator.SwapFailed
+ }
+ return activator.SwapDone
+ }
+ h.pointer("stable", "v2", promotedAt)
+ h.install("v2", "a", "b")
+ h.pass()
+ waitFor(t, "the first failure", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 1 })
+ waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
+
+ h.pointer("stable", "v2", promotedAt.Add(time.Minute))
+ h.advance(pointerReadEvery)
+ h.pass()
+ waitFor(t, "the retry to fail too", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 2 })
+ waitFor(t, "the record again", func() bool {
+ r, ok := h.book.Lookup("bab", "blog", "v2")
+ return ok && r.PromotedAt.Equal(promotedAt.Add(time.Minute))
+ })
+
+ h.failing.Store(false)
+ h.pointer("stable", "v2", promotedAt.Add(2*time.Minute))
+ h.advance(pointerReadEvery)
+ h.pass()
+ waitFor(t, "every host on v2", func() bool { return h.servingAll("v2", "a", "b") })
+ calls, _, _ := h.act.snapshot()
+ for i, c := range calls[:3] {
+ if c.a.Host != "a" || !c.a.Canary {
+ t.Fatalf("call %d = %+v, want a canary on a", i, c.a)
+ }
+ }
+ if c := calls[2].a; c.Cause != activator.CauseRetry {
+ t.Fatalf("second retry's attempt = %+v, want cause retry", c)
+ }
+}
+
+// Carried from Task 6 and plan 3 (O7): a hold attempt that fails is held
+// down in the book before its slot frees, so no wake planned in between can
+// boot the failed hold build. The mode is the one Swap ran (a foreign-layout
+// overlap runs as a hold, SwapResult.Mode), and the record's build comes from
+// the dispatch: a failed hold returns no generation.
+func TestAFailedHoldIsHeldDownBeforeItsSlotFrees(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ h.act.mode = activator.ModeHold
+ var failed atomic.Bool
+ h.act.outcome = func(activator.Attempt) activator.SwapOutcome {
+ failed.Store(true)
+ return activator.SwapDown
+ }
+ var bad, seen atomic.Int32
+ h.rows.mu.Lock()
+ h.rows.getHook = func(host string) {
+ if !failed.Load() {
+ return
+ }
+ s := h.m.schedulerOf(host)
+ s.mu.Lock()
+ free := s.q.hosts[host] != nil && s.q.hosts[host].running == nil
+ s.mu.Unlock()
+ if !free {
+ return
+ }
+ seen.Add(1)
+ if !h.book.HeldDown("bab", "blog", "v2", host) {
+ bad.Add(1)
+ }
+ }
+ h.rows.mu.Unlock()
+ 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") })
+ 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())
+ }
+ r, _ := h.book.Lookup("bab", "blog", "v2")
+ if r.Mode != string(activator.ModeHold) || r.Build != "v2" || r.Host != "a" {
+ t.Fatalf("record = %+v, want build v2 held down in the mode Swap ran", r)
+ }
+}
+
+// Carried from Task 4: every Pass prunes, a held-down instance whose route
+// still names the failed build keeps the record, and a record no route
+// installs any more goes.
+func TestPassPrunesOnlyRecordsNoRouteInstalls(t *testing.T) {
+ h := newHarness(t, "a", "b")
+ h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a", HeldDown: []string{"a"}, PromotedAt: promotedAt, At: t0})
+ h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v0", Host: "b", PromotedAt: promotedAt, At: t0})
+ h.install("v2", "a")
+ h.pass()
+ if _, ok := h.book.Lookup("bab", "blog", "v0"); ok {
+ t.Fatal("a record no route installs survived the pass")
+ }
+ if !h.book.HeldDown("bab", "blog", "v2", "a") {
+ t.Fatal("the pass pruned a held-down instance's record while its route still names the build")
+ }
+ h.install("v1", "a") // rolled back by hand
+ h.pass()
+ if len(h.book.List()) != 0 {
+ t.Fatalf("records = %+v after the last route left v2", h.book.List())
+ }
+}
+
+// Carried from Task 6, spec §8.2/§8.4: an install by hand that clears the
+// record lifts the halt with it. A halt is in memory and outlived its record,
+// so installing the build again later was refused for good.
+func TestAnInstallByHandThatClearsTheRecordLiftsTheHalt(t *testing.T) {
+ h := newHarness(t, "a", "b")
+ h.stage("v2")
+ h.failing.Store(true)
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Build == "v2" && h.failing.Load() {
+ return activator.SwapFailed
+ }
+ return activator.SwapDone
+ }
+ h.install("v2", "a", "b")
+ h.pass()
+ waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
+ h.install("v1", "a", "b")
+ h.pass()
+ if len(h.book.List()) != 0 {
+ t.Fatal("the record outlived every route installing the build")
+ }
+ h.failing.Store(false)
+ h.install("v2", "a", "b")
+ h.pass()
+ waitFor(t, "every host on v2", func() bool { return h.servingAll("v2", "a", "b") })
+ calls, _, _ := h.act.snapshot()
+ if c := calls[1].a; !c.Canary {
+ t.Fatalf("first attempt after the hold lifted = %+v, want a fresh canary", c)
+ }
+}
+
+// Carried from Task 6: `carlos instances retry` runs that host as a canary
+// through its cohort's halt, and only that host.
+func TestRetryHostRunsACanaryThroughAHalt(t *testing.T) {
+ h := newHarness(t, "a", "b")
+ h.stage("v2")
+ h.failing.Store(true)
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Build == "v2" && h.failing.Load() {
+ return activator.SwapFailed
+ }
+ return activator.SwapDone
+ }
+ h.install("v2", "a", "b")
+ h.pass()
+ waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
+ h.failing.Store(false)
+ if !h.m.RetryHost("b") {
+ t.Fatal("RetryHost did not clear b")
+ }
+ waitFor(t, "b on v2", func() bool { return h.servingAll("v2", "b") })
+ calls, _, _ := h.act.snapshot()
+ if c := calls[1].a; c.Host != "b" || !c.Canary || c.Cause != activator.CauseRetry {
+ t.Fatalf("attempt after the host retry = %+v, want b's retry canary", c)
+ }
+ h.pass()
+ time.Sleep(50 * time.Millisecond)
+ if h.servingAll("v2", "a") {
+ t.Fatal("a host retry lifted the halt for another host")
+ }
+}
+
+// Carried from Task 6: a failed config attempt backs off even when its row
+// cannot be read afterwards; the backoff is set in the step that frees the
+// slot, so nothing can re-admit the attempt before it. The row is unreadable
+// only for the read that follows the failure: the next attempt's own read
+// succeeds, so only the backoff can keep it from running.
+func TestAFailedConfigAttemptBacksOffWithItsRowUnreadable(t *testing.T) {
+ h := newHarness(t, "a")
+ var failed, refused atomic.Bool
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Cause == activator.CauseConfig {
+ failed.Store(true)
+ return activator.SwapFailed
+ }
+ return activator.SwapDone
+ }
+ h.rows.mu.Lock()
+ h.rows.getErr = func(string) error {
+ if failed.Load() && refused.CompareAndSwap(false, true) {
+ return errors.New("registry: database is locked")
+ }
+ return nil
+ }
+ h.rows.mu.Unlock()
+ h.pass()
+ h.m.ConfigDelivered("a", "sha256:c1")
+ waitFor(t, "the first config attempt", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) >= 1 })
+ h.pass()
+ time.Sleep(50 * time.Millisecond)
+ if calls, _, _ := h.act.snapshot(); len(calls) != 1 {
+ t.Fatalf("%d config attempts with no backoff after an unreadable row", len(calls))
+ }
+}
+
+// Carried from Task 7: activating an older revision than the delivered one
+// leaves the delivered revision's failure count, and so its backoff, alone.
+func TestAnOlderActivationKeepsTheConfigFailureCount(t *testing.T) {
+ h := newHarness(t, "a")
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Cause == activator.CauseConfig {
+ return activator.SwapFailed
+ }
+ return activator.SwapDone
+ }
+ h.pass()
+ h.m.ConfigDelivered("a", "sha256:c2")
+ waitFor(t, "the pending event", func() bool { return h.events.has("config delivered, not active") })
+ h.m.configActivated("a", "sha256:c1")
+ h.m.mu.Lock()
+ fails := h.m.cfg["a"].fails
+ h.m.mu.Unlock()
+ if fails != 1 {
+ t.Fatalf("fails = %d after an older revision activated, want 1", fails)
+ }
+}
+
+// Carried from Task 6, spec §8.1: an instance that goes to sleep with a
+// config attempt pending needs no attempt any more. Its next wake starts with
+// the delivered file, so the revision is active, and the held ack goes out as
+// a sleeper's does, marked Hibernating. After it wakes, no config attempt
+// restarts a generation that already runs the revision.
+func TestASleepersPendingConfigIsNoAttemptAfterItWakes(t *testing.T) {
+ h := newHarness(t, "a")
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Cause == activator.CauseConfig {
+ return activator.SwapFailed
+ }
+ return activator.SwapDone
+ }
+ h.pass()
+ var mu sync.Mutex
+ var acks []appconfig.ConfigAck
+ ack := h.m.DeferAck(func(a appconfig.ConfigAck) {
+ mu.Lock()
+ defer mu.Unlock()
+ acks = append(acks, a)
+ })
+ 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") })
+ h.act.mu.Lock()
+ delete(h.act.gens, "a") // hibernated inside its backoff
+ h.act.mu.Unlock()
+ h.pass()
+ waitFor(t, "the held ack", func() bool { mu.Lock(); defer mu.Unlock(); return len(acks) == 1 })
+ mu.Lock()
+ if !acks[0].Hibernating || acks[0].Restarted {
+ t.Fatalf("ack = %+v, want a sleeper's ack, Hibernating", acks[0])
+ }
+ mu.Unlock()
+ h.act.serve("a", "v1") // woke, with the delivered file
+ h.advance(2 * time.Minute)
+ h.pass()
+ time.Sleep(50 * time.Millisecond)
+ if calls, _, _ := h.act.snapshot(); len(calls) != 1 {
+ t.Fatalf("calls = %+v: a woken sleeper ran its old pending config attempt", calls)
+ }
+}
+
+// Plan 3's deferral (O5): an attempt abandoned while it is still the host's
+// newest (a hold whose stop withdrew, a lost re-check) waits before it runs
+// again, rather than looping stop cycles. One abandoned by a newer desire
+// does not wait (TestTwoReleasesTwentySecondsApartNeverSwitchToTheFirst).
+func TestAnAbandonedAttemptStillNewestWaitsBeforeItRunsAgain(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ h.act.outcome = func(activator.Attempt) activator.SwapOutcome { return activator.SwapAbandoned }
+ h.install("v2", "a")
+ h.pass()
+ waitFor(t, "the first attempt", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) >= 1 })
+ h.pass()
+ time.Sleep(50 * time.Millisecond)
+ if calls, _, _ := h.act.snapshot(); len(calls) != 1 {
+ t.Fatalf("%d attempts: an abandoned attempt ran again at once", len(calls))
+ }
+ h.advance(abandonDeferral + time.Second)
+ h.pass()
+ waitFor(t, "the attempt after the wait", func() bool { calls, _, _ := h.act.snapshot(); return len(calls) == 2 })
+ if len(h.book.List()) != 0 {
+ t.Fatal("an abandoned attempt wrote a failure record")
+ }
+}
+
+// PF5 (plan 3's O8): member-facing events carry the reason word, never the
+// swap's diagnostic text, which can name paths and internals.
+func TestMemberEventsCarryOnlyTheReasonWord(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ h.act.reason = "spawn: fork/exec /opt/carlos/versions/bab/v2/blog: permission denied"
+ 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") })
+ 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") {
+ t.Fatal("the events do not carry the reason word")
+ }
+}
+
+func TestReasonWords(t *testing.T) {
+ for text, want := range map[string]string{
+ "health check failed": reasonHealth,
+ "swap: successor exited during its settle": reasonSettle,
+ "exited within 10s of becoming healthy": reasonExit,
+ "staging: digest mismatch": reasonStaging,
+ "version name reused with different content, ship under a new name": reasonReused,
+ "GET / answered 500": reasonHealth,
+ "": reasonHealth,
+ } {
+ if got := reasonWord(text); got != want {
+ t.Errorf("reasonWord(%q) = %q, want %q", text, got, want)
+ }
+ }
+}
diff --git a/internal/rollout/scheduler.go b/internal/rollout/scheduler.go
new file mode 100644
index 000000000..1ec45b540
--- /dev/null
+++ b/internal/rollout/scheduler.go
@@ -0,0 +1,258 @@
+package rollout
+
+import (
+ "context"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// scheduler is one app's goroutine (spec §6.1). It runs off the agent's
+// tick, so a slow rollout never delays the tick or another app.
+type scheduler struct {
+ m *Manager
+ key appKey
+ mu sync.Mutex
+ q *appQueue
+ kick chan struct{}
+}
+
+func (s *scheduler) poke() {
+ select {
+ case s.kick <- struct{}{}:
+ default:
+ }
+}
+
+// newest is handed to Swap for its re-checks (spec §6.3).
+func (s *scheduler) newest(host string) uint64 {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.q.newest(host)
+}
+
+func (s *scheduler) loop(ctx context.Context) {
+ timer := time.NewTimer(time.Hour)
+ defer timer.Stop()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-s.kick:
+ case <-timer.C:
+ }
+ view, pendingConfig := s.view()
+ for host, rev := range pendingConfig {
+ if serving, seen := view[host]; seen && serving == "" {
+ s.m.configSlept(host, rev)
+ }
+ }
+ now := s.m.d.Now()
+ s.mu.Lock()
+ ds := s.q.admit(view, now)
+ next := s.q.nextDue(now)
+ s.mu.Unlock()
+ for _, d := range ds {
+ go s.execute(ctx, d)
+ }
+ wait := time.Hour
+ if !next.IsZero() {
+ wait = max(next.Sub(now), time.Millisecond)
+ }
+ timer.Reset(wait)
+ }
+}
+
+// view asks the activator, with no lock held, what each queued host serves:
+// a build when awake, "" when asleep, absent while waking or stopping. It
+// also returns each host's pending config revision as it stood BEFORE the
+// activator was asked: a host then reported asleep stopped with that file
+// already delivered, so its next wake runs it (configSlept).
+func (s *scheduler) view() (map[string]string, map[string]string) {
+ s.mu.Lock()
+ hosts := make([]string, 0, len(s.q.hosts))
+ pendingConfig := map[string]string{}
+ for name, h := range s.q.hosts {
+ hosts = append(hosts, name)
+ if h.wantConfig != "" {
+ pendingConfig[name] = h.wantConfig
+ }
+ }
+ s.mu.Unlock()
+ out := make(map[string]string, len(hosts))
+ for _, h := range hosts {
+ if g, ok := s.m.d.Act.Current(h); ok && g.Build != "" {
+ out[h] = g.Build
+ } else if !s.m.d.Act.Owns(h) {
+ out[h] = ""
+ }
+ }
+ return out, pendingConfig
+}
+
+// execute runs one attempt to its outcome.
+func (s *scheduler) execute(ctx context.Context, d dispatch) {
+ m := s.m
+ rt, err := m.d.Routes.Get(d.ID.Host)
+ if err != nil || rt == nil {
+ m.d.Logf("rollout: %s: route not readable for its %s attempt onto %s: %v", d.ID.Host, d.ID.Cause, d.ID.Build, err)
+ s.done(d, activator.SwapResult{Outcome: activator.SwapAbandoned, Reason: "route not readable"}, "")
+ return
+ }
+ a, err := m.resolve(ctx, d, *rt)
+ if err != nil {
+ m.stagingRefused(*rt, d.ID.Build, err)
+ s.mu.Lock()
+ s.q.deferHost(d.ID.Host, m.d.Now().Add(stagingDeferral))
+ s.mu.Unlock()
+ s.done(d, activator.SwapResult{Outcome: activator.SwapAbandoned, Reason: err.Error()}, "")
+ return
+ }
+ // The failing pointer's PromotedAt, read now, while nothing is at stake:
+ // done() publishes a failure without a blocking read (spec §8.1).
+ if d.Class == classAdopt {
+ d.FailedUnder = m.failedUnder(*rt, d.ID.Build)
+ }
+ m.emit(*rt, EvSwapping, "info", textSwapping(a.Build, a.Cause))
+ s.done(d, m.d.Act.Swap(ctx, a, s.newest), a.Mode)
+}
+
+// done records an attempt's outcome and acts on it. mode is the attempt's
+// swap mode, "" when no Swap ran.
+func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.SwapMode) {
+ m := s.m
+ if res.Mode != "" {
+ // The mode the attempt actually ran in: an overlap attempt on a
+ // foreign-layout row runs as a hold (spec §2.9), and a failed hold
+ // is held down (§6.4, §8.3).
+ mode = res.Mode
+ }
+ failed := res.Outcome == activator.SwapFailed || res.Outcome == activator.SwapDown
+ // A failure is published with no blocking read before it (spec §8.1):
+ // the failing pointer's PromotedAt was read before the attempt ran
+ // (d.FailedUnder), and the record is written in the same critical
+ // section that frees the slot and halts the cohort. No admission, no
+ // newest re-check and no wake plan (which reads the book) sees the slot
+ // free before the record. The record's build and host are the
+ // dispatch's, never res.Gen's: a failed hold returns no generation.
+ var rec *FailureRecord
+ if failed {
+ r := FailureRecord{Account: s.key.acct, App: s.key.app, Build: d.ID.Build, Host: d.ID.Host,
+ Cause: string(d.ID.Cause), Mode: string(mode), Reason: res.Reason, PromotedAt: d.FailedUnder, At: m.d.Now()}
+ if r.PromotedAt.IsZero() {
+ // No pointer named the build when the attempt started: the
+ // failure's own time, so only a pointer signed after it clears
+ // the record.
+ r.PromotedAt = r.At
+ }
+ if res.Outcome == activator.SwapDown && mode == activator.ModeHold {
+ // A hold build executed against this instance's database:
+ // nothing falls back on it automatically (spec §6.4, §8.3).
+ // Held down in the same step that frees the slot, so no wake
+ // planned in between can boot the failed hold build.
+ r.HeldDown = []string{d.ID.Host}
+ }
+ rec = &r
+ }
+ // A failed attempt that carried a config revision leaves it delivered and
+ // not active (spec §8.1). Its backoff is counted here, before the slot
+ // frees, and set on the queue in the same step that frees it, so the
+ // loop cannot re-admit the attempt in between. A failed adoption goes
+ // through the cohort-failure transition instead, and the next attempt
+ // for the host is its recovery onto the serving build with the config.
+ var (
+ cfgWait time.Duration
+ cfgCounted bool
+ )
+ if failed && d.Class == classRecover && d.ID.ConfigRev != "" {
+ cfgWait, cfgCounted = m.configFailure(d.ID.Host, d.ID.ConfigRev, res.Reason)
+ }
+ if res.Outcome == activator.SwapDone {
+ // The config the attempt started with is active, published under the
+ // same rule as the serving state (spec §8.1): before the slot frees,
+ // so a newer config attempt cannot complete first and be overwritten
+ // here, and with no registry read in front of it, so an unreadable
+ // row loses nothing.
+ m.configActivated(d.ID.Host, d.ID.ConfigRev)
+ // Published BEFORE the slot is freed (spec §8.5): while the attempt
+ // still holds its host's slot no newer attempt can be admitted and
+ // complete first, so this write can never land on top of a newer
+ // one. writeGood also requires the generation to be the one the
+ // activator serves (acknowledged).
+ m.served(d.ID.Host, res.Gen)
+ }
+ now := m.d.Now()
+ s.mu.Lock()
+ transition := s.q.finish(d, res.Outcome)
+ var (
+ added bool
+ superseded []string
+ recErr error
+ )
+ if transition && rec != nil {
+ // The whole cohort-failure transition (spec §8.1 steps 1 and 2): the
+ // record (in memory at once, on disk as it can) and the halt.
+ added, superseded, recErr = failCohort(m.d.Book, s.q, *rec)
+ }
+ h := s.q.hosts[d.ID.Host]
+ if cfgCounted && h != nil && h.wantConfig == d.ID.ConfigRev {
+ s.q.configFailed(d.ID.Host, now.Add(cfgWait))
+ }
+ if res.Outcome == activator.SwapAbandoned && h != nil && h.buildRev == d.SchedRev && h.notBefore.Before(now.Add(abandonDeferral)) {
+ // Abandoned while still the host's newest: the same attempt would be
+ // derived again at once (plan 3's deferral). A staging refusal has
+ // already deferred the host by as long.
+ s.q.deferHost(d.ID.Host, now.Add(abandonDeferral))
+ }
+ serving := ""
+ if h != nil {
+ serving = h.serving
+ }
+ s.mu.Unlock()
+ defer s.poke()
+ if recErr != nil {
+ m.d.Logf("rollout: write failure record %s/%s %s: %v (held in memory)", rec.Account, rec.App, rec.Build, recErr)
+ }
+ if len(superseded) > 0 {
+ m.d.Logf("rollout: %s/%s build %s failed: superseding attempts past spawn on %v", rec.Account, rec.App, rec.Build, superseded)
+ }
+ if failed {
+ // The raw reason is for the operator; members get the reason word.
+ m.d.Logf("rollout: %s: %s attempt onto %s %s (%s mode): %s", d.ID.Host, d.ID.Cause, d.ID.Build, res.Outcome, mode, res.Reason)
+ }
+ rt := s.route(d.ID.Host)
+ if added {
+ m.emit(rt, EvCohortHalted, "warn", textCohortHalted(rec.Build, m.d.Box, reasonWord(rec.Reason)))
+ }
+ switch res.Outcome {
+ case activator.SwapDone:
+ m.mu.Lock()
+ if m.retried[d.ID.Host] == d.ID.Build {
+ delete(m.retried, d.ID.Host)
+ }
+ m.mu.Unlock()
+ m.emit(rt, EvSwapped, "info", textSwapped(d.ID.Build, res.Drain))
+ case activator.SwapFailed, activator.SwapDown:
+ if res.Outcome == activator.SwapDown {
+ m.emit(rt, EvSwapDown, "error", textSwapDown(d.ID.Build))
+ } else {
+ m.emit(rt, EvSwapFailed, "warn", textSwapFailed(d.ID.Build, serving, reasonWord(res.Reason)))
+ }
+ if cfgCounted {
+ m.emit(rt, EvConfigPending, "warn", textConfigPending(reasonWord(res.Reason), cfgWait))
+ }
+ case activator.SwapAbandoned:
+ m.d.Logf("rollout: %s: %s attempt onto %s abandoned: %s", d.ID.Host, d.ID.Cause, d.ID.Build, res.Reason)
+ }
+}
+
+// route is host's row for an event. A row that cannot be read still gets its
+// events, addressed by what the scheduler knows: its app and the host.
+func (s *scheduler) route(host string) registry.Route {
+ if rt, err := s.m.d.Routes.Get(host); err == nil && rt != nil {
+ return *rt
+ }
+ return registry.Route{Host: host, Account: s.key.acct, App: s.key.app}
+}