soulsync/tests/downloads/test_background_download_worker.py
Broque Thomas 0ee092979e Self-review fixes before opening PR
Three findings from a final review pass:

1. **Worker clobbered Cancelled with Errored when impl returned
   None / raised mid-cancel.** The legacy per-client thread workers
   each had a guard (``if state != 'Cancelled': state = 'Errored'``);
   the shared worker dropped it. Fix: new ``_mark_terminal`` helper
   in BackgroundDownloadWorker reads current state before writing
   the terminal one and leaves Cancelled alone. SoundCloud test
   updated back to the strict Cancelled-only assertion (had been
   loosened to accept Errored as a workaround). Two new pinning
   tests catch the regression.

2. **Dead code in engine.py.** ``find_record`` and
   ``iter_all_records`` had no production callers — only tests.
   Removed them. Concurrent-add stress test rewritten to use the
   per-source iterator that's actually in use.

3. **Silent ``except Exception: pass`` in cross-source query
   methods.** Faithful to legacy behavior (one source failing
   shouldn't take down aggregation) but Cin's standard is "log
   even when you swallow." Each silent-swallow site now logs at
   debug level so the source name + exception are inspectable
   without adding warning-level noise.

Suite still green (2049 passed).
2026-05-04 16:24:13 -07:00

406 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for `BackgroundDownloadWorker` (Phase C1).
These tests pin the worker's state-machine semantics, semaphore
serialization, rate-limit-delay behavior, and exception handling.
Future phases (C2C7) migrate each per-source client onto this
worker — these tests stay green as the regression net.
"""
from __future__ import annotations
import threading
import time
from core.download_engine import DownloadEngine
# ---------------------------------------------------------------------------
# Dispatch — initial state + thread spawn
# ---------------------------------------------------------------------------
def test_dispatch_returns_uuid_download_id():
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
return '/tmp/file.flac'
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='abc123',
display_name='Some Song',
original_filename='abc123||Some Song',
impl_callable=impl,
)
assert len(download_id) == 36 # UUID4
assert download_id.count('-') == 4
def test_dispatch_inserts_initial_record_with_canonical_state():
"""Pinning: initial record matches the legacy per-client shape so
consumers reading the state dict via API or context-key lookup
keep working unchanged after migration."""
engine = DownloadEngine()
captured = threading.Event()
def impl(download_id, target_id, display_name):
captured.wait(timeout=1.0) # block so we can read 'Initializing' / 'InProgress' state
return '/tmp/file.flac'
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='abc',
display_name='X',
original_filename='abc||X',
impl_callable=impl,
)
record = engine.get_record('youtube', download_id)
assert record is not None
assert record['id'] == download_id
assert record['filename'] == 'abc||X'
assert record['username'] == 'youtube'
assert record['state'] in ('Initializing', 'InProgress, Downloading')
assert record['progress'] == 0.0
assert record['file_path'] is None
captured.set() # release impl
def test_dispatch_merges_extra_record_fields():
"""Pinning: source-specific slots (video_id, track_id, etc.)
merge into the initial record so frontend + status APIs that
read those keys keep working."""
engine = DownloadEngine()
started = threading.Event()
release = threading.Event()
def impl(download_id, target_id, display_name):
started.set()
release.wait(timeout=1.0)
return '/tmp/x.flac'
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid123',
display_name='Title',
original_filename='vid123||Title',
impl_callable=impl,
extra_record_fields={
'video_id': 'vid123',
'url': 'https://youtube.com/watch?v=vid123',
'title': 'Title',
},
)
started.wait(timeout=1.0)
record = engine.get_record('youtube', download_id)
assert record['video_id'] == 'vid123'
assert record['url'] == 'https://youtube.com/watch?v=vid123'
assert record['title'] == 'Title'
release.set()
def test_dispatch_username_override_preserves_legacy_slot():
"""Pinning: Deezer's record stores `'deezer_dl'` (legacy) in the
username slot, not the canonical `'deezer'`. Worker accepts
override so frontend status indicators keep their key."""
engine = DownloadEngine()
release = threading.Event()
def impl(download_id, target_id, display_name):
release.wait(timeout=1.0)
return '/tmp/x.flac'
download_id = engine.worker.dispatch(
source_name='deezer',
target_id='999',
display_name='X',
original_filename='999||X',
impl_callable=impl,
username_override='deezer_dl',
)
record = engine.get_record('deezer', download_id)
assert record['username'] == 'deezer_dl'
release.set()
# ---------------------------------------------------------------------------
# Worker lifecycle — state transitions
# ---------------------------------------------------------------------------
def test_worker_marks_completed_on_successful_impl():
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
return '/tmp/done.flac'
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
# Wait for thread to finish.
deadline = time.time() + 2.0
while time.time() < deadline:
record = engine.get_record('youtube', download_id)
if record and record['state'] == 'Completed, Succeeded':
break
time.sleep(0.01)
record = engine.get_record('youtube', download_id)
assert record['state'] == 'Completed, Succeeded'
assert record['progress'] == 100.0
assert record['file_path'] == '/tmp/done.flac'
def test_worker_preserves_cancelled_when_impl_returns_none():
"""Pinning: if the user cancels mid-download (state flips to
Cancelled via engine.update_record from cancel_download), the
worker must NOT clobber it back to Errored when impl returns
None. The legacy per-client thread workers had this guard
(``if state != 'Cancelled': state = 'Errored'``); the shared
worker preserves that contract."""
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
# Simulate user cancelling mid-impl by writing Cancelled.
engine.update_record('youtube', download_id, {'state': 'Cancelled'})
return None # impl returns None because download was interrupted
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
deadline = time.time() + 2.0
while time.time() < deadline:
record = engine.get_record('youtube', download_id)
if record and record['state'] in ('Cancelled', 'Errored'):
break
time.sleep(0.01)
record = engine.get_record('youtube', download_id)
assert record['state'] == 'Cancelled', (
f"Worker clobbered user's Cancelled with {record['state']}"
)
def test_worker_preserves_cancelled_when_impl_raises():
"""Same Cancelled-preserve guard, but for the impl-raises path."""
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
engine.update_record('youtube', download_id, {'state': 'Cancelled'})
raise RuntimeError("simulated mid-cancel exception")
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
deadline = time.time() + 2.0
while time.time() < deadline:
record = engine.get_record('youtube', download_id)
if record and record['state'] in ('Cancelled', 'Errored'):
break
time.sleep(0.01)
assert engine.get_record('youtube', download_id)['state'] == 'Cancelled'
def test_worker_marks_errored_when_impl_returns_none():
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
return None # signaling failure
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
deadline = time.time() + 2.0
while time.time() < deadline:
record = engine.get_record('youtube', download_id)
if record and record['state'] == 'Errored':
break
time.sleep(0.01)
record = engine.get_record('youtube', download_id)
assert record['state'] == 'Errored'
# file_path stays None (default).
assert record['file_path'] is None
def test_worker_marks_errored_and_captures_message_when_impl_raises():
engine = DownloadEngine()
def impl(download_id, target_id, display_name):
raise RuntimeError("api blew up")
download_id = engine.worker.dispatch(
source_name='youtube',
target_id='vid',
display_name='X',
original_filename='vid||X',
impl_callable=impl,
)
deadline = time.time() + 2.0
while time.time() < deadline:
record = engine.get_record('youtube', download_id)
if record and record['state'] == 'Errored':
break
time.sleep(0.01)
record = engine.get_record('youtube', download_id)
assert record['state'] == 'Errored'
assert 'api blew up' in record.get('error', '')
# ---------------------------------------------------------------------------
# Per-source semaphore serialization
# ---------------------------------------------------------------------------
def test_semaphore_serializes_downloads_for_same_source():
"""Pinning: with concurrency=1 (default), two dispatches against
the same source run sequentially. The legacy per-client
semaphore did the same — consumers depend on this for
rate-limit safety against APIs like YouTube."""
engine = DownloadEngine()
in_progress = threading.Event()
can_finish = threading.Event()
overlap_count = 0
overlap_lock = threading.Lock()
active_count = [0]
def impl(download_id, target_id, display_name):
nonlocal overlap_count
with overlap_lock:
active_count[0] += 1
if active_count[0] > 1:
overlap_count += 1
in_progress.set()
can_finish.wait(timeout=2.0)
with overlap_lock:
active_count[0] -= 1
return '/tmp/x.flac'
# Default concurrency=1 — two dispatches must serialize.
dl1 = engine.worker.dispatch(
source_name='youtube', target_id='a', display_name='A',
original_filename='a||A', impl_callable=impl,
)
in_progress.wait(timeout=1.0)
in_progress.clear()
dl2 = engine.worker.dispatch(
source_name='youtube', target_id='b', display_name='B',
original_filename='b||B', impl_callable=impl,
)
# Give second dispatch a chance to attempt running in parallel
# (it should be blocked on the semaphore).
time.sleep(0.1)
assert overlap_count == 0, "second dispatch should be blocked behind semaphore"
# Release first; second proceeds.
can_finish.set()
# Wait for both to finish.
deadline = time.time() + 3.0
while time.time() < deadline:
r1 = engine.get_record('youtube', dl1)
r2 = engine.get_record('youtube', dl2)
if r1 and r2 and r1['state'] == 'Completed, Succeeded' and r2['state'] == 'Completed, Succeeded':
break
time.sleep(0.01)
assert overlap_count == 0
def test_semaphore_concurrency_can_be_increased():
"""When `set_concurrency(source, N)` is called, N downloads can
run in parallel for that source. Used by sources that support
parallel transfers (none today, but contract supports it)."""
engine = DownloadEngine()
engine.worker.set_concurrency('parallel-source', 3)
in_flight = []
in_flight_lock = threading.Lock()
can_finish = threading.Event()
max_observed = [0]
def impl(download_id, target_id, display_name):
with in_flight_lock:
in_flight.append(download_id)
max_observed[0] = max(max_observed[0], len(in_flight))
can_finish.wait(timeout=2.0)
with in_flight_lock:
in_flight.remove(download_id)
return '/tmp/x.flac'
for i in range(3):
engine.worker.dispatch(
source_name='parallel-source',
target_id=str(i),
display_name=f'd{i}',
original_filename=f'{i}||d{i}',
impl_callable=impl,
)
# Give threads time to ramp up.
time.sleep(0.2)
can_finish.set()
# Wait for them to finish.
time.sleep(0.5)
assert max_observed[0] == 3
# ---------------------------------------------------------------------------
# Per-source rate-limit delay
# ---------------------------------------------------------------------------
def test_delay_enforces_minimum_gap_between_downloads():
"""Pinning: YouTube uses 3s delay today (legacy
`_download_delay`). Worker-driven delay must enforce the same
gap so YouTube doesn't 429."""
engine = DownloadEngine()
engine.worker.set_delay('youtube', 0.2) # 200ms — short for test speed
completion_times = []
def impl(download_id, target_id, display_name):
completion_times.append(time.time())
return '/tmp/x.flac'
# Two back-to-back dispatches.
engine.worker.dispatch(
source_name='youtube', target_id='a', display_name='A',
original_filename='a||A', impl_callable=impl,
)
engine.worker.dispatch(
source_name='youtube', target_id='b', display_name='B',
original_filename='b||B', impl_callable=impl,
)
# Wait for both to finish (semaphore serializes + delay).
deadline = time.time() + 3.0
while time.time() < deadline and len(completion_times) < 2:
time.sleep(0.01)
assert len(completion_times) == 2
gap = completion_times[1] - completion_times[0]
# Gap is at LEAST the configured delay.
assert gap >= 0.18, f"expected gap >= 0.2s, got {gap:.3f}"