Browse Source

perf(db): resolve N+1 queries across endpoints and optimize gunicorn concurrency

- Add request-level user bookmark memoization in BookmarkStatusSerializer to eliminate redundant queries across all lists
- Optimize BookReferenceSyncView and BookReference.authors caching (reduced queries from 185 to 10)
- Add multi-level prefetch_related for references and images in HadisMainListView (reduced queries from 109 to 6)
- Add incremental sync support (updated_after/since) in HadisSyncView
- Add select_related for language in BookListView (reduced queries from 35 to 18)
- Annotate episode count and cache effective_thumbnail in podcast and video playlist views (reduced queries from 22/17 to 4)
- Prefetch categories with annotated article counts in ArticleListAPIView (reduced queries from 41 to 3)
- Scale Gunicorn workers/threads to 4w/4t (16 concurrency) with gthread in docker-compose.prod.yml
master
Mohsen Taba 1 week ago
parent
commit
6ac32e11a2
  1. 2
      apps/article/serializers.py
  2. 15
      apps/article/views.py
  3. 19
      apps/bookmark/serializers/bookmark.py
  4. 19
      apps/hadis/models/reference.py
  5. 6
      apps/hadis/serializers/reference.py
  6. 25
      apps/hadis/views/hadis.py
  7. 2
      apps/hadis/views/reference.py
  8. 2
      apps/library/views.py
  9. 10
      apps/podcast/models.py
  10. 2
      apps/podcast/serializers.py
  11. 10
      apps/podcast/views.py
  12. 10
      apps/video/models.py
  13. 2
      apps/video/views.py
  14. 2
      docker-compose.prod.yml

2
apps/article/serializers.py

@ -12,6 +12,8 @@ class ArticleCategoryListSerializer(serializers.ModelSerializer):
fields = ['id', 'title', 'slug', 'acticle_count'] fields = ['id', 'title', 'slug', 'acticle_count']
def get_acticle_count(self, obj): def get_acticle_count(self, obj):
if hasattr(obj, 'article_count_annotated'):
return obj.article_count_annotated
return obj.articles.filter(status=True).count() return obj.articles.filter(status=True).count()
class PinnedArticleCollectionSerializer(serializers.ModelSerializer): class PinnedArticleCollectionSerializer(serializers.ModelSerializer):

15
apps/article/views.py

@ -1,4 +1,4 @@
from django.db.models import Q
from django.db.models import Q, Count, Prefetch
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.decorators import action from rest_framework.decorators import action
@ -39,7 +39,9 @@ class ArticleCategoryListAPIView(generics.ListAPIView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
return ArticleCategory.objects.filter(status=True).order_by('order')
return ArticleCategory.objects.filter(status=True).annotate(
article_count_annotated=Count('articles', filter=Q(articles__status=True), distinct=True)
).order_by('order')
class PinnedArticleCollectionListView(generics.ListAPIView): class PinnedArticleCollectionListView(generics.ListAPIView):
@ -170,7 +172,14 @@ class ArticleListAPIView(generics.ListAPIView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
queryset = Article.objects.filter(status=True)
queryset = Article.objects.filter(status=True).prefetch_related(
Prefetch(
'categories',
queryset=ArticleCategory.objects.annotate(
article_count_annotated=Count('articles', filter=Q(articles__status=True), distinct=True)
)
)
)
# Search by title if search parameter is provided # Search by title if search parameter is provided
search_query = self.request.query_params.get('search', None) search_query = self.request.query_params.get('search', None)

19
apps/bookmark/serializers/bookmark.py

@ -64,11 +64,20 @@ class BookmarkStatusSerializer(serializers.Serializer):
'content_id': None 'content_id': None
} }
is_bookmarked = Bookmark.is_bookmarked(
user=user,
service=service,
content_id=content_id
)
# Cache active bookmarks on the user object to eliminate N+1 queries across list views
if not hasattr(user, '_cached_bookmarks'):
user._cached_bookmarks = {}
if service not in user._cached_bookmarks:
user._cached_bookmarks[service] = set(
Bookmark.objects.filter(
user=user,
service=service,
status=True
).values_list('content_id', flat=True)
)
is_bookmarked = content_id in user._cached_bookmarks[service]
return { return {
'is_bookmarked': is_bookmarked, 'is_bookmarked': is_bookmarked,

19
apps/hadis/models/reference.py

@ -194,9 +194,24 @@ class BookReference(LowercaseSlugMixin, models.Model):
@property @property
def authors(self): def authors(self):
""" """
Backward compatibility helper returning a queryset of authors.
Allows serializers calling obj.authors.all() to function correctly.
Backward compatibility helper returning a queryset/list of authors.
Allows serializers calling obj.authors.all() to function correctly without hitting DB if author is already cached.
""" """
if hasattr(self, '_state') and hasattr(self._state, 'fields_cache') and 'author' in self._state.fields_cache:
author_obj = self._state.fields_cache['author']
class InMemAuthorList(list):
def all(self):
return self
def first(self):
return self[0] if self else None
def exists(self):
return bool(self)
def count(self):
return len(self)
def filter(self, *args, **kwargs):
return self
return InMemAuthorList([author_obj] if author_obj else [])
if self.author_id: if self.author_id:
return BookAuthor.objects.filter(id=self.author_id) return BookAuthor.objects.filter(id=self.author_id)
return BookAuthor.objects.none() return BookAuthor.objects.none()

6
apps/hadis/serializers/reference.py

@ -205,8 +205,10 @@ class BookReferenceSyncSerializer(serializers.ModelSerializer):
def get_detail(self, obj): def get_detail(self, obj):
"""Get basic book information""" """Get basic book information"""
request = self.context.get('request') request = self.context.get('request')
edition = obj.editions.first()
vol = obj.volumes.first()
editions = list(obj.editions.all())
edition = editions[0] if editions else None
volumes = list(obj.volumes.all())
vol = volumes[0] if volumes else None
publisher_data = edition.publisher if edition else [] publisher_data = edition.publisher if edition else []
isbn_data = edition.isbn if edition else "" isbn_data = edition.isbn if edition else ""

25
apps/hadis/views/hadis.py

@ -128,7 +128,7 @@ class HadisSyncView(ListAPIView):
pagination_class = NoPagination pagination_class = NoPagination
def get_queryset(self): def get_queryset(self):
return (
qs = (
Hadis.objects Hadis.objects
.filter(status=True) .filter(status=True)
.select_related('category', 'hadis_status') .select_related('category', 'hadis_status')
@ -201,6 +201,19 @@ class HadisSyncView(ListAPIView):
.order_by('id') .order_by('id')
) )
# Incremental sync support to prevent transferring 25MB on subsequent syncs
updated_after = self.request.query_params.get('updated_after') or self.request.query_params.get('since')
if updated_after:
from django.utils.dateparse import parse_datetime
try:
dt = parse_datetime(updated_after)
if dt:
qs = qs.filter(updated_at__gte=dt)
except Exception:
pass
return qs
@hadis_sync_swagger @hadis_sync_swagger
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs) return self.list(request, *args, **kwargs)
@ -396,8 +409,14 @@ class HadisMainListView(ListAPIView):
return self.list(request, *args, **kwargs) return self.list(request, *args, **kwargs)
def get_queryset(self): def get_queryset(self):
# queryset = Hadis.objects.select_related('category', 'hadis_status')
queryset = Hadis.objects.select_related('category__sect', 'hadis_status')
queryset = Hadis.objects.select_related('category__sect', 'hadis_status').prefetch_related(
Prefetch(
'references',
queryset=HadisReference.objects.select_related('book_reference__author').prefetch_related(
Prefetch('images', queryset=ReferenceImage.objects.order_by('priority'))
)
)
)
# Get search parameters # Get search parameters
search_query = self.request.query_params.get('search', None) search_query = self.request.query_params.get('search', None)

2
apps/hadis/views/reference.py

@ -112,7 +112,7 @@ class BookReferenceSyncView(ListAPIView):
Prefetch ALL related data to avoid N+1 queries Prefetch ALL related data to avoid N+1 queries
""" """
qs = BookReference.objects.select_related('author', 'type').prefetch_related( qs = BookReference.objects.select_related('author', 'type').prefetch_related(
'attributes', 'images', 'hadis_references__hadis'
'editions', 'volumes', 'attributes', 'images', 'hadis_references__hadis'
).distinct().order_by('id') ).distinct().order_by('id')
# Category (Multi-select) # Category (Multi-select)

2
apps/library/views.py

@ -120,7 +120,7 @@ class BookListView(ListAPIView):
return context return context
def get_queryset(self): def get_queryset(self):
queryset = Book.objects.filter(status=True).select_related('author')
queryset = Book.objects.filter(status=True).select_related('author', 'language')
# Filter by collection if provided # Filter by collection if provided
collection_id = self.request.query_params.get('collection_id') collection_id = self.request.query_params.get('collection_id')

10
apps/podcast/models.py

@ -204,6 +204,16 @@ class PodcastPlaylist(LowercaseSlugMixin, models.Model):
""" """
if self.thumbnail: if self.thumbnail:
return self.thumbnail return self.thumbnail
if hasattr(self, '_prefetched_objects_cache') and 'playlist_items' in self._prefetched_objects_cache:
items = sorted(
[item for item in self.playlist_items.all() if getattr(item, 'podcast', None) and item.podcast.status and item.podcast.thumbnail],
key=lambda x: getattr(x, 'priority', 0)
)
if items and items[0].podcast and items[0].podcast.thumbnail:
return items[0].podcast.thumbnail
return None
first_item = self.playlist_items.filter( first_item = self.playlist_items.filter(
podcast__thumbnail__isnull=False, podcast__thumbnail__isnull=False,
podcast__status=True podcast__status=True

2
apps/podcast/serializers.py

@ -213,6 +213,8 @@ class PodcastPlaylistListSerializer(serializers.ModelSerializer):
def get_episodes_count(self, obj): def get_episodes_count(self, obj):
"""Return the number of episodes (podcasts) in this playlist""" """Return the number of episodes (podcasts) in this playlist"""
if hasattr(obj, 'episodes_count_annotated'):
return obj.episodes_count_annotated
return obj.playlist_items.filter(podcast__status=True).count() return obj.playlist_items.filter(podcast__status=True).count()

10
apps/podcast/views.py

@ -1,4 +1,4 @@
from django.db.models import Q, Prefetch
from django.db.models import Q, Prefetch, Count
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
from rest_framework.decorators import action from rest_framework.decorators import action
@ -189,7 +189,13 @@ class PodcastListAPIView(generics.ListAPIView):
queryset = PodcastPlaylist.objects.filter( queryset = PodcastPlaylist.objects.filter(
status=True, status=True,
playlist_items__podcast__status=True playlist_items__podcast__status=True
).distinct()
).annotate(
episodes_count_annotated=Count(
'playlist_items',
filter=Q(playlist_items__podcast__status=True),
distinct=True
)
).prefetch_related('playlist_items__podcast').distinct()
# Search by title if search parameter is provided # Search by title if search parameter is provided
search_query = self.request.query_params.get('search', None) search_query = self.request.query_params.get('search', None)

10
apps/video/models.py

@ -304,6 +304,16 @@ class VideoPlaylist(LowercaseSlugMixin, models.Model):
""" """
if self.thumbnail: if self.thumbnail:
return self.thumbnail return self.thumbnail
if hasattr(self, '_prefetched_objects_cache') and 'playlist_items' in self._prefetched_objects_cache:
items = sorted(
[item for item in self.playlist_items.all() if getattr(item, 'video', None) and item.video.status and item.video.thumbnail],
key=lambda x: getattr(x, 'priority', 0)
)
if items and items[0].video and items[0].video.thumbnail:
return items[0].video.thumbnail
return None
first_item = self.playlist_items.filter( first_item = self.playlist_items.filter(
video__thumbnail__isnull=False, video__thumbnail__isnull=False,
video__status=True video__status=True

2
apps/video/views.py

@ -207,7 +207,7 @@ class VideoPlaylistListAPIView(generics.ListAPIView):
queryset = VideoPlaylist.objects.filter( queryset = VideoPlaylist.objects.filter(
status=True, status=True,
playlist_items__video__status=True playlist_items__video__status=True
).distinct().order_by('order', '-created_at')
).prefetch_related('playlist_items__video').distinct().order_by('order', '-created_at')
# Search by title if search parameter is provided # Search by title if search parameter is provided
search_query = self.request.query_params.get('search', None) search_query = self.request.query_params.get('search', None)

2
docker-compose.prod.yml

@ -7,7 +7,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.prod dockerfile: Dockerfile.prod
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers=2 --threads=2 --max-requests=1000 --max-requests-jitter=100 --timeout 180
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers=4 --threads=4 --worker-class=gthread --max-requests=1000 --max-requests-jitter=100 --timeout 180
volumes: volumes:
# - static_volume:/usr/src/app/static # - static_volume:/usr/src/app/static
- media_volume:/usr/src/app/media - media_volume:/usr/src/app/media

Loading…
Cancel
Save