Rename indexes in DropCampaign and RewardCampaign for clarity; add tests to verify index usage in dashboard queries
This commit is contained in:
parent
43077cde0c
commit
1f0109263c
3 changed files with 207 additions and 64 deletions
|
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue