cache-sync 0.3.1__tar.gz

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,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,99 @@
1
+ # cache-sync
2
+
3
+ Async hybrid Python cache with in-memory L1 caching, optional Redis L2 caching, pluggable invalidation, stampede protection, fail-safe stale values, and typed decorators.
4
+
5
+ ## Features
6
+
7
+ - Async-first API for Python 3.12 and newer.
8
+ - Fast in-process L1 cache with optional Redis-backed L2 storage.
9
+ - Pluggable invalidation buses for Redis Streams, RabbitMQ, Kafka, and PostgreSQL.
10
+ - Request stampede protection with per-key refresh coordination.
11
+ - Fail-safe stale reads for short backend outages.
12
+ - Typed decorators that preserve the wrapped function signature.
13
+ - Serializer choices for JSON, pickle, and Pydantic models.
14
+
15
+ ## Documentation
16
+
17
+ The end-user documentation is published at <https://petercinibulk.github.io/cache-sync/> and is built from [`docs/`](docs/index.md) with Zensical.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ uv add cache-sync
23
+ ```
24
+
25
+ Install optional providers only when your application uses them:
26
+
27
+ ```bash
28
+ uv add "cache-sync[redis]"
29
+ uv add "cache-sync[rabbitmq]"
30
+ uv add "cache-sync[kafka]"
31
+ uv add "cache-sync[postgres]"
32
+ uv add "cache-sync[all]"
33
+ ```
34
+
35
+ | Extra | Installs | Use when |
36
+ | --- | --- | --- |
37
+ | `redis` | `redis` | You need Redis L2 storage or Redis Streams invalidation. |
38
+ | `rabbitmq` | `aio-pika` | You use RabbitMQ as the invalidation bus. |
39
+ | `kafka` | `aiokafka` | You use Kafka as the invalidation bus. |
40
+ | `postgres` | `asyncpg` | You use PostgreSQL `LISTEN`/`NOTIFY` for invalidation. |
41
+ | `pydantic` | `pydantic` | You want Pydantic model serialization helpers. |
42
+ | `all` | all provider dependencies | You want every optional provider available. |
43
+
44
+ ## Quick Start
45
+
46
+ ```python
47
+ from cache_sync import CacheOptions, CacheSync
48
+
49
+ cache = CacheSync(
50
+ options=CacheOptions(
51
+ ttl_seconds=60,
52
+ fail_safe_seconds=300,
53
+ hard_timeout_seconds=5,
54
+ jitter_seconds=5,
55
+ ),
56
+ )
57
+
58
+ await cache.start()
59
+
60
+
61
+ @cache.cached(lambda user_id: f"user:{user_id}")
62
+ async def get_user(user_id: str) -> dict[str, str]:
63
+ return {"id": user_id, "name": "Peter"}
64
+
65
+
66
+ user = await get_user("123")
67
+ await get_user.remove_cached("123")
68
+ await cache.stop()
69
+ ```
70
+
71
+ ## Redis L2 Example
72
+
73
+ ```python
74
+ from redis.asyncio import Redis
75
+
76
+ from cache_sync import CacheOptions, CacheSync, RedisDistributedCache
77
+
78
+ redis = Redis.from_url("redis://localhost:6379/0")
79
+
80
+ cache = CacheSync(
81
+ distributed_cache=RedisDistributedCache(redis),
82
+ options=CacheOptions(ttl_seconds=60, fail_safe_seconds=300),
83
+ )
84
+
85
+ await cache.start()
86
+
87
+
88
+ @cache.cached(lambda product_id: f"product:{product_id}")
89
+ async def get_product(product_id: str) -> dict[str, str]:
90
+ return {"id": product_id}
91
+ ```
92
+
93
+ 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/).
94
+
95
+ ## Project
96
+
97
+ - License: MIT
98
+ - Source: <https://github.com/petercinibulk/cache-sync>
99
+ - Issues: <https://github.com/petercinibulk/cache-sync/issues>
@@ -0,0 +1,107 @@
1
+ [project]
2
+ name = "cache-sync"
3
+ version = "0.3.1"
4
+ description = "Async hybrid Python cache with in-memory L1, distributed L2 providers, pluggable invalidation, stampede protection, and typed decorators."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "Peter Cinibulk" }]
9
+
10
+ # Published dependencies.
11
+ dependencies = []
12
+ keywords = ["async", "cache", "redis", "invalidation", "stampede-protection"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Framework :: AsyncIO",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3 :: Only",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Typing :: Typed",
25
+ ]
26
+
27
+ [project.urls]
28
+ Changelog = "https://github.com/petercinibulk/cache-sync/blob/main/CHANGELOG.md"
29
+ Documentation = "https://petercinibulk.github.io/cache-sync/"
30
+ Issues = "https://github.com/petercinibulk/cache-sync/issues"
31
+ Repository = "https://github.com/petercinibulk/cache-sync"
32
+
33
+ # Published optional dependencies, or "extras".
34
+ [project.optional-dependencies]
35
+ kafka = ["aiokafka>=0.10.0"]
36
+ postgres = ["asyncpg>=0.29.0"]
37
+ pydantic = ["pydantic>=1.10.0"]
38
+ rabbitmq = ["aio-pika>=9.0.0"]
39
+ redis = ["redis>=5.0.0"]
40
+ all = [
41
+ "redis>=5.0.0",
42
+ "aio-pika>=9.0.0",
43
+ "aiokafka>=0.10.0",
44
+ "asyncpg>=0.29.0",
45
+ "pydantic>=1.10.0",
46
+ ]
47
+
48
+ # Local dependencies for development.
49
+ [dependency-groups]
50
+ redis = ["redis>=5.0.0"]
51
+ rabbitmq = ["aio-pika>=9.0.0"]
52
+ kafka = ["aiokafka>=0.10.0"]
53
+ postgres = ["asyncpg>=0.29.0"]
54
+ pydantic = ["pydantic>=1.10.0"]
55
+ docs = ["zensical>=0.0.45"]
56
+ all = [
57
+ { include-group = "redis" },
58
+ { include-group = "rabbitmq" },
59
+ { include-group = "kafka" },
60
+ { include-group = "postgres" },
61
+ { include-group = "pydantic" },
62
+ ]
63
+ dev = [
64
+ { include-group = "all" },
65
+ { include-group = "docs" },
66
+ "pytest>=8.0.0",
67
+ "pytest-asyncio>=0.23.0",
68
+ "pytest-cov>=7.1.0",
69
+ "ruff>=0.5.0",
70
+ "ty>=0.0.1a8",
71
+ ]
72
+
73
+ [build-system]
74
+ requires = ["uv_build>=0.11.21,<0.12"]
75
+ build-backend = "uv_build"
76
+
77
+ [tool.uv]
78
+ package = true
79
+
80
+ [tool.ruff]
81
+ line-length = 100
82
+ target-version = "py312"
83
+ src = ["src", "tests"]
84
+
85
+ [tool.ruff.lint]
86
+ select = ["A", "ASYNC", "B", "C4", "E", "F", "I", "N", "PERF", "RUF", "SIM", "UP", "W"]
87
+ ignore = ["B904", "UP046", "UP047"]
88
+
89
+ [tool.ruff.format]
90
+ quote-style = "double"
91
+ indent-style = "space"
92
+
93
+ [tool.pytest.ini_options]
94
+ addopts = [
95
+ "--cov=cache_sync",
96
+ "--cov-report=term-missing",
97
+ ]
98
+ asyncio_mode = "auto"
99
+ testpaths = ["tests"]
100
+
101
+ [tool.ty.environment]
102
+ python-version = "3.12"
103
+ python-platform = "all"
104
+ root = ["./src"]
105
+
106
+ [tool.ty.src]
107
+ include = ["src", "tests"]
@@ -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)
@@ -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)