pyuncommonkit 0.1.0__py3-none-any.whl
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.
- commonkit/__init__.py +19 -0
- commonkit/cache.py +125 -0
- commonkit/keys.py +229 -0
- commonkit/read.py +49 -0
- commonkit/shared.py +227 -0
- commonkit/store.py +963 -0
- pyuncommonkit-0.1.0.dist-info/METADATA +200 -0
- pyuncommonkit-0.1.0.dist-info/RECORD +10 -0
- pyuncommonkit-0.1.0.dist-info/WHEEL +4 -0
- pyuncommonkit-0.1.0.dist-info/licenses/LICENSE +21 -0
commonkit/store.py
ADDED
|
@@ -0,0 +1,963 @@
|
|
|
1
|
+
"""A digest-keyed store of DECODED arrays, shared across processes.
|
|
2
|
+
|
|
3
|
+
Extracted from the pixel-cache layer of a private image-corpus
|
|
4
|
+
application, where it ran in production first; the measurements below were
|
|
5
|
+
taken there. Nothing but numpy, because the python-3.11 model venvs that
|
|
6
|
+
consume it cannot import anything heavier.
|
|
7
|
+
|
|
8
|
+
Why named ``.npy`` files rather than anything cleverer, all measured on a
|
|
9
|
+
Linux host (x86-64, a ZFS pool plus tmpfs ``/dev/shm``) on 2026-09-10:
|
|
10
|
+
|
|
11
|
+
* A named segment **persists with zero attachers**, so it survives a queue hop --
|
|
12
|
+
a record can sit in RabbitMQ for minutes and a later consumer still attaches.
|
|
13
|
+
An anonymous ``memfd`` is freed when the last fd closes, which disqualifies
|
|
14
|
+
fd-passing however attractive its sealing story is.
|
|
15
|
+
* **Unlink while mapped is safe**: the name disappears, live mappings keep
|
|
16
|
+
reading, the kernel reclaims on last unmap. So the reaper never has to know who
|
|
17
|
+
is attached, and no cross-process refcounting is needed anywhere.
|
|
18
|
+
* ``np.load(mmap_mode="r")`` returns ``writeable=False`` and re-enabling it is
|
|
19
|
+
REFUSED by numpy. Read-only is enforced rather than advisory, for free.
|
|
20
|
+
* ``.npy`` is exactly the (shape, dtype, data) triple, with a 128-byte header and
|
|
21
|
+
the payload 64-byte aligned for mmap. ``np.save`` costs 0.93 ms against 0.89 ms
|
|
22
|
+
for a bare buffer write, so the container is free.
|
|
23
|
+
* ``multiprocessing.shared_memory`` is unusable here: its resource tracker
|
|
24
|
+
deletes segments when the first unrelated process exits, and ``track=False``
|
|
25
|
+
needs python 3.13 while every model venv is 3.11.
|
|
26
|
+
|
|
27
|
+
Running this on macOS is a different trade -- no /dev/shm, no Pss to measure
|
|
28
|
+
sharing with, and unified memory in which a RAM disk competes directly with
|
|
29
|
+
on-device model weights. Measure before enabling it there: on Apple Silicon the
|
|
30
|
+
internal SSD beat a RAM disk (first attach 0.69 ms against 0.76 ms), and a
|
|
31
|
+
``ram://`` disk commits its whole size up front as dirty memory.
|
|
32
|
+
|
|
33
|
+
THE INVARIANT THIS WHOLE DESIGN RESTS ON: *a missing segment is never an error,
|
|
34
|
+
only a slower path.* Every miss -- reaped, declined, torn, absent root -- returns
|
|
35
|
+
None and the caller re-decodes in 38 ms. Nothing here may raise into a caller,
|
|
36
|
+
with one deliberate exception: publishing a DIFFERENT array under a key that already
|
|
37
|
+
has a segment raises :class:`KeyCollision`, because that is a broken key scheme, not
|
|
38
|
+
a slower path (contract §6.4).
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import errno
|
|
43
|
+
import logging
|
|
44
|
+
import os
|
|
45
|
+
import re
|
|
46
|
+
import shutil
|
|
47
|
+
import stat
|
|
48
|
+
import struct
|
|
49
|
+
import sys
|
|
50
|
+
import threading
|
|
51
|
+
import time
|
|
52
|
+
from collections import Counter
|
|
53
|
+
|
|
54
|
+
import numpy as np
|
|
55
|
+
from numpy.lib import format as _npf
|
|
56
|
+
|
|
57
|
+
from . import keys as _keys
|
|
58
|
+
|
|
59
|
+
try: # POSIX only; Windows has no fcntl (contract §12)
|
|
60
|
+
import fcntl as _fcntl
|
|
61
|
+
except ImportError: # pragma: no cover - Windows
|
|
62
|
+
_fcntl = None
|
|
63
|
+
|
|
64
|
+
_log = logging.getLogger(__name__)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class KeyCollision(ValueError):
|
|
68
|
+
"""A different array was published under a key whose segment already exists.
|
|
69
|
+
|
|
70
|
+
The second of the contract's two loud paths (§1.6, §6.4). A key names one
|
|
71
|
+
immutable content (W1), so this means a key scheme or a digest is broken: two
|
|
72
|
+
contents hash to one name. Swallowing it would leave every reader of the key
|
|
73
|
+
holding whichever content got there first, silently, so it raises instead.
|
|
74
|
+
A ``ValueError`` so a caller that already treats bad keys as ``ValueError``
|
|
75
|
+
sees it without new handling.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
#: Touch mtime at most this often per segment. atime is unusable: /dev/shm
|
|
79
|
+
#: updates it per read but the ZFS roots do not, even with atime=on relatime=off,
|
|
80
|
+
#: because ARC-served reads never refresh it -- and the root is a parameter that
|
|
81
|
+
#: spans both. An explicit touch is also BETTER than working atime would be: a
|
|
82
|
+
#: metadata write per read is copy-on-write churn on a pool shared with Postgres.
|
|
83
|
+
TOUCH_INTERVAL_S = 60.0
|
|
84
|
+
|
|
85
|
+
#: statvfs is a syscall per publish otherwise. One second of staleness cannot
|
|
86
|
+
#: matter to a watermark measured in gigabytes.
|
|
87
|
+
_STATVFS_TTL_S = 1.0
|
|
88
|
+
|
|
89
|
+
#: The largest header a reader accepts and a publisher writes (contract §5.5). numpy's
|
|
90
|
+
#: own default is 10000 bytes, which misses every version 2 segment; 1 MiB holds a
|
|
91
|
+
#: structured dtype of ~25,000 fields and still bounds what a hostile length field
|
|
92
|
+
#: can make a reader allocate.
|
|
93
|
+
MAX_HEADER_BYTES = 2**20
|
|
94
|
+
|
|
95
|
+
_NPY_MAGIC = b"\x93NUMPY"
|
|
96
|
+
|
|
97
|
+
#: The reaper's candidates (contract §9.4): ``{form}/{xx}/{digest}.{variant}.npy`` with
|
|
98
|
+
#: the shard equal to the digest's first two characters. Matched per path component.
|
|
99
|
+
_SHARD_RE = re.compile(r"[0-9a-f]{2}")
|
|
100
|
+
_SEGMENT_NAME_RE = re.compile(r"([0-9a-f]{2})[0-9a-f]{30}\.[a-z0-9]+\.npy")
|
|
101
|
+
|
|
102
|
+
#: An orphaned temporary file is swept once it is this old (contract §9.5). A writer
|
|
103
|
+
#: holds its ``.tmp`` for one ``np.save`` -- milliseconds -- so an hour old means the
|
|
104
|
+
#: writer crashed, never that it is still writing.
|
|
105
|
+
TMP_ORPHAN_S = 3600.0
|
|
106
|
+
|
|
107
|
+
#: Error numbers that mean "the volume is full": a decline, not a failure. EDQUOT is
|
|
108
|
+
#: looked up rather than named because not every platform's ``errno`` defines it.
|
|
109
|
+
_FULL_ERRNOS = frozenset(e for e in (errno.ENOSPC, getattr(errno, "EDQUOT", None))
|
|
110
|
+
if e is not None)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _sync(fd: int) -> None:
|
|
114
|
+
"""Push ``fd``'s data to storage.
|
|
115
|
+
|
|
116
|
+
On macOS ``fsync`` only hands the data to the drive, which may hold it in its own
|
|
117
|
+
cache across a power loss; ``F_FULLFSYNC`` flushes that cache too. It is what
|
|
118
|
+
"to storage" means there, and it is dear: a 1 MiB write took 0.25 ms, 0.33 ms
|
|
119
|
+
with ``fsync`` and 3.98 ms with ``F_FULLFSYNC`` on thresher's internal SSD
|
|
120
|
+
(2026-10-28) -- one reason fsync is off by default (C17). A filesystem that
|
|
121
|
+
refuses ``F_FULLFSYNC`` still gets a plain ``fsync``.
|
|
122
|
+
"""
|
|
123
|
+
full = getattr(_fcntl, "F_FULLFSYNC", None) if sys.platform == "darwin" else None
|
|
124
|
+
if full is not None:
|
|
125
|
+
try:
|
|
126
|
+
_fcntl.fcntl(fd, full)
|
|
127
|
+
return
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
os.fsync(fd)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _sync_dir(path: str) -> None:
|
|
134
|
+
"""Make a rename in directory ``path`` durable (contract §6.5, step 5).
|
|
135
|
+
|
|
136
|
+
POSIX only: a directory is synced through a descriptor opened on it. Windows
|
|
137
|
+
cannot open a directory with ``os.open`` and has no directory fsync to call, so
|
|
138
|
+
there this is a no-op rather than a publish that always fails.
|
|
139
|
+
"""
|
|
140
|
+
if os.name != "posix":
|
|
141
|
+
return
|
|
142
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
143
|
+
try:
|
|
144
|
+
_sync(fd)
|
|
145
|
+
finally:
|
|
146
|
+
os.close(fd)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _check_prefix(fh) -> None:
|
|
150
|
+
"""Raise ``ValueError`` unless ``fh`` starts with a ``.npy`` prefix of contract §5.1
|
|
151
|
+
whose header length is within :data:`MAX_HEADER_BYTES`.
|
|
152
|
+
|
|
153
|
+
Checked before numpy sees the file, so an oversized length field is refused
|
|
154
|
+
without reading (or allocating for) the text it claims (§5.5).
|
|
155
|
+
"""
|
|
156
|
+
prefix = fh.read(12)
|
|
157
|
+
if len(prefix) < 10 or prefix[:6] != _NPY_MAGIC or prefix[7] != 0 \
|
|
158
|
+
or prefix[6] not in (1, 2, 3):
|
|
159
|
+
raise ValueError("not a .npy prefix")
|
|
160
|
+
if prefix[6] == 1:
|
|
161
|
+
return # a 16-bit length is always within the limit
|
|
162
|
+
if len(prefix) < 12 or struct.unpack("<I", prefix[8:12])[0] > MAX_HEADER_BYTES:
|
|
163
|
+
raise ValueError("header length over the limit")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _load(path: str, mmap: bool):
|
|
167
|
+
"""The array at ``path``, mapped read-only or copied.
|
|
168
|
+
|
|
169
|
+
Raises ``FileNotFoundError`` when there is no file. ANY other exception means the
|
|
170
|
+
file is torn (contract §5.7): callers catch ``Exception``, not a list, because
|
|
171
|
+
numpy's failures on a bad header are not a closed set -- a garbled one raised
|
|
172
|
+
``tokenize.TokenError`` (neither ``ValueError`` nor ``OSError``) straight out of
|
|
173
|
+
``attach`` until 2026-10-28.
|
|
174
|
+
"""
|
|
175
|
+
with open(path, "rb") as fh:
|
|
176
|
+
_check_prefix(fh)
|
|
177
|
+
fh.seek(0)
|
|
178
|
+
if not mmap:
|
|
179
|
+
return np.load(fh, allow_pickle=False, max_header_size=MAX_HEADER_BYTES)
|
|
180
|
+
# Map through the handle already open: ``np.load(mmap_mode=...)`` takes only a
|
|
181
|
+
# path, and opening it a second time cost ~16 us an attach (a 400 kB segment,
|
|
182
|
+
# 55 -> 71 us median on thresher, 2026-10-28), on the path cache.py exists to
|
|
183
|
+
# keep cheap. Version 3 headers (UTF-8 field names) have no public reader, so
|
|
184
|
+
# they, and empty arrays (which can't be mapped), take np.load's path below.
|
|
185
|
+
version = np.lib.format.read_magic(fh)
|
|
186
|
+
read_header = {(1, 0): np.lib.format.read_array_header_1_0,
|
|
187
|
+
(2, 0): np.lib.format.read_array_header_2_0}.get(version)
|
|
188
|
+
if read_header is not None:
|
|
189
|
+
shape, fortran, dtype = read_header(fh, max_header_size=MAX_HEADER_BYTES)
|
|
190
|
+
if dtype.hasobject:
|
|
191
|
+
raise ValueError("object arrays are never segments (contract §5.4)")
|
|
192
|
+
if dtype.itemsize and all(shape):
|
|
193
|
+
return np.memmap(fh, dtype=dtype, mode="r", offset=fh.tell(),
|
|
194
|
+
shape=shape, order="F" if fortran else "C")
|
|
195
|
+
return np.load(path, mmap_mode="r", allow_pickle=False, max_header_size=MAX_HEADER_BYTES)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _fortran(arr) -> bool:
|
|
199
|
+
"""The ``fortran_order`` numpy writes for ``arr`` (contract §5.3)."""
|
|
200
|
+
return bool(arr.flags.f_contiguous and not arr.flags.c_contiguous)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _header_len(arr) -> int:
|
|
204
|
+
"""``HLEN`` of the header ``np.save`` would write for ``arr`` (contract §5.2)."""
|
|
205
|
+
d = _npf.header_data_from_array_1_0(arr)
|
|
206
|
+
shape = d["shape"]
|
|
207
|
+
text = (f"{{'descr': {d['descr']!r}, 'fortran_order': {d['fortran_order']!r}, "
|
|
208
|
+
f"'shape': {shape!r}, }}")
|
|
209
|
+
growth = (max(0, 21 - len(str(shape[-1 if d["fortran_order"] else 0])))
|
|
210
|
+
if shape else 0)
|
|
211
|
+
try:
|
|
212
|
+
n = len(text.encode("latin1"))
|
|
213
|
+
latin1 = True
|
|
214
|
+
except UnicodeEncodeError:
|
|
215
|
+
n = len(text.encode("utf8"))
|
|
216
|
+
latin1 = False
|
|
217
|
+
prefix = 10
|
|
218
|
+
hlen = n + growth + 64 - ((prefix + n + growth + 1) % 64) + 1
|
|
219
|
+
if not latin1 or hlen > 0xFFFF:
|
|
220
|
+
prefix = 12
|
|
221
|
+
hlen = n + growth + 64 - ((prefix + n + growth + 1) % 64) + 1
|
|
222
|
+
return hlen
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _named_byte_mask(dtype: np.dtype):
|
|
226
|
+
"""A bool per byte of an item, True where a NAMED field covers it; None when every
|
|
227
|
+
byte is covered (no padding), the common case, so the caller compares whole items.
|
|
228
|
+
|
|
229
|
+
Padding bytes carry no meaning and a copying ``np.load`` zeroes them, so they must
|
|
230
|
+
never decide a collision (contract §5.8).
|
|
231
|
+
"""
|
|
232
|
+
if dtype.names is None:
|
|
233
|
+
return None
|
|
234
|
+
mask = np.zeros(dtype.itemsize, dtype=bool)
|
|
235
|
+
|
|
236
|
+
def cover(dt: np.dtype, offset: int) -> None:
|
|
237
|
+
if dt.subdtype is not None:
|
|
238
|
+
base, shape = dt.subdtype
|
|
239
|
+
count = int(np.prod(shape))
|
|
240
|
+
if base.names is None:
|
|
241
|
+
mask[offset:offset + count * base.itemsize] = True
|
|
242
|
+
else:
|
|
243
|
+
for i in range(count):
|
|
244
|
+
cover(base, offset + i * base.itemsize)
|
|
245
|
+
elif dt.names is None:
|
|
246
|
+
mask[offset:offset + dt.itemsize] = True
|
|
247
|
+
else:
|
|
248
|
+
for name in dt.names:
|
|
249
|
+
field = dt.fields[name]
|
|
250
|
+
cover(field[0], offset + field[1])
|
|
251
|
+
|
|
252
|
+
cover(dtype, 0)
|
|
253
|
+
return None if mask.all() else mask
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _payload_items(arr):
|
|
257
|
+
"""``arr``'s payload as written (contract §5.3), as an ``(items, itemsize)`` byte
|
|
258
|
+
matrix. Fortran order is the transpose's C order; anything else is C order, which
|
|
259
|
+
is what ``np.save`` writes for a non-contiguous array."""
|
|
260
|
+
flat = np.ascontiguousarray(arr.T if _fortran(arr) else arr).reshape(-1)
|
|
261
|
+
return flat.view(np.uint8).reshape(flat.shape[0], arr.dtype.itemsize)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _same_payload(old, new) -> bool:
|
|
265
|
+
"""Whether two arrays of one dtype, shape and order hold the same named-field bytes.
|
|
266
|
+
|
|
267
|
+
Compared in chunks of about 1 MiB, so a 128 MiB segment costs a 1 MiB temporary
|
|
268
|
+
rather than a second 128 MiB one.
|
|
269
|
+
"""
|
|
270
|
+
if new.nbytes == 0:
|
|
271
|
+
return True
|
|
272
|
+
a, b = _payload_items(old), _payload_items(new)
|
|
273
|
+
mask = _named_byte_mask(new.dtype)
|
|
274
|
+
step = max(1, (1 << 20) // max(1, new.dtype.itemsize))
|
|
275
|
+
for i in range(0, a.shape[0], step):
|
|
276
|
+
x, y = a[i:i + step], b[i:i + step]
|
|
277
|
+
if mask is not None:
|
|
278
|
+
x, y = x[:, mask], y[:, mask]
|
|
279
|
+
if not np.array_equal(x, y):
|
|
280
|
+
return False
|
|
281
|
+
return True
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class SharedArrayStore:
|
|
285
|
+
"""Publish and attach decoded arrays under one root directory.
|
|
286
|
+
|
|
287
|
+
Satisfies a consumer's ``PixelCache`` protocol (``get``/``set``), plus the
|
|
288
|
+
``set_array`` fast path an array-oriented caller prefers.
|
|
289
|
+
|
|
290
|
+
The constructor NEVER raises. An absent, unwritable or unusable root sets
|
|
291
|
+
``self.disabled`` and every method becomes a no-op: a broken cache backend
|
|
292
|
+
degrades to cache misses rather than breaking its caller.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
def __init__(self, root, *, mode="read", attach="mmap",
|
|
296
|
+
max_bytes=16 * 2**30, min_free_bytes=8 * 2**30,
|
|
297
|
+
ttl_s=900, min_array_bytes=256 * 1024,
|
|
298
|
+
max_array_bytes=128 * 2**20, reap_interval_s=30,
|
|
299
|
+
fsync=False, verify_republish=False):
|
|
300
|
+
# An empty root stays empty, so the check below disables the store (contract §6.1):
|
|
301
|
+
# ``os.path.abspath("")`` is the working directory, which an empty root used to
|
|
302
|
+
# become, publishing segments into wherever the process happened to start.
|
|
303
|
+
root = str(root or "")
|
|
304
|
+
self.root = os.path.abspath(root) if root else ""
|
|
305
|
+
self.mode = str(mode or "off")
|
|
306
|
+
self.attach_mode = str(attach or "mmap")
|
|
307
|
+
self.max_bytes = int(max_bytes)
|
|
308
|
+
self.min_free_bytes = int(min_free_bytes)
|
|
309
|
+
self.ttl_s = float(ttl_s)
|
|
310
|
+
self.min_array_bytes = int(min_array_bytes)
|
|
311
|
+
self.max_array_bytes = int(max_array_bytes)
|
|
312
|
+
self.reap_interval_s = float(reap_interval_s)
|
|
313
|
+
# Durability (C17): off, a crash can leave a short segment, which readers miss
|
|
314
|
+
# and publishers replace (W4, W5). Verification (C15): off, a republish
|
|
315
|
+
# compares headers only, so a same-header collision goes unseen.
|
|
316
|
+
self.fsync = bool(fsync)
|
|
317
|
+
self.verify_republish = bool(verify_republish)
|
|
318
|
+
|
|
319
|
+
self.stats: Counter = Counter()
|
|
320
|
+
self._lock = threading.Lock()
|
|
321
|
+
self._statvfs_at = 0.0
|
|
322
|
+
self._statvfs_free = 0
|
|
323
|
+
self._last_reap = 0.0
|
|
324
|
+
self.disabled = True
|
|
325
|
+
|
|
326
|
+
if self.mode == "off" or not self.root or self.root == "/":
|
|
327
|
+
return
|
|
328
|
+
# The probe name MUST be unique per process. A fixed ".writable" races:
|
|
329
|
+
# K workers starting together on one root all write the same path, the
|
|
330
|
+
# first unlink wins, and every loser's unlink raises FileNotFoundError --
|
|
331
|
+
# an OSError, caught below, leaving that worker permanently disabled with
|
|
332
|
+
# no log line. Measured: at 4 forked consumers one came up disabled and
|
|
333
|
+
# decoded all 48 pages while the other three attached.
|
|
334
|
+
probe = os.path.join(
|
|
335
|
+
self.root, f".writable.{os.getpid()}.{threading.get_ident():x}")
|
|
336
|
+
try:
|
|
337
|
+
os.makedirs(self.root, exist_ok=True)
|
|
338
|
+
with open(probe, "wb") as fh:
|
|
339
|
+
fh.write(b"1")
|
|
340
|
+
except OSError:
|
|
341
|
+
return # unusable root: stay disabled, silently
|
|
342
|
+
finally:
|
|
343
|
+
# Cleanup failure is NOT a writability failure: the write above
|
|
344
|
+
# already proved the root is usable.
|
|
345
|
+
try:
|
|
346
|
+
os.unlink(probe)
|
|
347
|
+
except OSError:
|
|
348
|
+
pass
|
|
349
|
+
self.disabled = False
|
|
350
|
+
|
|
351
|
+
# ------------------------------------------------------------- addressing
|
|
352
|
+
|
|
353
|
+
def path_for(self, key: str) -> str:
|
|
354
|
+
"""Absolute path for ``key``, guaranteed to live under ``self.root``.
|
|
355
|
+
|
|
356
|
+
``keys.relpath`` is strict about the key shape, so a malformed key raises
|
|
357
|
+
here rather than producing a path the reaper might later unlink.
|
|
358
|
+
"""
|
|
359
|
+
return os.path.join(self.root, _keys.relpath(key))
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------- reading
|
|
362
|
+
|
|
363
|
+
def attach(self, key: str):
|
|
364
|
+
"""The decoded array for ``key``, or None on any kind of miss."""
|
|
365
|
+
if self.disabled:
|
|
366
|
+
return None
|
|
367
|
+
try:
|
|
368
|
+
path = self.path_for(key)
|
|
369
|
+
except ValueError:
|
|
370
|
+
self.stats["shm_bad_key"] += 1
|
|
371
|
+
return None
|
|
372
|
+
try:
|
|
373
|
+
arr = _load(path, mmap=self.attach_mode != "read")
|
|
374
|
+
except FileNotFoundError:
|
|
375
|
+
self.stats["shm_miss"] += 1
|
|
376
|
+
return None
|
|
377
|
+
except Exception: # noqa: BLE001
|
|
378
|
+
# A truncated or half-written segment is a MISS, not an exception.
|
|
379
|
+
# os.replace makes this nearly impossible, but "nearly" is not a
|
|
380
|
+
# reason to let a caller see a traceback. Broad on purpose: see _load.
|
|
381
|
+
self.stats["shm_torn"] += 1
|
|
382
|
+
return None
|
|
383
|
+
self.stats["shm_hit"] += 1
|
|
384
|
+
self._touch(path)
|
|
385
|
+
return arr
|
|
386
|
+
|
|
387
|
+
def _touch(self, path: str) -> None:
|
|
388
|
+
"""Refresh mtime for LRU, at most once per TOUCH_INTERVAL_S per segment."""
|
|
389
|
+
try:
|
|
390
|
+
st = os.stat(path)
|
|
391
|
+
if time.time() - st.st_mtime > TOUCH_INTERVAL_S:
|
|
392
|
+
os.utime(path, None)
|
|
393
|
+
except OSError:
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
# ---------------------------------------------------------------- writing
|
|
397
|
+
|
|
398
|
+
def _admit_dtype(self, key: str, arr) -> bool:
|
|
399
|
+
"""Refuse a segment whose dtype its FORM declares it cannot have.
|
|
400
|
+
|
|
401
|
+
Enforced HERE rather than trusted of every publisher, because a bad
|
|
402
|
+
segment is not one bad call: it is published once and then poisons
|
|
403
|
+
every reader that attaches it, on a path where a miss is meant to be
|
|
404
|
+
the worst possible outcome. A publisher that forgets should be unable
|
|
405
|
+
to do this, not merely discouraged from it.
|
|
406
|
+
|
|
407
|
+
WHICH dtype is the DOMAIN's business, not the store's, so the scheme
|
|
408
|
+
declares it per form and this only enforces what it finds. (Pixels
|
|
409
|
+
declares ``pil`` uint8: consumers recover the channel count from
|
|
410
|
+
``.shape`` and trust the producer for the colour space, which a bool
|
|
411
|
+
array satisfies neither of -- and the store used to accept and
|
|
412
|
+
round-trip one happily.) A form with no declared dtype, like a float32
|
|
413
|
+
tensor or an int16 waveform, is unconstrained.
|
|
414
|
+
|
|
415
|
+
Whatever the form, two things are never written (contract §6.3). An object
|
|
416
|
+
dtype (``hasobject`` also covers a struct with an object field, and numpy 2's
|
|
417
|
+
``StringDType``) needs pickling, which no reader may do; ``np.save`` used to
|
|
418
|
+
raise ``ValueError`` here, uncaught, and leave the temporary file behind. And
|
|
419
|
+
a header over :data:`MAX_HEADER_BYTES`, which no reader would accept.
|
|
420
|
+
"""
|
|
421
|
+
dtype = getattr(arr, "dtype", None)
|
|
422
|
+
if dtype is None or dtype.hasobject:
|
|
423
|
+
self.stats["shm_declined_dtype"] += 1
|
|
424
|
+
return False
|
|
425
|
+
# Only a structured dtype can approach the limit: a simple descriptor and up to
|
|
426
|
+
# 64 dimensions stay under 2 KB, so the common case skips building the header.
|
|
427
|
+
if dtype.names is not None:
|
|
428
|
+
try:
|
|
429
|
+
fits = _header_len(arr) <= MAX_HEADER_BYTES
|
|
430
|
+
except Exception: # noqa: BLE001
|
|
431
|
+
fits = False # a header numpy cannot describe is not written
|
|
432
|
+
if not fits:
|
|
433
|
+
self.stats["shm_declined_dtype"] += 1
|
|
434
|
+
return False
|
|
435
|
+
try:
|
|
436
|
+
scheme = _keys.scheme_for(key)
|
|
437
|
+
form, _digest, _variant = scheme.parse(key)
|
|
438
|
+
except ValueError:
|
|
439
|
+
return True # path_for will reject it and count it there
|
|
440
|
+
want = scheme.dtype_for(form)
|
|
441
|
+
if want is None:
|
|
442
|
+
return True
|
|
443
|
+
if getattr(arr, "dtype", None) != np.dtype(want):
|
|
444
|
+
self.stats["shm_declined_dtype"] += 1
|
|
445
|
+
return False
|
|
446
|
+
return True
|
|
447
|
+
|
|
448
|
+
def publish(self, key: str, arr) -> bool:
|
|
449
|
+
"""Store ``arr`` under ``key``. Returns whether it was stored.
|
|
450
|
+
|
|
451
|
+
Returns a bool rather than an array on purpose: the publisher has already
|
|
452
|
+
paid for the decode and holds the pixels, so making it re-attach its own
|
|
453
|
+
data would be strictly worse than keeping what it has.
|
|
454
|
+
|
|
455
|
+
Republishing the same array touches the existing segment and returns True.
|
|
456
|
+
Publishing a DIFFERENT array under a key that already has a whole segment
|
|
457
|
+
raises :class:`KeyCollision` (contract §6.4) -- the one way this raises.
|
|
458
|
+
"""
|
|
459
|
+
if self.disabled or self.mode != "readwrite":
|
|
460
|
+
self.stats["shm_declined_mode"] += 1
|
|
461
|
+
return False
|
|
462
|
+
try:
|
|
463
|
+
nbytes = int(arr.nbytes)
|
|
464
|
+
except AttributeError:
|
|
465
|
+
return False
|
|
466
|
+
if not self._admit(nbytes):
|
|
467
|
+
return False
|
|
468
|
+
if not self._admit_dtype(key, arr):
|
|
469
|
+
return False
|
|
470
|
+
try:
|
|
471
|
+
path = self.path_for(key)
|
|
472
|
+
except ValueError:
|
|
473
|
+
self.stats["shm_bad_key"] += 1
|
|
474
|
+
return False
|
|
475
|
+
|
|
476
|
+
existing = self._existing(key, path, arr)
|
|
477
|
+
if existing in ("republish", "failed"):
|
|
478
|
+
return existing == "republish"
|
|
479
|
+
|
|
480
|
+
tmp = f"{path}.{os.getpid()}.{threading.get_ident()}.tmp"
|
|
481
|
+
try:
|
|
482
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
483
|
+
# np.save appends .npy when the name lacks it; ours already has one
|
|
484
|
+
# inside, so write through a handle and control the name exactly.
|
|
485
|
+
with open(tmp, "wb") as fh:
|
|
486
|
+
np.save(fh, arr, allow_pickle=False)
|
|
487
|
+
if self.fsync:
|
|
488
|
+
fh.flush()
|
|
489
|
+
_sync(fh.fileno())
|
|
490
|
+
os.replace(tmp, path) # atomic within one filesystem
|
|
491
|
+
except Exception as exc: # noqa: BLE001
|
|
492
|
+
# Broad, not OSError: np.save can raise for an array the gates admitted
|
|
493
|
+
# (and a UserWarning becomes an exception under ``-W error``), and a
|
|
494
|
+
# publish never raises into its caller except for a KeyCollision.
|
|
495
|
+
try:
|
|
496
|
+
os.unlink(tmp)
|
|
497
|
+
except OSError:
|
|
498
|
+
pass
|
|
499
|
+
if getattr(exc, "errno", None) in _FULL_ERRNOS:
|
|
500
|
+
self.stats["shm_declined_space"] += 1
|
|
501
|
+
else:
|
|
502
|
+
self.stats["shm_publish_failed"] += 1
|
|
503
|
+
return False
|
|
504
|
+
if existing == "torn":
|
|
505
|
+
self.stats["shm_torn_replaced"] += 1
|
|
506
|
+
if self.fsync:
|
|
507
|
+
# After the rename the segment is whole and readable, so a failed sync
|
|
508
|
+
# leaves it in place; but the caller asked for durability and did not get
|
|
509
|
+
# it, so the publish reports failure (contract §6.5).
|
|
510
|
+
try:
|
|
511
|
+
_sync_dir(os.path.dirname(path))
|
|
512
|
+
except Exception: # noqa: BLE001
|
|
513
|
+
self.stats["shm_publish_failed"] += 1
|
|
514
|
+
return False
|
|
515
|
+
self.stats["shm_publish"] += 1
|
|
516
|
+
self.stats["shm_bytes_published"] += nbytes
|
|
517
|
+
self._maybe_reap()
|
|
518
|
+
return True
|
|
519
|
+
|
|
520
|
+
def _existing(self, key: str, path: str, arr) -> str:
|
|
521
|
+
"""Apply the existing-key rule (contract §6.4, C15) before anything is written.
|
|
522
|
+
|
|
523
|
+
Returns ``"absent"`` or ``"torn"`` (write it), ``"republish"`` (the same array
|
|
524
|
+
is already there: touched, nothing written), or ``"failed"`` (the comparison
|
|
525
|
+
itself failed: counted, nothing written). Raises :class:`KeyCollision` for a
|
|
526
|
+
different array, leaving the file and its mtime exactly as they were.
|
|
527
|
+
|
|
528
|
+
Republishing used to rewrite the segment unconditionally, which is a write into
|
|
529
|
+
the tree every reader maps, for nothing, and which hid a broken digest: the
|
|
530
|
+
second content silently replaced the first under readers that had the first.
|
|
531
|
+
"""
|
|
532
|
+
try:
|
|
533
|
+
old = _load(path, mmap=True) # mapped: a check must not copy 128 MiB
|
|
534
|
+
except FileNotFoundError:
|
|
535
|
+
return "absent"
|
|
536
|
+
except Exception: # noqa: BLE001
|
|
537
|
+
# Torn (W4): damage from outside the contract, so replacing it is repair,
|
|
538
|
+
# not a rewrite of a whole segment.
|
|
539
|
+
return "torn"
|
|
540
|
+
try:
|
|
541
|
+
# The header the new array WOULD be written with, against the file's.
|
|
542
|
+
# fortran_order is read off the mapping's flags: they differ from the
|
|
543
|
+
# header's only for a degenerate shape, whose C and F bytes are identical.
|
|
544
|
+
same = (old.dtype == arr.dtype and old.shape == arr.shape
|
|
545
|
+
and _fortran(old) == _fortran(arr))
|
|
546
|
+
reason = "a different dtype, shape or memory order"
|
|
547
|
+
if same and self.verify_republish:
|
|
548
|
+
same = _same_payload(old, arr)
|
|
549
|
+
reason = "different named-field bytes"
|
|
550
|
+
except Exception: # noqa: BLE001
|
|
551
|
+
# Not knowing is not a collision: the whole segment stays, nothing raises.
|
|
552
|
+
self.stats["shm_publish_failed"] += 1
|
|
553
|
+
return "failed"
|
|
554
|
+
finally:
|
|
555
|
+
del old # unmap before the touch (Windows pins it)
|
|
556
|
+
if same:
|
|
557
|
+
self._touch(path)
|
|
558
|
+
return "republish"
|
|
559
|
+
self.stats["shm_key_collision"] += 1
|
|
560
|
+
_log.error("commonkit key collision: %s already holds a whole segment at %s; the "
|
|
561
|
+
"new array has %s. A key scheme or digest is broken.", key, path, reason)
|
|
562
|
+
raise KeyCollision(f"{key}: an existing segment holds {reason}")
|
|
563
|
+
|
|
564
|
+
def _admit(self, nbytes: int) -> bool:
|
|
565
|
+
"""Four gates, cheapest first. A decline is normal operation, not a fault.
|
|
566
|
+
|
|
567
|
+
A space or budget decline also gives the reaper its rate-limited turn. It used to
|
|
568
|
+
run only after a SUCCESSFUL publish, so a store that filled up declined every
|
|
569
|
+
publish from then on and nothing ever reaped it again -- not even TTL expiry
|
|
570
|
+
(found 2026-10-16: a store left full for 5.7 days with 1,104 expired segments).
|
|
571
|
+
|
|
572
|
+
This is the answer to "reading a whole stream into shared memory because
|
|
573
|
+
something downstream jammed". Over any gate we decline and the caller
|
|
574
|
+
keeps the private array it already holds -- we never block and never nack,
|
|
575
|
+
so the pipeline degrades to its pre-cache speed instead of stalling.
|
|
576
|
+
"""
|
|
577
|
+
if nbytes < self.min_array_bytes:
|
|
578
|
+
self.stats["shm_declined_small"] += 1
|
|
579
|
+
return False
|
|
580
|
+
if nbytes > self.max_array_bytes:
|
|
581
|
+
# Not optional: a long audio item can reach gigabytes as a
|
|
582
|
+
# waveform, which would swallow the whole tree in one publish.
|
|
583
|
+
self.stats["shm_declined_size"] += 1
|
|
584
|
+
return False
|
|
585
|
+
if self._free_bytes() < self.min_free_bytes:
|
|
586
|
+
self.stats["shm_declined_space"] += 1
|
|
587
|
+
self._maybe_reap()
|
|
588
|
+
return False
|
|
589
|
+
used, _ = self.usage()
|
|
590
|
+
if used >= 0 and used + nbytes > self.max_bytes:
|
|
591
|
+
self.stats["shm_declined_budget"] += 1
|
|
592
|
+
self._maybe_reap()
|
|
593
|
+
return False
|
|
594
|
+
return True
|
|
595
|
+
|
|
596
|
+
def _free_bytes(self, fresh: bool = False) -> int:
|
|
597
|
+
now = time.time()
|
|
598
|
+
if fresh or now - self._statvfs_at > _STATVFS_TTL_S:
|
|
599
|
+
self._statvfs_free = _measure_free(self.root) # 0 on any failure (§9.9)
|
|
600
|
+
self._statvfs_at = now
|
|
601
|
+
return self._statvfs_free
|
|
602
|
+
|
|
603
|
+
# ----------------------------------------------------- PixelCache protocol
|
|
604
|
+
|
|
605
|
+
def get(self, key: str):
|
|
606
|
+
return self.attach(key)
|
|
607
|
+
|
|
608
|
+
def get_with_source(self, key: str):
|
|
609
|
+
"""``(array, source)``, matching ``TieredPixelCache``.
|
|
610
|
+
|
|
611
|
+
A bare store is the L2 tier and nothing else, so a hit is always
|
|
612
|
+
``"shm"``. Implemented here so ``entry.decoded`` never has to GUESS
|
|
613
|
+
which tier a cache represents -- guessing is what it used to do.
|
|
614
|
+
"""
|
|
615
|
+
arr = self.attach(key)
|
|
616
|
+
return (arr, "shm") if arr is not None else (None, "decode")
|
|
617
|
+
|
|
618
|
+
def set_array(self, key: str, arr) -> None:
|
|
619
|
+
self.publish(key, arr)
|
|
620
|
+
|
|
621
|
+
def set(self, key: str, value: bytes) -> None:
|
|
622
|
+
"""Protocol compliance. The array path is ``set_array``; this exists so
|
|
623
|
+
the type still satisfies ``PixelCache`` when driven by bytes."""
|
|
624
|
+
try:
|
|
625
|
+
import io
|
|
626
|
+
arr = np.load(io.BytesIO(value), allow_pickle=False)
|
|
627
|
+
except Exception: # noqa: BLE001
|
|
628
|
+
return
|
|
629
|
+
self.publish(key, arr)
|
|
630
|
+
|
|
631
|
+
# ---------------------------------------------------------------- upkeep
|
|
632
|
+
|
|
633
|
+
def remove(self, key: str) -> bool:
|
|
634
|
+
"""Unlink the segment for ``key``. Returns whether a segment was removed.
|
|
635
|
+
|
|
636
|
+
The one sanctioned exception to write-once (contract §6.7): a caller that
|
|
637
|
+
has to REPLACE a value under an unchanged key -- recomputing after a fix the
|
|
638
|
+
key doesn't capture, say -- removes it and publishes again. Without this a
|
|
639
|
+
republish is only a touch, or a ``KeyCollision`` when the array differs.
|
|
640
|
+
|
|
641
|
+
No in-use check, by decision (2026-10-28). It only ever unlinks (§7), so a
|
|
642
|
+
reader in any process that holds the segment keeps the old bytes until it
|
|
643
|
+
lets go; the next attach misses or finds the new segment. On Windows an
|
|
644
|
+
open or mapped file can't be unlinked: that is a failed removal, not an
|
|
645
|
+
error. Never raises; a key this store can't address counts ``shm_bad_key``.
|
|
646
|
+
"""
|
|
647
|
+
if self.disabled or self.mode != "readwrite":
|
|
648
|
+
self.stats["shm_declined_mode"] += 1
|
|
649
|
+
return False
|
|
650
|
+
try:
|
|
651
|
+
path = self.path_for(key)
|
|
652
|
+
except ValueError:
|
|
653
|
+
self.stats["shm_bad_key"] += 1
|
|
654
|
+
return False
|
|
655
|
+
try:
|
|
656
|
+
if not stat.S_ISREG(os.lstat(path).st_mode):
|
|
657
|
+
return False # not a segment: never unlink what isn't ours
|
|
658
|
+
os.unlink(path)
|
|
659
|
+
except FileNotFoundError:
|
|
660
|
+
return False # nothing to remove is not a failure
|
|
661
|
+
except OSError:
|
|
662
|
+
self.stats["shm_remove_failed"] += 1
|
|
663
|
+
return False
|
|
664
|
+
self.stats["shm_removed"] += 1
|
|
665
|
+
return True
|
|
666
|
+
|
|
667
|
+
def usage(self) -> tuple[int, int]:
|
|
668
|
+
"""``(bytes, files)`` for the tree, from the reaper's cached walk.
|
|
669
|
+
|
|
670
|
+
An in-process counter under-counts badly when six replicas publish into
|
|
671
|
+
one tree, so the authoritative number comes from a walk written to
|
|
672
|
+
``.usage.json``. Returns ``(-1, -1)`` when no fresh walk exists, which
|
|
673
|
+
``_admit`` reads as "unknown" and falls back to the statvfs watermark.
|
|
674
|
+
"""
|
|
675
|
+
marker = os.path.join(self.root, ".usage.json")
|
|
676
|
+
try:
|
|
677
|
+
st = os.stat(marker)
|
|
678
|
+
if time.time() - st.st_mtime > 2 * self.reap_interval_s:
|
|
679
|
+
return (-1, -1)
|
|
680
|
+
import json
|
|
681
|
+
with open(marker, "rb") as fh:
|
|
682
|
+
d = json.loads(fh.read())
|
|
683
|
+
except Exception: # noqa: BLE001
|
|
684
|
+
return (-1, -1) # RecursionError too: a hostile file is unknown
|
|
685
|
+
# Valid only with an integer ``bytes`` (contract §9.7): ``int()`` used to take a
|
|
686
|
+
# float or a numeric string as well, which no other implementation would trust.
|
|
687
|
+
used = d.get("bytes") if isinstance(d, dict) else None
|
|
688
|
+
if type(used) is not int or used < 0:
|
|
689
|
+
return (-1, -1)
|
|
690
|
+
files = d.get("files")
|
|
691
|
+
return used, (files if type(files) is int else -1)
|
|
692
|
+
|
|
693
|
+
def _maybe_reap(self) -> None:
|
|
694
|
+
now = time.time()
|
|
695
|
+
if now - self._last_reap < self.reap_interval_s:
|
|
696
|
+
return
|
|
697
|
+
self._last_reap = now
|
|
698
|
+
try:
|
|
699
|
+
self.reap(budget_s=0.05)
|
|
700
|
+
except Exception: # noqa: BLE001
|
|
701
|
+
self.stats["shm_reap_failed"] += 1
|
|
702
|
+
|
|
703
|
+
def reap(self, *, budget_s=0.25, dry_run=False, now=None) -> dict:
|
|
704
|
+
"""Unlink expired segments, then the least recently used (oldest mtime; attach
|
|
705
|
+
refreshes it) until the store is under its low water mark and the volume above
|
|
706
|
+
its free-space floor. Lock-guarded; ``budget_s`` bounds the walk.
|
|
707
|
+
|
|
708
|
+
Safe because unlink-while-mapped is safe: a consumer mid-read keeps its
|
|
709
|
+
mapping, and a consumer that arrives later simply misses and re-decodes.
|
|
710
|
+
|
|
711
|
+
BLAST RADIUS. This is the only thing in the store that deletes anything,
|
|
712
|
+
so it is deliberately narrow: it walks only ``self.root``, unlinks only
|
|
713
|
+
regular files whose PATH matches the segment shape (and orphaned ``.tmp``
|
|
714
|
+
files over an hour old beside them), never follows symlinks, and refuses
|
|
715
|
+
to run at all if the root looks wrong.
|
|
716
|
+
|
|
717
|
+
``dry_run=True`` changes nothing: see :func:`reap_plan`, which it calls with this
|
|
718
|
+
store's settings (and ``now``, which only a dry run takes). Refusals still count.
|
|
719
|
+
"""
|
|
720
|
+
out = {"scanned": 0, "reaped": 0, "bytes": 0}
|
|
721
|
+
if self.disabled:
|
|
722
|
+
return out
|
|
723
|
+
if _suspicious_root(self.root):
|
|
724
|
+
self.stats["shm_reap_refused"] += 1
|
|
725
|
+
return out
|
|
726
|
+
if dry_run:
|
|
727
|
+
return _reap_tree(self.root, max_bytes=self.max_bytes,
|
|
728
|
+
min_free_bytes=self.min_free_bytes, ttl_s=self.ttl_s,
|
|
729
|
+
budget_s=budget_s, now=now,
|
|
730
|
+
free_bytes=lambda: self._free_bytes(fresh=True),
|
|
731
|
+
dry_run=True, on_failed=None)[0]
|
|
732
|
+
|
|
733
|
+
lock = os.path.join(self.root, ".reap.lock")
|
|
734
|
+
try:
|
|
735
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
736
|
+
except FileExistsError:
|
|
737
|
+
try: # break a lock left by a dead process
|
|
738
|
+
if time.time() - os.stat(lock).st_mtime > 300:
|
|
739
|
+
os.unlink(lock)
|
|
740
|
+
except OSError:
|
|
741
|
+
pass
|
|
742
|
+
return out
|
|
743
|
+
except OSError:
|
|
744
|
+
return out
|
|
745
|
+
|
|
746
|
+
try:
|
|
747
|
+
os.write(fd, str(os.getpid()).encode())
|
|
748
|
+
os.close(fd)
|
|
749
|
+
|
|
750
|
+
def failed():
|
|
751
|
+
self.stats["shm_reap_failed"] += 1
|
|
752
|
+
|
|
753
|
+
out, total, gone = _reap_tree(
|
|
754
|
+
self.root, max_bytes=self.max_bytes, min_free_bytes=self.min_free_bytes,
|
|
755
|
+
ttl_s=self.ttl_s, budget_s=budget_s, now=None,
|
|
756
|
+
free_bytes=lambda: self._free_bytes(fresh=True), dry_run=False,
|
|
757
|
+
on_failed=failed)
|
|
758
|
+
now = out.pop("now")
|
|
759
|
+
|
|
760
|
+
if out["complete"]: # a partial walk would under-count
|
|
761
|
+
try:
|
|
762
|
+
import json
|
|
763
|
+
tmp = os.path.join(self.root, ".usage.json.tmp")
|
|
764
|
+
# Created exclusively, after removing any leftover: ``open(tmp, "w")``
|
|
765
|
+
# followed a symlink planted at this name and truncated its target, and
|
|
766
|
+
# a truncated segment is SIGBUS for every reader that has it mapped
|
|
767
|
+
# (§7: never truncate, never follow a symlink).
|
|
768
|
+
try:
|
|
769
|
+
os.unlink(tmp)
|
|
770
|
+
except FileNotFoundError:
|
|
771
|
+
pass
|
|
772
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
773
|
+
| getattr(os, "O_NOFOLLOW", 0), 0o644)
|
|
774
|
+
with os.fdopen(fd, "w") as fh:
|
|
775
|
+
json.dump({"bytes": total,
|
|
776
|
+
"files": out["scanned"] - out["reaped"] - gone,
|
|
777
|
+
"ts": now}, fh)
|
|
778
|
+
os.replace(tmp, os.path.join(self.root, ".usage.json"))
|
|
779
|
+
except OSError:
|
|
780
|
+
pass
|
|
781
|
+
else:
|
|
782
|
+
self.stats["shm_reap_partial"] += 1
|
|
783
|
+
self._statvfs_at = 0.0 # free space changed: re-measure
|
|
784
|
+
|
|
785
|
+
self.stats["shm_reaped"] += out["reaped"]
|
|
786
|
+
self.stats["shm_reaped_bytes"] += out["bytes"]
|
|
787
|
+
return out
|
|
788
|
+
finally:
|
|
789
|
+
try:
|
|
790
|
+
os.unlink(lock)
|
|
791
|
+
except OSError:
|
|
792
|
+
pass
|
|
793
|
+
|
|
794
|
+
def drain_stats(self) -> Counter:
|
|
795
|
+
"""Take and reset the counters, for folding into a node's context.stats."""
|
|
796
|
+
with self._lock:
|
|
797
|
+
out = Counter(self.stats)
|
|
798
|
+
self.stats.clear()
|
|
799
|
+
return out
|
|
800
|
+
|
|
801
|
+
def __repr__(self) -> str:
|
|
802
|
+
state = "disabled" if self.disabled else f"{self.mode}/{self.attach_mode}"
|
|
803
|
+
return f"<SharedArrayStore {self.root!r} {state}>"
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _suspicious_root(root: str) -> bool:
|
|
807
|
+
"""The reaper's refusal (contract §9.1): empty, ``/``, or fewer than two separators."""
|
|
808
|
+
return not root or root == "/" or root.count(os.sep) < 2
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def _measure_free(root: str) -> int:
|
|
812
|
+
"""Free bytes on ``root``'s volume, 0 on any failure (contract §9.9)."""
|
|
813
|
+
# Any failure is 0 free, not an exception: catching only OSError let AttributeError
|
|
814
|
+
# out on a platform without os.statvfs. Windows has none, so it asks shutil, whose
|
|
815
|
+
# POSIX branch is this same f_bavail * f_frsize; how Windows measures is not yet
|
|
816
|
+
# contract (§12).
|
|
817
|
+
try:
|
|
818
|
+
if hasattr(os, "statvfs"):
|
|
819
|
+
st = os.statvfs(root)
|
|
820
|
+
return int(st.f_bavail * st.f_frsize)
|
|
821
|
+
return int(shutil.disk_usage(root).free) # pragma: no cover
|
|
822
|
+
except Exception: # noqa: BLE001
|
|
823
|
+
return 0
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def reap_plan(root, *, max_bytes, min_free_bytes, ttl_s, budget_s=0.25, now=None) -> dict:
|
|
827
|
+
"""What a reap of ``root`` with these settings would unlink, changing nothing.
|
|
828
|
+
|
|
829
|
+
An implementation aid, not on-disk behaviour (contract §9.10): it walks, sorts and
|
|
830
|
+
selects exactly as :meth:`SharedArrayStore.reap` does, treating every unlink as
|
|
831
|
+
successful, but takes no lock, unlinks nothing, sweeps nothing, writes no
|
|
832
|
+
``.usage.json`` and touches nothing. It needs no store, so it never writes the
|
|
833
|
+
store's probe file either: it is how a live tree is inspected (step 8 compared
|
|
834
|
+
thresher's real tree this way). ``now`` (Unix seconds) fixes the clock for expiry and
|
|
835
|
+
the ``.tmp`` age; free space is measured as the reaper does.
|
|
836
|
+
|
|
837
|
+
Returns the reap report plus ``dry_run: True``, ``unlink`` (the candidates' paths in
|
|
838
|
+
eviction order), ``sweep`` (orphaned ``.tmp`` paths), ``low_water`` and ``need_free``.
|
|
839
|
+
A refused root returns an empty report with ``refused: True``.
|
|
840
|
+
"""
|
|
841
|
+
root = os.path.abspath(str(root)) if root else ""
|
|
842
|
+
if _suspicious_root(root):
|
|
843
|
+
return {"scanned": 0, "reaped": 0, "bytes": 0, "complete": False, "dry_run": True,
|
|
844
|
+
"unlink": [], "sweep": [], "refused": True}
|
|
845
|
+
return _reap_tree(root, max_bytes=int(max_bytes), min_free_bytes=int(min_free_bytes),
|
|
846
|
+
ttl_s=float(ttl_s), budget_s=budget_s, now=now,
|
|
847
|
+
free_bytes=lambda: _measure_free(root), dry_run=True,
|
|
848
|
+
on_failed=None)[0]
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _reap_tree(root, *, max_bytes, min_free_bytes, ttl_s, budget_s, now, free_bytes,
|
|
852
|
+
dry_run, on_failed):
|
|
853
|
+
"""The reaper's walk, order and eviction (contract §9.3 to §9.6), under a lock the
|
|
854
|
+
caller holds -- or, with ``dry_run``, under none, unlinking nothing.
|
|
855
|
+
|
|
856
|
+
Returns ``(report, total, gone)``. A real reap's report carries ``now`` for the caller
|
|
857
|
+
to write into ``.usage.json``; a dry run's carries the plan instead.
|
|
858
|
+
"""
|
|
859
|
+
deadline = time.time() + float(budget_s)
|
|
860
|
+
if now is None or not dry_run: # only a dry run takes a fixed clock
|
|
861
|
+
now = time.time()
|
|
862
|
+
out = {"scanned": 0, "reaped": 0, "bytes": 0}
|
|
863
|
+
entries = []
|
|
864
|
+
swept = []
|
|
865
|
+
complete = True
|
|
866
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
867
|
+
# checked BEFORE each directory: "partial" means one went unscanned
|
|
868
|
+
if time.time() > deadline:
|
|
869
|
+
complete = False
|
|
870
|
+
break
|
|
871
|
+
# Candidates by PATH SHAPE only (C6(a)): {form}/{xx}/{xx...}.{variant}.npy.
|
|
872
|
+
# Any ``.npy`` anywhere under the root used to qualify, so a reserved
|
|
873
|
+
# ``.pinned/`` tree or a stray ``notes.npy`` was reaped like a segment.
|
|
874
|
+
# Pruning the walk to that shape also means it never descends further.
|
|
875
|
+
rel = os.path.relpath(dirpath, root)
|
|
876
|
+
parts = [] if rel == os.curdir else rel.split(os.sep)
|
|
877
|
+
if len(parts) == 0:
|
|
878
|
+
dirnames[:] = [d for d in dirnames if _keys._TOKEN_RE.fullmatch(d)]
|
|
879
|
+
continue
|
|
880
|
+
if len(parts) == 1:
|
|
881
|
+
dirnames[:] = [d for d in dirnames if _SHARD_RE.fullmatch(d)]
|
|
882
|
+
continue
|
|
883
|
+
dirnames[:] = []
|
|
884
|
+
shard = parts[1]
|
|
885
|
+
for name in filenames:
|
|
886
|
+
is_tmp = name.endswith(".tmp")
|
|
887
|
+
m = None if is_tmp else _SEGMENT_NAME_RE.fullmatch(name)
|
|
888
|
+
if not (is_tmp or (m and m.group(1) == shard)):
|
|
889
|
+
continue
|
|
890
|
+
p = os.path.join(dirpath, name)
|
|
891
|
+
try:
|
|
892
|
+
st = os.lstat(p) # lstat: never follow a symlink
|
|
893
|
+
except OSError:
|
|
894
|
+
continue
|
|
895
|
+
if not stat.S_ISREG(st.st_mode):
|
|
896
|
+
continue
|
|
897
|
+
if is_tmp:
|
|
898
|
+
# An orphan of a crashed writer (C6(b)). Not a segment: not
|
|
899
|
+
# scanned, not usage, not shm_reaped.
|
|
900
|
+
if now - st.st_mtime > TMP_ORPHAN_S:
|
|
901
|
+
if dry_run:
|
|
902
|
+
swept.append(p)
|
|
903
|
+
continue
|
|
904
|
+
try:
|
|
905
|
+
os.unlink(p)
|
|
906
|
+
except OSError:
|
|
907
|
+
pass
|
|
908
|
+
continue
|
|
909
|
+
out["scanned"] += 1
|
|
910
|
+
entries.append((st.st_mtime, st.st_size, p))
|
|
911
|
+
|
|
912
|
+
total = sum(e[1] for e in entries)
|
|
913
|
+
entries.sort() # oldest mtime first: LRU
|
|
914
|
+
|
|
915
|
+
# Two targets, least recently used first. The byte budget's low water mark
|
|
916
|
+
# (judged only on a complete walk: a partial one under-counts), and the
|
|
917
|
+
# free-space floor with 25% headroom. The floor used to be a publish gate
|
|
918
|
+
# only, so a volume that filled before the budget did -- a quota smaller than
|
|
919
|
+
# max_bytes + min_free_bytes -- declined everything and evicted nothing.
|
|
920
|
+
low_water = int(0.8 * max_bytes)
|
|
921
|
+
need_free = max(0, int(1.25 * min_free_bytes) - free_bytes())
|
|
922
|
+
freed = 0
|
|
923
|
+
gone = 0
|
|
924
|
+
planned = []
|
|
925
|
+
for mtime, size, p in entries:
|
|
926
|
+
expired = (now - mtime) > ttl_s
|
|
927
|
+
over = complete and total > low_water
|
|
928
|
+
short = freed < need_free
|
|
929
|
+
if not (expired or over or short):
|
|
930
|
+
break
|
|
931
|
+
# Unlinking is cheap and bounded by the walk, so eviction always reaches
|
|
932
|
+
# its target: the time budget limits the walk, not the eviction. A budget
|
|
933
|
+
# that cut eviction short left a store 1.2 GB over its low water mark.
|
|
934
|
+
if dry_run:
|
|
935
|
+
planned.append(p) # as if the unlink succeeded
|
|
936
|
+
else:
|
|
937
|
+
try:
|
|
938
|
+
os.unlink(p)
|
|
939
|
+
except FileNotFoundError:
|
|
940
|
+
# Another reaper took it (C8). It has left the tree all the same, so it
|
|
941
|
+
# counts toward both targets (contract §9.6): not counting it let two
|
|
942
|
+
# racing reapers take six segments to none against a low water mark of
|
|
943
|
+
# two. It isn't this reaper's eviction, so no counter and no bytes.
|
|
944
|
+
gone += 1
|
|
945
|
+
total -= size
|
|
946
|
+
freed += size
|
|
947
|
+
continue
|
|
948
|
+
except OSError:
|
|
949
|
+
# Skipped and counted, then on to the next candidate (§9.6). On Windows
|
|
950
|
+
# this is a segment some reader has open or mapped (§12).
|
|
951
|
+
on_failed()
|
|
952
|
+
continue
|
|
953
|
+
total -= size
|
|
954
|
+
freed += size
|
|
955
|
+
out["reaped"] += 1
|
|
956
|
+
out["bytes"] += size
|
|
957
|
+
out["complete"] = complete
|
|
958
|
+
if dry_run:
|
|
959
|
+
out.update(dry_run=True, unlink=planned, sweep=swept, low_water=low_water,
|
|
960
|
+
need_free=need_free, now=now)
|
|
961
|
+
else:
|
|
962
|
+
out["now"] = now
|
|
963
|
+
return out, total, gone
|