carlos / platform Public

rollout sim: grants to held hosts, a Retry then a stopped rewake under the marker (M14's witness), restarts reread the disk, rebuilds 1 in 150

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev f1c2582a70b6d0a4a2b9e1eee9e95c7f18c84e72 parent a713ffb
2 files changed, +149 −39
  • internal/rollout/sim_test.go +138 −34
  • internal/rollout/testdata/rolloutsim/mutate.sh +11 −5
diff --git a/internal/rollout/sim_test.go b/internal/rollout/sim_test.go
index e1c45d8cc..b2c9435b9 100644
--- a/internal/rollout/sim_test.go
+++ b/internal/rollout/sim_test.go
@@ -113,12 +113,15 @@ import (
// re-read from disk at that moment, under the identity the file holds.
//
// I9 and I10 run on the seeds whose book is on disk (seed%4 == 3, not as
-// root), with I8's read-only windows, and three things only those seeds
-// draw (sim.gr): grants, queued and delivered later, again and again, as
-// an agent re-reads its pending grants; agent restarts, which drop every
-// execution in flight and reopen the book from disk under a new Manager;
-// and box rebuilds, which remove the book's file and open a new book. The
-// grant queue and the facts outlive both.
+// root, where these seeds keep their book in memory and check neither),
+// with I8's read-only windows, and what only those seeds draw (sim.gr):
+// grants, queued and delivered later, again and again, as an agent
+// re-reads its pending grants; agent restarts, which drop every execution
+// in flight and reopen the book from disk under a new Manager; box
+// rebuilds, which remove the book's file and open a new book; and a Retry
+// on an instance its hold-ran marker holds, followed by a wake stopped
+// before it settles (rewakeArm). The grant queue and the facts outlive
+// restarts and rebuilds.
//
// Seeds: ROLLOUT_SIM_SEEDS (default 200; the per-revision sweep is 3000).
// ROLLOUT_SIM_SEED=<n> replays one seed. The production code iterates some
@@ -419,6 +422,9 @@ type sim struct {
// model's events; a record leaving the book (a pass) and a restart's
// reread of the disk are the only things it takes from the book.
grec map[string]*simRec
+ // rewake is the host rewakeArm granted a retry to, which rewakeRun wakes
+ // and stops at the next step.
+ rewake string
// late holds an ordinary wake's healthy event (GenHealthy, not a first
// wake) that the activator has not yet delivered, per host: its
@@ -956,16 +962,21 @@ func (s *sim) run(steps int) {
// after a restart's pass and after every grant delivered here, as
// the code's snapshot sees it.
switch {
- case s.gr.Intn(400) == 0:
+ case s.gr.Intn(150) == 0:
s.agentRestart(true)
case s.gr.Intn(40) == 0:
s.agentRestart(false)
}
+ if s.rewake != "" {
+ s.rewakeRun()
+ } else if s.gr.Intn(6) == 0 {
+ s.rewakeArm()
+ }
if s.gr.Intn(3) == 0 {
s.deliverGrants()
}
if s.gr.Intn(4) == 0 {
- s.issueGrant(s.hosts[s.gr.Intn(len(s.hosts))])
+ s.retryHeld()
}
}
if hosts := sortedKeys(s.late); len(hosts) > 0 && s.r.Intn(12) == 0 {
@@ -1305,10 +1316,13 @@ func bootable(plan activator.WakePlan, build string) error {
// wakeBegin is a wake's planning (Manager.Plan, as the activator calls it
// before the lease).
func (s *sim) wakeBegin() {
- h := s.pick(func(h *simHost) bool { return h.phase == simAsleep })
- if h == nil {
- return
+ if h := s.pick(func(h *simHost) bool { return h.phase == simAsleep }); h != nil {
+ s.wakeBeginOn(h)
}
+}
+
+// wakeBeginOn is wakeBegin for h, which is asleep.
+func (s *sim) wakeBeginOn(h *simHost) {
// The activator's outcome fence (awaitOutcomes): an earlier wake's
// events reach the agent before the host plans again.
if _, ok := s.late[h.name]; ok {
@@ -1390,10 +1404,13 @@ func (s *sim) checkPlanned(h *simHost, build string) {
// plan's Check, one replan if it refuses, the verdict floor, and the build's
// outcome reported through the real OnGen.
func (s *sim) wakeExec() {
- h := s.pick(func(h *simHost) bool { return h.phase == simWaking })
- if h == nil {
- return
+ if h := s.pick(func(h *simHost) bool { return h.phase == simWaking }); h != nil {
+ s.wakeExecOn(h)
}
+}
+
+// wakeExecOn is wakeExec for h, which is waking.
+func (s *sim) wakeExecOn(h *simHost) {
rt, plan := h.planRoute, h.plan
if s.r.Intn(20) == 0 {
// The lease was refused (a previous incarnation still holds it): the
@@ -1629,13 +1646,17 @@ func (s *sim) observeEnd() {
// sleep hibernates an instance with no attempt in Swap: at once, or through a
// stopping phase the view leaves out. An observation it ends is cancelled.
func (s *sim) sleep() {
- h := s.pick(func(h *simHost) bool {
+ if h := s.pick(func(h *simHost) bool {
_, inSwap := s.swaps[h.name]
return !inSwap && (h.phase == simAwake || h.phase == simObserving)
- })
- if h == nil {
- return
+ }); h != nil {
+ s.sleepOn(h)
}
+}
+
+// sleepOn is sleep for h, which is awake or observing with no attempt in
+// Swap.
+func (s *sim) sleepOn(h *simHost) {
observing := h.phase == simObserving
g := h.gen
h.phase, h.gen = simAsleep, activator.Gen{}
@@ -1696,7 +1717,7 @@ func (s *sim) hostRetry() {
if s.gr != nil {
// A seed whose book is on disk queues the grant (I9): delivered at
// later steps, again and again (deliverGrants).
- s.issueGrant(h)
+ s.issueGrant(h, "")
return
}
b := s.installed[h.ch]
@@ -1741,31 +1762,104 @@ func (s *sim) hostRetry() {
s.logf("instances retry %s onto %s: %v", h.name, b, ok)
}
-// simGrantsKept bounds the grant queue: the oldest goes first, as a real
-// grant expires (24 h; the whole run is well inside that).
-const simGrantsKept = 64
-
-// issueGrant is the console signing `carlos instances retry` for h, on a
-// seed whose book is on disk: it reads, as status does, the (book,
-// generation) the box publishes for a build holding h (the installed build
-// of h's channel, or its hold-ran marker's build), and queues a grant for
-// it, stamped with the step. A build the box publishes no generation for
-// gets no grant (the console refuses the retry).
-func (s *sim) issueGrant(h *simHost) {
+// heldBuilds are the builds that may hold h as status shows it: the
+// installed build of h's channel, and its hold-ran marker's build.
+func (s *sim) heldBuilds(h *simHost) []string {
builds := []string{s.installed[h.ch]}
if m, ok := s.book.HoldRanOn(h.name); ok && m.Build != builds[0] {
builds = append(builds, m.Build)
}
- b := builds[s.gr.Intn(len(builds))]
+ return builds
+}
+
+// retryHeld is an operator pressing Retry on a held instance, on a seed
+// whose book is on disk: one (host, build) the box publishes a generation
+// for, at random, gets a grant (issueGrant).
+func (s *sim) retryHeld() {
+ var held [][2]string
+ for _, h := range s.hosts {
+ for _, b := range s.heldBuilds(h) {
+ if s.published(b, h.name) != 0 {
+ held = append(held, [2]string{h.name, b})
+ }
+ }
+ }
+ if len(held) == 0 {
+ return
+ }
+ p := held[s.gr.Intn(len(held))]
+ s.issueGrant(s.byName[p[0]], p[1])
+}
+
+// rewakeArm and rewakeRun, on a seed whose book is on disk, are an operator
+// pressing Retry on an instance its hold-ran marker holds (asleep, the
+// marker's build X still installed), and the instance then woken again and
+// stopped before that wake settles, before the grant is read. The wake
+// boots X under the marker as a hold first wake (decideHoldRan): a run of
+// X that ends with no verdict renews the marker as a new execution's, and
+// a run that fails holds it down. Either is a fact of (X, host) after the
+// grant was issued, which must leave the grant clearing nothing (I9; M14's
+// witness: a renewal that moved no generation). The random schedule meets
+// this only rarely, as an instance is seldom woken again under its marker
+// before another restriction of its app moves the generation; here the two
+// halves are one step apart.
+//
+// rewakeArm issues the grant and remembers the host.
+func (s *sim) rewakeArm() {
+ var c []*simHost
+ for _, h := range s.hosts {
+ m, ok := s.book.HoldRanOn(h.name)
+ if h.phase == simAsleep && ok && m.Build == s.installed[h.ch] && s.published(m.Build, h.name) != 0 {
+ c = append(c, h)
+ }
+ }
+ if len(c) == 0 {
+ return
+ }
+ h := c[s.gr.Intn(len(c))]
+ s.issueGrant(h, s.installed[h.ch])
+ s.rewake = h.name
+ s.logf("rewake %s: retry pressed under its hold-ran marker", h.name)
+}
+
+// rewakeRun wakes the host rewakeArm armed, if it is still asleep: its plan
+// and exec, then its stop while the first wake is under observation. Then
+// the agent reads its grants.
+func (s *sim) rewakeRun() {
+ h := s.byName[s.rewake]
+ s.rewake = ""
+ if h.phase != simAsleep {
+ return
+ }
+ s.wakeBeginOn(h)
+ if h.phase == simWaking {
+ s.wakeExecOn(h)
+ }
+ if _, inSwap := s.swaps[h.name]; h.phase == simObserving && !inSwap {
+ s.sleepOn(h)
+ }
+ s.deliverGrants()
+}
+
+// issueGrant is the console signing `carlos instances retry` for h onto
+// build b ("" picks one of heldBuilds), on a seed whose book is on disk:
+// it reads, as status does, the (book, generation) the box publishes for
+// b's restriction of h, and queues a grant for it, stamped with the step.
+// A build the box publishes no generation for gets no grant (the console
+// refuses the retry).
+func (s *sim) issueGrant(h *simHost, b string) {
+ if b == "" {
+ builds := s.heldBuilds(h)
+ b = builds[s.gr.Intn(len(builds))]
+ }
epoch := s.published(b, h.name)
if epoch == 0 {
return
}
g := simGrant{build: b, host: h.name, book: s.book.ID(), epoch: epoch, step: s.step, at: s.now}
+ // Pending for the rest of the run: a grant expires after 24 h, and a
+ // run is minutes long.
s.grants = append(s.grants, g)
- if len(s.grants) > simGrantsKept {
- s.grants = s.grants[1:]
- }
k := [2]string{b, h.name}
s.issued[k] = append(s.issued[k], s.now)
s.logf("grant: instances retry %s onto %s issued (book %.8s, generation %d)", h.name, b, g.book, epoch)
@@ -1988,6 +2082,7 @@ func (s *sim) reread() {
case !ok:
delete(s.holdRan, h.name)
case s.holdRan[h.name] != m.Build:
+ // A marker the crash put back (its clear was never saved).
s.holdRan[h.name], s.holdRanAt[h.name], s.holdRanSet[h.name] = m.Build, m.At, s.now
}
}
@@ -2005,6 +2100,15 @@ func (s *sim) reread() {
s.holdRanOn(h, in.Build, start)
}
}
+ // Every restriction standing now was set before the open, and so before
+ // anything the new agent starts (its snapshots are newer than every
+ // marker's Seq): a good write by an execution starting in this very
+ // step lifts it, as the code's ClearHoldRanBefore does.
+ for _, host := range sortedKeys(s.holdRan) {
+ if !s.holdRanSet[host].Before(s.now) {
+ s.holdRanSet[host] = s.now.Add(-time.Nanosecond)
+ }
+ }
}
// forgetBook is the model's side of a box rebuild (agentRestart): the book
diff --git a/internal/rollout/testdata/rolloutsim/mutate.sh b/internal/rollout/testdata/rolloutsim/mutate.sh
index 7d037e80d..3fbda0f60 100755
--- a/internal/rollout/testdata/rolloutsim/mutate.sh
+++ b/internal/rollout/testdata/rolloutsim/mutate.sh
@@ -25,7 +25,7 @@
# The simulation's invariants are I1 to I10 (TestSimInvariants' doc). I9
# and I10 run only on the seeds whose failure book is on disk, which the
# simulation turns off when it runs as root (root writes into a read-only
-# directory, so it cannot fail a save): as root, M13 to M15 read MISSED.
+# directory, so it cannot fail a save): as root, M13 to M16 read MISSED.
#
# Execution integrity is recorded apart from detection. A run must build,
# start, and end in its own --- PASS or --- FAIL line for the test it ran
@@ -106,9 +106,13 @@ TIMEOUT_MIN=$((SEEDS * 3 / 100 + 20))
# it, and the last landed file holds another.
# M14 markLocked's marker set, renewed or replaced moves no generation:
# I9. A grant issued under X's hold-ran marker, then another hold run
-# of X on the host (a first wake under the marker, ended after its
-# exec with no verdict) renews the marker at the same generation, and
-# the grant, delivered again, clears the newer run's marker.
+# of X on the host (a first wake under the marker, stopped before it
+# settles: the simulation's rewakeArm and rewakeRun) renews the marker
+# at the same generation, and the grant, delivered after, clears the
+# newer run's marker. (The plan's witness, X's marker replaced by a
+# failed hold run of Y and Y's marker then cleared by a pointer,
+# catches nothing: the pointer's clear, ClearHoldRanOnPointer, moves
+# the generation on its own line.)
# M15 the reopen's seq keeps only the file's own seq, halved: I9. The
# reopened book hands out again generations it had published, so a
# grant issued before the restart matches a restriction a failure
@@ -116,7 +120,9 @@ TIMEOUT_MIN=$((SEEDS * 3 / 100 + 20))
# that would have repaired the max are not read).
# M16 RetryHostAt skips the book-identity comparison: I9. A box rebuild
# opens a new book whose generations start again from one, so a
-# grant for the old book matches a restriction the new one made.
+# grant for the old book, still pending, matches a restriction the
+# new one made. The rarest of the four kills: at 300 seeds it rests
+# on a few seeds (rebuilds are drawn 1 step in 150).
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/res, err = book\.Record\(rec\)/res, err = Recorded, nil/