Add indexes to drop_campaign_list_view
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
This commit is contained in:
parent
1f0109263c
commit
47d4f5341f
4 changed files with 332 additions and 37 deletions
32
twitch/migrations/0019_dropcampaign_campaign_list_indexes.py
Normal file
32
twitch/migrations/0019_dropcampaign_campaign_list_indexes.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -510,11 +510,62 @@ class DropCampaign(auto_prefetch.Model):
|
|||
name="tw_drop_start_end_game_idx",
|
||||
),
|
||||
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:
|
||||
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
|
||||
def active_for_dashboard(
|
||||
cls,
|
||||
|
|
|
|||
|
|
@ -3247,3 +3247,228 @@ class TestBadgeSetDetailView:
|
|||
assert uses_index, (
|
||||
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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@ def organization_detail_view(request: HttpRequest, twitch_id: str) -> HttpRespon
|
|||
|
||||
|
||||
# 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.
|
||||
|
||||
Args:
|
||||
|
|
@ -429,29 +429,13 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
|
|||
game_filter: str | None = request.GET.get("game")
|
||||
status_filter: str | None = request.GET.get("status")
|
||||
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()
|
||||
if status_filter == "active":
|
||||
queryset = queryset.filter(start_at__lte=now, end_at__gte=now)
|
||||
elif status_filter == "upcoming":
|
||||
queryset = queryset.filter(start_at__gt=now)
|
||||
elif status_filter == "expired":
|
||||
queryset = queryset.filter(end_at__lt=now)
|
||||
|
||||
queryset: QuerySet[DropCampaign] = DropCampaign.for_campaign_list(
|
||||
now,
|
||||
game_twitch_id=game_filter,
|
||||
status=status_filter,
|
||||
)
|
||||
|
||||
paginator: Paginator[DropCampaign] = Paginator(queryset, per_page)
|
||||
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:
|
||||
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"
|
||||
description = "Browse Twitch drops"
|
||||
if status_filter:
|
||||
title += f" ({status_filter.capitalize()})"
|
||||
description = status_descriptions.get(status_filter, description)
|
||||
if game_filter:
|
||||
try:
|
||||
game: Game = Game.objects.get(twitch_id=game_filter)
|
||||
title += f" - {game.display_name}"
|
||||
game_name: str = (
|
||||
Game.objects
|
||||
.only("display_name")
|
||||
.values_list("display_name", flat=True)
|
||||
.get(twitch_id=game_filter)
|
||||
)
|
||||
title += f" - {game_name}"
|
||||
except Game.DoesNotExist:
|
||||
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
|
||||
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}"
|
||||
if game_filter:
|
||||
base_url += f"&game={game_filter}"
|
||||
elif game_filter:
|
||||
base_url += f"?game={game_filter}"
|
||||
|
||||
|
|
@ -495,7 +483,6 @@ def drop_campaign_list_view(request: HttpRequest) -> HttpResponse: # noqa: PLR0
|
|||
base_url,
|
||||
)
|
||||
|
||||
# CollectionPage schema for campaign list
|
||||
collection_schema: dict[str, str] = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue