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/__init__.py +38 -0
- dialcache/cache.py +1185 -0
- dialcache/clock.py +53 -0
- dialcache/config.py +296 -0
- dialcache/context.py +133 -0
- dialcache/errors.py +46 -0
- dialcache/key.py +149 -0
- dialcache/local.py +77 -0
- dialcache/metrics.py +39 -0
- dialcache/protocol.py +301 -0
- dialcache/py.typed +0 -0
- dialcache/redis.py +213 -0
- dialcache/serializer.py +55 -0
- dialcache-0.25.0.dist-info/METADATA +256 -0
- dialcache-0.25.0.dist-info/RECORD +17 -0
- dialcache-0.25.0.dist-info/WHEEL +4 -0
- dialcache-0.25.0.dist-info/licenses/LICENSE +21 -0
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
|