modelrack 0.5.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.
- modelrack/__about__.py +3 -0
- modelrack/__init__.py +169 -0
- modelrack/cache.py +309 -0
- modelrack/errors.py +213 -0
- modelrack/events.py +299 -0
- modelrack/provider.py +468 -0
- modelrack/providers/__init__.py +13 -0
- modelrack/providers/_fake_errors.py +173 -0
- modelrack/providers/_fake_generation.py +423 -0
- modelrack/providers/_fake_script.py +764 -0
- modelrack/providers/_http.py +341 -0
- modelrack/providers/_ollama_wire.py +471 -0
- modelrack/providers/fake.py +1180 -0
- modelrack/providers/ollama.py +1417 -0
- modelrack/providers/openai_compatible.py +1351 -0
- modelrack/py.typed +0 -0
- modelrack/residency.py +215 -0
- modelrack/streaming.py +264 -0
- modelrack/testing.py +57 -0
- modelrack/types.py +636 -0
- modelrack-0.5.0.dist-info/METADATA +321 -0
- modelrack-0.5.0.dist-info/RECORD +24 -0
- modelrack-0.5.0.dist-info/WHEEL +4 -0
- modelrack-0.5.0.dist-info/licenses/LICENSE +201 -0
modelrack/__about__.py
ADDED
modelrack/__init__.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""modelrack — the suite's only model client.
|
|
2
|
+
|
|
3
|
+
Layer 3: a capability package over :mod:`baseaicore`'s vocabulary and ``httpx``. One
|
|
4
|
+
implementation of "talk to a local inference runtime", normalized into provider-neutral types, so
|
|
5
|
+
that FreeWeight, LoadCoach and IdeaPress never contain provider HTTP code, never parse provider
|
|
6
|
+
JSON, and never disagree about what a token count or a timing means
|
|
7
|
+
([spec §1](../../docs/packages/modelrack/spec.md)).
|
|
8
|
+
|
|
9
|
+
What is exported below is the public API as of Phase 5
|
|
10
|
+
(``docs/packages/modelrack/development-plan.md``): the provider-neutral request and result
|
|
11
|
+
vocabulary, the streamed-event union with its cancellation token, the ``Provider`` protocol and the
|
|
12
|
+
types describing what a provider is, the full error hierarchy, and the three operational modules
|
|
13
|
+
Phase 5 added — the residency vocabulary (:mod:`modelrack.residency`), the one metadata cache this
|
|
14
|
+
package is allowed to have (:mod:`modelrack.cache`), and the optional observability hook
|
|
15
|
+
(:mod:`modelrack.events`).
|
|
16
|
+
|
|
17
|
+
The first adapter that ships is the **fake** one, deliberately
|
|
18
|
+
(ADR-0007 rule 6): ``FakeProvider`` is imported from
|
|
19
|
+
``modelrack.testing``, not from here, so that the rest of the suite can be developed and tested
|
|
20
|
+
without a GPU, a model or a running runtime, while a test double stays one import away from the
|
|
21
|
+
production namespace rather than inside it. The first *real* adapter,
|
|
22
|
+
:class:`~modelrack.providers.ollama.OllamaProvider`, is imported from
|
|
23
|
+
``modelrack.providers.ollama`` for a related but distinct reason: it is the one place in this
|
|
24
|
+
package that imports ``httpx``, and a process that only ever talks to the fake — most of this
|
|
25
|
+
suite's own test runs — has no reason to pay for that import. The second real adapter,
|
|
26
|
+
:class:`~modelrack.providers.openai_compatible.OpenAICompatibleProvider`, is imported from
|
|
27
|
+
``modelrack.providers.openai_compatible`` for the same reason, and exists to prove the vocabulary
|
|
28
|
+
below is not secretly shaped around Ollama: nothing in this module changed to support it.
|
|
29
|
+
|
|
30
|
+
Anything not listed in ``__all__`` is private and may change without a version bump.
|
|
31
|
+
|
|
32
|
+
>>> from baseaicore import ModelIdentity, ProviderKind
|
|
33
|
+
>>> from modelrack import GenerationRequest, Message, Role
|
|
34
|
+
>>> request = GenerationRequest(
|
|
35
|
+
... identity=ModelIdentity(ProviderKind.OLLAMA, "qwen3.5:9b-q8_0"),
|
|
36
|
+
... messages=(Message(role=Role.USER, content="Explain KV caching."),),
|
|
37
|
+
... )
|
|
38
|
+
>>> request.timeout_seconds is None # the adapter's default, never "no timeout"
|
|
39
|
+
True
|
|
40
|
+
|
|
41
|
+
Two invariants run through every type here. An unavailable measurement is ``UNSUPPORTED``, never
|
|
42
|
+
``0`` (ADR-0016); and what a provider *reported*
|
|
43
|
+
about its own work is never merged with what this process *observed*, which is why
|
|
44
|
+
:class:`Timing` prefixes every field and offers no combined duration.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
from modelrack.__about__ import __version__
|
|
50
|
+
from modelrack.cache import (
|
|
51
|
+
DEFAULT_METADATA_TTL_SECONDS,
|
|
52
|
+
CacheStats,
|
|
53
|
+
MetadataCache,
|
|
54
|
+
MetadataSnapshot,
|
|
55
|
+
)
|
|
56
|
+
from modelrack.errors import (
|
|
57
|
+
CapabilityUnsupported,
|
|
58
|
+
ContextLimitExceeded,
|
|
59
|
+
GenerationCancelled,
|
|
60
|
+
ModelNotFound,
|
|
61
|
+
ProviderError,
|
|
62
|
+
ProviderProtocolError,
|
|
63
|
+
ProviderRejected,
|
|
64
|
+
ProviderTimeout,
|
|
65
|
+
ProviderUnavailable,
|
|
66
|
+
ProviderUnavailableReason,
|
|
67
|
+
)
|
|
68
|
+
from modelrack.events import (
|
|
69
|
+
EventCallback,
|
|
70
|
+
ProviderEvent,
|
|
71
|
+
ProviderEventKind,
|
|
72
|
+
)
|
|
73
|
+
from modelrack.provider import (
|
|
74
|
+
LoadResult,
|
|
75
|
+
Provider,
|
|
76
|
+
ProviderCapabilities,
|
|
77
|
+
ProviderHealth,
|
|
78
|
+
ProviderStatus,
|
|
79
|
+
ResidentModel,
|
|
80
|
+
refuse_capability,
|
|
81
|
+
require_capability,
|
|
82
|
+
)
|
|
83
|
+
from modelrack.residency import (
|
|
84
|
+
FORCE_UNLOAD,
|
|
85
|
+
RESIDENCY_QUERY,
|
|
86
|
+
ResidencySupport,
|
|
87
|
+
find_resident,
|
|
88
|
+
is_resident,
|
|
89
|
+
residency_support,
|
|
90
|
+
)
|
|
91
|
+
from modelrack.streaming import (
|
|
92
|
+
CancellationToken,
|
|
93
|
+
StreamCompleted,
|
|
94
|
+
StreamEvent,
|
|
95
|
+
StreamFailed,
|
|
96
|
+
ThinkingDelta,
|
|
97
|
+
TokenDelta,
|
|
98
|
+
ToolCallDelta,
|
|
99
|
+
)
|
|
100
|
+
from modelrack.types import (
|
|
101
|
+
FinishReason,
|
|
102
|
+
GenerationRequest,
|
|
103
|
+
GenerationResult,
|
|
104
|
+
GenerationUsage,
|
|
105
|
+
Message,
|
|
106
|
+
ResponseFormat,
|
|
107
|
+
ResponseFormatKind,
|
|
108
|
+
Role,
|
|
109
|
+
SamplingParameters,
|
|
110
|
+
Timing,
|
|
111
|
+
TokenUsage,
|
|
112
|
+
ToolCall,
|
|
113
|
+
ToolDefinition,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"DEFAULT_METADATA_TTL_SECONDS",
|
|
118
|
+
"FORCE_UNLOAD",
|
|
119
|
+
"RESIDENCY_QUERY",
|
|
120
|
+
"CacheStats",
|
|
121
|
+
"CancellationToken",
|
|
122
|
+
"CapabilityUnsupported",
|
|
123
|
+
"ContextLimitExceeded",
|
|
124
|
+
"FinishReason",
|
|
125
|
+
"GenerationCancelled",
|
|
126
|
+
"GenerationRequest",
|
|
127
|
+
"GenerationResult",
|
|
128
|
+
"GenerationUsage",
|
|
129
|
+
"LoadResult",
|
|
130
|
+
"Message",
|
|
131
|
+
"ModelNotFound",
|
|
132
|
+
"Provider",
|
|
133
|
+
"ProviderCapabilities",
|
|
134
|
+
"ProviderError",
|
|
135
|
+
"ProviderHealth",
|
|
136
|
+
"ProviderProtocolError",
|
|
137
|
+
"ProviderRejected",
|
|
138
|
+
"ProviderStatus",
|
|
139
|
+
"ProviderTimeout",
|
|
140
|
+
"ProviderUnavailable",
|
|
141
|
+
"ProviderUnavailableReason",
|
|
142
|
+
"ResidentModel",
|
|
143
|
+
"ResponseFormat",
|
|
144
|
+
"ResponseFormatKind",
|
|
145
|
+
"Role",
|
|
146
|
+
"SamplingParameters",
|
|
147
|
+
"StreamCompleted",
|
|
148
|
+
"StreamEvent",
|
|
149
|
+
"StreamFailed",
|
|
150
|
+
"ThinkingDelta",
|
|
151
|
+
"Timing",
|
|
152
|
+
"TokenDelta",
|
|
153
|
+
"TokenUsage",
|
|
154
|
+
"ToolCall",
|
|
155
|
+
"ToolCallDelta",
|
|
156
|
+
"ToolDefinition",
|
|
157
|
+
"EventCallback",
|
|
158
|
+
"MetadataCache",
|
|
159
|
+
"MetadataSnapshot",
|
|
160
|
+
"ProviderEvent",
|
|
161
|
+
"ProviderEventKind",
|
|
162
|
+
"ResidencySupport",
|
|
163
|
+
"find_resident",
|
|
164
|
+
"is_resident",
|
|
165
|
+
"refuse_capability",
|
|
166
|
+
"require_capability",
|
|
167
|
+
"residency_support",
|
|
168
|
+
"__version__",
|
|
169
|
+
]
|
modelrack/cache.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Domain module — the one cache this package is allowed to have.
|
|
2
|
+
|
|
3
|
+
Imports :mod:`baseaicore` and the standard library; performs no I/O.
|
|
4
|
+
[Spec §3](../../docs/packages/modelrack/spec.md) forbids caching in general and then carves out
|
|
5
|
+
exactly one exception: *no caching beyond a documented in-memory metadata cache with an explicit
|
|
6
|
+
TTL and a* ``clear()``. Spec §10 adds that it is "inspectable and clearable" and "never survives
|
|
7
|
+
the process". This module is that carve-out and nothing more.
|
|
8
|
+
|
|
9
|
+
**Metadata only — never a generation.** A model's descriptor is a fact about what the provider is
|
|
10
|
+
serving, and re-deriving it costs a ``/api/show`` round trip per model (spec §15 budgets a cold
|
|
11
|
+
20-model discovery at seconds for exactly that reason). A *generation* is not a fact about
|
|
12
|
+
anything; two identical requests to the same model are two different runs, and a cache that
|
|
13
|
+
returned the first result for the second would fabricate a measurement FreeWeight would then
|
|
14
|
+
record as real. Nothing in this package puts a :class:`~modelrack.types.GenerationResult` in here,
|
|
15
|
+
and a test asserts it.
|
|
16
|
+
|
|
17
|
+
**Why a monotonic clock.** A TTL measured against the wall clock is extended or expired by an NTP
|
|
18
|
+
correction or a DST-adjacent system-time change — the entry would outlive its own expiry through
|
|
19
|
+
no fault of the caller's. :func:`baseaicore.monotonic_ns` cannot go backwards, so an entry expires
|
|
20
|
+
after the time that actually passed. The clock is injected for the same reason every clock in this
|
|
21
|
+
suite is: a TTL test that has to sleep for 300 seconds is a test nobody runs.
|
|
22
|
+
|
|
23
|
+
**Why the TTL is not the whole answer.** A tag such as ``qwen3.5:latest`` can be repointed at any
|
|
24
|
+
moment, so a cached descriptor's digest can be stale the instant after it is stored — the
|
|
25
|
+
development plan names this as Phase 5's likely failure mode. The TTL bounds how long that can go
|
|
26
|
+
unnoticed; the ``refresh=True`` argument on every adapter method that reads metadata is what lets
|
|
27
|
+
a caller who *knows* a model was re-pulled bypass it immediately. Both exist because neither alone
|
|
28
|
+
is enough.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import threading
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import TYPE_CHECKING, Final
|
|
36
|
+
|
|
37
|
+
from baseaicore import ValidationError, monotonic_ns
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from collections.abc import Callable, Mapping
|
|
41
|
+
from datetime import datetime
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"DEFAULT_METADATA_TTL_SECONDS",
|
|
46
|
+
"CacheStats",
|
|
47
|
+
"MetadataCache",
|
|
48
|
+
"MetadataSnapshot",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
DEFAULT_METADATA_TTL_SECONDS: Final[float] = 300.0
|
|
52
|
+
"""Spec §10's default: five minutes."""
|
|
53
|
+
|
|
54
|
+
_NANOS_PER_SECOND: Final[int] = 1_000_000_000
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class CacheStats:
|
|
59
|
+
"""What a cache has done since it was created or last cleared.
|
|
60
|
+
|
|
61
|
+
Spec §10 requires the cache to be *inspectable*, and the development plan requires "cache-hit
|
|
62
|
+
reporting" — this is that report. Counters are cumulative and are reset by
|
|
63
|
+
:meth:`MetadataCache.clear`, so a caller measuring one discovery pass clears first and reads
|
|
64
|
+
after.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
hits: Reads that found a live entry.
|
|
68
|
+
misses: Reads that found nothing — never stored, or already dropped.
|
|
69
|
+
expirations: Reads that found an entry whose TTL had passed. Counted **in addition to**
|
|
70
|
+
the miss they also produce: a cache whose misses are all expirations is a cache whose
|
|
71
|
+
TTL is too short, and one whose misses are never expirations is being asked for keys
|
|
72
|
+
it was never given. Those are opposite problems and a single counter cannot tell them
|
|
73
|
+
apart.
|
|
74
|
+
stores: Values written.
|
|
75
|
+
entries: How many live-or-expired entries are held right now. Not a rate — a size.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
hits: int = 0
|
|
79
|
+
misses: int = 0
|
|
80
|
+
expirations: int = 0
|
|
81
|
+
stores: int = 0
|
|
82
|
+
entries: int = 0
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def lookups(self) -> int:
|
|
86
|
+
"""Total reads, hits and misses together.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
``hits + misses``. Offered because the hit *rate* is what a caller actually wants and
|
|
90
|
+
deriving it from two fields invites the off-by-one of forgetting expirations are
|
|
91
|
+
already counted inside ``misses``.
|
|
92
|
+
"""
|
|
93
|
+
return self.hits + self.misses
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class MetadataSnapshot:
|
|
98
|
+
"""One provider payload together with the instant it was actually read.
|
|
99
|
+
|
|
100
|
+
The pair exists because caching the payload alone would falsify
|
|
101
|
+
:attr:`~baseaicore.ModelDescriptor.observed_at`, whose documented meaning is *when this
|
|
102
|
+
snapshot was read from the provider*. An adapter that served a five-minute-old ``show`` body
|
|
103
|
+
and stamped it with the current clock would report a reading that never happened at that
|
|
104
|
+
instant — the same class of error as reporting an unmeasured value as ``0``
|
|
105
|
+
(ADR-0016), and one that would quietly
|
|
106
|
+
corrupt FreeWeight's freshness accounting.
|
|
107
|
+
|
|
108
|
+
Attributes:
|
|
109
|
+
observed_at: When the provider answered. Timezone-aware, UTC — it comes from the
|
|
110
|
+
adapter's injected clock, not from :func:`~baseaicore.monotonic_ns`, because it is a
|
|
111
|
+
point on the calendar a caller will store and compare, not a duration.
|
|
112
|
+
payload: The provider's own JSON body, unmodified. ``Any``-valued because it is provider
|
|
113
|
+
JSON: this is the same untouched shape that reaches
|
|
114
|
+
:attr:`~baseaicore.ModelDescriptor.raw`, and narrowing it here would mean parsing it
|
|
115
|
+
here, which is the adapter's job.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
observed_at: datetime
|
|
119
|
+
payload: Mapping[str, Any]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class MetadataCache[ValueT]:
|
|
123
|
+
"""An in-memory, TTL-bounded store for provider metadata, and nothing else.
|
|
124
|
+
|
|
125
|
+
Generic over what it holds so one implementation serves both a list of descriptors and a
|
|
126
|
+
single ``show`` payload without either being widened to ``Any`` at a boundary the coding
|
|
127
|
+
standards forbid one at.
|
|
128
|
+
|
|
129
|
+
Thread-safe. An adapter is a plain object a caller may share across threads — the suite's own
|
|
130
|
+
web layer dispatches synchronous provider work to a threadpool — and a dict mutated from two
|
|
131
|
+
threads mid-resize is the kind of failure that appears once a month in production and never in
|
|
132
|
+
a test. The lock is held only around the dictionary operations, never across a callback or an
|
|
133
|
+
HTTP call.
|
|
134
|
+
|
|
135
|
+
It is deliberately **not** a single-flight cache: two threads that miss the same key at the
|
|
136
|
+
same moment both fetch, and the second store wins. Holding the lock across the fetch would
|
|
137
|
+
serialize every caller behind one slow round trip and make an adapter's own timeout the whole
|
|
138
|
+
process's, which is a far worse failure than one duplicated ``/api/show``. What this cache
|
|
139
|
+
exists to remove is the *steady-state* cost of re-reading metadata, not a thundering herd.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
ttl_seconds: How long an entry stays live. ``0`` disables the cache entirely: every read
|
|
143
|
+
misses, every write is dropped, and the counters still tell the truth about what was
|
|
144
|
+
asked for — the honest way to spell "no caching" without a second code path in every
|
|
145
|
+
caller.
|
|
146
|
+
clock: Where "now" comes from, as a monotonic nanosecond reading. Injected so a TTL test
|
|
147
|
+
can advance time instead of sleeping through it (coding standards §5).
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ValidationError: If ``ttl_seconds`` is negative. A negative lifetime has no meaning, and
|
|
151
|
+
silently treating it as ``0`` would hide a caller's unit mistake — seconds passed
|
|
152
|
+
where a negative sentinel was intended is a bug worth surfacing at construction.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
__slots__ = (
|
|
156
|
+
"_clock",
|
|
157
|
+
"_entries",
|
|
158
|
+
"_expirations",
|
|
159
|
+
"_hits",
|
|
160
|
+
"_lock",
|
|
161
|
+
"_misses",
|
|
162
|
+
"_stores",
|
|
163
|
+
"_ttl_ns",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
ttl_seconds: float = DEFAULT_METADATA_TTL_SECONDS,
|
|
170
|
+
clock: Callable[[], int] = monotonic_ns,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Create an empty cache with the given lifetime."""
|
|
173
|
+
if ttl_seconds < 0:
|
|
174
|
+
raise ValidationError(
|
|
175
|
+
f"MetadataCache ttl_seconds must not be negative; got {ttl_seconds}. Pass 0 to "
|
|
176
|
+
"disable caching.",
|
|
177
|
+
details={"field": "ttl_seconds", "value": ttl_seconds},
|
|
178
|
+
)
|
|
179
|
+
self._ttl_ns = int(ttl_seconds * _NANOS_PER_SECOND)
|
|
180
|
+
self._clock = clock
|
|
181
|
+
self._lock = threading.Lock()
|
|
182
|
+
self._entries: dict[str, tuple[int, ValueT]] = {}
|
|
183
|
+
self._hits = 0
|
|
184
|
+
self._misses = 0
|
|
185
|
+
self._expirations = 0
|
|
186
|
+
self._stores = 0
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def ttl_seconds(self) -> float:
|
|
190
|
+
"""How long an entry stays live, in seconds.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
The lifetime this cache was constructed with. ``0.0`` when caching is disabled.
|
|
194
|
+
"""
|
|
195
|
+
return self._ttl_ns / _NANOS_PER_SECOND
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def is_enabled(self) -> bool:
|
|
199
|
+
"""Whether this cache stores anything at all.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
``False`` when the TTL is ``0``, in which case every :meth:`get` misses and every
|
|
203
|
+
:meth:`put` is dropped.
|
|
204
|
+
"""
|
|
205
|
+
return self._ttl_ns > 0
|
|
206
|
+
|
|
207
|
+
def get(self, key: str) -> ValueT | None:
|
|
208
|
+
"""Return the live value stored under ``key``, or ``None``.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
key: What the value was stored under.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
The value if it is present and its TTL has not passed; ``None`` otherwise. An expired
|
|
215
|
+
entry is dropped on the way out rather than left to accumulate, so a long-lived
|
|
216
|
+
adapter that asks for one model repeatedly does not grow a graveyard of the others.
|
|
217
|
+
|
|
218
|
+
Note:
|
|
219
|
+
``None`` is the miss signal, which means this cache cannot hold ``None`` as a *value*.
|
|
220
|
+
That is deliberate rather than an oversight: every value it exists to hold is a
|
|
221
|
+
descriptor or a payload, and "the provider has no metadata for this model" is a
|
|
222
|
+
:class:`~modelrack.errors.ModelNotFound`, not a cacheable answer.
|
|
223
|
+
"""
|
|
224
|
+
with self._lock:
|
|
225
|
+
entry = self._entries.get(key)
|
|
226
|
+
if entry is None:
|
|
227
|
+
self._misses += 1
|
|
228
|
+
return None
|
|
229
|
+
stored_ns, value = entry
|
|
230
|
+
if self._clock() - stored_ns >= self._ttl_ns:
|
|
231
|
+
del self._entries[key]
|
|
232
|
+
self._expirations += 1
|
|
233
|
+
self._misses += 1
|
|
234
|
+
return None
|
|
235
|
+
self._hits += 1
|
|
236
|
+
return value
|
|
237
|
+
|
|
238
|
+
def put(self, key: str, value: ValueT) -> None:
|
|
239
|
+
"""Store ``value`` under ``key``, starting its TTL now.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
key: What to store it under. An existing entry is replaced and its lifetime restarts —
|
|
243
|
+
a re-read of metadata is fresher than what it replaces, and keeping the older
|
|
244
|
+
entry's expiry would discard that freshness for no reason.
|
|
245
|
+
value: What to store. Dropped without comment when the cache is disabled.
|
|
246
|
+
"""
|
|
247
|
+
if self._ttl_ns <= 0:
|
|
248
|
+
return
|
|
249
|
+
with self._lock:
|
|
250
|
+
self._entries[key] = (self._clock(), value)
|
|
251
|
+
self._stores += 1
|
|
252
|
+
|
|
253
|
+
def invalidate(self, key: str) -> bool:
|
|
254
|
+
"""Drop one entry.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
key: The entry to drop.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
``True`` if an entry was there — live or expired — and has been removed. Used by an
|
|
261
|
+
adapter that has just learned one model's metadata changed and has no reason to
|
|
262
|
+
discard the other nineteen.
|
|
263
|
+
"""
|
|
264
|
+
with self._lock:
|
|
265
|
+
return self._entries.pop(key, None) is not None
|
|
266
|
+
|
|
267
|
+
def clear(self) -> None:
|
|
268
|
+
"""Drop every entry and reset the counters.
|
|
269
|
+
|
|
270
|
+
Spec §10's required escape hatch: a caller who has re-pulled a model, or who simply does
|
|
271
|
+
not trust what is held, gets a guaranteed-cold next read. Counters reset with the
|
|
272
|
+
contents, because a hit rate that spans a clear describes two different caches.
|
|
273
|
+
"""
|
|
274
|
+
with self._lock:
|
|
275
|
+
self._entries.clear()
|
|
276
|
+
self._hits = 0
|
|
277
|
+
self._misses = 0
|
|
278
|
+
self._expirations = 0
|
|
279
|
+
self._stores = 0
|
|
280
|
+
|
|
281
|
+
def stats(self) -> CacheStats:
|
|
282
|
+
"""Report what this cache has done.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
A snapshot. Taken under the lock so the counters are mutually consistent — a report
|
|
286
|
+
whose ``hits`` and ``entries`` came from either side of a concurrent write would
|
|
287
|
+
describe a state the cache was never in.
|
|
288
|
+
"""
|
|
289
|
+
with self._lock:
|
|
290
|
+
return CacheStats(
|
|
291
|
+
hits=self._hits,
|
|
292
|
+
misses=self._misses,
|
|
293
|
+
expirations=self._expirations,
|
|
294
|
+
stores=self._stores,
|
|
295
|
+
entries=len(self._entries),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def __len__(self) -> int:
|
|
299
|
+
"""Return how many entries are held, live or expired.
|
|
300
|
+
|
|
301
|
+
Counts expired-but-not-yet-read entries too: they occupy memory until something asks for
|
|
302
|
+
them, and a length that pretended otherwise would understate what the process is holding.
|
|
303
|
+
"""
|
|
304
|
+
with self._lock:
|
|
305
|
+
return len(self._entries)
|
|
306
|
+
|
|
307
|
+
def __repr__(self) -> str:
|
|
308
|
+
"""Return a representation naming the TTL and the size, for a debugger session."""
|
|
309
|
+
return f"MetadataCache(ttl_seconds={self.ttl_seconds:g}, entries={len(self)})"
|