carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package rollout
2
3import (
4 "crypto/rand"
5 "encoding/hex"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "maps"
10 "math"
11 "os"
12 "path/filepath"
13 "slices"
14 "strings"
15 "sync"
16 "time"
17
18 "github.com/carlosframework/platform/internal/activator"
19 "github.com/carlosframework/platform/internal/release"
20)
21
22// FailureFile is the failure records' home in the agent's data dir (spec
23// §8.4): on disk, so a record survives an agent restart.
24const FailureFile = "swap-failures.json"
25
26// FailureRecord holds one build off this box for instances that have not
27// served it (spec §8.4). Its scope is (Account, App, Build) on this box;
28// Host is the instance whose attempt failed, kept for status.
29type FailureRecord struct {
30 Account string `json:"account"`
31 App string `json:"app"`
32 Build string `json:"build"`
33 Host string `json:"host"`
34 Cause string `json:"cause"`
35 Mode string `json:"mode"`
36 Reason string `json:"reason"`
37 // PromotedAt is the PromotedAt of the channel pointer the build failed
38 // under (spec rev 14 §8.4). A pointer naming the same build with a newer
39 // one, which only `carlos promote --retry` or a fresh promotion can sign,
40 // clears the record: the refusal records' rule (internal/release/
41 // refusal.go), so the two kinds of record behave alike.
42 PromotedAt time.Time `json:"promoted_at"`
43 At time.Time `json:"at"`
44 // HeldDown are instances where a hold build executed and failed (spec
45 // §6.4, §8.3): nothing falls back automatically on them, so their wakes
46 // are refused until a newer build or a retry.
47 HeldDown []string `json:"held_down,omitempty"`
48 // Retried are instances `carlos instances retry` cleared: the record no
49 // longer holds them, and their next attempt is a canary.
50 Retried []string `json:"retried,omitempty"`
51 // Epochs are the per-record epochs an agent before the crash-epoch hard
52 // limit (readiness round 2, Task 1) wrote: the book's sequence values at
53 // which the record last changed. They are read only so a reopen's
54 // sequence starts past them (OpenFailureBook), then dropped, and never
55 // written again: what a signed host retry names now is the app's
56 // generation (FailureBook.gens).
57 Epochs map[string]uint64 `json:"epochs,omitempty"`
58 // MarkHoldRan asks Record to set Host's hold-ran marker onto this
59 // record's build in the same save (final review wave 2, N2): an attempt
60 // that failed in hold mode, ran its build, and was not held down. It is
61 // an instruction to Record, never stored in the record.
62 MarkHoldRan bool `json:"-"`
63 // HoldRanAt is the marker's At for MarkHoldRan: when the attempt that
64 // ran the build started (its dispatch). An instruction too, never stored.
65 HoldRanAt time.Time `json:"-"`
66 // HoldRanExec is the marker's Exec: the identity of the execution that
67 // failed (HoldRan.Exec). An instruction, never stored.
68 HoldRanExec string `json:"-"`
69 // Seq is the book's snapshot (FailureBook.Snapshot) taken when the
70 // failing execution started; zero when it is not known. Record compares
71 // it with the retries since (Astra ruling 191, retry ordering): a failure
72 // of an execution that began before a retry never takes the retry back.
73 // An instruction, never stored.
74 Seq uint64 `json:"-"`
75}
76
77// RecordResult is what Record did with a failure. failCohort runs the rest
78// of the cohort-failure transition (the halt and the supersession) for every
79// result but PreRetry (plan review finding 3).
80type RecordResult int
81
82const (
83 // Recorded: the build had no record; this failure is its first.
84 Recorded RecordResult = iota
85 // Merged: a record stood, and this failure changed it (its host's retry
86 // spent, a hold-down added, a newer PromotedAt).
87 Merged
88 // Unchanged: a record stood, and this failure added nothing to it.
89 Unchanged
90 // PreRetry: the failing execution began before a retry that has since
91 // cleared the build for its host or for the app (`carlos instances
92 // retry`, `carlos promote --retry`). It is stale: nothing of the record
93 // changes, and no halt or supersession may follow it. A hold-ran marker
94 // it carries, or one in place of its hold-down, is still set (Record).
95 PreRetry
96)
97
98func (r RecordResult) String() string {
99 switch r {
100 case Recorded:
101 return "recorded"
102 case Merged:
103 return "merged"
104 case Unchanged:
105 return "unchanged"
106 case PreRetry:
107 return "pre-retry"
108 }
109 return fmt.Sprintf("RecordResult(%d)", int(r))
110}
111
112// HoldRan is an instance's "hold ran" marker (final review wave 2, N2): a
113// build that executed on it in hold mode and neither passed nor was held
114// down there (an attempt superseded or stopped after its exec, a first wake
115// whose observation was cancelled). It may have migrated the instance's
116// database, so nothing falls back on that instance automatically (spec
117// §6.4): while the marker stands the planner never boots the good build
118// (decideWake, planCheck), and a worker's replacement carries no fallback
119// onto it (executeWorker, Astra R3 N5). It is a restriction on the
120// instance, not a failure of the build: it blocks no sibling and halts no
121// cohort, and it lifts no failure record either (Astra R3 N7). A good-build
122// write decided after the marker was set (a pass whose execution began after
123// it, Astra R3 N6), a signed `carlos instances retry` naming the marker's
124// build (RetryHostAt, plan review finding 5), or a signed rollback pointer
125// newer than the marker (applyPointers, ruling 205) clears it.
126type HoldRan struct {
127 Account string `json:"account"`
128 App string `json:"app"`
129 Build string `json:"build"`
130 // Exec is the identity of the execution that set the marker:
131 // "a<incarnation>-<n>" for a scheduled attempt (dispatch.ExecID),
132 // "w<incarnation>-<token>" for a planned wake (Manager.wakeExecID). The
133 // incarnation is the agent process's, so a run after a restart is never
134 // taken for one before it (final review round 1, finding 1); a file an
135 // earlier agent wrote holds "a<n>" or "w<token>", which no identity
136 // handed out now equals. A marker belongs to one execution, not to its build (plan review
137 // finding 4): another run of the same build renews the marker's Seq, so
138 // a good write decided between the two runs clears nothing. Empty when
139 // the caller could not tell, which counts as a new execution every time.
140 Exec string `json:"exec,omitempty"`
141 // Seq is the book's sequence when the marker was set (markLocked), so a
142 // good write clears only markers set before its execution began
143 // (ClearHoldRanBefore, Astra R3 N6). Zero in a file an earlier agent
144 // wrote, which any snapshot is newer than.
145 Seq uint64 `json:"seq,omitempty"`
146 // At is when the execution that set the marker started: an attempt's
147 // dispatch, a planned wake's plan (Task 3 fix round 1). Only a rollback
148 // pointer signed after it lifts the marker (ruling 205, Astra R3): a
149 // pointer from before the hold build started says nothing about
150 // recovering from it, while `carlos rollback` signed during the
151 // execution (the rollback that superseded it) does. It is not the time
152 // the marker was recorded: that comes after the supersession, so the
153 // rollback that caused it would always be older. Zero in a file an
154 // earlier agent wrote, which any signed rollback pointer is newer than.
155 At time.Time `json:"at,omitempty"`
156}
157
158// same reports whether h and o mark the same build of the same app.
159func (h HoldRan) same(o HoldRan) bool {
160 return h.Account == o.Account && h.App == o.App && h.Build == o.Build
161}
162
163// Intent is an execution that may leave a failure behind, written to the
164// book before the execution runs anything (Astra R1 #5, R3 §5 item 2): an
165// adoption attempt, or a planned first wake. Its outcome's own book write
166// (Record, MarkHoldRan) comes later, and End removes the intent only after
167// it. A failure held only in memory because its save failed, followed by an
168// agent restart, therefore still finds the intent on disk, and
169// OpenFailureBook turns it into the failure record it may have been.
170type Intent struct {
171 Account string `json:"account"`
172 App string `json:"app"`
173 Build string `json:"build"`
174 // Mode is the mode the execution runs in: the effective one, after the
175 // activator's own overrides (an overlap attempt on a foreign-layout row
176 // runs as a hold, plan review finding 2), so a reconciled hold intent
177 // holds the instance down.
178 Mode string `json:"mode"`
179 // ID is the execution's identity (HoldRan.Exec): "a<incarnation>-<n>"
180 // for an attempt (dispatch.ExecID), "w<incarnation>-<token>" for a
181 // planned wake (Manager.wakeExecID). A reconciled hold intent's marker carries it.
182 ID string `json:"id"`
183 // Start is when the execution started (dispatch.Started, a wake's plan
184 // time): a reconciled hold intent's marker carries it as its At (ruling
185 // 205). Zero when not known; reconciliation then uses the open time.
186 Start time.Time `json:"start"`
187}
188
189// wakeIntent reports whether id is a planned wake's intent: "w" leads a
190// wake's identity, in this agent's form and an earlier one's alike.
191func wakeIntent(id string) bool { return strings.HasPrefix(id, "w") }
192
193// ErrIntentStands is Begin's refusal while another execution's intent stands
194// for the host: an attempt's, or a wake's that an attempt would replace. Its
195// outcome is not yet written, so replacing its intent would lose it across a
196// failed save and a restart.
197var ErrIntentStands = errors.New("another execution on this instance has not recorded its outcome yet")
198
199// ErrSeqExhausted is every save's answer once the book's sequence has run
200// out (seqLimit, fix wave 1, item 5): the book is never saved or published
201// again, so no generation repeats under its identity. Rotating the book's
202// identity alone does not reset an exhausted sequence: a reopen keeps the
203// highest saved sequence (highestSaved), so the next allocation exhausts
204// again. There is no supported reset yet; an exhausted book refuses managed
205// starts on an armed box, as an unreadable book does. The file must not be
206// deleted: it holds the failure records and hold-ran markers that keep
207// instances off builds that failed or ran on them. Reaching the limit takes
208// about 1.8e19 book events, so this is a guard, not an operating condition.
209var ErrSeqExhausted = errors.New("rollout: the failure book's sequence is exhausted; it is never saved or published again")
210
211// reconciledReason is a reconciled intent's Reason. reasonWord reads it as
212// a failed health check (status.ReasonHealth): the execution's verdict was
213// never recorded, which is all the box knows.
214const reconciledReason = "the agent restarted before this execution recorded its outcome"
215
216// failureSchema is the schema the book is written as, whatever the file was,
217// so an older agent refuses the file rather than read it and break what the
218// newer one keeps. Schema 2 added the intents (Task 5): an agent before it
219// accepts only schema 1 and would drop markers and intents. Schema 3 is the
220// crash-epoch hard limit (readiness round 2, Task 1; FailureBook's doc): an
221// agent before it accepts schema 1 and 2, and would keep the book's
222// identity, publish epochs it never saved and drop durable_epochs, so a
223// grant it issued could later match a generation this rule hands out. Either
224// way an agent downgrade with the flag on refuses managed starts until the
225// file is upgraded or removed. This agent reads schema 1, 2 and 3, and gives
226// a file of schema 1 or 2 a new identity (rule 4).
227const failureSchema = 3
228
229type failureFile struct {
230 Schema int `json:"schema"`
231 Records []FailureRecord `json:"records"`
232 // HoldRan is the hold-ran markers by host. It is additive to schema 1: an
233 // agent that predates it ignores the key (encoding/json skips unknown
234 // fields), and a file without it has no markers.
235 HoldRan map[string]HoldRan `json:"hold_ran,omitempty"`
236 // Seq is the book's sequence (FailureBook.seq), additive like HoldRan,
237 // so a snapshot after a restart is newer than every marker before it.
238 Seq uint64 `json:"seq,omitempty"`
239 // Intents are the executions in flight by host (Intent), schema 2.
240 Intents map[string]Intent `json:"intents,omitempty"`
241 // Book is the book's identity (FailureBook.id), additive like HoldRan:
242 // 16 random bytes, hex, made when the book is first created, and again
243 // when a file from before the crash-epoch rule is adopted (Durable).
244 Book string `json:"book"`
245 // Gens are the apps' restriction generations (FailureBook.gens), keyed
246 // account + "/" + app. Additive: an older agent ignores them.
247 Gens map[string]uint64 `json:"gens,omitempty"`
248 // Durable says the file was written under the crash-epoch rule (rule 4
249 // in FailureBook's doc): every generation it ever published was on disk
250 // first. A file without it, or of schema 1 or 2, was written by an
251 // earlier agent, which may have published epochs it never saved, so
252 // OpenFailureBook gives such a book a new identity.
253 Durable bool `json:"durable_epochs,omitempty"`
254}
255
256// highestSaved is the largest sequence value the file holds: its own seq,
257// every app's generation, every marker's, and every epoch an earlier agent
258// wrote into a record. A reopen's sequence starts there (rule 3). In a file
259// this rule wrote it is the file's own seq; the max keeps a hand-edited or
260// older file from handing out a value one of its entries already holds.
261func (f failureFile) highestSaved() uint64 {
262 n := f.Seq
263 for _, g := range f.Gens {
264 n = max(n, g)
265 }
266 for _, h := range f.HoldRan {
267 n = max(n, h.Seq)
268 }
269 for _, r := range f.Records {
270 for _, e := range r.Epochs {
271 n = max(n, e)
272 }
273 }
274 return n
275}
276
277// randRead is crypto/rand's Read, the one source of a book's identity
278// (rule 5). A variable so a test can make it fail.
279var randRead = rand.Read
280
281// newBookID is a book's identity (FailureBook.id): 16 bytes from
282// crypto/rand, hex. It never falls back to anything weaker: an identity
283// two books could share would let a grant for one clear the other, so a
284// read that fails is an error for the caller (OpenFailureBook) to fail on.
285// (Since Go 1.24 crypto/rand.Read crashes the process rather than return an
286// error, so in production this path is a guard; the seam reaches it.)
287func newBookID() (string, error) {
288 var b [16]byte
289 if _, err := randRead(b[:]); err != nil {
290 return "", fmt.Errorf("make the failure book's identity: %w", err)
291 }
292 return hex.EncodeToString(b[:]), nil
293}
294
295// genKey is an app's key in FailureBook.gens and failureFile.Gens.
296func genKey(acct, app string) string { return acct + "/" + app }
297
298// FailureBook is the box's failure records. An empty path keeps them in
299// memory only, which is what tests and the simulation use.
300//
301// Records handed out (Lookup, ForApp, List) are copies, inner slices
302// included, because a status reader holds them outside the lock while
303// RetryHost and Record edit the book.
304//
305// # The crash-epoch hard limit
306//
307// A signed `carlos instances retry` grant (release.HostRetryGrant) names a
308// book, an app and a generation, which the box's status published, and a
309// stale grant must never clear a later failure (Paul's ruling 2026-10-12,
310// readiness round 2 Task 1). The book keeps these rules:
311//
312// 1. One generation per app. gens[acct/app] takes bumpLocked's next value
313// (touchLocked) on every change to any restriction of that app: a
314// record created, a merge that changes a record, a host retried, a
315// record removed (ClearOnPointer, Prune), a marker of the app set,
316// renewed or replaced (markLocked; a replaced marker of another app
317// moves that app's too) or removed (ClearHoldRan, ClearHoldRanBefore,
318// ClearHoldRanOnPointer, RetryHostAt), an intent reconciled into a
319// record, and an epoch given at open to an older marker or an app
320// without a generation (epochOlderLocked). A call that changes nothing
321// (PreRetry, Unchanged, a repeated marker of the same execution) moves
322// nothing. The generation of build's restriction of host is the app's
323// when build's record blocks host or host's marker names build, else 0
324// (restrictionEpochLocked). Prune drops an app's generation only when
325// the app has no record and no marker left; a later restriction of it
326// takes a fresh value, larger than every earlier one.
327// 2. Durable before use. dirty is true whenever memory differs from the
328// last save that landed: every call that changes recs, held, intents,
329// gens or seq saves under the same hold of mu (a failed save sets
330// dirty), or the open leaves dirty set. PublishedEpoch answers 0 while
331// dirty, and RetryHostAt first retries an owed save and clears nothing
332// while the book is still dirty.
333// 3. Monotonic across restarts. Every landed save writes the in-memory
334// seq, which only grows, and a reopen starts from the file's seq, maxed
335// with every saved generation, marker and epoch (highestSaved). So the
336// disk's seq only grows, every published generation is at most it, and
337// every value a later run hands out is larger.
338// 4. Adoption rotates the identity. A file of schema 1 or 2, or without
339// durable_epochs, written by an earlier agent that may have published
340// epochs it never saved, opens with a new identity and owes a save
341// (dirty), so nothing is published until the rotation is on disk, and
342// every grant issued before names the old identity. The book is written
343// as schema 3 (failureSchema), which an earlier agent refuses to open.
344// 5. The identity comes from crypto/rand only (newBookID). An open that
345// needs one and cannot get one fails. Only a memory-only book, which
346// tests and the simulation use, falls back to the clock.
347// 6. An owed save is retried on its own: the armed manager's pass and a
348// declined box's periodic pass call SaveIfDirty, and a declined box
349// saves a dirty book at startup.
350//
351// Proof sketch: a grant G names book X, app A and generation E, which
352// status published, so E was on X's disk (2, 4). RetryHostAt clears only
353// when X is this book, memory equals disk, and A's generation now equals E
354// with build still restricting host. Within a run every change takes a
355// fresh larger value (1), so equality means no change of A's restrictions
356// since publication. The values never wrap: past seqLimit the book is
357// exhausted and never saved or published again (ErrSeqExhausted). Across a crash the reopened book starts at the disk's
358// seq, which is at least E, and hands out larger values (3). A book written
359// before the rule has another identity (4), and another book has another
360// random identity (5). So G clears only the restriction state it was
361// issued for.
362//
363// The guarantee rests on two assumptions about the file, which the book
364// cannot check from inside:
365//
366// - Only an agent of this rule writes it. An earlier agent would keep the
367// identity while publishing epochs it never saved, and write the file
368// back without durable_epochs. Schema 3 enforces this against a
369// downgrade (failureSchema): an earlier agent refuses the file. A hand
370// edit or another tool writing it is outside the guarantee.
371// - It is never restored from an older copy. A snapshot, a backup or a
372// cloned data dir carries the identity with a lower seq than the disk
373// last held, so a reopen from it can hand out again a generation
374// already published, and a stale grant naming that value would match
375// (rule 3 fails); a clone also gives two books one identity (rule 5
376// fails). Restoring or cloning a data dir must give the copy a new
377// identity: drop the file's "book" key, and the open makes one (rule 4).
378type FailureBook struct {
379 write func([]byte) error
380 path string
381 mu sync.Mutex
382 recs []FailureRecord
383 // held is the hold-ran markers by host (HoldRan), saved with the records.
384 held map[string]HoldRan
385 // intents are the executions in flight by host (Intent), saved with the
386 // records. reconciled is how many intents OpenFailureBook turned into
387 // records.
388 intents map[string]Intent
389 reconciled int
390 // dirty is set whenever memory differs from the last save that landed
391 // (rule 2): a save failed, or the open changed something (a rotated
392 // identity, reconciled intents, epochs given to older entries). Memory is
393 // the truth meanwhile, and every later mutating call, Prune and
394 // SaveIfDirty retry the save until it lands; nothing is published or
395 // retried until then. What keeps a failure across an agent restart before
396 // that is the execution's intent (Begin), saved before the execution ran
397 // anything and removed only after its outcome was written (End): a
398 // restart finds it on disk and records the failure from it (spec §8.4,
399 // Astra R1 #5).
400 dirty bool
401 // now stamps a marker whose caller gave no At (markLocked). Nil means
402 // time.Now; tests and the simulation set their own clock.
403 now func() time.Time
404 // seq orders the book's events that later outcomes are compared with
405 // (Astra R3 N6, ruling 191): every marker set, every retry clear and
406 // every app generation (gens) takes the next value, and an execution
407 // takes a Snapshot (seq+1) when it starts. An outcome is older than an
408 // event exactly when its snapshot is not greater than the event's value.
409 // It is saved with the markers and the generations, so snapshots and
410 // generations after a restart are newer than every one on disk (rule 3).
411 // It never reaches seqLimit (bumpLocked). The cleared map below is not
412 // saved; a host retry's clear moves the
413 // app's generation, which is, so a spent grant stays spent across a
414 // restart.
415 seq uint64
416 // exhausted is set once a change needed a sequence value past the last
417 // usable one (bumpLocked): from then on no save lands and nothing is
418 // published (saveLocked), so memory never publishes a repeated value.
419 exhausted bool
420 // gens are the apps' restriction generations by genKey (rule 1), saved
421 // with the records. A signed host retry names the one it was issued for.
422 gens map[string]uint64
423 // cleared holds, in memory only, the sequence of the latest retry clear
424 // per (account, app, build, host): `carlos instances retry` per host
425 // (RetryHost), `carlos promote --retry` app-wide with host "" (a
426 // ClearOnPointer that cleared a record). Executions in flight do not
427 // survive an agent restart, so neither need these. Prune drops the
428 // entries of builds no route installs, with their records.
429 cleared map[[4]string]uint64
430 // id is the book's identity, saved with it (failureFile.Book). Status
431 // publishes it beside every epoch (HostRollout.HeldBook), the console
432 // signs it into its retry grant, and RetryHostAt clears only for a grant
433 // naming it (final review round 1, finding 3, Astra): epochs are a
434 // counter every book starts alike, so two books (a host relocated, a box
435 // rebuilt) can give one value to two restrictions, and without the
436 // identity a grant for one would clear the other. OpenFailureBook sets
437 // it (rules 4 and 5); a memory-only book makes one on first use
438 // (idLocked).
439 id string
440}
441
442// ID is the book's identity (FailureBook.id).
443func (b *FailureBook) ID() string {
444 b.mu.Lock()
445 defer b.mu.Unlock()
446 return b.idLocked()
447}
448
449// idLocked is the book's identity. Only a memory-only book (tests, the
450// simulation) reaches it without one, since OpenFailureBook always sets
451// it; such a book falls back to the clock if crypto/rand fails (rule 5).
452func (b *FailureBook) idLocked() string {
453 if b.id == "" {
454 id, err := newBookID()
455 if err != nil {
456 id = fmt.Sprintf("m%x", time.Now().UnixNano())
457 }
458 b.id = id
459 }
460 return b.id
461}
462
463// touchLocked moves (acct, app)'s restriction generation to the book's next
464// sequence value (rule 1). The caller holds b.mu and saves.
465func (b *FailureBook) touchLocked(acct, app string) {
466 if b.gens == nil {
467 b.gens = map[string]uint64{}
468 }
469 b.gens[genKey(acct, app)] = b.bumpLocked()
470}
471
472func (b *FailureBook) clock() time.Time {
473 if b.now != nil {
474 return b.now()
475 }
476 return time.Now()
477}
478
479// cloneRec copies r's host lists so the copy shares nothing with r. A
480// record in memory has no Epochs (OpenFailureBook drops them).
481func cloneRec(r FailureRecord) FailureRecord {
482 r.HeldDown = slices.Clone(r.HeldDown)
483 r.Retried = slices.Clone(r.Retried)
484 return r
485}
486
487// OpenFailureBook loads the book from dataDir. A file that exists but cannot
488// be read is an error, never an empty book: an empty book would move every
489// held instance straight back onto the build that failed. Intents left in
490// the file become failure records (reconcileLocked, Reconciled), which the
491// caller saves with Flush.
492//
493// The sequence starts at the largest value the file holds (rule 3 in
494// FailureBook's doc), so nothing handed out from here repeats a generation
495// status published: that was on disk. A book with no file gets a new
496// identity, and so does a file of schema 1 or 2 or without durable_epochs
497// (rule 4): an earlier agent wrote it and may have published epochs it
498// never saved. Either owes a save (dirty), as does an open that reconciled
499// intents or gave older entries epochs; nothing is published until it
500// lands. An identity crypto/rand cannot make is an error (rule 5): the
501// caller treats the book as unreadable. A book this rule wrote opens with
502// no save owed unless the open changed something.
503func OpenFailureBook(dataDir string) (*FailureBook, error) {
504 b := &FailureBook{path: filepath.Join(dataDir, FailureFile)}
505 raw, err := os.ReadFile(b.path)
506 if errors.Is(err, os.ErrNotExist) {
507 id, err := newBookID()
508 if err != nil {
509 return nil, fmt.Errorf("%s: %w", b.path, err)
510 }
511 b.id, b.dirty = id, true
512 return b, nil
513 }
514 if err != nil {
515 return nil, err
516 }
517 var f failureFile
518 if err := json.Unmarshal(raw, &f); err != nil {
519 return nil, fmt.Errorf("%s: %w", b.path, err)
520 }
521 // Schema 1 is an agent before Task 5 (no intents), 2 an agent before the
522 // crash-epoch rule; 3 is written now.
523 if f.Schema != 1 && f.Schema != 2 && f.Schema != failureSchema {
524 return nil, fmt.Errorf("%s: unknown schema %d", b.path, f.Schema)
525 }
526 b.recs = f.Records
527 b.held = f.HoldRan
528 b.intents = f.Intents
529 b.gens = f.Gens
530 b.id = f.Book
531 b.seq = f.highestSaved() // the reopen's sequence (rule 3); rolloutsim:M15
532 for i := range b.recs {
533 // An earlier agent's per-record epochs: read for the sequence above,
534 // never written again, and never what a grant is checked against.
535 if b.recs[i].Epochs != nil {
536 b.recs[i].Epochs = nil
537 b.dirty = true
538 }
539 }
540 if f.Schema < failureSchema || !f.Durable || b.id == "" {
541 // Adoption of the rule (rule 4): grants issued before name the old
542 // identity, or none. Only an agent of this rule writes schema 3, so a
543 // file of an older schema is adopted even if it says durable_epochs.
544 id, err := newBookID()
545 if err != nil {
546 return nil, fmt.Errorf("%s: %w", b.path, err)
547 }
548 b.id, b.dirty = id, true
549 }
550 b.mu.Lock()
551 b.epochOlderLocked()
552 b.reconcileLocked()
553 b.mu.Unlock()
554 return b, nil
555}
556
557// epochOlderLocked gives what an earlier agent wrote without a sequence
558// value one now: each marker with no Seq (by host, sorted), then each app
559// that has a record or a marker and no generation (sorted by key), so two
560// opens of the same file hand out the same values. A zero generation is
561// never accepted by a signed retry, so without one such a hold could not be
562// retried from the console. A marker given a Seq moves its app's generation
563// (rule 1). The book then owes a save (dirty), which the caller or the next
564// mutating call makes; nothing is written here. The caller holds b.mu.
565func (b *FailureBook) epochOlderLocked() {
566 hosts := make([]string, 0, len(b.held))
567 for host, h := range b.held {
568 if h.Seq == 0 {
569 hosts = append(hosts, host)
570 }
571 }
572 slices.Sort(hosts)
573 apps := map[string][2]string{}
574 for _, host := range hosts {
575 h := b.held[host]
576 h.Seq = b.bumpLocked()
577 b.held[host] = h
578 apps[genKey(h.Account, h.App)] = [2]string{h.Account, h.App}
579 }
580 for _, r := range b.recs {
581 if b.gens[genKey(r.Account, r.App)] == 0 {
582 apps[genKey(r.Account, r.App)] = [2]string{r.Account, r.App}
583 }
584 }
585 for _, h := range b.held {
586 if b.gens[genKey(h.Account, h.App)] == 0 {
587 apps[genKey(h.Account, h.App)] = [2]string{h.Account, h.App}
588 }
589 }
590 for _, k := range slices.Sorted(maps.Keys(apps)) {
591 b.touchLocked(apps[k][0], apps[k][1])
592 b.dirty = true
593 }
594}
595
596// reconcileLocked turns each intent read from the file into the failure
597// record its execution may have left (rule 3; spec §8.4, Astra R1 #5): its
598// build on its host, cause release, the intent's mode, stamped with the open
599// time, so only a pointer signed after the restart clears it. A hold intent
600// holds the instance down and sets its hold-ran marker too, with the
601// intent's identity and start (Task 4), as a hold that went down would have:
602// the build may have migrated the database, so nothing falls back there
603// automatically. An agent crash mid-execution therefore halts that build's
604// cohort until a retry, even when the execution would have passed (the
605// plan's ruling). The intents then go from memory and the book is owed a
606// save, which the caller makes (Flush); nothing is written here, so a
607// read-only open of a book never changes the file. The caller holds b.mu.
608func (b *FailureBook) reconcileLocked() {
609 if len(b.intents) == 0 {
610 b.intents = nil
611 return
612 }
613 now := b.clock()
614 hosts := make([]string, 0, len(b.intents))
615 for host := range b.intents {
616 hosts = append(hosts, host)
617 }
618 slices.Sort(hosts) // one order for every open of the same file
619 for _, host := range hosts {
620 in := b.intents[host]
621 r := FailureRecord{Account: in.Account, App: in.App, Build: in.Build, Host: host,
622 Cause: string(activator.CauseRelease), Mode: in.Mode, Reason: reconciledReason, PromotedAt: now, At: now}
623 if in.Mode == string(activator.ModeHold) {
624 // A zero start leaves the marker's At to markLocked, which stamps
625 // it with the book's clock: the open time.
626 r.HeldDown, r.MarkHoldRan, r.HoldRanExec, r.HoldRanAt = []string{host}, true, in.ID, in.Start
627 }
628 b.recordLocked(r)
629 }
630 b.reconciled = len(hosts)
631 b.intents = nil
632 b.dirty = true
633}
634
635// Reconciled is how many intents OpenFailureBook turned into failure
636// records: executions an earlier agent began and never saw recorded. The
637// caller logs them and flushes the book (Flush).
638func (b *FailureBook) Reconciled() int {
639 b.mu.Lock()
640 defer b.mu.Unlock()
641 return b.reconciled
642}
643
644// Flush saves the book now: after reconciliation, so the records it made
645// and the intents it removed reach the disk before anything runs.
646func (b *FailureBook) Flush() error {
647 b.mu.Lock()
648 defer b.mu.Unlock()
649 return b.saveLocked()
650}
651
652// SaveIfDirty saves the book when a save is owed (dirty: a save failed, or
653// the open changed something), else does nothing and returns nil (rule 6).
654// The armed manager's pass and a declined box's periodic pass call it, so a
655// book whose disk recovers publishes its generations again without waiting
656// for another change, pointer or grant.
657func (b *FailureBook) SaveIfDirty() error {
658 b.mu.Lock()
659 defer b.mu.Unlock()
660 return b.retryLocked()
661}
662
663// SaveOwed reports whether the book owes a save (dirty): while it does,
664// PublishedEpoch answers 0 and RetryHostAt clears nothing.
665func (b *FailureBook) SaveOwed() bool {
666 b.mu.Lock()
667 defer b.mu.Unlock()
668 return b.dirty
669}
670
671// Begin writes host's intent and saves it before the execution runs
672// anything (rule 1). On a save error nothing of it stays in memory, the host's
673// earlier intent (if this one replaced it) is put back, and the error is
674// returned: the caller must not execute, since its failure could then be
675// lost across a failed save and a restart.
676//
677// One intent stands per host. A planned wake's intent replaces an older
678// wake's intent for the host: the activator delivers a planned wake's
679// outcome before a newer wake of the host plans (its outcome fence,
680// internal/activator awaitOutcomes, final review F3 and wave 2 N1), so the
681// older wake's intent is one whose outcome is written, or one whose build
682// never ran (GenCancelled, not executed), which an event on its way ends
683// anyway. Two plans for one host never race: the activator plans a host
684// only from its one waking entry. Any other standing intent refuses the
685// Begin (ErrIntentStands): an attempt's, whose outcome done() has yet to
686// write, or, for an attempt, a wake's, whose outcome may still be on its way
687// (a fallen-back wake's entry serves before its GenFellBack is delivered).
688// The refused execution is retried as any refused one is.
689func (b *FailureBook) Begin(host string, in Intent) error {
690 if in.ID == "" {
691 return errors.New("an intent needs its execution's identity")
692 }
693 b.mu.Lock()
694 defer b.mu.Unlock()
695 prev, had := b.intents[host]
696 if had && prev.ID != in.ID && !(wakeIntent(prev.ID) && wakeIntent(in.ID)) {
697 return fmt.Errorf("%s: %w (%s)", host, ErrIntentStands, prev.ID)
698 }
699 if b.intents == nil {
700 b.intents = map[string]Intent{}
701 }
702 b.intents[host] = in
703 if err := b.saveLocked(); err != nil {
704 if had {
705 b.intents[host] = prev
706 } else {
707 delete(b.intents, host)
708 }
709 return err
710 }
711 return nil
712}
713
714// End removes host's intent when it is id's, and saves (rule 2). It runs
715// after the execution's outcome is written to the book (Record,
716// MarkHoldRan), never before. Its save failing leaves the intent on disk,
717// which is the safe direction: a restart then records the failure the
718// outcome may also have written. An intent of another execution (a newer
719// wake that replaced this one's) is left alone.
720func (b *FailureBook) End(host, id string) error {
721 b.mu.Lock()
722 defer b.mu.Unlock()
723 if cur, ok := b.intents[host]; !ok || id == "" || cur.ID != id {
724 return b.retryLocked()
725 }
726 delete(b.intents, host)
727 return b.saveLocked()
728}
729
730// saveLocked writes the book and records whether the file is now behind
731// memory, so a failure is retried by retryLocked rather than forgotten. It
732// writes the in-memory seq, which only grows (rule 3), the generations, and
733// durable_epochs, since everything this agent publishes is on disk first
734// (rule 2).
735func (b *FailureBook) saveLocked() error {
736 if b.exhausted {
737 b.dirty = true
738 return ErrSeqExhausted
739 }
740 if b.path == "" && b.write == nil {
741 return nil
742 }
743 raw, err := json.MarshalIndent(failureFile{Schema: failureSchema, Records: b.recs, HoldRan: b.held, Seq: b.seq, Intents: b.intents,
744 Book: b.idLocked(), Gens: b.gens, Durable: true}, "", " ")
745 if err == nil {
746 if b.write != nil {
747 err = b.write(raw)
748 } else {
749 err = release.WriteFileAtomic(b.path, raw, 0o644)
750 }
751 }
752 b.dirty = err != nil
753 return err
754}
755
756// retryLocked is the save a call that changed nothing still owes when an
757// earlier save failed.
758func (b *FailureBook) retryLocked() error {
759 if !b.dirty {
760 return nil
761 }
762 return b.saveLocked()
763}
764
765func (b *FailureBook) find(acct, app, build string) int {
766 return slices.IndexFunc(b.recs, func(r FailureRecord) bool {
767 return r.Account == acct && r.App == app && r.Build == build
768 })
769}
770
771// Snapshot is the book's sequence plus one, taken when an execution starts
772// (an attempt in scheduler.execute, a planned wake in Plan): every marker
773// and retry clear already in the book is older than it, and every one set
774// later is not (Astra R3 N6, ruling 191). Zero is never a snapshot: it
775// means "not known", which clears no marker and makes no outcome stale.
776func (b *FailureBook) Snapshot() uint64 {
777 b.mu.Lock()
778 defer b.mu.Unlock()
779 if b.exhausted || b.seq >= seqLimit-1 {
780 // At the boundary a snapshot would be seqLimit or more: "not known"
781 // instead, the safe answer (fix wave 1, item 5).
782 return 0
783 }
784 return b.seq + 1
785}
786
787// seqLimit bounds the book's sequence (fix wave 1, item 5): every value it
788// hands out, and every snapshot, is below it, so the sequence never wraps
789// and no generation repeats under one identity. The last value handed out
790// is seqLimit-1 (math.MaxUint64-2).
791const seqLimit = math.MaxUint64 - 1
792
793// bumpLocked takes the book's next sequence value. At the boundary it hands
794// out nothing new: the book is exhausted and owes a save that never lands
795// (saveLocked), so nothing is published or cleared again, and the value it
796// returns (seqLimit, never a real one) is never seen outside memory. The
797// caller holds b.mu.
798func (b *FailureBook) bumpLocked() uint64 {
799 if b.exhausted || b.seq >= seqLimit-1 {
800 b.exhausted, b.dirty = true, true
801 return seqLimit
802 }
803 b.seq++
804 return b.seq
805}
806
807// clearKey is a retry clear's key in FailureBook.cleared; host "" is the
808// app-wide clear of `carlos promote --retry`.
809func clearKey(acct, app, build, host string) [4]string {
810 return [4]string{acct, app, build, host}
811}
812
813// preRetryLocked reports whether r's execution began before a retry that
814// cleared its build since, for its host or app-wide (ruling 191): its
815// snapshot is not newer than the clear. The caller holds b.mu.
816func (b *FailureBook) preRetryLocked(r FailureRecord) bool {
817 if r.Seq == 0 { // rolloutsim:M9
818 return false
819 }
820 return r.Seq <= b.cleared[clearKey(r.Account, r.App, r.Build, r.Host)] ||
821 r.Seq <= b.cleared[clearKey(r.Account, r.App, r.Build, "")]
822}
823
824// clearedLocked notes a retry clear of build for host ("" app-wide) at the
825// book's next sequence value. The caller holds b.mu.
826func (b *FailureBook) clearedLocked(acct, app, build, host string) {
827 if b.cleared == nil {
828 b.cleared = map[[4]string]uint64{}
829 }
830 b.cleared[clearKey(acct, app, build, host)] = b.bumpLocked()
831}
832
833// Record writes r unless a record for its build already stands, in which case
834// the first failure stands and only r's host is merged in: it leaves Retried
835// (its retry was spent), joins HeldDown if r holds it down, and the record
836// keeps the newer of the two PromotedAt stamps, so only a pointer signed
837// after the latest failure clears it. The in-memory record is kept even when
838// the file write fails, so the caller's transition still blocks the build;
839// the error is for the journal, and the next call on the book (this one
840// again, if nothing new, or Prune) retries the save. Until a save lands, what
841// keeps the failure across an agent restart is the execution's intent
842// (Begin), still on disk because its End comes after this call: the restart
843// records the failure from it. r.MarkHoldRan sets r's host's hold-ran marker
844// in the same save.
845//
846// A failure of an execution that began before a retry of its build (r.Seq,
847// ruling 191) is stale: the record is neither created nor changed, the
848// host's retry is not spent and no hold-down is added, and Record reports
849// PreRetry so the caller runs none of the transition (plan review finding
850// 3). What the execution did to the instance's database still stands: a
851// requested hold-ran marker is set, and so is one in place of the hold-down
852// r asked for (a hold build that ran there and went down, Task 4 fix round
853// 1), with the execution's identity and start (HoldRanExec, HoldRanAt), so
854// nothing falls back onto the instance automatically (spec §6.4). A stale
855// hold run that went down is restricted as one that was superseded is.
856func (b *FailureBook) Record(r FailureRecord) (RecordResult, error) {
857 b.mu.Lock()
858 defer b.mu.Unlock()
859 res, changed := b.recordLocked(r)
860 if changed {
861 return res, b.saveLocked()
862 }
863 return res, b.retryLocked()
864}
865
866// recordLocked is Record's change to memory, and reports whether it changed
867// anything to save. Reconciliation (reconcileLocked) shares it, so a
868// reconciled intent merges into a standing record as a live failure does.
869// The caller holds b.mu and saves.
870func (b *FailureBook) recordLocked(r FailureRecord) (RecordResult, bool) {
871 stale := b.preRetryLocked(r)
872 mark := r.MarkHoldRan || (stale && slices.Contains(r.HeldDown, r.Host))
873 marked := mark && b.markLocked(r.Host, HoldRan{Account: r.Account, App: r.App, Build: r.Build, Exec: r.HoldRanExec, At: r.HoldRanAt})
874 r.MarkHoldRan, r.HoldRanAt, r.HoldRanExec, r.Seq = false, time.Time{}, "", 0
875 if stale {
876 return PreRetry, marked
877 }
878 i := b.find(r.Account, r.App, r.Build)
879 if i < 0 {
880 r.Retried, r.Epochs = nil, nil
881 // A new record is a new restriction of every host of the app (rule 1).
882 b.recs = append(b.recs, cloneRec(r))
883 b.touchLocked(r.Account, r.App) // rolloutsim:M17
884 return Recorded, true
885 }
886 cur := &b.recs[i]
887 changed := false
888 if j := slices.Index(cur.Retried, r.Host); j >= 0 {
889 cur.Retried = slices.Delete(slices.Clone(cur.Retried), j, j+1)
890 changed = true
891 }
892 if r.PromotedAt.After(cur.PromotedAt) {
893 cur.PromotedAt = r.PromotedAt
894 changed = true
895 }
896 for _, h := range r.HeldDown {
897 if !slices.Contains(cur.HeldDown, h) {
898 cur.HeldDown = append(slices.Clone(cur.HeldDown), h)
899 changed = true
900 }
901 }
902 if changed {
903 // A merge with a change moves the app's generation, so a grant
904 // signed before it clears nothing (plan review finding 13, rule 1).
905 b.touchLocked(r.Account, r.App)
906 return Merged, true
907 }
908 return Unchanged, marked
909}
910
911// markLocked sets host's hold-ran marker to h and reports whether that
912// changed anything. h.At is the execution's start, from the caller. A zero
913// one (a caller that could not know it) is stamped with the book's clock
914// now: later than any real start, so only a pointer signed later still
915// lifts the marker, the safe direction.
916//
917// A marker is an execution's (plan review finding 4). A repeated report of
918// the execution that set the standing marker (same build, same non-empty
919// Exec) changes nothing and writes nothing. Any other execution gets the
920// book's next sequence value, so a good write whose execution began before
921// it clears nothing (ClearHoldRanBefore). It keeps the later of the two
922// stamps, whatever the builds (Task 4 fix round 1): a late report of an
923// older execution never moves At back, so a rollback signed between the two
924// runs never looks newer than the marker.
925//
926// Every marker set, renewed or replaced moves its app's generation (rule
927// 1); a marker of another app it replaces (a host now of another app) moves
928// that app's too. The caller holds b.mu and saves.
929func (b *FailureBook) markLocked(host string, h HoldRan) bool {
930 cur, ok := b.held[host]
931 if ok && cur.same(h) && h.Exec != "" && cur.Exec == h.Exec {
932 return false
933 }
934 if b.held == nil {
935 b.held = map[string]HoldRan{}
936 }
937 if h.At.IsZero() {
938 h.At = b.clock()
939 }
940 if ok && cur.At.After(h.At) {
941 h.At = cur.At
942 }
943 h.Seq = b.bumpLocked() // rolloutsim:M12
944 if ok && (cur.Account != h.Account || cur.App != h.App) {
945 b.touchLocked(cur.Account, cur.App) // the replaced marker's app lost a restriction
946 }
947 // The marker set, renewed or replaced: its app's generation moves.
948 b.touchLocked(h.Account, h.App) // rolloutsim:M14
949 b.held[host] = h
950 return true
951}
952
953// MarkHoldRan sets host's hold-ran marker (HoldRan) and saves it: the
954// outcome that set it changed nothing else in the book. A marker already
955// standing for another build is replaced: the newest hold build to run on
956// the instance is the one its database may now need. h.At is the
957// execution's start (markLocked).
958func (b *FailureBook) MarkHoldRan(host string, h HoldRan) error {
959 b.mu.Lock()
960 defer b.mu.Unlock()
961 if !b.markLocked(host, h) {
962 return b.retryLocked()
963 }
964 return b.saveLocked()
965}
966
967// HoldRanOn returns host's hold-ran marker, if one stands.
968func (b *FailureBook) HoldRanOn(host string) (HoldRan, bool) {
969 b.mu.Lock()
970 defer b.mu.Unlock()
971 h, ok := b.held[host]
972 return h, ok
973}
974
975// HoldRanCount is how many hold-ran markers stand. A box that declined
976// CARLOS_SWAP=overlap honours its book when it holds records or markers
977// (internal/host, honourDeclinedFailures).
978func (b *FailureBook) HoldRanCount() int {
979 b.mu.Lock()
980 defer b.mu.Unlock()
981 return len(b.held)
982}
983
984// ClearHoldRan drops host's hold-ran marker, whatever it is, and reports
985// whether a marker stood. A host retry no longer uses it: its grant clears
986// only a marker naming the grant's build, at the grant's epoch
987// (RetryHostAt, plan review findings 5 and 13).
988func (b *FailureBook) ClearHoldRan(host string) (bool, error) {
989 b.mu.Lock()
990 defer b.mu.Unlock()
991 cur, ok := b.held[host]
992 if !ok {
993 return false, b.retryLocked()
994 }
995 delete(b.held, host)
996 b.touchLocked(cur.Account, cur.App)
997 return true, b.saveLocked()
998}
999
1000// ClearHoldRanBefore drops host's hold-ran marker for a good-build write
1001// whose execution took snapshot snap (Astra R3 N6): only a marker set before
1002// that execution began, which the instance has since passed over. A marker
1003// set later is a hold build that ran after the write was decided (the write
1004// was delayed, say, between its acknowledgement check and here), and it
1005// stands. A zero snapshot (Bootstrap, a wake whose start is not known)
1006// clears nothing. The check and the clear are one hold of the lock.
1007func (b *FailureBook) ClearHoldRanBefore(host string, snap uint64) (bool, error) {
1008 b.mu.Lock()
1009 defer b.mu.Unlock()
1010 cur, ok := b.held[host]
1011 if !ok || snap == 0 || cur.Seq >= snap { // rolloutsim:M8
1012 return false, b.retryLocked()
1013 }
1014 delete(b.held, host)
1015 b.touchLocked(cur.Account, cur.App)
1016 return true, b.saveLocked()
1017}
1018
1019// ClearHoldRanOnPointer is an explicit rollback as this box sees it (ruling
1020// 205, Astra R3): host's route installs its good build, and the signed
1021// pointer of its channel names that build with promotedAt. The caller has
1022// checked both. It clears host's marker only when the marker is (acct,
1023// app)'s and older than the pointer: `carlos rollback` signs a fresh
1024// pointer, while root's automatic sibling repoint signs none, so the
1025// pointer the good build was first promoted under, from before the hold
1026// build ran, lifts nothing. The check and the clear are one hold of the
1027// lock, so a marker set by a newer run between the caller's reads and this
1028// call is never the one cleared.
1029func (b *FailureBook) ClearHoldRanOnPointer(host, acct, app string, promotedAt time.Time) (bool, error) {
1030 b.mu.Lock()
1031 defer b.mu.Unlock()
1032 cur, ok := b.held[host]
1033 if !ok || cur.Account != acct || cur.App != app || !promotedAt.After(cur.At) {
1034 return false, b.retryLocked()
1035 }
1036 delete(b.held, host)
1037 b.touchLocked(acct, app)
1038 return true, b.saveLocked()
1039}
1040
1041// Lookup returns the record for (acct, app, build), if one stands.
1042func (b *FailureBook) Lookup(acct, app, build string) (FailureRecord, bool) {
1043 b.mu.Lock()
1044 defer b.mu.Unlock()
1045 if i := b.find(acct, app, build); i >= 0 {
1046 return cloneRec(b.recs[i]), true
1047 }
1048 return FailureRecord{}, false
1049}
1050
1051// Blocks reports whether host may not be moved onto build (spec §8.4). An
1052// instance that has served the build (served) is never blocked, and neither
1053// is one a host retry cleared.
1054func (b *FailureBook) Blocks(acct, app, build, host string, served bool) bool {
1055 if served {
1056 return false
1057 }
1058 b.mu.Lock()
1059 defer b.mu.Unlock()
1060 i := b.find(acct, app, build)
1061 return i >= 0 && !slices.Contains(b.recs[i].Retried, host)
1062}
1063
1064// Retried reports whether build's record stands and lists host as retried:
1065// a `carlos instances retry` of host cleared it and no failure of host has
1066// spent it since. The scheduler keeps a retry's canary entitlement only
1067// while this holds (appQueue.dropRetries, final review round 2, finding 5).
1068func (b *FailureBook) Retried(acct, app, build, host string) bool {
1069 b.mu.Lock()
1070 defer b.mu.Unlock()
1071 i := b.find(acct, app, build)
1072 return i >= 0 && slices.Contains(b.recs[i].Retried, host)
1073}
1074
1075// HeldDown reports whether host's wakes are refused after a failed hold
1076// attempt onto build (spec §6.4).
1077func (b *FailureBook) HeldDown(acct, app, build, host string) bool {
1078 b.mu.Lock()
1079 defer b.mu.Unlock()
1080 i := b.find(acct, app, build)
1081 return i >= 0 && slices.Contains(b.recs[i].HeldDown, host) && !slices.Contains(b.recs[i].Retried, host)
1082}
1083
1084// RetryHost is `carlos instances retry <host>`: it clears the record for that
1085// host only (spec §8.4). It checks no grant: the box reaches it through
1086// RetryHostAt, once the console's signed grant has verified and its epoch
1087// is still the restriction's.
1088//
1089// A retry that clears the host also notes a host clear at the book's next
1090// sequence value (ruling 191): a failure of an execution of host that began
1091// before it is then stale (Record), and takes nothing back. A retry of a
1092// host already retried notes nothing (Task 4 fix round 1): the canary the
1093// first retry let through may be running, and its failure must spend the
1094// retry, or the host would be left neither held nor allowed through the
1095// halt, with no later retry able to free it.
1096func (b *FailureBook) RetryHost(acct, app, build, host string) (bool, error) {
1097 b.mu.Lock()
1098 defer b.mu.Unlock()
1099 if !b.retryHostLocked(acct, app, build, host) {
1100 return false, b.retryLocked()
1101 }
1102 return true, b.saveLocked()
1103}
1104
1105// retryHostLocked is RetryHost's change to memory, and reports whether it
1106// un-retried the host. It moves the app's generation (rule 1), saved with
1107// the book: a grant that was spent here never matches again, even after a
1108// reopen. The caller holds b.mu and saves.
1109func (b *FailureBook) retryHostLocked(acct, app, build, host string) bool {
1110 i := b.find(acct, app, build)
1111 if i < 0 || slices.Contains(b.recs[i].Retried, host) {
1112 return false
1113 }
1114 b.clearedLocked(acct, app, build, host)
1115 cur := &b.recs[i]
1116 cur.Retried = append(slices.Clone(cur.Retried), host)
1117 if j := slices.Index(cur.HeldDown, host); j >= 0 {
1118 cur.HeldDown = slices.Delete(slices.Clone(cur.HeldDown), j, j+1)
1119 }
1120 b.touchLocked(acct, app)
1121 return true
1122}
1123
1124// RestrictionEpoch is the generation of build's restriction of host as it
1125// stands in memory (restrictionEpochLocked), saved or not. It is for logs
1126// and tests: status publishes PublishedEpoch, never this.
1127func (b *FailureBook) RestrictionEpoch(acct, app, build, host string) uint64 {
1128 b.mu.Lock()
1129 defer b.mu.Unlock()
1130 return b.restrictionEpochLocked(acct, app, build, host)
1131}
1132
1133// PublishedEpoch is the generation of build's restriction of host that
1134// status publishes beside HeldBy (HostRollout.HeldEpoch) and the console
1135// signs into its retry grant: the app's generation when build's record
1136// blocks host or host's marker names build, and the book's memory equals
1137// its last landed save (rule 2). Zero otherwise: nothing of build restricts
1138// host, or the book owes a save, so the value might not survive a crash.
1139func (b *FailureBook) PublishedEpoch(acct, app, build, host string) uint64 {
1140 b.mu.Lock()
1141 defer b.mu.Unlock()
1142 if b.dirty { // durable before use (rule 2); rolloutsim:M13
1143 return 0
1144 }
1145 return b.restrictionEpochLocked(acct, app, build, host)
1146}
1147
1148// restrictionEpochLocked is (acct, app)'s generation when build restricts
1149// host, through its record (which blocks every host of the app it does not
1150// list as retried) or host's hold-ran marker naming build; else 0 (rule 1).
1151// The caller holds b.mu.
1152func (b *FailureBook) restrictionEpochLocked(acct, app, build, host string) uint64 {
1153 i := b.find(acct, app, build)
1154 byRecord := i >= 0 && !slices.Contains(b.recs[i].Retried, host)
1155 m, ok := b.held[host]
1156 byMarker := ok && m.Account == acct && m.App == app && m.Build == build
1157 if !byRecord && !byMarker {
1158 return 0
1159 }
1160 return b.gens[genKey(acct, app)]
1161}
1162
1163// RetryHostAt is a signed `carlos instances retry <host>` grant (spec §8.4,
1164// §11; HostRetryGrant) applied to the book: when the grant names this book
1165// (book, the book's ID), the book owes no save, and epoch is build's
1166// restriction of host now (the app's generation, PublishedEpoch), it clears
1167// build's record for host, when that record holds host (RetryHost), and
1168// host's hold-ran marker, when the marker names build (plan review finding
1169// 5). A grant from before any restriction of the app last changed, a zero
1170// epoch, or one for a build that restricts nothing clears nothing. Nor does
1171// a grant for another book, or one that names no book (a console or a box
1172// from before the identity): its epoch could be another book's (final
1173// review round 1, finding 3). An owed save is retried first, and while it
1174// fails nothing is cleared (rule 2): the generation in memory may be one no
1175// disk holds. The check and the clears are one hold of the lock. now is the
1176// generation at the check, for the caller's log; err is the save's.
1177func (b *FailureBook) RetryHostAt(acct, app, build, host, book string, epoch uint64) (rec, mark bool, now uint64, err error) {
1178 b.mu.Lock()
1179 defer b.mu.Unlock()
1180 now = b.restrictionEpochLocked(acct, app, build, host)
1181 if err := b.retryLocked(); b.dirty {
1182 return false, false, now, err
1183 }
1184 if book == "" || book != b.idLocked() || epoch == 0 || now != epoch { // rolloutsim:M16
1185 return false, false, now, nil
1186 }
1187 if m, ok := b.held[host]; ok && m.Account == acct && m.App == app && m.Build == build {
1188 delete(b.held, host)
1189 mark = true
1190 }
1191 rec = b.retryHostLocked(acct, app, build, host)
1192 if !rec && !mark {
1193 return false, false, now, nil
1194 }
1195 if !rec {
1196 // Only the marker was removed (no record held the host): still a
1197 // retry, so its place in the sequence is noted as one (final review
1198 // round 1, finding 4, Astra). A failure of an execution of build on
1199 // host that began before it is then stale (Record, PreRetry): no
1200 // record, no halt, though a late hold outcome still sets its marker.
1201 // retryHostLocked has noted it, and moved the generation, when it
1202 // cleared a record, and a retry that removed nothing notes nothing.
1203 b.clearedLocked(acct, app, build, host)
1204 b.touchLocked(acct, app)
1205 }
1206 return rec, mark, now, b.saveLocked()
1207}
1208
1209// ClearOnPointer is `carlos promote --retry` as this box sees it: a signed
1210// channel pointer naming build whose PromotedAt is newer than the record's
1211// clears the record (spec rev 14 §8.4). The pointer's signature is verified
1212// by the caller's read; nobody can forge a fresh one. A clear is noted
1213// app-wide at the book's next sequence value (ruling 191), so a failure of
1214// any instance's execution that began before it is stale (Record) and never
1215// writes the record back.
1216func (b *FailureBook) ClearOnPointer(acct, app, build string, promotedAt time.Time) (bool, error) {
1217 b.mu.Lock()
1218 defer b.mu.Unlock()
1219 i := b.find(acct, app, build)
1220 if i < 0 || !promotedAt.After(b.recs[i].PromotedAt) {
1221 return false, b.retryLocked()
1222 }
1223 b.recs = slices.Delete(b.recs, i, i+1)
1224 b.clearedLocked(acct, app, build, "")
1225 b.touchLocked(acct, app)
1226 return true, b.saveLocked()
1227}
1228
1229// ForApp returns the app's records.
1230func (b *FailureBook) ForApp(acct, app string) []FailureRecord {
1231 b.mu.Lock()
1232 defer b.mu.Unlock()
1233 var out []FailureRecord
1234 for _, r := range b.recs {
1235 if r.Account == acct && r.App == app {
1236 out = append(out, cloneRec(r))
1237 }
1238 }
1239 return out
1240}
1241
1242// Prune drops the records for builds no route on this box still has
1243// installed. "The record stays for any instance still on the failed build"
1244// (spec §8.4), and only for those. installed is called under the book's lock
1245// and must take no lock of its own (nor call back into the book). The
1246// scheduler runs Prune every sweep, so it also retries a save that failed.
1247// The retry clears of such builds go too (they are in memory only): a stale
1248// failure of one arriving after is a new record, which the next Prune drops
1249// again.
1250//
1251// An app that lost a record and still has a record or a marker gets a new
1252// generation; an app with neither left loses its generation, which is the
1253// only way one is dropped (rule 1): nothing of it restricts any host, and a
1254// later restriction takes a fresh value, larger than every earlier one.
1255func (b *FailureBook) Prune(installed func(acct, app, build string) bool) error {
1256 b.mu.Lock()
1257 defer b.mu.Unlock()
1258 for k := range b.cleared {
1259 if !installed(k[0], k[1], k[2]) {
1260 delete(b.cleared, k)
1261 }
1262 }
1263 lost := map[string][2]string{} // apps that lost a record
1264 kept := b.recs[:0]
1265 for _, r := range b.recs {
1266 if installed(r.Account, r.App, r.Build) {
1267 kept = append(kept, r)
1268 } else {
1269 lost[genKey(r.Account, r.App)] = [2]string{r.Account, r.App}
1270 }
1271 }
1272 b.recs = kept
1273 standing := map[string]bool{} // apps with a record or a marker left
1274 for _, r := range b.recs {
1275 standing[genKey(r.Account, r.App)] = true
1276 }
1277 for _, h := range b.held {
1278 standing[genKey(h.Account, h.App)] = true
1279 }
1280 changed := false
1281 for k := range b.gens {
1282 if !standing[k] {
1283 delete(b.gens, k)
1284 changed = true
1285 }
1286 }
1287 for _, k := range slices.Sorted(maps.Keys(lost)) {
1288 if standing[k] {
1289 b.touchLocked(lost[k][0], lost[k][1])
1290 }
1291 changed = true
1292 }
1293 if !changed {
1294 return b.retryLocked()
1295 }
1296 return b.saveLocked()
1297}
1298
1299// List returns a copy of every record, for status.
1300func (b *FailureBook) List() []FailureRecord {
1301 b.mu.Lock()
1302 defer b.mu.Unlock()
1303 out := make([]FailureRecord, len(b.recs))
1304 for i, r := range b.recs {
1305 out[i] = cloneRec(r)
1306 }
1307 return out
1308}
1309