-
-
Notifications
You must be signed in to change notification settings - Fork 14
Cache Last-Modified in Redis to avoid DB hits on conditional GETs #596
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,7 @@ | |
| from django.http import Http404 | ||
| from django.shortcuts import get_object_or_404 | ||
| from django.utils import timezone | ||
| from django.utils.http import parse_http_date_safe | ||
| from djmoney.money import Money | ||
| from drf_spectacular.utils import OpenApiParameter, extend_schema | ||
| from rest_framework import mixins, status, viewsets | ||
|
|
@@ -77,6 +78,7 @@ | |
| WishListItemReadSerializer, | ||
| WishListSerializer, | ||
| ) | ||
| from comicsdb.cache import get_last_modified, set_last_modified | ||
| from comicsdb.filters.collection import CollectionFilter | ||
| from comicsdb.filters.issue import IssueFilter | ||
| from comicsdb.filters.name import ComicVineFilter, NameFilter, UniverseFilter | ||
|
|
@@ -111,26 +113,54 @@ class ReadingListItemsPagination(PageNumberPagination): | |
|
|
||
|
|
||
| class CachedObjectMixin: | ||
| """Memoizes get_object() per request.""" | ||
|
|
||
| def get_object(self): | ||
| if not hasattr(self, "_cached_object"): | ||
| self._cached_object = super().get_object() | ||
|
|
||
| return self._cached_object | ||
|
|
||
|
|
||
| class ConditionalRetrieveModelMixin(CachedObjectMixin, mixins.RetrieveModelMixin): | ||
| def retrieve(self, request, *args, **kwargs): | ||
| retrieve = last_modified(last_modified_func=self._retrieve_last_modified)(super().retrieve) | ||
|
|
||
| return retrieve(self, request, *args, **kwargs) | ||
| class LastModifiedMixin(CachedObjectMixin): | ||
| """Supplies `_last_modified` via a plain DB fetch.""" | ||
|
|
||
| def _retrieve_last_modified(self, *args, **kwargs): | ||
| def _last_modified(self, *args, **kwargs): | ||
| obj = self.get_object() | ||
|
|
||
| if obj and getattr(obj, "modified", None): | ||
| return obj.modified | ||
| return getattr(obj, "modified", None) if obj else None | ||
|
|
||
|
|
||
| class CachedLastModifiedMixin(LastModifiedMixin): | ||
| """Answers conditional-GET checks from Redis, skipping the DB on a cache hit. | ||
|
|
||
| Only use on viewsets whose queryset isn't filtered by request.user - a cache | ||
| hit skips that filtering, which would leak other users' rows via a 304. | ||
| """ | ||
|
|
||
| def _last_modified(self, request, *args, **kwargs): | ||
| if_modified_since = parse_http_date_safe(request.META.get("HTTP_IF_MODIFIED_SINCE", "")) | ||
|
|
||
| if if_modified_since is not None: | ||
| pk = self.kwargs.get(self.lookup_url_kwarg or self.lookup_field) | ||
| cached = get_last_modified(self.get_queryset().model, pk) if pk else None | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This calls the viewset's overridden For
cached = get_last_modified(self.queryset.model, pk) if pk else None |
||
|
|
||
| if cached is not None and int(cached.timestamp()) <= if_modified_since: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On a cache hit, this returns the cached timestamp directly without ever calling There are two ways this can currently go stale and mask a
Both cases mean a conditional GET for that pk returns Worth considering whether the fast path should periodically re-verify via
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Following up on trigger 2 above (delete invalidation failure): the fix isn't to stop swallowing the exception entirely — there's no Sentry/ The more useful fix is in def _safe_delete_many(keys, *, retries=3):
"""Best-effort cache invalidation, retried because a failure here — unlike
_safe_get/_safe_set — has no self-healing path: the stale entry keeps
serving until it's naturally overwritten or the TTL expires.
"""
for attempt in range(1, retries + 1):
try:
cache.delete_many(keys)
return
except Exception: # noqa: BLE001
if attempt == retries:
LOGGER.error(
"Failed to invalidate cache keys %s after %d attempts; "
"stale entries will serve until TTL expiry (%ds)",
keys, retries, LAST_MODIFIED_CACHE_TTL, exc_info=True,
)
else:
LOGGER.warning(
"Retrying cache invalidation for %s (attempt %d/%d)",
keys, attempt, retries, exc_info=True,
) |
||
| return cached | ||
|
|
||
| dt = super()._last_modified(request, *args, **kwargs) | ||
|
|
||
| return None | ||
| if if_modified_since is not None and dt is not None: | ||
| set_last_modified(self.get_object()) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's no compare-and-swap here, so a slower request can overwrite a fresher value a concurrent writer already cached:
A client that already received
def set_last_modified(instance) -> None:
modified = getattr(instance, "modified", None)
if modified is None:
return
key = last_modified_cache_key(instance.__class__, instance.pk)
current = _safe_get(key)
if isinstance(current, int) and current >= int(modified.timestamp()):
return
_safe_set(key, int(modified.timestamp()), LAST_MODIFIED_CACHE_TTL)This doesn't close the race entirely — the get-then-set pair still isn't atomic — but it shrinks the window from "however long request processing takes" down to a single round trip, which should be enough given how rarely two writes to the same row land within milliseconds of each other. A fully atomic fix would need a Redis-native compare-and-set (e.g. a Lua script or |
||
|
|
||
| return dt | ||
|
|
||
|
|
||
| class ConditionalRetrieveModelMixin(LastModifiedMixin, mixins.RetrieveModelMixin): | ||
| def retrieve(self, request, *args, **kwargs): | ||
| retrieve = last_modified(last_modified_func=self._last_modified)(super().retrieve) | ||
|
|
||
| return retrieve(self, request, *args, **kwargs) | ||
|
|
||
|
|
||
| class UserTrackingMixin: | ||
|
|
@@ -143,7 +173,7 @@ def perform_update(self, serializer): | |
| serializer.save(edited_by=self.request.user) | ||
|
|
||
|
|
||
| class IssueListMixin(CachedObjectMixin): | ||
| class IssueListMixin(LastModifiedMixin): | ||
| """Mixin to provide a standard issue_list action for related models.""" | ||
|
|
||
| def get_issue_queryset(self, obj): | ||
|
|
@@ -155,9 +185,7 @@ def get_issue_queryset(self, obj): | |
| @extend_schema(responses={200: IssueListSerializer(many=True)}, filters=False) | ||
| @action(detail=True) | ||
| def issue_list(self, request, *args, **kwargs): | ||
| issue_list = last_modified(last_modified_func=self._issue_list_last_modified)( | ||
| self._issue_list | ||
| ) | ||
| issue_list = last_modified(last_modified_func=self._last_modified)(self._issue_list) | ||
|
|
||
| return issue_list(self, request, *args, **kwargs) | ||
|
|
||
|
|
@@ -171,16 +199,9 @@ def _issue_list(self, request, *args, **kwargs): | |
| return self.get_paginated_response(serializer.data) | ||
| raise Http404 | ||
|
|
||
| def _issue_list_last_modified(self, *args, **kwargs): | ||
| obj = self.get_object() | ||
|
|
||
| if obj and getattr(obj, "modified", None): | ||
| return obj.modified | ||
|
|
||
| return None | ||
|
|
||
|
|
||
| class ArcViewSet( | ||
| CachedLastModifiedMixin, | ||
| UserTrackingMixin, | ||
| IssueListMixin, | ||
| mixins.CreateModelMixin, | ||
|
|
@@ -212,6 +233,7 @@ def get_serializer_class(self): | |
|
|
||
|
|
||
| class CharacterViewSet( | ||
| CachedLastModifiedMixin, | ||
| UserTrackingMixin, | ||
| IssueListMixin, | ||
| mixins.CreateModelMixin, | ||
|
|
@@ -251,6 +273,7 @@ def get_serializer_class(self): | |
|
|
||
|
|
||
| class CreatorViewSet( | ||
| CachedLastModifiedMixin, | ||
| UserTrackingMixin, | ||
| mixins.CreateModelMixin, | ||
| ConditionalRetrieveModelMixin, | ||
|
|
@@ -301,6 +324,7 @@ def create(self, request, *args, **kwargs) -> Response: | |
|
|
||
|
|
||
| class ImprintViewSet( | ||
| CachedLastModifiedMixin, | ||
| UserTrackingMixin, | ||
| mixins.CreateModelMixin, | ||
| ConditionalRetrieveModelMixin, | ||
|
|
@@ -343,6 +367,7 @@ def get_serializer_class(self): | |
|
|
||
|
|
||
| class IssueViewSet( | ||
| CachedLastModifiedMixin, | ||
| UserTrackingMixin, | ||
| mixins.CreateModelMixin, | ||
| ConditionalRetrieveModelMixin, | ||
|
|
@@ -414,6 +439,7 @@ def get_serializer_class(self): | |
|
|
||
|
|
||
| class PublisherViewSet( | ||
| CachedLastModifiedMixin, | ||
| UserTrackingMixin, | ||
| mixins.CreateModelMixin, | ||
| ConditionalRetrieveModelMixin, | ||
|
|
@@ -479,6 +505,7 @@ class RoleViewset(mixins.ListModelMixin, viewsets.GenericViewSet): | |
|
|
||
|
|
||
| class SeriesViewSet( | ||
| CachedLastModifiedMixin, | ||
| UserTrackingMixin, | ||
| IssueListMixin, | ||
| mixins.CreateModelMixin, | ||
|
|
@@ -562,6 +589,7 @@ class SeriesTypeViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): | |
|
|
||
|
|
||
| class TeamViewSet( | ||
| CachedLastModifiedMixin, | ||
| UserTrackingMixin, | ||
| IssueListMixin, | ||
| mixins.CreateModelMixin, | ||
|
|
@@ -601,6 +629,7 @@ def get_serializer_class(self): | |
|
|
||
|
|
||
| class UniverseViewSet( | ||
| CachedLastModifiedMixin, | ||
| UserTrackingMixin, | ||
| mixins.CreateModelMixin, | ||
| ConditionalRetrieveModelMixin, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ | |
| from django.db.models.signals import m2m_changed, post_delete, post_save, pre_delete | ||
|
|
||
| from comicsdb.signals import ( | ||
| post_delete_last_modified, | ||
| pre_delete_credit, | ||
| pre_delete_image, | ||
| update_arc_modified, | ||
|
|
@@ -65,3 +66,14 @@ def ready(self): | |
|
|
||
| credits_ = self.get_model("Credits") | ||
| pre_delete.connect(pre_delete_credit, sender=credits_, dispatch_uid="pre_delete_credits") | ||
|
|
||
| # Clear the cache entry on delete, so a removed row 404s instead of 304ing. | ||
| from comicsdb.models.common import LastModifiedCacheMixin # noqa: PLC0415 | ||
|
|
||
| for model in self.get_models(): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Every other signal connection in That's a reasonable trade-off, not a bug — the loop is what lets a 10th cached model get wired automatically without an Worth extending the existing comment on the line above to say so explicitly, e.g.: # Clear the cache entry on delete, so a removed row 404s instead of 304ing.
# Auto-registered (unlike the explicit connects above) so new LastModifiedCacheMixin
# models don't need an apps.py edit to get invalidation wired up.
from comicsdb.models.common import LastModifiedCacheMixin # noqa: PLC0415
for model in self.get_models():
if issubclass(model, LastModifiedCacheMixin):
post_delete.connect(
post_delete_last_modified,
sender=model,
dispatch_uid=f"post_delete_last_modified_{model._meta.model_name}",
) |
||
| if issubclass(model, LastModifiedCacheMixin): | ||
| post_delete.connect( | ||
| post_delete_last_modified, | ||
| sender=model, | ||
| dispatch_uid=f"post_delete_last_modified_{model._meta.model_name}", | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| """Redis-backed cache of each cacheable model's `modified` timestamp.""" | ||
|
|
||
| import logging | ||
| from datetime import UTC, datetime | ||
|
|
||
| from django.core.cache import cache | ||
|
|
||
| LOGGER = logging.getLogger(__name__) | ||
|
|
||
| LAST_MODIFIED_CACHE_TTL = 60 * 60 * 24 * 30 # 30 days | ||
|
|
||
|
|
||
| def last_modified_cache_key(model, pk) -> str: | ||
| return f"modified:{model._meta.label_lower}:{pk}" | ||
|
|
||
|
|
||
| def _safe_get(key): | ||
| try: | ||
| return cache.get(key) | ||
| except Exception: # noqa: BLE001 | ||
| LOGGER.warning("Failed to read cache key %s", key, exc_info=True) | ||
| return None | ||
|
|
||
|
|
||
| def _safe_set(key, value, timeout): | ||
| try: | ||
| cache.set(key, value, timeout) | ||
| except Exception: # noqa: BLE001 | ||
| LOGGER.warning("Failed to write cache key %s", key, exc_info=True) | ||
|
|
||
|
|
||
| def _safe_delete_many(keys): | ||
| try: | ||
| cache.delete_many(keys) | ||
| except Exception: # noqa: BLE001 | ||
| LOGGER.warning("Failed to delete cache keys %s", keys, exc_info=True) | ||
|
|
||
|
|
||
| def get_last_modified(model, pk) -> datetime | None: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
(Django's Splitting the epoch read from the datetime conversion would remove the redundant round trip and let the comparison happen entirely in int space, matching def get_last_modified_epoch(model, pk) -> int | None:
"""Cached `modified` for `model`/`pk` as epoch seconds, or None on a miss."""
value = _safe_get(last_modified_cache_key(model, pk))
return value if isinstance(value, int) else None
def get_last_modified(model, pk) -> datetime | None:
"""Cached `modified` for `model`/`pk`, or None on a miss."""
epoch = get_last_modified_epoch(model, pk)
return datetime.fromtimestamp(epoch, tz=UTC) if epoch is not None else None
if if_modified_since is not None:
pk = self.kwargs.get(self.lookup_url_kwarg or self.lookup_field)
cached_epoch = get_last_modified_epoch(self.queryset.model, pk) if pk else None
if cached_epoch is not None and cached_epoch <= if_modified_since:
return datetime.fromtimestamp(cached_epoch, tz=UTC)(assumes the |
||
| """Cached `modified` for `model`/`pk`, or None on a miss.""" | ||
| value = _safe_get(last_modified_cache_key(model, pk)) | ||
|
|
||
| if not isinstance(value, int): | ||
| return None | ||
|
|
||
| return datetime.fromtimestamp(value, tz=UTC) | ||
|
|
||
|
|
||
| def set_last_modified(instance) -> None: | ||
| """Write-through cache update for a model instance.""" | ||
| modified = getattr(instance, "modified", None) | ||
|
|
||
| if modified is None: | ||
| return | ||
|
|
||
| key = last_modified_cache_key(instance.__class__, instance.pk) | ||
| _safe_set(key, int(modified.timestamp()), LAST_MODIFIED_CACHE_TTL) | ||
|
|
||
|
|
||
| def delete_last_modified(model, pk) -> None: | ||
| _safe_delete_many([last_modified_cache_key(model, pk)]) | ||
|
|
||
|
|
||
| def delete_last_modified_many(model, pks) -> None: | ||
| _safe_delete_many([last_modified_cache_key(model, pk) for pk in pks]) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ | |
| from django.db.models.functions import Now | ||
| from django.utils.text import slugify | ||
|
|
||
| from comicsdb.cache import set_last_modified | ||
|
|
||
| MIN_RATING = 1 | ||
| MAX_RATING = 5 | ||
| RATING_CHOICES = [(i, str(i)) for i in range(MIN_RATING, MAX_RATING + 1)] | ||
|
|
@@ -37,6 +39,20 @@ def pre_save_slug(sender, instance, **kwargs): | |
| instance.slug = generate_slug_from_name(instance) | ||
|
|
||
|
|
||
| class LastModifiedCacheMixin(models.Model): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There are currently two independent, uncoordinated mechanisms keeping this cache in sync with the DB: the The exact same Rather than relying on remembering to pair every class LastModifiedQuerySet(models.QuerySet):
"""Auto-invalidates the Redis cache for any bulk .update() on a
LastModifiedCacheMixin model, since .update() bypasses save()."""
def update(self, **kwargs):
pks = frozenset(self.values_list("pk", flat=True))
rows_updated = super().update(**kwargs)
if pks:
transaction.on_commit(
lambda model=self.model, pks=pks: delete_last_modified_many(model, pks)
)
return rows_updated
class LastModifiedCacheMixin(models.Model):
"""Writes `modified` to comicsdb.cache on every save(), and invalidates it
on every .update() (which bypasses save() and would otherwise leave a
stale entry with no self-healing path).
Use only on models whose viewset reads it via CachedLastModifiedMixin.
"""
objects = LastModifiedQuerySet.as_manager()
class Meta:
abstract = True
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
set_last_modified(self)This closes the gap for any future One prerequisite: |
||
| """Writes `modified` to comicsdb.cache on every save(). | ||
|
|
||
| Use only on models whose viewset reads it via CachedLastModifiedMixin. | ||
| """ | ||
|
|
||
| class Meta: | ||
| abstract = True | ||
|
|
||
| def save(self, *args, **kwargs): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If this save happens inside a Suggest matching the pattern used elsewhere in the PR: def save(self, *args, **kwargs):
super().save(*args, **kwargs)
transaction.on_commit(lambda: set_last_modified(self))
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Beyond the transaction-ordering issue above: even on a fully committed save, if this That makes this failure mode different from a cache miss: a miss self-heals on the next conditional GET ( Given that, |
||
| super().save(*args, **kwargs) | ||
| set_last_modified(self) | ||
|
|
||
|
|
||
| class CommonInfo(models.Model): | ||
| name = models.CharField(max_length=255) | ||
| slug = models.SlugField(max_length=255, unique=True) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The only thing preventing this mixin from being used on a per-user-filtered viewset is this docstring — there's no runtime check enforcing it structurally.
ReadingListViewSet(below, line 668) is a concrete near-miss: itsget_queryset()filters byself.request.user(public lists + own lists), which is exactly the shape this docstring warns about, and it currently avoids the mixin correctly. But nothing stops a future PR from copy-pasting the pattern fromSeriesViewSet/ArcViewSetonto it — it already supports conditional GET, so it's a very plausible next candidate. If that happens, it silently reintroduces the exact cross-user 304 leak thattest_conditional_request_cannot_leak_other_users_item(tests/user_collection/test_api_collection.py:85) was written to catch forCollectionViewSetspecifically — but that test doesn't generalize to a new viewset.Rather than relying on every future contributor reading this docstring, the mixin could require each subclass to explicitly declare that it's safe, and fail loudly at class-definition time (i.e. at Django startup, since
urls.pyimports every viewset) if that declaration is missing or wrong:Note this is a two-part change, not a drop-in mixin edit: adding
__init_subclass__alone would break the app at startup, since none of the 9 current viewsets (ArcViewSet,CharacterViewSet,CreatorViewSet,ImprintViewSet,IssueViewSet,PublisherViewSet,SeriesViewSet,TeamViewSet,UniverseViewSet) declare the attribute yet. Each of them would also need one added line:queryset_is_user_scoped = False. That's a small one-time cost, but it converts "silently reintroduces a data leak" into "the app fails to start with a clear message" the moment someone adds the mixin to a user-scoped viewset — a much stronger guarantee than a docstring, and one that doesn't depend on a test being written or run.