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/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)
@@ -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}")