Files
e77d9e933c fix(catalog): bound Next Up anchors by distinct series, not rows (#593)
* fix(catalog): bound Next Up anchors by distinct series, not rows

The global Next Up query capped its anchor scan at the 500 most recently
completed rows (nextUpAnchorMaxRows, #350). Bulk mark-watched writes
hundreds of completed rows with the newest timestamps, so one series
could flood the whole window and evict every other series from the rail
- observed in production wiping a user's Next Up row entirely.

Replace the row-capped CTE with a recursive skip-scan over
idx_uwp_profile_completed that emits the newest completed row of each
not-yet-seen series and stops after nextUpAnchorMaxSeries (96) distinct
series. A compound (updated_at, media_item_id) cursor keeps the walk
total when bulk writes share one timestamp across series. The
series-scoped branch (show-detail tile) keeps its unbounded shape.

DB-backed regression tests cover the flood shape (600 same-timestamp
rows of one series must not evict others) and same-timestamp anchors
across series; the flood test fails against the previous query.

Fixes #592

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): continue Next Up anchor walk until enough eligible series found

The 96-series walk budget counted visited series, but eligibility (next
episode exists with a present file, no newer partial progress) is only
decided downstream, so 96 consecutive ineligible anchors - all caught
up, unavailable, or blocked - still emptied the rail and silently capped
/Shows/NextUp pagination at whatever survived one walk.

Run the walk in batches: each batch keeps the 96-series budget, reports
its frontier (compound cursor position, seen-series array, rows walked),
and ListNextUp resumes the next batch below that frontier until the
requested limit is filled, history is exhausted (frontier short of the
budget), or a 10-batch runaway guard trips (logged, never silent).
Batches emit anchors in strictly descending (updated_at, media_item_id)
order, so appending preserves rail order. Series-scoped queries keep
their single-shot shape.

DB-backed regressions: 100 fully-watched series must not hide older
series with eligible next episodes (fails on the single-batch code), and
an all-caught-up profile returns empty without spinning to the batch
cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): anchor Next Up on the highest episode, not the highest ID

Review follow-up to the anchor walk. Two issues remained after the
batching fix.

The walk ordered anchors by (updated_at, media_item_id) and then by
(season, episode). media_item_id is unique, so no two rows ever tie on
that pair and the season/episode clause was unreachable. For a bulk
mark-watched series — every row written with one timestamp — the anchor
was therefore whichever content_id sorted highest. With production-shape
18-digit IDs where season 2 was scanned before a season-1 backfill,
season 1 sorts higher, so the rail surfaced s01e04: an episode following
one the user had already watched.

Choosing the series and choosing its anchor episode are now separate
steps. The walk still advances on the compound cursor, which is the total
order it needs; a lateral then picks the highest (season, episode) among
that series' rows sharing pick.updated_at, matching what the
series-scoped branch already did. Pinning updated_at instead of
re-sorting keeps it an index probe, and both rows carry the same
updated_at, so cursor order and the reported CompletedAt are unchanged.

Also adds the index the compound cursor needs. idx_uwp_profile_completed
stops at updated_at, so the media_item_id half of the seek was a filter
and every step re-read the rows tied on one timestamp — the exact shape a
bulk mark-watched profile has. Measured by dropping and recreating the
index around the shipped query on a synthetic 40k-row profile: 341ms ->
224ms, same plan shape otherwise.

Verified against Postgres 18.3. The new DB regression seeds the
scanned-out-of-order ID shape and fails on the previous ordering
(returns s01e04, wants s02e04); the SQL-shape test pins that
media_item_id can no longer gate the episode choice.

Part of #592

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 12:08:45 -04:00
..