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/clock.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Separate wall timestamps, monotonic elapsed time, and timer delivery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TimerHandle(Protocol):
|
|
12
|
+
def cancel(self) -> None: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Clock(Protocol):
|
|
16
|
+
"""Injectable clocks and timers for deterministic application tests.
|
|
17
|
+
|
|
18
|
+
Wall time stamps Redis frames. Monotonic time governs local expiry and
|
|
19
|
+
deadlines. A controlled clock may advance elapsed time without delivering
|
|
20
|
+
timers, so callers also check elapsed time when operations settle.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def wall_ms(self) -> int | float: ...
|
|
24
|
+
|
|
25
|
+
def monotonic_ms(self) -> int | float: ...
|
|
26
|
+
|
|
27
|
+
def call_later(self, milliseconds: float, callback: Callable[[], None]) -> TimerHandle: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SystemClock:
|
|
31
|
+
"""System wall time and the process-wide monotonic millisecond grid."""
|
|
32
|
+
|
|
33
|
+
def wall_ms(self) -> int:
|
|
34
|
+
return time.time_ns() // 1_000_000
|
|
35
|
+
|
|
36
|
+
def monotonic_ms(self) -> float:
|
|
37
|
+
return time.monotonic_ns() / 1_000_000
|
|
38
|
+
|
|
39
|
+
def call_later(self, milliseconds: float, callback: Callable[[], None]) -> TimerHandle:
|
|
40
|
+
return asyncio.get_running_loop().call_later(milliseconds / 1_000, callback)
|
|
41
|
+
|
|
42
|
+
async def sleep_ms(self, milliseconds: float) -> None:
|
|
43
|
+
future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
44
|
+
|
|
45
|
+
def wake() -> None:
|
|
46
|
+
if not future.done():
|
|
47
|
+
future.set_result(None)
|
|
48
|
+
|
|
49
|
+
timer = self.call_later(milliseconds, wake)
|
|
50
|
+
try:
|
|
51
|
+
await future
|
|
52
|
+
finally:
|
|
53
|
+
timer.cancel()
|
dialcache/config.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Sparse runtime policy and the portable DialCache configuration domains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .errors import ConfigError
|
|
13
|
+
|
|
14
|
+
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
15
|
+
MAX_CACHE_TTL_SEC = 31_536_000
|
|
16
|
+
MAX_SUPPORTED_DURATION_MS = MAX_CACHE_TTL_SEC * 1_000
|
|
17
|
+
MAX_TRACKED_REDIS_VALUE_TTL_MS = 3_600_000
|
|
18
|
+
MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
19
|
+
DEFAULT_REMOTE_READ_TIMEOUT_MS = 50
|
|
20
|
+
DEFAULT_FALLBACK_TIMEOUT_MS = 60_000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Unset:
|
|
24
|
+
__slots__ = ()
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
return "UNSET"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
UNSET = _Unset()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CacheLayer(StrEnum):
|
|
34
|
+
LOCAL = "local"
|
|
35
|
+
REMOTE = "remote"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_safe_integer(value: Any) -> bool:
|
|
39
|
+
return (
|
|
40
|
+
not isinstance(value, bool)
|
|
41
|
+
and isinstance(value, (int, float))
|
|
42
|
+
and abs(value) <= MAX_SAFE_INTEGER
|
|
43
|
+
and math.isfinite(value)
|
|
44
|
+
and value == int(value)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_supported_cache_ttl_sec(value: Any) -> bool:
|
|
49
|
+
return is_safe_integer(value) and 0 < value <= MAX_CACHE_TTL_SEC
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cache_ttl_sec_to_ms(value: Any) -> int:
|
|
53
|
+
if not is_supported_cache_ttl_sec(value):
|
|
54
|
+
raise ConfigError(f"cache TTL must be an integer from 1 through {MAX_CACHE_TTL_SEC} seconds")
|
|
55
|
+
return int(value) * 1_000
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_deadline_ms(value: Any, name: str = "deadline") -> int:
|
|
59
|
+
if not is_safe_integer(value) or value <= 0 or value > MAX_TIMER_DELAY_MS:
|
|
60
|
+
raise ConfigError(f"{name} must be an integer from 1 through {MAX_TIMER_DELAY_MS} milliseconds")
|
|
61
|
+
return int(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _layer_map(value: Any, name: str) -> Mapping[str, Any]:
|
|
65
|
+
if value is UNSET:
|
|
66
|
+
return MappingProxyType({})
|
|
67
|
+
if not isinstance(value, Mapping):
|
|
68
|
+
raise ConfigError(f"{name} must be a layer map")
|
|
69
|
+
# Only the supported layers participate in the portable policy.
|
|
70
|
+
return MappingProxyType({layer: value[layer] for layer in ("local", "remote") if layer in value})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _shadow_map(value: Any) -> Any:
|
|
74
|
+
if value is UNSET:
|
|
75
|
+
return UNSET
|
|
76
|
+
if not isinstance(value, Mapping):
|
|
77
|
+
raise ConfigError("shadow must be an object")
|
|
78
|
+
result: dict[str, Any] = {}
|
|
79
|
+
if "ramp" in value:
|
|
80
|
+
result["ramp"] = value["ramp"]
|
|
81
|
+
if "log_mismatches" in value:
|
|
82
|
+
result["log_mismatches"] = value["log_mismatches"]
|
|
83
|
+
elif "logMismatches" in value:
|
|
84
|
+
result["log_mismatches"] = value["logMismatches"]
|
|
85
|
+
return MappingProxyType(result)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Policy:
|
|
90
|
+
"""Per-use-case policy; omitted fields inherit in runtime overlays.
|
|
91
|
+
|
|
92
|
+
``None`` is a supplied value, never an omitted leaf. Constructing a Policy
|
|
93
|
+
validates container shapes, boolean switches and read deadlines. TTL,
|
|
94
|
+
ramp and optional shadow/recovery leaves remain available for narrow
|
|
95
|
+
runtime fail-open resolution. Static operation setup uses
|
|
96
|
+
:func:`validate_static_policy` to reject invalid defaults before calls.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
ttl_sec: Mapping[str, Any] = field(default_factory=dict)
|
|
100
|
+
ramp: Mapping[str, Any] = field(default_factory=dict)
|
|
101
|
+
request_local: Any = UNSET
|
|
102
|
+
coalesce: Any = UNSET
|
|
103
|
+
stale_on_error_max_age_sec: Any = UNSET
|
|
104
|
+
remote_read_timeout_ms: Any = UNSET
|
|
105
|
+
shadow: Any = UNSET
|
|
106
|
+
|
|
107
|
+
def __post_init__(self) -> None:
|
|
108
|
+
object.__setattr__(self, "ttl_sec", _layer_map(self.ttl_sec, "ttl_sec"))
|
|
109
|
+
object.__setattr__(self, "ramp", _layer_map(self.ramp, "ramp"))
|
|
110
|
+
object.__setattr__(self, "shadow", _shadow_map(self.shadow))
|
|
111
|
+
for name in ("request_local", "coalesce"):
|
|
112
|
+
value = getattr(self, name)
|
|
113
|
+
if value is not UNSET and not isinstance(value, bool):
|
|
114
|
+
raise ConfigError(f"{name} must be a boolean")
|
|
115
|
+
if self.remote_read_timeout_ms is not UNSET:
|
|
116
|
+
validate_deadline_ms(self.remote_read_timeout_ms, "remote_read_timeout_ms")
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def enabled(cls, ttl_sec: int) -> Policy:
|
|
120
|
+
return cls(ttl_sec={"local": ttl_sec, "remote": ttl_sec}, ramp={"local": 100, "remote": 100})
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def disabled(cls) -> Policy:
|
|
124
|
+
"""Disable every inherited serving, recovery and shadow path."""
|
|
125
|
+
return cls(
|
|
126
|
+
request_local=False,
|
|
127
|
+
stale_on_error_max_age_sec=0,
|
|
128
|
+
shadow={"ramp": 0, "log_mismatches": False},
|
|
129
|
+
ramp={"local": 0, "remote": 0},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def from_mapping(cls, value: Mapping[str, Any]) -> Policy:
|
|
134
|
+
result = normalize_policy(value)
|
|
135
|
+
assert result is not None
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
KeyConfig = Policy
|
|
140
|
+
DialCacheKeyConfig = Policy
|
|
141
|
+
PolicyInput = Policy | Mapping[str, Any] | None
|
|
142
|
+
|
|
143
|
+
_ALIASES = {
|
|
144
|
+
"ttl_sec": "ttlSec",
|
|
145
|
+
"ramp": "ramp",
|
|
146
|
+
"request_local": "requestLocal",
|
|
147
|
+
"coalesce": "coalesce",
|
|
148
|
+
"stale_on_error_max_age_sec": "staleOnErrorMaxAgeSec",
|
|
149
|
+
"remote_read_timeout_ms": "remoteReadTimeoutMs",
|
|
150
|
+
"shadow": "shadow",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def normalize_policy(value: PolicyInput) -> Policy | None:
|
|
155
|
+
if value is None:
|
|
156
|
+
return None
|
|
157
|
+
if isinstance(value, Policy):
|
|
158
|
+
return value
|
|
159
|
+
if not isinstance(value, Mapping):
|
|
160
|
+
raise ConfigError("DialCache policy must be an object")
|
|
161
|
+
if "shadowRamp" in value or "shadow_ramp" in value:
|
|
162
|
+
raise ConfigError('shadow_ramp was replaced by "shadow.ramp"')
|
|
163
|
+
supplied: dict[str, Any] = {}
|
|
164
|
+
for native, portable in _ALIASES.items():
|
|
165
|
+
if native in value:
|
|
166
|
+
supplied[native] = value[native]
|
|
167
|
+
elif portable in value:
|
|
168
|
+
supplied[native] = value[portable]
|
|
169
|
+
return Policy(**supplied)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def merge_policy(defaults: PolicyInput, runtime: PolicyInput) -> Policy | None:
|
|
173
|
+
"""Snapshot a sparse provider reply over the operation's static policy.
|
|
174
|
+
|
|
175
|
+
A whole-provider ``None`` inherits the static policy. Invalid explicitly
|
|
176
|
+
supplied leaves stay supplied, including ``None``, so runtime resolution
|
|
177
|
+
cannot accidentally enable an inherited layer.
|
|
178
|
+
"""
|
|
179
|
+
base = normalize_policy(defaults)
|
|
180
|
+
overlay = normalize_policy(runtime)
|
|
181
|
+
if overlay is None:
|
|
182
|
+
return base
|
|
183
|
+
if base is None:
|
|
184
|
+
return overlay
|
|
185
|
+
values: dict[str, Any] = {
|
|
186
|
+
"ttl_sec": {**base.ttl_sec, **overlay.ttl_sec},
|
|
187
|
+
"ramp": {**base.ramp, **overlay.ramp},
|
|
188
|
+
}
|
|
189
|
+
for name in ("request_local", "coalesce", "stale_on_error_max_age_sec", "remote_read_timeout_ms"):
|
|
190
|
+
value = getattr(overlay, name)
|
|
191
|
+
values[name] = getattr(base, name) if value is UNSET else value
|
|
192
|
+
if base.shadow is UNSET and overlay.shadow is UNSET:
|
|
193
|
+
values["shadow"] = UNSET
|
|
194
|
+
else:
|
|
195
|
+
values["shadow"] = {
|
|
196
|
+
**({} if base.shadow is UNSET else base.shadow),
|
|
197
|
+
**({} if overlay.shadow is UNSET else overlay.shadow),
|
|
198
|
+
}
|
|
199
|
+
return Policy(**values)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _valid_ramp(value: Any) -> bool:
|
|
203
|
+
return (
|
|
204
|
+
not isinstance(value, bool)
|
|
205
|
+
and isinstance(value, (int, float))
|
|
206
|
+
and 0 <= value <= 100
|
|
207
|
+
and math.isfinite(value)
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def validate_static_policy(value: PolicyInput) -> Policy | None:
|
|
212
|
+
"""Validate and capture operation defaults before registering the use case."""
|
|
213
|
+
policy = normalize_policy(value)
|
|
214
|
+
if policy is None:
|
|
215
|
+
return None
|
|
216
|
+
for layer in ("local", "remote"):
|
|
217
|
+
if layer in policy.ttl_sec and not is_supported_cache_ttl_sec(policy.ttl_sec[layer]):
|
|
218
|
+
raise ConfigError(f"ttl_sec.{layer} must be an integer from 1 through {MAX_CACHE_TTL_SEC}")
|
|
219
|
+
if layer in policy.ramp and not _valid_ramp(policy.ramp[layer]):
|
|
220
|
+
raise ConfigError(f"ramp.{layer} must be a finite number from 0 through 100")
|
|
221
|
+
age = policy.stale_on_error_max_age_sec
|
|
222
|
+
if age is not UNSET:
|
|
223
|
+
if not is_safe_integer(age) or age < 0 or age > MAX_CACHE_TTL_SEC:
|
|
224
|
+
raise ConfigError("stale_on_error_max_age_sec must be a supported nonnegative integer")
|
|
225
|
+
if age > 0 and ("remote" not in policy.ttl_sec or age <= policy.ttl_sec["remote"]):
|
|
226
|
+
raise ConfigError("stale_on_error_max_age_sec requires a smaller positive remote TTL")
|
|
227
|
+
if policy.shadow is not UNSET:
|
|
228
|
+
if "ramp" in policy.shadow and not _valid_ramp(policy.shadow["ramp"]):
|
|
229
|
+
raise ConfigError("shadow.ramp must be a finite number from 0 through 100")
|
|
230
|
+
if "log_mismatches" in policy.shadow and not isinstance(policy.shadow["log_mismatches"], bool):
|
|
231
|
+
raise ConfigError("shadow.log_mismatches must be a boolean")
|
|
232
|
+
return policy
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def deterministic_ramp_sample(urn: str, layer: str) -> float:
|
|
236
|
+
"""Stable FNV-1a over UTF-16 code units, identical across DialCache ports."""
|
|
237
|
+
encoded = f"{urn}:{layer}".encode("utf-16-le", errors="surrogatepass")
|
|
238
|
+
value = 0x811C9DC5
|
|
239
|
+
for index in range(0, len(encoded), 2):
|
|
240
|
+
value ^= encoded[index] | (encoded[index + 1] << 8)
|
|
241
|
+
value = (value * 0x01000193) & 0xFFFFFFFF
|
|
242
|
+
return value / 0x1_0000_0000 * 100
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def deterministic_shadow_ramp_sample(urn: str) -> float:
|
|
246
|
+
return deterministic_ramp_sample(urn, "shadow")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass(frozen=True)
|
|
250
|
+
class LayerResolution:
|
|
251
|
+
status: str
|
|
252
|
+
reason: str | None = None
|
|
253
|
+
ttl_sec: int | None = None
|
|
254
|
+
ramp: float | None = None
|
|
255
|
+
stale_on_error_max_age_sec: int | None = None
|
|
256
|
+
stale_on_error_config_error: bool = False
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def enabled(self) -> bool:
|
|
260
|
+
return self.status == "enabled"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def resolve_layer(policy: Policy | None, urn: str, layer: str | CacheLayer) -> LayerResolution:
|
|
264
|
+
"""Resolve one serving layer; malformed leaves disable only that layer."""
|
|
265
|
+
if layer not in ("local", "remote"):
|
|
266
|
+
raise ConfigError(f"Unknown cache layer: {layer}")
|
|
267
|
+
age = UNSET if policy is None else policy.stale_on_error_max_age_sec
|
|
268
|
+
recovery_off = age is UNSET or (is_safe_integer(age) and age == 0)
|
|
269
|
+
if policy is None or layer not in policy.ttl_sec:
|
|
270
|
+
return LayerResolution(
|
|
271
|
+
"disabled",
|
|
272
|
+
"policy_disabled",
|
|
273
|
+
stale_on_error_config_error=layer == "remote" and not recovery_off,
|
|
274
|
+
)
|
|
275
|
+
ttl = policy.ttl_sec[layer]
|
|
276
|
+
if not is_supported_cache_ttl_sec(ttl):
|
|
277
|
+
return LayerResolution("disabled", "invalid_ttl")
|
|
278
|
+
ramp = policy.ramp.get(layer, 100)
|
|
279
|
+
if not _valid_ramp(ramp):
|
|
280
|
+
return LayerResolution("disabled", "invalid_ramp")
|
|
281
|
+
enabled = ramp >= 100 or (ramp > 0 and deterministic_ramp_sample(urn, str(layer)) < ramp)
|
|
282
|
+
recovery = None
|
|
283
|
+
recovery_error = False
|
|
284
|
+
if layer == "remote" and not recovery_off:
|
|
285
|
+
if is_supported_cache_ttl_sec(age) and age > ttl:
|
|
286
|
+
recovery = int(age)
|
|
287
|
+
else:
|
|
288
|
+
recovery_error = True
|
|
289
|
+
return LayerResolution(
|
|
290
|
+
status="enabled" if enabled else "disabled",
|
|
291
|
+
reason=None if enabled else "ramped_down",
|
|
292
|
+
ttl_sec=int(ttl),
|
|
293
|
+
ramp=float(ramp),
|
|
294
|
+
stale_on_error_max_age_sec=recovery,
|
|
295
|
+
stale_on_error_config_error=recovery_error,
|
|
296
|
+
)
|
dialcache/context.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Per-instance request scope with a shared, explicitly closed memo holder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar, Token
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class RequestLocalCache:
|
|
13
|
+
"""Request values and flights; closing prevents all late publication."""
|
|
14
|
+
|
|
15
|
+
in_flight: dict[str, Any] = field(default_factory=dict)
|
|
16
|
+
_values: dict[str, Any] = field(default_factory=dict)
|
|
17
|
+
closed: bool = False
|
|
18
|
+
|
|
19
|
+
def read(self, key: str) -> tuple[bool, Any]:
|
|
20
|
+
if self.closed or key not in self._values:
|
|
21
|
+
return False, None
|
|
22
|
+
return True, self._values[key]
|
|
23
|
+
|
|
24
|
+
def set(self, key: str, value: Any) -> None:
|
|
25
|
+
if not self.closed:
|
|
26
|
+
self._values[key] = value
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
self.closed = True
|
|
30
|
+
self._values.clear()
|
|
31
|
+
self.in_flight.clear()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class _Holder:
|
|
36
|
+
closed: bool = False
|
|
37
|
+
memo: RequestLocalCache | None = None
|
|
38
|
+
|
|
39
|
+
def close(self) -> None:
|
|
40
|
+
self.closed = True
|
|
41
|
+
if self.memo is not None:
|
|
42
|
+
self.memo.close()
|
|
43
|
+
self.memo = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class _Store:
|
|
48
|
+
enabled: bool
|
|
49
|
+
holder: _Holder | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Scope:
|
|
53
|
+
def __init__(self, context: DialCacheContext, enabled: bool) -> None:
|
|
54
|
+
self._context = context
|
|
55
|
+
self._enabled = enabled
|
|
56
|
+
self._token: Token[_Store | None] | None = None
|
|
57
|
+
self._owned: _Holder | None = None
|
|
58
|
+
self._entered = False
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> _Scope:
|
|
61
|
+
if self._entered:
|
|
62
|
+
raise RuntimeError("A DialCache scope context manager can only be entered once")
|
|
63
|
+
self._entered = True
|
|
64
|
+
holder = self._context._live_holder()
|
|
65
|
+
if self._enabled and holder is None:
|
|
66
|
+
holder = self._owned = _Holder()
|
|
67
|
+
self._token = self._context._storage.set(_Store(self._enabled, holder))
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(
|
|
71
|
+
self,
|
|
72
|
+
exception_type: type[BaseException] | None,
|
|
73
|
+
exception: BaseException | None,
|
|
74
|
+
traceback: TracebackType | None,
|
|
75
|
+
) -> None:
|
|
76
|
+
if self._token is None:
|
|
77
|
+
raise RuntimeError("DialCache scope was not entered or was already closed")
|
|
78
|
+
if self._owned is not None:
|
|
79
|
+
self._owned.close()
|
|
80
|
+
self._context._storage.reset(self._token)
|
|
81
|
+
self._token = None
|
|
82
|
+
|
|
83
|
+
async def __aenter__(self) -> _Scope:
|
|
84
|
+
return self.__enter__()
|
|
85
|
+
|
|
86
|
+
async def __aexit__(
|
|
87
|
+
self,
|
|
88
|
+
exception_type: type[BaseException] | None,
|
|
89
|
+
exception: BaseException | None,
|
|
90
|
+
traceback: TracebackType | None,
|
|
91
|
+
) -> None:
|
|
92
|
+
self.__exit__(exception_type, exception, traceback)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class DialCacheContext:
|
|
96
|
+
"""An independently enabled context for one cache instance.
|
|
97
|
+
|
|
98
|
+
Both ``with context.enable():`` and ``async with context.enable():`` are
|
|
99
|
+
supported. Async tasks inherit the live holder, but calls made after its
|
|
100
|
+
outer scope exits are disabled, even from a copied context.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self) -> None:
|
|
104
|
+
self._storage: ContextVar[_Store | None] = ContextVar("dialcache_scope", default=None)
|
|
105
|
+
|
|
106
|
+
def _live_holder(self) -> _Holder | None:
|
|
107
|
+
store = self._storage.get()
|
|
108
|
+
if store is None or store.holder is None or store.holder.closed:
|
|
109
|
+
return None
|
|
110
|
+
return store.holder
|
|
111
|
+
|
|
112
|
+
def is_enabled(self) -> bool:
|
|
113
|
+
store = self._storage.get()
|
|
114
|
+
return store is not None and store.enabled and self._live_holder() is not None
|
|
115
|
+
|
|
116
|
+
def enable(self) -> _Scope:
|
|
117
|
+
return _Scope(self, True)
|
|
118
|
+
|
|
119
|
+
def disable(self) -> _Scope:
|
|
120
|
+
return _Scope(self, False)
|
|
121
|
+
|
|
122
|
+
def request_cache(self) -> RequestLocalCache | None:
|
|
123
|
+
if not self.is_enabled():
|
|
124
|
+
return None
|
|
125
|
+
holder = self._live_holder()
|
|
126
|
+
assert holder is not None
|
|
127
|
+
if holder.memo is None:
|
|
128
|
+
holder.memo = RequestLocalCache()
|
|
129
|
+
return holder.memo
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_or_create_request_local_cache(context: DialCacheContext) -> RequestLocalCache | None:
|
|
133
|
+
return context.request_cache()
|
dialcache/errors.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Public errors raised by DialCache operations and configuration."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DialCacheError(Exception):
|
|
5
|
+
"""Base class for errors owned by DialCache."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigError(DialCacheError, ValueError):
|
|
9
|
+
"""Invalid static configuration or malformed runtime policy."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FallbackTimeoutError(DialCacheError, TimeoutError):
|
|
13
|
+
"""The enabled source invocation exceeded its DialCache deadline."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, use_case: str, timeout_ms: int) -> None:
|
|
16
|
+
self.use_case = use_case
|
|
17
|
+
self.timeout_ms = timeout_ms
|
|
18
|
+
super().__init__(f'DialCache fallback for use case "{use_case}" timed out after {timeout_ms} ms')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RemoteReadTimeoutError(DialCacheError, TimeoutError):
|
|
22
|
+
"""DialCache stopped waiting for a remote read."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, use_case: str, timeout_ms: int) -> None:
|
|
25
|
+
self.use_case = use_case
|
|
26
|
+
self.timeout_ms = timeout_ms
|
|
27
|
+
super().__init__(f'DialCache Redis read for use case "{use_case}" timed out after {timeout_ms} ms')
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
RedisReadTimeoutError = RemoteReadTimeoutError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UseCaseIsAlreadyRegisteredError(DialCacheError):
|
|
34
|
+
def __init__(self, use_case: str) -> None:
|
|
35
|
+
self.use_case = use_case
|
|
36
|
+
super().__init__(f"Use case already registered: {use_case}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UseCaseNameIsReservedError(DialCacheError):
|
|
40
|
+
def __init__(self, use_case: str) -> None:
|
|
41
|
+
self.use_case = use_case
|
|
42
|
+
super().__init__(f"Use case name is reserved: {use_case}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MissingRemoteError(DialCacheError):
|
|
46
|
+
"""An explicit remote maintenance operation has no remote adapter."""
|
dialcache/key.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Portable DialCache identity, URI escaping, and deterministic cohorts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from .serializer import UNDEFINED
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _integer_string(value: int) -> str:
|
|
14
|
+
# Python's configurable decimal-digit guard must not truncate bigint identity.
|
|
15
|
+
negative = value < 0
|
|
16
|
+
value = abs(value)
|
|
17
|
+
parts: list[int] = []
|
|
18
|
+
while value >= 1_000_000_000:
|
|
19
|
+
value, remainder = divmod(value, 1_000_000_000)
|
|
20
|
+
parts.append(remainder)
|
|
21
|
+
result = str(value) + "".join(f"{part:09d}" for part in reversed(parts))
|
|
22
|
+
return "-" + result if negative else result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def scalar_string(value: object) -> str:
|
|
26
|
+
"""JavaScript-compatible scalar spelling; Python integers retain all digits.
|
|
27
|
+
|
|
28
|
+
Python's shortest-round-trip float digits use the same nearest-even rule;
|
|
29
|
+
ECMAScript differs in the decimal/exponent presentation thresholds.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(value, str):
|
|
32
|
+
return value
|
|
33
|
+
if value is None:
|
|
34
|
+
return "null"
|
|
35
|
+
if value is True:
|
|
36
|
+
return "true"
|
|
37
|
+
if value is False:
|
|
38
|
+
return "false"
|
|
39
|
+
if isinstance(value, int):
|
|
40
|
+
return _integer_string(value)
|
|
41
|
+
if isinstance(value, float):
|
|
42
|
+
if math.isnan(value):
|
|
43
|
+
return "NaN"
|
|
44
|
+
if math.isinf(value):
|
|
45
|
+
return "-Infinity" if value < 0 else "Infinity"
|
|
46
|
+
if value == 0:
|
|
47
|
+
return "0"
|
|
48
|
+
sign = "-" if value < 0 else ""
|
|
49
|
+
raw = repr(abs(value)).lower()
|
|
50
|
+
coefficient, _, exponent = raw.partition("e")
|
|
51
|
+
whole, _dot, fraction = coefficient.partition(".")
|
|
52
|
+
digits = (whole + fraction).lstrip("0")
|
|
53
|
+
position = len(whole) + (int(exponent) if exponent else 0)
|
|
54
|
+
if whole == "0":
|
|
55
|
+
position -= len(whole + fraction) - len(digits)
|
|
56
|
+
digits = digits.rstrip("0")
|
|
57
|
+
if 0 < position <= 21:
|
|
58
|
+
return sign + (
|
|
59
|
+
digits[:position] + "." + digits[position:]
|
|
60
|
+
if position < len(digits)
|
|
61
|
+
else digits + "0" * (position - len(digits))
|
|
62
|
+
)
|
|
63
|
+
if -6 < position <= 0:
|
|
64
|
+
return sign + "0." + "0" * -position + digits
|
|
65
|
+
mantissa = digits[0] + ("." + digits[1:] if len(digits) > 1 else "")
|
|
66
|
+
power = position - 1
|
|
67
|
+
return sign + mantissa + "e" + ("+" if power >= 0 else "") + str(power)
|
|
68
|
+
raise TypeError("Cache key scalar must be str, int, float, bool, or None")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _scalar_text(value: str, *, replace: bool = False) -> str:
|
|
72
|
+
"""Combine explicit UTF-16 pairs; reject (keys) or replace (payloads) lone units."""
|
|
73
|
+
if not isinstance(value, str):
|
|
74
|
+
raise TypeError("Cache key components must be strings")
|
|
75
|
+
return value.encode("utf-16-le", "surrogatepass").decode("utf-16-le", "replace" if replace else "strict")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def encode_component(value: str) -> str:
|
|
79
|
+
return quote(_scalar_text(value), safe="~!*'()-._", encoding="utf-8", errors="strict")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def normalize_args(args: Mapping[str, object]) -> tuple[tuple[str, str], ...]:
|
|
83
|
+
"""Omit UNDEFINED and sort names by UTF-16 units; None remains literal null."""
|
|
84
|
+
pairs = [(name, scalar_string(value)) for name, value in args.items() if value is not UNDEFINED]
|
|
85
|
+
pairs.sort(key=lambda pair: pair[0].encode("utf-16-be", "surrogatepass"))
|
|
86
|
+
return tuple(pairs)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def invalidation_prefix(namespace: str, key_type: str, id: object) -> str:
|
|
90
|
+
entity_id = scalar_string(id)
|
|
91
|
+
for name, value in [("namespace", namespace), ("key_type", key_type), ("id", entity_id)]:
|
|
92
|
+
if "{" in value or "}" in value:
|
|
93
|
+
raise ValueError(f"Tracked {name} must not contain braces")
|
|
94
|
+
return ":".join(encode_component(part) for part in (namespace, key_type, entity_id))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class Key:
|
|
99
|
+
namespace: str
|
|
100
|
+
key_type: str
|
|
101
|
+
id: object
|
|
102
|
+
use_case: str
|
|
103
|
+
args: Sequence[tuple[str, str]] = ()
|
|
104
|
+
tracked: bool = False
|
|
105
|
+
prefix: str = field(init=False)
|
|
106
|
+
logical: str = field(init=False)
|
|
107
|
+
value_key: str = field(init=False)
|
|
108
|
+
watermark_key: str | None = field(init=False)
|
|
109
|
+
|
|
110
|
+
def __post_init__(self) -> None:
|
|
111
|
+
if "{" in self.namespace or "}" in self.namespace:
|
|
112
|
+
raise ValueError("DialCache namespace must not contain braces")
|
|
113
|
+
entity_id = scalar_string(self.id)
|
|
114
|
+
pairs = tuple((name, value) for name, value in self.args)
|
|
115
|
+
if self.tracked:
|
|
116
|
+
prefix = "{" + invalidation_prefix(self.namespace, self.key_type, entity_id) + "}"
|
|
117
|
+
else:
|
|
118
|
+
prefix = ":".join(encode_component(part) for part in (self.namespace, self.key_type, entity_id))
|
|
119
|
+
query = (
|
|
120
|
+
"?" + "&".join(f"{encode_component(name)}={encode_component(value)}" for name, value in pairs)
|
|
121
|
+
if pairs
|
|
122
|
+
else ""
|
|
123
|
+
)
|
|
124
|
+
logical = prefix + query + "#" + encode_component(self.use_case)
|
|
125
|
+
object.__setattr__(self, "id", entity_id)
|
|
126
|
+
object.__setattr__(self, "args", pairs)
|
|
127
|
+
object.__setattr__(self, "prefix", prefix)
|
|
128
|
+
object.__setattr__(self, "logical", logical)
|
|
129
|
+
object.__setattr__(self, "value_key", logical + ":dialcache-frame-v1")
|
|
130
|
+
object.__setattr__(self, "watermark_key", prefix + "#watermark" if self.tracked else None)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def urn(self) -> str:
|
|
134
|
+
return self.logical
|
|
135
|
+
|
|
136
|
+
def __str__(self) -> str:
|
|
137
|
+
return self.logical
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def ramp_hash(key: Key | str, discriminator: str) -> int:
|
|
141
|
+
units = (str(key) + ":" + discriminator).encode("utf-16-le", "surrogatepass")
|
|
142
|
+
hashed = 0x811C9DC5
|
|
143
|
+
for offset in range(0, len(units), 2):
|
|
144
|
+
hashed = ((hashed ^ (units[offset] | units[offset + 1] << 8)) * 0x01000193) & 0xFFFFFFFF
|
|
145
|
+
return hashed
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def ramp_sample(key: Key | str, discriminator: str) -> float:
|
|
149
|
+
return ramp_hash(key, discriminator) / 0x1_0000_0000 * 100
|