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 ADDED
@@ -0,0 +1,80 @@
1
+ """Public API for cache-sync."""
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from cache_sync.core import CacheOptions, CacheSync
6
+ from cache_sync.decorators import CachedFunction
7
+ from cache_sync.distributed_cache import DistributedCache
8
+ from cache_sync.invalidation import (
9
+ InvalidationBus,
10
+ InvalidationHandler,
11
+ InvalidationMessage,
12
+ InvalidationTransport,
13
+ TransportInvalidationBus,
14
+ )
15
+ from cache_sync.serializers import (
16
+ JsonSerializer,
17
+ PickleSerializer,
18
+ PydanticSerializer,
19
+ Serializer,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from cache_sync.providers.kafka import KafkaInvalidationBus
24
+ from cache_sync.providers.postgres import PostgresNotifyInvalidationBus
25
+ from cache_sync.providers.rabbitmq import RabbitMQInvalidationBus
26
+ from cache_sync.providers.redis import (
27
+ RedisDistributedCache,
28
+ RedisStreamsInvalidationBus,
29
+ )
30
+
31
+ __all__ = [
32
+ "CacheOptions",
33
+ "CacheSync",
34
+ "CachedFunction",
35
+ "DistributedCache",
36
+ "InvalidationBus",
37
+ "InvalidationHandler",
38
+ "InvalidationMessage",
39
+ "InvalidationTransport",
40
+ "JsonSerializer",
41
+ "KafkaInvalidationBus",
42
+ "PickleSerializer",
43
+ "PostgresNotifyInvalidationBus",
44
+ "PydanticSerializer",
45
+ "RabbitMQInvalidationBus",
46
+ "RedisDistributedCache",
47
+ "RedisStreamsInvalidationBus",
48
+ "Serializer",
49
+ "TransportInvalidationBus",
50
+ ]
51
+
52
+
53
+ def __getattr__(name: str) -> Any:
54
+ if name == "RedisDistributedCache":
55
+ from cache_sync.providers.redis import RedisDistributedCache
56
+
57
+ return RedisDistributedCache
58
+
59
+ if name == "RedisStreamsInvalidationBus":
60
+ from cache_sync.providers.redis import RedisStreamsInvalidationBus
61
+
62
+ return RedisStreamsInvalidationBus
63
+
64
+ if name == "RabbitMQInvalidationBus":
65
+ from cache_sync.providers.rabbitmq import RabbitMQInvalidationBus
66
+
67
+ return RabbitMQInvalidationBus
68
+
69
+ if name == "KafkaInvalidationBus":
70
+ from cache_sync.providers.kafka import KafkaInvalidationBus
71
+
72
+ return KafkaInvalidationBus
73
+
74
+ if name == "PostgresNotifyInvalidationBus":
75
+ from cache_sync.providers.postgres import PostgresNotifyInvalidationBus
76
+
77
+ return PostgresNotifyInvalidationBus
78
+
79
+ msg = f"module {__name__!r} has no attribute {name!r}"
80
+ raise AttributeError(msg)
cache_sync/core.py ADDED
@@ -0,0 +1,256 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import time
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass, field
8
+ from typing import TYPE_CHECKING, ParamSpec, TypeVar, cast
9
+
10
+ from cache_sync.distributed_cache import DistributedCache
11
+ from cache_sync.invalidation import InvalidationBus
12
+
13
+ T = TypeVar("T")
14
+ P = ParamSpec("P")
15
+ _CACHE_OPTION_DEFAULTS = {
16
+ "ttl_seconds": 60.0,
17
+ "fail_safe_seconds": 300.0,
18
+ "hard_timeout_seconds": 5.0,
19
+ "jitter_seconds": 0.0,
20
+ }
21
+
22
+
23
+ class _Unset:
24
+ __slots__ = ()
25
+
26
+
27
+ _UNSET = _Unset()
28
+
29
+ if TYPE_CHECKING:
30
+ from cache_sync.decorators import CachedFunction
31
+
32
+
33
+ @dataclass(frozen=True, slots=True, init=False)
34
+ class CacheOptions:
35
+ """Runtime policy for cache freshness, factory timeouts, and TTL jitter."""
36
+
37
+ ttl_seconds: float = 60
38
+ fail_safe_seconds: float = 300
39
+ hard_timeout_seconds: float = 5
40
+ jitter_seconds: float = 0
41
+ _supplied: frozenset[str] = field(
42
+ default_factory=frozenset,
43
+ repr=False,
44
+ compare=False,
45
+ )
46
+
47
+ def __init__(
48
+ self,
49
+ ttl_seconds: float | _Unset = _UNSET,
50
+ fail_safe_seconds: float | _Unset = _UNSET,
51
+ hard_timeout_seconds: float | _Unset = _UNSET,
52
+ jitter_seconds: float | _Unset = _UNSET,
53
+ ) -> None:
54
+ values = {
55
+ "ttl_seconds": ttl_seconds,
56
+ "fail_safe_seconds": fail_safe_seconds,
57
+ "hard_timeout_seconds": hard_timeout_seconds,
58
+ "jitter_seconds": jitter_seconds,
59
+ }
60
+ supplied = frozenset(name for name, value in values.items() if value is not _UNSET)
61
+
62
+ for name, default in _CACHE_OPTION_DEFAULTS.items():
63
+ value = values[name]
64
+ object.__setattr__(self, name, default if value is _UNSET else value)
65
+
66
+ object.__setattr__(self, "_supplied", supplied)
67
+
68
+ def merge_over(self, defaults: CacheOptions) -> CacheOptions:
69
+ """Return this option object's supplied fields over cache defaults."""
70
+
71
+ values = {
72
+ name: getattr(self if name in self._supplied else defaults, name)
73
+ for name in _CACHE_OPTION_DEFAULTS
74
+ }
75
+ return CacheOptions(**values)
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class CacheEntry:
80
+ """In-memory cache entry with freshness and fail-safe deadlines."""
81
+
82
+ value: object
83
+ expires_at: float
84
+ fail_safe_until: float
85
+
86
+ @property
87
+ def is_fresh(self) -> bool:
88
+ return time.monotonic() < self.expires_at
89
+
90
+ @property
91
+ def is_fail_safe_available(self) -> bool:
92
+ return time.monotonic() < self.fail_safe_until
93
+
94
+
95
+ class CacheSync:
96
+ """Async two-level cache with optional distributed storage and invalidation."""
97
+
98
+ def __init__(
99
+ self,
100
+ *,
101
+ distributed_cache: DistributedCache | None = None,
102
+ invalidation_bus: InvalidationBus | None = None,
103
+ options: CacheOptions | None = None,
104
+ ) -> None:
105
+ """Create a cache using optional L2 storage and invalidation providers."""
106
+
107
+ self._memory: dict[str, CacheEntry] = {}
108
+ self._locks: dict[str, asyncio.Lock] = {}
109
+ self._distributed_cache = distributed_cache
110
+ self._invalidation_bus = invalidation_bus
111
+ self._options = options or CacheOptions()
112
+
113
+ async def start(self) -> None:
114
+ """Start the configured invalidation bus, if any."""
115
+
116
+ if self._invalidation_bus is None:
117
+ return
118
+
119
+ await self._invalidation_bus.start(
120
+ remove_local=self.remove_local,
121
+ clear_local=self.clear_memory,
122
+ )
123
+
124
+ async def stop(self) -> None:
125
+ """Stop the configured invalidation bus, if any."""
126
+
127
+ if self._invalidation_bus is not None:
128
+ await self._invalidation_bus.stop()
129
+
130
+ def cached(
131
+ self,
132
+ key: str | Callable[..., str] | None = None,
133
+ *,
134
+ options: CacheOptions | None = None,
135
+ ) -> Callable[[Callable[P, Awaitable[T]]], CachedFunction[P, T]]:
136
+ """Decorate an async function using this cache instance."""
137
+
138
+ from cache_sync.decorators import CachedFunction
139
+
140
+ def decorator(func: Callable[P, Awaitable[T]]) -> CachedFunction[P, T]:
141
+ return CachedFunction(self, func, key, options)
142
+
143
+ return decorator
144
+
145
+ async def get_or_set(
146
+ self,
147
+ key: str,
148
+ factory: Callable[[], Awaitable[T]],
149
+ *,
150
+ options: CacheOptions | None = None,
151
+ ) -> T:
152
+ """Return a cached value or compute, store, and return a new value."""
153
+
154
+ opts = self._effective_options(options)
155
+ entry = self._memory.get(key)
156
+
157
+ if entry and entry.is_fresh:
158
+ return cast(T, entry.value)
159
+
160
+ lock = self._locks.setdefault(key, asyncio.Lock())
161
+
162
+ async with lock:
163
+ entry = self._memory.get(key)
164
+ if entry and entry.is_fresh:
165
+ return cast(T, entry.value)
166
+
167
+ if self._distributed_cache is not None:
168
+ cached_value = await self._distributed_cache.get(key)
169
+ if cached_value is not None:
170
+ self._set_memory(key, cached_value, opts)
171
+ return cast(T, cached_value)
172
+
173
+ stale = entry if entry and entry.is_fail_safe_available else None
174
+
175
+ try:
176
+ value = await asyncio.wait_for(
177
+ factory(),
178
+ timeout=opts.hard_timeout_seconds,
179
+ )
180
+ await self.set(key, value, options=opts, publish_invalidation=False)
181
+ return value
182
+ except Exception:
183
+ if stale is not None:
184
+ return cast(T, stale.value)
185
+ raise
186
+
187
+ async def set(
188
+ self,
189
+ key: str,
190
+ value: object,
191
+ *,
192
+ options: CacheOptions | None = None,
193
+ publish_invalidation: bool = True,
194
+ ) -> None:
195
+ """Store a value in local memory and optional distributed storage."""
196
+
197
+ opts = self._effective_options(options)
198
+ self._set_memory(key, value, opts)
199
+
200
+ if self._distributed_cache is not None:
201
+ await self._distributed_cache.set(
202
+ key,
203
+ value,
204
+ ttl_seconds=self._ttl_with_jitter(opts),
205
+ )
206
+
207
+ if publish_invalidation and self._invalidation_bus is not None:
208
+ await self._invalidation_bus.invalidate(key)
209
+
210
+ async def remove(self, key: str) -> None:
211
+ """Remove a key locally, from distributed storage, and from peer nodes."""
212
+
213
+ self.remove_local(key)
214
+
215
+ if self._distributed_cache is not None:
216
+ await self._distributed_cache.delete(key)
217
+
218
+ if self._invalidation_bus is not None:
219
+ await self._invalidation_bus.invalidate(key)
220
+
221
+ async def clear(self) -> None:
222
+ """Clear all local entries and publish a clear message to peer nodes."""
223
+
224
+ self.clear_memory()
225
+
226
+ if self._invalidation_bus is not None:
227
+ await self._invalidation_bus.clear()
228
+
229
+ def remove_local(self, key: str) -> None:
230
+ """Remove a key from only this process's in-memory cache."""
231
+
232
+ self._memory.pop(key, None)
233
+
234
+ def clear_memory(self) -> None:
235
+ """Clear only this process's in-memory cache."""
236
+
237
+ self._memory.clear()
238
+
239
+ def _effective_options(self, options: CacheOptions | None) -> CacheOptions:
240
+ if options is None:
241
+ return self._options
242
+ return options.merge_over(self._options)
243
+
244
+ def _set_memory(self, key: str, value: object, opts: CacheOptions) -> None:
245
+ ttl = self._ttl_with_jitter(opts)
246
+ now = time.monotonic()
247
+ self._memory[key] = CacheEntry(
248
+ value=value,
249
+ expires_at=now + ttl,
250
+ fail_safe_until=now + ttl + opts.fail_safe_seconds,
251
+ )
252
+
253
+ def _ttl_with_jitter(self, opts: CacheOptions) -> float:
254
+ if opts.jitter_seconds <= 0:
255
+ return opts.ttl_seconds
256
+ return opts.ttl_seconds + random.uniform(0, opts.jitter_seconds)
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar
6
+
7
+ from cache_sync.core import CacheOptions
8
+
9
+ if TYPE_CHECKING:
10
+ from cache_sync.core import CacheSync
11
+
12
+ P = ParamSpec("P")
13
+ T = TypeVar("T")
14
+
15
+
16
+ class CachedFunction(Generic[P, T]):
17
+ """Callable wrapper returned by `CacheSync.cached` with cache helpers."""
18
+
19
+ def __init__(
20
+ self,
21
+ cache: CacheSync,
22
+ func: Callable[P, Awaitable[T]],
23
+ key: str | Callable[..., str] | None,
24
+ options: CacheOptions | None,
25
+ ) -> None:
26
+ self._cache = cache
27
+ self._func = func
28
+ self._key = key
29
+ self._options = options
30
+
31
+ async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T:
32
+ """Return the cached result for this call or invoke the wrapped function."""
33
+
34
+ return await self._cache.get_or_set(
35
+ self.cache_key(*args, **kwargs),
36
+ lambda: self._func(*args, **kwargs),
37
+ options=self._options,
38
+ )
39
+
40
+ async def remove_cached(self, *args: P.args, **kwargs: P.kwargs) -> None:
41
+ """Remove the cached value associated with this call's cache key."""
42
+
43
+ await self._cache.remove(self.cache_key(*args, **kwargs))
44
+
45
+ def cache_key(self, *args: P.args, **kwargs: P.kwargs) -> str:
46
+ """Build the cache key for the supplied function arguments."""
47
+
48
+ if isinstance(self._key, str):
49
+ return self._key
50
+ if self._key is None:
51
+ return default_cache_key(self._func, *args, **kwargs)
52
+ return self._key(*args, **kwargs)
53
+
54
+
55
+ def default_cache_key(func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> str:
56
+ """Build a deterministic key from a function and call arguments."""
57
+
58
+ signature = inspect.signature(func)
59
+ bound = signature.bind(*args, **kwargs)
60
+ bound.apply_defaults()
61
+ arguments = ",".join(
62
+ f"{name}={_stable_key_part(value)}" for name, value in bound.arguments.items()
63
+ )
64
+ module = getattr(func, "__module__", type(func).__module__)
65
+ qualname = getattr(func, "__qualname__", type(func).__qualname__)
66
+
67
+ return f"{module}.{qualname}({arguments})"
68
+
69
+
70
+ def _stable_key_part(value: Any) -> str:
71
+ if isinstance(value, dict):
72
+ items = sorted(
73
+ (_stable_key_part(key), _stable_key_part(item_value))
74
+ for key, item_value in value.items()
75
+ )
76
+ return "{" + ",".join(f"{key}:{item_value}" for key, item_value in items) + "}"
77
+
78
+ if isinstance(value, (list, tuple)):
79
+ opener, closer = ("[", "]") if isinstance(value, list) else ("(", ")")
80
+ return opener + ",".join(_stable_key_part(item) for item in value) + closer
81
+
82
+ if isinstance(value, (set, frozenset)):
83
+ items = sorted(_stable_key_part(item) for item in value)
84
+ return "{" + ",".join(items) + "}"
85
+
86
+ return repr(value)
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+ from cache_sync.serializers import PickleSerializer as PickleSerializer
6
+ from cache_sync.serializers import Serializer as Serializer
7
+
8
+
9
+ class DistributedCache(Protocol):
10
+ """Protocol for optional shared L2 cache storage."""
11
+
12
+ async def get(self, key: str) -> object | None: ...
13
+
14
+ async def set(self, key: str, value: object, ttl_seconds: float) -> None: ...
15
+
16
+ async def delete(self, key: str) -> None: ...
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from dataclasses import dataclass
5
+ from typing import Literal, Protocol
6
+
7
+ type InvalidationAction = Literal["remove", "clear"]
8
+ type RemoveLocal = Callable[[str], None]
9
+ type ClearLocal = Callable[[], None]
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class InvalidationMessage:
14
+ """Message sent between cache nodes to remove keys or clear local memory."""
15
+
16
+ action: InvalidationAction
17
+ key: str | None = None
18
+
19
+ @classmethod
20
+ def remove(cls, key: str) -> InvalidationMessage:
21
+ """Create a message that removes one key from peer local caches."""
22
+
23
+ return cls(action="remove", key=key)
24
+
25
+ @classmethod
26
+ def clear(cls) -> InvalidationMessage:
27
+ """Create a message that clears peer local caches."""
28
+
29
+ return cls(action="clear")
30
+
31
+
32
+ type InvalidationHandler = Callable[[InvalidationMessage], Awaitable[None]]
33
+
34
+
35
+ class InvalidationTransport(Protocol):
36
+ """Low-level transport used by `TransportInvalidationBus`."""
37
+
38
+ async def start(self, handler: InvalidationHandler) -> None: ...
39
+
40
+ async def stop(self) -> None: ...
41
+
42
+ async def publish(self, message: InvalidationMessage) -> None: ...
43
+
44
+
45
+ class InvalidationBus(Protocol):
46
+ """Protocol for publishing and receiving cache invalidation events."""
47
+
48
+ async def start(
49
+ self,
50
+ *,
51
+ remove_local: RemoveLocal,
52
+ clear_local: ClearLocal,
53
+ ) -> None: ...
54
+
55
+ async def stop(self) -> None: ...
56
+
57
+ async def invalidate(self, key: str) -> None: ...
58
+
59
+ async def clear(self) -> None: ...
60
+
61
+
62
+ class TransportInvalidationBus:
63
+ """Adapt an `InvalidationTransport` into the `InvalidationBus` protocol."""
64
+
65
+ def __init__(self, transport: InvalidationTransport) -> None:
66
+ """Create an invalidation bus backed by a generic transport."""
67
+
68
+ self._transport = transport
69
+ self._remove_local: RemoveLocal | None = None
70
+ self._clear_local: ClearLocal | None = None
71
+
72
+ async def start(
73
+ self,
74
+ *,
75
+ remove_local: RemoveLocal,
76
+ clear_local: ClearLocal,
77
+ ) -> None:
78
+ """Start listening for remote invalidation messages."""
79
+
80
+ self._remove_local = remove_local
81
+ self._clear_local = clear_local
82
+ await self._transport.start(self._handle_message)
83
+
84
+ async def stop(self) -> None:
85
+ """Stop listening and release local callbacks."""
86
+
87
+ await self._transport.stop()
88
+ self._remove_local = None
89
+ self._clear_local = None
90
+
91
+ async def invalidate(self, key: str) -> None:
92
+ """Publish a message instructing peers to remove one key."""
93
+
94
+ await self._transport.publish(InvalidationMessage.remove(key))
95
+
96
+ async def clear(self) -> None:
97
+ """Publish a message instructing peers to clear local memory."""
98
+
99
+ await self._transport.publish(InvalidationMessage.clear())
100
+
101
+ async def _handle_message(self, message: InvalidationMessage) -> None:
102
+ remove_local = self._remove_local
103
+ clear_local = self._clear_local
104
+
105
+ if message.action == "remove" and message.key is not None:
106
+ if remove_local is not None:
107
+ remove_local(message.key)
108
+ return
109
+
110
+ if message.action == "clear" and clear_local is not None:
111
+ clear_local()
@@ -0,0 +1 @@
1
+ """Provider implementations for cache-sync."""
@@ -0,0 +1,7 @@
1
+ """Kafka provider exports."""
2
+
3
+ from cache_sync.providers.kafka.invalidation_bus import KafkaInvalidationBus
4
+
5
+ __all__ = [
6
+ "KafkaInvalidationBus",
7
+ ]