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 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "errors"
8 "log"
9 "os"
10 "slices"
11 "strconv"
12 "strings"
13 "sync"
14 "time"
15
16 "github.com/carlosframework/platform/internal/activator"
17 "github.com/carlosframework/platform/internal/appconfig"
18 "github.com/carlosframework/platform/internal/registry"
19 "github.com/carlosframework/platform/internal/release"
20)
21
22// Activator is the part of *activator.Service the scheduler drives. Swap is
23// plan 3's, for instances; ReplaceWorker is Task 11's same-entry
24// stop-then-start for workers and native sidecar members (spec §7.9), and
25// PublishHeldServing releases a worker fallback's serving identity once
26// done() has recorded the failure it falls back from (spec §8.1).
27// EffectiveMode is the mode Swap will run an attempt of mode m on host in
28// (an overlap on a foreign-layout row runs as a hold, spec §2.9), which the
29// attempt's intent records before Swap runs (plan review finding 2).
30type Activator interface {
31 Current(host string) (activator.Gen, bool)
32 Owns(host string) bool
33 Swap(ctx context.Context, a activator.Attempt, newest func(host string) uint64) activator.SwapResult
34 ReplaceWorker(ctx context.Context, a activator.Attempt, fallback *release.StagedIdentity, newest func(host string) uint64) activator.SwapResult
35 PublishHeldServing(host string)
36 EffectiveMode(host string, m activator.SwapMode) activator.SwapMode
37}
38
39// Routes is the part of *registry.Registry the scheduler reads and writes.
40type Routes interface {
41 Get(host string) (*registry.Route, error)
42 SetGoodBuild(host string, expect, next registry.GoodBuild) error
43 SetServingVersion(host, ver string) error
44}
45
46// Deps is everything the manager needs from the box. Its funcs are always
47// called without the manager's locks held.
48type Deps struct {
49 Act Activator
50 Routes Routes
51 Book *FailureBook
52 OptDir string
53 DataDir string
54 // Box is this box's label (host.BoxLabel), named by the cohort-halted
55 // event (spec §9.4: app, box, build, reason).
56 Box string
57 // Demand is release.CanonicalDemand bound to the deploy store and this
58 // box's platform (spec §4.2).
59 Demand func(ctx context.Context, acct, app, version, stagedPath string) (demand, digest string, err error)
60 // Policy reads the app's default config bundle (spec §4.1).
61 Policy func(ctx context.Context, acct, app string) (*appconfig.Manifest, error)
62 // Pointer reads a channel's signed pointer, verified (release.Channel).
63 // It is the retry signal (spec rev 14 §8.4) and the PromotedAt a failure
64 // record keeps. Nil: records clear only by a newer installed build. It
65 // is the raw signed read, not the per-box digest ratchet: only its
66 // Version and PromotedAt are used, to clear failure records, and root's
67 // install, which the ratchet guards, decides which build is installed.
68 Pointer func(ctx context.Context, acct, app, channel string) (*release.ChannelPointer, error)
69 Events activator.EventSink
70 // Touch asks root for a stage pass (the stage relay, spec §7.7).
71 Touch func()
72 // Materialize installs and stamps a cold managed host's build (the exec
73 // backend's Materialize hook) so a wake is planned against it (Task 10,
74 // spec §6.4, §8.4). Nil: a cold managed host's wake is refused.
75 Materialize func(ctx context.Context, host string) error
76 // OnChange, when set, is told after every rollout transition (an
77 // attempt's outcome, a wake's event, a newly installed build, a retry),
78 // so the box republishes its status within seconds instead of at the
79 // next tick (plan 6's deploy watch). It is a nudge: it is called with no
80 // Manager or scheduler lock held, and it must not block.
81 OnChange func()
82 Now func() time.Time
83 Logf func(format string, args ...any)
84}
85
86// pointerReadEvery bounds how often an app with a standing failure record
87// has its channel pointers read for a retry. An app with no record costs no
88// read at all.
89const pointerReadEvery = 30 * time.Second
90
91// stagingDeferral is how long an attempt refused as a staging error waits:
92// one stage-relay window, for root to restage (spec §4.2, §7.7).
93const stagingDeferral = 15 * time.Second
94
95// abandonDeferral is how long a host waits after an attempt was abandoned
96// while it was still the host's newest: a hold whose stop withdrew, a lost
97// lease, a re-check that refused. Running the same attempt again at once
98// would only repeat the stop cycle (plan 3's deferral to this plan). An
99// attempt abandoned because a newer desire superseded it does not wait: the
100// newer attempt goes as soon as it is admitted.
101const abandonDeferral = 15 * time.Second
102
103// Manager is the box's rollout scheduler: one goroutine per (account, app),
104// fed by the agent pass, the adopt pass, `carlos restart` and config sync.
105//
106// Lock order: goodMu, then activator.Service.mu, then Manager.mu, then
107// scheduler.mu, then FailureBook.mu. The activator (Current, Owns, Swap,
108// ReplaceWorker) is never called while Manager.mu or a scheduler.mu is held.
109// goodMu comes first because served, publishServing and writeGood call
110// Act.Current under it, and observeGood takes Manager.mu and then a
111// scheduler.mu under it. So never call served, publishServing or writeGood
112// (or OnGen, which reaches them) while holding activator.Service.mu: that
113// caller would hold Service.mu and wait for goodMu while another holds goodMu
114// and waits for Service.mu.
115type Manager struct {
116 d Deps
117 ctx context.Context
118 policies *policyCache
119
120 mu sync.Mutex
121 apps map[appKey]*scheduler
122 hostApp map[string]appKey
123 cfg map[string]*configTrack
124 retried map[string]string // host -> build a retry cleared: its next attempt's cause is retry
125 pointerRead map[appKey]time.Time
126 // nudging holds the hosts a Nudge is working on; true asks it to look
127 // once more for a nudge that arrived meanwhile.
128 nudging map[string]bool
129 // observing holds the hosts whose first wake is inside its 10 s
130 // observation, each with the token of the wake it observes: opened by
131 // that wake's healthy first-wake event, closed by an event carrying the
132 // same token (OnGen). Status reports them as settling.
133 observing map[string]uint64
134 // planned holds each host whose planned first wake is in flight, and the
135 // build it boots: from its Plan to its outcome (an OnGen event carrying
136 // the plan's token), its next Plan, or the activator letting the host go
137 // (a Pass that finds Owns false). prune keeps those builds' failure
138 // records, because a wake's last selection check (planCheck) reads only
139 // the book: a record pruned after the plan, once a promotion moved every
140 // route off the build, would let the wake boot a build that failed on
141 // this box. plannedSeq tells one registration from the next for that
142 // Pass, which asks Owns off every lock.
143 planned map[string]plannedWake
144 plannedSeq uint64
145 // wakeTok is each host's newest wake token: the token of the last plan
146 // made for it (final review F4). Tokens come from one counter, so they
147 // are unique and only grow. An event carrying any other token is an older
148 // wake's, and acts on nothing that belongs to the newer one.
149 wakeTok map[string]uint64
150 tokenCount uint64
151 // wakeAt holds, per host, the start of each planned wake that may still
152 // report (wakeStart, by token): its plan time, which a hold-ran marker
153 // the wake leaves carries as its At (ruling 205, Task 3 fix round 1), and
154 // the failure book's snapshot taken with it, which orders the wake's
155 // outcome against the book (Astra R3 N6, ruling 191). Kept per token,
156 // not only the newest plan's, because neither a marker nor a failure is
157 // token-scoped: a late event of an older wake still acts, as of its own
158 // start. An entry goes with its wake's last event, with the host
159 // (forget), or when the host has wakeStartsKept newer ones.
160 wakeAt map[string][]wakeStart
161 // execCount numbers the scheduled attempts' executions (nextExecID,
162 // dispatch.ExecID).
163 execCount uint64
164 // inc is this manager's incarnation: 8 random bytes, hex, drawn once per
165 // process start (NewManager). Every execution identity carries it
166 // (nextExecID, wakeExecID), because the counters above start again at
167 // zero in a new process while the markers and intents they named
168 // survive in the book: without it the first wake after a restart could
169 // be "w1" again, and markLocked would take the new execution for the
170 // old one and keep its Seq and At, so a grant or a rollback that matched
171 // the old execution would clear the new one (final review round 1,
172 // finding 1, Astra).
173 inc string
174
175 // ackSeq numbers the config acks the tracks hand back, under mu, in the
176 // critical section of the transition that releases each one: the
177 // writer's order (ackLocked, plan review #10).
178 ackSeq uint64
179 // acks writes those acks, off the scheduler's slot and loop (Task 11).
180 acks *ackWriter
181
182 // goodMu serialises every serving- and good-build write with its
183 // acknowledgement check (served, publishServing, writeGood), so an older
184 // generation's write can never land after a newer one's (spec §8.5).
185 goodMu sync.Mutex
186
187 // The shutdown join (Close, spec §8.6). joinMu is a leaf: nothing else
188 // is taken while it is held, so it may be taken under any other lock
189 // (CoalesceRecycle takes it under activator.Service.mu and a
190 // scheduler.mu). inflight counts the attempts' executions, the Plan and
191 // OnGen calls under way, and the planned wakes in wakes; closed stops
192 // admission; joined, when set, is closed once inflight is zero.
193 joinMu sync.Mutex
194 closed bool
195 inflight int
196 joined chan struct{}
197 // wakes holds the token of each planned wake whose intent beginWake
198 // wrote and whose terminal event has not yet reached OnGen (Task 11 fix
199 // round 1). Each is one count in inflight, taken and given back exactly
200 // once whatever the order of plans and events.
201 wakes map[uint64]struct{}
202}
203
204// NewManager builds the manager. Its schedulers run until ctx ends.
205func NewManager(ctx context.Context, d Deps) *Manager {
206 if d.Now == nil {
207 d.Now = time.Now
208 }
209 if d.Logf == nil {
210 d.Logf = log.Printf
211 }
212 m := &Manager{d: d, ctx: ctx, inc: newIncarnation(),
213 apps: map[appKey]*scheduler{}, hostApp: map[string]appKey{}, cfg: map[string]*configTrack{},
214 retried: map[string]string{}, pointerRead: map[appKey]time.Time{}, nudging: map[string]bool{}, observing: map[string]uint64{},
215 planned: map[string]plannedWake{}, wakeTok: map[string]uint64{}, wakeAt: map[string][]wakeStart{}}
216 m.policies = &policyCache{load: d.Policy, ttl: 30 * time.Second, now: d.Now, m: map[appKey]*policyEntry{}}
217 m.acks = newAckWriter(ctx, d.Logf)
218 return m
219}
220
221// Close is the scheduler's part of a graceful stop (spec §8.6: shutdown
222// cancels and joins the whole operation set; Task 5 review). The agent calls
223// it after the activator's Shutdown, which has stopped every entry and so
224// cancelled any swap in flight. It stops admitting attempts, then waits,
225// until ctx ends, for every attempt's execution to finish its done() (the
226// outcome's book writes and the intent's End), for every Plan and OnGen
227// call under way to return, and for every planned wake whose intent is on
228// disk to deliver the terminal event that ends it, and then writes the acks
229// still pending and stops the ack writer. A planned restart in the middle
230// of a swap or a wake so leaves the outcome on disk and no intent behind,
231// and the next agent reconciles nothing. A Close that runs out of ctx
232// returns anyway: what was not finished is what OpenFailureBook reconciles.
233//
234// The planned wakes are counted here, not left to Shutdown (Task 11 fix
235// round 1): Shutdown waits for each entry's e.ready, and wake() closes it
236// before afterWake sends the terminal event, while the first-wake
237// observer's cancellation is sent from its own goroutine. A wake already
238// past doWake's context check when Shutdown began can also still plan and
239// write its intent. Each of those reaches OnGen after Shutdown returns.
240func (m *Manager) Close(ctx context.Context) {
241 m.joinMu.Lock()
242 m.closed = true
243 var wait chan struct{}
244 if m.inflight > 0 {
245 if m.joined == nil {
246 m.joined = make(chan struct{})
247 }
248 wait = m.joined
249 }
250 n := m.inflight
251 m.joinMu.Unlock()
252 if wait != nil {
253 select {
254 case <-wait:
255 case <-ctx.Done():
256 m.d.Logf("rollout: stopping with %d attempt(s), plan(s), wake(s) or wake event(s) still running: %v", n, ctx.Err())
257 }
258 }
259 m.acks.close(ctx)
260}
261
262// enter counts one operation Close joins. An admission (admit true) is
263// refused once Close has begun; a Plan or OnGen call is always counted, so
264// one that arrives while Close waits is waited for too.
265func (m *Manager) enter(admit bool) bool {
266 m.joinMu.Lock()
267 defer m.joinMu.Unlock()
268 if admit && m.closed {
269 return false
270 }
271 m.inflight++
272 return true
273}
274
275// leave ends an operation enter counted.
276func (m *Manager) leave() {
277 m.joinMu.Lock()
278 defer m.joinMu.Unlock()
279 m.leaveLocked()
280}
281
282func (m *Manager) leaveLocked() {
283 m.inflight--
284 if m.inflight == 0 && m.joined != nil {
285 close(m.joined)
286 m.joined = nil
287 }
288}
289
290// enterWake counts the planned wake with token tok, whose intent beginWake
291// has just written, until leaveWake (its terminal event). Always counted,
292// even once Close has begun: the intent is on disk, and only that event's
293// OnGen ends it. A token already counted is not counted again.
294func (m *Manager) enterWake(tok uint64) {
295 m.joinMu.Lock()
296 defer m.joinMu.Unlock()
297 if _, ok := m.wakes[tok]; ok {
298 return
299 }
300 if m.wakes == nil {
301 m.wakes = map[uint64]struct{}{}
302 }
303 m.wakes[tok] = struct{}{}
304 m.inflight++
305}
306
307// leaveWake gives back the count enterWake took for tok, once. Every
308// terminal event of a planned wake ends here; one whose wake wrote no
309// intent (a refused plan, a failed Begin, an ordinary wake), or one
310// delivered again, finds nothing and changes nothing.
311func (m *Manager) leaveWake(tok uint64) {
312 m.joinMu.Lock()
313 defer m.joinMu.Unlock()
314 if _, ok := m.wakes[tok]; !ok {
315 return
316 }
317 delete(m.wakes, tok)
318 m.leaveLocked()
319}
320
321// closing reports whether Close has begun.
322func (m *Manager) closing() bool {
323 m.joinMu.Lock()
324 defer m.joinMu.Unlock()
325 return m.closed
326}
327
328// changed tells OnChange a transition happened. Every caller has released
329// Manager.mu and any scheduler.mu first, so the callback may read Status.
330func (m *Manager) changed() {
331 if m.d.OnChange != nil {
332 m.d.OnChange()
333 }
334}
335
336func (m *Manager) scheduler(k appKey) *scheduler {
337 m.mu.Lock()
338 defer m.mu.Unlock()
339 s := m.apps[k]
340 if s == nil {
341 book := m.d.Book
342 s = &scheduler{m: m, key: k, kick: make(chan struct{}, 1),
343 q: newAppQueue(k.acct, k.app, func(build, host string, served bool) bool {
344 return book.Blocks(k.acct, k.app, build, host, served)
345 })}
346 m.apps[k] = s
347 go s.loop(m.ctx)
348 }
349 return s
350}
351
352func (m *Manager) schedulerOf(host string) *scheduler {
353 m.mu.Lock()
354 defer m.mu.Unlock()
355 k, ok := m.hostApp[host]
356 if !ok {
357 return nil
358 }
359 return m.apps[k]
360}
361
362// track is host's config track; the caller holds m.mu.
363func (m *Manager) track(host string) *configTrack {
364 t := m.cfg[host]
365 if t == nil {
366 t = &configTrack{}
367 m.cfg[host] = t
368 }
369 return t
370}
371
372// Pass is the agent pass of spec §6.1: each managed route's installed build,
373// stamped by root, against the build its current generation serves. routes
374// must be the box's whole list, because Pass also prunes failure records no
375// route installs any more, and lifts the halts those records held.
376func (m *Manager) Pass(routes []registry.Route) {
377 keys := m.observe(routes)
378 m.forget(routes)
379 m.applyPointers(routes, keys)
380 m.dropUnownedWakes()
381 m.prune(routes)
382 m.liftClearedHalts()
383}
384
385// heldOnBox reports whether rt is a route this manager keeps state for:
386// Managed, but with a passing status (draining, moving, landing) read as
387// ready. A row passing through one of those for a Pass keeps its place in
388// the queue, its pending restart or config, and its held ack; a row that is
389// gone, or that stopped being an exec route this scheduler moves, does not.
390func heldOnBox(rt registry.Route) bool {
391 return Restrictable(rt)
392}
393
394// forget drops what the manager holds for hosts no longer on the box: a
395// route removed or moved away, or one that now belongs to another app or
396// stopped being managed. Without it such a host stayed in Status for the
397// life of the process, possibly as queued, and could hold a deploy watch
398// open (Task 13). A host with an attempt running is kept: the attempt's
399// done() still needs its slot, and the next Pass after it finishes forgets
400// it. Failure records and cohort halts are not per host and are not
401// touched; prune and liftClearedHalts own those.
402//
403// Manager.mu, then each scheduler.mu: the documented order. Nothing here
404// calls the activator.
405func (m *Manager) forget(routes []registry.Route) {
406 present := make(map[string]appKey, len(routes))
407 for _, rt := range routes {
408 if heldOnBox(rt) {
409 present[rt.Host] = appKey{release.RouteAccount(rt), rt.App}
410 }
411 }
412 m.mu.Lock()
413 running := map[string]bool{}
414 var gone []string
415 for k, s := range m.apps {
416 s.mu.Lock()
417 for host, h := range s.q.hosts {
418 if pk, ok := present[host]; ok && pk == k {
419 continue
420 }
421 if h.running != nil {
422 running[host] = true
423 continue
424 }
425 delete(s.q.hosts, host)
426 gone = append(gone, host)
427 }
428 s.mu.Unlock()
429 }
430 for _, host := range gone {
431 if _, ok := present[host]; ok || running[host] {
432 continue // moved to another app on this box, or still running there
433 }
434 delete(m.hostApp, host)
435 delete(m.cfg, host)
436 delete(m.retried, host)
437 delete(m.observing, host)
438 // A later event of the host's last wake then matches no token: the
439 // host is gone, and there is nothing of its wake left to act on.
440 delete(m.wakeTok, host)
441 delete(m.wakeAt, host)
442 }
443 m.mu.Unlock()
444 if len(gone) > 0 {
445 m.d.Logf("rollout: forgot %d host(s) no longer on this box: %v", len(gone), gone)
446 m.changed() // the hosts left the report
447 }
448}
449
450// Nudge re-reads one host and wakes its scheduler. The activator sweep's
451// drift arm calls it (spec §10, OnDrift) on a goroutine of its own, for every
452// drifted entry on every sweep, with no dedupe of its own. So nudges for one
453// host collapse here: while one is reading and observing, the others only
454// ask it for one more look and return, and a slow read (the row, the
455// policy) never collects a goroutine per sweep. A nudge for the build the
456// host already wants changes nothing (wantBuild), so a queued or running
457// attempt is neither queued again nor superseded.
458func (m *Manager) Nudge(host string) {
459 m.mu.Lock()
460 if _, busy := m.nudging[host]; busy {
461 m.nudging[host] = true
462 m.mu.Unlock()
463 return
464 }
465 m.nudging[host] = false
466 m.mu.Unlock()
467 for {
468 if rt, err := m.d.Routes.Get(host); err == nil && rt != nil {
469 m.observe([]registry.Route{*rt})
470 }
471 m.mu.Lock()
472 again := m.nudging[host]
473 if !again {
474 delete(m.nudging, host)
475 m.mu.Unlock()
476 return
477 }
478 m.nudging[host] = false
479 m.mu.Unlock()
480 }
481}
482
483// Adopted replaces BounceAdopted as the adopt pass's restart callback when
484// the flag is on: the pass has repointed the live symlink, and the scheduler
485// takes the move from here, paced. It reports whether an awake instance will
486// be attempted, so makeRestartRoute does not fall through to systemctl.
487func (m *Manager) Adopted(host string) bool {
488 if _, awake := m.d.Act.Current(host); !awake {
489 return false
490 }
491 m.Nudge(host)
492 return true
493}
494
495func (m *Manager) observe(routes []registry.Route) []appKey {
496 type seen struct {
497 rt registry.Route
498 serving string
499 known bool // false while waking or stopping: the slot is left alone
500 }
501 byApp := map[appKey][]seen{}
502 for _, rt := range routes {
503 if !Managed(rt) {
504 continue
505 }
506 s := seen{rt: rt, known: true}
507 if g, ok := m.d.Act.Current(rt.Host); ok && g.Build != "" {
508 s.serving = g.Build
509 } else if m.d.Act.Owns(rt.Host) {
510 s.known = false
511 }
512 k := appKey{release.RouteAccount(rt), rt.App}
513 byApp[k] = append(byApp[k], s)
514 }
515 now := m.d.Now()
516 keys := make([]appKey, 0, len(byApp))
517 moved := false
518 for k, list := range byApp {
519 keys = append(keys, k)
520 policy := m.policies.get(m.ctx, k.acct, k.app)
521 s := m.scheduler(k)
522 retried := map[string]string{}
523 m.mu.Lock()
524 for _, x := range list {
525 m.hostApp[x.rt.Host] = k
526 if b, ok := m.retried[x.rt.Host]; ok {
527 retried[x.rt.Host] = b
528 }
529 }
530 m.mu.Unlock()
531 rearm := false
532 s.mu.Lock()
533 s.q.concurrency = policy.Concurrency
534 for _, x := range list {
535 if x.known {
536 // Serving state only from a stable entry. The desired build
537 // and its revision are updated either way (spec §6.3): a
538 // build root installs while a hold drains or a wake runs
539 // supersedes the attempt's, so its gates (pinCheck, the
540 // result check) refuse the older build. Admission still
541 // waits for a stable entry (admit's view leaves a waking or
542 // stopping host out).
543 s.q.observe(x.rt.Host, x.serving, x.rt.GoodVersion, now)
544 }
545 cause := activator.CauseRelease
546 if retried[x.rt.Host] == x.rt.Version {
547 cause = activator.CauseRetry
548 }
549 if s.q.wantBuild(x.rt.Host, cause, x.rt.Version, now) {
550 moved = true
551 // A marked host's installed build moved: it may be a rollback
552 // onto its good build, so the app's pointers are read at the
553 // next applyPointers rather than up to pointerReadEvery later
554 // (ruling 205). The book's lock is the last in the order.
555 if _, ok := holdRanFor(m.d.Book, k.acct, k.app, x.rt.Host); ok {
556 rearm = true
557 }
558 }
559 }
560 s.mu.Unlock()
561 if rearm {
562 m.pointerReadDue(k)
563 }
564 s.poke()
565 }
566 if moved {
567 // A sleeper has no attempt to finish, so the pass that first sees
568 // its new build is the news for the deploy watch.
569 m.changed()
570 }
571 return keys
572}
573
574// pointerReadDue makes the next applyPointers read k's pointers at once
575// rather than up to pointerReadEvery later (ruling 205). observe calls it
576// when a marked host's installed build moves; done() and OnGen call it when
577// they set a marker, because the rollback that superseded the hold build is
578// signed and installed while that build runs, before any marker exists, so
579// the pass that saw the install could not make the read due. The caller
580// holds no lock (Manager.mu is taken here).
581func (m *Manager) pointerReadDue(k appKey) {
582 m.mu.Lock()
583 delete(m.pointerRead, k)
584 m.mu.Unlock()
585}
586
587// routeChannel is the channel a route follows: "" means stable, as
588// release's own (unexported) routeChannel reads it.
589func routeChannel(rt registry.Route) string {
590 if rt.Channel == "" {
591 return "stable"
592 }
593 return rt.Channel
594}
595
596// applyPointers is the retry signal of spec rev 14 §8.4. For each app with a
597// standing failure record, at most every pointerReadEvery, it reads the
598// signed pointer of every channel the app's managed routes on this box
599// follow, and clears a record when a pointer names its build with a newer
600// PromotedAt (`carlos promote --retry`). The pointer the build failed under,
601// read again, clears nothing. A second retry of the same build is the same
602// rule again: the retry's own failure recorded the newer PromotedAt, so only
603// a pointer signed after it clears the record.
604//
605// It is also the rollback signal for hold-ran markers (ruling 205, Astra
606// R3). An app with a marked host whose route installs its good build is read
607// too, on the same schedule, and that host's marker is cleared when its
608// channel's pointer names the good build with a PromotedAt newer than the
609// marker: `carlos rollback` re-signs the pointer, root's automatic sibling
610// repoint does not, so only an explicit rollback lets the good build boot
611// over the marker. The signature is the pointer read's to verify
612// (release.Channel). The marker's At is the start of the execution that set
613// it (an attempt's dispatch, a planned wake's plan), so the rollback that
614// superseded a running hold build is newer than it. The read is made due at
615// once when a marked host's installed build moves and when a marker is set
616// (pointerReadDue), so a rollback is not left waiting out pointerReadEvery.
617func (m *Manager) applyPointers(routes []registry.Route, keys []appKey) {
618 if m.d.Pointer == nil {
619 return
620 }
621 now := m.d.Now()
622 for _, k := range keys {
623 w := pointerWorkFor(m.d.Book, routes, k)
624 if w.empty() {
625 continue
626 }
627 m.mu.Lock()
628 due := now.Sub(m.pointerRead[k]) >= pointerReadEvery
629 if due {
630 m.pointerRead[k] = now
631 }
632 m.mu.Unlock()
633 if !due {
634 continue
635 }
636 // Each cleared record's build is requeued as a retry at once, before
637 // the app's next channel is read (Task 10 review minor 1).
638 _, unmarked := w.apply(m.ctx, m.d.Book, m.d.Pointer, routes, k, m.d.Logf, func(build string) { m.retryBuild(k, build, "") })
639 if unmarked {
640 m.changed()
641 }
642 }
643}
644
645// pointerWork is what one app's channel pointers could clear on this box
646// (applyPointers): its failure records, and its marked hosts whose route
647// installs the good build, by channel. An app with neither costs no read.
648type pointerWork struct {
649 recs []FailureRecord
650 rollback map[string][]registry.Route
651}
652
653func (w pointerWork) empty() bool { return len(w.recs) == 0 && len(w.rollback) == 0 }
654
655// pointerWorkFor is k's pointerWork against book and the box's routes.
656func pointerWorkFor(book *FailureBook, routes []registry.Route, k appKey) pointerWork {
657 w := pointerWork{recs: book.ForApp(k.acct, k.app), rollback: map[string][]registry.Route{}}
658 for _, rt := range routes {
659 if !Managed(rt) || release.RouteAccount(rt) != k.acct || rt.App != k.app || rt.GoodVersion == "" || rt.Version != rt.GoodVersion {
660 continue
661 }
662 if _, ok := holdRanFor(book, k.acct, k.app, rt.Host); ok {
663 ch := routeChannel(rt)
664 w.rollback[ch] = append(w.rollback[ch], rt)
665 }
666 }
667 return w
668}
669
670// apply reads the signed pointer of every channel k's managed routes follow
671// and applies applyPointers' two rules to the book: a record whose build a
672// pointer names with a newer PromotedAt is cleared (ClearOnPointer), and so
673// is the marker of a host whose channel's pointer names its installed good
674// build with a PromotedAt after the marker's At (ClearHoldRanOnPointer). A
675// read error is logged and that channel skipped; each read is bounded by
676// readBound (Task 11 rule 2). onCleared is called with each cleared record's
677// build as soon as the book has cleared it, before the next record or
678// channel, with no lock held: the armed pass requeues the build there, so
679// the retry is not left waiting on the rest of the app's reads (Task 10
680// review minor 1). It reports how many records it cleared and whether it
681// cleared a marker; what follows from a marker is the caller's.
682func (w pointerWork) apply(ctx context.Context, book *FailureBook, read PointerRead, routes []registry.Route, k appKey,
683 logf func(format string, args ...any), onCleared func(build string)) (cleared int, unmarked bool) {
684 channels := map[string]bool{}
685 for _, rt := range routes {
686 if Managed(rt) && release.RouteAccount(rt) == k.acct && rt.App == k.app {
687 channels[routeChannel(rt)] = true
688 }
689 }
690 for ch := range channels {
691 rctx, cancel := context.WithTimeout(ctx, readBound)
692 ptr, err := read(rctx, k.acct, k.app, ch)
693 cancel()
694 if err != nil {
695 logf("rollout: %s/%s channel %s: %v (retry not checked this pass)", k.acct, k.app, ch, err)
696 continue
697 }
698 if ptr == nil {
699 continue
700 }
701 for _, r := range w.recs {
702 if ptr.Version != r.Build {
703 continue
704 }
705 ok, err := book.ClearOnPointer(k.acct, k.app, r.Build, ptr.PromotedAt)
706 if err != nil {
707 logf("rollout: %s/%s clear record for %s: %v (cleared in memory)", k.acct, k.app, r.Build, err)
708 }
709 if ok {
710 cleared++
711 onCleared(r.Build)
712 }
713 }
714 for _, rt := range w.rollback[ch] {
715 if ptr.Version != rt.GoodVersion {
716 continue
717 }
718 ok, err := book.ClearHoldRanOnPointer(rt.Host, k.acct, k.app, ptr.PromotedAt)
719 if err != nil {
720 logf("rollout: %s: clear hold-ran marker on a rollback to %s: %v (cleared in memory)", rt.Host, rt.GoodVersion, err)
721 }
722 if ok {
723 logf("rollout: %s: a signed rollback to %s (pointer of %s) cleared its hold-ran marker", rt.Host, rt.GoodVersion, ptr.PromotedAt.UTC().Format(time.RFC3339))
724 unmarked = true
725 }
726 }
727 }
728 return cleared, unmarked
729}
730
731// PointerRead reads a channel's signed pointer, verified (release.Channel
732// with the box's store and root key): Deps.Pointer's shape.
733type PointerRead = func(ctx context.Context, acct, app, channel string) (*release.ChannelPointer, error)
734
735// PointerReadEvery is pointerReadEvery, for the pointer pass of a box with
736// no Manager (ApplyPointers), which paces itself.
737const PointerReadEvery = pointerReadEvery
738
739// ApplyPointers is applyPointers for a box with no Manager: one that
740// declined CARLOS_SWAP=overlap but honours the records and hold-ran markers
741// an armed run left (internal/host's failureHolds; Astra R2 ruling 194
742// "startup-only records", Task 10). Every app with a managed route in routes
743// and something its pointers could clear is read now, under the same two
744// rules on the same book; the caller paces the pass, at most every
745// PointerReadEvery. There is no scheduler to requeue a cleared build, so a
746// cleared record is only logged. It reports whether anything was cleared.
747func ApplyPointers(ctx context.Context, book *FailureBook, read PointerRead, routes []registry.Route, logf func(format string, args ...any)) bool {
748 changed := false
749 seen := map[appKey]bool{}
750 for _, rt := range routes {
751 k := appKey{release.RouteAccount(rt), rt.App}
752 if !Managed(rt) || seen[k] {
753 continue
754 }
755 seen[k] = true
756 w := pointerWorkFor(book, routes, k)
757 if w.empty() {
758 continue
759 }
760 cleared, unmarked := w.apply(ctx, book, read, routes, k, logf, func(build string) {
761 logf("rollout: %s/%s: a signed pointer re-promoted %s, so its failure record is cleared", k.acct, k.app, build)
762 })
763 changed = changed || cleared > 0 || unmarked
764 }
765 return changed
766}
767
768// retryBuild follows a cleared record: the next attempt onto build carries
769// cause retry and runs as a fresh canary. host == "" is an app-wide retry,
770// which reopens the halted cohort: its next attempt is a canary whatever
771// wantBuild reports, because unhalt starts the cohort again. A host names
772// `carlos instances retry`, whose attempt is that host's own canary;
773// RetryHost has already bound the host's retry flag, in the step that
774// cleared the book, and the flag is not set again here, where a forced
775// attempt may already have started and spent it.
776func (m *Manager) retryBuild(k appKey, build, host string) {
777 s := m.scheduler(k)
778 now := m.d.Now()
779 s.mu.Lock()
780 var hosts []string
781 for name, h := range s.q.hosts {
782 if h.wantBuild == build && (host == "" || name == host) {
783 hosts = append(hosts, name)
784 s.q.wantBuild(name, activator.CauseRetry, build, now)
785 }
786 }
787 if host == "" {
788 s.q.unhalt(build)
789 }
790 s.mu.Unlock()
791 m.mu.Lock()
792 for _, name := range hosts {
793 m.retried[name] = build
794 }
795 m.mu.Unlock()
796 m.d.Logf("rollout: %s/%s build %s retried (%v)", k.acct, k.app, build, hosts)
797 s.poke()
798 m.changed()
799}
800
801// RetryHost is the box half of `carlos instances retry <host>` (spec §8.4,
802// §11): a grant the console signed (release.HostRetryGrant), which the
803// caller has verified, naming the build whose restriction of host it lifts
804// and that restriction's epoch and book as the box last reported them
805// (plan review findings 5 and 13; final review round 1, finding 3). The
806// book clears build's record for host, when one holds it, and host's
807// hold-ran marker, when the marker names build, and only while the app's
808// restriction generation is still epoch, the book owes no save, and the
809// grant names this box's book (RetryHostAt; the crash-epoch hard limit in
810// FailureBook's doc): a grant from before any later change of the app's
811// restrictions, one replayed after it was spent, or one for another book's
812// restriction at the same value, clears nothing. True means either was
813// cleared.
814//
815// When the record was cleared and host's slot wants build, its next attempt
816// is a canary. The book entry is cleared and the host's retry flag bound in
817// one hold of the scheduler's lock (scheduler.mu before FailureBook.mu), the
818// book first. Admission cannot run between the two, so the adoption onto
819// the cleared build is the attempt the flag lets through, as a canary, and
820// a restart or config attempt cannot start in between and spend the flag.
821// The book also notes the retry's place in its sequence there (ruling 191):
822// an outcome of an execution that began before it is stale, and takes the
823// retry back no more than it halts the cohort (failCohort). No write is
824// added under the scheduler's lock: the book saves as it did before.
825//
826// A grant naming another build than the slot's (the marker's build B while
827// the slot wants A) clears B's restriction and leaves A's attempt alone; a
828// cleared record of B still binds the host's canary entitlement for B,
829// which the adoption onto B spends when the slot comes to want it, through
830// B's halt if one stands (final review round 1, finding 5).
831func (m *Manager) RetryHost(host, build, book string, epoch uint64) bool {
832 m.mu.Lock()
833 k, ok := m.hostApp[host]
834 m.mu.Unlock()
835 if !ok || build == "" {
836 return false
837 }
838 s := m.scheduler(k)
839 s.mu.Lock()
840 want := ""
841 if h := s.q.hosts[host]; h != nil {
842 want = h.wantBuild
843 }
844 cleared, unmarked, cur, err := m.d.Book.RetryHostAt(k.acct, k.app, build, host, book, epoch)
845 if cleared {
846 // The canary entitlement is build's whatever the slot wants now
847 // (final review round 1, finding 5): bound here, in the step that
848 // cleared the book, and spent only by the adoption onto build.
849 s.q.retryHost(host, build)
850 }
851 requeue := cleared && want == build
852 if requeue {
853 s.q.wantBuild(host, activator.CauseRetry, build, m.d.Now())
854 }
855 s.mu.Unlock()
856 if err != nil && (cleared || unmarked) {
857 m.d.Logf("rollout: retry %s on %s: %v (cleared in memory)", build, host, err)
858 } else if err != nil {
859 // The book owed a save and it failed again: nothing is cleared until
860 // one lands (the crash-epoch hard limit, rule 2).
861 m.d.Logf("rollout: retry %s on %s: the failure book cannot be saved (%v), so nothing was cleared", build, host, err)
862 } else if !cleared && !unmarked && cur != epoch {
863 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)
864 } else if !cleared && !unmarked && book != m.d.Book.ID() {
865 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)
866 }
867 if requeue {
868 m.retryBuild(k, build, host)
869 } else if cleared || unmarked {
870 m.changed()
871 }
872 return cleared || unmarked
873}
874
875// failedUnder is the PromotedAt a failure record keeps (spec rev 14 §8.4):
876// the pointer that named the build when it failed. If that pointer cannot be
877// read, or already names another build, it is zero, and the failure's own
878// time stands in when the record is written: only a pointer signed after the
879// failure can then clear the record, which is the safe direction.
880//
881// ctx is the caller's: the wake's in planRow, the attempt's in execute
882// (Task 11 rule 2, Astra R1 Flag triage T10). A cancelled wake no longer
883// waits on the store for its pointer, and no read waits past readBound.
884func (m *Manager) failedUnder(ctx context.Context, rt registry.Route, build string) time.Time {
885 if m.d.Pointer != nil {
886 ctx, cancel := context.WithTimeout(ctx, readBound)
887 defer cancel()
888 ptr, err := m.d.Pointer(ctx, release.RouteAccount(rt), rt.App, routeChannel(rt))
889 if err == nil && ptr != nil && ptr.Version == build {
890 return ptr.PromotedAt
891 }
892 }
893 return time.Time{}
894}
895
896// plannedWake is a planned first wake in flight (Manager.planned). token is
897// its plan's (WakePlan.Token): only an event carrying it ends the entry.
898type plannedWake struct {
899 acct, app, build string
900 seq uint64
901 token uint64
902}
903
904// newWakeToken starts a plan for host: a fresh token, recorded as the
905// host's newest. It is taken before the plan registers anything (Plan), so
906// the plan's registration and every event of its wake carry it.
907// It also returns the plan's time, the start of the wake's execution
908// (noteWakeStart).
909func (m *Manager) newWakeToken(host string) (uint64, time.Time) {
910 now := m.d.Now()
911 m.mu.Lock()
912 defer m.mu.Unlock()
913 m.tokenCount++
914 m.wakeTok[host] = m.tokenCount
915 return m.tokenCount, now
916}
917
918// newIncarnation draws a manager's incarnation (Manager.inc). crypto/rand
919// does not fail on the platforms the agent runs on; if it ever did, the
920// clock's nanoseconds still tell one process start from the next.
921func newIncarnation() string {
922 var b [8]byte
923 if _, err := rand.Read(b[:]); err != nil {
924 return strconv.FormatInt(time.Now().UnixNano(), 16)
925 }
926 return hex.EncodeToString(b[:])
927}
928
929// nextExecID is a scheduled attempt's execution identity (dispatch.ExecID):
930// "a<incarnation>-<n>", n from this manager's counter. The "a" is what
931// Begin's wake rule reads (wakeIntent).
932func (m *Manager) nextExecID() string {
933 m.mu.Lock()
934 defer m.mu.Unlock()
935 m.execCount++
936 return "a" + m.inc + "-" + strconv.FormatUint(m.execCount, 10)
937}
938
939// wakeExecID is a planned wake's execution identity (HoldRan.Exec):
940// "w<incarnation>-<token>". Zero, an event of no known plan, has none. The
941// incarnation is fixed for the manager's life, so every event of one wake
942// names the identity its plan's intent was written with.
943func (m *Manager) wakeExecID(tok uint64) string {
944 if tok == 0 {
945 return ""
946 }
947 return "w" + m.inc + "-" + strconv.FormatUint(tok, 10)
948}
949
950// wakeStart is a planned wake's start, by its token (Manager.wakeAt): its
951// plan time (the plan's start) and the failure book's snapshot taken at the
952// plan's end, after the planner's book reads (Plan).
953type wakeStart struct {
954 token uint64
955 at time.Time
956 snap uint64
957}
958
959// wakeStartsKept bounds Manager.wakeAt per host. Every planned wake ends
960// with a terminal event (Task 5), which drops its entry; a plan the
961// activator never ran (one Plan returned to a caller that then gave up)
962// leaves its entry until this many newer plans push it out; a live wake is
963// never that far behind its host's newest plan, because the activator waits
964// for a planned wake's outcome before it plans again (outcomeFenceWait).
965const wakeStartsKept = 4
966
967// noteWakeStart keeps the start of a planned wake that boots a build (tok):
968// a first wake's end can leave a hold-ran marker or a failure (OnGen), and
969// any wake's healthy event writes the good build, which clears only markers
970// older than its snapshot (Astra R3 N6). Any mode is kept: the activator
971// reports the mode the wake ran in, which the plan does not fix.
972func (m *Manager) noteWakeStart(host string, tok uint64, at time.Time, snap uint64, plan activator.WakePlan) {
973 if tok == 0 || plan.Refuse != "" || plan.Boot.StagedPath == "" {
974 return
975 }
976 m.mu.Lock()
977 defer m.mu.Unlock()
978 starts := append(m.wakeAt[host], wakeStart{token: tok, at: at, snap: snap})
979 if len(starts) > wakeStartsKept {
980 starts = slices.Delete(starts, 0, len(starts)-wakeStartsKept)
981 }
982 m.wakeAt[host] = starts
983}
984
985// wakeStarted is the start of the wake whose token is tok, and drops its
986// entry when ended (the wake's last event). A zero result means the start
987// is not known (a token pushed out by newer plans, or a host forgotten): the
988// book then stamps a marker with its own clock, later than the real start,
989// so only a pointer signed later still lifts it; the good write clears no
990// marker; and a failure is never taken as stale. Each is the safe
991// direction.
992func (m *Manager) wakeStarted(host string, tok uint64, ended bool) wakeStart {
993 m.mu.Lock()
994 defer m.mu.Unlock()
995 if tok == 0 {
996 return wakeStart{}
997 }
998 starts := m.wakeAt[host]
999 i := slices.IndexFunc(starts, func(w wakeStart) bool { return w.token == tok })
1000 if i < 0 {
1001 return wakeStart{}
1002 }
1003 w := starts[i]
1004 if ended {
1005 if starts = slices.Delete(starts, i, i+1); len(starts) == 0 {
1006 delete(m.wakeAt, host)
1007 } else {
1008 m.wakeAt[host] = starts
1009 }
1010 }
1011 return w
1012}
1013
1014// notePlanned records what host's newest plan boots: an entry for a first
1015// wake, none for anything else. Either way it replaces the host's previous
1016// plan, whose wake is over or is being planned again. A plan that is no
1017// longer the host's newest (tok) changes nothing: the newer plan owns the
1018// entry.
1019func (m *Manager) notePlanned(rt registry.Route, plan activator.WakePlan, tok uint64) {
1020 m.mu.Lock()
1021 defer m.mu.Unlock()
1022 if m.wakeTok[rt.Host] != tok {
1023 return
1024 }
1025 if !plan.FirstWake || plan.Boot.Version == "" || plan.Refuse != "" {
1026 delete(m.planned, rt.Host)
1027 return
1028 }
1029 m.plannedSeq++
1030 m.planned[rt.Host] = plannedWake{acct: release.RouteAccount(rt), app: rt.App, build: plan.Boot.Version,
1031 seq: m.plannedSeq, token: tok}
1032}
1033
1034// notePlanning registers rt's installed build as host's in-flight planned
1035// build while its plan (tok) is still being made (Plan, planRow), so a Pass
1036// during the planner's slow reads keeps the build's failure record. A
1037// plan's end (notePlanned) replaces or clears the entry, and a newer seq
1038// keeps a Pass that snapshotted the old one (dropUnownedWakes) from
1039// dropping it.
1040func (m *Manager) notePlanning(rt registry.Route, tok uint64) {
1041 m.mu.Lock()
1042 defer m.mu.Unlock()
1043 if m.wakeTok[rt.Host] != tok {
1044 return
1045 }
1046 if rt.Version == "" {
1047 delete(m.planned, rt.Host)
1048 return
1049 }
1050 m.plannedSeq++
1051 m.planned[rt.Host] = plannedWake{acct: release.RouteAccount(rt), app: rt.App, build: rt.Version,
1052 seq: m.plannedSeq, token: tok}
1053}
1054
1055// dropUnownedWakes forgets the planned wakes of hosts the activator no
1056// longer owns: a planned wake of an older activator, or one whose terminal
1057// event is still on its way, leaves its entry behind, and only the entry's
1058// own end may let the record go. Owns is asked with no lock held (the lock
1059// order on Manager), and an entry is dropped only if no newer plan replaced
1060// it meanwhile.
1061//
1062// It never ends a wake's intent (plan review #1): a failed wake's entry goes
1063// before its GenFailed is delivered (activator's wake, then afterWake), so
1064// Owns false is no proof the outcome is written, and ending the intent there
1065// could lose a failure across a failed save and a restart. The intent ends
1066// on the wake's own terminal event (OnGen); every planned wake sends one.
1067func (m *Manager) dropUnownedWakes() {
1068 m.mu.Lock()
1069 seen := make(map[string]uint64, len(m.planned))
1070 for host, w := range m.planned {
1071 seen[host] = w.seq
1072 }
1073 m.mu.Unlock()
1074 gone := map[string]uint64{}
1075 for host, seq := range seen {
1076 if !m.d.Act.Owns(host) {
1077 gone[host] = seq
1078 }
1079 }
1080 if len(gone) == 0 {
1081 return
1082 }
1083 m.mu.Lock()
1084 for host, seq := range gone {
1085 if w, ok := m.planned[host]; ok && w.seq == seq {
1086 delete(m.planned, host)
1087 }
1088 }
1089 m.mu.Unlock()
1090}
1091
1092// prune drops failure records for builds no route installs. A held-down
1093// instance's route still names its failed build, so its record stays (spec
1094// §8.4). Every route counts, whatever its status or shape: a row passing
1095// through "draining", "moving" or "landing" for one Pass, or one the
1096// scheduler does not manage, still names its build, and dropping the record
1097// then would lift the halt and let a later wake boot the failed hold build
1098// against its database. Pruning errs towards keeping. An empty route list is
1099// never authority to drop anything: registry.Open creates an empty database
1100// on a wrong path. A build a planned first wake still carries counts as
1101// installed (Manager.planned): the wake was planned before the install moved
1102// off it, and its exec may yet boot it. The installed callback runs under the
1103// book's lock and reads only this map, built first.
1104//
1105// The pass also retries a save the book owes (the crash-epoch hard limit,
1106// rule 6; SaveIfDirty): while one is owed status publishes no generation
1107// and no retry grant clears anything, so a box whose disk recovered must
1108// not wait for another change, pointer or grant to save. Prune retries it
1109// when it runs; a pass with no routes still calls SaveIfDirty, and so does
1110// one whose Prune landed, for a save owed since.
1111func (m *Manager) prune(routes []registry.Route) {
1112 if err := m.pruneBook(routes); err != nil {
1113 m.d.Logf("rollout: prune failure records: %v", err)
1114 } else if err := m.d.Book.SaveIfDirty(); err != nil {
1115 m.d.Logf("rollout: save the failure book: %v", err)
1116 }
1117}
1118
1119// pruneBook is prune's Prune call, nil when there are no routes to judge by.
1120func (m *Manager) pruneBook(routes []registry.Route) error {
1121 if len(routes) == 0 {
1122 return nil
1123 }
1124 installed := map[[3]string]bool{}
1125 for _, rt := range routes {
1126 if rt.Version != "" {
1127 installed[[3]string{release.RouteAccount(rt), rt.App, rt.Version}] = true
1128 }
1129 }
1130 m.mu.Lock()
1131 for _, w := range m.planned {
1132 installed[[3]string{w.acct, w.app, w.build}] = true
1133 }
1134 m.mu.Unlock()
1135 return m.d.Book.Prune(func(acct, app, build string) bool { return installed[[3]string{acct, app, build}] })
1136}
1137
1138// liftClearedHalts reopens every halted adoption cohort whose failure record
1139// no longer stands (spec §8.2, §8.4: a newer build, a retry or an install by
1140// hand lifts the hold). A halt lives in memory beside its record, so a record
1141// pruned after an install by hand would otherwise leave the build refused for
1142// good if it were installed again. failCohort writes the record and the halt
1143// under the same scheduler lock this reads them under. In the same hold it
1144// drops every retry entitlement the book no longer lists (final review round
1145// 2, finding 5): RetryHost binds one in the step that lists the host, so an
1146// entitlement and its listing go together.
1147func (m *Manager) liftClearedHalts() {
1148 m.mu.Lock()
1149 scheds := make([]*scheduler, 0, len(m.apps))
1150 for _, s := range m.apps {
1151 scheds = append(scheds, s)
1152 }
1153 m.mu.Unlock()
1154 for _, s := range scheds {
1155 var lifted []string
1156 s.mu.Lock()
1157 for key, c := range s.q.cohorts {
1158 build, adopt := strings.CutPrefix(key, "adopt:")
1159 if !adopt || !c.halted {
1160 continue
1161 }
1162 if _, held := m.d.Book.Lookup(s.key.acct, s.key.app, build); !held {
1163 s.q.unhalt(build)
1164 lifted = append(lifted, build)
1165 }
1166 }
1167 s.q.dropRetries(func(build, host string) bool {
1168 return m.d.Book.Retried(s.key.acct, s.key.app, build, host)
1169 })
1170 s.mu.Unlock()
1171 if len(lifted) > 0 {
1172 m.d.Logf("rollout: %s/%s: halt lifted for %v, their failure records are gone", s.key.acct, s.key.app, lifted)
1173 s.poke()
1174 m.changed()
1175 }
1176 }
1177}
1178
1179// Bootstrap is spec §8.5's first start: every awake instance records the
1180// build it serves as good. Nothing is inferred from Version or PrevVersion.
1181// No execution of this agent started the generation, so the write has no
1182// book snapshot and clears no hold-ran marker (Astra R3 N6).
1183func (m *Manager) Bootstrap(routes []registry.Route) {
1184 for _, rt := range routes {
1185 if !Managed(rt) || rt.GoodVersion != "" {
1186 continue
1187 }
1188 if g, ok := m.d.Act.Current(rt.Host); ok && g.Build != "" {
1189 m.writeGood(rt.Host, g, 0)
1190 }
1191 }
1192}
1193
1194// Restart is a `carlos restart` request for host (spec §10). id is the
1195// request's identity: the restart generation's etag, or a narrowed request's
1196// id. A sleeping instance is never woken. One that is waking, stopping or
1197// inside its first-wake observation keeps the request, which runs once it
1198// is stable (spec §6.2).
1199func (m *Manager) Restart(host, id string) bool {
1200 s := m.schedulerOf(host)
1201 if s == nil {
1202 return false
1203 }
1204 if !m.d.Act.Owns(host) {
1205 return false
1206 }
1207 s.mu.Lock()
1208 changed := s.q.wantRestart(host, id, m.d.Now())
1209 s.mu.Unlock()
1210 if changed {
1211 s.poke()
1212 m.changed()
1213 }
1214 return changed
1215}
1216
1217// ConfigDelivered is config sync's report that host's config file now holds
1218// revision rev (spec §8.1). An instance with a process (awake, waking or
1219// stopping) gets a pending config attempt; if its wake turns out to have read
1220// rev, the wake's activation clears the attempt before it runs. A sleeping
1221// one needs none, because its next wake starts with the file: rev counts as
1222// what that wake will run, and config sync's ack goes out at once with
1223// today's hibernating acknowledgement (configTrack.deferAck). A host this
1224// manager does not manage is not tracked, so its ack goes out as before.
1225func (m *Manager) ConfigDelivered(host, rev string) {
1226 s := m.schedulerOf(host)
1227 if s == nil {
1228 return
1229 }
1230 // Asked before m.mu: the activator is never called under the manager's
1231 // lock (the lock order on Manager).
1232 awake := m.d.Act.Owns(host)
1233 m.mu.Lock()
1234 t := m.track(host)
1235 t.delivered, t.fails, t.lastErr = rev, 0, ""
1236 t.sleeping = !awake
1237 if !awake {
1238 t.active = rev
1239 }
1240 m.mu.Unlock()
1241 if !awake {
1242 // A sleeper needs no attempt, including one still pending for an
1243 // older revision it was awake for: its next wake starts with this
1244 // file. Left in place, that attempt would restart a generation
1245 // already running rev and then mark the older revision active.
1246 s.mu.Lock()
1247 s.q.dropConfig(host)
1248 s.mu.Unlock()
1249 return
1250 }
1251 s.mu.Lock()
1252 changed := s.q.wantConfig(host, rev, m.d.Now())
1253 s.mu.Unlock()
1254 if changed {
1255 s.poke()
1256 m.changed() // the revision is delivered and not yet active, which Status shows
1257 }
1258}
1259
1260// DeferAck wraps config sync's ack writer: a revision is acknowledged only
1261// once it is active (spec §8.1). Every ack, one written at once included,
1262// goes through the manager's writer (Manager.acks), numbered here under m.mu
1263// with the transitions that release held ones: written in line, an ack at
1264// delivery could land before an older held ack the writer still has, and be
1265// overwritten by it (plan review #10). write is handed a context bounded by
1266// ackWriteBound.
1267func (m *Manager) DeferAck(write func(context.Context, appconfig.ConfigAck)) func(appconfig.ConfigAck) {
1268 return func(a appconfig.ConfigAck) {
1269 m.mu.Lock()
1270 now := m.ackLocked(m.track(a.Host).deferAck(a, write))
1271 m.mu.Unlock()
1272 m.submitAck(a.Host, now)
1273 }
1274}
1275
1276// ackLocked numbers an ack a config track handed back (nil when none). The
1277// caller holds m.mu and is in the critical section of the transition that
1278// released it, so the numbers follow the order of the transitions, which
1279// the writer keeps per host (plan review #10).
1280func (m *Manager) ackLocked(write func(context.Context)) ackJob {
1281 if write == nil {
1282 return ackJob{}
1283 }
1284 m.ackSeq++
1285 return ackJob{seq: m.ackSeq, write: write}
1286}
1287
1288// submitAck hands an ack to the writer; it never blocks.
1289func (m *Manager) submitAck(host string, j ackJob) {
1290 if j.write != nil {
1291 m.acks.submit(host, j)
1292 }
1293}
1294
1295// resolve turns a dispatch into the Attempt Swap runs: its artifact and mode.
1296// Recovery onto the serving build uses the generation's captured identity,
1297// and a move back onto the good build uses the retained one; neither is
1298// checked against the canonical manifest (spec §4.2). An adoption checks the
1299// staged file exists and then the canonical demand. A missing staged file,
1300// or a staged file that is not the release's canonical artifact
1301// (release.ErrStagedDigestMismatch), is a staging refusal, never a build
1302// failure. Any other trouble reading the demand runs the attempt in hold:
1303// missing or unverifiable policy data never permits an overlap.
1304func (m *Manager) resolve(ctx context.Context, d dispatch, rt registry.Route) (activator.Attempt, error) {
1305 acct, app := release.RouteAccount(rt), rt.App
1306 policy := m.policies.get(ctx, acct, app)
1307 a := activator.Attempt{Host: d.ID.Host, Cause: d.ID.Cause, Build: d.ID.Build, ConfigRev: d.ID.ConfigRev,
1308 RestartID: d.ID.RestartID, SchedRev: d.SchedRev, Canary: d.Canary}
1309 if g, ok := m.d.Act.Current(rt.Host); ok && g.Build == d.ID.Build {
1310 a.StagedPath, a.Digest, a.Demand = g.StagedPath, g.Digest, g.Demand
1311 a.Mode = RecoveryMode(policy, g.Demand)
1312 return a, nil
1313 }
1314 path := release.VersionPath(m.d.OptDir, acct, d.ID.Build, app)
1315 if d.ID.Build == rt.GoodVersion && rt.GoodDigest != "" {
1316 a.StagedPath, a.Digest, a.Demand = path, rt.GoodDigest, rt.GoodDemand
1317 a.Mode = RecoveryMode(policy, rt.GoodDemand)
1318 return a, nil
1319 }
1320 demand, digest, err := m.stagedDemand(ctx, rt, d.ID.Build, path)
1321 if err != nil {
1322 return a, err
1323 }
1324 a.StagedPath, a.Digest, a.Demand = path, digest, demand
1325 a.Mode = AttemptMode(policy, demand)
1326 return a, nil
1327}
1328
1329// stagedDemand is the canonical demand and digest of build's staged file at
1330// path, for an adoption attempt (resolve) and a first wake (Plan) alike
1331// (spec §4.2). A missing staged file, or one that is not the release's
1332// canonical artifact (release.ErrStagedDigestMismatch), is an error: a
1333// staging refusal, never a build failure. Any other trouble reading the
1334// demand is hold, on the staged bytes hashed here.
1335func (m *Manager) stagedDemand(ctx context.Context, rt registry.Route, build, path string) (demand, digest string, err error) {
1336 if _, err := os.Stat(path); err != nil {
1337 return "", "", err
1338 }
1339 // Bounded (Task 11 rule 2): a demand read that times out is a read
1340 // error, so the attempt or the wake runs in hold, as below.
1341 rctx, cancel := context.WithTimeout(ctx, readBound)
1342 demand, digest, err = m.d.Demand(rctx, release.RouteAccount(rt), rt.App, build, path)
1343 cancel()
1344 if errors.Is(err, release.ErrStagedDigestMismatch) {
1345 return "", "", err
1346 }
1347 if err != nil {
1348 // The canonical manifest could not be read or compared: hold, on
1349 // the bytes hashed below.
1350 m.d.Logf("rollout: %s: canonical demand of %s: %v (runs in hold)", rt.Host, build, err)
1351 demand, digest = release.DemandHold, ""
1352 }
1353 if digest == "" {
1354 // Nothing compared the staged bytes with the manifest (plan 4:
1355 // DemandHold with no digest), so the demand read describes nothing
1356 // verified: hold. The build still runs only on the bytes hashed
1357 // here, because plan 3's verifyStaged refuses an empty digest.
1358 demand = release.DemandHold
1359 if digest, err = release.FileSHA256(path); err != nil {
1360 return "", "", err
1361 }
1362 }
1363 return demand, digest, nil
1364}
1365
1366// served records a generation that became current after its checks: the
1367// serving build, and the good build (spec §8.5). Both only while g is the
1368// generation the activator serves (acknowledged). snap is the book's
1369// snapshot from the start of the execution that produced g (writeGoodLocked).
1370func (m *Manager) served(host string, g activator.Gen, snap uint64) {
1371 if g.Build == "" {
1372 return
1373 }
1374 m.goodMu.Lock()
1375 defer m.goodMu.Unlock()
1376 if !m.acknowledged(host, g) {
1377 m.d.Logf("rollout: %s: generation %d (%s) is no longer served; not recording it", host, g.N, g.Build)
1378 return
1379 }
1380 m.setServingLocked(host, g)
1381 m.writeGoodLocked(host, g, snap)
1382}
1383
1384// servedOneShot records the good build of a replacement whose build exited
1385// cleanly as a one-shot (spec §6.5, §8.5; Astra R3 flag triage T9). Nothing
1386// of it keeps running, so the activator has no current generation for the
1387// host to acknowledge, and served would drop the write. It skips only the
1388// acknowledged check: done() calls it before the attempt's slot is freed,
1389// so no newer attempt for the host can have been admitted, let alone
1390// completed, and the result is the host's newest outcome. Everything else
1391// is served's: goodMu, the generation's identity, and the book snapshot of
1392// the execution's start (snap), so only a hold-ran marker older than the
1393// attempt is cleared. A one-shot has no serving build to publish, and a
1394// worker's is the activator's alone (setServingLocked).
1395func (m *Manager) servedOneShot(host string, g activator.Gen, snap uint64) {
1396 if g.Build == "" {
1397 return
1398 }
1399 m.goodMu.Lock()
1400 defer m.goodMu.Unlock()
1401 m.writeGoodLocked(host, g, snap)
1402}
1403
1404// setServingLocked writes an instance's serving build. A worker's is the
1405// activator's alone (Task 11, publishWorkerServing, ordered by incarnation),
1406// so two writers never race on one row. The caller holds m.goodMu.
1407func (m *Manager) setServingLocked(host string, g activator.Gen) {
1408 if g.Worker {
1409 return
1410 }
1411 if err := m.d.Routes.SetServingVersion(host, g.Build); err != nil {
1412 m.d.Logf("rollout: %s: record serving build %s: %v", host, g.Build, err)
1413 }
1414}
1415
1416// publishServing records the serving build alone (a healthy first wake, a
1417// fallback), on the same terms as served.
1418func (m *Manager) publishServing(host string, g activator.Gen) {
1419 if g.Build == "" {
1420 return
1421 }
1422 m.goodMu.Lock()
1423 defer m.goodMu.Unlock()
1424 if !m.acknowledged(host, g) {
1425 return
1426 }
1427 m.setServingLocked(host, g)
1428}
1429
1430// acknowledged reports whether g is the generation the activator serves
1431// for host right now (spec §8.5: the good build is written expecting the
1432// current one). A late completion or event of an older generation is not,
1433// so it can never overwrite what a newer one wrote. Generation numbers
1434// decide when both sides have one; a worker's (no number) is compared by
1435// its staged artifact.
1436func (m *Manager) acknowledged(host string, g activator.Gen) bool {
1437 cur, ok := m.d.Act.Current(host)
1438 if !ok {
1439 return false
1440 }
1441 if cur.N != 0 && g.N != 0 {
1442 return cur.N == g.N
1443 }
1444 return cur.StagedPath == g.StagedPath && cur.Digest == g.Digest
1445}
1446
1447// writeGood is writeGoodLocked for a caller outside served: Bootstrap.
1448// Same terms: g must be acknowledged.
1449func (m *Manager) writeGood(host string, g activator.Gen, snap uint64) {
1450 m.goodMu.Lock()
1451 defer m.goodMu.Unlock()
1452 if !m.acknowledged(host, g) {
1453 return
1454 }
1455 m.writeGoodLocked(host, g, snap)
1456}
1457
1458// writeGoodLocked replaces host's good build with g's identity,
1459// conditionally on the row still holding what was just read. goodMu
1460// serialises every writer and each has just proven g current, so a retry
1461// only covers a write from outside this manager; a second loss is logged
1462// and the next serve tries again. The caller holds m.goodMu.
1463//
1464// A good build recorded (written, or found already written) clears the
1465// host's hold-ran marker (final review wave 2, N2): the instance passed on
1466// g, so g is what a later wake may fall back to. Only a marker set before
1467// g's execution began (snap, Astra R3 N6): the write can be delayed after
1468// its acknowledgement check, and a hold build that ran on the host
1469// meanwhile (an attempt done() records without goodMu) left a marker the
1470// instance has not passed over.
1471func (m *Manager) writeGoodLocked(host string, g activator.Gen, snap uint64) {
1472 if g.Build == "" || g.Digest == "" {
1473 return
1474 }
1475 next := registry.GoodBuild{Version: g.Build, Digest: g.Digest, Demand: g.Demand}
1476 for range 2 {
1477 cur, err := m.d.Routes.Get(host)
1478 if err != nil || cur == nil {
1479 return
1480 }
1481 if cur.Good() == next {
1482 m.goodRecorded(host, next.Version, snap)
1483 return
1484 }
1485 err = m.d.Routes.SetGoodBuild(host, cur.Good(), next)
1486 if err == nil {
1487 m.goodRecorded(host, next.Version, snap)
1488 return
1489 }
1490 if !errors.Is(err, registry.ErrGoodBuildChanged) {
1491 m.d.Logf("rollout: %s: record good build %s: %v", host, g.Build, err)
1492 return
1493 }
1494 }
1495 m.d.Logf("rollout: %s: good build moved twice under this write, leaving it", host)
1496}
1497
1498// goodRecorded follows a good-build write that landed (writeGoodLocked):
1499// a hold-ran marker older than the write's execution (snap) goes, and the
1500// queue learns the build.
1501func (m *Manager) goodRecorded(host, ver string, snap uint64) {
1502 if _, err := m.d.Book.ClearHoldRanBefore(host, snap); err != nil {
1503 m.d.Logf("rollout: %s: clear hold-ran marker after good build %s: %v (cleared in memory)", host, ver, err)
1504 }
1505 m.observeGood(host, ver)
1506}
1507
1508func (m *Manager) observeGood(host, ver string) {
1509 s := m.schedulerOf(host)
1510 if s == nil {
1511 return
1512 }
1513 s.mu.Lock()
1514 if h := s.q.hosts[host]; h != nil {
1515 h.good = ver
1516 }
1517 s.mu.Unlock()
1518}
1519
1520func (m *Manager) configActivated(host, rev string) {
1521 if rev == "" {
1522 return
1523 }
1524 m.mu.Lock()
1525 write, _ := m.track(host).activate(rev)
1526 ack := m.ackLocked(write)
1527 m.mu.Unlock()
1528 m.activationDone(host, rev, ack)
1529}
1530
1531// activationDone is the rest of an activation of rev, after the config
1532// track has recorded it under m.mu: the queue's pending config attempt for
1533// rev is cleared, and the held ack (ack, empty when none), numbered in that
1534// critical section, is handed to the writer. Called with no lock held. The
1535// ack is not written here (Task 11 rule 3, Astra R1 Flag triage T8): done()
1536// calls this before the attempt's slot frees, and configSlept from the
1537// scheduler loop, and neither may wait on the store.
1538func (m *Manager) activationDone(host, rev string, ack ackJob) {
1539 if s := m.schedulerOf(host); s != nil {
1540 s.mu.Lock()
1541 s.q.configActive(host, rev)
1542 s.mu.Unlock()
1543 }
1544 m.submitAck(host, ack)
1545}
1546
1547// configSlept is configActivated for an instance seen asleep with rev
1548// pending (spec §8.1): its next wake starts with the delivered file, so rev
1549// is what that wake runs and no config attempt is left to make. A held ack
1550// goes out as a sleeper's does at delivery, marked Hibernating. rev was read
1551// before the activator reported the instance asleep, so the file already
1552// held it when the instance stopped: this can only under-claim.
1553func (m *Manager) configSlept(host, rev string) {
1554 m.mu.Lock()
1555 t := m.track(host)
1556 if t.ack != nil && t.ackRev == rev {
1557 t.ack.Hibernating, t.ack.Restarted = true, false
1558 }
1559 write, _ := t.activate(rev)
1560 ack := m.ackLocked(write)
1561 m.mu.Unlock()
1562 m.activationDone(host, rev, ack)
1563}
1564
1565// configFailure counts a failed attempt that carried config revision rev
1566// and returns its backoff. Only a failure of the delivered revision counts:
1567// one of an older revision says nothing about the newer one's attempts. The
1568// caller sets the queue's backoff (appQueue.configFailed) in the step that
1569// frees the attempt's slot.
1570func (m *Manager) configFailure(host, rev, reason string) (time.Duration, bool) {
1571 m.mu.Lock()
1572 defer m.mu.Unlock()
1573 t := m.track(host)
1574 if rev == "" || rev != t.delivered {
1575 return 0, false
1576 }
1577 t.fails++
1578 t.lastErr = reason
1579 return configBackoff(t.fails), true
1580}
1581
1582// cohortFailed runs the cohort-failure transition for s's app. A failure of
1583// an execution that began before a retry of its build runs none of it
1584// (PreRetry, ruling 191); a hold-down it asked for is a hold-ran marker
1585// instead (FailureBook.Record), whose rollback read is then due.
1586func (m *Manager) cohortFailed(s *scheduler, rt registry.Route, rec FailureRecord) {
1587 s.mu.Lock()
1588 res, superseded, err := failCohort(m.d.Book, s.q, rec)
1589 s.mu.Unlock()
1590 if err != nil {
1591 m.d.Logf("rollout: write failure record %s/%s %s: %v (held in memory)", rec.Account, rec.App, rec.Build, err)
1592 }
1593 if res == PreRetry {
1594 m.d.Logf("rollout: %s/%s build %s failed on %s, but in an execution that began before a retry of the build: the retry stands", rec.Account, rec.App, rec.Build, rec.Host)
1595 if slices.Contains(rec.HeldDown, rec.Host) {
1596 m.pointerReadDue(s.key)
1597 }
1598 s.poke()
1599 return
1600 }
1601 if len(superseded) > 0 {
1602 m.d.Logf("rollout: %s/%s build %s failed: superseding attempts past spawn on %v", rec.Account, rec.App, rec.Build, superseded)
1603 }
1604 if res == Recorded {
1605 m.d.Logf("rollout: %s/%s build %s failed on %s: %s", rec.Account, rec.App, rec.Build, rec.Host, rec.Reason)
1606 m.emit(rt, EvCohortHalted, "warn", textCohortHalted(rec.Build, m.d.Box, reasonWord(rec.Reason)))
1607 }
1608 s.poke()
1609}
1610
1611func (m *Manager) stagingRefused(rt registry.Route, build string, err error) {
1612 m.d.Logf("rollout: %s: build %s refused as a staging error: %v", rt.Host, build, err)
1613 m.emit(rt, EvStagingRefused, "warn", textStagingRefused(build))
1614 if m.d.Touch != nil {
1615 m.d.Touch()
1616 }
1617}
1618
1619// emit logs an event and publishes it to the member's log. text is built
1620// from build names and reason words only; RedactPaths stays as a second
1621// guard against a box path in a build name.
1622func (m *Manager) emit(rt registry.Route, code EventCode, level, text string) {
1623 m.d.Logf("rollout: %s %s: %s", code, rt.Host, text)
1624 if m.d.Events != nil {
1625 m.d.Events.Event(release.RouteAccount(rt), rt.App, rt.Host, level, activator.RedactPaths(text))
1626 }
1627}
1628