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/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