build: Convert to module - #9
Conversation
faec224 to
076040e
Compare
Pijukatel
left a comment
There was a problem hiding this comment.
This is really great. It is a big change, and I want to review it carefully, so I will do it in steps over several days. Some comments are more like open questions, not necessarily a change request.
This is the first-step review:
Public documentation + test result glance
(without looking at internal implementation)
Follow-up reviews will be:
- Interface and usage
- Implementation internals
- Test internals
- CI
| return controllers.exists() and bool(controllers.read_text().strip()) | ||
|
|
||
|
|
||
| def machine_memory_bytes() -> int: |
There was a problem hiding this comment.
For this and some other reference values, why not use psutils where we know it correctly estimates?
There was a problem hiding this comment.
Ideally, I'd like to keep this package zero-dependency. That guarantees it runs anywhere Python does. psutil ships a C extension, so it needs a prebuilt wheel matching the platform, or a compiler on the machine to build one.
| """ | ||
|
|
||
|
|
||
| def unavailable(capability: str, reason: str) -> None: |
There was a problem hiding this comment.
Have you considered using explicit Pytest markers for tests?
https://docs.pytest.org/en/stable/example/markers.html#marking-test-functions-and-selecting-them-for-a-run
The reader of the test suite can be surprised by entries like this and wonder whether it is missing some coverage due to runners not being set up correctly or if this is the desired behavior:
SKIPPED [1] tests/e2e/test_docker.py:174: docker uses the 'cgroupfs' cgroup driver; this needs systemd
SKIPPED [1] tests/e2e/test_kubernetes.py:182: kind, kubectl and docker are needed to run a cluster
SKIPPED [1] tests/e2e/test_kubernetes.py:202: kind, kubectl and docker are needed to run a cluster
SKIPPED [1] tests/e2e/test_machine.py:50: the controllers are not on the unified hierarchy
If it is known in advance which environment has which capability, then we should be able to call just the right subset of the tests through markers.
| python3 report.py results/ --check # same, exit 1 if a probe failed | ||
| This package reads the files, walks the levels, and reports only what restricts the process. `None` means nothing restricts it - the machine is then the honest answer, and `get_machine_cpu_count()` and `get_machine_memory_bytes()` below give it. | ||
|
|
||
| Linux only, Python 3.10 or newer, and no dependencies. Off Linux every limit reads as `None`. Some examples below pair it with `psutil`, which this package does not require - it is there to answer what the machine is using, which is not a question about limits. |
There was a problem hiding this comment.
Off Linux every limit reads as None
Should we rather raise when not running in the expected environment for now?
Could you also elaborate on the environment, like a Linux container that is running through different mechanisms on MacOS or Windows?
There was a problem hiding this comment.
hould we rather raise when not running in the expected environment for now?
None says there are no limits, or that they could not be determined.On an unsupported environment that is the correct answer.
We can add an is_supported() function as a simple way to check whether the current environment supports cgroups.
Could you also elaborate on the environment, like a Linux container that is running through different mechanisms on MacOS or Windows?
This is about macOS and Windows themselves. A Linux container should work correctly wherever it runs. With one correction: the machine-wide values come from the virtual machine, not from the physical host.
| """The total and the used memory a budget should be derived from, in bytes.""" | ||
| budget = cgroups_sensor.get_memory_budget() | ||
| if budget is not None: | ||
| return budget.limit, budget.working_set |
There was a problem hiding this comment.
What is the reasoning behind memory.total, memory.total - memory.available not being the default(when there are no tighther restrictions) of budget.limit, budget.working_set? Is it to avoid dependencies?
or if we keep it this way, could we turn def memory_budget() into a utility function closed behind psutils as an optional dependency?
I can imagine this or a similar utility function would be repeated by a huge number of users.
(Ok, here I am going wild and just throwing ideas. We could also keep this package completely clear of dependencies and later add a second package like "psutils_cgropups_aware" which would be thin wrapper around the original psutils, delegating almost everything back to psutils and patching just some return values using new functionality you have introduced here. The motivation would be to have a "drop-in replacement" like import psutils_cgropups_aware as psutils)
| time.sleep(5) | ||
| ``` | ||
|
|
||
| The loop has to pace itself, as the `sleep` above does: every call here returns at once where there is nothing to measure, and `sample()` never waits at all. For a single measurement there is `get_cpu_used_ratio(interval)`, which waits out the window itself, and `get_cpu_used_ratio_async(interval)` for asyncio. Keep the interval generous - the kernel updates the counter in coarse steps, so a tenth of a second can report a busy process as idle, and anything below 0.01 seconds is refused outright. |
There was a problem hiding this comment.
From just this description, I could not understand whether the load.sample() is measuring against its previous call or what it is actually measuring. I need to go inside the function to understand it. Could you please try to explain it more in this readme so that the readers do not need to check the internals for top level description. (Maybe it is just me being slow though :D )
|
|
||
| ## Diagnostics | ||
|
|
||
| `describe()` explains a reading that looks wrong. It carries the readings themselves, so one dump answers what was reported as well as why, and around them a `Source` per metric - the mechanism it was read through and the levels searched - the raw values before filtering, the machine it compared against, the levels the memory and the CPU limit actually came from, and a notice for every reading that is not there. A reading of `None` with no notice about it means the mechanism was there and nothing limited this process in a way that kills it - only hard limits are read, and `memory.high` throttles reclaim instead. The levels searched are not the levels a reading came from: a level carries no files until a limit is written there, and it is kept in the chain regardless. |
There was a problem hiding this comment.
The levels searched are not the levels a reading came from This sounds strange in isolation, could you please further explain or rephrase it.
My dumb interpretation without knowing the internals yet. "We searched some levels, but they are unrelated to the reading :-)"
| page cache — what `docker stats` shows. The second table is the same run seen through `get_memory_info()` and | ||
| `get_cpu_info()`, where a limit either reaches the caller or falls back to host values. When a reading looks | ||
| wrong, `evidence` says who is at fault: the limit is in the control files, or it never got there. | ||
| | `MEMORY_LIMIT_COVERS_MACHINE` | the limit is at least the memory of the machine | |
There was a problem hiding this comment.
"at least the memory of the machine" , does it mean that memory limit can be higher than that?
Maybe something like "the limit is limited(or saturated) by the memory of the machine" ?
|
|
||
| `Source.interface` is an `Interface` member, and a notice carries a `NoticeCode`. Branch on those rather than on the strings they print as: | ||
|
|
||
| | `NoticeCode` | Meaning | |
There was a problem hiding this comment.
It would be great if each notice carried at least one real-world example of when such a notice can arrive
| | `CPU_USAGE_SCOPE_MISMATCH` | the level the CPU limit applies to counts no CPU time, so no rate can be measured there | | ||
| | `MEMORY_METRICS_UNAVAILABLE` | nothing here carries a memory limit at all, which is what a machine without cgroups looks like | | ||
| | `CPU_METRICS_UNAVAILABLE` | nothing here carries a CPU limit at all, for the same reasons | | ||
| | `MEMORY_LIMIT_UNREADABLE` | a level holds a memory limit that says nothing usable, so what it enforces is unknown | |
There was a problem hiding this comment.
Haha, I am so curious about an example of when this happens. I want to know what the unknown enforcement is :-)
There was a problem hiding this comment.
printf 'not a number\n' > /tmp/fake-memory-max && docker run --rm --memory 512m \
-v "$PWD/src:/sensor:ro" -e PYTHONPATH=/sensor \
-v /tmp/fake-memory-max:/sys/fs/cgroup/memory.max:ro \
python:3.13-alpine python -c '
import dataclasses, json, cgroups_sensor
print(json.dumps(dataclasses.asdict(cgroups_sensor.describe()), indent=2, default=str))'| build-backend = "uv_build" | ||
|
|
||
| [project] | ||
| name = "cgroups-sensor" |
There was a problem hiding this comment.
Let's keep it as a working placeholder name for now, but have an internal debate with the rest of the team before publication
(I am also bad with names)
There was a problem hiding this comment.
Unless we find something better, my suggestion is synomys or prairidog (уeah, that's the best I could come up with).
Pijukatel
left a comment
There was a problem hiding this comment.
A few more comments.
I also asked Claude to come up with scenarios where estimation is not working as expected. It did not come up with any normal scenario, just some edge cases:
"""Regressions: readings that go wrong on layouts found in the wild.
The first two pin the hybrid machine whose unified hierarchy carries a stray controller. A kernel offers
controllers younger than the systemd that mounts the cgroup v1 hierarchies (`misc`, `rdma`, `hugetlb`), so
they stay bound to the otherwise controller-less cgroup2 - `cgroup.controllers` there is then not empty.
The CPU limits still live on cgroup v1, and a runtime with the cgroupfs driver names its groups only there,
so the time counted on the unified hierarchy belongs to another scope entirely.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
import cgroups_sensor
from .conftest import HYBRID_MOUNTINFO, HYBRID_SELF_CGROUP
from .test_sensor import fake_time, notice_codes
if TYPE_CHECKING:
from collections.abc import Callable
from pathlib import Path
HYBRID_STRAY_FILES = {
# The stray controller: the only thing bound to the unified hierarchy.
'unified/cgroup.controllers': 'hugetlb\n',
# systemd tracks this process as the docker service on the unified hierarchy, so the time counted
# there is the daemon's scope, not the container's.
'unified/system.slice/docker.service/cpu.stat': 'usage_usec 999000000\n',
'unified/cpu.stat': 'usage_usec 999000000\n',
# The quota and the time of the container, both on cgroup v1 where the runtime wrote them.
'cpu,cpuacct/docker/abc/cpu.cfs_quota_us': '50000\n',
'cpu,cpuacct/docker/abc/cpu.cfs_period_us': '100000\n',
'cpu,cpuacct/docker/abc/cpuacct.usage': '3000000000\n',
}
"""A hybrid layout with `hugetlb` riding the unified mount and the CPU metrics on cgroup v1."""
def test_get_cpu_usage_hybrid_stray_controller_reads_the_quota_interface(
fake_cgroup: Callable[..., Path],
) -> None:
"""Reads the time where the limits live, not on a unified hierarchy that merely carries some controller.
`cpu.stat` exists on every cgroup v2 group whether or not the CPU controller is bound there, so a stray
`hugetlb` must not make it the source: the group it names belongs to the service, and its counter says
999 seconds where the container's `cpuacct` says 3.
"""
root = fake_cgroup(
mountinfo=HYBRID_MOUNTINFO,
self_cgroup=HYBRID_SELF_CGROUP,
files=HYBRID_STRAY_FILES,
)
assert cgroups_sensor.get_cpu_usage() == pytest.approx(3.0)
description = cgroups_sensor.describe()
assert description.cpu_usage_source is not None
assert description.cpu_usage_source.interface is cgroups_sensor.Interface.CGROUP_V1
# The rate has to be measurable where the quota binds, and the comount carries the counter right there.
assert description.cpu_rate_level == str(root / 'cpu,cpuacct' / 'docker' / 'abc')
def test_get_cpu_used_ratio_hybrid_stray_controller(
fake_cgroup: Callable[..., Path], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Measures the rate against the cgroup v1 quota, instead of reporting a scope mismatch.
The counter sits next to the quota in the `cpu,cpuacct` comount. Translating the quota's level into the
unified hierarchy and giving up when it is not there loses a rate that was one file away.
"""
root = fake_cgroup(
mountinfo=HYBRID_MOUNTINFO,
self_cgroup=HYBRID_SELF_CGROUP,
files=HYBRID_STRAY_FILES,
)
def advance(_seconds: float) -> None:
# A quarter of a CPU second over one second of the fake clock, against half a core: half the allowance.
(root / 'cpu,cpuacct' / 'docker' / 'abc' / 'cpuacct.usage').write_text('3250000000\n')
fake_time(monkeypatch, sleep=advance)
assert cgroups_sensor.get_cpu_used_ratio() == pytest.approx(0.5)
assert notice_codes('cpu') == ()
def test_describe_subtree_mount_names_the_hidden_ancestors(fake_cgroup: Callable[..., Path]) -> None:
"""Says that levels above the mount exist and cannot be read, the way an unreadable file already does.
A cgroup v1 runtime exposes only the container's own subtree: the mount root is the container's path. A
limit on an ancestor - a tenant slice, `--cgroup-parent`, the pod - then binds without being visible, and
the kernel kills at a cap this process was told does not exist. The values are unknowable, but their
possibility is not: the mount root says levels were cut off, so a notice has to say so too.
"""
fake_cgroup(
mountinfo='30 25 0:26 /tenant/app {root}/memory rw,nosuid shared:14 - cgroup cgroup rw,memory',
self_cgroup='2:memory:/tenant/app\n',
files={
'memory/memory.limit_in_bytes': '9223372036854771712\n',
'memory/memory.usage_in_bytes': '1000\n',
'memory/memory.stat': 'total_inactive_file 400\n',
},
)
assert 'mount-hides-ancestors' in notice_codes()
| files={ | ||
| # The pod is nearly full of its own generous limit. | ||
| 'qos/pod/memory.max': '1000\n', | ||
| 'qos/pod/memory.current': '960\n', | ||
| 'qos/pod/memory.stat': 'inactive_file 10\n', | ||
| # The QoS class above is tighter, but half of what it holds belongs to the sibling pods. | ||
| 'qos/memory.max': '500\n', | ||
| 'qos/memory.current': '260\n', | ||
| 'qos/memory.stat': 'inactive_file 10\n', |
There was a problem hiding this comment.
These values confuse me. Child consuming more than the parents' limit. Isn't this an impossible scenario?
Can we rewrite these values to prove the behavior in a real scenario?
| return Controllers(memory=None, cpu_quota=None, cpu_usage=None, cpu_set=None) | ||
|
|
||
| return Controllers( | ||
| memory=_locate_controller(unified, v1, 'memory', v2_probe=_V2.memory_usage, v1_probe=_V1.memory_usage), |
There was a problem hiding this comment.
This makes me confused
...'memory', v2_probe=_V2.memory_usage, v1_probe=_V1.memory_usage...
Here it looks like coincidental coupling, while it should be an implication, or?
v2_probe maps to exactly one v1_probe and exactly one v1_name
Maybe the arguments to this function could be reduced and some relations hard-coded to avoid this illusion of flexibility.
| read again on every sample. | ||
| """ | ||
| try: | ||
| unified, v1 = _read_hierarchies() |
There was a problem hiding this comment.
_read_hierarchies has a quite complex return type. Why not just return
unified as another key in the hierarchy dict?
| return max(current - inactive_file, 0) | ||
|
|
||
|
|
||
| def _locate_controller( |
There was a problem hiding this comment.
Together with some other comments, I think that it could be sufficient to have just two arguments:
hierarchies: dict[str, _Hierarchy],
v1_name: str,
where unified can be trivially extracted from hierarchies and v1_name implies v2_probe and v1_probe
| from __future__ import annotations | ||
|
|
||
|
|
||
| def count_cpu_list(cpu_list: str) -> int | None: |
There was a problem hiding this comment.
count_cpu_list("0,0") returns 2
or
count_cpu_list("0-2, 1-3") returns 6
The Kernel can still understand those ill-defined sets correctly.
Maybe we should internally try to create such a set to handle duplicates
cpus=set(...)
and just return its length
| ) | ||
|
|
||
|
|
||
| def test_read_memory_ancestor_without_usage_files(fake_cgroup: Callable[..., Path]) -> None: |
There was a problem hiding this comment.
Seems like exactly the same test as the one on line 890 test_read_memory_ancestor_without_usage_drops_the_distance , with just different comments
Closes: #1
Closes: #2