carlos / platform Public

rollout: a restriction's generation is durable before use and never repeats (Paul's hard limit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7c7c61411898a7addbc08f5bac05461e4702f9dd parent d0acab1
12 files changed, +972 −244
  • internal/host/rollout.go +6 −6
  • internal/host/rolloutdeclined.go +53 −33
  • internal/host/rolloutdeclined_test.go +79 −0
  • internal/release/hostretry.go +11 −6
  • internal/rollout/failures.go +342 −151
  • internal/rollout/failures_epoch_test.go +379 −0
  • internal/rollout/finalreview1_test.go +22 −11
  • internal/rollout/manager.go +31 −12
  • internal/rollout/retrygrant_test.go +23 −14
  • internal/rollout/sim_test.go +15 −3
  • internal/rollout/status.go +5 −4
  • internal/status/snapshot.go +6 −4
diff --git a/internal/host/rollout.go b/internal/host/rollout.go
index 126b668cf..d57f6024b 100644
--- a/internal/host/rollout.go
+++ b/internal/host/rollout.go
@@ -341,12 +341,12 @@ func armRollout(ctx context.Context, getenv func(string) string, svc *activator.
// nothing it recorded meanwhile would be kept.
log.Printf("agent: %s=overlap: %d execution(s) an earlier agent left unrecorded are now failure records", rollout.SwapEnv, n)
}
- // Every open owes a save before anything runs (final review round 1,
- // finding 3): the open moved the book's sequence past every epoch an
- // earlier run may have handed out unsaved, and gave a new book its
- // identity. Unsaved, a crash would hand those epochs out again, so a
- // grant signed for one restriction could clear another; a box that
- // cannot save refuses managed starts, as it does for reconciliation.
+ // The book is saved before anything runs: an open may owe a save (a new
+ // book's identity, a book an earlier agent wrote given a new one,
+ // reconciled intents), and until it lands status publishes no
+ // generation and no retry grant clears anything (the crash-epoch hard
+ // limit in rollout.FailureBook's doc). A box that cannot save refuses
+ // managed starts, as it does for reconciliation.
if err := book.Flush(); err != nil {
log.Printf("agent: %s=overlap: save the failure book after opening it: %v", rollout.SwapEnv, err)
refuseManaged(svc, rolloutBookUnreadable)
diff --git a/internal/host/rolloutdeclined.go b/internal/host/rolloutdeclined.go
index a2c25040c..f58b31df9 100644
--- a/internal/host/rolloutdeclined.go
+++ b/internal/host/rolloutdeclined.go
@@ -45,8 +45,10 @@ const refuseNoSwaps = "this box cannot run two builds side by side, which this i
// `carlos promote --retry` pointer and a signed rollback pointer
// (applyPointers) each clear in this one book and save it. The planner, the
// launch gate and the legs read it live, so a clear lets the instance start
-// at once, with no agent restart. It is also written once at arming when an
-// armed run left intents in it (honourDeclinedFailures).
+// at once, with no agent restart. It is also written at arming when it owes
+// a save (an armed run left intents in it, or an earlier agent wrote it),
+// and by the periodic pass while that save keeps failing (pass; the
+// crash-epoch hard limit, rule 6).
type failureHolds struct {
book *rollout.FailureBook
// get reads a route by host, for the legs that are handed only a host.
@@ -86,13 +88,10 @@ func honourDeclinedFailures(svc *activator.Service, reg *registry.Registry, dd,
}
if n := book.Reconciled(); n > 0 {
// An armed run's executions left unrecorded (Task 5): their records
- // are honoured below whether or not this save lands, since the book
- // in memory holds them either way, and the next restart reconciles
- // the intents again if it did not.
+ // are honoured below whether or not the save below lands, since the
+ // book in memory holds them either way, and the next restart
+ // reconciles the intents again if it did not.
log.Printf("agent: %s=overlap declined: %d execution(s) an armed run left unrecorded are now failure records", rollout.SwapEnv, n)
- if err := book.Flush(); err != nil {
- log.Printf("agent: %s=overlap declined: save the failure book after reconciling it: %v", rollout.SwapEnv, err)
- }
}
// Records or hold-ran markers (Task 9): a marker alone still bars the good
// build from an instance whose database a hold build may have migrated.
@@ -100,14 +99,16 @@ func honourDeclinedFailures(svc *activator.Service, reg *registry.Registry, dd,
if recs == 0 && marks == 0 {
return
}
- // The open moved the book's sequence past every epoch an armed run may
- // have handed out unsaved (final review round 1, finding 3). A declined
- // box does not save that here, unlike an armed one: it never writes its
- // book except to apply a retry or a pointer (bookSnapshot in its
- // tests), and the values those take are clears, never a restriction's
- // epoch that status publishes; the only restrictions it makes are
- // reconciliation's, saved above. Its first such save carries the
- // advance.
+ // A book that owes a save is saved now, whatever reconciliation found
+ // (the crash-epoch hard limit, rule 6): reconciled records, a new
+ // identity for a book an earlier agent wrote (rule 4), epochs given to
+ // older entries. Until it lands, status publishes no generation and no
+ // retry grant clears anything; the periodic pass (pass) retries it. A
+ // book this rule wrote and the open did not change owes nothing, and is
+ // not written.
+ if err := book.SaveIfDirty(); err != nil {
+ log.Printf("agent: %s=overlap declined: save the failure book after opening it: %v (retried by the periodic pass)", rollout.SwapEnv, err)
+ }
h := &failureHolds{book: book, get: reg.Get, list: reg.List, optDir: optDir}
declinedHolds.Store(h)
svc.Planner = h.plan
@@ -234,11 +235,12 @@ func (h *failureHolds) plan(_ context.Context, rt registry.Route) activator.Wake
// report is the declined box's per-host rollout status (Task 10, plan
// review #5): each managed instance the book holds, with the build that
-// holds it (HeldBy) and that restriction's epoch (HeldEpoch), which the
-// console's retry doors sign into their grant, attempt down when it stays
-// down for the hold and failed otherwise. Nil when nothing is held or the
-// routes cannot be read. A declined box runs no scheduler, so it reports no
-// serving build and no attempt in progress.
+// holds it (HeldBy) and that restriction's generation as published
+// (HeldEpoch, rollout.FailureBook.PublishedEpoch: 0 while the book owes a
+// save), which the console's retry doors sign into their grant, attempt
+// down when it stays down for the hold and failed otherwise. Nil when
+// nothing is held or the routes cannot be read. A declined box runs no
+// scheduler, so it reports no serving build and no attempt in progress.
func (h *failureHolds) report() map[string]status.HostRollout {
routes, err := h.list()
if err != nil {
@@ -259,7 +261,7 @@ func (h *failureHolds) report() map[string]status.HostRollout {
out = map[string]status.HostRollout{}
}
out[rt.Host] = status.HostRollout{Account: acct, App: rt.App, Installed: rt.Version, Good: rt.GoodVersion,
- Attempt: attempt, AttemptBuild: hd.by, HeldBy: hd.by, HeldEpoch: h.book.RestrictionEpoch(acct, rt.App, hd.by, rt.Host),
+ Attempt: attempt, AttemptBuild: hd.by, HeldBy: hd.by, HeldEpoch: h.book.PublishedEpoch(acct, rt.App, hd.by, rt.Host),
HeldBook: h.book.ID()} // the book the epoch counts in (final review round 1, finding 3)
}
return out
@@ -284,10 +286,13 @@ func (h *failureHolds) retryHost(host, build, book string, epoch uint64) bool {
}
acct := release.RouteAccount(*rt)
rec, mark, now, err := h.book.RetryHostAt(acct, rt.App, build, host, book, epoch)
- if err != nil {
+ if err != nil && (rec || mark) {
log.Printf("agent: retry %s on %s: save the failure book: %v (cleared in memory)", build, host, err)
- }
- if !rec && !mark && now != epoch {
+ } else if err != nil {
+ // The book owed a save and it failed again: nothing is cleared until
+ // one lands (the crash-epoch hard limit, rule 2).
+ log.Printf("agent: retry %s on %s: the failure book cannot be saved (%v), so nothing was cleared", build, host, err)
+ } else if !rec && !mark && now != epoch {
log.Printf("agent: retry %s on %s: the grant is for epoch %d, the restriction's is %d, so nothing was cleared", build, host, epoch, now)
} else if !rec && !mark && book != h.book.ID() {
log.Printf("agent: retry %s on %s: the grant is for book %q, not this box's, so nothing was cleared", build, host, book)
@@ -309,16 +314,31 @@ func (h *failureHolds) applyPointers(ctx context.Context, read rollout.PointerRe
}
}
-// pointerLoop runs applyPointers over the registry's routes, at most every
-// rollout.PointerReadEvery, for the life of the agent. An app with nothing
-// the pass could clear costs no read.
-func (h *failureHolds) pointerLoop(ctx context.Context, read rollout.PointerRead) {
- for {
- if routes, err := h.list(); err != nil {
- log.Printf("agent: declined pointer pass: list routes: %v", err)
+// pass is one turn of the declined box's periodic pass: the save the book
+// owes, if any (the crash-epoch hard limit, rule 6), so a box whose disk
+// recovers publishes its generations again with no pointer or grant
+// arriving, then the pointer pass over the registry's routes.
+func (h *failureHolds) pass(ctx context.Context, read rollout.PointerRead) {
+ if h.book.SaveOwed() {
+ if err := h.book.SaveIfDirty(); err != nil {
+ log.Printf("agent: declined pass: save the failure book: %v", err)
} else {
- h.applyPointers(ctx, read, routes)
+ nudgeStatus()
}
+ }
+ if routes, err := h.list(); err != nil {
+ log.Printf("agent: declined pointer pass: list routes: %v", err)
+ } else {
+ h.applyPointers(ctx, read, routes)
+ }
+}
+
+// pointerLoop runs pass, at most every rollout.PointerReadEvery, for the
+// life of the agent. An app with nothing the pointer pass could clear costs
+// no read.
+func (h *failureHolds) pointerLoop(ctx context.Context, read rollout.PointerRead) {
+ for {
+ h.pass(ctx, read)
select {
case <-ctx.Done():
return
diff --git a/internal/host/rolloutdeclined_test.go b/internal/host/rolloutdeclined_test.go
index dfe790875..e7b816e5a 100644
--- a/internal/host/rolloutdeclined_test.go
+++ b/internal/host/rolloutdeclined_test.go
@@ -913,6 +913,85 @@ func TestDeclinedBoxProcessesAVerifiedSignedRetry(t *testing.T) {
}
}
+// writePreRuleBook writes dd's failure book as an agent before the
+// crash-epoch hard limit left it (readiness round 2, Task 1): schema 2 with
+// a book identity and per-record epochs, no generations and no
+// durable_epochs. v2's record holds host down.
+func writePreRuleBook(t *testing.T, dd, acct, host string) {
+ t.Helper()
+ raw, err := json.Marshal(map[string]any{
+ "schema": 2,
+ "book": "old",
+ "seq": 10,
+ "records": []map[string]any{{"account": acct, "app": "blog", "build": "v2", "host": "sib.example.com",
+ "mode": "hold", "reason": "health", "held_down": []string{host}, "epochs": map[string]uint64{"": 11, host: 11}}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dd, rollout.FailureFile), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Readiness round 2, Task 1 (plan 10): a declined box saves a book that
+// owes a save at startup, whatever reconciliation found (here a book an
+// earlier agent wrote, whose identity the open rotates), and publishes its
+// generation at once. When that save fails it publishes the hold with no
+// generation, and the next periodic pass, with no pointer or grant
+// arriving, saves the book once the disk recovers and publishes it.
+func TestDeclinedBoxPublishesAGenerationOnceItsBookIsSaved(t *testing.T) {
+ for _, diskFails := range []bool{false, true} {
+ t.Run(map[bool]string{false: "saved at startup", true: "saved by the periodic pass"}[diskFails], func(t *testing.T) {
+ if diskFails && os.Geteuid() == 0 {
+ t.Skip("running as root: a read-only directory does not fail a save")
+ }
+ t.Setenv("CARLOS_OPT_DIR", t.TempDir())
+ svc, reg, dd := rolloutFixture(t, false)
+ const host = "h.example.com"
+ rt := heldRouteIn(t, reg, host, "v2", "v1")
+ acct := release.RouteAccount(rt)
+ writePreRuleBook(t, dd, acct, host)
+ if diskFails {
+ if err := os.Chmod(dd, 0o500); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.Chmod(dd, 0o700) })
+ }
+ declineWithBook(t, svc, reg, dd)
+ hr, ok := declinedHostReport(t, host)
+ if !ok || hr.HeldBy != "v2" || hr.Attempt != status.AttemptDown || hr.HeldBook == "old" || hr.HeldBook == "" {
+ t.Fatalf("published %+v (%v), want v2 holding %s down under a new book identity", hr, ok, host)
+ }
+ if diskFails {
+ if hr.HeldEpoch != 0 {
+ t.Fatalf("published generation %d while the book owes a save, want 0", hr.HeldEpoch)
+ }
+ if err := os.Chmod(dd, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if hr, _ := declinedHostReport(t, host); hr.HeldEpoch != 0 {
+ t.Fatalf("published generation %d before any save landed, want 0", hr.HeldEpoch)
+ }
+ p := &pointerReads{ptrs: map[string]*release.ChannelPointer{}, errs: map[string]error{"blog/stable": errors.New("no pointer")}}
+ heldHolds().pass(context.Background(), p.read)
+ hr, _ = declinedHostReport(t, host)
+ }
+ if hr.HeldEpoch == 0 {
+ t.Fatalf("published %+v: want a generation once the book is saved", hr)
+ }
+ b, err := rollout.OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b.ID() != hr.HeldBook || b.PublishedEpoch(acct, "blog", "v2", host) != hr.HeldEpoch {
+ t.Fatalf("the book on disk is %q at %d, want the published %q at %d",
+ b.ID(), b.PublishedEpoch(acct, "blog", "v2", host), hr.HeldBook, hr.HeldEpoch)
+ }
+ })
+ }
+}
+
// pointerReads is a fake signed pointer read keyed "<app>/<channel>", which
// counts the reads it is asked for.
type pointerReads struct {
diff --git a/internal/release/hostretry.go b/internal/release/hostretry.go
index 9ac7cc634..2aba99e9b 100644
--- a/internal/release/hostretry.go
+++ b/internal/release/hostretry.go
@@ -14,9 +14,11 @@ import (
// The restart-scope log it rides is writable by every box (Astra impl-6
// finding 5), so an unsigned entry is a request anyone with a box could
// have written, and it clears nothing. The signature closes forgery; Epoch
-// closes replay (plan review finding 13): it is the restriction's persisted
-// epoch as the box reported it (rollout.FailureBook.RestrictionEpoch), so a
-// grant clears the restriction the member saw and nothing that failed
+// and Book close replay (plan review finding 13; the crash-epoch hard limit,
+// readiness round 2 Task 1): Epoch is the app's restriction generation as
+// the box published it (rollout.FailureBook.PublishedEpoch), which the box
+// publishes only once it is on disk and never hands out twice, so a grant
+// clears the restriction state the member saw and nothing that changed
// after, even across an agent restart.
type HostRetryGrant struct {
V int `json:"v"`
@@ -28,9 +30,12 @@ type HostRetryGrant struct {
// names that build (plan review finding 5). It is not the build the
// host should move to.
Build string `json:"build"`
- // Epoch is the restriction's epoch the console read from the box's
- // report. Zero is an older box that reported none: the console still
- // signs it, and no box accepts it.
+ // Epoch is the app's restriction generation as published, which the
+ // console read from the box's report (status.HostRollout.HeldEpoch):
+ // every change to any restriction of the app moves it, so the grant
+ // clears only while nothing of the app's restrictions changed since.
+ // Zero is an older box that reported none, or a box whose book owes a
+ // save: the console still signs it, and no box accepts it.
Epoch uint64 `json:"epoch"`
// Book is the identity of the failure book Epoch counts in, as the box
// reported it (status.HostRollout.HeldBook): epochs are a counter every
diff --git a/internal/rollout/failures.go b/internal/rollout/failures.go
index 413fb3ce3..35e359853 100644
--- a/internal/rollout/failures.go
+++ b/internal/rollout/failures.go
@@ -47,14 +47,12 @@ type FailureRecord struct {
// Retried are instances `carlos instances retry` cleared: the record no
// longer holds them, and their next attempt is a canary.
Retried []string `json:"retried,omitempty"`
- // Epochs are the book's sequence values at which this record last
- // changed (plan review finding 13): under "" the record as a whole
- // (created, or merged with a change), under a host that host's standing
- // in it (a hold-down added, a retry spent or granted). A signed host
- // retry names the epoch it was issued for (RestrictionEpoch), so it
- // clears the restriction the member saw and never a later failure of the
- // same build. Saved with the record, so a reopen keeps them; a record an
- // earlier agent wrote gets one when the book opens.
+ // Epochs are the per-record epochs an agent before the crash-epoch hard
+ // limit (readiness round 2, Task 1) wrote: the book's sequence values at
+ // which the record last changed. They are read only so a reopen's
+ // sequence starts past them (OpenFailureBook), then dropped, and never
+ // written again: what a signed host retry names now is the app's
+ // generation (FailureBook.gens).
Epochs map[string]uint64 `json:"epochs,omitempty"`
// MarkHoldRan asks Record to set Host's hold-ran marker onto this
// record's build in the same save (final review wave 2, N2): an attempt
@@ -222,37 +220,124 @@ type failureFile struct {
// Intents are the executions in flight by host (Intent), schema 2.
Intents map[string]Intent `json:"intents,omitempty"`
// Book is the book's identity (FailureBook.id), additive like HoldRan:
- // 16 random bytes, hex, made when the book is first created or first
- // opened without one, and kept for its life.
+ // 16 random bytes, hex, made when the book is first created, and again
+ // when a file from before the crash-epoch rule is adopted (Durable).
Book string `json:"book"`
+ // Gens are the apps' restriction generations (FailureBook.gens), keyed
+ // account + "/" + app. Additive: an older agent ignores them.
+ Gens map[string]uint64 `json:"gens,omitempty"`
+ // Durable says the file was written under the crash-epoch rule (rule 4
+ // in FailureBook's doc): every generation it ever published was on disk
+ // first. A file without it was written by an earlier agent, which may
+ // have published epochs it never saved, so OpenFailureBook gives such a
+ // book a new identity.
+ Durable bool `json:"durable_epochs,omitempty"`
}
-// reopenAdvance is how far OpenFailureBook moves the sequence past the one
-// on disk (final review round 1, finding 3): epochs handed out after the
-// last save that landed live only in memory, and status may have published
-// them, so a reopen must not hand the same values out again. The caller
-// saves the advance before it runs anything (Flush); an armed box whose save
-// fails refuses managed starts. A run would need over a million book events
-// between two saves that land to reach it.
-const reopenAdvance = 1 << 20
-
-// newBookID is a book's identity (FailureBook.id). crypto/rand does not fail
-// on the platforms the agent runs on; if it ever did, the clock's
-// nanoseconds still tell two books apart.
-func newBookID() string {
+// highestSaved is the largest sequence value the file holds: its own seq,
+// every app's generation, every marker's, and every epoch an earlier agent
+// wrote into a record. A reopen's sequence starts there (rule 3). In a file
+// this rule wrote it is the file's own seq; the max keeps a hand-edited or
+// older file from handing out a value one of its entries already holds.
+func (f failureFile) highestSaved() uint64 {
+ n := f.Seq
+ for _, g := range f.Gens {
+ n = max(n, g)
+ }
+ for _, h := range f.HoldRan {
+ n = max(n, h.Seq)
+ }
+ for _, r := range f.Records {
+ for _, e := range r.Epochs {
+ n = max(n, e)
+ }
+ }
+ return n
+}
+
+// randRead is crypto/rand's Read, the one source of a book's identity
+// (rule 5). A variable so a test can make it fail.
+var randRead = rand.Read
+
+// newBookID is a book's identity (FailureBook.id): 16 bytes from
+// crypto/rand, hex. It never falls back to anything weaker: an identity
+// two books could share would let a grant for one clear the other, so a
+// read that fails is an error for the caller (OpenFailureBook) to fail on.
+// (Since Go 1.24 crypto/rand.Read crashes the process rather than return an
+// error, so in production this path is a guard; the seam reaches it.)
+func newBookID() (string, error) {
var b [16]byte
- if _, err := rand.Read(b[:]); err != nil {
- return fmt.Sprintf("t%x", time.Now().UnixNano())
+ if _, err := randRead(b[:]); err != nil {
+ return "", fmt.Errorf("make the failure book's identity: %w", err)
}
- return hex.EncodeToString(b[:])
+ return hex.EncodeToString(b[:]), nil
}
+// genKey is an app's key in FailureBook.gens and failureFile.Gens.
+func genKey(acct, app string) string { return acct + "/" + app }
+
// FailureBook is the box's failure records. An empty path keeps them in
// memory only, which is what tests and the simulation use.
//
// Records handed out (Lookup, ForApp, List) are copies, inner slices
// included, because a status reader holds them outside the lock while
// RetryHost and Record edit the book.
+//
+// # The crash-epoch hard limit
+//
+// A signed `carlos instances retry` grant (release.HostRetryGrant) names a
+// book, an app and a generation, which the box's status published, and a
+// stale grant must never clear a later failure (Paul's ruling 2026-10-12,
+// readiness round 2 Task 1). The book keeps these rules:
+//
+// 1. One generation per app. gens[acct/app] takes bumpLocked's next value
+// (touchLocked) on every change to any restriction of that app: a
+// record created, a merge that changes a record, a host retried, a
+// record removed (ClearOnPointer, Prune), a marker of the app set,
+// renewed or replaced (markLocked; a replaced marker of another app
+// moves that app's too) or removed (ClearHoldRan, ClearHoldRanBefore,
+// ClearHoldRanOnPointer, RetryHostAt), an intent reconciled into a
+// record, and an epoch given at open to an older marker or an app
+// without a generation (epochOlderLocked). A call that changes nothing
+// (PreRetry, Unchanged, a repeated marker of the same execution) moves
+// nothing. The generation of build's restriction of host is the app's
+// when build's record blocks host or host's marker names build, else 0
+// (restrictionEpochLocked). Prune drops an app's generation only when
+// the app has no record and no marker left; a later restriction of it
+// takes a fresh value, larger than every earlier one.
+// 2. Durable before use. dirty is true whenever memory differs from the
+// last save that landed: every call that changes recs, held, intents,
+// gens or seq saves under the same hold of mu (a failed save sets
+// dirty), or the open leaves dirty set. PublishedEpoch answers 0 while
+// dirty, and RetryHostAt first retries an owed save and clears nothing
+// while the book is still dirty.
+// 3. Monotonic across restarts. Every landed save writes the in-memory
+// seq, which only grows, and a reopen starts from the file's seq, maxed
+// with every saved generation, marker and epoch (highestSaved). So the
+// disk's seq only grows, every published generation is at most it, and
+// every value a later run hands out is larger.
+// 4. Adoption rotates the identity. A file without durable_epochs, written
+// by an earlier agent that may have published epochs it never saved,
+// opens with a new identity and owes a save (dirty), so nothing is
+// published until the rotation is on disk, and every grant issued
+// before names the old identity.
+// 5. The identity comes from crypto/rand only (newBookID). An open that
+// needs one and cannot get one fails. Only a memory-only book, which
+// tests and the simulation use, falls back to the clock.
+// 6. An owed save is retried on its own: the armed manager's pass and a
+// declined box's periodic pass call SaveIfDirty, and a declined box
+// saves a dirty book at startup.
+//
+// Proof sketch: a grant G names book X, app A and generation E, which
+// status published, so E was on X's disk (2, 4). RetryHostAt clears only
+// when X is this book, memory equals disk, and A's generation now equals E
+// with build still restricting host. Within a run every change takes a
+// fresh larger value (1), so equality means no change of A's restrictions
+// since publication. Across a crash the reopened book starts at the disk's
+// seq, which is at least E, and hands out larger values (3). A book written
+// before the rule has another identity (4), and another book has another
+// random identity (5). So G clears only the restriction state it was
+// issued for.
type FailureBook struct {
path string
mu sync.Mutex
@@ -264,27 +349,34 @@ type FailureBook struct {
// records.
intents map[string]Intent
reconciled int
- // dirty is set when a save failed. Memory is the truth meanwhile, and
- // every later mutating call and Prune retries the save until it lands.
- // What keeps a failure across an agent restart before that is the
- // execution's intent (Begin), saved before the execution ran anything and
- // removed only after its outcome was written (End): a restart finds it
- // on disk and records the failure from it (spec §8.4, Astra R1 #5).
+ // dirty is set whenever memory differs from the last save that landed
+ // (rule 2): a save failed, or the open changed something (a rotated
+ // identity, reconciled intents, epochs given to older entries). Memory is
+ // the truth meanwhile, and every later mutating call, Prune and
+ // SaveIfDirty retry the save until it lands; nothing is published or
+ // retried until then. What keeps a failure across an agent restart before
+ // that is the execution's intent (Begin), saved before the execution ran
+ // anything and removed only after its outcome was written (End): a
+ // restart finds it on disk and records the failure from it (spec §8.4,
+ // Astra R1 #5).
dirty bool
// now stamps a marker whose caller gave no At (markLocked). Nil means
// time.Now; tests and the simulation set their own clock.
now func() time.Time
// seq orders the book's events that later outcomes are compared with
// (Astra R3 N6, ruling 191): every marker set, every retry clear and
- // every change to a record (its epochs, plan review finding 13) 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 and the
- // records' epochs, so snapshots and epochs after a restart are newer than
- // every one on disk. The cleared map below is not saved; a host retry's
- // clear value is, as the host's epoch in its record, so a spent grant
- // stays spent across a restart.
+ // every app generation (gens) 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 and the generations, so snapshots and
+ // generations after a restart are newer than every one on disk (rule 3).
+ // The cleared map below is not saved; a host retry's clear moves the
+ // app's generation, which is, so a spent grant stays spent across a
+ // restart.
seq uint64
+ // gens are the apps' restriction generations by genKey (rule 1), saved
+ // with the records. A signed host retry names the one it was issued for.
+ gens map[string]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
@@ -298,8 +390,9 @@ type FailureBook struct {
// naming it (final review round 1, finding 3, Astra): epochs are a
// counter every book starts alike, so two books (a host relocated, a box
// rebuilt) can give one value to two restrictions, and without the
- // identity a grant for one would clear the other. A memory-only book
- // makes one on first use (idLocked).
+ // identity a grant for one would clear the other. OpenFailureBook sets
+ // it (rules 4 and 5); a memory-only book makes one on first use
+ // (idLocked).
id string
}
@@ -310,13 +403,29 @@ func (b *FailureBook) ID() string {
return b.idLocked()
}
+// idLocked is the book's identity. Only a memory-only book (tests, the
+// simulation) reaches it without one, since OpenFailureBook always sets
+// it; such a book falls back to the clock if crypto/rand fails (rule 5).
func (b *FailureBook) idLocked() string {
if b.id == "" {
- b.id = newBookID()
+ id, err := newBookID()
+ if err != nil {
+ id = fmt.Sprintf("m%x", time.Now().UnixNano())
+ }
+ b.id = id
}
return b.id
}
+// touchLocked moves (acct, app)'s restriction generation to the book's next
+// sequence value (rule 1). The caller holds b.mu and saves.
+func (b *FailureBook) touchLocked(acct, app string) {
+ if b.gens == nil {
+ b.gens = map[string]uint64{}
+ }
+ b.gens[genKey(acct, app)] = b.bumpLocked()
+}
+
func (b *FailureBook) clock() time.Time {
if b.now != nil {
return b.now()
@@ -324,12 +433,11 @@ func (b *FailureBook) clock() time.Time {
return time.Now()
}
-// cloneRec copies r's host lists and epochs so the copy shares nothing
-// with r.
+// cloneRec copies r's host lists so the copy shares nothing with r. A
+// record in memory has no Epochs (OpenFailureBook drops them).
func cloneRec(r FailureRecord) FailureRecord {
r.HeldDown = slices.Clone(r.HeldDown)
r.Retried = slices.Clone(r.Retried)
- r.Epochs = maps.Clone(r.Epochs)
return r
}
@@ -339,17 +447,25 @@ func cloneRec(r FailureRecord) FailureRecord {
// the file become failure records (reconcileLocked, Reconciled), which the
// caller saves with Flush.
//
-// Every open moves the sequence reopenAdvance past the file's (final review
-// round 1, finding 3): an epoch handed out after the last save that landed
-// was never written, and must not be handed out again for another
-// restriction. A book with no identity on disk (a new one, or a file an
-// earlier agent wrote) gets one. Both are owed a save (dirty), which the
-// caller makes with Flush before it runs anything.
+// The sequence starts at the largest value the file holds (rule 3 in
+// FailureBook's doc), so nothing handed out from here repeats a generation
+// status published: that was on disk. A book with no file gets a new
+// identity, and so does a file without durable_epochs (rule 4): an earlier
+// agent wrote it and may have published epochs it never saved. Either owes
+// a save (dirty), as does an open that reconciled intents or gave older
+// entries epochs; nothing is published until it lands. An identity
+// crypto/rand cannot make is an error (rule 5): the caller treats the book
+// as unreadable. A book this rule wrote opens with no save owed unless the
+// open changed something.
func OpenFailureBook(dataDir string) (*FailureBook, error) {
b := &FailureBook{path: filepath.Join(dataDir, FailureFile)}
raw, err := os.ReadFile(b.path)
if errors.Is(err, os.ErrNotExist) {
- b.id, b.seq, b.dirty = newBookID(), reopenAdvance, true
+ id, err := newBookID()
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", b.path, err)
+ }
+ b.id, b.dirty = id, true
return b, nil
}
if err != nil {
@@ -365,25 +481,27 @@ func OpenFailureBook(dataDir string) (*FailureBook, error) {
}
b.recs = f.Records
b.held = f.HoldRan
- b.seq = f.Seq
b.intents = f.Intents
+ b.gens = f.Gens
b.id = f.Book
- if b.id == "" {
- b.id = newBookID()
- }
- for _, h := range b.held {
- // A marker's sequence is saved with the book's, so this changes
- // nothing in a file this book wrote; it keeps a hand-edited one from
- // handing out a snapshot a standing marker is newer than.
- b.seq = max(b.seq, h.Seq)
+ b.seq = f.highestSaved() // the reopen's sequence (rule 3)
+ for i := range b.recs {
+ // An earlier agent's per-record epochs: read for the sequence above,
+ // never written again, and never what a grant is checked against.
+ if b.recs[i].Epochs != nil {
+ b.recs[i].Epochs = nil
+ b.dirty = true
+ }
}
- for _, r := range b.recs {
- for _, e := range r.Epochs { // the same, for the records' epochs
- b.seq = max(b.seq, e)
+ if !f.Durable || b.id == "" {
+ // Adoption of the rule (rule 4): grants issued before name the old
+ // identity, or none.
+ id, err := newBookID()
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", b.path, err)
}
+ b.id, b.dirty = id, true
}
- b.seq += reopenAdvance
- b.dirty = true
b.mu.Lock()
b.epochOlderLocked()
b.reconcileLocked()
@@ -391,23 +509,15 @@ func OpenFailureBook(dataDir string) (*FailureBook, error) {
return b, nil
}
-// epochOlderLocked gives every record and marker an earlier agent wrote
-// without an epoch one now (plan review finding 13): a zero epoch is never
-// accepted by a signed retry, so without one its hold could not be retried
-// from the console. Records first, in file order, then markers by host, so
-// two opens of the same file hand out the same values. The book then owes a
-// save (dirty), which the next mutating call or Prune makes; nothing is
-// written here. The caller holds b.mu.
+// epochOlderLocked gives what an earlier agent wrote without a sequence
+// value one now: each marker with no Seq (by host, sorted), then each app
+// that has a record or a marker and no generation (sorted by key), so two
+// opens of the same file hand out the same values. A zero generation is
+// never accepted by a signed retry, so without one such a hold could not be
+// retried from the console. A marker given a Seq moves its app's generation
+// (rule 1). The book then owes a save (dirty), which the caller or the next
+// mutating call makes; nothing is written here. The caller holds b.mu.
func (b *FailureBook) epochOlderLocked() {
- for i := range b.recs {
- if b.recs[i].Epochs[""] == 0 {
- if b.recs[i].Epochs == nil {
- b.recs[i].Epochs = map[string]uint64{}
- }
- b.recs[i].Epochs[""] = b.bumpLocked()
- b.dirty = true
- }
- }
hosts := make([]string, 0, len(b.held))
for host, h := range b.held {
if h.Seq == 0 {
@@ -415,10 +525,25 @@ func (b *FailureBook) epochOlderLocked() {
}
}
slices.Sort(hosts)
+ apps := map[string][2]string{}
for _, host := range hosts {
h := b.held[host]
h.Seq = b.bumpLocked()
b.held[host] = h
+ apps[genKey(h.Account, h.App)] = [2]string{h.Account, h.App}
+ }
+ for _, r := range b.recs {
+ if b.gens[genKey(r.Account, r.App)] == 0 {
+ apps[genKey(r.Account, r.App)] = [2]string{r.Account, r.App}
+ }
+ }
+ for _, h := range b.held {
+ if b.gens[genKey(h.Account, h.App)] == 0 {
+ apps[genKey(h.Account, h.App)] = [2]string{h.Account, h.App}
+ }
+ }
+ for _, k := range slices.Sorted(maps.Keys(apps)) {
+ b.touchLocked(apps[k][0], apps[k][1])
b.dirty = true
}
}
@@ -479,6 +604,25 @@ func (b *FailureBook) Flush() error {
return b.saveLocked()
}
+// SaveIfDirty saves the book when a save is owed (dirty: a save failed, or
+// the open changed something), else does nothing and returns nil (rule 6).
+// The armed manager's pass and a declined box's periodic pass call it, so a
+// book whose disk recovers publishes its generations again without waiting
+// for another change, pointer or grant.
+func (b *FailureBook) SaveIfDirty() error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.retryLocked()
+}
+
+// SaveOwed reports whether the book owes a save (dirty): while it does,
+// PublishedEpoch answers 0 and RetryHostAt clears nothing.
+func (b *FailureBook) SaveOwed() bool {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.dirty
+}
+
// Begin writes host's intent and saves it before the execution runs
// anything (rule 1). On a save error nothing of it stays in memory, the host's
// earlier intent (if this one replaced it) is put back, and the error is
@@ -539,12 +683,16 @@ func (b *FailureBook) End(host, id string) error {
}
// saveLocked writes the book and records whether the file is now behind
-// memory, so a failure is retried by retryLocked rather than forgotten.
+// memory, so a failure is retried by retryLocked rather than forgotten. It
+// writes the in-memory seq, which only grows (rule 3), the generations, and
+// durable_epochs, since everything this agent publishes is on disk first
+// (rule 2).
func (b *FailureBook) saveLocked() error {
if b.path == "" {
return nil
}
- raw, err := json.MarshalIndent(failureFile{Schema: failureSchema, Records: b.recs, HoldRan: b.held, Seq: b.seq, Intents: b.intents, Book: b.idLocked()}, "", " ")
+ raw, err := json.MarshalIndent(failureFile{Schema: failureSchema, Records: b.recs, HoldRan: b.held, Seq: b.seq, Intents: b.intents,
+ Book: b.idLocked(), Gens: b.gens, Durable: true}, "", " ")
if err == nil {
err = release.WriteFileAtomic(b.path, raw, 0o644)
}
@@ -657,47 +805,32 @@ func (b *FailureBook) recordLocked(r FailureRecord) (RecordResult, bool) {
}
i := b.find(r.Account, r.App, r.Build)
if i < 0 {
- r.Retried = nil
- // A new record is a new restriction for every host it holds: the
- // record-wide epoch and the failing host's (plan review finding 13).
- e := b.bumpLocked()
- r.Epochs = map[string]uint64{"": e, r.Host: e}
- for _, h := range r.HeldDown {
- r.Epochs[h] = e
- }
+ r.Retried, r.Epochs = nil, nil
+ // A new record is a new restriction of every host of the app (rule 1).
b.recs = append(b.recs, cloneRec(r))
+ b.touchLocked(r.Account, r.App)
return Recorded, true
}
cur := &b.recs[i]
- var moved []string // hosts whose standing in the record changed
- recChanged := false
+ changed := false
if j := slices.Index(cur.Retried, r.Host); j >= 0 {
cur.Retried = slices.Delete(slices.Clone(cur.Retried), j, j+1)
- moved = append(moved, r.Host)
+ changed = true
}
if r.PromotedAt.After(cur.PromotedAt) {
cur.PromotedAt = r.PromotedAt
- recChanged = true
+ changed = true
}
for _, h := range r.HeldDown {
if !slices.Contains(cur.HeldDown, h) {
cur.HeldDown = append(slices.Clone(cur.HeldDown), h)
- moved = append(moved, h)
+ changed = true
}
}
- if recChanged || len(moved) > 0 {
- // A merge with a change moves the record-wide epoch and the epochs
- // of the hosts it moved, so a grant signed before it clears nothing
- // (plan review finding 13).
- cur.Epochs = maps.Clone(cur.Epochs)
- if cur.Epochs == nil {
- cur.Epochs = map[string]uint64{}
- }
- e := b.bumpLocked()
- cur.Epochs[""] = e
- for _, h := range moved {
- cur.Epochs[h] = e
- }
+ if changed {
+ // A merge with a change moves the app's generation, so a grant
+ // signed before it clears nothing (plan review finding 13, rule 1).
+ b.touchLocked(r.Account, r.App)
return Merged, true
}
return Unchanged, marked
@@ -716,7 +849,11 @@ func (b *FailureBook) recordLocked(r FailureRecord) (RecordResult, bool) {
// it clears nothing (ClearHoldRanBefore). It keeps the later of the two
// stamps, whatever the builds (Task 4 fix round 1): a late report of an
// older execution never moves At back, so a rollback signed between the two
-// runs never looks newer than the marker. The caller holds b.mu and saves.
+// runs never looks newer than the marker.
+//
+// Every marker set, renewed or replaced moves its app's generation (rule
+// 1); a marker of another app it replaces (a host now of another app) moves
+// that app's too. The caller holds b.mu and saves.
func (b *FailureBook) markLocked(host string, h HoldRan) bool {
cur, ok := b.held[host]
if ok && cur.same(h) && h.Exec != "" && cur.Exec == h.Exec {
@@ -732,6 +869,10 @@ func (b *FailureBook) markLocked(host string, h HoldRan) bool {
h.At = cur.At
}
h.Seq = b.bumpLocked() // rolloutsim:M12
+ if ok && (cur.Account != h.Account || cur.App != h.App) {
+ b.touchLocked(cur.Account, cur.App) // the replaced marker's app lost a restriction
+ }
+ b.touchLocked(h.Account, h.App) // the marker set, renewed or replaced: its app's generation moves
b.held[host] = h
return true
}
@@ -774,10 +915,12 @@ func (b *FailureBook) HoldRanCount() int {
func (b *FailureBook) ClearHoldRan(host string) (bool, error) {
b.mu.Lock()
defer b.mu.Unlock()
- if _, ok := b.held[host]; !ok {
+ cur, ok := b.held[host]
+ if !ok {
return false, b.retryLocked()
}
delete(b.held, host)
+ b.touchLocked(cur.Account, cur.App)
return true, b.saveLocked()
}
@@ -796,6 +939,7 @@ func (b *FailureBook) ClearHoldRanBefore(host string, snap uint64) (bool, error)
return false, b.retryLocked()
}
delete(b.held, host)
+ b.touchLocked(cur.Account, cur.App)
return true, b.saveLocked()
}
@@ -817,6 +961,7 @@ func (b *FailureBook) ClearHoldRanOnPointer(host, acct, app string, promotedAt t
return false, b.retryLocked()
}
delete(b.held, host)
+ b.touchLocked(acct, app)
return true, b.saveLocked()
}
@@ -885,10 +1030,9 @@ func (b *FailureBook) RetryHost(acct, app, build, host string) (bool, error) {
}
// retryHostLocked is RetryHost's change to memory, and reports whether it
-// un-retried the host. The clear's sequence value is also the host's epoch
-// in the record (plan review finding 13), saved with it: a grant that was
-// spent here never matches again, even after a reopen. The caller holds
-// b.mu and saves.
+// un-retried the host. It moves the app's generation (rule 1), saved with
+// the book: a grant that was spent here never matches again, even after a
+// reopen. The caller holds b.mu and saves.
func (b *FailureBook) retryHostLocked(acct, app, build, host string) bool {
i := b.find(acct, app, build)
if i < 0 || slices.Contains(b.recs[i].Retried, host) {
@@ -900,55 +1044,72 @@ func (b *FailureBook) retryHostLocked(acct, app, build, host string) bool {
if j := slices.Index(cur.HeldDown, host); j >= 0 {
cur.HeldDown = slices.Delete(slices.Clone(cur.HeldDown), j, j+1)
}
- cur.Epochs = maps.Clone(cur.Epochs)
- if cur.Epochs == nil {
- cur.Epochs = map[string]uint64{}
- }
- cur.Epochs[host] = b.cleared[clearKey(acct, app, build, host)]
+ b.touchLocked(acct, app)
return true
}
-// RestrictionEpoch is the epoch of build's restriction of host (plan review
-// finding 13): the larger of the build's record's record-wide and host
-// epochs (FailureRecord.Epochs), and host's hold-ran marker's sequence when
-// the marker names build. Zero when nothing of build restricts host. Status
-// publishes it beside HeldBy (HostRollout.HeldEpoch), the console signs it
-// into its retry grant, and RetryHostAt clears only while it is unchanged.
+// RestrictionEpoch is the generation of build's restriction of host as it
+// stands in memory (restrictionEpochLocked), saved or not. It is for logs
+// and tests: status publishes PublishedEpoch, never this.
func (b *FailureBook) RestrictionEpoch(acct, app, build, host string) uint64 {
b.mu.Lock()
defer b.mu.Unlock()
return b.restrictionEpochLocked(acct, app, build, host)
}
-func (b *FailureBook) restrictionEpochLocked(acct, app, build, host string) uint64 {
- var e uint64
- if i := b.find(acct, app, build); i >= 0 {
- ep := b.recs[i].Epochs
- e = max(ep[""], ep[host])
+// PublishedEpoch is the generation of build's restriction of host that
+// status publishes beside HeldBy (HostRollout.HeldEpoch) and the console
+// signs into its retry grant: the app's generation when build's record
+// blocks host or host's marker names build, and the book's memory equals
+// its last landed save (rule 2). Zero otherwise: nothing of build restricts
+// host, or the book owes a save, so the value might not survive a crash.
+func (b *FailureBook) PublishedEpoch(acct, app, build, host string) uint64 {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.dirty { // durable before use (rule 2)
+ return 0
}
- if m, ok := b.held[host]; ok && m.Account == acct && m.App == app && m.Build == build {
- e = max(e, m.Seq)
+ return b.restrictionEpochLocked(acct, app, build, host)
+}
+
+// restrictionEpochLocked is (acct, app)'s generation when build restricts
+// host, through its record (which blocks every host of the app it does not
+// list as retried) or host's hold-ran marker naming build; else 0 (rule 1).
+// The caller holds b.mu.
+func (b *FailureBook) restrictionEpochLocked(acct, app, build, host string) uint64 {
+ i := b.find(acct, app, build)
+ byRecord := i >= 0 && !slices.Contains(b.recs[i].Retried, host)
+ m, ok := b.held[host]
+ byMarker := ok && m.Account == acct && m.App == app && m.Build == build
+ if !byRecord && !byMarker {
+ return 0
}
- return e
+ return b.gens[genKey(acct, app)]
}
// RetryHostAt is a signed `carlos instances retry <host>` grant (spec §8.4,
// §11; HostRetryGrant) applied to the book: when the grant names this book
-// (book, the book's ID) and epoch is build's restriction of host now
-// (RestrictionEpoch), it clears build's record for
-// host, when that record holds host (RetryHost), and host's hold-ran
-// marker, when the marker names build (plan review finding 5). A grant from
-// before the restriction last changed, a zero epoch, or one for a build that
-// restricts nothing clears nothing. Nor does a grant for another book, or
-// one that names no book (a console or a box from before the identity):
-// its epoch could be another book's (final review round 1, finding 3). The check and the clears are one hold of
-// the lock. now is the epoch at the check, for the caller's log.
+// (book, the book's ID), the book owes no save, and epoch is build's
+// restriction of host now (the app's generation, PublishedEpoch), it clears
+// build's record for host, when that record holds host (RetryHost), and
+// host's hold-ran marker, when the marker names build (plan review finding
+// 5). A grant from before any restriction of the app last changed, a zero
+// epoch, or one for a build that restricts nothing clears nothing. Nor does
+// a grant for another book, or one that names no book (a console or a box
+// from before the identity): its epoch could be another book's (final
+// review round 1, finding 3). An owed save is retried first, and while it
+// fails nothing is cleared (rule 2): the generation in memory may be one no
+// disk holds. The check and the clears are one hold of the lock. now is the
+// generation at the check, for the caller's log; err is the save's.
func (b *FailureBook) RetryHostAt(acct, app, build, host, book string, epoch uint64) (rec, mark bool, now uint64, err error) {
b.mu.Lock()
defer b.mu.Unlock()
now = b.restrictionEpochLocked(acct, app, build, host)
+ if err := b.retryLocked(); b.dirty {
+ return false, false, now, err
+ }
if book == "" || book != b.idLocked() || epoch == 0 || now != epoch {
- return false, false, now, b.retryLocked()
+ return false, false, now, nil
}
if m, ok := b.held[host]; ok && m.Account == acct && m.App == app && m.Build == build {
delete(b.held, host)
@@ -956,7 +1117,7 @@ func (b *FailureBook) RetryHostAt(acct, app, build, host, book string, epoch uin
}
rec = b.retryHostLocked(acct, app, build, host)
if !rec && !mark {
- return false, false, now, b.retryLocked()
+ return false, false, now, nil
}
if !rec {
// Only the marker was removed (no record held the host): still a
@@ -964,9 +1125,10 @@ func (b *FailureBook) RetryHostAt(acct, app, build, host, book string, epoch uin
// round 1, finding 4, Astra). A failure of an execution of build on
// host that began before it is then stale (Record, PreRetry): no
// record, no halt, though a late hold outcome still sets its marker.
- // retryHostLocked has noted it when it cleared a record, and a retry
- // that removed nothing notes nothing.
+ // retryHostLocked has noted it, and moved the generation, when it
+ // cleared a record, and a retry that removed nothing notes nothing.
b.clearedLocked(acct, app, build, host)
+ b.touchLocked(acct, app)
}
return rec, mark, now, b.saveLocked()
}
@@ -987,6 +1149,7 @@ func (b *FailureBook) ClearOnPointer(acct, app, build string, promotedAt time.Ti
}
b.recs = slices.Delete(b.recs, i, i+1)
b.clearedLocked(acct, app, build, "")
+ b.touchLocked(acct, app)
return true, b.saveLocked()
}
@@ -1011,6 +1174,11 @@ func (b *FailureBook) ForApp(acct, app string) []FailureRecord {
// 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.
+//
+// An app that lost a record and still has a record or a marker gets a new
+// generation; an app with neither left loses its generation, which is the
+// only way one is dropped (rule 1): nothing of it restricts any host, and a
+// later restriction takes a fresh value, larger than every earlier one.
func (b *FailureBook) Prune(installed func(acct, app, build string) bool) error {
b.mu.Lock()
defer b.mu.Unlock()
@@ -1019,16 +1187,39 @@ func (b *FailureBook) Prune(installed func(acct, app, build string) bool) error
delete(b.cleared, k)
}
}
+ lost := map[string][2]string{} // apps that lost a record
kept := b.recs[:0]
for _, r := range b.recs {
if installed(r.Account, r.App, r.Build) {
kept = append(kept, r)
+ } else {
+ lost[genKey(r.Account, r.App)] = [2]string{r.Account, r.App}
+ }
+ }
+ b.recs = kept
+ standing := map[string]bool{} // apps with a record or a marker left
+ for _, r := range b.recs {
+ standing[genKey(r.Account, r.App)] = true
+ }
+ for _, h := range b.held {
+ standing[genKey(h.Account, h.App)] = true
+ }
+ changed := false
+ for k := range b.gens {
+ if !standing[k] {
+ delete(b.gens, k)
+ changed = true
+ }
+ }
+ for _, k := range slices.Sorted(maps.Keys(lost)) {
+ if standing[k] {
+ b.touchLocked(lost[k][0], lost[k][1])
}
+ changed = true
}
- if len(kept) == len(b.recs) {
+ if !changed {
return b.retryLocked()
}
- b.recs = kept
return b.saveLocked()
}
diff --git a/internal/rollout/failures_epoch_test.go b/internal/rollout/failures_epoch_test.go
new file mode 100644
index 000000000..25e24508d
--- /dev/null
+++ b/internal/rollout/failures_epoch_test.go
@@ -0,0 +1,379 @@
+package rollout
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+)
+
+// The crash-epoch hard limit (zero-downtime flag readiness round 2, Task 1;
+// Paul's ruling 2026-10-12): a stale signed retry grant never clears a later
+// failure. A restriction's generation is per (account, app), moves on every
+// change of the app's restrictions, is published only once it is on disk,
+// never repeats across a restart, and counts in a book identity that comes
+// from crypto/rand and is rotated when the rule is adopted.
+
+// diskBook is a book saved under a data dir of its own, its owed save
+// landed, and the dir.
+func diskBook(t *testing.T) (*FailureBook, string) {
+ t.Helper()
+ dd := t.TempDir()
+ b, err := OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := b.Flush(); err != nil {
+ t.Fatal(err)
+ }
+ return b, dd
+}
+
+// published is the generation status publishes for build's restriction of
+// host in bab/blog.
+func published(b *FailureBook, build, host string) uint64 {
+ return b.PublishedEpoch("bab", "blog", build, host)
+}
+
+// grantAt applies a signed host retry of build on host naming b's identity
+// at epoch.
+func grantAt(b *FailureBook, build, host string, epoch uint64) (rec, mark bool) {
+ rec, mark, _, _ = b.RetryHostAt("bab", "blog", build, host, b.ID(), epoch)
+ return rec, mark
+}
+
+func mustRecord(t *testing.T, b *FailureBook, r FailureRecord) RecordResult {
+ t.Helper()
+ res, err := b.Record(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res
+}
+
+func mustMark(t *testing.T, b *FailureBook, host string, h HoldRan) {
+ t.Helper()
+ if err := b.MarkHoldRan(host, h); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// Plan 8: a book whose save fails publishes nothing, and a grant for the
+// generation published before the change clears nothing. Once the owed save
+// lands the new generation is published and a grant at it clears.
+func TestAFailingDiskPublishesNoEpoch(t *testing.T) {
+ b, dd := diskBook(t)
+ mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h1", Mode: string(activator.ModeHold), HeldDown: []string{"h1"}})
+ e1 := published(b, "v2", "h1")
+ if e1 == 0 {
+ t.Fatal("a saved restriction published no generation")
+ }
+ readOnly(t, dd)
+ if _, err := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "h2"}); err == nil {
+ t.Fatal("the save of v3's record did not fail: the case needs a failing disk")
+ }
+ if e := published(b, "v2", "h1"); e != 0 {
+ t.Fatalf("published %d while the book's save is owed, want 0", e)
+ }
+ if rec, mark, _, err := b.RetryHostAt("bab", "blog", "v2", "h1", b.ID(), e1); rec || mark {
+ t.Fatalf("a grant from before the unsaved change cleared record %v, marker %v (%v)", rec, mark, err)
+ }
+ if !b.HeldDown("bab", "blog", "v2", "h1") {
+ t.Fatal("h1 is no longer held down")
+ }
+ writable(t, dd)
+ if err := b.SaveIfDirty(); err != nil {
+ t.Fatalf("the owed save did not land: %v", err)
+ }
+ e2 := published(b, "v2", "h1")
+ if e2 <= e1 {
+ t.Fatalf("published %d after the save landed, want past %d", e2, e1)
+ }
+ if rec, _ := grantAt(b, "v2", "h1", e2); !rec {
+ t.Fatal("a grant at the published generation cleared nothing")
+ }
+ again, err := OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.HeldDown("bab", "blog", "v2", "h1") {
+ t.Fatal("the retry was not saved")
+ }
+}
+
+// Plan 1: every removal of a restriction of the app moves the app's
+// generation, so a grant signed before it clears nothing.
+func TestEveryRemovalMovesTheGeneration(t *testing.T) {
+ const host = "h1"
+ // v2's record (from a sibling's failure) holds h1, and v2's marker
+ // stands on h1.
+ setup := func(t *testing.T) *FailureBook {
+ b, _ := diskBook(t)
+ mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", PromotedAt: promotedAt, At: t0})
+ mustMark(t, b, host, HoldRan{Account: "bab", App: "blog", Build: "v2", Exec: "a1", At: t0})
+ return b
+ }
+ for name, change := range map[string]func(t *testing.T, b *FailureBook){
+ "the marker replaced by another build's": func(t *testing.T, b *FailureBook) {
+ mustMark(t, b, host, HoldRan{Account: "bab", App: "blog", Build: "v3", Exec: "a2", At: t0})
+ },
+ "the record cleared by a pointer": func(t *testing.T, b *FailureBook) {
+ if ok, err := b.ClearOnPointer("bab", "blog", "v2", promotedAt.Add(time.Minute)); !ok || err != nil {
+ t.Fatalf("ClearOnPointer = %v, %v", ok, err)
+ }
+ },
+ "the record pruned": func(t *testing.T, b *FailureBook) {
+ if err := b.Prune(func(string, string, string) bool { return false }); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := b.Lookup("bab", "blog", "v2"); ok {
+ t.Fatal("the record was not pruned")
+ }
+ },
+ "the marker cleared by a good write": func(t *testing.T, b *FailureBook) {
+ if ok, err := b.ClearHoldRanBefore(host, b.Snapshot()); !ok || err != nil {
+ t.Fatalf("ClearHoldRanBefore = %v, %v", ok, err)
+ }
+ },
+ "the marker cleared by a rollback": func(t *testing.T, b *FailureBook) {
+ if ok, err := b.ClearHoldRanOnPointer(host, "bab", "blog", t0.Add(time.Minute)); !ok || err != nil {
+ t.Fatalf("ClearHoldRanOnPointer = %v, %v", ok, err)
+ }
+ },
+ "the marker cleared": func(t *testing.T, b *FailureBook) {
+ if ok, err := b.ClearHoldRan(host); !ok || err != nil {
+ t.Fatalf("ClearHoldRan = %v, %v", ok, err)
+ }
+ },
+ "the record merged with a newer pointer": func(t *testing.T, b *FailureBook) {
+ if res := mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib2", PromotedAt: promotedAt.Add(time.Minute)}); res != Merged {
+ t.Fatalf("Record = %v, want merged", res)
+ }
+ },
+ } {
+ t.Run(name, func(t *testing.T) {
+ b := setup(t)
+ e := published(b, "v2", host)
+ if e == 0 {
+ t.Fatal("no generation published before the change")
+ }
+ change(t, b)
+ now := published(b, "v2", host)
+ if now == e {
+ t.Fatalf("the generation stayed at %d across the change", e)
+ }
+ if now == 0 {
+ t.Fatal("v2 still restricts h1 through its record or marker, but no generation is published")
+ }
+ if rec, mark := grantAt(b, "v2", host, e); rec || mark {
+ t.Fatalf("a grant from before the change cleared record %v, marker %v", rec, mark)
+ }
+ })
+ }
+}
+
+// A call that changes nothing (a stale failure, an unchanged one, a repeated
+// marker of the same execution) moves nothing: a grant at the generation
+// published before it still clears.
+func TestANoOpOutcomeDoesNotMoveTheGeneration(t *testing.T) {
+ b, _ := diskBook(t)
+ mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", Mode: string(activator.ModeHold),
+ HeldDown: []string{"sib"}, MarkHoldRan: true, HoldRanExec: "a1", HoldRanAt: t0})
+ snap := b.Snapshot() // an execution of v2 on h1 starts
+ if rec, _ := grantAt(b, "v2", "h1", published(b, "v2", "h1")); !rec {
+ t.Fatal("the retry of h1 cleared nothing")
+ }
+ e := published(b, "v2", "h2")
+ if e == 0 {
+ t.Fatal("no generation published for h2")
+ }
+ steady := func(what string) {
+ t.Helper()
+ if now := published(b, "v2", "h2"); now != e {
+ t.Fatalf("%s moved the generation from %d to %d", what, e, now)
+ }
+ }
+ if res := mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h1", Seq: snap}); res != PreRetry {
+ t.Fatalf("the failure that began before the retry = %v, want pre-retry", res)
+ }
+ steady("a pre-retry failure")
+ if res := mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib"}); res != Unchanged {
+ t.Fatalf("a repeated failure = %v, want unchanged", res)
+ }
+ steady("an unchanged failure")
+ mustMark(t, b, "sib", HoldRan{Account: "bab", App: "blog", Build: "v2", Exec: "a1", At: t0})
+ steady("a repeated marker of the same execution")
+ if res := mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", Mode: string(activator.ModeHold),
+ HeldDown: []string{"sib"}, MarkHoldRan: true, HoldRanExec: "a1", HoldRanAt: t0}); res != Unchanged {
+ t.Fatalf("a repeated hold failure = %v, want unchanged", res)
+ }
+ steady("a repeated hold failure of the same execution")
+ if rec, _ := grantAt(b, "v2", "h2", e); !rec {
+ t.Fatal("a grant at the unchanged generation cleared nothing")
+ }
+}
+
+// Plan 2: a file an earlier binary wrote (no durable_epochs) may hold
+// epochs that binary published and never saved. Opening it under the rule
+// rotates the book's identity, so no grant issued before clears anything,
+// and nothing is published until the rotation is on disk.
+func TestABookFromBeforeTheRuleGetsANewIdentity(t *testing.T) {
+ dd := t.TempDir()
+ raw, err := json.Marshal(map[string]any{
+ "schema": 2,
+ "book": "old",
+ "seq": 10,
+ "records": []map[string]any{{"account": "bab", "app": "blog", "build": "v2", "host": "h1",
+ "held_down": []string{"h1"}, "epochs": map[string]uint64{"": 11, "h1": 11}}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dd, FailureFile), raw, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ b, err := OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := b.ID()
+ if id == "old" || id == "" {
+ t.Fatalf("identity %q after adopting the rule, want a new one", id)
+ }
+ if e := published(b, "v2", "h1"); e != 0 {
+ t.Fatalf("published %d before the rotation was saved, want 0", e)
+ }
+ for _, epoch := range []uint64{11, 12} {
+ if rec, mark, _, _ := b.RetryHostAt("bab", "blog", "v2", "h1", "old", epoch); rec || mark {
+ t.Fatalf("a grant naming the old identity at %d cleared record %v, marker %v", epoch, rec, mark)
+ }
+ }
+ if err := b.Flush(); err != nil {
+ t.Fatal(err)
+ }
+ e := published(b, "v2", "h1")
+ if e <= 11 {
+ t.Fatalf("published %d after the save, want a generation past the file's 11", e)
+ }
+ again, err := OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.ID() != id || published(again, "v2", "h1") != e {
+ t.Fatalf("reopened as %q at %d, want %q at %d", again.ID(), published(again, "v2", "h1"), id, e)
+ }
+ if f := fileOf(t, dd); !f.Durable || f.Book != id {
+ t.Fatalf("file durable_epochs %v, book %q: want true and %q", f.Durable, f.Book, id)
+ }
+ if rec, _ := grantAt(again, "v2", "h1", e); !rec {
+ t.Fatal("a grant naming the new identity at the published generation cleared nothing")
+ }
+}
+
+// Rule 3: a reopen starts from the disk's sequence, so every generation
+// handed out after it is larger than every one published before it.
+func TestAReopenNeverReissuesAPublishedGeneration(t *testing.T) {
+ b, dd := diskBook(t)
+ var before uint64
+ read := func(b *FailureBook) uint64 {
+ t.Helper()
+ e := max(published(b, "v2", "h1"), published(b, "v3", "h1"), published(b, "v4", "h1"), published(b, "v5", "h2"))
+ if e == 0 {
+ t.Fatal("nothing published")
+ }
+ return e
+ }
+ mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", PromotedAt: promotedAt})
+ before = max(before, read(b))
+ mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "sib", HeldDown: []string{"h1"}})
+ before = max(before, read(b))
+ mustMark(t, b, "h1", HoldRan{Account: "bab", App: "blog", Build: "v3", Exec: "a1", At: t0})
+ before = max(before, read(b))
+ mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v4", Host: "sib"})
+ before = max(before, read(b))
+
+ again, err := OpenFailureBook(dd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := again.Flush(); err != nil {
+ t.Fatal(err)
+ }
+ for i, change := range []func(){
+ func() { mustRecord(t, again, FailureRecord{Account: "bab", App: "blog", Build: "v5", Host: "sib"}) },
+ func() {
+ mustMark(t, again, "h2", HoldRan{Account: "bab", App: "blog", Build: "v5", Exec: "a2", At: t0})
+ },
+ func() {
+ if ok, err := again.ClearOnPointer("bab", "blog", "v4", t0); !ok || err != nil {
+ t.Fatalf("ClearOnPointer = %v, %v", ok, err)
+ }
+ },
+ } {
+ change()
+ for _, c := range [][2]string{{"v2", "h1"}, {"v3", "h1"}, {"v5", "h2"}} {
+ if e := published(again, c[0], c[1]); e <= before {
+ t.Fatalf("after change %d of the reopened book, %s on %s published %d, not past %d", i+1, c[0], c[1], e, before)
+ }
+ }
+ }
+}
+
+// Rule 5 and finding 3: two books opened with equal numeric state publish
+// the same generation; a grant for one clears nothing in the other.
+func TestAGrantForAnotherBookClearsNothing(t *testing.T) {
+ one, _ := diskBook(t)
+ other, _ := diskBook(t)
+ for _, b := range []*FailureBook{one, other} {
+ mustRecord(t, b, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h", Mode: string(activator.ModeHold),
+ HeldDown: []string{"h"}, MarkHoldRan: true, HoldRanExec: "a1", HoldRanAt: t0})
+ }
+ e := published(one, "v2", "h")
+ if e == 0 || e != published(other, "v2", "h") {
+ t.Fatalf("published %d and %d: the case needs equal generations", e, published(other, "v2", "h"))
+ }
+ if one.ID() == other.ID() {
+ t.Fatalf("two books share the identity %q", one.ID())
+ }
+ if rec, mark, _, _ := other.RetryHostAt("bab", "blog", "v2", "h", one.ID(), e); rec || mark {
+ t.Fatalf("a grant for one book cleared record %v, marker %v in another", rec, mark)
+ }
+ if !other.HeldDown("bab", "blog", "v2", "h") {
+ t.Fatal("the other book's hold-down was lifted")
+ }
+ if rec, mark := grantAt(one, "v2", "h", e); !rec || !mark {
+ t.Fatalf("the grant cleared record %v, marker %v in its own book; want both", rec, mark)
+ }
+}
+
+// Rule 5: a book's identity comes from crypto/rand only. A book that needs
+// one and cannot get it does not open; one already on disk under the rule
+// needs none.
+func TestABookThatCannotGetAnIdentityDoesNotOpen(t *testing.T) {
+ durable, ddDurable := diskBook(t)
+ mustRecord(t, durable, FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h"})
+ ddOld := t.TempDir()
+ if err := os.WriteFile(filepath.Join(ddOld, FailureFile), []byte(`{"schema": 2, "book": "old", "records": []}`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ broken := errors.New("no entropy")
+ old := randRead
+ randRead = func([]byte) (int, error) { return 0, broken }
+ t.Cleanup(func() { randRead = old })
+ for name, dd := range map[string]string{"a new book": t.TempDir(), "a book from before the rule": ddOld} {
+ if b, err := OpenFailureBook(dd); !errors.Is(err, broken) || b != nil {
+ t.Fatalf("%s opened (%v, %v) without an identity", name, b, err)
+ }
+ }
+ b, err := OpenFailureBook(ddDurable)
+ if err != nil {
+ t.Fatalf("a book with its identity on disk did not open: %v", err)
+ }
+ if b.ID() != durable.ID() {
+ t.Fatalf("identity %q, want %q", b.ID(), durable.ID())
+ }
+}
diff --git a/internal/rollout/finalreview1_test.go b/internal/rollout/finalreview1_test.go
index 157ccb93f..8d52d7ff8 100644
--- a/internal/rollout/finalreview1_test.go
+++ b/internal/rollout/finalreview1_test.go
@@ -169,12 +169,13 @@ func TestARetryGrantForOneBookClearsNothingInAnother(t *testing.T) {
}
}
-// Finding 3's failed-save half: epochs handed out after a save failed live
-// only in memory, and status may already have published them. An agent
-// crash then a reopen must never hand the same values out again for
-// another restriction, or the grant the console signed for the first
-// would clear the second.
-func TestAReopenAfterUnsavedEpochsNeverReissuesThem(t *testing.T) {
+// Finding 3's failed-save half, under the crash-epoch hard limit (readiness
+// round 2, Task 1, which replaced the reopen's bounded advance): a
+// generation handed out after a save failed lives only in memory, so status
+// never publishes it and no grant at it clears anything, before a crash or
+// after. A reopen may hand the same number out again, which no grant can
+// name; every value it hands out is past every one status published.
+func TestAnUnsavedGenerationIsNeverPublished(t *testing.T) {
dd := t.TempDir()
b, err := OpenFailureBook(dd)
if err != nil {
@@ -186,11 +187,21 @@ func TestAReopenAfterUnsavedEpochsNeverReissuesThem(t *testing.T) {
if _, err := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v2", Host: "h"}); err != nil {
t.Fatal(err)
}
+ saved := b.PublishedEpoch("bab", "blog", "v2", "h")
+ if saved == 0 {
+ t.Fatal("a saved restriction published nothing")
+ }
readOnly(t, dd)
if _, err := b.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "h", HeldDown: []string{"h"}}); err == nil {
t.Skip("the save did not fail")
}
- published := grantFor(b, "v3", "h") // status publishes it; the console signs it
+ if e := b.PublishedEpoch("bab", "blog", "v3", "h"); e != 0 {
+ t.Fatalf("status published %d for a restriction not on disk, want 0", e)
+ }
+ unsaved := grantFor(b, "v3", "h") // a console that signed the in-memory value anyway
+ if rec, mark := applyGrant(b, "v3", "h", unsaved); rec || mark {
+ t.Fatal("a grant at the unsaved generation cleared before the save landed")
+ }
writable(t, dd)
b2, err := OpenFailureBook(dd) // the agent crashed: v3's record never reached the disk
@@ -201,11 +212,11 @@ func TestAReopenAfterUnsavedEpochsNeverReissuesThem(t *testing.T) {
t.Fatal(err)
}
b2.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "h", HeldDown: []string{"h"}})
- if e := b2.RestrictionEpoch("bab", "blog", "v3", "h"); e <= published.epoch {
- t.Fatalf("the reopened book handed out epoch %d, not past the unsaved %d", e, published.epoch)
+ if e := b2.PublishedEpoch("bab", "blog", "v3", "h"); e <= saved {
+ t.Fatalf("the reopened book published %d, not past the published %d", e, saved)
}
- if rec, mark := applyGrant(b2, "v3", "h", published); rec || mark {
- t.Fatal("the grant for the unsaved restriction cleared the new one")
+ if rec, mark := applyGrant(b2, "v2", "h", grant{book: b.ID(), epoch: saved}); rec || mark {
+ t.Fatal("the grant for the published restriction cleared after the app's restrictions changed")
}
}
diff --git a/internal/rollout/manager.go b/internal/rollout/manager.go
index e6cbc0e87..fd239baca 100644
--- a/internal/rollout/manager.go
+++ b/internal/rollout/manager.go
@@ -804,11 +804,13 @@ func (m *Manager) retryBuild(k appKey, build, host string) {
// and that restriction's epoch and book as the box last reported them
// (plan review findings 5 and 13; final review round 1, finding 3). The
// book clears build's record for host, when one holds it, and host's
-// hold-ran marker, when the marker names build, and only while the
-// restriction's epoch is still epoch and the grant names this box's book
-// (RetryHostAt): a grant from before a later failure of the same build, one
-// replayed after it was spent, or one for another book's restriction at the
-// same epoch, clears nothing. True means either was cleared.
+// hold-ran marker, when the marker names build, and only while the app's
+// restriction generation is still epoch, the book owes no save, and the
+// grant names this box's book (RetryHostAt; the crash-epoch hard limit in
+// FailureBook's doc): a grant from before any later change of the app's
+// restrictions, one replayed after it was spent, or one for another book's
+// restriction at the same value, clears nothing. True means either was
+// cleared.
//
// When the record was cleared and host's slot wants build, its next attempt
// is a canary. The book entry is cleared and the host's retry flag bound in
@@ -851,10 +853,13 @@ func (m *Manager) RetryHost(host, build, book string, epoch uint64) bool {
s.q.wantBuild(host, activator.CauseRetry, build, m.d.Now())
}
s.mu.Unlock()
- if err != nil {
+ if err != nil && (cleared || unmarked) {
m.d.Logf("rollout: retry %s on %s: %v (cleared in memory)", build, host, err)
- }
- if !cleared && !unmarked && cur != epoch {
+ } else if err != nil {
+ // The book owed a save and it failed again: nothing is cleared until
+ // one lands (the crash-epoch hard limit, rule 2).
+ m.d.Logf("rollout: retry %s on %s: the failure book cannot be saved (%v), so nothing was cleared", build, host, err)
+ } else if !cleared && !unmarked && cur != epoch {
m.d.Logf("rollout: retry %s on %s: the grant is for epoch %d, the restriction's is %d, so nothing was cleared", build, host, epoch, cur)
} else if !cleared && !unmarked && book != m.d.Book.ID() {
m.d.Logf("rollout: retry %s on %s: the grant is for book %q, not this box's, so nothing was cleared", build, host, book)
@@ -1096,9 +1101,25 @@ func (m *Manager) dropUnownedWakes() {
// installed (Manager.planned): the wake was planned before the install moved
// off it, and its exec may yet boot it. The installed callback runs under the
// book's lock and reads only this map, built first.
+//
+// The pass also retries a save the book owes (the crash-epoch hard limit,
+// rule 6; SaveIfDirty): while one is owed status publishes no generation
+// and no retry grant clears anything, so a box whose disk recovered must
+// not wait for another change, pointer or grant to save. Prune retries it
+// when it runs; a pass with no routes still calls SaveIfDirty, and so does
+// one whose Prune landed, for a save owed since.
func (m *Manager) prune(routes []registry.Route) {
+ if err := m.pruneBook(routes); err != nil {
+ m.d.Logf("rollout: prune failure records: %v", err)
+ } else if err := m.d.Book.SaveIfDirty(); err != nil {
+ m.d.Logf("rollout: save the failure book: %v", err)
+ }
+}
+
+// pruneBook is prune's Prune call, nil when there are no routes to judge by.
+func (m *Manager) pruneBook(routes []registry.Route) error {
if len(routes) == 0 {
- return
+ return nil
}
installed := map[[3]string]bool{}
for _, rt := range routes {
@@ -1111,9 +1132,7 @@ func (m *Manager) prune(routes []registry.Route) {
installed[[3]string{w.acct, w.app, w.build}] = true
}
m.mu.Unlock()
- if err := m.d.Book.Prune(func(acct, app, build string) bool { return installed[[3]string{acct, app, build}] }); err != nil {
- m.d.Logf("rollout: prune failure records: %v", err)
- }
+ return m.d.Book.Prune(func(acct, app, build string) bool { return installed[[3]string{acct, app, build}] })
}
// liftClearedHalts reopens every halted adoption cohort whose failure record
diff --git a/internal/rollout/retrygrant_test.go b/internal/rollout/retrygrant_test.go
index 10f16dbef..ecd7dde3c 100644
--- a/internal/rollout/retrygrant_test.go
+++ b/internal/rollout/retrygrant_test.go
@@ -11,13 +11,14 @@ import (
)
// retry is the console's signed host retry as the box applies it: a grant
-// naming build, carrying the epoch the box's status publishes for it.
+// naming build, carrying the generation the box's status publishes for it.
func (h *harness) retry(host, build string) bool {
- return h.m.RetryHost(host, build, h.book.ID(), h.book.RestrictionEpoch("bab", "blog", build, host))
+ return h.m.RetryHost(host, build, h.book.ID(), h.book.PublishedEpoch("bab", "blog", build, host))
}
-// Plan review finding 13: a restriction's epoch is a persisted book
-// sequence that moves whenever the restriction changes for the host.
+// Plan review finding 13, under the crash-epoch hard limit (readiness round
+// 2, Task 1): a restriction's epoch is its app's generation, a persisted
+// book sequence that moves whenever any restriction of the app changes.
func TestRestrictionEpochFollowsTheRecordAndTheMarker(t *testing.T) {
dd := t.TempDir()
b, err := OpenFailureBook(dd)
@@ -42,12 +43,15 @@ func TestRestrictionEpochFollowsTheRecordAndTheMarker(t *testing.T) {
t.Fatalf("a merged hold-down: a %d, b %d; both must move past %d", e2, b.RestrictionEpoch("bab", "blog", "v2", "b"), e1)
}
b.MarkHoldRan("c", HoldRan{Account: "bab", App: "blog", Build: "v3"})
- m, _ := b.HoldRanOn("c")
- if e := b.RestrictionEpoch("bab", "blog", "v3", "c"); e != m.Seq || e == 0 {
- t.Fatalf("a marker-only restriction's epoch = %d, want the marker's sequence %d", e, m.Seq)
+ e3 := b.RestrictionEpoch("bab", "blog", "v3", "c")
+ if e3 <= e2 {
+ t.Fatalf("a marker-only restriction's epoch = %d, want the app's generation moved past %d", e3, e2)
}
- if e := b.RestrictionEpoch("bab", "blog", "v2", "c"); e != b.RestrictionEpoch("bab", "blog", "v2", "b") {
- t.Fatalf("c's epoch for v2 = %d: a marker of v3 must not count for v2", e)
+ if e := b.RestrictionEpoch("bab", "blog", "v2", "b"); e != e3 {
+ t.Fatalf("v2's epoch for b = %d after c's marker: one generation per app, want %d", e, e3)
+ }
+ if e := b.RestrictionEpoch("bab", "blog", "v3", "b"); e != 0 {
+ t.Fatalf("b's epoch for v3 = %d: c's marker of v3 restricts only c, want 0", e)
}
if e := b.RestrictionEpoch("bab", "shop", "v3", "c"); e != 0 {
t.Fatalf("another app's epoch = %d, want 0", e)
@@ -64,9 +68,11 @@ func TestRestrictionEpochFollowsTheRecordAndTheMarker(t *testing.T) {
}
}
-// A book an earlier agent wrote has no epochs: the open gives each record
-// and marker one, newer than anything in the file, so its holds can still be
-// retried (a zero epoch is never accepted).
+// A book an earlier agent wrote has no generations: the open gives its app
+// one, and its marker a sequence, newer than anything in the file, so its
+// holds can still be retried (a zero epoch is never accepted). One app has
+// one generation (the crash-epoch hard limit, rule 1), so the record's and
+// the marker's restrictions share it.
func TestOpenGivesAnOlderFilesRestrictionsAnEpoch(t *testing.T) {
dd := t.TempDir()
raw, _ := json.Marshal(map[string]any{
@@ -84,8 +90,11 @@ func TestOpenGivesAnOlderFilesRestrictionsAnEpoch(t *testing.T) {
}
rec := b.RestrictionEpoch("bab", "blog", "v2", "a")
mark := b.RestrictionEpoch("bab", "blog", "v3", "c")
- if rec <= 5 || mark <= 5 || rec == mark {
- t.Fatalf("epochs record %d, marker %d: want two distinct values newer than the file's 5", rec, mark)
+ if rec <= 5 || rec != mark {
+ t.Fatalf("epochs record %d, marker %d: want the app's one generation, newer than the file's 5", rec, mark)
+ }
+ if m, _ := b.HoldRanOn("c"); m.Seq <= 5 {
+ t.Fatalf("the marker's sequence = %d, want one newer than the file's 5", m.Seq)
}
if b.Snapshot() <= max(rec, mark) {
t.Fatal("a snapshot after the open must be newer than the epochs it handed out")
diff --git a/internal/rollout/sim_test.go b/internal/rollout/sim_test.go
index 06f29fdbb..f075fa81a 100644
--- a/internal/rollout/sim_test.go
+++ b/internal/rollout/sim_test.go
@@ -1508,9 +1508,21 @@ func (s *sim) hostRetry() {
// that was cleared and written again.
rec, recorded := s.book.Lookup("bab", "blog", b)
clears := recorded && !slices.Contains(rec.Retried, h.name)
- // The console's grant names the build and the epoch the box reports
- // for it (Task 8); the sim reads the epoch just as the console would.
- ok := s.m.RetryHost(h.name, b, s.book.ID(), s.book.RestrictionEpoch("bab", "blog", b, h.name))
+ // The console's grant names the build and the generation the box
+ // publishes for it (Task 8; readiness round 2 Task 1): the sim reads it
+ // just as the console would. A book that owes a save publishes none, and
+ // clears nothing for a grant (the crash-epoch hard limit, rule 2): the
+ // retry then changes nothing, in the code or the model.
+ epoch := s.book.PublishedEpoch("bab", "blog", b, h.name)
+ if epoch == 0 && s.book.SaveOwed() {
+ s.logf("instances retry %s onto %s: no generation published, the book owes a save", h.name, b)
+ return
+ }
+ ok := s.m.RetryHost(h.name, b, s.book.ID(), epoch)
+ if !ok && s.book.SaveOwed() {
+ s.logf("instances retry %s onto %s: refused, the book owes a save", h.name, b)
+ return
+ }
// The retry exempts h from the record that stands now, and from no
// record written after that one goes (pass): with no record there is
// nothing to exempt it from.
diff --git a/internal/rollout/status.go b/internal/rollout/status.go
index 5cedf5a4c..7c6bde9b5 100644
--- a/internal/rollout/status.go
+++ b/internal/rollout/status.go
@@ -84,9 +84,10 @@ func (m *Manager) Status() status.RolloutReport {
if rec, ok := m.d.Book.Lookup(s.key.acct, s.key.app, h.wantBuild); ok &&
m.d.Book.Blocks(s.key.acct, s.key.app, h.wantBuild, host, h.wantBuild == h.good) {
hr.Attempt, hr.AttemptBuild, hr.HeldBy, hr.Reason = status.AttemptFailed, rec.Build, rec.Build, reasonWord(rec.Reason)
- // The epoch the console signs into its retry grant
- // (plan review finding 13).
- hr.HeldEpoch = m.d.Book.RestrictionEpoch(s.key.acct, s.key.app, rec.Build, host)
+ // The generation the console signs into its retry grant
+ // (plan review finding 13), 0 while the book owes a
+ // save (the crash-epoch hard limit, rule 2).
+ hr.HeldEpoch = m.d.Book.PublishedEpoch(s.key.acct, s.key.app, rec.Build, host)
hr.HeldBook = m.d.Book.ID() // final review round 1, finding 3
if m.d.Book.HeldDown(s.key.acct, s.key.app, h.wantBuild, host) {
hr.Attempt = status.AttemptDown
@@ -107,7 +108,7 @@ func (m *Manager) Status() status.RolloutReport {
hr.Attempt = status.AttemptDown
case hr.Attempt == "":
hr.HeldBy = mark.Build
- hr.HeldEpoch = m.d.Book.RestrictionEpoch(s.key.acct, s.key.app, mark.Build, host)
+ hr.HeldEpoch = m.d.Book.PublishedEpoch(s.key.acct, s.key.app, mark.Build, host)
hr.HeldBook = m.d.Book.ID()
if rec, ok := m.d.Book.Lookup(s.key.acct, s.key.app, mark.Build); ok {
hr.Reason = reasonWord(rec.Reason)
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index 86e21d544..375ffa728 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -325,10 +325,12 @@ type HostRollout struct {
ConfigPending bool `json:"config_pending,omitempty"`
// ConfigError is a reason word (Reason*), never raw error text.
ConfigError string `json:"config_error,omitempty"`
- // HeldEpoch is the epoch of HeldBy's restriction of this instance
- // (rollout.FailureBook.RestrictionEpoch): the console signs it into a
- // `carlos instances retry` grant, which the box honours only while the
- // restriction is unchanged. Absent from an older box, which is 0.
+ // HeldEpoch is the generation of HeldBy's restriction of this instance
+ // as published (rollout.FailureBook.PublishedEpoch): the console signs
+ // it into a `carlos instances retry` grant, which the box honours only
+ // while the app's restrictions are unchanged. 0 on a held instance means
+ // the box's failure book is not saved yet; absent from an older box,
+ // which is 0 too.
HeldEpoch uint64 `json:"held_epoch,omitempty"`
// HeldBook is the identity of the failure book HeldEpoch counts in
// (rollout.FailureBook.ID): epochs are a counter every book starts