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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.