Skip to content

Commit ec76dff

Browse files
authored
Merge pull request #815 from seapagan/fix/cache-invalidation-performance
perf: parallelize cache invalidation for better performance
2 parents f66d715 + 9e92b88 commit ec76dff

File tree

5 files changed

+94
-29
lines changed

5 files changed

+94
-29
lines changed

SECURITY-REVIEW.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,12 @@
531531

532532
### 33. Cache Invalidation Overhead
533533

534+
> [!NOTE]
535+
> **Done**: Created `invalidate_user_related_caches()` helper in
536+
> `app/cache/invalidation.py` that uses `asyncio.gather()` to invalidate
537+
> user-specific and users-list caches in parallel. Updated all 5 user
538+
> mutation endpoints to use the new helper.
539+
534540
**Location**: `app/resources/user.py` (lines 123-124, 162-163, 184-185, 205-206,
535541
225-226)
536542

app/cache/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
invalidate_api_keys_cache,
99
invalidate_namespace,
1010
invalidate_user_cache,
11+
invalidate_user_related_caches,
1112
invalidate_users_list_cache,
1213
)
1314
from app.cache.key_builders import (
@@ -25,6 +26,7 @@
2526
"invalidate_api_keys_cache",
2627
"invalidate_namespace",
2728
"invalidate_user_cache",
29+
"invalidate_user_related_caches",
2830
"invalidate_users_list_cache",
2931
"paginated_key_builder",
3032
"user_scoped_key_builder",

app/cache/invalidation.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
fails.
1010
"""
1111

12+
import asyncio
13+
1214
from fastapi_cache import FastAPICache
1315
from redis.exceptions import RedisError
1416

@@ -115,6 +117,33 @@ async def invalidate_api_keys_cache(user_id: int) -> None:
115117
)
116118

117119

120+
async def invalidate_user_related_caches(user_id: int) -> None:
121+
"""Invalidate all user-related caches in parallel for better performance.
122+
123+
Clears both user-specific cache entries and the users list cache
124+
concurrently using `asyncio.gather()`. This is more efficient than
125+
calling the invalidation functions sequentially.
126+
127+
Args:
128+
user_id: The ID of the user whose caches should be cleared.
129+
130+
Example:
131+
```python
132+
# After user edit, delete, or role change
133+
await invalidate_user_related_caches(user.id)
134+
```
135+
136+
Note:
137+
Cache failures are logged but don't raise exceptions. Individual
138+
cache invalidation failures don't prevent other caches from being
139+
cleared. The app continues with stale cache until TTL expires.
140+
"""
141+
await asyncio.gather(
142+
invalidate_user_cache(user_id),
143+
invalidate_users_list_cache(),
144+
)
145+
146+
118147
async def invalidate_namespace(namespace: str) -> None:
119148
"""Invalidate all cache keys under a namespace.
120149

app/resources/user.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99

1010
from app.cache import (
1111
cached,
12-
invalidate_user_cache,
13-
invalidate_users_list_cache,
12+
invalidate_user_related_caches,
1413
user_scoped_key_builder,
1514
users_list_key_builder,
1615
)
@@ -129,8 +128,7 @@ async def make_admin(
129128
"""Make the User with this ID an Admin."""
130129
await UserManager.change_role(RoleType.admin, user_id, db)
131130
# Invalidate user cache and users list cache after role change
132-
await invalidate_user_cache(user_id)
133-
await invalidate_users_list_cache()
131+
await invalidate_user_related_caches(user_id)
134132

135133

136134
@router.post(
@@ -168,8 +166,7 @@ async def ban_user(
168166
user_id, request.state.user.id, db, banned=True
169167
)
170168
# Invalidate user cache and users list cache after ban
171-
await invalidate_user_cache(user_id)
172-
await invalidate_users_list_cache()
169+
await invalidate_user_related_caches(user_id)
173170

174171

175172
@router.post(
@@ -190,8 +187,7 @@ async def unban_user(
190187
user_id, request.state.user.id, db, banned=False
191188
)
192189
# Invalidate user cache and users list cache after unban
193-
await invalidate_user_cache(user_id)
194-
await invalidate_users_list_cache()
190+
await invalidate_user_related_caches(user_id)
195191

196192

197193
@router.put(
@@ -211,8 +207,7 @@ async def edit_user(
211207
"""
212208
await UserManager.update_user(user_id, user_data, db)
213209
# Invalidate user cache and users list cache after editing
214-
await invalidate_user_cache(user_id)
215-
await invalidate_users_list_cache()
210+
await invalidate_user_related_caches(user_id)
216211
return await db.get(User, user_id)
217212

218213

@@ -231,8 +226,7 @@ async def delete_user(
231226
"""
232227
await UserManager.delete_user(user_id, db)
233228
# Invalidate user cache and users list cache after deletion
234-
await invalidate_user_cache(user_id)
235-
await invalidate_users_list_cache()
229+
await invalidate_user_related_caches(user_id)
236230

237231

238232
@router.get(

docs/usage/caching.md

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,24 @@ Cached endpoints automatically:
119119

120120
### Cache Key Structure
121121

122-
Cache keys are organized by namespace for easy invalidation:
122+
Cache keys are organized by namespace for easy invalidation. Use the
123+
`CacheNamespaces` constants from `app.cache.constants` to avoid typos:
123124

125+
```python
126+
from app.cache.constants import CacheNamespaces
127+
128+
# Available namespaces
129+
CacheNamespaces.USER_ME # "user" - User-scoped endpoints
130+
CacheNamespaces.USERS_SINGLE # "users" - Base namespace for /users/ endpoint
131+
CacheNamespaces.USERS_LIST # "users:list" - Paginated user lists
132+
CacheNamespaces.API_KEYS_LIST # "apikeys" - User's API keys list
133+
CacheNamespaces.API_KEY_SINGLE # "apikey" - Single API key lookup
134+
```
135+
136+
Key format patterns:
124137
- `user:{user_id}` - User-scoped endpoints (/users/me, /users/keys)
125138
- `users:list` - Paginated user lists
126-
- `users:{user_id}:single` - Single user lookups
139+
- `users:{user_id}` - Single user lookups
127140
- `apikeys:{user_id}` - User's API keys list
128141

129142
## Configuration
@@ -197,17 +210,23 @@ Use the `@cached()` decorator on route handlers:
197210
```python
198211
from fastapi import APIRouter, Request, Response
199212
from app.cache import cached
213+
from app.cache.constants import CacheNamespaces
200214

201215
router = APIRouter()
202216

203217
@router.get("/expensive-query")
204-
@cached(expire=300, namespace="queries")
218+
@cached(expire=300, namespace=CacheNamespaces.USER_ME)
205219
async def expensive_query(request: Request, response: Response):
206220
# This will be cached for 5 minutes
207221
result = await perform_expensive_operation()
208222
return result
209223
```
210224

225+
!!! tip "Use Namespace Constants"
226+
Always use `CacheNamespaces` constants instead of hardcoded strings
227+
to avoid typos and make refactoring easier. See [Cache Key
228+
Structure](#cache-key-structure) for available constants.
229+
211230
!!! note "Decorator Order"
212231
The `@cached()` decorator MUST be placed AFTER the route decorator
213232
(`@router.get()`, etc.) to work correctly.
@@ -236,12 +255,16 @@ For user-scoped caching, use built-in key builders:
236255

237256
```python
238257
from app.cache import cached, user_scoped_key_builder
258+
from app.cache.constants import CacheNamespaces
239259
from app.managers.auth import AuthManager
240260
from app.models.user import User
241261

242262
@router.get("/users/me")
243-
@cached(expire=300, namespace="user",
244-
key_builder=user_scoped_key_builder)
263+
@cached(
264+
expire=300,
265+
namespace=CacheNamespaces.USER_ME,
266+
key_builder=user_scoped_key_builder,
267+
)
245268
async def get_current_user(
246269
request: Request,
247270
response: Response,
@@ -267,33 +290,44 @@ The `cached()` decorator accepts these parameters:
267290
The template provides helper functions to invalidate cache when data
268291
changes:
269292

270-
### User Cache Invalidation
293+
### Combined User Cache Invalidation (Recommended)
294+
295+
For user mutations (create, update, delete, ban, role changes), use the
296+
combined helper that invalidates multiple cache namespaces in parallel:
271297

272298
```python
273-
from app.cache import invalidate_user_cache
299+
from app.cache import invalidate_user_related_caches
274300

275-
# After updating user data
301+
# After user mutation
276302
await db.commit()
277-
await invalidate_user_cache(user.id)
303+
await invalidate_user_related_caches(user.id)
278304
```
279305

280-
This clears:
306+
This clears both user-specific and list caches concurrently using
307+
`asyncio.gather()` for better performance:
281308

282309
- User-scoped cache (`user:{user_id}`)
283-
- Single user lookup (`users:{user_id}:single`)
310+
- Single user lookup (`users:{user_id}`)
311+
- Users list cache (`users:list`)
284312

285-
### Users List Cache Invalidation
313+
### Individual Cache Invalidation
314+
315+
For fine-grained control, you can invalidate specific cache namespaces
316+
individually:
286317

287318
```python
288-
from app.cache import invalidate_users_list_cache
319+
from app.cache import (
320+
invalidate_user_cache,
321+
invalidate_users_list_cache,
322+
)
289323

290-
# After creating/deleting users or changing roles
291-
await db.commit()
324+
# Clear user-specific caches only
325+
await invalidate_user_cache(user.id)
326+
327+
# Clear users list only (after role changes, etc.)
292328
await invalidate_users_list_cache()
293329
```
294330

295-
This clears all paginated user list entries.
296-
297331
### API Keys Cache Invalidation
298332

299333
```python

0 commit comments

Comments
 (0)