Rename indexes in DropCampaign and RewardCampaign for clarity; add tests to verify index usage in dashboard queries

This commit is contained in:
Joakim Hellsén 2026-04-11 01:20:35 +02:00
commit 1f0109263c
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
3 changed files with 207 additions and 64 deletions

View file

@ -0,0 +1,39 @@
# Generated by Django 6.0.4 on 2026-04-10 23:18
from django.db import migrations
class Migration(migrations.Migration):
"""Rename some indexes on DropCampaign and RewardCampaign to be more descriptive."""
dependencies = [
("twitch", "0017_dropbenefit_twitch_drop_distrib_70d961_idx"),
]
operations = [
migrations.RenameIndex(
model_name="dropcampaign",
new_name="tw_drop_start_desc_idx",
old_name="twitch_drop_start_a_929f09_idx",
),
migrations.RenameIndex(
model_name="dropcampaign",
new_name="tw_drop_start_end_idx",
old_name="twitch_drop_start_a_6e5fb6_idx",
),
migrations.RenameIndex(
model_name="dropcampaign",
new_name="tw_drop_start_end_game_idx",
old_name="twitch_drop_start_a_b02d4c_idx",
),
migrations.RenameIndex(
model_name="rewardcampaign",
new_name="tw_reward_starts_desc_idx",
old_name="twitch_rewa_starts__4df564_idx",
),
migrations.RenameIndex(
model_name="rewardcampaign",
new_name="tw_reward_starts_ends_idx",
old_name="twitch_rewa_starts__dd909d_idx",
),
]

View file

@ -2,7 +2,6 @@ import logging
from collections import OrderedDict from collections import OrderedDict
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
from typing import cast
import auto_prefetch import auto_prefetch
from django.conf import settings from django.conf import settings
@ -492,7 +491,7 @@ class DropCampaign(auto_prefetch.Model):
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
ordering = ["-start_at"] ordering = ["-start_at"]
indexes = [ indexes = [
models.Index(fields=["-start_at"]), models.Index(fields=["-start_at"], name="tw_drop_start_desc_idx"),
models.Index(fields=["end_at"]), models.Index(fields=["end_at"]),
models.Index(fields=["game"]), models.Index(fields=["game"]),
models.Index(fields=["twitch_id"]), models.Index(fields=["twitch_id"]),
@ -504,9 +503,12 @@ class DropCampaign(auto_prefetch.Model):
models.Index(fields=["updated_at"]), models.Index(fields=["updated_at"]),
# Composite indexes for common queries # Composite indexes for common queries
models.Index(fields=["game", "-start_at"]), models.Index(fields=["game", "-start_at"]),
models.Index(fields=["start_at", "end_at"]), models.Index(fields=["start_at", "end_at"], name="tw_drop_start_end_idx"),
# For dashboard and game_detail active campaign filtering # For dashboard and game_detail active campaign filtering
models.Index(fields=["start_at", "end_at", "game"]), models.Index(
fields=["start_at", "end_at", "game"],
name="tw_drop_start_end_game_idx",
),
models.Index(fields=["end_at", "-start_at"]), models.Index(fields=["end_at", "-start_at"]),
] ]
@ -584,71 +586,28 @@ class DropCampaign(auto_prefetch.Model):
""" """
campaigns_by_game: OrderedDict[str, dict[str, Any]] = OrderedDict() campaigns_by_game: OrderedDict[str, dict[str, Any]] = OrderedDict()
campaigns_list: list[DropCampaign] = list(campaigns) for campaign in campaigns:
game_pks: list[int] = sorted({ game: Game = campaign.game
cast("Any", campaign).game_id for campaign in campaigns_list game_id: str = game.twitch_id
}) game_display_name: str = game.display_name
games: models.QuerySet[Game, Game] = (
Game.objects
.filter(pk__in=game_pks)
.only(
"pk",
"twitch_id",
"display_name",
"slug",
"box_art",
"box_art_file",
"box_art_width",
"box_art_height",
)
.prefetch_related(
models.Prefetch(
"owners",
queryset=Organization.objects.only("twitch_id", "name"),
),
)
)
games_by_pk: dict[int, Game] = {game.pk: game for game in games}
def _clean_name(campaign_name: str, game_display_name: str) -> str: game_bucket: dict[str, Any] = campaigns_by_game.setdefault(
if not game_display_name: game_id,
return campaign_name {
game_variations: list[str] = [game_display_name]
if "&" in game_display_name:
game_variations.append(game_display_name.replace("&", "and"))
if "and" in game_display_name:
game_variations.append(game_display_name.replace("and", "&"))
for game_name in game_variations:
for separator in [" - ", " | ", " "]:
prefix_to_check: str = game_name + separator
if campaign_name.startswith(prefix_to_check):
return campaign_name.removeprefix(prefix_to_check).strip()
return campaign_name
for campaign in campaigns_list:
game_pk: int = cast("Any", campaign).game_id
game: Game | None = games_by_pk.get(game_pk)
game_id: str = game.twitch_id if game else ""
game_display_name: str = game.display_name if game else ""
if game_id not in campaigns_by_game:
campaigns_by_game[game_id] = {
"name": game_display_name, "name": game_display_name,
"box_art": game.box_art_best_url if game else "", "box_art": game.box_art_best_url,
"owners": list(game.owners.all()) if game else [], "owners": list(game.owners.all()),
"campaigns": [], "campaigns": [],
} },
)
campaigns_by_game[game_id]["campaigns"].append({ game_bucket["campaigns"].append({
"campaign": campaign, "campaign": campaign,
"clean_name": _clean_name(campaign.name, game_display_name), "clean_name": campaign.clean_name,
"image_url": campaign.listing_image_url, "image_url": campaign.listing_image_url,
"allowed_channels": getattr(campaign, "channels_ordered", []), "allowed_channels": getattr(campaign, "channels_ordered", []),
"game_display_name": game_display_name, "game_display_name": game_display_name,
"game_twitch_directory_url": game.twitch_directory_url if game else "", "game_twitch_directory_url": game.twitch_directory_url,
}) })
return campaigns_by_game return campaigns_by_game
@ -1165,7 +1124,7 @@ class RewardCampaign(auto_prefetch.Model):
class Meta(auto_prefetch.Model.Meta): class Meta(auto_prefetch.Model.Meta):
ordering = ["-starts_at"] ordering = ["-starts_at"]
indexes = [ indexes = [
models.Index(fields=["-starts_at"]), models.Index(fields=["-starts_at"], name="tw_reward_starts_desc_idx"),
models.Index(fields=["ends_at"]), models.Index(fields=["ends_at"]),
models.Index(fields=["twitch_id"]), models.Index(fields=["twitch_id"]),
models.Index(fields=["name"]), models.Index(fields=["name"]),
@ -1176,7 +1135,10 @@ class RewardCampaign(auto_prefetch.Model):
models.Index(fields=["added_at"]), models.Index(fields=["added_at"]),
models.Index(fields=["updated_at"]), models.Index(fields=["updated_at"]),
# Composite indexes for common queries # Composite indexes for common queries
models.Index(fields=["starts_at", "ends_at"]), models.Index(
fields=["starts_at", "ends_at"],
name="tw_reward_starts_ends_idx",
),
models.Index(fields=["status", "-starts_at"]), models.Index(fields=["status", "-starts_at"]),
] ]

View file

@ -650,6 +650,97 @@ class TestChannelListView:
assert campaigns_uses_index, campaigns_plan assert campaigns_uses_index, campaigns_plan
assert rewards_uses_index, reward_plan assert rewards_uses_index, reward_plan
@pytest.mark.django_db
def test_dashboard_query_plans_reference_expected_index_names(self) -> None:
"""Dashboard active-window plans should mention concrete index names."""
now: datetime.datetime = timezone.now()
org: Organization = Organization.objects.create(
twitch_id="org_index_name_test",
name="Org Index Name Test",
)
game: Game = Game.objects.create(
twitch_id="game_index_name_test",
name="Game Index Name Test",
display_name="Game Index Name Test",
)
game.owners.add(org)
DropCampaign.objects.create(
twitch_id="active_for_dashboard_index_name_test",
name="Active campaign index-name test",
game=game,
operation_names=["DropCampaignDetails"],
start_at=now - timedelta(hours=1),
end_at=now + timedelta(hours=1),
)
RewardCampaign.objects.create(
twitch_id="reward_active_for_dashboard_index_name_test",
name="Active reward campaign index-name test",
game=game,
starts_at=now - timedelta(hours=1),
ends_at=now + timedelta(hours=1),
)
# Keep this assertion scoped to engines whose plans typically include index names.
if connection.vendor not in {"sqlite", "postgresql"}:
pytest.skip(
f"Unsupported DB vendor for index-name plan assertion: {connection.vendor}",
)
def _index_names(table_name: str) -> set[str]:
with connection.cursor() as cursor:
constraints = connection.introspection.get_constraints(
cursor,
table_name,
)
names: set[str] = set()
for name, meta in constraints.items():
if not meta.get("index"):
continue
names.add(name)
return names
expected_drop_indexes: set[str] = {
"tw_drop_start_desc_idx",
"tw_drop_start_end_idx",
"tw_drop_start_end_game_idx",
}
expected_reward_indexes: set[str] = {
"tw_reward_starts_desc_idx",
"tw_reward_starts_ends_idx",
}
drop_index_names: set[str] = _index_names(DropCampaign._meta.db_table)
reward_index_names: set[str] = _index_names(RewardCampaign._meta.db_table)
missing_drop_indexes: set[str] = expected_drop_indexes - drop_index_names
missing_reward_indexes: set[str] = expected_reward_indexes - reward_index_names
assert not missing_drop_indexes, (
"Missing expected DropCampaign dashboard indexes: "
f"{sorted(missing_drop_indexes)}"
)
assert not missing_reward_indexes, (
"Missing expected RewardCampaign dashboard indexes: "
f"{sorted(missing_reward_indexes)}"
)
campaigns_plan: str = DropCampaign.active_for_dashboard(now).explain().lower()
reward_plan: str = RewardCampaign.active_for_dashboard(now).explain().lower()
assert any(name.lower() in campaigns_plan for name in expected_drop_indexes), (
"DropCampaign active-for-dashboard plan did not reference an expected "
"named dashboard index. "
f"Expected one of {sorted(expected_drop_indexes)}. Plan={campaigns_plan}"
)
assert any(name.lower() in reward_plan for name in expected_reward_indexes), (
"RewardCampaign active-for-dashboard plan did not reference an expected "
"named dashboard index. "
f"Expected one of {sorted(expected_reward_indexes)}. Plan={reward_plan}"
)
@pytest.mark.django_db @pytest.mark.django_db
def test_dashboard_query_count_stays_flat_with_more_data( def test_dashboard_query_count_stays_flat_with_more_data(
self, self,
@ -758,6 +849,57 @@ class TestChannelListView:
f"baseline={baseline_select_count}, scaled={scaled_select_count}" f"baseline={baseline_select_count}, scaled={scaled_select_count}"
) )
@pytest.mark.django_db
def test_dashboard_grouping_reuses_selected_game_relation(self) -> None:
"""Dashboard grouping should not issue extra standalone Game queries."""
now: datetime.datetime = timezone.now()
org: Organization = Organization.objects.create(
twitch_id="org_grouping_no_extra_game_select",
name="Org Grouping No Extra Game Select",
)
game: Game = Game.objects.create(
twitch_id="game_grouping_no_extra_game_select",
name="game_grouping_no_extra_game_select",
display_name="Game Grouping No Extra Game Select",
)
game.owners.add(org)
campaigns: list[DropCampaign] = [
DropCampaign(
twitch_id=f"grouping_campaign_{i}",
name=f"Grouping campaign {i}",
game=game,
operation_names=["DropCampaignDetails"],
start_at=now - timedelta(hours=1),
end_at=now + timedelta(hours=1),
)
for i in range(5)
]
DropCampaign.objects.bulk_create(campaigns)
with CaptureQueriesContext(connection) as queries:
grouped: dict[str, dict[str, Any]] = (
DropCampaign.campaigns_by_game_for_dashboard(now)
)
assert game.twitch_id in grouped
assert len(grouped[game.twitch_id]["campaigns"]) == 5
game_select_queries: list[str] = [
query_info["sql"]
for query_info in queries.captured_queries
if query_info["sql"].lstrip().upper().startswith("SELECT")
and 'from "twitch_game"' in query_info["sql"].lower()
and " join " not in query_info["sql"].lower()
]
assert not game_select_queries, (
"Dashboard grouping should reuse DropCampaign.active_for_dashboard() "
"select_related game rows instead of standalone Game SELECTs. "
f"Queries: {game_select_queries}"
)
@pytest.mark.django_db @pytest.mark.django_db
def test_dashboard_avoids_n_plus_one_game_queries_in_drop_loop( def test_dashboard_avoids_n_plus_one_game_queries_in_drop_loop(
self, self,