Enhance performance by prefetching more

This commit is contained in:
Joakim Hellsén 2026-04-10 22:32:38 +02:00
commit fb087a01c0
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
6 changed files with 104 additions and 12 deletions

View file

@ -1,7 +1,9 @@
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.db import connection
from django.test import TestCase from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@ -16,6 +18,75 @@ if TYPE_CHECKING:
class ChzzkDashboardViewTests(TestCase): class ChzzkDashboardViewTests(TestCase):
"""Test cases for the dashboard view of the chzzk app.""" """Test cases for the dashboard view of the chzzk app."""
def test_dashboard_view_no_n_plus_one_on_rewards(self) -> None:
"""Test that the dashboard view does not trigger an N+1 query for rewards."""
now = timezone.now()
base_kwargs = {
"category_type": "game",
"category_id": "1",
"category_value": "TestGame",
"service_id": "chzzk",
"state": "ACTIVE",
"start_date": now - timedelta(days=1),
"end_date": now + timedelta(days=1),
"has_ios_based_reward": False,
"drops_campaign_not_started": False,
"source_api": "unit-test",
}
reward_kwargs = {
"reward_type": "ITEM",
"campaign_reward_type": "Standard",
"condition_type": "watch",
"ios_based_reward": False,
"code_remaining_count": 100,
}
campaign1 = ChzzkCampaign.objects.create(
campaign_no=9001,
title="C1",
**base_kwargs,
)
campaign1.rewards.create( # pyright: ignore[reportAttributeAccessIssue]
reward_no=901,
title="R1",
condition_for_minutes=10,
**reward_kwargs,
) # pyright: ignore[reportAttributeAccessIssue]
campaign2 = ChzzkCampaign.objects.create(
campaign_no=9002,
title="C2",
**base_kwargs,
)
campaign2.rewards.create( # pyright: ignore[reportAttributeAccessIssue]
reward_no=902,
title="R2",
condition_for_minutes=20,
**reward_kwargs,
) # pyright: ignore[reportAttributeAccessIssue]
with CaptureQueriesContext(connection) as one_campaign_ctx:
self.client.get(reverse("chzzk:dashboard"))
query_count_two = len(one_campaign_ctx)
campaign3 = ChzzkCampaign.objects.create(
campaign_no=9003,
title="C3",
**base_kwargs,
)
campaign3.rewards.create( # pyright: ignore[reportAttributeAccessIssue]
reward_no=903,
title="R3",
condition_for_minutes=30,
**reward_kwargs,
) # pyright: ignore[reportAttributeAccessIssue]
with CaptureQueriesContext(connection) as three_campaign_ctx:
self.client.get(reverse("chzzk:dashboard"))
query_count_three = len(three_campaign_ctx)
# With prefetch_related, adding more campaigns should not add extra queries per campaign.
assert query_count_two == query_count_three
def test_dashboard_view_excludes_testing_state_campaigns(self) -> None: def test_dashboard_view_excludes_testing_state_campaigns(self) -> None:
"""Test that the dashboard view excludes campaigns in the TESTING state.""" """Test that the dashboard view excludes campaigns in the TESTING state."""
now: datetime = timezone.now() now: datetime = timezone.now()

View file

@ -34,6 +34,7 @@ def dashboard_view(request: HttpRequest) -> HttpResponse:
models.ChzzkCampaign.objects models.ChzzkCampaign.objects
.filter(end_date__gte=timezone.now()) .filter(end_date__gte=timezone.now())
.exclude(state="TESTING") .exclude(state="TESTING")
.prefetch_related("rewards")
.order_by("-start_date") .order_by("-start_date")
) )
return render( return render(

View file

@ -224,6 +224,10 @@ DATABASES: dict[str, dict[str, Any]] = configure_databases(
base_dir=BASE_DIR, base_dir=BASE_DIR,
) )
if DEBUG:
INSTALLED_APPS.append("zeal")
MIDDLEWARE.append("zeal.middleware.zeal_middleware")
if not TESTING: if not TESTING:
INSTALLED_APPS = [*INSTALLED_APPS, "debug_toolbar", "silk"] INSTALLED_APPS = [*INSTALLED_APPS, "debug_toolbar", "silk"]
MIDDLEWARE = [ MIDDLEWARE = [

View file

@ -292,10 +292,19 @@ class KickDropCampaign(auto_prefetch.Model):
def image_url(self) -> str: def image_url(self) -> str:
"""Return the image URL for the campaign.""" """Return the image URL for the campaign."""
# Image from first drop # Image from first drop
if self.rewards.exists(): # pyright: ignore[reportAttributeAccessIssue] rewards_prefetched: list[KickReward] | None = getattr(
first_reward: KickReward | None = self.rewards.first() # pyright: ignore[reportAttributeAccessIssue] self,
if first_reward and first_reward.image_url: "rewards_ordered",
return first_reward.full_image_url None,
)
if rewards_prefetched is not None:
first_reward: KickReward | None = (
rewards_prefetched[0] if rewards_prefetched else None
)
else:
first_reward = self.rewards.first() # pyright: ignore[reportAttributeAccessIssue]
if first_reward and first_reward.image_url:
return first_reward.full_image_url
if self.category and self.category.image_url: if self.category and self.category.image_url:
return self.category.image_url return self.category.image_url

View file

@ -31,6 +31,7 @@ dependencies = [
"setproctitle", "setproctitle",
"sitemap-parser", "sitemap-parser",
"tqdm", "tqdm",
"django-zeal>=2.1.0",
] ]

View file

@ -438,7 +438,13 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
if game_filter: if game_filter:
queryset = queryset.filter(game__twitch_id=game_filter) queryset = queryset.filter(game__twitch_id=game_filter)
queryset = queryset.prefetch_related("game__owners").order_by("-start_at") queryset = queryset.prefetch_related(
"game__owners",
Prefetch(
"time_based_drops",
queryset=TimeBasedDrop.objects.prefetch_related("benefits"),
),
).order_by("-start_at")
# Optionally filter by status (active, upcoming, expired) # Optionally filter by status (active, upcoming, expired)
now: datetime.datetime = timezone.now() now: datetime.datetime = timezone.now()
@ -588,18 +594,18 @@ def drop_campaign_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespo
queryset=Channel.objects.order_by("display_name"), queryset=Channel.objects.order_by("display_name"),
to_attr="channels_ordered", to_attr="channels_ordered",
), ),
Prefetch(
"time_based_drops",
queryset=TimeBasedDrop.objects.prefetch_related("benefits").order_by(
"required_minutes_watched",
),
),
).get(twitch_id=twitch_id) ).get(twitch_id=twitch_id)
except DropCampaign.DoesNotExist as exc: except DropCampaign.DoesNotExist as exc:
msg = "No campaign found matching the query" msg = "No campaign found matching the query"
raise Http404(msg) from exc raise Http404(msg) from exc
drops: QuerySet[TimeBasedDrop] = ( drops: QuerySet[TimeBasedDrop] = campaign.time_based_drops.all() # pyright: ignore[reportAttributeAccessIssue]
TimeBasedDrop.objects
.filter(campaign=campaign)
.select_related("campaign")
.prefetch_related("benefits")
.order_by("required_minutes_watched")
)
now: datetime.datetime = timezone.now() now: datetime.datetime = timezone.now()
enhanced_drops: list[dict[str, Any]] = _enhance_drops_with_context(drops, now) enhanced_drops: list[dict[str, Any]] = _enhance_drops_with_context(drops, now)