cache-sync 0.3.1__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.
- cache_sync/__init__.py +80 -0
- cache_sync/core.py +256 -0
- cache_sync/decorators.py +86 -0
- cache_sync/distributed_cache.py +16 -0
- cache_sync/invalidation.py +111 -0
- cache_sync/providers/__init__.py +1 -0
- cache_sync/providers/kafka/__init__.py +7 -0
- cache_sync/providers/kafka/invalidation_bus.py +173 -0
- cache_sync/providers/postgres/__init__.py +7 -0
- cache_sync/providers/postgres/invalidation_bus.py +129 -0
- cache_sync/providers/rabbitmq/__init__.py +7 -0
- cache_sync/providers/rabbitmq/invalidation_bus.py +168 -0
- cache_sync/providers/redis/__init__.py +9 -0
- cache_sync/providers/redis/cache.py +52 -0
- cache_sync/providers/redis/invalidation_bus.py +181 -0
- cache_sync/py.typed +1 -0
- cache_sync/serializers.py +83 -0
- cache_sync-0.3.1.dist-info/METADATA +140 -0
- cache_sync-0.3.1.dist-info/RECORD +20 -0
- cache_sync-0.3.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import socket
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from cache_sync.invalidation import (
|
|
12
|
+
ClearLocal,
|
|
13
|
+
InvalidationMessage,
|
|
14
|
+
RemoveLocal,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class KafkaInvalidationBus:
|
|
19
|
+
"""Invalidation bus backed by a Kafka topic."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
bootstrap_servers: str | Sequence[str],
|
|
25
|
+
topic: str = "cache-sync-invalidations",
|
|
26
|
+
node_name: str | None = None,
|
|
27
|
+
group_id: str | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""Create a Kafka invalidation bus.
|
|
30
|
+
|
|
31
|
+
By default, each node gets a unique consumer group so every node receives
|
|
32
|
+
every invalidation. Supplying the same `group_id` for multiple nodes will
|
|
33
|
+
load-balance messages and is usually wrong for cache invalidation.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
self._bootstrap_servers = bootstrap_servers
|
|
37
|
+
self._topic = topic
|
|
38
|
+
self._source_id = str(uuid.uuid4())
|
|
39
|
+
self._node_name = node_name or f"{socket.gethostname()}-{self._source_id}"
|
|
40
|
+
self._group_id = group_id or f"cache-sync-node:{self._node_name}"
|
|
41
|
+
self._remove_local: RemoveLocal | None = None
|
|
42
|
+
self._clear_local: ClearLocal | None = None
|
|
43
|
+
self._producer: Any | None = None
|
|
44
|
+
self._consumer: Any | None = None
|
|
45
|
+
self._listener_task: asyncio.Task[None] | None = None
|
|
46
|
+
|
|
47
|
+
async def start(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
remove_local: RemoveLocal,
|
|
51
|
+
clear_local: ClearLocal,
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Start the Kafka producer, consumer, and listener task."""
|
|
54
|
+
|
|
55
|
+
if self._listener_task is not None:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
from aiokafka import AIOKafkaConsumer, AIOKafkaProducer
|
|
60
|
+
except ImportError as ex: # pragma: no cover - exercised only without optional deps
|
|
61
|
+
msg = "Install cache-sync with the kafka dependency group to use Kafka."
|
|
62
|
+
raise RuntimeError(msg) from ex
|
|
63
|
+
|
|
64
|
+
self._remove_local = remove_local
|
|
65
|
+
self._clear_local = clear_local
|
|
66
|
+
self._producer = AIOKafkaProducer(bootstrap_servers=self._bootstrap_servers)
|
|
67
|
+
self._consumer = AIOKafkaConsumer(
|
|
68
|
+
self._topic,
|
|
69
|
+
bootstrap_servers=self._bootstrap_servers,
|
|
70
|
+
group_id=self._group_id,
|
|
71
|
+
auto_offset_reset="latest",
|
|
72
|
+
)
|
|
73
|
+
await self._producer.start()
|
|
74
|
+
await self._consumer.start()
|
|
75
|
+
self._listener_task = asyncio.create_task(self._listen())
|
|
76
|
+
|
|
77
|
+
async def stop(self) -> None:
|
|
78
|
+
"""Stop the listener task and close Kafka clients."""
|
|
79
|
+
|
|
80
|
+
if self._listener_task is not None:
|
|
81
|
+
self._listener_task.cancel()
|
|
82
|
+
|
|
83
|
+
with suppress(asyncio.CancelledError):
|
|
84
|
+
await self._listener_task
|
|
85
|
+
|
|
86
|
+
if self._consumer is not None:
|
|
87
|
+
await self._consumer.stop()
|
|
88
|
+
|
|
89
|
+
if self._producer is not None:
|
|
90
|
+
await self._producer.stop()
|
|
91
|
+
|
|
92
|
+
self._listener_task = None
|
|
93
|
+
self._consumer = None
|
|
94
|
+
self._producer = None
|
|
95
|
+
self._remove_local = None
|
|
96
|
+
self._clear_local = None
|
|
97
|
+
|
|
98
|
+
async def invalidate(self, key: str) -> None:
|
|
99
|
+
"""Publish a key-removal message to the Kafka topic."""
|
|
100
|
+
|
|
101
|
+
await self._publish(InvalidationMessage.remove(key))
|
|
102
|
+
|
|
103
|
+
async def clear(self) -> None:
|
|
104
|
+
"""Publish a clear-all message to the Kafka topic."""
|
|
105
|
+
|
|
106
|
+
await self._publish(InvalidationMessage.clear())
|
|
107
|
+
|
|
108
|
+
async def _publish(self, message: InvalidationMessage) -> None:
|
|
109
|
+
if self._producer is None:
|
|
110
|
+
msg = "KafkaInvalidationBus must be started before publishing."
|
|
111
|
+
raise RuntimeError(msg)
|
|
112
|
+
|
|
113
|
+
await self._producer.send_and_wait(
|
|
114
|
+
self._topic,
|
|
115
|
+
self._encode_message(message),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
async def _listen(self) -> None:
|
|
119
|
+
consumer = self._consumer
|
|
120
|
+
if consumer is None:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
async for record in consumer:
|
|
124
|
+
self._apply_payload(record.value)
|
|
125
|
+
|
|
126
|
+
def _apply_payload(self, payload: bytes | str) -> None:
|
|
127
|
+
message = self._decode_message(payload)
|
|
128
|
+
|
|
129
|
+
if message is not None:
|
|
130
|
+
self._apply_message(message)
|
|
131
|
+
|
|
132
|
+
def _apply_message(self, message: InvalidationMessage) -> None:
|
|
133
|
+
if message.action == "remove" and message.key is not None:
|
|
134
|
+
remove_local = self._remove_local
|
|
135
|
+
if remove_local is not None:
|
|
136
|
+
remove_local(message.key)
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
if message.action == "clear":
|
|
140
|
+
clear_local = self._clear_local
|
|
141
|
+
if clear_local is not None:
|
|
142
|
+
clear_local()
|
|
143
|
+
|
|
144
|
+
def _encode_message(self, message: InvalidationMessage) -> bytes:
|
|
145
|
+
payload: dict[str, str] = {
|
|
146
|
+
"action": message.action,
|
|
147
|
+
"source_id": self._source_id,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if message.key is not None:
|
|
151
|
+
payload["key"] = message.key
|
|
152
|
+
|
|
153
|
+
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
154
|
+
|
|
155
|
+
def _decode_message(self, payload: bytes | str) -> InvalidationMessage | None:
|
|
156
|
+
if isinstance(payload, bytes):
|
|
157
|
+
payload = payload.decode("utf-8")
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
data = json.loads(payload)
|
|
161
|
+
except (TypeError, ValueError, UnicodeDecodeError):
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
if not isinstance(data, dict) or data.get("source_id") == self._source_id:
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
if data.get("action") == "remove" and isinstance(data.get("key"), str):
|
|
168
|
+
return InvalidationMessage.remove(data["key"])
|
|
169
|
+
|
|
170
|
+
if data.get("action") == "clear":
|
|
171
|
+
return InvalidationMessage.clear()
|
|
172
|
+
|
|
173
|
+
return None
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from cache_sync.invalidation import (
|
|
9
|
+
ClearLocal,
|
|
10
|
+
InvalidationMessage,
|
|
11
|
+
RemoveLocal,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PostgresNotifyInvalidationBus:
|
|
16
|
+
"""Invalidation bus backed by PostgreSQL LISTEN/NOTIFY."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
connection: Any,
|
|
21
|
+
*,
|
|
22
|
+
channel: str = "cache_sync_invalidations",
|
|
23
|
+
node_name: str | None = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Create a Postgres notification invalidation bus."""
|
|
26
|
+
|
|
27
|
+
self._connection = connection
|
|
28
|
+
self._channel = channel
|
|
29
|
+
self._source_id = str(uuid.uuid4())
|
|
30
|
+
self._node_name = node_name or f"{socket.gethostname()}-{self._source_id}"
|
|
31
|
+
self._remove_local: RemoveLocal | None = None
|
|
32
|
+
self._clear_local: ClearLocal | None = None
|
|
33
|
+
self._started = False
|
|
34
|
+
|
|
35
|
+
async def start(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
remove_local: RemoveLocal,
|
|
39
|
+
clear_local: ClearLocal,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Register a notification listener on the configured channel."""
|
|
42
|
+
|
|
43
|
+
if self._started:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
self._remove_local = remove_local
|
|
47
|
+
self._clear_local = clear_local
|
|
48
|
+
await self._connection.add_listener(self._channel, self._handle_notification)
|
|
49
|
+
self._started = True
|
|
50
|
+
|
|
51
|
+
async def stop(self) -> None:
|
|
52
|
+
"""Remove the notification listener and clear local callbacks."""
|
|
53
|
+
|
|
54
|
+
if self._started:
|
|
55
|
+
await self._connection.remove_listener(self._channel, self._handle_notification)
|
|
56
|
+
|
|
57
|
+
self._started = False
|
|
58
|
+
self._remove_local = None
|
|
59
|
+
self._clear_local = None
|
|
60
|
+
|
|
61
|
+
async def invalidate(self, key: str) -> None:
|
|
62
|
+
"""Publish a key-removal notification."""
|
|
63
|
+
|
|
64
|
+
await self._publish(InvalidationMessage.remove(key))
|
|
65
|
+
|
|
66
|
+
async def clear(self) -> None:
|
|
67
|
+
"""Publish a clear-all notification."""
|
|
68
|
+
|
|
69
|
+
await self._publish(InvalidationMessage.clear())
|
|
70
|
+
|
|
71
|
+
async def _publish(self, message: InvalidationMessage) -> None:
|
|
72
|
+
await self._connection.execute(
|
|
73
|
+
"select pg_notify($1, $2)",
|
|
74
|
+
self._channel,
|
|
75
|
+
self._encode_message(message),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _handle_notification(
|
|
79
|
+
self,
|
|
80
|
+
connection: Any,
|
|
81
|
+
pid: int,
|
|
82
|
+
channel: str,
|
|
83
|
+
payload: str,
|
|
84
|
+
) -> None:
|
|
85
|
+
del connection, pid, channel
|
|
86
|
+
message = self._decode_message(payload)
|
|
87
|
+
|
|
88
|
+
if message is not None:
|
|
89
|
+
self._apply_message(message)
|
|
90
|
+
|
|
91
|
+
def _apply_message(self, message: InvalidationMessage) -> None:
|
|
92
|
+
if message.action == "remove" and message.key is not None:
|
|
93
|
+
remove_local = self._remove_local
|
|
94
|
+
if remove_local is not None:
|
|
95
|
+
remove_local(message.key)
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
if message.action == "clear":
|
|
99
|
+
clear_local = self._clear_local
|
|
100
|
+
if clear_local is not None:
|
|
101
|
+
clear_local()
|
|
102
|
+
|
|
103
|
+
def _encode_message(self, message: InvalidationMessage) -> str:
|
|
104
|
+
payload: dict[str, str] = {
|
|
105
|
+
"action": message.action,
|
|
106
|
+
"source_id": self._source_id,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if message.key is not None:
|
|
110
|
+
payload["key"] = message.key
|
|
111
|
+
|
|
112
|
+
return json.dumps(payload, separators=(",", ":"))
|
|
113
|
+
|
|
114
|
+
def _decode_message(self, payload: str) -> InvalidationMessage | None:
|
|
115
|
+
try:
|
|
116
|
+
data = json.loads(payload)
|
|
117
|
+
except (TypeError, ValueError):
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
if not isinstance(data, dict) or data.get("source_id") == self._source_id:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
if data.get("action") == "remove" and isinstance(data.get("key"), str):
|
|
124
|
+
return InvalidationMessage.remove(data["key"])
|
|
125
|
+
|
|
126
|
+
if data.get("action") == "clear":
|
|
127
|
+
return InvalidationMessage.clear()
|
|
128
|
+
|
|
129
|
+
return None
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import socket
|
|
5
|
+
import uuid
|
|
6
|
+
from contextlib import suppress
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from cache_sync.invalidation import (
|
|
10
|
+
ClearLocal,
|
|
11
|
+
InvalidationMessage,
|
|
12
|
+
RemoveLocal,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RabbitMQInvalidationBus:
|
|
17
|
+
"""Invalidation bus backed by a RabbitMQ fanout exchange."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
connection: Any,
|
|
22
|
+
*,
|
|
23
|
+
exchange_name: str = "cache-sync-invalidations",
|
|
24
|
+
node_name: str | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Create a RabbitMQ invalidation bus using an existing connection."""
|
|
27
|
+
|
|
28
|
+
self._connection = connection
|
|
29
|
+
self._exchange_name = exchange_name
|
|
30
|
+
self._source_id = str(uuid.uuid4())
|
|
31
|
+
self._node_name = node_name or f"{socket.gethostname()}-{self._source_id}"
|
|
32
|
+
self._remove_local: RemoveLocal | None = None
|
|
33
|
+
self._clear_local: ClearLocal | None = None
|
|
34
|
+
self._channel: Any | None = None
|
|
35
|
+
self._exchange: Any | None = None
|
|
36
|
+
self._queue: Any | None = None
|
|
37
|
+
self._consumer_tag: str | None = None
|
|
38
|
+
|
|
39
|
+
async def start(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
remove_local: RemoveLocal,
|
|
43
|
+
clear_local: ClearLocal,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Declare the fanout exchange, bind an exclusive queue, and consume."""
|
|
46
|
+
|
|
47
|
+
if self._channel is not None:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
from aio_pika import ExchangeType
|
|
52
|
+
except ImportError as ex: # pragma: no cover - exercised only without optional deps
|
|
53
|
+
msg = "Install cache-sync with the rabbitmq dependency group to use RabbitMQ."
|
|
54
|
+
raise RuntimeError(msg) from ex
|
|
55
|
+
|
|
56
|
+
self._remove_local = remove_local
|
|
57
|
+
self._clear_local = clear_local
|
|
58
|
+
self._channel = await self._connection.channel()
|
|
59
|
+
self._exchange = await self._channel.declare_exchange(
|
|
60
|
+
self._exchange_name,
|
|
61
|
+
ExchangeType.FANOUT,
|
|
62
|
+
)
|
|
63
|
+
self._queue = await self._channel.declare_queue(
|
|
64
|
+
exclusive=True,
|
|
65
|
+
auto_delete=True,
|
|
66
|
+
)
|
|
67
|
+
await self._queue.bind(self._exchange)
|
|
68
|
+
self._consumer_tag = await self._queue.consume(self._handle_incoming_message)
|
|
69
|
+
|
|
70
|
+
async def stop(self) -> None:
|
|
71
|
+
"""Cancel consumption and close the created channel."""
|
|
72
|
+
|
|
73
|
+
if self._queue is not None and self._consumer_tag is not None:
|
|
74
|
+
with suppress(Exception):
|
|
75
|
+
await self._queue.cancel(self._consumer_tag)
|
|
76
|
+
|
|
77
|
+
if self._channel is not None:
|
|
78
|
+
with suppress(Exception):
|
|
79
|
+
await self._channel.close()
|
|
80
|
+
|
|
81
|
+
self._channel = None
|
|
82
|
+
self._exchange = None
|
|
83
|
+
self._queue = None
|
|
84
|
+
self._consumer_tag = None
|
|
85
|
+
self._remove_local = None
|
|
86
|
+
self._clear_local = None
|
|
87
|
+
|
|
88
|
+
async def invalidate(self, key: str) -> None:
|
|
89
|
+
"""Publish a key-removal message to the fanout exchange."""
|
|
90
|
+
|
|
91
|
+
await self._publish(InvalidationMessage.remove(key))
|
|
92
|
+
|
|
93
|
+
async def clear(self) -> None:
|
|
94
|
+
"""Publish a clear-all message to the fanout exchange."""
|
|
95
|
+
|
|
96
|
+
await self._publish(InvalidationMessage.clear())
|
|
97
|
+
|
|
98
|
+
async def _publish(self, message: InvalidationMessage) -> None:
|
|
99
|
+
if self._exchange is None:
|
|
100
|
+
msg = "RabbitMQInvalidationBus must be started before publishing."
|
|
101
|
+
raise RuntimeError(msg)
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
from aio_pika import Message
|
|
105
|
+
except ImportError as ex: # pragma: no cover - exercised only without optional deps
|
|
106
|
+
msg = "Install cache-sync with the rabbitmq dependency group to use RabbitMQ."
|
|
107
|
+
raise RuntimeError(msg) from ex
|
|
108
|
+
|
|
109
|
+
await self._exchange.publish(
|
|
110
|
+
Message(
|
|
111
|
+
body=self._encode_message(message),
|
|
112
|
+
content_type="application/json",
|
|
113
|
+
),
|
|
114
|
+
routing_key="",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def _handle_incoming_message(self, incoming_message: Any) -> None:
|
|
118
|
+
async with incoming_message.process():
|
|
119
|
+
self._apply_payload(incoming_message.body)
|
|
120
|
+
|
|
121
|
+
def _apply_payload(self, payload: bytes | str) -> None:
|
|
122
|
+
message = self._decode_message(payload)
|
|
123
|
+
|
|
124
|
+
if message is not None:
|
|
125
|
+
self._apply_message(message)
|
|
126
|
+
|
|
127
|
+
def _apply_message(self, message: InvalidationMessage) -> None:
|
|
128
|
+
if message.action == "remove" and message.key is not None:
|
|
129
|
+
remove_local = self._remove_local
|
|
130
|
+
if remove_local is not None:
|
|
131
|
+
remove_local(message.key)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
if message.action == "clear":
|
|
135
|
+
clear_local = self._clear_local
|
|
136
|
+
if clear_local is not None:
|
|
137
|
+
clear_local()
|
|
138
|
+
|
|
139
|
+
def _encode_message(self, message: InvalidationMessage) -> bytes:
|
|
140
|
+
payload: dict[str, str] = {
|
|
141
|
+
"action": message.action,
|
|
142
|
+
"source_id": self._source_id,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if message.key is not None:
|
|
146
|
+
payload["key"] = message.key
|
|
147
|
+
|
|
148
|
+
return json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
149
|
+
|
|
150
|
+
def _decode_message(self, payload: bytes | str) -> InvalidationMessage | None:
|
|
151
|
+
if isinstance(payload, bytes):
|
|
152
|
+
payload = payload.decode("utf-8")
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
data = json.loads(payload)
|
|
156
|
+
except (TypeError, ValueError, UnicodeDecodeError):
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
if not isinstance(data, dict) or data.get("source_id") == self._source_id:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
if data.get("action") == "remove" and isinstance(data.get("key"), str):
|
|
163
|
+
return InvalidationMessage.remove(data["key"])
|
|
164
|
+
|
|
165
|
+
if data.get("action") == "clear":
|
|
166
|
+
return InvalidationMessage.clear()
|
|
167
|
+
|
|
168
|
+
return None
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Redis provider exports."""
|
|
2
|
+
|
|
3
|
+
from cache_sync.providers.redis.cache import RedisDistributedCache
|
|
4
|
+
from cache_sync.providers.redis.invalidation_bus import RedisStreamsInvalidationBus
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"RedisDistributedCache",
|
|
8
|
+
"RedisStreamsInvalidationBus",
|
|
9
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from redis.asyncio import Redis
|
|
4
|
+
|
|
5
|
+
from cache_sync.serializers import PickleSerializer, Serializer
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RedisDistributedCache:
|
|
9
|
+
"""Distributed cache implementation backed by Redis string keys."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
redis: Redis,
|
|
14
|
+
*,
|
|
15
|
+
prefix: str = "cache-sync:",
|
|
16
|
+
serializer: Serializer | None = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
"""Create a Redis distributed cache with an optional key prefix."""
|
|
19
|
+
|
|
20
|
+
self._redis = redis
|
|
21
|
+
self._prefix = prefix
|
|
22
|
+
self._serializer = serializer or PickleSerializer()
|
|
23
|
+
|
|
24
|
+
async def get(self, key: str) -> object | None:
|
|
25
|
+
"""Return a deserialized value or `None` when the key is missing."""
|
|
26
|
+
|
|
27
|
+
value = await self._redis.get(self._key(key))
|
|
28
|
+
|
|
29
|
+
if value is None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
if isinstance(value, str):
|
|
33
|
+
value = value.encode("utf-8")
|
|
34
|
+
|
|
35
|
+
return self._serializer.loads(value)
|
|
36
|
+
|
|
37
|
+
async def set(self, key: str, value: object, ttl_seconds: float) -> None:
|
|
38
|
+
"""Serialize and store a value with a Redis expiration."""
|
|
39
|
+
|
|
40
|
+
await self._redis.set(
|
|
41
|
+
self._key(key),
|
|
42
|
+
self._serializer.dumps(value),
|
|
43
|
+
ex=max(1, int(ttl_seconds)),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async def delete(self, key: str) -> None:
|
|
47
|
+
"""Delete a key from Redis."""
|
|
48
|
+
|
|
49
|
+
await self._redis.delete(self._key(key))
|
|
50
|
+
|
|
51
|
+
def _key(self, key: str) -> str:
|
|
52
|
+
return f"{self._prefix}{key}"
|