dialcache 0.25.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.
dialcache/cache.py ADDED
@@ -0,0 +1,1185 @@
1
+ """Async DialCache engine: admission, captured policy, traversal and ownership.
2
+
3
+ The implementation follows formal/SPEC.md. External work is kept alive when a
4
+ deadline stops a caller waiting: timing out never grants late work permission
5
+ to publish a value or cancels another caller's shared source.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextvars
12
+ import functools
13
+ import inspect
14
+ import json
15
+ import logging
16
+ import math
17
+ from collections.abc import Awaitable, Callable, Mapping
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, ParamSpec, TypeVar
20
+
21
+ from .clock import SystemClock
22
+ from .config import UNSET, Policy, _valid_ramp, merge_policy, resolve_layer, validate_static_policy
23
+ from .context import DialCacheContext
24
+ from .errors import (
25
+ ConfigError,
26
+ FallbackTimeoutError,
27
+ MissingRemoteError,
28
+ RemoteReadTimeoutError,
29
+ UseCaseIsAlreadyRegisteredError,
30
+ UseCaseNameIsReservedError,
31
+ )
32
+ from .key import Key, invalidation_prefix, normalize_args, ramp_sample
33
+ from .local import LocalCache
34
+ from .metrics import Metrics, emit_metric
35
+ from .protocol import Frame, Miss, compress_payload, decompress_payload, escape_raw_payload, utf8_bytes
36
+ from .redis import InvalidationRequest, ReadContext, ReadRequest, WriteRequest
37
+ from .serializer import JsonSerializer
38
+
39
+ T = TypeVar("T")
40
+ P = ParamSpec("P")
41
+ MAX_SAFE = 9_007_199_254_740_991
42
+
43
+
44
+ async def _await(value: Any) -> Any:
45
+ return await value if inspect.isawaitable(value) else value
46
+
47
+
48
+ async def _call_dependency(callback: Callable[..., Any], *args: Any) -> Any:
49
+ """Classify dependency cancellation without swallowing cancellation of this task."""
50
+ try:
51
+ return await _await(callback(*args))
52
+ except asyncio.CancelledError as error:
53
+ task = asyncio.current_task()
54
+ if task is not None and task.cancelling():
55
+ raise
56
+ raise RuntimeError("DialCache dependency was cancelled") from error
57
+
58
+
59
+ def _valid_integer(value: Any, minimum: int = 0, maximum: int = MAX_SAFE) -> bool:
60
+ return (
61
+ isinstance(value, (int, float))
62
+ and not isinstance(value, bool)
63
+ and minimum <= value <= maximum
64
+ and math.isfinite(value)
65
+ and int(value) == value
66
+ )
67
+
68
+
69
+ def _deep_equal(left: Any, right: Any) -> bool:
70
+ """Keep booleans distinct from numbers in JSON-like semantic comparisons."""
71
+ if isinstance(left, bool) or isinstance(right, bool):
72
+ return left is right
73
+ if isinstance(left, (int, float)) and isinstance(right, (int, float)):
74
+ if left == 0 and right == 0:
75
+ return math.copysign(1, left) == math.copysign(1, right)
76
+ return left == right or (
77
+ isinstance(left, float) and isinstance(right, float) and math.isnan(left) and math.isnan(right)
78
+ )
79
+ if type(left) is not type(right):
80
+ return False
81
+ if isinstance(left, dict):
82
+ return left.keys() == right.keys() and all(_deep_equal(left[k], right[k]) for k in left)
83
+ if isinstance(left, (list, tuple)):
84
+ return len(left) == len(right) and all(_deep_equal(a, b) for a, b in zip(left, right))
85
+ return left == right
86
+
87
+
88
+ def _budget(value: Any) -> int | None:
89
+ if value is None:
90
+ return None
91
+ if not _valid_integer(value, 1, 2_147_483_647):
92
+ raise ConfigError("fallback_timeout_ms must be None or a positive integer <= 2147483647")
93
+ return int(value)
94
+
95
+
96
+ class AbortSignal:
97
+ """Cooperative read cancellation; adapters may register an abort callback."""
98
+
99
+ def __init__(self) -> None:
100
+ self.aborted = False
101
+ self._callbacks: list[Callable[[], Any]] = []
102
+
103
+ def add_callback(self, callback: Callable[[], Any]) -> None:
104
+ if self.aborted:
105
+ callback()
106
+ else:
107
+ self._callbacks.append(callback)
108
+
109
+ def abort(self) -> None:
110
+ if self.aborted:
111
+ return
112
+ self.aborted = True
113
+ callbacks, self._callbacks = self._callbacks, []
114
+ for callback in callbacks:
115
+ try:
116
+ callback()
117
+ except (Exception, asyncio.CancelledError):
118
+ pass
119
+
120
+
121
+ @dataclass
122
+ class _Flight:
123
+ task: asyncio.Future[Any]
124
+ started: float
125
+ owner: _Operation
126
+ followers: int = 0
127
+
128
+
129
+ @dataclass
130
+ class _Operation:
131
+ load: Callable[[], Any]
132
+ select_key: Callable[[], Any]
133
+ key_type: str
134
+ use_case: str
135
+ policy: Policy
136
+ timeout: int | None
137
+ serializer: Any
138
+ tracked: bool
139
+ comparator: Callable[[Any, Any], bool]
140
+ recovery: Callable[[BaseException], bool]
141
+ did_timeout: bool = False
142
+ deliveries: set[asyncio.Future[Any]] = field(default_factory=set)
143
+
144
+
145
+ @dataclass
146
+ class _Shadow:
147
+ started: float
148
+ budget: int
149
+ pending_reads: set[asyncio.Task[Any]] = field(default_factory=set)
150
+ finished: bool = False
151
+ abandoned: bool = False
152
+
153
+
154
+ class DialCache:
155
+ """Explicitly enabled caching for one asyncio event loop.
156
+
157
+ The application owns Redis connections. Instances have independent scopes,
158
+ LRU storage and in-flight tables. Values held in memory are shared by
159
+ reference and should be treated as immutable.
160
+ """
161
+
162
+ def __init__(
163
+ self,
164
+ *,
165
+ namespace: str = "urn",
166
+ redis: Any = None,
167
+ policy_provider: Callable[[Key], Any] | None = None,
168
+ metrics: Metrics | None = None,
169
+ logger: Any = None,
170
+ clock: Any = None,
171
+ local_max_size: int = 10_000,
172
+ local_store: Any = None,
173
+ shadow_max_in_flight: int = 1,
174
+ read_timeout_ms: int = 50,
175
+ should_attempt_stale_recovery: Callable[[BaseException], bool] | None = None,
176
+ serializer: Any = None,
177
+ compression: Any = True,
178
+ ) -> None:
179
+ if not isinstance(namespace, str) or "{" in namespace or "}" in namespace:
180
+ raise ConfigError("namespace must be a string without braces")
181
+ if not _valid_integer(local_max_size):
182
+ raise ConfigError("local_max_size must be a nonnegative safe integer")
183
+ if not _valid_integer(shadow_max_in_flight, 1):
184
+ raise ConfigError("shadow_max_in_flight must be a positive safe integer")
185
+ if not _valid_integer(read_timeout_ms, 1, 2_147_483_647):
186
+ raise ConfigError("read_timeout_ms must be a positive bounded integer")
187
+ if should_attempt_stale_recovery is not None and not callable(should_attempt_stale_recovery):
188
+ raise ConfigError("should_attempt_stale_recovery must be callable")
189
+ if compression is not True and compression is not False and not isinstance(compression, Mapping):
190
+ raise ConfigError("compression must be True, False, or an options mapping")
191
+ if isinstance(compression, Mapping):
192
+ compression = dict(compression)
193
+ if set(compression) - {"threshold_bytes", "level"}:
194
+ raise ConfigError("compression supports threshold_bytes and level")
195
+ if not _valid_integer(compression.get("threshold_bytes", 4096), 1):
196
+ raise ConfigError("compression.threshold_bytes must be a positive safe integer")
197
+ if not _valid_integer(compression.get("level", 3), 1, 22):
198
+ raise ConfigError("compression.level must be an integer from 1 through 22")
199
+ self.namespace, self.redis = namespace, redis
200
+ self.policy_provider, self.metrics = policy_provider, metrics
201
+ self.logger = logger or logging.getLogger("dialcache")
202
+ self.clock = clock or SystemClock()
203
+ self._context = DialCacheContext()
204
+ self._local = local_store if local_store is not None else LocalCache(local_max_size, self.clock)
205
+ self._flights: dict[str, _Flight] = {}
206
+ self._shadows: dict[str, _Shadow] = {}
207
+ self._registered: set[str] = set()
208
+ self._tasks: set[asyncio.Task[Any]] = set()
209
+ self._shadow_max = shadow_max_in_flight
210
+ self.read_timeout_ms = read_timeout_ms
211
+ self.serializer = serializer or JsonSerializer()
212
+ self.compression = compression
213
+ self._recovery = should_attempt_stale_recovery or (
214
+ lambda error: isinstance(error, FallbackTimeoutError)
215
+ )
216
+
217
+ def enable(self, enabled: bool = True) -> Any:
218
+ """Enable a request scope; nested scopes share the live outer memo."""
219
+ return self._context.enable() if enabled else self._context.disable()
220
+
221
+ def disable(self) -> Any:
222
+ """Temporarily disable caching, preserving a live outer request memo."""
223
+ return self._context.disable()
224
+
225
+ def is_enabled(self) -> bool:
226
+ return self._context.is_enabled()
227
+
228
+ def get_coalescing_state(self) -> dict[str, Any]:
229
+ """Report live process leaders, followers and oldest leader age."""
230
+ return {
231
+ "process": {
232
+ "active_leaders": len(self._flights),
233
+ "active_followers": sum(f.followers for f in self._flights.values()),
234
+ "oldest_leader_age_ms": max(
235
+ 0, self.clock.monotonic_ms() - next(iter(self._flights.values())).started
236
+ )
237
+ if self._flights
238
+ else None,
239
+ }
240
+ }
241
+
242
+ def cached(
243
+ self,
244
+ *,
245
+ key_type: str,
246
+ cache_key: Callable[..., Any] | None = None,
247
+ id_arg: str | tuple[str, Callable[[Any], Any]] | None = None,
248
+ use_case: str | None = None,
249
+ arg_adapters: Mapping[str, Callable[[Any], Any]] | None = None,
250
+ ignore_args: list[str] | tuple[str, ...] = (),
251
+ **options: Any,
252
+ ) -> Callable[..., Any]:
253
+ """Decorate a loader using an explicit selector or gcache-style arguments.
254
+
255
+ The wrapper is always awaitable, including for a synchronous loader.
256
+ Key callbacks and argument adapters are never called while disabled.
257
+ """
258
+ if (cache_key is None) == (id_arg is None):
259
+ raise ConfigError("Supply exactly one of cache_key or id_arg")
260
+
261
+ def decorate(fn: Callable[P, T | Awaitable[T]]) -> Callable[P, Awaitable[T]]:
262
+ name = use_case or f"{fn.__module__}.{fn.__qualname__}"
263
+ self._check_use_case(name)
264
+ if name in self._registered:
265
+ raise UseCaseIsAlreadyRegisteredError(name)
266
+ signature = inspect.signature(fn)
267
+ adapters = dict(arg_adapters or {})
268
+ ignored = frozenset(ignore_args)
269
+ # Validate/snapshot static settings once, before reserving the name.
270
+ prototype = self._operation(lambda: None, lambda: None, key_type, name, **options)
271
+ id_name = id_arg[0] if isinstance(id_arg, tuple) else id_arg
272
+ if id_name is not None and id_name not in signature.parameters:
273
+ raise ConfigError(f"id_arg does not name a function parameter: {id_name}")
274
+ if any(n not in signature.parameters for n in (*adapters, *ignored)):
275
+ raise ConfigError("arg_adapters and ignore_args must name function parameters")
276
+ self._registered.add(name)
277
+
278
+ @functools.wraps(fn)
279
+ async def wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
280
+ def select() -> Any:
281
+ if cache_key is not None:
282
+ return cache_key(*args, **kwargs)
283
+ bound = signature.bind(*args, **kwargs)
284
+ bound.apply_defaults()
285
+ entity_id = bound.arguments[id_name]
286
+ if isinstance(id_arg, tuple):
287
+ entity_id = id_arg[1](entity_id)
288
+ key_args = {
289
+ n: adapters[n](v) if n in adapters else v
290
+ for n, v in bound.arguments.items()
291
+ if (n != "self" or n in adapters)
292
+ and n not in ignored
293
+ and (n != id_name or n in adapters)
294
+ }
295
+ return {"id": entity_id, "args": key_args}
296
+
297
+ op = _Operation(
298
+ lambda: fn(*args, **kwargs),
299
+ select,
300
+ prototype.key_type,
301
+ name,
302
+ prototype.policy,
303
+ prototype.timeout,
304
+ prototype.serializer,
305
+ prototype.tracked,
306
+ prototype.comparator,
307
+ prototype.recovery,
308
+ )
309
+ return await self._execute(op)
310
+
311
+ return wrapped
312
+
313
+ return decorate
314
+
315
+ async def get_or_load(
316
+ self,
317
+ load: Callable[[], T | Awaitable[T]],
318
+ *,
319
+ key: Any = None,
320
+ key_type: str,
321
+ use_case: str,
322
+ key_selector: Callable[[], Any] | None = None,
323
+ **options: Any,
324
+ ) -> T:
325
+ """Read a key or run its loader. Repeated inline use-case names are valid."""
326
+ op = self._operation(load, key_selector or (lambda: key), key_type, use_case, **options)
327
+ return await self._execute(op)
328
+
329
+ async def aget(self, key: Key, fallback: Callable[[], Any], **options: Any) -> Any:
330
+ """Structured-key form of get_or_load, familiar to gcache callers."""
331
+ return await self.get_or_load(
332
+ fallback, key=key, key_type=key.key_type, use_case=key.use_case, **options
333
+ )
334
+
335
+ def _operation(
336
+ self,
337
+ load: Callable[[], Any],
338
+ select: Callable[[], Any],
339
+ key_type: str,
340
+ use_case: str,
341
+ *,
342
+ default_config: Any = None,
343
+ fallback_timeout_ms: Any = 60_000,
344
+ serializer: Any = None,
345
+ track_for_invalidation: bool = False,
346
+ shadow_comparator: Any = None,
347
+ should_attempt_stale_recovery: Any = None,
348
+ ) -> _Operation:
349
+ self._check_use_case(use_case)
350
+ policy = validate_static_policy(default_config) or Policy()
351
+ comparator = shadow_comparator if shadow_comparator is not None else _deep_equal
352
+ recovery = (
353
+ should_attempt_stale_recovery if should_attempt_stale_recovery is not None else self._recovery
354
+ )
355
+ if not callable(comparator) or not callable(recovery):
356
+ raise ConfigError("Comparator and recovery predicate must be callable")
357
+ return _Operation(
358
+ load,
359
+ select,
360
+ key_type,
361
+ use_case,
362
+ policy,
363
+ _budget(fallback_timeout_ms),
364
+ serializer or self.serializer,
365
+ track_for_invalidation,
366
+ comparator,
367
+ recovery,
368
+ )
369
+
370
+ @staticmethod
371
+ def _check_use_case(name: str) -> None:
372
+ if name == "watermark":
373
+ raise UseCaseNameIsReservedError(name)
374
+
375
+ def _spawn(self, work: Awaitable[Any]) -> asyncio.Task[Any]:
376
+ task = asyncio.ensure_future(work)
377
+ self._tasks.add(task)
378
+
379
+ def consume(done: asyncio.Task[Any]) -> None:
380
+ self._tasks.discard(done)
381
+ if not done.cancelled():
382
+ done.exception()
383
+
384
+ task.add_done_callback(consume)
385
+ return task
386
+
387
+ def _emit(self, event: str, labels: Mapping[str, Any], **fields: Any) -> None:
388
+ emit_metric(self.metrics, {"event": event, **labels, **fields})
389
+
390
+ def _log(self, message: str, error: Any = None) -> None:
391
+ try:
392
+ self.logger.warning(message, error) if error is not None else self.logger.warning(message)
393
+ except (Exception, asyncio.CancelledError):
394
+ pass
395
+
396
+ @staticmethod
397
+ def _discard_awaitable(value: Any) -> None:
398
+ # Recovery predicates are synchronous. A rejected result must not
399
+ # start work or take ownership of an application's existing task.
400
+ if inspect.iscoroutine(value):
401
+ if inspect.getcoroutinestate(value) == inspect.CORO_CREATED:
402
+ value.close()
403
+ elif asyncio.isfuture(value):
404
+
405
+ def consume(done: asyncio.Future[Any]) -> None:
406
+ if not done.cancelled():
407
+ done.exception()
408
+
409
+ value.add_done_callback(consume, context=contextvars.Context())
410
+
411
+ def _labels(self, key: Key | _Operation, layer: str | None = None) -> dict[str, Any]:
412
+ labels = {"cacheNamespace": self.namespace, "useCase": key.use_case, "keyType": key.key_type}
413
+ if layer is not None:
414
+ labels["layer"] = layer
415
+ return labels
416
+
417
+ def _error(self, key: Any, layer: str, kind: str) -> None:
418
+ self._emit("error", self._labels(key, layer), error=kind, inFallback=False)
419
+
420
+ def _seconds(self, start: float) -> float:
421
+ return max(0, self.clock.monotonic_ms() - start) / 1000
422
+
423
+ async def _deadline(
424
+ self,
425
+ pending: asyncio.Future[Any],
426
+ budget: int | None,
427
+ error: Callable[[], Exception],
428
+ *,
429
+ started: float | None = None,
430
+ on_timeout: Callable[[], Any] | None = None,
431
+ ) -> Any:
432
+ if budget is None:
433
+ return await asyncio.shield(pending)
434
+ start = self.clock.monotonic_ms() if started is None else started
435
+ result = asyncio.get_running_loop().create_future()
436
+ handle: Any = None
437
+
438
+ def timeout() -> None:
439
+ nonlocal handle
440
+ if result.done():
441
+ return
442
+ remaining = budget - max(0, self.clock.monotonic_ms() - start)
443
+ if remaining > 0:
444
+ handle = self.clock.call_later(math.ceil(remaining), timeout)
445
+ return
446
+ if on_timeout is not None:
447
+ try:
448
+ on_timeout()
449
+ except (Exception, asyncio.CancelledError):
450
+ pass
451
+ result.set_exception(error())
452
+
453
+ def settled(done: asyncio.Future[Any]) -> None:
454
+ if result.done():
455
+ return
456
+ if max(0, self.clock.monotonic_ms() - start) >= budget:
457
+ timeout()
458
+ elif done.cancelled():
459
+ result.cancel()
460
+ elif done.exception() is not None:
461
+ result.set_exception(done.exception())
462
+ else:
463
+ result.set_result(done.result())
464
+
465
+ pending.add_done_callback(settled)
466
+ remaining = budget - max(0, self.clock.monotonic_ms() - start)
467
+ handle = self.clock.call_later(max(0, math.ceil(remaining)), timeout)
468
+ try:
469
+ return await result
470
+ finally:
471
+ handle.cancel()
472
+ pending.remove_done_callback(settled)
473
+
474
+ async def _source(self, op: _Operation, layer: str) -> Any:
475
+ invoked = asyncio.get_running_loop().create_future()
476
+
477
+ async def invoke() -> Any:
478
+ invoked.set_result(self.clock.monotonic_ms())
479
+ return await _await(op.load())
480
+
481
+ pending = self._spawn(invoke())
482
+ start = await asyncio.shield(invoked)
483
+
484
+ def timeout_error() -> Exception:
485
+ op.did_timeout = True
486
+ return FallbackTimeoutError(op.use_case, op.timeout)
487
+
488
+ try:
489
+ return await self._deadline(pending, op.timeout, timeout_error, started=start)
490
+ except Exception:
491
+ self._emit("error", self._labels(op, layer), error="fallback", inFallback=True)
492
+ raise
493
+ finally:
494
+ self._emit("fallback", self._labels(op, layer), seconds=self._seconds(start))
495
+
496
+ async def _execute(self, op: _Operation) -> Any:
497
+ if not self.is_enabled():
498
+ self._emit("disabled", self._labels(op, "noop"), reason="context")
499
+ return await _await(op.load())
500
+ delivered = asyncio.get_running_loop().create_future()
501
+ self._track_delivery(op.deliveries, delivered)
502
+ try:
503
+ return await self._execute_enabled(op)
504
+ finally:
505
+ delivered.set_result(None)
506
+ # A cancelled task's traceback can outlive the invocation (notably
507
+ # with native shield bookkeeping). It need not retain this marker.
508
+ del delivered
509
+
510
+ @staticmethod
511
+ def _track_delivery(group: set[asyncio.Future[Any]], pending: asyncio.Future[Any]) -> None:
512
+ if not pending.done() and pending not in group:
513
+ group.add(pending)
514
+ # Register on each destination during a group transfer as well:
515
+ # cancelled followers must not accumulate behind unbounded work.
516
+ pending.add_done_callback(group.discard)
517
+
518
+ @staticmethod
519
+ async def _await_delivery(op: _Operation) -> None:
520
+ while pending := {future for future in op.deliveries if not future.done()}:
521
+ # wait() observes completion without cancelling inputs or raising
522
+ # their errors. Recheck for followers added while we were waiting.
523
+ await asyncio.wait(pending)
524
+
525
+ def _join_delivery(self, op: _Operation, owner: _Operation) -> None:
526
+ group = owner.deliveries
527
+ if op.deliveries is not group:
528
+ for pending in op.deliveries:
529
+ self._track_delivery(group, pending)
530
+ op.deliveries = group
531
+
532
+ async def _execute_enabled(self, op: _Operation) -> Any:
533
+ try:
534
+ selected = op.select_key()
535
+ if isinstance(selected, Key):
536
+ key = selected
537
+ if key.namespace != self.namespace:
538
+ raise ValueError("Key namespace differs from cache namespace")
539
+ else:
540
+ spec = selected if isinstance(selected, Mapping) else {"id": selected}
541
+ key = Key(
542
+ self.namespace,
543
+ op.key_type,
544
+ spec["id"],
545
+ op.use_case,
546
+ normalize_args(spec.get("args", {})),
547
+ op.tracked,
548
+ )
549
+ except (Exception, asyncio.CancelledError) as error:
550
+ self._error(op, "noop", "key_construction")
551
+ self._log("Could not construct DialCache key: %s", error)
552
+ return await self._source(op, "noop")
553
+ try:
554
+ overlay = (
555
+ await _call_dependency(self.policy_provider, key)
556
+ if self.policy_provider is not None
557
+ else None
558
+ )
559
+ policy = merge_policy(op.policy, overlay) or Policy()
560
+ except Exception as error:
561
+ self._error(key, "noop", "config_resolution")
562
+ self._emit("disabled", self._labels(key, "noop"), reason="config_error")
563
+ self._log("Could not resolve DialCache policy: %s", error)
564
+ return await self._source(op, "noop")
565
+ if not self.is_enabled():
566
+ self._emit("disabled", self._labels(key, "noop"), reason="context")
567
+ return await self._source(op, "noop")
568
+ memo = self._context.request_cache() if policy.request_local is True else None
569
+ if memo is None:
570
+ return await self._shared(op, key, policy, "local")
571
+
572
+ async def request() -> Any:
573
+ start = self.clock.monotonic_ms()
574
+ found, value = memo.read(key.logical)
575
+ self._emit("request", self._labels(key, "request_local"))
576
+ self._emit("get", self._labels(key, "request_local"), seconds=self._seconds(start))
577
+ if found:
578
+ return value
579
+ self._emit("miss", self._labels(key, "request_local"), reason="value_absent")
580
+ value = await self._shared(op, key, policy, "request_local")
581
+ memo.set(key.logical, value)
582
+ return value
583
+
584
+ if policy.coalesce is False:
585
+ return await request()
586
+ return await self._single_flight(memo.in_flight, key, request, "request_local", op)
587
+
588
+ async def _single_flight(
589
+ self, table: dict[str, Any], key: Key, run: Callable[[], Awaitable[Any]], scope: str, op: _Operation
590
+ ) -> Any:
591
+ existing = table.get(key.logical)
592
+ if existing is not None:
593
+ self._join_delivery(op, existing.owner)
594
+ existing.followers += 1
595
+ self._emit("coalesced", self._labels(key), scope=scope)
596
+ return await asyncio.shield(existing.task)
597
+
598
+ # Publish the result holder before scheduling the leader. Python's
599
+ # eager task factory can run a complete cache hit inside create_task.
600
+ # Followers (including reentrant observers) must already have a valid
601
+ # result to join, and completion must never resurrect a settled flight.
602
+ flight = _Flight(asyncio.get_running_loop().create_future(), self.clock.monotonic_ms(), op)
603
+ # Keep the barrier closed if the original public caller cancels while
604
+ # this leader remains joinable by later callers.
605
+ self._track_delivery(op.deliveries, flight.task)
606
+ flight.task.add_done_callback(lambda done: None if done.cancelled() else done.exception())
607
+ table[key.logical] = flight
608
+
609
+ async def lead() -> Any:
610
+ try:
611
+ return await run()
612
+ finally:
613
+ if table.get(key.logical) is flight:
614
+ del table[key.logical]
615
+
616
+ def transfer(done: asyncio.Task[Any]) -> None:
617
+ if done.cancelled():
618
+ flight.task.cancel()
619
+ elif done.exception() is not None:
620
+ flight.task.set_exception(done.exception())
621
+ else:
622
+ flight.task.set_result(done.result())
623
+
624
+ self._spawn(lead()).add_done_callback(transfer)
625
+ return await asyncio.shield(flight.task)
626
+
627
+ def _layer(self, key: Key, policy: Policy, name: str) -> Any:
628
+ layer = resolve_layer(policy, key.logical, name)
629
+ if getattr(layer, "stale_on_error_config_error", False):
630
+ self._error(key, name, "config_resolution")
631
+ if not layer.enabled:
632
+ self._emit("disabled", self._labels(key, name), reason=layer.reason)
633
+ if layer.reason in ("invalid_ttl", "invalid_ramp"):
634
+ self._error(key, name, "config_resolution")
635
+ return layer
636
+
637
+ async def _shared(self, op: _Operation, key: Key, policy: Policy, fallback_layer: str) -> Any:
638
+ local = self._layer(key, policy, "local")
639
+ if local.enabled:
640
+
641
+ async def run() -> Any:
642
+ start = self.clock.monotonic_ms()
643
+ can_put = True
644
+ try:
645
+ found, value = self._local.read(key.logical)
646
+ self._emit("request", self._labels(key, "local"))
647
+ self._emit("get", self._labels(key, "local"), seconds=self._seconds(start))
648
+ if found:
649
+ return value
650
+ self._emit("miss", self._labels(key, "local"), reason="value_absent")
651
+ except (Exception, asyncio.CancelledError):
652
+ can_put = False
653
+ self._error(key, "local", "cache_read")
654
+ self._emit("disabled", self._labels(key, "local"), reason="config_error")
655
+ return await self._lower(op, key, policy, local if can_put else None, "local")
656
+
657
+ return (
658
+ await run()
659
+ if policy.coalesce is False
660
+ else await self._single_flight(self._flights, key, run, "process", op)
661
+ )
662
+ if self.redis is None:
663
+ return await self._source(op, fallback_layer)
664
+ remote = self._layer(key, policy, "remote")
665
+ if not remote.enabled:
666
+ return await self._disabled_remote(op, key, policy, None, remote, fallback_layer)
667
+
668
+ async def run_remote() -> Any:
669
+ return await self._remote_chain(op, key, policy, None, remote)
670
+
671
+ return (
672
+ await run_remote()
673
+ if policy.coalesce is False
674
+ else await self._single_flight(self._flights, key, run_remote, "process", op)
675
+ )
676
+
677
+ async def _lower(self, op: _Operation, key: Key, policy: Policy, local: Any, fallback_layer: str) -> Any:
678
+ if self.redis is None:
679
+ value = await self._source(op, fallback_layer)
680
+ self._put_local(key, value, local)
681
+ return value
682
+ remote = self._layer(key, policy, "remote")
683
+ if not remote.enabled:
684
+ return await self._disabled_remote(op, key, policy, local, remote, fallback_layer)
685
+ return await self._remote_chain(op, key, policy, local, remote)
686
+
687
+ async def _disabled_remote(
688
+ self, op: _Operation, key: Key, policy: Policy, local: Any, remote: Any, fallback_layer: str
689
+ ) -> Any:
690
+ start = self.clock.monotonic_ms()
691
+ source = self._spawn(self._source(op, fallback_layer))
692
+ if remote.reason == "ramped_down":
693
+ self._schedule_shadow(op, key, policy, remote, source=source, started=start)
694
+ value = await asyncio.shield(source)
695
+ self._put_local(key, value, local)
696
+ return value
697
+
698
+ def _put_local(self, key: Key, value: Any, local: Any) -> None:
699
+ if local is not None:
700
+ try:
701
+ self._local.put(key.logical, value, local.ttl_sec)
702
+ except (Exception, asyncio.CancelledError):
703
+ self._error(key, "local", "cache_write")
704
+
705
+ def _read_budget(self, policy: Policy) -> int:
706
+ return (
707
+ self.read_timeout_ms if policy.remote_read_timeout_ms is UNSET else policy.remote_read_timeout_ms
708
+ )
709
+
710
+ async def _raw_read(self, key: Key, policy: Policy, job: _Shadow | None = None) -> Frame | Miss:
711
+ budget = self._read_budget(policy)
712
+ signal = AbortSignal()
713
+ invoked = asyncio.get_running_loop().create_future()
714
+
715
+ async def invoke() -> Any:
716
+ invoked.set_result(self.clock.monotonic_ms())
717
+ return await _call_dependency(
718
+ self.redis.read, ReadRequest(key.value_key, key.watermark_key), ReadContext(budget, signal)
719
+ )
720
+
721
+ pending = self._spawn(invoke())
722
+ if job is not None:
723
+ job.pending_reads.add(pending)
724
+
725
+ def finished(done: asyncio.Task[Any]) -> None:
726
+ job.pending_reads.discard(done)
727
+ self._release_shadow(key, job)
728
+
729
+ pending.add_done_callback(finished)
730
+ start = await asyncio.shield(invoked)
731
+ value = await self._deadline(
732
+ pending,
733
+ budget,
734
+ lambda: RemoteReadTimeoutError(key.use_case, budget),
735
+ started=start,
736
+ on_timeout=signal.abort,
737
+ )
738
+ if isinstance(value, Miss):
739
+ fence = (
740
+ value.observed_watermark_ms
741
+ if key.tracked and _valid_integer(value.observed_watermark_ms)
742
+ else None
743
+ )
744
+ reason = (
745
+ value.reason
746
+ if value.reason in ("value_absent", "expired", "watermark_fenced", "unclassified")
747
+ else "unclassified"
748
+ )
749
+ if reason == "watermark_fenced" and fence is None:
750
+ reason = "unclassified"
751
+ return Miss(reason, fence)
752
+ return value if isinstance(value, Frame) else Miss("unclassified")
753
+
754
+ def _age(self, key: Key, frame: Frame, layer: str) -> float | None:
755
+ if not _valid_integer(frame.created_at_ms):
756
+ return None
757
+ age = self.clock.wall_ms() - frame.created_at_ms
758
+ if age < 0:
759
+ self._emit("futureOffset", self._labels(key, layer), seconds=-age / 1000)
760
+ return age
761
+
762
+ async def _decode(self, op: _Operation, key: Key, payload: Any, layer: str) -> Any:
763
+ decompressed = decompress_payload(payload)
764
+ if decompressed.outcome != "passthrough":
765
+ self._emit("compression", self._labels(key, layer), outcome=decompressed.outcome)
766
+ start = self.clock.monotonic_ms()
767
+ try:
768
+ return await _call_dependency(op.serializer.load, decompressed.payload)
769
+ except Exception:
770
+ self._error(key, layer, "serialization_load")
771
+ raise
772
+ finally:
773
+ self._emit(
774
+ "serialization", self._labels(key, layer), operation="load", seconds=self._seconds(start)
775
+ )
776
+
777
+ async def _serving_read(
778
+ self, op: _Operation, key: Key, policy: Policy, remote: Any
779
+ ) -> tuple[str, Any, Any]:
780
+ start = self.clock.monotonic_ms()
781
+ labels = self._labels(key, "remote")
782
+ self._emit("request", labels)
783
+ try:
784
+ try:
785
+ read = await self._raw_read(key, policy)
786
+ except Exception as error:
787
+ self._error(
788
+ key,
789
+ "remote",
790
+ "cache_read_timeout" if isinstance(error, RemoteReadTimeoutError) else "cache_read",
791
+ )
792
+ return "error", None, None
793
+ if isinstance(read, Miss):
794
+ self._emit("miss", labels, reason=read.reason)
795
+ return "miss", None, read.observed_watermark_ms
796
+ age = self._age(key, read, "remote")
797
+ maximum = remote.stale_on_error_max_age_sec or remote.ttl_sec
798
+ if age is None or age < 0:
799
+ self._emit("miss", labels, reason="unclassified")
800
+ return "miss", None, None
801
+ if age >= remote.ttl_sec * 1000:
802
+ self._emit("miss", labels, reason="expired")
803
+ return ("retained", read, None) if age < maximum * 1000 else ("miss", None, None)
804
+ try:
805
+ value = await self._decode(op, key, read.payload, "remote")
806
+ return "hit", (value, read), None
807
+ except Exception:
808
+ self._emit("miss", labels, reason="unclassified")
809
+ return "decode_error", None, None
810
+ finally:
811
+ self._emit("get", labels, seconds=self._seconds(start))
812
+
813
+ async def _remote_chain(self, op: _Operation, key: Key, policy: Policy, local: Any, remote: Any) -> Any:
814
+ status, acquired, fence = await self._serving_read(op, key, policy, remote)
815
+ if status == "hit":
816
+ value, frame = acquired
817
+ self._put_local(key, value, local)
818
+ self._schedule_shadow(op, key, policy, remote, frame=frame)
819
+ return value
820
+ try:
821
+ value = await self._source(op, "remote")
822
+ except Exception as error:
823
+ maximum = remote.stale_on_error_max_age_sec
824
+ if maximum and status in ("miss", "retained"):
825
+ try:
826
+ try:
827
+ allow = op.recovery(error)
828
+ except asyncio.CancelledError:
829
+ # Only the synchronous predicate is isolated here;
830
+ # caller cancellation during recovery must propagate.
831
+ allow = False
832
+ if allow is True:
833
+ present, value = await self._recover(op, key, acquired, maximum)
834
+ if present:
835
+ return value
836
+ else:
837
+ self._discard_awaitable(allow)
838
+ except Exception:
839
+ pass
840
+ raise
841
+ if status != "error":
842
+ try:
843
+ await self._write(op, key, value, remote, "remote", fence)
844
+ except Exception:
845
+ pass
846
+ if not key.tracked:
847
+ self._put_local(key, value, local)
848
+ return value
849
+
850
+ async def _recover(self, op: _Operation, key: Key, frame: Frame | None, maximum: int) -> tuple[bool, Any]:
851
+ def record(outcome: str, age: float | None = None) -> None:
852
+ self._emit("staleRecovery", self._labels(key), outcome=outcome)
853
+ if age is not None:
854
+ self._emit("recoveryAge", self._labels(key), outcome=outcome, seconds=age / 1000)
855
+
856
+ age = self._age(key, frame, "remote") if frame is not None else None
857
+ if age is None or age < 0 or age >= maximum * 1000:
858
+ record("miss")
859
+ return False, None
860
+ try:
861
+ value = await self._decode(op, key, frame.payload, "remote")
862
+ except Exception:
863
+ record("deserialization_error")
864
+ return False, None
865
+ age = self._age(key, frame, "remote")
866
+ if age is None or age < 0 or age >= maximum * 1000:
867
+ record("miss")
868
+ return False, None
869
+ record("served", age)
870
+ return True, value
871
+
872
+ async def _write(
873
+ self,
874
+ op: _Operation,
875
+ key: Key,
876
+ value: Any,
877
+ remote: Any,
878
+ layer: str,
879
+ fence: int | None,
880
+ live: Callable[[], bool] | None = None,
881
+ ) -> bool:
882
+ labels = self._labels(key, layer)
883
+ if not key.tracked:
884
+ fence = None
885
+ if fence is not None and self.clock.wall_ms() <= fence:
886
+ return False
887
+ start = self.clock.monotonic_ms()
888
+ try:
889
+ payload = await _call_dependency(op.serializer.dump, value)
890
+ if not isinstance(payload, (str, bytes)):
891
+ raise TypeError("Serializer.dump must return str or bytes")
892
+ except Exception:
893
+ self._error(key, layer, "serialization_dump")
894
+ raise
895
+ finally:
896
+ self._emit("serialization", labels, operation="dump", seconds=self._seconds(start))
897
+ size = len(utf8_bytes(payload) if isinstance(payload, str) else payload)
898
+ self._emit("size", labels, bytes=size)
899
+ try:
900
+ if self.compression is False:
901
+ payload = escape_raw_payload(payload)
902
+ stored_size = len(utf8_bytes(payload) if isinstance(payload, str) else payload)
903
+ else:
904
+ options = self.compression if isinstance(self.compression, Mapping) else {}
905
+ compressed = compress_payload(payload, **options)
906
+ payload = compressed.payload
907
+ stored_size = compressed.stored_bytes
908
+ self._emit("compression", labels, outcome=compressed.outcome)
909
+ except Exception:
910
+ self._error(key, layer, "compression")
911
+ raise
912
+ self._emit("storedSize", labels, bytes=stored_size)
913
+ if live is not None and not live():
914
+ return False
915
+ stamp = self.clock.wall_ms()
916
+ if not _valid_integer(stamp):
917
+ self._error(key, layer, "cache_write")
918
+ raise ValueError("Invalid writer timestamp")
919
+ if fence is not None and stamp <= fence:
920
+ return False
921
+ ttl_ms = (remote.stale_on_error_max_age_sec or remote.ttl_sec) * 1000
922
+ if key.tracked and ttl_ms > 3_600_000:
923
+ ttl_ms = 3_600_000
924
+ self._error(key, layer, "tracked_ttl_clamped")
925
+ try:
926
+ await _call_dependency(self.redis.write, WriteRequest(key.value_key, ttl_ms, payload, stamp))
927
+ except Exception:
928
+ self._error(key, layer, "cache_write")
929
+ raise
930
+ return True
931
+
932
+ async def invalidate_remote(self, key_type: str, id: Any, future_buffer_ms: int = 0) -> None:
933
+ """Write an entity fence after its source mutation commits; failures raise."""
934
+ if not _valid_integer(future_buffer_ms, 0, 31_536_000_000):
935
+ raise ConfigError("future_buffer_ms must be a nonnegative integer <= 31536000000")
936
+ labels = {"cacheNamespace": self.namespace, "keyType": key_type, "layer": "remote"}
937
+ self._emit("invalidation", labels)
938
+ try:
939
+ if self.redis is None:
940
+ raise MissingRemoteError("invalidate_remote requires a configured Redis client")
941
+ watermark = "{" + invalidation_prefix(self.namespace, key_type, id) + "}#watermark"
942
+ await _await(
943
+ self.redis.invalidate(InvalidationRequest(watermark, future_buffer_ms, self.clock.wall_ms()))
944
+ )
945
+ except Exception:
946
+ self._emit("error", {**labels, "useCase": "watermark"}, error="invalidation", inFallback=False)
947
+ raise
948
+
949
+ ainvalidate = invalidate_remote
950
+
951
+ def _schedule_shadow(
952
+ self,
953
+ op: _Operation,
954
+ key: Key,
955
+ policy: Policy,
956
+ remote: Any,
957
+ *,
958
+ frame: Frame | None = None,
959
+ source: asyncio.Task[Any] | None = None,
960
+ started: float | None = None,
961
+ ) -> None:
962
+ shadow = policy.shadow
963
+ if shadow is UNSET:
964
+ return
965
+ if not isinstance(shadow, Mapping):
966
+ self._error(key, "remote", "config_resolution")
967
+ return
968
+ ramp = shadow.get("ramp", 0)
969
+ if not _valid_ramp(ramp):
970
+ self._error(key, "remote", "config_resolution")
971
+ return
972
+ if ramp == 0 or self.metrics is None:
973
+ return
974
+ try:
975
+ if hasattr(self.metrics, "supports") and not self.metrics.supports("shadowValidation"):
976
+ return
977
+ except (Exception, asyncio.CancelledError):
978
+ return
979
+ if ramp < 100 and ramp_sample(key, "shadow") >= ramp:
980
+ return
981
+ if key.logical in self._shadows or len(self._shadows) >= self._shadow_max:
982
+ self._emit("shadowValidation", self._labels(key), outcome="dropped")
983
+ return
984
+ log = shadow.get("log_mismatches", shadow.get("logMismatches", False))
985
+ if type(log) is not bool:
986
+ self._error(key, "remote", "config_resolution")
987
+ log = False
988
+ job = _Shadow(self.clock.monotonic_ms() if started is None else started, op.timeout or 60_000)
989
+ self._shadows[key.logical] = job
990
+ self._spawn(self._run_shadow(op, key, policy, remote, job, frame, source, log))
991
+
992
+ def _release_shadow(self, key: Key, job: _Shadow) -> None:
993
+ if job.finished and not job.pending_reads and self._shadows.get(key.logical) is job:
994
+ del self._shadows[key.logical]
995
+
996
+ async def _shadow_read(self, key: Key, policy: Policy, maximum: int | None, job: _Shadow) -> Frame | Miss:
997
+ labels = self._labels(key, "remote_shadow")
998
+ start = self.clock.monotonic_ms()
999
+ self._emit("request", labels)
1000
+ try:
1001
+ result = await self._raw_read(key, policy, job)
1002
+ if isinstance(result, Frame):
1003
+ age = self._age(key, result, "remote_shadow")
1004
+ if age is None or (age < 0 and maximum is not None):
1005
+ result = Miss("unclassified")
1006
+ elif maximum is not None and age >= maximum * 1000:
1007
+ result = Miss("expired")
1008
+ if isinstance(result, Miss):
1009
+ self._emit("miss", labels, reason=result.reason)
1010
+ return result
1011
+ except Exception as error:
1012
+ self._error(
1013
+ key,
1014
+ "remote_shadow",
1015
+ "cache_read_timeout" if isinstance(error, RemoteReadTimeoutError) else "cache_read",
1016
+ )
1017
+ raise
1018
+ finally:
1019
+ self._emit("get", labels, seconds=self._seconds(start))
1020
+
1021
+ async def _run_shadow(
1022
+ self,
1023
+ op: _Operation,
1024
+ key: Key,
1025
+ policy: Policy,
1026
+ remote: Any,
1027
+ job: _Shadow,
1028
+ frame: Frame | None,
1029
+ source: asyncio.Task[Any] | None,
1030
+ log: bool,
1031
+ ) -> None:
1032
+ if source is None:
1033
+ try:
1034
+ await self._await_delivery(op)
1035
+ except BaseException:
1036
+ job.finished = True
1037
+ self._release_shadow(key, job)
1038
+ raise
1039
+ job.started = self.clock.monotonic_ms()
1040
+
1041
+ def abandon() -> None:
1042
+ nonlocal frame
1043
+ job.abandoned = True
1044
+ frame = None
1045
+
1046
+ def live() -> bool:
1047
+ if self.clock.monotonic_ms() - job.started >= job.budget:
1048
+ abandon()
1049
+ return not job.abandoned
1050
+
1051
+ details: dict[str, Any] = {}
1052
+
1053
+ async def work() -> str:
1054
+ nonlocal frame
1055
+ try:
1056
+ if not live():
1057
+ return "timeout"
1058
+ miss: Miss | None = None
1059
+ if source is not None:
1060
+ try:
1061
+ read = await self._shadow_read(key, policy, remote.ttl_sec, job)
1062
+ except Exception:
1063
+ return "redis_error"
1064
+ if not live():
1065
+ return "timeout"
1066
+ if isinstance(read, Miss):
1067
+ miss = read
1068
+ else:
1069
+ frame = read
1070
+ try:
1071
+ if source is not None:
1072
+ # This source belongs to the caller. A dark job stops
1073
+ # waiting at its deadline without retaining capacity
1074
+ # for an unbounded caller-owned operation.
1075
+ value = await _call_dependency(
1076
+ lambda: self._deadline(
1077
+ source,
1078
+ job.budget,
1079
+ lambda: TimeoutError("shadow deadline"),
1080
+ started=job.started,
1081
+ )
1082
+ )
1083
+ await self._await_delivery(op)
1084
+ else:
1085
+ with self.disable():
1086
+ value = await _call_dependency(op.load)
1087
+ except Exception:
1088
+ return (
1089
+ "timeout" if not live() or (source is not None and op.did_timeout) else "source_error"
1090
+ )
1091
+ if not live():
1092
+ return "timeout"
1093
+ if miss is not None:
1094
+ try:
1095
+ filled = await self._write(
1096
+ op, key, value, remote, "remote_shadow", miss.observed_watermark_ms, live
1097
+ )
1098
+ return ("filled" if filled else "fill_fenced") if live() else "timeout"
1099
+ except Exception:
1100
+ return "fill_error"
1101
+ if frame is None:
1102
+ return "timeout"
1103
+ try:
1104
+ cached = await self._decode(op, key, frame.payload, "remote_shadow")
1105
+ except Exception:
1106
+ return "deserialization_error"
1107
+ if not live():
1108
+ return "timeout"
1109
+ try:
1110
+ try:
1111
+ matched = op.comparator(cached, value)
1112
+ except asyncio.CancelledError:
1113
+ return "comparison_error"
1114
+ if type(matched) is not bool:
1115
+ try:
1116
+ await _call_dependency(lambda: matched)
1117
+ except Exception:
1118
+ pass
1119
+ return "comparison_error" if live() else "timeout"
1120
+ except Exception:
1121
+ return "comparison_error"
1122
+ if not live():
1123
+ return "timeout"
1124
+ if not matched:
1125
+ try:
1126
+ confirmation = await self._shadow_read(key, policy, None, job)
1127
+ except Exception:
1128
+ return "confirmation_error"
1129
+ if not live():
1130
+ return "timeout"
1131
+ if not isinstance(confirmation, Frame) or self._payload_bytes(
1132
+ confirmation.payload
1133
+ ) != self._payload_bytes(frame.payload):
1134
+ return "superseded"
1135
+ if log:
1136
+ details.update(
1137
+ cacheKey=self._clamp(key.logical, 2048),
1138
+ cachedValueJson=self._preview(cached),
1139
+ sourceValueJson=self._preview(value),
1140
+ )
1141
+ details["age"] = max(0, self.clock.wall_ms() - frame.created_at_ms) / 1000
1142
+ return "match" if matched else "mismatch"
1143
+ finally:
1144
+ job.finished = True
1145
+ self._release_shadow(key, job)
1146
+
1147
+ pending = self._spawn(work())
1148
+ try:
1149
+ outcome = await self._deadline(
1150
+ pending,
1151
+ job.budget,
1152
+ lambda: TimeoutError("shadow deadline"),
1153
+ started=job.started,
1154
+ on_timeout=abandon,
1155
+ )
1156
+ except Exception:
1157
+ outcome = "timeout"
1158
+ self._emit("shadowValidation", self._labels(key), outcome=outcome)
1159
+ if "age" in details and outcome in ("match", "mismatch"):
1160
+ self._emit("shadowAge", self._labels(key), outcome=outcome, seconds=details.pop("age"))
1161
+ if outcome == "mismatch" and log:
1162
+ self._log(
1163
+ "DialCache shadow validation mismatch: %s",
1164
+ {**self._labels(key), "outcome": outcome, **details},
1165
+ )
1166
+
1167
+ @staticmethod
1168
+ def _payload_bytes(value: str | bytes) -> bytes:
1169
+ return utf8_bytes(value) if isinstance(value, str) else value
1170
+
1171
+ @staticmethod
1172
+ def _clamp(text: str, limit: int) -> str:
1173
+ encoded = utf8_bytes(text)
1174
+ marker = b"...[truncated]"
1175
+ if len(encoded) <= limit:
1176
+ return text
1177
+ return encoded[: limit - len(marker)].decode("utf-8", "ignore") + marker.decode()
1178
+
1179
+ @staticmethod
1180
+ def _preview(value: Any) -> str | None:
1181
+ try:
1182
+ text = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
1183
+ return DialCache._clamp(text, 8192)
1184
+ except Exception:
1185
+ return None