Browse Source
feat(cache): implement automatic cache invalidation and smart cache bypass
feat(cache): implement automatic cache invalidation and smart cache bypass
- Add utils/cache.py with invalidate_cache_patterns and smart_cached_view - Support cache bypass on Cache-Control: no-cache headers and query params - Add automatic signals (post_save, post_delete, m2m_changed) for library, video, podcast, article, calendar, and hadis - Hook cache invalidation in custom admin viewset actionsmaster
22 changed files with 309 additions and 104 deletions
-
4apps/article/apps.py
-
39apps/article/signals.py
-
23apps/article/urls.py
-
2apps/article/views_admin.py
-
4apps/dobodbi_calendar/apps.py
-
20apps/dobodbi_calendar/signals.py
-
8apps/dobodbi_calendar/urls.py
-
26apps/hadis/signals.py
-
8apps/hadis/urls.py
-
4apps/library/apps.py
-
27apps/library/signals.py
-
21apps/library/urls.py
-
4apps/library/views_admin.py
-
3apps/podcast/apps.py
-
41apps/podcast/signals.py
-
26apps/podcast/urls.py
-
7apps/podcast/views_admin.py
-
4apps/video/apps.py
-
41apps/video/signals.py
-
23apps/video/urls.py
-
7apps/video/views_admin.py
-
69utils/cache.py
@ -0,0 +1,39 @@ |
|||
from django.db.models.signals import post_save, post_delete, m2m_changed |
|||
from django.dispatch import receiver |
|||
from utils.cache import invalidate_cache_patterns |
|||
from .models import ( |
|||
Article, |
|||
ArticleCategory, |
|||
ArticleCollection, |
|||
ArticleContent, |
|||
ArticleInCollection, |
|||
) |
|||
|
|||
TARGET_MODELS = [ |
|||
ArticleCategory, |
|||
ArticleCollection, |
|||
Article, |
|||
ArticleContent, |
|||
ArticleInCollection, |
|||
] |
|||
|
|||
|
|||
def invalidate_article_cache(): |
|||
""" |
|||
Invalidates all Redis cache keys for article API views. |
|||
""" |
|||
return invalidate_cache_patterns("*article_api*", "*article_list*") |
|||
|
|||
|
|||
@receiver(post_save) |
|||
@receiver(post_delete) |
|||
def clear_article_cache_on_save_or_delete(sender, instance, **kwargs): |
|||
if sender in TARGET_MODELS: |
|||
invalidate_article_cache() |
|||
|
|||
|
|||
@receiver(m2m_changed) |
|||
def clear_article_cache_on_m2m(sender, instance, action, **kwargs): |
|||
if action in ('post_add', 'post_remove', 'post_clear'): |
|||
if isinstance(instance, (Article, ArticleCollection, ArticleCategory)): |
|||
invalidate_article_cache() |
|||
@ -0,0 +1,20 @@ |
|||
from django.db.models.signals import post_save, post_delete |
|||
from django.dispatch import receiver |
|||
from utils.cache import invalidate_cache_patterns |
|||
from .models import CalendarOccasions |
|||
|
|||
TARGET_MODELS = [CalendarOccasions] |
|||
|
|||
|
|||
def invalidate_calendar_cache(): |
|||
""" |
|||
Invalidates all Redis cache keys for calendar API views. |
|||
""" |
|||
return invalidate_cache_patterns("*calendar_api*") |
|||
|
|||
|
|||
@receiver(post_save) |
|||
@receiver(post_delete) |
|||
def clear_calendar_cache_on_save_or_delete(sender, instance, **kwargs): |
|||
if sender in TARGET_MODELS: |
|||
invalidate_calendar_cache() |
|||
@ -0,0 +1,27 @@ |
|||
from django.db.models.signals import post_save, post_delete, m2m_changed |
|||
from django.dispatch import receiver |
|||
from utils.cache import invalidate_cache_patterns |
|||
from .models import Author, Book, BookCollection, Category |
|||
|
|||
TARGET_MODELS = [Author, Book, BookCollection, Category] |
|||
|
|||
|
|||
def invalidate_library_cache(): |
|||
""" |
|||
Invalidates all Redis cache keys for library API views. |
|||
""" |
|||
return invalidate_cache_patterns("*library_api*", "*library_books*") |
|||
|
|||
|
|||
@receiver(post_save) |
|||
@receiver(post_delete) |
|||
def clear_library_cache_on_save_or_delete(sender, instance, **kwargs): |
|||
if sender in TARGET_MODELS: |
|||
invalidate_library_cache() |
|||
|
|||
|
|||
@receiver(m2m_changed) |
|||
def clear_library_cache_on_m2m(sender, instance, action, **kwargs): |
|||
if action in ('post_add', 'post_remove', 'post_clear'): |
|||
if isinstance(instance, (Book, BookCollection, Category)): |
|||
invalidate_library_cache() |
|||
@ -0,0 +1,41 @@ |
|||
from django.db.models.signals import post_save, post_delete, m2m_changed |
|||
from django.dispatch import receiver |
|||
from utils.cache import invalidate_cache_patterns |
|||
from .models import ( |
|||
PlaylistItem, |
|||
Podcast, |
|||
PodcastCategory, |
|||
PodcastCollection, |
|||
PodcastPlaylist, |
|||
PodcastPlaylistInCollection, |
|||
) |
|||
|
|||
TARGET_MODELS = [ |
|||
PodcastCategory, |
|||
PodcastCollection, |
|||
Podcast, |
|||
PodcastPlaylist, |
|||
PodcastPlaylistInCollection, |
|||
PlaylistItem, |
|||
] |
|||
|
|||
|
|||
def invalidate_podcast_cache(): |
|||
""" |
|||
Invalidates all Redis cache keys for podcast API views. |
|||
""" |
|||
return invalidate_cache_patterns("*podcast_api*", "*podcast_list*") |
|||
|
|||
|
|||
@receiver(post_save) |
|||
@receiver(post_delete) |
|||
def clear_podcast_cache_on_save_or_delete(sender, instance, **kwargs): |
|||
if sender in TARGET_MODELS: |
|||
invalidate_podcast_cache() |
|||
|
|||
|
|||
@receiver(m2m_changed) |
|||
def clear_podcast_cache_on_m2m(sender, instance, action, **kwargs): |
|||
if action in ('post_add', 'post_remove', 'post_clear'): |
|||
if isinstance(instance, (Podcast, PodcastPlaylist, PodcastCollection, PodcastCategory)): |
|||
invalidate_podcast_cache() |
|||
@ -0,0 +1,41 @@ |
|||
from django.db.models.signals import post_save, post_delete, m2m_changed |
|||
from django.dispatch import receiver |
|||
from utils.cache import invalidate_cache_patterns |
|||
from .models import ( |
|||
PlaylistItem, |
|||
Video, |
|||
VideoCategory, |
|||
VideoCollection, |
|||
VideoPlaylist, |
|||
VideoPlaylistInCollection, |
|||
) |
|||
|
|||
TARGET_MODELS = [ |
|||
VideoCategory, |
|||
VideoCollection, |
|||
Video, |
|||
VideoPlaylist, |
|||
VideoPlaylistInCollection, |
|||
PlaylistItem, |
|||
] |
|||
|
|||
|
|||
def invalidate_video_cache(): |
|||
""" |
|||
Invalidates all Redis cache keys for video API views. |
|||
""" |
|||
return invalidate_cache_patterns("*video_api*", "*video_playlists*", "*video_list*") |
|||
|
|||
|
|||
@receiver(post_save) |
|||
@receiver(post_delete) |
|||
def clear_video_cache_on_save_or_delete(sender, instance, **kwargs): |
|||
if sender in TARGET_MODELS: |
|||
invalidate_video_cache() |
|||
|
|||
|
|||
@receiver(m2m_changed) |
|||
def clear_video_cache_on_m2m(sender, instance, action, **kwargs): |
|||
if action in ('post_add', 'post_remove', 'post_clear'): |
|||
if isinstance(instance, (Video, VideoPlaylist, VideoCollection, VideoCategory)): |
|||
invalidate_video_cache() |
|||
@ -0,0 +1,69 @@ |
|||
from functools import wraps |
|||
import logging |
|||
from django.core.cache import cache |
|||
from django.views.decorators.cache import cache_page |
|||
from django.views.decorators.vary import vary_on_headers |
|||
|
|||
logger = logging.getLogger(__name__) |
|||
|
|||
|
|||
def invalidate_cache_patterns(*patterns): |
|||
""" |
|||
Safely invalidates Redis cache keys matching given glob patterns. |
|||
Falls back to cache.clear() if the cache backend does not support delete_pattern. |
|||
""" |
|||
try: |
|||
if hasattr(cache, 'delete_pattern'): |
|||
total_deleted = 0 |
|||
for pattern in patterns: |
|||
deleted = cache.delete_pattern(pattern) |
|||
total_deleted += deleted or 0 |
|||
logger.info(f"Cache cleared for patterns {patterns} (deleted {total_deleted} keys)") |
|||
return total_deleted |
|||
else: |
|||
cache.clear() |
|||
logger.info(f"Cache cleared (fallback clear for patterns: {patterns})") |
|||
return 1 |
|||
except Exception as e: |
|||
logger.error(f"Cache invalidation failed for patterns {patterns}: {e}") |
|||
return 0 |
|||
|
|||
|
|||
def smart_cached_view(timeout=60 * 60 * 2, key_prefix=''): |
|||
""" |
|||
Smart cache decorator for API views that: |
|||
1. Caches responses per Accept-Language header using Django's cache_page. |
|||
2. Bypasses cache when: |
|||
- Cache-Control / Pragma: no-cache headers are present (e.g., Pull-to-refresh from mobile / web). |
|||
- Query parameters: no_cache=true, refresh=true, is_bookmark=true, in_playlist=true are passed. |
|||
""" |
|||
def decorator(view_func): |
|||
cached_func = cache_page(timeout, key_prefix=key_prefix)( |
|||
vary_on_headers('Accept-Language')(view_func) |
|||
) |
|||
|
|||
@wraps(view_func) |
|||
def _wrapped_view(request, *args, **kwargs): |
|||
# 1. Check HTTP headers for no-cache directives |
|||
cache_control = (request.META.get('HTTP_CACHE_CONTROL') or '').lower() |
|||
pragma = (request.META.get('HTTP_PRAGMA') or '').lower() |
|||
if 'no-cache' in cache_control or 'no-store' in cache_control or 'max-age=0' in cache_control or 'no-cache' in pragma: |
|||
return view_func(request, *args, **kwargs) |
|||
|
|||
# 2. Check query params for explicit bypass |
|||
query_params = getattr(request, 'GET', {}) |
|||
no_cache_param = (query_params.get('no_cache') or query_params.get('refresh') or '').lower() |
|||
if no_cache_param in ('1', 'true'): |
|||
return view_func(request, *args, **kwargs) |
|||
|
|||
# 3. Check bookmark & playlist specific bypasses |
|||
is_bookmark = (query_params.get('is_bookmark') or query_params.get('is_bookmarked') or '').lower() |
|||
in_playlist = (query_params.get('in_playlist') or '').lower() |
|||
if is_bookmark in ('1', 'true') or in_playlist in ('1', 'true'): |
|||
return view_func(request, *args, **kwargs) |
|||
|
|||
return cached_func(request, *args, **kwargs) |
|||
|
|||
return _wrapped_view |
|||
|
|||
return decorator |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue