sg-query-cache 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.
- sg_query_cache/__init__.py +39 -0
- sg_query_cache/__main__.py +8 -0
- sg_query_cache/_async_backend.py +101 -0
- sg_query_cache/_async_cache.py +1240 -0
- sg_query_cache/_async_client.py +1390 -0
- sg_query_cache/_async_local.py +318 -0
- sg_query_cache/_async_support.py +314 -0
- sg_query_cache/_async_valkey_control.py +580 -0
- sg_query_cache/_backend.py +168 -0
- sg_query_cache/_backend_validation.py +110 -0
- sg_query_cache/_cache.py +1608 -0
- sg_query_cache/_cache_common.py +387 -0
- sg_query_cache/_cli.py +313 -0
- sg_query_cache/_client.py +1061 -0
- sg_query_cache/_client_common.py +600 -0
- sg_query_cache/_codec.py +345 -0
- sg_query_cache/_config.py +1217 -0
- sg_query_cache/_dependencies.py +888 -0
- sg_query_cache/_downstream.py +141 -0
- sg_query_cache/_event_mapping.py +409 -0
- sg_query_cache/_keys.py +298 -0
- sg_query_cache/_local.py +301 -0
- sg_query_cache/_local_store.py +604 -0
- sg_query_cache/_metrics.py +200 -0
- sg_query_cache/_model.py +546 -0
- sg_query_cache/_policy.py +378 -0
- sg_query_cache/_query.py +53 -0
- sg_query_cache/_urls.py +324 -0
- sg_query_cache/_validation.py +70 -0
- sg_query_cache/_valkey_common.py +1037 -0
- sg_query_cache/_valkey_control.py +437 -0
- sg_query_cache/_valkey_proof.py +64 -0
- sg_query_cache/_valkey_scripts.py +454 -0
- sg_query_cache/daemon_plugin.py +159 -0
- sg_query_cache/py.typed +0 -0
- sg_query_cache/valkey.py +1533 -0
- sg_query_cache/valkey_async.py +1568 -0
- sg_query_cache-0.1.0.dist-info/METADATA +247 -0
- sg_query_cache-0.1.0.dist-info/RECORD +42 -0
- sg_query_cache-0.1.0.dist-info/WHEEL +4 -0
- sg_query_cache-0.1.0.dist-info/entry_points.txt +2 -0
- sg_query_cache-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1240 @@
|
|
|
1
|
+
# Same-file collaborators use private implementation members.
|
|
2
|
+
# pyright: reportPrivateUsage=false
|
|
3
|
+
|
|
4
|
+
"""Compose native asynchronous reads, parsed memo reuse, fences, and terminal close."""
|
|
5
|
+
|
|
6
|
+
from asyncio import (
|
|
7
|
+
CancelledError,
|
|
8
|
+
Future,
|
|
9
|
+
Task,
|
|
10
|
+
create_task,
|
|
11
|
+
get_running_loop,
|
|
12
|
+
shield,
|
|
13
|
+
sleep,
|
|
14
|
+
timeout_at,
|
|
15
|
+
)
|
|
16
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Mapping
|
|
17
|
+
from contextlib import asynccontextmanager, contextmanager, suppress
|
|
18
|
+
from contextvars import ContextVar
|
|
19
|
+
from dataclasses import dataclass, field, replace
|
|
20
|
+
from inspect import iscoroutinefunction
|
|
21
|
+
from time import monotonic
|
|
22
|
+
from types import TracebackType
|
|
23
|
+
from typing import Literal, Self, TypeAlias, TypeVar
|
|
24
|
+
|
|
25
|
+
from sg_query_cache._async_backend import AsyncCacheBackend
|
|
26
|
+
from sg_query_cache._async_support import (
|
|
27
|
+
Admission,
|
|
28
|
+
CleanupOutcome,
|
|
29
|
+
OperationToken,
|
|
30
|
+
enter_cleanup,
|
|
31
|
+
join_shared_close,
|
|
32
|
+
joined_cleanup,
|
|
33
|
+
)
|
|
34
|
+
from sg_query_cache._backend import (
|
|
35
|
+
BackendCapacity,
|
|
36
|
+
BackendProtocolError,
|
|
37
|
+
BackendUnavailable,
|
|
38
|
+
StaleIncarnation,
|
|
39
|
+
)
|
|
40
|
+
from sg_query_cache._cache import Parse, QueryCache
|
|
41
|
+
from sg_query_cache._cache_common import (
|
|
42
|
+
PolicyFrame,
|
|
43
|
+
PolicyMode,
|
|
44
|
+
PopulationCallback,
|
|
45
|
+
StaleHitError,
|
|
46
|
+
TimedResult,
|
|
47
|
+
WaitCategory,
|
|
48
|
+
WaitOutcome,
|
|
49
|
+
WaitRecord,
|
|
50
|
+
apply_policy_frames,
|
|
51
|
+
bound_reason,
|
|
52
|
+
decode_hit,
|
|
53
|
+
encode_result,
|
|
54
|
+
merge_changes,
|
|
55
|
+
minimum_policy_age,
|
|
56
|
+
policy_bypass_active,
|
|
57
|
+
policy_frame,
|
|
58
|
+
population_limit,
|
|
59
|
+
)
|
|
60
|
+
from sg_query_cache._codec import CodecBoundError, CodecError
|
|
61
|
+
from sg_query_cache._config import CacheLayer, CacheSettings, TTLContext, select_maximum_age
|
|
62
|
+
from sg_query_cache._keys import KeyBuilder, NormalizationError
|
|
63
|
+
from sg_query_cache._metrics import MetricsSink, NullMetrics, SafeMetrics
|
|
64
|
+
from sg_query_cache._model import (
|
|
65
|
+
DependencyPlan,
|
|
66
|
+
ExactCall,
|
|
67
|
+
FillLease,
|
|
68
|
+
GenerationChange,
|
|
69
|
+
Identity,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_T = TypeVar('_T')
|
|
73
|
+
|
|
74
|
+
_Fetch: TypeAlias = Callable[[], Awaitable[object]]
|
|
75
|
+
_Layer: TypeAlias = Literal['l1', 'l2']
|
|
76
|
+
|
|
77
|
+
_BACKEND_ERRORS = (BackendCapacity, BackendProtocolError, BackendUnavailable, StaleIncarnation)
|
|
78
|
+
_METHODS = ('change', 'close', 'commit', 'flush_site', 'lookup', 'release', 'reserve', 'resolve')
|
|
79
|
+
_MISS = object()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _CachePathError(Exception):
|
|
83
|
+
"""Redirect one failed cache path to caller-owned SG I/O."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, reason: str) -> None:
|
|
86
|
+
"""Retain one safe metric reason."""
|
|
87
|
+
super().__init__(reason)
|
|
88
|
+
self._reason = reason
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True)
|
|
92
|
+
class _CloseState:
|
|
93
|
+
"""Retain backend obligations and one outcome through shared-task interruption."""
|
|
94
|
+
|
|
95
|
+
_pending: list[AsyncCacheBackend]
|
|
96
|
+
_completion: Future[CleanupOutcome[None]]
|
|
97
|
+
_settling: bool = False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class _FenceState:
|
|
102
|
+
"""Pair retained work with the revision that owns its acknowledgment."""
|
|
103
|
+
|
|
104
|
+
_retained: tuple[GenerationChange, ...] = ()
|
|
105
|
+
_revision: int = 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class _Obligation:
|
|
110
|
+
"""Track one fence layer until acknowledged or synchronously retained."""
|
|
111
|
+
|
|
112
|
+
_layer: _Layer
|
|
113
|
+
_backend: AsyncCacheBackend
|
|
114
|
+
_changes: tuple[GenerationChange, ...]
|
|
115
|
+
_settled: bool = False
|
|
116
|
+
_acknowledged: bool = False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class _ReadInput:
|
|
121
|
+
"""Keep the immutable inputs shared by one read's asynchronous phases."""
|
|
122
|
+
|
|
123
|
+
_base_key: str
|
|
124
|
+
_call: ExactCall
|
|
125
|
+
_plan: DependencyPlan
|
|
126
|
+
_fetch: _Fetch
|
|
127
|
+
_age: float | None
|
|
128
|
+
_population: PopulationCallback | None = None
|
|
129
|
+
_waits: WaitRecord = field(default_factory=WaitRecord, compare=False)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True)
|
|
133
|
+
class _Resolved:
|
|
134
|
+
"""Retain the original qualified key and its local fence snapshot."""
|
|
135
|
+
|
|
136
|
+
_backend: AsyncCacheBackend
|
|
137
|
+
_layer: _Layer
|
|
138
|
+
_key: str
|
|
139
|
+
_fence: _FenceState
|
|
140
|
+
_age: float
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class _Outcome:
|
|
145
|
+
"""Carry a value with its original age and layer validity for promotion and memo."""
|
|
146
|
+
|
|
147
|
+
_value: object
|
|
148
|
+
_first: _Resolved
|
|
149
|
+
_shared: _Resolved | None
|
|
150
|
+
_origin_at: float
|
|
151
|
+
_observed_at: float
|
|
152
|
+
_first_deadline: float
|
|
153
|
+
_deep_deadline: float
|
|
154
|
+
_sg_duration: float
|
|
155
|
+
_stored: bool
|
|
156
|
+
_from_cache: bool = False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class _MemoEntry:
|
|
161
|
+
"""Retain exactly one immutable parsed result and its acquisition deadlines."""
|
|
162
|
+
|
|
163
|
+
_outcome: _Outcome
|
|
164
|
+
_parse: Parse
|
|
165
|
+
_value: object
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class AsyncQueryCache:
|
|
169
|
+
"""Cache exact SG reads on one event loop with terminal resource ownership.
|
|
170
|
+
|
|
171
|
+
Construction is loop-independent; the first operation binds the instance.
|
|
172
|
+
Close joins admitted operations before closing all backends. Start close
|
|
173
|
+
outside their dependency chains; only direct owner self-close is detected.
|
|
174
|
+
The async context manager closes this cache and preserves a body exception
|
|
175
|
+
ahead of ordinary cleanup failure, with caller cancellation taking
|
|
176
|
+
precedence.
|
|
177
|
+
|
|
178
|
+
Backend failures on read paths fall back to the caller's fetch, whose
|
|
179
|
+
failures propagate. The cache never owns or closes the fetch callback's SG
|
|
180
|
+
client.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__( # noqa: PLR0913, PLR0917 - required public topology interface
|
|
184
|
+
self,
|
|
185
|
+
settings: CacheSettings,
|
|
186
|
+
identity: Identity,
|
|
187
|
+
key_builder: KeyBuilder | None = None,
|
|
188
|
+
l1: AsyncCacheBackend | None = None,
|
|
189
|
+
l2: AsyncCacheBackend | None = None,
|
|
190
|
+
metrics: MetricsSink | None = None,
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Validate topology and retain the supplied backend resources.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
settings: Immutable policy and operational bounds.
|
|
196
|
+
identity: Exact effective SG identity namespace.
|
|
197
|
+
key_builder: Optional standard-compatible exact key builder.
|
|
198
|
+
l1: Local backend when enabled by settings.
|
|
199
|
+
l2: Shared backend when enabled by settings, distinct from L1.
|
|
200
|
+
metrics: Optional synchronous, prompt metrics destination.
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
ValueError: Wiring disagrees with settings or a backend method is
|
|
204
|
+
not a coroutine function; the error names that method.
|
|
205
|
+
"""
|
|
206
|
+
if (l1 is not None, l2 is not None) != (settings.l1.enabled, settings.l2.enabled) or (
|
|
207
|
+
l1 is not None and l1 is l2
|
|
208
|
+
):
|
|
209
|
+
raise ValueError('backend wiring does not match a supported cache topology')
|
|
210
|
+
for backend in (l1, l2):
|
|
211
|
+
if backend is not None:
|
|
212
|
+
for method in _METHODS:
|
|
213
|
+
if not iscoroutinefunction(getattr(backend, method, None)):
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f'async backend method {method} must be a coroutine function'
|
|
216
|
+
)
|
|
217
|
+
self._admission = Admission()
|
|
218
|
+
self._close_task: Task[CleanupOutcome[None]] | None = None
|
|
219
|
+
self._close_state: _CloseState | None = None
|
|
220
|
+
self._fences = {'l1': _FenceState(), 'l2': _FenceState()}
|
|
221
|
+
self._flights: dict[str, Future[None]] = {}
|
|
222
|
+
self._identity = identity
|
|
223
|
+
self._key_builder = KeyBuilder() if key_builder is None else key_builder
|
|
224
|
+
self._l1 = l1
|
|
225
|
+
self._l2 = l2
|
|
226
|
+
self._memo: _MemoEntry | None = None
|
|
227
|
+
self._metrics = NullMetrics() if metrics is None else metrics
|
|
228
|
+
self._correlations: ContextVar[tuple[str | None, ...]] = ContextVar(
|
|
229
|
+
'correlations', default=()
|
|
230
|
+
)
|
|
231
|
+
self._policies: ContextVar[tuple[PolicyFrame, ...]] = ContextVar('policies', default=())
|
|
232
|
+
self._settings = settings
|
|
233
|
+
|
|
234
|
+
def _deadline(self) -> float:
|
|
235
|
+
"""Anchor one cooperative operation budget on the bound running loop."""
|
|
236
|
+
return get_running_loop().time() + self._settings.limits.operation_timeout_seconds
|
|
237
|
+
|
|
238
|
+
async def _close_backend(self, backend: AsyncCacheBackend) -> CleanupOutcome[None]:
|
|
239
|
+
"""Join one backend under its deadline, retaining any unstarted obligation."""
|
|
240
|
+
return await enter_cleanup(backend.close, deadline=self._deadline())
|
|
241
|
+
|
|
242
|
+
def _finish_flight(self, key: str, flight: Future[None]) -> None:
|
|
243
|
+
"""Retire the exact leader and publish notification without sharing a value."""
|
|
244
|
+
if self._flights.get(key) is flight:
|
|
245
|
+
del self._flights[key]
|
|
246
|
+
flight.set_result(None)
|
|
247
|
+
|
|
248
|
+
def _increment(self, name: str, labels: Mapping[str, str]) -> None:
|
|
249
|
+
"""Emit an isolated counter after any associated state decision."""
|
|
250
|
+
correlated = dict(labels)
|
|
251
|
+
stack = self._correlations.get()
|
|
252
|
+
if stack and stack[-1] is not None:
|
|
253
|
+
correlated['exact_call_digest'] = stack[-1]
|
|
254
|
+
with suppress(Exception):
|
|
255
|
+
self._metrics.increment(name, correlated)
|
|
256
|
+
|
|
257
|
+
def _begin_wait(self, waits: WaitRecord, category: WaitCategory) -> float:
|
|
258
|
+
"""Count the category's first wait and return its loop-clock start."""
|
|
259
|
+
if waits.begin(category):
|
|
260
|
+
self._increment('cache_collapse', {'outcome': category})
|
|
261
|
+
return get_running_loop().time()
|
|
262
|
+
|
|
263
|
+
def _bypass(self, reason: str) -> None:
|
|
264
|
+
"""Record one safe cache bypass reason."""
|
|
265
|
+
self._increment('cache_bypass', {'reason': reason})
|
|
266
|
+
|
|
267
|
+
def _cleanup_error(self, error: Exception | None) -> Exception | None:
|
|
268
|
+
"""Record cleanup failure and translate cooperative deadline expiration."""
|
|
269
|
+
if error is None:
|
|
270
|
+
return None
|
|
271
|
+
reason = 'finalization_timeout' if isinstance(error, TimeoutError) else 'backend_failure'
|
|
272
|
+
self._increment('backend_failure', {'reason': reason})
|
|
273
|
+
if isinstance(error, TimeoutError):
|
|
274
|
+
return BackendUnavailable('async backend operation deadline expired')
|
|
275
|
+
return error
|
|
276
|
+
|
|
277
|
+
async def _backend_call(self, action: Callable[[], Awaitable[_T]]) -> _T:
|
|
278
|
+
"""Await one backend operation under its original cooperative deadline."""
|
|
279
|
+
try:
|
|
280
|
+
async with timeout_at(self._deadline()):
|
|
281
|
+
return await action()
|
|
282
|
+
except TimeoutError as error:
|
|
283
|
+
self._cleanup_error(error)
|
|
284
|
+
raise BackendUnavailable('async backend operation deadline expired') from error
|
|
285
|
+
|
|
286
|
+
async def _close_all(self, state: _CloseState) -> CleanupOutcome[None]:
|
|
287
|
+
"""Drain active owners, then join every backend cleanup and finish closed."""
|
|
288
|
+
await self._admission.wait_idle()
|
|
289
|
+
first_error: Exception | None = None
|
|
290
|
+
cancellation: CancelledError | None = None
|
|
291
|
+
while state._pending:
|
|
292
|
+
outcome = await self._close_backend(state._pending.pop(0))
|
|
293
|
+
cancellation = cancellation or outcome.cancellation
|
|
294
|
+
error = outcome.error
|
|
295
|
+
if outcome.cancellation is not None:
|
|
296
|
+
error = error or BackendUnavailable('cleanup task cancelled')
|
|
297
|
+
error = self._cleanup_error(error)
|
|
298
|
+
if first_error is None:
|
|
299
|
+
first_error = error
|
|
300
|
+
self._memo = None
|
|
301
|
+
self._flights.clear()
|
|
302
|
+
self._admission.finish_close()
|
|
303
|
+
return CleanupOutcome(error=first_error, cancellation=cancellation)
|
|
304
|
+
|
|
305
|
+
def _expire_wait(self, waits: WaitRecord, category: WaitCategory) -> None:
|
|
306
|
+
"""Count at most one expired budget per request and wait category."""
|
|
307
|
+
if waits.expire(category):
|
|
308
|
+
self._increment('cache_lease_timeout', {'outcome': category})
|
|
309
|
+
|
|
310
|
+
@asynccontextmanager
|
|
311
|
+
async def _held_lease(
|
|
312
|
+
self, backend: AsyncCacheBackend, lease: FillLease
|
|
313
|
+
) -> AsyncGenerator[None]:
|
|
314
|
+
"""Own release through repeated cancellation without replacing its first cause."""
|
|
315
|
+
original: CancelledError | None = None
|
|
316
|
+
try:
|
|
317
|
+
yield
|
|
318
|
+
except CancelledError as error:
|
|
319
|
+
original = error
|
|
320
|
+
raise
|
|
321
|
+
finally:
|
|
322
|
+
outcome = await joined_cleanup(
|
|
323
|
+
lambda: backend.release(lease), deadline=self._deadline()
|
|
324
|
+
)
|
|
325
|
+
self._cleanup_error(outcome.error)
|
|
326
|
+
if original is None and outcome.cancellation is not None:
|
|
327
|
+
raise outcome.cancellation
|
|
328
|
+
|
|
329
|
+
@contextmanager
|
|
330
|
+
def _correlation_scope(self, digest: str | None) -> Generator[None]:
|
|
331
|
+
"""Restore this instance's immutable correlation snapshot on scope exit."""
|
|
332
|
+
token = self._correlations.set((*self._correlations.get(), digest))
|
|
333
|
+
try:
|
|
334
|
+
yield
|
|
335
|
+
finally:
|
|
336
|
+
self._correlations.reset(token)
|
|
337
|
+
|
|
338
|
+
def _layer_name(self, layer: _Layer) -> CacheLayer:
|
|
339
|
+
"""Select the configured age and metric name for one backend layer."""
|
|
340
|
+
if layer == 'l2':
|
|
341
|
+
return 'l2'
|
|
342
|
+
return 'local_l1' if self._l2 is None else 'hierarchical_l1'
|
|
343
|
+
|
|
344
|
+
async def _direct_fetch(self, fetch: _Fetch) -> object:
|
|
345
|
+
"""Deliver pending cancellation before dispatching a bypassed SG operation."""
|
|
346
|
+
try:
|
|
347
|
+
await sleep(0)
|
|
348
|
+
return await fetch()
|
|
349
|
+
except CancelledError:
|
|
350
|
+
layer = self._layer_name('l1' if self._l1 is not None else 'l2')
|
|
351
|
+
self._increment('cache_fill', {'layer': layer, 'outcome': 'cancelled'})
|
|
352
|
+
raise
|
|
353
|
+
|
|
354
|
+
async def _fetch(self, request: _ReadInput, resolved: _Resolved) -> _Outcome:
|
|
355
|
+
"""Record a fill only after SG returns, preserving cancellation as its own outcome."""
|
|
356
|
+
layer = self._layer_name(resolved._layer)
|
|
357
|
+
started_at = monotonic()
|
|
358
|
+
try:
|
|
359
|
+
await sleep(0)
|
|
360
|
+
value = await request._fetch()
|
|
361
|
+
except CancelledError:
|
|
362
|
+
self._increment('cache_fill', {'layer': layer, 'outcome': 'cancelled'})
|
|
363
|
+
raise
|
|
364
|
+
fetched_at = monotonic()
|
|
365
|
+
outcome = _Outcome(
|
|
366
|
+
value,
|
|
367
|
+
resolved,
|
|
368
|
+
resolved if resolved._layer == 'l2' else None,
|
|
369
|
+
fetched_at,
|
|
370
|
+
fetched_at,
|
|
371
|
+
fetched_at + resolved._age,
|
|
372
|
+
fetched_at + resolved._age,
|
|
373
|
+
fetched_at - started_at,
|
|
374
|
+
_stored=False,
|
|
375
|
+
)
|
|
376
|
+
self._increment('cache_fill', {'layer': layer})
|
|
377
|
+
return outcome
|
|
378
|
+
|
|
379
|
+
def _observe(self, name: str, value: float, labels: Mapping[str, str]) -> None:
|
|
380
|
+
"""Emit an isolated observation after any associated state decision."""
|
|
381
|
+
correlated = dict(labels)
|
|
382
|
+
stack = self._correlations.get()
|
|
383
|
+
if stack and stack[-1] is not None:
|
|
384
|
+
correlated['exact_call_digest'] = stack[-1]
|
|
385
|
+
with suppress(Exception):
|
|
386
|
+
self._metrics.observe(name, value, correlated)
|
|
387
|
+
|
|
388
|
+
def _age(self, request: _ReadInput, layer: _Layer) -> float:
|
|
389
|
+
"""Select a layer's age through the shared policy owner."""
|
|
390
|
+
name = self._layer_name(layer)
|
|
391
|
+
context = TTLContext.from_call(request._call, self._identity, request._plan, layer=name)
|
|
392
|
+
age = select_maximum_age(self._settings, context, request._age)
|
|
393
|
+
self._observe(
|
|
394
|
+
'cache_ttl_selection',
|
|
395
|
+
age,
|
|
396
|
+
{'classification': request._plan.classification, 'layer': name},
|
|
397
|
+
)
|
|
398
|
+
return age
|
|
399
|
+
|
|
400
|
+
def _emit_waits(self, waits: WaitRecord) -> None:
|
|
401
|
+
"""Emit each category's accumulated duration and latest wait outcome."""
|
|
402
|
+
for seconds, outcome in waits.observations():
|
|
403
|
+
self._observe('cache_wait_seconds', seconds, {'outcome': outcome})
|
|
404
|
+
|
|
405
|
+
def _population_deadline(self, request: _ReadInput, outcome: _Outcome) -> float | None:
|
|
406
|
+
"""Apply the shared callback contract without changing the SG origin sample."""
|
|
407
|
+
if request._population is None:
|
|
408
|
+
return outcome._deep_deadline
|
|
409
|
+
limit = population_limit(outcome._value, request._population)
|
|
410
|
+
if limit.reason is not None:
|
|
411
|
+
self._observe('cache_url_age_cap', limit.seconds or 0.0, {'reason': limit.reason})
|
|
412
|
+
if limit.seconds is None:
|
|
413
|
+
self._bypass(limit.bypass_reason or 'population_decision_failure')
|
|
414
|
+
return None
|
|
415
|
+
return min(outcome._deep_deadline, outcome._origin_at + limit.seconds)
|
|
416
|
+
|
|
417
|
+
async def _reserve(self, resolved: _Resolved) -> FillLease | None:
|
|
418
|
+
"""Reserve a deepest-layer lease through the cache failure boundary."""
|
|
419
|
+
try:
|
|
420
|
+
return await self._backend_call(
|
|
421
|
+
lambda: resolved._backend.reserve(
|
|
422
|
+
resolved._key, self._settings.limits.fill_lease_seconds
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
except _BACKEND_ERRORS as error:
|
|
426
|
+
raise _CachePathError('backend_failure') from error
|
|
427
|
+
|
|
428
|
+
def _retain(self, obligation: _Obligation) -> None:
|
|
429
|
+
"""Settle unacknowledged work synchronously before another layer can await."""
|
|
430
|
+
current = self._fences[obligation._layer]
|
|
431
|
+
self._fences[obligation._layer] = _FenceState(
|
|
432
|
+
merge_changes(current._retained, obligation._changes), current._revision + 1
|
|
433
|
+
)
|
|
434
|
+
obligation._settled = True
|
|
435
|
+
|
|
436
|
+
async def _fence(self, obligations: list[_Obligation]) -> None:
|
|
437
|
+
"""Attempt L2 before L1, recording failed layers before the next await."""
|
|
438
|
+
for obligation in obligations:
|
|
439
|
+
snapshot = self._fences[obligation._layer]
|
|
440
|
+
obligation._changes = merge_changes(snapshot._retained, obligation._changes)
|
|
441
|
+
try:
|
|
442
|
+
await obligation._backend.change(obligation._changes)
|
|
443
|
+
except _BACKEND_ERRORS:
|
|
444
|
+
self._retain(obligation)
|
|
445
|
+
self._bypass('backend_failure')
|
|
446
|
+
else:
|
|
447
|
+
if self._fences[obligation._layer]._revision == snapshot._revision:
|
|
448
|
+
self._fences[obligation._layer] = _FenceState((), snapshot._revision + 1)
|
|
449
|
+
obligation._acknowledged = True
|
|
450
|
+
obligation._settled = True
|
|
451
|
+
|
|
452
|
+
async def _settle_close(
|
|
453
|
+
self, task: Task[CleanupOutcome[None]], state: _CloseState
|
|
454
|
+
) -> CancelledError | None:
|
|
455
|
+
"""Claim interrupted cleanup inline and publish the same outcome to waiters."""
|
|
456
|
+
if state._completion.done() or state._settling:
|
|
457
|
+
return None
|
|
458
|
+
state._settling = True
|
|
459
|
+
cancellation: CancelledError | None = None
|
|
460
|
+
if task.cancelled():
|
|
461
|
+
# Cancellation before entry or during admission leaves every backend
|
|
462
|
+
# pending. This waiter owns settlement without creating another task.
|
|
463
|
+
while True:
|
|
464
|
+
try:
|
|
465
|
+
await self._admission.wait_idle()
|
|
466
|
+
break
|
|
467
|
+
except CancelledError as error:
|
|
468
|
+
cancellation = cancellation or error
|
|
469
|
+
outcome = await self._close_all(state)
|
|
470
|
+
cancellation = cancellation or outcome.cancellation
|
|
471
|
+
else:
|
|
472
|
+
outcome = task.result()
|
|
473
|
+
failure = outcome.error
|
|
474
|
+
if task.cancelled():
|
|
475
|
+
interrupted = self._cleanup_error(BackendUnavailable('cleanup task cancelled'))
|
|
476
|
+
failure = failure or interrupted
|
|
477
|
+
state._completion.set_result(CleanupOutcome(error=failure))
|
|
478
|
+
return cancellation
|
|
479
|
+
|
|
480
|
+
def _unchanged(self, resolved: _Resolved) -> bool:
|
|
481
|
+
"""Require the empty retained state observed before backend I/O."""
|
|
482
|
+
current = self._fences[resolved._layer]
|
|
483
|
+
return (
|
|
484
|
+
not current._retained and not resolved._fence._retained and current == resolved._fence
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
async def _lookup(self, resolved: _Resolved) -> _Outcome | None:
|
|
488
|
+
"""Decode one isolated hit while preserving authoritative age and fences."""
|
|
489
|
+
try:
|
|
490
|
+
hit = await self._backend_call(lambda: resolved._backend.lookup(resolved._key))
|
|
491
|
+
except _BACKEND_ERRORS as error:
|
|
492
|
+
raise _CachePathError('backend_failure') from error
|
|
493
|
+
observed_at = monotonic()
|
|
494
|
+
if not self._unchanged(resolved):
|
|
495
|
+
raise _CachePathError('unacknowledged_fence')
|
|
496
|
+
if hit is None:
|
|
497
|
+
return None
|
|
498
|
+
try:
|
|
499
|
+
decoded = decode_hit(hit, resolved._age)
|
|
500
|
+
except StaleHitError:
|
|
501
|
+
return None
|
|
502
|
+
except CodecError as error:
|
|
503
|
+
raise _CachePathError('codec_failure') from error
|
|
504
|
+
decided_at = monotonic()
|
|
505
|
+
origin_at = observed_at - decoded.origin_elapsed
|
|
506
|
+
deadline = min(observed_at + hit.remaining_seconds, origin_at + resolved._age)
|
|
507
|
+
if decided_at >= deadline:
|
|
508
|
+
return None
|
|
509
|
+
outcome = _Outcome(
|
|
510
|
+
decoded.value,
|
|
511
|
+
resolved,
|
|
512
|
+
resolved if resolved._layer == 'l2' else None,
|
|
513
|
+
origin_at,
|
|
514
|
+
decided_at,
|
|
515
|
+
deadline,
|
|
516
|
+
deadline,
|
|
517
|
+
decoded.sg_duration,
|
|
518
|
+
_stored=True,
|
|
519
|
+
_from_cache=True,
|
|
520
|
+
)
|
|
521
|
+
layer = self._layer_name(resolved._layer)
|
|
522
|
+
self._increment('cache_hit', {'layer': layer})
|
|
523
|
+
self._increment('cache_sg_avoided_calls', {'layer': layer})
|
|
524
|
+
self._observe('cache_sg_avoided_seconds', decoded.sg_duration, {'layer': layer})
|
|
525
|
+
return outcome
|
|
526
|
+
|
|
527
|
+
async def _resolve(self, request: _ReadInput, layer: _Layer) -> _Resolved:
|
|
528
|
+
"""Resolve the requested vector without allowing a concurrent retained fence."""
|
|
529
|
+
backend = self._l1 if layer == 'l1' else self._l2
|
|
530
|
+
if backend is None:
|
|
531
|
+
raise _CachePathError('backend_failure')
|
|
532
|
+
fence = self._fences[layer]
|
|
533
|
+
if fence._retained:
|
|
534
|
+
raise _CachePathError('unacknowledged_fence')
|
|
535
|
+
age = self._age(request, layer)
|
|
536
|
+
try:
|
|
537
|
+
vector = await self._backend_call(lambda: backend.resolve(request._plan.tags))
|
|
538
|
+
key = self._key_builder.qualified_key(request._base_key, vector, 'result')
|
|
539
|
+
except _BACKEND_ERRORS as error:
|
|
540
|
+
raise _CachePathError('backend_failure') from error
|
|
541
|
+
except NormalizationError as error:
|
|
542
|
+
raise _CachePathError('normalization_failure') from error
|
|
543
|
+
resolved = _Resolved(backend, layer, key, fence, age)
|
|
544
|
+
if not self._unchanged(resolved):
|
|
545
|
+
raise _CachePathError('unacknowledged_fence')
|
|
546
|
+
return resolved
|
|
547
|
+
|
|
548
|
+
async def _memo_value(self, request: _ReadInput, first: _Resolved, parse: Parse) -> object:
|
|
549
|
+
"""Reuse or revalidate the single memo at one callback-free decision sample."""
|
|
550
|
+
entry = self._memo
|
|
551
|
+
if entry is None or entry._parse is not parse or entry._outcome._first._key != first._key:
|
|
552
|
+
self._memo = None
|
|
553
|
+
return _MISS
|
|
554
|
+
outcome = entry._outcome
|
|
555
|
+
now = monotonic()
|
|
556
|
+
if request._age is not None and now - outcome._origin_at >= request._age:
|
|
557
|
+
return _MISS
|
|
558
|
+
if now < outcome._first_deadline:
|
|
559
|
+
self._increment('cache_schema_view', {'outcome': 'reuse'})
|
|
560
|
+
return entry._value
|
|
561
|
+
if (
|
|
562
|
+
self._l1 is None
|
|
563
|
+
or self._l2 is None
|
|
564
|
+
or outcome._shared is None
|
|
565
|
+
or now >= outcome._deep_deadline
|
|
566
|
+
):
|
|
567
|
+
return _MISS
|
|
568
|
+
deep = await self._resolve(request, 'l2')
|
|
569
|
+
decided_at = monotonic()
|
|
570
|
+
valid = (
|
|
571
|
+
self._memo is entry
|
|
572
|
+
and self._unchanged(first)
|
|
573
|
+
and self._unchanged(deep)
|
|
574
|
+
and deep._key == outcome._shared._key
|
|
575
|
+
and decided_at < outcome._deep_deadline
|
|
576
|
+
and (request._age is None or decided_at - outcome._origin_at < request._age)
|
|
577
|
+
)
|
|
578
|
+
if not valid:
|
|
579
|
+
return _MISS
|
|
580
|
+
self._memo = replace(
|
|
581
|
+
entry,
|
|
582
|
+
_outcome=replace(
|
|
583
|
+
outcome,
|
|
584
|
+
_first=first,
|
|
585
|
+
_shared=deep,
|
|
586
|
+
_first_deadline=min(decided_at + first._age, outcome._deep_deadline),
|
|
587
|
+
),
|
|
588
|
+
)
|
|
589
|
+
self._increment('cache_schema_view', {'outcome': 'revalidated'})
|
|
590
|
+
return entry._value
|
|
591
|
+
|
|
592
|
+
async def _poll_deep(
|
|
593
|
+
self, request: _ReadInput, resolved: _Resolved, wait_deadline: float
|
|
594
|
+
) -> tuple[_Resolved, FillLease | None, _Outcome | None]:
|
|
595
|
+
"""Finish deepest-layer coordination before any takeover or fallback fetch."""
|
|
596
|
+
attempt = 0
|
|
597
|
+
started: float | None = None
|
|
598
|
+
outcome: WaitOutcome = 'cancelled'
|
|
599
|
+
try:
|
|
600
|
+
while True:
|
|
601
|
+
if get_running_loop().time() >= wait_deadline:
|
|
602
|
+
self._expire_wait(request._waits, 'distributed')
|
|
603
|
+
outcome = 'timeout'
|
|
604
|
+
return resolved, None, None
|
|
605
|
+
lease = await self._reserve(resolved)
|
|
606
|
+
if lease is not None:
|
|
607
|
+
outcome = 'takeover'
|
|
608
|
+
return resolved, lease, None
|
|
609
|
+
remaining = wait_deadline - get_running_loop().time()
|
|
610
|
+
if remaining <= 0:
|
|
611
|
+
self._expire_wait(request._waits, 'distributed')
|
|
612
|
+
outcome = 'timeout'
|
|
613
|
+
return resolved, None, None
|
|
614
|
+
if started is None:
|
|
615
|
+
started = self._begin_wait(request._waits, 'distributed')
|
|
616
|
+
await sleep(min(remaining, 0.01 * 2 ** min(attempt, 3)))
|
|
617
|
+
attempt += 1
|
|
618
|
+
resolved = await self._resolve(request, resolved._layer)
|
|
619
|
+
hit = await self._lookup(resolved)
|
|
620
|
+
if hit is not None:
|
|
621
|
+
outcome = 'completed'
|
|
622
|
+
return resolved, None, hit
|
|
623
|
+
except _CachePathError:
|
|
624
|
+
outcome = 'failure'
|
|
625
|
+
raise
|
|
626
|
+
finally:
|
|
627
|
+
if started is not None:
|
|
628
|
+
request._waits.end('distributed', get_running_loop().time() - started, outcome)
|
|
629
|
+
|
|
630
|
+
def _valid_outcome(self, outcome: _Outcome) -> bool:
|
|
631
|
+
"""Recheck every layer used by one acquisition after asynchronous work."""
|
|
632
|
+
return self._unchanged(outcome._first) and (
|
|
633
|
+
outcome._shared is None or self._unchanged(outcome._shared)
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
def _checked_outcome(self, outcome: _Outcome) -> _Outcome:
|
|
637
|
+
"""Reject a cached source invalidated or expired during promotion or release."""
|
|
638
|
+
if outcome._from_cache:
|
|
639
|
+
now = monotonic()
|
|
640
|
+
if not self._valid_outcome(outcome):
|
|
641
|
+
raise _CachePathError('unacknowledged_fence')
|
|
642
|
+
if now >= outcome._deep_deadline:
|
|
643
|
+
raise _CachePathError('maximum_age_elapsed')
|
|
644
|
+
return outcome
|
|
645
|
+
|
|
646
|
+
async def _commit(self, lease: FillLease, outcome: _Outcome, deadline: float) -> bool:
|
|
647
|
+
"""Encode and commit one optional value under its exact lease and age."""
|
|
648
|
+
lifetime = deadline - outcome._observed_at
|
|
649
|
+
if lifetime <= 0:
|
|
650
|
+
return False
|
|
651
|
+
try:
|
|
652
|
+
payload = encode_result(
|
|
653
|
+
TimedResult(outcome._sg_duration, outcome._value),
|
|
654
|
+
max(0.0, outcome._observed_at - outcome._origin_at),
|
|
655
|
+
lifetime,
|
|
656
|
+
)
|
|
657
|
+
if len(payload) > self._settings.limits.max_serialized_result_bytes:
|
|
658
|
+
self._bypass('result_too_large_bytes')
|
|
659
|
+
return False
|
|
660
|
+
# Deliver pending cancellation before dispatch, even for an inline backend.
|
|
661
|
+
await sleep(0)
|
|
662
|
+
remaining = deadline - monotonic()
|
|
663
|
+
if remaining <= 0 or not self._valid_outcome(outcome):
|
|
664
|
+
return False
|
|
665
|
+
return await self._backend_call(
|
|
666
|
+
lambda: outcome._first._backend.commit(lease, payload, remaining)
|
|
667
|
+
)
|
|
668
|
+
except CodecBoundError as error:
|
|
669
|
+
self._bypass(bound_reason(error))
|
|
670
|
+
except CodecError:
|
|
671
|
+
self._bypass('codec_failure')
|
|
672
|
+
except _BACKEND_ERRORS:
|
|
673
|
+
self._bypass('backend_failure')
|
|
674
|
+
return False
|
|
675
|
+
|
|
676
|
+
async def _promote(self, first: _Resolved, outcome: _Outcome) -> _Outcome:
|
|
677
|
+
"""Promote under the original L1 vector without extending L2 origin age."""
|
|
678
|
+
deadline = min(outcome._deep_deadline, outcome._observed_at + first._age)
|
|
679
|
+
promoted = replace(outcome, _first=first, _shared=outcome._first, _first_deadline=deadline)
|
|
680
|
+
if not outcome._stored or not self._valid_outcome(promoted):
|
|
681
|
+
return promoted
|
|
682
|
+
try:
|
|
683
|
+
lease = await self._backend_call(
|
|
684
|
+
lambda: first._backend.reserve(
|
|
685
|
+
first._key, self._settings.limits.fill_lease_seconds
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
except _BACKEND_ERRORS:
|
|
689
|
+
self._bypass('backend_failure')
|
|
690
|
+
return promoted
|
|
691
|
+
if lease is None:
|
|
692
|
+
self._increment('cache_lease_rejection', {'layer': 'hierarchical_l1'})
|
|
693
|
+
return promoted
|
|
694
|
+
async with self._held_lease(first._backend, lease):
|
|
695
|
+
if self._valid_outcome(promoted):
|
|
696
|
+
await self._commit(lease, promoted, deadline)
|
|
697
|
+
return promoted
|
|
698
|
+
|
|
699
|
+
async def _reserved(
|
|
700
|
+
self, request: _ReadInput, resolved: _Resolved, lease: FillLease
|
|
701
|
+
) -> _Outcome:
|
|
702
|
+
"""Recheck a granted lease and always join its token-fenced release."""
|
|
703
|
+
async with self._held_lease(resolved._backend, lease):
|
|
704
|
+
rechecked = await self._resolve(request, resolved._layer)
|
|
705
|
+
hit = await self._lookup(rechecked)
|
|
706
|
+
if hit is not None:
|
|
707
|
+
return hit
|
|
708
|
+
outcome = await self._fetch(request, resolved)
|
|
709
|
+
if rechecked._key != lease.key:
|
|
710
|
+
return outcome
|
|
711
|
+
deadline = self._population_deadline(request, outcome)
|
|
712
|
+
if deadline is None:
|
|
713
|
+
return outcome
|
|
714
|
+
committed = await self._commit(lease, outcome, deadline)
|
|
715
|
+
if committed:
|
|
716
|
+
return replace(
|
|
717
|
+
outcome, _first_deadline=deadline, _deep_deadline=deadline, _stored=True
|
|
718
|
+
)
|
|
719
|
+
return outcome
|
|
720
|
+
|
|
721
|
+
async def _deep(
|
|
722
|
+
self, request: _ReadInput, resolved: _Resolved, wait_deadline: float
|
|
723
|
+
) -> _Outcome:
|
|
724
|
+
"""Compete for a deepest-layer lease within the caller's original wait budget."""
|
|
725
|
+
resolved, lease, hit = await self._poll_deep(request, resolved, wait_deadline)
|
|
726
|
+
if hit is not None:
|
|
727
|
+
return hit
|
|
728
|
+
if lease is not None:
|
|
729
|
+
return await self._reserved(request, resolved, lease)
|
|
730
|
+
return await self._fetch(request, resolved)
|
|
731
|
+
|
|
732
|
+
async def _wait_flight(self, flight: Future[None], deadline: float, waits: WaitRecord) -> bool:
|
|
733
|
+
"""Wait for notification without cancelling the leader or renewing the budget."""
|
|
734
|
+
started = self._begin_wait(waits, 'local')
|
|
735
|
+
outcome: WaitOutcome = 'cancelled'
|
|
736
|
+
try:
|
|
737
|
+
try:
|
|
738
|
+
async with timeout_at(deadline):
|
|
739
|
+
await shield(flight)
|
|
740
|
+
except TimeoutError:
|
|
741
|
+
outcome = 'timeout'
|
|
742
|
+
self._expire_wait(waits, 'local')
|
|
743
|
+
return False
|
|
744
|
+
outcome = 'completed'
|
|
745
|
+
return True
|
|
746
|
+
finally:
|
|
747
|
+
waits.end('local', get_running_loop().time() - started, outcome)
|
|
748
|
+
|
|
749
|
+
async def _read_cacheable(self, request: _ReadInput, wait_deadline: float) -> _Outcome:
|
|
750
|
+
"""Read the configured hierarchy with notification-only leader re-election."""
|
|
751
|
+
while True:
|
|
752
|
+
first_layer: _Layer = 'l1' if self._l1 is not None else 'l2'
|
|
753
|
+
try:
|
|
754
|
+
first = await self._resolve(request, first_layer)
|
|
755
|
+
hit = await self._lookup(first)
|
|
756
|
+
except _CachePathError as error:
|
|
757
|
+
if (
|
|
758
|
+
first_layer != 'l1'
|
|
759
|
+
or self._l2 is None
|
|
760
|
+
or error._reason == 'normalization_failure'
|
|
761
|
+
):
|
|
762
|
+
raise
|
|
763
|
+
self._bypass(error._reason)
|
|
764
|
+
first = await self._resolve(request, 'l2')
|
|
765
|
+
hit = await self._lookup(first)
|
|
766
|
+
if hit is not None:
|
|
767
|
+
return hit
|
|
768
|
+
flight = self._flights.get(first._key)
|
|
769
|
+
if flight is not None:
|
|
770
|
+
if (
|
|
771
|
+
await self._wait_flight(flight, wait_deadline, request._waits)
|
|
772
|
+
and get_running_loop().time() < wait_deadline
|
|
773
|
+
):
|
|
774
|
+
continue
|
|
775
|
+
return await self._fetch(request, first)
|
|
776
|
+
flight = get_running_loop().create_future()
|
|
777
|
+
self._flights[first._key] = flight
|
|
778
|
+
try:
|
|
779
|
+
if first._layer == 'l1' and self._l2 is not None:
|
|
780
|
+
deep = await self._resolve(request, 'l2')
|
|
781
|
+
outcome = await self._lookup(deep)
|
|
782
|
+
if outcome is None:
|
|
783
|
+
outcome = await self._deep(request, deep, wait_deadline)
|
|
784
|
+
return self._checked_outcome(await self._promote(first, outcome))
|
|
785
|
+
return self._checked_outcome(await self._deep(request, first, wait_deadline))
|
|
786
|
+
finally:
|
|
787
|
+
self._finish_flight(first._key, flight)
|
|
788
|
+
|
|
789
|
+
async def _acquire_parsed(self, request: _ReadInput, parse: Parse, deadline: float) -> object:
|
|
790
|
+
"""Keep acquisition, parsing, malformed refetch, and memo publication in one flight."""
|
|
791
|
+
outcome = await self._read_cacheable(request, deadline)
|
|
792
|
+
result = 'rebuild'
|
|
793
|
+
try:
|
|
794
|
+
parsed = parse(outcome._value)
|
|
795
|
+
except (TypeError, ValueError):
|
|
796
|
+
self._increment('cache_schema_view', {'outcome': 'malformed'})
|
|
797
|
+
fresh = await self._fetch(request, outcome._first)
|
|
798
|
+
parsed = parse(fresh._value)
|
|
799
|
+
outcome = replace(
|
|
800
|
+
fresh,
|
|
801
|
+
_shared=outcome._shared,
|
|
802
|
+
_deep_deadline=fresh._origin_at
|
|
803
|
+
+ (outcome._shared._age if outcome._shared is not None else outcome._first._age),
|
|
804
|
+
)
|
|
805
|
+
result = 'refetch'
|
|
806
|
+
if self._valid_outcome(outcome):
|
|
807
|
+
self._memo = _MemoEntry(outcome, parse, parsed)
|
|
808
|
+
self._increment('cache_schema_view', {'outcome': result})
|
|
809
|
+
return parsed
|
|
810
|
+
|
|
811
|
+
async def _read_parsed(self, request: _ReadInput, parse: Parse) -> object:
|
|
812
|
+
"""Coordinate parsed acquisitions, including results no backend can store."""
|
|
813
|
+
deadline = get_running_loop().time() + self._settings.limits.lease_wait_seconds
|
|
814
|
+
while True:
|
|
815
|
+
first = await self._resolve(request, 'l1' if self._l1 is not None else 'l2')
|
|
816
|
+
reused = await self._memo_value(request, first, parse)
|
|
817
|
+
if reused is not _MISS:
|
|
818
|
+
return reused
|
|
819
|
+
key = f'memo:{first._key}'
|
|
820
|
+
flight = self._flights.get(key)
|
|
821
|
+
if flight is not None:
|
|
822
|
+
if (
|
|
823
|
+
await self._wait_flight(flight, deadline, request._waits)
|
|
824
|
+
and get_running_loop().time() < deadline
|
|
825
|
+
):
|
|
826
|
+
continue
|
|
827
|
+
return parse((await self._fetch(request, first))._value)
|
|
828
|
+
flight = get_running_loop().create_future()
|
|
829
|
+
self._flights[key] = flight
|
|
830
|
+
try:
|
|
831
|
+
return await self._acquire_parsed(request, parse, deadline)
|
|
832
|
+
finally:
|
|
833
|
+
self._finish_flight(key, flight)
|
|
834
|
+
|
|
835
|
+
@contextmanager
|
|
836
|
+
def admit(self) -> Generator[OperationToken]:
|
|
837
|
+
"""Admit one complete wrapper operation, including planning and cleanup.
|
|
838
|
+
|
|
839
|
+
Yields:
|
|
840
|
+
An explicit cache-specific token, valid only until context exit.
|
|
841
|
+
|
|
842
|
+
Raises:
|
|
843
|
+
RuntimeError: Cache is closing, closed, or bound to another loop.
|
|
844
|
+
"""
|
|
845
|
+
with self._admission.operation() as token:
|
|
846
|
+
yield token
|
|
847
|
+
|
|
848
|
+
async def __aenter__(self) -> Self:
|
|
849
|
+
"""Bind and validate this open cache before entering its owning context.
|
|
850
|
+
|
|
851
|
+
Raises:
|
|
852
|
+
RuntimeError: The cache is closing, closed, or bound to another
|
|
853
|
+
loop.
|
|
854
|
+
"""
|
|
855
|
+
with self.admit():
|
|
856
|
+
return self
|
|
857
|
+
|
|
858
|
+
@property
|
|
859
|
+
def bypass_active(self) -> bool:
|
|
860
|
+
"""Report whether this task's lexical scopes require bypass."""
|
|
861
|
+
return policy_bypass_active(self._policies.get())
|
|
862
|
+
|
|
863
|
+
async def close(self) -> None:
|
|
864
|
+
"""Join all admitted work and close every backend exactly once.
|
|
865
|
+
|
|
866
|
+
Caller cancellation is deferred until cleanup completes, then
|
|
867
|
+
propagated.
|
|
868
|
+
There is no imposed deadline for active operations. Calls after
|
|
869
|
+
terminal close return quietly on the bound loop.
|
|
870
|
+
|
|
871
|
+
Raises:
|
|
872
|
+
RuntimeError: Called by this cache's active owner or on another
|
|
873
|
+
loop.
|
|
874
|
+
Exception: First ordinary backend failure after every close
|
|
875
|
+
attempt.
|
|
876
|
+
"""
|
|
877
|
+
if self._admission.begin_close():
|
|
878
|
+
self._close_state = _CloseState(
|
|
879
|
+
[backend for backend in (self._l1, self._l2) if backend is not None],
|
|
880
|
+
get_running_loop().create_future(),
|
|
881
|
+
)
|
|
882
|
+
self._close_task = create_task(self._close_all(self._close_state))
|
|
883
|
+
elif self._admission.closed:
|
|
884
|
+
return
|
|
885
|
+
task, state = self._close_task, self._close_state
|
|
886
|
+
if task is None or state is None:
|
|
887
|
+
raise RuntimeError('close task was not published')
|
|
888
|
+
await join_shared_close(task, state._completion, lambda: self._settle_close(task, state))
|
|
889
|
+
|
|
890
|
+
async def __aexit__(
|
|
891
|
+
self,
|
|
892
|
+
exc_type: type[BaseException] | None,
|
|
893
|
+
exc: BaseException | None,
|
|
894
|
+
traceback: TracebackType | None,
|
|
895
|
+
) -> None:
|
|
896
|
+
"""Close resources with cancellation, body error, then cleanup precedence.
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
exc_type: Active body exception type, when present.
|
|
900
|
+
exc: Active body exception whose precedence must be retained.
|
|
901
|
+
traceback: Traceback supplied by the async context manager
|
|
902
|
+
protocol.
|
|
903
|
+
"""
|
|
904
|
+
try:
|
|
905
|
+
if exc is None:
|
|
906
|
+
await self.close()
|
|
907
|
+
else:
|
|
908
|
+
with suppress(Exception):
|
|
909
|
+
await self.close()
|
|
910
|
+
except CancelledError:
|
|
911
|
+
if isinstance(exc, CancelledError):
|
|
912
|
+
raise exc from None
|
|
913
|
+
raise
|
|
914
|
+
|
|
915
|
+
async def invalidate_admitted(
|
|
916
|
+
self, changes: tuple[GenerationChange, ...], *, token: OperationToken
|
|
917
|
+
) -> bool:
|
|
918
|
+
"""Attempt and settle an ordered fence within an existing operation.
|
|
919
|
+
|
|
920
|
+
Args:
|
|
921
|
+
changes: Ordered conservative generation changes to apply.
|
|
922
|
+
token: Live capability from this cache's `admit` context.
|
|
923
|
+
|
|
924
|
+
Returns:
|
|
925
|
+
Whether every configured layer acknowledged this attempt. Failed
|
|
926
|
+
and unvisited layers retain work for replay by the next fence.
|
|
927
|
+
|
|
928
|
+
Raises:
|
|
929
|
+
RuntimeError: The token is foreign or expired, or the cache belongs
|
|
930
|
+
to another loop.
|
|
931
|
+
"""
|
|
932
|
+
self._admission.validate(token)
|
|
933
|
+
pairs: tuple[tuple[_Layer, AsyncCacheBackend | None], ...] = (
|
|
934
|
+
('l2', self._l2),
|
|
935
|
+
('l1', self._l1),
|
|
936
|
+
)
|
|
937
|
+
obligations = [
|
|
938
|
+
_Obligation(layer, backend, changes) for layer, backend in pairs if backend is not None
|
|
939
|
+
]
|
|
940
|
+
outcome = await joined_cleanup(lambda: self._fence(obligations), deadline=self._deadline())
|
|
941
|
+
# The operation owner performs this even if the inner task never ran.
|
|
942
|
+
for obligation in obligations:
|
|
943
|
+
if not obligation._settled:
|
|
944
|
+
self._retain(obligation)
|
|
945
|
+
self._cleanup_error(outcome.error)
|
|
946
|
+
if outcome.cancellation is not None:
|
|
947
|
+
raise outcome.cancellation
|
|
948
|
+
return outcome.error is None and all(item._acknowledged for item in obligations)
|
|
949
|
+
|
|
950
|
+
async def invalidate(self, changes: tuple[GenerationChange, ...]) -> bool:
|
|
951
|
+
"""Apply L2 then L1 fences and retain every unacknowledged layer.
|
|
952
|
+
|
|
953
|
+
Args:
|
|
954
|
+
changes: Ordered conservative generation changes to apply.
|
|
955
|
+
|
|
956
|
+
Returns:
|
|
957
|
+
Whether every configured layer acknowledged this attempt.
|
|
958
|
+
|
|
959
|
+
Raises:
|
|
960
|
+
RuntimeError: The cache is closing, closed, or bound to another
|
|
961
|
+
loop.
|
|
962
|
+
"""
|
|
963
|
+
with self.admit() as token:
|
|
964
|
+
return await self.invalidate_admitted(changes, token=token)
|
|
965
|
+
|
|
966
|
+
@contextmanager
|
|
967
|
+
def policy(
|
|
968
|
+
self, mode: PolicyMode | None = None, maximum_age: float | None = None
|
|
969
|
+
) -> Generator[None]:
|
|
970
|
+
"""Apply a lexical policy snapshot inherited independently by child tasks.
|
|
971
|
+
|
|
972
|
+
Args:
|
|
973
|
+
mode: Optional conservative policy downgrade.
|
|
974
|
+
maximum_age: Optional positive age in seconds, combined by minimum
|
|
975
|
+
with other scoped and per-call ages. The resulting explicit age
|
|
976
|
+
replaces configured rules and defaults; global safety,
|
|
977
|
+
response, remaining-L2, and hierarchical-L1 caps still apply.
|
|
978
|
+
|
|
979
|
+
Yields:
|
|
980
|
+
Control while this cache instance's policy scope is active.
|
|
981
|
+
|
|
982
|
+
Raises:
|
|
983
|
+
TypeError: A supplied maximum age is not a finite non-boolean
|
|
984
|
+
number.
|
|
985
|
+
ValueError: The mode is unsupported or the maximum age is not
|
|
986
|
+
positive.
|
|
987
|
+
"""
|
|
988
|
+
frame = policy_frame(mode, maximum_age)
|
|
989
|
+
token = self._policies.set((*self._policies.get(), frame))
|
|
990
|
+
try:
|
|
991
|
+
yield
|
|
992
|
+
finally:
|
|
993
|
+
self._policies.reset(token)
|
|
994
|
+
|
|
995
|
+
@contextmanager
|
|
996
|
+
def bypass(self) -> Generator[None]:
|
|
997
|
+
"""Bypass cache reads and population within this inherited lexical scope."""
|
|
998
|
+
with self.policy(mode='bypass'):
|
|
999
|
+
yield
|
|
1000
|
+
|
|
1001
|
+
@staticmethod
|
|
1002
|
+
def build_metrics(
|
|
1003
|
+
settings: CacheSettings, environ: Mapping[str, object], sink: MetricsSink
|
|
1004
|
+
) -> MetricsSink:
|
|
1005
|
+
"""Build a validating metrics sink with optional keyed correlation.
|
|
1006
|
+
|
|
1007
|
+
Args:
|
|
1008
|
+
settings: Loaded settings naming an optional correlation secret.
|
|
1009
|
+
environ: Environment mapping consulted only during construction.
|
|
1010
|
+
sink: Destination metrics implementation.
|
|
1011
|
+
|
|
1012
|
+
Returns:
|
|
1013
|
+
A validating sink with optional keyed exact-call correlation.
|
|
1014
|
+
|
|
1015
|
+
Raises:
|
|
1016
|
+
ValueError: A configured secret is absent, non-UTF-8, or shorter
|
|
1017
|
+
than 32 encoded bytes.
|
|
1018
|
+
"""
|
|
1019
|
+
return QueryCache.build_metrics(settings, environ, sink)
|
|
1020
|
+
|
|
1021
|
+
async def read_admitted( # noqa: PLR0913 - preserve read inputs plus explicit admission token
|
|
1022
|
+
self,
|
|
1023
|
+
call: ExactCall,
|
|
1024
|
+
plan: DependencyPlan,
|
|
1025
|
+
fetch: _Fetch,
|
|
1026
|
+
client_options: Mapping[str, object],
|
|
1027
|
+
per_call_age: float | None = None,
|
|
1028
|
+
*,
|
|
1029
|
+
token: OperationToken,
|
|
1030
|
+
_population_decision: PopulationCallback | None = None,
|
|
1031
|
+
) -> object:
|
|
1032
|
+
"""Read within the explicit lifetime of an already admitted operation.
|
|
1033
|
+
|
|
1034
|
+
Args:
|
|
1035
|
+
call: Exact normalized SG call.
|
|
1036
|
+
plan: Conservative pre-fetch dependency plan.
|
|
1037
|
+
fetch: Caller-owned native async SG operation.
|
|
1038
|
+
client_options: Complete response-affecting client options.
|
|
1039
|
+
per_call_age: Optional positive maximum age in seconds, combined by
|
|
1040
|
+
minimum with active scopes. This explicit age replaces
|
|
1041
|
+
configured rules before safety caps are applied.
|
|
1042
|
+
token: Live capability from this cache's `admit` context.
|
|
1043
|
+
_population_decision: Optional private post-fetch age cap or
|
|
1044
|
+
decline.
|
|
1045
|
+
|
|
1046
|
+
Returns:
|
|
1047
|
+
An isolated cache hit or the direct SG result.
|
|
1048
|
+
|
|
1049
|
+
Raises:
|
|
1050
|
+
RuntimeError: The token is foreign or expired, or the cache belongs
|
|
1051
|
+
to another loop.
|
|
1052
|
+
TypeError: A non-bypassed call supplies a nonfinite, boolean, or
|
|
1053
|
+
nonnumeric maximum age.
|
|
1054
|
+
ValueError: A non-bypassed call supplies a nonpositive maximum age.
|
|
1055
|
+
"""
|
|
1056
|
+
self._admission.validate(token)
|
|
1057
|
+
frames = self._policies.get()
|
|
1058
|
+
effective = apply_policy_frames(plan, frames)
|
|
1059
|
+
with self._correlation_scope(None):
|
|
1060
|
+
self._increment(
|
|
1061
|
+
'cache_query_classification',
|
|
1062
|
+
{'classification': effective.classification, 'reason': effective.reason},
|
|
1063
|
+
)
|
|
1064
|
+
for step in effective.broadening_steps:
|
|
1065
|
+
self._increment('cache_dependency_broadening', {'reason': step})
|
|
1066
|
+
if effective.classification == 'bypass':
|
|
1067
|
+
self._bypass(effective.reason)
|
|
1068
|
+
return await self._direct_fetch(fetch)
|
|
1069
|
+
age = minimum_policy_age(frames, per_call_age)
|
|
1070
|
+
try:
|
|
1071
|
+
base = self._key_builder.base_key(
|
|
1072
|
+
self._settings.site_namespace, self._identity, client_options, call
|
|
1073
|
+
)
|
|
1074
|
+
except NormalizationError:
|
|
1075
|
+
self._bypass('normalization_failure')
|
|
1076
|
+
return await self._direct_fetch(fetch)
|
|
1077
|
+
digest = None
|
|
1078
|
+
if isinstance(self._metrics, SafeMetrics):
|
|
1079
|
+
with suppress(Exception):
|
|
1080
|
+
digest = self._metrics.correlation_digest(self._key_builder, base)
|
|
1081
|
+
request = _ReadInput(base, call, effective, fetch, age, _population_decision)
|
|
1082
|
+
with self._correlation_scope(digest):
|
|
1083
|
+
try:
|
|
1084
|
+
deadline = get_running_loop().time() + self._settings.limits.lease_wait_seconds
|
|
1085
|
+
return (await self._read_cacheable(request, deadline))._value
|
|
1086
|
+
except _CachePathError as error:
|
|
1087
|
+
self._bypass(error._reason)
|
|
1088
|
+
return await self._direct_fetch(fetch)
|
|
1089
|
+
finally:
|
|
1090
|
+
self._emit_waits(request._waits)
|
|
1091
|
+
|
|
1092
|
+
async def read(
|
|
1093
|
+
self,
|
|
1094
|
+
call: ExactCall,
|
|
1095
|
+
plan: DependencyPlan,
|
|
1096
|
+
fetch: _Fetch,
|
|
1097
|
+
client_options: Mapping[str, object],
|
|
1098
|
+
per_call_age: float | None = None,
|
|
1099
|
+
*,
|
|
1100
|
+
_population_decision: PopulationCallback | None = None,
|
|
1101
|
+
) -> object:
|
|
1102
|
+
"""Read one exact call through native asynchronous cache operations.
|
|
1103
|
+
|
|
1104
|
+
Args:
|
|
1105
|
+
call: Exact normalized SG call.
|
|
1106
|
+
plan: Conservative pre-fetch dependency plan.
|
|
1107
|
+
fetch: Caller-owned native async SG operation.
|
|
1108
|
+
client_options: Complete response-affecting client options.
|
|
1109
|
+
per_call_age: Optional positive maximum age in seconds, combined by
|
|
1110
|
+
minimum with active scopes. This explicit age replaces
|
|
1111
|
+
configured rules before safety caps are applied.
|
|
1112
|
+
_population_decision: Optional private post-fetch age cap or
|
|
1113
|
+
decline.
|
|
1114
|
+
|
|
1115
|
+
Returns:
|
|
1116
|
+
An isolated cache hit or the direct SG result.
|
|
1117
|
+
|
|
1118
|
+
Raises:
|
|
1119
|
+
RuntimeError: The cache is closing, closed, or bound to another
|
|
1120
|
+
loop.
|
|
1121
|
+
TypeError: A non-bypassed call supplies a nonfinite, boolean, or
|
|
1122
|
+
nonnumeric maximum age.
|
|
1123
|
+
ValueError: A non-bypassed call supplies a nonpositive maximum age.
|
|
1124
|
+
"""
|
|
1125
|
+
with self.admit() as token:
|
|
1126
|
+
return await self.read_admitted(
|
|
1127
|
+
call,
|
|
1128
|
+
plan,
|
|
1129
|
+
fetch,
|
|
1130
|
+
client_options,
|
|
1131
|
+
per_call_age,
|
|
1132
|
+
token=token,
|
|
1133
|
+
_population_decision=_population_decision,
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
async def read_parsed_admitted( # noqa: PLR0913 - preserve read inputs plus explicit admission token
|
|
1137
|
+
self,
|
|
1138
|
+
call: ExactCall,
|
|
1139
|
+
plan: DependencyPlan,
|
|
1140
|
+
fetch: _Fetch,
|
|
1141
|
+
client_options: Mapping[str, object],
|
|
1142
|
+
parse: Parse,
|
|
1143
|
+
*,
|
|
1144
|
+
token: OperationToken,
|
|
1145
|
+
) -> object:
|
|
1146
|
+
"""Acquire one immutable parsed view within an existing admission lifetime.
|
|
1147
|
+
|
|
1148
|
+
When rebuilding the parsed memo, a first parse that raises `TypeError`
|
|
1149
|
+
or `ValueError` triggers one direct refetch and another parse. Fetch
|
|
1150
|
+
failures and a failing second parse propagate. Direct fallback parsing
|
|
1151
|
+
is not retried.
|
|
1152
|
+
Memo hits may return the same immutable object.
|
|
1153
|
+
|
|
1154
|
+
Args:
|
|
1155
|
+
call: Exact normalized call for the reduced view.
|
|
1156
|
+
plan: Complete conservative schema dependency plan.
|
|
1157
|
+
fetch: Native async acquisition returning a storable value.
|
|
1158
|
+
client_options: Complete response-affecting client options.
|
|
1159
|
+
parse: Pure conversion to an immutable view; identity controls
|
|
1160
|
+
reuse.
|
|
1161
|
+
token: Live capability from this cache's `admit` context.
|
|
1162
|
+
|
|
1163
|
+
Returns:
|
|
1164
|
+
The parsed view for the currently resolved key and valid age.
|
|
1165
|
+
|
|
1166
|
+
Raises:
|
|
1167
|
+
RuntimeError: The token is foreign or expired, or the cache belongs
|
|
1168
|
+
to another loop.
|
|
1169
|
+
"""
|
|
1170
|
+
self._admission.validate(token)
|
|
1171
|
+
frames = self._policies.get()
|
|
1172
|
+
effective = apply_policy_frames(plan, frames)
|
|
1173
|
+
with self._correlation_scope(None):
|
|
1174
|
+
if effective.classification == 'bypass':
|
|
1175
|
+
self._bypass(effective.reason)
|
|
1176
|
+
return parse(await self._direct_fetch(fetch))
|
|
1177
|
+
try:
|
|
1178
|
+
base = self._key_builder.base_key(
|
|
1179
|
+
self._settings.site_namespace, self._identity, client_options, call
|
|
1180
|
+
)
|
|
1181
|
+
request = _ReadInput(
|
|
1182
|
+
base, call, effective, fetch, minimum_policy_age(frames, None)
|
|
1183
|
+
)
|
|
1184
|
+
try:
|
|
1185
|
+
return await self._read_parsed(request, parse)
|
|
1186
|
+
finally:
|
|
1187
|
+
self._emit_waits(request._waits)
|
|
1188
|
+
except NormalizationError:
|
|
1189
|
+
self._bypass('normalization_failure')
|
|
1190
|
+
except _CachePathError as error:
|
|
1191
|
+
self._bypass(error._reason)
|
|
1192
|
+
return parse(await self._direct_fetch(fetch))
|
|
1193
|
+
|
|
1194
|
+
async def read_parsed(
|
|
1195
|
+
self,
|
|
1196
|
+
call: ExactCall,
|
|
1197
|
+
plan: DependencyPlan,
|
|
1198
|
+
fetch: _Fetch,
|
|
1199
|
+
client_options: Mapping[str, object],
|
|
1200
|
+
parse: Parse,
|
|
1201
|
+
) -> object:
|
|
1202
|
+
"""Read an exact call through the cache's single immutable parsed memo.
|
|
1203
|
+
|
|
1204
|
+
When rebuilding the parsed memo, a first parse that raises `TypeError`
|
|
1205
|
+
or `ValueError` triggers one direct refetch and another parse. Fetch
|
|
1206
|
+
failures and a failing second parse propagate. Direct fallback parsing
|
|
1207
|
+
is not retried.
|
|
1208
|
+
Memo hits may return the same immutable object.
|
|
1209
|
+
|
|
1210
|
+
Args:
|
|
1211
|
+
call: Exact normalized call for the reduced view.
|
|
1212
|
+
plan: Complete conservative schema dependency plan.
|
|
1213
|
+
fetch: Native async acquisition returning a storable value.
|
|
1214
|
+
client_options: Complete response-affecting client options.
|
|
1215
|
+
parse: Pure conversion to an immutable view; identity controls
|
|
1216
|
+
reuse.
|
|
1217
|
+
|
|
1218
|
+
Returns:
|
|
1219
|
+
The parsed view for the currently resolved key and valid age.
|
|
1220
|
+
|
|
1221
|
+
Raises:
|
|
1222
|
+
RuntimeError: The cache is closing, closed, or bound to another
|
|
1223
|
+
loop.
|
|
1224
|
+
"""
|
|
1225
|
+
with self.admit() as token:
|
|
1226
|
+
return await self.read_parsed_admitted(
|
|
1227
|
+
call, plan, fetch, client_options, parse, token=token
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def client_binding(cache: AsyncQueryCache) -> tuple[CacheSettings, Identity]:
|
|
1232
|
+
"""Return the supported package-internal settings and exact identity binding.
|
|
1233
|
+
|
|
1234
|
+
Args:
|
|
1235
|
+
cache: Async cache wrapped by the package's SG client adapter.
|
|
1236
|
+
|
|
1237
|
+
Returns:
|
|
1238
|
+
The immutable settings and identity retained by the cache.
|
|
1239
|
+
"""
|
|
1240
|
+
return cache._settings, cache._identity
|