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.
@@ -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,7 @@
1
+ """PostgreSQL provider exports."""
2
+
3
+ from cache_sync.providers.postgres.invalidation_bus import PostgresNotifyInvalidationBus
4
+
5
+ __all__ = [
6
+ "PostgresNotifyInvalidationBus",
7
+ ]
@@ -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,7 @@
1
+ """RabbitMQ provider exports."""
2
+
3
+ from cache_sync.providers.rabbitmq.invalidation_bus import RabbitMQInvalidationBus
4
+
5
+ __all__ = [
6
+ "RabbitMQInvalidationBus",
7
+ ]
@@ -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}"