cachefence 0.1.0__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,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(pip install *)",
5
+ "Bash(rm -rf dist/ build/ *.egg-info src/*.egg-info)",
6
+ "Bash(python -m build)"
7
+ ]
8
+ }
9
+ }
@@ -0,0 +1,32 @@
1
+ name: tests
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.11", "3.12", "3.13"]
14
+ services:
15
+ redis:
16
+ image: redis:7
17
+ ports:
18
+ - 6379:6379
19
+ options: >-
20
+ --health-cmd "redis-cli ping"
21
+ --health-interval 10s
22
+ --health-timeout 5s
23
+ --health-retries 5
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: ${{ matrix.python-version }}
29
+ - run: pip install -e ".[dev]"
30
+ - run: ruff check .
31
+ - run: mypy --strict src/cachefence/
32
+ - run: pytest -q
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .venv/
9
+ venv/
10
+ *.egg
11
+ .coverage
12
+ htmlcov/
@@ -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.
@@ -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,129 @@
1
+ # cachefence
2
+
3
+ **Cache-aside for Redis without the stampede.**
4
+
5
+ When a hot cache key expires, naive cache-aside lets *every* concurrent request
6
+ miss at the same instant and pile onto your database to rebuild the same value.
7
+ That's a cache stampede (a.k.a. thundering herd), and it's one of the most common
8
+ ways a cache makes things *worse* under load.
9
+
10
+ cachefence stops it:
11
+
12
+ ```
13
+ 500 concurrent requests hit a cold key (each DB query takes 50ms)
14
+
15
+ naive cache-aside DB hits: 500
16
+ with cachefence DB hits: 1
17
+ ```
18
+
19
+ Same workload, one extra import: **500 database queries become 1.**
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install cachefence
25
+ ```
26
+
27
+ Requires Python 3.11+ and a Redis server (4.2+).
28
+
29
+ ## Usage
30
+
31
+ ```python
32
+ from redis.asyncio import Redis
33
+ from cachefence import CacheFence
34
+
35
+ redis = Redis()
36
+ cache = CacheFence(redis)
37
+
38
+ async def get_user(user_id: int) -> dict:
39
+ return await cache.get_or_set(
40
+ key=f"user:{user_id}",
41
+ ttl=60, # fresh for 60 seconds
42
+ recompute=lambda: load_user_from_db(user_id),
43
+ )
44
+ ```
45
+
46
+ `recompute` can be sync or async. It runs at most once per refresh, no matter how
47
+ many requests arrive together. Invalidate manually when the underlying data
48
+ changes:
49
+
50
+ ```python
51
+ await cache.invalidate(f"user:{user_id}")
52
+ ```
53
+
54
+ ## How it works
55
+
56
+ cachefence layers two mechanisms so a key almost never goes cold *and* a cold key
57
+ is never rebuilt more than once:
58
+
59
+ 1. **Probabilistic early refresh (XFetch).** Each read rolls a weighted dice; as
60
+ the key nears expiry, one lucky request is nudged to refresh it *ahead of
61
+ time* while everyone else keeps serving the still-valid cached value. The
62
+ weighting uses how long the last recompute took, so expensive keys refresh
63
+ earlier. Based on Vattani, Chierichetti & Lowenstein, *"Optimal Probabilistic
64
+ Cache Stampede Prevention"* (VLDB 2015).
65
+
66
+ 2. **Distributed rebuild lock.** On a true miss, workers race for a short-lived
67
+ Redis lock. The winner rebuilds; the rest wait briefly and pick up the fresh
68
+ value the moment it lands, with a bounded fallback so a crashed rebuilder
69
+ never hangs requests forever.
70
+
71
+ The lock is released with a compare-and-delete (Lua when the server supports it,
72
+ an optimistic `WATCH`/`MULTI` transaction otherwise) so a worker can never delete
73
+ a lock it no longer owns.
74
+
75
+ ## Configuration
76
+
77
+ ```python
78
+ cache = CacheFence(
79
+ redis,
80
+ beta=1.0, # XFetch aggressiveness; higher = refresh earlier
81
+ lock_timeout=10.0, # seconds before a rebuild lock auto-expires
82
+ wait_for_lock=5.0, # max seconds a waiter blocks before rebuilding itself
83
+ namespace="app:", # optional key prefix
84
+ )
85
+ ```
86
+
87
+ Custom serialization (default is JSON):
88
+
89
+ ```python
90
+ import pickle
91
+ cache = CacheFence(redis, serializer=pickle.dumps, deserializer=pickle.loads)
92
+ # serializer returns bytes, deserializer takes bytes
93
+ ```
94
+
95
+ ## A note on connection pools
96
+
97
+ Under a genuine burst (hundreds of simultaneous coroutines), the default
98
+ `redis-py` pool can raise `MaxConnectionsError` because waiters don't block for a
99
+ free connection. Use a blocking pool sized for your concurrency:
100
+
101
+ ```python
102
+ from redis.asyncio import BlockingConnectionPool, Redis
103
+
104
+ pool = BlockingConnectionPool(max_connections=30, timeout=15)
105
+ redis = Redis(connection_pool=pool)
106
+ ```
107
+
108
+ ## Run the demo
109
+
110
+ ```bash
111
+ git clone https://github.com/bourne44/cachefence
112
+ cd cachefence
113
+ pip install -e ".[test]"
114
+ python examples/stampede_demo.py
115
+ ```
116
+
117
+ ## Development
118
+
119
+ ```bash
120
+ pip install -e ".[test]"
121
+ pytest
122
+ ```
123
+
124
+ The test suite includes a 100-way concurrent-miss test asserting the recompute
125
+ runs exactly once — the core guarantee of the library.
126
+
127
+ ## License
128
+
129
+ MIT
@@ -0,0 +1,79 @@
1
+ """Demo: cache stampede, with and without cachefence.
2
+
3
+ Simulates a hot key expiring while N requests arrive at once. Counts how many
4
+ times the "database" gets hit to rebuild the value.
5
+
6
+ Run::
7
+
8
+ python examples/stampede_demo.py
9
+
10
+ Requires a Redis server on localhost:6379.
11
+ """
12
+
13
+ import asyncio
14
+ import time
15
+
16
+ from redis.asyncio import BlockingConnectionPool, Redis
17
+
18
+ from cachefence import CacheFence
19
+
20
+ CONCURRENCY = 500
21
+ DB_LATENCY = 0.05 # seconds per "query"
22
+
23
+
24
+ async def naive_get_or_set(redis, key, ttl, recompute):
25
+ """The cache-aside pattern almost everyone writes by hand."""
26
+ cached = await redis.get(key)
27
+ if cached is not None:
28
+ return cached
29
+ value = await recompute()
30
+ await redis.set(key, value, ex=ttl)
31
+ return value
32
+
33
+
34
+ async def run(label, getter):
35
+ pool = BlockingConnectionPool(max_connections=30, timeout=15)
36
+ redis = Redis(connection_pool=pool, decode_responses=True)
37
+ await redis.flushall()
38
+
39
+ db_hits = {"n": 0}
40
+
41
+ async def recompute():
42
+ db_hits["n"] += 1
43
+ await asyncio.sleep(DB_LATENCY)
44
+ return "rebuilt-value"
45
+
46
+ start = time.monotonic()
47
+ await asyncio.gather(*(getter(redis, recompute) for _ in range(CONCURRENCY)))
48
+ elapsed = time.monotonic() - start
49
+
50
+ await redis.aclose()
51
+ print(f"{label:<22} DB hits: {db_hits['n']:>4} wall time: {elapsed:.2f}s")
52
+ return db_hits["n"]
53
+
54
+
55
+ async def main():
56
+ print(f"\n{CONCURRENCY} concurrent requests hit a cold key "
57
+ f"(each DB query takes {DB_LATENCY*1000:.0f}ms)\n")
58
+
59
+ naive = await run(
60
+ "naive cache-aside",
61
+ lambda r, rc: naive_get_or_set(r, "hot", 60, rc),
62
+ )
63
+
64
+ cache = None
65
+
66
+ async def with_fence(redis, recompute):
67
+ nonlocal cache
68
+ if cache is None:
69
+ cache = CacheFence(redis)
70
+ return await cache.get_or_set("hot", 60, recompute)
71
+
72
+ fenced = await run("with cachefence", with_fence)
73
+
74
+ print(f"\ncachefence cut DB load by {(1 - fenced / naive) * 100:.0f}% "
75
+ f"({naive} -> {fenced} queries)\n")
76
+
77
+
78
+ if __name__ == "__main__":
79
+ asyncio.run(main())
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cachefence"
7
+ version = "0.1.0"
8
+ description = "Cache-aside for Redis without the stampede. Probabilistic early refresh + distributed lock, so a hot key expiring never hammers your database."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Bourne" }]
13
+ keywords = ["redis", "cache", "stampede", "thundering-herd", "xfetch", "async"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Topic :: Database",
22
+ "Topic :: System :: Distributed Computing",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = ["redis>=4.2"]
26
+
27
+ [project.optional-dependencies]
28
+ test = ["pytest", "pytest-asyncio", "fakeredis"]
29
+ dev = ["pytest", "pytest-asyncio", "fakeredis", "mypy", "ruff", "types-redis"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/bourne44/cachefence"
33
+ Issues = "https://github.com/bourne44/cachefence/issues"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/cachefence"]
37
+
38
+ [tool.pytest.ini_options]
39
+ asyncio_mode = "auto"
40
+ testpaths = ["tests"]
41
+
42
+ [tool.mypy]
43
+ strict = true
44
+ python_version = "3.11"
45
+
46
+ [tool.ruff]
47
+ target-version = "py311"
48
+ line-length = 90
49
+
50
+ [tool.ruff.lint]
51
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -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"
@@ -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)
@@ -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
+ """
File without changes
@@ -0,0 +1,112 @@
1
+ """Tests for cachefence.
2
+
3
+ The headline test is ``test_no_stampede_on_concurrent_miss``: it fires many
4
+ concurrent requests at a cold key and asserts the expensive recompute runs only
5
+ once. That's the entire reason this library exists.
6
+ """
7
+
8
+ import asyncio
9
+
10
+ import fakeredis.aioredis
11
+ import pytest
12
+
13
+ from cachefence import CacheFence, RecomputeError
14
+
15
+ pytestmark = pytest.mark.asyncio
16
+
17
+
18
+ @pytest.fixture
19
+ async def redis():
20
+ client = fakeredis.aioredis.FakeRedis()
21
+ yield client
22
+ await client.flushall()
23
+ await client.aclose()
24
+
25
+
26
+ async def test_basic_get_or_set_caches(redis):
27
+ cache = CacheFence(redis)
28
+ calls = 0
29
+
30
+ async def recompute():
31
+ nonlocal calls
32
+ calls += 1
33
+ return {"hello": "world"}
34
+
35
+ first = await cache.get_or_set("k", ttl=60, recompute=recompute)
36
+ second = await cache.get_or_set("k", ttl=60, recompute=recompute)
37
+
38
+ assert first == {"hello": "world"}
39
+ assert second == {"hello": "world"}
40
+ assert calls == 1 # second read served from cache
41
+
42
+
43
+ async def test_sync_recompute_supported(redis):
44
+ cache = CacheFence(redis)
45
+ value = await cache.get_or_set("k", ttl=60, recompute=lambda: 42)
46
+ assert value == 42
47
+
48
+
49
+ async def test_no_stampede_on_concurrent_miss(redis):
50
+ """100 concurrent requests for a cold key -> recompute runs exactly once."""
51
+ cache = CacheFence(redis)
52
+ calls = 0
53
+
54
+ async def slow_recompute():
55
+ nonlocal calls
56
+ calls += 1
57
+ await asyncio.sleep(0.1) # simulate a slow DB query
58
+ return "expensive-value"
59
+
60
+ results = await asyncio.gather(
61
+ *(cache.get_or_set("hot", ttl=60, recompute=slow_recompute) for _ in range(100))
62
+ )
63
+
64
+ assert all(r == "expensive-value" for r in results)
65
+ assert calls == 1 # the whole point: no stampede
66
+
67
+
68
+ async def test_invalidate_forces_recompute(redis):
69
+ cache = CacheFence(redis)
70
+ calls = 0
71
+
72
+ async def recompute():
73
+ nonlocal calls
74
+ calls += 1
75
+ return calls
76
+
77
+ assert await cache.get_or_set("k", ttl=60, recompute=recompute) == 1
78
+ await cache.invalidate("k")
79
+ assert await cache.get_or_set("k", ttl=60, recompute=recompute) == 2
80
+
81
+
82
+ async def test_recompute_error_wrapped(redis):
83
+ cache = CacheFence(redis)
84
+
85
+ async def boom():
86
+ raise ValueError("db down")
87
+
88
+ with pytest.raises(RecomputeError):
89
+ await cache.get_or_set("k", ttl=60, recompute=boom)
90
+
91
+
92
+ async def test_namespace_applied(redis):
93
+ cache = CacheFence(redis, namespace="app:")
94
+ await cache.get_or_set("k", ttl=60, recompute=lambda: 1)
95
+ assert await redis.exists("app:k")
96
+ assert not await redis.exists("k")
97
+
98
+
99
+ async def test_expiry_then_refresh(redis):
100
+ cache = CacheFence(redis)
101
+ calls = 0
102
+
103
+ async def recompute():
104
+ nonlocal calls
105
+ calls += 1
106
+ return calls
107
+
108
+ await cache.get_or_set("k", ttl=0.1, recompute=recompute)
109
+ await asyncio.sleep(0.2) # let it expire
110
+ result = await cache.get_or_set("k", ttl=0.1, recompute=recompute)
111
+ assert result == 2
112
+ assert calls == 2