quantpad-data 0.5.0__tar.gz → 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/PKG-INFO +1 -1
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/pyproject.toml +1 -1
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/client.py +1 -1
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/memory.py +59 -3
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_v2.py +101 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/.gitignore +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/README.md +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/__init__.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/cache.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/errors.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/lean.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/py.typed +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/rth.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/quantpad_data/v2.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/conftest.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_client.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_lean.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_message_encoding.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_packaging.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_rth.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_v2_cache.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_v2_gate.py +0 -0
- {quantpad_data-0.5.0 → quantpad_data-0.5.1}/tests/test_version.py +0 -0
|
@@ -59,6 +59,34 @@ def _read_int(path: str) -> int | None:
|
|
|
59
59
|
return None
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
def _stat_map(path: str) -> dict[str, int]:
|
|
63
|
+
"""A cgroup ``memory.stat`` file as a mapping, empty if unreadable."""
|
|
64
|
+
out: dict[str, int] = {}
|
|
65
|
+
try:
|
|
66
|
+
with open(path) as handle:
|
|
67
|
+
for line in handle:
|
|
68
|
+
key, _, value = line.partition(" ")
|
|
69
|
+
try:
|
|
70
|
+
out[key] = int(value)
|
|
71
|
+
except ValueError:
|
|
72
|
+
continue
|
|
73
|
+
except OSError:
|
|
74
|
+
pass
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _unreclaimable(stat: dict[str, int], keys: tuple[str, ...]) -> int | None:
|
|
79
|
+
"""What this cgroup holds that the kernel cannot take back.
|
|
80
|
+
|
|
81
|
+
Everything charged to a cgroup is *not* equally spent. Page cache is
|
|
82
|
+
charged, and is handed back the moment anything needs the memory, so
|
|
83
|
+
subtracting it from the limit describes a process that is nowhere near
|
|
84
|
+
its ceiling as though it were at it.
|
|
85
|
+
"""
|
|
86
|
+
present = [stat[key] for key in keys if key in stat]
|
|
87
|
+
return sum(present) if present else None
|
|
88
|
+
|
|
89
|
+
|
|
62
90
|
def _cgroup_free() -> int | None:
|
|
63
91
|
"""Headroom under this container's memory limit, if it has one.
|
|
64
92
|
|
|
@@ -66,12 +94,34 @@ def _cgroup_free() -> int | None:
|
|
|
66
94
|
reports the HOST's memory: in a sandbox on a large machine that is wrong
|
|
67
95
|
by an order of magnitude and wrong in the generous direction, which is
|
|
68
96
|
the direction that kills the process.
|
|
97
|
+
|
|
98
|
+
HEADROOM IS THE LIMIT LESS WHAT CANNOT BE RECLAIMED, NOT LESS WHAT IS
|
|
99
|
+
CHARGED, and the difference is the whole reason this function is not two
|
|
100
|
+
lines. ``memory.current`` includes page cache, so *writing a file* looks
|
|
101
|
+
identical to *allocating memory*: measured on a 4 GiB Daytona sandbox,
|
|
102
|
+
2 GiB of files on disk took this from 3.91 GiB to 1.93 GiB while the
|
|
103
|
+
process's own ``anon`` never left 0.09 GiB, and deleting the files put it
|
|
104
|
+
straight back. Under ``mode="auto"`` that halved the declared budget and
|
|
105
|
+
refused windows that fitted twice over, with a message naming memory the
|
|
106
|
+
caller had. The disk cache makes it worse by design: every partition it
|
|
107
|
+
writes, and every one it reads back, is more page cache.
|
|
108
|
+
|
|
109
|
+
Note this is also what makes the two sources answer the SAME question --
|
|
110
|
+
``MemAvailable`` already counts reclaimable cache as available, so the old
|
|
111
|
+
subtraction had them disagreeing and ``min()`` let the wrong one win.
|
|
69
112
|
"""
|
|
70
|
-
for limit_path, usage_path in (
|
|
71
|
-
(
|
|
113
|
+
for limit_path, usage_path, stat_path, keys in (
|
|
114
|
+
(
|
|
115
|
+
"/sys/fs/cgroup/memory.max",
|
|
116
|
+
"/sys/fs/cgroup/memory.current",
|
|
117
|
+
"/sys/fs/cgroup/memory.stat",
|
|
118
|
+
("anon", "unevictable", "slab_unreclaimable"),
|
|
119
|
+
),
|
|
72
120
|
(
|
|
73
121
|
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
|
74
122
|
"/sys/fs/cgroup/memory/memory.usage_in_bytes",
|
|
123
|
+
"/sys/fs/cgroup/memory/memory.stat",
|
|
124
|
+
("total_rss", "total_unevictable"),
|
|
75
125
|
),
|
|
76
126
|
):
|
|
77
127
|
limit = _read_int(limit_path)
|
|
@@ -79,7 +129,13 @@ def _cgroup_free() -> int | None:
|
|
|
79
129
|
# None; v1 writes a sentinel. Both mean unlimited.
|
|
80
130
|
if limit is None or limit >= _NO_LIMIT:
|
|
81
131
|
continue
|
|
82
|
-
|
|
132
|
+
spent = _unreclaimable(_stat_map(stat_path), keys)
|
|
133
|
+
if spent is None:
|
|
134
|
+
# No usable stat file. Fall back to the charged figure, which is
|
|
135
|
+
# pessimistic rather than dangerous: it can refuse a request that
|
|
136
|
+
# would have fitted, and never admits one that will not.
|
|
137
|
+
spent = _read_int(usage_path) or 0
|
|
138
|
+
return max(limit - spent, 0)
|
|
83
139
|
return None
|
|
84
140
|
|
|
85
141
|
|
|
@@ -1059,6 +1059,107 @@ class TestWhatThisProcessCanHold:
|
|
|
1059
1059
|
assert memory.available_bytes() is None
|
|
1060
1060
|
assert memory.declared_budget("auto") is None
|
|
1061
1061
|
|
|
1062
|
+
@staticmethod
|
|
1063
|
+
def _fake_cgroup(monkeypatch, *, v2=True, limit=4 * 2**30, stat=None,
|
|
1064
|
+
charged=None):
|
|
1065
|
+
"""Stand in for the cgroup files, since their paths are absolute."""
|
|
1066
|
+
limit_name = "memory.max" if v2 else "memory.limit_in_bytes"
|
|
1067
|
+
usage_name = "memory.current" if v2 else "memory.usage_in_bytes"
|
|
1068
|
+
|
|
1069
|
+
def read_int(path):
|
|
1070
|
+
if limit_name in path:
|
|
1071
|
+
return limit
|
|
1072
|
+
if usage_name in path:
|
|
1073
|
+
return charged
|
|
1074
|
+
return None
|
|
1075
|
+
|
|
1076
|
+
monkeypatch.setattr(memory, "_read_int", read_int)
|
|
1077
|
+
monkeypatch.setattr(memory, "_stat_map", lambda path: dict(stat or {}))
|
|
1078
|
+
|
|
1079
|
+
def test_page_cache_is_not_charged_to_the_caller(self, monkeypatch):
|
|
1080
|
+
"""The defect this arithmetic exists to stop, measured not imagined.
|
|
1081
|
+
|
|
1082
|
+
On a 4 GiB Daytona sandbox, writing 2 GiB of files took the detected
|
|
1083
|
+
figure from 3.91 GiB to 1.93 GiB while the process's own ``anon``
|
|
1084
|
+
stayed at 0.09 GiB, and deleting the files restored it exactly. Under
|
|
1085
|
+
``mode="auto"`` that halved the declared budget and refused windows
|
|
1086
|
+
that fitted twice over, blaming memory the caller had. The disk cache
|
|
1087
|
+
feeds it: every partition written and every one read back is page
|
|
1088
|
+
cache.
|
|
1089
|
+
"""
|
|
1090
|
+
limit = 4 * 2**30
|
|
1091
|
+
self._fake_cgroup(
|
|
1092
|
+
monkeypatch,
|
|
1093
|
+
limit=limit,
|
|
1094
|
+
# 2 GiB of page cache on top of a 100 MiB process.
|
|
1095
|
+
charged=100 * 2**20 + 2 * 2**30,
|
|
1096
|
+
stat={
|
|
1097
|
+
"anon": 100 * 2**20,
|
|
1098
|
+
"file": 2 * 2**30,
|
|
1099
|
+
"unevictable": 0,
|
|
1100
|
+
"slab_unreclaimable": 8 * 2**20,
|
|
1101
|
+
},
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
free = memory._cgroup_free()
|
|
1105
|
+
|
|
1106
|
+
assert free == limit - (100 * 2**20 + 8 * 2**20)
|
|
1107
|
+
# Emphatically not the charged figure, which is what it used to be.
|
|
1108
|
+
assert free > 3 * 2**30
|
|
1109
|
+
|
|
1110
|
+
def test_a_cgroup_v1_container_reads_its_resident_set(self, monkeypatch):
|
|
1111
|
+
"""v1 spells the same quantity ``total_rss`` rather than ``anon``."""
|
|
1112
|
+
limit = 2 * 2**30
|
|
1113
|
+
self._fake_cgroup(
|
|
1114
|
+
monkeypatch,
|
|
1115
|
+
v2=False,
|
|
1116
|
+
limit=limit,
|
|
1117
|
+
charged=limit, # wholly charged, almost all of it cache
|
|
1118
|
+
stat={
|
|
1119
|
+
"total_rss": 300 * 2**20,
|
|
1120
|
+
"total_cache": limit - 300 * 2**20,
|
|
1121
|
+
"total_unevictable": 0,
|
|
1122
|
+
},
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
assert memory._cgroup_free() == limit - 300 * 2**20
|
|
1126
|
+
|
|
1127
|
+
def test_a_process_that_really_is_near_its_limit_still_reads_as_full(
|
|
1128
|
+
self, monkeypatch
|
|
1129
|
+
):
|
|
1130
|
+
"""The guard must still guard.
|
|
1131
|
+
|
|
1132
|
+
Discounting page cache is only correct because it is reclaimable;
|
|
1133
|
+
anonymous memory is not, so a process that has genuinely filled its
|
|
1134
|
+
cgroup must still report almost nothing free.
|
|
1135
|
+
"""
|
|
1136
|
+
limit = 4 * 2**30
|
|
1137
|
+
self._fake_cgroup(
|
|
1138
|
+
monkeypatch,
|
|
1139
|
+
limit=limit,
|
|
1140
|
+
charged=limit,
|
|
1141
|
+
stat={"anon": limit - 64 * 2**20, "file": 32 * 2**20},
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
assert memory._cgroup_free() == 64 * 2**20
|
|
1145
|
+
assert memory.declared_budget("auto") < 16 * 2**20
|
|
1146
|
+
|
|
1147
|
+
def test_an_unreadable_stat_file_falls_back_to_the_charged_figure(
|
|
1148
|
+
self, monkeypatch
|
|
1149
|
+
):
|
|
1150
|
+
"""Pessimistic rather than dangerous.
|
|
1151
|
+
|
|
1152
|
+
Without ``memory.stat`` there is no way to separate cache from
|
|
1153
|
+
anonymous memory, and the old subtraction is the safe direction: it
|
|
1154
|
+
can refuse something that would have fitted, but never admits
|
|
1155
|
+
something that will not.
|
|
1156
|
+
"""
|
|
1157
|
+
limit = 4 * 2**30
|
|
1158
|
+
self._fake_cgroup(monkeypatch, limit=limit, stat={},
|
|
1159
|
+
charged=3 * 2**30)
|
|
1160
|
+
|
|
1161
|
+
assert memory._cgroup_free() == limit - 3 * 2**30
|
|
1162
|
+
|
|
1062
1163
|
def test_an_unlimited_cgroup_is_not_read_as_a_limit(self, monkeypatch):
|
|
1063
1164
|
"""v2 writes the literal "max" and v1 writes a huge sentinel.
|
|
1064
1165
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|