| index a18fe9ad2..5fe09af8f 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 |
| @@ -42,7 +42,7 @@ Every command below runs from `/home/paulca/amadan.net/carlos/platform-zdd-3-swa |
| - **Nothing calls `Service.Swap` in this plan.** Plan 5 wires it. |
| - **Spec values, verbatim:** "`GET /` below 500 within `spawnHealthTimeout` (30 s)". "A canary must then survive the 10 s settle." The retiring generation gets "SIGTERM … with `actStopTimeout` (20 s) before SIGKILL". The hold holds requests "up to `wakeWaitBound`" (30 s). "There is one successor per entry at a time." The canonical path is "replaced atomically (temp link plus rename) at every switch". |
| - **The §7.4 checks** are: still awake, lease still held, still the newest attempt, and no stop-evidence operation in progress. They run before the spawn and again before the switch. Claims off (`hostwrite.Legacy`) runs them under `a.mu`, against the barrier, the lease and `a.fences`. Claims on runs them inside `Work.With`, which validates the claim and the installation stamp first. **A failed claims-on check abandons the successor. It is never treated as permission to take the claims-off path.** |
| -- **Foreign-layout rows** keep their canonical socket (plan 1's accepted reading). They never overlap. An overlap attempt on one is abandoned before any spawn. A `hold` works on any layout. |
| +- **Foreign-layout rows** keep their canonical socket (plan 1's accepted reading). They never overlap. `StartStaged` spawns one on its canonical socket for a cold, pinned or hold start (Task 1), and `Swap` runs an overlap attempt on one as a hold (Task 6). A `hold` works on any layout. |
| - **#231 test trap:** `Service.Owns` stays true while an entry drains. Prove a stop from the backend's spawn count after a fresh request (`startCount()`, `genCalls`), never from `Owns`. |
| - **Copy:** |
| - No new member-facing event text. The one event this plan emits is the existing `"instance stopping"`, for the retiring generation. |
| @@ -96,7 +96,9 @@ The cross-plan items this plan owns (index review focus 2 and 4) are Task 12. |
| | `internal/activator/staged.go` (create) | `verifyStaged` | |
| | `internal/activator/claimgen.go` (create) | Claims-on generations (Task 10): `ClaimRuntime.Generations`, the entry's strict parent, `startClaimedSuccessor` | |
| | `internal/activator/activator.go` (modify) | `actEntry.ops`, `pin`, `holdNext`; `Service.genHealth`; `wake()`; `doWake` (pinned spawn, sidecar); `stop()` barrier; `finishPendingStops`; `finishStop` (hold, parent); `sweepOnce` sidecar; `Shutdown` (waking); `startSidecar`, `replaceSidecar`; `respawnWorkerLocked` (Task 11) | |
| -| `internal/activator/backend_exec.go` (modify) | `ExecBackend.Retired`; `startChildFrom`; `startSidecarChildFrom`; `MountGated` in the flat spawn | |
| +| `internal/activator/backend_exec.go` (modify) | `ExecBackend.Retired`, `ExecBackend.TierOf`; `spawnChildFrom` (spawn without health); `startSidecarChildFrom`; `startSidecarUnitFrom`; `MountGated` in the flat spawn | |
| +| `internal/activator/containment.go` (modify) | `spawnInCgroupAtTier` | |
| +| `internal/host/memtier.go` (modify) | `memTierOf` | |
| | `internal/activator/prepare.go` (modify) | `MountGated` in `listenControl` | |
| | `internal/activator/claimwake.go` (modify) | `StartPolicy.Pin`, `StartPolicy.Gen`, `StartPolicy.Fallback`, `StartPolicy.HealthBy`; `ClaimStart.Gen`; the checks, the health cap and the one-time fallback in `startAdmitted` | |
| | `internal/instancectl/client.go`, `server.go`, `stagedexe.go` (modify, create) | The staged executable on the nested generation start, and root's validation of it (Task 9) | |
| @@ -114,11 +116,11 @@ The cross-plan items this plan owns (index review focus 2 and 4) are Task 12. |
| |
| **Files:** |
| - Create: `internal/activator/swap.go`, `internal/activator/genbackend.go`, `internal/activator/staged.go` |
| -- Modify: `internal/activator/backend_exec.go` (plan 1's `startChildAt`) |
| -- Test: `internal/activator/genbackend_test.go` |
| +- Modify: `internal/activator/backend_exec.go` (plan 1's `startChildAt`, `ExecBackend` fields), `internal/host/memtier.go` (`memTierOf`), `internal/host/agent.go` (`TierOf` wiring) |
| +- Test: `internal/activator/genbackend_test.go`, `internal/host/memtier_test.go` (append) |
| |
| **Interfaces:** |
| -- Consumes: C1, C5, C6 and C7. |
| +- Consumes: C1, C5, C6 and C7; plan 2 Task 10's `placeFor`, `spawnInCgroupAt` and `Cgroups.StartChild(host, child, parent, own, build)`; `release.ReadStagedManifest`, `memTier` (`internal/host/memtier.go`). |
| - Produces: |
| ```go |
| type Cause string; const CauseRelease, CauseInstall, CauseRetry, CauseRestart, CauseConfig Cause |
| @@ -126,18 +128,30 @@ The cross-plan items this plan owns (index review focus 2 and 4) are Task 12. |
| type Attempt struct{ Host string; Cause Cause; Build, StagedPath, Digest, Demand string; Mode SwapMode; ConfigRev, RestartID string; SchedRev uint64; Canary bool } |
| type SwapOutcome string; const SwapDone, SwapFailed, SwapAbandoned, SwapDown SwapOutcome |
| type SwapResult struct{ Outcome SwapOutcome; Gen Gen; Reason string; Drain time.Duration } |
| + // Gen gains (index: "a plan may add fields"): Tier cgroup.Limit, the artifact's own tier, captured at attempt time |
| type SwapBackend interface { |
| + // StartStaged spawns and returns at once: no health wait inside. The caller owns the health deadline and the termination. |
| StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error) |
| - RaiseGenParent(ctx context.Context, rt registry.Route) error |
| - RestoreGenParent(ctx context.Context, rt registry.Route) error |
| + GenTier(rt registry.Route, build string) cgroup.Limit |
| + GenReady(rt registry.Route, g Gen, p Proc) |
| + RaiseGenParent(ctx context.Context, rt registry.Route, incumbent, successor cgroup.Limit) error |
| + RestoreGenParent(ctx context.Context, rt registry.Route, tier cgroup.Limit) error |
| GenSwitched(rt registry.Route, g Gen, p Proc) error |
| } |
| var errSwapNoGens, errUnitNoGens, errSwapLayout, errNestedStaged error |
| func verifyStaged(path, digest string) error |
| - func (b *ExecBackend) startChildFrom(ctx context.Context, rt registry.Route, sock string, n uint64, exe string) (Proc, string, error) |
| + // ExecBackend gains: TierOf func(rt registry.Route, version string) cgroup.Limit |
| + func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, sock string, n uint64, exe string, tier cgroup.Limit) (Proc, string, error) // spawn only |
| + func (b *ExecBackend) spawnInCgroupAtTier(rt registry.Route, child string, ownTier bool, tier cgroup.Limit, build func() *exec.Cmd) (*exec.Cmd, error) |
| + func memTierOf(load func(rt registry.Route, version string) (*release.Manifest, error)) func(registry.Route, string) cgroup.Limit // internal/host |
| ``` |
| `SwapBackend` is new and separate from plan 1's `GenBackend` (`StartGen(ctx, rt, n, perGen)`, the wake's live-link spawn). Plan 3 never changes plan 1's interface. |
| |
| + Three rules this seam keeps (findings 3, 8 and 9 of the P1 review): |
| + - **Spawning is not health.** Plan 1's `startChildAt` (and `startUnitAt`) wait for health inside the spawn, and on a failure they stop the process and return a nil handle. A caller that reads nil as "nothing executed" then never records the failure or falls back (spec §8.2), and its own shorter deadline only starts after the backend's 30 s. So the body is split at the health wait: `spawnChildFrom` returns the process the moment it runs, and `startChildAt` is `spawnChildFrom` plus today's health tail, byte for byte. `StartStaged` uses the spawn half only. Every caller (`swapOverlap`, `startPinned`, plan 5's `spawnPlanned`) health-checks through the handle with its own deadline and stops or kills what failed. `GenReady` widens the socket mode once a pinned wake is healthy, which the health tail used to do (issue #103). |
| + - **Each artifact's own tier** (spec §7.3, plan 2 D3). `b.tier(rt)` reads the live link's manifest, which root can repoint at any time, and which names neither the incumbent's build nor the successor's after a fallback. `GenTier(rt, build)` reads that build's staged manifest (immutable once referenced, plan 4). The swap captures the incumbent's tier and the successor's at admission into `Gen.Tier`, the parent is raised to their sum, each staged child gets its own `g.Tier`, and the restore uses the current generation's captured tier. |
| + - **Foreign rows** keep their canonical socket (plan 1). `StartStaged` spawns one on it, for a cold, pinned or hold start, where only one generation exists at a time. `Swap` runs an overlap attempt on a foreign row as a hold (Task 4). |
| + |
| - [ ] **Step 1: Check the plan 1 and 2 contract** |
| |
| Run: |
| @@ -167,11 +181,13 @@ import ( |
| "encoding/hex" |
| "errors" |
| "os" |
| + "os/exec" |
| "path/filepath" |
| "strings" |
| "testing" |
| "time" |
| |
| + "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| |
| @@ -221,9 +237,15 @@ func (r *recordingGenExec) StartStaged(_ context.Context, _ registry.Route, g Ge |
| r.started++ |
| return g, &fakeProc{}, nil |
| } |
| -func (r *recordingGenExec) RaiseGenParent(context.Context, registry.Route) error { return nil } |
| -func (r *recordingGenExec) RestoreGenParent(context.Context, registry.Route) error { return nil } |
| -func (r *recordingGenExec) GenSwitched(registry.Route, Gen, Proc) error { return nil } |
| +func (r *recordingGenExec) GenTier(registry.Route, string) cgroup.Limit { return cgroup.TierDefault } |
| +func (r *recordingGenExec) GenReady(registry.Route, Gen, Proc) {} |
| +func (r *recordingGenExec) RaiseGenParent(context.Context, registry.Route, cgroup.Limit, cgroup.Limit) error { |
| + return nil |
| +} |
| +func (r *recordingGenExec) RestoreGenParent(context.Context, registry.Route, cgroup.Limit) error { |
| + return nil |
| +} |
| +func (r *recordingGenExec) GenSwitched(registry.Route, Gen, Proc) error { return nil } |
| |
| // Swaps are for exec rows only (spec §2 decision 9): a unit-backed row is |
| // refused, and an exec half that cannot run staged generations refuses |
| @@ -236,7 +258,7 @@ func TestSplitBackendSwapsAreExecOnly(t *testing.T) { |
| if _, _, err := s.StartStaged(ctx, unitRt, Gen{N: 1}); !errors.Is(err, errUnitNoGens) { |
| t.Fatalf("unit row StartStaged: %v, want errUnitNoGens", err) |
| } |
| - if err := s.RaiseGenParent(ctx, unitRt); !errors.Is(err, errUnitNoGens) { |
| + if err := s.RaiseGenParent(ctx, unitRt, cgroup.TierDefault, cgroup.TierDefault); !errors.Is(err, errUnitNoGens) { |
| t.Fatalf("unit row RaiseGenParent: %v", err) |
| } |
| execRt := registry.Route{Host: "e.example.com"} |
| @@ -252,12 +274,8 @@ func TestSplitBackendSwapsAreExecOnly(t *testing.T) { |
| |
| // A flat staged generation execs the attempt's staged path, not the live |
| // link, on its own generation socket (spec §7.5 step 3). The fake binary |
| -// never binds, so the spawn's own health wait fails. What it recorded as |
| -// argv[0] is the proof. |
| +// never binds. What it recorded as argv[0] is the proof. |
| func TestExecBackendStartStagedExecsTheStagedPath(t *testing.T) { |
| - old := spawnHealthTimeout |
| - spawnHealthTimeout = 200 * time.Millisecond |
| - t.Cleanup(func() { spawnHealthTimeout = old }) |
| b := testExecBackend(t) |
| b.RunDirOverride = t.TempDir() |
| host := "staged.example.com" |
| @@ -281,12 +299,126 @@ func TestExecBackendStartStagedExecsTheStagedPath(t *testing.T) { |
| } |
| } |
| |
| -// A foreign row has no generation socket (plan 1) and never overlaps. |
| -func TestExecBackendStartStagedRefusesAForeignRow(t *testing.T) { |
| +// StartStaged is a spawn, not a health check (spec §8.2): it hands back the |
| +// running process at once, so the caller's own deadline governs and a build |
| +// that never answers is the caller's to record and kill. This runs the real |
| +// flat spawn with the real 30 s spawnHealthTimeout. A StartStaged that still |
| +// waits for health inside blocks for those 30 s and then returns a nil |
| +// handle, which a planned wake reads as "nothing executed". |
| +func TestExecBackendStartStagedReturnsTheProcessAtOnce(t *testing.T) { |
| b := testExecBackend(t) |
| - rt := registry.Route{Host: "f.example.com", Kind: "instance", Socket: filepath.Join(t.TempDir(), "odd.sock")} |
| - if _, _, err := b.StartStaged(context.Background(), rt, Gen{N: 1}); !errors.Is(err, errSwapLayout) { |
| - t.Fatalf("foreign row: %v, want errSwapLayout", err) |
| + b.RunDirOverride = t.TempDir() |
| + host := "silent.example.com" |
| + stageLive(t, b, host, "v1") |
| + v2 := filepath.Join(b.OptDir, "versions", "v2", "app") |
| + if err := os.MkdirAll(filepath.Dir(v2), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(v2, []byte("#!/bin/sh\nexec sleep 30\n"), 0o755); err != nil { // never binds |
| + t.Fatal(err) |
| + } |
| + rt := registry.Route{Host: host, Kind: "instance", DBPath: filepath.Join(b.DataDir, host+".db"), |
| + Socket: filepath.Join(b.RunDirOverride, host+".sock")} |
| + type out struct { |
| + g Gen |
| + proc Proc |
| + err error |
| + } |
| + done := make(chan out, 1) |
| + go func() { |
| + g, p, err := b.StartStaged(context.Background(), rt, Gen{N: 6, Build: "v2", StagedPath: v2}) |
| + done <- out{g, p, err} |
| + }() |
| + var o out |
| + select { |
| + case o = <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("StartStaged waited for health: it must return the running process at once") |
| + } |
| + if o.err != nil || o.proc == nil { |
| + t.Fatalf("StartStaged = %v, %v: want the running process", o.proc, o.err) |
| + } |
| + defer o.proc.Stop(context.Background()) //nolint:errcheck |
| + if o.proc.Exited() { |
| + t.Fatal("the returned process has already exited") |
| + } |
| + if o.g.Socket != b.flatGenSocket(host, 6) { |
| + t.Fatalf("Socket = %q, want generation 6's", o.g.Socket) |
| + } |
| +} |
| + |
| +// A foreign row keeps its canonical socket (plan 1): a cold, pinned or hold |
| +// start execs the staged build there, one generation at a time. Refusing it |
| +// would break every planned wake of a foreign row once the flag is on. |
| +func TestExecBackendStartStagedRunsAForeignRowOnItsCanonicalSocket(t *testing.T) { |
| + b := testExecBackend(t) |
| + host := "f.example.com" |
| + marker := filepath.Join(t.TempDir(), "argv0") |
| + v2 := filepath.Join(b.OptDir, "versions", "v2", "app") |
| + if err := os.MkdirAll(filepath.Dir(v2), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(v2, []byte("#!/bin/sh\nprintf '%s' \"$0\" > '"+marker+"'\n"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt := registry.Route{Host: host, Kind: "instance", DBPath: filepath.Join(b.DataDir, host+".db"), |
| + Socket: filepath.Join(t.TempDir(), "odd.sock")} // hand-set: LayoutForeign |
| + if l, _ := b.Layout(rt); l != LayoutForeign { |
| + t.Fatalf("fixture layout = %v, want LayoutForeign", l) |
| + } |
| + g, proc, err := b.StartStaged(context.Background(), rt, Gen{N: 1, Build: "v2", StagedPath: v2}) |
| + if err != nil { |
| + t.Fatalf("foreign row: %v", err) |
| + } |
| + defer proc.Stop(context.Background()) //nolint:errcheck |
| + if g.Socket != rt.Socket || g.Control != rt.Socket+".control" { |
| + t.Fatalf("foreign generation sockets %q %q, want the canonical %q", g.Socket, g.Control, rt.Socket) |
| + } |
| + if got := waitForFile(t, marker, 2*time.Second); got != v2 { |
| + t.Fatalf("the generation execd %q, want the staged %q", got, v2) |
| + } |
| +} |
| + |
| +// Each artifact's own tier (spec §7.3): a staged child gets g.Tier, not the |
| +// live link's tier, and the overlap parent is the incumbent's tier plus the |
| +// successor's. Here the live link (b.Tier) says 512 MiB throughout, while |
| +// the incumbent declares children (1 GiB) and the successor does not. Using |
| +// b.tier(rt) for both terms gives a 1 GiB parent, and an incumbent that |
| +// fills its own 1 GiB is OOM-killed by the successor's first allocation. |
| +func TestExecBackendUsesEachArtifactsTier(t *testing.T) { |
| + const host = "t.example.com" |
| + b, parent := delegatedBackend(t, host) |
| + b.GenContainment = true |
| + b.Tier = func(registry.Route) cgroup.Limit { return cgroup.TierDefault } |
| + b.TierOf = func(_ registry.Route, v string) cgroup.Limit { |
| + if v == "v1" { |
| + return cgroup.TierChildren |
| + } |
| + return cgroup.TierDefault |
| + } |
| + rt := registry.Route{Host: host} |
| + if got := b.GenTier(rt, "v1"); got != cgroup.TierChildren { |
| + t.Fatalf("GenTier(v1) = %v, want the children tier", got) |
| + } |
| + if err := b.RaiseGenParent(context.Background(), rt, b.GenTier(rt, "v1"), b.GenTier(rt, "v2")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, err := os.ReadFile(filepath.Join(parent, "memory.max")); err != nil || string(got) != "1610612736" { |
| + t.Fatalf("overlap parent memory.max = %q, %v; want 1 GiB + 512 MiB", got, err) |
| + } |
| + cmd, err := b.spawnInCgroupAtTier(rt, "g9", true, cgroup.TierChildren, func() *exec.Cmd { return exec.Command("/bin/true") }) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _ = cmd.Wait() |
| + if got, err := os.ReadFile(filepath.Join(parent, "g9", "memory.max")); err != nil || string(got) != "1073741824" { |
| + t.Fatalf("g9 memory.max = %q, %v; want its own artifact's 1 GiB", got, err) |
| + } |
| + if err := b.RestoreGenParent(context.Background(), rt, cgroup.TierChildren); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, _ := os.ReadFile(filepath.Join(parent, "memory.max")); string(got) != "1073741824" { |
| + t.Fatalf("restored parent memory.max = %q, want the current generation's 1 GiB", got) |
| } |
| } |
| |
| @@ -311,7 +443,7 @@ func TestExecBackendGenSwitchedRepointsTheCanonicalLink(t *testing.T) { |
| // and the swap is refused before anything spawns. |
| func TestExecBackendRaiseNeedsContainment(t *testing.T) { |
| b := testExecBackend(t) |
| - if err := b.RaiseGenParent(context.Background(), registry.Route{Host: "h.example.com"}); !errors.Is(err, errNoGenContainment) { |
| + if err := b.RaiseGenParent(context.Background(), registry.Route{Host: "h.example.com"}, cgroup.TierDefault, cgroup.TierDefault); !errors.Is(err, errNoGenContainment) { |
| t.Fatalf("RaiseGenParent without GenContainment: %v", err) |
| } |
| } |
| @@ -322,10 +454,39 @@ var ( |
| ) |
| ``` |
| |
| +Append to `internal/host/memtier_test.go`: |
| + |
| +```go |
| +// memTierOf reads the NAMED version's manifest, never the live link's: a |
| +// generation's limits are its own artifact's (zero-downtime spec §7.3). |
| +func TestMemTierOfReadsTheNamedVersion(t *testing.T) { |
| + rt := registry.Route{Host: "h.example.com", App: "app"} |
| + var asked []string |
| + tier := memTierOf(func(_ registry.Route, v string) (*release.Manifest, error) { |
| + asked = append(asked, v) |
| + if v == "v1" { |
| + return &release.Manifest{Capabilities: &caps.Set{V: caps.Version, Children: true}}, nil |
| + } |
| + return &release.Manifest{}, nil |
| + }) |
| + if got := tier(rt, "v1"); got != cgroup.TierChildren { |
| + t.Errorf("v1 = %v, want the children tier", got) |
| + } |
| + if got := tier(rt, "v2"); got != cgroup.TierDefault { |
| + t.Errorf("v2 = %v, want the default tier", got) |
| + } |
| + if strings.Join(asked, ",") != "v1,v2" { |
| + t.Errorf("read the manifests of %v, want v1 then v2", asked) |
| + } |
| +} |
| +``` |
| + |
| +(add `strings` to that file's imports). |
| + |
| - [ ] **Step 3: Run the tests to verify they fail** |
| |
| -Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestVerifyStaged|TestSplitBackendSwaps|TestExecBackendStartStaged|TestExecBackendGenSwitched|TestExecBackendRaise' -count=1` |
| -Expected: build failure: `undefined: verifyStaged`, `undefined: SwapBackend`, `b.StartStaged undefined`. |
| +Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator ./internal/host -run 'TestVerifyStaged|TestSplitBackendSwaps|TestExecBackendStartStaged|TestExecBackendGenSwitched|TestExecBackendRaise|TestExecBackendUsesEachArtifactsTier|TestMemTierOf' -count=1` |
| +Expected: build failure: `undefined: verifyStaged`, `undefined: SwapBackend`, `b.StartStaged undefined`, `undefined: memTierOf`. |
| |
| - [ ] **Step 4: Write the types and `verifyStaged`** |
| |
| @@ -448,24 +609,87 @@ func verifyStaged(path, digest string) error { |
| } |
| ``` |
| |
| -- [ ] **Step 5: Give the flat spawn an executable parameter** |
| +Add a field to plan 1's `Gen` (`internal/activator/generations.go`), after `Demand` (the index allows a plan to add fields), and the `internal/cgroup` import: |
| |
| -In `backend_exec.go`, rename the body of `startChildAt` (plan 1's, with plan 2's trailing generation number `n`, which places the spawn in its `g<n>` child) to `startChildFrom`, add an `exe string` parameter after `n`, and keep `startChildAt` as a forwarder: |
| +```go |
| + // Tier is the generation's own artifact's tier (zero-downtime spec |
| + // §7.3), captured when an attempt is admitted (plan 3). Zero for a |
| + // live-link wake until a swap captures it from Build. |
| + Tier cgroup.Limit |
| +``` |
| + |
| +- [ ] **Step 5: Split the flat spawn from its health wait, and give it an executable and a tier** |
| + |
| +Add to `ExecBackend`, after plan 2's `GenContainment` field: |
| + |
| +```go |
| + // TierOf is one named version's tier, read from that version's staged |
| + // manifest (zero-downtime spec §7.3). A generation's limits are its own |
| + // artifact's, never whatever the live link names at the moment. Nil |
| + // falls back to Tier (tests, and every flag-off spawn). |
| + TierOf func(rt registry.Route, version string) cgroup.Limit |
| +``` |
| + |
| +In `backend_exec.go`, split plan 1's `startChildAt` body (with plan 2's trailing generation number `n`, which places the spawn in its `g<n>` child) at its health wait. Everything from the top of the body down to, but not including, `if err := waitHealthyCtx(ctx, sock, spawnHealthTimeout); err != nil {` becomes `spawnChildFrom`, which ends with `return p, spawnVer, nil`. The health tail stays in `startChildAt`, unchanged: |
| |
| ```go |
| func (b *ExecBackend) startChildAt(ctx context.Context, rt registry.Route, sock string, n uint64) (Proc, string, error) { |
| - return b.startChildFrom(ctx, rt, sock, n, "") |
| + p, spawnVer, err := b.spawnChildFrom(ctx, rt, sock, n, "", cgroup.Limit{}) |
| + if err != nil { |
| + return p, "", err |
| + } |
| + if err := waitHealthyCtx(ctx, sock, spawnHealthTimeout); err != nil { |
| + stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout) |
| + defer cancel() |
| + _ = p.Stop(stopCtx) |
| + return nil, "", err |
| + } |
| + b.normalizeSocketMode(rt, sock, p) |
| + return p, spawnVer, nil |
| } |
| |
| -// startChildFrom is the flat spawn with the executable named. Empty is the |
| -// live link, which is every wake. A staged path is a swap's successor or a |
| -// hold's pinned wake (zero-downtime spec §7.5 step 3): the bytes the attempt |
| -// chose, whatever root does to the live link meanwhile. n is the generation |
| -// number plan 2's containment places the child by. |
| -func (b *ExecBackend) startChildFrom(ctx context.Context, rt registry.Route, sock string, n uint64, exe string) (Proc, string, error) { |
| +// spawnChildFrom is the flat spawn with the executable and the tier named, |
| +// and nothing after the exec: it returns the moment the process runs. Empty |
| +// exe is the live link, which is every wake (startChildAt adds today's |
| +// health wait). A staged path is a swap's successor or a pinned wake |
| +// (zero-downtime spec §7.5 step 3): the bytes the attempt chose, whatever |
| +// root does to the live link meanwhile, with the caller owning the health |
| +// deadline and the termination. tier is that artifact's own tier; zero is |
| +// the live tier (b.tier). n is the generation number plan 2's containment |
| +// places the child by. |
| +func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, sock string, n uint64, exe string, tier cgroup.Limit) (Proc, string, error) { |
| ``` |
| |
| -Inside the body, make three replacements. |
| +`spawnChildFrom`'s early failure returns keep their shape; a failure after the exec (there is none today once `cmd.Start` succeeded) would return the process with the error, never nil. Inside the body, make four replacements. |
| + |
| +Plan 2's spawn call, `cmd, err := b.spawnInCgroupAt(rt, child, ownTier, func() *exec.Cmd {`, becomes: |
| +```go |
| + cmd, err := b.spawnInCgroupAtTier(rt, child, ownTier, tier, func() *exec.Cmd { |
| +``` |
| + |
| +and add, beside plan 2's `spawnInCgroupAt` in `containment.go`: |
| + |
| +```go |
| +// spawnInCgroupAtTier is spawnInCgroupAt with the artifact's tier named: |
| +// the generation child's own limit, and the parent's floor for StartChild |
| +// (which never lowers a parent a swap has raised). Zero is b.tier(rt), so |
| +// spawnInCgroupAt is this with a zero tier. |
| +func (b *ExecBackend) spawnInCgroupAtTier(rt registry.Route, child string, ownTier bool, tier cgroup.Limit, build func() *exec.Cmd) (*exec.Cmd, error) { |
| + if child == "" { |
| + return b.spawnInCgroup(rt, build) |
| + } |
| + if tier == (cgroup.Limit{}) { |
| + tier = b.tier(rt) |
| + } |
| + own := cgroup.Limit{} |
| + if ownTier { |
| + own = tier |
| + } |
| + return b.Cgroups.StartChild(rt.Host, child, tier, own, build) |
| +} |
| +``` |
| + |
| +Then make plan 2's `spawnInCgroupAt` body a forwarder: `return b.spawnInCgroupAtTier(rt, child, ownTier, cgroup.Limit{}, build)`. (If `main`'s `spawnInCgroup` returns a `settle` func by then, carry it through both exactly as plan 2's merged `spawnInCgroupAt` does.) |
| |
| The live-link lines: |
| ```go |
| @@ -507,6 +731,28 @@ The spawn-time cache purge, `if b.Cache != nil {`, becomes: |
| if b.Cache != nil && exe == "" { |
| ``` |
| |
| +Append to `internal/host/memtier.go`: |
| + |
| +```go |
| +// memTierOf is memTier for one named version: that version's staged |
| +// manifest, never the live link's (zero-downtime spec §7.3: a generation's |
| +// limits are its own artifact's). A staged copy is immutable once anything |
| +// refers to it (plan 4), so the answer for a version does not move. |
| +func memTierOf(load func(rt registry.Route, version string) (*release.Manifest, error)) func(registry.Route, string) cgroup.Limit { |
| + return func(rt registry.Route, version string) cgroup.Limit { |
| + return memTier(func(rt registry.Route) (*release.Manifest, error) { return load(rt, version) })(rt) |
| + } |
| +} |
| +``` |
| + |
| +In `internal/host/agent.go`, beside `Tier: memTier(stagedManifestOfLiveVersion(release.OptDir())),`, add: |
| + |
| +```go |
| + TierOf: memTierOf(func(rt registry.Route, v string) (*release.Manifest, error) { |
| + return release.ReadStagedManifest(release.OptDir(), release.RouteAccount(rt), v, rt.App) |
| + }), |
| +``` |
| + |
| - [ ] **Step 6: Write the seam** |
| |
| Create `internal/activator/genbackend.go`: |
| @@ -527,7 +773,7 @@ import ( |
| var ( |
| errSwapNoGens = errors.New("swap: backend cannot run staged generations") |
| errUnitNoGens = errors.New("swap: unit-backed routes keep stop-then-start") |
| - errSwapLayout = errors.New("swap: only flat and nested rows have generation sockets") |
| + errSwapLayout = errors.New("swap: this row's layout has no generation sockets") |
| errNestedStaged = errors.New("swap: nested staged generations need Task 9") |
| ) |
| |
| @@ -538,17 +784,26 @@ var ( |
| // decision 9). |
| type SwapBackend interface { |
| // StartStaged spawns generation g.N of rt from g.StagedPath, never |
| - // through the live link, on the generation's own socket and in its own |
| - // child cgroup. It returns g with Socket and Control filled in. The |
| - // caller checks health through that handle; a health wait inside is |
| - // harmless. |
| + // through the live link, in its own child cgroup at g.Tier, on the |
| + // generation's own socket (a foreign row: its canonical socket). It |
| + // returns as soon as the process runs, with g's Socket and Control |
| + // filled in, and never waits for health: the caller checks health |
| + // through that handle under its own deadline, and stops (or kills) a |
| + // process that fails. A nil Proc means nothing ran. |
| StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error) |
| - // RaiseGenParent sets the host's cgroup parent to the overlap limit |
| - // before the successor spawns (spec §7.3). |
| - RaiseGenParent(ctx context.Context, rt registry.Route) error |
| - // RestoreGenParent sets the parent back to the tier, once every |
| - // generation but the current one has proven empty. |
| - RestoreGenParent(ctx context.Context, rt registry.Route) error |
| + // GenTier is build's own tier, from its staged manifest (spec §7.3). |
| + // The swap captures it into Gen.Tier at admission. |
| + GenTier(rt registry.Route, build string) cgroup.Limit |
| + // GenReady runs once a pinned wake's generation is healthy: it widens |
| + // the socket's mode (issue #103), as the live-link spawn's health tail |
| + // does. A swap's successor gets the same from GenSwitched. |
| + GenReady(rt registry.Route, g Gen, p Proc) |
| + // RaiseGenParent sets the host's cgroup parent to the incumbent's tier |
| + // plus the successor's, before the successor spawns (spec §7.3). |
| + RaiseGenParent(ctx context.Context, rt registry.Route, incumbent, successor cgroup.Limit) error |
| + // RestoreGenParent sets the parent back to tier, the current |
| + // generation's captured tier, once every other generation is empty. |
| + RestoreGenParent(ctx context.Context, rt registry.Route, tier cgroup.Limit) error |
| // GenSwitched runs under the entry lock at the switch (spec §7.5 step |
| // 5), before the in-memory switch. It points the canonical path at the |
| // new generation's socket (rev 15), purges the host's cached responses |
| @@ -557,10 +812,15 @@ type SwapBackend interface { |
| } |
| |
| // StartStaged is plan 1's StartGen with perGen true, except for the |
| -// executable. A flat child execs g.StagedPath directly. A nested unit's |
| -// executable mount names it, which is Task 9's leg; until then a nested row |
| -// is refused. A foreign row has no generation path and never overlaps. |
| +// executable, the tier and the health wait, which is the caller's. A flat |
| +// child execs g.StagedPath directly. A nested unit's executable mount names |
| +// it, which is Task 9's leg; until then a nested row is refused. A foreign |
| +// row keeps its canonical socket (plan 1): it gets one generation at a time |
| +// (a cold, pinned or hold start), and Swap never overlaps it. |
| func (b *ExecBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen) (Gen, Proc, error) { |
| + if g.Tier == (cgroup.Limit{}) { |
| + g.Tier = b.GenTier(rt, g.Build) |
| + } |
| p := b.pathsFor(rt) |
| switch p.layout { |
| case LayoutFlat: |
| @@ -568,12 +828,19 @@ func (b *ExecBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen) |
| if err := clearOwnGenSocket(sock); err != nil { |
| return g, nil, fmt.Errorf("%s: generation socket: %w", rt.Host, err) |
| } |
| - proc, _, err := b.startChildFrom(ctx, rt, sock, g.N, g.StagedPath) |
| + proc, _, err := b.spawnChildFrom(ctx, rt, sock, g.N, g.StagedPath, g.Tier) |
| if err != nil { |
| return g, proc, err |
| } |
| g.Socket, g.Control = sock, sock+".control" |
| return g, proc, nil |
| + case LayoutForeign: |
| + proc, _, err := b.spawnChildFrom(ctx, rt, rt.Socket, g.N, g.StagedPath, g.Tier) |
| + if err != nil { |
| + return g, proc, err |
| + } |
| + g.Socket, g.Control = rt.Socket, rt.Socket+".control" |
| + return g, proc, nil |
| case LayoutNested: |
| return g, nil, errNestedStaged |
| case LayoutFenced, LayoutConflict: |
| @@ -582,19 +849,31 @@ func (b *ExecBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen) |
| return g, nil, errSwapLayout |
| } |
| |
| +// GenTier is build's own tier: TierOf when it is wired (the agent reads |
| +// the build's staged manifest), the live tier otherwise. |
| +func (b *ExecBackend) GenTier(rt registry.Route, build string) cgroup.Limit { |
| + if b.TierOf != nil && build != "" { |
| + return b.TierOf(rt, build) |
| + } |
| + return b.tier(rt) |
| +} |
| + |
| +// GenReady widens a healthy pinned generation's socket mode (issue #103). |
| +func (b *ExecBackend) GenReady(rt registry.Route, g Gen, p Proc) { |
| + b.normalizeSocketMode(rt, g.Socket, p) |
| +} |
| + |
| // RaiseGenParent sets the parent to the incumbent's tier plus the |
| -// successor's (plan 2, D3), through plan 2's ParentLimit. That keeps the |
| -// parent's expected-limit record in step. b.tier reads the live build's |
| -// manifest, so both terms are the live tier here. See the plan's open |
| -// question on a tier change across a swap. |
| -func (b *ExecBackend) RaiseGenParent(ctx context.Context, rt registry.Route) error { |
| - t := b.tier(rt) |
| - return b.ParentLimit(ctx, rt, cgroup.OverlapLimit(t, t)) |
| +// successor's (spec §7.3, plan 2 D3), each captured from its own artifact |
| +// at the attempt's admission, through plan 2's ParentLimit. That keeps the |
| +// parent's expected-limit record in step. |
| +func (b *ExecBackend) RaiseGenParent(ctx context.Context, rt registry.Route, incumbent, successor cgroup.Limit) error { |
| + return b.ParentLimit(ctx, rt, cgroup.OverlapLimit(incumbent, successor)) |
| } |
| |
| -// RestoreGenParent puts the parent back at the tier. |
| -func (b *ExecBackend) RestoreGenParent(ctx context.Context, rt registry.Route) error { |
| - return b.ParentLimit(ctx, rt, b.tier(rt)) |
| +// RestoreGenParent puts the parent back at the current generation's tier. |
| +func (b *ExecBackend) RestoreGenParent(ctx context.Context, rt registry.Route, tier cgroup.Limit) error { |
| + return b.ParentLimit(ctx, rt, tier) |
| } |
| |
| // GenSwitched points the canonical socket path at g's socket first, with |
| @@ -646,20 +925,34 @@ func (b *SplitBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen |
| return gb.StartStaged(ctx, rt, g) |
| } |
| |
| -func (b *SplitBackend) RaiseGenParent(ctx context.Context, rt registry.Route) error { |
| +func (b *SplitBackend) GenTier(rt registry.Route, build string) cgroup.Limit { |
| + gb, err := b.genExec(rt) |
| + if err != nil { |
| + return cgroup.TierDefault |
| + } |
| + return gb.GenTier(rt, build) |
| +} |
| + |
| +func (b *SplitBackend) GenReady(rt registry.Route, g Gen, p Proc) { |
| + if gb, err := b.genExec(rt); err == nil { |
| + gb.GenReady(rt, g, p) |
| + } |
| +} |
| + |
| +func (b *SplitBackend) RaiseGenParent(ctx context.Context, rt registry.Route, incumbent, successor cgroup.Limit) error { |
| gb, err := b.genExec(rt) |
| if err != nil { |
| return err |
| } |
| - return gb.RaiseGenParent(ctx, rt) |
| + return gb.RaiseGenParent(ctx, rt, incumbent, successor) |
| } |
| |
| -func (b *SplitBackend) RestoreGenParent(ctx context.Context, rt registry.Route) error { |
| +func (b *SplitBackend) RestoreGenParent(ctx context.Context, rt registry.Route, tier cgroup.Limit) error { |
| gb, err := b.genExec(rt) |
| if err != nil { |
| return err |
| } |
| - return gb.RestoreGenParent(ctx, rt) |
| + return gb.RestoreGenParent(ctx, rt, tier) |
| } |
| |
| func (b *SplitBackend) GenSwitched(rt registry.Route, g Gen, p Proc) error { |
| @@ -673,7 +966,7 @@ func (b *SplitBackend) GenSwitched(rt registry.Route, g Gen, p Proc) error { |
| |
| - [ ] **Step 7: Run the tests to verify they pass** |
| |
| -Run: `go build ./... && go vet ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestVerifyStaged|TestSplitBackendSwaps|TestExecBackendStartStaged|TestExecBackendGenSwitched|TestExecBackendRaise' -count=1` |
| +Run: `go build ./... && go vet ./internal/activator ./internal/host && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator ./internal/host -run 'TestVerifyStaged|TestSplitBackendSwaps|TestExecBackendStartStaged|TestExecBackendGenSwitched|TestExecBackendRaise|TestExecBackendUsesEachArtifactsTier|TestMemTierOf' -count=1` |
| Expected: `ok github.com/carlosframework/platform/internal/activator` |
| |
| - [ ] **Step 8: Run the whole package (flag off: wakes still exec the live link)** |
| @@ -684,8 +977,8 @@ Expected: no gofmt output; `ok`. Plan 1's and 2's spawn tests pass unchanged, be |
| - [ ] **Step 9: Commit** |
| |
| ```bash |
| -git add internal/activator/swap.go internal/activator/genbackend.go internal/activator/staged.go internal/activator/backend_exec.go internal/activator/genbackend_test.go |
| -git commit -m "zdd plan 3 task 1: swap types, the SwapBackend seam, flat staged exec, canonical link at the switch" |
| +git add internal/activator/swap.go internal/activator/genbackend.go internal/activator/staged.go internal/activator/backend_exec.go internal/activator/containment.go internal/activator/generations.go internal/activator/genbackend_test.go internal/host/memtier.go internal/host/memtier_test.go internal/host/agent.go |
| +git commit -m "zdd plan 3 task 1: swap types, the SwapBackend seam, flat staged spawn without health, each artifact's tier, canonical link at the switch" |
| ``` |
| |
| --- |
| @@ -1225,6 +1518,12 @@ func (a *Service) stopMember(e *actEntry, m *genMember) bool { |
| // is the proof (plan 2, "stop evidence on the parent"). Any other error is |
| // an unproven termination. |
| func (a *Service) genChildEmpty(e *actEntry, m *genMember) bool { |
| + if _, ok := m.proc.(*claimProcess); ok { |
| + // A claims-on generation (Task 10): claimProcess.Stop already ran |
| + // KillAndEmpty on its own child and succeeded, or Stop failed and |
| + // stopMember never got here. |
| + return true |
| + } |
| k, ok := a.backend.(genKiller) |
| if !ok || m.gen.N == 0 { |
| return true |
| @@ -1262,16 +1561,18 @@ func (a *Service) dropMember(e *actEntry, m *genMember) bool { |
| // last member and the parent was raised, the parent is restored FIRST, |
| // while the member still holds the set. That way no new attempt can raise |
| // the parent again in between (spec §7.3: the restore happens after the |
| -// retiring child is empty). done closes last. |
| +// retiring child is empty). The restore is to the CURRENT generation's |
| +// captured tier (spec §7.3): the incumbent's after a failed successor, the |
| +// successor's after a switch. done closes last. |
| func (a *Service) leaveSet(e *actEntry, m *genMember) { |
| a.mu.Lock() |
| restore := e.ops.raised && len(e.ops.members) == 1 && e.ops.members[m.gen.N] == m |
| - rt := e.route |
| + rt, tier := e.route, e.gen.Tier |
| a.mu.Unlock() |
| if restore { |
| if gb, ok := a.backend.(SwapBackend); ok { |
| rctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl) |
| - if err := gb.RestoreGenParent(rctx, rt); err != nil { |
| + if err := gb.RestoreGenParent(rctx, rt, tier); err != nil { |
| log.Printf("activator: restore parent limit for %s: %v", rt.Host, err) |
| } |
| cancel() |
| @@ -1797,11 +2098,13 @@ import ( |
| "testing" |
| "time" |
| |
| + "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| |
| // fakeSwapBackend is fakeBackend plus the swap seam (SwapBackend). Every call is recorded in |
| -// the shared order log: raise, startGen:<path>, switch:<n>, restore, |
| +// the shared order log: raise:<incumbent>+<successor>, startGen:<path>, |
| +// switch:<n>, restore:<tier>, |
| // startSidecarGen:<path>. Generation children are fakeProcs labelled |
| // gen:<n>. |
| type fakeSwapBackend struct { |
| @@ -1821,6 +2124,9 @@ type fakeSwapBackend struct { |
| // Task 7: legacy sidecars started from a generation. |
| sidecarGens []Gen |
| sidecarGenGate chan struct{} |
| + // tiers is each build's own tier (GenTier); a build not here is the |
| + // default tier. |
| + tiers map[string]cgroup.Limit |
| } |
| |
| func (g *fakeSwapBackend) StartStaged(ctx context.Context, _ registry.Route, gen Gen) (Gen, Proc, error) { |
| @@ -1856,16 +2162,28 @@ func (g *fakeSwapBackend) StartStaged(ctx context.Context, _ registry.Route, gen |
| return gen, proc, nil |
| } |
| |
| -func (g *fakeSwapBackend) RaiseGenParent(context.Context, registry.Route) error { |
| - g.record("raise") |
| +// GenTier answers from tiers (by build), and the default tier otherwise. |
| +func (g *fakeSwapBackend) GenTier(_ registry.Route, build string) cgroup.Limit { |
| + g.gmu.Lock() |
| + defer g.gmu.Unlock() |
| + if t, ok := g.tiers[build]; ok { |
| + return t |
| + } |
| + return cgroup.TierDefault |
| +} |
| + |
| +func (g *fakeSwapBackend) GenReady(registry.Route, Gen, Proc) {} |
| + |
| +func (g *fakeSwapBackend) RaiseGenParent(_ context.Context, _ registry.Route, inc, succ cgroup.Limit) error { |
| + g.record(fmt.Sprintf("raise:%d+%d", inc.MemoryMax, succ.MemoryMax)) |
| g.gmu.Lock() |
| g.raises++ |
| g.gmu.Unlock() |
| return nil |
| } |
| |
| -func (g *fakeSwapBackend) RestoreGenParent(context.Context, registry.Route) error { |
| - g.record("restore") |
| +func (g *fakeSwapBackend) RestoreGenParent(_ context.Context, _ registry.Route, tier cgroup.Limit) error { |
| + g.record(fmt.Sprintf("restore:%d", tier.MemoryMax)) |
| g.gmu.Lock() |
| g.restores++ |
| g.gmu.Unlock() |
| @@ -2023,6 +2341,7 @@ import ( |
| "testing" |
| "time" |
| |
| + "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/hostwrite" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -2072,6 +2391,42 @@ func TestSwapOverlapSwitchesAfterHealthAndRetiresTheIncumbent(t *testing.T) { |
| } |
| } |
| |
| +// During the overlap the parent holds the incumbent's tier plus the |
| +// successor's, each captured from its own artifact (spec §7.3), and the |
| +// restore is to whichever generation is current afterwards. Here the |
| +// incumbent (v1) declares children (1 GiB) and the successor (v2) does |
| +// not (512 MiB). Summing one tier twice gives a 1 GiB parent (or 2 GiB), |
| +// and restoring to the live tier leaves the wrong limit after a failure. |
| +func TestSwapOverlapUsesEachArtifactsTier(t *testing.T) { |
| + const gib, half = int64(1 << 30), int64(1 << 29) |
| + t.Run("switched: restored to the successor's tier", func(t *testing.T) { |
| + f := newSwapFixture(t) |
| + f.b.tiers = map[string]cgroup.Limit{"v1": cgroup.TierChildren, "v2": cgroup.TierDefault} |
| + res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) |
| + if res.Outcome != SwapDone || res.Gen.Tier != cgroup.TierDefault { |
| + t.Fatalf("Swap = %+v, want done with the successor's own tier", res) |
| + } |
| + ev := f.b.events() |
| + if indexPrefix(ev, fmt.Sprintf("raise:%d+%d", gib, half)) < 0 || indexPrefix(ev, fmt.Sprintf("restore:%d", half)) < 0 { |
| + t.Fatalf("events %v: want raise:%d+%d and restore:%d", ev, gib, half, half) |
| + } |
| + if g, _ := f.b.lastGen(); g.Tier != cgroup.TierDefault { |
| + t.Fatalf("the successor spawned at %v, want its own 512 MiB tier", g.Tier) |
| + } |
| + }) |
| + t.Run("failed: restored to the incumbent's tier", func(t *testing.T) { |
| + f := newSwapFixture(t) |
| + f.b.tiers = map[string]cgroup.Limit{"v1": cgroup.TierChildren, "v2": cgroup.TierDefault} |
| + f.a.genHealth = func(context.Context, Gen) error { return errors.New("unhealthy: HTTP 503") } |
| + if res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest); res.Outcome != SwapFailed { |
| + t.Fatalf("Swap = %+v", res) |
| + } |
| + if ev := f.b.events(); indexPrefix(ev, fmt.Sprintf("restore:%d", gib)) < 0 { |
| + t.Fatalf("events %v: want restore:%d (the incumbent is still current)", ev, gib) |
| + } |
| + }) |
| +} |
| + |
| // The "swapped" event carries the retiring generation's drain time (spec |
| // §9.4), so the result measures it: from SIGTERM to the proven exit. |
| func TestSwapOverlapReportsTheDrainTime(t *testing.T) { |
| @@ -2297,7 +2652,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`, and `internal/registry`): |
| +Append to `internal/activator/swap.go`, and add its imports (`context`, `errors`, `fmt`, `time`, `internal/cgroup` and `internal/registry`): |
| |
| ```go |
| var ( |
| @@ -2384,8 +2739,9 @@ func (a *Service) swapOverlap(ctx context.Context, gb SwapBackend, at Attempt, n |
| done: make(chan struct{}), |
| } |
| var ( |
| - e *actEntry |
| - rt registry.Route |
| + e *actEntry |
| + rt registry.Route |
| + incumbent Gen // the current generation at admission, for its tier |
| ) |
| // Step 1: the checks and joining the set, inside the selected work. Only |
| // the registration runs there. A claims-on spawn re-enters the work |
| @@ -2413,7 +2769,7 @@ func (a *Service) swapOverlap(ctx context.Context, gb SwapBackend, at Attempt, n |
| return errSwapBusy |
| } |
| a.addMemberLocked(e, m) |
| - rt = e.route |
| + rt, incumbent = e.route, e.gen |
| return nil |
| }) |
| if err != nil { |
| @@ -2423,8 +2779,24 @@ func (a *Service) swapOverlap(ctx context.Context, gb SwapBackend, at Attempt, n |
| return abandon(err) |
| } |
| |
| + // Each artifact's own tier, captured now (spec §7.3): the incumbent's |
| + // from the build it runs, the successor's from the attempt's build. |
| + // Neither is read from the live link, which root may repoint. They size |
| + // the overlap parent, the successor's child, and the restore (leaveSet |
| + // restores to the current generation's Tier). |
| + if incumbent.Tier == (cgroup.Limit{}) { |
| + incumbent.Tier = gb.GenTier(rt, incumbent.Build) |
| + } |
| + succTier := gb.GenTier(rt, at.Build) |
| + a.mu.Lock() |
| + if e.gen.N == incumbent.N { |
| + e.gen.Tier = incumbent.Tier |
| + } |
| + m.gen.Tier = succTier |
| + a.mu.Unlock() |
| + |
| // Step 2: the parent raise, before the successor spawns (spec §7.3). |
| - if err := gb.RaiseGenParent(ctx, rt); err != nil { |
| + if err := gb.RaiseGenParent(ctx, rt, incumbent.Tier, succTier); err != nil { |
| a.dropMember(e, m) |
| return abandon(fmt.Errorf("raise parent limit: %w", err)) |
| } |
| @@ -2818,7 +3190,7 @@ git commit -m "zdd plan 3 task 5: canary settle; an intentional stop cancels it |
| **Files:** |
| - Create: `internal/activator/swaphold.go`, `internal/activator/swap_hold_test.go` |
| - Modify: |
| - - `internal/activator/activator.go`: the `actEntry` fields; `doWake` step 4 (lines 1281–1283); `finishStop` (lines 2369–2375); |
| + - `internal/activator/activator.go`: the `actEntry` fields; `doWake` step 4 (lines 1281–1283); `beginStopLocked`; `finishStop` (lines 2369–2375); |
| - `internal/activator/swap.go`: `Swap`'s mode switch; |
| - `internal/activator/claimwake.go`: `StartPolicy` (line 21), `ClaimRuntime` (line 34), `doClaimWake` (line 108), `startAdmitted` (after line 159). |
| |
| @@ -2826,16 +3198,18 @@ git commit -m "zdd plan 3 task 5: canary settle; an intentional stop cancels it |
| - Consumes: Tasks 1–4; C3 (`w.gen`, copied into `e.gen` by `wake()`); `cleanupFailedWake`. |
| - Produces: |
| ```go |
| - // actEntry gains: pin *Gen; holdNext *holdSpec |
| - type holdSpec struct{ gen Gen; at Attempt; newest func(string) uint64; woke chan holdWoke } |
| + // actEntry gains: pin *Gen; holdNext *holdSpec; settleCancel context.CancelFunc |
| + type holdSpec struct{ gen Gen; at Attempt; newest func(string) uint64; woke chan holdWoke; settleCancel context.CancelFunc } |
| type holdWoke struct{ next *actEntry; err error } |
| func (a *Service) swapHold(ctx context.Context, at Attempt, newest func(string) uint64) SwapResult |
| func (a *Service) holdWakeLocked(e *actEntry, h *holdSpec) (*actEntry, error) |
| func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) (Proc, string, Gen, error) |
| func (a *Service) lifeDone() <-chan struct{} |
| + func (a *Service) foreignRow(host string) bool // Swap runs an overlap attempt on a foreign row as a hold |
| var errHoldNeedsPins error |
| // StartPolicy gains: Pin *Gen. ClaimRuntime gains: Pins bool. |
| ``` |
| + **A `hold` canary settles too** (spec §§6.2, 6.4, 8.5). Plan 5 sends a pinned wake straight to `startPinned` and does not run its own first-wake observer on it, so the observation is this task's. When `at.Canary` is set, `swapHold` watches the new generation for `swapSettle` (Task 5's `observeSettle`) after it is healthy, and only then answers `SwapDone`. An exit in the window is `SwapDown` with `errSettleExit`, and nothing falls back. An intentional stop of the new entry in the window cancels the observation (`settleCancel`, called by `beginStopLocked` while the process has not exited) and is `SwapAbandoned`, never a failure. A process that had already exited when the stop began died of its build, so its stop does not cancel. |
| |
| - [ ] **Step 1: Write the failing tests** |
| |
| @@ -2912,6 +3286,73 @@ func TestSwapWitnessHoldExecsTheStagedBuild(t *testing.T) { |
| } |
| } |
| |
| +// A hold canary is not done when it is healthy: it must survive the 10 s |
| +// settle (spec §6.2, §8.5). Without the settle, swapHold answers SwapDone |
| +// the moment the pinned wake is ready, and a crash a second later neither |
| +// fails the attempt nor halts the rollout. |
| +func TestSwapHoldCanarySettles(t *testing.T) { |
| + old := swapSettle |
| + t.Cleanup(func() { swapSettle = old }) |
| + start := func(t *testing.T, f *swapFixture) (chan SwapResult, Gen, *fakeProc) { |
| + t.Helper() |
| + at := f.attempt(ModeHold) |
| + at.Canary = true |
| + results := make(chan SwapResult, 1) |
| + go func() { results <- f.a.Swap(context.Background(), at, f.newest) }() |
| + var g Gen |
| + var p *fakeProc |
| + waitFor(t, "the pinned generation awake", func() bool { |
| + g, p = f.b.lastGen() |
| + f.a.mu.Lock() |
| + defer f.a.mu.Unlock() |
| + e := f.a.entries[f.host] |
| + return p != nil && e != nil && e.state == actAwake && e.gen.N == g.N |
| + }) |
| + return results, g, p |
| + } |
| + t.Run("an exit in the window is down, with no fallback", func(t *testing.T) { |
| + swapSettle = 5 * time.Second |
| + f := newSwapFixture(t) |
| + results, _, p := start(t, f) |
| + select { |
| + case res := <-results: |
| + t.Fatalf("Swap answered %+v before the settle ended", res) |
| + case <-time.After(50 * time.Millisecond): |
| + } |
| + p.exit() |
| + res := <-results |
| + if res.Outcome != SwapDown || !strings.Contains(res.Reason, errSettleExit.Error()) { |
| + t.Fatalf("Swap = %+v, want down: %v", res, errSettleExit) |
| + } |
| + if gens, _, _ := f.b.calls(); gens != 1 || f.b.startCount() != 1 { |
| + t.Fatalf("a failed hold canary fell back: StartGen %d, Start %d", gens, f.b.startCount()) |
| + } |
| + }) |
| + t.Run("surviving the window is done", func(t *testing.T) { |
| + swapSettle = 100 * time.Millisecond |
| + f := newSwapFixture(t) |
| + began := time.Now() |
| + results, g, _ := start(t, f) |
| + res := <-results |
| + if res.Outcome != SwapDone || res.Gen.N != g.N { |
| + t.Fatalf("Swap = %+v", res) |
| + } |
| + if took := time.Since(began); took < swapSettle { |
| + t.Fatalf("done after %s, inside the %s settle", took, swapSettle) |
| + } |
| + }) |
| + t.Run("an intentional stop in the window is not a failure", func(t *testing.T) { |
| + swapSettle = 5 * time.Second |
| + f := newSwapFixture(t) |
| + results, _, _ := start(t, f) |
| + f.a.Bounce(f.host) |
| + res := <-results |
| + if res.Outcome != SwapAbandoned || !strings.Contains(res.Reason, errSwapStopped.Error()) { |
| + t.Fatalf("Swap = %+v, want abandoned: %v", res, errSwapStopped) |
| + } |
| + }) |
| +} |
| + |
| // A hold build that fails its health check leaves nothing serving and does |
| // not fall back (spec §8.3). |
| func TestSwapHoldFailureIsDown(t *testing.T) { |
| @@ -3031,7 +3472,7 @@ func TestStartAdmittedRefusesAPinMismatch(t *testing.T) { |
| |
| - [ ] **Step 2: Run the tests to verify they fail** |
| |
| -Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessHold|TestSwapHold|TestStartAdmittedRefusesAPin' -count=1` |
| +Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessHold|TestSwapHold|TestStartAdmittedRefusesAPin|TestSwapOverlapOnAForeignRow' -count=1` |
| Expected: build failure: `undefined: errHoldNeedsPins`, `unknown field Pin in struct literal of type StartPolicy`. |
| |
| - [ ] **Step 3: Write `swaphold.go`** |
| @@ -3047,6 +3488,7 @@ import ( |
| "fmt" |
| "time" |
| |
| + "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| |
| @@ -3059,6 +3501,10 @@ type holdSpec struct { |
| at Attempt |
| newest func(string) uint64 |
| woke chan holdWoke // buffered 1 |
| + // settleCancel ends a canary's settle observation. holdWakeLocked puts |
| + // it on the pinned entry, so an intentional stop of that entry cancels |
| + // the observation before anything is stopped (spec §6.4). |
| + settleCancel context.CancelFunc |
| } |
| |
| // holdWoke is finishStop's answer: the pinned wake it installed, or why it |
| @@ -3094,12 +3540,17 @@ func (a *Service) swapHold(ctx context.Context, at Attempt, newest func(string) |
| if err != nil { |
| return abandon(fmt.Errorf("admission: %w", err)) |
| } |
| + sctx, scancel := context.WithCancel(ctx) |
| + defer scancel() |
| h := &holdSpec{ |
| gen: Gen{Build: at.Build, StagedPath: at.StagedPath, Digest: at.Digest, Demand: at.Demand}, |
| at: at, |
| newest: newest, |
| woke: make(chan holdWoke, 1), |
| } |
| + if at.Canary { |
| + h.settleCancel = scancel |
| + } |
| var stopAt time.Time // wall clock: the old generation's stop, for the drain time (spec §9.4) |
| err = a.admit(ctx, work, func() error { |
| a.mu.Lock() |
| @@ -3134,9 +3585,25 @@ func (a *Service) swapHold(ctx context.Context, at Attempt, newest func(string) |
| } |
| <-hw.next.ready |
| a.mu.Lock() |
| - g, werr := hw.next.gen, hw.next.err |
| + g, werr, proc := hw.next.gen, hw.next.err, hw.next.proc |
| a.mu.Unlock() |
| switch { |
| + case werr == nil && at.Canary: |
| + // A canary must survive the settle before it counts (spec §6.2, |
| + // §8.5). Plan 5 does not observe a pinned wake itself. |
| + r := observeSettle(sctx, proc, swapSettle) |
| + a.mu.Lock() |
| + hw.next.settleCancel = nil |
| + a.mu.Unlock() |
| + switch r { |
| + case settleExited: |
| + // Nothing falls back from a hold (spec §8.3): the instance is |
| + // down until the sweep notices the exit and a later wake runs. |
| + return SwapResult{Outcome: SwapDown, Gen: g, Reason: errSettleExit.Error(), Drain: drain} |
| + case settleCancelled: |
| + return abandon(errSwapStopped) |
| + } |
| + return SwapResult{Outcome: SwapDone, Gen: g, Drain: drain} |
| case werr == nil: |
| return SwapResult{Outcome: SwapDone, Gen: g, Drain: drain} |
| case errors.Is(werr, ErrFenced) || errors.Is(werr, context.Canceled): |
| @@ -3159,15 +3626,19 @@ func (a *Service) holdWakeLocked(e *actEntry, h *holdSpec) (*actEntry, error) { |
| return nil, errSwapSuperseded |
| } |
| pin := h.gen |
| - next := &actEntry{host: e.host, route: e.route, state: actWaking, ready: make(chan struct{}), pin: &pin} |
| + next := &actEntry{host: e.host, route: e.route, state: actWaking, ready: make(chan struct{}), pin: &pin, |
| + settleCancel: h.settleCancel} |
| a.entries[e.host] = next |
| return next, nil |
| } |
| |
| // startPinned is a hold's spawn: the attempt's staged build, verified and |
| -// exec'd by path, then the health check through the generation handle. |
| -// The proc comes back even on failure, so doWake's cleanupFailedWake stops |
| -// it and records the outcome. |
| +// exec'd by path at its own tier, then the health check through the |
| +// generation handle. StartStaged returns as soon as the process runs (Task |
| +// 1), so this health check, and its deadline, is the only one. The proc |
| +// comes back even on failure, so doWake's cleanupFailedWake stops it and |
| +// records the outcome, and a caller that reads a nil proc as "nothing ran" |
| +// is right to. |
| func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) (Proc, string, Gen, error) { |
| gb, ok := a.backend.(SwapBackend) |
| if !ok { |
| @@ -3177,6 +3648,9 @@ func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) ( |
| return nil, "", pin, fmt.Errorf("staging: %w", err) |
| } |
| pin.N = nextGen() |
| + if pin.Tier == (cgroup.Limit{}) { |
| + pin.Tier = gb.GenTier(rt, pin.Build) // the pinned artifact's own tier (spec §7.3) |
| + } |
| g, proc, err := gb.StartStaged(ctx, rt, pin) |
| if err != nil { |
| return proc, "", pin, err |
| @@ -3187,23 +3661,88 @@ func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) ( |
| if err := a.genHealthy(ctx, g); err != nil { |
| return proc, "", g, err |
| } |
| + gb.GenReady(rt, g, proc) |
| return proc, g.Build, g, nil |
| } |
| ``` |
| |
| - [ ] **Step 4: Wire the hold into the activator and the claims path** |
| |
| -`swap.go`, in `Swap`'s mode switch, add the hold case: |
| +`swap.go`, in `Swap`'s mode switch, add the hold case, and send an overlap attempt on a foreign row through the hold: |
| |
| ```go |
| switch at.Mode { |
| case ModeOverlap: |
| + if a.foreignRow(at.Host) { |
| + // A foreign row keeps its canonical socket (plan 1), so two |
| + // generations of it cannot coexist: its replacement is a hold |
| + // (plan 1's reading, accepted in the index). Its result reads |
| + // as a hold's: SwapDown when the new build fails. |
| + return a.swapHold(ctx, at, newest) |
| + } |
| return a.swapOverlap(ctx, gb, at, newest) |
| case ModeHold: |
| return a.swapHold(ctx, at, newest) |
| } |
| ``` |
| |
| +and add to `swaphold.go`: |
| + |
| +```go |
| +// foreignRow reports whether host's row has the foreign layout (a |
| +// hand-set socket path), which keeps its canonical socket. |
| +func (a *Service) foreignRow(host string) bool { |
| + c, ok := a.backend.(Classifier) |
| + if !ok { |
| + return false |
| + } |
| + a.mu.Lock() |
| + e := a.entries[host] |
| + var rt registry.Route |
| + if e != nil { |
| + rt = e.route |
| + } |
| + a.mu.Unlock() |
| + if e == nil { |
| + return false |
| + } |
| + l, _ := c.Layout(rt) |
| + return l == LayoutForeign |
| +} |
| +``` |
| + |
| +`swapOverlap` keeps its own layout check (`errSwapLayout`) under the admission lock, as the backstop for a row that turns foreign between this read and the admission. |
| + |
| +In Task 4's `TestSwapRefusals`, delete the `"foreign layout"` case (an overlap on a foreign row is no longer refused), and add to `swap_hold_test.go`: |
| + |
| +```go |
| +// A foreign row keeps its canonical socket, so an overlap attempt on it |
| +// runs as a hold (plan 1's reading): the old process stops, then the staged |
| +// build starts on the same socket. Refusing it (errSwapLayout) left a |
| +// foreign row unable to take any release with the flag on. |
| +func TestSwapOverlapOnAForeignRowRunsAsAHold(t *testing.T) { |
| + f := newSwapFixture(t) |
| + f.b.layoutFn = func(registry.Route) (Layout, string) { return LayoutForeign, "hand-set socket" } |
| + incumbent := f.b.lastProc() |
| + res := f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) |
| + if res.Outcome != SwapDone || res.Gen.StagedPath != f.staged { |
| + t.Fatalf("Swap = %+v, want done through the hold", res) |
| + } |
| + ev := f.b.events() |
| + if i, j := slices.Index(ev, "stop:instance"), indexPrefix(ev, "startGen:"+f.staged); i < 0 || j < 0 || i > j { |
| + t.Fatalf("order %v: a foreign row stops before its replacement starts", ev) |
| + } |
| + if indexPrefix(ev, "raise") >= 0 { |
| + t.Fatalf("events %v: a foreign row never raises an overlap parent", ev) |
| + } |
| + if !incumbent.isStopped() { |
| + t.Fatal("the old process outlived the hold") |
| + } |
| +} |
| +``` |
| + |
| +(add `slices` to that file's imports). |
| + |
| `activator.go`, add to `actEntry` after `ops opSet`: |
| |
| ```go |
| @@ -3216,6 +3755,22 @@ func (a *Service) startPinned(ctx context.Context, rt registry.Route, pin Gen) ( |
| // holdNext is a hold swap waiting for this entry's stop to finish. |
| // finishStop installs its pinned wake. Written under a.mu. |
| holdNext *holdSpec |
| + // settleCancel, on a hold canary's pinned entry, ends its settle |
| + // observation. beginStopLocked calls it while the process has not |
| + // exited, so an intentional stop is never read as a crash (spec §6.4). |
| + // Written under a.mu. |
| + settleCancel context.CancelFunc |
| +``` |
| + |
| +`activator.go`, `beginStopLocked`: insert as its first statement: |
| + |
| +```go |
| + // A hold canary's settle ends with an intentional stop, before anything |
| + // is stopped (zero-downtime spec §6.4). A process that already exited |
| + // died of its build, and its stop must not hide that. |
| + if e.settleCancel != nil && (e.proc == nil || !e.proc.Exited()) { |
| + e.settleCancel() |
| + } |
| ``` |
| |
| `activator.go`, `doWake` step 4. Plan 1 has turned the `else` arm at lines 1281–1283 into a call that also fills `w.gen` (contract C3). Insert the pinned arm before it: |
| @@ -3302,7 +3857,7 @@ In `startAdmitted`, after the `if !s.valid() || ...` block (line 157–159), ins |
| |
| - [ ] **Step 5: Run the tests to verify they pass** |
| |
| -Run: `go vet ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessHold|TestSwapHold|TestStartAdmittedRefusesAPin' -count=1 -v 2>&1 | tail -20` |
| +Run: `go vet ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessHold|TestSwapHold|TestStartAdmittedRefusesAPin|TestSwapOverlapOnAForeignRow' -count=1 -v 2>&1 | tail -20` |
| Expected: every test `--- PASS`, then `ok`. |
| |
| - [ ] **Step 6: Run the whole package** |
| @@ -3330,17 +3885,21 @@ git commit -m "zdd plan 3 task 6: the hold swap, a pinned wake installed by fini |
| - Test: `internal/activator/swap_sidecar_test.go`; `swap_fixture_test.go` (append `StartSidecarGen`) |
| |
| **Interfaces:** |
| -- Consumes: C2 (`e.gen`), C3 (`w.gen`); `startSidecarUnit`, `LiveVersion`, `stopDiscardedSidecar`, the `sidecarStarting`/`sidecarStartDone` interlock. |
| +- Consumes: C2 (`e.gen`), C3 (`w.gen`); plan 2 Task 10's `placeFor(spawnSidecar, 0)`, `spawnInCgroupAt` and the `role-sidecar` child's `RemoveChild` in `startSidecarChild`; plan 2's `delegatedBackend` test helper; `stopDiscardedSidecar`, the `sidecarStarting`/`sidecarStartDone` interlock. |
| - Produces: |
| ```go |
| type SidecarGenStarter interface{ StartSidecarGen(ctx context.Context, rt registry.Route, g Gen) (Proc, error) } |
| - var errNestedSidecarGen error |
| + var errNestedSidecarGen error // nested arm until Task 9 gives it the staged executable |
| func (b *ExecBackend) StartSidecarGen(ctx context.Context, rt registry.Route, g Gen) (Proc, error) |
| - func (b *ExecBackend) startSidecarChildFrom(ctx context.Context, rt registry.Route, bin string) (Proc, error) |
| + func (b *ExecBackend) startSidecarChildFrom(ctx context.Context, rt registry.Route, bin string) (Proc, error) // plan 2's body, placement kept; only the binary is a parameter |
| func (b *SplitBackend) StartSidecarGen(ctx context.Context, rt registry.Route, g Gen) (Proc, error) |
| func (a *Service) startSidecar(ctx context.Context, rt registry.Route, g Gen) (Proc, error) |
| - func (a *Service) replaceSidecar(e *actEntry) |
| + func (a *Service) replaceSidecar(e *actEntry) // joins an in-flight supervised start, then replaces |
| ``` |
| + Three rules this task keeps (findings 2, 10 and 11 of the P1 review): |
| + - **Placement.** `startSidecarChildFrom` is plan 2 Task 10's `startSidecarChild` body with one change: the binary. Plan 2's `placeFor(spawnSidecar, 0)`, `spawnInCgroupAt(rt, child, false, …)` and the goroutine's `RemoveChild(rt.Host, child)` stay. Going back to `spawnInCgroup` would put the tier and `memory.oom.group=1` back on the generation parent (spec §7.3) and, with controllers delegated, try to place a process in a parent that must hold none. |
| + - **No live-link read for a nested sidecar.** The nested unit binds whatever root lays out, so a check of `LiveVersion` before the start is a race with a repoint, and after a fallback it refuses for ever. Until Task 9 the nested arm refuses outright (`errNestedSidecarGen`), the way Task 1's nested `StartStaged` does. Task 9 hands root the generation's captured staged executable, which root validates under the same rule as the run unit's. |
| + - **Replacement is never skipped.** A supervised restart in flight captured the generation it read when it began, which can be the one just retired. `replaceSidecar` waits for it (`sidecarStartDone`) and then replaces whatever it installed. The supervisor's own completion also discards a sidecar started for a generation that is no longer current. |
| |
| - [ ] **Step 1: Write the failing tests** |
| |
| @@ -3385,7 +3944,6 @@ import ( |
| "os" |
| "path/filepath" |
| "slices" |
| - "strings" |
| "testing" |
| "time" |
| |
| @@ -3497,9 +4055,96 @@ func TestSwapSidecarReplacementHoldsTheSupervisorOff(t *testing.T) { |
| } |
| } |
| |
| +// A supervised restart already in flight when the swap switches captured the |
| +// OLD generation. The swap's replacement joins it and then replaces what it |
| +// installed, so the new server never runs beside the old build's sidecar |
| +// (spec §7.5). Returning early because sidecarStarting is set (the old |
| +// replaceSidecar) leaves the supervisor's old-build sidecar installed with |
| +// no replacement pending: the third spawn never comes. |
| +func TestSwapSidecarReplacementJoinsAnInFlightSupervisor(t *testing.T) { |
| + f := newSwapFixture(t) |
| + f.b.sidecarStaged = true |
| + f.a.sweepOnce() |
| + waitFor(t, "late attachment", func() bool { return f.a.sidecarSettled(f.host) && len(f.b.sidecarGenPaths()) == 1 }) |
| + gate := make(chan struct{}) |
| + f.b.gmu.Lock() |
| + f.b.sidecarGenGate = gate |
| + f.b.gmu.Unlock() |
| + f.b.lastSidecar().kill() |
| + f.clk.advance(3 * time.Second) |
| + f.a.sweepOnce() // the supervisor starts a sidecar for the incumbent and blocks |
| + waitFor(t, "the supervisor's start", func() bool { return len(f.b.sidecarGenPaths()) == 2 }) |
| + results := make(chan SwapResult, 1) |
| + go func() { results <- f.a.Swap(context.Background(), f.attempt(ModeOverlap), f.newest) }() |
| + time.Sleep(50 * time.Millisecond) |
| + close(gate) |
| + if res := <-results; res.Outcome != SwapDone { |
| + t.Fatalf("Swap = %+v", res) |
| + } |
| + waitFor(t, "the replacement onto the new generation", func() bool { |
| + p := f.b.sidecarGenPaths() |
| + return len(p) == 3 && f.a.sidecarSettled(f.host) |
| + }) |
| + paths := f.b.sidecarGenPaths() |
| + if paths[1] != f.incumbent.StagedPath || paths[2] != f.staged { |
| + t.Fatalf("sidecar spawns %v: want the supervisor's on %q, then the replacement on %q", paths, f.incumbent.StagedPath, f.staged) |
| + } |
| + // The supervisor's sidecar is stopped either by its own completion (its |
| + // generation was retired) or by the replacement's stop-then-start. |
| + f.b.mu.Lock() |
| + supervised, current := f.b.sidecars[1], f.b.sidecars[len(f.b.sidecars)-1] |
| + f.b.mu.Unlock() |
| + if !supervised.isStopped() { |
| + t.Fatal("the supervisor's old-build sidecar outlived the swap") |
| + } |
| + f.a.mu.Lock() |
| + installed := f.a.entries[f.host].sidecar |
| + f.a.mu.Unlock() |
| + if installed != Proc(current) { |
| + t.Fatal("the installed sidecar is not the replacement") |
| + } |
| +} |
| + |
| +// Under plan 2's containment the sidecar keeps its role-sidecar child: no |
| +// limit of its own, and the generation parent keeps memory.oom.group=0 and |
| +// holds no process (spec §7.3, plan 2 D4). Spawning through spawnInCgroup |
| +// instead writes the tier and memory.oom.group=1 onto the parent and makes |
| +// no role-sidecar child. |
| +func TestStartSidecarGenKeepsTheRoleSidecarChild(t *testing.T) { |
| + const host = "sc.example.com" |
| + b, parent := delegatedBackend(t, host) |
| + b.GenContainment = true |
| + v2 := filepath.Join(b.OptDir, "versions", "v2", "app") |
| + if err := os.MkdirAll(filepath.Dir(v2), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(v2, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(b.DataDir, host+"-sidecar.env"), []byte("X=1\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "s.sock")} |
| + p, err := b.StartSidecarGen(context.Background(), rt, Gen{N: 3, Build: "v2", StagedPath: v2}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _ = p.Stop(context.Background()) |
| + if fi, err := os.Stat(filepath.Join(parent, "role-sidecar")); err != nil || !fi.IsDir() { |
| + t.Fatalf("no role-sidecar child: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(parent, "role-sidecar", "memory.max")); !os.IsNotExist(err) { |
| + t.Errorf("the sidecar's child got a limit of its own: %v", err) |
| + } |
| + if got, err := os.ReadFile(filepath.Join(parent, "memory.oom.group")); err != nil || string(got) != "0" { |
| + t.Errorf("parent memory.oom.group = %q, %v; want 0", got, err) |
| + } |
| +} |
| + |
| // ExecBackend: a flat sidecar execs the generation's staged path. A nested |
| -// sidecar binds the live build, so it starts only while that is the |
| -// serving generation's build, and is refused otherwise. |
| +// sidecar is refused until Task 9 hands root the staged executable: it never |
| +// reads the live link to decide, because a repoint can land between that |
| +// read and the start. |
| func TestExecBackendStartSidecarGen(t *testing.T) { |
| t.Run("flat", func(t *testing.T) { |
| b := testExecBackend(t) |
| @@ -3534,12 +4179,10 @@ func TestExecBackendStartSidecarGen(t *testing.T) { |
| stageLive(t, b, host, "v1") |
| rt := registry.Route{Host: host, Kind: "instance", DBPath: provision.NestedDBPath(b.DataDir, host), |
| Socket: filepath.Join(rd, "instances", provision.InstanceID(host), "app.sock")} |
| - if _, err := b.StartSidecarGen(context.Background(), rt, Gen{Build: "v2"}); !errors.Is(err, errNestedSidecarGen) { |
| - t.Fatalf("live v1, serving v2: %v, want errNestedSidecarGen", err) |
| - } |
| - _, err := b.StartSidecarGen(context.Background(), rt, Gen{Build: "v1"}) |
| - if err == nil || errors.Is(err, errNestedSidecarGen) || !strings.Contains(err.Error(), "instance control channel not configured") { |
| - t.Fatalf("live and serving agree: %v, want the nested start (refused here only for its missing control channel)", err) |
| + for _, build := range []string{"v1", "v2"} { // the live build and another |
| + if _, err := b.StartSidecarGen(context.Background(), rt, Gen{N: 3, Build: build}); !errors.Is(err, errNestedSidecarGen) { |
| + t.Fatalf("serving %s: %v, want errNestedSidecarGen until Task 9", build, err) |
| + } |
| } |
| }) |
| } |
| @@ -3549,12 +4192,12 @@ var _ SidecarGenStarter = (*SplitBackend)(nil) |
| |
| - [ ] **Step 2: Run the tests to verify they fail** |
| |
| -Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestLegacySidecar|TestSwapSidecar|TestExecBackendStartSidecarGen' -count=1` |
| +Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestLegacySidecar|TestSwapSidecar|TestExecBackendStartSidecarGen|TestStartSidecarGenKeeps' -count=1` |
| Expected: build failure: `undefined: SidecarGenStarter`, `b.StartSidecarGen undefined`, `undefined: errNestedSidecarGen`. |
| |
| - [ ] **Step 3: Write the backend side** |
| |
| -`backend_exec.go`: replace `startSidecarChild` (lines 1315–1342) with a forwarder plus the body, which now takes the binary: |
| +`backend_exec.go`: rename the body of `startSidecarChild` **as plan 2 Task 10 left it** to `startSidecarChildFrom`, add a `bin string` parameter, and keep `startSidecarChild` as a forwarder. The only line of the body that changes is the `exec.Command`: `b.liveLink(rt.Host)` becomes `bin`. Plan 2's placement stays exactly as it is: `placeFor(spawnSidecar, 0)`, `spawnInCgroupAt(rt, child, false, …)` and the goroutine's `RemoveChild(rt.Host, child)`, plus anything else `main` has in that body when you cut the branch (a strict `settle`, `cleanupHost`). The result, as plan 2 writes it: |
| |
| ```go |
| func (b *ExecBackend) startSidecarChild(ctx context.Context, rt registry.Route) (Proc, error) { |
| @@ -3563,20 +4206,27 @@ func (b *ExecBackend) startSidecarChild(ctx context.Context, rt registry.Route) |
| |
| // startSidecarChildFrom is startSidecarChild with the binary named. That is |
| // the live link for today's StartSidecar, and a generation's captured |
| -// staged path for StartSidecarGen (zero-downtime spec §7.5). |
| +// staged path for StartSidecarGen (zero-downtime spec §7.5). Placement is |
| +// plan 2's: under GenContainment the sidecar lands in role-sidecar, with no |
| +// limit of its own, under the generation parent's backstop (§7.3, plan 2 |
| +// D4). The parent holds no process and keeps memory.oom.group=0. |
| func (b *ExecBackend) startSidecarChildFrom(_ context.Context, rt registry.Route, bin string) (Proc, error) { |
| fileEnv, err := readEnvFile(b.pathsFor(rt).sidecarEnv) |
| if err != nil { |
| return nil, fmt.Errorf("read sidecar env: %w", err) |
| } |
| - // Same host cgroup as the instance: a sidecar's load is that instance's |
| - // work, and attributing it elsewhere would be a lie in the other |
| - // direction. |
| - cmd, err := b.spawnInCgroup(rt, func() *exec.Cmd { |
| + child, _, err := b.placeFor(spawnSidecar, 0) |
| + if err != nil { |
| + return nil, err |
| + } |
| + // Same host cgroup as the instance (its role-sidecar child under |
| + // containment): a sidecar's load is that instance's work, and |
| + // attributing it elsewhere would be a lie in the other direction. |
| + cmd, err := b.spawnInCgroupAt(rt, child, false, func() *exec.Cmd { |
| cmd := exec.Command(bin, "sidecar", "run") |
| cmd.Env = childEnv(fileEnv) |
| // Like the instance child: logs through the agent's own streams |
| - // (journald under systemd), teed into the log sink when armed — a |
| + // (journald under systemd), teed into the log sink when armed. A |
| // sidecar's output is the app's output. |
| b.teeStd(cmd, rt) |
| return cmd |
| @@ -3587,6 +4237,7 @@ func (b *ExecBackend) startSidecarChildFrom(_ context.Context, rt registry.Route |
| p := &childProc{cmd: cmd, done: make(chan struct{})} |
| go func() { |
| p.err = cmd.Wait() |
| + b.Cgroups.RemoveChild(rt.Host, child) // no-op for "" (flag off) |
| b.Cgroups.CleanupHost(rt.Host) |
| close(p.done) |
| }() |
| @@ -3594,10 +4245,12 @@ func (b *ExecBackend) startSidecarChildFrom(_ context.Context, rt registry.Route |
| } |
| ``` |
| |
| +Check that placement survived: `grep -n "placeFor(" internal/activator/backend_exec.go` still prints exactly three lines (`spawnInstance, n`, `spawnSidecar`, `spawnSeed`), and `grep -n "spawnInCgroup(" internal/activator/backend_exec.go` prints no sidecar call. |
| + |
| Append to `genbackend.go`, adding the `fmt` import: |
| |
| ```go |
| -var errNestedSidecarGen = errors.New("nested sidecar: the live build is not the serving generation's build") |
| +var errNestedSidecarGen = errors.New("nested sidecar: a generation's sidecar needs Task 9's staged executable") |
| |
| // SidecarGenStarter starts a legacy co-spawned sidecar from a generation's |
| // captured staged path (spec §7.5: "Every legacy co-spawned sidecar runs |
| @@ -3607,18 +4260,17 @@ type SidecarGenStarter interface { |
| } |
| |
| // StartSidecarGen execs g.StagedPath for a flat (or foreign) host. A |
| -// nested host's sidecar unit binds the host's live build (instancectl's |
| -// ensureExe), not a generation's, so it may start only while the two |
| -// agree. Otherwise it is refused. The supervisor retries with its backoff |
| -// rather than run a build its instance is not serving. |
| +// nested host's sidecar unit binds what root lays out for it, which is the |
| +// live link until Task 9 lets the agent name the generation's staged |
| +// executable. Until then the nested arm refuses. It never reads the live |
| +// link to decide: a repoint can land between that read and the start, and |
| +// after a fallback the live link names a build the instance is not serving. |
| +// The supervisor retries with its backoff. |
| func (b *ExecBackend) StartSidecarGen(ctx context.Context, rt registry.Route, g Gen) (Proc, error) { |
| p := b.pathsFor(rt) |
| switch p.layout { |
| case LayoutNested: |
| - if cur, err := b.LiveVersion(rt.Host); err != nil || cur != g.Build { |
| - return nil, errNestedSidecarGen |
| - } |
| - return b.startSidecarUnit(ctx, rt) |
| + return nil, errNestedSidecarGen |
| case LayoutFenced, LayoutConflict: |
| return nil, fmt.Errorf("%s: refusing to start sidecar: %s", rt.Host, p.reason) |
| } |
| @@ -3661,9 +4313,24 @@ func (a *Service) startSidecar(ctx context.Context, rt registry.Route, g Gen) (P |
| // supervisor's interlock (sidecarStarting, sidecarStartDone) for the whole |
| // replacement. A supervised restart cannot race it, and stop() waits on it |
| // exactly as it waits on a supervised restart. |
| +// |
| +// A supervised start already in flight captured the generation it read |
| +// when it began, which may be the one this swap just retired. Skipping the |
| +// replacement because of it would leave the old build's sidecar beside the |
| +// new server with nothing pending, so replaceSidecar joins it (its |
| +// sidecarStartDone, bounded by the supervisor's DeadlineControl) and then |
| +// replaces whatever it installed. |
| func (a *Service) replaceSidecar(e *actEntry) { |
| a.mu.Lock() |
| - if a.entries[e.host] != e || e.state != actAwake || !e.sidecarWanted || e.sidecarStarting { |
| + for e.sidecarStarting && a.entries[e.host] == e && e.state == actAwake { |
| + wait := e.sidecarStartDone |
| + a.mu.Unlock() |
| + if wait != nil { |
| + <-wait |
| + } |
| + a.mu.Lock() |
| + } |
| + if a.entries[e.host] != e || e.state != actAwake || !e.sidecarWanted { |
| a.mu.Unlock() |
| return |
| } |
| @@ -3723,6 +4390,20 @@ func (a *Service) replaceSidecar(e *actEntry) { |
| sc, err := a.startSidecar(ctx, route, gen) |
| ``` |
| |
| +In the same goroutine, directly after the existing `if e0.state != actAwake { … stopDiscardedSidecar(sc); return }` block, add: |
| + |
| +```go |
| + if a.gens && e0.gen.N != gen.N { |
| + // A swap switched generations while this start was in |
| + // flight, so sc runs the retired generation's build |
| + // (spec §7.5). The swap's replaceSidecar is waiting on |
| + // this goroutine and starts the current generation's |
| + // sidecar next; nothing old is installed meanwhile. |
| + stopDiscardedSidecar(sc) |
| + return |
| + } |
| +``` |
| + |
| `swap.go`, in `swapOverlap`, step 8: replace the final `return res` with: |
| |
| ```go |
| @@ -3733,7 +4414,7 @@ func (a *Service) replaceSidecar(e *actEntry) { |
| |
| - [ ] **Step 5: Run the tests to verify they pass** |
| |
| -Run: `go vet ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestLegacySidecar|TestSwapSidecar|TestExecBackendStartSidecarGen' -count=1 -v 2>&1 | tail -20` |
| +Run: `go vet ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestLegacySidecar|TestSwapSidecar|TestExecBackendStartSidecarGen|TestStartSidecarGenKeeps' -count=1 -v 2>&1 | tail -20` |
| Expected: every subtest `--- PASS`, then `ok`. |
| |
| - [ ] **Step 6: Run the whole package (the existing sidecar tests are the flag-off proof)** |
| @@ -4084,11 +4765,11 @@ A refusal answers 403. The client turns that into `ErrStagedExe`, and the swap i |
| **Files:** |
| - Create: `internal/instancectl/stagedexe.go`, `internal/instancectl/stagedexe_linux_test.go`, `internal/instancectl/stagedexe_test.go`, `internal/activator/swap_nested_test.go` |
| - Modify: |
| - - `internal/instancectl/server.go`: `Server` fields; `Run`; plan 2's `startGen` and its `/start` query parsing; |
| + - `internal/instancectl/server.go`: `Server` fields; `Run`; plan 2's `startGen`, `handleStart`'s original-template path, and the `/start` query parsing; |
| - `internal/instancectl/client.go`: `StartGenExe`; |
| - `internal/activator/containment.go`: plan 2's `GenControl`, `startRole`; |
| - - `internal/activator/backend_exec.go`: plan 1's `startUnitAt`; |
| - - `internal/activator/genbackend.go`: the nested arm of `StartStaged`; |
| + - `internal/activator/backend_exec.go`: plan 1's `startUnitAt`; `startSidecarUnit`; |
| + - `internal/activator/genbackend.go`: the nested arms of `StartStaged` and `StartSidecarGen`; |
| - `internal/activator/swap.go`, `swaphold.go`: a refusal is an abandonment. |
| |
| **Interfaces:** |
| @@ -4103,11 +4784,18 @@ A refusal answers 403. The client turns that into `ErrStagedExe`, and the swap i |
| func (s *Server) ensureGenExeAt(host string, n uint64, x StagedExe) error |
| func registryHostApp(path string) func(host string) (string, string, error) |
| func (c *Client) StartGenExe(ctx context.Context, host string, n uint64, memoryMax int64, x StagedExe) (string, error) |
| + func (s *Server) ensureHostExeAt(host string, x StagedExe) error |
| + func (c *Client) StartSidecarExe(ctx context.Context, host string, memoryMax int64, x StagedExe) (string, error) |
| // activator |
| // GenControl gains: StartGenExe(ctx, host, n, memoryMax, x instancectl.StagedExe) (string, error) |
| + // StartSidecarExe(ctx, host, memoryMax, x instancectl.StagedExe) (string, error) |
| // startRole gains a trailing exe *instancectl.StagedExe parameter (nil: plan 2's StartGen, the live link) |
| - func (b *ExecBackend) startUnitFrom(ctx context.Context, rt registry.Route, sock string, gen uint64, exe *instancectl.StagedExe) (Proc, string, error) |
| + func (b *ExecBackend) spawnUnitFrom(ctx context.Context, rt registry.Route, sock string, gen uint64, exe *instancectl.StagedExe, tier cgroup.Limit) (Proc, string, error) // spawn only; startUnitAt = this + the health tail |
| + // startRole gains a second trailing parameter, tier cgroup.Limit (zero: b.tier(rt)) |
| + func (b *ExecBackend) startSidecarUnitFrom(ctx context.Context, rt registry.Route, exe *instancectl.StagedExe) (Proc, error) |
| + // ExecBackend.StartSidecarGen's nested arm (Task 7's errNestedSidecarGen) now starts the sidecar unit on g's staged executable |
| ``` |
| + **The nested legacy sidecar** (spec §7.5, "every legacy co-spawned sidecar runs its instance's serving artifact") gets the same treatment as the run unit. Its unit, `carlos-instance-sidecar@<id>`, binds `<ExeRoot>/<id>/app`, which root's `ensureHostExe` points at the live link. A `/start` for the sidecar role may now carry the generation's captured `exe`, `version` and `digest`. Root runs the same `validateStagedExe` (the host's own app's `VersionPath`, opened without following a symlink, the digest equal), answers 403 on a refusal, and only then points that `app` link at the staged build. The sidecar therefore never depends on the live link, so neither a fallback (serving differs from installed) nor a repoint during the start changes what it runs. A run-role start on the original template still refuses `exe`: only generation starts and the sidecar take one. |
| |
| - [ ] **Step 1: Check the import graph** |
| |
| @@ -4124,14 +4812,19 @@ Create `internal/instancectl/stagedexe_linux_test.go` (Linux only: `OpenBeneath` |
| package instancectl |
| |
| import ( |
| + "context" |
| "crypto/sha256" |
| "encoding/hex" |
| "errors" |
| + "net/http" |
| "os" |
| "path/filepath" |
| + "strconv" |
| "strings" |
| "testing" |
| |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| |
| @@ -4190,6 +4883,51 @@ func TestValidateStagedExe(t *testing.T) { |
| t.Errorf("no registry view: %v, want ErrStagedExe", err) |
| } |
| } |
| + |
| +// The nested legacy sidecar runs the serving generation's staged build |
| +// (spec §7.5), validated by root under the run unit's rule before its |
| +// executable mount is pointed at it. The live link names another build |
| +// throughout and makes no difference. A refused path answers 403 and |
| +// leaves the mount alone. Before this task a sidecar /start could not name |
| +// an executable at all, so the sidecar bound the live link. |
| +func TestSidecarStartMountsTheValidatedStagedExe(t *testing.T) { |
| + opt, exeRoot := t.TempDir(), t.TempDir() |
| + staged := release.VersionPath(opt, release.OpsAccount, "v2", "blog") |
| + digest := writeStaged(t, staged, "build v2") |
| + _, sock := serveOnSocket(t, &fakeCtl{}, func(s *Server) { |
| + s.DropInDir, s.ExeRoot, s.OptDir = t.TempDir(), exeRoot, opt |
| + s.HostApp = func(string) (string, string, error) { return release.OpsAccount, "blog", nil } |
| + }) |
| + c := NewClient(sock) |
| + app := filepath.Join(exeRoot, provision.InstanceID(genHost), "app") |
| + if _, err := c.StartSidecarExe(context.Background(), genHost, cgroup.TierDefault.MemoryMax, |
| + StagedExe{Path: staged, Version: "v2", Digest: digest}); err != nil { |
| + t.Fatalf("the host's own staged build was refused for its sidecar: %v", err) |
| + } |
| + if got, err := os.Readlink(app); err != nil || got != staged { |
| + t.Fatalf("the sidecar's exe = %q (%v), want the staged %q", got, err, staged) |
| + } |
| + for name, x := range map[string]StagedExe{ |
| + "bytes that differ": {Path: staged, Version: "v2", Digest: strings.Repeat("0", 64)}, |
| + "a path outside": {Path: "/etc/passwd", Version: "v2", Digest: digest}, |
| + } { |
| + if _, err := c.StartSidecarExe(context.Background(), genHost, cgroup.TierDefault.MemoryMax, x); !errors.Is(err, ErrStagedExe) { |
| + t.Errorf("%s: %v, want ErrStagedExe", name, err) |
| + } |
| + if got, _ := os.Readlink(app); got != staged { |
| + t.Errorf("%s: a refused start moved the mount to %q", name, got) |
| + } |
| + } |
| + // A run-role start on the original template names no executable. |
| + q := hostRole(genHost, RoleRun) |
| + q.Set("memory_max", strconv.FormatInt(cgroup.TierDefault.MemoryMax, 10)) |
| + q.Set("exe", staged) |
| + q.Set("version", "v2") |
| + q.Set("digest", digest) |
| + if code, _ := c.do(context.Background(), DeadlineControl, http.MethodPost, "/start", q, nil); code != http.StatusBadRequest { |
| + t.Errorf("a run-role start with exe answered %d, want 400", code) |
| + } |
| +} |
| ``` |
| |
| Create `internal/instancectl/stagedexe_test.go`: |
| @@ -4284,6 +5022,7 @@ import ( |
| "context" |
| "errors" |
| "fmt" |
| + "os" |
| "strings" |
| "sync" |
| "testing" |
| @@ -4295,13 +5034,25 @@ import ( |
| // exeControl is a root control channel with the generation verbs. Its plain |
| // StartGen refuses, so a test sees which start was used. |
| type exeControl struct { |
| - mu sync.Mutex |
| - got []instancectl.StagedExe |
| + mu sync.Mutex |
| + got []instancectl.StagedExe |
| + sidecarExes []instancectl.StagedExe |
| + plainStarts []instancectl.Role // Start calls: the live-link exe layout |
| + tiers []int64 // StartGenExe's memoryMax, per call |
| } |
| |
| -func (c *exeControl) Start(context.Context, string, instancectl.Role, int64) (string, error) { |
| +func (c *exeControl) Start(_ context.Context, _ string, role instancectl.Role, _ int64) (string, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + c.plainStarts = append(c.plainStarts, role) |
| return "inv", nil |
| } |
| +func (c *exeControl) StartSidecarExe(_ context.Context, _ string, _ int64, x instancectl.StagedExe) (string, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + c.sidecarExes = append(c.sidecarExes, x) |
| + return "inv-sc", nil |
| +} |
| func (c *exeControl) Seed(context.Context, string, int64) (instancectl.SeedResponse, error) { |
| return instancectl.SeedResponse{}, nil |
| } |
| @@ -4317,10 +5068,11 @@ func (c *exeControl) StartGen(context.Context, string, uint64, int64) (string, e |
| func (c *exeControl) StopGen(context.Context, string, uint64, bool) error { return nil } |
| func (c *exeControl) ResetGen(context.Context, string, uint64) error { return nil } |
| func (c *exeControl) Parent(context.Context, string, int64) error { return nil } |
| -func (c *exeControl) StartGenExe(_ context.Context, _ string, _ uint64, _ int64, x instancectl.StagedExe) (string, error) { |
| +func (c *exeControl) StartGenExe(_ context.Context, _ string, _ uint64, memoryMax int64, x instancectl.StagedExe) (string, error) { |
| c.mu.Lock() |
| defer c.mu.Unlock() |
| c.got = append(c.got, x) |
| + c.tiers = append(c.tiers, memoryMax) |
| return "inv-7", nil |
| } |
| |
| @@ -4333,15 +5085,46 @@ func TestStartRoleNamesTheStagedExe(t *testing.T) { |
| b.Control = ctl |
| rt := registry.Route{Host: "n.example.com"} |
| x := instancectl.StagedExe{Path: "/opt/carlos/versions/v2/blog", Version: "v2", Digest: strings.Repeat("a", 64)} |
| - inv, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7, &x) |
| + inv, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7, &x, cgroup.Limit{}) |
| if err != nil || inv != "inv-7" || len(ctl.got) != 1 || ctl.got[0] != x { |
| t.Fatalf("startRole = %q, %v; root was sent %+v", inv, err, ctl.got) |
| } |
| - if _, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7, nil); err == nil || !strings.Contains(err.Error(), "live-link") { |
| + if _, err := b.startRole(context.Background(), rt, instancectl.RoleRun, 7, nil, cgroup.Limit{}); err == nil || !strings.Contains(err.Error(), "live-link") { |
| t.Fatalf("no staged exe: %v, want plan 2's start", err) |
| } |
| } |
| |
| +// A nested legacy sidecar is started on the serving generation's captured |
| +// staged executable, which root validates (spec §7.5). The live link names |
| +// v1 while the generation serves v2, as after a fallback, and the sidecar |
| +// still starts, on v2. Task 7's interim nested arm refused it |
| +// (errNestedSidecarGen), and today's StartSidecar would bind the live v1. |
| +func TestStartSidecarGenNestedNamesTheStagedExe(t *testing.T) { |
| + b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{}) |
| + b.GenContainment = true |
| + ctl := &exeControl{} |
| + b.Control = ctl |
| + stageLive(t, b, rt.Host, "v1") |
| + if err := os.WriteFile(b.pathsFor(rt).sidecarEnv, []byte("X=1\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + g := Gen{N: 4, Build: "v2", StagedPath: "/opt/carlos/versions/v2/blog", Digest: strings.Repeat("b", 64)} |
| + p, err := b.StartSidecarGen(context.Background(), rt, g) |
| + if err != nil { |
| + t.Fatalf("StartSidecarGen: %v", err) |
| + } |
| + defer p.Stop(context.Background()) //nolint:errcheck |
| + want := instancectl.StagedExe{Path: g.StagedPath, Version: "v2", Digest: g.Digest} |
| + ctl.mu.Lock() |
| + defer ctl.mu.Unlock() |
| + if len(ctl.sidecarExes) != 1 || ctl.sidecarExes[0] != want { |
| + t.Fatalf("root was sent %+v for the sidecar, want %+v", ctl.sidecarExes, want) |
| + } |
| + if len(ctl.plainStarts) != 0 { |
| + t.Fatalf("a live-link start ran too: %v", ctl.plainStarts) |
| + } |
| +} |
| + |
| // Root refusing the staged path abandons the swap. It is not a failure of |
| // the build, and the incumbent keeps serving. |
| func TestSwapAbandonsWhenRootRefusesTheStagedExe(t *testing.T) { |
| @@ -4359,8 +5142,8 @@ func TestSwapAbandonsWhenRootRefusesTheStagedExe(t *testing.T) { |
| |
| - [ ] **Step 3: Run the tests to verify they fail** |
| |
| -Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/instancectl ./internal/activator -run 'TestValidateStagedExe|TestEnsureGenExeAt|TestGenStartRefusesAStagedExe|TestRegistryHostApp|TestStartRoleNamesTheStagedExe|TestSwapAbandonsWhenRootRefuses' -count=1` |
| -Expected: build failure: `undefined: StagedExe`, `s.validateStagedExe undefined`, `undefined: registryHostApp`, `too many arguments in call to b.startRole`. |
| +Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/instancectl ./internal/activator -run 'TestValidateStagedExe|TestSidecarStartMounts|TestEnsureGenExeAt|TestGenStartRefusesAStagedExe|TestRegistryHostApp|TestStartRoleNamesTheStagedExe|TestStartSidecarGenNested|TestStartStagedNested|TestSwapAbandonsWhenRootRefuses' -count=1` |
| +Expected: build failure: `undefined: StagedExe`, `s.validateStagedExe undefined`, `undefined: registryHostApp`, `c.StartSidecarExe undefined`, `too many arguments in call to b.startRole`. |
| |
| - [ ] **Step 4: Write root's validation** |
| |
| @@ -4493,18 +5276,59 @@ In `Run`, add after `ExeRoot: filepath.Join(controlsock.Dir, "exe"),`: |
| HostApp: registryHostApp("/data/registry.db"), |
| ``` |
| |
| -Add `exe StagedExe` to plan 2's `request` struct. In the `/start` query parsing, directly after plan 2 reads `gen`, add: |
| +Add `exe StagedExe` to plan 2's `request` struct. In the `/start` query parsing, directly after plan 2 reads `gen` (the role is parsed before it), add: |
| |
| ```go |
| if x := q.Get("exe"); x != "" { |
| - if req.gen == 0 { |
| - return req, errors.New("exe is only for a generation start") |
| + // A generation start, or the legacy sidecar (zero-downtime spec |
| + // §7.5: it runs its instance's serving artifact). The original |
| + // template's run unit keeps the live link. |
| + if req.gen == 0 && req.role != RoleSidecar { |
| + return req, errors.New("exe is only for a generation start or a sidecar") |
| } |
| req.exe = StagedExe{Path: x, Version: q.Get("version"), Digest: q.Get("digest")} |
| } |
| ``` |
| |
| -Return the error the way plan 2's parser returns its other refusals (a 400). In plan 2's `startGen`, replace |
| +Return the error the way plan 2's parser returns its other refusals (a 400). |
| + |
| +Append to `stagedexe.go`: |
| + |
| +```go |
| +// ensureHostExeAt lays out the host's own executable mount (the one the |
| +// original templates bind: <ExeRoot>/<id>/app). It points at a validated |
| +// staged build when x names one, which only a legacy sidecar's start does, |
| +// and at the live link otherwise (ensureHostExe). |
| +func (s *Server) ensureHostExeAt(host string, x StagedExe) error { |
| + if x.Path == "" { |
| + return s.ensureHostExe(host) |
| + } |
| + return ensureExe(s.ExeRoot, provision.InstanceID(host), x.Path, host) |
| +} |
| +``` |
| + |
| +(add the `internal/provision` import). In `handleStart`'s original-template path, replace plan 2's |
| + |
| +```go |
| + if s.refuseStart(w, req.host, unit, func() error { return s.ensureHostExe(req.host) }) { |
| +``` |
| +with |
| +```go |
| + // A sidecar naming its generation's staged build is validated before |
| + // anything is laid out, under the run unit's rule (validateStagedExe). |
| + // 403 tells the agent it is a refusal, not a failure of the build. |
| + if req.exe.Path != "" { |
| + if err := s.validateStagedExe(req.host, req.exe); err != nil { |
| + http.Error(w, "not starting "+unit+": "+err.Error(), http.StatusForbidden) |
| + return |
| + } |
| + } |
| + if s.refuseStart(w, req.host, unit, func() error { return s.ensureHostExeAt(req.host, req.exe) }) { |
| +``` |
| + |
| +The bind is made when the unit starts, so a later start for the same host (a seed, or a flag-off run unit) that points `app` back at the live link does not change what an already-running sidecar runs. |
| + |
| +In plan 2's `startGen`, replace |
| |
| ```go |
| if s.refuseStart(w, req.host, unit, func() error { return s.ensureGenExe(req.host, req.gen) }) { |
| @@ -4545,6 +5369,27 @@ func (c *Client) StartGenExe(ctx context.Context, host string, n uint64, memoryM |
| } |
| return out.InvocationID, nil |
| } |
| + |
| +// StartSidecarExe is Start for the legacy sidecar role with its executable |
| +// named: the serving generation's staged build, which root validates and |
| +// mounts in place of the live link (zero-downtime spec §7.5). A 403 is |
| +// root's refusal, reported as ErrStagedExe. |
| +func (c *Client) StartSidecarExe(ctx context.Context, host string, memoryMax int64, x StagedExe) (string, error) { |
| + q := hostRole(host, RoleSidecar) |
| + q.Set("memory_max", strconv.FormatInt(memoryMax, 10)) |
| + q.Set("exe", x.Path) |
| + q.Set("version", x.Version) |
| + q.Set("digest", x.Digest) |
| + var out StartResponse |
| + code, err := c.do(ctx, DeadlineControl, http.MethodPost, "/start", q, &out) |
| + if code == http.StatusForbidden { |
| + return "", fmt.Errorf("%w: %v", ErrStagedExe, err) |
| + } |
| + if err != nil { |
| + return "", err |
| + } |
| + return out.InvocationID, nil |
| +} |
| ``` |
| |
| - [ ] **Step 5: Thread the staged executable through the agent's nested start** |
| @@ -4553,32 +5398,101 @@ In plan 2's `GenControl` interface, add: |
| |
| ```go |
| StartGenExe(ctx context.Context, host string, n uint64, memoryMax int64, x instancectl.StagedExe) (string, error) |
| + StartSidecarExe(ctx context.Context, host string, memoryMax int64, x instancectl.StagedExe) (string, error) |
| ``` |
| |
| -Plan 2's test fake for `GenControl` (`fakeGenControl`) gains the same method, returning what its `StartGen` returns. `instancectl.Client` satisfies the interface through Step 4. |
| +Plan 2's test fake for `GenControl` (`fakeGenControl`) gains both methods: `StartGenExe` returns what its `StartGen` returns, and `StartSidecarExe` records `startsidecarexe <host> <max>` and returns `f.inv, f.startErr`. `instancectl.Client` satisfies the interface through Step 4. |
| |
| -Give plan 2's `startRole` a trailing `exe *instancectl.StagedExe` parameter, and every existing call passes `nil`. Its generation branch becomes: |
| +**The nested legacy sidecar.** In `backend_exec.go`, rename the body of `startSidecarUnit` to `startSidecarUnitFrom(ctx context.Context, rt registry.Route, exe *instancectl.StagedExe) (Proc, error)` and keep `startSidecarUnit` as a forwarder: `return b.startSidecarUnitFrom(ctx, rt, nil)`. In the body, replace |
| + |
| +```go |
| + inv, err := b.Control.Start(ctx, rt.Host, instancectl.RoleSidecar, b.tier(rt).MemoryMax) |
| +``` |
| +with |
| +```go |
| + var inv string |
| + if exe != nil { |
| + // The serving generation's captured staged build (zero-downtime |
| + // spec §7.5), which root validates before it lays out the mount. |
| + gc, gerr := b.genControl() |
| + if gerr != nil { |
| + logs.Close() |
| + return nil, gerr |
| + } |
| + inv, err = gc.StartSidecarExe(ctx, rt.Host, b.tier(rt).MemoryMax, *exe) |
| + } else { |
| + inv, err = b.Control.Start(ctx, rt.Host, instancectl.RoleSidecar, b.tier(rt).MemoryMax) |
| + } |
| +``` |
| + |
| +The launch file's argv is unchanged (`/opt/carlos/live/<host>` inside the namespace, which the mount resolves to the staged build). |
| + |
| +In `genbackend.go`, Task 7's nested arm of `StartSidecarGen`, `return nil, errNestedSidecarGen`, becomes: |
| + |
| +```go |
| + case LayoutNested: |
| + // The unit binds what root lays out, so root is told the |
| + // generation's staged build and validates it (Task 9). Nothing here |
| + // reads the live link: a fallback or a repoint cannot change what |
| + // this sidecar runs. |
| + if g.StagedPath == "" { |
| + return nil, errNestedSidecarGen |
| + } |
| + exe := instancectl.StagedExe{Path: g.StagedPath, Version: g.Build, Digest: g.Digest} |
| + return b.startSidecarUnitFrom(ctx, rt, &exe) |
| +``` |
| + |
| +and `errNestedSidecarGen`'s message becomes `"nested sidecar: the generation has no staged executable"`. Update Task 7's `TestExecBackendStartSidecarGen` nested subtest to match: with `Gen{N: 3, Build: build}` (no staged path) it still answers `errNestedSidecarGen`. A root refusal (`ErrStagedExe`) reaches the supervisor as a failed start, which retries on its backoff; the instance keeps serving. |
| + |
| +Give plan 2's `startRole` two trailing parameters, `exe *instancectl.StagedExe` and `tier cgroup.Limit`, and every existing call passes `nil, cgroup.Limit{}`. Its generation branch becomes: |
| |
| ```go |
| gc, err := b.genControl() |
| if err != nil { |
| return "", err |
| } |
| + if tier == (cgroup.Limit{}) { |
| + tier = b.tier(rt) |
| + } |
| if exe != nil { |
| - return gc.StartGenExe(ctx, rt.Host, gen, b.tier(rt).MemoryMax, *exe) |
| + // The staged artifact's own tier (Task 1), not the live link's. |
| + return gc.StartGenExe(ctx, rt.Host, gen, tier.MemoryMax, *exe) |
| + } |
| + return gc.StartGen(ctx, rt.Host, gen, tier.MemoryMax) |
| +``` |
| + |
| +Split `startUnitAt` (plan 1's, with plan 2's trailing `n uint64`) at its health wait, exactly as Task 1 split `startChildAt`. Everything from the top of the body down to, but not including, `if err := waitHealthyCtx(ctx, sock, spawnHealthTimeout); err != nil {` becomes `spawnUnitFrom(ctx context.Context, rt registry.Route, sock string, gen uint64, exe *instancectl.StagedExe, tier cgroup.Limit) (Proc, string, error)`, ending `return up, spawnVer, nil`. `startUnitAt` keeps the health tail, byte for byte: |
| + |
| +```go |
| +func (b *ExecBackend) startUnitAt(ctx context.Context, rt registry.Route, sock string, n uint64) (Proc, string, error) { |
| + p, spawnVer, err := b.spawnUnitFrom(ctx, rt, sock, n, nil, cgroup.Limit{}) |
| + if err != nil { |
| + return p, "", err |
| + } |
| + up := p.(*unitProc) |
| + if err := waitHealthyCtx(ctx, sock, spawnHealthTimeout); err != nil { |
| + up.stopRequested.Store(true) |
| + stopCtx, cancel := context.WithTimeout(context.Background(), actStopTimeout) |
| + defer cancel() |
| + if serr := up.Stop(stopCtx); serr != nil { |
| + log.Printf("activator: %s: stop after failed health wait: %v", rt.Host, serr) |
| + } |
| + return nil, "", err |
| } |
| - return gc.StartGen(ctx, rt.Host, gen, b.tier(rt).MemoryMax) |
| + b.normalizeSocketMode(rt, sock, up) |
| + return up, spawnVer, nil |
| +} |
| ``` |
| |
| -Rename the body of `startUnitAt` (plan 1's, with plan 2's trailing `n uint64`) to `startUnitFrom(ctx context.Context, rt registry.Route, sock string, gen uint64, exe *instancectl.StagedExe) (Proc, string, error)`. Keep `func (b *ExecBackend) startUnitAt(ctx context.Context, rt registry.Route, sock string, n uint64) (Proc, string, error)` as a forwarder: `return b.startUnitFrom(ctx, rt, sock, n, nil)`. Inside the body, plan 2's `gen, err := b.unitGen(n)` becomes `ug, err := b.unitGen(gen)`, and every later use of that result (`prepareAt`'s last argument, `writeLaunchFor`, `startRole`, `newUnitProcFor`, `unitName`) takes `ug`. Pass `exe` to the run role's `startRole` call: `b.startRole(ctx, rt, instancectl.RoleRun, ug, exe)`. The launch file's argv is unchanged: inside the namespace the trampoline still execs `/opt/carlos/live/<host>`, which the generation's executable mount now resolves to the staged build. |
| +(keep the tail's long comments from `main` as they are). Inside `spawnUnitFrom`, plan 2's `gen, err := b.unitGen(n)` becomes `ug, err := b.unitGen(gen)`, and every later use of that result (`prepareAt`'s last argument, `writeLaunchFor`, `startRole`, `newUnitProcFor`, `unitName`) takes `ug`. Pass `exe` and `tier` to the run role's `startRole` call: `b.startRole(ctx, rt, instancectl.RoleRun, ug, exe, tier)`. The launch file's argv is unchanged: inside the namespace the trampoline still execs `/opt/carlos/live/<host>`, which the generation's executable mount now resolves to the staged build. |
| |
| -In `genbackend.go`, the nested arm of `StartStaged` becomes: |
| +In `genbackend.go`, the nested arm of `StartStaged` becomes (no health wait: the caller owns it, Task 1): |
| |
| ```go |
| case LayoutNested: |
| sock := nestedGenSocketIn(p.runDir, g.N) |
| exe := instancectl.StagedExe{Path: g.StagedPath, Version: g.Build, Digest: g.Digest} |
| - proc, _, err := b.startUnitFrom(ctx, rt, sock, g.N, &exe) |
| + proc, _, err := b.spawnUnitFrom(ctx, rt, sock, g.N, &exe, g.Tier) |
| if err != nil { |
| return g, proc, err |
| } |
| @@ -4586,6 +5500,45 @@ In `genbackend.go`, the nested arm of `StartStaged` becomes: |
| return g, proc, nil |
| ``` |
| |
| +Append to `swap_nested_test.go` the nested form of Task 1's real-backend test: |
| + |
| +```go |
| +// A nested staged generation is returned as soon as root has started its |
| +// unit, with no health wait inside (spec §8.2): the socket here is never |
| +// bound, and the real 30 s spawnHealthTimeout is left alone. root is sent |
| +// the generation's own tier. |
| +func TestStartStagedNestedReturnsTheUnitAtOnce(t *testing.T) { |
| + b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{}) |
| + b.GenContainment = true |
| + ctl := &exeControl{} |
| + b.Control = ctl |
| + g := Gen{N: 7, Build: "v2", StagedPath: "/opt/carlos/versions/v2/blog", Digest: strings.Repeat("c", 64), Tier: cgroup.TierChildren} |
| + done := make(chan error, 1) |
| + var proc Proc |
| + go func() { |
| + var err error |
| + _, proc, err = b.StartStaged(context.Background(), rt, g) |
| + done <- err |
| + }() |
| + select { |
| + case err := <-done: |
| + if err != nil || proc == nil { |
| + t.Fatalf("StartStaged = %v, %v: want the started unit", proc, err) |
| + } |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("StartStaged waited for health: it must return the started unit at once") |
| + } |
| + defer proc.Stop(context.Background()) //nolint:errcheck |
| + ctl.mu.Lock() |
| + defer ctl.mu.Unlock() |
| + if len(ctl.got) != 1 || ctl.tiers[0] != cgroup.TierChildren.MemoryMax { |
| + t.Fatalf("root was sent %+v at %v, want one start at the generation's 1 GiB", ctl.got, ctl.tiers) |
| + } |
| +} |
| +``` |
| + |
| +(add `internal/cgroup` and `time` to the file's imports). |
| + |
| Then delete `errNestedStaged`. |
| |
| In `swapOverlap` (`swap.go`), in the `if spawnErr != nil` block, before `return failSwap(...)`: |
| @@ -4607,7 +5560,7 @@ In `swapHold` (`swaphold.go`), add this case to the `switch` that ends the funct |
| |
| - [ ] **Step 6: Run the tests to verify they pass** |
| |
| -Run: `go vet ./internal/instancectl ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/instancectl ./internal/activator -run 'TestValidateStagedExe|TestEnsureGenExeAt|TestGenStartRefusesAStagedExe|TestRegistryHostApp|TestStartRoleNamesTheStagedExe|TestSwapAbandonsWhenRootRefuses' -count=1` |
| +Run: `go vet ./internal/instancectl ./internal/activator && TMPDIR=/tmp/k nice -n 19 go test ./internal/instancectl ./internal/activator -run 'TestValidateStagedExe|TestSidecarStartMounts|TestEnsureGenExeAt|TestGenStartRefusesAStagedExe|TestRegistryHostApp|TestStartRoleNamesTheStagedExe|TestStartSidecarGenNested|TestStartStagedNested|TestSwapAbandonsWhenRootRefuses' -count=1` |
| Expected: `ok` twice. |
| |
| - [ ] **Step 7: Run both packages in full and cross-compile** |
| @@ -4659,7 +5612,7 @@ Claims-on wakes spawn through the claim runtime's provider (`ClaimStart.Build`), |
| 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) setParent(ctx context.Context, gb SwapBackend, e *actEntry, rt registry.Route, overlap bool) error |
| + 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) (Gen, Proc, error) |
| func (a *Service) startClaimedSuccessor(ctx context.Context, e *actEntry, rt registry.Route, work hostwrite.Work, g Gen) (Gen, Proc, error) |
| // Service gains: claimSpawn func(ctx context.Context, e *actEntry, rt registry.Route, work hostwrite.Work, g Gen) (Gen, Proc, error) (test seam) |
| @@ -5162,25 +6115,27 @@ func (a *Service) closeEntryParent(e *actEntry) { |
| } |
| } |
| |
| -// setParent raises the parent to the overlap limit or restores it to the |
| -// tier. Under claims that is the entry's strict parent, whose |
| -// expected-limit record SetParentLimit keeps in step. Otherwise it is the |
| -// backend's. |
| -func (a *Service) setParent(ctx context.Context, gb SwapBackend, e *actEntry, rt registry.Route, overlap bool) error { |
| +// setParent raises the parent to the incumbent's tier plus the successor's |
| +// (succ non-nil) or restores it to the current generation's tier (succ |
| +// nil). Every tier is a captured one (spec §7.3, Task 1): never the |
| +// parent's stored preparation tier summed with itself. Under claims the |
| +// parent is the entry's strict parent, whose expected-limit record |
| +// SetParentLimit keeps in step. Otherwise it is the backend's. |
| +func (a *Service) setParent(ctx context.Context, gb SwapBackend, e *actEntry, rt registry.Route, cur cgroup.Limit, succ *cgroup.Limit) error { |
| a.mu.Lock() |
| - p, tier := e.ops.parent, e.ops.parentTier |
| + p := e.ops.parent |
| a.mu.Unlock() |
| if p != nil { |
| - l := tier |
| - if overlap { |
| - l = cgroup.OverlapLimit(tier, tier) |
| + l := cur |
| + if succ != nil { |
| + l = cgroup.OverlapLimit(cur, *succ) |
| } |
| return p.SetParentLimit(l) |
| } |
| - if overlap { |
| - return gb.RaiseGenParent(ctx, rt) |
| + if succ != nil { |
| + return gb.RaiseGenParent(ctx, rt, cur, *succ) |
| } |
| - return gb.RestoreGenParent(ctx, rt) |
| + return gb.RestoreGenParent(ctx, rt, cur) |
| } |
| |
| // spawnSuccessor is §7.5 step 3 in both modes. Claims off, it is the |
| @@ -5224,7 +6179,11 @@ func (a *Service) startClaimedSuccessor(ctx context.Context, e *actEntry, rt reg |
| if err != nil { |
| return g, nil, err |
| } |
| - cmd, child, err := spawnClaimedGen(ctx, parent, g.N, s.Limit, work, s.Identity, s.Build, nil) |
| + tier := s.Limit |
| + if g.Tier != (cgroup.Limit{}) { |
| + tier = g.Tier // the successor's own artifact's tier, captured at admission (Task 1) |
| + } |
| + cmd, child, err := spawnClaimedGen(ctx, parent, g.N, tier, work, s.Identity, s.Build, nil) |
| p := newClaimProcess(cmd, child) |
| if err != nil { |
| return g, p, err |
| @@ -5254,9 +6213,9 @@ In `swapOverlap`, after `work, err := a.selectWork(...)` and its error check, ad |
| |
| Then make two replacements. |
| |
| -`if err := gb.RaiseGenParent(ctx, rt); err != nil {` becomes: |
| +`if err := gb.RaiseGenParent(ctx, rt, incumbent.Tier, succTier); err != nil {` becomes: |
| ```go |
| - if err := a.setParent(ctx, gb, e, rt, true); err != nil { |
| + if err := a.setParent(ctx, gb, e, rt, incumbent.Tier, &succTier); err != nil { |
| ``` |
| |
| `g, proc, spawnErr := gb.StartStaged(mctx, rt, m.gen)` becomes: |
| @@ -5280,7 +6239,7 @@ In `leaveSet` (`opset.go`), the restore block becomes: |
| if restore { |
| if gb, ok := a.backend.(SwapBackend); ok { |
| rctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl) |
| - if err := a.setParent(rctx, gb, e, rt, false); err != nil { |
| + if err := a.setParent(rctx, gb, e, rt, tier, nil); err != nil { |
| log.Printf("activator: restore parent limit for %s: %v", rt.Host, err) |
| } |
| cancel() |
| @@ -5787,6 +6746,7 @@ import ( |
| "testing" |
| "time" |
| |
| + "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| |
| @@ -6097,8 +7057,14 @@ func (b *simBackend) StartStaged(ctx context.Context, rt registry.Route, g Gen) |
| g.Control = g.Socket + ".control" |
| return g, b.w.spawn(rt.Host, g.Build, g.N), nil |
| } |
| -func (b *simBackend) RaiseGenParent(context.Context, registry.Route) error { return nil } |
| -func (b *simBackend) RestoreGenParent(context.Context, registry.Route) error { return nil } |
| +func (b *simBackend) GenTier(registry.Route, string) cgroup.Limit { return cgroup.TierDefault } |
| +func (b *simBackend) GenReady(registry.Route, Gen, Proc) {} |
| +func (b *simBackend) RaiseGenParent(context.Context, registry.Route, cgroup.Limit, cgroup.Limit) error { |
| + return nil |
| +} |
| +func (b *simBackend) RestoreGenParent(context.Context, registry.Route, cgroup.Limit) error { |
| + return nil |
| +} |
| func (b *simBackend) GenSwitched(rt registry.Route, g Gen, _ Proc) error { |
| b.w.logf("switch %s -> gen %d (%s)", rt.Host, g.N, g.Build) |
| return nil |