soulsync/tests/test_reorganize_queue.py
Broque Thomas 37aefd2ff1 Reorganize queue: race + dedupe fixes from kettui review
Five issues kettui flagged on PR #377:

- Worker race (reorganize_queue.py): _next_queued() picked an item and
  released the lock, then re-acquired to flip status='running'. A
  cancel() landing in that window marked the item cancelled but the
  worker still ran it. Replaced with _claim_next_or_wait() that picks
  AND flips under one lock acquisition.

- Wakeup race (reorganize_queue.py): _wakeup.clear() after the empty
  check could lose an enqueue's _wakeup.set(), parking a freshly-queued
  album for up to 60 seconds. Replaced Lock + Event with a single
  threading.Condition; cond.wait() releases and re-acquires atomically
  on notify.

- Bulk dedupe (reorganize_queue.py:enqueue_many): looped single-item
  enqueue, so a duplicate album_id later in the same batch could slip
  through if the worker finished the first copy before the loop
  reached the second. Now holds the lock for the whole batch and tracks
  a per-batch seen set, so intra-batch duplicates dedupe against each
  other and not just pre-existing items.

- Preview button stuck disabled (library.js:loadReorganizePreview):
  early returns and thrown errors skipped the re-enable line. Moved
  state into a canApply flag committed in finally, so any exit path
  lands the button correctly.

- DB helpers swallowing failures (music_database.py): get_album_display_meta
  and get_artist_albums_for_reorganize used to catch every Exception
  and return None / [], so a real DB outage masqueraded as "album not
  found" / "no albums". Now lets exceptions bubble; the route layer
  already wraps them as 500.

Tests:
- test_cancel_and_run_are_mutually_exclusive — hammers enqueue+cancel
  pairs and asserts the invariant that no successfully-cancelled item
  ever ran (catches regressions to the atomic pick).
- test_enqueue_many_dedupes_batch_internal_duplicates — pins the
  intra-batch dedupe.
- test_get_album_display_meta_propagates_db_errors and
  test_get_artist_albums_for_reorganize_propagates_db_errors — pin
  the bubble-up behavior.

Changelog updated in helper.js and version modal.
2026-04-26 08:40:24 -07:00

479 lines
18 KiB
Python

"""Tests for `core.reorganize_queue.ReorganizeQueue`.
Contract this test file pins:
1. **Dedupe on enqueue** — re-submitting an album that's already queued or
running returns ``{'queued': False, 'reason': 'already_queued'}`` and
the existing queue_id, never a duplicate.
2. **FIFO order** — the worker drains items in submission order.
3. **Per-item source preserved** — the source string the user picked at
enqueue time is what the runner sees, even when multiple items with
different sources are interleaved.
4. **Continue on failure** — a runner that raises (or one whose summary
reports a non-completed status) marks that item failed and the
worker moves to the next item, it does not stall.
5. **Cancel queued** — items in `queued` state can be dropped before
they reach the runner.
6. **Cancel running rejected** — the currently-running item can NOT be
cancelled, the API returns `running_cant_cancel`.
7. **Clear queued** — bulk-cancels all `queued` items at once, leaves
the running item alone.
8. **Snapshot shape** — `active`, `queued`, `recent`, and `totals` keys
are always present and reflect the current state.
9. **update_active_progress** — live progress fields propagate onto the
running item (and only the running item).
10. **Setting runner late** — items enqueued before `set_runner()` was
called still get processed once the runner shows up.
"""
import threading
import time
import pytest
from core.reorganize_queue import ReorganizeQueue, QueueItem
# --- helpers ---------------------------------------------------------------
def _make_runner(record, *, raise_on=None, summary_factory=None,
block_event=None, runtime=0.0):
"""Build a runner closure that records what it was called with.
Args:
record: list to append `(queue_id, source)` to per call.
raise_on: queue_id (or set of queue_ids) for which the runner
should raise — used to test continue-on-failure.
summary_factory: optional callable `(item) -> summary dict` to
override the default `{'status': 'completed', ...}` shape.
block_event: optional `threading.Event` the runner blocks on
before returning — used to keep an item in 'running' state
while the test pokes at it.
runtime: seconds the runner sleeps before returning.
"""
raise_set = set()
if isinstance(raise_on, str):
raise_set = {raise_on}
elif raise_on:
raise_set = set(raise_on)
def runner(item):
record.append((item.queue_id, item.source))
if block_event is not None:
block_event.wait(timeout=2.0)
if runtime:
time.sleep(runtime)
if item.queue_id in raise_set:
raise RuntimeError(f"Simulated failure for {item.queue_id}")
if summary_factory is not None:
return summary_factory(item)
return {
'status': 'completed',
'source': item.source or 'spotify',
'total': 1,
'moved': 1,
'skipped': 0,
'failed': 0,
'errors': [],
}
return runner
def _enqueue(queue, *, album_id, source=None, title=None, artist='Aerosmith'):
return queue.enqueue(
album_id=album_id,
album_title=title or f"Album {album_id}",
artist_id='artist-1',
artist_name=artist,
source=source,
)
def _wait_for(predicate, timeout=2.0, interval=0.02):
"""Poll until predicate() is truthy or timeout elapses."""
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(interval)
return False
@pytest.fixture
def queue():
q = ReorganizeQueue()
yield q
q.stop()
# --- tests -----------------------------------------------------------------
def test_enqueue_returns_queued_with_position(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
r1 = _enqueue(queue, album_id='alb-1')
# Wait for the worker to actually pick up alb-1 so r2 lands while
# alb-1 is running, not while it's still queued — otherwise the
# position number depends on thread-scheduling timing.
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
r2 = _enqueue(queue, album_id='alb-2')
assert r1['queued'] is True
assert r1['position'] == 1
assert r2['queued'] is True
assert r2['position'] == 1
block.set()
def test_enqueue_same_album_dedupes(queue):
queue.set_runner(_make_runner([], block_event=threading.Event()))
r1 = _enqueue(queue, album_id='alb-1', source='spotify')
r2 = _enqueue(queue, album_id='alb-1', source='deezer') # different source
assert r1['queued'] is True
assert r2['queued'] is False
assert r2['reason'] == 'already_queued'
assert r2['queue_id'] == r1['queue_id']
def test_dedupe_releases_after_completion(queue):
"""Once an item finishes (done/failed/cancelled), the same album_id
can be re-enqueued. Otherwise users couldn't retry after a fix."""
record = []
queue.set_runner(_make_runner(record))
r1 = _enqueue(queue, album_id='alb-1')
assert _wait_for(lambda: any(r[0] == r1['queue_id'] for r in record))
# Wait for the item to flip into the recent bucket.
assert _wait_for(lambda: queue.snapshot()['active'] is None)
r2 = _enqueue(queue, album_id='alb-1')
assert r2['queued'] is True
assert r2['queue_id'] != r1['queue_id']
def test_fifo_order(queue):
record = []
queue.set_runner(_make_runner(record))
ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(5)]
assert _wait_for(lambda: len(record) == 5)
assert [r[0] for r in record] == ids
def test_per_item_source_preserved(queue):
record = []
queue.set_runner(_make_runner(record))
sources = ['spotify', 'deezer', 'itunes', None, 'discogs']
for i, src in enumerate(sources):
_enqueue(queue, album_id=f'alb-{i}', source=src)
assert _wait_for(lambda: len(record) == len(sources))
assert [r[1] for r in record] == sources
def test_continue_on_runner_exception(queue):
"""A runner that raises must not stall the queue — the item is
marked failed and the next item runs."""
record = []
# Pre-allocate queue_ids by enqueuing first, then point the runner
# at the middle one. Block the runner so all three sit in the queue
# before any actually run.
block = threading.Event()
raise_target = {}
def runner(item):
record.append((item.queue_id, item.source))
block.wait(timeout=2.0)
if item.queue_id == raise_target.get('id'):
raise RuntimeError(f"Simulated failure for {item.queue_id}")
return {
'status': 'completed', 'source': 'spotify',
'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
queue.set_runner(runner)
ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(3)]
raise_target['id'] = ids[1]
block.set()
assert _wait_for(lambda: len(record) == 3)
assert [r[0] for r in record] == ids
assert _wait_for(lambda: queue.snapshot()['active'] is None)
snap = queue.snapshot()
recent_by_id = {r['queue_id']: r for r in snap['recent']}
assert recent_by_id[ids[0]]['status'] == 'done'
assert recent_by_id[ids[1]]['status'] == 'failed'
assert recent_by_id[ids[2]]['status'] == 'done'
def test_failed_status_when_runner_reports_failed_tracks(queue):
"""A summary with ``failed > 0`` should mark the queue item as
'failed' even if the runner returned normally."""
queue.set_runner(_make_runner([], summary_factory=lambda item: {
'status': 'completed',
'source': 'spotify',
'total': 5,
'moved': 4,
'skipped': 0,
'failed': 1,
'errors': [{'track_id': 't-1', 'title': 'X', 'error': 'boom'}],
}))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
# Wait for the item to land in `recent` (active is None both before
# the worker picks up the item and after it's done — only the
# presence in recent is unambiguous).
assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent']))
snap = queue.snapshot()
item = next(i for i in snap['recent'] if i['queue_id'] == qid)
assert item['status'] == 'failed'
assert item['moved'] == 4
assert item['failed'] == 1
assert item['error'] == 'boom'
def test_failed_status_when_runner_reports_non_completed_status(queue):
"""``status='no_source_id'`` and friends are setup-failures — they
leave failed=0 but the item is still NOT a success."""
queue.set_runner(_make_runner([], summary_factory=lambda item: {
'status': 'no_source_id',
'source': None,
'total': 0,
'moved': 0,
'skipped': 0,
'failed': 0,
'errors': [],
}))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent']))
snap = queue.snapshot()
item = next(r for r in snap['recent'] if r['queue_id'] == qid)
assert item['status'] == 'failed'
assert item['result_status'] == 'no_source_id'
def test_cancel_queued_item(queue):
"""Cancel BEFORE the worker reaches the item drops it cleanly."""
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
first = _enqueue(queue, album_id='alb-1')['queue_id'] # gets pulled to running, blocks
second = _enqueue(queue, album_id='alb-2')['queue_id'] # sits in queued
# Wait for first to be running so we know the worker is parked on it.
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
result = queue.cancel(second)
assert result['cancelled'] is True
snap = queue.snapshot()
assert all(i['queue_id'] != second for i in snap['queued'])
# And the cancelled one shows up in recent with status 'cancelled'.
assert any(i['queue_id'] == second and i['status'] == 'cancelled' for i in snap['recent'])
block.set() # release the running item
def test_cancel_running_rejected(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
result = queue.cancel(qid)
assert result['cancelled'] is False
assert result['reason'] == 'running_cant_cancel'
block.set()
def test_cancel_unknown_id(queue):
result = queue.cancel('does-not-exist')
assert result['cancelled'] is False
assert result['reason'] == 'not_found'
def test_clear_queued_bulk_cancel(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
_enqueue(queue, album_id='alb-1') # running, blocked
queued_ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(2, 6)]
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
assert _wait_for(lambda: len(queue.snapshot()['queued']) == 4)
cancelled = queue.clear_queued()
assert cancelled == 4
snap = queue.snapshot()
assert len(snap['queued']) == 0
# Running item is untouched.
assert snap['active'] is not None
cancelled_in_recent = [i for i in snap['recent'] if i['status'] == 'cancelled']
assert {i['queue_id'] for i in cancelled_in_recent} == set(queued_ids)
block.set()
def test_snapshot_shape(queue):
snap = queue.snapshot()
assert set(snap.keys()) == {'active', 'queued', 'recent', 'totals'}
assert set(snap['totals'].keys()) >= {'queued', 'running', 'done', 'failed', 'cancelled'}
assert snap['active'] is None
assert snap['queued'] == []
assert snap['recent'] == []
def test_update_active_progress_only_targets_running(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
queue.update_active_progress(
queue_id=qid,
current_track='Dream On',
total=8,
processed=3,
moved=3,
skipped=0,
failed=0,
)
snap = queue.snapshot()
assert snap['active']['current_track'] == 'Dream On'
assert snap['active']['progress_total'] == 8
assert snap['active']['progress_processed'] == 3
assert snap['active']['moved'] == 3
block.set()
def test_update_progress_for_unknown_id_is_noop(queue):
"""Calling update_active_progress for an item that isn't running
must not raise, must not corrupt other items."""
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
queue.update_active_progress(queue_id='not-a-real-id', current_track='X', total=999)
snap = queue.snapshot()
assert snap['active']['queue_id'] == qid
assert snap['active']['progress_total'] == 0 # unchanged
block.set()
def test_enqueue_many_tallies_enqueued_and_dedupes(queue):
"""Bulk enqueue returns ``{enqueued, already_queued, total}`` so
the route handler doesn't have to count itself. Re-enqueuing the
same album-id twice in the same batch dedupes."""
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
# Pre-existing item — should appear as already_queued.
queue.enqueue(album_id='alb-existing', album_title='X',
artist_id='ar-1', artist_name='A', source=None)
# Wait for it to be running so the dedupe path triggers.
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
items = [
{'album_id': 'alb-existing', 'album_title': 'X', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-new-1', 'album_title': 'Y', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-new-2', 'album_title': 'Z', 'artist_id': 'ar-1', 'artist_name': 'A'},
]
result = queue.enqueue_many(items)
assert result == {'enqueued': 2, 'already_queued': 1, 'total': 3}
block.set()
def test_enqueue_many_carries_source_per_item(queue):
"""Each dict's ``source`` is honoured independently — the bulk
helper doesn't collapse them to one value."""
record = []
queue.set_runner(_make_runner(record))
items = [
{'album_id': 'a', 'album_title': 'A', 'artist_id': 'x', 'artist_name': 'X', 'source': 'spotify'},
{'album_id': 'b', 'album_title': 'B', 'artist_id': 'x', 'artist_name': 'X', 'source': 'deezer'},
{'album_id': 'c', 'album_title': 'C', 'artist_id': 'x', 'artist_name': 'X', 'source': None},
]
queue.enqueue_many(items)
assert _wait_for(lambda: len(record) == 3)
assert [r[1] for r in record] == ['spotify', 'deezer', None]
def test_enqueue_many_handles_empty_list(queue):
queue.set_runner(_make_runner([]))
assert queue.enqueue_many([]) == {'enqueued': 0, 'already_queued': 0, 'total': 0}
def test_enqueue_many_dedupes_batch_internal_duplicates(queue):
"""Same album_id appearing twice in the same bulk request must be
deduped against each other — not just against pre-existing items.
Regression for the race where a fast runner finishes the first copy
before the loop reaches the second, letting both slip through."""
record = []
queue.set_runner(_make_runner(record))
items = [
{'album_id': 'alb-x', 'album_title': 'X', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-y', 'album_title': 'Y', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-x', 'album_title': 'X (dup)', 'artist_id': 'ar-1', 'artist_name': 'A'},
]
result = queue.enqueue_many(items)
assert result == {'enqueued': 2, 'already_queued': 1, 'total': 3}
# Wait for the queue to drain, then give the worker a moment to
# try (and fail) to pick a phantom third item. If the dedupe leaked,
# a third runner call would land here.
assert _wait_for(lambda: queue.snapshot()['active'] is None and not queue.snapshot()['queued'])
time.sleep(0.05)
assert len(record) == 2
def test_cancel_and_run_are_mutually_exclusive(queue):
"""Regression for kettui's ``_next_queued() → status flip`` race:
a successfully-cancelled item must NEVER have its runner invoked.
With the old non-atomic pick + flip, cancel could land between
the worker's pick and its flip-to-running, leaving the item
marked 'cancelled' but the worker still runs it.
Hammers many enqueue-then-immediately-cancel pairs to exercise the
race window. After draining, every queue_id whose cancel returned
``cancelled: True`` must NOT appear in the runner's record."""
runner_called: set = set()
runner_lock = threading.Lock()
def runner(item):
with runner_lock:
runner_called.add(item.queue_id)
# Slight runtime widens the window where overlapping cancels
# could (incorrectly) fire on a running item.
time.sleep(0.002)
return {
'status': 'completed', 'source': 'spotify',
'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
queue.set_runner(runner)
successful_cancels: set = set()
for i in range(50):
r = _enqueue(queue, album_id=f'alb-race-{i}')
# Immediately try to cancel — half will land while item is still
# 'queued', half will land after worker has flipped to 'running'.
if queue.cancel(r['queue_id'])['cancelled']:
successful_cancels.add(r['queue_id'])
assert _wait_for(
lambda: queue.snapshot()['active'] is None and not queue.snapshot()['queued'],
timeout=5.0,
)
leaked = successful_cancels & runner_called
assert not leaked, f"Runner ran for cancelled items: {leaked}"
def test_no_runner_marks_item_failed(queue):
"""If the worker pulls an item but no runner has been set, the item
must be marked failed (not silently dropped). In practice
web_server.py wires the runner at module load before any request
can land, so this is a defensive-failure path more than a real
one — but the failure mode must be loud."""
queue.set_runner(None)
qid = _enqueue(queue, album_id='alb-orphan')['queue_id']
assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent']))
snap = queue.snapshot()
failed = next(i for i in snap['recent'] if i['queue_id'] == qid)
assert failed['status'] == 'failed'
assert 'runner' in (failed['error'] or '').lower()