Skip to content

Commit a878d9c

Browse files
dannyvfilmsclaude
andcommitted
Fix N+1 and unbounded response body in /api/v1/media and /api/v1/history (#1004)
- Media-list API pages issued one query per deferred Item field per row (get_media_list defers heavy fields for the list scan; ItemSerializer reads them all), scaling query count with page size instead of the library. Rehydrate just the requested page's Items in one bulk query before serialization. - History's type-only window paginates by day, not entries, so a single busy day (imports, binge sessions) could return megabytes for limit=10. Cap entries per day to HISTORY_ENTRIES_PER_DAY_PAGE, mirroring the existing web history page's per-day cap, with entry_count/ entries_truncated markers. - Extend scripts/bench.sh with podcast-history and game-media-list cases to measure both going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 9bf94aa commit a878d9c

5 files changed

Lines changed: 210 additions & 2 deletions

File tree

scripts/bench.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,12 @@ cmd_history_api() {
153153
"history_types|/api/v1/history/?limit=3&types=episodes,movies"
154154
"history_media_type|/api/v1/history/?limit=3&media_type=episode,movie"
155155
"history_all|/api/v1/history/?limit=3"
156+
# issue #1004: podcast/game weren't covered by the #576/#948 optimization
157+
# pass. bytes column shows the day-entry-cap fix; media_game exercises
158+
# the media-list N+1 fix (add PERF_LOG_SLOW_REQUEST_MS=1 to the server
159+
# env and grep gunicorn output for queries= to see the query count too).
160+
"history_podcast|/api/v1/history/?limit=10&types=podcast"
161+
"media_game|/api/v1/media/game/?status=1&limit=10"
156162
)
157163
echo -e "case\tendpoint\tstatus\tmedian_s\tp90_s\tmin_s\tmax_s\tbytes\tn" >"$out"
158164
local entry name path code t bytes stats mode

src/api/tests/test_fork_tracking.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# FORK: tests for the tracking parity endpoints — episode watch/drop,
22
# tag management, and the history timeline.
33
import datetime
4+
from decimal import Decimal
45
from http import HTTPStatus as HTTP # noqa: N814
56
from unittest.mock import patch
67

@@ -103,6 +104,55 @@ def test_watch_invalid_date_rejected(self):
103104
response = self._watch(1, payload={"end_date": "not-a-date"})
104105
self.assertEqual(response.status_code, HTTP.BAD_REQUEST)
105106

107+
@patch(
108+
"app.models.providers.services.get_media_metadata",
109+
side_effect=_season_metadata_side_effect,
110+
)
111+
def test_watch_with_score_sets_episode_score(self, _mock):
112+
"""POST watch with a score sets it on all plays of the episode."""
113+
response = self._watch(1, payload={"score": "8.3"})
114+
self.assertEqual(response.status_code, HTTP.CREATED)
115+
self.assertEqual(response.json()["score"], 8.3)
116+
scores = set(
117+
Episode.objects.filter(
118+
related_season=self.season_medias[0],
119+
item__episode_number=1,
120+
).values_list("score", flat=True),
121+
)
122+
self.assertEqual(scores, {Decimal("8.3")})
123+
124+
@patch(
125+
"app.models.providers.services.get_media_metadata",
126+
side_effect=_season_metadata_side_effect,
127+
)
128+
def test_watch_without_score_leaves_score_unset(self, _mock):
129+
"""POST watch without a score field doesn't touch the score."""
130+
response = self._watch(1)
131+
self.assertEqual(response.status_code, HTTP.CREATED)
132+
self.assertIsNone(response.json()["score"])
133+
134+
@patch(
135+
"app.models.providers.services.get_media_metadata",
136+
side_effect=_season_metadata_side_effect,
137+
)
138+
def test_watch_invalid_score_rejected(self, _mock):
139+
"""POST watch with an out-of-range score 400s and creates no play."""
140+
play_count_before = Episode.objects.filter(
141+
related_season=self.season_medias[0],
142+
item__episode_number=1,
143+
).count()
144+
145+
response = self._watch(1, payload={"score": "11"})
146+
147+
self.assertEqual(response.status_code, HTTP.BAD_REQUEST)
148+
self.assertEqual(
149+
Episode.objects.filter(
150+
related_season=self.season_medias[0],
151+
item__episode_number=1,
152+
).count(),
153+
play_count_before,
154+
)
155+
106156
@patch(
107157
"app.models.providers.services.get_media_metadata",
108158
side_effect=_season_metadata_side_effect,
@@ -562,6 +612,45 @@ def test_history_returns_days(self):
562612
any(entry["media_type"] == "movie" for entry in all_entries),
563613
)
564614

615+
def test_history_day_entries_are_capped(self):
616+
"""A day with many plays doesn't blow up the response body (#1004).
617+
618+
`limit`/`offset` on this endpoint paginate over DAYS, not entries, so
619+
a single busy day (imports, binge sessions, frequent podcast
620+
scrobbles) must still be bounded — mirrors the web history page's
621+
existing per-day cap (HISTORY_ENTRIES_PER_DAY_PAGE).
622+
"""
623+
entry_count = history_cache.HISTORY_ENTRIES_PER_DAY_PAGE + 5
624+
same_day = datetime.datetime(2024, 6, 1, tzinfo=datetime.UTC)
625+
for index in range(entry_count):
626+
item = Item.objects.create(
627+
media_id=f"history-cap-movie-{index}",
628+
source=Sources.TMDB.value,
629+
media_type=MediaTypes.MOVIE.value,
630+
title=f"History Cap Movie {index}",
631+
)
632+
Movie.objects.create(
633+
item=item,
634+
user=self.user1,
635+
end_date=same_day,
636+
)
637+
cache.clear()
638+
639+
response = self.call_api(
640+
"get",
641+
"api_history",
642+
params={"media_type": "movie", "limit": 1},
643+
headers=self.auth_headers,
644+
)
645+
646+
self.assertEqual(response.status_code, HTTP.OK)
647+
days = response.json()["results"]
648+
self.assertEqual(len(days), 1)
649+
day = days[0]
650+
self.assertLessEqual(len(day["entries"]), history_cache.HISTORY_ENTRIES_PER_DAY_PAGE)
651+
self.assertEqual(day["entry_count"], entry_count)
652+
self.assertTrue(day["entries_truncated"])
653+
565654
def test_history_flat_returns_paginated_entry_list(self):
566655
"""?flat=1 returns a flat, card-oriented entry list, not day buckets."""
567656
day_response = self.call_api(

src/api/tests/test_media_list_filters.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
from http import HTTPStatus as HTTP # noqa: N814
22

3+
from django.db import connection
4+
from django.test.utils import CaptureQueriesContext
35
from django.utils import timezone
46

5-
from app.models import TV, CollectionEntry, Item, ItemTag, Season, Status, Tag
7+
from app.models import (
8+
TV,
9+
CollectionEntry,
10+
Game,
11+
Item,
12+
ItemTag,
13+
MediaTypes,
14+
Season,
15+
Sources,
16+
Status,
17+
Tag,
18+
)
619
from events.models import Event
720

821
from .base import FloppyApiTestCase
@@ -290,3 +303,59 @@ def test_invalid_filter_values_return_bad_request(self):
290303
):
291304
response = self._get_tv(**params)
292305
self.assertEqual(response.status_code, HTTP.BAD_REQUEST, params)
306+
307+
308+
class MediaListQueryBudgetTests(FloppyApiTestCase):
309+
"""Pin the query count for a paginated media-list page (#1004).
310+
311+
Serializing a page must not scale with the size of the user's whole
312+
library — regression test for a per-page-item N+1 caused by Item fields
313+
that `get_media_list` defers for the list scan but `ItemSerializer`
314+
(via `MediaSerializer`) reads in full.
315+
"""
316+
317+
def _seed_extra_games(self, count, *, start=0):
318+
for index in range(start, start + count):
319+
item = Item.objects.create(
320+
media_id=f"query-budget-game-{index}",
321+
source=Sources.IGDB.value,
322+
media_type=MediaTypes.GAME.value,
323+
title=f"Query Budget Game {index}",
324+
)
325+
Game.objects.create(item=item, user=self.user1)
326+
327+
def test_query_count_does_not_scale_with_library_size(self):
328+
self._seed_extra_games(5)
329+
with CaptureQueriesContext(connection) as small_ctx:
330+
response = self.client.get(
331+
"/api/v1/media/game/",
332+
{"limit": 10},
333+
**self.auth_headers,
334+
)
335+
self.assertEqual(response.status_code, HTTP.OK)
336+
small_queries = len(small_ctx.captured_queries)
337+
338+
self._seed_extra_games(120, start=5)
339+
with CaptureQueriesContext(connection) as big_ctx:
340+
response = self.client.get(
341+
"/api/v1/media/game/",
342+
{"limit": 10},
343+
**self.auth_headers,
344+
)
345+
self.assertEqual(response.status_code, HTTP.OK)
346+
big_queries = len(big_ctx.captured_queries)
347+
348+
self.assertEqual(len(response.json()["results"]), 10)
349+
self.assertLessEqual(
350+
big_queries,
351+
small_queries + 2,
352+
f"query count grew from {small_queries} to {big_queries} as the "
353+
"library grew — page serialization is scaling with library size "
354+
"again, not just the requested page.",
355+
)
356+
self.assertLessEqual(
357+
big_queries,
358+
30,
359+
f"{big_queries} queries for a 10-item page; budget is 30. If "
360+
"this increase is intentional, update the pin deliberately.",
361+
)

src/api/views.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,30 @@ def get(self, request, list_id, item_id):
825825
)
826826

827827

828+
def _rehydrate_deferred_items(page_entries):
829+
"""Refetch full Item rows for one page of entries.
830+
831+
`get_media_list`/`get_media_list_entries` defer several rarely-used Item
832+
fields (trakt/provider metadata) to keep the whole-library scan cheap.
833+
`ItemSerializer` (via `MediaSerializer`/`UntrackedMediaSerializer`) reads
834+
every Item field, so each deferred field on a paginated instance would
835+
otherwise trigger its own single-row query — a full-page-sized N+1. One
836+
bulk, undeferred fetch of just the page's items avoids that without
837+
touching the list-scan's defer optimization.
838+
"""
839+
item_ids = {entry.item.id for entry in page_entries if entry.item is not None}
840+
if not item_ids:
841+
return
842+
fresh_items_by_id = {item.pk: item for item in Item.objects.filter(pk__in=item_ids)}
843+
for entry in page_entries:
844+
fresh_item = fresh_items_by_id.get(entry.item.id if entry.item else None)
845+
if fresh_item is None:
846+
continue
847+
entry.item = fresh_item
848+
if entry.media is not None:
849+
entry.media.item = fresh_item
850+
851+
828852
# /api/v1/media/
829853
def _media_list_response(request, media_type=None):
830854
"""Build a filtered and serialized media-list response."""
@@ -849,6 +873,7 @@ def _media_list_response(request, media_type=None):
849873

850874
paginated_data = paginate_data(request, entries, limit, offset)
851875
page_entries = paginated_data["results"]
876+
_rehydrate_deferred_items(page_entries)
852877
lists_by_item_id = build_lists_by_item_id(request.user, page_entries)
853878
next_episode_by_item_id = get_next_episode_map(page_entries)
854879
serializer_context = {

src/app/history_cache_reader.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
HISTORY_COVERAGE_REPAIR_BATCH_SIZE,
3434
HISTORY_DAY_CACHE_TIMEOUT,
3535
HISTORY_DAYS_PER_PAGE,
36+
HISTORY_ENTRIES_PER_DAY_PAGE,
3637
HISTORY_STALE_AFTER,
3738
HISTORY_WARM_DAYS,
3839
_cache_key,
@@ -376,8 +377,25 @@ def get_cached_history_window(
376377
)
377378

378379
history_days.sort(key=lambda day: day.get("date") or date.min, reverse=True)
380+
381+
# A day's entry count is unbounded (imports, binge sessions, frequent
382+
# podcast scrobbles can put hundreds of entries on one day), while
383+
# `limit`/`offset` here only bound the number of DAYS returned. Without
384+
# this cap a single busy day can blow up the response to megabytes even
385+
# for `limit=1` — mirrors the web history page's existing per-day
386+
# entry cap (HISTORY_ENTRIES_PER_DAY_PAGE, see history_views.py).
387+
total_entries = 0
388+
for day_payload in history_days:
389+
entries = day_payload.get("entries", [])
390+
entry_count = len(entries)
391+
total_entries += entry_count
392+
if entry_count > HISTORY_ENTRIES_PER_DAY_PAGE:
393+
day_payload["entries"] = entries[:HISTORY_ENTRIES_PER_DAY_PAGE]
394+
day_payload["entry_count"] = entry_count
395+
day_payload["entries_truncated"] = entry_count > HISTORY_ENTRIES_PER_DAY_PAGE
396+
379397
logger.info(
380-
"history_cached_window user_id=%s logging_style=%s filters=%s indexed=%s offset=%s limit=%s cached=%s missing=%s returned=%s",
398+
"history_cached_window user_id=%s logging_style=%s filters=%s indexed=%s offset=%s limit=%s cached=%s missing=%s returned=%s entries=%s",
381399
user.id,
382400
logging_style,
383401
filters,
@@ -387,6 +405,7 @@ def get_cached_history_window(
387405
len(payloads),
388406
len(missing_day_keys),
389407
len(history_days),
408+
total_entries,
390409
)
391410
return history_days, total_days
392411

0 commit comments

Comments
 (0)