carlos / platform Public

zdd-readiness: write-ahead intents and schema 2 keep failures across a failed save and a restart (F5)

An adoption (in execute, after resolve) and a planned first wake (at the end of Plan) write an intent to the failure book before anything runs; a failed write refuses the execution. The intent ends after the outcome's own book write: in done() for attempts, on the wake's first terminal OnGen event for wakes (never on dropUnownedWakes, plan review #1). OpenFailureBook turns intents left on disk into failure records (held down and marked in hold mode, marker Exec = intent ID, At = intent start); armRollout flushes them or refuses managed starts, a declined box flushes and logs. The book is written as schema 2 and reads 1 and 2. The intent records the effective mode (Activator.EffectiveMode, foreign rows run overlap as hold, plan review #2). In the activator every planned wake with a token now ends with one terminal event: refused/replaced/abandoned exits send GenCancelled (Executed as the build ran), outside the outcome fence. The unreadable-book refusal is one shared constant (rollout.BookUnreadableRefusal). The simulation models the new events and checks I6: no intent outlives its execution.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8836e521c9c899de1b5e6d007305e2498ddcd362 parent 3e875ff
19 files changed, +1359 −61
  • internal/activator/holdran_event_test.go +25 −9
  • internal/activator/intent_events_test.go +152 −0
  • internal/activator/swap.go +13 −0
  • internal/activator/swap_hold_test.go +18 −0
  • internal/activator/wake_token_test.go +16 −1
  • internal/activator/wakeplan.go +53 −1
  • internal/host/rollout.go +17 −2
  • internal/host/rollout_test.go +98 −0
  • internal/host/rolloutdeclined.go +14 −3
  • internal/host/rolloutdeclined_test.go +38 −0
  • internal/rollout/bookseq_test.go +7 −0
  • internal/rollout/failures.go +208 −18
  • internal/rollout/fake_test.go +11 −0
  • internal/rollout/holdran_test.go +7 −4
  • internal/rollout/intent_test.go +506 −0
  • internal/rollout/manager.go +21 −12
  • internal/rollout/scheduler.go +40 −0
  • internal/rollout/sim_test.go +53 −9
  • internal/rollout/wakeplan.go +62 −2
diff --git a/internal/activator/holdran_event_test.go b/internal/activator/holdran_event_test.go
index 0368db5d5..3cc5bf872 100644
--- a/internal/activator/holdran_event_test.go
+++ b/internal/activator/holdran_event_test.go
@@ -15,9 +15,11 @@ import (
// observation of a healthy first wake, or the wake's lifecycle ends it after
// the exec (here Shutdown during the health check). The second, which
// reported nothing before, is reported for a hold first wake only, under the
-// host's outcome fence, so no new wake plans before the scheduler has it. A
-// wake that ran nothing reports nothing, and an overlap wake ended the same
-// way is unchanged (nothing reported).
+// host's outcome fence, so no new wake plans before the scheduler has it.
+// Since Task 5 every planned wake with a token ends with a terminal event:
+// one that ran nothing is cancelled, not Executed, and an overlap wake ended
+// the same way is cancelled Executed. Neither records anything, so neither
+// is delivered under the fence.
func TestAFirstWakeEndedAfterItsExecReportsThatItRan(t *testing.T) {
const host = "one.example.com"
endInHealth := func(t *testing.T, mode SwapMode, refuse bool) (*Service, *genLog, *planGenBackend, chan bool) {
@@ -69,17 +71,31 @@ func TestAFirstWakeEndedAfterItsExecReportsThatItRan(t *testing.T) {
}
})
t.Run("overlap, ended in its health check", func(t *testing.T) {
- _, evs, _, _ := endInHealth(t, ModeOverlap, false)
+ _, evs, _, fenced := endInHealth(t, ModeOverlap, false)
+ ev := evs.wait(t, GenCancelled)
+ if !ev.Executed || ev.Gen.Build != "v2" || ev.Mode != ModeOverlap || ev.Token != 41 {
+ t.Fatalf("event %+v: want GenCancelled, Executed, naming v2, in overlap mode, with the plan's token", ev)
+ }
+ if <-fenced {
+ t.Fatal("an overlap wake's cancellation records nothing, yet it held the outcome fence")
+ }
time.Sleep(100 * time.Millisecond)
- if k := evs.kinds(); len(k) != 0 {
- t.Fatalf("events %v: an overlap first wake ended by its lifecycle reports nothing, as before", k)
+ if k := evs.kinds(); len(k) != 1 {
+ t.Fatalf("events %v: want the one cancellation", k)
}
})
t.Run("hold, refused before its exec", func(t *testing.T) {
- _, evs, b, _ := endInHealth(t, ModeHold, true)
+ _, evs, b, fenced := endInHealth(t, ModeHold, true)
+ ev := evs.wait(t, GenCancelled)
+ if ev.Executed || ev.Token != 41 || len(b.started()) != 0 {
+ t.Fatalf("event %+v, started %+v: a wake that ran nothing is cancelled, not Executed", ev, b.started())
+ }
+ if <-fenced {
+ t.Fatal("a cancellation with nothing run held the outcome fence")
+ }
time.Sleep(100 * time.Millisecond)
- if k := evs.kinds(); len(k) != 0 || len(b.started()) != 0 {
- t.Fatalf("events %v, started %+v: a wake that ran nothing reports nothing", k, b.started())
+ if k := evs.kinds(); len(k) != 1 {
+ t.Fatalf("events %v: want the one cancellation", k)
}
})
t.Run("hold, its observation cancelled", func(t *testing.T) {
diff --git a/internal/activator/intent_events_test.go b/internal/activator/intent_events_test.go
new file mode 100644
index 000000000..c164caaec
--- /dev/null
+++ b/internal/activator/intent_events_test.go
@@ -0,0 +1,152 @@
+package activator
+
+import (
+ "context"
+ "errors"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// Task 5, plan review #1: the scheduler ends a planned first wake's
+// write-ahead intent only on that wake's own terminal event, never on the
+// activator letting the host go (a failed wake's entry goes before its
+// outcome is delivered). So every planned wake that carries a token ends
+// with exactly one token-carrying terminal event. The exits that sent none
+// before now send GenCancelled: not Executed when the plan's build never
+// ran (refused at the lease, at its check, replaced by a replan), Executed
+// when it ran and its lifecycle ended it with no verdict. None of them is an
+// outcome the fence must cover: they record nothing, so a new wake may plan
+// before they arrive.
+
+// terminal reports the token's events that settle its build's fate (the
+// scheduler's End): every kind but a first wake's healthy.
+func terminal(evs []GenEvent, tok uint64) []GenEvent {
+ var out []GenEvent
+ for _, ev := range evs {
+ if ev.Token == tok && (ev.Kind != GenHealthy || !ev.FirstWake) {
+ out = append(out, ev)
+ }
+ }
+ return out
+}
+
+// settle waits until the activator has gone quiet: events arrive on the
+// wake's goroutine after the request returns.
+func settle() { time.Sleep(100 * time.Millisecond) }
+
+func TestAPlannedWakeRefusedAtItsLeaseIsCancelledUnrun(t *testing.T) {
+ dir := t.TempDir()
+ good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
+ a, b, evs := planActivator(t, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good, Token: 51, ConfigRev: "c1"})
+ var fenced atomic.Bool
+ a.OnGen = func(ev GenEvent) {
+ a.mu.Lock()
+ _, held := a.outcomeFences["one.example.com"]
+ a.mu.Unlock()
+ fenced.Store(fenced.Load() || held)
+ evs.add(ev)
+ }
+ b.mu.Lock()
+ b.acquireErr = errors.New("lease held by a previous incarnation")
+ b.mu.Unlock()
+ if _, err := a.beginRequest(context.Background(), testRoute("one.example.com")); err == nil {
+ t.Fatal("the wake was served without its lease")
+ }
+ settle()
+ got := terminal(evs.all(), 51)
+ if len(got) != 1 || got[0].Kind != GenCancelled || got[0].Executed || got[0].ConfigRev != "c1" {
+ t.Fatalf("events %+v: want one GenCancelled, not executed, with the plan's token", evs.all())
+ }
+ if len(evs.all()) != 1 {
+ t.Fatalf("events %+v: want the one", evs.all())
+ }
+ if fenced.Load() {
+ t.Fatal("an event that records nothing was delivered under the outcome fence")
+ }
+}
+
+// A plan refused by its check before the exec, whose replan's check refuses
+// too: both plans are cancelled unrun, each once, by its own token.
+func TestARefusedReplanCancelsBothPlans(t *testing.T) {
+ dir := t.TempDir()
+ good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
+ a, b, evs := planActivator(t, WakePlan{})
+ refuse := func(string) error { return errors.New("held") }
+ var plans atomic.Uint64
+ a.Planner = func(context.Context, registry.Route) WakePlan {
+ return WakePlan{Boot: next, Mode: ModeHold, FirstWake: true, Fallback: &good, Check: refuse, Token: 60 + plans.Add(1)}
+ }
+ if _, err := a.beginRequest(context.Background(), testRoute("one.example.com")); !errors.Is(err, errPlanStale) {
+ t.Fatalf("err = %v, want the stale plan", err)
+ }
+ settle()
+ if gens := b.started(); len(gens) != 0 {
+ t.Fatalf("spawned %+v", gens)
+ }
+ all := evs.all()
+ if len(all) != 2 {
+ t.Fatalf("events %+v: want one per plan", all)
+ }
+ for _, tok := range []uint64{61, 62} {
+ got := terminal(all, tok)
+ if len(got) != 1 || got[0].Kind != GenCancelled || got[0].Executed {
+ t.Fatalf("token %d: events %+v, want one GenCancelled, not executed", tok, all)
+ }
+ }
+}
+
+// A plan replaced by a replan that boots: the replaced plan is cancelled
+// unrun, and the replan's own events follow.
+func TestAReplacedPlanIsCancelledUnrun(t *testing.T) {
+ dir := t.TempDir()
+ good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
+ a, b, evs := planActivator(t, WakePlan{})
+ held := func(build string) error {
+ if build == "v2" {
+ return errors.New("v2 failed on a sibling")
+ }
+ return nil
+ }
+ var plans atomic.Int32
+ a.Planner = func(context.Context, registry.Route) WakePlan {
+ if plans.Add(1) == 1 {
+ return WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good, Check: held, Token: 71}
+ }
+ return WakePlan{Boot: good, Mode: ModeOverlap, Check: held, Token: 72}
+ }
+ mustServe(t, a)
+ settle()
+ if gens := b.started(); len(gens) != 1 || gens[0].Build != "v1" {
+ t.Fatalf("spawned %+v, want the good v1 alone", gens)
+ }
+ old := terminal(evs.all(), 71)
+ if len(old) != 1 || old[0].Kind != GenCancelled || old[0].Executed {
+ t.Fatalf("replaced plan's events %+v: want one GenCancelled, not executed", evs.all())
+ }
+ cur := terminal(evs.all(), 72)
+ if len(cur) != 1 || cur[0].Kind != GenHealthy {
+ t.Fatalf("replan's events %+v: want its healthy wake", evs.all())
+ }
+}
+
+// A planned wake whose worker token could not be minted reports no
+// wokenInstance at all; it still ends with its cancellation. And the flag
+// off (no OnGen) hears nothing, whatever the plan carries.
+func TestAWakeWithNoResultIsStillCancelled(t *testing.T) {
+ plan := WakePlan{Boot: stagedID(t, t.TempDir(), "v2"), Mode: ModeHold, FirstWake: true, Token: 81}
+ a, _, evs := planActivator(t, plan)
+ e := &actEntry{host: "one.example.com", route: testRoute("one.example.com"), plan: plan}
+ a.afterWake(e, nil, errors.New("start: no token"))
+ if got := terminal(evs.all(), 81); len(got) != 1 || got[0].Kind != GenCancelled || got[0].Executed {
+ t.Fatalf("events %+v: want one GenCancelled, not executed", evs.all())
+ }
+ a.OnGen = nil
+ a.afterWake(e, nil, errors.New("start: no token")) // must not panic or emit
+ a.afterWake(e, &wokenInstance{}, errors.New("refused"))
+ if len(evs.all()) != 1 {
+ t.Fatalf("events %+v after OnGen was unset", evs.all())
+ }
+}
diff --git a/internal/activator/swap.go b/internal/activator/swap.go
index 0142fc7e8..5c47e68bc 100644
--- a/internal/activator/swap.go
+++ b/internal/activator/swap.go
@@ -282,6 +282,19 @@ func (a *Service) Swap(ctx context.Context, at Attempt, newest func(host string)
return abandon(errSwapMode)
}
+// EffectiveMode is the mode Swap runs an attempt of mode m on host in: an
+// overlap on a foreign-layout row runs as a hold (foreignRow, spec §2.9),
+// anything else as given. The scheduler writes it into the attempt's
+// write-ahead intent before calling Swap (rollout Task 5, plan review
+// finding 2), so an agent that crashes mid-swap reopens its failure book
+// with the hold it was running. Called without a.mu held.
+func (a *Service) EffectiveMode(host string, m SwapMode) SwapMode {
+ if m == ModeOverlap && a.foreignRow(host) {
+ return ModeHold
+ }
+ return m
+}
+
// attemptCtx is the attempt's own lifetime. It ends with the caller's ctx,
// with the agent (Shutdown cancels lifeCtx), or with the barrier, which
// calls the returned cancel through the member.
diff --git a/internal/activator/swap_hold_test.go b/internal/activator/swap_hold_test.go
index fc1ce0a33..299edd80e 100644
--- a/internal/activator/swap_hold_test.go
+++ b/internal/activator/swap_hold_test.go
@@ -707,6 +707,24 @@ func TestSwapOverlapOnAForeignRowRunsAsAHold(t *testing.T) {
}
}
+// Task 5, plan review #2: the scheduler writes an attempt's intent in the
+// mode Swap will run it in, which EffectiveMode answers with Swap's own
+// override: an overlap on a foreign row is a hold, anything else is as given.
+func TestEffectiveModeIsTheModeSwapRuns(t *testing.T) {
+ f := newSwapFixture(t)
+ host := f.attempt(ModeOverlap).Host
+ if m := f.a.EffectiveMode(host, ModeOverlap); m != ModeOverlap {
+ t.Fatalf("flat row: %s, want overlap", m)
+ }
+ f.b.layoutFn = func(registry.Route) (Layout, string) { return LayoutForeign, "hand-set socket" }
+ if m := f.a.EffectiveMode(host, ModeOverlap); m != ModeHold {
+ t.Fatalf("foreign row: %s, want hold", m)
+ }
+ if m := f.a.EffectiveMode(host, ModeHold); m != ModeHold {
+ t.Fatalf("foreign row, hold: %s", m)
+ }
+}
+
// A nested row can hold-swap now that root mounts the staged build (Task 9):
// the refusal Task 6 made for it is gone, and the hold runs to SwapDone.
func TestSwapHoldAcceptsANestedRow(t *testing.T) {
diff --git a/internal/activator/wake_token_test.go b/internal/activator/wake_token_test.go
index aac2a7e45..fdd76c088 100644
--- a/internal/activator/wake_token_test.go
+++ b/internal/activator/wake_token_test.go
@@ -110,7 +110,22 @@ func TestEveryGenEventCarriesItsWakesToken(t *testing.T) {
}
mustServe(t, a)
evs.wait(t, GenHealthy)
- return evs.all()
+ // The replaced plan's own cancellation (Task 5) carries its own
+ // token; every other event is the replan's.
+ var out []GenEvent
+ for _, ev := range evs.all() {
+ if ev.Token == token-1 {
+ if ev.Kind != GenCancelled || ev.Executed || ev.ConfigRev != "cfg-6" {
+ t.Fatalf("replaced plan's event %+v: want GenCancelled, not executed, with its own token and config", ev)
+ }
+ continue
+ }
+ out = append(out, ev)
+ }
+ if len(out) == len(evs.all()) {
+ t.Fatalf("events %+v: the replaced plan was not cancelled", evs.all())
+ }
+ return out
}},
} {
t.Run(tc.name, func(t *testing.T) {
diff --git a/internal/activator/wakeplan.go b/internal/activator/wakeplan.go
index fb035f276..3849e2989 100644
--- a/internal/activator/wakeplan.go
+++ b/internal/activator/wakeplan.go
@@ -92,12 +92,25 @@ func (a *Service) checkPlan(ctx context.Context, e *actEntry, rt registry.Route,
}
if next.Check != nil {
if nerr := next.Check(next.Boot.Version); nerr != nil {
+ // The replan never becomes the wake's plan, so afterWake will
+ // report only the first plan: the replan's own terminal event
+ // is sent here (rollout Task 5). Nothing of it ran. A planner
+ // that handed out the same token again gets one event for it.
+ if next.Token != plan.Token {
+ a.cancelPlanned(rt, next, false)
+ }
return plan, fmt.Errorf("%w: %v", errPlanStale, nerr)
}
}
a.mu.Lock()
e.plan, e.firstWake = next, next.FirstWake
a.mu.Unlock()
+ // The replaced plan's build never ran (its check refused it before the
+ // exec), and afterWake now reports the replan: the replaced plan's
+ // terminal event is sent here (rollout Task 5).
+ if plan.Token != next.Token {
+ a.cancelPlanned(rt, plan, false)
+ }
// The replanned build (and its fallback) is published before it execs
// (spec §4.2, §8.5). A failed write execs neither: the wake fails with
// errStagedBuildNotPublished, which is abandonment, never a failure.
@@ -646,11 +659,46 @@ func (a *Service) emitGen(ev GenEvent) {
}
}
+// cancelPlanned reports that a planned wake of plan ended with no verdict
+// on its build and nothing owed to the outcome fence (rollout Task 5, plan
+// review #1): refused at its lease, its exec or its check, replaced by a
+// replan, or (ran true) an overlap or ordinary wake its lifecycle ended
+// after the exec. It records nothing; it is the wake's terminal event, which
+// ends the scheduler's write-ahead intent for it. Only a plan with a token
+// is reported, and only to an OnGen that is set, so a box with the flag off
+// hears nothing. Never called with a.mu held (emitGen's rule).
+func (a *Service) cancelPlanned(rt registry.Route, plan WakePlan, ran bool) {
+ if a.OnGen == nil || plan.Token == 0 || !plan.planned() {
+ return
+ }
+ ev := GenEvent{Kind: GenCancelled, Route: rt, Mode: plan.Mode, FirstWake: plan.FirstWake, At: time.Now(), Executed: ran}
+ if ran {
+ ev.Gen = genOf(plan.Boot)
+ }
+ a.emitPlanned(plan, ev)
+}
+
// afterWake reports a planned wake's outcome. wake() calls it after
// e.ready has closed, without a.mu (emitGen's rule).
+//
+// Every planned wake with a token ends with exactly one terminal event for
+// it (rollout Task 5): its failure, its fallback, its verdict or its
+// cancellation here, or the first-wake observer's. A failed wake that owed
+// nothing to the outcome fence (outcomeOwedLocked) reports its cancellation
+// (cancelPlanned) after its entry is gone and admission has reopened; that
+// is safe because the event records nothing, and a newer wake's intent
+// replaces this one's, so its late arrival ends nothing it should not.
func (a *Service) afterWake(e *actEntry, w *wokenInstance, err error) {
plan := e.plan // written only by this wake's own goroutine
- if a.OnGen == nil || w == nil || !plan.planned() {
+ if a.OnGen == nil || !plan.planned() {
+ return
+ }
+ if w == nil {
+ // No result at all (a worker's token not minted, a claimed wake that
+ // ran nothing of the planned build): nothing of it ran.
+ if err != nil {
+ a.cancelPlanned(e.route, plan, false)
+ }
return
}
now := time.Now()
@@ -661,6 +709,10 @@ func (a *Service) afterWake(e *actEntry, w *wokenInstance, err error) {
a.emitPlanned(plan, GenEvent{Kind: GenFailed, Route: e.route, Failed: *w.failed, Mode: plan.Mode, FirstWake: true, Reason: w.failReason, At: now, FailedUnder: plan.FailedUnder})
case w.holdRanUnjudged(plan):
a.emitPlanned(plan, GenEvent{Kind: GenCancelled, Route: e.route, Gen: genOf(plan.Boot), Mode: plan.Mode, FirstWake: true, At: now, Executed: true})
+ default:
+ // Refused before the exec (its lease, a fence, its check), or an
+ // overlap or ordinary wake ended after the exec with no verdict.
+ a.cancelPlanned(e.route, plan, w.bootRan)
}
case w.fellBack:
a.emitPlanned(plan, GenEvent{Kind: GenFellBack, Route: e.route, Gen: w.planned, Failed: *w.failed, Mode: plan.Mode, FirstWake: true, Reason: w.failReason, At: now, FailedUnder: plan.FailedUnder})
diff --git a/internal/host/rollout.go b/internal/host/rollout.go
index 7fd4ccef9..b4d4ccd1e 100644
--- a/internal/host/rollout.go
+++ b/internal/host/rollout.go
@@ -43,11 +43,13 @@ const (
// refuseTexts is the wake refusal each refuseManaged code carries. It reaches
// a member through the activator's "wake failed:" event, so it names the
-// missing fact and never the raw error, which goes to the agent's log.
+// missing fact and never the raw error, which goes to the agent's log. The
+// unreadable book's text is the scheduler's own (rollout Task 5 refuses a
+// wake whose intent it cannot write with it), shared rather than copied.
var refuseTexts = map[string]string{
rolloutNoStore: "this box cannot start instances right now: the deploy store is unavailable",
rolloutNoReleaseKey: "this box cannot start instances right now: the release key is unavailable",
- rolloutBookUnreadable: "this box cannot start instances right now: its record of failed builds cannot be read",
+ rolloutBookUnreadable: rollout.BookUnreadableRefusal,
}
var rolloutMgr atomic.Pointer[rollout.Manager]
@@ -331,6 +333,19 @@ func armRollout(ctx context.Context, getenv func(string) string, svc *activator.
refuseManaged(svc, rolloutBookUnreadable)
return nil
}
+ if n := book.Reconciled(); n > 0 {
+ // Executions an earlier agent began and never saw recorded (Task 5,
+ // spec §8.4): their intents are failure records now, saved before
+ // anything runs. A box that cannot save them must not run either:
+ // its next restart would find the intents again, and nothing it
+ // recorded meanwhile would be kept.
+ log.Printf("agent: %s=overlap: %d execution(s) an earlier agent left unrecorded are now failure records", rollout.SwapEnv, n)
+ if err := book.Flush(); err != nil {
+ log.Printf("agent: %s=overlap: save the failure book after reconciling it: %v", rollout.SwapEnv, err)
+ refuseManaged(svc, rolloutBookUnreadable)
+ return nil
+ }
+ }
platform := runtime.GOOS + "-" + runtime.GOARCH
m := rollout.NewManager(ctx, rollout.Deps{
Act: svc, Routes: reg, Book: book, OptDir: release.OptDir(), DataDir: dd, Box: BoxLabel(),
diff --git a/internal/host/rollout_test.go b/internal/host/rollout_test.go
index ac39aae6d..77041afe8 100644
--- a/internal/host/rollout_test.go
+++ b/internal/host/rollout_test.go
@@ -293,6 +293,104 @@ func TestUnavailableStateRefusesManagedStarts(t *testing.T) {
}
}
+// leaveIntent leaves in's intent for host in dd's failure book, as an agent
+// that died mid-execution would have.
+func leaveIntent(t *testing.T, dd, host string, in rollout.Intent) {
+ t.Helper()
+ b, err := rollout.OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := b.Begin(host, in); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Task 5 rule 5: an armed box whose book holds an intent an earlier agent
+// left turns it into the failure it may have been, and saves that before
+// the scheduler runs: a reopen finds the record and no intent.
+func TestAnArmedBoxReconcilesAnIntentLeftInItsBook(t *testing.T) {
+ svc, reg, dd := rolloutFixture(t, true)
+ svc.GenerationsFile = release.GenerationsPath(dd)
+ _, pub, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready",
+ Hibernate: true, Version: "v2", GoodVersion: "v1", GoodDigest: "sha256:v1"}); err != nil {
+ t.Fatal(err)
+ }
+ leaveIntent(t, dd, "a.example.com", rollout.Intent{Account: "bab", App: "blog", Build: "v2", Mode: "hold", ID: "a3"})
+ store := release.NewDirStore(t.TempDir())
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ m := armRollout(ctx, overlapEnv(pub), svc, reg, dd, nil, func(context.Context) (release.Store, error) { return store, nil })
+ if m == nil || rolloutUnavailable() != "" {
+ t.Fatalf("not armed (%q)", rolloutUnavailable())
+ }
+ b, err := rollout.OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b.Reconciled() != 0 {
+ t.Fatal("the reconciled book was not saved: the intent is still on disk")
+ }
+ if !b.HeldDown("bab", "blog", "v2", "a.example.com") {
+ t.Fatal("the hold intent's failure was not kept")
+ }
+ if p := svc.Planner(ctx, *mustGet(t, reg, "a.example.com")); p.Refuse != rollout.HeldDownRefusal("v2") {
+ t.Fatalf("plan %+v: want the held-down refusal", p)
+ }
+}
+
+// Task 5 rule 5: a reconciled book the box cannot save refuses every
+// managed start, as an unreadable one does: the scheduler must not run while
+// a restart would find the intents again with no way to keep their records.
+func TestAnArmedBoxThatCannotSaveItsReconciledBookRefusesManagedStarts(t *testing.T) {
+ if os.Geteuid() == 0 {
+ t.Skip("running as root: a read-only directory does not fail a save")
+ }
+ svc, reg, dd := rolloutFixture(t, true)
+ _, pub, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ leaveIntent(t, dd, "a.example.com", rollout.Intent{Account: "bab", App: "blog", Build: "v2", Mode: "overlap", ID: "w4"})
+ if err := os.Chmod(dd, 0o500); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(dd, 0o700) })
+ m := armRollout(context.Background(), overlapEnv(pub), svc, reg, dd, nil,
+ func(context.Context) (release.Store, error) { return release.NewDirStore(t.TempDir()), nil })
+ if m != nil || armedRollout() != nil {
+ t.Fatal("armed over a book it could not save")
+ }
+ if rolloutUnavailable() != rolloutBookUnreadable {
+ t.Fatalf("unavailable %q, want %q", rolloutUnavailable(), rolloutBookUnreadable)
+ }
+ managed := registry.Route{Host: "a.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", Hibernate: true, Version: "v2"}
+ if p := svc.Planner(context.Background(), managed); p.Refuse != refuseTexts[rolloutBookUnreadable] {
+ t.Fatalf("plan %+v: want the unreadable-book refusal", p)
+ }
+}
+
+// The unreadable-book refusal is one approved text, shared with the
+// scheduler's refusal of a wake whose intent it could not write (Task 5).
+func TestTheUnreadableBookRefusalIsShared(t *testing.T) {
+ if refuseTexts[rolloutBookUnreadable] != rollout.BookUnreadableRefusal {
+ t.Fatalf("host %q, rollout %q", refuseTexts[rolloutBookUnreadable], rollout.BookUnreadableRefusal)
+ }
+}
+
+func mustGet(t *testing.T, reg *registry.Registry, host string) *registry.Route {
+ t.Helper()
+ rt, err := reg.Get(host)
+ if err != nil || rt == nil {
+ t.Fatalf("get %s: %v", host, err)
+ }
+ return rt
+}
+
// PF15: with the flag on but no delegated cgroup subtree, the flag is
// refused before the activator exists. Generations and containment stay
// off, no manager is armed, no hook is set, every leg is today's, and the
diff --git a/internal/host/rolloutdeclined.go b/internal/host/rolloutdeclined.go
index af7b06f68..2390885c6 100644
--- a/internal/host/rolloutdeclined.go
+++ b/internal/host/rolloutdeclined.go
@@ -35,9 +35,10 @@ const refuseNoSwaps = "this box cannot run two builds side by side, which this i
// restart leave such an instance running rather than stop it into a refused
// wake.
//
-// The book is read once, at arming, and never written: nothing on a declined
-// box records, retries or prunes, so what was read stays the truth until the
-// agent restarts.
+// The book is read once, at arming, and written at most once then: when an
+// armed run left intents in it, the records reconciled from them are saved
+// (honourDeclinedFailures). Nothing on a declined box records, retries or
+// prunes, so what was read stays the truth until the agent restarts.
type failureHolds struct {
book *rollout.FailureBook
// get reads a route by host, for the legs that are handed only a host.
@@ -72,6 +73,16 @@ func honourDeclinedFailures(svc *activator.Service, reg *registry.Registry, dd,
refuseManaged(svc, rolloutBookUnreadable)
return
}
+ if n := book.Reconciled(); n > 0 {
+ // An armed run's executions left unrecorded (Task 5): their records
+ // are honoured below whether or not this save lands, since the book
+ // in memory holds them either way, and the next restart reconciles
+ // the intents again if it did not.
+ log.Printf("agent: %s=overlap declined: %d execution(s) an armed run left unrecorded are now failure records", rollout.SwapEnv, n)
+ if err := book.Flush(); err != nil {
+ log.Printf("agent: %s=overlap declined: save the failure book after reconciling it: %v", rollout.SwapEnv, err)
+ }
+ }
recs := book.List()
if len(recs) == 0 {
return
diff --git a/internal/host/rolloutdeclined_test.go b/internal/host/rolloutdeclined_test.go
index 5cda71769..1fdcaeb08 100644
--- a/internal/host/rolloutdeclined_test.go
+++ b/internal/host/rolloutdeclined_test.go
@@ -392,6 +392,44 @@ func TestDeclinedBoxKeepsAHeldBuildOffARealActivator(t *testing.T) {
}
}
+// Task 5 rule 5: a declined box honours the records reconciled from an
+// intent an armed run left, and saves them; a save that fails is logged and
+// the records are honoured all the same.
+func TestDeclinedBoxHonoursAReconciledIntent(t *testing.T) {
+ for _, canSave := range []bool{true, false} {
+ t.Run(map[bool]string{true: "saved", false: "not saved"}[canSave], func(t *testing.T) {
+ if !canSave && os.Geteuid() == 0 {
+ t.Skip("running as root: a read-only directory does not fail a save")
+ }
+ t.Setenv("CARLOS_OPT_DIR", t.TempDir())
+ svc, reg, dd := rolloutFixture(t, false)
+ acct := release.RouteAccount(managedRoute("x", ""))
+ leaveIntent(t, dd, "down.example.com", rollout.Intent{Account: acct, App: "blog", Build: "v2", Mode: "hold", ID: "a9"})
+ if !canSave {
+ if err := os.Chmod(dd, 0o500); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(dd, 0o700) })
+ }
+ declineWithBook(t, svc, reg, dd)
+ if heldHolds() == nil || svc.Planner == nil || rolloutUnavailable() != "" {
+ t.Fatalf("the reconciled record was not honoured (unavailable %q)", rolloutUnavailable())
+ }
+ if p := svc.Planner(context.Background(), managedRoute("down.example.com", "v2")); p.Refuse == "" {
+ t.Fatalf("plan %+v: want the held-down instance refused", p)
+ }
+ os.Chmod(dd, 0o700)
+ b, err := rollout.OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if saved := b.Reconciled() == 0; saved != canSave {
+ t.Fatalf("book saved %v, want %v", saved, canSave)
+ }
+ })
+ }
+}
+
// Final review F6 (d): a declined box whose failure book cannot be read
// fails closed, as refuseManaged does on an armed box: every managed wake is
// refused with the fixed text, and the bounce legs stand down. The status
diff --git a/internal/rollout/bookseq_test.go b/internal/rollout/bookseq_test.go
index 3451d82c5..aa44f86f1 100644
--- a/internal/rollout/bookseq_test.go
+++ b/internal/rollout/bookseq_test.go
@@ -291,6 +291,13 @@ func TestASettleDecidedBeforeAHoldRanKeepsItsMarker(t *testing.T) {
}
g, _ := h.act.Current("h")
h.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: h.rows.get("h"), Gen: g, Mode: p.Mode, FirstWake: true, Token: p.Token})
+ // Since Task 5 the wake's intent, which stands until its settle, refuses
+ // an adoption of h (ErrIntentStands), so this ordering needs the intent
+ // gone. It is removed by hand: the marker rule must hold whatever lets a
+ // hold run between a plan and its settle.
+ if err := h.book.End("h", wakeExecID(p.Token)); err != nil {
+ t.Fatal(err)
+ }
gate := h.act.hold("h")
h.install("v2", "h")
diff --git a/internal/rollout/failures.go b/internal/rollout/failures.go
index 4aef326ae..32cdf2450 100644
--- a/internal/rollout/failures.go
+++ b/internal/rollout/failures.go
@@ -7,9 +7,11 @@ import (
"os"
"path/filepath"
"slices"
+ "strings"
"sync"
"time"
+ "github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/release"
)
@@ -142,6 +144,53 @@ func (h HoldRan) same(o HoldRan) bool {
return h.Account == o.Account && h.App == o.App && h.Build == o.Build
}
+// Intent is an execution that may leave a failure behind, written to the
+// book before the execution runs anything (Astra R1 #5, R3 §5 item 2): an
+// adoption attempt, or a planned first wake. Its outcome's own book write
+// (Record, MarkHoldRan) comes later, and End removes the intent only after
+// it. A failure held only in memory because its save failed, followed by an
+// agent restart, therefore still finds the intent on disk, and
+// OpenFailureBook turns it into the failure record it may have been.
+type Intent struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Build string `json:"build"`
+ // Mode is the mode the execution runs in: the effective one, after the
+ // activator's own overrides (an overlap attempt on a foreign-layout row
+ // runs as a hold, plan review finding 2), so a reconciled hold intent
+ // holds the instance down.
+ Mode string `json:"mode"`
+ // ID is the execution's identity (HoldRan.Exec): "a<n>" for an attempt
+ // (dispatch.ExecID), "w<token>" for a planned wake (wakeExecID). A
+ // reconciled hold intent's marker carries it.
+ ID string `json:"id"`
+ // Start is when the execution started (dispatch.Started, a wake's plan
+ // time): a reconciled hold intent's marker carries it as its At (ruling
+ // 205). Zero when not known; reconciliation then uses the open time.
+ Start time.Time `json:"start"`
+}
+
+// wakeIntent reports whether id is a planned wake's intent ("w<token>").
+func wakeIntent(id string) bool { return strings.HasPrefix(id, "w") }
+
+// ErrIntentStands is Begin's refusal while another execution's intent stands
+// for the host: an attempt's, or a wake's that an attempt would replace. Its
+// outcome is not yet written, so replacing its intent would lose it across a
+// failed save and a restart.
+var ErrIntentStands = errors.New("another execution on this instance has not recorded its outcome yet")
+
+// reconciledReason is a reconciled intent's Reason. reasonWord reads it as
+// a failed health check (status.ReasonHealth): the execution's verdict was
+// never recorded, which is all the box knows.
+const reconciledReason = "the agent restarted before this execution recorded its outcome"
+
+// failureSchema is the schema the book is written as. Schema 2 added the
+// intents (Task 5). It is written as 2 whatever the file was, so an older
+// agent, which accepts only schema 1, refuses the file rather than read it
+// and drop its markers and intents (an agent downgrade with the flag on
+// refuses managed starts until the file is upgraded or removed).
+const failureSchema = 2
+
type failureFile struct {
Schema int `json:"schema"`
Records []FailureRecord `json:"records"`
@@ -152,6 +201,8 @@ type failureFile struct {
// Seq is the book's sequence (FailureBook.seq), additive like HoldRan,
// so a snapshot after a restart is newer than every marker before it.
Seq uint64 `json:"seq,omitempty"`
+ // Intents are the executions in flight by host (Intent), schema 2.
+ Intents map[string]Intent `json:"intents,omitempty"`
}
// FailureBook is the box's failure records. An empty path keeps them in
@@ -166,9 +217,17 @@ type FailureBook struct {
recs []FailureRecord
// held is the hold-ran markers by host (HoldRan), saved with the records.
held map[string]HoldRan
+ // intents are the executions in flight by host (Intent), saved with the
+ // records. reconciled is how many intents OpenFailureBook turned into
+ // records.
+ intents map[string]Intent
+ reconciled int
// dirty is set when a save failed. Memory is the truth meanwhile, and
- // every later mutating call and Prune retries the save until it lands,
- // so the record still survives an agent restart (spec §8.4).
+ // every later mutating call and Prune retries the save until it lands.
+ // What keeps a failure across an agent restart before that is the
+ // execution's intent (Begin), saved before the execution ran anything and
+ // removed only after its outcome was written (End): a restart finds it
+ // on disk and records the failure from it (spec §8.4, Astra R1 #5).
dirty bool
// now stamps a marker whose caller gave no At (markLocked). Nil means
// time.Now; tests and the simulation set their own clock.
@@ -207,7 +266,9 @@ func cloneRec(r FailureRecord) FailureRecord {
// OpenFailureBook loads the book from dataDir. A file that exists but cannot
// be read is an error, never an empty book: an empty book would move every
-// held instance straight back onto the build that failed.
+// held instance straight back onto the build that failed. Intents left in
+// the file become failure records (reconcileLocked, Reconciled), which the
+// caller saves with Flush.
func OpenFailureBook(dataDir string) (*FailureBook, error) {
b := &FailureBook{path: filepath.Join(dataDir, FailureFile)}
raw, err := os.ReadFile(b.path)
@@ -221,28 +282,148 @@ func OpenFailureBook(dataDir string) (*FailureBook, error) {
if err := json.Unmarshal(raw, &f); err != nil {
return nil, fmt.Errorf("%s: %w", b.path, err)
}
- if f.Schema != 1 {
+ // Schema 1 is an agent before Task 5 (no intents); 2 is written now.
+ if f.Schema != 1 && f.Schema != failureSchema {
return nil, fmt.Errorf("%s: unknown schema %d", b.path, f.Schema)
}
b.recs = f.Records
b.held = f.HoldRan
b.seq = f.Seq
+ b.intents = f.Intents
for _, h := range b.held {
// A marker's sequence is saved with the book's, so this changes
// nothing in a file this book wrote; it keeps a hand-edited one from
// handing out a snapshot a standing marker is newer than.
b.seq = max(b.seq, h.Seq)
}
+ b.mu.Lock()
+ b.reconcileLocked()
+ b.mu.Unlock()
return b, nil
}
+// reconcileLocked turns each intent read from the file into the failure
+// record its execution may have left (rule 3; spec §8.4, Astra R1 #5): its
+// build on its host, cause release, the intent's mode, stamped with the open
+// time, so only a pointer signed after the restart clears it. A hold intent
+// holds the instance down and sets its hold-ran marker too, with the
+// intent's identity and start (Task 4), as a hold that went down would have:
+// the build may have migrated the database, so nothing falls back there
+// automatically. An agent crash mid-execution therefore halts that build's
+// cohort until a retry, even when the execution would have passed (the
+// plan's ruling). The intents then go from memory and the book is owed a
+// save, which the caller makes (Flush); nothing is written here, so a
+// read-only open of a book never changes the file. The caller holds b.mu.
+func (b *FailureBook) reconcileLocked() {
+ if len(b.intents) == 0 {
+ b.intents = nil
+ return
+ }
+ now := b.clock()
+ hosts := make([]string, 0, len(b.intents))
+ for host := range b.intents {
+ hosts = append(hosts, host)
+ }
+ slices.Sort(hosts) // one order for every open of the same file
+ for _, host := range hosts {
+ in := b.intents[host]
+ r := FailureRecord{Account: in.Account, App: in.App, Build: in.Build, Host: host,
+ Cause: string(activator.CauseRelease), Mode: in.Mode, Reason: reconciledReason, PromotedAt: now, At: now}
+ if in.Mode == string(activator.ModeHold) {
+ // A zero start leaves the marker's At to markLocked, which stamps
+ // it with the book's clock: the open time.
+ r.HeldDown, r.MarkHoldRan, r.HoldRanExec, r.HoldRanAt = []string{host}, true, in.ID, in.Start
+ }
+ b.recordLocked(r)
+ }
+ b.reconciled = len(hosts)
+ b.intents = nil
+ b.dirty = true
+}
+
+// Reconciled is how many intents OpenFailureBook turned into failure
+// records: executions an earlier agent began and never saw recorded. The
+// caller logs them and flushes the book (Flush).
+func (b *FailureBook) Reconciled() int {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.reconciled
+}
+
+// Flush saves the book now: after reconciliation, so the records it made
+// and the intents it removed reach the disk before anything runs.
+func (b *FailureBook) Flush() error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.saveLocked()
+}
+
+// Begin writes host's intent and saves it before the execution runs
+// anything (rule 1). On a save error nothing of it stays in memory, the host's
+// earlier intent (if this one replaced it) is put back, and the error is
+// returned: the caller must not execute, since its failure could then be
+// lost across a failed save and a restart.
+//
+// One intent stands per host. A planned wake's intent replaces an older
+// wake's intent for the host: the activator delivers a planned wake's
+// outcome before a newer wake of the host plans (its outcome fence,
+// internal/activator awaitOutcomes, final review F3 and wave 2 N1), so the
+// older wake's intent is one whose outcome is written, or one whose build
+// never ran (GenCancelled, not executed), which an event on its way ends
+// anyway. Two plans for one host never race: the activator plans a host
+// only from its one waking entry. Any other standing intent refuses the
+// Begin (ErrIntentStands): an attempt's, whose outcome done() has yet to
+// write, or, for an attempt, a wake's, whose outcome may still be on its way
+// (a fallen-back wake's entry serves before its GenFellBack is delivered).
+// The refused execution is retried as any refused one is.
+func (b *FailureBook) Begin(host string, in Intent) error {
+ if in.ID == "" {
+ return errors.New("an intent needs its execution's identity")
+ }
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ prev, had := b.intents[host]
+ if had && prev.ID != in.ID && !(wakeIntent(prev.ID) && wakeIntent(in.ID)) {
+ return fmt.Errorf("%s: %w (%s)", host, ErrIntentStands, prev.ID)
+ }
+ if b.intents == nil {
+ b.intents = map[string]Intent{}
+ }
+ b.intents[host] = in
+ if err := b.saveLocked(); err != nil {
+ if had {
+ b.intents[host] = prev
+ } else {
+ delete(b.intents, host)
+ }
+ return err
+ }
+ return nil
+}
+
+// End removes host's intent when it is id's, and saves (rule 2). It runs
+// after the execution's outcome is written to the book (Record,
+// MarkHoldRan), never before. Its save failing leaves the intent on disk,
+// which is the safe direction: a restart then records the failure the
+// outcome may also have written. An intent of another execution (a newer
+// wake that replaced this one's) is left alone.
+func (b *FailureBook) End(host, id string) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if cur, ok := b.intents[host]; !ok || id == "" || cur.ID != id {
+ return b.retryLocked()
+ }
+ delete(b.intents, host)
+ return b.saveLocked()
+}
+
// saveLocked writes the book and records whether the file is now behind
// memory, so a failure is retried by retryLocked rather than forgotten.
func (b *FailureBook) saveLocked() error {
if b.path == "" {
return nil
}
- raw, err := json.MarshalIndent(failureFile{Schema: 1, Records: b.recs, HoldRan: b.held, Seq: b.seq}, "", " ")
+ raw, err := json.MarshalIndent(failureFile{Schema: failureSchema, Records: b.recs, HoldRan: b.held, Seq: b.seq, Intents: b.intents}, "", " ")
if err == nil {
err = release.WriteFileAtomic(b.path, raw, 0o644)
}
@@ -315,8 +496,11 @@ func (b *FailureBook) clearedLocked(acct, app, build, host string) {
// after the latest failure clears it. The in-memory record is kept even when
// the file write fails, so the caller's transition still blocks the build;
// the error is for the journal, and the next call on the book (this one
-// again, if nothing new, or Prune) retries the save. r.MarkHoldRan sets r's
-// host's hold-ran marker in the same save.
+// again, if nothing new, or Prune) retries the save. Until a save lands, what
+// keeps the failure across an agent restart is the execution's intent
+// (Begin), still on disk because its End comes after this call: the restart
+// records the failure from it. r.MarkHoldRan sets r's host's hold-ran marker
+// in the same save.
//
// A failure of an execution that began before a retry of its build (r.Seq,
// ruling 191) is stale: the record is neither created nor changed, the
@@ -331,21 +515,30 @@ func (b *FailureBook) clearedLocked(acct, app, build, host string) {
func (b *FailureBook) Record(r FailureRecord) (RecordResult, error) {
b.mu.Lock()
defer b.mu.Unlock()
+ res, changed := b.recordLocked(r)
+ if changed {
+ return res, b.saveLocked()
+ }
+ return res, b.retryLocked()
+}
+
+// recordLocked is Record's change to memory, and reports whether it changed
+// anything to save. Reconciliation (reconcileLocked) shares it, so a
+// reconciled intent merges into a standing record as a live failure does.
+// The caller holds b.mu and saves.
+func (b *FailureBook) recordLocked(r FailureRecord) (RecordResult, bool) {
stale := b.preRetryLocked(r)
mark := r.MarkHoldRan || (stale && slices.Contains(r.HeldDown, r.Host))
marked := mark && b.markLocked(r.Host, HoldRan{Account: r.Account, App: r.App, Build: r.Build, Exec: r.HoldRanExec, At: r.HoldRanAt})
r.MarkHoldRan, r.HoldRanAt, r.HoldRanExec, r.Seq = false, time.Time{}, "", 0
if stale {
- if marked {
- return PreRetry, b.saveLocked()
- }
- return PreRetry, b.retryLocked()
+ return PreRetry, marked
}
i := b.find(r.Account, r.App, r.Build)
if i < 0 {
r.Retried = nil
b.recs = append(b.recs, cloneRec(r))
- return Recorded, b.saveLocked()
+ return Recorded, true
}
cur := &b.recs[i]
changed := false
@@ -363,13 +556,10 @@ func (b *FailureBook) Record(r FailureRecord) (RecordResult, error) {
changed = true
}
}
- switch {
- case changed:
- return Merged, b.saveLocked()
- case marked:
- return Unchanged, b.saveLocked()
+ if changed {
+ return Merged, true
}
- return Unchanged, b.retryLocked()
+ return Unchanged, marked
}
// markLocked sets host's hold-ran marker to h and reports whether that
diff --git a/internal/rollout/fake_test.go b/internal/rollout/fake_test.go
index 2e5918e17..2a4173ab5 100644
--- a/internal/rollout/fake_test.go
+++ b/internal/rollout/fake_test.go
@@ -66,6 +66,17 @@ func (f *fakeAct) PublishHeldServing(host string) {
}
}
+// EffectiveMode is the mode Swap will run m in: mode when a test set it (the
+// foreign-layout override it models), m otherwise.
+func (f *fakeAct) EffectiveMode(_ string, m activator.SwapMode) activator.SwapMode {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.mode != "" {
+ return f.mode
+ }
+ return m
+}
+
func newFakeAct() *fakeAct {
return &fakeAct{gens: map[string]activator.Gen{}, owned: map[string]bool{}, gates: map[string]chan struct{}{}}
}
diff --git a/internal/rollout/holdran_test.go b/internal/rollout/holdran_test.go
index fba7cfd68..6d2fd08ac 100644
--- a/internal/rollout/holdran_test.go
+++ b/internal/rollout/holdran_test.go
@@ -451,8 +451,11 @@ func TestTheHoldRanMarkerSurvivesReopen(t *testing.T) {
// Final review wave 2, N2: the markers are additive to schema 1. A file an
// older agent wrote (no hold_ran key) opens with no markers. A file with
-// markers still parses as an older agent reads it: plain encoding/json into
-// the old shape, which skips the unknown key, and every record intact.
+// markers still parses into the old shape (plain encoding/json skips the
+// unknown key) with every record intact. Since Task 5 it is written as
+// schema 2, which an older agent's reader refuses on purpose rather than
+// read it and drop the markers and intents
+// (TestASchema2BookIsUnreadableToASchema1Reader).
func TestTheHoldRanMarkerIsSchemaAdditive(t *testing.T) {
dir := t.TempDir()
old := `{"schema": 1, "records": [{"account": "bab", "app": "blog", "build": "v2", "host": "a", "cause": "release",
@@ -485,8 +488,8 @@ func TestTheHoldRanMarkerIsSchemaAdditive(t *testing.T) {
if err := json.Unmarshal(raw, &older); err != nil {
t.Fatalf("an older agent cannot parse the file with markers: %v", err)
}
- if older.Schema != 1 || len(older.Records) != 1 || older.Records[0].Build != "v2" {
- t.Fatalf("older agent's view = %+v: want schema 1 and the one record", older)
+ if older.Schema != 2 || len(older.Records) != 1 || older.Records[0].Build != "v2" {
+ t.Fatalf("older agent's view = %+v: want schema 2 and the one record", older)
}
if !strings.Contains(string(raw), `"hold_ran"`) {
t.Fatalf("the marker is not in the file:\n%s", raw)
diff --git a/internal/rollout/intent_test.go b/internal/rollout/intent_test.go
new file mode 100644
index 000000000..45b0d3927
--- /dev/null
+++ b/internal/rollout/intent_test.go
@@ -0,0 +1,506 @@
+package rollout
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// Task 5 (Astra F5; R1 #5, R3 §5 item 2): failure state survives a failed
+// book write followed by an agent restart. Every execution that may leave a
+// failure writes an intent first; a restart turns an intent left on disk
+// into the failure it may have been.
+//
+// A save fails by making the data dir read-only (WriteFileAtomic writes its
+// temp file beside the target). Running as root defeats it: the tests skip.
+
+func readOnly(t *testing.T, dir string) {
+ t.Helper()
+ if os.Geteuid() == 0 {
+ t.Skip("running as root: a read-only directory does not fail a save")
+ }
+ if err := os.Chmod(dir, 0o500); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(dir, 0o700) })
+}
+
+func writable(t *testing.T, dir string) {
+ t.Helper()
+ if err := os.Chmod(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// onDisk gives the harness a book saved under a data dir of its own, before
+// anything has run, and returns the dir.
+func (h *harness) onDisk() string {
+ h.t.Helper()
+ dir := h.t.TempDir()
+ b, err := OpenFailureBook(dir)
+ if err != nil {
+ h.t.Fatal(err)
+ }
+ b.now = h.clock
+ h.book, h.m.d.Book = b, b
+ return dir
+}
+
+// fileOf reads the book file in dir as it stands on disk.
+func fileOf(t *testing.T, dir string) failureFile {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join(dir, FailureFile))
+ if errors.Is(err, os.ErrNotExist) {
+ return failureFile{}
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ var f failureFile
+ if err := json.Unmarshal(raw, &f); err != nil {
+ t.Fatal(err)
+ }
+ return f
+}
+
+// Review Focus 1: B's failure is held only in memory because the save
+// failed; the agent restarts. The intent written before B ran is still on
+// disk and comes back as B's failure.
+func TestAFailedSaveThenRestartKeepsTheFailure(t *testing.T) {
+ dd := t.TempDir()
+ b, _ := OpenFailureBook(dd)
+ start := t0.Add(-time.Minute)
+ if err := b.Begin("h", Intent{Account: "bab", App: "blog", Build: "v2", Mode: "hold", ID: "a7", Start: start}); err != nil {
+ t.Fatal(err)
+ }
+ readOnly(t, dd)
+ if _, err := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Mode: "hold", HeldDown: []string{"h"}}); err == nil {
+ t.Skip("the save did not fail")
+ }
+ _ = b.End("h", "a7") // fails too: the intent stays on disk
+ writable(t, dd)
+ b2, err := OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b2.Reconciled() != 1 || !b2.HeldDown("bab", "blog", "v2", "h") {
+ t.Fatalf("after restart: reconciled %d, held down %v", b2.Reconciled(), b2.HeldDown("bab", "blog", "v2", "h"))
+ }
+ m, ok := b2.HoldRanOn("h")
+ if !ok || m.Build != "v2" {
+ t.Fatal("a hold intent must leave its marker")
+ }
+ if m.Exec != "a7" || !m.At.Equal(start) {
+ t.Fatalf("marker %+v: want the intent's execution (a7) and its start %v", m, start)
+ }
+}
+
+func TestABeginThatCannotSaveRefusesAndLeavesNothing(t *testing.T) {
+ dd := t.TempDir()
+ b, _ := OpenFailureBook(dd)
+ readOnly(t, dd)
+ if err := b.Begin("h", Intent{Account: "bab", App: "blog", Build: "v2", Mode: "overlap", ID: "a1"}); err == nil {
+ t.Skip("the save did not fail")
+ }
+ if _, ok := b.intents["h"]; ok {
+ t.Fatal("a refused begin kept its intent in memory")
+ }
+ writable(t, dd)
+ b2, _ := OpenFailureBook(dd)
+ if b2.Reconciled() != 0 {
+ t.Fatal("a refused begin left an intent")
+ }
+ // The memory a later save writes has no intent either.
+ if err := b.Flush(); err != nil {
+ t.Fatal(err)
+ }
+ if b3, _ := OpenFailureBook(dd); b3.Reconciled() != 0 {
+ t.Fatal("a refused begin's intent reached the file at the next save")
+ }
+}
+
+func TestASchema1BookReadsAndIsWrittenAsSchema2(t *testing.T) {
+ dd := t.TempDir()
+ raw := `{"schema":1,"records":[{"account":"bab","app":"blog","build":"v2","host":"h","cause":"release","mode":"overlap","reason":"x","promoted_at":"2026-10-01T00:00:00Z","at":"2026-10-01T00:00:00Z"}]}`
+ if err := os.WriteFile(filepath.Join(dd, FailureFile), []byte(raw), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ b, err := OpenFailureBook(dd)
+ if err != nil || !b.Blocks("bab", "blog", "v2", "x", false) {
+ t.Fatalf("schema 1: %v", err)
+ }
+ if err := b.Flush(); err != nil {
+ t.Fatal(err)
+ }
+ out, _ := os.ReadFile(filepath.Join(dd, FailureFile))
+ if !strings.Contains(string(out), `"schema": 2`) {
+ t.Fatalf("written as %s", out)
+ }
+ if _, err := OpenFailureBook(dd); err != nil {
+ t.Fatalf("schema 2 written, then refused: %v", err)
+ }
+}
+
+// Review Focus 2: what an older agent sees. Its reader refused anything
+// but schema 1 (OpenFailureBook before Task 5).
+func TestASchema2BookIsUnreadableToASchema1Reader(t *testing.T) {
+ var f struct {
+ Schema int `json:"schema"`
+ }
+ dd := t.TempDir()
+ b, _ := OpenFailureBook(dd)
+ if err := b.Flush(); err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := os.ReadFile(filepath.Join(dd, FailureFile))
+ if err := json.Unmarshal(raw, &f); err != nil {
+ t.Fatal(err)
+ }
+ if f.Schema == 1 {
+ t.Fatal("an old reader would accept the file and drop markers and intents")
+ }
+}
+
+func TestABookOfAnUnknownSchemaIsRefused(t *testing.T) {
+ for _, schema := range []string{"0", "3"} {
+ dd := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dd, FailureFile), []byte(`{"schema":`+schema+`,"records":[]}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := OpenFailureBook(dd); err == nil {
+ t.Fatalf("schema %s was read", schema)
+ }
+ }
+}
+
+// Rule 3: each intent left in the file becomes a failure record for its
+// build on that host: cause release, the intent's mode, held down with a
+// marker in hold mode, stamped with the open time, and a reason the member
+// surfaces word as a failed health check. The intents go, and the book is
+// owed a save (Flush), which leaves none on disk.
+func TestAnIntentLeftOnDiskBecomesItsFailure(t *testing.T) {
+ dd := t.TempDir()
+ b, _ := OpenFailureBook(dd)
+ start := t0.Add(-time.Hour)
+ for host, in := range map[string]Intent{
+ "a": {Account: "bab", App: "blog", Build: "v2", Mode: "overlap", ID: "a3", Start: start},
+ "b": {Account: "bab", App: "blog", Build: "v2", Mode: "hold", ID: "w9", Start: start},
+ "c": {Account: "bab", App: "blog", Build: "v3", Mode: "hold", ID: "a4"},
+ } {
+ if err := b.Begin(host, in); err != nil {
+ t.Fatal(err)
+ }
+ }
+ before := time.Now()
+ b2, err := OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ after := time.Now()
+ if b2.Reconciled() != 3 {
+ t.Fatalf("reconciled %d, want 3", b2.Reconciled())
+ }
+ r, ok := b2.Lookup("bab", "blog", "v2")
+ if !ok || r.Cause != "release" || len(r.HeldDown) != 1 || r.HeldDown[0] != "b" {
+ t.Fatalf("v2's record %+v: want a release failure holding down b (the hold intent) alone", r)
+ }
+ if r.At.Before(before) || r.At.After(after) || !r.PromotedAt.Equal(r.At) {
+ t.Fatalf("v2's record stamped at %v, promoted at %v: want the open time for both", r.At, r.PromotedAt)
+ }
+ if reasonWord(r.Reason) != status.ReasonHealth {
+ t.Fatalf("reason %q words as %q", r.Reason, reasonWord(r.Reason))
+ }
+ if !b2.Blocks("bab", "blog", "v2", "a", false) || b2.HeldDown("bab", "blog", "v2", "a") {
+ t.Fatal("the overlap intent's host must be blocked, not held down")
+ }
+ if _, ok := b2.HoldRanOn("a"); ok {
+ t.Fatal("an overlap intent left a hold-ran marker")
+ }
+ if m, ok := b2.HoldRanOn("b"); !ok || m.Build != "v2" || m.Exec != "w9" || !m.At.Equal(start) {
+ t.Fatalf("b's marker %+v, %v: want v2's, from w9, at its start", m, ok)
+ }
+ rc, ok := b2.Lookup("bab", "blog", "v3")
+ if !ok || rc.Mode != "hold" || !b2.HeldDown("bab", "blog", "v3", "c") {
+ t.Fatalf("v3's record %+v: want c held down", rc)
+ }
+ if m, ok := b2.HoldRanOn("c"); !ok || m.Exec != "a4" || m.At.Before(before) || m.At.After(after) {
+ t.Fatalf("c's marker %+v: an intent with no start is stamped with the open time", m)
+ }
+ if len(b2.intents) != 0 {
+ t.Fatalf("intents %v stayed in memory", b2.intents)
+ }
+ // Nothing is written at open: the caller flushes.
+ if f := fileOf(t, dd); len(f.Intents) != 3 {
+ t.Fatalf("the open rewrote the file: %d intents on disk", len(f.Intents))
+ }
+ if err := b2.Flush(); err != nil {
+ t.Fatal(err)
+ }
+ if f := fileOf(t, dd); len(f.Intents) != 0 || len(f.Records) != 2 || f.Schema != 2 {
+ t.Fatalf("after the flush: %+v", f)
+ }
+ if b3, _ := OpenFailureBook(dd); b3.Reconciled() != 0 || !b3.HeldDown("bab", "blog", "v2", "b") {
+ t.Fatal("the flushed book did not keep the reconciled records")
+ }
+}
+
+// Rule 1 and 2: a newer wake's intent replaces an older wake's for the host;
+// an attempt's intent is replaced by nothing, and an attempt never replaces
+// a wake's; End removes only its own intent.
+func TestBeginAndEndKeepOneIntentPerHost(t *testing.T) {
+ dd := t.TempDir()
+ b, _ := OpenFailureBook(dd)
+ in := func(id string) Intent {
+ return Intent{Account: "bab", App: "blog", Build: "v2", Mode: "overlap", ID: id}
+ }
+ if err := b.Begin("h", in("w1")); err != nil {
+ t.Fatal(err)
+ }
+ if err := b.Begin("h", in("w2")); err != nil {
+ t.Fatalf("a newer wake's intent was refused: %v", err)
+ }
+ if err := b.Begin("h", in("a5")); !errors.Is(err, ErrIntentStands) {
+ t.Fatalf("an attempt replaced a wake's intent: %v", err)
+ }
+ if f := fileOf(t, dd); f.Intents["h"].ID != "w2" {
+ t.Fatalf("on disk %+v, want w2's intent", f.Intents)
+ }
+ if err := b.End("h", "w1"); err != nil {
+ t.Fatal(err)
+ }
+ if f := fileOf(t, dd); f.Intents["h"].ID != "w2" {
+ t.Fatal("an older wake's End removed the newer wake's intent")
+ }
+ if err := b.End("h", "w2"); err != nil {
+ t.Fatal(err)
+ }
+ if f := fileOf(t, dd); len(f.Intents) != 0 {
+ t.Fatalf("End left %+v on disk", f.Intents)
+ }
+ if err := b.Begin("h", in("a6")); err != nil {
+ t.Fatal(err)
+ }
+ for _, id := range []string{"w3", "a7"} {
+ if err := b.Begin("h", in(id)); !errors.Is(err, ErrIntentStands) {
+ t.Fatalf("%s replaced an attempt's intent: %v", id, err)
+ }
+ }
+ if err := b.Begin("other", in("w4")); err != nil {
+ t.Fatalf("another host's intent was refused: %v", err)
+ }
+}
+
+// Manager (a): an adoption whose intent cannot be written never reaches
+// Swap. It is abandoned unexecuted, waits out the abandon deferral, and runs
+// once the book can be written.
+func TestAnAdoptionWhoseIntentCannotBeWrittenNeverRuns(t *testing.T) {
+ h := newHarness(t, "a")
+ dir := h.onDisk()
+ h.stage("v2")
+ readOnly(t, dir)
+ h.install("v2", "a")
+ h.pass()
+ waitFor(t, "the attempt to be abandoned and deferred", func() bool {
+ s := h.m.schedulerOf("a")
+ if s == nil {
+ return false
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ hs := s.q.hosts["a"]
+ return hs != nil && hs.running == nil && hs.notBefore.After(h.clock())
+ })
+ if calls, started, _ := h.act.snapshot(); len(calls) != 0 || len(started) != 0 {
+ t.Fatalf("Swap ran (%+v, %v) with no intent written", calls, started)
+ }
+ if len(h.book.List()) != 0 {
+ t.Fatal("a refused attempt wrote a failure")
+ }
+ writable(t, dir)
+ h.advance(abandonDeferral + time.Second)
+ h.pass()
+ waitFor(t, "the attempt after the wait", func() bool { return h.servingAll("v2", "a") })
+ waitFor(t, "the intent to end", func() bool { return len(fileOf(t, dir).Intents) == 0 })
+}
+
+// An adoption's intent is on disk while its Swap runs, with its execution's
+// identity, mode and start, and is gone once its outcome is written.
+func TestAnAdoptionsIntentStandsWhileItRuns(t *testing.T) {
+ for _, fail := range []bool{false, true} {
+ t.Run(map[bool]string{false: "done", true: "failed"}[fail], func(t *testing.T) {
+ h := newHarness(t, "a")
+ dir := h.onDisk()
+ h.stage("v2")
+ if fail {
+ h.act.outcome = func(activator.Attempt) activator.SwapOutcome { return activator.SwapFailed }
+ }
+ gate := h.act.hold("a")
+ h.install("v2", "a")
+ h.pass()
+ waitFor(t, "the swap to start", func() bool { _, started, _ := h.act.snapshot(); return len(started) == 1 })
+ in, ok := fileOf(t, dir).Intents["a"]
+ if !ok || !strings.HasPrefix(in.ID, "a") || in.Build != "v2" || in.Mode != "overlap" || !in.Start.Equal(t0) {
+ t.Fatalf("intent on disk during the swap: %+v, %v", in, ok)
+ }
+ close(gate)
+ waitFor(t, "the intent to end", func() bool { return len(fileOf(t, dir).Intents) == 0 })
+ recorded := false
+ for _, r := range fileOf(t, dir).Records {
+ recorded = recorded || r.Build == "v2"
+ }
+ if recorded != fail {
+ t.Fatalf("failed %v, but v2 recorded %v", fail, recorded)
+ }
+ })
+ }
+}
+
+// Plan review #2: Swap runs an overlap attempt on a foreign-layout row as a
+// hold. The intent carries the mode the activator will run (EffectiveMode),
+// so a crash mid-swap reopens the book with the instance held down and
+// marked, as a hold that went down would have left it.
+func TestAForeignLayoutHoldCrashReopensHeldDown(t *testing.T) {
+ h := newHarness(t, "a")
+ dir := h.onDisk()
+ h.stage("v2")
+ h.act.mode = activator.ModeHold // the foreign row's override
+ gate := h.act.hold("a")
+ t.Cleanup(func() { close(gate) })
+ h.install("v2", "a")
+ h.pass()
+ waitFor(t, "the swap to start", func() bool { _, started, _ := h.act.snapshot(); return len(started) == 1 })
+ if in := fileOf(t, dir).Intents["a"]; in.Mode != "hold" {
+ t.Fatalf("intent %+v: want the effective mode, hold", in)
+ }
+ // The agent dies here; the next one opens the book.
+ b, err := OpenFailureBook(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b.Reconciled() != 1 || !b.HeldDown("bab", "blog", "v2", "a") {
+ t.Fatalf("reconciled %d: want a held down on v2", b.Reconciled())
+ }
+ m, ok := b.HoldRanOn("a")
+ if !ok || m.Build != "v2" || !strings.HasPrefix(m.Exec, "a") || !m.At.Equal(t0) {
+ t.Fatalf("marker %+v, %v: want v2's, from the attempt, at its dispatch", m, ok)
+ }
+}
+
+// Manager (b): a first wake whose intent cannot be written is refused with
+// the approved text, and nothing of it is registered.
+func TestAFirstWakeWhoseIntentCannotBeWrittenIsRefused(t *testing.T) {
+ h := newHarness(t, "a")
+ dir := h.onDisk()
+ h.stage("v2")
+ h.registerThenInstall("v2", "a")
+ h.wakeHost("a")
+ readOnly(t, dir)
+ p := h.m.Plan(context.Background(), h.rows.get("a"))
+ if p.Refuse != BookUnreadableRefusal || p.Boot.StagedPath != "" {
+ t.Fatalf("plan %+v: want the wake refused with %q", p, BookUnreadableRefusal)
+ }
+ h.m.mu.Lock()
+ _, planned := h.m.planned["a"]
+ h.m.mu.Unlock()
+ if planned {
+ t.Fatal("a refused wake stayed registered as planned")
+ }
+}
+
+// A planned wake does not take over an attempt's intent: its plan is
+// refused as the attempt's own planning refusal is, and nothing is lost.
+func TestAFirstWakeNeverReplacesAnAttemptsIntent(t *testing.T) {
+ h := newHarness(t, "a")
+ h.onDisk()
+ h.stage("v2")
+ h.registerThenInstall("v2", "a")
+ h.wakeHost("a")
+ if err := h.book.Begin("a", Intent{Account: "bab", App: "blog", Build: "v3", Mode: "hold", ID: "a1"}); err != nil {
+ t.Fatal(err)
+ }
+ p := h.m.Plan(context.Background(), h.rows.get("a"))
+ if p.Refuse != "this instance is still moving to another build" {
+ t.Fatalf("plan %+v: want the moving refusal", p)
+ }
+ if in := h.book.intents["a"]; in.ID != "a1" {
+ t.Fatalf("intent %+v: the attempt's was replaced", in)
+ }
+}
+
+// Manager (c), with plan review #1: a planned first wake's intent is written
+// at its plan, with the wake's identity and start, and ends with the first
+// event that settles its build's fate, never with the activator letting the
+// host go (an entry deleted before its failure is delivered).
+func TestAPlannedWakesIntentEndsWithItsOutcome(t *testing.T) {
+ ctx := context.Background()
+ setup := func(t *testing.T) (*harness, string, activator.WakePlan) {
+ h := newHarness(t, "a")
+ dir := h.onDisk()
+ h.stage("v2")
+ h.registerThenInstall("v2", "a")
+ h.wakeHost("a")
+ p := h.m.Plan(ctx, h.rows.get("a"))
+ if !p.FirstWake || p.Boot.Version != "v2" {
+ t.Fatalf("plan %+v: want a first wake of v2", p)
+ }
+ in, ok := fileOf(t, dir).Intents["a"]
+ if !ok || in.ID != wakeExecID(p.Token) || in.Build != "v2" || in.Mode != string(p.Mode) || !in.Start.Equal(t0) {
+ t.Fatalf("intent on disk %+v, %v: want the plan's", in, ok)
+ }
+ return h, dir, p
+ }
+ ev := func(p activator.WakePlan, h *harness, kind activator.GenEventKind, first bool) activator.GenEvent {
+ return activator.GenEvent{Kind: kind, Route: h.rows.get("a"), Gen: activator.Gen{N: 3, Build: "v2", Digest: "sha-v2"},
+ Mode: p.Mode, FirstWake: first, Token: p.Token, ConfigRev: p.ConfigRev}
+ }
+ for _, tc := range []struct {
+ name string
+ kinds []activator.GenEventKind
+ first bool
+ ends bool
+ }{
+ {"healthy first wake, under observation", []activator.GenEventKind{activator.GenHealthy}, true, false},
+ {"settled", []activator.GenEventKind{activator.GenHealthy, activator.GenSettled}, true, true},
+ {"failed", []activator.GenEventKind{activator.GenFailed}, true, true},
+ {"cancelled with nothing run", []activator.GenEventKind{activator.GenCancelled}, true, true},
+ {"the good build booted untried", []activator.GenEventKind{activator.GenHealthy}, false, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ h, dir, p := setup(t)
+ for _, k := range tc.kinds {
+ e := ev(p, h, k, tc.first)
+ if k == activator.GenFailed {
+ e.Failed = p.Boot
+ }
+ h.m.OnGen(e)
+ }
+ if _, stands := fileOf(t, dir).Intents["a"]; stands == tc.ends {
+ t.Fatalf("after %v: intent stands %v", tc.kinds, stands)
+ }
+ })
+ }
+ t.Run("an older wake's event leaves a newer wake's intent", func(t *testing.T) {
+ h, dir, old := setup(t)
+ cur := h.m.Plan(ctx, h.rows.get("a"))
+ h.m.OnGen(ev(old, h, activator.GenCancelled, true))
+ if in := fileOf(t, dir).Intents["a"]; in.ID != wakeExecID(cur.Token) {
+ t.Fatalf("intent %+v, want the newer wake's", in)
+ }
+ })
+ t.Run("not by the activator letting the host go", func(t *testing.T) {
+ h, dir, _ := setup(t)
+ h.sleepHost("a") // Owns false: the failed wake's entry is gone
+ h.pass()
+ if _, stands := fileOf(t, dir).Intents["a"]; !stands {
+ t.Fatal("dropUnownedWakes ended the intent of a wake whose outcome is not delivered")
+ }
+ })
+}
diff --git a/internal/rollout/manager.go b/internal/rollout/manager.go
index 78efcedc3..fa06d268c 100644
--- a/internal/rollout/manager.go
+++ b/internal/rollout/manager.go
@@ -22,12 +22,16 @@ import (
// stop-then-start for workers and native sidecar members (spec §7.9), and
// PublishHeldServing releases a worker fallback's serving identity once
// done() has recorded the failure it falls back from (spec §8.1).
+// EffectiveMode is the mode Swap will run an attempt of mode m on host in
+// (an overlap on a foreign-layout row runs as a hold, spec §2.9), which the
+// attempt's intent records before Swap runs (plan review finding 2).
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
PublishHeldServing(host string)
+ EffectiveMode(host string, m activator.SwapMode) activator.SwapMode
}
// Routes is the part of *registry.Registry the scheduler reads and writes.
@@ -700,11 +704,12 @@ type wakeStart struct {
snap uint64
}
-// wakeStartsKept bounds Manager.wakeAt per host. A wake that ends with no
-// event (refused at its lease, a failed ordinary wake) leaves its entry
-// until this many newer plans push it out; a live wake is never that far
-// behind its host's newest plan, because the activator waits for a planned
-// wake's outcome before it plans again (outcomeFenceWait).
+// wakeStartsKept bounds Manager.wakeAt per host. Every planned wake ends
+// with a terminal event (Task 5), which drops its entry; a plan the
+// activator never ran (one Plan returned to a caller that then gave up)
+// leaves its entry until this many newer plans push it out; a live wake is
+// never that far behind its host's newest plan, because the activator waits
+// for a planned wake's outcome before it plans again (outcomeFenceWait).
const wakeStartsKept = 4
// noteWakeStart keeps the start of a planned wake that boots a build (tok):
@@ -796,13 +801,17 @@ func (m *Manager) notePlanning(rt registry.Route, tok uint64) {
}
// dropUnownedWakes forgets the planned wakes of hosts the activator no
-// longer owns: a wake that ended with no event (refused at its lease,
-// abandoned at its exec) sends no event carrying its token and leaves its
-// entry behind, and only the entry's own end may let the record go. (A
-// replan's first plan gets no event either; the replan, a newer Plan,
-// replaces its entry.) Owns is asked with no lock held (the lock order
-// on Manager), and an entry is dropped only if no newer plan replaced it
-// meanwhile.
+// longer owns: a planned wake of an older activator, or one whose terminal
+// event is still on its way, leaves its entry behind, and only the entry's
+// own end may let the record go. Owns is asked with no lock held (the lock
+// order on Manager), and an entry is dropped only if no newer plan replaced
+// it meanwhile.
+//
+// It never ends a wake's intent (plan review #1): a failed wake's entry goes
+// before its GenFailed is delivered (activator's wake, then afterWake), so
+// Owns false is no proof the outcome is written, and ending the intent there
+// could lose a failure across a failed save and a restart. The intent ends
+// on the wake's own terminal event (OnGen); every planned wake sends one.
func (m *Manager) dropUnownedWakes() {
m.mu.Lock()
seen := make(map[string]uint64, len(m.planned))
diff --git a/internal/rollout/scheduler.go b/internal/rollout/scheduler.go
index de5b7ea8b..37e442a3c 100644
--- a/internal/rollout/scheduler.go
+++ b/internal/rollout/scheduler.go
@@ -2,6 +2,7 @@ package rollout
import (
"context"
+ "errors"
"strconv"
"sync"
"time"
@@ -144,6 +145,32 @@ func (s *scheduler) execute(ctx context.Context, d dispatch) {
d.Started = m.d.Now()
d.BookSeq = m.d.Book.Snapshot()
d.ExecID = "a" + strconv.FormatUint(m.nextExec(), 10)
+ if d.Class == classAdopt {
+ // Write-ahead (Task 5, Astra R1 #5): the adoption's intent is on disk
+ // before the activator stops or starts anything, so a failure whose
+ // own save fails is still found by an agent that restarts before a
+ // save lands (OpenFailureBook). Its mode is the one the activator
+ // will run (plan review finding 2): Swap runs an overlap on a
+ // foreign-layout row as a hold, and a reconciled hold holds the
+ // instance down. A worker's replacement runs its attempt's mode as
+ // given. No intent, no execution: an attempt whose intent could not
+ // be written is abandoned unexecuted, and done() defers and retries
+ // it as any refusal before the exec. Not under scheduler.mu.
+ mode := a.Mode
+ if !rt.IsSidecar() {
+ mode = m.d.Act.EffectiveMode(rt.Host, a.Mode)
+ }
+ in := Intent{Account: s.key.acct, App: s.key.app, Build: d.ID.Build, Mode: string(mode), ID: d.ExecID, Start: d.Started}
+ if err := m.d.Book.Begin(d.ID.Host, in); err != nil {
+ m.d.Logf("rollout: %s: write the intent of its %s attempt onto %s: %v; not running it", d.ID.Host, d.ID.Cause, d.ID.Build, err)
+ reason := "failure records could not be written"
+ if errors.Is(err, ErrIntentStands) {
+ reason = "an earlier execution on this instance has not recorded its outcome"
+ }
+ s.done(d, activator.SwapResult{Outcome: activator.SwapAbandoned, Reason: reason}, "")
+ return
+ }
+ }
if rt.IsSidecar() {
// Workers and native sidecar members keep stop-then-start on the
// same entry (spec §7.9). done() runs the cohort-failure transition
@@ -290,6 +317,16 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw
serving = h.serving
}
s.mu.Unlock()
+ // The adoption's intent ends now (Task 5 rule 2), after every book write
+ // its outcome makes (the record, the hold-ran marker, served's marker
+ // clear above), never before, and off scheduler.mu. If those writes
+ // failed to save, this save fails too and the intent stays on disk, where
+ // a restart finds it. Ended before the poke below, so the next admission
+ // for the host does not meet it.
+ var endErr error
+ if d.Class == classAdopt && d.ExecID != "" {
+ endErr = m.d.Book.End(d.ID.Host, d.ExecID)
+ }
if mark || (stale && heldDown) {
// A marker was set: the requested one, or one the book set in place
// of a stale hold-down (Task 4 fix round 1).
@@ -306,6 +343,9 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw
if markErr != nil {
m.d.Logf("rollout: %s: write hold-ran marker for %s: %v (held in memory)", d.ID.Host, d.ID.Build, markErr)
}
+ if endErr != nil {
+ m.d.Logf("rollout: %s: end the intent of its attempt onto %s: %v (it stays on disk until a save lands)", d.ID.Host, d.ID.Build, endErr)
+ }
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)
}
diff --git a/internal/rollout/sim_test.go b/internal/rollout/sim_test.go
index d3d0e1bbc..9fd86b94d 100644
--- a/internal/rollout/sim_test.go
+++ b/internal/rollout/sim_test.go
@@ -3,6 +3,7 @@ package rollout
import (
"context"
"fmt"
+ "maps"
"math/rand"
"os"
"runtime/debug"
@@ -74,6 +75,9 @@ import (
// see what the Pass cleared; the evidence is the model's.) When the
// marked build is itself the installed good build, it boots under the
// marked-build rule, and I1 requires its wake to boot.
+// - I6: every execution's intent (Task 5) ends with its outcome: once the
+// run has drained, an intent stands only for a first wake still in
+// flight, and it is that wake's.
//
// Seeds: ROLLOUT_SIM_SEEDS (default 200; the per-revision sweep is 3000).
// ROLLOUT_SIM_SEED=<n> replays one seed. The production code iterates some
@@ -507,6 +511,9 @@ func (f simAct) ReplaceWorker(context.Context, activator.Attempt, *release.Stage
func (f simAct) PublishHeldServing(string) {}
+// EffectiveMode: the simulation has no foreign-layout rows.
+func (f simAct) EffectiveMode(_ string, m activator.SwapMode) activator.SwapMode { return m }
+
// finishSwap is the modelled Swap's end (plan 3), run on the attempt's
// goroutine while the simulation waits: the re-check against newest, then
// the build's health, then the switch. It is one transition: nothing else
@@ -670,6 +677,7 @@ func (s *sim) run(steps int) {
close(sw.release)
s.await()
}
+ s.checkIntents()
}
func sortedKeys[V any](m map[string]V) []string {
@@ -1007,12 +1015,12 @@ func (s *sim) wakeExec() {
rt, plan := h.planRoute, h.plan
if s.r.Intn(20) == 0 {
// The lease was refused (a previous incarnation still holds it): the
- // wake ends before any exec and reports no outcome, so only a Pass
- // that finds the activator no longer owns the host lets go of the
- // build its plan carried (Manager.planned). Not a build failure, so
- // no I1 check.
+ // wake ends before any exec. The activator reports it as cancelled
+ // with nothing run (Task 5), which ends the plan's registration and
+ // its intent. Not a build failure, so no I1 check.
h.phase = simAsleep
- s.logf("wake %s: refused at its lease, no outcome reported", h.name)
+ s.logf("wake %s: refused at its lease, cancelled with nothing run", h.name)
+ s.cancelUnrun(rt, plan)
return
}
if plan.Check != nil && plan.Check(plan.Boot.Version) != nil {
@@ -1020,10 +1028,18 @@ func (s *sim) wakeExec() {
if next.Refuse != "" || next.Boot.StagedPath == "" || (next.Check != nil && next.Check(next.Boot.Version) != nil) {
h.phase = simAsleep
s.logf("wake %s: abandoned at exec (plan stale, replan %q)", h.name, next.Refuse)
+ // The activator's checkPlan cancels a replan its own check
+ // refused, then afterWake the wake's plan (Task 5): neither ran.
+ if next.Refuse == "" && next.Boot.StagedPath != "" {
+ s.cancelUnrun(rt, next)
+ }
+ s.cancelUnrun(rt, plan)
// The replan read the row afresh (Plan replans a moved row).
s.checkWakeRefused(h, s.rows.get(h.name).Version, "the plan went stale and the replan refused")
return
}
+ // The replan replaces the plan, which checkPlan cancels: it never ran.
+ s.cancelUnrun(rt, plan)
plan, h.plan, h.planAt, h.stamp = next, next, s.now, s.stampFor(h, next.Boot.Version)
h.underMark = s.holdRan[h.name] != ""
s.logf("wake %s replanned: boot %s first %v", h.name, plan.Boot.Version, plan.FirstWake)
@@ -1039,6 +1055,7 @@ func (s *sim) wakeExec() {
// The good build's own gate refused it at the exec: nothing ran.
h.phase = simAsleep
s.logf("wake %s: %s untried, and the good build %s was refused at its exec: %v", h.name, plan.Boot.Version, good.Version, err)
+ s.cancelUnrun(rt, plan)
s.checkWakeRefused(h, rt.Version, "the untried first wake's good build was refused at its exec")
return
}
@@ -1069,15 +1086,16 @@ func (s *sim) wakeExec() {
if s.r.Intn(12) == 0 {
// The wake's lifecycle ended it after the build's exec (a stop, a
// fence, a step after the spawn): no verdict. The activator reports
- // a hold first wake's run (GenCancelled, Executed); an overlap one
- // reports nothing, as before (N2).
+ // it as cancelled after a run (GenCancelled, Executed): a hold one
+ // leaves the hold-ran restriction (N2), an overlap one only ends the
+ // wake (Task 5).
h.phase, h.gen = simAsleep, activator.Gen{}
s.logf("wake %s: first wake of %s ended after its exec, no verdict (mode %s)", h.name, boot.Version, plan.Mode)
if plan.Mode == activator.ModeHold {
s.holdRanOn(h, boot.Version, h.planAt)
- s.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: rt, Gen: activator.Gen{Build: boot.Version}, Mode: plan.Mode,
- FirstWake: true, At: s.now, Token: plan.Token, ConfigRev: plan.ConfigRev, Executed: true})
}
+ s.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: rt, Gen: activator.Gen{Build: boot.Version}, Mode: plan.Mode,
+ FirstWake: true, At: s.now, Token: plan.Token, ConfigRev: plan.ConfigRev, Executed: true})
return
}
b := s.builds[boot.Version]
@@ -1124,6 +1142,32 @@ func (s *sim) wakeExec() {
}
}
+// cancelUnrun is the activator's report of a planned wake that ended before
+// its build ran (Task 5): refused at its lease or its exec, or replaced by a
+// replan. It records nothing, and ends the plan's registration and intent.
+func (s *sim) cancelUnrun(rt registry.Route, plan activator.WakePlan) {
+ s.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: rt, Mode: plan.Mode, FirstWake: plan.FirstWake, At: s.now,
+ Token: plan.Token, ConfigRev: plan.ConfigRev})
+}
+
+// checkIntents is I6 once the run has drained (Task 5): every execution's
+// intent ends with its outcome, so the only intents left are those of first
+// wakes still in flight (planned or under observation), each the host's
+// current plan's. A leaked intent would refuse the host's adoptions, and an
+// agent restart would turn it into a failure nothing failed.
+func (s *sim) checkIntents() {
+ s.book.mu.Lock()
+ intents := maps.Clone(s.book.intents)
+ s.book.mu.Unlock()
+ for _, host := range sortedKeys(intents) {
+ in, h := intents[host], s.byName[host]
+ inFlight := h != nil && (h.phase == simWaking || h.phase == simObserving) && h.plan.FirstWake && in.ID == wakeExecID(h.plan.Token)
+ if !inFlight {
+ violate("I6", "%s's intent %+v outlived its execution", host, in)
+ }
+ }
+}
+
// observeEnd ends a first wake's observation: it settles, or the build exits.
func (s *sim) observeEnd() {
h := s.pick(func(h *simHost) bool { return h.phase == simObserving })
diff --git a/internal/rollout/wakeplan.go b/internal/rollout/wakeplan.go
index 401064810..3500693e3 100644
--- a/internal/rollout/wakeplan.go
+++ b/internal/rollout/wakeplan.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "time"
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/registry"
@@ -47,6 +48,12 @@ func HeldDownRefusal(build string) string {
return fmt.Sprintf("build %s failed in hold mode on this instance, so it stays down until you roll back or retry the build", build)
}
+// BookUnreadableRefusal is the wake refusal when this box's failure records
+// cannot be read or written: an unreadable book at arming (internal/host's
+// refuseTexts), or a planned first wake whose intent could not be saved
+// (Task 5). Approved text, shared rather than copied.
+const BookUnreadableRefusal = "this box cannot start instances right now: its record of failed builds cannot be read"
+
// decideWake is spec §8.2's "when the wake picks a build", as a pure
// function. The installed build (root's stamp) boots unless a failure record
// holds it for this instance, in which case the good build boots from its
@@ -207,6 +214,13 @@ func (m *Manager) Plan(ctx context.Context, rt registry.Route) (plan activator.W
tok, planAt := m.newWakeToken(rt.Host)
defer func() {
plan.Token = tok
+ // Write-ahead (Task 5, Astra R1 #5): a first wake's intent is on disk
+ // before the activator takes the lease, with no activator lock held
+ // here. It ends on the wake's terminal event (OnGen). A plan whose
+ // intent could not be written boots nothing, with the approved
+ // refusal for a failure book this box cannot keep, and is
+ // registered as the refusal it now is.
+ plan = m.beginWake(rt, plan, tok, planAt)
m.notePlanned(rt, plan, tok)
// The book's snapshot is taken here, at the plan's end, after every
// book read the planner made (decideWake, the replan): the wake's
@@ -223,7 +237,7 @@ func (m *Manager) Plan(ctx context.Context, rt registry.Route) (plan activator.W
// hold that went down, or a swap whose entry stopped under it, is
// recorded by done() before anything wakes the instance, so no wake
// can boot the good build ahead of a hold-down record.
- return activator.WakePlan{Refuse: "this instance is still moving to another build"}
+ return activator.WakePlan{Refuse: movingRefusal}
}
if rt.Version == "" {
// A cold managed host (spec §6.4, §8.4): its build is installed and
@@ -271,6 +285,37 @@ func (m *Manager) Plan(ctx context.Context, rt registry.Route) (plan activator.W
}
}
+// movingRefusal is a wake's refusal while the instance's attempt has not
+// reported (Plan's attemptRunning check, and beginWake meeting the
+// attempt's intent between its slot freeing and its End).
+const movingRefusal = "this instance is still moving to another build"
+
+// beginWake writes the intent of plan, a first wake planned with token tok
+// at planAt (Task 5 rule 1), and returns the plan to hand the activator:
+// plan itself, or a refusal when the intent could not be written. Any
+// other plan writes nothing. The intent's mode is the plan's: the activator
+// runs a planned wake in the mode it was planned in (a foreign-layout row
+// gets one generation at a time, so no override applies; its events carry
+// plan.Mode), and that is the mode a failure of the wake records.
+func (m *Manager) beginWake(rt registry.Route, plan activator.WakePlan, tok uint64, planAt time.Time) activator.WakePlan {
+ if !plan.FirstWake || plan.Boot.Version == "" || plan.Refuse != "" {
+ return plan
+ }
+ in := Intent{Account: release.RouteAccount(rt), App: rt.App, Build: plan.Boot.Version, Mode: string(plan.Mode),
+ ID: wakeExecID(tok), Start: planAt}
+ err := m.d.Book.Begin(rt.Host, in)
+ if err == nil {
+ return plan
+ }
+ m.d.Logf("rollout: %s: write the intent of its first wake of %s: %v; refusing the wake", rt.Host, plan.Boot.Version, err)
+ if errors.Is(err, ErrIntentStands) {
+ // The instance's attempt finished and has not yet ended its intent:
+ // it is still, for a moment, moving. Not a book fault.
+ return activator.WakePlan{Refuse: movingRefusal, Token: tok}
+ }
+ return activator.WakePlan{Refuse: BookUnreadableRefusal, Token: tok}
+}
+
// planRow is one plan against rt (Plan's body, after the cold install), for
// the plan whose token is tok. It registers rt's installed build as the
// host's in-flight planned build before any slow read (the policy, the
@@ -403,7 +448,8 @@ func (m *Manager) OnGen(ev activator.GenEvent) {
// Every kind but a first wake's healthy is the wake's last event, which
// lets the entry go: an ordinary wake (an untried first wake's good
// build included) reports healthy once and nothing after.
- started := m.wakeStarted(rt.Host, ev.Token, ev.Kind != activator.GenHealthy || !ev.FirstWake)
+ ended := ev.Kind != activator.GenHealthy || !ev.FirstWake
+ started := m.wakeStarted(rt.Host, ev.Token, ended)
m.mu.Lock()
newest := ev.Token != 0 && m.wakeTok[rt.Host] == ev.Token
// The wake has an outcome: its build is no longer only planned, so its
@@ -472,6 +518,20 @@ func (m *Manager) OnGen(ev activator.GenEvent) {
m.pointerReadDue(appKey{mark.Account, mark.App})
}
}
+ // The wake's intent ends with the first event that settles its planned
+ // build's fate (Task 5 rule 2, plan review #1), after that event's own
+ // book write above (buildFailed's record, the hold-ran marker): settled,
+ // failed, fell back, cancelled (with or without a run), and healthy when
+ // it is not a first wake's, which is the good build booted under the
+ // verdict floor with the planned build never run. A first wake's healthy
+ // event opens its observation and is not the end. An older wake's event
+ // carries its own token, so it never ends a newer wake's intent. Not
+ // under activator.Service.mu (emitGen's rule).
+ if ended {
+ if err := m.d.Book.End(rt.Host, wakeExecID(ev.Token)); err != nil {
+ m.d.Logf("rollout: %s: end the intent of its wake: %v (it stays on disk until a save lands)", rt.Host, err)
+ }
+ }
}
// wakeConfigActivated is configActivated for a planned wake's event (final