Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 (and memory.high when 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.max and the swap ceiling, which are one budget for the function, and has memory.max = max and memory.oom.group = 0 written 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 mem dies alone. The zygote stays warm; the requests beside it finish. make verify-oom-linux runs 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 (FORKED carries the pid, and nothing runs before GO), 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 mem to mean the whole function’s budget again, with a measurement of what the per-request shape costs them.