Add indexes to drop_campaign_list_view
All checks were successful
Deploy to Server / deploy (push) Successful in 26s

This commit is contained in:
Joakim Hellsén 2026-04-11 04:30:08 +02:00
commit 47d4f5341f
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
4 changed files with 332 additions and 37 deletions

View file

@ -0,0 +1,32 @@
# Generated by Django 6.0.4 on 2026-04-10 23:25
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
"""Add indexes to optimize queries for the campaign list view."""
dependencies = [
(
"twitch",
"0018_rename_twitch_drop_start_a_929f09_idx_tw_drop_start_desc_idx_and_more",
),
]
operations = [
migrations.AddIndex(
model_name="dropcampaign",
index=models.Index(
fields=["is_fully_imported", "-start_at"],
name="tw_drop_imported_start_idx",
),
),
migrations.AddIndex(
model_name="dropcampaign",
index=models.Index(
fields=["is_fully_imported", "start_at", "end_at"],
name="tw_drop_imported_start_end_idx",
),
),
]

View file

@ -510,11 +510,62 @@ class DropCampaign(auto_prefetch.Model):
name="tw_drop_start_end_game_idx", name="tw_drop_start_end_game_idx",
), ),
models.Index(fields=["end_at", "-start_at"]), models.Index(fields=["end_at", "-start_at"]),
# For campaign list view: is_fully_imported filter + ordering
models.Index(
fields=["is_fully_imported", "-start_at"],
name="tw_drop_imported_start_idx",
),
# For campaign list view: is_fully_imported + active-window filter
models.Index(
fields=["is_fully_imported", "start_at", "end_at"],
name="tw_drop_imported_start_end_idx",
),
] ]
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
@classmethod
def for_campaign_list(
cls,
now: datetime.datetime,
*,
game_twitch_id: str | None = None,
status: str | None = None,
) -> models.QuerySet[DropCampaign]:
"""Return fully-imported campaigns with relations needed by the campaign list view.
Args:
now: Current timestamp used to evaluate status filters.
game_twitch_id: Optional Twitch game ID to filter campaigns by.
status: Optional status filter; one of "active", "upcoming", or "expired".
Returns:
QuerySet of campaigns ordered by newest start date.
"""
queryset = (
cls.objects
.filter(is_fully_imported=True)
.select_related("game")
.prefetch_related(
"game__owners",
models.Prefetch(
"time_based_drops",
queryset=TimeBasedDrop.objects.prefetch_related("benefits"),
),
)
.order_by("-start_at")
)
if game_twitch_id:
queryset = queryset.filter(game__twitch_id=game_twitch_id)
if status == "active":
queryset = queryset.filter(start_at__lte=now, end_at__gte=now)
elif status == "upcoming":
queryset = queryset.filter(start_at__gt=now)
elif status == "expired":
queryset = queryset.filter(end_at__lt=now)
return queryset
@classmethod @classmethod
def active_for_dashboard( def active_for_dashboard(
cls, cls,

View file

@ -3247,3 +3247,228 @@ class TestBadgeSetDetailView:
assert uses_index, ( assert uses_index, (
f"DropBenefit query on (distribution_type, name) did not use an index.\n{plan}" f"DropBenefit query on (distribution_type, name) did not use an index.\n{plan}"
) )
@pytest.mark.django_db
class TestDropCampaignListView:
"""Tests for drop_campaign_list_view index usage and fat-model delegation."""
@pytest.fixture
def game_with_campaigns(self) -> dict[str, Any]:
"""Create a game with a mix of imported/not-imported campaigns.
Returns:
Dict with 'org' and 'game' keys for the created Organization and Game.
"""
org: Organization = Organization.objects.create(
twitch_id="org_list_test",
name="List Test Org",
)
game: Game = Game.objects.create(
twitch_id="game_list_test",
name="game_list_test",
display_name="List Test Game",
)
game.owners.add(org)
return {"org": org, "game": game}
def test_campaign_list_returns_200(self, client: Client) -> None:
"""Campaign list view loads successfully."""
response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:campaign_list"),
)
assert response.status_code == 200
def test_only_fully_imported_campaigns_shown(
self,
client: Client,
game_with_campaigns: dict[str, Any],
) -> None:
"""Only campaigns with is_fully_imported=True appear in the list."""
game: Game = game_with_campaigns["game"]
imported: DropCampaign = DropCampaign.objects.create(
twitch_id="cl_imported",
name="Imported Campaign",
game=game,
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
DropCampaign.objects.create(
twitch_id="cl_not_imported",
name="Not Imported Campaign",
game=game,
operation_names=["DropCampaignDetails"],
is_fully_imported=False,
)
response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:campaign_list"),
)
context: ContextList | dict[str, Any] = response.context # type: ignore[assignment]
if isinstance(context, list):
context = context[-1]
campaign_ids = {c.twitch_id for c in context["campaigns"].object_list}
assert imported.twitch_id in campaign_ids
assert "cl_not_imported" not in campaign_ids
def test_status_filter_active(
self,
client: Client,
game_with_campaigns: dict[str, Any],
) -> None:
"""Status=active returns only currently-running campaigns."""
game: Game = game_with_campaigns["game"]
now = timezone.now()
active: DropCampaign = DropCampaign.objects.create(
twitch_id="cl_active",
name="Active",
game=game,
operation_names=[],
is_fully_imported=True,
start_at=now - timedelta(hours=1),
end_at=now + timedelta(hours=1),
)
DropCampaign.objects.create(
twitch_id="cl_expired",
name="Expired",
game=game,
operation_names=[],
is_fully_imported=True,
start_at=now - timedelta(days=10),
end_at=now - timedelta(days=1),
)
response: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:campaign_list") + "?status=active",
)
context: ContextList | dict[str, Any] = response.context # type: ignore[assignment]
if isinstance(context, list):
context = context[-1]
campaign_ids = {c.twitch_id for c in context["campaigns"].object_list}
assert active.twitch_id in campaign_ids
assert "cl_expired" not in campaign_ids
def test_campaign_list_indexes_exist(self) -> None:
"""Required composite indexes for the campaign list query must exist on DropCampaign."""
expected: set[str] = {
"tw_drop_imported_start_idx",
"tw_drop_imported_start_end_idx",
}
with connection.cursor() as cursor:
constraints = connection.introspection.get_constraints(
cursor,
DropCampaign._meta.db_table,
)
actual: set[str] = {
name for name, meta in constraints.items() if meta.get("index")
}
missing = expected - actual
assert not missing, (
f"Missing expected DropCampaign campaign-list indexes: {sorted(missing)}"
)
@pytest.mark.django_db
def test_campaign_list_query_uses_index(self) -> None:
"""for_campaign_list() should use an index when filtering is_fully_imported."""
now: datetime.datetime = timezone.now()
game: Game = Game.objects.create(
twitch_id="game_cl_idx",
name="game_cl_idx",
display_name="CL Idx Game",
)
# Bulk-create enough rows to give the query planner a reason to use indexes.
rows: list[DropCampaign] = [
DropCampaign(
twitch_id=f"cl_idx_not_imported_{i}",
name=f"Not imported {i}",
game=game,
operation_names=[],
is_fully_imported=False,
start_at=now - timedelta(days=i + 1),
end_at=now + timedelta(days=1),
)
for i in range(300)
]
rows.append(
DropCampaign(
twitch_id="cl_idx_imported",
name="Imported",
game=game,
operation_names=[],
is_fully_imported=True,
start_at=now - timedelta(hours=1),
end_at=now + timedelta(hours=1),
),
)
DropCampaign.objects.bulk_create(rows)
plan: str = DropCampaign.for_campaign_list(now).explain()
if connection.vendor == "sqlite":
uses_index: bool = "USING INDEX" in plan.upper()
elif connection.vendor == "postgresql":
uses_index = (
"INDEX SCAN" in plan.upper()
or "BITMAP INDEX SCAN" in plan.upper()
or "INDEX ONLY SCAN" in plan.upper()
)
else:
pytest.skip(
f"Unsupported DB vendor for index assertion: {connection.vendor}",
)
assert uses_index, f"for_campaign_list() did not use an index.\n{plan}"
def test_campaign_list_query_count_stays_flat(self, client: Client) -> None:
"""Campaign list should not issue N+1 queries as campaign volume grows."""
game: Game = Game.objects.create(
twitch_id="game_cl_flat",
name="game_cl_flat",
display_name="CL Flat Game",
)
now = timezone.now()
def _select_count() -> int:
with CaptureQueriesContext(connection) as ctx:
resp: _MonkeyPatchedWSGIResponse = client.get(
reverse("twitch:campaign_list"),
)
assert resp.status_code == 200
return sum(
1
for q in ctx.captured_queries
if q["sql"].lstrip().upper().startswith("SELECT")
)
DropCampaign.objects.create(
twitch_id="cl_flat_base",
name="Base campaign",
game=game,
operation_names=[],
is_fully_imported=True,
start_at=now - timedelta(hours=1),
end_at=now + timedelta(hours=1),
)
baseline: int = _select_count()
extra = [
DropCampaign(
twitch_id=f"cl_flat_extra_{i}",
name=f"Extra {i}",
game=game,
operation_names=[],
is_fully_imported=True,
start_at=now - timedelta(hours=2),
end_at=now + timedelta(hours=2),
)
for i in range(15)
]
DropCampaign.objects.bulk_create(extra)
scaled: int = _select_count()
assert scaled <= baseline + 2, (
f"Campaign list SELECT count grew; possible N+1. "
f"baseline={baseline}, scaled={scaled}"
)

View file

@ -417,7 +417,7 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
# MARK: /campaigns/ # MARK: /campaigns/
def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914, PLR0915 def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0914
"""Function-based view for drop campaigns list. """Function-based view for drop campaigns list.
Args: Args:
@ -429,29 +429,13 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
game_filter: str | None = request.GET.get("game") game_filter: str | None = request.GET.get("game")
status_filter: str | None = request.GET.get("status") status_filter: str | None = request.GET.get("status")
per_page: int = 100 per_page: int = 100
queryset: QuerySet[DropCampaign] = DropCampaign.objects.filter(
is_fully_imported=True,
)
if game_filter:
queryset = queryset.filter(game__twitch_id=game_filter)
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)
now: datetime.datetime = timezone.now() now: datetime.datetime = timezone.now()
if status_filter == "active":
queryset = queryset.filter(start_at__lte=now, end_at__gte=now) queryset: QuerySet[DropCampaign] = DropCampaign.for_campaign_list(
elif status_filter == "upcoming": now,
queryset = queryset.filter(start_at__gt=now) game_twitch_id=game_filter,
elif status_filter == "expired": status=status_filter,
queryset = queryset.filter(end_at__lt=now) )
paginator: Paginator[DropCampaign] = Paginator(queryset, per_page) paginator: Paginator[DropCampaign] = Paginator(queryset, per_page)
page: str | Literal[1] = request.GET.get("page") or 1 page: str | Literal[1] = request.GET.get("page") or 1
@ -462,30 +446,34 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
except EmptyPage: except EmptyPage:
campaigns = paginator.page(paginator.num_pages) campaigns = paginator.page(paginator.num_pages)
status_descriptions: dict[str, str] = {
"active": "Browse active Twitch drops.",
"upcoming": "View upcoming Twitch drops starting soon.",
"expired": "Browse expired Twitch drops.",
}
title = "Twitch Drops" title = "Twitch Drops"
description = "Browse Twitch drops"
if status_filter: if status_filter:
title += f" ({status_filter.capitalize()})" title += f" ({status_filter.capitalize()})"
description = status_descriptions.get(status_filter, description)
if game_filter: if game_filter:
try: try:
game: Game = Game.objects.get(twitch_id=game_filter) game_name: str = (
title += f" - {game.display_name}" Game.objects
.only("display_name")
.values_list("display_name", flat=True)
.get(twitch_id=game_filter)
)
title += f" - {game_name}"
except Game.DoesNotExist: except Game.DoesNotExist:
pass pass
description = "Browse Twitch drops"
if status_filter == "active":
description = "Browse active Twitch drops."
elif status_filter == "upcoming":
description = "View upcoming Twitch drops starting soon."
elif status_filter == "expired":
description = "Browse expired Twitch drops."
# Build base URL for pagination # Build base URL for pagination
base_url = "/campaigns/" base_url = "/campaigns/"
if status_filter: if status_filter and game_filter:
base_url += f"?status={status_filter}&game={game_filter}"
elif status_filter:
base_url += f"?status={status_filter}" base_url += f"?status={status_filter}"
if game_filter:
base_url += f"&game={game_filter}"
elif game_filter: elif game_filter:
base_url += f"?game={game_filter}" base_url += f"?game={game_filter}"
@ -495,7 +483,6 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
base_url, base_url,
) )
# CollectionPage schema for campaign list
collection_schema: dict[str, str] = { collection_schema: dict[str, str] = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "CollectionPage", "@type": "CollectionPage",