soulsync/tests/library/test_bulk_paginate.py
BoulderBadgeDad 2fb142dded jellyfin scan: page the bulk fetch so the no-progress watchdog can't false-stall
Discord (DXP4800 NAS, 7148 tracks): library updates kept dying with "Update appears stuck — no
progress for 300s (last phase: Fetching all tracks in bulk...)". not actually hung.

root cause: the bulk track/album fetch used a single 10000-item page, so a whole library came
back in ONE request that emitted NO progress while in flight. the watchdog (database_update_health)
kills a job with no progress for 300s — so on a slow server that one silent request tripped it even
though it was alive, not stuck. raising the timeout cap only buys the silent request more rope; a
bigger library or slower disk just needs a higher number. the per-batch progress line also only ran
when there was a NEXT page, so a sub-page-size library reported nothing at all.

fix: extract a pure paginate_all_items seam (core/library/bulk_paginate.py) that pages in 1000s and
reports progress after EVERY page — so the watchdog is fed on a cadence set by page size, not library
size, and can't starve mid-fetch no matter how big the library. both Jellyfin bulk loops (tracks +
albums, same defect) now route through it. preserves the failure-shrink resilience (halve to a floor,
then give up). does NOT change what's fetched — same query, fields, items.

note: changes nothing about WHICH tracks come back; only how they're paged + that every page reports.
keep the raised cap on dev as a margin — this is the actual fix. Plex/Navidrome don't share the
pattern (checked). 9 seam tests incl. the watchdog-feed invariant (progress count scales with
N/page_size, never one call for the whole library) + the sub-page regression + failure-shrink.
467 jellyfin/library tests green, ruff clean.
2026-06-28 13:41:38 -07:00

95 lines
3.5 KiB
Python

"""Bulk-fetch pagination seam — pages a server fetch while feeding the no-progress
watchdog every page (the DXP4800 "Fetching all tracks in bulk… stuck 300s" bug).
Pure: a fake fetch_page stands in for the server, a list records progress calls."""
import math
from core.library.bulk_paginate import (
paginate_all_items,
DEFAULT_PAGE_SIZE,
)
def _server(total, *, fail_at=None, fail_times=0):
"""A fake server holding `total` items. Returns the right page slice for
(start_index, limit). Optionally returns None (a failed request) the first
`fail_times` times it's called at offset `fail_at`."""
items = list(range(total))
state = {"fails": 0}
def fetch_page(start_index, limit):
if fail_at is not None and start_index == fail_at and state["fails"] < fail_times:
state["fails"] += 1
return None
return items[start_index:start_index + limit]
return fetch_page
def test_returns_every_item_across_pages():
out = paginate_all_items(_server(7148), page_size=1000)
assert out == list(range(7148))
def test_progress_fed_every_page_not_once_for_whole_library():
# The watchdog-feed invariant: progress count scales with N/page_size, so the
# gap between progress beats is one page — never the whole library. A single
# 7148-track library used to emit ZERO progress (one 10k page) and stall.
calls = []
paginate_all_items(_server(7148), page_size=1000, report_progress=calls.append)
assert len(calls) == math.ceil(7148 / 1000) == 8
def test_sub_page_library_still_reports_progress():
# Regression: the old loop skipped progress on the final/only page, so a
# library smaller than one page reported nothing → watchdog starved.
calls = []
out = paginate_all_items(_server(500), page_size=1000, report_progress=calls.append)
assert out == list(range(500))
assert len(calls) == 1 # was 0 before the fix
def test_exact_multiple_of_page_size():
calls = []
out = paginate_all_items(_server(2000), page_size=1000, report_progress=calls.append)
assert out == list(range(2000))
assert len(calls) == 2 # two full pages, both reported
def test_empty_library():
calls = []
out = paginate_all_items(_server(0), page_size=1000, report_progress=calls.append)
assert out == []
assert calls == []
def test_no_progress_callback_is_safe():
assert paginate_all_items(_server(2500), page_size=1000) == list(range(2500))
def test_failed_page_shrinks_then_succeeds():
# First request at offset 0 fails once; the helper halves the page size, retries,
# and still returns everything — the slow-server resilience path.
waits = []
out = paginate_all_items(
_server(1500, fail_at=0, fail_times=1),
page_size=1000, min_page_size=250,
on_retry_wait=lambda: waits.append(1),
)
assert out == list(range(1500))
assert waits == [1] # waited once before the retry
def test_gives_up_after_repeated_failures_at_floor():
# A page that always fails at the floor must terminate (not loop forever) and
# return what was gathered — here nothing, since it fails on the first page.
def always_fail(_start, _limit):
return None
out = paginate_all_items(always_fail, page_size=250, min_page_size=250)
assert out == []
def test_default_page_size_is_watchdog_safe():
# A guard on the constant itself: the default must be far below a library size
# that would fit in one request, so progress is always paged.
assert DEFAULT_PAGE_SIZE <= 1000