Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions SECURITY-REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,12 @@

### 33. Cache Invalidation Overhead

> [!NOTE]
> ✅ **Done**: Created `invalidate_user_related_caches()` helper in
> `app/cache/invalidation.py` that uses `asyncio.gather()` to invalidate
> user-specific and users-list caches in parallel. Updated all 5 user
> mutation endpoints to use the new helper.

**Location**: `app/resources/user.py` (lines 123-124, 162-163, 184-185, 205-206,
225-226)

Expand Down
2 changes: 2 additions & 0 deletions app/cache/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
invalidate_api_keys_cache,
invalidate_namespace,
invalidate_user_cache,
invalidate_user_related_caches,
invalidate_users_list_cache,
)
from app.cache.key_builders import (
Expand All @@ -25,6 +26,7 @@
"invalidate_api_keys_cache",
"invalidate_namespace",
"invalidate_user_cache",
"invalidate_user_related_caches",
"invalidate_users_list_cache",
"paginated_key_builder",
"user_scoped_key_builder",
Expand Down
29 changes: 29 additions & 0 deletions app/cache/invalidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
fails.
"""

import asyncio

from fastapi_cache import FastAPICache
from redis.exceptions import RedisError

Expand Down Expand Up @@ -115,6 +117,33 @@ async def invalidate_api_keys_cache(user_id: int) -> None:
)


async def invalidate_user_related_caches(user_id: int) -> None:
"""Invalidate all user-related caches in parallel for better performance.

Clears both user-specific cache entries and the users list cache
concurrently using `asyncio.gather()`. This is more efficient than
calling the invalidation functions sequentially.

Args:
user_id: The ID of the user whose caches should be cleared.

Example:
```python
# After user edit, delete, or role change
await invalidate_user_related_caches(user.id)
```

Note:
Cache failures are logged but don't raise exceptions. Individual
cache invalidation failures don't prevent other caches from being
cleared. The app continues with stale cache until TTL expires.
"""
await asyncio.gather(
invalidate_user_cache(user_id),
invalidate_users_list_cache(),
)


async def invalidate_namespace(namespace: str) -> None:
"""Invalidate all cache keys under a namespace.

Expand Down
18 changes: 6 additions & 12 deletions app/resources/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@

from app.cache import (
cached,
invalidate_user_cache,
invalidate_users_list_cache,
invalidate_user_related_caches,
user_scoped_key_builder,
users_list_key_builder,
)
Expand Down Expand Up @@ -129,8 +128,7 @@ async def make_admin(
"""Make the User with this ID an Admin."""
await UserManager.change_role(RoleType.admin, user_id, db)
# Invalidate user cache and users list cache after role change
await invalidate_user_cache(user_id)
await invalidate_users_list_cache()
await invalidate_user_related_caches(user_id)


@router.post(
Expand Down Expand Up @@ -168,8 +166,7 @@ async def ban_user(
user_id, request.state.user.id, db, banned=True
)
# Invalidate user cache and users list cache after ban
await invalidate_user_cache(user_id)
await invalidate_users_list_cache()
await invalidate_user_related_caches(user_id)


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


@router.put(
Expand All @@ -211,8 +207,7 @@ async def edit_user(
"""
await UserManager.update_user(user_id, user_data, db)
# Invalidate user cache and users list cache after editing
await invalidate_user_cache(user_id)
await invalidate_users_list_cache()
await invalidate_user_related_caches(user_id)
return await db.get(User, user_id)


Expand All @@ -231,8 +226,7 @@ async def delete_user(
"""
await UserManager.delete_user(user_id, db)
# Invalidate user cache and users list cache after deletion
await invalidate_user_cache(user_id)
await invalidate_users_list_cache()
await invalidate_user_related_caches(user_id)


@router.get(
Expand Down
68 changes: 51 additions & 17 deletions docs/usage/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,24 @@ Cached endpoints automatically:

### Cache Key Structure

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

```python
from app.cache.constants import CacheNamespaces

# Available namespaces
CacheNamespaces.USER_ME # "user" - User-scoped endpoints
CacheNamespaces.USERS_SINGLE # "users" - Base namespace for /users/ endpoint
CacheNamespaces.USERS_LIST # "users:list" - Paginated user lists
CacheNamespaces.API_KEYS_LIST # "apikeys" - User's API keys list
CacheNamespaces.API_KEY_SINGLE # "apikey" - Single API key lookup
```

Key format patterns:
- `user:{user_id}` - User-scoped endpoints (/users/me, /users/keys)
- `users:list` - Paginated user lists
- `users:{user_id}:single` - Single user lookups
- `users:{user_id}` - Single user lookups
- `apikeys:{user_id}` - User's API keys list

## Configuration
Expand Down Expand Up @@ -197,17 +210,23 @@ Use the `@cached()` decorator on route handlers:
```python
from fastapi import APIRouter, Request, Response
from app.cache import cached
from app.cache.constants import CacheNamespaces

router = APIRouter()

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

!!! tip "Use Namespace Constants"
Always use `CacheNamespaces` constants instead of hardcoded strings
to avoid typos and make refactoring easier. See [Cache Key
Structure](#cache-key-structure) for available constants.

!!! note "Decorator Order"
The `@cached()` decorator MUST be placed AFTER the route decorator
(`@router.get()`, etc.) to work correctly.
Expand Down Expand Up @@ -236,12 +255,16 @@ For user-scoped caching, use built-in key builders:

```python
from app.cache import cached, user_scoped_key_builder
from app.cache.constants import CacheNamespaces
from app.managers.auth import AuthManager
from app.models.user import User

@router.get("/users/me")
@cached(expire=300, namespace="user",
key_builder=user_scoped_key_builder)
@cached(
expire=300,
namespace=CacheNamespaces.USER_ME,
key_builder=user_scoped_key_builder,
)
async def get_current_user(
request: Request,
response: Response,
Expand All @@ -267,33 +290,44 @@ The `cached()` decorator accepts these parameters:
The template provides helper functions to invalidate cache when data
changes:

### User Cache Invalidation
### Combined User Cache Invalidation (Recommended)

For user mutations (create, update, delete, ban, role changes), use the
combined helper that invalidates multiple cache namespaces in parallel:

```python
from app.cache import invalidate_user_cache
from app.cache import invalidate_user_related_caches

# After updating user data
# After user mutation
await db.commit()
await invalidate_user_cache(user.id)
await invalidate_user_related_caches(user.id)
```

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

- User-scoped cache (`user:{user_id}`)
- Single user lookup (`users:{user_id}:single`)
- Single user lookup (`users:{user_id}`)
- Users list cache (`users:list`)

### Users List Cache Invalidation
### Individual Cache Invalidation

For fine-grained control, you can invalidate specific cache namespaces
individually:

```python
from app.cache import invalidate_users_list_cache
from app.cache import (
invalidate_user_cache,
invalidate_users_list_cache,
)

# After creating/deleting users or changing roles
await db.commit()
# Clear user-specific caches only
await invalidate_user_cache(user.id)

# Clear users list only (after role changes, etc.)
await invalidate_users_list_cache()
```

This clears all paginated user list entries.

### API Keys Cache Invalidation

```python
Expand Down