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
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)
|
cache_sync/decorators.py
ADDED
|
@@ -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."""
|