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/local.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Bounded process-local LRU storage with whole-millisecond expiry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections import OrderedDict
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .clock import Clock, SystemClock
|
|
11
|
+
from .errors import ConfigError
|
|
12
|
+
|
|
13
|
+
MISSING = object()
|
|
14
|
+
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class _Entry:
|
|
19
|
+
value: Any
|
|
20
|
+
expires_at_ms: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LocalCache:
|
|
24
|
+
"""Local entries are shared by the use cases on one cache instance.
|
|
25
|
+
|
|
26
|
+
Reads promote recency but never renew expiration. ``None`` is a present
|
|
27
|
+
value. A zero capacity disables storage while leaving policy eligibility
|
|
28
|
+
and coalescing to the core engine.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, max_size: int = 10_000, clock: Clock | None = None) -> None:
|
|
32
|
+
if (
|
|
33
|
+
isinstance(max_size, bool)
|
|
34
|
+
or not isinstance(max_size, int)
|
|
35
|
+
or max_size < 0
|
|
36
|
+
or max_size > MAX_SAFE_INTEGER
|
|
37
|
+
):
|
|
38
|
+
raise ConfigError("local_max_size must be a nonnegative safe integer")
|
|
39
|
+
self.max_size = max_size
|
|
40
|
+
self.clock = clock if clock is not None else SystemClock()
|
|
41
|
+
self._entries: OrderedDict[str, _Entry] = OrderedDict()
|
|
42
|
+
|
|
43
|
+
def _get_entry(self, key: str, now_ms: float | None = None) -> _Entry | None:
|
|
44
|
+
entry = self._entries.get(key)
|
|
45
|
+
if entry is None:
|
|
46
|
+
return None
|
|
47
|
+
now = math.floor(self.clock.monotonic_ms() if now_ms is None else now_ms)
|
|
48
|
+
if now >= entry.expires_at_ms:
|
|
49
|
+
del self._entries[key]
|
|
50
|
+
return None
|
|
51
|
+
self._entries.move_to_end(key)
|
|
52
|
+
return entry
|
|
53
|
+
|
|
54
|
+
def get(self, key: str, now_ms: float | None = None) -> Any:
|
|
55
|
+
entry = self._get_entry(key, now_ms)
|
|
56
|
+
return MISSING if entry is None else entry.value
|
|
57
|
+
|
|
58
|
+
def read(self, key: str) -> tuple[bool, Any]:
|
|
59
|
+
entry = self._get_entry(key)
|
|
60
|
+
return (False, None) if entry is None else (True, entry.value)
|
|
61
|
+
|
|
62
|
+
def put(self, key: str, value: Any, ttl_sec: int) -> None:
|
|
63
|
+
if self.max_size == 0:
|
|
64
|
+
return
|
|
65
|
+
from .config import cache_ttl_sec_to_ms
|
|
66
|
+
|
|
67
|
+
ttl_ms = cache_ttl_sec_to_ms(ttl_sec)
|
|
68
|
+
self._entries[key] = _Entry(value, math.floor(self.clock.monotonic_ms()) + ttl_ms)
|
|
69
|
+
self._entries.move_to_end(key)
|
|
70
|
+
while len(self._entries) > self.max_size:
|
|
71
|
+
self._entries.popitem(last=False)
|
|
72
|
+
|
|
73
|
+
def clear(self) -> None:
|
|
74
|
+
self._entries.clear()
|
|
75
|
+
|
|
76
|
+
def __len__(self) -> int:
|
|
77
|
+
return len(self._entries)
|
dialcache/metrics.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Backend-neutral metrics observer contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from typing import Any, Protocol, TypeAlias
|
|
9
|
+
|
|
10
|
+
MetricEvent: TypeAlias = dict[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MetricsObserver(Protocol):
|
|
14
|
+
"""Receive bounded events with the shared DialCache diagnostic labels.
|
|
15
|
+
|
|
16
|
+
Events use the cross-port names, including ``cacheNamespace``, ``useCase``
|
|
17
|
+
and ``keyType``. Observers are synchronous. Their failures never change a
|
|
18
|
+
cache call's outcome.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def observe(self, event: MetricEvent) -> None: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
Metrics: TypeAlias = MetricsObserver | Callable[[MetricEvent], None]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def emit_metric(metrics: Metrics | None, event: str | Mapping[str, Any], **labels: Any) -> None:
|
|
28
|
+
if metrics is None:
|
|
29
|
+
return
|
|
30
|
+
payload = {"event": event, **labels} if isinstance(event, str) else dict(event)
|
|
31
|
+
try:
|
|
32
|
+
callback = metrics if callable(metrics) else metrics.observe
|
|
33
|
+
result = callback(payload)
|
|
34
|
+
# An async observer violates the synchronous contract. Do not schedule
|
|
35
|
+
# it, and avoid leaving an un-awaited coroutine warning behind.
|
|
36
|
+
if inspect.iscoroutine(result):
|
|
37
|
+
result.close()
|
|
38
|
+
except (Exception, asyncio.CancelledError):
|
|
39
|
+
pass
|
dialcache/protocol.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""DialCache version-1 frames and payload envelopes, independent of Redis clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
from .errors import DialCacheError
|
|
11
|
+
from .key import _scalar_text
|
|
12
|
+
from .serializer import Payload
|
|
13
|
+
|
|
14
|
+
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
15
|
+
MAX_SUPPORTED_DURATION_MS = 31_536_000_000
|
|
16
|
+
MAX_TRACKED_REDIS_VALUE_TTL_MS = 3_600_000
|
|
17
|
+
MAX_DECOMPRESSED_BYTES = 512 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RedisPayloadError(DialCacheError):
|
|
21
|
+
"""A Redis reply is not a bulk byte string or nil."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RedisPayloadEncodingError(DialCacheError):
|
|
25
|
+
"""An eligible frame carries an unsupported payload encoding."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RedisProtocolError(DialCacheError):
|
|
29
|
+
"""Redis returned an unexpected command reply."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Frame:
|
|
34
|
+
created_at_ms: int
|
|
35
|
+
payload: Payload
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Miss:
|
|
40
|
+
reason: str
|
|
41
|
+
observed_watermark_ms: int | None = None
|
|
42
|
+
kind: str = field(default="miss", init=False)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
ReadResult = Frame | Miss
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def valid_timestamp(value: object) -> bool:
|
|
49
|
+
return (
|
|
50
|
+
isinstance(value, (int, float))
|
|
51
|
+
and not isinstance(value, bool)
|
|
52
|
+
and 0 <= value <= MAX_SAFE_INTEGER
|
|
53
|
+
and value == int(value)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_timestamp(value: object) -> int:
|
|
58
|
+
if not valid_timestamp(value):
|
|
59
|
+
raise ValueError("DialCache timestamp must be a nonnegative safe integer")
|
|
60
|
+
return int(value) # type: ignore[arg-type]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ceil_supported_cache_ttl_ms(value: float) -> int:
|
|
64
|
+
if (
|
|
65
|
+
not isinstance(value, (int, float))
|
|
66
|
+
or isinstance(value, bool)
|
|
67
|
+
or not 0 < value <= MAX_SUPPORTED_DURATION_MS
|
|
68
|
+
):
|
|
69
|
+
raise ValueError("DialCache Redis TTL must be a positive finite duration")
|
|
70
|
+
result = math.ceil(value)
|
|
71
|
+
if not 0 < result <= MAX_SUPPORTED_DURATION_MS:
|
|
72
|
+
raise ValueError("DialCache Redis TTL must be positive and no greater than 365 days")
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_future_buffer_ms(value: object) -> int:
|
|
77
|
+
result = validate_timestamp(value)
|
|
78
|
+
if result > MAX_SUPPORTED_DURATION_MS:
|
|
79
|
+
raise ValueError("DialCache future buffer must be no greater than 365 days")
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def utf8_bytes(value: str) -> bytes:
|
|
84
|
+
return _scalar_text(value, replace=True).encode("utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _payload_bytes(payload: Payload) -> bytes:
|
|
88
|
+
if isinstance(payload, bytes):
|
|
89
|
+
return payload
|
|
90
|
+
if isinstance(payload, str):
|
|
91
|
+
return utf8_bytes(payload)
|
|
92
|
+
raise TypeError("DialCache serializer payload must be str or immutable bytes")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def encode_frame(payload: Payload, created_at_ms: int) -> bytes:
|
|
96
|
+
timestamp = validate_timestamp(created_at_ms)
|
|
97
|
+
return (
|
|
98
|
+
b"\x01"
|
|
99
|
+
+ timestamp.to_bytes(8, "big")
|
|
100
|
+
+ bytes([int(isinstance(payload, bytes))])
|
|
101
|
+
+ _payload_bytes(payload)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _bulk(raw: object) -> bytes | None:
|
|
106
|
+
if raw is None or isinstance(raw, bytes):
|
|
107
|
+
return raw
|
|
108
|
+
raise RedisPayloadError("Invalid Redis read reply; expected immutable bytes or None")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _supported(raw: bytes) -> bool:
|
|
112
|
+
return len(raw) >= 10 and raw[0] == 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _watermark(raw: bytes | None) -> int | None:
|
|
116
|
+
if raw is None or not re.fullmatch(rb"[0-9]+", raw):
|
|
117
|
+
return None
|
|
118
|
+
# Avoid Python's decimal conversion guard on hostile or huge numeric strings.
|
|
119
|
+
digits = raw.lstrip(b"0") or b"0"
|
|
120
|
+
if len(digits) > 16 or (len(digits) == 16 and digits > b"9007199254740991"):
|
|
121
|
+
return None
|
|
122
|
+
return int(digits)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _frame(raw: bytes) -> Frame:
|
|
126
|
+
tag = raw[9]
|
|
127
|
+
if tag == 0:
|
|
128
|
+
payload: Payload = raw[10:].decode("utf-8", errors="replace")
|
|
129
|
+
elif tag == 1:
|
|
130
|
+
payload = raw[10:]
|
|
131
|
+
else:
|
|
132
|
+
raise RedisPayloadEncodingError("Invalid DialCache Redis payload encoding")
|
|
133
|
+
return Frame(int.from_bytes(raw[1:9], "big"), payload)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def decode_read(raw: object) -> ReadResult:
|
|
137
|
+
frame = _bulk(raw)
|
|
138
|
+
if frame is None:
|
|
139
|
+
return Miss("value_absent")
|
|
140
|
+
if not _supported(frame):
|
|
141
|
+
return Miss("unclassified")
|
|
142
|
+
return _frame(frame)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def decode_tracked_read(raw: object, raw_watermark: object) -> ReadResult:
|
|
146
|
+
frame, watermark_bytes = _bulk(raw), _bulk(raw_watermark)
|
|
147
|
+
watermark = _watermark(watermark_bytes)
|
|
148
|
+
if frame is None:
|
|
149
|
+
return Miss("value_absent", watermark)
|
|
150
|
+
if not _supported(frame):
|
|
151
|
+
return Miss("unclassified", watermark)
|
|
152
|
+
if watermark_bytes is not None and watermark is None:
|
|
153
|
+
return Miss("unclassified")
|
|
154
|
+
timestamp = int.from_bytes(frame[1:9], "big")
|
|
155
|
+
if timestamp == 0:
|
|
156
|
+
return Miss("unclassified", watermark)
|
|
157
|
+
if watermark is not None and timestamp <= watermark:
|
|
158
|
+
return Miss("watermark_fenced", watermark)
|
|
159
|
+
return _frame(frame)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def validate_set_reply(reply: object) -> None:
|
|
163
|
+
# redis-py maps the native OK status to True through its SET response callback.
|
|
164
|
+
if reply is not True and reply not in ("OK", b"OK"):
|
|
165
|
+
raise RedisProtocolError("Invalid Redis SET reply; expected OK")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def validate_invalidation_reply(reply: object) -> None:
|
|
169
|
+
if type(reply) is not int or reply != 1:
|
|
170
|
+
raise RedisProtocolError("Invalid Redis invalidate reply; expected integer 1")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True)
|
|
174
|
+
class CompressionResult:
|
|
175
|
+
payload: Payload
|
|
176
|
+
outcome: str
|
|
177
|
+
original_bytes: int
|
|
178
|
+
stored_bytes: int
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class DecompressionResult:
|
|
183
|
+
payload: Payload
|
|
184
|
+
outcome: str
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def escape_raw_payload(payload: Payload) -> Payload:
|
|
188
|
+
if not isinstance(payload, (str, bytes)):
|
|
189
|
+
raise TypeError("DialCache serializer payload must be str or immutable bytes")
|
|
190
|
+
return b"\x00" + payload if isinstance(payload, bytes) and payload and payload[0] <= 2 else payload
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def compress_payload(
|
|
194
|
+
payload: Payload, threshold_bytes: int = 4096, level: int = 3, maximum: int = MAX_DECOMPRESSED_BYTES
|
|
195
|
+
) -> CompressionResult:
|
|
196
|
+
import zstandard
|
|
197
|
+
|
|
198
|
+
raw = _payload_bytes(payload)
|
|
199
|
+
escaped = escape_raw_payload(payload)
|
|
200
|
+
stored_size = len(escaped) if isinstance(escaped, bytes) else len(raw)
|
|
201
|
+
if len(raw) < threshold_bytes:
|
|
202
|
+
return CompressionResult(escaped, "below_threshold", len(raw), stored_size)
|
|
203
|
+
if len(raw) > maximum:
|
|
204
|
+
return CompressionResult(escaped, "write_over_limit", len(raw), stored_size)
|
|
205
|
+
encoded = zstandard.ZstdCompressor(level=level).compress(raw)
|
|
206
|
+
if len(encoded) + 1 >= stored_size:
|
|
207
|
+
return CompressionResult(escaped, "not_smaller", len(raw), stored_size)
|
|
208
|
+
result = bytes([2 if isinstance(payload, bytes) else 1]) + encoded
|
|
209
|
+
return CompressionResult(result, "compressed", len(raw), len(result))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _CompressedInput(io.BytesIO):
|
|
213
|
+
"""Distinguish iterator input exhaustion from a completed first frame."""
|
|
214
|
+
|
|
215
|
+
exhausted = False
|
|
216
|
+
|
|
217
|
+
def read(self, size: int = -1) -> bytes:
|
|
218
|
+
chunk = super().read(size)
|
|
219
|
+
if not chunk:
|
|
220
|
+
self.exhausted = True
|
|
221
|
+
return chunk
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _failed_frame_exceeds_limit(encoded: bytes, maximum: int) -> bool:
|
|
225
|
+
import zstandard
|
|
226
|
+
|
|
227
|
+
# The iterator already failed before completing the first frame. Replaying
|
|
228
|
+
# only this error case cannot enter a valid second frame. Read exactly up
|
|
229
|
+
# to cap+1 to preserve limit-before-later-corruption classification without
|
|
230
|
+
# allocating the cap or repeating decompression on successful reads.
|
|
231
|
+
total = 0
|
|
232
|
+
try:
|
|
233
|
+
with zstandard.ZstdDecompressor().stream_reader(
|
|
234
|
+
io.BytesIO(encoded), read_across_frames=False
|
|
235
|
+
) as reader:
|
|
236
|
+
while total <= maximum:
|
|
237
|
+
chunk = reader.read(min(64 * 1024, maximum + 1 - total))
|
|
238
|
+
total += len(chunk)
|
|
239
|
+
if total > maximum:
|
|
240
|
+
return True
|
|
241
|
+
if not chunk:
|
|
242
|
+
break
|
|
243
|
+
except (zstandard.ZstdError, ValueError, OverflowError, OSError):
|
|
244
|
+
pass
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def decompress_payload(payload: Payload, maximum: int = MAX_DECOMPRESSED_BYTES) -> DecompressionResult:
|
|
249
|
+
if not isinstance(payload, bytes) or not payload:
|
|
250
|
+
return DecompressionResult(payload, "passthrough")
|
|
251
|
+
marker = payload[0]
|
|
252
|
+
if marker == 0:
|
|
253
|
+
value = payload[1:] if len(payload) > 1 and payload[1] <= 2 else payload
|
|
254
|
+
return DecompressionResult(value, "passthrough")
|
|
255
|
+
if marker not in (1, 2):
|
|
256
|
+
return DecompressionResult(payload, "passthrough")
|
|
257
|
+
import zstandard
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
encoded = payload[1:]
|
|
261
|
+
content_size = zstandard.frame_content_size(encoded)
|
|
262
|
+
unknown_size = content_size in (-1, zstandard.CONTENTSIZE_UNKNOWN)
|
|
263
|
+
if unknown_size or content_size > maximum:
|
|
264
|
+
# Unlike repeated stream_reader reads, this iterator stops at the
|
|
265
|
+
# first completed frame, including an empty frame. Truncation makes
|
|
266
|
+
# it request more input and encounter EOF instead. Retain only
|
|
267
|
+
# actual output: unknown-size one-shot decode allocates its limit.
|
|
268
|
+
source = _CompressedInput(encoded)
|
|
269
|
+
chunks: list[bytes] = []
|
|
270
|
+
total = 0
|
|
271
|
+
try:
|
|
272
|
+
for chunk in zstandard.ZstdDecompressor().read_to_iter(
|
|
273
|
+
source, write_size=min(64 * 1024, maximum + 1)
|
|
274
|
+
):
|
|
275
|
+
total += len(chunk)
|
|
276
|
+
if total > maximum:
|
|
277
|
+
return DecompressionResult(payload, "read_over_limit")
|
|
278
|
+
# Known oversized frames can only return the raw input;
|
|
279
|
+
# validate their stream without retaining unusable output.
|
|
280
|
+
if unknown_size:
|
|
281
|
+
chunks.append(chunk)
|
|
282
|
+
except zstandard.ZstdError:
|
|
283
|
+
chunks.clear()
|
|
284
|
+
outcome = (
|
|
285
|
+
"read_over_limit" if _failed_frame_exceeds_limit(encoded, maximum) else "fallback_raw"
|
|
286
|
+
)
|
|
287
|
+
return DecompressionResult(payload, outcome)
|
|
288
|
+
if source.exhausted or not unknown_size:
|
|
289
|
+
return DecompressionResult(payload, "fallback_raw")
|
|
290
|
+
decoded = b"".join(chunks)
|
|
291
|
+
else:
|
|
292
|
+
decoded = zstandard.ZstdDecompressor().decompress(
|
|
293
|
+
encoded, max_output_size=max(1, maximum), allow_extra_data=True
|
|
294
|
+
)
|
|
295
|
+
if len(decoded) > maximum:
|
|
296
|
+
return DecompressionResult(payload, "read_over_limit")
|
|
297
|
+
except (zstandard.ZstdError, ValueError, OverflowError, OSError):
|
|
298
|
+
return DecompressionResult(payload, "fallback_raw")
|
|
299
|
+
return DecompressionResult(
|
|
300
|
+
decoded.decode("utf-8", errors="replace") if marker == 1 else decoded, "decompressed"
|
|
301
|
+
)
|
dialcache/py.typed
ADDED
|
File without changes
|
dialcache/redis.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Semantic Redis boundary and a resource-free adapter for redis.asyncio clients.
|
|
2
|
+
|
|
3
|
+
The application owns connections and finite socket/retry budgets. Cancellation
|
|
4
|
+
can stop a Python wait but cannot retract a dispatched Redis command. Writes
|
|
5
|
+
may have executed after a connection error. Invalidation retries the idempotent
|
|
6
|
+
script once with EVAL, preserving its original timestamp. This adapter never
|
|
7
|
+
connects, disconnects, flushes, or closes the borrowed client.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import hashlib
|
|
14
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
|
|
18
|
+
from .protocol import (
|
|
19
|
+
ReadResult,
|
|
20
|
+
RedisProtocolError,
|
|
21
|
+
ceil_supported_cache_ttl_ms,
|
|
22
|
+
decode_read,
|
|
23
|
+
decode_tracked_read,
|
|
24
|
+
encode_frame,
|
|
25
|
+
validate_future_buffer_ms,
|
|
26
|
+
validate_invalidation_reply,
|
|
27
|
+
validate_set_reply,
|
|
28
|
+
validate_timestamp,
|
|
29
|
+
)
|
|
30
|
+
from .serializer import Payload
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ReadRequest:
|
|
35
|
+
value_key: str
|
|
36
|
+
watermark_key: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AbortSignal(Protocol):
|
|
40
|
+
@property
|
|
41
|
+
def aborted(self) -> bool: ...
|
|
42
|
+
def add_callback(self, callback: Callable[[], Any]) -> None: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ReadContext:
|
|
47
|
+
timeout_ms: float
|
|
48
|
+
signal: AbortSignal | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class WriteRequest:
|
|
53
|
+
value_key: str
|
|
54
|
+
cache_ttl_ms: float
|
|
55
|
+
value: Payload
|
|
56
|
+
created_at_ms: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class InvalidationRequest:
|
|
61
|
+
watermark_key: str
|
|
62
|
+
future_buffer_ms: int
|
|
63
|
+
invalidated_at_ms: int
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RedisClient(Protocol):
|
|
67
|
+
def read(
|
|
68
|
+
self, request: ReadRequest, context: ReadContext | None = None
|
|
69
|
+
) -> ReadResult | Awaitable[ReadResult]: ...
|
|
70
|
+
def write(self, request: WriteRequest) -> None | Awaitable[None]: ...
|
|
71
|
+
def invalidate(self, request: InvalidationRequest) -> None | Awaitable[None]: ...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# Same atomic transition as the TypeScript/Go/Rust adapters. Values never write
|
|
75
|
+
# watermarks. The clock sample belongs to one logical invalidation invocation.
|
|
76
|
+
INVALIDATE_CACHE_SCRIPT = """local function parse_safe_integer(raw)
|
|
77
|
+
if not string.match(raw, "^%d+$") then
|
|
78
|
+
return nil
|
|
79
|
+
end
|
|
80
|
+
local value = tonumber(raw)
|
|
81
|
+
if not value or value > 9007199254740991 then
|
|
82
|
+
return nil
|
|
83
|
+
end
|
|
84
|
+
return value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
local future_buffer_ms = parse_safe_integer(ARGV[1])
|
|
88
|
+
if not future_buffer_ms or future_buffer_ms < 0 or future_buffer_ms > 31536000000 then
|
|
89
|
+
return redis.error_reply("ERR invalid DialCache future buffer")
|
|
90
|
+
end
|
|
91
|
+
local invalidated_at_ms = parse_safe_integer(ARGV[2])
|
|
92
|
+
if not invalidated_at_ms or invalidated_at_ms > 9007199254740991 - future_buffer_ms then
|
|
93
|
+
return redis.error_reply("ERR invalid DialCache invalidatedAtMs")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
local proposed_watermark = invalidated_at_ms + future_buffer_ms
|
|
97
|
+
local raw_watermark = redis.pcall("GET", KEYS[1])
|
|
98
|
+
if type(raw_watermark) == "table" and raw_watermark.err then
|
|
99
|
+
if not string.match(raw_watermark.err, "^WRONGTYPE ") then
|
|
100
|
+
return raw_watermark
|
|
101
|
+
end
|
|
102
|
+
-- A wrong-type key cannot contain a valid watermark. Treat it as absent so
|
|
103
|
+
-- the final SET repairs it, while preserving every other Redis error.
|
|
104
|
+
raw_watermark = false
|
|
105
|
+
end
|
|
106
|
+
local current_watermark = 0
|
|
107
|
+
|
|
108
|
+
if raw_watermark then
|
|
109
|
+
local parsed_watermark = parse_safe_integer(raw_watermark)
|
|
110
|
+
if parsed_watermark then
|
|
111
|
+
current_watermark = parsed_watermark
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
local watermark = math.max(current_watermark, proposed_watermark)
|
|
116
|
+
local current_ttl_ms = -2
|
|
117
|
+
if raw_watermark then
|
|
118
|
+
current_ttl_ms = redis.call("PTTL", KEYS[1])
|
|
119
|
+
end
|
|
120
|
+
local desired_ttl_ms = math.max(
|
|
121
|
+
7200000,
|
|
122
|
+
watermark - invalidated_at_ms + 3600000 + 60000
|
|
123
|
+
)
|
|
124
|
+
if current_ttl_ms > desired_ttl_ms then
|
|
125
|
+
desired_ttl_ms = current_ttl_ms
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
local encoded_watermark = string.format("%.0f", watermark)
|
|
129
|
+
if current_ttl_ms == -1 then
|
|
130
|
+
redis.call("SET", KEYS[1], encoded_watermark)
|
|
131
|
+
else
|
|
132
|
+
redis.call("SET", KEYS[1], encoded_watermark, "PX", desired_ttl_ms)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
return 1"""
|
|
136
|
+
INVALIDATE_CACHE_SCRIPT_SHA1 = hashlib.sha1(INVALIDATE_CACHE_SCRIPT.encode()).hexdigest()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class RedisAdapter:
|
|
140
|
+
"""Borrow a redis.asyncio.Redis or RedisCluster with decode_responses=False.
|
|
141
|
+
|
|
142
|
+
Tracked Cluster reads require a client constructed for primary-only reads,
|
|
143
|
+
with unchanged connection settings and no READONLY connection hook. A
|
|
144
|
+
replica-configured client remains usable for untracked reads and mutations.
|
|
145
|
+
Tracked MGET is one atomic snapshot, including through client redirects.
|
|
146
|
+
ReadContext is informational; core owns its authoritative deadline.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(self, client: Any) -> None:
|
|
150
|
+
self.client = client
|
|
151
|
+
|
|
152
|
+
def _require_primary_connections(self) -> None:
|
|
153
|
+
message = (
|
|
154
|
+
"Tracked reads require a dedicated primary-only RedisCluster with unchanged connection settings"
|
|
155
|
+
)
|
|
156
|
+
try:
|
|
157
|
+
configuration = self.client.get_connection_kwargs()
|
|
158
|
+
safe = (
|
|
159
|
+
not self.client.read_from_replicas
|
|
160
|
+
and getattr(self.client, "load_balancing_strategy", None) is None
|
|
161
|
+
and isinstance(configuration, Mapping)
|
|
162
|
+
and configuration.get("redis_connect_func") is None
|
|
163
|
+
)
|
|
164
|
+
except Exception as error:
|
|
165
|
+
raise RedisProtocolError(message) from error
|
|
166
|
+
if not safe:
|
|
167
|
+
raise RedisProtocolError(message)
|
|
168
|
+
|
|
169
|
+
async def _command(self, key: str, *arguments: object, tracked_read: bool = False) -> Any:
|
|
170
|
+
options: dict[str, object] = {}
|
|
171
|
+
if hasattr(self.client, "get_node_from_key"):
|
|
172
|
+
if tracked_read:
|
|
173
|
+
self._require_primary_connections()
|
|
174
|
+
# RedisCluster initializes its topology lazily. Explicit routing
|
|
175
|
+
# must wait for that initialization before looking up the primary.
|
|
176
|
+
await self.client.initialize()
|
|
177
|
+
if tracked_read:
|
|
178
|
+
# Replica routing affects redirects too. Constructor-installed
|
|
179
|
+
# READONLY hooks survive flag changes and can serve a demoted
|
|
180
|
+
# primary without any redirect; never borrow those connections.
|
|
181
|
+
self._require_primary_connections()
|
|
182
|
+
options["target_nodes"] = self.client.get_node_from_key(key, replica=False)
|
|
183
|
+
return await self.client.execute_command(*arguments, **options)
|
|
184
|
+
|
|
185
|
+
async def read(self, request: ReadRequest, context: ReadContext | None = None) -> ReadResult:
|
|
186
|
+
if context is not None and context.signal is not None and context.signal.aborted:
|
|
187
|
+
raise asyncio.CancelledError()
|
|
188
|
+
if request.watermark_key is None:
|
|
189
|
+
return decode_read(await self._command(request.value_key, "GET", request.value_key))
|
|
190
|
+
result = await self._command(
|
|
191
|
+
request.value_key, "MGET", request.value_key, request.watermark_key, tracked_read=True
|
|
192
|
+
)
|
|
193
|
+
if not isinstance(result, (list, tuple)) or len(result) != 2:
|
|
194
|
+
raise RedisProtocolError("Invalid Redis MGET reply; expected two bulk strings")
|
|
195
|
+
return decode_tracked_read(result[0], result[1])
|
|
196
|
+
|
|
197
|
+
async def write(self, request: WriteRequest) -> None:
|
|
198
|
+
ttl = ceil_supported_cache_ttl_ms(request.cache_ttl_ms)
|
|
199
|
+
frame = encode_frame(request.value, request.created_at_ms)
|
|
200
|
+
result = await self._command(request.value_key, "SET", request.value_key, frame, "PX", str(ttl))
|
|
201
|
+
validate_set_reply(result)
|
|
202
|
+
|
|
203
|
+
async def invalidate(self, request: InvalidationRequest) -> None:
|
|
204
|
+
buffer = validate_future_buffer_ms(request.future_buffer_ms)
|
|
205
|
+
timestamp = validate_timestamp(request.invalidated_at_ms)
|
|
206
|
+
args = ("1", request.watermark_key, str(buffer), str(timestamp))
|
|
207
|
+
try:
|
|
208
|
+
result = await self._command(
|
|
209
|
+
request.watermark_key, "EVALSHA", INVALIDATE_CACHE_SCRIPT_SHA1, *args
|
|
210
|
+
)
|
|
211
|
+
except Exception: # noqa: BLE001 -- Any EVALSHA rejection gets one idempotent recovery.
|
|
212
|
+
result = await self._command(request.watermark_key, "EVAL", INVALIDATE_CACHE_SCRIPT, *args)
|
|
213
|
+
validate_invalidation_reply(result)
|
dialcache/serializer.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Caller-supplied serialization and the portable JSON value binding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Awaitable
|
|
7
|
+
from typing import Protocol, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
Payload = str | bytes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Undefined:
|
|
14
|
+
__slots__ = ()
|
|
15
|
+
|
|
16
|
+
def __repr__(self) -> str:
|
|
17
|
+
return "UNDEFINED"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
UNDEFINED = _Undefined()
|
|
21
|
+
JSON_UNDEFINED_SENTINEL = "__dialcache_json_undefined_v1__"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Serializer(Protocol[T]):
|
|
25
|
+
"""Payloads are immutable. Asynchronous methods need an application deadline."""
|
|
26
|
+
|
|
27
|
+
def dump(self, value: T) -> Payload | Awaitable[Payload]: ...
|
|
28
|
+
def load(self, value: Payload) -> T | Awaitable[T]: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JsonSerializer:
|
|
32
|
+
"""Compact native JSON, plus the cross-language top-level undefined sentinel.
|
|
33
|
+
|
|
34
|
+
Nonfinite numbers and values outside JSON's domain are rejected. For custom
|
|
35
|
+
Python types, supply a Serializer with an explicit portable representation.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def dump(self, value: object) -> str:
|
|
39
|
+
if value is UNDEFINED:
|
|
40
|
+
return JSON_UNDEFINED_SENTINEL
|
|
41
|
+
payload = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
|
|
42
|
+
# JSON must escape lone surrogate units before the frame's UTF-8 text
|
|
43
|
+
# boundary replaces malformed text. This preserves JSON string values
|
|
44
|
+
# in the same way as well-formed ECMAScript JSON.stringify.
|
|
45
|
+
return payload.encode("utf-8", errors="backslashreplace").decode("utf-8")
|
|
46
|
+
|
|
47
|
+
def load(self, value: Payload) -> object:
|
|
48
|
+
payload = value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value
|
|
49
|
+
if payload == JSON_UNDEFINED_SENTINEL:
|
|
50
|
+
return UNDEFINED
|
|
51
|
+
return json.loads(payload, parse_constant=self._reject_constant)
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _reject_constant(value: str) -> object:
|
|
55
|
+
raise ValueError(f"Invalid JSON constant: {value}")
|