carlos / platform Public

zdd-readiness: a persisted book sequence orders marker clears and pre-retry outcomes (N6, retry ordering)

The failure book keeps a persisted sequence. Every hold-ran marker takes the next value (HoldRan.Seq), and every retry clear does too, in memory: carlos instances retry per host, and a promote --retry pointer clear app-wide. An execution takes a snapshot when it starts (an attempt in scheduler.execute beside Started, with an ExecID from a Manager-wide counter; a planned wake in Plan, kept per token beside its plan time). A good write clears only a marker set before its execution began (ClearHoldRanBefore; Bootstrap passes zero and clears nothing), so a delayed older publication no longer erases a newer marker (Astra R3 N6). Markers are per execution (HoldRan.Exec, plan review finding 4): another run of the same build renews the sequence. Record returns a disposition; a failure whose execution began before a retry since is PreRetry: no record change, no hold-down, no halt and no supersession (plan review finding 3, ruling 191), while a requested marker is still set. The simulation models the same ordering; mutate.sh's M3 follows the renamed line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 0d6029b3dab9b3fb5a7cb4de4e83d6ca40dd1a90 parent 53f3a21
12 files changed, +861 −133
  • internal/rollout/admit_test.go +1 −1
  • internal/rollout/bookseq_test.go +376 −0
  • internal/rollout/failures.go +219 −24
  • internal/rollout/failures_test.go +4 −4
  • internal/rollout/holdran_test.go +9 −6
  • internal/rollout/manager.go +95 −49
  • internal/rollout/queue.go +26 −4
  • internal/rollout/scheduler.go +27 −9
  • internal/rollout/sim_test.go +77 −18
  • internal/rollout/testdata/rolloutsim/mutate.sh +1 −1
  • internal/rollout/wakeplan.go +23 −14
  • internal/rollout/wakeplan_test.go +3 −3
diff --git a/internal/rollout/admit_test.go b/internal/rollout/admit_test.go
index dd2eb5b01..afe72fd4f 100644
--- a/internal/rollout/admit_test.go
+++ b/internal/rollout/admit_test.go
@@ -116,7 +116,7 @@ type orderBook struct {
atRecord uint64
}
-func (o *orderBook) Record(r FailureRecord) (bool, error) {
+func (o *orderBook) Record(r FailureRecord) (RecordResult, error) {
o.atRecord = o.q.newest(o.host)
return o.FailureBook.Record(r)
}
diff --git a/internal/rollout/bookseq_test.go b/internal/rollout/bookseq_test.go
new file mode 100644
index 000000000..dfe3bb732
--- /dev/null
+++ b/internal/rollout/bookseq_test.go
@@ -0,0 +1,376 @@
+package rollout
+
+import (
+ "context"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/appconfig"
+)
+
+// N6, Astra's sequence: A's good write was decided before hold B ran on the
+// host; B's marker is newer, and A's write must not clear it.
+func TestAnOlderGoodWriteNeverClearsANewerMarker(t *testing.T) {
+ b := &FailureBook{}
+ snapA := b.Snapshot()
+ if err := b.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2"}); err != nil {
+ t.Fatal(err)
+ }
+ if cleared, _ := b.ClearHoldRanBefore("h", snapA); cleared {
+ t.Fatal("a write decided before the marker cleared it")
+ }
+ if cleared, _ := b.ClearHoldRanBefore("h", b.Snapshot()); !cleared {
+ t.Fatal("a write decided after the marker must clear it")
+ }
+}
+
+func TestAZeroSnapshotClearsNothing(t *testing.T) {
+ b := &FailureBook{}
+ _ = b.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2"})
+ if cleared, _ := b.ClearHoldRanBefore("h", 0); cleared {
+ t.Fatal("no snapshot must clear nothing")
+ }
+}
+
+// Plan review finding 4: a marker belongs to one execution, not to its
+// build. B's marker (execution a1) stands before C's snapshot; B then runs
+// again (a2) while C's good write is delayed. The second execution renews
+// the marker's sequence, so C's write clears nothing. A repeated report of
+// one execution changes nothing.
+func TestAnotherExecutionOfTheMarkedBuildRenewsTheMarker(t *testing.T) {
+ now := t0
+ b := &FailureBook{now: func() time.Time { return now }}
+ if err := b.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2", Exec: "a1", At: t0}); err != nil {
+ t.Fatal(err)
+ }
+ snapC := b.Snapshot()
+ second := t0.Add(time.Minute)
+ if err := b.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2", Exec: "a2", At: second}); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := b.HoldRanOn("h")
+ if got.Exec != "a2" || got.Seq < snapC || !got.At.Equal(second) {
+ t.Fatalf("marker %+v after a second execution of v2: want a2's, sequence at least %d, stamped %v", got, snapC, second)
+ }
+ if cleared, _ := b.ClearHoldRanBefore("h", snapC); cleared {
+ t.Fatal("C's good write cleared the marker of an execution that began after C's snapshot")
+ }
+ // The same execution reported again keeps its sequence and its stamp.
+ _ = b.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2", Exec: "a2", At: second.Add(time.Minute)})
+ if again, _ := b.HoldRanOn("h"); again != got {
+ t.Fatalf("marker %+v after a repeated report of a2, want it unchanged at %+v", again, got)
+ }
+ // A late report of an earlier execution of the same build never moves
+ // the stamp back: a rollback signed between the two runs predates a2.
+ _ = b.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2", Exec: "a0", At: t0.Add(-time.Minute)})
+ if late, _ := b.HoldRanOn("h"); !late.At.Equal(second) || late.Seq <= got.Seq {
+ t.Fatalf("marker %+v after a late report of an earlier run: want the stamp kept at %v and a newer sequence", late, second)
+ }
+}
+
+// Retry ordering: a failure of an attempt that began before the host's retry
+// never takes the retry back.
+func TestAPreRetryFailureLeavesTheRetryStanding(t *testing.T) {
+ b := &FailureBook{}
+ before := b.Snapshot()
+ b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "s", Seq: before})
+ if ok, _ := b.RetryHost("bab", "blog", "v2", "h"); !ok {
+ t.Fatal("retry")
+ }
+ if res, _ := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Seq: before, HeldDown: []string{"h"}}); res != PreRetry {
+ t.Fatalf("Record = %v, want PreRetry", res)
+ }
+ if b.Blocks("bab", "blog", "v2", "h", false) || b.HeldDown("bab", "blog", "v2", "h") {
+ t.Fatal("a pre-retry failure undid the retry")
+ }
+ b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Seq: b.Snapshot()})
+ if !b.Blocks("bab", "blog", "v2", "h", false) {
+ t.Fatal("a failure after the retry must stand")
+ }
+}
+
+func TestAPreRetryFailureNeverRecreatesAClearedRecord(t *testing.T) {
+ b := &FailureBook{}
+ before := b.Snapshot()
+ b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "s", Seq: before})
+ if ok, _ := b.ClearOnPointer("bab", "blog", "v2", time.Now().Add(time.Hour)); !ok {
+ t.Fatal("clear")
+ }
+ b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "t", Seq: before, MarkHoldRan: true, Mode: "hold"})
+ if _, held := b.Lookup("bab", "blog", "v2"); held {
+ t.Fatal("a pre-retry failure recreated the record")
+ }
+ if _, marked := b.HoldRanOn("t"); !marked {
+ t.Fatal("the marker is a fact about t's database and must still be set")
+ }
+}
+
+// A pre-retry hold-down leaves what the failure followed by the retry would
+// have left: the retry clears the hold-down, so nothing holds the host down
+// and no marker stands in its place. A requested marker is set, with the
+// execution's identity and start.
+func TestAPreRetryHoldDownLeavesWhatTheRetryLeaves(t *testing.T) {
+ b := &FailureBook{}
+ before := b.Snapshot()
+ b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "s", Seq: before})
+ b.RetryHost("bab", "blog", "v2", "h")
+ started := t0.Add(-time.Minute)
+ b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Seq: before, Mode: "hold", HeldDown: []string{"h"},
+ HoldRanAt: started, HoldRanExec: "a7"})
+ if b.HeldDown("bab", "blog", "v2", "h") {
+ t.Fatal("a pre-retry failure held the retried host down")
+ }
+ if got, ok := b.HoldRanOn("h"); ok {
+ t.Fatalf("marker %+v: a hold-down that was never made left one", got)
+ }
+ b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Seq: before, Mode: "hold",
+ MarkHoldRan: true, HoldRanAt: started, HoldRanExec: "a8"})
+ got, ok := b.HoldRanOn("h")
+ if !ok || got.Build != "v2" || got.Exec != "a8" || !got.At.Equal(started) {
+ t.Fatalf("marker %+v, %v: want v2's from execution a8, stamped %v", got, ok, started)
+ }
+}
+
+// Every `carlos instances retry` of a host on a standing record orders the
+// failures after it, a second retry included: an execution that began
+// between the two is before the newest one.
+func TestASecondRetryOrdersTheFailuresBeforeIt(t *testing.T) {
+ b := &FailureBook{}
+ b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "s"})
+ b.RetryHost("bab", "blog", "v2", "h")
+ between := b.Snapshot()
+ if ok, _ := b.RetryHost("bab", "blog", "v2", "h"); ok {
+ t.Fatal("a second retry of a retried host reported a change")
+ }
+ if res, _ := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Seq: between}); res != PreRetry {
+ t.Fatalf("Record = %v, want PreRetry for an execution before the second retry", res)
+ }
+}
+
+// What Record reports, which failCohort acts on (plan review finding 3).
+func TestRecordReportsWhatItDid(t *testing.T) {
+ b := &FailureBook{}
+ if res, _ := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a"}); res != Recorded {
+ t.Fatalf("first failure: %v, want Recorded", res)
+ }
+ if res, _ := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "b", HeldDown: []string{"b"}}); res != Merged {
+ t.Fatalf("a new hold-down: %v, want Merged", res)
+ }
+ if res, _ := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "b", HeldDown: []string{"b"}}); res != Unchanged {
+ t.Fatalf("the same failure again: %v, want Unchanged", res)
+ }
+ if res, _ := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "c", Seq: b.Snapshot()}); res == PreRetry {
+ t.Fatal("a failure with no retry before it was taken as pre-retry")
+ }
+}
+
+// A retry clear goes with its build's record when no route installs the
+// build: Prune drops both.
+func TestPruneDropsTheRetryClearsOfBuildsNoRouteInstalls(t *testing.T) {
+ b := &FailureBook{}
+ before := b.Snapshot()
+ b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "s"})
+ b.RetryHost("bab", "blog", "v2", "h")
+ if err := b.Prune(func(string, string, string) bool { return false }); err != nil {
+ t.Fatal(err)
+ }
+ if len(b.cleared) != 0 {
+ t.Fatalf("clears %v survived the prune of their build", b.cleared)
+ }
+ if res, _ := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Seq: before}); res != Recorded {
+ t.Fatalf("Record = %v after the prune, want Recorded", res)
+ }
+}
+
+func TestTheSequenceSurvivesAReopen(t *testing.T) {
+ dd := t.TempDir()
+ b, _ := OpenFailureBook(dd)
+ _ = b.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2"})
+ b2, err := OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cleared, _ := b2.ClearHoldRanBefore("h", b2.Snapshot()); !cleared {
+ t.Fatal("after a reopen a fresh snapshot must exceed the old marker")
+ }
+}
+
+// N6 through the Manager: h's first wake of v1 is planned (its snapshot
+// taken) and is observed; meanwhile a scheduled hold attempt onto v2 runs
+// on h and is superseded, which marks h. v1's settle then arrives and
+// writes v1 as h's good build: the write was decided before v2 ran, so v2's
+// marker stands.
+func TestASettleDecidedBeforeAHoldRanKeepsItsMarker(t *testing.T) {
+ ctx := context.Background()
+ h := newHarness(t, "h")
+ r := h.rows.get("h")
+ r.GoodVersion, r.GoodDigest = "", "" // v1 has not passed here yet
+ h.rows.put(r)
+ h.swap = appconfig.SwapHold
+ h.stage("v2")
+ h.act.mode = activator.ModeHold
+ h.act.executed = func(a activator.Attempt, _ activator.SwapOutcome) bool { return a.Build == "v2" }
+
+ p := h.m.Plan(ctx, h.rows.get("h"))
+ if !p.FirstWake || p.Boot.Version != "v1" {
+ t.Fatalf("plan %+v: want a first wake of v1", p)
+ }
+ 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})
+
+ gate := h.act.hold("h")
+ h.install("v2", "h")
+ h.pass()
+ waitFor(t, "h's hold attempt onto v2", func() bool {
+ _, started, _ := h.act.snapshot()
+ return slices.Contains(started, "h:v2")
+ })
+ h.install("v3", "h") // v3 is not staged: its attempt is refused, unrun
+ h.pass()
+ close(gate)
+ waitFor(t, "v2's marker", func() bool { m, ok := h.book.HoldRanOn("h"); return ok && m.Build == "v2" })
+
+ h.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: h.rows.get("h"), Gen: g, Mode: p.Mode, FirstWake: true, Token: p.Token})
+ if r := h.rows.get("h"); r.GoodVersion != "v1" {
+ t.Fatalf("good build %q after v1's settle, want v1", r.GoodVersion)
+ }
+ if m, ok := h.book.HoldRanOn("h"); !ok || m.Build != "v2" {
+ t.Fatalf("marker %+v, %v after v1's settle: want v2's, set after v1's wake was planned", m, ok)
+ }
+}
+
+// Plan review finding 3: an outcome that began before a retry is stale, and
+// none of the failure transition runs for it. After `carlos instances retry`
+// of the failing host, or a `carlos promote --retry` pointer that cleared
+// the record, a delayed failure of an attempt or of a planned wake that
+// began before it leaves the cohort unhalted, supersedes no running attempt
+// (y's), and leaves the retried host free: its queued adoption is admitted,
+// or its next wake plans the retried build.
+func TestAPreRetryOutcomeRunsNoFailureTransition(t *testing.T) {
+ for _, retry := range []string{"host", "pointer"} {
+ for _, kind := range []string{"attempt", "wake"} {
+ t.Run(retry+" retry, "+kind, func(t *testing.T) {
+ preRetryOutcome(t, retry, kind)
+ })
+ }
+ }
+}
+
+func preRetryOutcome(t *testing.T, retry, kind string) {
+ ctx := context.Background()
+ k := appKey{"bab", "blog"}
+ h := newHarness(t, "h", "y", "w")
+ h.concurrency = 2
+ h.swap = appconfig.SwapHold
+ h.stage("v2")
+ h.install("v2", "h", "y", "w")
+ s := h.m.scheduler(k)
+ s.mu.Lock()
+ // The cohort's canary passed on another host already, so h and y run
+ // side by side.
+ *s.q.cohortFor(cohortKey("v2", classAdopt)) = cohort{canary: "x", settled: true}
+ s.mu.Unlock()
+ gateH, gateY := h.act.hold("h"), h.act.hold("y")
+ h.sleepHost("w")
+ h.pass()
+ waitFor(t, "h's and y's attempts onto v2", func() bool {
+ _, started, _ := h.act.snapshot()
+ return slices.Contains(started, "h:v2") && slices.Contains(started, "y:v2")
+ })
+ host := "h"
+ var plan activator.WakePlan
+ if kind == "wake" {
+ host = "w"
+ h.wakeHost("w")
+ plan = h.m.Plan(ctx, h.rows.get("w"))
+ if !plan.FirstWake || plan.Boot.Version != "v2" {
+ t.Fatalf("w's plan %+v: want a first wake of v2", plan)
+ }
+ }
+
+ // A sibling's failure of v2, written straight into the book: no halt
+ // stands, so a halt after this point is the stale outcome's.
+ h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "s", PromotedAt: t0, At: t0})
+ switch retry {
+ case "host":
+ if !h.m.RetryHost(host) {
+ t.Fatalf("RetryHost(%s) cleared nothing", host)
+ }
+ case "pointer":
+ h.advance(time.Minute)
+ h.pointer("stable", "v2", h.clock())
+ h.pass()
+ if _, held := h.book.Lookup("bab", "blog", "v2"); held {
+ t.Fatal("the retry pointer left v2's record standing")
+ }
+ }
+ s.mu.Lock()
+ revY := s.q.newest("y")
+ s.mu.Unlock()
+
+ // The outcome that began before the retry arrives now.
+ if kind == "attempt" {
+ h.act.mu.Lock()
+ h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
+ if a.Host == "h" && a.Cause != activator.CauseRetry {
+ return activator.SwapFailed
+ }
+ return activator.SwapDone
+ }
+ h.act.mu.Unlock()
+ gateH2 := h.act.hold("h") // h's next attempt parks in Swap
+ defer close(gateH2)
+ close(gateH)
+ waitFor(t, "h's attempt after the stale failure", func() bool {
+ calls, started, _ := h.act.snapshot()
+ n := 0
+ for _, st := range started {
+ if st == "h:v2" {
+ n++
+ }
+ }
+ return len(calls) >= 1 && n == 2
+ })
+ } else {
+ defer close(gateH)
+ h.m.OnGen(activator.GenEvent{Kind: activator.GenFailed, Route: h.rows.get("w"), Failed: plan.Boot, Mode: plan.Mode,
+ FirstWake: true, Reason: "health check failed", Token: plan.Token, ConfigRev: plan.ConfigRev})
+ }
+ defer close(gateY)
+
+ s.mu.Lock()
+ halted := s.q.cohortFor(cohortKey("v2", classAdopt)).halted
+ y := s.q.hosts["y"]
+ yRunning, yRev, yNewest := y.running != nil, uint64(0), y.buildRev
+ if y.running != nil {
+ yRev = y.running.SchedRev
+ }
+ s.mu.Unlock()
+ if halted {
+ t.Fatal("a stale outcome halted the cohort")
+ }
+ if !yRunning || yRev != revY || yNewest != revY {
+ t.Fatalf("y's attempt: running %v, rev %d, newest %d; want it running unsuperseded at %d", yRunning, yRev, yNewest, revY)
+ }
+ if h.book.Blocks("bab", "blog", "v2", host, false) || h.book.HeldDown("bab", "blog", "v2", host) {
+ t.Fatalf("the stale outcome took %s's retry back", host)
+ }
+ if retry == "pointer" {
+ if _, held := h.book.Lookup("bab", "blog", "v2"); held {
+ t.Fatal("the stale outcome recreated the cleared record")
+ }
+ }
+ if kind == "attempt" {
+ calls, _, _ := h.act.snapshot()
+ if c := calls[0].a; c.Host != "h" || calls[0].outcome != activator.SwapFailed {
+ t.Fatalf("first finished call %+v: want h's stale failure", calls[0])
+ }
+ return
+ }
+ h.wakeHost("w")
+ if p := h.m.Plan(ctx, h.rows.get("w")); p.Refuse != "" || p.Boot.Version != "v2" || !p.FirstWake {
+ t.Fatalf("w's next plan %+v: want the retried v2", p)
+ }
+}
diff --git a/internal/rollout/failures.go b/internal/rollout/failures.go
index 7725c2a1a..f54eb73b2 100644
--- a/internal/rollout/failures.go
+++ b/internal/rollout/failures.go
@@ -50,6 +50,50 @@ type FailureRecord struct {
// HoldRanAt is the marker's At for MarkHoldRan: when the attempt that
// ran the build started (its dispatch). An instruction too, never stored.
HoldRanAt time.Time `json:"-"`
+ // HoldRanExec is the marker's Exec: the identity of the execution that
+ // failed (HoldRan.Exec). An instruction, never stored.
+ HoldRanExec string `json:"-"`
+ // Seq is the book's snapshot (FailureBook.Snapshot) taken when the
+ // failing execution started; zero when it is not known. Record compares
+ // it with the retries since (Astra ruling 191, retry ordering): a failure
+ // of an execution that began before a retry never takes the retry back.
+ // An instruction, never stored.
+ Seq uint64 `json:"-"`
+}
+
+// RecordResult is what Record did with a failure. failCohort runs the rest
+// of the cohort-failure transition (the halt and the supersession) for every
+// result but PreRetry (plan review finding 3).
+type RecordResult int
+
+const (
+ // Recorded: the build had no record; this failure is its first.
+ Recorded RecordResult = iota
+ // Merged: a record stood, and this failure changed it (its host's retry
+ // spent, a hold-down added, a newer PromotedAt).
+ Merged
+ // Unchanged: a record stood, and this failure added nothing to it.
+ Unchanged
+ // PreRetry: the failing execution began before a retry that has since
+ // cleared the build for its host or for the app (`carlos instances
+ // retry`, `carlos promote --retry`). It is stale: nothing of the record
+ // changes, and no halt or supersession may follow it. A hold-ran marker
+ // it carries is still set (Record).
+ PreRetry
+)
+
+func (r RecordResult) String() string {
+ switch r {
+ case Recorded:
+ return "recorded"
+ case Merged:
+ return "merged"
+ case Unchanged:
+ return "unchanged"
+ case PreRetry:
+ return "pre-retry"
+ }
+ return fmt.Sprintf("RecordResult(%d)", int(r))
}
// HoldRan is an instance's "hold ran" marker (final review wave 2, N2): a
@@ -61,13 +105,26 @@ type FailureRecord struct {
// (decideWake, planCheck), and a worker's replacement carries no fallback
// onto it (executeWorker, Astra R3 N5). It is a restriction on the
// instance, not a failure of the build: it blocks no sibling and halts no
-// cohort, and it lifts no failure record either (Astra R3 N7). Its own
-// good-build write (a pass), `carlos instances retry`, or a signed rollback
-// pointer newer than the marker (applyPointers, ruling 205) clears it.
+// cohort, and it lifts no failure record either (Astra R3 N7). A good-build
+// write decided after the marker was set (a pass whose execution began after
+// it, Astra R3 N6), `carlos instances retry`, or a signed rollback pointer
+// newer than the marker (applyPointers, ruling 205) clears it.
type HoldRan struct {
Account string `json:"account"`
App string `json:"app"`
Build string `json:"build"`
+ // Exec is the identity of the execution that set the marker: "a<n>" for
+ // a scheduled attempt (dispatch.ExecID), "w<token>" for a planned wake.
+ // A marker belongs to one execution, not to its build (plan review
+ // finding 4): another run of the same build renews the marker's Seq, so
+ // a good write decided between the two runs clears nothing. Empty when
+ // the caller could not tell, which counts as a new execution every time.
+ Exec string `json:"exec,omitempty"`
+ // Seq is the book's sequence when the marker was set (markLocked), so a
+ // good write clears only markers set before its execution began
+ // (ClearHoldRanBefore, Astra R3 N6). Zero in a file an earlier agent
+ // wrote, which any snapshot is newer than.
+ Seq uint64 `json:"seq,omitempty"`
// At is when the execution that set the marker started: an attempt's
// dispatch, a planned wake's plan (Task 3 fix round 1). Only a rollback
// pointer signed after it lifts the marker (ruling 205, Astra R3): a
@@ -80,8 +137,7 @@ type HoldRan struct {
At time.Time `json:"at,omitempty"`
}
-// same reports whether h and o mark the same build of the same app. At is
-// left out: marking the build again changes nothing worth a write.
+// same reports whether h and o mark the same build of the same app.
func (h HoldRan) same(o HoldRan) bool {
return h.Account == o.Account && h.App == o.App && h.Build == o.Build
}
@@ -93,6 +149,9 @@ type failureFile struct {
// agent that predates it ignores the key (encoding/json skips unknown
// fields), and a file without it has no markers.
HoldRan map[string]HoldRan `json:"hold_ran,omitempty"`
+ // 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"`
}
// FailureBook is the box's failure records. An empty path keeps them in
@@ -114,6 +173,22 @@ type FailureBook struct {
// now stamps a marker whose caller gave no At (markLocked). Nil means
// time.Now; tests and the simulation set their own clock.
now func() time.Time
+ // seq orders the book's events that later outcomes are compared with
+ // (Astra R3 N6, ruling 191): every marker set and every retry clear takes
+ // the next value, and an execution takes a Snapshot (seq+1) when it
+ // starts. An outcome is older than an event exactly when its snapshot is
+ // not greater than the event's value. It is saved with the markers, so
+ // snapshots after a restart are newer than every marker on disk; a value
+ // taken by a retry clear and never saved is harmless, because clears are
+ // not saved either.
+ seq uint64
+ // cleared holds, in memory only, the sequence of the latest retry clear
+ // per (account, app, build, host): `carlos instances retry` per host
+ // (RetryHost), `carlos promote --retry` app-wide with host "" (a
+ // ClearOnPointer that cleared a record). Executions in flight do not
+ // survive an agent restart, so neither need these. Prune drops the
+ // entries of builds no route installs, with their records.
+ cleared map[[4]string]uint64
}
func (b *FailureBook) clock() time.Time {
@@ -151,6 +226,13 @@ func OpenFailureBook(dataDir string) (*FailureBook, error) {
}
b.recs = f.Records
b.held = f.HoldRan
+ b.seq = f.Seq
+ 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)
+ }
return b, nil
}
@@ -160,7 +242,7 @@ func (b *FailureBook) saveLocked() error {
if b.path == "" {
return nil
}
- raw, err := json.MarshalIndent(failureFile{Schema: 1, Records: b.recs, HoldRan: b.held}, "", " ")
+ raw, err := json.MarshalIndent(failureFile{Schema: 1, Records: b.recs, HoldRan: b.held, Seq: b.seq}, "", " ")
if err == nil {
err = release.WriteFileAtomic(b.path, raw, 0o644)
}
@@ -183,6 +265,49 @@ func (b *FailureBook) find(acct, app, build string) int {
})
}
+// Snapshot is the book's sequence plus one, taken when an execution starts
+// (an attempt in scheduler.execute, a planned wake in Plan): every marker
+// and retry clear already in the book is older than it, and every one set
+// later is not (Astra R3 N6, ruling 191). Zero is never a snapshot: it
+// means "not known", which clears no marker and makes no outcome stale.
+func (b *FailureBook) Snapshot() uint64 {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.seq + 1
+}
+
+// bumpLocked takes the book's next sequence value. The caller holds b.mu.
+func (b *FailureBook) bumpLocked() uint64 {
+ b.seq++
+ return b.seq
+}
+
+// clearKey is a retry clear's key in FailureBook.cleared; host "" is the
+// app-wide clear of `carlos promote --retry`.
+func clearKey(acct, app, build, host string) [4]string {
+ return [4]string{acct, app, build, host}
+}
+
+// preRetryLocked reports whether r's execution began before a retry that
+// cleared its build since, for its host or app-wide (ruling 191): its
+// snapshot is not newer than the clear. The caller holds b.mu.
+func (b *FailureBook) preRetryLocked(r FailureRecord) bool {
+ if r.Seq == 0 {
+ return false
+ }
+ return r.Seq <= b.cleared[clearKey(r.Account, r.App, r.Build, r.Host)] ||
+ r.Seq <= b.cleared[clearKey(r.Account, r.App, r.Build, "")]
+}
+
+// clearedLocked notes a retry clear of build for host ("" app-wide) at the
+// book's next sequence value. The caller holds b.mu.
+func (b *FailureBook) clearedLocked(acct, app, build, host string) {
+ if b.cleared == nil {
+ b.cleared = map[[4]string]uint64{}
+ }
+ b.cleared[clearKey(acct, app, build, host)] = b.bumpLocked()
+}
+
// Record writes r unless a record for its build already stands, in which case
// the first failure stands and only r's host is merged in: it leaves Retried
// (its retry was spent), joins HeldDown if r holds it down, and the record
@@ -192,19 +317,34 @@ func (b *FailureBook) find(acct, app, build string) int {
// 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.
-func (b *FailureBook) Record(r FailureRecord) (bool, error) {
+//
+// 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
+// host's retry is not spent and no hold-down is added, and Record reports
+// PreRetry so the caller runs none of the transition (plan review finding
+// 3). That is what the failure followed by the retry would have left: the
+// retry clears the host's hold-down. A requested hold-ran marker is still
+// set, because it is a fact about the instance's database, not a failure.
+func (b *FailureBook) Record(r FailureRecord) (RecordResult, error) {
b.mu.Lock()
defer b.mu.Unlock()
- marked := r.MarkHoldRan && b.markLocked(r.Host, HoldRan{Account: r.Account, App: r.App, Build: r.Build, At: r.HoldRanAt})
- r.MarkHoldRan, r.HoldRanAt = false, time.Time{}
+ stale := b.preRetryLocked(r)
+ marked := r.MarkHoldRan && 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()
+ }
i := b.find(r.Account, r.App, r.Build)
if i < 0 {
r.Retried = nil
b.recs = append(b.recs, cloneRec(r))
- return true, b.saveLocked()
+ return Recorded, b.saveLocked()
}
cur := &b.recs[i]
- changed := marked
+ changed := false
if j := slices.Index(cur.Retried, r.Host); j >= 0 {
cur.Retried = slices.Delete(slices.Clone(cur.Retried), j, j+1)
changed = true
@@ -219,22 +359,32 @@ func (b *FailureBook) Record(r FailureRecord) (bool, error) {
changed = true
}
}
- if !changed {
- return false, b.retryLocked()
+ switch {
+ case changed:
+ return Merged, b.saveLocked()
+ case marked:
+ return Unchanged, b.saveLocked()
}
- return false, b.saveLocked()
+ return Unchanged, b.retryLocked()
}
// markLocked sets host's hold-ran marker to h and reports whether that
// changed anything. h.At is the execution's start, from the caller. A zero
// one (a caller that could not know it) is stamped with the book's clock
// now: later than any real start, so only a pointer signed later still
-// lifts the marker, the safe direction. A marker already standing for the
-// same build is left as it is, its first stamp included, so marking it
-// again writes nothing (Task 4 gives each execution its own identity). The
-// caller holds b.mu and saves.
+// lifts the marker, the safe direction.
+//
+// A marker is an execution's (plan review finding 4). A repeated report of
+// the execution that set the standing marker (same build, same non-empty
+// Exec) changes nothing and writes nothing. Any other execution gets the
+// book's next sequence value, so a good write whose execution began before
+// it clears nothing (ClearHoldRanBefore); for the same build it keeps the
+// later of the two stamps, so a late report of an earlier run never makes a
+// rollback signed between the runs look newer than the marker. The caller
+// holds b.mu and saves.
func (b *FailureBook) markLocked(host string, h HoldRan) bool {
- if cur, ok := b.held[host]; ok && cur.same(h) {
+ cur, ok := b.held[host]
+ if ok && cur.same(h) && h.Exec != "" && cur.Exec == h.Exec {
return false
}
if b.held == nil {
@@ -243,6 +393,10 @@ func (b *FailureBook) markLocked(host string, h HoldRan) bool {
if h.At.IsZero() {
h.At = b.clock()
}
+ if ok && cur.same(h) && cur.At.After(h.At) {
+ h.At = cur.At
+ }
+ h.Seq = b.bumpLocked()
b.held[host] = h
return true
}
@@ -269,9 +423,9 @@ func (b *FailureBook) HoldRanOn(host string) (HoldRan, bool) {
return h, ok
}
-// ClearHoldRan drops host's hold-ran marker: the instance recorded a new
-// good build, or `carlos instances retry` named it. It reports whether a
-// marker stood.
+// ClearHoldRan drops host's hold-ran marker, whatever it is: `carlos
+// instances retry` named the host, the explicit recovery. It reports whether
+// a marker stood.
func (b *FailureBook) ClearHoldRan(host string) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -282,6 +436,24 @@ func (b *FailureBook) ClearHoldRan(host string) (bool, error) {
return true, b.saveLocked()
}
+// ClearHoldRanBefore drops host's hold-ran marker for a good-build write
+// whose execution took snapshot snap (Astra R3 N6): only a marker set before
+// that execution began, which the instance has since passed over. A marker
+// set later is a hold build that ran after the write was decided (the write
+// was delayed, say, between its acknowledgement check and here), and it
+// stands. A zero snapshot (Bootstrap, a wake whose start is not known)
+// clears nothing. The check and the clear are one hold of the lock.
+func (b *FailureBook) ClearHoldRanBefore(host string, snap uint64) (bool, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ cur, ok := b.held[host]
+ if !ok || snap == 0 || cur.Seq >= snap {
+ return false, b.retryLocked()
+ }
+ delete(b.held, host)
+ return true, b.saveLocked()
+}
+
// ClearHoldRanOnPointer is an explicit rollback as this box sees it (ruling
// 205, Astra R3): host's route installs its good build, and the signed
// pointer of its channel names that build with promotedAt. The caller has
@@ -337,11 +509,22 @@ func (b *FailureBook) HeldDown(acct, app, build, host string) bool {
// RetryHost is `carlos instances retry <host>`: it clears the record for that
// host only (spec §8.4). How the request reaches the box is plan 6's.
+//
+// Every retry of a standing record also notes a host clear at the book's
+// next sequence value (ruling 191), a retry of an already retried host
+// included: a failure of an execution of host that began before the newest
+// retry is then stale (Record), and takes nothing back. Only the clear is
+// new for a host already retried, and it is kept in memory, so nothing is
+// written for it.
func (b *FailureBook) RetryHost(acct, app, build, host string) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
i := b.find(acct, app, build)
- if i < 0 || slices.Contains(b.recs[i].Retried, host) {
+ if i < 0 {
+ return false, b.retryLocked()
+ }
+ b.clearedLocked(acct, app, build, host)
+ if slices.Contains(b.recs[i].Retried, host) {
return false, b.retryLocked()
}
cur := &b.recs[i]
@@ -355,7 +538,10 @@ func (b *FailureBook) RetryHost(acct, app, build, host string) (bool, error) {
// ClearOnPointer is `carlos promote --retry` as this box sees it: a signed
// channel pointer naming build whose PromotedAt is newer than the record's
// clears the record (spec rev 14 §8.4). The pointer's signature is verified
-// by the caller's read; nobody can forge a fresh one.
+// by the caller's read; nobody can forge a fresh one. A clear is noted
+// app-wide at the book's next sequence value (ruling 191), so a failure of
+// any instance's execution that began before it is stale (Record) and never
+// writes the record back.
func (b *FailureBook) ClearOnPointer(acct, app, build string, promotedAt time.Time) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -364,6 +550,7 @@ func (b *FailureBook) ClearOnPointer(acct, app, build string, promotedAt time.Ti
return false, b.retryLocked()
}
b.recs = slices.Delete(b.recs, i, i+1)
+ b.clearedLocked(acct, app, build, "")
return true, b.saveLocked()
}
@@ -385,9 +572,17 @@ func (b *FailureBook) ForApp(acct, app string) []FailureRecord {
// (spec §8.4), and only for those. installed is called under the book's lock
// and must take no lock of its own (nor call back into the book). The
// scheduler runs Prune every sweep, so it also retries a save that failed.
+// The retry clears of such builds go too (they are in memory only): a stale
+// failure of one arriving after is a new record, which the next Prune drops
+// again.
func (b *FailureBook) Prune(installed func(acct, app, build string) bool) error {
b.mu.Lock()
defer b.mu.Unlock()
+ for k := range b.cleared {
+ if !installed(k[0], k[1], k[2]) {
+ delete(b.cleared, k)
+ }
+ }
kept := b.recs[:0]
for _, r := range b.recs {
if installed(r.Account, r.App, r.Build) {
diff --git a/internal/rollout/failures_test.go b/internal/rollout/failures_test.go
index 77c84ce95..93351996a 100644
--- a/internal/rollout/failures_test.go
+++ b/internal/rollout/failures_test.go
@@ -21,7 +21,7 @@ func rec(build, host string) FailureRecord {
func TestFailureRecordBlocksOnlyInstancesThatHaveNotServedTheBuild(t *testing.T) {
b := &FailureBook{}
added, err := b.Record(rec("v2", "a.example.com"))
- if err != nil || !added {
+ if err != nil || added != Recorded {
t.Fatalf("Record = %v, %v", added, err)
}
if !b.Blocks("bab", "blog", "v2", "b.example.com", false) {
@@ -36,7 +36,7 @@ func TestFailureRecordBlocksOnlyInstancesThatHaveNotServedTheBuild(t *testing.T)
if b.Blocks("bab", "other", "v2", "b.example.com", false) {
t.Fatal("the scope is (app, build, box), not the build name alone")
}
- if added, _ := b.Record(rec("v2", "b.example.com")); added {
+ if added, _ := b.Record(rec("v2", "b.example.com")); added == Recorded {
t.Fatal("the first failure stands; a second is not a second record")
}
}
@@ -222,8 +222,8 @@ func TestFailedSaveIsRetriedByTheNextRecord(t *testing.T) {
t.Fatal("the first Record must report the failed save")
}
heal()
- if added, err := b.Record(rec("v2", "a.example.com")); err != nil || added {
- t.Fatalf("the repeat Record = %v, %v; want false, nil", added, err)
+ if added, err := b.Record(rec("v2", "a.example.com")); err != nil || added != Unchanged {
+ t.Fatalf("the repeat Record = %v, %v; want unchanged, nil", added, err)
}
again, err := OpenFailureBook(dir)
if err != nil {
diff --git a/internal/rollout/holdran_test.go b/internal/rollout/holdran_test.go
index 6eeb741c2..2ac2d71d9 100644
--- a/internal/rollout/holdran_test.go
+++ b/internal/rollout/holdran_test.go
@@ -683,17 +683,20 @@ func TestAWakesHoldRanMarkerIsStampedWithItsPlanTime(t *testing.T) {
// Ruling 205's book half: a marker keeps the At its caller gives (the
// execution's start), and one given none is stamped by the book's clock; a
-// re-mark of the same build keeps the first stamp, and writes nothing. The
-// rollback clear lifts only (acct, app)'s marker, and only for a pointer
-// newer than it.
+// repeated report of the same execution keeps the first stamp, and writes
+// nothing (another execution renews it: bookseq_test.go). The rollback
+// clear lifts only (acct, app)'s marker, and only for a pointer newer than
+// it.
func TestTheHoldRanMarkerStampAndItsRollbackClear(t *testing.T) {
now := t0
b := &FailureBook{now: func() time.Time { return now }}
- b.MarkHoldRan("a", markV2)
+ one := markV2
+ one.Exec = "a1"
+ b.MarkHoldRan("a", one)
now = now.Add(time.Minute)
- b.MarkHoldRan("a", markV2)
+ b.MarkHoldRan("a", one)
if got, _ := b.HoldRanOn("a"); !got.At.Equal(t0) {
- t.Fatalf("marker At %v after a re-mark of the same build, want %v", got.At, t0)
+ t.Fatalf("marker At %v after a repeated report of its execution, want %v", got.At, t0)
}
if ok, _ := b.ClearHoldRanOnPointer("a", "bab", "blog", t0); ok {
t.Fatal("a pointer as old as the marker cleared it")
diff --git a/internal/rollout/manager.go b/internal/rollout/manager.go
index a1218b66d..ea942bf14 100644
--- a/internal/rollout/manager.go
+++ b/internal/rollout/manager.go
@@ -6,6 +6,7 @@ import (
"log"
"os"
"slices"
+ "strconv"
"strings"
"sync"
"time"
@@ -141,15 +142,19 @@ type Manager struct {
// wake's, and acts on nothing that belongs to the newer one.
wakeTok map[string]uint64
tokenCount uint64
- // wakeAt holds, per host, the plan time of each planned first wake that
- // may still end with its build run (wakeStart, by token): the start of
- // that wake's execution, which a hold-ran marker the wake leaves carries
- // as its At (ruling 205, Task 3 fix round 1). Kept per token, not only
- // the newest plan's, because the marker is not token-scoped: a late
- // event of an older wake still marks the instance, with its own start.
- // An entry goes with its wake's last event (any kind but healthy), with
- // the host (forget), or when the host has wakeStartsKept newer ones.
+ // wakeAt holds, per host, the start of each planned wake that may still
+ // report (wakeStart, by token): its plan time, which a hold-ran marker
+ // the wake leaves carries as its At (ruling 205, Task 3 fix round 1), and
+ // the failure book's snapshot taken with it, which orders the wake's
+ // outcome against the book (Astra R3 N6, ruling 191). Kept per token,
+ // not only the newest plan's, because neither a marker nor a failure is
+ // token-scoped: a late event of an older wake still acts, as of its own
+ // start. An entry goes with its wake's last event, with the host
+ // (forget), or when the host has wakeStartsKept newer ones.
wakeAt map[string][]wakeStart
+ // execCount numbers the scheduled attempts' executions (nextExec,
+ // dispatch.ExecID).
+ execCount uint64
// goodMu serialises every serving- and good-build write with its
// acknowledgement check (served, publishServing, writeGood), so an older
@@ -582,7 +587,11 @@ func (m *Manager) retryBuild(k appKey, build, host string) {
// 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.
+// config attempt cannot start in between and spend the flag. The book also
+// notes the retry's place in its sequence there (ruling 191), in memory: an
+// outcome of an execution that began before it is stale, and takes the
+// retry back no more than it halts the cohort (failCohort). No write is
+// added under the scheduler's lock: the book saves as it did before.
//
// It also clears host's hold-ran marker (final review wave 2, N2), whatever
// build the marker names and whether or not a record held the host: the
@@ -665,53 +674,75 @@ func (m *Manager) newWakeToken(host string) (uint64, time.Time) {
return m.tokenCount, now
}
-// wakeStart is a planned first wake's plan time, by its token
-// (Manager.wakeAt).
+// nextExec numbers a scheduled attempt's execution (dispatch.ExecID).
+func (m *Manager) nextExec() uint64 {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.execCount++
+ return m.execCount
+}
+
+// wakeExecID is a planned wake's execution identity (HoldRan.Exec): "w"
+// and its token. Zero, an event of no known plan, has none.
+func wakeExecID(tok uint64) string {
+ if tok == 0 {
+ return ""
+ }
+ return "w" + strconv.FormatUint(tok, 10)
+}
+
+// wakeStart is a planned wake's start, by its token (Manager.wakeAt): its
+// plan time and the failure book's snapshot taken in the same plan.
type wakeStart struct {
token uint64
at time.Time
+ snap uint64
}
// wakeStartsKept bounds Manager.wakeAt per host. A wake that ends with no
-// event (refused at its lease) 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).
+// 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).
const wakeStartsKept = 4
-// noteWakeStart keeps the plan time of a planned first wake (tok), the only
-// kind whose end can leave a hold-ran marker (OnGen). Any mode is kept: the
-// activator reports the mode the wake ran in, which the plan does not fix.
-func (m *Manager) noteWakeStart(host string, tok uint64, at time.Time, plan activator.WakePlan) {
- if tok == 0 || !plan.FirstWake || plan.Refuse != "" || plan.Boot.StagedPath == "" {
+// noteWakeStart keeps the start of a planned wake that boots a build (tok):
+// a first wake's end can leave a hold-ran marker or a failure (OnGen), and
+// any wake's healthy event writes the good build, which clears only markers
+// older than its snapshot (Astra R3 N6). Any mode is kept: the activator
+// reports the mode the wake ran in, which the plan does not fix.
+func (m *Manager) noteWakeStart(host string, tok uint64, at time.Time, snap uint64, plan activator.WakePlan) {
+ if tok == 0 || plan.Refuse != "" || plan.Boot.StagedPath == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
- starts := append(m.wakeAt[host], wakeStart{token: tok, at: at})
+ starts := append(m.wakeAt[host], wakeStart{token: tok, at: at, snap: snap})
if len(starts) > wakeStartsKept {
starts = slices.Delete(starts, 0, len(starts)-wakeStartsKept)
}
m.wakeAt[host] = starts
}
-// wakeStartedAt is when the wake whose token is tok was planned, and drops
-// its entry when ended (the wake's last event). Zero means the start is not
-// known (a token pushed out by newer plans, or a host forgotten): the book
-// then stamps the marker with its own clock, later than the real start, so
-// only a pointer signed later still lifts it (the safe direction).
-func (m *Manager) wakeStartedAt(host string, tok uint64, ended bool) time.Time {
+// wakeStarted is the start of the wake whose token is tok, and drops its
+// entry when ended (the wake's last event). A zero result means the start
+// is not known (a token pushed out by newer plans, or a host forgotten): the
+// book then stamps a marker with its own clock, later than the real start,
+// so only a pointer signed later still lifts it; the good write clears no
+// marker; and a failure is never taken as stale. Each is the safe
+// direction.
+func (m *Manager) wakeStarted(host string, tok uint64, ended bool) wakeStart {
m.mu.Lock()
defer m.mu.Unlock()
if tok == 0 {
- return time.Time{}
+ return wakeStart{}
}
starts := m.wakeAt[host]
i := slices.IndexFunc(starts, func(w wakeStart) bool { return w.token == tok })
if i < 0 {
- return time.Time{}
+ return wakeStart{}
}
- at := starts[i].at
+ w := starts[i]
if ended {
if starts = slices.Delete(starts, i, i+1); len(starts) == 0 {
delete(m.wakeAt, host)
@@ -719,7 +750,7 @@ func (m *Manager) wakeStartedAt(host string, tok uint64, ended bool) time.Time {
m.wakeAt[host] = starts
}
}
- return at
+ return w
}
// notePlanned records what host's newest plan boots: an entry for a first
@@ -865,13 +896,15 @@ func (m *Manager) liftClearedHalts() {
// 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.
+// No execution of this agent started the generation, so the write has no
+// book snapshot and clears no hold-ran marker (Astra R3 N6).
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)
+ m.writeGood(rt.Host, g, 0)
}
}
}
@@ -1024,8 +1057,9 @@ func (m *Manager) stagedDemand(ctx context.Context, rt registry.Route, build, pa
// 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) {
+// generation the activator serves (acknowledged). snap is the book's
+// snapshot from the start of the execution that produced g (writeGoodLocked).
+func (m *Manager) served(host string, g activator.Gen, snap uint64) {
if g.Build == "" {
return
}
@@ -1036,7 +1070,7 @@ func (m *Manager) served(host string, g activator.Gen) {
return
}
m.setServingLocked(host, g)
- m.writeGoodLocked(host, g)
+ m.writeGoodLocked(host, g, snap)
}
// setServingLocked writes an instance's serving build. A worker's is the
@@ -1082,15 +1116,15 @@ func (m *Manager) acknowledged(host string, g activator.Gen) bool {
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) {
+// writeGood is writeGoodLocked for a caller outside served: Bootstrap.
+// Same terms: g must be acknowledged.
+func (m *Manager) writeGood(host string, g activator.Gen, snap uint64) {
m.goodMu.Lock()
defer m.goodMu.Unlock()
if !m.acknowledged(host, g) {
return
}
- m.writeGoodLocked(host, g)
+ m.writeGoodLocked(host, g, snap)
}
// writeGoodLocked replaces host's good build with g's identity,
@@ -1101,8 +1135,12 @@ func (m *Manager) writeGood(host string, g activator.Gen) {
//
// A good build recorded (written, or found already written) clears the
// host's hold-ran marker (final review wave 2, N2): the instance passed on
-// g, so g is what a later wake may fall back to.
-func (m *Manager) writeGoodLocked(host string, g activator.Gen) {
+// g, so g is what a later wake may fall back to. Only a marker set before
+// g's execution began (snap, Astra R3 N6): the write can be delayed after
+// its acknowledgement check, and a hold build that ran on the host
+// meanwhile (an attempt done() records without goodMu) left a marker the
+// instance has not passed over.
+func (m *Manager) writeGoodLocked(host string, g activator.Gen, snap uint64) {
if g.Build == "" || g.Digest == "" {
return
}
@@ -1113,12 +1151,12 @@ func (m *Manager) writeGoodLocked(host string, g activator.Gen) {
return
}
if cur.Good() == next {
- m.goodRecorded(host, next.Version)
+ m.goodRecorded(host, next.Version, snap)
return
}
err = m.d.Routes.SetGoodBuild(host, cur.Good(), next)
if err == nil {
- m.goodRecorded(host, next.Version)
+ m.goodRecorded(host, next.Version, snap)
return
}
if !errors.Is(err, registry.ErrGoodBuildChanged) {
@@ -1130,9 +1168,10 @@ func (m *Manager) writeGoodLocked(host string, g activator.Gen) {
}
// goodRecorded follows a good-build write that landed (writeGoodLocked):
-// the hold-ran marker goes, and the queue learns the build.
-func (m *Manager) goodRecorded(host, ver string) {
- if _, err := m.d.Book.ClearHoldRan(host); err != nil {
+// a hold-ran marker older than the write's execution (snap) goes, and the
+// queue learns the build.
+func (m *Manager) goodRecorded(host, ver string, snap uint64) {
+ if _, err := m.d.Book.ClearHoldRanBefore(host, snap); err != nil {
m.d.Logf("rollout: %s: clear hold-ran marker after good build %s: %v (cleared in memory)", host, ver, err)
}
m.observeGood(host, ver)
@@ -1209,18 +1248,25 @@ func (m *Manager) configFailure(host, rev, reason string) (time.Duration, bool)
return configBackoff(t.fails), true
}
-// cohortFailed runs the cohort-failure transition for s's app.
+// cohortFailed runs the cohort-failure transition for s's app. A failure of
+// an execution that began before a retry of its build runs none of it
+// (PreRetry, ruling 191).
func (m *Manager) cohortFailed(s *scheduler, rt registry.Route, rec FailureRecord) {
s.mu.Lock()
- added, superseded, err := failCohort(m.d.Book, s.q, rec)
+ res, 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 res == PreRetry {
+ m.d.Logf("rollout: %s/%s build %s failed on %s, but in an execution that began before a retry of the build: the retry stands", rec.Account, rec.App, rec.Build, rec.Host)
+ s.poke()
+ return
+ }
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 {
+ if res == Recorded {
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)))
}
diff --git a/internal/rollout/queue.go b/internal/rollout/queue.go
index cfd535b2c..6c0c7e1aa 100644
--- a/internal/rollout/queue.go
+++ b/internal/rollout/queue.go
@@ -51,6 +51,17 @@ type dispatch struct {
// ReplaceWorker): its execution's start, the At of a hold-ran marker it
// leaves (ruling 205, Task 3 fix round 1). Zero until then.
Started time.Time
+ // BookSeq is the failure book's snapshot (FailureBook.Snapshot), taken
+ // with Started: the order of this execution's start against the book's
+ // markers and retry clears. Its good write clears only a marker set
+ // before it (Astra R3 N6), and its failure is stale after a retry since
+ // (ruling 191). Zero until then.
+ BookSeq uint64
+ // ExecID is this execution's identity, "a<n>" from a Manager-wide counter
+ // (Manager.nextExec), set with Started. A hold-ran marker it leaves
+ // carries it (HoldRan.Exec). SchedRev cannot serve: an abandoned
+ // attempt is derived again with the same revision.
+ ExecID string
}
// hostSlot is one instance's desired state. The attempt to run is derived
@@ -485,7 +496,7 @@ func (q *appQueue) nextDue(now time.Time) time.Time {
// recorder is the failure book's write half, an interface so a test can
// watch the transition's order.
type recorder interface {
- Record(FailureRecord) (bool, error)
+ Record(FailureRecord) (RecordResult, error)
}
// failCohort is the cohort-failure transition of spec §8.1, in its order:
@@ -498,8 +509,19 @@ type recorder interface {
// holds the scheduler's lock for q, so the record, the halt and the freed
// slot (finish) are one critical section. A failed file write still returns
// the record held in memory, so the transition never stops halfway.
-func failCohort(book recorder, q *appQueue, rec FailureRecord) (added bool, superseded []string, err error) {
- added, err = book.Record(rec) // rolloutsim:M3
+//
+// A failure the book reports PreRetry began before a retry that has since
+// cleared its build (ruling 191): it is stale, and none of the transition
+// runs (plan review finding 3). Halting would stop the cohort the retry
+// reopened and supersede attempts the retry let start, though the record
+// that would justify it was never written. Every other result halts, an
+// Unchanged one included: the record it found still holds the build, and a
+// halt lives in memory beside it (an agent restart loses it).
+func failCohort(book recorder, q *appQueue, rec FailureRecord) (res RecordResult, superseded []string, err error) {
+ res, err = book.Record(rec) // rolloutsim:M3
+ if res == PreRetry {
+ return res, nil, err
+ }
superseded = q.halt(rec.Build, rec.Reason)
- return added, superseded, err
+ return res, superseded, err
}
diff --git a/internal/rollout/scheduler.go b/internal/rollout/scheduler.go
index c2016ca50..4f7dbe4ed 100644
--- a/internal/rollout/scheduler.go
+++ b/internal/rollout/scheduler.go
@@ -2,6 +2,7 @@ package rollout
import (
"context"
+ "strconv"
"sync"
"time"
@@ -135,8 +136,14 @@ func (s *scheduler) execute(ctx context.Context, d dispatch) {
}
// The execution's start, after every read and before the activator runs
// anything: a hold-ran marker this attempt leaves carries it (ruling 205),
- // so a rollback signed while it runs is newer than the marker.
+ // so a rollback signed while it runs is newer than the marker. The book's
+ // snapshot is taken at the same point, so both describe one start: the
+ // attempt's good write clears only markers set before it (Astra R3 N6),
+ // and its failure is stale after a retry since (ruling 191). ExecID names
+ // this execution for a marker it leaves (plan review finding 4).
d.Started = m.d.Now()
+ d.BookSeq = m.d.Book.Snapshot()
+ d.ExecID = "a" + strconv.FormatUint(m.nextExec(), 10)
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
@@ -178,7 +185,8 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw
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()}
+ Cause: string(d.ID.Cause), Mode: string(mode), Reason: res.Reason, PromotedAt: d.FailedUnder, At: m.d.Now(),
+ Seq: d.BookSeq, HoldRanAt: d.Started, HoldRanExec: d.ExecID}
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
@@ -218,14 +226,15 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw
// 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)
+ // activator serves (acknowledged), and clears only a hold-ran marker
+ // set before this attempt started (d.BookSeq, Astra R3 N6).
+ m.served(d.ID.Host, res.Gen, d.BookSeq)
}
now := m.d.Now()
s.mu.Lock()
transition := s.q.finish(d, res.Outcome)
var (
- added bool
+ recorded RecordResult
superseded []string
recErr error
markErr error
@@ -241,15 +250,21 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw
heldDown := transition && rec != nil && len(rec.HeldDown) > 0
mark := mode == activator.ModeHold && d.Class == classAdopt && res.Executed &&
res.Outcome != activator.SwapDone && !heldDown
+ stale := false
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.
+ // record (in memory at once, on disk as it can) and the halt. None of
+ // it runs for an attempt that began before a retry of its build
+ // (PreRetry, ruling 191): the book sets its marker, if any, and
+ // nothing else.
r := *rec
- r.MarkHoldRan, r.HoldRanAt = mark, d.Started
- added, superseded, recErr = failCohort(m.d.Book, s.q, r)
+ r.MarkHoldRan = mark
+ recorded, superseded, recErr = failCohort(m.d.Book, s.q, r)
+ stale = recorded == PreRetry
} else if mark {
- markErr = m.d.Book.MarkHoldRan(d.ID.Host, HoldRan{Account: s.key.acct, App: s.key.app, Build: d.ID.Build, At: d.Started})
+ markErr = m.d.Book.MarkHoldRan(d.ID.Host, HoldRan{Account: s.key.acct, App: s.key.app, Build: d.ID.Build, Exec: d.ExecID, At: d.Started})
}
+ added := transition && rec != nil && recorded == Recorded
if res.Outcome == activator.SwapAbandoned && !res.Executed {
// It is proven that nothing of the build ran (a refusal before its
// exec, a staging refusal, an unreadable row): a retried host's
@@ -282,6 +297,9 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw
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 stale {
+ m.d.Logf("rollout: %s: %s attempt onto %s failed, but it began before a retry of the build: the retry stands", d.ID.Host, d.ID.Cause, d.ID.Build)
+ }
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)
}
diff --git a/internal/rollout/sim_test.go b/internal/rollout/sim_test.go
index 5d76141f5..fa65fd46b 100644
--- a/internal/rollout/sim_test.go
+++ b/internal/rollout/sim_test.go
@@ -63,6 +63,7 @@ import (
// first wake ended with no verdict after its exec), no wake of that
// instance boots its good build in place of another installed build,
// as a recovery or a fallback, until the instance records a good build
+// from an execution that began after the hold build ran (Astra R3 N6)
// or a host retry names it (spec §6.4). A Pass may also lift the
// restriction, but only on rollback evidence (ruling 205): the
// instance's installed build is its good build, and its channel's
@@ -277,6 +278,18 @@ type sim struct {
// pointer must be newer than.
holdRan map[string]string
holdRanAt map[string]time.Time
+ // holdRanSet is when the restriction was last set (the step), which a
+ // good write must come after the start of: a good build whose execution
+ // began before a hold build ran on the host never lifts the restriction
+ // (Astra R3 N6).
+ holdRanSet map[string]time.Time
+ // Retry ordering (ruling 191). retryAt is each host's latest `carlos
+ // instances retry` of a build, clearedAt the latest Pass that cleared a
+ // build's record on a `carlos promote --retry` pointer. A failure of an
+ // execution that started before either is stale: it changes nothing a
+ // retry cleared and holds nothing down.
+ retryAt map[string]map[string]time.Time
+ clearedAt map[string]time.Time
swaps map[string]*simSwap
events chan simEvent
@@ -290,7 +303,8 @@ func newSim(seed int64, opt string, staged map[string]bool) *sim {
pointers: map[string]release.ChannelPointer{}, builds: map[string]*simBuild{},
lastStamp: map[string]time.Time{}, lastSigned: map[string]time.Time{}, failedEver: map[string]bool{},
exempt: map[string]map[string]bool{}, heldDown: map[string]map[string]bool{}, holdRan: map[string]string{},
- holdRanAt: map[string]time.Time{}, swaps: map[string]*simSwap{}, events: make(chan simEvent)}
+ holdRanAt: map[string]time.Time{}, holdRanSet: map[string]time.Time{}, retryAt: map[string]map[string]time.Time{},
+ clearedAt: map[string]time.Time{}, swaps: map[string]*simSwap{}, events: make(chan simEvent)}
s.book.now = func() time.Time { return s.now }
s.hold = s.r.Intn(4) == 0
s.concurrency = 1 + s.r.Intn(3)
@@ -416,8 +430,17 @@ func (s *sim) expectHold(build string) bool {
return s.hold || s.builds[build].holdDemand
}
-// failure is the ground truth of a build that ran and failed on host.
-func (s *sim) failure(build, host string, stamp time.Time, heldDown bool) {
+// failure is the ground truth of a build that ran and failed on host, in the
+// execution that started at start. One that started before a retry of the
+// build since (the host's, or a Pass's clear on a re-signed pointer) is
+// stale (ruling 191): it stands for nothing, as if the retry had come after
+// it, and holds nothing down. (A hold-ran restriction the run leaves is the
+// caller's, as for any run.)
+func (s *sim) failure(build, host string, stamp time.Time, heldDown bool, start time.Time) {
+ if !start.IsZero() && (start.Before(s.retryAt[host][build]) || start.Before(s.clearedAt[build])) {
+ s.logf("ground truth: %s failed on %s in an execution started at %s, before a retry of it: stale", build, host, start.Sub(t0))
+ return
+ }
if stamp.IsZero() {
stamp = s.now
}
@@ -529,7 +552,7 @@ func (s *sim) finishSwap(sw *simSwap) activator.SwapResult {
}
s.logf("swap %s %s %s (%s): %s", a.Host, a.Cause, a.Build, a.Mode, res.Outcome)
if fresh {
- s.failure(a.Build, a.Host, sw.stamp, res.Outcome == activator.SwapDown)
+ s.failure(a.Build, a.Host, sw.stamp, res.Outcome == activator.SwapDown, sw.started)
}
if res.Outcome == activator.SwapFailed && a.Mode == activator.ModeHold && !sw.serves {
// A hold adoption that ran, failed and was not held down.
@@ -547,7 +570,7 @@ func (s *sim) finishSwap(sw *simSwap) activator.SwapResult {
}
g := s.newGen(release.StagedIdentity{Version: a.Build, StagedPath: a.StagedPath, Digest: a.Digest, Demand: a.Demand})
h.phase, h.gen = simAwake, g
- s.goodAt(h, a.Build)
+ s.goodAt(h, a.Build, sw.started)
h.served[a.Build] = true
res.Outcome, res.Gen = activator.SwapDone, g
s.logf("swap %s %s %s (%s): done", a.Host, a.Cause, a.Build, a.Mode)
@@ -670,6 +693,12 @@ func (s *sim) pick(ok func(h *simHost) bool) *simHost {
// Pass may clear is one a signed rollback lifts (applyPointers, ruling 205),
// so each marker it cleared is checked against the model's evidence, then
// lifted in the model too.
+//
+// A record the Pass cleared while a row still installs its build was cleared
+// by a re-signed pointer (a prune drops only builds no row installs): that
+// is the retry ordering's app-wide clear (ruling 191), noted as clearedAt.
+// (Read from the book like the markers; the model's stands rule already
+// says whether it may go.)
func (s *sim) pass() {
marked := map[string]bool{}
for _, h := range s.hosts {
@@ -677,8 +706,26 @@ func (s *sim) pass() {
marked[h.name] = true
}
}
+ recorded := map[string]bool{}
+ for _, r := range s.book.List() {
+ recorded[r.Build] = true
+ }
rs, _ := s.rows.List()
s.m.Pass(rs)
+ installed := map[string]bool{}
+ for _, r := range rs {
+ installed[r.Version] = true
+ }
+ still := map[string]bool{}
+ for _, r := range s.book.List() {
+ still[r.Build] = true
+ }
+ for _, b := range sortedKeys(recorded) {
+ if !still[b] && installed[b] {
+ s.clearedAt[b] = s.now
+ s.logf("ground truth: a pass cleared %s's record on a re-signed pointer", b)
+ }
+ }
for _, h := range s.hosts {
if _, still := s.book.HoldRanOn(h.name); !marked[h.name] || still {
continue
@@ -814,21 +861,29 @@ func (s *sim) heldDownOn(host, build string) bool {
// holdRanOn is the ground truth of a hold build that ran on h without
// passing or being held down there (N2), in the execution that started at
-// start. The stamp is the first run's: running the same build again under
-// its own restriction leaves the book's marker, and its stamp, as they were.
+// start. Each execution renews the restriction (plan review finding 4): its
+// stamp is the latest start of the build's runs, so a rollback signed
+// between two runs lifts nothing, and it is set again now, so a good write
+// decided before now lifts nothing either.
func (s *sim) holdRanOn(h *simHost, build string, start time.Time) {
- if s.holdRan[h.name] != build {
+ if s.holdRan[h.name] != build || start.After(s.holdRanAt[h.name]) {
s.holdRanAt[h.name] = start
}
s.holdRan[h.name] = build
+ s.holdRanSet[h.name] = s.now
s.logf("ground truth: hold build %s ran on %s without a verdict", build, h.name)
}
// goodAt is h recording build as its good build (the code's good-build
-// write), which lifts any hold-ran restriction.
-func (s *sim) goodAt(h *simHost, build string) {
+// write) for the execution that started at start. It lifts a hold-ran
+// restriction set before that start, and only such a one (Astra R3 N6): a
+// hold build that ran on the host after the passing execution began is not
+// one the instance has passed over.
+func (s *sim) goodAt(h *simHost, build string, start time.Time) {
h.good = build
- delete(s.holdRan, h.name)
+ if s.holdRanSet[h.name].Before(start) {
+ delete(s.holdRan, h.name)
+ }
}
// checkI5 is I5 at the exec of build on h, for a wake planned against rt.
@@ -964,7 +1019,7 @@ func (s *sim) wakeExec() {
s.checkI5(h, rt, good.Version)
h.phase, h.gen = simAwake, s.newGen(good)
h.served[good.Version] = true
- s.goodAt(h, good.Version) // served: the good build is written again
+ s.goodAt(h, good.Version, h.planAt) // served: the good build is written again
s.logf("wake %s: %s untried, booted the good build %s", h.name, plan.Boot.Version, good.Version)
s.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: rt, Gen: h.gen, Mode: plan.Mode, At: s.now, Token: plan.Token, ConfigRev: plan.ConfigRev})
return
@@ -978,7 +1033,7 @@ func (s *sim) wakeExec() {
}
if !plan.FirstWake {
h.phase, h.gen = simAwake, s.newGen(boot)
- s.goodAt(h, boot.Version)
+ s.goodAt(h, boot.Version, h.planAt)
h.served[boot.Version] = true
s.logf("wake %s: booted %s", h.name, boot.Version)
s.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: rt, Gen: h.gen, Mode: plan.Mode, At: s.now, Token: plan.Token, ConfigRev: plan.ConfigRev})
@@ -1014,7 +1069,7 @@ func (s *sim) wakeExec() {
case canFall && bootable(plan, plan.Fallback.Version) != nil:
// B failed, and its fallback's own gate refused the good build at
// its exec: B's failure is recorded, nothing fell back.
- s.failure(boot.Version, h.name, h.stamp, false)
+ s.failure(boot.Version, h.name, h.stamp, false, h.planAt)
h.phase, h.gen = simAsleep, activator.Gen{}
s.logf("wake %s: first wake of %s failed, its fallback %s refused at its exec", h.name, boot.Version, plan.Fallback.Version)
s.m.OnGen(activator.GenEvent{Kind: activator.GenFailed, Route: rt, Failed: boot, Mode: plan.Mode,
@@ -1024,14 +1079,14 @@ func (s *sim) wakeExec() {
}
case canFall:
s.checkI5(h, rt, plan.Fallback.Version)
- s.failure(boot.Version, h.name, h.stamp, false)
+ s.failure(boot.Version, h.name, h.stamp, false, h.planAt)
h.phase, h.gen = simAwake, s.newGen(*plan.Fallback)
s.logf("wake %s: first wake of %s failed, fell back to %s", h.name, boot.Version, plan.Fallback.Version)
s.m.OnGen(activator.GenEvent{Kind: activator.GenFellBack, Route: rt, Gen: h.gen, Failed: boot, Mode: plan.Mode,
FirstWake: true, Reason: "health check failed", At: s.now, FailedUnder: plan.FailedUnder, Token: plan.Token, ConfigRev: plan.ConfigRev})
default:
hold := plan.Mode == activator.ModeHold
- s.failure(boot.Version, h.name, h.stamp, hold)
+ s.failure(boot.Version, h.name, h.stamp, hold, h.planAt)
h.phase, h.gen = simAsleep, activator.Gen{}
s.logf("wake %s: first wake of %s failed, nothing fell back (mode %s)", h.name, boot.Version, plan.Mode)
s.m.OnGen(activator.GenEvent{Kind: activator.GenFailed, Route: rt, Failed: boot, Mode: plan.Mode,
@@ -1050,7 +1105,7 @@ func (s *sim) observeEnd() {
}
rt, plan := h.planRoute, h.plan
if h.failLater {
- s.failure(plan.Boot.Version, h.name, h.stamp, plan.Mode == activator.ModeHold)
+ s.failure(plan.Boot.Version, h.name, h.stamp, plan.Mode == activator.ModeHold, h.planAt)
h.phase, h.gen = simAsleep, activator.Gen{}
s.logf("wake %s: %s exited inside its observation", h.name, plan.Boot.Version)
s.m.OnGen(activator.GenEvent{Kind: activator.GenFailed, Route: rt, Failed: plan.Boot, Mode: plan.Mode, FirstWake: true,
@@ -1058,7 +1113,7 @@ func (s *sim) observeEnd() {
return
}
h.phase = simAwake
- s.goodAt(h, h.gen.Build)
+ s.goodAt(h, h.gen.Build, h.planAt)
h.served[h.gen.Build] = true
s.logf("wake %s: %s settled", h.name, h.gen.Build)
s.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: rt, Gen: h.gen, Mode: plan.Mode, FirstWake: true, At: s.now, Token: plan.Token, ConfigRev: plan.ConfigRev})
@@ -1140,6 +1195,10 @@ func (s *sim) hostRetry() {
s.exempt[h.name] = map[string]bool{}
}
s.exempt[h.name][b] = true
+ if s.retryAt[h.name] == nil {
+ s.retryAt[h.name] = map[string]time.Time{}
+ }
+ s.retryAt[h.name][b] = s.now
delete(s.heldDown[h.name], b)
delete(s.holdRan, h.name)
s.logf("instances retry %s onto %s: %v", h.name, b, ok)
diff --git a/internal/rollout/testdata/rolloutsim/mutate.sh b/internal/rollout/testdata/rolloutsim/mutate.sh
index ac7b0a3cb..65b232cf7 100755
--- a/internal/rollout/testdata/rolloutsim/mutate.sh
+++ b/internal/rollout/testdata/rolloutsim/mutate.sh
@@ -57,7 +57,7 @@ ONLY=" $* "
# rolloutsim:M10. The substitution is the last field, so it may contain "|".
MUTANTS='M1|queue.go|I2|s/if q\.running >= q\.concurrency \{/if false \&\& q.running >= q.concurrency {/
M2|queue.go|I3|s/class := classRecover/class := classAdopt/
-M3|queue.go|I1|s/added, err = book\.Record\(rec\)/added, err = true, nil/
+M3|queue.go|I1|s/res, err = book\.Record\(rec\)/res, err = Recorded, nil/
M4|queue.go|I4|s/h\.buildRev = q\.nextRev\(\)/_ = q.nextRev()/
M5|wakeplan.go|I1|s/if hasGood \&\& in\.Book\.Blocks\(/if false \&\& in.Book.Blocks(/
M6|wakeplan.go|I1|s/plan\.Fallback = \&good/plan.Fallback = nil/'
diff --git a/internal/rollout/wakeplan.go b/internal/rollout/wakeplan.go
index 91f055cca..b40a8d505 100644
--- a/internal/rollout/wakeplan.go
+++ b/internal/rollout/wakeplan.go
@@ -205,10 +205,14 @@ func (m *Manager) Plan(ctx context.Context, rt registry.Route) (plan activator.W
return activator.WakePlan{}
}
tok, planAt := m.newWakeToken(rt.Host)
+ // The book's snapshot, with the plan time: the start of the wake's
+ // execution. Its good write clears only markers set before it (Astra R3
+ // N6), and its failure is stale after a retry since (ruling 191).
+ snap := m.d.Book.Snapshot()
defer func() {
plan.Token = tok
m.notePlanned(rt, plan, tok)
- m.noteWakeStart(rt.Host, tok, planAt, plan)
+ m.noteWakeStart(rt.Host, tok, planAt, snap, plan)
}()
if m.attemptRunning(rt.Host) {
// The instance's attempt has not reported yet (spec §6.4, §8.1): a
@@ -389,10 +393,13 @@ func (m *Manager) attemptRunning(host string) bool {
// activator serves (acknowledged). A token of zero matches nothing.
func (m *Manager) OnGen(ev activator.GenEvent) {
rt := ev.Route
- // The wake's plan time, the start of its execution: a hold-ran marker it
- // leaves carries it (ruling 205). Every kind but healthy is the wake's
- // last event, which lets the entry go.
- started := m.wakeStartedAt(rt.Host, ev.Token, ev.Kind != activator.GenHealthy)
+ // The wake's start, its plan: a hold-ran marker it leaves carries the
+ // plan time (ruling 205), and its good write and its failure are ordered
+ // against the book by the plan's snapshot (Astra R3 N6, ruling 191).
+ // 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)
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
@@ -423,23 +430,23 @@ func (m *Manager) OnGen(ev activator.GenEvent) {
// untried under Task 9's verdict floor: an ordinary wake of the good
// build, recorded as served, with nothing against the build it skipped.
if !ev.FirstWake {
- m.served(rt.Host, ev.Gen)
+ m.served(rt.Host, ev.Gen, started.snap)
}
// A first wake is published when it settles: until then the
// activator hides it (Current), so it is not yet acknowledged.
m.wakeConfigActivated(rt.Host, ev.Token, ev.ConfigRev)
case activator.GenSettled:
- m.served(rt.Host, ev.Gen)
+ m.served(rt.Host, ev.Gen, started.snap)
case activator.GenFellBack:
// The failure hold first (spec §8.1): nothing that reads the
// serving build or the active config may see the fallback before the
// failed build is held.
- m.buildFailed(rt, ev)
+ m.buildFailed(rt, ev, started)
m.publishServing(rt.Host, ev.Gen)
m.wakeConfigActivated(rt.Host, ev.Token, ev.ConfigRev)
m.emit(rt, EvFallbackServed, "warn", textFallback(ev.Failed.Version, ev.Gen.Build))
case activator.GenFailed:
- m.buildFailed(rt, ev)
+ m.buildFailed(rt, ev, started)
case activator.GenCancelled:
// An intentional stop ended the first wake's observation, or the
// wake's lifecycle ended it after its build ran (spec §6.4): no
@@ -451,10 +458,10 @@ func (m *Manager) OnGen(ev activator.GenEvent) {
if ev.Executed && ev.FirstWake && ev.Mode == activator.ModeHold && ev.Gen.Build != "" {
// At is the wake's plan time, its execution's start (ruling 205):
// a rollback signed while the wake ran is newer than the marker.
- if started.IsZero() {
+ if started.at.IsZero() {
m.d.Logf("rollout: %s: the start of the wake that ran %s is not known; its hold-ran marker is stamped now", rt.Host, ev.Gen.Build)
}
- mark := HoldRan{Account: release.RouteAccount(rt), App: rt.App, Build: ev.Gen.Build, At: started}
+ mark := HoldRan{Account: release.RouteAccount(rt), App: rt.App, Build: ev.Gen.Build, Exec: wakeExecID(ev.Token), At: started.at}
if err := m.d.Book.MarkHoldRan(rt.Host, mark); err != nil {
m.d.Logf("rollout: %s: write hold-ran marker for %s: %v (held in memory)", rt.Host, mark.Build, err)
}
@@ -486,13 +493,15 @@ func (m *Manager) wakeConfigActivated(host string, tok uint64, rev string) {
}
// buildFailed is a failed first wake's cohort-failure transition (spec
-// §8.1, §8.2 step 4).
-func (m *Manager) buildFailed(rt registry.Route, ev activator.GenEvent) {
+// §8.1, §8.2 step 4). started is the wake's start (wakeStarted): a wake
+// planned before a retry of its build fails stale (ruling 191), and runs
+// none of the transition.
+func (m *Manager) buildFailed(rt registry.Route, ev activator.GenEvent, started wakeStart) {
// No blocking read before the record (spec §8.1): the failing pointer's
// PromotedAt was read when the wake was planned (WakePlan.FailedUnder).
rec := FailureRecord{Account: release.RouteAccount(rt), App: rt.App, Build: ev.Failed.Version, Host: rt.Host,
Cause: string(activator.CauseRelease), Mode: string(ev.Mode), Reason: ev.Reason,
- PromotedAt: ev.FailedUnder, At: m.d.Now()}
+ PromotedAt: ev.FailedUnder, At: m.d.Now(), Seq: started.snap}
if rec.PromotedAt.IsZero() {
// No pointer named the build when the wake was planned: the
// failure's own time, so only a pointer signed after it clears the
diff --git a/internal/rollout/wakeplan_test.go b/internal/rollout/wakeplan_test.go
index dcb0a8eab..eb65428b9 100644
--- a/internal/rollout/wakeplan_test.go
+++ b/internal/rollout/wakeplan_test.go
@@ -211,7 +211,7 @@ func TestTheSchedulerLeavesAWorkersServingVersionAlone(t *testing.T) {
h.act.serve("a", "v2")
g, _ := h.act.Current("a")
g.Worker = true
- h.m.served("a", g)
+ h.m.served("a", g, 0)
if r := h.rows.get("a"); r.ServingVersion != "" || r.GoodVersion != "v2" {
t.Fatalf("row = %+v: want the good build recorded and the serving build left to the activator", r)
}
@@ -228,8 +228,8 @@ func TestAStaleSuccessNeverOverwritesANewerBuild(t *testing.T) {
older, _ := h.act.Current("a")
h.act.serve("a", "v3") // a newer generation took over
newer, _ := h.act.Current("a")
- h.m.served("a", newer)
- h.m.served("a", older) // the older one's completion arrives late
+ h.m.served("a", newer, 0)
+ h.m.served("a", older, 0) // the older one's completion arrives late
h.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: h.rows.get("a"), Gen: older, FirstWake: true})
if r := h.rows.get("a"); r.GoodVersion != "v3" || r.ServingVersion != "v3" {
t.Fatalf("row = %+v: an older generation overwrote the newer good and serving build", r)