scripts.container_cpu_accounting
Reference › scripts › container_cpu_accounting
Incus container CPU accounting: it reads the cgroup cpuacct core-seconds of the test-fleet containers before and after one run, so a run's CPU cost can be stated. run_tests imports it, tests/scripts/test_container_cpu_accounting.py tests it, and it shares the live-observer plumbing in pytest_live_observer.
Invocation: main has three subcommands. snapshot reads the current usage and prints it as JSON, or writes it to a file with -o. diff compares two snapshot files and prints the delta. run takes a snapshot, runs the given command, takes another and reports the difference. The snapshot commands take a project and a comma-separated container list.
resolve_project picks the Incus project, from the profile or the environment. list_container_pids asks Incus for each container's init process id, cgroup_cpu_dir finds that process's CPU cgroup directory and read_usage_seconds reads the accumulated usage. take_snapshot gathers these into a ContainerCpuSnapshot, which converts to and from a dictionary. diff_report computes per-container and total core-seconds, and persist_diff_report appends the report to an NDJSON file whose location container_cpu_ndjson_path resolves from the environment. A failure to read raises ContainerCpuError.
At a glance
| Source | scripts/container_cpu_accounting.py |
| Lines of code | 454 |
| Classes | 2 |
| Functions | 12 |
| Imports (internal) | orchestrata_ultimate.core.threading.pytest_live_observer |
| Imported by | scripts.run_tests |
| Tests that import it | tests/scripts/test_container_cpu_accounting.py |
Class diagram
Dependency diagram
API
Classes
ContainerCpuError
Bases: RuntimeError · Line 70
A container's CPU usage could not be resolved or read.
ContainerCpuSnapshot (dataclass)
Decorators: dataclass(frozen=True) · Line 75
One point-in-time read of cumulative CPU usage per container.
| Attribute | Type | Default |
|---|---|---|
ts |
float |
|
project |
str |
|
usage_seconds |
dict[str, float] |
| Method | Signature | Summary |
|---|---|---|
to_dict |
to_dict() -> dict[str, Any] |
|
from_dict staticmethod |
from_dict(data: Mapping[str, Any]) -> ContainerCpuSnapshot |
Functions
| Function | Signature | Summary |
|---|---|---|
resolve_project |
resolve_project(profile: str | None = None, *, env: Mapping[str, str] | None = None) -> str |
Incus project for profile (default: ORCHESTRATA_ENV_PROFILE, itself defaulting to test -- the same default env/test.env documents for the rest of the suite). |
list_container_pids |
list_container_pids(project: str, *, env: Mapping[str, str] | None = None, timeout: float = 30.0) -> dict[str, int] |
{container_name: host_pid} for every RUNNING instance in project, via incus list --format json's state.pid -- the same field incus info derives its own (whole-second) CPU line… |
cgroup_cpu_dir |
cgroup_cpu_dir(pid: int, *, cgroup_root: Path = _CGROUP_ROOT) -> Path |
The host cgroup-v2 directory that accounts for the WHOLE container owning pid -- not just that one process's own leaf scope. |
read_usage_seconds |
read_usage_seconds(cgroup_dir: Path) -> float |
Cumulative CPU time this cgroup has consumed, in seconds, from cpu.stat's usage_usec (cgroup v2's cpuacct-equivalent field -- microsecond precision, unlike incus info's whole-second… |
take_snapshot |
take_snapshot(project: str, containers: Sequence[str] = DEFAULT_CONTAINERS, *, env: Mapping[str, str] | None = None) -> ContainerCpuSnapshot |
Read cumulative CPU usage for containers in project right now. |
diff_report |
diff_report(before: ContainerCpuSnapshot, after: ContainerCpuSnapshot) -> dict[str, Any] |
Per-container and total CPU-seconds consumed between two snapshots. |
container_cpu_ndjson_path |
container_cpu_ndjson_path(env: Mapping[str, str] | None = None) -> Path |
Where container-CPU delta records are appended. |
persist_diff_report |
persist_diff_report(report: Mapping[str, Any], *, env: Mapping[str, str] | None = None) -> None |
Append report (from diff_report) as one NDJSON line, keyed by the SAME run identity (run_id/head_commit/batch_id/lane, each omitted rather than null when unset) that… |
_print_report |
_print_report(report: dict[str, Any]) -> None |
|
_add_common_args |
_add_common_args(parser: argparse.ArgumentParser) -> None |
|
_resolve_project_arg |
_resolve_project_arg(args: argparse.Namespace) -> str |
|
main |
main(argv: list[str] | None = None) -> int |
Source
#!/usr/bin/env python3
"""Incus container CPU accounting — cgroup ``cpuacct`` core-seconds around one
suite run (``plans/prolog-learning-subsystem-research-2026-08-12.md``,
recommendation #5).
The research record measured the suite's CPU floor at 2,567.7 core-seconds /
6 physical cores = 428.0s, and called every "the suite is at its floor" claim
built on that number -- including its own -- conditioned on one omission:
Memgraph and Redis run on the SAME 6 physical cores as the suite process
itself and are EXCLUDED from the 2,567.7 core-s figure. The true floor is
above 428s by an unmeasured amount. This script is the measurement: two
cgroup reads (before/after a run) turn "unmeasured amount" into a number.
Infra is Incus containers on ``10.53.2.0/24`` for the ``test`` profile (see
``INFRASTRUCTURE.md`` and ``env/test.env``) -- NOT Docker, NOT localhost.
``incus list`` inspects them directly; this script reads the SAME accounting
Incus itself derives, but at microsecond precision (``cpu.stat``'s
``usage_usec``) rather than the whole-second rounding ``incus info`` prints,
because a run's CPU delta is often smaller than one second of rounding error.
Usage
-----
# Wrap a run end-to-end (recommended): snapshots before/after, reports
# the delta, and exits with the wrapped command's own exit code.
python scripts/container_cpu_accounting.py run -- uv run pytest tests/core/threading
# Or take snapshots manually around an already-running job:
python scripts/container_cpu_accounting.py snapshot -o /tmp/before.json
... # run the suite yourself
python scripts/container_cpu_accounting.py snapshot -o /tmp/after.json
python scripts/container_cpu_accounting.py diff /tmp/before.json /tmp/after.json
Both forms default to the ``memgraph``/``redis`` containers in the
``orchestrata-test`` Incus project (the two the research record names) --
override with ``--containers`` and/or ``--project``/``--profile``.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
#: Fleet -> Incus project, from INFRASTRUCTURE.md's "Environment fleets"
#: table. Mirrors the mapping every other script in this tree hardcodes
#: per-profile (e.g. scripts/promote_env.py's "orchestrata-test" literal) --
#: there is no single canonical resolver to import, so this is that mapping
#: made local and explicit rather than re-guessed inline.
PROJECT_BY_PROFILE = {
"prod": "default",
"test": "orchestrata-test",
"dev": "orchestrata-dev",
}
#: The two containers the research record names as excluded from the
#: measured 2,567.7 core-s floor because they share the suite's own 6
#: physical cores.
DEFAULT_CONTAINERS = ("memgraph", "redis")
_CGROUP_ROOT = Path("/sys/fs/cgroup")
class ContainerCpuError(RuntimeError):
"""A container's CPU usage could not be resolved or read."""
@dataclass(frozen=True)
class ContainerCpuSnapshot:
"""One point-in-time read of cumulative CPU usage per container."""
ts: float
project: str
usage_seconds: dict[str, float]
def to_dict(self) -> dict[str, Any]:
return {
"ts": self.ts,
"project": self.project,
"usage_seconds": self.usage_seconds,
}
@staticmethod
def from_dict(data: Mapping[str, Any]) -> ContainerCpuSnapshot:
try:
usage = {str(k): float(v) for k, v in data["usage_seconds"].items()}
return ContainerCpuSnapshot(
ts=float(data["ts"]), project=str(data["project"]), usage_seconds=usage
)
except (KeyError, TypeError, ValueError) as exc:
raise ContainerCpuError(f"malformed snapshot: {data!r}") from exc
def resolve_project(
profile: str | None = None, *, env: Mapping[str, str] | None = None
) -> str:
"""Incus project for ``profile`` (default: ``ORCHESTRATA_ENV_PROFILE``,
itself defaulting to ``test`` -- the same default ``env/test.env``
documents for the rest of the suite)."""
e = env if env is not None else os.environ
p = profile if profile is not None else e.get("ORCHESTRATA_ENV_PROFILE", "test")
try:
return PROJECT_BY_PROFILE[p]
except KeyError:
known = ", ".join(sorted(PROJECT_BY_PROFILE))
raise ContainerCpuError(
f"unknown env profile {p!r}; expected one of: {known}"
) from None
def list_container_pids(
project: str,
*,
env: Mapping[str, str] | None = None,
timeout: float = 30.0,
) -> dict[str, int]:
"""``{container_name: host_pid}`` for every RUNNING instance in
``project``, via ``incus list --format json``'s ``state.pid`` -- the same
field ``incus info`` derives its own (whole-second) CPU line from.
``env``/``timeout`` are dependency-injection seams: production leaves
both at their defaults (the real process environment, 30s); tests use
them to trigger the real ``incus`` CLI's own failure modes (an
unreachable socket, an impossible timeout) without mocking anything.
"""
try:
result = subprocess.run(
["incus", "--project", project, "list", "--format", "json"],
capture_output=True,
text=True,
check=True,
timeout=timeout,
env=dict(env) if env is not None else None,
)
except FileNotFoundError as exc:
raise ContainerCpuError("incus executable not found on PATH") from exc
except subprocess.CalledProcessError as exc:
raise ContainerCpuError(
f"incus list --project {project} failed: {exc.stderr.strip()}"
) from exc
except subprocess.TimeoutExpired as exc:
raise ContainerCpuError(f"incus list --project {project} timed out") from exc
try:
instances = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ContainerCpuError(f"incus list returned non-JSON output: {exc}") from exc
pids: dict[str, int] = {}
for inst in instances:
if inst.get("status") != "Running":
continue
pid = (inst.get("state") or {}).get("pid")
name = inst.get("name")
if pid and name:
pids[str(name)] = int(pid)
return pids
def cgroup_cpu_dir(pid: int, *, cgroup_root: Path = _CGROUP_ROOT) -> Path:
"""The host cgroup-v2 directory that accounts for the WHOLE container
owning ``pid`` -- not just that one process's own leaf scope.
Derived from ``/proc/<pid>/cgroup`` rather than assumed from a naming
convention: Incus/LXC nests a container's init process one level under a
directory named ``lxc.payload.<project>_<name>`` (e.g.
``.../lxc.payload.orchestrata-test_memgraph/init.scope``); that
directory -- not the ``init.scope`` leaf under it -- is what accounts for
every process the container has ever spawned, matching what ``incus
info``'s own CPU line is computed from.
"""
cgroup_path = Path(f"/proc/{pid}/cgroup")
try:
line = cgroup_path.read_text(encoding="utf-8").strip()
except OSError as exc:
raise ContainerCpuError(f"cannot read {cgroup_path}: {exc}") from exc
# cgroup v2 unified hierarchy: exactly one line, "0::<path>".
_, sep, rel = line.partition("::")
if not sep or not rel.startswith("/"):
raise ContainerCpuError(
f"pid {pid}: unexpected {cgroup_path} contents {line!r} "
"(expected a cgroup v2 unified '0::/...' line -- cgroup v1 host?)"
)
segments = PurePosixPath(rel).parts[1:] # drop the leading "/"
for i, seg in enumerate(segments):
if seg.startswith("lxc.payload."):
return cgroup_root.joinpath(*segments[: i + 1])
raise ContainerCpuError(
f"pid {pid}: no 'lxc.payload.*' segment in cgroup path {rel!r} "
"-- not an Incus/LXC container process?"
)
def read_usage_seconds(cgroup_dir: Path) -> float:
"""Cumulative CPU time this cgroup has consumed, in seconds, from
``cpu.stat``'s ``usage_usec`` (cgroup v2's cpuacct-equivalent field --
microsecond precision, unlike ``incus info``'s whole-second rounding)."""
stat_path = cgroup_dir / "cpu.stat"
try:
text = stat_path.read_text(encoding="utf-8")
except OSError as exc:
raise ContainerCpuError(f"cannot read {stat_path}: {exc}") from exc
for line in text.splitlines():
key, _, value = line.partition(" ")
if key == "usage_usec":
try:
return int(value) / 1_000_000
except ValueError as exc:
raise ContainerCpuError(
f"{stat_path}: bad usage_usec {value!r}"
) from exc
raise ContainerCpuError(f"{stat_path} has no usage_usec field")
def take_snapshot(
project: str,
containers: Sequence[str] = DEFAULT_CONTAINERS,
*,
env: Mapping[str, str] | None = None,
) -> ContainerCpuSnapshot:
"""Read cumulative CPU usage for ``containers`` in ``project`` right now.
``env`` is the SAME dependency-injection seam ``list_container_pids``
already exposes, threaded through rather than re-invented: production
callers leave it at the default (the real process environment); tests
pass an explicit ``Mapping`` (e.g. a real, unreachable ``INCUS_SOCKET``)
so the real ``incus`` CLI's own failure modes can be exercised without
mutating ``os.environ`` (RUL-002).
"""
pids = list_container_pids(project, env=env)
missing = [name for name in containers if name not in pids]
if missing:
have = ", ".join(sorted(pids)) or "none"
raise ContainerCpuError(
f"container(s) not running in project {project!r}: {', '.join(missing)} "
f"(running: {have})"
)
usage = {
name: read_usage_seconds(cgroup_cpu_dir(pids[name])) for name in containers
}
return ContainerCpuSnapshot(ts=time.time(), project=project, usage_seconds=usage)
def diff_report(
before: ContainerCpuSnapshot, after: ContainerCpuSnapshot
) -> dict[str, Any]:
"""Per-container and total CPU-seconds consumed between two snapshots.
Deliberately no averaging/history across MULTIPLE diffs -- like the
fixture-cost instrument (recommendation #3), this is an n=1 measurement
around one run, not a learner.
"""
if before.project != after.project:
raise ContainerCpuError(
f"snapshot project mismatch: {before.project!r} vs {after.project!r}"
)
names = sorted(set(before.usage_seconds) & set(after.usage_seconds))
skipped = sorted(set(before.usage_seconds) ^ set(after.usage_seconds))
core_seconds = {
name: round(after.usage_seconds[name] - before.usage_seconds[name], 3)
for name in names
}
return {
"project": before.project,
"wall_seconds": round(after.ts - before.ts, 3),
"core_seconds": core_seconds,
"total_core_seconds": round(sum(core_seconds.values()), 3),
"skipped_containers": skipped,
}
# ---------------------------------------------------------------------------
# Persistence (recommendation #5's wiring into scripts/run_tests.py): a
# printed number scrolls away -- exactly the "the research keeps being done
# and deleted" failure the research record complains about for
# recommendation #1. This durably appends the delta so a later question can
# answer itself from reports/, the same discipline
# pytest_live_observer._append_test_durations already follows.
# ---------------------------------------------------------------------------
_CONTAINER_CPU_PATH_ENV = "ORCH_CONTAINER_CPU_PATH"
def container_cpu_ndjson_path(env: Mapping[str, str] | None = None) -> Path:
"""Where container-CPU delta records are appended.
Mirrors ``pytest_live_observer.durations_ndjson_path``'s override
convention: ``ORCH_CONTAINER_CPU_PATH`` lets tests redirect output away
from the real ``reports/`` directory. Default:
``<cwd>/reports/container-cpu.ndjson``.
"""
e = env if env is not None else os.environ
override = e.get(_CONTAINER_CPU_PATH_ENV)
if override:
return Path(override)
return Path.cwd() / "reports" / "container-cpu.ndjson"
def persist_diff_report(
report: Mapping[str, Any], *, env: Mapping[str, str] | None = None
) -> None:
"""Append ``report`` (from ``diff_report``) as one NDJSON line, keyed by
the SAME run identity (``run_id``/``head_commit``/``batch_id``/``lane``,
each omitted rather than null when unset) that
``pytest_live_observer.run_identity`` already stamps on every
test-duration and fixture-cost record -- recommendation #3's join-key
convention, reused here rather than inventing a second identity scheme.
A row written by run_tests.py's OWN process (not a batch subprocess)
typically carries only ``run_id``/``head_commit``: ``batch_id``/``lane``
are per-subprocess and never present in the top-level runner's own
environment.
Best-effort: never raises, matching every other sink in this project
(``_emit``, ``_append_test_durations``) -- a write failure must never
fail the run it observed. The caller (``run_tests.py``) additionally
treats the WHOLE measurement (snapshot/diff/persist) as pure
observation with no correctness role; this function's own contract is
narrower and matches its siblings: only the write step is swallowed.
"""
# Lazy: keeps this script's own CLI usage (snapshot/diff/run) free of a
# src/orchestrata_ultimate import when persistence is never invoked --
# only run_tests.py's wiring pays for it.
from orchestrata_ultimate.core.threading.pytest_live_observer import run_identity
e = env if env is not None else os.environ
record: dict[str, Any] = {
"event": "container_cpu",
**report,
**run_identity(env=e),
}
try:
path = container_cpu_ndjson_path(e)
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644)
try:
os.write(fd, (json.dumps(record) + "\n").encode("utf-8"))
finally:
os.close(fd)
except OSError:
pass
def _print_report(report: dict[str, Any]) -> None:
print(json.dumps(report, indent=2, sort_keys=True))
print(
f"\ntotal: {report['total_core_seconds']:.3f} core-s over "
f"{report['wall_seconds']:.3f}s wall ({report['project']})",
file=sys.stderr,
)
def _add_common_args(parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"--project", help="Incus project (default: derived from --profile)"
)
parser.add_argument(
"--profile",
help="Env profile (test/dev/prod) selecting the Incus project when --project is not given "
"(default: $ORCHESTRATA_ENV_PROFILE, itself defaulting to 'test')",
)
parser.add_argument(
"--containers",
default=",".join(DEFAULT_CONTAINERS),
help=f"comma-separated container names (default: {','.join(DEFAULT_CONTAINERS)})",
)
def _resolve_project_arg(args: argparse.Namespace) -> str:
return args.project if args.project else resolve_project(args.profile)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="container_cpu_accounting.py",
description="cgroup cpuacct core-seconds for Incus test-fleet containers, before/after one run.",
)
sub = parser.add_subparsers(dest="cmd", required=True)
p_snap = sub.add_parser(
"snapshot", help="Read current CPU usage and print/save it as JSON."
)
_add_common_args(p_snap)
p_snap.add_argument("-o", "--output", help="write JSON here instead of stdout")
p_diff = sub.add_parser("diff", help="Compare two snapshot JSON files.")
p_diff.add_argument("before", help="snapshot JSON taken before the run")
p_diff.add_argument("after", help="snapshot JSON taken after the run")
p_run = sub.add_parser(
"run", help="Snapshot, run a command, snapshot again, report the delta."
)
_add_common_args(p_run)
p_run.add_argument(
"command",
nargs=argparse.REMAINDER,
help="command to run, e.g. -- uv run pytest ...",
)
args = parser.parse_args(argv)
try:
if args.cmd == "snapshot":
containers = [c.strip() for c in args.containers.split(",") if c.strip()]
snap = take_snapshot(_resolve_project_arg(args), containers)
payload = json.dumps(snap.to_dict(), indent=2, sort_keys=True)
if args.output:
Path(args.output).write_text(payload + "\n", encoding="utf-8")
else:
print(payload)
return 0
if args.cmd == "diff":
before = ContainerCpuSnapshot.from_dict(
json.loads(Path(args.before).read_text(encoding="utf-8"))
)
after = ContainerCpuSnapshot.from_dict(
json.loads(Path(args.after).read_text(encoding="utf-8"))
)
_print_report(diff_report(before, after))
return 0
if args.cmd == "run":
command = args.command
if command and command[0] == "--":
command = command[1:]
if not command:
print(
"usage: container_cpu_accounting.py run -- <command...>",
file=sys.stderr,
)
return 2
containers = [c.strip() for c in args.containers.split(",") if c.strip()]
project = _resolve_project_arg(args)
before = take_snapshot(project, containers)
completed = subprocess.run(command)
after = take_snapshot(project, containers)
_print_report(diff_report(before, after))
return completed.returncode
return 2
except ContainerCpuError as exc:
print(f"container_cpu_accounting: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())