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",
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue