cachefence 0.1.0__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.
- cachefence/__init__.py +32 -0
- cachefence/cache.py +270 -0
- cachefence/errors.py +14 -0
- cachefence/py.typed +0 -0
- cachefence-0.1.0.dist-info/METADATA +163 -0
- cachefence-0.1.0.dist-info/RECORD +8 -0
- cachefence-0.1.0.dist-info/WHEEL +4 -0
- cachefence-0.1.0.dist-info/licenses/LICENSE +21 -0
cachefence/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""cachefence — cache-aside for Redis without the stampede.
|
|
2
|
+
|
|
3
|
+
When a hot key expires, naive cache-aside lets every concurrent request miss at
|
|
4
|
+
once and hammer your database to rebuild the same value. cachefence prevents that
|
|
5
|
+
with two cooperating mechanisms:
|
|
6
|
+
|
|
7
|
+
1. Probabilistic early recomputation (XFetch): a single worker is nudged to
|
|
8
|
+
refresh the value *before* it actually expires, so the key rarely goes cold.
|
|
9
|
+
2. A distributed lock: if the value is gone, exactly one worker rebuilds it while
|
|
10
|
+
everyone else briefly waits or serves the stale value.
|
|
11
|
+
|
|
12
|
+
Basic usage::
|
|
13
|
+
|
|
14
|
+
from redis.asyncio import Redis
|
|
15
|
+
from cachefence import CacheFence
|
|
16
|
+
|
|
17
|
+
redis = Redis()
|
|
18
|
+
cache = CacheFence(redis)
|
|
19
|
+
|
|
20
|
+
async def get_user(user_id: int) -> dict:
|
|
21
|
+
return await cache.get_or_set(
|
|
22
|
+
key=f"user:{user_id}",
|
|
23
|
+
ttl=60,
|
|
24
|
+
recompute=lambda: load_user_from_db(user_id),
|
|
25
|
+
)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .cache import CacheFence
|
|
29
|
+
from .errors import CacheFenceError, RecomputeError
|
|
30
|
+
|
|
31
|
+
__all__ = ["CacheFence", "CacheFenceError", "RecomputeError"]
|
|
32
|
+
__version__ = "0.1.0"
|
cachefence/cache.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Core CacheFence implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Awaitable, Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Generic, TypeVar, cast
|
|
15
|
+
|
|
16
|
+
from redis.asyncio import Redis
|
|
17
|
+
|
|
18
|
+
from .errors import RecomputeError
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
Recompute = Callable[[], "Awaitable[T] | T"]
|
|
22
|
+
Serializer = Callable[[object], bytes]
|
|
23
|
+
Deserializer = Callable[[bytes], object]
|
|
24
|
+
|
|
25
|
+
# Compare-and-delete: release the lock only if we still own it. Prevents a worker
|
|
26
|
+
# whose lock already expired from deleting a lock another worker now holds. Runs as
|
|
27
|
+
# a Lua script by default; if the server rejects scripting at runtime we fall back
|
|
28
|
+
# to a WATCH/MULTI transaction, which gives the same atomic guarantee.
|
|
29
|
+
_RELEASE_LOCK = """
|
|
30
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
31
|
+
return redis.call("del", KEYS[1])
|
|
32
|
+
else
|
|
33
|
+
return 0
|
|
34
|
+
end
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# Field names inside the cache hash. The value field is the one read on every
|
|
38
|
+
# hit, so it gets a one-byte name; the metadata fields stay spelled out.
|
|
39
|
+
_F_VALUE = b"v"
|
|
40
|
+
_F_DELTA = b"delta"
|
|
41
|
+
_F_EXPIRY = b"expiry"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _default_serializer(value: object) -> bytes:
|
|
45
|
+
return json.dumps(value).encode()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _default_deserializer(raw: bytes) -> object:
|
|
49
|
+
return json.loads(raw)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class _Entry:
|
|
54
|
+
"""A value read back from the cache, with the metadata XFetch needs."""
|
|
55
|
+
|
|
56
|
+
value: object
|
|
57
|
+
delta: float # seconds the last recompute took
|
|
58
|
+
expiry: float # absolute unix time at which the value goes stale
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class CacheFence(Generic[T]):
|
|
62
|
+
"""Cache-aside helper for Redis with built-in stampede protection.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
redis:
|
|
67
|
+
A ``redis.asyncio.Redis`` client. It is used in raw-bytes mode
|
|
68
|
+
internally, so ``decode_responses`` on the client is irrelevant.
|
|
69
|
+
beta:
|
|
70
|
+
XFetch aggressiveness. Higher refreshes earlier. ``1.0`` is the value
|
|
71
|
+
from the original paper and a sensible default.
|
|
72
|
+
lock_timeout:
|
|
73
|
+
Seconds a rebuild lock is held before it auto-expires, so a crashed
|
|
74
|
+
worker cannot block rebuilds forever.
|
|
75
|
+
wait_for_lock:
|
|
76
|
+
Maximum seconds a worker waits for another worker's rebuild before
|
|
77
|
+
rebuilding the value itself.
|
|
78
|
+
serializer / deserializer:
|
|
79
|
+
Convert values to/from the ``bytes`` stored in Redis. Defaults to JSON.
|
|
80
|
+
namespace:
|
|
81
|
+
Optional prefix applied to every key.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
__slots__ = (
|
|
85
|
+
"_redis", "_beta", "_lock_timeout", "_wait_for_lock",
|
|
86
|
+
"_dumps", "_loads", "_ns", "_release", "_lua_ok",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
redis: Redis[bytes],
|
|
92
|
+
*,
|
|
93
|
+
beta: float = 1.0,
|
|
94
|
+
lock_timeout: float = 10.0,
|
|
95
|
+
wait_for_lock: float = 5.0,
|
|
96
|
+
serializer: Serializer = _default_serializer,
|
|
97
|
+
deserializer: Deserializer = _default_deserializer,
|
|
98
|
+
namespace: str = "",
|
|
99
|
+
) -> None:
|
|
100
|
+
self._redis = redis
|
|
101
|
+
self._beta = beta
|
|
102
|
+
self._lock_timeout = lock_timeout
|
|
103
|
+
self._wait_for_lock = wait_for_lock
|
|
104
|
+
self._dumps = serializer
|
|
105
|
+
self._loads = deserializer
|
|
106
|
+
self._ns = namespace
|
|
107
|
+
self._release = redis.register_script(_RELEASE_LOCK)
|
|
108
|
+
self._lua_ok = True # flips to False if the server rejects scripting
|
|
109
|
+
|
|
110
|
+
def _key(self, key: str) -> str:
|
|
111
|
+
return f"{self._ns}{key}" if self._ns else key
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _lock_key(rkey: str) -> str:
|
|
115
|
+
return f"{rkey}:lock"
|
|
116
|
+
|
|
117
|
+
async def get_or_set(
|
|
118
|
+
self,
|
|
119
|
+
key: str,
|
|
120
|
+
ttl: float,
|
|
121
|
+
recompute: Recompute[T],
|
|
122
|
+
*,
|
|
123
|
+
beta: float | None = None,
|
|
124
|
+
) -> T:
|
|
125
|
+
"""Return the cached value for ``key``, recomputing it if needed.
|
|
126
|
+
|
|
127
|
+
``recompute`` may be sync or async. ``ttl`` is the fresh lifetime in
|
|
128
|
+
seconds. At most one worker recomputes at a time; the rest serve the
|
|
129
|
+
still-valid cached value or wait briefly, never stampeding the backing
|
|
130
|
+
store.
|
|
131
|
+
"""
|
|
132
|
+
rkey = self._key(key)
|
|
133
|
+
beta = self._beta if beta is None else beta
|
|
134
|
+
|
|
135
|
+
entry = await self._read(rkey)
|
|
136
|
+
if entry is not None:
|
|
137
|
+
if not self._should_refresh_early(entry, beta):
|
|
138
|
+
return cast(T, entry.value)
|
|
139
|
+
# Near expiry: one worker wins the lock and refreshes ahead of time
|
|
140
|
+
# while everyone else keeps serving the value that is still valid.
|
|
141
|
+
token = await self._acquire(rkey)
|
|
142
|
+
if token is None:
|
|
143
|
+
return cast(T, entry.value)
|
|
144
|
+
try:
|
|
145
|
+
return await self._recompute_and_store(rkey, ttl, recompute)
|
|
146
|
+
finally:
|
|
147
|
+
await self._release_lock(rkey, token)
|
|
148
|
+
|
|
149
|
+
# Hard miss: the value is gone. Exactly one worker rebuilds it.
|
|
150
|
+
return await self._rebuild_on_miss(rkey, ttl, recompute)
|
|
151
|
+
|
|
152
|
+
async def invalidate(self, key: str) -> None:
|
|
153
|
+
"""Delete a cached key so the next read recomputes it."""
|
|
154
|
+
await self._redis.delete(self._key(key))
|
|
155
|
+
|
|
156
|
+
# --- internals ---------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
async def _read(self, rkey: str) -> _Entry | None:
|
|
159
|
+
data: dict[bytes, bytes] = await self._redis.hgetall(rkey)
|
|
160
|
+
raw = data.get(_F_VALUE)
|
|
161
|
+
if raw is None:
|
|
162
|
+
return None
|
|
163
|
+
return _Entry(
|
|
164
|
+
value=self._loads(raw),
|
|
165
|
+
delta=float(data[_F_DELTA]),
|
|
166
|
+
expiry=float(data[_F_EXPIRY]),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _should_refresh_early(self, entry: _Entry, beta: float) -> bool:
|
|
170
|
+
# XFetch (Vattani et al., VLDB 2015): -ln(uniform(0,1]) is exponentially
|
|
171
|
+
# distributed; scaling it by delta*beta makes expensive-to-rebuild keys
|
|
172
|
+
# refresh earlier, spreading recomputes out instead of bunching them at
|
|
173
|
+
# expiry. The gap widens as we approach expiry, so the trigger probability
|
|
174
|
+
# rises smoothly toward 1.
|
|
175
|
+
gap = entry.delta * beta * -math.log(random.random() or 1e-12)
|
|
176
|
+
return time.time() + gap >= entry.expiry
|
|
177
|
+
|
|
178
|
+
async def _acquire(self, rkey: str) -> str | None:
|
|
179
|
+
"""Try to take the rebuild lock. Return the ownership token, or None."""
|
|
180
|
+
token = uuid.uuid4().hex
|
|
181
|
+
acquired = await self._redis.set(
|
|
182
|
+
self._lock_key(rkey),
|
|
183
|
+
token,
|
|
184
|
+
nx=True,
|
|
185
|
+
px=int(self._lock_timeout * 1000),
|
|
186
|
+
)
|
|
187
|
+
return token if acquired else None
|
|
188
|
+
|
|
189
|
+
async def _release_lock(self, rkey: str, token: str) -> None:
|
|
190
|
+
lock_key = self._lock_key(rkey)
|
|
191
|
+
if self._lua_ok:
|
|
192
|
+
try:
|
|
193
|
+
await self._release(keys=[lock_key], args=[token])
|
|
194
|
+
return
|
|
195
|
+
except asyncio.CancelledError:
|
|
196
|
+
raise
|
|
197
|
+
except Exception as exc: # noqa: BLE001
|
|
198
|
+
message = str(exc).lower()
|
|
199
|
+
if "evalsha" in message or "unknown command" in message:
|
|
200
|
+
self._lua_ok = False # server lacks scripting; use fallback
|
|
201
|
+
else:
|
|
202
|
+
return # never fail a request because unlock hiccuped
|
|
203
|
+
await self._release_lock_fallback(lock_key, token)
|
|
204
|
+
|
|
205
|
+
async def _release_lock_fallback(self, lock_key: str, token: str) -> None:
|
|
206
|
+
"""Compare-and-delete via an optimistic WATCH/MULTI transaction."""
|
|
207
|
+
wanted = token.encode()
|
|
208
|
+
try:
|
|
209
|
+
async with self._redis.pipeline(transaction=True) as pipe:
|
|
210
|
+
await pipe.watch(lock_key)
|
|
211
|
+
if await pipe.get(lock_key) == wanted:
|
|
212
|
+
pipe.multi()
|
|
213
|
+
pipe.delete(lock_key)
|
|
214
|
+
await pipe.execute()
|
|
215
|
+
else:
|
|
216
|
+
await pipe.reset()
|
|
217
|
+
except asyncio.CancelledError:
|
|
218
|
+
raise
|
|
219
|
+
except Exception: # noqa: BLE001
|
|
220
|
+
pass # the lock's own TTL will clean it up
|
|
221
|
+
|
|
222
|
+
async def _recompute_and_store(
|
|
223
|
+
self, rkey: str, ttl: float, recompute: Recompute[T]
|
|
224
|
+
) -> T:
|
|
225
|
+
start = time.monotonic()
|
|
226
|
+
try:
|
|
227
|
+
result = recompute()
|
|
228
|
+
if inspect.isawaitable(result):
|
|
229
|
+
result = await result
|
|
230
|
+
except asyncio.CancelledError:
|
|
231
|
+
raise
|
|
232
|
+
except Exception as exc:
|
|
233
|
+
raise RecomputeError(str(exc)) from exc
|
|
234
|
+
|
|
235
|
+
value = cast(T, result)
|
|
236
|
+
delta = time.monotonic() - start
|
|
237
|
+
async with self._redis.pipeline(transaction=True) as pipe:
|
|
238
|
+
pipe.hset(rkey, mapping={
|
|
239
|
+
_F_VALUE: self._dumps(value),
|
|
240
|
+
_F_DELTA: delta,
|
|
241
|
+
_F_EXPIRY: time.time() + ttl,
|
|
242
|
+
})
|
|
243
|
+
pipe.pexpire(rkey, int(ttl * 1000))
|
|
244
|
+
await pipe.execute()
|
|
245
|
+
return value
|
|
246
|
+
|
|
247
|
+
async def _rebuild_on_miss(
|
|
248
|
+
self, rkey: str, ttl: float, recompute: Recompute[T]
|
|
249
|
+
) -> T:
|
|
250
|
+
token = await self._acquire(rkey)
|
|
251
|
+
if token is not None:
|
|
252
|
+
try:
|
|
253
|
+
return await self._recompute_and_store(rkey, ttl, recompute)
|
|
254
|
+
finally:
|
|
255
|
+
await self._release_lock(rkey, token)
|
|
256
|
+
|
|
257
|
+
# Another worker holds the lock. Wait for the value to appear, backing
|
|
258
|
+
# off so we don't busy-poll Redis.
|
|
259
|
+
deadline = time.monotonic() + self._wait_for_lock
|
|
260
|
+
delay = 0.02
|
|
261
|
+
while time.monotonic() < deadline:
|
|
262
|
+
await asyncio.sleep(delay)
|
|
263
|
+
entry = await self._read(rkey)
|
|
264
|
+
if entry is not None:
|
|
265
|
+
return cast(T, entry.value)
|
|
266
|
+
delay = min(delay * 1.5, 0.2)
|
|
267
|
+
|
|
268
|
+
# The holder crashed or is pathologically slow. Rebuild ourselves rather
|
|
269
|
+
# than hang the request indefinitely.
|
|
270
|
+
return await self._recompute_and_store(rkey, ttl, recompute)
|
cachefence/errors.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Exception types raised by cachefence."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CacheFenceError(Exception):
|
|
5
|
+
"""Base class for all cachefence errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RecomputeError(CacheFenceError):
|
|
9
|
+
"""Raised when the user-supplied recompute callable fails.
|
|
10
|
+
|
|
11
|
+
The error always propagates: cachefence does not fall back to a stale
|
|
12
|
+
value, even during an early refresh where one is still available. The
|
|
13
|
+
original exception is available via ``__cause__``.
|
|
14
|
+
"""
|
cachefence/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cachefence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cache-aside for Redis without the stampede. Probabilistic early refresh + distributed lock, so a hot key expiring never hammers your database.
|
|
5
|
+
Project-URL: Homepage, https://github.com/bourne44/cachefence
|
|
6
|
+
Project-URL: Issues, https://github.com/bourne44/cachefence/issues
|
|
7
|
+
Author: Bourne
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: async,cache,redis,stampede,thundering-herd,xfetch
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Database
|
|
18
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: redis>=4.2
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: fakeredis; extra == 'dev'
|
|
24
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
28
|
+
Requires-Dist: types-redis; extra == 'dev'
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: fakeredis; extra == 'test'
|
|
31
|
+
Requires-Dist: pytest; extra == 'test'
|
|
32
|
+
Requires-Dist: pytest-asyncio; extra == 'test'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# cachefence
|
|
36
|
+
|
|
37
|
+
**Cache-aside for Redis without the stampede.**
|
|
38
|
+
|
|
39
|
+
When a hot cache key expires, naive cache-aside lets *every* concurrent request
|
|
40
|
+
miss at the same instant and pile onto your database to rebuild the same value.
|
|
41
|
+
That's a cache stampede (a.k.a. thundering herd), and it's one of the most common
|
|
42
|
+
ways a cache makes things *worse* under load.
|
|
43
|
+
|
|
44
|
+
cachefence stops it:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
500 concurrent requests hit a cold key (each DB query takes 50ms)
|
|
48
|
+
|
|
49
|
+
naive cache-aside DB hits: 500
|
|
50
|
+
with cachefence DB hits: 1
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Same workload, one extra import: **500 database queries become 1.**
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install cachefence
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Requires Python 3.11+ and a Redis server (4.2+).
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from redis.asyncio import Redis
|
|
67
|
+
from cachefence import CacheFence
|
|
68
|
+
|
|
69
|
+
redis = Redis()
|
|
70
|
+
cache = CacheFence(redis)
|
|
71
|
+
|
|
72
|
+
async def get_user(user_id: int) -> dict:
|
|
73
|
+
return await cache.get_or_set(
|
|
74
|
+
key=f"user:{user_id}",
|
|
75
|
+
ttl=60, # fresh for 60 seconds
|
|
76
|
+
recompute=lambda: load_user_from_db(user_id),
|
|
77
|
+
)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`recompute` can be sync or async. It runs at most once per refresh, no matter how
|
|
81
|
+
many requests arrive together. Invalidate manually when the underlying data
|
|
82
|
+
changes:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
await cache.invalidate(f"user:{user_id}")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## How it works
|
|
89
|
+
|
|
90
|
+
cachefence layers two mechanisms so a key almost never goes cold *and* a cold key
|
|
91
|
+
is never rebuilt more than once:
|
|
92
|
+
|
|
93
|
+
1. **Probabilistic early refresh (XFetch).** Each read rolls a weighted dice; as
|
|
94
|
+
the key nears expiry, one lucky request is nudged to refresh it *ahead of
|
|
95
|
+
time* while everyone else keeps serving the still-valid cached value. The
|
|
96
|
+
weighting uses how long the last recompute took, so expensive keys refresh
|
|
97
|
+
earlier. Based on Vattani, Chierichetti & Lowenstein, *"Optimal Probabilistic
|
|
98
|
+
Cache Stampede Prevention"* (VLDB 2015).
|
|
99
|
+
|
|
100
|
+
2. **Distributed rebuild lock.** On a true miss, workers race for a short-lived
|
|
101
|
+
Redis lock. The winner rebuilds; the rest wait briefly and pick up the fresh
|
|
102
|
+
value the moment it lands, with a bounded fallback so a crashed rebuilder
|
|
103
|
+
never hangs requests forever.
|
|
104
|
+
|
|
105
|
+
The lock is released with a compare-and-delete (Lua when the server supports it,
|
|
106
|
+
an optimistic `WATCH`/`MULTI` transaction otherwise) so a worker can never delete
|
|
107
|
+
a lock it no longer owns.
|
|
108
|
+
|
|
109
|
+
## Configuration
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
cache = CacheFence(
|
|
113
|
+
redis,
|
|
114
|
+
beta=1.0, # XFetch aggressiveness; higher = refresh earlier
|
|
115
|
+
lock_timeout=10.0, # seconds before a rebuild lock auto-expires
|
|
116
|
+
wait_for_lock=5.0, # max seconds a waiter blocks before rebuilding itself
|
|
117
|
+
namespace="app:", # optional key prefix
|
|
118
|
+
)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Custom serialization (default is JSON):
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import pickle
|
|
125
|
+
cache = CacheFence(redis, serializer=pickle.dumps, deserializer=pickle.loads)
|
|
126
|
+
# serializer returns bytes, deserializer takes bytes
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## A note on connection pools
|
|
130
|
+
|
|
131
|
+
Under a genuine burst (hundreds of simultaneous coroutines), the default
|
|
132
|
+
`redis-py` pool can raise `MaxConnectionsError` because waiters don't block for a
|
|
133
|
+
free connection. Use a blocking pool sized for your concurrency:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from redis.asyncio import BlockingConnectionPool, Redis
|
|
137
|
+
|
|
138
|
+
pool = BlockingConnectionPool(max_connections=30, timeout=15)
|
|
139
|
+
redis = Redis(connection_pool=pool)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Run the demo
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
git clone https://github.com/bourne44/cachefence
|
|
146
|
+
cd cachefence
|
|
147
|
+
pip install -e ".[test]"
|
|
148
|
+
python examples/stampede_demo.py
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
pip install -e ".[test]"
|
|
155
|
+
pytest
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The test suite includes a 100-way concurrent-miss test asserting the recompute
|
|
159
|
+
runs exactly once — the core guarantee of the library.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
cachefence/__init__.py,sha256=YBkDsLaYNZulzrlKcjDdBZvqazqrhz__xd0La7Rvr6E,1077
|
|
2
|
+
cachefence/cache.py,sha256=qVaP7PXpOMRqK0eOzUnZRnDitUdp1ebxONXWvZwZZCs,9640
|
|
3
|
+
cachefence/errors.py,sha256=28n7Or0soJLZBTbj6vCXAdzsoCbjpKRhdHEVQQuwQi4,444
|
|
4
|
+
cachefence/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
cachefence-0.1.0.dist-info/METADATA,sha256=l0eU889HYWj9fwkFROyg93H2Pv8vVG0qm4tX5kcrFAI,4989
|
|
6
|
+
cachefence-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
cachefence-0.1.0.dist-info/licenses/LICENSE,sha256=pSct4FJDg-bJmTBQ9bSEXmpuNqYh3dGvrt-lOO3S0LI,1063
|
|
8
|
+
cachefence-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bourne
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|