Downloads: cached-first quarantine retry — stop re-searching the same source
Each AcoustID/integrity quarantine retry re-ran the FULL search (all queries, all sources) before picking the next-best candidate — so a track that failed verification a dozen times re-queried Soulseek a dozen times (~3 min/cycle in the field). The next-best pick was already sitting in cached_candidates. Now the monitor flags the re-queue as a quarantine retry; the worker walks the already-found candidates first (skipping used + budget-exhausted sources) and hands them straight to the download path — no search. A source is searched exactly once: once its candidates are cached, later quarantine retries exclude it (searched_sources) so the hybrid chain falls through to a not-yet-searched source instead of re-querying the spent one. Fresh downloads and the monitor's dead-connection/stuck retries clear searched_sources and search fresh, so the only re-search is for a genuinely new source or a dead peer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
5e0f86c5f5
commit
07ca7eacfa
4 changed files with 244 additions and 3 deletions
|
|
@ -237,6 +237,12 @@ def requeue_quarantined_task_for_retry(task_id, batch_id, trigger):
|
||||||
task['used_sources'] = used_sources
|
task['used_sources'] = used_sources
|
||||||
|
|
||||||
task['quarantine_retry_count'] = total_count + 1
|
task['quarantine_retry_count'] = total_count + 1
|
||||||
|
# Flag the re-run as a quarantine retry so the worker walks the
|
||||||
|
# already-found candidates (cached-first) before re-searching — the
|
||||||
|
# connection was fine, the content was just wrong. Dead-connection /
|
||||||
|
# stuck retries (handled elsewhere in the monitor) deliberately do NOT
|
||||||
|
# set this, so they re-search fresh.
|
||||||
|
task['_quarantine_retry'] = True
|
||||||
# Drop the stale download identity + the prior attempt's quarantine link.
|
# Drop the stale download identity + the prior attempt's quarantine link.
|
||||||
task.pop('download_id', None)
|
task.pop('download_id', None)
|
||||||
task.pop('username', None)
|
task.pop('username', None)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,65 @@ from core.spotify_client import Track as SpotifyTrack
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_worker_source(username):
|
||||||
|
"""Logical source bucket for a candidate's username (Soulseek peers all
|
||||||
|
collapse to 'soulseek'; streaming sources keep their name). Mirrors the
|
||||||
|
monitor's resolver — imported lazily to avoid an import cycle."""
|
||||||
|
try:
|
||||||
|
from core.downloads.monitor import _resolve_download_source
|
||||||
|
return _resolve_download_source(username)
|
||||||
|
except Exception:
|
||||||
|
return 'soulseek'
|
||||||
|
|
||||||
|
|
||||||
|
def _cand_user_file(candidate):
|
||||||
|
"""Read (username, filename) from a candidate that may be a TrackResult
|
||||||
|
object or a plain dict (tests / cached raw rows)."""
|
||||||
|
if isinstance(candidate, dict):
|
||||||
|
return candidate.get('username'), candidate.get('filename')
|
||||||
|
return getattr(candidate, 'username', None), getattr(candidate, 'filename', None)
|
||||||
|
|
||||||
|
|
||||||
|
def _try_cached_candidates(task_id, batch_id, track, deps):
|
||||||
|
"""Quarantine-retry fast path: attempt the already-found candidates before
|
||||||
|
re-searching anything.
|
||||||
|
|
||||||
|
When a verified-bad file is re-queued, the connection was fine (the file
|
||||||
|
downloaded, it was just the wrong/broken content) — so the next-best pick is
|
||||||
|
almost always already sitting in ``cached_candidates``. Walk those (skipping
|
||||||
|
sources already tried or budget-exhausted) and hand them to the normal
|
||||||
|
download path. Returns True if a download was started; False to fall through
|
||||||
|
to a fresh search (which only happens for a not-yet-searched source).
|
||||||
|
"""
|
||||||
|
with tasks_lock:
|
||||||
|
task = download_tasks.get(task_id)
|
||||||
|
if not task:
|
||||||
|
return False
|
||||||
|
cached = list(task.get('cached_candidates') or [])
|
||||||
|
used = set(task.get('used_sources') or ())
|
||||||
|
exhausted = {str(s).lower() for s in (task.get('exhausted_download_sources') or ())}
|
||||||
|
|
||||||
|
remaining = []
|
||||||
|
for c in cached:
|
||||||
|
uname, fname = _cand_user_file(c)
|
||||||
|
if not uname or not fname:
|
||||||
|
continue
|
||||||
|
if f"{uname}_{fname}" in used:
|
||||||
|
continue
|
||||||
|
if _resolve_worker_source(uname).lower() in exhausted:
|
||||||
|
continue
|
||||||
|
remaining.append(c)
|
||||||
|
|
||||||
|
if not remaining:
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[Modal Worker] Quarantine retry: trying {len(remaining)} cached "
|
||||||
|
f"candidate(s) before re-searching (task {task_id})"
|
||||||
|
)
|
||||||
|
return deps.attempt_download_with_candidates(task_id, remaining, track, batch_id)
|
||||||
|
|
||||||
|
|
||||||
def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any) -> Optional[str]:
|
def _private_album_bundle_staging_miss_reason(batch_id: Optional[str], deps: Any) -> Optional[str]:
|
||||||
"""Return a user-facing miss reason when per-track search should stop.
|
"""Return a user-facing miss reason when per-track search should stop.
|
||||||
|
|
||||||
|
|
@ -206,6 +265,26 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
||||||
download_tasks[task_id]['used_sources'] = set()
|
download_tasks[task_id]['used_sources'] = set()
|
||||||
# Else: keep existing used_sources to avoid retrying same failed hosts
|
# Else: keep existing used_sources to avoid retrying same failed hosts
|
||||||
|
|
||||||
|
# Cached-first quarantine retry. The monitor sets ``_quarantine_retry``
|
||||||
|
# when a verified-bad file is re-queued; in that case we walk the
|
||||||
|
# already-found candidates before re-searching (the connection was fine,
|
||||||
|
# just the content was wrong). A NON-quarantine entry (fresh download, or
|
||||||
|
# the monitor's dead-connection/stuck retry) instead starts a new search
|
||||||
|
# generation: clear the searched-source memory so each source can be
|
||||||
|
# searched fresh again.
|
||||||
|
with tasks_lock:
|
||||||
|
_t = download_tasks.get(task_id, {})
|
||||||
|
is_quarantine_retry = bool(_t.pop('_quarantine_retry', False))
|
||||||
|
if not is_quarantine_retry:
|
||||||
|
_t.pop('searched_sources', None)
|
||||||
|
if is_quarantine_retry and _try_cached_candidates(task_id, batch_id, track, deps):
|
||||||
|
with tasks_lock:
|
||||||
|
used_filename = download_tasks.get(task_id, {}).get('filename')
|
||||||
|
used_username = download_tasks.get(task_id, {}).get('username')
|
||||||
|
if used_filename and used_username:
|
||||||
|
deps.store_batch_source(batch_id, used_username, used_filename)
|
||||||
|
return
|
||||||
|
|
||||||
# 1. Generate multiple search queries (like GUI's generate_smart_search_queries)
|
# 1. Generate multiple search queries (like GUI's generate_smart_search_queries)
|
||||||
artist_name = track.artists[0] if track.artists else None
|
artist_name = track.artists[0] if track.artists else None
|
||||||
track_name = track.name
|
track_name = track.name
|
||||||
|
|
@ -294,10 +373,16 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
||||||
# source instead of re-fetching the same exhausted one (e.g. Soulseek
|
# source instead of re-fetching the same exhausted one (e.g. Soulseek
|
||||||
# keeps returning fresh wrong peers — once its budget is gone, switch to
|
# keeps returning fresh wrong peers — once its budget is gone, switch to
|
||||||
# HiFi/Tidal/…). See monitor.requeue_quarantined_task_for_retry.
|
# HiFi/Tidal/…). See monitor.requeue_quarantined_task_for_retry.
|
||||||
|
# On a quarantine retry we also exclude sources we've ALREADY searched:
|
||||||
|
# their candidates are walked via the cached-first path, so re-searching
|
||||||
|
# them is the wasteful repeat the cached-first design removes. The chain
|
||||||
|
# then falls through to a not-yet-searched source. Fresh / dead-connection
|
||||||
|
# runs cleared searched_sources above, so they search everything again.
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
_exhausted_sources = [
|
_t = download_tasks.get(task_id, {})
|
||||||
str(s) for s in (download_tasks.get(task_id, {}).get('exhausted_download_sources') or ())
|
_exhausted_sources = [str(s) for s in (_t.get('exhausted_download_sources') or ())]
|
||||||
]
|
if is_quarantine_retry:
|
||||||
|
_exhausted_sources.extend(str(s) for s in (_t.get('searched_sources') or ()))
|
||||||
for query_index, query in enumerate(search_queries):
|
for query_index, query in enumerate(search_queries):
|
||||||
# Cancellation check before each query
|
# Cancellation check before each query
|
||||||
with tasks_lock:
|
with tasks_lock:
|
||||||
|
|
@ -373,6 +458,17 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
||||||
return
|
return
|
||||||
# Store candidates for retry fallback (like GUI)
|
# Store candidates for retry fallback (like GUI)
|
||||||
download_tasks[task_id]['cached_candidates'] = candidates
|
download_tasks[task_id]['cached_candidates'] = candidates
|
||||||
|
# Remember which sources produced candidates so a later
|
||||||
|
# quarantine retry walks them via cache instead of
|
||||||
|
# re-searching them (cached-first design).
|
||||||
|
_searched = download_tasks[task_id].get('searched_sources')
|
||||||
|
if not isinstance(_searched, set):
|
||||||
|
_searched = set()
|
||||||
|
for _c in candidates:
|
||||||
|
_u, _ = _cand_user_file(_c)
|
||||||
|
if _u:
|
||||||
|
_searched.add(_resolve_worker_source(_u))
|
||||||
|
download_tasks[task_id]['searched_sources'] = _searched
|
||||||
|
|
||||||
# Try to download with these candidates
|
# Try to download with these candidates
|
||||||
success = deps.attempt_download_with_candidates(task_id, candidates, track, batch_id)
|
success = deps.attempt_download_with_candidates(task_id, candidates, track, batch_id)
|
||||||
|
|
@ -457,6 +553,14 @@ def download_track_worker(task_id: str, batch_id: Optional[str], deps: TaskWorke
|
||||||
fb_candidates = deps.get_valid_candidates(fb_results, track, fb_query)
|
fb_candidates = deps.get_valid_candidates(fb_results, track, fb_query)
|
||||||
if fb_candidates:
|
if fb_candidates:
|
||||||
logger.warning(f"[Hybrid Fallback] {fallback_source} found {len(fb_candidates)} valid candidates!")
|
logger.warning(f"[Hybrid Fallback] {fallback_source} found {len(fb_candidates)} valid candidates!")
|
||||||
|
with tasks_lock:
|
||||||
|
if task_id in download_tasks:
|
||||||
|
download_tasks[task_id]['cached_candidates'] = fb_candidates
|
||||||
|
_searched = download_tasks[task_id].get('searched_sources')
|
||||||
|
if not isinstance(_searched, set):
|
||||||
|
_searched = set()
|
||||||
|
_searched.add(_resolve_worker_source(fallback_source))
|
||||||
|
download_tasks[task_id]['searched_sources'] = _searched
|
||||||
success = deps.attempt_download_with_candidates(task_id, fb_candidates, track, batch_id)
|
success = deps.attempt_download_with_candidates(task_id, fb_candidates, track, batch_id)
|
||||||
if success:
|
if success:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ class _FakeClient:
|
||||||
self._results = results if results is not None else []
|
self._results = results if results is not None else []
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.search_calls = []
|
self.search_calls = []
|
||||||
|
self.exclude_calls = [] # exclude_sources arg per search() call
|
||||||
self._client_map = {}
|
self._client_map = {}
|
||||||
for k, v in (subclients or {}).items():
|
for k, v in (subclients or {}).items():
|
||||||
if k in self._CLIENT_NAMES:
|
if k in self._CLIENT_NAMES:
|
||||||
|
|
@ -58,6 +59,7 @@ class _FakeClient:
|
||||||
|
|
||||||
async def search(self, query, timeout=30, exclude_sources=None):
|
async def search(self, query, timeout=30, exclude_sources=None):
|
||||||
self.search_calls.append((query, timeout))
|
self.search_calls.append((query, timeout))
|
||||||
|
self.exclude_calls.append(exclude_sources)
|
||||||
return (self._results, None)
|
return (self._results, None)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -506,6 +508,121 @@ def test_hybrid_fallback_skipped_when_mode_not_hybrid():
|
||||||
assert yt.search_calls == []
|
assert yt.search_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cached-first quarantine retry: walk already-found candidates before any
|
||||||
|
# re-search; never re-search a source whose candidates are spent (only switch
|
||||||
|
# to a not-yet-searched source). See task_worker cached-first phase.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _Cand:
|
||||||
|
def __init__(self, username, filename):
|
||||||
|
self.username = username
|
||||||
|
self.filename = filename
|
||||||
|
|
||||||
|
|
||||||
|
def test_quarantine_retry_tries_cached_candidates_without_searching():
|
||||||
|
_seed_task(
|
||||||
|
_quarantine_retry=True,
|
||||||
|
cached_candidates=[_Cand('peerA', 'f1.flac'), _Cand('peerB', 'f2.flac')],
|
||||||
|
used_sources={'peerA_f1.flac'}, # peerA already tried
|
||||||
|
)
|
||||||
|
attempted = []
|
||||||
|
|
||||||
|
def _attempt(task_id, candidates, track, batch_id):
|
||||||
|
attempted.append([getattr(c, 'filename', None) for c in candidates])
|
||||||
|
return True
|
||||||
|
|
||||||
|
sk = _FakeClient(results=['should-not-be-used'])
|
||||||
|
deps, _ = _build_deps(
|
||||||
|
soulseek=sk,
|
||||||
|
matching=_FakeMatchEngine(queries=['q1']),
|
||||||
|
attempt_download_with_candidates=_attempt,
|
||||||
|
)
|
||||||
|
tw.download_track_worker('t1', 'b1', deps)
|
||||||
|
# No search performed — cached candidates used directly.
|
||||||
|
assert sk.search_calls == []
|
||||||
|
# Only the unused candidate (peerB) was passed to attempt.
|
||||||
|
assert attempted == [['f2.flac']]
|
||||||
|
|
||||||
|
|
||||||
|
def test_quarantine_retry_skips_cached_from_exhausted_source():
|
||||||
|
# hifi is budget-exhausted; its cached candidate must be skipped, soulseek's tried.
|
||||||
|
_seed_task(
|
||||||
|
_quarantine_retry=True,
|
||||||
|
cached_candidates=[_Cand('hifi', 'h.flac'), _Cand('peerB', 'f2.flac')],
|
||||||
|
used_sources=set(),
|
||||||
|
exhausted_download_sources={'hifi'},
|
||||||
|
)
|
||||||
|
attempted = []
|
||||||
|
|
||||||
|
def _attempt(task_id, candidates, track, batch_id):
|
||||||
|
attempted.append([getattr(c, 'username', None) for c in candidates])
|
||||||
|
return True
|
||||||
|
|
||||||
|
sk = _FakeClient(results=['x'])
|
||||||
|
deps, _ = _build_deps(
|
||||||
|
soulseek=sk,
|
||||||
|
matching=_FakeMatchEngine(queries=['q1']),
|
||||||
|
attempt_download_with_candidates=_attempt,
|
||||||
|
)
|
||||||
|
tw.download_track_worker('t1', 'b1', deps)
|
||||||
|
assert sk.search_calls == []
|
||||||
|
assert attempted == [['peerB']] # hifi candidate excluded
|
||||||
|
|
||||||
|
|
||||||
|
def test_quarantine_retry_searches_excluding_searched_source_when_cached_spent():
|
||||||
|
# All cached used → Phase A finds nothing → search runs, but excludes the
|
||||||
|
# already-searched source so the chain falls through to a new source.
|
||||||
|
_seed_task(
|
||||||
|
_quarantine_retry=True,
|
||||||
|
cached_candidates=[_Cand('peerA', 'f1.flac')],
|
||||||
|
used_sources={'peerA_f1.flac'},
|
||||||
|
searched_sources={'soulseek'},
|
||||||
|
)
|
||||||
|
sk = _FakeClient(results=[], mode='hybrid',
|
||||||
|
subclients={'hybrid_order': ['soulseek', 'hifi']})
|
||||||
|
deps, _ = _build_deps(
|
||||||
|
soulseek=sk,
|
||||||
|
matching=_FakeMatchEngine(queries=['q1']),
|
||||||
|
)
|
||||||
|
tw.download_track_worker('t1', 'b1', deps)
|
||||||
|
# Search ran (cached spent) and every call excluded the searched soulseek source.
|
||||||
|
assert sk.search_calls
|
||||||
|
assert all(ex and 'soulseek' in ex for ex in sk.exclude_calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_quarantine_run_ignores_cached_first_and_searches():
|
||||||
|
# A fresh (non-quarantine) run must NOT use cached-first — it searches.
|
||||||
|
_seed_task(
|
||||||
|
cached_candidates=[_Cand('peerA', 'f1.flac')],
|
||||||
|
used_sources=set(),
|
||||||
|
)
|
||||||
|
sk = _FakeClient(results=[])
|
||||||
|
deps, _ = _build_deps(
|
||||||
|
soulseek=sk,
|
||||||
|
matching=_FakeMatchEngine(queries=['q1']),
|
||||||
|
attempt_download_with_candidates=lambda *a, **kw: False,
|
||||||
|
)
|
||||||
|
tw.download_track_worker('t1', 'b1', deps)
|
||||||
|
assert sk.search_calls # searched, did not short-circuit on cache
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_quarantine_run_resets_searched_sources():
|
||||||
|
# A fresh / dead-connection retry starts a new search generation: the stale
|
||||||
|
# searched-source memory is cleared so sources can be searched again.
|
||||||
|
_seed_task(
|
||||||
|
cached_candidates=[],
|
||||||
|
searched_sources={'soulseek', 'hifi'},
|
||||||
|
)
|
||||||
|
sk = _FakeClient(results=[])
|
||||||
|
deps, _ = _build_deps(
|
||||||
|
soulseek=sk,
|
||||||
|
matching=_FakeMatchEngine(queries=['q1']),
|
||||||
|
)
|
||||||
|
tw.download_track_worker('t1', 'b1', deps)
|
||||||
|
assert download_tasks['t1'].get('searched_sources', set()) == set()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Top-level exception path
|
# Top-level exception path
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -403,6 +403,20 @@ def test_acoustid_mismatch_requeues_next_candidate(monkeypatch):
|
||||||
assert context_key not in runtime_state.matched_downloads_context
|
assert context_key not in runtime_state.matched_downloads_context
|
||||||
|
|
||||||
|
|
||||||
|
def test_requeue_flags_quarantine_retry_for_cached_first(monkeypatch):
|
||||||
|
_wire_retry_engine(monkeypatch)
|
||||||
|
|
||||||
|
def _fake_inner(ck, ctx, fp, runtime, metadata_runtime=None):
|
||||||
|
ctx["_acoustid_quarantined"] = True
|
||||||
|
ctx["_acoustid_failure_msg"] = "wrong song"
|
||||||
|
|
||||||
|
task, _, _ = _run_wrapper_with_quarantine(monkeypatch, _fake_inner)
|
||||||
|
|
||||||
|
# The re-run is flagged so the worker walks cached candidates before
|
||||||
|
# re-searching (cached-first), rather than re-running the full search.
|
||||||
|
assert task["_quarantine_retry"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_integrity_mismatch_requeues_next_candidate(monkeypatch):
|
def test_integrity_mismatch_requeues_next_candidate(monkeypatch):
|
||||||
submitted = _wire_retry_engine(monkeypatch)
|
submitted = _wire_retry_engine(monkeypatch)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue