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,181 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import socket
5
+ import uuid
6
+ from collections.abc import Mapping
7
+ from contextlib import suppress
8
+ from typing import cast
9
+
10
+ from redis.asyncio import Redis
11
+ from redis.exceptions import ResponseError
12
+ from redis.typing import EncodableT, FieldT
13
+
14
+ from cache_sync.invalidation import (
15
+ ClearLocal,
16
+ InvalidationAction,
17
+ InvalidationMessage,
18
+ RemoveLocal,
19
+ )
20
+
21
+ type RedisFields = Mapping[bytes | str, bytes | str]
22
+ type RedisMessage = tuple[bytes | str, RedisFields]
23
+ type RedisStreamResponse = list[tuple[bytes | str, list[RedisMessage]]]
24
+
25
+
26
+ class RedisStreamsInvalidationBus:
27
+ """Invalidation bus backed by Redis Streams consumer groups."""
28
+
29
+ def __init__(
30
+ self,
31
+ redis: Redis,
32
+ *,
33
+ stream_name: str = "cache-sync:invalidations",
34
+ node_name: str | None = None,
35
+ max_length: int = 10_000,
36
+ ) -> None:
37
+ """Create a Redis Streams invalidation bus."""
38
+
39
+ self._redis = redis
40
+ self._stream_name = stream_name
41
+ self._source_id = str(uuid.uuid4())
42
+ self._node_name = node_name or f"{socket.gethostname()}-{self._source_id}"
43
+ self._group_name = f"cache-sync-node:{self._node_name}"
44
+ self._consumer_name = self._node_name
45
+ self._max_length = max_length
46
+ self._remove_local: RemoveLocal | None = None
47
+ self._clear_local: ClearLocal | None = None
48
+ self._listener_task: asyncio.Task[None] | None = None
49
+ self._stopped = asyncio.Event()
50
+
51
+ async def start(
52
+ self,
53
+ *,
54
+ remove_local: RemoveLocal,
55
+ clear_local: ClearLocal,
56
+ ) -> None:
57
+ """Create the consumer group if needed and start the listener task."""
58
+
59
+ if self._listener_task is not None:
60
+ return
61
+
62
+ self._remove_local = remove_local
63
+ self._clear_local = clear_local
64
+ self._stopped.clear()
65
+ await self._ensure_group()
66
+ self._listener_task = asyncio.create_task(self._listen())
67
+
68
+ async def stop(self) -> None:
69
+ """Cancel the listener task and clear local callbacks."""
70
+
71
+ self._stopped.set()
72
+
73
+ if self._listener_task is None:
74
+ return
75
+
76
+ self._listener_task.cancel()
77
+
78
+ with suppress(asyncio.CancelledError):
79
+ await self._listener_task
80
+
81
+ self._listener_task = None
82
+ self._remove_local = None
83
+ self._clear_local = None
84
+
85
+ async def invalidate(self, key: str) -> None:
86
+ """Publish a key-removal message to the stream."""
87
+
88
+ await self._publish(InvalidationMessage.remove(key))
89
+
90
+ async def clear(self) -> None:
91
+ """Publish a clear-all message to the stream."""
92
+
93
+ await self._publish(InvalidationMessage.clear())
94
+
95
+ async def _publish(self, message: InvalidationMessage) -> None:
96
+ fields: dict[FieldT, EncodableT] = {
97
+ "action": message.action,
98
+ "source_id": self._source_id,
99
+ }
100
+
101
+ if message.key is not None:
102
+ fields["key"] = message.key
103
+
104
+ await self._redis.xadd(
105
+ self._stream_name,
106
+ fields,
107
+ maxlen=self._max_length,
108
+ approximate=True,
109
+ )
110
+
111
+ async def _ensure_group(self) -> None:
112
+ try:
113
+ await self._redis.xgroup_create(
114
+ self._stream_name,
115
+ self._group_name,
116
+ id="$",
117
+ mkstream=True,
118
+ )
119
+ except ResponseError as ex:
120
+ if "BUSYGROUP" not in str(ex):
121
+ raise
122
+
123
+ async def _listen(self) -> None:
124
+ while not self._stopped.is_set():
125
+ response = cast(
126
+ RedisStreamResponse,
127
+ await self._redis.xreadgroup(
128
+ groupname=self._group_name,
129
+ consumername=self._consumer_name,
130
+ streams={self._stream_name: ">"},
131
+ count=25,
132
+ block=5_000,
133
+ ),
134
+ )
135
+
136
+ for _, messages in response:
137
+ for message_id, fields in messages:
138
+ await self._process_message(message_id, fields)
139
+
140
+ async def _process_message(
141
+ self,
142
+ message_id: bytes | str,
143
+ fields: RedisFields,
144
+ ) -> None:
145
+ source_id = self._get_field(fields, "source_id")
146
+
147
+ if source_id != self._source_id:
148
+ self._apply_message(self._to_message(fields))
149
+
150
+ await self._redis.xack(
151
+ self._stream_name,
152
+ self._group_name,
153
+ message_id,
154
+ )
155
+
156
+ def _apply_message(self, message: InvalidationMessage) -> None:
157
+ if message.action == "remove" and message.key is not None:
158
+ remove_local = self._remove_local
159
+ if remove_local is not None:
160
+ remove_local(message.key)
161
+ return
162
+
163
+ if message.action == "clear":
164
+ clear_local = self._clear_local
165
+ if clear_local is not None:
166
+ clear_local()
167
+
168
+ def _to_message(self, fields: RedisFields) -> InvalidationMessage:
169
+ action = cast(InvalidationAction, self._get_field(fields, "action"))
170
+
171
+ if action == "remove":
172
+ return InvalidationMessage.remove(self._get_field(fields, "key"))
173
+
174
+ return InvalidationMessage.clear()
175
+
176
+ def _get_field(self, fields: RedisFields, key: str) -> str:
177
+ value = fields.get(key)
178
+ if value is None:
179
+ value = fields[key.encode("utf-8")]
180
+
181
+ return value.decode("utf-8") if isinstance(value, bytes) else value
cache_sync/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import pickle
5
+ from collections.abc import Callable
6
+ from typing import Generic, Protocol, TypeVar, cast
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ class Serializer(Protocol):
12
+ """Protocol for converting distributed-cache values to and from bytes."""
13
+
14
+ def dumps(self, value: object) -> bytes: ...
15
+
16
+ def loads(self, value: bytes) -> object: ...
17
+
18
+
19
+ class PickleSerializer:
20
+ """Serialize arbitrary trusted Python objects with pickle."""
21
+
22
+ def dumps(self, value: object) -> bytes:
23
+ """Serialize a Python object to bytes."""
24
+
25
+ return pickle.dumps(value)
26
+
27
+ def loads(self, value: bytes) -> object:
28
+ """Deserialize bytes into a Python object."""
29
+
30
+ return pickle.loads(value)
31
+
32
+
33
+ class JsonSerializer:
34
+ """Serialize JSON-compatible values as UTF-8 JSON bytes."""
35
+
36
+ def dumps(self, value: object) -> bytes:
37
+ """Serialize a JSON-compatible value to bytes."""
38
+
39
+ return json.dumps(value).encode("utf-8")
40
+
41
+ def loads(self, value: bytes) -> object:
42
+ """Deserialize UTF-8 JSON bytes."""
43
+
44
+ return json.loads(value.decode("utf-8"))
45
+
46
+
47
+ class PydanticSerializer(Generic[T]):
48
+ """Serialize and deserialize Pydantic model instances."""
49
+
50
+ def __init__(self, model_type: type[T]) -> None:
51
+ """Create a serializer for the supplied Pydantic model type."""
52
+
53
+ self._model_type = model_type
54
+
55
+ def dumps(self, value: object) -> bytes:
56
+ """Serialize a Pydantic model instance to JSON bytes."""
57
+
58
+ model_dump_json = getattr(value, "model_dump_json", None)
59
+ if callable(model_dump_json):
60
+ return cast(Callable[[], str], model_dump_json)().encode("utf-8")
61
+
62
+ json_method = getattr(value, "json", None)
63
+ if callable(json_method):
64
+ return cast(Callable[[], str], json_method)().encode("utf-8")
65
+
66
+ msg = "PydanticSerializer can only dump Pydantic model instances"
67
+ raise TypeError(msg)
68
+
69
+ def loads(self, value: bytes) -> T:
70
+ """Deserialize JSON bytes into the configured Pydantic model type."""
71
+
72
+ raw = value.decode("utf-8")
73
+
74
+ model_validate_json = getattr(self._model_type, "model_validate_json", None)
75
+ if callable(model_validate_json):
76
+ return cast(Callable[[str], T], model_validate_json)(raw)
77
+
78
+ parse_raw = getattr(self._model_type, "parse_raw", None)
79
+ if callable(parse_raw):
80
+ return cast(Callable[[str], T], parse_raw)(raw)
81
+
82
+ msg = "PydanticSerializer requires a Pydantic model type"
83
+ raise TypeError(msg)
@@ -0,0 +1,140 @@
1
+ Metadata-Version: 2.3
2
+ Name: cache-sync
3
+ Version: 0.3.1
4
+ Summary: Async hybrid Python cache with in-memory L1, distributed L2 providers, pluggable invalidation, stampede protection, and typed decorators.
5
+ Keywords: async,cache,redis,invalidation,stampede-protection
6
+ Author: Peter Cinibulk
7
+ License: MIT
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Framework :: AsyncIO
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Typing :: Typed
19
+ Requires-Dist: redis>=5.0.0 ; extra == 'all'
20
+ Requires-Dist: aio-pika>=9.0.0 ; extra == 'all'
21
+ Requires-Dist: aiokafka>=0.10.0 ; extra == 'all'
22
+ Requires-Dist: asyncpg>=0.29.0 ; extra == 'all'
23
+ Requires-Dist: pydantic>=1.10.0 ; extra == 'all'
24
+ Requires-Dist: aiokafka>=0.10.0 ; extra == 'kafka'
25
+ Requires-Dist: asyncpg>=0.29.0 ; extra == 'postgres'
26
+ Requires-Dist: pydantic>=1.10.0 ; extra == 'pydantic'
27
+ Requires-Dist: aio-pika>=9.0.0 ; extra == 'rabbitmq'
28
+ Requires-Dist: redis>=5.0.0 ; extra == 'redis'
29
+ Requires-Python: >=3.12
30
+ Project-URL: Changelog, https://github.com/petercinibulk/cache-sync/blob/main/CHANGELOG.md
31
+ Project-URL: Documentation, https://petercinibulk.github.io/cache-sync/
32
+ Project-URL: Issues, https://github.com/petercinibulk/cache-sync/issues
33
+ Project-URL: Repository, https://github.com/petercinibulk/cache-sync
34
+ Provides-Extra: all
35
+ Provides-Extra: kafka
36
+ Provides-Extra: postgres
37
+ Provides-Extra: pydantic
38
+ Provides-Extra: rabbitmq
39
+ Provides-Extra: redis
40
+ Description-Content-Type: text/markdown
41
+
42
+ # cache-sync
43
+
44
+ Async hybrid Python cache with in-memory L1 caching, optional Redis L2 caching, pluggable invalidation, stampede protection, fail-safe stale values, and typed decorators.
45
+
46
+ ## Features
47
+
48
+ - Async-first API for Python 3.12 and newer.
49
+ - Fast in-process L1 cache with optional Redis-backed L2 storage.
50
+ - Pluggable invalidation buses for Redis Streams, RabbitMQ, Kafka, and PostgreSQL.
51
+ - Request stampede protection with per-key refresh coordination.
52
+ - Fail-safe stale reads for short backend outages.
53
+ - Typed decorators that preserve the wrapped function signature.
54
+ - Serializer choices for JSON, pickle, and Pydantic models.
55
+
56
+ ## Documentation
57
+
58
+ The end-user documentation is published at <https://petercinibulk.github.io/cache-sync/> and is built from [`docs/`](docs/index.md) with Zensical.
59
+
60
+ ## Install
61
+
62
+ ```bash
63
+ uv add cache-sync
64
+ ```
65
+
66
+ Install optional providers only when your application uses them:
67
+
68
+ ```bash
69
+ uv add "cache-sync[redis]"
70
+ uv add "cache-sync[rabbitmq]"
71
+ uv add "cache-sync[kafka]"
72
+ uv add "cache-sync[postgres]"
73
+ uv add "cache-sync[all]"
74
+ ```
75
+
76
+ | Extra | Installs | Use when |
77
+ | --- | --- | --- |
78
+ | `redis` | `redis` | You need Redis L2 storage or Redis Streams invalidation. |
79
+ | `rabbitmq` | `aio-pika` | You use RabbitMQ as the invalidation bus. |
80
+ | `kafka` | `aiokafka` | You use Kafka as the invalidation bus. |
81
+ | `postgres` | `asyncpg` | You use PostgreSQL `LISTEN`/`NOTIFY` for invalidation. |
82
+ | `pydantic` | `pydantic` | You want Pydantic model serialization helpers. |
83
+ | `all` | all provider dependencies | You want every optional provider available. |
84
+
85
+ ## Quick Start
86
+
87
+ ```python
88
+ from cache_sync import CacheOptions, CacheSync
89
+
90
+ cache = CacheSync(
91
+ options=CacheOptions(
92
+ ttl_seconds=60,
93
+ fail_safe_seconds=300,
94
+ hard_timeout_seconds=5,
95
+ jitter_seconds=5,
96
+ ),
97
+ )
98
+
99
+ await cache.start()
100
+
101
+
102
+ @cache.cached(lambda user_id: f"user:{user_id}")
103
+ async def get_user(user_id: str) -> dict[str, str]:
104
+ return {"id": user_id, "name": "Peter"}
105
+
106
+
107
+ user = await get_user("123")
108
+ await get_user.remove_cached("123")
109
+ await cache.stop()
110
+ ```
111
+
112
+ ## Redis L2 Example
113
+
114
+ ```python
115
+ from redis.asyncio import Redis
116
+
117
+ from cache_sync import CacheOptions, CacheSync, RedisDistributedCache
118
+
119
+ redis = Redis.from_url("redis://localhost:6379/0")
120
+
121
+ cache = CacheSync(
122
+ distributed_cache=RedisDistributedCache(redis),
123
+ options=CacheOptions(ttl_seconds=60, fail_safe_seconds=300),
124
+ )
125
+
126
+ await cache.start()
127
+
128
+
129
+ @cache.cached(lambda product_id: f"product:{product_id}")
130
+ async def get_product(product_id: str) -> dict[str, str]:
131
+ return {"id": product_id}
132
+ ```
133
+
134
+ For a complete walkthrough with shared values and cross-instance invalidation, see the [get started tutorial](https://petercinibulk.github.io/cache-sync/tutorials/get-started/).
135
+
136
+ ## Project
137
+
138
+ - License: MIT
139
+ - Source: <https://github.com/petercinibulk/cache-sync>
140
+ - Issues: <https://github.com/petercinibulk/cache-sync/issues>
@@ -0,0 +1,20 @@
1
+ cache_sync/__init__.py,sha256=av8jQbh1cKp0hXgxrBg8cJ_o3nEswH7OZ4M6xcshdd0,2272
2
+ cache_sync/core.py,sha256=XPBSn4cBfjeT-UXgs_4j6-TJXLbu2VLPgZ8cMdonLw4,7964
3
+ cache_sync/decorators.py,sha256=EbZkDiertdt5RYHO4dKe643-RDjme0RldqREBtkiAB4,2917
4
+ cache_sync/distributed_cache.py,sha256=x6US0OCJXNpJBvLGrVAQDGonekI3RcEjWF_AK-Zr5So,481
5
+ cache_sync/invalidation.py,sha256=vhYvpWstVXRCbDhKcHGSSjHnve7DYMDD3mciitgBJhw,3308
6
+ cache_sync/providers/__init__.py,sha256=vFj6aHry3F1rSqDOAUzlo_weG-ymXsXTPalNMhU4PMQ,47
7
+ cache_sync/providers/kafka/__init__.py,sha256=PGhdXK_FMLKKrJeq0zvIOxcvFuwvnXzgH-SgSsjU_XI,151
8
+ cache_sync/providers/kafka/invalidation_bus.py,sha256=LiNIzdomR50gPDxcTrCTDaX9UEhWG9mmsTIU4oOEXFE,5620
9
+ cache_sync/providers/postgres/__init__.py,sha256=10deqZGvUbRUQ3EZfsHMTq0Vb-Lp5TlNOWy0cX-sFSk,177
10
+ cache_sync/providers/postgres/invalidation_bus.py,sha256=D2XwceJvHJXOj58cnXhLu_BlZTbMSSODlnjrKfzjU78,3803
11
+ cache_sync/providers/rabbitmq/__init__.py,sha256=RmdeHVqoRqDaQil6tjMG0heV3htgxgzgaUYNwAvbUhA,163
12
+ cache_sync/providers/rabbitmq/invalidation_bus.py,sha256=X57JkfMHU3FjBslLT5aYKPnTwh0xrpsRVBeJvoH_T2E,5613
13
+ cache_sync/providers/redis/__init__.py,sha256=L5HFwdqHdjURLjR0rx4r_FleFiLaIedZ6KjIC1SlKeA,261
14
+ cache_sync/providers/redis/cache.py,sha256=cl7Q12vydsKLy_LfK26eqjS0bsFOxY1caELX6Lb2L00,1467
15
+ cache_sync/providers/redis/invalidation_bus.py,sha256=XDO6NdgIXhxsUOsZF7j0BmuBzn0faOWQjWWCW-MdvKA,5523
16
+ cache_sync/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
17
+ cache_sync/serializers.py,sha256=Ga3VzqVmMIK_dSYhF4YyQZhQDOup93wrnB1ZrBgHLbM,2552
18
+ cache_sync-0.3.1.dist-info/WHEEL,sha256=oBsDExVIEya4llboy9Ce1l6on8xt3GrtT29y6pYVypw,81
19
+ cache_sync-0.3.1.dist-info/METADATA,sha256=kcyDd3_fKmINbSNwUGOXHyXUYHQ16VbWIG8nAbmdAVo,4732
20
+ cache_sync-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.23
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any