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/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Named shared-memory segments for numpy arrays.
|
|
2
|
+
|
|
3
|
+
The name is for Fortran's ``COMMON`` block: a named region several separately
|
|
4
|
+
compiled programs agree to share. The name is the whole interface, and the
|
|
5
|
+
segment outlives whoever wrote it.
|
|
6
|
+
|
|
7
|
+
``store`` is the mechanism and carries the measured reasons behind it, ``keys``
|
|
8
|
+
the addressing, ``cache`` the in-process memo that keeps a tight loop from
|
|
9
|
+
paying an attach per call.
|
|
10
|
+
"""
|
|
11
|
+
from .cache import TieredPixelCache
|
|
12
|
+
from .keys import KeyScheme, parse, register, relpath, scheme_for
|
|
13
|
+
from .shared import shared_store
|
|
14
|
+
from .store import KeyCollision, SharedArrayStore
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"KeyCollision", "KeyScheme", "SharedArrayStore", "TieredPixelCache",
|
|
18
|
+
"parse", "register", "relpath", "scheme_for", "shared_store",
|
|
19
|
+
]
|
commonkit/cache.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Two-tier pixel cache: an in-process memo over the shared store.
|
|
2
|
+
|
|
3
|
+
Why two tiers and not a straight replacement, in one measurement:
|
|
4
|
+
|
|
5
|
+
The consumer's face-crop path calls its pixel accessor **twice per face**
|
|
6
|
+
-- once for the base dimensions, once for the crop. An 18-face page is
|
|
7
|
+
therefore 36 lookups. Today that is one 38 ms decode plus 35 free in-process
|
|
8
|
+
hits. Against a shared store alone it would be 36 attaches at ~3.3 ms each =
|
|
9
|
+
119 ms, **three times slower than doing nothing at all**. Attaching is cheap, but
|
|
10
|
+
it is not free, and a loop that attaches per crop is the one access pattern that
|
|
11
|
+
turns a cache into a pessimization.
|
|
12
|
+
|
|
13
|
+
So L1 memoises the ATTACHED MAPPING, not a second copy of the pixels. The first
|
|
14
|
+
lookup attaches; every later lookup in that process is a dict hit. The pixels
|
|
15
|
+
still live once in shared memory and are still shared across processes -- L1 adds
|
|
16
|
+
no bytes, it removes syscalls.
|
|
17
|
+
|
|
18
|
+
Tier order: L1 in-process memo -> L2 shared store -> decode.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections import Counter
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from .store import KeyCollision
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TieredPixelCache:
|
|
30
|
+
"""Satisfies the consumer's ``PixelCache`` protocol.
|
|
31
|
+
|
|
32
|
+
``l1`` is anything with ``get``/``set_array`` (the original consumer
|
|
33
|
+
passes an LRU array cache); ``l2`` is a
|
|
34
|
+
:class:`~commonkit.store.SharedArrayStore`. Either may be None,
|
|
35
|
+
which degrades to the other -- and with both None this is a no-op cache,
|
|
36
|
+
leaving its caller exactly as it would be with no cache at all.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, l1=None, l2=None):
|
|
40
|
+
self.l1 = l1
|
|
41
|
+
self.l2 = l2
|
|
42
|
+
self.stats: Counter = Counter()
|
|
43
|
+
|
|
44
|
+
# ``_base_array`` checks for this and prefers it over the bytes path.
|
|
45
|
+
def get(self, key: str):
|
|
46
|
+
"""The PixelCache protocol: the array, or None."""
|
|
47
|
+
return self.get_with_source(key)[0]
|
|
48
|
+
|
|
49
|
+
def get_with_source(self, key: str):
|
|
50
|
+
"""``(array, source)`` -- which TIER answered, or ``(None, "decode")``.
|
|
51
|
+
|
|
52
|
+
The return channel this class used to lack, and the lack of which was a
|
|
53
|
+
real defect rather than an inelegance. ``entry.decoded`` could not ask
|
|
54
|
+
what happened, so it inferred it from counter DELTAS -- reading
|
|
55
|
+
``shm_hit`` off this object, which deliberately does not tick it because
|
|
56
|
+
the store already does. The inference silently defaulted to "decode",
|
|
57
|
+
so every cross-process attach was reported as a decode.
|
|
58
|
+
|
|
59
|
+
Answering directly deletes the inference instead of correcting it. It is
|
|
60
|
+
also the only thread-safe shape: this cache is a module-global shared
|
|
61
|
+
across a node's CONCURRENCY threads, so recording "what I did last" on
|
|
62
|
+
``self`` would be a race between them.
|
|
63
|
+
"""
|
|
64
|
+
if self.l1 is not None:
|
|
65
|
+
arr = self.l1.get(key)
|
|
66
|
+
if arr is not None:
|
|
67
|
+
self.stats["l1_hit"] += 1
|
|
68
|
+
return arr, "l1"
|
|
69
|
+
if self.l2 is not None:
|
|
70
|
+
arr = self.l2.attach(key)
|
|
71
|
+
if arr is not None:
|
|
72
|
+
# The store counts the hit (and the publish, below) itself.
|
|
73
|
+
# Counting it here too doubled both in drain_stats, which
|
|
74
|
+
# sums the tier's counters with the store's.
|
|
75
|
+
# Promote so a crop loop pays one attach, not one per crop.
|
|
76
|
+
if self.l1 is not None:
|
|
77
|
+
try:
|
|
78
|
+
self.l1.set_array(key, arr)
|
|
79
|
+
except Exception: # noqa: BLE001, S110 -- an L1 that can't take the array costs a later attach, never an error
|
|
80
|
+
pass
|
|
81
|
+
return arr, "shm"
|
|
82
|
+
self.stats["miss"] += 1
|
|
83
|
+
return None, "decode"
|
|
84
|
+
|
|
85
|
+
def set_array(self, key: str, arr) -> None:
|
|
86
|
+
"""Called by ``_base_array`` after a decode. Raises only :class:`KeyCollision`."""
|
|
87
|
+
if self.l1 is not None:
|
|
88
|
+
try:
|
|
89
|
+
self.l1.set_array(key, arr)
|
|
90
|
+
except Exception: # noqa: BLE001, S110 -- an L1 that can't take the array costs a later decode, never an error
|
|
91
|
+
pass
|
|
92
|
+
if self.l2 is not None:
|
|
93
|
+
try:
|
|
94
|
+
if not self.l2.publish(key, arr):
|
|
95
|
+
self.stats["shm_declined"] += 1
|
|
96
|
+
except KeyCollision:
|
|
97
|
+
# The one exception that must reach the caller (contract §6.4): a
|
|
98
|
+
# broken key scheme hidden here would serve two contents under one key.
|
|
99
|
+
raise
|
|
100
|
+
except Exception: # noqa: BLE001
|
|
101
|
+
self.stats["shm_publish_failed"] += 1
|
|
102
|
+
|
|
103
|
+
def set(self, key: str, value: bytes) -> None:
|
|
104
|
+
"""Bytes path, for protocol compliance. ``_base_array`` prefers
|
|
105
|
+
``set_array`` and will not normally reach this."""
|
|
106
|
+
try:
|
|
107
|
+
import io
|
|
108
|
+
arr = np.load(io.BytesIO(value), allow_pickle=False)
|
|
109
|
+
except Exception: # noqa: BLE001 -- undecodable bytes are a miss: the cache never raises
|
|
110
|
+
return
|
|
111
|
+
self.set_array(key, arr) # outside the try, so a KeyCollision gets out
|
|
112
|
+
|
|
113
|
+
def drain_stats(self) -> Counter:
|
|
114
|
+
"""Take and reset counters, including the store's own."""
|
|
115
|
+
out = Counter(self.stats)
|
|
116
|
+
self.stats.clear()
|
|
117
|
+
if self.l2 is not None:
|
|
118
|
+
try:
|
|
119
|
+
out.update(self.l2.drain_stats())
|
|
120
|
+
except Exception: # noqa: BLE001, S110 -- a store whose stats fail still lets the tier's own counters out
|
|
121
|
+
pass
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
def __repr__(self) -> str:
|
|
125
|
+
return f"<TieredPixelCache l1={self.l1!r} l2={self.l2!r}>"
|
commonkit/keys.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Addressing: the key for a segment, and the path that key names.
|
|
2
|
+
|
|
3
|
+
Key shape::
|
|
4
|
+
|
|
5
|
+
{domain}:{version}:{form}:{digest}:{variant}
|
|
6
|
+
|
|
7
|
+
Path shape::
|
|
8
|
+
|
|
9
|
+
{root}/{form}/{digest[:2]}/{digest}.{variant}.npy
|
|
10
|
+
|
|
11
|
+
Two-level sharding like a CAS, so no directory ever holds a million entries.
|
|
12
|
+
|
|
13
|
+
A :class:`KeyScheme` fixes one domain's vocabulary. The pieces mean:
|
|
14
|
+
|
|
15
|
+
``domain``
|
|
16
|
+
Who the segments belong to -- ``pixels``, ``audio``. Also the dispatch key:
|
|
17
|
+
schemes register themselves, so :func:`parse` and :func:`relpath` can work
|
|
18
|
+
from a key alone and one store can hold several domains.
|
|
19
|
+
``version``
|
|
20
|
+
The DECODE CONTRACT, not a release number. Bump it when what a segment
|
|
21
|
+
contains changes meaning, so old segments become unreachable rather than
|
|
22
|
+
being attached by a reader that assumes the new contract. (``pixels`` is at
|
|
23
|
+
``v3`` because ``v2`` segments could hold LA/RGBA, which ``v3`` readers
|
|
24
|
+
assume away.)
|
|
25
|
+
``form``
|
|
26
|
+
What KIND of array -- ``pil`` uint8 images, ``f32chw`` model tensors,
|
|
27
|
+
``wav16k`` int16 audio. Deliberately NOT colour order: a BGR view is
|
|
28
|
+
converted from the one stored RGB segment in about a millisecond against a
|
|
29
|
+
38 ms decode, and storing both would double the tree to save that
|
|
30
|
+
millisecond while letting the two drift.
|
|
31
|
+
``variant``
|
|
32
|
+
What distinguishes two renderings of the SAME source bytes. This is the
|
|
33
|
+
axis that differs per domain and the reason the scheme is a class rather
|
|
34
|
+
than a constant: images vary by orientation (``o0`` raw, ``o1`` oriented,
|
|
35
|
+
``oa`` Apple), a waveform would vary by sample rate (``sr16000``).
|
|
36
|
+
|
|
37
|
+
The form segment precedes the digest so that a domain whose variant suffix is
|
|
38
|
+
already load-bearing elsewhere keeps it at the end of the key, untouched.
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import re
|
|
43
|
+
from collections.abc import Iterable
|
|
44
|
+
|
|
45
|
+
#: Segments are addressed by md5, lowercase. Every pattern here is used with
|
|
46
|
+
#: ``fullmatch``, never ``match`` with ``$``: ``$`` also matches before a final
|
|
47
|
+
#: newline, which let ``"...\n"`` through as a digest or token (contract §3.1).
|
|
48
|
+
_DIGEST_RE = re.compile(r"[0-9a-f]{32}")
|
|
49
|
+
|
|
50
|
+
#: Every other component is a bare lowercase token. Restrictive on purpose:
|
|
51
|
+
#: these become PATH components, and a key is the only thing standing between
|
|
52
|
+
#: a caller and a path the reaper will later be asked to unlink.
|
|
53
|
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
54
|
+
|
|
55
|
+
#: Fold ASCII upper case only (contract §3.3): ``str.lower`` folds far more.
|
|
56
|
+
_ASCII_LOWER = str.maketrans("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class KeyScheme:
|
|
60
|
+
"""One domain's key vocabulary, and the only thing that builds its keys.
|
|
61
|
+
|
|
62
|
+
``variants`` is either an explicit tuple of legal variants or a regex
|
|
63
|
+
string for an open-ended axis (sample rates, say). Explicit is preferred
|
|
64
|
+
where the set is closed: an unknown variant is then a loud error at the
|
|
65
|
+
call site rather than a segment nobody will ever look for.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, domain: str, version: str, forms: Iterable[str],
|
|
69
|
+
variants, dtypes: dict[str, str] | None = None):
|
|
70
|
+
for name, value in (("domain", domain), ("version", version)):
|
|
71
|
+
if not _TOKEN_RE.fullmatch(str(value)):
|
|
72
|
+
raise ValueError(f"{name} must be [a-z0-9]+, got {value!r}")
|
|
73
|
+
self.domain = str(domain)
|
|
74
|
+
self.version = str(version)
|
|
75
|
+
self.forms = tuple(forms)
|
|
76
|
+
if not self.forms:
|
|
77
|
+
raise ValueError(f"{self.domain}: at least one form is required")
|
|
78
|
+
for form in self.forms:
|
|
79
|
+
if not _TOKEN_RE.fullmatch(form):
|
|
80
|
+
raise ValueError(f"form must be [a-z0-9]+, got {form!r}")
|
|
81
|
+
if len(set(self.forms)) != len(self.forms):
|
|
82
|
+
raise ValueError(f"{self.domain}: forms must be distinct, got {self.forms}")
|
|
83
|
+
|
|
84
|
+
if isinstance(variants, str):
|
|
85
|
+
self.variants: tuple[str, ...] = ()
|
|
86
|
+
self._variant_re = re.compile(variants)
|
|
87
|
+
#: The pattern's text, which is what makes two pattern schemes the
|
|
88
|
+
#: same or different (contract §3.4).
|
|
89
|
+
self.variant_pattern: str | None = variants
|
|
90
|
+
else:
|
|
91
|
+
self.variants = tuple(variants)
|
|
92
|
+
if not self.variants:
|
|
93
|
+
raise ValueError(f"{self.domain}: at least one variant")
|
|
94
|
+
for variant in self.variants:
|
|
95
|
+
if not _TOKEN_RE.fullmatch(variant):
|
|
96
|
+
raise ValueError(f"variant must be [a-z0-9]+, got {variant!r}")
|
|
97
|
+
self._variant_re = None
|
|
98
|
+
self.variant_pattern = None
|
|
99
|
+
|
|
100
|
+
# form -> required numpy dtype NAME, for forms whose consumers rely on
|
|
101
|
+
# one. Declared here rather than branched on inside the store, which
|
|
102
|
+
# would put one domain's policy in the generic layer. A segment that
|
|
103
|
+
# violates it is refused at publish: a bad segment is not one bad call,
|
|
104
|
+
# it is published once and then poisons every reader that attaches it,
|
|
105
|
+
# on a path where a miss is meant to be the worst outcome available.
|
|
106
|
+
self.dtypes = dict(dtypes or {})
|
|
107
|
+
for form in self.dtypes:
|
|
108
|
+
if form not in self.forms:
|
|
109
|
+
raise ValueError(f"{self.domain}: dtype for unknown form {form!r}")
|
|
110
|
+
|
|
111
|
+
self._key_re = re.compile(
|
|
112
|
+
rf"{re.escape(self.domain)}:{re.escape(self.version)}:"
|
|
113
|
+
r"(?P<form>[a-z0-9]+):(?P<digest>[0-9a-f]{32}):"
|
|
114
|
+
r"(?P<variant>[a-z0-9]+)")
|
|
115
|
+
|
|
116
|
+
# ------------------------------------------------------------- building
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def default_form(self) -> str:
|
|
120
|
+
return self.forms[0]
|
|
121
|
+
|
|
122
|
+
def dtype_for(self, form: str) -> str | None:
|
|
123
|
+
"""The dtype ``form`` must have, or None when anything goes."""
|
|
124
|
+
return self.dtypes.get(form)
|
|
125
|
+
|
|
126
|
+
def valid_variant(self, variant: str) -> bool:
|
|
127
|
+
"""A token, and in the closed set or matched WHOLE by the pattern (contract §3.2).
|
|
128
|
+
|
|
129
|
+
``re.match`` anchored only the start, so ``sr[0-9]+`` accepted ``sr16000x``;
|
|
130
|
+
and a pattern like ``sr.+`` could accept ``sr-1``, which no parser reads back.
|
|
131
|
+
"""
|
|
132
|
+
variant = str(variant)
|
|
133
|
+
if not _TOKEN_RE.fullmatch(variant):
|
|
134
|
+
return False
|
|
135
|
+
if self._variant_re is not None:
|
|
136
|
+
return self._variant_re.fullmatch(variant) is not None
|
|
137
|
+
return variant in self.variants
|
|
138
|
+
|
|
139
|
+
def key(self, digest: str, variant: str, form: str | None = None) -> str:
|
|
140
|
+
"""The store key for one array. Raises on anything it cannot address."""
|
|
141
|
+
digest = str(digest).translate(_ASCII_LOWER)
|
|
142
|
+
if not _DIGEST_RE.fullmatch(digest):
|
|
143
|
+
raise ValueError(f"not an md5 digest: {digest!r}")
|
|
144
|
+
form = self.default_form if form is None else str(form)
|
|
145
|
+
if form not in self.forms:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"{self.domain}: unknown form {form!r}; have {self.forms}")
|
|
148
|
+
variant = str(variant)
|
|
149
|
+
if not self.valid_variant(variant):
|
|
150
|
+
raise ValueError(f"{self.domain}: unknown variant {variant!r}")
|
|
151
|
+
return f"{self.domain}:{self.version}:{form}:{digest}:{variant}"
|
|
152
|
+
|
|
153
|
+
# -------------------------------------------------------------- reading
|
|
154
|
+
|
|
155
|
+
def parse(self, key: str) -> tuple[str, str, str]:
|
|
156
|
+
"""``key -> (form, digest, variant)``. Raises on anything unparseable.
|
|
157
|
+
|
|
158
|
+
Strict on purpose: this is the only thing between a malformed key and
|
|
159
|
+
a path the reaper might later unlink.
|
|
160
|
+
"""
|
|
161
|
+
m = self._key_re.fullmatch(str(key))
|
|
162
|
+
if m is None:
|
|
163
|
+
raise ValueError(f"unparseable {self.domain} key: {key!r}")
|
|
164
|
+
form, variant = m.group("form"), m.group("variant")
|
|
165
|
+
if form not in self.forms or not self.valid_variant(variant):
|
|
166
|
+
raise ValueError(f"unparseable {self.domain} key: {key!r}")
|
|
167
|
+
return form, m.group("digest"), variant
|
|
168
|
+
|
|
169
|
+
def relpath(self, key: str) -> str:
|
|
170
|
+
"""``key -> "{form}/{2-hex}/{digest}.{variant}.npy"``, never absolute."""
|
|
171
|
+
form, digest, variant = self.parse(key)
|
|
172
|
+
return f"{form}/{digest[:2]}/{digest}.{variant}.npy"
|
|
173
|
+
|
|
174
|
+
def __repr__(self) -> str:
|
|
175
|
+
return f"<KeyScheme {self.domain}:{self.version} forms={self.forms}>"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------ registry
|
|
179
|
+
#
|
|
180
|
+
# Dispatch by domain is what lets ONE store hold several domains: the store
|
|
181
|
+
# parses a key without being told which scheme it belongs to, so it never has
|
|
182
|
+
# to grow a parameter for something the key already says.
|
|
183
|
+
|
|
184
|
+
_REGISTRY: dict[str, KeyScheme] = {}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def register(scheme: KeyScheme) -> KeyScheme:
|
|
188
|
+
"""Register ``scheme``, refusing a second, different one for its domain.
|
|
189
|
+
|
|
190
|
+
Idempotent for an identical re-registration, because a module that binds a
|
|
191
|
+
scheme at import time may legitimately be imported twice under different
|
|
192
|
+
names. A DISAGREEING second registration is the bug worth catching: two
|
|
193
|
+
spellings of one domain means the publisher writes one key and the reader
|
|
194
|
+
asks for another, and the hit rate silently goes to zero with no error.
|
|
195
|
+
"""
|
|
196
|
+
prior = _REGISTRY.get(scheme.domain)
|
|
197
|
+
if prior is not None:
|
|
198
|
+
same = (prior.version == scheme.version
|
|
199
|
+
and prior.forms == scheme.forms
|
|
200
|
+
# A closed variant SET (contract §3.4): order carries no meaning there,
|
|
201
|
+
# unlike forms, whose first is the default.
|
|
202
|
+
and set(prior.variants) == set(scheme.variants)
|
|
203
|
+
and prior.variant_pattern == scheme.variant_pattern
|
|
204
|
+
and prior.dtypes == scheme.dtypes)
|
|
205
|
+
if not same:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f"two disagreeing schemes for domain {scheme.domain!r}")
|
|
208
|
+
return prior
|
|
209
|
+
_REGISTRY[scheme.domain] = scheme
|
|
210
|
+
return scheme
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def scheme_for(key: str) -> KeyScheme:
|
|
214
|
+
"""The registered scheme that owns ``key``."""
|
|
215
|
+
domain = str(key).split(":", 1)[0]
|
|
216
|
+
scheme = _REGISTRY.get(domain)
|
|
217
|
+
if scheme is None:
|
|
218
|
+
raise ValueError(f"no registered scheme for key: {key!r}")
|
|
219
|
+
return scheme
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def parse(key: str) -> tuple[str, str, str]:
|
|
223
|
+
"""``key -> (form, digest, variant)``, dispatching on the key's domain."""
|
|
224
|
+
return scheme_for(key).parse(key)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def relpath(key: str) -> str:
|
|
228
|
+
"""``key -> relative segment path``, dispatching on the key's domain."""
|
|
229
|
+
return scheme_for(key).relpath(key)
|
commonkit/read.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Counting what a lookup did, in ONE vocabulary.
|
|
2
|
+
|
|
3
|
+
A store answers three ways and the names for them are worth owning centrally,
|
|
4
|
+
because the alternative is what happened before this module existed: two paths
|
|
5
|
+
to the same pixels each decided independently what to call a hit, wrote the
|
|
6
|
+
SAME counter names for DIFFERENT events, and nothing downstream could tell
|
|
7
|
+
which one it was reading. One of them classified by counter DELTA and was
|
|
8
|
+
wrong for two days; a correct production measurement was retracted on the
|
|
9
|
+
strength of the ambiguity.
|
|
10
|
+
|
|
11
|
+
The counter PREFIX is the caller's, not ours: ``pixels_shm`` and ``audio_shm``
|
|
12
|
+
are different measurements and must not collide in one stats table.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
#: Every tier a lookup can be answered by. ``decode`` means "nothing had it" --
|
|
19
|
+
#: the caller recomputes, which is always allowed and never an error.
|
|
20
|
+
SOURCES = ("l1", "shm", "decode")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def note(stats: Any, source: str, *, prefix: str) -> str:
|
|
24
|
+
"""Tick ``{prefix}{source}``. THE one place that counter is written.
|
|
25
|
+
|
|
26
|
+
Returns ``source`` so callers can use this inline. A bad source name is a
|
|
27
|
+
programming error and raises: these counters are the instrument the whole
|
|
28
|
+
exercise is judged by, and a typo would silently invent a tier.
|
|
29
|
+
"""
|
|
30
|
+
if source not in SOURCES:
|
|
31
|
+
raise ValueError(f"unknown source {source!r}; have {SOURCES}")
|
|
32
|
+
if stats is not None:
|
|
33
|
+
stats[f"{prefix}{source}"] += 1
|
|
34
|
+
return source
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def attach(store: Any, key: str) -> Any | None:
|
|
38
|
+
"""The array for ``key``, or None. A miss is never an error.
|
|
39
|
+
|
|
40
|
+
An absent store, an unusable key, an absent segment and a torn file all
|
|
41
|
+
return None, because every caller's answer to all four is the same:
|
|
42
|
+
recompute it.
|
|
43
|
+
"""
|
|
44
|
+
if store is None or not key:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
return store.attach(key)
|
|
48
|
+
except Exception: # noqa: BLE001
|
|
49
|
+
return None
|
commonkit/shared.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""The ONE store per host that every commonkit user shares, and its tree-wide policy.
|
|
2
|
+
|
|
3
|
+
Why one store rather than one per application, measured on an M4 Max (macOS, 16 KB pages)
|
|
4
|
+
by the first consumer:
|
|
5
|
+
|
|
6
|
+
* Every budget is enforced per TREE, by whichever process happens to reap. Two
|
|
7
|
+
applications with two roots therefore hold the SUM of their budgets, and neither
|
|
8
|
+
sees the other's usage. One root means one budget and one age-out for everything.
|
|
9
|
+
* On macOS, Time Machine's hourly local snapshots keep a reaped segment's blocks
|
|
10
|
+
allocated (a 539 MB segment reaped after a snapshot freed 0 MB, TM exclusion of
|
|
11
|
+
~/Library/Caches notwithstanding). A tree that turns over within the hour can pin up
|
|
12
|
+
to ~24x its budget of purgeable space until the snapshots thin. That is why the macOS
|
|
13
|
+
budget is 2 GB, and why a second tree with its own budget multiplies the pinned space.
|
|
14
|
+
|
|
15
|
+
Tree-wide policy -- root, byte budget, TTL, free-space floor, reap interval, largest
|
|
16
|
+
array, fsync, republish verification -- comes ONLY from the tree's ``.policy.json``, the
|
|
17
|
+
environment (``COMMONKIT_STORE_*``) or the defaults here, never from a caller, because the
|
|
18
|
+
reaper applies whatever the reaping process was given to every segment in the tree: a
|
|
19
|
+
caller with a longer TTL or a bigger budget would silently override everyone else's. A
|
|
20
|
+
caller chooses only what concerns itself: ``mode``, how it reads (``attach``: ``mmap``
|
|
21
|
+
shares one physical copy, ``read`` gives it a private one), and the smallest array worth
|
|
22
|
+
storing. Tests may pass a ``root`` to stay out of the shared tree.
|
|
23
|
+
|
|
24
|
+
The policy FILE wins over the environment (C7), because the environment is per process
|
|
25
|
+
and the tree is not: on thresher the policy lived in ``~/.bashrc``, so an app launched
|
|
26
|
+
from the Finder ran with the defaults and reaped a 7 GB tree down to 2 GB. A file at the
|
|
27
|
+
root is seen by every process that finds the root. The environment fills only what the
|
|
28
|
+
file leaves out (contract §10.6).
|
|
29
|
+
|
|
30
|
+
Segments of different domains coexist because forms partition the tree
|
|
31
|
+
(``{form}/{digest[:2]}/...``). Form names are therefore global: pick ones no other
|
|
32
|
+
domain uses.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import math
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
import stat
|
|
41
|
+
import sys
|
|
42
|
+
|
|
43
|
+
from .store import SharedArrayStore
|
|
44
|
+
|
|
45
|
+
ENV_PREFIX = "COMMONKIT_STORE_"
|
|
46
|
+
_DARWIN = sys.platform == "darwin"
|
|
47
|
+
|
|
48
|
+
#: Per-platform defaults. macOS: the user cache directory on the internal SSD -- it has no
|
|
49
|
+
#: /dev/shm, the SSD beat a RAM disk on first attach (0.69 ms against 0.76 ms), and a
|
|
50
|
+
#: ``ram://`` disk commits its whole size up front as dirty memory. Linux: /dev/shm, which
|
|
51
|
+
#: is RAM, so the budget is its only real bound.
|
|
52
|
+
DEFAULTS = {
|
|
53
|
+
"ROOT": (os.path.expanduser("~/Library/Caches/commonkit/store") if _DARWIN
|
|
54
|
+
else "/dev/shm/commonkit"),
|
|
55
|
+
"MAX_GB": 2.0 if _DARWIN else 8.0,
|
|
56
|
+
"MIN_FREE_GB": 8.0,
|
|
57
|
+
"TTL_S": 900.0,
|
|
58
|
+
"REAP_S": 30.0,
|
|
59
|
+
"MAX_ARRAY_BYTES": 128 * 2**20,
|
|
60
|
+
"FSYNC": False,
|
|
61
|
+
"VERIFY_REPUBLISH": False,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
#: The tree's policy file, directly under the root (contract §10.5), and the most of it
|
|
65
|
+
#: that is read: a real one is a few hundred bytes.
|
|
66
|
+
POLICY_FILE = ".policy.json"
|
|
67
|
+
_POLICY_MAX_BYTES = 65536
|
|
68
|
+
|
|
69
|
+
#: The settings that become byte counts, and their scale. The derived integer must fit
|
|
70
|
+
#: in 64 signed bits (contract §10.2), or a value is malformed: ``int(float("inf"))``
|
|
71
|
+
#: raised OverflowError out of ``shared_store`` until 2026-10-28.
|
|
72
|
+
_BYTE_SCALE = {"MAX_GB": 2**30, "MIN_FREE_GB": 2**30, "MAX_ARRAY_BYTES": 1}
|
|
73
|
+
|
|
74
|
+
#: The number grammar (contract §10.2). ``float()`` is far looser: ``inf``, ``nan``,
|
|
75
|
+
#: ``1_000``, Arabic-Indic digits and Unicode whitespace all parse, and a Rust or C
|
|
76
|
+
#: reader of the same environment would disagree with every one of them.
|
|
77
|
+
_NUMBER_RE = re.compile(r"[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?")
|
|
78
|
+
_ASCII_SPACE = " \t\n\v\f\r"
|
|
79
|
+
_FLAG_ON = frozenset({"1", "true", "yes", "on"})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_number(raw: str) -> float | None:
|
|
83
|
+
"""A finite float from ``raw`` under the contract's grammar, or None if malformed."""
|
|
84
|
+
text = raw.strip(_ASCII_SPACE)
|
|
85
|
+
if not _NUMBER_RE.fullmatch(text):
|
|
86
|
+
return None
|
|
87
|
+
value = float(text) # the grammar is a subset of float()'s
|
|
88
|
+
return value if math.isfinite(value) else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse_flag(raw: str) -> bool:
|
|
92
|
+
"""Whether ``raw`` turns a flag on (contract §10.3): exact, ASCII case-insensitive.
|
|
93
|
+
|
|
94
|
+
``isascii`` first, because ``str.lower`` folds beyond ASCII. Nothing is trimmed:
|
|
95
|
+
``" 1"`` is off, so a value means one thing whichever language reads it.
|
|
96
|
+
"""
|
|
97
|
+
return raw.isascii() and raw.lower() in _FLAG_ON
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class _Object(dict):
|
|
101
|
+
"""A JSON object that remembers whether its text repeated a key."""
|
|
102
|
+
|
|
103
|
+
duplicate = False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _object_pairs(pairs):
|
|
107
|
+
obj = _Object(pairs)
|
|
108
|
+
obj.duplicate = len(obj) != len(pairs)
|
|
109
|
+
return obj
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _not_json(name):
|
|
113
|
+
raise ValueError(f"{name} is not JSON")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read_policy(root) -> dict:
|
|
117
|
+
"""The tree's ``.policy.json`` as a dict, or ``{}`` when it is absent or invalid.
|
|
118
|
+
|
|
119
|
+
Invalid is ENTIRELY absent (contract §10.5): not a regular file, over 64 KiB,
|
|
120
|
+
not UTF-8, not JSON (``NaN`` and ``Infinity`` included), not an object, or a
|
|
121
|
+
repeated top-level key, which JSON parsers resolve differently. Never raises.
|
|
122
|
+
"""
|
|
123
|
+
path = os.path.join(os.path.abspath(str(root)), POLICY_FILE)
|
|
124
|
+
try:
|
|
125
|
+
# stat first: opening a FIFO someone named .policy.json would block forever.
|
|
126
|
+
st = os.stat(path)
|
|
127
|
+
if not stat.S_ISREG(st.st_mode) or st.st_size > _POLICY_MAX_BYTES:
|
|
128
|
+
return {}
|
|
129
|
+
with open(path, "rb") as fh:
|
|
130
|
+
data = fh.read(_POLICY_MAX_BYTES + 1)
|
|
131
|
+
if len(data) > _POLICY_MAX_BYTES:
|
|
132
|
+
return {}
|
|
133
|
+
# Decoded first: json.loads on bytes would also accept UTF-16 and UTF-32.
|
|
134
|
+
doc = json.loads(data.decode("utf-8"), object_pairs_hook=_object_pairs,
|
|
135
|
+
parse_constant=_not_json)
|
|
136
|
+
except Exception: # noqa: BLE001
|
|
137
|
+
return {}
|
|
138
|
+
if not isinstance(doc, _Object) or doc.duplicate:
|
|
139
|
+
return {}
|
|
140
|
+
return dict(doc)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _from_policy(name: str, value):
|
|
144
|
+
"""``value`` from the policy file as a setting of ``name``'s kind, or None (left out)."""
|
|
145
|
+
if isinstance(DEFAULTS[name], bool):
|
|
146
|
+
if isinstance(value, bool):
|
|
147
|
+
return value
|
|
148
|
+
return parse_flag(value) if isinstance(value, str) else None
|
|
149
|
+
if isinstance(value, bool): # bool is an int to Python, never a number here
|
|
150
|
+
return None
|
|
151
|
+
if isinstance(value, (int, float)):
|
|
152
|
+
try:
|
|
153
|
+
value = float(value) # an integer past the double range overflows
|
|
154
|
+
except OverflowError:
|
|
155
|
+
return None
|
|
156
|
+
return value if math.isfinite(value) else None
|
|
157
|
+
return parse_number(value) if isinstance(value, str) else None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _from_env(name: str):
|
|
161
|
+
raw = os.environ.get(ENV_PREFIX + name)
|
|
162
|
+
if raw is None or raw == "":
|
|
163
|
+
return None
|
|
164
|
+
if isinstance(DEFAULTS[name], bool):
|
|
165
|
+
return parse_flag(raw)
|
|
166
|
+
return parse_number(raw)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _in_range(name: str, value) -> bool:
|
|
170
|
+
scale = _BYTE_SCALE.get(name)
|
|
171
|
+
if scale is None:
|
|
172
|
+
return True
|
|
173
|
+
try:
|
|
174
|
+
n = int(value * scale) # trunc; inf (1e300 GiB) raises OverflowError
|
|
175
|
+
except (OverflowError, ValueError):
|
|
176
|
+
return False
|
|
177
|
+
return -2**63 <= n <= 2**63 - 1
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def setting(name: str, policy: dict | None = None):
|
|
181
|
+
"""The effective tree-wide value of ``name`` (a key of DEFAULTS).
|
|
182
|
+
|
|
183
|
+
The first valid one of: ``policy`` (a :func:`read_policy` result), the environment,
|
|
184
|
+
the default (contract §10.6). ``ROOT`` never comes from the policy file, which is
|
|
185
|
+
found BY the root. A malformed value is skipped rather than raised: like the store
|
|
186
|
+
itself, configuration must never be the reason a caller fails.
|
|
187
|
+
"""
|
|
188
|
+
default = DEFAULTS[name]
|
|
189
|
+
if name == "ROOT":
|
|
190
|
+
raw = os.environ.get(ENV_PREFIX + name)
|
|
191
|
+
return os.path.expanduser(raw) if raw else default
|
|
192
|
+
candidates = []
|
|
193
|
+
if policy and name in policy:
|
|
194
|
+
candidates.append(_from_policy(name, policy[name]))
|
|
195
|
+
candidates.append(_from_env(name))
|
|
196
|
+
for value in candidates:
|
|
197
|
+
if value is not None and _in_range(name, value):
|
|
198
|
+
return type(default)(value)
|
|
199
|
+
return default
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def shared_store(*, mode: str = "readwrite", attach: str = "mmap",
|
|
203
|
+
min_array_bytes: int = 256 * 1024, root=None) -> SharedArrayStore:
|
|
204
|
+
"""The host's shared store, with the tree-wide policy from ``.policy.json`` and the
|
|
205
|
+
environment. The file is read once, here.
|
|
206
|
+
|
|
207
|
+
Never raises (the store's own contract): an unusable root comes up disabled.
|
|
208
|
+
"""
|
|
209
|
+
root = root or setting("ROOT")
|
|
210
|
+
policy = read_policy(root)
|
|
211
|
+
|
|
212
|
+
def get(name):
|
|
213
|
+
return setting(name, policy)
|
|
214
|
+
|
|
215
|
+
return SharedArrayStore(
|
|
216
|
+
root,
|
|
217
|
+
mode=mode,
|
|
218
|
+
attach=attach,
|
|
219
|
+
max_bytes=int(get("MAX_GB") * 2**30),
|
|
220
|
+
min_free_bytes=int(get("MIN_FREE_GB") * 2**30),
|
|
221
|
+
ttl_s=get("TTL_S"),
|
|
222
|
+
min_array_bytes=min_array_bytes,
|
|
223
|
+
max_array_bytes=get("MAX_ARRAY_BYTES"),
|
|
224
|
+
reap_interval_s=get("REAP_S"),
|
|
225
|
+
fsync=get("FSYNC"),
|
|
226
|
+
verify_republish=get("VERIFY_REPUBLISH"),
|
|
227
|
+
)
|