carlos / platform Public

activator: exec instances get a memory tier from the staged manifest — 512M, 1G with children (memory-pressure spec §2.3)

Paul Campbell pushed by paul@keymail.dev f9d772b246f48461faa2e6eef16fba3a31188ead parent 36fc91c
5 files changed, +108 −3
  • internal/activator/backend_exec.go +17 −3
  • internal/activator/backend_exec_test.go +23 −0
  • internal/host/agent.go +4 −0
  • internal/host/memtier.go +26 −0
  • internal/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)
+ }
+ }
+}