| index 2e46480e1..bdaf63f65 100644 |
| --- a/internal/rollout/failures.go |
| +++ b/internal/rollout/failures.go |
| @@ -7,6 +7,7 @@ import ( |
| "errors" |
| "fmt" |
| "maps" |
| + "math" |
| "os" |
| "path/filepath" |
| "slices" |
| @@ -193,6 +194,12 @@ func wakeIntent(id string) bool { return strings.HasPrefix(id, "w") } |
| // for the host: an attempt's, or a wake's that an attempt would replace. Its |
| // outcome is not yet written, so replacing its intent would lose it across a |
| // failed save and a restart. |
| +// ErrSeqExhausted is every save's answer once the book's sequence has run |
| +// out (seqLimit, fix wave 1, item 5): the book is never saved or published |
| +// again, so no generation repeats under its identity. Recovery is a new |
| +// book: drop the file's "book" key, or the file, and restart the agent. |
| +var ErrSeqExhausted = errors.New("rollout: the failure book's sequence is exhausted; it is never saved or published again (give the box a new book)") |
| + |
| var ErrIntentStands = errors.New("another execution on this instance has not recorded its outcome yet") |
| |
| // reconciledReason is a reconciled intent's Reason. reasonWord reads it as |
| @@ -340,7 +347,8 @@ func genKey(acct, app string) string { return acct + "/" + app } |
| // 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 |
| +// since publication. The values never wrap: past seqLimit the book is |
| +// exhausted and never saved or published again (ErrSeqExhausted). 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 |
| @@ -393,10 +401,15 @@ type FailureBook struct { |
| // 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 |
| + // It never reaches seqLimit (bumpLocked). 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 |
| + // exhausted is set once a change needed a sequence value past the last |
| + // usable one (bumpLocked): from then on no save lands and nothing is |
| + // published (saveLocked), so memory never publishes a repeated value. |
| + exhausted bool |
| // 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 |
| @@ -713,6 +726,10 @@ func (b *FailureBook) End(host, id string) error { |
| // durable_epochs, since everything this agent publishes is on disk first |
| // (rule 2). |
| func (b *FailureBook) saveLocked() error { |
| + if b.exhausted { |
| + b.dirty = true |
| + return ErrSeqExhausted |
| + } |
| if b.path == "" { |
| return nil |
| } |
| @@ -748,11 +765,30 @@ func (b *FailureBook) find(acct, app, build string) int { |
| func (b *FailureBook) Snapshot() uint64 { |
| b.mu.Lock() |
| defer b.mu.Unlock() |
| + if b.exhausted || b.seq >= seqLimit-1 { |
| + // At the boundary a snapshot would be seqLimit or more: "not known" |
| + // instead, the safe answer (fix wave 1, item 5). |
| + return 0 |
| + } |
| return b.seq + 1 |
| } |
| |
| -// bumpLocked takes the book's next sequence value. The caller holds b.mu. |
| +// seqLimit bounds the book's sequence (fix wave 1, item 5): every value it |
| +// hands out, and every snapshot, is below it, so the sequence never wraps |
| +// and no generation repeats under one identity. The last value handed out |
| +// is seqLimit-1 (math.MaxUint64-2). |
| +const seqLimit = math.MaxUint64 - 1 |
| + |
| +// bumpLocked takes the book's next sequence value. At the boundary it hands |
| +// out nothing new: the book is exhausted and owes a save that never lands |
| +// (saveLocked), so nothing is published or cleared again, and the value it |
| +// returns (seqLimit, never a real one) is never seen outside memory. The |
| +// caller holds b.mu. |
| func (b *FailureBook) bumpLocked() uint64 { |
| + if b.exhausted || b.seq >= seqLimit-1 { |
| + b.exhausted, b.dirty = true, true |
| + return seqLimit |
| + } |
| b.seq++ |
| return b.seq |
| } |