job-reports-success-while-its-worker-was-oom-killed · v1.0.0 · 2026-09-17 · sha256 a7d8cb56166b70c7

job-reports-success-while-its-worker-was-oom-killed v1.0.0A

Immutable. This exact content is served forever at /api/v1/blob/a7d8cb56166b70c7.

---
name: job-reports-success-while-its-worker-was-oom-killed
description: |
  A batch or cloud job lists as SUCCEEDED while the work inside it produced
  nothing, because the kernel's out-of-memory killer took a forked WORKER and the
  parent caught the failure, wrote its record and exited 0. Use when: (1) a run's
  own output says a worker "died"/"was terminated abruptly" but the execution
  listing says succeeded, (2) you are about to report "the job failed" or "the run
  died" to a coordinator who will quote you upstream, (3) a peer checks the
  listing, sees success and tells you your sentence is wrong, (4) a harness has
  classified the death as transient or retryable, (5) you are sizing a machine
  from a run that died and need its true peak. The platform logs a memory message
  only for a CONTAINER kill, so silence about memory is expected for a child kill
  and is NOT evidence against it. Record the cgroup counters (`memory.oom_control`
  oom_kill, `memory.failcnt`) at the start and end of every run so a kill is
  COUNTED rather than inferred, and read the kernel's own peak, because a
  half-second sampler misses a spike that arrives in five seconds. Name the layer
  that failed: "a worker was killed and the search returned nothing" and "the job
  failed" are different sentences and only one of them is true. NOT for a genuine
  application failure with a recorded exit status, and NOT for a container kill,
  where the platform does say so.
version: 1.0.0
date: 2026-09-17
author: wan-huiyan
disable-model-invocation: true
---
# A Job Reports SUCCESS While Its Worker Was OOM-Killed

## Problem

A long research run fans routing work out to forked worker processes. One worker
is killed by the kernel's out-of-memory killer. The parent process catches the
resulting "a process in the process pool was terminated abruptly", records the
failure in its own JSON, writes its log and returns 0 — which is correct
behaviour for a harness that reports rather than crashes.

The result is three artefacts that disagree in a way nobody notices:

| artefact | what it says |
|---|---|
| the execution listing | `succeededCount 1`, condition `Completed True` |
| the run's own record | the work raised "a worker died while routing item 10 of 100", zero results written |
| the platform's logs | nothing about memory at all |

So "the run died" is wrong, "the run succeeded" is wrong, and the useful sentence
is longer than either.

## Why the platform says nothing about memory

A managed runtime emits its memory-limit message when it kills the CONTAINER. A
child process killed inside a container that stays alive is invisible to it: the
task did not exceed anything the platform enforces, and from outside, nothing
failed. **Absence of a memory message is therefore the expected state for a child
kill, not evidence that memory was not the cause** — which is exactly the
inference a reviewer makes when asked "was it really memory?".

## Make it counted, not inferred

Read the kernel's own counters into the run's record, at the start and again at
the end. On cgroup v1:

```bash
CG=/sys/fs/cgroup/memory
cat $CG/memory.oom_control        # oom_kill_disable 0 / under_oom 0 / oom_kill N
cat $CG/memory.failcnt            # charges refused at the limit
cat $CG/memory.max_usage_in_bytes # the kernel's own peak, never reset
cat $CG/memory.limit_in_bytes
```

On cgroup v2 the equivalents are `memory.events` (`oom_kill`), `memory.peak` and
`memory.max`. Read both layouts: a runtime advertising a modern generation can
still present v1 inside the container, which is what the observed run did.

Two readings did the work in the observed case:

- **`oom_kill` was 0 at the moment the workers forked and 1 at the end.** One line,
  and the finding stopped being a guess. A coordinator quoting it upstream could
  say "the kernel counted the kill" instead of "we think it ran out of memory".
- **`memory.max_usage_in_bytes` was 7.41 GiB against an 8 GiB limit**, while a
  sampler polling `memory.usage_in_bytes` every half second had caught less: usage
  went from about 3 GB just after the fork to about 8 GB within roughly five
  seconds of work starting. **Sample for the shape, but report the kernel's peak.**

## Per-process peaks cannot be added up

`getrusage(RUSAGE_CHILDREN).ru_maxrss` is the peak of the LARGEST single
descendant, not a sum — and for a forked child it counts pages the child still
SHARES with the parent. A worker reading 2.70 GB beside a 2.57 GB parent has not
used 2.70 GB extra; it has copied about 0.13 GB of its own.

To get what a fan-out really costs the machine:

- read the container's total (cgroup usage or peak) and subtract the parent, or
- compare container usage just after the fork with usage at the peak.

Reporting the per-child figure as a sum overstates the workers and understates
the parent, and it points a fix at the wrong half. Say which number you read.

## Why the workers cost so much: it is copied, not built

In the observed run each worker grew by roughly 1.2 GB within seconds of
starting, on top of a 2.6 GB parent, and the cause is worth knowing because it
decides the fix:

- the shared structure was a graph held as plain interpreter objects — a
  dictionary of about 1.7 million node positions plus nested edge dictionaries;
- forked children share those pages until they touch them, and a
  reference-counting runtime WRITES to an object's header when it merely reads it;
- so traversing the structure turns shared pages private, and the container pays
  for them once per worker;
- nothing in the worker's path built an index of its own — which the readings
  support: each worker's peak sat level with the parent's rather than far above it.

**Fixes that follow:** fewer workers, more memory, or holding the shared
structure in a form that survives forking (arrays rather than per-element
objects); a pre-fork `gc.freeze()` is the cheap partial. **A fix that shares a
rebuilt per-worker index is aimed at nothing**, because no such index exists.

## The sentence to send a coordinator

Say which layer failed, what the run produced, and where each reading came from:

> "The search returned no route: a routing worker was killed for memory at the
> first item, and the job itself finished and reported the failure. Container peak
> 7.41 GiB of 8 GiB, kernel `oom_kill` 1 (0 at the fork), `failcnt` 0, read from
> the run's own record and the execution listing."

- **"The job failed" invites a peer to check the listing and correct you**, which
  is what happened: the listing said succeeded, and the peer was right to ask.
- **"The job succeeded" hides a search that produced nothing**, and if the run was
  a machine-sizing measurement, that is the whole result.
- **A harness may have filed the death as `transient`.** A memory kill is not
  retryable at the same size, so a retry classifier reading "worker died" as
  transient will loop a run that cannot pass. Say so when you report it.

## Notes

- **Deaths that predate the counters are weaker evidence.** Two earlier runs in
  the same measurement died the same way before the counters were being read;
  they can only be reported as "most likely memory". Add the counters to the
  harness the first time you see this, not the second.
- **A run that survives is not proof of headroom.** The same wish at the same size
  finished at 7.11 GiB of 8 GiB, and the pair differed only in which items each
  happened to work on. Report the margin, not the pass.
- **Sister skill**: [`parallel-gate-legs-killed-for-memory-count-cannot-see-ram`](../parallel-gate-legs-killed-for-memory-count-cannot-see-ram/SKILL.md)
  — the same killer one layer down, where a local test leg is stopped for memory
  on a shared machine and a process-count guard cannot see RAM.