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/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