Skip to content

Commit 5c795fa

Browse files
committed
feat: add dogpile prevention
Signed-off-by: Patrick Rodrigues <23041890+pythrick@users.noreply.github.com>
1 parent b2143ef commit 5c795fa

12 files changed

Lines changed: 1350 additions & 63 deletions

File tree

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ backends supporting Redis, Memcached, and Amazon DynamoDB.
1414
- Supports `redis`, `memcache`, `dynamodb`, and `in-memory` backends.
1515
- Easy integration with [FastAPI](https://fastapi.tiangolo.com/).
1616
- Support for HTTP cache headers like `ETag` and `Cache-Control`, as well as conditional `If-Match-None` requests.
17+
- Dogpile prevention (cache stampede mitigation) to prevent multiple simultaneous cache refreshes.
1718

1819
## Requirements
1920

@@ -103,9 +104,71 @@ Parameter | type | default | description
103104
`key_builder` | `KeyBuilder` callable | `default_key_builder` | which key builder to use
104105
`injected_dependency_namespace` | `str` | `__fastapi_cache` | prefix for injected dependency keywords.
105106
`cache_status_header` | `str` | `X-FastAPI-Cache` | Name for the header on the response indicating if the request was served from cache; either `HIT` or `MISS`.
107+
`enable_dogpile_prevention` | `bool` | `None` | Enable dogpile prevention (defaults to global setting)
108+
`dogpile_grace_time` | `float` | `None` | Maximum time to wait for another request to complete (defaults to global setting)
109+
`dogpile_wait_time` | `float` | `None` | Time to wait between checks (defaults to global setting)
110+
`dogpile_max_wait_time` | `float` | `None` | Maximum total wait time (defaults to global setting)
106111

107112
You can also use the `@cache` decorator on regular functions to cache their result.
108113

114+
### Dogpile Prevention
115+
116+
`fastapi-cache` includes built-in dogpile prevention (also known as cache stampede prevention) to handle the common scenario where multiple concurrent requests try to refresh an expired cache entry simultaneously.
117+
118+
When dogpile prevention is enabled:
119+
1. The first request to find an expired cache entry will proceed to compute the new value
120+
2. Subsequent requests for the same key will wait briefly for the first request to complete
121+
3. Once the value is computed, all waiting requests will use the newly cached value
122+
4. If the computation takes too long, waiting requests will eventually proceed independently
123+
124+
This prevents overwhelming your backend when popular cache entries expire.
125+
126+
#### Configuring Dogpile Prevention
127+
128+
You can configure dogpile prevention globally when initializing FastAPICache:
129+
130+
```python
131+
from fastapi import FastAPI
132+
from fastapi_cache import FastAPICache
133+
from fastapi_cache.backends.redis import RedisBackend
134+
135+
app = FastAPI()
136+
137+
@app.on_event("startup")
138+
async def startup():
139+
FastAPICache.init(
140+
RedisBackend(redis_client),
141+
prefix="my-app",
142+
enable_dogpile_prevention=True, # Enable globally
143+
dogpile_grace_time=60.0, # Wait up to 60 seconds for computation
144+
dogpile_wait_time=0.1, # Check every 100ms
145+
dogpile_max_wait_time=5.0, # Wait maximum 5 seconds
146+
)
147+
```
148+
149+
Or configure it per endpoint:
150+
151+
```python
152+
@app.get("/expensive-computation")
153+
@cache(
154+
expire=300,
155+
enable_dogpile_prevention=True,
156+
dogpile_grace_time=30.0, # This endpoint's computation might take up to 30s
157+
)
158+
async def expensive_endpoint():
159+
# Expensive computation here
160+
return compute_expensive_result()
161+
```
162+
163+
To disable dogpile prevention for specific endpoints:
164+
165+
```python
166+
@app.get("/fast-endpoint")
167+
@cache(expire=60, enable_dogpile_prevention=False)
168+
async def fast_endpoint():
169+
return {"data": "fast"}
170+
```
171+
109172
### Injected Request and Response dependencies
110173

111174
The `cache` decorator injects dependencies for the `Request` and `Response`
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Add dogpile prevention (cache stampede mitigation) to prevent multiple simultaneous cache refreshes
2+
3+
When enabled, if multiple requests arrive for an expired cache entry:
4+
- The first request will compute the new value
5+
- Subsequent requests will wait briefly for the computation to complete
6+
- All waiting requests will use the newly cached value once available
7+
8+
This feature can be configured globally or per-endpoint with customizable wait times and grace periods.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Dogpile prevention example for fastapi-cache
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
"""Example demonstrating dogpile prevention in fastapi-cache."""
2+
import asyncio
3+
import time
4+
from contextlib import asynccontextmanager
5+
from typing import AsyncIterator
6+
7+
import uvicorn
8+
from fastapi import BackgroundTasks, FastAPI
9+
from fastapi.responses import HTMLResponse
10+
from fastapi_cache import FastAPICache
11+
from fastapi_cache.backends.inmemory import InMemoryBackend
12+
from fastapi_cache.decorator import cache
13+
from starlette.requests import Request
14+
from starlette.responses import Response
15+
16+
# Track computation times for demonstration
17+
computation_tracker = {}
18+
19+
20+
@asynccontextmanager
21+
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
22+
# Initialize cache with dogpile prevention enabled
23+
FastAPICache.init(
24+
InMemoryBackend(),
25+
prefix="dogpile-demo",
26+
enable_dogpile_prevention=True,
27+
dogpile_grace_time=30.0, # Allow 30 seconds for computation
28+
dogpile_wait_time=0.1, # Check every 100ms
29+
dogpile_max_wait_time=5.0, # Wait max 5 seconds
30+
)
31+
yield
32+
33+
34+
app = FastAPI(lifespan=lifespan)
35+
36+
37+
@app.get("/")
38+
async def index():
39+
"""Home page with example links."""
40+
return HTMLResponse("""
41+
<html>
42+
<head>
43+
<title>Dogpile Prevention Demo</title>
44+
<style>
45+
body { font-family: Arial, sans-serif; margin: 40px; }
46+
.button {
47+
display: inline-block;
48+
padding: 10px 20px;
49+
margin: 5px;
50+
background-color: #4CAF50;
51+
color: white;
52+
text-decoration: none;
53+
border-radius: 4px;
54+
}
55+
.info {
56+
background-color: #f0f0f0;
57+
padding: 15px;
58+
margin: 20px 0;
59+
border-radius: 4px;
60+
}
61+
pre { background-color: #f5f5f5; padding: 10px; overflow-x: auto; }
62+
</style>
63+
</head>
64+
<body>
65+
<h1>FastAPI Cache - Dogpile Prevention Demo</h1>
66+
67+
<div class="info">
68+
<h2>What is Dogpile Prevention?</h2>
69+
<p>Dogpile prevention (also known as cache stampede prevention) ensures that when multiple
70+
requests arrive for the same uncached resource, only one request computes the value while
71+
others wait for the result.</p>
72+
</div>
73+
74+
<h2>Examples:</h2>
75+
76+
<h3>1. Expensive Computation (3 seconds)</h3>
77+
<a class="button" href="/expensive/1" target="_blank">Request Item 1</a>
78+
<a class="button" href="/expensive/2" target="_blank">Request Item 2</a>
79+
<p>Try opening multiple tabs quickly for the same item!</p>
80+
81+
<h3>2. Without Dogpile Prevention</h3>
82+
<a class="button" href="/no-dogpile/1" target="_blank">Request Item 1 (No Protection)</a>
83+
<p>Compare the behavior when dogpile prevention is disabled.</p>
84+
85+
<h3>3. Simulate Concurrent Requests</h3>
86+
<a class="button" href="/simulate-concurrent/1">Simulate 5 Concurrent Requests</a>
87+
<p>This will simulate 5 concurrent requests for the same resource.</p>
88+
89+
<h3>4. View Statistics</h3>
90+
<a class="button" href="/stats">View Computation Statistics</a>
91+
<a class="button" href="/clear-stats">Clear Statistics</a>
92+
93+
<script>
94+
// Auto-refresh stats every 2 seconds if on stats page
95+
if (window.location.pathname === '/stats') {
96+
setTimeout(() => location.reload(), 2000);
97+
}
98+
</script>
99+
</body>
100+
</html>
101+
""")
102+
103+
104+
@app.get("/expensive/{item_id}")
105+
@cache(expire=60) # Cache for 60 seconds
106+
async def expensive_computation(item_id: int, request: Request, response: Response):
107+
"""Simulate an expensive computation with dogpile prevention."""
108+
start_time = time.time()
109+
110+
# Track when computation starts
111+
key = f"expensive_{item_id}"
112+
if key not in computation_tracker:
113+
computation_tracker[key] = []
114+
computation_tracker[key].append({
115+
"start_time": start_time,
116+
"status": "started"
117+
})
118+
119+
# Simulate expensive computation
120+
await asyncio.sleep(3.0)
121+
122+
# Mark computation as complete
123+
computation_tracker[key][-1]["end_time"] = time.time()
124+
computation_tracker[key][-1]["status"] = "completed"
125+
computation_tracker[key][-1]["duration"] = computation_tracker[key][-1]["end_time"] - start_time
126+
127+
return {
128+
"item_id": item_id,
129+
"data": f"Expensive result for item {item_id}",
130+
"computed_at": time.time(),
131+
"computation_time": 3.0
132+
}
133+
134+
135+
@app.get("/no-dogpile/{item_id}")
136+
@cache(expire=60, enable_dogpile_prevention=False) # Explicitly disable dogpile prevention
137+
async def no_dogpile_computation(item_id: int):
138+
"""Same expensive computation but without dogpile prevention."""
139+
start_time = time.time()
140+
141+
# Track computation
142+
key = f"no_dogpile_{item_id}"
143+
if key not in computation_tracker:
144+
computation_tracker[key] = []
145+
computation_tracker[key].append({
146+
"start_time": start_time,
147+
"status": "started"
148+
})
149+
150+
# Simulate expensive computation
151+
await asyncio.sleep(3.0)
152+
153+
# Mark completion
154+
computation_tracker[key][-1]["end_time"] = time.time()
155+
computation_tracker[key][-1]["status"] = "completed"
156+
computation_tracker[key][-1]["duration"] = computation_tracker[key][-1]["end_time"] - start_time
157+
158+
return {
159+
"item_id": item_id,
160+
"data": f"Result for item {item_id} (no dogpile prevention)",
161+
"computed_at": time.time(),
162+
"computation_time": 3.0
163+
}
164+
165+
166+
async def make_request(item_id: int):
167+
"""Helper to simulate a request to the expensive endpoint."""
168+
# In a real scenario, this would be an HTTP request
169+
# For demo purposes, we'll call the function directly
170+
request = Request({"type": "http", "method": "GET", "url": f"/expensive/{item_id}"})
171+
response = Response()
172+
return await expensive_computation(item_id, request, response)
173+
174+
175+
@app.get("/simulate-concurrent/{item_id}")
176+
async def simulate_concurrent_requests(item_id: int, background_tasks: BackgroundTasks):
177+
"""Simulate multiple concurrent requests for the same resource."""
178+
# Clear previous stats for this item
179+
key = f"concurrent_{item_id}"
180+
computation_tracker[key] = {
181+
"start_time": time.time(),
182+
"requests": 5,
183+
"status": "started"
184+
}
185+
186+
# Start 5 concurrent tasks
187+
tasks = []
188+
for i in range(5):
189+
# Add small delays to simulate realistic request timing
190+
await asyncio.sleep(0.05 * i)
191+
tasks.append(asyncio.create_task(make_request(item_id)))
192+
193+
# Wait for all to complete
194+
results = await asyncio.gather(*tasks)
195+
196+
computation_tracker[key]["end_time"] = time.time()
197+
computation_tracker[key]["duration"] = computation_tracker[key]["end_time"] - computation_tracker[key]["start_time"]
198+
computation_tracker[key]["status"] = "completed"
199+
200+
# Count unique computation times (should be 1 with dogpile prevention)
201+
unique_computations = len({r["computed_at"] for r in results})
202+
203+
return {
204+
"item_id": item_id,
205+
"total_requests": 5,
206+
"unique_computations": unique_computations,
207+
"dogpile_prevented": unique_computations == 1,
208+
"total_time": computation_tracker[key]["duration"],
209+
"message": f"{'✅ Dogpile prevention worked!' if unique_computations == 1 else '❌ Multiple computations occurred'}"
210+
}
211+
212+
213+
@app.get("/stats")
214+
async def view_stats():
215+
"""View computation statistics."""
216+
stats_html = """
217+
<html>
218+
<head>
219+
<title>Computation Statistics</title>
220+
<style>
221+
body { font-family: Arial, sans-serif; margin: 40px; }
222+
table { border-collapse: collapse; width: 100%; }
223+
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
224+
th { background-color: #4CAF50; color: white; }
225+
tr:nth-child(even) { background-color: #f2f2f2; }
226+
.back-button {
227+
display: inline-block;
228+
padding: 10px 20px;
229+
margin: 10px 0;
230+
background-color: #008CBA;
231+
color: white;
232+
text-decoration: none;
233+
border-radius: 4px;
234+
}
235+
</style>
236+
</head>
237+
<body>
238+
<h1>Computation Statistics</h1>
239+
<a class="back-button" href="/">← Back to Home</a>
240+
241+
<h2>Computation History</h2>
242+
<table>
243+
<tr>
244+
<th>Resource</th>
245+
<th>Start Time</th>
246+
<th>Duration</th>
247+
<th>Status</th>
248+
<th>Total Computations</th>
249+
</tr>
250+
"""
251+
252+
for key, computations in computation_tracker.items():
253+
if isinstance(computations, list):
254+
for comp in computations:
255+
start_time = time.strftime('%H:%M:%S', time.localtime(comp['start_time']))
256+
duration = f"{comp.get('duration', 'N/A'):.2f}s" if 'duration' in comp else 'In Progress'
257+
stats_html += f"""
258+
<tr>
259+
<td>{key}</td>
260+
<td>{start_time}</td>
261+
<td>{duration}</td>
262+
<td>{comp['status']}</td>
263+
<td>{len(computations)}</td>
264+
</tr>
265+
"""
266+
267+
stats_html += """
268+
</table>
269+
270+
<h2>Summary</h2>
271+
<ul>
272+
"""
273+
274+
# Add summary statistics
275+
with_dogpile = sum(1 for k in computation_tracker.keys() if k.startswith('expensive_'))
276+
without_dogpile = sum(1 for k in computation_tracker.keys() if k.startswith('no_dogpile_'))
277+
278+
stats_html += f"""
279+
<li>Resources with dogpile prevention: {with_dogpile}</li>
280+
<li>Resources without dogpile prevention: {without_dogpile}</li>
281+
</ul>
282+
283+
<p><em>Page auto-refreshes every 2 seconds</em></p>
284+
</body>
285+
</html>
286+
"""
287+
288+
return HTMLResponse(stats_html)
289+
290+
291+
@app.get("/clear-stats")
292+
async def clear_stats():
293+
"""Clear computation statistics."""
294+
computation_tracker.clear()
295+
return {"message": "Statistics cleared", "redirect": "/"}
296+
297+
298+
@app.get("/clear-cache")
299+
async def clear_cache():
300+
"""Clear all cached data."""
301+
await FastAPICache.clear()
302+
return {"message": "Cache cleared", "items_cleared": "all"}
303+
304+
305+
if __name__ == "__main__":
306+
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)

0 commit comments

Comments
 (0)