carlos / platform Public

plans: Astra P5 fixed structurally (typed gate verdict, retain-until-proven, failure-first publication, incarnation-conditional serving writes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a00dfd6eacf2c7ca47bf6e65ab8d001a31c767f7 parent cc9e177
3 files changed, +631 −71
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md +49 −4
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md +407 −50
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md +175 −17

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md
index 55c9c9c1b..a295d5598 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md
@@ -438,6 +438,48 @@ Per finding:
- **4** (plan 1 Task 5): with no current generation, `RoundTrip` waits for
one (`waitServingGen`) and then takes the normal attributed path.
+## Fixes for Astra's P5 review of the plans (2026-10-08)
+
+`CODEX-FINDINGS-ZDD-PLANS-P5.md`: five partial P4 fixes and four new
+findings. Two rules close most of them, each with its own test:
+
+- **The gate's typed verdict** (`gateVerdict`, `verdictOf`, plan 3 Task 4):
+ Proceed, Superseded (a restore may run, only through a fresh gate at its
+ exec) or NoOwnership (lease lost, fenced, shut down, not awake, or any
+ hostguard claims refusal). NoOwnership stops every spawn, restores
+ included. `classifyStep` maps every non-Proceed verdict to `stepStale`
+ (abandonment), never `stepFailed`.
+- **Retain until proven** (`releasable`, plan 3 Task 2): a stop finishes,
+ and gives up its lease, only when no member and no stray sidecar remains
+ and the current generation passed `genTerminated`. Otherwise it stays
+ pending (`swapsim:M8`) and `finishPendingStops` re-proves it.
+
+Per finding:
+
+- **P1 #1, completed** (plan 3 Task 2): `stop()` runs `releasable` whatever
+ `KillAndEmpty` returned. A leader that outlives a failed kill keeps the
+ lease.
+- **P1 #6, completed** (plan 5 Task 10): `GenFellBack` records the failure
+ hold (`buildFailed`) before `publishServing` and the config activation.
+- **P2 #3, completed** (plan 3 Tasks 2, 7): a tracked stray sidecar blocks
+ `releasable`; `straySidecars` is declared in Task 2.
+- **P4 #1, completed** (plan 3 Task 11): a nil-`stopOld` respawn proves
+ the old generation through its host cgroup; `KillHost` proves a nested
+ layout's worker too (it always runs in the flat host cgroup); a failed
+ worker wake's generation carries `Gen.Worker`.
+- **P4 #3 and new 4** (plan 5 Tasks 8, 11): a worker's `ServingVersion` has
+ one writer, `publishWorkerServing`, which delivers only the entry's
+ latest incarnation (`workerInc`) under `servingMu`. The scheduler's
+ `setServingLocked` skips `Gen.Worker` generations; its other writes were
+ already conditional on the served generation (`acknowledged`, `goodMu`).
+- **1** (plan 5 Task 11): `ReplaceWorker`'s respawn restores only on
+ `gateSuperseded`; any other refusal spawns nothing.
+- **2** (plan 3 Task 10): the empty strict parent is closed with
+ `Prepared.Close`; a failed close keeps the handle in `unclosedParents`
+ and `finishPendingStops` retries it.
+- **3** (plan 3 Tasks 4, 10): the hostguard refusals from the claimed
+ spawn's last `Work.With` classify as abandonment.
+
## Cross-plan interface ledger
Every type, function, method or field that one plan defines and another
@@ -509,10 +551,11 @@ many line numbers in plans 1, 3 and 5; find each use by its quoted name.
| `instancectl.Client.StartSidecarExe(ctx, host string, memoryMax int64, x StagedExe) (string, error)`; `Server.ensureHostExeAt(host, x)` | P3 T9 | P3 only |
| `func (a *Service) swapChecksLocked(e *actEntry, at Attempt, newest func(string) uint64) error` (now `attemptCurrentLocked(e, at, newest, false)`); `errSwapNotAwake`, `errSwapBusy`, `errSwapKind`, `errSwapStopped`; `func abandon(err error) SwapResult` | P3 T3, T4 | P5 T1 L172, T11 (`ReplaceWorker`, its `attemptGate`) |
| **The attempt gate**: `func (a *Service) attemptCurrentLocked(e *actEntry, at Attempt, newest func(string) uint64, waking bool) error`; `memberCurrentLocked(e, m, mctx, at, newest)`. Every exec point and completion path asks it: overlap Step 3a, the switch, `spawnSuccessor`'s claims `before` hook, a hold's `pinCheck` and result, `ReplaceWorker` | P3 T3 | P3 T4, T6, T10; P5 T11 (through `swapChecksLocked`) |
-| **The outcome classifier**: `type stepOutcome`; `stepOK`, `stepCancelled`, `stepStale`, `stepFailed`; `func classifyStep(lifecycle context.Context, err error) stepOutcome`; `stepResult` | P3 T4 | P3 T4, T6; P5 T9 (`spawnPlanned`, `spawnPlannedWorker`: only `stepFailed` records a failure or falls back) |
-| **The termination proof**: `func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool`; claims on: `func (p *claimProcess) provenStopped(ctx) bool`; workers: `Gen.Worker bool`, `type hostKiller interface{ KillHost(ctx, rt) error }`, `ExecBackend.KillHost`, `SplitBackend.KillHost` (P3 T11) | P3 T2, T11 | every lease- or set-giving path in P3 (member stop, reaper, `stop()`, `reapCurrentGen`, `cleanupFailedWake`, `reapUnprovenWakesLocked`, a worker respawn's retire and shed); P5 T9 (`killFailed` for a worker) |
+| **The outcome classifier**: `type stepOutcome`; `stepOK`, `stepCancelled`, `stepStale`, `stepFailed`; `func classifyStep(lifecycle context.Context, err error) stepOutcome`; `stepResult`; the gate's typed verdict: `type gateVerdict`, `gateProceed`, `gateSuperseded`, `gateNoOwnership`, `func verdictOf(err error) gateVerdict` (every non-Proceed verdict, hostguard claims refusals included, classifies `stepStale`) | P3 T4 | P3 T4, T6, T10; P5 T9 (`spawnPlanned`, `spawnPlannedWorker`: only `stepFailed` records a failure or falls back), T11 (`ReplaceWorker`'s respawn gate: only `gateSuperseded` may restore) |
+| **The termination proof**: `func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool`; claims on: `func (p *claimProcess) provenStopped(ctx) bool`; workers: `Gen.Worker bool`, `type hostKiller interface{ KillHost(ctx, rt) error }`, `ExecBackend.KillHost`, `SplitBackend.KillHost` (P3 T11; no nested-layout exemption); the lease rule: `func (a *Service) releasable(e *actEntry) bool` (no member, no stray sidecar, current generation `genTerminated`) | P3 T2, T11 | every lease- or set-giving path in P3 (member stop, reaper, `stop()` and `reapCurrentGen` through `releasable`, `cleanupFailedWake`, `reapUnprovenWakesLocked`, a worker respawn's retire and shed, a nil-`stopOld` respawn); P5 T9 (`killFailed` for a worker) |
| `StartPolicy.Gate func() error` (the attempt gate at every claims-on exec, in `startAdmitted`'s `beforeStart`) | P3 T10 (set by T6's `doClaimWake` hold block) | P3 only |
-| `func (a *Service) discardSidecar(host string, sc Proc)`; `trackStraySidecar`, `reapStraySidecarsLocked`, `Service.straySidecars` (`sidecarJoinBound` is gone: with generations on the join has no timeout) | P3 T7 | P3 only |
+| `func (a *Service) discardSidecar(host string, sc Proc)`; `trackStraySidecar`, `reapStraySidecarsLocked`, `Service.straySidecars` (`sidecarJoinBound` is gone: with generations on the join has no timeout) | P3 T7 (`straySidecars` declared in T2, read by `releasable`) | P3 only |
+| `var closeStrictParent = (*cgroup.Prepared).Close`; `Service.unclosedParents`, `func (a *Service) retryUnclosedParentsLocked()` (an empty strict parent is closed, not `CloseRemove`d, and its handle kept until `Close` succeeds) | P3 T10 | P3 only (consumes P2 T3's `Prepared.Close`) |
| `actEntry.ops opSet` (`members map[uint64]*genMember`, `genMember.gen`); `addMemberLocked`; `leaveSet` | P3 T2 | P5 T1, T12 L6170, L6366, L6419 |
| `swapOverlap`'s `// Step 3a` gate block, before `g, proc, spawnErr := a.spawnSuccessor(mctx, gb, e, rt, work, m.gen, gate)` (`spawnSuccessor` and `startClaimedSuccessor` gain `gate func() error`) | P3 T4, T10 | P5 T12 (persist point, before Step 3a; a failed write abandons the attempt there) |
| `Service.genHealth func(ctx context.Context, g Gen) error` (test seam) | P3 T4 | P5 T1 L186, T9 L4344 |
@@ -556,7 +599,9 @@ many line numbers in plans 1, 3 and 5; find each use by its quoted name.
| `Manager.served`, `publishServing`, `acknowledged`, `writeGood`/`writeGoodLocked`, `Manager.goodMu` (every serving- and good-build write is for the generation the activator serves, serialised; `done` publishes before it frees the slot) | P5 T8 | P5 T10 (`OnGen`) |
| `generationsLocked` includes `unprovenWakes` | P5 T12 | consumes P3 T2's `unprovenWakes` |
| `WakePlan.FailedUnder`, `GenEvent.FailedUnder` (a first wake's failing pointer, read by the planner before the wake runs) | P5 T9, T10 | P5 only (`buildFailed`) |
-| `Service.OnWorkerServing func(host string, id release.StagedIdentity)` (flag-independent; the agent writes `ServingVersion` with it) | P5 T11, T14 | P6 T13 (`carlos sidecars ls` reads `ServingVersion`) |
+| `Service.OnWorkerServing func(host string, id release.StagedIdentity)` (flag-independent; the agent writes `ServingVersion` with it); `func (a *Service) publishWorkerServing(e *actEntry, id release.StagedIdentity, inc uint64)`, `Service.servingMu`, `actEntry.workerInc` (delivered only for the entry's latest worker incarnation) | P5 T11, T14 | P6 T13 (`carlos sidecars ls` reads `ServingVersion`) |
+| `func (m *Manager) setServingLocked(host string, g activator.Gen)` (the scheduler's only `SetServingVersion`; skips a `Gen.Worker` generation, whose serving build is the activator's alone) | P5 T8 | P5 T10 (`OnGen`), T11 |
+| `fakeRoutes.setServingHook` (test seam: runs at each `SetServingVersion`) | P5 T8 | P5 T10 |
| `func plannedWorkerReady(ctx context.Context, p Proc) error` (spec §6.5 for planned socketless wakes) | P5 T9 | P5 only |
| `func (a *Service) persistGenerations() error`; `errGenerationsUnpublished` (a spawn whose build is not published does not run) | P5 T12 | P5 only |
| `status.RolloutReport`, `HostRollout`, `AppRollout`, `RolloutFailure`; `Attempt*` and `Reason*` words; `Probes.Rollout`, `BoxMetrics.Rollout`; `func reasonWord(text string) string`; `Deps.OnChange` | P5 T13 | P6 T2, T8 L2110–2183, T10 L2779, T11 L3670, L4022, T12 L4064 (through `rolloutView`), T14 L4553 |
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
index b5758edec..0225ac6b9 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
@@ -1021,7 +1021,8 @@ git commit -m "zdd plan 3 task 1: swap types, the SwapBackend seam, flat staged
func (a *Service) genChildEmpty(rt registry.Route, m *genMember) bool
func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool // THE termination proof: leader exited and child empty; every lease- or set-giving path asks it
func (p *claimProcess) provenStopped(ctx context.Context) bool // claims-on: its own Stop completed (KillAndEmpty, exit, settle, close)
- func (a *Service) reapCurrentGen(e *actEntry) // a pending stop's current generation, proven empty before finishStop
+ func (a *Service) reapCurrentGen(e *actEntry) // a pending stop finishes once releasable holds
+ func (a *Service) releasable(e *actEntry) bool // THE retain-until-proven rule: no member, no stray sidecar, current generation genTerminated
type unprovenWake struct{ rt registry.Route; gen Gen; proc Proc; lease Lease; rep Rep; busy bool }
var errWakeUnproven error
func (a *Service) reapUnprovenWakesLocked()
@@ -1050,6 +1051,7 @@ import (
"os/exec"
"slices"
"sync"
+ "sync/atomic"
"testing"
"time"
@@ -1301,6 +1303,70 @@ func TestMemberStopWithoutContainmentAcceptsTheLeaderExit(t *testing.T) {
}
}
+// stubbornProc is a main process whose Stop fails with an error other than
+// ErrStillRunning (a claims-on KillAndEmpty error) and whose leader lives
+// on until exit.
+type stubbornProc struct{ exited atomic.Bool }
+
+func (p *stubbornProc) Stop(context.Context) error { return errors.New("cgroup.kill: the child is not empty") }
+func (p *stubbornProc) Exited() bool { return p.exited.Load() }
+func (p *stubbornProc) Crashed() bool { return false }
+
+// THE retain-until-proven rule (spec §7.4): a stop whose main process is
+// still alive after a failed kill keeps its lease. Only ErrStillRunning
+// kept it before; any other Stop error, with the leader alive, skipped the
+// proof (it ran only for an exited leader) and released the lease.
+func TestAStopKeepsTheLeaseWhileItsLeaderLivesOn(t *testing.T) {
+ x := awakeForBarrier(t, true)
+ p := &stubbornProc{}
+ x.a.mu.Lock()
+ x.a.entries[x.host].proc = p
+ x.a.mu.Unlock()
+ rep := x.b.lastRep()
+ x.a.Bounce(x.host)
+ waitFor(t, "the stop to pend", func() bool {
+ x.a.mu.Lock()
+ defer x.a.mu.Unlock()
+ e := x.a.entries[x.host]
+ return e != nil && e.pendingStop
+ })
+ x.a.sweepOnce()
+ time.Sleep(50 * time.Millisecond)
+ if rep.isClosed() {
+ t.Fatal("the lease was released while the leader still ran after a failed kill")
+ }
+ p.exited.Store(true)
+ x.a.sweepOnce()
+ waitFor(t, "the lease release", rep.isClosed)
+}
+
+// A stop keeps its lease while a stray sidecar of the host is tracked
+// (spec §7.4): its process may still run against the database. The lease
+// went with the stop before.
+func TestAStopKeepsTheLeaseWhileAStraySidecarRuns(t *testing.T) {
+ x := awakeForBarrier(t, true)
+ stray := &fakeProc{label: "sidecar", rec: x.b, stuck: true}
+ x.a.mu.Lock()
+ x.a.straySidecars = map[string][]Proc{x.host: {stray}}
+ x.a.mu.Unlock()
+ rep := x.b.lastRep()
+ x.a.Bounce(x.host)
+ waitFor(t, "the stop to pend", func() bool {
+ x.a.mu.Lock()
+ defer x.a.mu.Unlock()
+ e := x.a.entries[x.host]
+ return e != nil && e.pendingStop
+ })
+ if rep.isClosed() {
+ t.Fatal("the lease was released while a stray sidecar ran")
+ }
+ x.a.mu.Lock()
+ delete(x.a.straySidecars, x.host) // proven gone (Task 7's reaper does this)
+ x.a.mu.Unlock()
+ x.a.sweepOnce()
+ waitFor(t, "the lease release", rep.isClosed)
+}
+
// failingContainment is a claims-on child whose KillAndEmpty fails until
// heal is called.
type failingContainment struct {
@@ -1622,6 +1688,11 @@ func (a *Service) stopMember(e *actEntry, m *genMember) bool {
// process never ran.
func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool {
if p == nil {
+ if g.Worker {
+ // A worker's host cgroup is proven whether or not a process
+ // handle is left: a dead leader's descendants live there (Task 11).
+ return a.genChildEmpty(rt, &genMember{gen: g})
+ }
return true
}
if !p.Exited() {
@@ -1769,10 +1840,7 @@ func (a *Service) reapExited(e *actEntry, m *genMember) {
// the current generation's child is proven empty. A failed proof leaves the
// stop pending, and the next sweep tries again.
func (a *Service) reapCurrentGen(e *actEntry) {
- a.mu.Lock()
- rt, g, p := e.route, e.gen, e.proc
- a.mu.Unlock()
- if !a.genTerminated(rt, g, p) {
+ if !a.releasable(e) {
a.mu.Lock()
e.genReaping = false
a.mu.Unlock()
@@ -1780,11 +1848,15 @@ func (a *Service) reapCurrentGen(e *actEntry) {
}
a.mu.Lock()
e.genReaping, e.pendingStop = false, false
- outcome := e.stopOutcome
+ outcome, p := e.stopOutcome, e.proc
a.mu.Unlock()
if outcome == nil {
outcome = &stopObservation{}
}
+ if p == nil {
+ a.finishStop(e, outcome) // only the barrier left it pending
+ return
+ }
a.finishPendingStop(e, outcome)
}
@@ -1899,27 +1971,47 @@ At the end of `stop()`, replace `a.finishStop(e, outcome)` (line 2268) with:
a.mu.Unlock()
return
}
- if a.gens && e.proc != nil && e.proc.Exited() {
- // The current generation is a generation like any other (spec
- // §7.4): its leader's exit is not its termination until its child
- // is empty. Unproven, the stop stays pending with the lease held,
- // exactly as for ErrStillRunning, and the sweep retries the proof.
+ if a.gens && !a.releasable(e) { // swapsim:M8
+ // THE retain-until-proven rule (spec §7.4): unproven, the stop stays
+ // pending with the lease held, exactly as for ErrStillRunning, and
+ // the sweep retries (reapCurrentGen). That covers a leader still
+ // alive after a failed kill (a claims-on KillAndEmpty error is not
+ // ErrStillRunning, so it reaches here), a child not proven empty, a
+ // worker's host cgroup, and a stray sidecar.
+ log.Printf("activator: stop %s: not every process of the entry is proven gone; keeping the lease until it is", e.host)
a.mu.Lock()
- rt, g := e.route, e.gen
+ e.pendingStop = true
+ e.stopOutcome = outcome
+ e.stopRetriedAt = a.now()
a.mu.Unlock()
- if !a.genTerminated(rt, g, e.proc) { // swapsim:M8
- log.Printf("activator: stop %s: generation %d's child is not proven empty; keeping the lease until it is", e.host, g.N)
- a.mu.Lock()
- e.pendingStop = true
- e.stopOutcome = outcome
- e.stopRetriedAt = a.now()
- a.mu.Unlock()
- return
- }
+ return
}
a.finishStop(e, outcome)
```
+Add to `opset.go`:
+
+```go
+// releasable is THE retain-until-proven rule for an entry's lease (spec
+// §7.4): a stop finishes, and its lease is released, only when every
+// member has left the operation set, no stray sidecar of the host is
+// tracked (Task 7), and the current generation has passed genTerminated
+// (its leader exited and its child, or a worker's host cgroup, empty). It
+// runs without a.mu held; genTerminated may take DeadlineControl.
+func (a *Service) releasable(e *actEntry) bool {
+ a.mu.Lock()
+ members, strays := len(e.ops.members), len(a.straySidecars[e.host])
+ rt, g, p := e.route, e.gen, e.proc
+ a.mu.Unlock()
+ if members > 0 || strays > 0 {
+ return false
+ }
+ return a.genTerminated(rt, g, p)
+}
+```
+
+Add the field it reads to `Service` now: `straySidecars map[string][]Proc // legacy sidecars whose stop is unproven, per host; Task 7 tracks into it; under a.mu`.
+
Add to `claimwake.go`, beside `claimProcess.Stop`:
```go
@@ -1956,6 +2048,17 @@ with
if !e.pendingStop || len(e.ops.members) > 0 {
continue
}
+ if a.gens && (e.proc == nil || e.proc.Exited()) {
+ // Members gone and the main process (if any) exited: the stop
+ // finishes once the retain-until-proven rule holds (releasable,
+ // in reapCurrentGen). A process still alive falls through to
+ // main's re-stop cadence below.
+ if !e.genReaping {
+ e.genReaping = true
+ go a.reapCurrentGen(e)
+ }
+ continue
+ }
if e.proc == nil {
// Only the barrier leaves a stop pending without a process. Its
// members are gone now, so the stop can finish.
@@ -1967,15 +2070,6 @@ with
go a.finishStop(e, outcome)
continue
}
- if a.gens && e.proc.Exited() {
- // The main process has exited: its generation's child must be
- // proven empty before the stop finishes (spec §7.4).
- if !e.genReaping {
- e.genReaping = true
- go a.reapCurrentGen(e)
- }
- continue
- }
```
and add, as the first statement of `finishPendingStops` (before the loop), `a.reapUnprovenWakesLocked()`.
@@ -2405,6 +2499,8 @@ git commit -m "zdd plan 3 task 3: swap admission through hostwrite, claims off a
func (a *Service) genHealthy(ctx context.Context, g Gen) error
type stepOutcome int; const stepOK, stepCancelled, stepStale, stepFailed stepOutcome
func classifyStep(lifecycle context.Context, err error) stepOutcome // THE outcome classifier: only stepFailed is a failure of the build
+ type gateVerdict int; const gateProceed, gateSuperseded, gateNoOwnership gateVerdict
+ func verdictOf(err error) gateVerdict // the attempt gate's typed verdict; NoOwnership (lease, fence, shutdown, a claims refusal such as hostguard.ErrStampMismatch) stops every spawn, restores included
func stepResult(lifecycle context.Context, err error) (SwapResult, bool)
// Service gains: genHealth func(ctx context.Context, g Gen) error (test seam)
var errSwapOff, errSwapBusy, errSwapKind, errSwapStopped, errSuccessorExited, errSwapMode error
@@ -2691,6 +2787,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/registry"
)
@@ -2945,6 +3042,10 @@ func TestClassifyStep(t *testing.T) {
{"the barrier", live, errSwapStopped, stepCancelled},
{"superseded", live, fmt.Errorf("pin: %w", errSwapSuperseded), stepStale},
{"lease lost", live, errSwapLeaseLost, stepStale},
+ // Claims validation at the exec (spawnClaimedGen's Work.With):
+ // nothing ran, so no failure of the build and no halted cohort.
+ {"a changed stamp at the exec", live, fmt.Errorf("with: %w", hostguard.ErrStampMismatch), stepStale},
+ {"a durable fence at the exec", live, fmt.Errorf("with: %w", hostguard.ErrFenced), stepStale},
{"a health deadline", live, fmt.Errorf("health: %w", context.DeadlineExceeded), stepFailed},
{"HTTP 500", live, errors.New("GET / answered 500"), stepFailed},
} {
@@ -2954,6 +3055,30 @@ func TestClassifyStep(t *testing.T) {
}
}
+// The gate's verdict is typed (spec §6.3, §7.4): a supersession may restore
+// behind a fresh ownership check; a lost lease, a fence, shutdown or a
+// claims refusal stops every spawn.
+func TestVerdictOf(t *testing.T) {
+ for _, tc := range []struct {
+ err error
+ want gateVerdict
+ }{
+ {nil, gateProceed},
+ {errors.New("GET / answered 500"), gateProceed},
+ {fmt.Errorf("gate: %w", errSwapSuperseded), gateSuperseded},
+ {errSwapLeaseLost, gateNoOwnership},
+ {errSwapFenced, gateNoOwnership},
+ {errSwapShutdown, gateNoOwnership},
+ {errSwapNotAwake, gateNoOwnership},
+ {fmt.Errorf("with: %w", hostguard.ErrStampMismatch), gateNoOwnership},
+ {fmt.Errorf("with: %w", hostguard.ErrFenced), gateNoOwnership},
+ } {
+ if got := verdictOf(tc.err); got != tc.want {
+ t.Errorf("verdictOf(%v) = %d, want %d", tc.err, got, tc.want)
+ }
+ }
+}
+
// Spec §6.3, §7.4: the checks run again at the exec, not only at the
// admission. A newer attempt that lands while the parent is being raised
// (or, with plan 5, while the pins are being published) means nothing
@@ -3037,7 +3162,7 @@ func TestSwapClaimsOn(t *testing.T) {
- [ ] **Step 3: Run the tests to verify they fail**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapOverlap|TestSwapRefusals|TestSwapWitnessNoAdmission|TestSwapClaimsOn' -count=1`
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapOverlap|TestSwapRefusals|TestSwapWitnessNoAdmission|TestSwapClaimsOn|TestClassifyStep|TestVerdictOf' -count=1`
Expected: build failure: `f.a.Swap undefined`, `undefined: errSwapOff`, `unknown field genHealth`.
- [ ] **Step 4: Write the code**
@@ -3051,7 +3176,7 @@ Add to `Service` in `activator.go` (after `OnWake`, line 682):
genHealth func(ctx context.Context, g Gen) error
```
-Append to `internal/activator/swap.go`, and add its imports (`context`, `errors`, `fmt`, `time`, `internal/cgroup` and `internal/registry`):
+Append to `internal/activator/swap.go`, and add its imports (`context`, `errors`, `fmt`, `time`, `internal/cgroup`, `internal/hostbox`, `internal/hostguard` and `internal/registry`; neither `hostbox` nor `hostguard` imports `activator`):
```go
var (
@@ -3091,13 +3216,52 @@ func classifyStep(lifecycle context.Context, err error) stepOutcome {
case lifecycle != nil && lifecycle.Err() != nil,
errors.Is(err, context.Canceled), errors.Is(err, errSwapStopped), errors.Is(err, ErrFenced):
return stepCancelled
- case errors.Is(err, errSwapSuperseded), errors.Is(err, errSwapNotAwake), errors.Is(err, errSwapLeaseLost),
- errors.Is(err, errSwapFenced), errors.Is(err, errSwapShutdown):
+ case verdictOf(err) != gateProceed:
+ // The attempt gate's own refusal, wherever it surfaced: at Step 3a,
+ // or as a claims validation inside the work at the exec. Nothing of
+ // the build ran because of it (spec §7.4).
return stepStale
}
return stepFailed
}
+// gateVerdict is the attempt gate's answer, typed (spec §6.3, §7.4).
+type gateVerdict int
+
+const (
+ // gateProceed: the attempt is current and owns the host.
+ gateProceed gateVerdict = iota
+ // gateSuperseded: still the owner, but a newer attempt or a halted
+ // cohort replaced this one. A restore of what it replaced may run, and
+ // only behind a fresh ownership check at that exec.
+ gateSuperseded
+ // gateNoOwnership: the entry is not current, the lease is lost, a fence
+ // or the agent's shutdown holds the host, or the claims validation
+ // refused (a changed stamp, a durable fence, a changed claim). Nothing
+ // may spawn, a restore included.
+ gateNoOwnership
+)
+
+// verdictOf types an attempt-gate error. An error that is not the gate's
+// (a failure of the build) is gateProceed: the gate did not refuse.
+func verdictOf(err error) gateVerdict {
+ switch {
+ case err == nil:
+ return gateProceed
+ case errors.Is(err, errSwapSuperseded):
+ return gateSuperseded
+ case errors.Is(err, errSwapNotAwake), errors.Is(err, errSwapLeaseLost), errors.Is(err, errSwapFenced),
+ errors.Is(err, errSwapShutdown), errors.Is(err, ErrFenced),
+ errors.Is(err, hostguard.ErrStampMismatch), errors.Is(err, hostguard.ErrFenced),
+ errors.Is(err, hostguard.ErrInactive), errors.Is(err, hostguard.ErrRetiredStamp),
+ errors.Is(err, hostguard.ErrOldStamp), errors.Is(err, hostguard.ErrClaimIncarnation),
+ errors.Is(err, hostguard.ErrRecordIncarnation), errors.Is(err, hostguard.ErrNotParticipant),
+ errors.Is(err, hostbox.ErrChanged):
+ return gateNoOwnership
+ }
+ return gateProceed
+}
+
// stepResult is classifyStep for a swap step: a cancelled or stale step
// abandons the attempt (over is true); a failure of the build is the
// caller's to report (over is false).
@@ -5249,7 +5413,7 @@ func (a *Service) reapStraySidecarsLocked() {
}
```
-Add to `Service`: `straySidecars map[string][]Proc // legacy sidecars whose stop is unproven, per host; under a.mu`. Call `a.reapStraySidecarsLocked()` beside Task 2's `a.reapUnprovenWakesLocked()` at the top of `finishPendingStops`. In `doWake`, extend Task 2's unproven-wake refusal so a tracked stray also refuses: `_, unproven := a.unprovenWakes[host]; unproven = unproven || len(a.straySidecars[host]) > 0`.
+(`Service.straySidecars` is Task 2's, and Task 2's `releasable` already keeps a stop's lease while the host has one.) Call `a.reapStraySidecarsLocked()` beside Task 2's `a.reapUnprovenWakesLocked()` at the top of `finishPendingStops`. In `doWake`, extend Task 2's unproven-wake refusal so a tracked stray also refuses: `_, unproven := a.unprovenWakes[host]; unproven = unproven || len(a.straySidecars[host]) > 0`.
In `stop()`'s co-stop, with generations on, a sidecar whose stop is unproven is tracked as well. After main's `outcome.sidecarStop, outcome.sidecarExited = err == nil, e.sidecar.Exited()`, add:
@@ -6584,7 +6748,10 @@ Claims-on wakes spawn through the claim runtime's provider (`ClaimStart.Build`),
func (b *SplitBackend) GenPaths(rt registry.Route, n uint64) (Gen, bool)
func (a *Service) claimGenAt(rt registry.Route, n uint64) Gen
func (a *Service) entryParent(e *actEntry, s ClaimStart) (*cgroup.Prepared, error)
- func (a *Service) closeEntryParent(e *actEntry)
+ func (a *Service) closeEntryParent(e *actEntry) // Close (not CloseRemove) the empty parent; a handle that does not close is kept and retried
+ var closeStrictParent = (*cgroup.Prepared).Close
+ func (a *Service) retryUnclosedParentsLocked()
+ // Service gains unclosedParents []*cgroup.Prepared; parentsRetrying bool
func (a *Service) setParent(ctx context.Context, gb SwapBackend, e *actEntry, rt registry.Route, cur cgroup.Limit, succ *cgroup.Limit) error // captured tiers only
func (a *Service) spawnSuccessor(ctx context.Context, gb SwapBackend, e *actEntry, rt registry.Route, work hostwrite.Work, g Gen, gate func() error) (Gen, Proc, error)
func (a *Service) startClaimedSuccessor(ctx context.Context, e *actEntry, rt registry.Route, work hostwrite.Work, g Gen, gate func() error) (Gen, Proc, error) // gate after Prepare, and as spawnClaimedGen's before hook
@@ -6760,6 +6927,66 @@ func TestStartAdmittedFallsBackToTheGoodBuildOnce(t *testing.T) {
}
}
+// Spec §7.3, §7.4: an entry's empty strict parent is closed with Close,
+// which releases its reservation; a handle that does not close is kept and
+// retried, never dropped. CloseRemove (a generation child's) refused every
+// parent, leaking the reservation so the host's next wake was refused.
+func TestAnEmptyStrictParentIsClosedAndKeptUntilItCloses(t *testing.T) {
+ old := closeStrictParent
+ t.Cleanup(func() { closeStrictParent = old })
+ var calls atomic.Int32
+ var failing atomic.Bool
+ failing.Store(true)
+ closeStrictParent = func(*cgroup.Prepared) error {
+ calls.Add(1)
+ if failing.Load() {
+ return errors.New("strict: still populated")
+ }
+ return nil
+ }
+ a, _, _, _ := testActivator(t, time.Hour)
+ e := &actEntry{host: "x.example"}
+ e.ops.parent = &cgroup.Prepared{}
+ a.closeEntryParent(e)
+ if calls.Load() != 1 {
+ t.Fatalf("Close calls = %d, want the parent closed with Close", calls.Load())
+ }
+ a.mu.Lock()
+ kept := len(a.unclosedParents)
+ a.mu.Unlock()
+ if kept != 1 {
+ t.Fatal("a parent that did not close was dropped")
+ }
+ failing.Store(false)
+ a.sweepOnce()
+ waitFor(t, "the retried close", func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return len(a.unclosedParents) == 0 && !a.parentsRetrying
+ })
+ if calls.Load() != 2 {
+ t.Fatalf("Close calls = %d, want one retry", calls.Load())
+ }
+}
+
+// Spec §7.4: the claims validation inside spawnClaimedGen's Work.With (a
+// changed stamp, a durable fence) refuses the exec. Nothing ran, so the
+// attempt is abandoned, never SwapFailed: a failure record would halt the
+// cohort for a build that never executed.
+func TestAClaimsRefusalAtTheExecIsNoFailure(t *testing.T) {
+ for _, refusal := range []error{hostguard.ErrStampMismatch, hostguard.ErrFenced} {
+ f := newSwapFixture(t)
+ f.a.cfg.HostWrites = hostwrite.Config{Enabled: true, Selector: &swapSelector{work: &swapWork{}}}
+ f.a.cfg.Claims = &ClaimRuntime{Pins: true, Generations: true}
+ f.a.claimSpawn = func(context.Context, *actEntry, registry.Route, hostwrite.Work, Gen) (Gen, Proc, error) {
+ return Gen{}, nil, fmt.Errorf("work: %w", refusal)
+ }
+ if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapAbandoned {
+ t.Fatalf("%v at the exec: Swap = %+v, want abandoned", refusal, res)
+ }
+ }
+}
+
// claimedStartFor is a ClaimStart for obs with every hook a no-op, version v.
func claimedStartFor(obs hostclaim.Observed, work hostwrite.Work, v string) ClaimStart {
return ClaimStart{Host: obs.Host, Work: work, Identity: hostwrite.Identity{Config: "c", Release: "r"}, Version: v,
@@ -6888,11 +7115,11 @@ func TestClaimsOnSuccessorReChecksAfterPrepare(t *testing.T) {
}
```
-Add `"sync"` and `"github.com/carlosframework/platform/internal/cgroup"` to the file's imports.
+Add `"fmt"`, `"sync"`, `"sync/atomic"`, `"github.com/carlosframework/platform/internal/cgroup"` and `"github.com/carlosframework/platform/internal/hostguard"` to the file's imports.
- [ ] **Step 2: Run the tests to verify they fail**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestClaimsOnSwapNeedsGenerations|TestStartAdmittedRefusesAGenerationMismatch|TestClaimGenAt|TestStartAdmittedFallsBack|TestClaimsOnSuccessorReChecks|TestAClaimsOnHoldRunsTheGate|TestAClaimedWakeCancelled' -count=1`
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestClaimsOnSwapNeedsGenerations|TestStartAdmittedRefusesAGenerationMismatch|TestClaimGenAt|TestStartAdmittedFallsBack|TestClaimsOnSuccessorReChecks|TestAClaimsOnHoldRunsTheGate|TestAClaimedWakeCancelled|TestAClaimsRefusalAtTheExec|TestAnEmptyStrictParent' -count=1`
Expected: build failure: `undefined: errClaimsNoGenerations`, `unknown field Gen in struct literal of type ClaimStart`, `a.claimGenAt undefined`, `unknown field Fallback in struct literal of type StartPolicy`.
- [ ] **Step 3: Extend the claim types and `startAdmitted`**
@@ -7227,9 +7454,18 @@ func (a *Service) entryParent(e *actEntry, s ClaimStart) (*cgroup.Prepared, erro
return p, nil
}
+// closeStrictParent closes an entry's empty strict parent: Close, which
+// releases its descriptors and its reservation. CloseRemove is for a
+// generation child and refuses a parent handle (plan 2 Task 3), which
+// leaked the reservation and refused the host's next wake. A var so a test
+// can make it fail.
+var closeStrictParent = (*cgroup.Prepared).Close
+
// closeEntryParent releases the entry's strict parent. finishStop calls it
// after the barrier has joined every generation, and wake() after a failed
-// claims-on wake.
+// claims-on wake. A handle that does not close (still populated, an
+// identity check failed) is kept, never dropped: it holds the host's
+// reservation, and the sweep retries it (retryUnclosedParentsLocked).
func (a *Service) closeEntryParent(e *actEntry) {
a.mu.Lock()
p := e.ops.parent
@@ -7238,9 +7474,35 @@ func (a *Service) closeEntryParent(e *actEntry) {
if p == nil {
return
}
- if err := p.CloseRemove(); err != nil {
- log.Printf("activator: release the cgroup parent of %s: %v", e.host, err)
+ if err := closeStrictParent(p); err != nil {
+ log.Printf("activator: release the cgroup parent of %s: %v (kept; the sweep retries)", e.host, err)
+ a.mu.Lock()
+ a.unclosedParents = append(a.unclosedParents, p)
+ a.mu.Unlock()
+ }
+}
+
+// retryUnclosedParentsLocked closes the strict parents a stop could not,
+// keeping each until it closes. The caller holds a.mu; the closes run on
+// their own goroutine.
+func (a *Service) retryUnclosedParentsLocked() {
+ if len(a.unclosedParents) == 0 || a.parentsRetrying {
+ return
}
+ pending := a.unclosedParents
+ a.unclosedParents, a.parentsRetrying = nil, true
+ go func() {
+ var still []*cgroup.Prepared
+ for _, p := range pending {
+ if err := closeStrictParent(p); err != nil {
+ still = append(still, p)
+ }
+ }
+ a.mu.Lock()
+ a.unclosedParents = append(a.unclosedParents, still...)
+ a.parentsRetrying = false
+ a.mu.Unlock()
+ }()
}
// setParent raises the parent to the incumbent's tier plus the successor's
@@ -7391,6 +7653,8 @@ In `leaveSet` (`opset.go`), the restore block becomes:
}
```
+Add to `Service`: `unclosedParents []*cgroup.Prepared; parentsRetrying bool // strict parents a stop could not close, kept and retried; under a.mu`. Call `a.retryUnclosedParentsLocked()` beside Task 2's `a.reapUnprovenWakesLocked()` at the top of `finishPendingStops`.
+
In `finishStop` (`activator.go`), insert at the top, before `ctx, cancel := context.WithTimeout(...)`:
```go
@@ -7470,10 +7734,12 @@ package activator
import (
"context"
"errors"
+ "path/filepath"
"sync"
"testing"
"time"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
)
@@ -7512,6 +7778,23 @@ func TestWorkerGenerations(t *testing.T) {
}
}
+// StartWorker spawns every worker into the flat host cgroup, whatever the
+// row's layout, so KillHost proves that cgroup for a nested-looking row too.
+// Here the cgroup is a fixture, not cgroup v2, so the proof is refused.
+// Answering nil for a nested layout skipped the proof for a worker that
+// runs in carlos-exec@<host> all the same.
+func TestKillHostProvesANestedLayoutWorkersHostCgroup(t *testing.T) {
+ host := "nw.example.com"
+ b, _ := delegatedBackend(t, host)
+ rd := t.TempDir()
+ b.RunDirOverride = rd
+ rt := registry.Route{Host: host, Kind: "sidecar", DBPath: provision.NestedDBPath(b.DataDir, host),
+ Socket: filepath.Join(rd, "instances", provision.InstanceID(host), "app.sock")}
+ if err := b.KillHost(context.Background(), rt); err == nil {
+ t.Fatal("KillHost answered a nested-layout worker's host cgroup as proven without proving it")
+ }
+}
+
// hostKillBackend is fakeBackend plus KillHost, failing while bad.
type hostKillBackend struct {
*fakeBackend
@@ -7567,6 +7850,65 @@ func TestWorkerTerminationProvesItsHostCgroup(t *testing.T) {
a.sweepOnce()
waitFor(t, "the lease release", rep.isClosed)
})
+ t.Run("a respawn with no old handle still proves the host cgroup", func(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ a.gens = true
+ hk := &hostKillBackend{fakeBackend: b}
+ a.backend = hk
+ rt := testWorkerRoute("bot3.example.com")
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatal(err)
+ }
+ hk.hmu.Lock()
+ hk.bad = true
+ hk.hmu.Unlock()
+ a.mu.Lock()
+ // main's signature; once plan 4 Task 9 has merged, pass nil as its
+ // fourth (onto) argument.
+ a.respawnWorkerLocked(a.entries[rt.Host], nil, "test: a restart with no old handle")
+ a.mu.Unlock()
+ waitFor(t, "the respawn to finish", func() bool { return !a.entryRespawning(rt.Host) })
+ if got := b.workerStarts(); got != 1 {
+ t.Fatalf("worker starts = %d: a nil-stopOld respawn skipped the host cgroup proof", got)
+ }
+ })
+ t.Run("a failed worker wake keeps its lease", func(t *testing.T) {
+ old := sidecarSettle
+ sidecarSettle = 20 * time.Millisecond
+ t.Cleanup(func() { sidecarSettle = old })
+ a, b, _, _ := testActivator(t, time.Hour)
+ a.gens = true
+ hk := &hostKillBackend{fakeBackend: b, bad: true}
+ a.backend = hk
+ b.mu.Lock()
+ b.workerDeadOnStart = true // the wake fails its settle
+ b.mu.Unlock()
+ if err := a.WakeWorker(testWorkerRoute("bot4.example.com")); err == nil {
+ t.Fatal("a worker dead on start woke")
+ }
+ // The lease goes back by Release, or by closing the replica when the
+ // wake had one (cleanupFailedWake).
+ given := func() bool {
+ b.mu.Lock()
+ released, reps := b.releases, append([]*fakeRep(nil), b.reps...)
+ b.mu.Unlock()
+ if released > 0 {
+ return true
+ }
+ for _, r := range reps {
+ if r.isClosed() {
+ return true
+ }
+ }
+ return false
+ }
+ if given() {
+ t.Fatal("a failed worker wake gave its lease back over an unproven host cgroup")
+ }
+ hk.heal()
+ a.sweepOnce()
+ waitFor(t, "the lease given back", given)
+ })
t.Run("a respawn execs nothing beside the old child", func(t *testing.T) {
old := sidecarSettle
sidecarSettle = 20 * time.Millisecond
@@ -7594,7 +7936,7 @@ func TestWorkerTerminationProvesItsHostCgroup(t *testing.T) {
- [ ] **Step 2: Run the test to verify it fails**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestWorkerGenerations|TestWorkerTermination' -count=1`
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestWorkerGenerations|TestWorkerTermination|TestKillHostProvesANestedLayout' -count=1`
Expected: FAIL: `gens true: worker generation {N:0 ...}`.
- [ ] **Step 3: Write the code**
@@ -7620,6 +7962,18 @@ Add the field to plan 1's `Gen`, after Task 1's `Tier`:
Worker bool
```
+A failed worker wake carries its Worker generation too, so `cleanupFailedWake` (Task 2) proves its host cgroup before the lease goes. In `doWake`'s worker branch, directly after the spawn line that sets `proc` (plan 4's `startWorker` call, or plan 5's `spawnPlannedWorker`) and before any error handling, add:
+
+```go
+ if a.gens {
+ // The wake's generation, whether or not the worker becomes ready
+ // (zero-downtime spec §7.4): cleanupFailedWake proves it.
+ w.gen = Gen{N: nextGen(), Build: ident.Version, Worker: true}
+ }
+```
+
+and make the `wake()` block above keep a number already set: `if a.gens && e.worker && e.gen.N == 0 {` stays as the fallback for a worker woken some other way.
+
In `opset.go`'s `genChildEmpty`, insert directly after its `claimProcess` branch:
```go
@@ -7656,13 +8010,11 @@ Add to `genbackend.go`:
```go
// KillHost kills whatever is left in rt's own host cgroup and returns once
// it is empty: a worker's termination proof (zero-downtime spec §7.4, §7.9).
-// A nested or unit-backed worker is a systemd unit, whose stop already
-// takes its whole cgroup, so there is nothing more to prove here. An absent
-// cgroup is empty. No cgroup delegation at all is an unproven kill.
+// It does not look at the row's layout: StartWorker spawns every worker
+// through spawnInCgroup into carlos-exec@<host>, whatever the layout says,
+// so a nested-looking worker is proven there too. An absent cgroup is
+// empty. No cgroup delegation at all is an unproven kill.
func (b *ExecBackend) KillHost(ctx context.Context, rt registry.Route) error {
- if p := b.pathsFor(rt); p.layout == LayoutNested {
- return nil
- }
if b.Cgroups == nil {
return cgroup.ErrNoDelegation
}
@@ -7738,7 +8090,12 @@ Directly after the retire block (`if stopOld != nil { … }`) and before the "St
// spec §7.4, §7.9). Unproven: nothing spawns; the entry keeps its
// lease, and the sweep's crash path stops it through genTerminated.
retired := true
- if a.gens && stopOld != nil {
+ if a.gens {
+ // stopOld may be nil (a crash restart, a restore): the old child
+ // is gone, but what it forked lives in the host cgroup, and
+ // genTerminated proves a Worker generation there with or
+ // without a handle.
+ oldGen.Worker = true
retired = a.genTerminated(rt, oldGen, stopOld)
}
```
@@ -8653,7 +9010,7 @@ M4|opset.go|swapsim:M4|s/proven := a\.stopMember\(e, m\)/proven := true; go a.st
M5|swapadmit.go|swapsim:M5|s/!waking \&\& e\.state != actAwake/false/
M6|activator.go|swapsim:M6|s/if a\.gens \{/if false {/
M7|opset.go|swapsim:M7|s/return a\.genTerminated\(rt, m\.gen, m\.proc\)/return true || a.genTerminated(rt, m.gen, m.proc)/
-M8|activator.go|swapsim:M8|s/if !a\.genTerminated\(/if false \&\& !a.genTerminated(/'
+M8|activator.go|swapsim:M8|s/if a\.gens \&\& !a\.releasable\(e\)/if false \&\& !a.releasable(e)/'
fail=0
while IFS='|' read -r name file marker sub; do
src="$SCRATCH/tree/$A/$file"
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md
index be62862ed..eb475e251 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md
@@ -2791,8 +2791,9 @@ type fakeRoutes struct {
getHook func(host string)
getErr func(host string) error
// setGoodHook, when set, runs at the start of every SetGoodBuild, with
- // no lock held.
- setGoodHook func(host string)
+ // no lock held. setServingHook does the same for SetServingVersion.
+ setGoodHook func(host string)
+ setServingHook func(host string)
}
func (f *fakeRoutes) Get(host string) (*registry.Route, error) {
@@ -2846,6 +2847,12 @@ func (f *fakeRoutes) SetGoodBuild(host string, expect, next registry.GoodBuild)
}
func (f *fakeRoutes) SetServingVersion(host, ver string) error {
+ f.mu.Lock()
+ hook := f.setServingHook
+ f.mu.Unlock()
+ if hook != nil {
+ hook(host)
+ }
f.mu.Lock()
defer f.mu.Unlock()
r := f.rows[host]
@@ -4138,10 +4145,20 @@ func (m *Manager) served(host string, g activator.Gen) {
m.d.Logf("rollout: %s: generation %d (%s) is no longer served; not recording it", host, g.N, g.Build)
return
}
+ m.setServingLocked(host, g)
+ m.writeGoodLocked(host, g)
+}
+
+// setServingLocked writes an instance's serving build. A worker's is the
+// activator's alone (Task 11, publishWorkerServing, ordered by incarnation),
+// so two writers never race on one row. The caller holds m.goodMu.
+func (m *Manager) setServingLocked(host string, g activator.Gen) {
+ if g.Worker {
+ return
+ }
if err := m.d.Routes.SetServingVersion(host, g.Build); err != nil {
m.d.Logf("rollout: %s: record serving build %s: %v", host, g.Build, err)
}
- m.writeGoodLocked(host, g)
}
// publishServing records the serving build alone (a healthy first wake, a
@@ -4155,9 +4172,7 @@ func (m *Manager) publishServing(host string, g activator.Gen) {
if !m.acknowledged(host, g) {
return
}
- if err := m.d.Routes.SetServingVersion(host, g.Build); err != nil {
- m.d.Logf("rollout: %s: record serving build %s: %v", host, g.Build, err)
- }
+ m.setServingLocked(host, g)
}
// acknowledged reports whether g is the generation the activator serves
@@ -6091,6 +6106,35 @@ func TestAFellBackWakeRecordsTheFailureAndKeepsTheGoodBuild(t *testing.T) {
}
}
+// Spec §8.1: a fallback publishes the failure hold before the serving build
+// and the active config. Writing the serving build first showed the good
+// build serving with no hold on the failed one, a window in which a wake or
+// an admission could select the failed build again.
+func TestAFallbackHoldsTheFailedBuildBeforeItPublishesServing(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ h.install("v2", "a")
+ h.pass()
+ h.act.serve("a", "v1")
+ served, _ := h.act.Current("a")
+ var early atomic.Int32
+ h.rows.mu.Lock()
+ h.rows.setServingHook = func(string) {
+ if _, ok := h.book.Lookup("bab", "blog", "v2"); !ok {
+ early.Add(1)
+ }
+ }
+ h.rows.mu.Unlock()
+ h.m.OnGen(activator.GenEvent{Kind: activator.GenFellBack, Route: h.rows.get("a"), FirstWake: true, Mode: activator.ModeOverlap,
+ Gen: served, Failed: release.StagedIdentity{Version: "v2"}, Reason: "health check failed"})
+ if n := early.Load(); n != 0 {
+ t.Fatal("the serving build was published before the failed build was held")
+ }
+ if r := h.rows.get("a"); r.ServingVersion != "v1" {
+ t.Fatalf("serving = %q, want the good v1", r.ServingVersion)
+ }
+}
+
func TestOnlyASettledFirstWakeBecomesTheGoodBuild(t *testing.T) {
h := newHarness(t, "a")
h.stage("v2")
@@ -6109,6 +6153,21 @@ func TestOnlyASettledFirstWakeBecomesTheGoodBuild(t *testing.T) {
}
}
+// Spec §7.9: a worker's serving build has one writer, the activator
+// (ordered by incarnation). The scheduler records a worker's good build but
+// leaves its ServingVersion alone, so its write can never interleave with
+// the activator's and restore an older value.
+func TestTheSchedulerLeavesAWorkersServingVersionAlone(t *testing.T) {
+ h := newHarness(t, "a")
+ h.act.serve("a", "v2")
+ g, _ := h.act.Current("a")
+ g.Worker = true
+ h.m.served("a", g)
+ if r := h.rows.get("a"); r.ServingVersion != "" || r.GoodVersion != "v2" {
+ t.Fatalf("row = %+v: want the good build recorded and the serving build left to the activator", r)
+ }
+}
+
// Spec §8.5: a good build is written expecting the current one. A late
// success of an older generation (a completion delayed past a newer swap, a
// stale wake event) is not the generation the activator serves, so it never
@@ -6285,7 +6344,7 @@ Add `"sync/atomic"`, `"time"` and `"github.com/carlosframework/platform/internal
- [ ] **Step 2: Run them to see them fail**
-Run: `go test ./internal/rollout/ -run 'TestDecideWake|TestPlanBoots|TestAFellBack|TestOnlyASettled|TestAStaleSuccess|TestAFailedHold|TestAFailedFirstWakeIsRecorded|TestAWakeActivates|TestASleepersConfig|TestAForeignRowsFailedHold|TestAColdManagedHost' -count=1`
+Run: `go test ./internal/rollout/ -run 'TestDecideWake|TestPlanBoots|TestAFellBack|TestOnlyASettled|TestAStaleSuccess|TestAFailedHold|TestAFailedFirstWakeIsRecorded|TestAFallbackHolds|TestTheSchedulerLeavesAWorkers|TestAWakeActivates|TestASleepersConfig|TestAForeignRowsFailedHold|TestAColdManagedHost' -count=1`
Expected: FAIL, `undefined: decideWake`.
- [ ] **Step 3: Write the planner and the outcome handler**
@@ -6462,9 +6521,12 @@ func (m *Manager) OnGen(ev activator.GenEvent) {
case activator.GenSettled:
m.served(rt.Host, ev.Gen)
case activator.GenFellBack:
+ // The failure hold first (spec §8.1): nothing that reads the
+ // serving build or the active config may see the fallback before the
+ // failed build is held.
+ m.buildFailed(rt, ev)
m.publishServing(rt.Host, ev.Gen)
m.wakeConfigActive(rt.Host)
- m.buildFailed(rt, ev)
m.emit(rt, EvFallbackServed, "warn", textFallback(ev.Failed.Version, ev.Gen.Build))
case activator.GenFailed:
m.buildFailed(rt, ev)
@@ -6558,6 +6620,7 @@ package activator
import (
"context"
+ "errors"
"strings"
"sync/atomic"
"testing"
@@ -6750,6 +6813,35 @@ func TestReplaceWorkerReChecksAtTheSpawnAndBeforeTheResult(t *testing.T) {
})
}
+// Spec §7.4: a respawn whose gate finds the host no longer owned (here the
+// lease lost while the old child drained) spawns nothing at all, the
+// replaced build included. Turning every refusal into a restore ran the old
+// build against a database the box no longer owned.
+func TestReplaceWorkerWithoutOwnershipSpawnsNothing(t *testing.T) {
+ shrinkFirstWake(t, 55*time.Second, 25*time.Second, 100*time.Millisecond)
+ a, b, _, _, _ := stagedActivator(t)
+ host := "bot.x.internal"
+ if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
+ t.Fatal(err)
+ }
+ gate := make(chan struct{})
+ b.lastWorker().setStopGate(gate)
+ results := make(chan SwapResult, 1)
+ go func() {
+ results <- a.ReplaceWorker(context.Background(), workerAttempt(CauseRelease, ModeOverlap, fakeIdentity("v2")), nil, one)
+ }()
+ waitFor(t, "the retire to begin", func() bool { return a.entryRespawning(host) })
+ b.lastRep().setErr(errors.New("lease renewal failed")) // ownership lost mid-retire
+ close(gate)
+ if res := <-results; res.Outcome != SwapAbandoned {
+ t.Fatalf("ReplaceWorker = %+v, want abandoned", res)
+ }
+ waitFor(t, "the respawn to finish", func() bool { return !a.entryRespawning(host) })
+ if calls := b.calls(); len(calls) != 1 {
+ t.Fatalf("spawns %v: something exec'd after the lease was lost", calls)
+ }
+}
+
// A superseded attempt touches nothing.
func TestReplaceWorkerSupersededSpawnsNothing(t *testing.T) {
a, b, _, _, _ := stagedActivator(t)
@@ -6815,7 +6907,19 @@ becomes
// back instead, and ReplaceWorker abandons the attempt.
if gerr := e.attemptGate(); gerr != nil {
e.attemptVeto = gerr
- onto = e.attemptRestore
+ switch verdictOf(gerr) {
+ case gateSuperseded:
+ // Still the owner (the gate checked the entry, the lease,
+ // the fences and the agent before the revision, in this
+ // same critical section): the build it replaced may come
+ // back, through main's spawn below.
+ onto = e.attemptRestore
+ default:
+ // No ownership (the lease lost, a fence, shutdown, the
+ // entry gone): nothing spawns, not even a restore (spec
+ // §7.4). The entry stops through the sweep's crash path.
+ wanted = false
+ }
}
}
a.mu.Unlock()
@@ -7054,23 +7158,45 @@ A worker child that crashes during the observation is not recycled by the sweep
OnWorkerServing func(host string, id release.StagedIdentity)
```
-In `wake()`, in its success branch, capture the identity under the lock (`servingID, publish := e.workerServing, e.worker && a.OnWorkerServing != nil`, beside plan 4's `e.workerServing = w.spawnIdentity`), and after that block's `a.mu.Unlock()`, before `close(e.ready)`, add:
+**Ordered by incarnation** (P5 review finding 4). Every install of a worker child (a wake, a respawn) bumps the entry's incarnation under `a.mu`, and a publication is delivered only while its incarnation is still the entry's, checked and delivered under one mutex. An older publication that was delayed (A's callback paused while B was installed and published) is dropped, never written over B. The rollout manager does not write a worker's `ServingVersion` at all (its `served`/`publishServing` skip a `Gen.Worker` generation), so the activator is its only writer. Add to `Service` (`servingMu sync.Mutex // orders OnWorkerServing deliveries`) and to `actEntry` (`workerInc uint64 // a worker child's incarnation, bumped at every install; under a.mu`), and to `activator.go`:
```go
- if err == nil && publish && servingID.Version != "" {
- a.OnWorkerServing(e.host, servingID)
+// publishWorkerServing delivers a worker's captured identity for
+// incarnation inc of entry e (zero-downtime spec §7.9). It is delivered only
+// while e is the host's entry and inc its current incarnation, checked and
+// delivered under servingMu, so an older publication can never land after a
+// newer one. Called with no lock held.
+func (a *Service) publishWorkerServing(e *actEntry, id release.StagedIdentity, inc uint64) {
+ if a.OnWorkerServing == nil || id.Version == "" {
+ return
}
+ a.servingMu.Lock()
+ defer a.servingMu.Unlock()
+ a.mu.Lock()
+ current := a.entries[e.host] == e && e.workerInc == inc
+ a.mu.Unlock()
+ if !current {
+ return
+ }
+ a.OnWorkerServing(e.host, id)
+}
```
-In plan 4's `respawnWorkerLocked` goroutine, in the `default:` arm, after its `a.mu.Unlock()` and before its `log.Printf("activator: worker %s respawned …`, add:
+In `wake()`, in its success branch, beside plan 4's `e.workerServing = w.spawnIdentity`, add `e.workerInc++` and capture `servingID, inc, publish := e.workerServing, e.workerInc, e.worker`; after that block's `a.mu.Unlock()`, before `close(e.ready)`, add:
```go
- if a.OnWorkerServing != nil && ident.Version != "" {
- a.OnWorkerServing(host, ident)
- }
+ if err == nil && publish {
+ a.publishWorkerServing(e, servingID, inc)
+ }
+```
+
+In plan 4's `respawnWorkerLocked` goroutine, in the `default:` arm, beside `e.workerServing = ident` add `e.workerInc++; inc := e.workerInc`, and after the arm's `a.mu.Unlock()`, before its `log.Printf("activator: worker %s respawned …`, add:
+
+```go
+ a.publishWorkerServing(e, ident, inc)
```
-(`ident` is the identity plan 4's arm installed as `e.workerServing`.) Append to `workerswap_test.go`:
+(`ident` is the identity plan 4's arm installed as `e.workerServing`.) Task 8's `served` and `publishServing` already leave a `Gen.Worker` generation's serving build alone (`setServingLocked`). Mark a worker's generations: `Current` returns `g := genOf(e.workerServing); g.Worker = true`, Task 9's `w.planned = genOf(ident)` becomes `w.planned = genOf(ident); w.planned.Worker = true`, and `ReplaceWorker`'s `served` helper sets `g.Worker = true`. Append to `workerswap_test.go`:
```go
// Spec §7.9: with CARLOS_SWAP unset (no generations, no rollout manager), a
@@ -7102,6 +7228,38 @@ func TestAWorkersServingIdentityIsPublishedWithTheFlagOff(t *testing.T) {
t.Fatalf("published %v, want the respawned child's %q last", got, want)
}
}
+
+// Spec §7.9: a delayed publication of an older worker incarnation never
+// overwrites a newer one. Here the wake's publication (incarnation 1) is
+// replayed after the respawn published incarnation 2: it is dropped. An
+// unconditional write left the older version on display indefinitely.
+func TestAnOlderWorkerPublicationIsDropped(t *testing.T) {
+ a, b, _, _, _ := stagedActivator(t)
+ var mu sync.Mutex
+ var got []string
+ a.OnWorkerServing = func(_ string, id release.StagedIdentity) {
+ mu.Lock()
+ got = append(got, id.Version)
+ mu.Unlock()
+ }
+ host := "bot.x.internal"
+ if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
+ t.Fatal(err)
+ }
+ a.mu.Lock()
+ e := a.entries[host]
+ firstID, firstInc := e.workerServing, e.workerInc
+ a.mu.Unlock()
+ b.lastWorker().crash()
+ a.sweepOnce()
+ waitFor(t, "the respawn's publication", func() bool { mu.Lock(); defer mu.Unlock(); return len(got) == 2 })
+ a.publishWorkerServing(e, firstID, firstInc) // the older one, delayed
+ mu.Lock()
+ defer mu.Unlock()
+ if len(got) != 2 {
+ t.Fatalf("published %v: an older incarnation's publication was delivered after a newer one", got)
+ }
+}
```
(add `"sync"` to that file's imports).