soulsync/tests/test_database_update_reconcile_hook.py
BoulderBadgeDad 79a30c055a Run auto-reconcile as a scan phase inside the running window
The post-scan reconcile previously ran AFTER the worker's 'finished'
signal, which flips db_update_state status to 'finished'. Automations
wait for a scan by polling that status, so they stopped waiting before
the reconcile ran — and the dashboard/Tools card showed "Completed" then
flipped to "Reading file tags…". For incremental scans this was
invisible (sub-second); for a full refresh it was a real gap (a chained
automation would fire minutes before the IDs were filled).

Fix: the worker now routes completion through _emit_finished(), which
runs self.post_scan_hook (the reconcile) FIRST, then emits 'finished'.
The hook is injected by the web layer (it owns path resolution). So:

- status stays 'running' through the reconcile,
- the reconcile pushes its phase ("Reading file tags for N new tracks…")
  and per-track progress through the SAME db_update_state callbacks the
  scan already uses — so automations, the dashboard card, and the Tools
  page all see it for free and wait for it,
- 'finished' is emitted exactly once, AFTER the reconcile — race-free, no
  status blip a poll could catch,
- best-effort: a hook exception never blocks 'finished', so a scan can't
  get stranded as perpetually 'running'.

Both scan entry points (_run_database_update_task, _run_deep_scan_task)
set the hook before run()/run_deep_scan(); the redundant post-run calls
are removed.

5 ordering tests pin the contract (hook-before-finished, finished still
fires without a hook, hook exception doesn't block finished, hook gets
the worker). Full suite clean (only pre-existing soundcloud /app env
failures remain).
2026-06-05 19:10:31 -07:00

66 lines
2.4 KiB
Python

"""The auto-reconcile must run as the FINAL scan phase — inside the worker's
completion, BEFORE the 'finished' signal — so the scan's status stays
'running' through it. That ordering is what makes automations (which poll for
completion), the dashboard card, and the Tools page all treat the reconcile as
part of the scan and wait for it, rather than seeing 'finished' early and
missing the tail. These pin that contract on DatabaseUpdateWorker._emit_finished.
"""
from __future__ import annotations
from core.database_update_worker import DatabaseUpdateWorker
def _bare_worker():
# __new__ avoids the full media-client/config init; _emit_finished only
# touches self.callbacks + self.post_scan_hook.
w = DatabaseUpdateWorker.__new__(DatabaseUpdateWorker)
w.callbacks = {'finished': [], 'error': [], 'progress_updated': [],
'phase_changed': [], 'artist_processed': []}
w.post_scan_hook = None
return w
def test_post_scan_hook_runs_before_finished():
w = _bare_worker()
order = []
w.post_scan_hook = lambda worker: order.append('hook')
w.callbacks['finished'].append(lambda *a: order.append('finished'))
w._emit_finished(1, 2, 3, 4, 5)
assert order == ['hook', 'finished'] # reconcile happens inside the running window
def test_finished_receives_original_args():
w = _bare_worker()
got = []
w.callbacks['finished'].append(lambda *a: got.append(a))
w._emit_finished(1, 2, 3, 4, 5)
assert got == [(1, 2, 3, 4, 5)]
def test_no_hook_still_emits_finished():
# Backward-compatible: a worker with no hook signals finished exactly as before.
w = _bare_worker()
got = []
w.callbacks['finished'].append(lambda *a: got.append(a))
w._emit_finished(0, 0, 0, 0, 0)
assert got == [(0, 0, 0, 0, 0)]
def test_hook_exception_never_blocks_finished():
# A reconcile failure must not strand the scan as perpetually 'running'.
w = _bare_worker()
fired = []
w.post_scan_hook = lambda worker: (_ for _ in ()).throw(RuntimeError("boom"))
w.callbacks['finished'].append(lambda *a: fired.append(a))
w._emit_finished(1, 1, 1, 1, 1)
assert fired == [(1, 1, 1, 1, 1)]
def test_hook_receives_the_worker():
w = _bare_worker()
seen = []
w.post_scan_hook = lambda worker: seen.append(worker)
w.callbacks['finished'].append(lambda *a: None)
w._emit_finished(0, 0, 0, 0, 0)
assert seen == [w]