activator: exec instances get a memory tier from the staged manifest — 512M, 1G with children (memory-pressure spec §2.3)
5 files changed,
+108
−3
internal/activator/backend_exec.go+17 −3internal/activator/backend_exec_test.go+23 −0internal/host/agent.go+4 −0internal/host/memtier.go+26 −0internal/host/memtier_test.go+38 −0
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 5bc06d2bb..580e63fe9 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -71,6 +71,12 @@ type ExecBackend struct { |
| // metrics sampler can account it (exec-child accounting spec). Nil — no |
| // delegation: old unit file, tests, darwin — spawns exactly as before. |
| Cgroups *cgroup.Manager |
| + // Tier, when non-nil, picks the memory cap for a route's carlos-exec@ |
| + // cgroup (memory-pressure spec §2.3). The box wiring reads the staged |
| + // manifest of the live version; tests leave it nil and get the default |
| + // tier. Instance, seed and sidecar share the cgroup, so they share the |
| + // tier — a seed's first-boot cost is that instance's work. |
| + Tier func(registry.Route) cgroup.Limit |
| // Logs, when non-nil, gets each instance and seed child's stdout/stderr |
| // teed in line-wise for `carlos logs` (spec §5) — journald keeps every |
| // byte it got before. Nil (tests, a box without the shipping loop) |
| @@ -116,6 +122,14 @@ func (b *ExecBackend) teeStd(cmd *exec.Cmd, rt registry.Route) { |
| var _ Backend = (*ExecBackend)(nil) |
| +// tier is Tier with the nil default applied. |
| +func (b *ExecBackend) tier(rt registry.Route) cgroup.Limit { |
| + if b.Tier == nil { |
| + return cgroup.TierDefault |
| + } |
| + return b.Tier(rt) |
| +} |
| + |
| func (b *ExecBackend) liveDir() string { return filepath.Join(b.OptDir, "live") } |
| func (b *ExecBackend) liveLink(host string) string { return filepath.Join(b.liveDir(), host) } |
| func (b *ExecBackend) defaultDBPath(host string) string { |
| @@ -311,7 +325,7 @@ func (b *ExecBackend) Seed(ctx context.Context, rt registry.Route) error { |
| // cost is that instance's work. No CleanupHost here — the instance spawn |
| // reuses the dir moments later, and its exit path (or the next agent |
| // start's sweep) removes it. |
| - cmd, err := b.Cgroups.Start(host, cgroup.Limit{}, func() *exec.Cmd { |
| + cmd, err := b.Cgroups.Start(host, b.tier(rt), func() *exec.Cmd { |
| cmd := exec.CommandContext(ctx, b.liveLink(host), argv...) |
| // The control-channel vars are agent-owned in BOTH directions here too |
| // (Start's discipline, see withoutVars), and here they are always |
| @@ -619,7 +633,7 @@ func (b *ExecBackend) Start(ctx context.Context, rt registry.Route) (Proc, strin |
| // carlos-exec@<host> and the sampler can account it. build may run |
| // twice — the manager's clone3-refusal fallback — so everything about |
| // the Cmd is configured inside it. |
| - cmd, err := b.Cgroups.Start(host, cgroup.Limit{}, func() *exec.Cmd { |
| + cmd, err := b.Cgroups.Start(host, b.tier(rt), func() *exec.Cmd { |
| cmd := exec.Command(bin, "--socket", rt.Socket, "--db", dbPath) |
| cmd.Env = childEnv(childVars) |
| // The child logs through the agent's own stderr/stdout (journald |
| @@ -822,7 +836,7 @@ func (b *ExecBackend) StartSidecar(_ context.Context, rt registry.Route) (Proc, |
| // 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.Cgroups.Start(rt.Host, cgroup.Limit{}, func() *exec.Cmd { |
| + cmd, err := b.Cgroups.Start(rt.Host, b.tier(rt), func() *exec.Cmd { |
| cmd := exec.Command(b.liveLink(rt.Host), "sidecar", "run") |
| cmd.Env = childEnv(fileEnv) |
| // Like the instance child: logs through the agent's own streams |
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go| new file mode 100644 |
| index 000000000..b1f165250 |
| --- /dev/null |
| +++ b/internal/activator/backend_exec_test.go |
| @@ -0,0 +1,23 @@ |
| +package activator |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// TestExecBackendTierSeam: a nil Tier means the default tier, and a set |
| +// Tier is consulted with the route. The cgroup manager is nil here (no |
| +// delegation in a test), so this pins the seam's contract, not the write. |
| +func TestExecBackendTierSeam(t *testing.T) { |
| + var b ExecBackend |
| + rt := registry.Route{Host: "h.example.com"} |
| + if got := b.tier(rt); got != cgroup.TierDefault { |
| + t.Fatalf("nil Tier: %v, want TierDefault", got) |
| + } |
| + b.Tier = func(registry.Route) cgroup.Limit { return cgroup.TierChildren } |
| + if got := b.tier(rt); got != cgroup.TierChildren { |
| + t.Fatalf("set Tier: %v, want TierChildren", got) |
| + } |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 4f12d4a93..0a9825ef8 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -266,6 +266,10 @@ func RunAgent(args []string) { |
| Cache: cache, |
| Materialize: materializeHook(reg, dd), |
| Cgroups: cgm, |
| + // The memory tier for each spawned child's cgroup, read off the |
| + // staged manifest of the route's live version (memory-pressure |
| + // spec §2.3): 512M, or 1G for a family that declares children. |
| + Tier: memTier(stagedManifestOfLiveVersion(release.OptDir())), |
| // The scheduled-work verbs on every spawned instance's control |
| // socket write to the SAME registry this agent's converger and |
| // runner read (schedule spec §7.2): an app that asks for a |
diff --git a/internal/host/memtier.go b/internal/host/memtier.go| new file mode 100644 |
| index 000000000..5c877b777 |
| --- /dev/null |
| +++ b/internal/host/memtier.go |
| @@ -0,0 +1,26 @@ |
| +package host |
| + |
| +import ( |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// memTier builds the exec backend's Tier seam from a manifest reader — |
| +// on a box, stagedManifestOfLiveVersion, the same reader the caps pass |
| +// uses, so "what does this app declare" has one answer on the box. |
| +// |
| +// One bit decides it: capabilities.children → the 1G tier a forking family |
| +// gets on the unit path (caps/render.go); anything else → 512M, the |
| +// carlos-app@ template's number. A missing or unreadable sibling is the |
| +// default tier, not an error: the smaller cap is the safe direction, and |
| +// the caps pass already reports the unreadable sibling in its own advice. |
| +func memTier(load func(registry.Route) (*release.Manifest, error)) func(registry.Route) cgroup.Limit { |
| + return func(rt registry.Route) cgroup.Limit { |
| + m, err := load(rt) |
| + if err != nil || m == nil || m.Capabilities == nil || !m.Capabilities.Children { |
| + return cgroup.TierDefault |
| + } |
| + return cgroup.TierChildren |
| + } |
| +} |
diff --git a/internal/host/memtier_test.go b/internal/host/memtier_test.go| new file mode 100644 |
| index 000000000..d193bb7fd |
| --- /dev/null |
| +++ b/internal/host/memtier_test.go |
| @@ -0,0 +1,38 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/caps" |
| + "github.com/carlosframework/platform/internal/cgroup" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestMemTier: the tier is a platform choice keyed on ONE manifest bit. |
| +// Everything that isn't "declares children" — no manifest, no |
| +// capabilities block, an unreadable sibling — is the default tier: the |
| +// safe direction is the smaller cap, and the caps pass will say why the |
| +// sibling is unreadable in its own advice. |
| +func TestMemTier(t *testing.T) { |
| + rt := registry.Route{Host: "h.example.com", App: "app"} |
| + cases := []struct { |
| + name string |
| + m *release.Manifest |
| + err error |
| + want cgroup.Limit |
| + }{ |
| + {"no manifest", nil, nil, cgroup.TierDefault}, |
| + {"unreadable", nil, errors.New("boom"), cgroup.TierDefault}, |
| + {"no capabilities", &release.Manifest{}, nil, cgroup.TierDefault}, |
| + {"capabilities, no children", &release.Manifest{Capabilities: &caps.Set{V: caps.Version}}, nil, cgroup.TierDefault}, |
| + {"children", &release.Manifest{Capabilities: &caps.Set{V: caps.Version, Children: true}}, nil, cgroup.TierChildren}, |
| + } |
| + for _, c := range cases { |
| + tier := memTier(func(registry.Route) (*release.Manifest, error) { return c.m, c.err }) |
| + if got := tier(rt); got != c.want { |
| + t.Errorf("%s: tier = %v, want %v", c.name, got, c.want) |
| + } |
| + } |
| +} |