carlos / platform Public

zdd-readiness: a marked good build boots as the marked build, markers carry their execution's start, and moves onto the good build wait for a signed rollback (Task 3 fix round 1)

Finding 1: when the marker names the installed build, the marked-build rule applies even if it is also the good build (decideHoldRan, planCheck, status). Finding 2: HoldRan.At is the execution's start, an attempt's dispatch (dispatch.Started) or a planned wake's plan time (kept per token in Manager.wakeAt), so a carlos rollback signed while the hold build ran clears the marker; setting a marker makes the pointer read due at once. Finding 3: scheduler.execute abandons, unexecuted, a move (release, install, retry) onto the installed good build while another build's marker stands, for socket instances and workers alike; the queue derives that move as classRecover, so the gate is on the cause. Simulation: execution-start stamps, checkI5 exempts the marked build, checkWakeRefused requires a boot of a marked installed good build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 42865b81fe08d457fdf47b5676679e7ee223c82b parent b402bc0
8 files changed, +515 −89
  • internal/rollout/failures.go +28 −15
  • internal/rollout/holdran_test.go +265 −24
  • internal/rollout/manager.go +93 −9
  • internal/rollout/queue.go +4 −0
  • internal/rollout/scheduler.go +34 −2
  • internal/rollout/sim_test.go +42 −17
  • internal/rollout/status.go +6 −3
  • internal/rollout/wakeplan.go +43 −19
diff --git a/internal/rollout/failures.go b/internal/rollout/failures.go
index 6ffd6c6fc..7725c2a1a 100644
--- a/internal/rollout/failures.go
+++ b/internal/rollout/failures.go
@@ -47,6 +47,9 @@ type FailureRecord struct {
// that failed in hold mode, ran its build, and was not held down. It is
// an instruction to Record, never stored in the record.
MarkHoldRan bool `json:"-"`
+ // 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:"-"`
}
// HoldRan is an instance's "hold ran" marker (final review wave 2, N2): a
@@ -65,11 +68,15 @@ type HoldRan struct {
Account string `json:"account"`
App string `json:"app"`
Build string `json:"build"`
- // At is when the book set the marker (its clock). Only a rollback
+ // 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
- // pointer from before the hold build ran says nothing about recovering
- // from it. Zero in a file an earlier agent wrote, which any signed
- // rollback pointer is newer than.
+ // pointer from before the hold build started says nothing about
+ // recovering from it, while `carlos rollback` signed during the
+ // execution (the rollback that superseded it) does. It is not the time
+ // the marker was recorded: that comes after the supersession, so the
+ // rollback that caused it would always be older. Zero in a file an
+ // earlier agent wrote, which any signed rollback pointer is newer than.
At time.Time `json:"at,omitempty"`
}
@@ -104,8 +111,8 @@ type FailureBook struct {
// every later mutating call and Prune retries the save until it lands,
// so the record still survives an agent restart (spec §8.4).
dirty bool
- // now stamps a new marker's At. Nil means time.Now; tests and the
- // simulation set their own clock.
+ // 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
}
@@ -188,8 +195,8 @@ func (b *FailureBook) find(acct, app, build string) int {
func (b *FailureBook) Record(r FailureRecord) (bool, 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})
- r.MarkHoldRan = false
+ 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{}
i := b.find(r.Account, r.App, r.Build)
if i < 0 {
r.Retried = nil
@@ -218,10 +225,14 @@ func (b *FailureBook) Record(r FailureRecord) (bool, error) {
return false, b.saveLocked()
}
-// markLocked sets host's hold-ran marker to h, stamped now by the book's
-// clock, and reports whether that changed anything. A marker already
-// standing for the same build is left as it is, its first stamp included,
-// so marking it again writes nothing. The caller holds b.mu and saves.
+// 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.
func (b *FailureBook) markLocked(host string, h HoldRan) bool {
if cur, ok := b.held[host]; ok && cur.same(h) {
return false
@@ -229,7 +240,9 @@ func (b *FailureBook) markLocked(host string, h HoldRan) bool {
if b.held == nil {
b.held = map[string]HoldRan{}
}
- h.At = b.clock()
+ if h.At.IsZero() {
+ h.At = b.clock()
+ }
b.held[host] = h
return true
}
@@ -237,8 +250,8 @@ func (b *FailureBook) markLocked(host string, h HoldRan) bool {
// MarkHoldRan sets host's hold-ran marker (HoldRan) and saves it: the
// outcome that set it changed nothing else in the book. A marker already
// standing for another build is replaced: the newest hold build to run on
-// the instance is the one its database may now need. h.At is ignored; the
-// book stamps the marker itself.
+// the instance is the one its database may now need. h.At is the
+// execution's start (markLocked).
func (b *FailureBook) MarkHoldRan(host string, h HoldRan) error {
b.mu.Lock()
defer b.mu.Unlock()
diff --git a/internal/rollout/holdran_test.go b/internal/rollout/holdran_test.go
index 45c2c3880..6eeb741c2 100644
--- a/internal/rollout/holdran_test.go
+++ b/internal/rollout/holdran_test.go
@@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"errors"
+ "fmt"
"os"
"path/filepath"
"strings"
+ "sync"
"testing"
"time"
@@ -72,6 +74,21 @@ func TestDecideWakeWithAHoldRanMarker(t *testing.T) {
if p, _ := decideWake(inputs(wakeRoute("v1", "v1"), book(), overlap)); p.Refuse != HeldDownRefusal("v2") || p.Boot.StagedPath != "" {
t.Fatalf("the good build installed under a marker: want the wake refused, got %+v", p)
}
+ // Task 3 fix round 1: the marked build is also the good build, and it is
+ // installed. The marked-build rule applies, not the good-build arm: it
+ // boots, in hold mode with no fallback. A sibling's record of it does not
+ // hold an instance that served it (spec §8.4, Blocks with served); only a
+ // hold-down refuses it.
+ for name, b := range map[string]*FailureBook{"no record": book(), "a sibling's record": book(sibV2)} {
+ if p, err := decideWake(inputs(wakeRoute("v2", "v2"), b, overlap)); err != nil || p.Refuse != "" || p.Boot.Version != "v2" ||
+ !p.FirstWake || p.Fallback != nil || p.Mode != activator.ModeHold {
+ t.Fatalf("marked build = installed = good, %s: want a hold first wake of v2 with no fallback, got %+v, %v", name, p, err)
+ }
+ }
+ downGood := book(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "a", HeldDown: []string{"a"}, At: t0})
+ if p, _ := decideWake(inputs(wakeRoute("v2", "v2"), downGood, overlap)); p.Refuse != HeldDownRefusal("v2") || p.Boot.StagedPath != "" {
+ t.Fatalf("marked build = installed = good, held down: want the hold-down refusal, got %+v", p)
+ }
// A staging refusal never boots the good build instead.
in := inputs(wakeRoute("v2", "v1"), book(), overlap)
in.Demand = func(string, string) (string, string, error) { return "", "", errors.New("digest mismatch") }
@@ -143,7 +160,7 @@ func TestAFirstWakeCancelledAfterItsHoldBuildRanAndItsClearingByAPass(t *testing
h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("s"), Gen: g, Mode: p.Mode, FirstWake: true,
Token: p.Token, Executed: true})
if got, ok := h.book.HoldRanOn("s"); !ok || !got.same(markV2) || !got.At.Equal(t0) {
- t.Fatalf("marker = %+v, %v: want v2's, stamped at the book's clock", got, ok)
+ t.Fatalf("marker = %+v, %v: want v2's, stamped at its wake's plan", got, ok)
}
if _, ok := h.book.Lookup("bab", "blog", "v2"); ok {
t.Fatal("a cancelled observation recorded a failure")
@@ -236,7 +253,10 @@ func TestRetryHostClearsTheHoldRanMarker(t *testing.T) {
// that ran nothing, an overlap attempt, a restart, a hold-down and a pass do
// not.
func TestWhichAttemptOutcomesLeaveTheHoldRanMarker(t *testing.T) {
- adopt := dispatch{ID: Identity{Host: "a", Cause: activator.CauseRelease, Build: "v2"}, SchedRev: 1, Class: classAdopt, Cohort: cohortKey("v2", classAdopt)}
+ // Started is the dispatch's (scheduler.execute): the marker's At, on disk
+ // too, whichever save sets it (Task 3 fix round 1).
+ started := t0.Add(-time.Minute)
+ adopt := dispatch{ID: Identity{Host: "a", Cause: activator.CauseRelease, Build: "v2"}, SchedRev: 1, Class: classAdopt, Cohort: cohortKey("v2", classAdopt), Started: started}
restart := dispatch{ID: Identity{Host: "a", Cause: activator.CauseRestart, Build: "v1", RestartID: "r1"}, SchedRev: 1, Class: classRecover, Cohort: cohortKey("v1", classRecover)}
for _, tc := range []struct {
name string
@@ -275,8 +295,8 @@ func TestWhichAttemptOutcomesLeaveTheHoldRanMarker(t *testing.T) {
t.Fatal(err)
}
got, ok := again.HoldRanOn("a")
- if ok != tc.want || (ok && !got.same(markV2)) {
- t.Fatalf("marker on disk = %+v, %v; want set %v", got, ok, tc.want)
+ if ok != tc.want || (ok && (!got.same(markV2) || !got.At.Equal(started))) {
+ t.Fatalf("marker on disk = %+v, %v; want set %v, stamped at the dispatch %v", got, ok, tc.want, started)
}
if tc.res.Outcome == activator.SwapFailed {
if _, ok := again.Lookup("bab", "blog", "v2"); !ok {
@@ -327,6 +347,54 @@ func TestStatusShowsAHoldRanInstanceAsHeld(t *testing.T) {
if got := d.m.Status().Hosts["d"]; got.HeldBy != "v2" || got.Attempt != status.AttemptDown || got.Reason != status.ReasonExit {
t.Fatalf("d = %+v: want down, held by v2 (the marked build is held by a sibling's record)", got)
}
+
+ // Task 3 fix round 1: the marked build is also the installed good build.
+ // Its wake boots it (the marked-build rule), so it is held, not down.
+ e := newHarness(t, "e")
+ e.stage("v2")
+ r2 := e.rows.get("e")
+ r2.Version, r2.GoodVersion, r2.GoodDigest = "v2", "v2", "sha-v2"
+ e.rows.put(r2)
+ e.sleepHost("e")
+ e.pass()
+ e.book.MarkHoldRan("e", markV2)
+ e.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", Reason: "exited early", At: t0})
+ if got := e.m.Status().Hosts["e"]; got.HeldBy != "v2" || got.Attempt != "" {
+ t.Fatalf("e = %+v: want held by v2 and not down (its marked build is its installed good build, which its wake boots)", got)
+ }
+}
+
+// Task 3 fix round 1: when the marker names the installed good build, the
+// marked-build rule applies at the plan and at the exec, even with a
+// sibling's record of the build standing (the instance served it). The good
+// build is still refused at the exec of any other plan while the marker
+// stands.
+func TestAMarkerOnTheInstalledGoodBuildBootsIt(t *testing.T) {
+ ctx := context.Background()
+ h := newHarness(t, "h")
+ h.stage("v2", "v3")
+ r := h.rows.get("h")
+ r.Version, r.GoodVersion, r.GoodDigest = "v2", "v2", "sha-v2"
+ h.rows.put(r)
+ h.book.MarkHoldRan("h", markV2)
+ h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", At: t0})
+ h.sleepHost("h")
+ h.wakeHost("h")
+ p := h.m.Plan(ctx, h.rows.get("h"))
+ if p.Refuse != "" || p.Boot.Version != "v2" || p.Mode != activator.ModeHold || p.Fallback != nil || p.Check == nil {
+ t.Fatalf("plan %+v: want a hold wake of v2 with no fallback", p)
+ }
+ if err := p.Check("v2"); err != nil {
+ t.Fatalf("the plan's check refuses v2, the marked and installed good build: %v", err)
+ }
+ // Another build installed: the good v2 is not the installed build, and
+ // the good-build arm refuses it at that plan's exec.
+ h.install("v3", "h")
+ other := h.rows.get("h")
+ check := h.m.planCheck(other, activator.WakePlan{Boot: p.Boot, Mode: activator.ModeHold, FirstWake: true})
+ if err := check("v2"); err == nil || err.Error() != HeldDownRefusal("v2") {
+ t.Fatalf("check of the good v2 with v3 installed = %v, want the hold-down refusal", err)
+ }
}
// Final review wave 2, N2: the markers survive a reopen; a record written
@@ -500,31 +568,124 @@ func TestASignedRollbackClearsTheMarkerAndTheGoodBuildBoots(t *testing.T) {
}
}
-// Ruling 205: a marked host's installed build moving makes the app's pointer
-// read due at once, so a rollback onto the good build is honoured at the
-// pass that sees it, not up to pointerReadEvery later.
+// Ruling 205, in the order it happens (Task 3 fix round 1): hold build v2 is
+// dispatched and running on h; `carlos rollback` signs v1 and root installs
+// v1 on h; the attempt is superseded after v2 ran, and marks h. The marker's
+// At is the attempt's dispatch, so the rollback signed while v2 ran is newer
+// than it, and the pass after the mark clears it at once (setting the marker
+// made the pointer read due; the pass that saw the install came before any
+// marker). The sleeping h's next wake boots v1.
+//
+// A pointer naming v1 signed before the attempt started clears nothing: it
+// says nothing about recovering from v2.
func TestAnInstallOntoTheGoodBuildMakesTheRollbackReadDueAtOnce(t *testing.T) {
- h := newHarness(t, "h")
- h.stage("v2", "v3")
- h.install("v2", "h")
- h.sleepHost("h")
- // A record of another build keeps the app's pointers read; this pass
- // reads them, so the next read is not due for pointerReadEvery.
- h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "sib", PromotedAt: t0, At: t0})
- h.pass()
- h.book.MarkHoldRan("h", markV2)
- h.advance(5 * time.Second)
- h.pointer("stable", "v1", h.clock()) // `carlos rollback` to v1
- h.install("v1", "h")
+ ctx := context.Background()
+ for _, tc := range []struct {
+ name string
+ signedAt time.Duration // the v1 pointer's PromotedAt, from the attempt's dispatch
+ cleared bool
+ }{
+ {"rollback signed while the hold build ran", 5 * time.Second, true},
+ {"pointer signed before the hold build started", -time.Minute, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ h := newHarness(t, "h")
+ h.swap = appconfig.SwapHold
+ h.stage("v2", "v3")
+ h.act.mode = activator.ModeHold
+ h.act.executed = func(a activator.Attempt, _ activator.SwapOutcome) bool { return a.Build == "v2" }
+ // A record of another build keeps the app's pointers read; the
+ // first pass reads them, so no read is due for pointerReadEvery
+ // unless something makes it so.
+ h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "sib", PromotedAt: t0, At: t0})
+ 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 len(started) == 1 && started[0] == "h:v2"
+ })
+ start := h.clock()
+
+ h.advance(5 * time.Second)
+ h.pointer("stable", "v1", start.Add(tc.signedAt)) // `carlos rollback` to v1 (or an older pointer)
+ h.install("v1", "h") // root installs it
+ h.pass() // the newer desire supersedes the attempt
+ h.advance(5 * time.Second)
+ close(gate)
+ waitFor(t, "h's attempt to finish", func() bool { return !h.m.attemptRunning("h") })
+ got, ok := h.book.HoldRanOn("h")
+ if !ok || !got.same(markV2) || !got.At.Equal(start) {
+ t.Fatalf("marker = %+v, %v: want v2's, stamped at the attempt's dispatch %v", got, ok, start)
+ }
+
+ h.sleepHost("h")
+ h.pass()
+ if _, still := h.book.HoldRanOn("h"); still == tc.cleared {
+ t.Fatalf("after the pass the marker stands: %v, want %v", still, !tc.cleared)
+ }
+ h.wakeHost("h")
+ p := h.m.Plan(ctx, h.rows.get("h"))
+ if tc.cleared {
+ if p.Refuse != "" || p.Boot.Version != "v1" || p.FirstWake {
+ t.Fatalf("plan %+v after the rollback: want v1's ordinary wake", p)
+ }
+ if err := p.Check("v1"); err != nil {
+ t.Fatalf("after the rollback the check refuses v1: %v", err)
+ }
+ return
+ }
+ if p.Refuse != HeldDownRefusal("v2") {
+ t.Fatalf("plan %+v with only an older pointer: want refused", p)
+ }
+ })
+ }
+}
+
+// Ruling 205 (Task 3 fix round 1): a planned wake's marker is stamped with
+// its own plan's time, the start of its execution, kept per token: a newer
+// plan for the host does not change an older wake's start. A wake whose
+// start is not known (no token) falls back to the book's clock, the safe
+// direction.
+func TestAWakesHoldRanMarkerIsStampedWithItsPlanTime(t *testing.T) {
+ ctx := context.Background()
+ h := newHarness(t, "s")
+ h.swap = appconfig.SwapHold
+ h.stage("v2")
+ h.install("v2", "s")
+ h.sleepHost("s")
h.pass()
- if got, ok := h.book.HoldRanOn("h"); ok {
- t.Fatalf("marker %+v survived the pass that saw the rollback's install", got)
+ h.wakeHost("s")
+ first := h.m.Plan(ctx, h.rows.get("s"))
+ if !first.FirstWake || first.Boot.Version != "v2" || first.Mode != activator.ModeHold {
+ t.Fatalf("plan %+v: want a hold first wake of v2", first)
+ }
+ planned := h.clock()
+ h.advance(time.Minute)
+ h.wakeHost("s")
+ if second := h.m.Plan(ctx, h.rows.get("s")); second.Token == first.Token || second.Boot.Version != "v2" {
+ t.Fatalf("second plan %+v: want another plan of v2", second)
+ }
+ h.advance(time.Minute)
+ h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("s"), Gen: activator.Gen{Build: "v2"},
+ Mode: activator.ModeHold, FirstWake: true, Token: first.Token, Executed: true})
+ if got, ok := h.book.HoldRanOn("s"); !ok || !got.same(markV2) || !got.At.Equal(planned) {
+ t.Fatalf("marker = %+v, %v: want v2's, stamped at its wake's plan %v", got, ok, planned)
+ }
+
+ h.book.ClearHoldRan("s")
+ h.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.rows.get("s"), Gen: activator.Gen{Build: "v2"},
+ Mode: activator.ModeHold, FirstWake: true, Executed: true})
+ if got, ok := h.book.HoldRanOn("s"); !ok || !got.At.Equal(h.clock()) {
+ t.Fatalf("marker = %+v, %v: a wake of unknown start, want the book's clock %v", got, ok, h.clock())
}
}
-// Ruling 205's book half: a marker is stamped once by the book's clock (a
-// re-mark of the same build keeps it, and writes nothing), and the rollback
-// clear lifts only (acct, app)'s marker, and only for a pointer newer than it.
+// 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.
func TestTheHoldRanMarkerStampAndItsRollbackClear(t *testing.T) {
now := t0
b := &FailureBook{now: func() time.Time { return now }}
@@ -547,4 +708,84 @@ func TestTheHoldRanMarkerStampAndItsRollbackClear(t *testing.T) {
if got, _ := b.HoldRanOn("a"); got.Build != "v3" || !got.At.Equal(now) {
t.Fatalf("marker %+v: want v3's, stamped at %v", got, now)
}
+ started := now.Add(-30 * time.Second)
+ b.MarkHoldRan("a", HoldRan{Account: "bab", App: "blog", Build: "v4", At: started})
+ if got, _ := b.HoldRanOn("a"); got.Build != "v4" || !got.At.Equal(started) {
+ t.Fatalf("marker %+v: want v4's, stamped at its execution's start %v, not the book's clock", got, started)
+ }
+}
+
+// Ruling 205 at the scheduler (Task 3 fix round 1): an awake instance (a
+// socket instance, or a worker) serving v2 under v2's hold-ran marker, with
+// its good v1 installed (root's repoint, say), is not moved onto v1 until a
+// signed rollback lifts the marker. The gated attempt ends abandoned with
+// nothing run, is derived again after the abandon deferral, and is gated
+// again; once the pointer pass sees `carlos rollback`'s pointer and clears
+// the marker, the next derivation runs the move.
+func TestAMoveOntoTheInstalledGoodBuildWaitsForASignedRollback(t *testing.T) {
+ for _, worker := range []bool{false, true} {
+ name := "socket instance"
+ if worker {
+ name = "worker"
+ }
+ t.Run(name, func(t *testing.T) {
+ var h *harness
+ if worker {
+ h = workerHarness(t, "h")
+ } else {
+ h = newHarness(t, "h")
+ }
+ var (
+ mu sync.Mutex
+ gated int
+ )
+ h.m.d.Logf = func(format string, args ...any) {
+ if strings.Contains(fmt.Sprintf(format, args...), "waiting for a signed rollback") {
+ mu.Lock()
+ gated++
+ mu.Unlock()
+ }
+ }
+ gatedTimes := func() int {
+ mu.Lock()
+ defer mu.Unlock()
+ return gated
+ }
+ moves := func() int {
+ calls, _, _ := h.act.snapshot()
+ return len(calls) + len(h.act.replacements())
+ }
+ h.stage("v2")
+ h.act.serve("h", "v2")
+ h.book.MarkHoldRan("h", HoldRan{Account: "bab", App: "blog", Build: "v2", At: t0})
+ h.pass()
+ waitFor(t, "the move onto v1 to be gated", func() bool { return gatedTimes() == 1 && !h.m.attemptRunning("h") })
+ if n := moves(); n != 0 {
+ t.Fatalf("%d Swap/ReplaceWorker calls while v2's marker stands, want none", n)
+ }
+ // After the abandon deferral it is derived again, and gated again.
+ h.advance(abandonDeferral + time.Second)
+ h.pass()
+ waitFor(t, "the move onto v1 to be gated again", func() bool { return gatedTimes() == 2 && !h.m.attemptRunning("h") })
+ if n := moves(); n != 0 {
+ t.Fatalf("%d Swap/ReplaceWorker calls while v2's marker stands, want none", n)
+ }
+
+ // `carlos rollback` signs v1; the pointer pass clears the marker,
+ // and the next derivation moves h onto v1.
+ h.pointer("stable", "v1", h.clock())
+ h.advance(pointerReadEvery)
+ h.pass()
+ if got, ok := h.book.HoldRanOn("h"); ok {
+ t.Fatalf("marker %+v survived the signed rollback", got)
+ }
+ waitFor(t, "the move onto v1", func() bool { return moves() == 1 })
+ if calls, _, _ := h.act.snapshot(); len(calls) == 1 && (worker || calls[0].a.Build != "v1") {
+ t.Fatalf("swap %+v: want one onto v1 (and none for a worker)", calls[0].a)
+ }
+ if r := h.act.replacements(); len(r) == 1 && (!worker || r[0].a.Build != "v1") {
+ t.Fatalf("replacement %+v: want one onto v1 (and none for a socket instance)", r[0].a)
+ }
+ })
+ }
}
diff --git a/internal/rollout/manager.go b/internal/rollout/manager.go
index 75d7316a5..a1218b66d 100644
--- a/internal/rollout/manager.go
+++ b/internal/rollout/manager.go
@@ -5,6 +5,7 @@ import (
"errors"
"log"
"os"
+ "slices"
"strings"
"sync"
"time"
@@ -140,6 +141,15 @@ 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 map[string][]wakeStart
// goodMu serialises every serving- and good-build write with its
// acknowledgement check (served, publishServing, writeGood), so an older
@@ -158,7 +168,7 @@ func NewManager(ctx context.Context, d Deps) *Manager {
m := &Manager{d: d, ctx: ctx,
apps: map[appKey]*scheduler{}, hostApp: map[string]appKey{}, cfg: map[string]*configTrack{},
retried: map[string]string{}, pointerRead: map[appKey]time.Time{}, nudging: map[string]bool{}, observing: map[string]uint64{},
- planned: map[string]plannedWake{}, wakeTok: map[string]uint64{}}
+ planned: map[string]plannedWake{}, wakeTok: map[string]uint64{}, wakeAt: map[string][]wakeStart{}}
m.policies = &policyCache{load: d.Policy, ttl: 30 * time.Second, now: d.Now, m: map[appKey]policyEntry{}}
return m
}
@@ -277,6 +287,7 @@ func (m *Manager) forget(routes []registry.Route) {
// A later event of the host's last wake then matches no token: the
// host is gone, and there is nothing of its wake left to act on.
delete(m.wakeTok, host)
+ delete(m.wakeAt, host)
}
m.mu.Unlock()
if len(gone) > 0 {
@@ -397,9 +408,7 @@ func (m *Manager) observe(routes []registry.Route) []appKey {
}
s.mu.Unlock()
if rearm {
- m.mu.Lock()
- delete(m.pointerRead, k)
- m.mu.Unlock()
+ m.pointerReadDue(k)
}
s.poke()
}
@@ -411,6 +420,19 @@ func (m *Manager) observe(routes []registry.Route) []appKey {
return keys
}
+// pointerReadDue makes the next applyPointers read k's pointers at once
+// rather than up to pointerReadEvery later (ruling 205). observe calls it
+// when a marked host's installed build moves; done() and OnGen call it when
+// they set a marker, because the rollback that superseded the hold build is
+// signed and installed while that build runs, before any marker exists, so
+// the pass that saw the install could not make the read due. The caller
+// holds no lock (Manager.mu is taken here).
+func (m *Manager) pointerReadDue(k appKey) {
+ m.mu.Lock()
+ delete(m.pointerRead, k)
+ m.mu.Unlock()
+}
+
// routeChannel is the channel a route follows: "" means stable, as
// release's own (unexported) routeChannel reads it.
func routeChannel(rt registry.Route) string {
@@ -436,9 +458,11 @@ func routeChannel(rt registry.Route) string {
// marker: `carlos rollback` re-signs the pointer, root's automatic sibling
// repoint does not, so only an explicit rollback lets the good build boot
// over the marker. The signature is the pointer read's to verify
-// (release.Channel). observe makes the read due at once when a marked
-// host's installed build moves, so a rollback is not left waiting out
-// pointerReadEvery.
+// (release.Channel). The marker's At is the start of the execution that set
+// it (an attempt's dispatch, a planned wake's plan), so the rollback that
+// superseded a running hold build is newer than it. The read is made due at
+// once when a marked host's installed build moves and when a marker is set
+// (pointerReadDue), so a rollback is not left waiting out pointerReadEvery.
func (m *Manager) applyPointers(routes []registry.Route, keys []appKey) {
if m.d.Pointer == nil {
return
@@ -630,12 +654,72 @@ type plannedWake struct {
// newWakeToken starts a plan for host: a fresh token, recorded as the
// host's newest. It is taken before the plan registers anything (Plan), so
// the plan's registration and every event of its wake carry it.
-func (m *Manager) newWakeToken(host string) uint64 {
+// It also returns the plan's time, the start of the wake's execution
+// (noteWakeStart).
+func (m *Manager) newWakeToken(host string) (uint64, time.Time) {
+ now := m.d.Now()
m.mu.Lock()
defer m.mu.Unlock()
m.tokenCount++
m.wakeTok[host] = m.tokenCount
- return m.tokenCount
+ return m.tokenCount, now
+}
+
+// wakeStart is a planned first wake's plan time, by its token
+// (Manager.wakeAt).
+type wakeStart struct {
+ token uint64
+ at time.Time
+}
+
+// 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).
+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 == "" {
+ return
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ starts := append(m.wakeAt[host], wakeStart{token: tok, at: at})
+ 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 {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if tok == 0 {
+ return time.Time{}
+ }
+ starts := m.wakeAt[host]
+ i := slices.IndexFunc(starts, func(w wakeStart) bool { return w.token == tok })
+ if i < 0 {
+ return time.Time{}
+ }
+ at := starts[i].at
+ if ended {
+ if starts = slices.Delete(starts, i, i+1); len(starts) == 0 {
+ delete(m.wakeAt, host)
+ } else {
+ m.wakeAt[host] = starts
+ }
+ }
+ return at
}
// notePlanned records what host's newest plan boots: an entry for a first
diff --git a/internal/rollout/queue.go b/internal/rollout/queue.go
index c59763273..cfd535b2c 100644
--- a/internal/rollout/queue.go
+++ b/internal/rollout/queue.go
@@ -47,6 +47,10 @@ type dispatch struct {
// An attempt that ends with nothing of its build run gets it back
// (rearmRetry; final review F8).
Forced bool
+ // Started is when execute handed the attempt to the activator (Swap or
+ // 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
}
// hostSlot is one instance's desired state. The attempt to run is derived
diff --git a/internal/rollout/scheduler.go b/internal/rollout/scheduler.go
index d7cfa4028..c2016ca50 100644
--- a/internal/rollout/scheduler.go
+++ b/internal/rollout/scheduler.go
@@ -101,6 +101,24 @@ func (s *scheduler) execute(ctx context.Context, d dispatch) {
s.done(d, activator.SwapResult{Outcome: activator.SwapAbandoned, Reason: "route not readable"}, "")
return
}
+ if movesBuild(d.ID.Cause) && rt.GoodVersion != "" && d.ID.Build == rt.GoodVersion {
+ if mark, ok := holdRanFor(m.d.Book, s.key.acct, s.key.app, d.ID.Host); ok && mark.Build != d.ID.Build {
+ // Ruling 205 at the scheduler (Task 3 fix round 1): a move onto
+ // the installed good build over another build's hold-ran marker
+ // is the planner's refused case, for an awake instance, a worker
+ // or a sidecar member. Root's sibling repoint installs the good
+ // build too, so only a signed rollback may run it. The queue
+ // derives such a move as classRecover (the instance has served
+ // its good build), so the gate is on the cause, not the class: a
+ // restart or config attempt re-runs the build the instance
+ // serves and is not gated. Nothing ran: abandoned unexecuted, so
+ // done() defers the host (abandonDeferral) and the next
+ // derivation tries again. The pointer pass, due at once on the
+ // install (observe), clears the marker on a signed rollback.
+ s.done(d, activator.SwapResult{Outcome: activator.SwapAbandoned, Reason: "a hold build ran here; waiting for a signed rollback"}, "")
+ return
+ }
+ }
a, err := m.resolve(ctx, d, *rt)
if err != nil {
m.stagingRefused(*rt, d.ID.Build, err)
@@ -115,6 +133,10 @@ func (s *scheduler) execute(ctx context.Context, d dispatch) {
if d.Class == classAdopt {
d.FailedUnder = m.failedUnder(*rt, d.ID.Build)
}
+ // 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.
+ d.Started = m.d.Now()
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
@@ -128,6 +150,13 @@ func (s *scheduler) execute(ctx context.Context, d dispatch) {
s.done(d, m.d.Act.Swap(ctx, a, s.newest), a.Mode)
}
+// movesBuild reports whether an attempt of cause moves the instance onto
+// its desired build (a release, install or retry), rather than re-running
+// the build it serves (a restart or config attempt).
+func movesBuild(c activator.Cause) bool {
+ return c != activator.CauseRestart && c != activator.CauseConfig
+}
+
// done records an attempt's outcome and acts on it. mode is the attempt's
// swap mode, "" when no Swap ran.
func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.SwapMode) {
@@ -216,10 +245,10 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw
// 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.
r := *rec
- r.MarkHoldRan = mark
+ r.MarkHoldRan, r.HoldRanAt = mark, d.Started
added, superseded, recErr = failCohort(m.d.Book, s.q, r)
} else if mark {
- markErr = m.d.Book.MarkHoldRan(d.ID.Host, HoldRan{Account: s.key.acct, App: s.key.app, Build: d.ID.Build})
+ markErr = m.d.Book.MarkHoldRan(d.ID.Host, HoldRan{Account: s.key.acct, App: s.key.app, Build: d.ID.Build, At: d.Started})
}
if res.Outcome == activator.SwapAbandoned && !res.Executed {
// It is proven that nothing of the build ran (a refusal before its
@@ -245,6 +274,9 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw
serving = h.serving
}
s.mu.Unlock()
+ if mark {
+ m.pointerReadDue(s.key)
+ }
defer s.poke()
defer m.changed() // runs first: the slot is free and the record is written
if recErr != nil {
diff --git a/internal/rollout/sim_test.go b/internal/rollout/sim_test.go
index 134f5608b..5d76141f5 100644
--- a/internal/rollout/sim_test.go
+++ b/internal/rollout/sim_test.go
@@ -66,9 +66,12 @@ import (
// 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
-// pointer names that build signed after the hold build ran. A marker
-// the Pass cleared without it is an I5 violation. (The book is read
-// here only to see what the Pass cleared; the evidence is the model's.)
+// pointer names that build signed after the execution that ran the hold
+// build started (its dispatch, or its wake's plan). A marker the Pass
+// cleared without it is an I5 violation. (The book is read here only to
+// 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.
//
// Seeds: ROLLOUT_SIM_SEEDS (default 200; the per-revision sweep is 3000).
// ROLLOUT_SIM_SEED=<n> replays one seed. The production code iterates some
@@ -188,6 +191,10 @@ type simHost struct {
plan activator.WakePlan
planRoute registry.Route
wakeStart time.Time
+ // planAt is when the plan the wake runs was made (the first plan, or
+ // its replan at the exec): the start of its execution, which a hold-ran
+ // marker the wake leaves is stamped with (ruling 205).
+ planAt time.Time
stamp time.Time
failLater bool // observing: the build exits inside its observation
// underMark: the wake's plan was made while a hold-ran restriction
@@ -210,6 +217,9 @@ type simSwap struct {
newest func(string) uint64
release chan struct{}
stamp time.Time // the FailedUnder the model expects
+ // started is when the attempt was dispatched (scheduler.execute's
+ // d.Started): the At of a hold-ran marker it leaves (ruling 205).
+ started time.Time
// Set by finishSwap for the I3 record check: whether the instance
// serves the attempt's build, and the book's record for it beforehand.
serves bool
@@ -262,7 +272,8 @@ type sim struct {
// holdRan is the hold-ran restriction per host: the hold build that ran
// there without passing, until the host records a good build (goodAt),
// a host retry names it, or a Pass lifts it on rollback evidence.
- // holdRanAt is when it was set: the book's stamp, which a rollback
+ // holdRanAt is the start of the execution that set it (an attempt's
+ // dispatch, a planned wake's plan; Task 3 fix round 1), which a rollback
// pointer must be newer than.
holdRan map[string]string
holdRanAt map[string]time.Time
@@ -490,7 +501,7 @@ func (s *sim) finishSwap(sw *simSwap) activator.SwapResult {
if a.Mode == activator.ModeHold {
h.phase, h.gen = simAsleep, activator.Gen{}
if !sw.serves {
- s.holdRanOn(h, a.Build)
+ s.holdRanOn(h, a.Build, sw.started)
}
}
}
@@ -522,7 +533,7 @@ func (s *sim) finishSwap(sw *simSwap) activator.SwapResult {
}
if res.Outcome == activator.SwapFailed && a.Mode == activator.ModeHold && !sw.serves {
// A hold adoption that ran, failed and was not held down.
- s.holdRanOn(h, a.Build)
+ s.holdRanOn(h, a.Build, sw.started)
}
return res
}
@@ -583,6 +594,8 @@ func (s *sim) launch(d dispatch) {
return
}
ev.swap.stamp = stamp
+ // execute ran up to Swap within this step, so its dispatch time is now.
+ ev.swap.started = s.now
s.swaps[d.ID.Host] = ev.swap
s.logf("launch %s %s %s rev %d canary %v class %d", d.ID.Host, d.ID.Cause, d.ID.Build, d.SchedRev, d.Canary, d.Class)
}
@@ -800,12 +813,12 @@ 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).
-// 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.
-func (s *sim) holdRanOn(h *simHost, build string) {
+// 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.
+func (s *sim) holdRanOn(h *simHost, build string, start time.Time) {
if s.holdRan[h.name] != build {
- s.holdRanAt[h.name] = s.now
+ s.holdRanAt[h.name] = start
}
s.holdRan[h.name] = build
s.logf("ground truth: hold build %s ran on %s without a verdict", build, h.name)
@@ -821,9 +834,11 @@ func (s *sim) goodAt(h *simHost, build string) {
// checkI5 is I5 at the exec of build on h, for a wake planned against rt.
// The installed good build is barred too (ruling 205): root's sibling
// repoint installs it without any rollback, and only a Pass that saw a
-// signed one lifts the restriction.
+// signed one lifts the restriction. The marked build itself is exempt even
+// when it is the good build (Task 3 fix round 1): booting it is the
+// marked-build rule, not a boot of the good build in its place.
func (s *sim) checkI5(h *simHost, rt registry.Route, build string) {
- if mark := s.holdRan[h.name]; mark != "" && build == h.good {
+ if mark := s.holdRan[h.name]; mark != "" && build == h.good && build != mark {
violate("I5", "%s boots its good build %s (installed %s) after hold build %s ran there, with no signed rollback since", h.name, build, rt.Version, mark)
}
}
@@ -853,7 +868,7 @@ func (s *sim) wakeBegin() {
s.checkWakeRefused(h, row.Version, "its plan refused: "+plan.Refuse)
return
}
- h.plan, h.planRoute, h.wakeStart, h.stamp = plan, row, s.now, s.stampFor(h, plan.Boot.Version)
+ h.plan, h.planRoute, h.wakeStart, h.planAt, h.stamp = plan, row, s.now, s.now, s.stampFor(h, plan.Boot.Version)
h.underMark = s.holdRan[h.name] != ""
s.logf("wake %s planned: boot %s first %v fallback %v mode %s", h.name, plan.Boot.Version, plan.FirstWake, plan.Fallback != nil, plan.Mode)
}
@@ -863,6 +878,16 @@ func (s *sim) checkWakeRefused(h *simHost, installed, why string) {
return
}
if mark := s.holdRan[h.name]; mark != "" {
+ if installed == mark && installed == h.good {
+ // The marked build is the installed good build (Task 3 fix round
+ // 1): it boots under the marked-build rule. A record does not
+ // hold a build the instance served, and the hold-down returned
+ // above, so only a staging refusal may refuse it.
+ if s.builds[installed].misstaged {
+ return
+ }
+ violate("I1", "%s has its good build %s installed, the hold build that ran there, but its wake boots nothing: %s", h.name, installed, why)
+ }
// Restricted (N2): refused for an installed build held for the
// instance, the marked build included (Astra R3 N7: the marker lifts
// no record), for its installed good build until a signed rollback
@@ -917,7 +942,7 @@ func (s *sim) wakeExec() {
s.checkWakeRefused(h, s.rows.get(h.name).Version, "the plan went stale and the replan refused")
return
}
- plan, h.plan, h.stamp = next, next, s.stampFor(h, next.Boot.Version)
+ 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)
}
@@ -967,7 +992,7 @@ func (s *sim) wakeExec() {
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)
+ 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})
}
@@ -1060,7 +1085,7 @@ func (s *sim) sleep() {
// The observed build was healthy, so it ran: a hold one leaves the
// hold-ran restriction (N2).
if h.plan.Mode == activator.ModeHold {
- s.holdRanOn(h, g.Build)
+ s.holdRanOn(h, g.Build, h.planAt)
}
s.m.OnGen(activator.GenEvent{Kind: activator.GenCancelled, Route: h.planRoute, Gen: g, Mode: h.plan.Mode, FirstWake: true, At: s.now,
Token: h.plan.Token, ConfigRev: h.plan.ConfigRev, Executed: true})
diff --git a/internal/rollout/status.go b/internal/rollout/status.go
index 132457348..ddc5c1eb1 100644
--- a/internal/rollout/status.go
+++ b/internal/rollout/status.go
@@ -82,8 +82,9 @@ func (m *Manager) Status() status.RolloutReport {
// good build, its wakes are refused (decideHoldRan), so it is down
// when a record holds its installed build, the marked build
// included (Astra R3 N7), and when its installed build is the good
- // build, until a signed rollback lifts the marker (ruling 205).
- // Awake, it serves what it serves.
+ // build, until a signed rollback lifts the marker (ruling 205),
+ // unless the good build is the marked build itself, which its wake
+ // boots. Awake, it serves what it serves.
if mark, ok := holdRanFor(m.d.Book, s.key.acct, s.key.app, host); ok && h.serving == "" {
switch {
case hr.HeldBy != "" && h.good != "":
@@ -93,7 +94,9 @@ func (m *Manager) Status() status.RolloutReport {
if rec, ok := m.d.Book.Lookup(s.key.acct, s.key.app, mark.Build); ok {
hr.Reason = reasonWord(rec.Reason)
}
- if h.good != "" && h.wantBuild == h.good {
+ // The marked build installed boots under its own rule, even
+ // when it is the good build (Task 3 fix round 1).
+ if h.good != "" && h.wantBuild == h.good && h.wantBuild != mark.Build {
hr.Attempt = status.AttemptDown
}
}
diff --git a/internal/rollout/wakeplan.go b/internal/rollout/wakeplan.go
index 5b86911a5..91f055cca 100644
--- a/internal/rollout/wakeplan.go
+++ b/internal/rollout/wakeplan.go
@@ -110,8 +110,17 @@ func decideWake(in wakeInputs) (activator.WakePlan, error) {
// decideHoldRan is decideWake for an instance with a hold-ran marker (final
// review wave 2, N2; spec §6.4: once a hold build has executed against an
// instance's database, nothing falls back automatically on that instance).
-// The good build never boots, as a recovery or as a fallback, and the
-// marker never lifts a failure record. With a good build:
+// The good build never boots in place of the marked build, as a recovery
+// or as a fallback, and the marker never lifts a failure record.
+// - the installed build is the marked build: it boots, in hold mode with
+// no fallback, unless a failure record holds it for the instance (Astra
+// R3 N7: spec §8.4 exempts an instance that passed on the build, not one
+// where it merely ran). That holds even when the marked build is also
+// the good build (Task 3 fix round 1): the instance has served it, so a
+// sibling's record does not hold it (Blocks with served), and booting
+// it is the marked-build rule, not a fallback.
+//
+// With a good build, for any other installed build:
// - the installed build is the good build: refused. An installed good
// build is no proof of an explicit rollback, because root's automatic
// sibling repoint installs it too (ruling 205, Astra R3). The proof is
@@ -119,12 +128,9 @@ func decideWake(in wakeInputs) (activator.WakePlan, error) {
// pointer pass (applyPointers) clears the marker when it sees one; the
// next wake then boots the good build as an ordinary wake. The planner
// itself reads no pointer.
-// - the installed build is held for the instance by a failure record,
-// the marked build included: refused (Astra R3 N7). Spec §8.4 exempts an
-// instance that passed on the build, not one where it merely ran, so a
-// sibling's failure of the marked build holds this instance too. With
-// the good build barred as well, it stays down until a retry or a newer
-// build.
+// - the installed build is held for the instance by a failure record:
+// refused. With the good build barred as well, the instance stays down
+// until a retry or a newer build.
//
// Otherwise the installed build boots as a first wake with no fallback: the
// marked build in hold mode, whose failure here is a hold-down, any other
@@ -138,6 +144,10 @@ func decideHoldRan(in wakeInputs, mark HoldRan, hasGood bool) (activator.WakePla
acct, app, host := release.RouteAccount(rt), rt.App, rt.Host
installed := rt.Version
switch {
+ case installed == mark.Build:
+ if hasGood && in.Book.Blocks(acct, app, installed, host, installed == rt.GoodVersion) {
+ return activator.WakePlan{Refuse: HeldDownRefusal(mark.Build)}, nil
+ }
case hasGood && installed == rt.GoodVersion:
return activator.WakePlan{Refuse: HeldDownRefusal(mark.Build)}, nil
case hasGood && in.Book.Blocks(acct, app, installed, host, false):
@@ -194,10 +204,11 @@ func (m *Manager) Plan(ctx context.Context, rt registry.Route) (plan activator.W
if !Managed(rt) {
return activator.WakePlan{}
}
- tok := m.newWakeToken(rt.Host)
+ tok, planAt := m.newWakeToken(rt.Host)
defer func() {
plan.Token = tok
m.notePlanned(rt, plan, tok)
+ m.noteWakeStart(rt.Host, tok, planAt, plan)
}()
if m.attemptRunning(rt.Host) {
// The instance's attempt has not reported yet (spec §6.4, §8.1): a
@@ -300,14 +311,16 @@ func (m *Manager) planRow(ctx context.Context, rt registry.Route, tok uint64) ac
//
// A hold-ran marker (final review wave 2, N2), set since the plan or
// before it, applies decideHoldRan's rule at the exec:
-// - the good build may not boot, whatever the route installs: as a
-// recovery, a fallback, an untried first wake's stand-in, or the
+// - the marked build may not boot while it is held down there or, with a
+// good build, while a failure record holds it for the instance (Astra
+// R3 N7: the marker lifts no record). When it is also the good build it
+// is under this rule only as the installed build (Task 3 fix round 1),
+// and a record does not hold a build the instance served;
+// - otherwise the good build may not boot, whatever the route installs:
+// as a recovery, a fallback, an untried first wake's stand-in, or the
// installed build itself (ruling 205: only the pointer pass's clearing
// of the marker on a signed rollback lets it boot again, and the check
// reads no pointer);
-// - the marked build may not boot while it is held down there or, with a
-// good build, while a failure record holds it for the instance (Astra
-// R3 N7: the marker lifts no record);
// - any other build is checked as without a marker.
//
// The book is in memory; nothing here blocks.
@@ -322,14 +335,15 @@ func (m *Manager) planCheck(rt registry.Route, plan activator.WakePlan) func(bui
}
return func(build string) error {
if mark, ok := holdRanFor(m.d.Book, acct, app, host); ok {
+ isGood := hasGood && build == rt.GoodVersion
switch {
- case hasGood && build == rt.GoodVersion:
- return errors.New(HeldDownRefusal(mark.Build))
- case build == mark.Build:
- if m.d.Book.HeldDown(acct, app, build, host) || (hasGood && m.d.Book.Blocks(acct, app, build, host, false)) {
+ case build == mark.Build && (!isGood || rt.Version == mark.Build):
+ if m.d.Book.HeldDown(acct, app, build, host) || (hasGood && m.d.Book.Blocks(acct, app, build, host, isGood)) {
return held(build)
}
return nil
+ case isGood:
+ return errors.New(HeldDownRefusal(mark.Build))
}
}
if !plan.FirstWake || build != boot {
@@ -375,6 +389,10 @@ 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)
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
@@ -431,10 +449,16 @@ func (m *Manager) OnGen(ev activator.GenEvent) {
// must not boot the good build. A fact about the instance, like a
// failure, so it is not token-scoped.
if ev.Executed && ev.FirstWake && ev.Mode == activator.ModeHold && ev.Gen.Build != "" {
- mark := HoldRan{Account: release.RouteAccount(rt), App: rt.App, Build: 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() {
+ 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}
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)
}
+ m.pointerReadDue(appKey{mark.Account, mark.App})
}
}
}