carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// swap.go is the overlap and hold swap (zero-downtime deploys spec §7.4,
2// §7.5, §7.8): one attempt moves one awake instance to a new generation.
3// Nothing calls Service.Swap until plan 5 wires CARLOS_SWAP. With plan 1's
4// generations switch off, it refuses every attempt and spawns nothing.
5package activator
6
7import (
8 "context"
9 "errors"
10 "fmt"
11 "log"
12 "time"
13
14 "github.com/carlosframework/platform/internal/cgroup"
15 "github.com/carlosframework/platform/internal/hostbox"
16 "github.com/carlosframework/platform/internal/hostguard"
17 "github.com/carlosframework/platform/internal/hostwrite"
18 "github.com/carlosframework/platform/internal/instancectl"
19 "github.com/carlosframework/platform/internal/registry"
20)
21
22// Cause is why an attempt exists (spec §6.3).
23type Cause string
24
25const (
26 CauseRelease Cause = "release"
27 CauseInstall Cause = "install"
28 CauseRetry Cause = "retry"
29 CauseRestart Cause = "restart"
30 CauseConfig Cause = "config"
31)
32
33// SwapMode is how an awake instance moves to a new generation (spec §4.1).
34type SwapMode string
35
36const (
37 ModeOverlap SwapMode = "overlap"
38 ModeHold SwapMode = "hold"
39)
40
41// Attempt is one try at moving one instance to a new generation (spec §6.3).
42type Attempt struct {
43 Host string
44 Cause Cause
45 Build string
46 StagedPath string
47 Digest string // expected sha256 of StagedPath, verified before exec
48 Demand string // release demand for the artifact: "hold" or ""
49 Mode SwapMode
50 ConfigRev string
51 RestartID string
52 SchedRev uint64 // scheduler revision; the newest-attempt check compares it
53 Canary bool // observe the 10 s settle
54}
55
56// SwapOutcome is how an attempt ended.
57type SwapOutcome string
58
59const (
60 SwapDone SwapOutcome = "done" // switched (overlap) or replaced (hold)
61 SwapFailed SwapOutcome = "failed" // successor failed health or settle; incumbent still serving (overlap)
62 SwapAbandoned SwapOutcome = "abandoned" // superseded or a re-check failed; not a build failure
63 SwapDown SwapOutcome = "down" // hold mode failed: nothing serving
64)
65
66// SwapResult is Swap's answer. Reason is diagnostic text for the
67// scheduler (plan 5); member wording is plan 6's.
68type SwapResult struct {
69 Outcome SwapOutcome
70 Gen Gen // the generation now current (on SwapDone)
71 Reason string
72 // Drain is how long the retiring generation took from SIGTERM to its
73 // proven exit (an overlap), or the old generation's stop (a hold). Zero
74 // when nothing retired. The "swapped" event reports it (spec §9.4).
75 Drain time.Duration
76 // Mode is the mode the attempt actually ran in. It differs from
77 // Attempt.Mode when an overlap attempt on a foreign-layout row ran as a
78 // hold (spec §2.9): the scheduler records a failure under this mode, so
79 // a failed hold is held down (spec §6.4, §8.3). Swap sets it.
80 Mode SwapMode
81 // Executed is the attempt's execution evidence (final review F8), one
82 // side of a tri-state (final review wave 2, N4): false means it is
83 // PROVEN that nothing of the attempt's build ran; true means it ran OR
84 // it is not known whether it ran. True once a process of the build
85 // started, whatever happened to it after (an overlap successor, a
86 // hold's pinned wake, a worker's respawn child, installed or shed), and
87 // also when a start was handed off and failed with its outcome
88 // uncertain (startMayHaveRun) or when ReplaceWorker could not learn
89 // what its respawn did. Only false lets an abandoned attempt count as
90 // having spent nothing of what it was granted: the scheduler re-arms a
91 // `carlos instances retry` allowance on it. Swap and ReplaceWorker set
92 // it; every other result leaves it false, and each of those returns
93 // before anything of the build could start.
94 Executed bool
95 // CleanExit is set by ReplaceWorker on a SwapDone whose replacement's
96 // build ran to a clean exit inside the settle window: a one-shot's
97 // success (spec §6.5). It has no generation the activator will go on
98 // serving, so nothing acknowledges it afterwards; the scheduler records
99 // it as the good build from this result (spec §8.5, Astra R3 flag
100 // triage T9). A replacement still running at the end of the window is
101 // SwapDone with CleanExit false.
102 CleanExit bool
103}
104
105var (
106 errSwapOff = errors.New("swap: off on this box")
107 errSwapBusy = errors.New("swap: another generation operation is in flight")
108 errSwapKind = errors.New("swap: only exec instances overlap; workers and sidecars keep stop-then-start")
109 errSuccessorExited = errors.New("swap: successor exited before the switch")
110 errSwapMode = errors.New("swap: unknown mode")
111)
112
113func abandon(err error) SwapResult { return SwapResult{Outcome: SwapAbandoned, Reason: err.Error()} }
114func failSwap(err error) SwapResult { return SwapResult{Outcome: SwapFailed, Reason: err.Error()} }
115
116// stepOutcome is how one step of an attempt (a spawn, a health check, a
117// readiness or settle window, a pinned wake) ended.
118type stepOutcome int
119
120const (
121 stepOK stepOutcome = iota
122 stepCancelled // the attempt's lifecycle ended it: its context, the barrier, a fence, an intentional stop
123 stepStale // the attempt gate refused it: a newer attempt, a halted cohort, a lost lease, shutdown
124 stepFailed // the build failed: spawn, health or readiness
125)
126
127// classifyStep is THE outcome classifier (spec §6.4, §7.4): every path
128// that might record a build failure asks it first, the swaps here and plan
129// 5's planned wakes alike. Only stepFailed is a failure of the build; a
130// cancelled or stale step records nothing and falls back to nothing.
131// lifecycle is the attempt's own context (nil when the caller has none). A
132// health deadline that expires is the build's failure (DeadlineExceeded);
133// the lifecycle's own end, or a cancellation, is not.
134func classifyStep(lifecycle context.Context, err error) stepOutcome {
135 switch {
136 case err == nil:
137 return stepOK
138 case lifecycle != nil && lifecycle.Err() != nil,
139 errors.Is(err, context.Canceled), errors.Is(err, errSwapStopped), errors.Is(err, ErrFenced):
140 return stepCancelled
141 case verdictOf(err) != gateProceed:
142 // The attempt gate's own refusal, wherever it surfaced: at Step 3a,
143 // or as a claims validation inside the work at the exec. Nothing of
144 // the build ran because of it (spec §7.4).
145 return stepStale
146 case errors.Is(err, ErrBuildNotPublished), errors.Is(err, errStagedBuildNotPublished),
147 errors.Is(err, errBuildStateUnwritten), errors.Is(err, errStaleLink):
148 // A spawn refused because its build could not be published before
149 // the exec (ruling R19 item 3; the worker and the staged instance
150 // sentinels, final review F9): nothing of the build ran, so it is
151 // abandonment, never a failure of the build. Classified here and not
152 // in verdictOf, which main's flag-off respawn reads; every caller of
153 // classifyStep is flag-on (swaps, holds, planned wakes).
154 // errBuildStateUnwritten (settings spec §4.2) is the same kind of
155 // refusal; verdictOf also names it, so it is stale through either
156 // arm, and named here beside its kin. errStaleLink (settings spec
157 // §4.3 rule 3) is a held wake refusing, before its record and its
158 // exec, an identity that is not its target: a planned wake's boot
159 // never ran either.
160 return stepStale
161 }
162 return stepFailed
163}
164
165// gateVerdict is the attempt gate's answer, typed (spec §6.3, §7.4).
166type gateVerdict int
167
168const (
169 // gateProceed: the attempt is current and owns the host.
170 gateProceed gateVerdict = iota
171 // gateSuperseded: still the owner, but a newer attempt or a halted
172 // cohort replaced this one. A restore of what it replaced may run, and
173 // only behind a fresh ownership check at that exec.
174 gateSuperseded
175 // gateNoOwnership: the entry is not current, the lease is lost, a fence
176 // or the agent's shutdown holds the host, or the claims validation
177 // refused (a changed stamp, a durable fence, a changed claim). Nothing
178 // may spawn, a restore included.
179 gateNoOwnership
180)
181
182// verdictOf types an attempt-gate error. An error that is not the gate's
183// (a failure of the build) is gateProceed: the gate did not refuse.
184//
185// Besides the gate's own refusals, eight kinds of refusal that stop a spawn
186// before anything of the build runs are NoOwnership, so the one classifier
187// reads them as abandonment, never as a failed build:
188// - plan 2's strict-host gate on placed spawns (index P2-8):
189// ErrStrictRequired, ErrStrictGroupInUse, ErrStrictCleanupPending;
190// - a closed claims-on launch (index P2-9): ErrClaimIsolationUnavailable.
191// It wraps hostbox.ErrUnsupported, which is not otherwise classified,
192// so it is named here;
193// - a row that cannot run a staged generation at all: errUnitNoGens,
194// errSwapNoGens;
195// - the cgroup manager refusing a generation start because the host's
196// parent cgroup holds a process directly (cgroup.ErrLegacyParentPopulated,
197// amadan #272): it checks before it creates the child or execs, and the
198// condition is the box's, not the build's;
199// - the cgroup manager refusing a start because the cgroup it would spawn
200// into took cgroup.kill and could not be made fresh
201// (cgroup.ErrKilledCgroupKept, amadan #272, Task 19): it refuses before
202// it builds the command, so nothing ran, and the condition is the box's;
203// - the cgroup manager refusing a generation start because a limit it
204// would run under could not be applied, or it could only be started
205// outside its cgroup and moved after (cgroup.ErrLimitNotApplied, zdd
206// readiness round 2 Task 7): it refuses before it builds the command
207// (or, for a failed placement, the shim proved it never reached exec),
208// so nothing ran, and the condition is the box's;
209// - root refusing the nested staged executable (instancectl.ErrStagedExe,
210// Task 9): it validates before it mounts or starts anything;
211// - a build state that could not be written before the exec
212// (errBuildStateUnwritten, settings spec §4.2). Unlike
213// ErrBuildNotPublished it is a new sentinel, so naming it here changes
214// nothing main did: a worker respawn it refuses logs a refusal, not
215// "failed to respawn" and a member error event, and the sweep asks
216// again; a ReplaceWorker attempt it refuses is abandoned and restores
217// the build it replaced at once (its veto switch names this sentinel
218// before the NoOwnership case), since nothing of the attempt ran.
219//
220// errSwapStopped (the barrier's cancel, from memberCurrentLocked) is
221// NoOwnership too: the entry is stopping, so nothing may spawn for it.
222// classifyStep reads it as stepCancelled before it asks this.
223func verdictOf(err error) gateVerdict {
224 switch {
225 case err == nil:
226 return gateProceed
227 case errors.Is(err, errSwapSuperseded):
228 return gateSuperseded
229 case errors.Is(err, errSwapNotAwake), errors.Is(err, errSwapLeaseLost), errors.Is(err, errSwapFenced),
230 errors.Is(err, errSwapShutdown), errors.Is(err, errSwapStopped), errors.Is(err, ErrFenced),
231 errors.Is(err, hostguard.ErrStampMismatch), errors.Is(err, hostguard.ErrFenced),
232 errors.Is(err, hostguard.ErrInactive), errors.Is(err, hostguard.ErrRetiredStamp),
233 errors.Is(err, hostguard.ErrOldStamp), errors.Is(err, hostguard.ErrClaimIncarnation),
234 errors.Is(err, hostguard.ErrRecordIncarnation), errors.Is(err, hostguard.ErrNotParticipant),
235 errors.Is(err, hostguard.ErrNoClaim), errors.Is(err, hostguard.ErrNoClaims),
236 errors.Is(err, hostguard.ErrNoHolder), errors.Is(err, hostguard.ErrMalformedClaim),
237 errors.Is(err, hostbox.ErrChanged), errors.Is(err, errClaimsAdmission),
238 errors.Is(err, ErrStrictRequired), errors.Is(err, ErrStrictGroupInUse), errors.Is(err, ErrStrictCleanupPending),
239 errors.Is(err, ErrClaimIsolationUnavailable), errors.Is(err, cgroup.ErrLegacyParentPopulated),
240 errors.Is(err, cgroup.ErrKilledCgroupKept), errors.Is(err, cgroup.ErrLimitNotApplied),
241 errors.Is(err, errUnitNoGens), errors.Is(err, errSwapNoGens), errors.Is(err, instancectl.ErrStagedExe),
242 errors.Is(err, errBuildStateUnwritten):
243 return gateNoOwnership
244 }
245 return gateProceed
246}
247
248// errClaimsAdmission marks an error a claims-on spawn met before its exec
249// hook ran (claimAdmission): the claim read failed (a store error, a
250// timeout), the claim was absent or malformed, or the work's validation
251// refused before the spawn. Nothing of the build ran, so it is never a
252// failure of the build (spec §7.4).
253var errClaimsAdmission = errors.New("the claims admission refused before the exec")
254
255// claimAdmissionPrefix is the text claimAdmission puts before its cause's.
256// memberSafeWakeReason removes it, so a member's event reads as the cause.
257var claimAdmissionPrefix = errClaimsAdmission.Error() + ": "
258
259// claimAdmission wraps err in errClaimsAdmission when the spawn never
260// reached its exec hook (reached false). An error after the hook ran is the
261// spawn's own, and is returned as it is.
262func claimAdmission(err error, reached bool) error {
263 if err == nil || reached {
264 return err
265 }
266 return fmt.Errorf("%w: %w", errClaimsAdmission, err)
267}
268
269// stepResult is classifyStep for a swap step: a cancelled or stale step
270// abandons the attempt (over is true); a failure of the build is the
271// caller's to report (over is false).
272func stepResult(lifecycle context.Context, err error) (SwapResult, bool) {
273 switch classifyStep(lifecycle, err) {
274 case stepCancelled:
275 return abandon(errSwapStopped), true
276 case stepStale:
277 return abandon(err), true
278 }
279 return SwapResult{}, false
280}
281
282// Swap runs one attempt on an awake entry (spec §7.3–§7.5, §7.8). It
283// blocks until the attempt is done, failed or abandoned. For an overlap
284// that includes the retiring generation's drain. For a hold it lasts until
285// the new generation is healthy or has failed.
286//
287// newest reports the scheduler's newest revision for a host. It is called
288// with a.mu held, so it must not block and must not call back into the
289// Service.
290func (a *Service) Swap(ctx context.Context, at Attempt, newest func(host string) uint64) SwapResult {
291 if !a.gens {
292 return abandon(errSwapOff)
293 }
294 gb, ok := a.backend.(SwapBackend)
295 if !ok {
296 return abandon(errSwapNoGens)
297 }
298 if newest == nil {
299 return abandon(errSwapSuperseded)
300 }
301 switch at.Mode {
302 case ModeOverlap:
303 if a.foreignRow(at.Host) {
304 // A foreign row keeps its canonical socket (plan 1), so two
305 // generations of it cannot coexist: its replacement is a hold
306 // (spec §2.9, rev 17). Its result reads as a hold's, SwapDown
307 // when the new build fails, and Mode tells the scheduler so: a
308 // hold build that ran is held down (spec §6.4, §8.3).
309 res := a.swapHold(ctx, at, newest)
310 res.Mode = ModeHold
311 return res
312 }
313 res := a.swapOverlap(ctx, gb, at, newest)
314 res.Mode = ModeOverlap
315 return res
316 case ModeHold:
317 res := a.swapHold(ctx, at, newest)
318 res.Mode = ModeHold
319 return res
320 }
321 return abandon(errSwapMode)
322}
323
324// EffectiveMode is the mode Swap runs an attempt of mode m on host in: an
325// overlap on a foreign-layout row runs as a hold (foreignRow, spec §2.9),
326// anything else as given. The scheduler writes it into the attempt's
327// write-ahead intent before calling Swap (rollout Task 5, plan review
328// finding 2), so an agent that crashes mid-swap reopens its failure book
329// with the hold it was running. Called without a.mu held.
330func (a *Service) EffectiveMode(host string, m SwapMode) SwapMode {
331 if m == ModeOverlap && a.foreignRow(host) {
332 return ModeHold
333 }
334 return m
335}
336
337// attemptCtx is the attempt's own lifetime. It ends with the caller's ctx,
338// with the agent (Shutdown cancels lifeCtx), or with the barrier, which
339// calls the returned cancel through the member.
340func (a *Service) attemptCtx(ctx context.Context) (context.Context, context.CancelFunc) {
341 mctx, cancel := context.WithCancel(ctx)
342 if a.lifeCtx == nil {
343 return mctx, cancel
344 }
345 stop := context.AfterFunc(a.lifeCtx, cancel)
346 return mctx, func() { stop(); cancel() }
347}
348
349// genHealthy is the successor's health check, through its own handle:
350// GET / below 500 within spawnHealthTimeout (spec §7.5 step 4).
351func (a *Service) genHealthy(ctx context.Context, g Gen) error {
352 if a.genHealth != nil {
353 return a.genHealth(ctx, g)
354 }
355 if g.Socket == "" {
356 return errors.New("generation has no socket")
357 }
358 return waitHealthyCtx(ctx, g.Socket, spawnHealthTimeout)
359}
360
361// spawnSuccessor is §7.5 step 3 in both modes. Claims off, it is the
362// backend's staged spawn. Claims on, a claim-backed spawn would run inside
363// the selected work, which validates the claim and stamp at the exec.
364//
365// gate is the attempt gate (memberCurrentLocked, taking a.mu itself). It
366// runs immediately before every exec: claims off, right before
367// StartStaged, and again inside it at the backend's final launch point,
368// after the backend's preparation (withExecGate; final review F1). Claims
369// on, it belongs after the runtime's Read and Prepare
370// and again inside the selected work at the exec, as the spawn's before
371// hook (plan 3 Task 10).
372//
373// Every claims-on launch is closed on this base (index P2-9): the isolated
374// root launcher cannot carry claim reservations. swapOverlap refuses every
375// claims-backed attempt before its raise (claimsSwapRefusal), so production
376// never reaches the claims-on arm here; only the claimSpawn test seam, the
377// stand-in for an open launch, does. Without the seam the arm still refuses,
378// as a claims admission (NoOwnership, the attempt abandoned), and a
379// claims-on spawn is never sent down the claims-off StartStaged.
380func (a *Service) spawnSuccessor(ctx context.Context, gb SwapBackend, e *actEntry, rt registry.Route, work hostwrite.Work, g Gen, gate func() error) (Gen, Proc, error) {
381 if hostwrite.Legacy(work) {
382 if err := gate(); err != nil {
383 return g, nil, err
384 }
385 // Settings spec §4.2: the successor's build is recorded before it
386 // runs against the data, under the incumbent's lease. A refusal is
387 // abandonment (classifyStep), never the build's failure.
388 if err := a.recordBuild(ctx, rt.Host, g.Build); err != nil {
389 return g, nil, err
390 }
391 return gb.StartStaged(withExecGate(ctx, gate), rt, g)
392 }
393 if a.claimSpawn != nil {
394 return a.claimSpawn(ctx, e, rt, work, g)
395 }
396 return g, nil, claimAdmission(ErrClaimIsolationUnavailable, false)
397}
398
399// swapOverlap is §7.5 steps 1 to 7. The successor is registered in the
400// operation set under the same lock that runs the §7.4 checks, so a
401// terminal stop that begins later always sees it and joins it.
402//
403// Every exit after the admission leaves the set through dropMember (or, on
404// success, promotes the successor and drops the retiring generation), and
405// leaveSet restores a raised parent when the last member leaves. Nothing
406// here blocks under a.mu except GenSwitched, which plan 1's lock contract
407// puts in the same critical section as e.gen.
408func (a *Service) swapOverlap(ctx context.Context, gb SwapBackend, at Attempt, newest func(string) uint64) (out SwapResult) {
409 // The execution evidence (SwapResult.Executed): the backend returned a
410 // successor process, or its error says the build may have run
411 // (startMayHaveRun), whatever ends the attempt after that.
412 var executed bool
413 defer func() { out.Executed = executed }()
414 // The bytes first. An artifact that is not what the attempt chose never
415 // reaches admission, let alone exec (spec §4.2, §7.5 step 3).
416 if err := verifyStaged(at.StagedPath, at.Digest); err != nil {
417 return abandon(fmt.Errorf("staging: %w", err))
418 }
419 work, err := a.selectWork(ctx, at.Host)
420 if err != nil {
421 return abandon(fmt.Errorf("admission: %w", err))
422 }
423 if !hostwrite.Legacy(work) {
424 // Claims on: refused before the admission and the raise, whatever
425 // the runtime's capabilities (claimsSwapRefusal).
426 if err := a.claimsSwapRefusal(true); err != nil {
427 return abandon(err)
428 }
429 }
430 mctx, cancel := a.attemptCtx(ctx)
431 defer cancel()
432 m := &genMember{
433 gen: Gen{N: nextGen(), Build: at.Build, StagedPath: at.StagedPath, Digest: at.Digest, Demand: at.Demand},
434 role: roleSpawning,
435 cancel: cancel,
436 done: make(chan struct{}),
437 }
438 var (
439 e *actEntry
440 rt registry.Route
441 incumbent Gen // the current generation at admission, for its tier
442 )
443 // Step 1: the checks and joining the set, inside the selected work. Only
444 // the registration runs there. A claims-on spawn re-enters the work
445 // itself (Task 10), and hostguard's flock does not nest. Registering
446 // under the same lock as the checks is what lets a stop that begins
447 // afterwards see the successor and join it.
448 err = a.admit(ctx, work, func() error {
449 a.mu.Lock()
450 defer a.mu.Unlock()
451 e = a.entries[at.Host]
452 if err := a.swapChecksLocked(e, at, newest); err != nil {
453 return err
454 }
455 if e.worker || e.route.Kind != "instance" {
456 return errSwapKind
457 }
458 // A foreign row keeps its canonical socket (plan 1), so two
459 // generations of it cannot coexist. It may still hold-swap. So
460 // can a flat row whose generation path would not fit (rev 19
461 // rule 4), which swapLayout reads as foreign.
462 if l, ok := a.swapLayout(e.route); ok && l != LayoutFlat && l != LayoutNested {
463 return errSwapLayout
464 }
465 // A parent still marked raised with no member is a restore that
466 // failed and awaits the sweep's retry (final review F11): an overlap
467 // raising it again now could have that retry lower it under the
468 // successor.
469 if len(e.ops.members) > 0 || e.ops.raised {
470 return errSwapBusy
471 }
472 // A run's unit may be starting against the incumbent right now
473 // (merge ruling M2). Admitting now could retire the incumbent under
474 // a command that has not even started; the attempt is abandoned
475 // instead, and the scheduler tries again once the start has
476 // returned. From here on RunCmd waits for this successor rather
477 // than starting a unit (switchPendingLocked).
478 if cmdStartInFlightLocked(e) {
479 return errSwapBusy
480 }
481 a.addMemberLocked(e, m)
482 rt, incumbent = e.route, e.gen
483 return nil
484 })
485 if err != nil {
486 // The work may refuse after its body registered the member.
487 if a.isMember(e, m) {
488 a.dropMember(e, m)
489 }
490 return abandon(err)
491 }
492
493 // Each artifact's own tier, captured now (spec §7.3): the incumbent's
494 // from the build it runs, the successor's from the attempt's build.
495 // Neither is read from the live link, which root may repoint. They size
496 // the overlap parent, the successor's child, and the restore (leaveSet
497 // restores to the current generation's Tier).
498 if incumbent.Tier == (cgroup.Limit{}) {
499 incumbent.Tier = gb.GenTier(rt, incumbent.Build)
500 }
501 succTier := gb.GenTier(rt, at.Build)
502 a.mu.Lock()
503 if e.gen.N == incumbent.N {
504 e.gen.Tier = incumbent.Tier
505 }
506 m.gen.Tier = succTier
507 a.mu.Unlock()
508
509 // Step 2: the parent raise, before the successor spawns (spec §7.3).
510 // The parent is marked potentially changed BEFORE the raise is asked: a
511 // raise that wrote memory.max and then failed a later write has changed
512 // it, and the member's cleanup (leaveSet) must restore it (final review
513 // F11).
514 a.mu.Lock()
515 e.ops.raised = true
516 a.mu.Unlock()
517 if err := gb.RaiseGenParent(ctx, rt, incumbent.Tier, succTier); err != nil {
518 if parentUntouched(err) {
519 // Refused before any write: there is nothing to restore.
520 a.mu.Lock()
521 e.ops.raised = false
522 a.mu.Unlock()
523 }
524 a.dropMember(e, m) // restores a possibly changed parent (leaveSet)
525 return abandon(fmt.Errorf("raise parent limit: %w", err))
526 }
527
528 // The successor's build is published before its exec (spec §4.2, §8.5):
529 // it has been in the operation set since admission, so this write names
530 // it. A failed write abandons the attempt; nothing of it ran, so it is
531 // never a failure of the build.
532 if err := a.publishBeforeExec(); err != nil {
533 a.dropMember(e, m) // restores the raised parent (leaveSet)
534 return abandon(err)
535 }
536
537 // Step 3a: the §7.4 checks again, at the exec (spec §6.3, §7.4). The
538 // raise (and the pin publication just above this step) took time:
539 // a newer attempt, a halted cohort, a lost lease or a fence since
540 // admission means nothing execs. Claims on, this runs inside the
541 // selected work as the admission did.
542 gate := func() error {
543 a.mu.Lock()
544 defer a.mu.Unlock()
545 return a.memberCurrentLocked(e, m, mctx, at, newest)
546 }
547 if err := a.admit(ctx, work, gate); err != nil {
548 a.dropMember(e, m) // restores the raised parent (leaveSet)
549 return abandon(err)
550 }
551
552 // Step 3: exec the attempt's staged build by path. The backend reports
553 // what it ran, and anything but the attempt's choice is refused.
554 g, proc, spawnErr := a.spawnSuccessor(mctx, gb, e, rt, work, m.gen, gate)
555 executed = startMayHaveRun(proc, spawnErr)
556 if spawnErr == nil && (g.N != m.gen.N || g.Build != m.gen.Build || g.StagedPath != m.gen.StagedPath) {
557 spawnErr = fmt.Errorf("backend ran generation %d of %s from %q; the attempt chose %d of %s from %q",
558 g.N, g.Build, g.StagedPath, m.gen.N, m.gen.Build, m.gen.StagedPath)
559 }
560 a.mu.Lock()
561 m.proc = proc
562 if spawnErr == nil {
563 m.gen, m.role = g, roleSuccessor
564 }
565 a.mu.Unlock()
566 if spawnErr != nil {
567 a.dropMember(e, m)
568 if res, over := stepResult(mctx, spawnErr); over {
569 return res
570 }
571 return failSwap(fmt.Errorf("spawn: %w", spawnErr))
572 }
573
574 // Step 4: the health check, through the generation's own handle.
575 if err := a.genHealthy(mctx, m.gen); err != nil {
576 a.dropMember(e, m)
577 if res, over := stepResult(mctx, err); over {
578 return res
579 }
580 return failSwap(fmt.Errorf("health: %w", err))
581 }
582
583 // Step 4, the canary's half: the 10 s settle (spec §6.2). An
584 // intentional stop of the entry cancels the observation through the
585 // barrier, and that is not a failure (spec §6.4).
586 if at.Canary {
587 switch observeSettle(mctx, m.proc, swapSettle) {
588 case settleCancelled:
589 a.dropMember(e, m)
590 return abandon(errSwapStopped)
591 case settleExited:
592 a.dropMember(e, m)
593 return failSwap(errSettleExit)
594 }
595 }
596
597 // Step 5: the switch. The checks run again, then, under the entry lock,
598 // AppSocket, the serving build and the cache generation all move with
599 // e.gen (contract C2).
600 var (
601 retiring *genMember
602 switched bool
603 )
604 // The host's schedule write fence (final review F8): the switch waits
605 // for the schedule writes in flight, keeps new ones out, and latches the
606 // retiring generation in the same step, so no write of it lands after
607 // the switch. Taken before a.mu (the fence's lock order).
608 fence := a.schedFenceFor(at.Host)
609 err = a.admit(ctx, work, func() error {
610 fence.lock()
611 defer fence.unlock()
612 a.mu.Lock()
613 defer a.mu.Unlock()
614 if err := a.memberCurrentLocked(e, m, mctx, at, newest); err != nil {
615 return err
616 }
617 if m.proc == nil || m.proc.Exited() {
618 return errSuccessorExited
619 }
620 // Admission refused a run's start in flight and RunCmd starts none
621 // while this successor is live, so none can be in flight here
622 // (merge ruling M2). Checked again because a switch over one would
623 // retire the generation that run is starting against.
624 if cmdStartInFlightLocked(e) {
625 return errSwapBusy
626 }
627 // The canonical symlink, the purge and the socket mode first. If the
628 // link cannot move, nothing has switched yet. This runs under a.mu,
629 // in the same critical section that sets e.gen below: plan 1's lock
630 // contract for swapCanonicalLink.
631 if err := gb.GenSwitched(e.route, m.gen, m.proc); err != nil {
632 return err
633 }
634 retiring = &genMember{gen: e.gen, role: roleRetiring, proc: e.proc, done: make(chan struct{})}
635 fence.retire(retiring.gen.N) // its schedule writes are refused from here, for good
636 delete(e.ops.members, m.gen.N)
637 a.addMemberLocked(e, retiring) // swapsim:M2
638 e.gen, e.proc = m.gen, m.proc
639 e.spawnVersion, e.staleSince, e.spawnedAt = m.gen.Build, time.Time{}, a.now()
640 a.noteCurrent(e.host, m.gen.N) // plan 1's cache generation (C4)
641 // Runs admitted against the retiring generation are interrupted
642 // with it, as a scheduled restart's stop interrupts them (merge
643 // ruling M2): a command never outlives the build it was started
644 // from as that build retires.
645 a.interruptCmdsLocked(e, retiring.gen.N, stopWhyFor(at.Cause))
646 switched = true
647 return nil
648 })
649 if err != nil && switched {
650 // The work refused after its body had switched. The successor is
651 // current and serving, so it is never dropped: the swap finishes,
652 // and the retiring generation still drains below.
653 log.Printf("activator: %s: the selected work reported %v after the switch to generation %d; the switch stands", at.Host, err, m.gen.N)
654 } else if err != nil {
655 a.dropMember(e, m)
656 if errors.Is(err, errSuccessorExited) {
657 return failSwap(err)
658 }
659 return abandon(err)
660 }
661 close(m.done) // promoted: it is current now, no longer a member
662 // The retiring generation is no longer current. The edge closes its idle
663 // connections now (spec §7.2), outside every activator lock (C4).
664 a.genRetired(at.Host, retiring.gen)
665
666 // Steps 6 and 7. The retiring generation drains under its own SIGTERM
667 // handling, bounded by actStopTimeout. Once it is proven empty,
668 // leaveSet restores the parent limit. The good build (§8.5) is plan 5's,
669 // recorded from this result.
670 a.mu.Lock()
671 rt = e.route
672 a.mu.Unlock()
673 emitEvent(a.Logs, rt, "info", "instance stopping")
674 res := SwapResult{Outcome: SwapDone, Gen: m.gen}
675 // The drain is timed on the wall clock (not a.now, which tests fake), for
676 // the "swapped" event's drain time (spec §9.4).
677 drainStart := time.Now()
678 proven := a.dropMember(e, retiring)
679 res.Drain = time.Since(drainStart)
680 if !proven {
681 res.Reason = "retiring generation not yet proven stopped; the sweep finishes it"
682 }
683 // Step 8: a legacy co-spawned sidecar moves onto the new generation.
684 a.replaceSidecar(e)
685 return res
686}
687