feat(worker): persisted scan worklist (O(N) scan, robust to mid-scan changes)
Replace scan_chunk's O(N^2) tree re-walk-and-skip-to-cursor with a persisted ScanWorkItem worklist: build_worklist walks the tree once at scan start and inserts one row per album (idempotent via ON CONFLICT), scan_chunk pops up to `limit` not-done rows by indexed cursor and marks them done, and clear_worklist drops the scan's rows once drained. scan_library is reimplemented on top of the worklist (one-shot scan_id, drain, clear) with an unchanged signature/behavior. main.py's maybe_run_scan now drives a generated scan.id through Config (scan.id/scan.inProgress/scan.progress/scan.result) instead of a scan.cursor string, so a mid-scan library change no longer risks skipping or re-walking albums.
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
from lyra_worker.adapters.fakes import FakeMbBrowser
|
||||
from lyra_worker.browser import ReleaseGroupInfo
|
||||
from lyra_worker.scan import scan_chunk
|
||||
from lyra_worker.scan import build_worklist, scan_chunk, clear_worklist
|
||||
from lyra_worker.types import MBTarget
|
||||
from tests.test_scan import FakeProbe, FakeResolver, _album, _counts
|
||||
|
||||
|
||||
def _tree(tmp_path):
|
||||
"""Three albums across two artists, in nested-sorted order:
|
||||
Artist A/Alpha, Artist A/Beta, Artist B/Gamma."""
|
||||
"""Three albums across two artists: Artist A/Alpha, Artist A/Beta, Artist B/Gamma."""
|
||||
_album(tmp_path, "Artist A", "Alpha (2001)")
|
||||
_album(tmp_path, "Artist A", "Beta (2002)")
|
||||
_album(tmp_path, "Artist B", "Gamma (2003)")
|
||||
@@ -24,58 +23,66 @@ def _tree(tmp_path):
|
||||
return resolver, browser
|
||||
|
||||
|
||||
def test_scan_chunk_respects_limit_and_returns_cursor(conn, tmp_path):
|
||||
resolver, browser = _tree(tmp_path)
|
||||
cursor, imported, skipped, done = scan_chunk(
|
||||
conn, resolver, FakeProbe(quality_class=2), browser, str(tmp_path), cursor="", limit=2)
|
||||
assert (imported, skipped, done) == (2, 0, False) # processed 2 of 3, more remain
|
||||
assert cursor == "Artist A/Beta (2002)" # last processed key
|
||||
def test_build_worklist_inserts_one_row_per_album(conn, tmp_path):
|
||||
resolver, _ = _tree(tmp_path)
|
||||
n = build_worklist(conn, "scan1", str(tmp_path))
|
||||
assert n == 3
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT count(*) FROM "ScanWorkItem" WHERE "scanId" = %s AND NOT done', ("scan1",))
|
||||
assert cur.fetchone()[0] == 3
|
||||
|
||||
|
||||
def test_build_worklist_is_idempotent(conn, tmp_path):
|
||||
resolver, _ = _tree(tmp_path)
|
||||
assert build_worklist(conn, "scan1", str(tmp_path)) == 3
|
||||
assert build_worklist(conn, "scan1", str(tmp_path)) == 0 # ON CONFLICT DO NOTHING
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT count(*) FROM "ScanWorkItem" WHERE "scanId" = %s', ("scan1",))
|
||||
assert cur.fetchone()[0] == 3
|
||||
|
||||
|
||||
def test_scan_chunk_respects_limit_and_marks_done(conn, tmp_path):
|
||||
resolver, browser = _tree(tmp_path)
|
||||
build_worklist(conn, "scan1", str(tmp_path))
|
||||
imported, skipped, done = scan_chunk(conn, resolver, FakeProbe(quality_class=2), browser, "scan1", limit=2)
|
||||
assert (imported, skipped, done) == (2, 0, False) # 2 of 3 processed, more remain
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT count(*) FROM "ScanWorkItem" WHERE "scanId" = %s AND done', ("scan1",))
|
||||
assert cur.fetchone()[0] == 2
|
||||
cur.execute('SELECT count(*) FROM "LibraryItem"')
|
||||
assert cur.fetchone()[0] == 2
|
||||
|
||||
|
||||
def test_scan_chunk_resumes_from_cursor(conn, tmp_path):
|
||||
def test_scan_chunk_resumes_and_finishes(conn, tmp_path):
|
||||
resolver, browser = _tree(tmp_path)
|
||||
cursor, imported, skipped, done = scan_chunk(
|
||||
conn, resolver, FakeProbe(quality_class=2), browser, str(tmp_path),
|
||||
cursor="Artist A/Beta (2002)", limit=25)
|
||||
assert (imported, skipped, done) == (1, 0, True) # only Gamma remains
|
||||
assert cursor == "Artist B/Gamma (2003)"
|
||||
build_worklist(conn, "scan1", str(tmp_path))
|
||||
scan_chunk(conn, resolver, FakeProbe(quality_class=2), browser, "scan1", limit=2)
|
||||
imported, skipped, done = scan_chunk(conn, resolver, FakeProbe(quality_class=2), browser, "scan1", limit=25)
|
||||
assert (imported, skipped, done) == (1, 0, True) # last album, then drained
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT album FROM "LibraryItem"')
|
||||
assert cur.fetchall() == [("Gamma",)] # only Gamma imported
|
||||
cur.execute('SELECT album FROM "LibraryItem" ORDER BY album')
|
||||
assert cur.fetchall() == [("Alpha",), ("Beta",), ("Gamma",)]
|
||||
|
||||
|
||||
def test_scan_chunk_cursor_past_end_is_noop(conn, tmp_path):
|
||||
def test_scan_chunk_one_per_chunk_totals_correctly(conn, tmp_path):
|
||||
resolver, browser = _tree(tmp_path)
|
||||
cursor, imported, skipped, done = scan_chunk(
|
||||
conn, resolver, FakeProbe(), browser, str(tmp_path), cursor="zzzz/zzzz", limit=10)
|
||||
assert (imported, skipped, done) == (0, 0, True)
|
||||
assert cursor == "zzzz/zzzz" # unchanged
|
||||
assert _counts(conn)["LibraryItem"] == 0
|
||||
|
||||
|
||||
def test_scan_chunk_splits_artist_across_chunks_idempotently(conn, tmp_path):
|
||||
# limit=1 forces Artist A's two albums into separate chunks, re-browsing its
|
||||
# discography — which must stay idempotent (ON CONFLICT) and total the same as one scan.
|
||||
resolver, browser = _tree(tmp_path)
|
||||
cursor, done, total_i, total_s, guard = "", False, 0, 0, 0
|
||||
build_worklist(conn, "scan1", str(tmp_path))
|
||||
total_i = total_s = guard = 0
|
||||
done = False
|
||||
while not done and guard < 10:
|
||||
cursor, i, s, done = scan_chunk(
|
||||
conn, resolver, FakeProbe(quality_class=2), browser, str(tmp_path), cursor=cursor, limit=1)
|
||||
i, s, done = scan_chunk(conn, resolver, FakeProbe(quality_class=2), browser, "scan1", limit=1)
|
||||
total_i += i
|
||||
total_s += s
|
||||
guard += 1
|
||||
assert (total_i, total_s) == (3, 0)
|
||||
assert guard == 3 # three albums, one per chunk, then done
|
||||
assert guard == 3
|
||||
assert _counts(conn) == {"LibraryItem": 3, "MonitoredRelease": 3, "WatchedArtist": 2}
|
||||
|
||||
|
||||
def test_scan_chunk_unbounded_matches_full_walk(conn, tmp_path):
|
||||
resolver, browser = _tree(tmp_path)
|
||||
cursor, imported, skipped, done = scan_chunk(
|
||||
conn, resolver, FakeProbe(quality_class=2), browser, str(tmp_path), cursor="", limit=None)
|
||||
assert (imported, skipped, done) == (3, 0, True)
|
||||
assert cursor == "Artist B/Gamma (2003)"
|
||||
assert _counts(conn) == {"LibraryItem": 3, "MonitoredRelease": 3, "WatchedArtist": 2}
|
||||
def test_clear_worklist_removes_this_scans_rows(conn, tmp_path):
|
||||
resolver, _ = _tree(tmp_path)
|
||||
build_worklist(conn, "scan1", str(tmp_path))
|
||||
clear_worklist(conn, "scan1")
|
||||
with conn.cursor() as cur:
|
||||
cur.execute('SELECT count(*) FROM "ScanWorkItem" WHERE "scanId" = %s', ("scan1",))
|
||||
assert cur.fetchone()[0] == 0
|
||||
|
||||
Reference in New Issue
Block a user