ADR 0006 — The memory limit is each request’s, not the function’s
A record of the decision as it was taken; the numbers in it are as of its date. Today’s numbers are in chapter 25.
Status: accepted, 2026-09-26. Prompted by a defect found in the Lima VM (kernel 6.8) with two runtime pools at default limits.
Context
A warm function or a runtime pool serves several requests at once from one
sandbox: a zygote, and a process per request under it. Its limits were
written on the function’s cgroup — memory.max = mem, memory.oom.group = 1,
pids.max, cpu.max — and every generation, the zygote and every request sat
below that. The per-request cgroup carried only a tenant’s narrower limits,
when a tenant had any.
So mem was one budget for the zygote and every concurrent request
together, and the group kill sat at the level that held all of them. One
request allocating 2 GB in a pool at mem = 256M was killed by the kernel —
inside its own cgroup, as the log said — and so were the zygote, the Node
agent’s parked workers and the three requests sleeping beside it, in one
event. For a runtime pool those requests belong to different tenants. The
book said the opposite: “each request has its own group, so it can be killed
alone”.
Decision
mem is written on the leaves and nowhere above them:
- each request’s own cgroup gets
memory.max = mem(andmemory.highwhen swap is allowed,memory.swap.max,memory.oom.group = 1); - the zygote’s leaf gets the same, so the warm process is bounded on its own;
- the function’s cgroup keeps
pids.max,cpu.maxand the swap ceiling, which are one budget for the function, and hasmemory.max = maxandmemory.oom.group = 0written explicitly, so a function directory left by an earlier Zygo does not keep the shared limit it used to carry.
A tenant’s narrower limits still go on the request’s cgroup; they replace the function’s numbers there rather than nesting under a function ceiling.
What a whole function may use is therefore at most (concurrency + 1) × mem
per sandbox. The tenant budget above bounds the sum, as before. No
generation-level ceiling was added: with both leaves bounded it could only
fire on memory charged to the zygote before a forked child was moved into
its request cgroup, and a kill there would again be one that reaches the
wrong process.
Consequences
- A request over
memdies alone. The zygote stays warm; the requests beside it finish.make verify-oom-linuxruns three sleepers beside a 2 GB hog for the Python agent and the Node agent and checks exactly that. - The Node agent’s parked workers are moved into the request’s cgroup before
they run (
FORKEDcarries the pid, and nothing runs beforeGO), so a worker over the limit is the one killed; the parked ones, in the zygote’s leaf, are not. - Memory charged to the zygote’s leaf before a child is moved — the shared,
copy-on-write pages of a fork — stays charged there. Only what a request
allocates after admission counts against its own
mem. - A host sized as functions × mem was sized for the old budget. The new
bound per function is
(concurrency + 1) × mem; chapters 13 and 20 say so.
What would reopen this
- A kernel that lets a moved process’s charge follow it, which would make a generation ceiling exact and worth adding.
- An embedder that wants
memto mean the whole function’s budget again, with a measurement of what the per-request shape costs them.