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.
Files changed (42) hide show
  1. sg_query_cache/__init__.py +39 -0
  2. sg_query_cache/__main__.py +8 -0
  3. sg_query_cache/_async_backend.py +101 -0
  4. sg_query_cache/_async_cache.py +1240 -0
  5. sg_query_cache/_async_client.py +1390 -0
  6. sg_query_cache/_async_local.py +318 -0
  7. sg_query_cache/_async_support.py +314 -0
  8. sg_query_cache/_async_valkey_control.py +580 -0
  9. sg_query_cache/_backend.py +168 -0
  10. sg_query_cache/_backend_validation.py +110 -0
  11. sg_query_cache/_cache.py +1608 -0
  12. sg_query_cache/_cache_common.py +387 -0
  13. sg_query_cache/_cli.py +313 -0
  14. sg_query_cache/_client.py +1061 -0
  15. sg_query_cache/_client_common.py +600 -0
  16. sg_query_cache/_codec.py +345 -0
  17. sg_query_cache/_config.py +1217 -0
  18. sg_query_cache/_dependencies.py +888 -0
  19. sg_query_cache/_downstream.py +141 -0
  20. sg_query_cache/_event_mapping.py +409 -0
  21. sg_query_cache/_keys.py +298 -0
  22. sg_query_cache/_local.py +301 -0
  23. sg_query_cache/_local_store.py +604 -0
  24. sg_query_cache/_metrics.py +200 -0
  25. sg_query_cache/_model.py +546 -0
  26. sg_query_cache/_policy.py +378 -0
  27. sg_query_cache/_query.py +53 -0
  28. sg_query_cache/_urls.py +324 -0
  29. sg_query_cache/_validation.py +70 -0
  30. sg_query_cache/_valkey_common.py +1037 -0
  31. sg_query_cache/_valkey_control.py +437 -0
  32. sg_query_cache/_valkey_proof.py +64 -0
  33. sg_query_cache/_valkey_scripts.py +454 -0
  34. sg_query_cache/daemon_plugin.py +159 -0
  35. sg_query_cache/py.typed +0 -0
  36. sg_query_cache/valkey.py +1533 -0
  37. sg_query_cache/valkey_async.py +1568 -0
  38. sg_query_cache-0.1.0.dist-info/METADATA +247 -0
  39. sg_query_cache-0.1.0.dist-info/RECORD +42 -0
  40. sg_query_cache-0.1.0.dist-info/WHEEL +4 -0
  41. sg_query_cache-0.1.0.dist-info/entry_points.txt +2 -0
  42. 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