youtube fulfillment: orphan reaper for restart-stalled downloads

closes the real gap: a restart kills the yt-dlp worker threads but leaves their
rows at 'downloading', which the pump counts as busy -> the queue wedges and they
never finish.

track live worker dl_ids in _active_worker_ids (added at worker start, dropped in
finally). requeue_orphaned_youtube() puts any 'downloading' youtube row with no
live worker back to 'queued' so the pump re-runs it. after a restart the set is
empty, so all stuck rows recover; during normal operation active ones are
protected (no false positives, no timestamp guessing). the hourly drain calls it
before pumping + logs the count. seam-tested.
This commit is contained in:
BoulderBadgeDad 2026-06-25 22:48:54 -07:00
parent 846d217dca
commit 5bb90053bf
4 changed files with 83 additions and 4 deletions

View file

@ -91,6 +91,13 @@ def _default_start_next() -> Any:
return start_next_queued(get_video_db) return start_next_queued(get_video_db)
def _default_reap() -> int:
"""Recover downloads orphaned by a restart (stuck 'downloading', no live worker)."""
from api.video import get_video_db
from core.video.youtube_download import requeue_orphaned_youtube
return requeue_orphaned_youtube(get_video_db)
def auto_video_process_youtube_wishlist( def auto_video_process_youtube_wishlist(
config: Dict[str, Any], config: Dict[str, Any],
deps: AutomationDeps, deps: AutomationDeps,
@ -101,6 +108,7 @@ def auto_video_process_youtube_wishlist(
running_count: Optional[Callable[[], int]] = None, running_count: Optional[Callable[[], int]] = None,
enqueue: Optional[Callable[[Dict[str, Any], str], Any]] = None, enqueue: Optional[Callable[[Dict[str, Any], str], Any]] = None,
start_next: Optional[Callable[[], Any]] = None, start_next: Optional[Callable[[], Any]] = None,
reap: Optional[Callable[[], int]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Queue the whole YouTube wishlist for download and start up to ``max_concurrent`` now. """Queue the whole YouTube wishlist for download and start up to ``max_concurrent`` now.
@ -111,6 +119,7 @@ def auto_video_process_youtube_wishlist(
running_count = running_count or _default_running_count running_count = running_count or _default_running_count
enqueue = enqueue or _default_enqueue enqueue = enqueue or _default_enqueue
start_next = start_next or _default_start_next start_next = start_next or _default_start_next
reap = reap or _default_reap
automation_id = config.get('_automation_id') automation_id = config.get('_automation_id')
max_concurrent = max(1, int(config.get('max_concurrent', 3) or 3)) max_concurrent = max(1, int(config.get('max_concurrent', 3) or 3))
@ -125,6 +134,13 @@ def auto_video_process_youtube_wishlist(
return {'status': 'completed', 'queued': 0, 'started': 0, 'running': 0, return {'status': 'completed', 'queued': 0, 'started': 0, 'running': 0,
'skipped': 'no_youtube_folder', '_manages_own_progress': True} 'skipped': 'no_youtube_folder', '_manages_own_progress': True}
# Recover any downloads orphaned by a restart (stuck 'downloading', no worker) so
# they don't wedge the concurrency count — they go back to 'queued' and re-run.
recovered = int(reap() or 0)
if recovered:
deps.update_progress(automation_id, log_type='info',
log_line='Recovered %d stalled download(s) from a restart' % recovered)
deps.update_progress(automation_id, phase='Checking the YouTube wishlist…', progress=15, deps.update_progress(automation_id, phase='Checking the YouTube wishlist…', progress=15,
log_line='Queueing new videos for download', log_type='info') log_line='Queueing new videos for download', log_type='info')
wanted = fetch_wanted() or [] wanted = fetch_wanted() or []

View file

@ -196,11 +196,30 @@ def _pace(delay: float) -> None:
time.sleep(wait) time.sleep(wait)
# Download ids with a live worker thread right now. After a restart this is empty, so any
# row still marked 'downloading' is an orphan (its thread died) → the reaper re-queues it.
_active_worker_ids: set = set()
def _spawn_worker(dl_id: Any, db_provider: Callable) -> None: def _spawn_worker(dl_id: Any, db_provider: Callable) -> None:
threading.Thread(target=run_youtube_download, args=(dl_id, db_provider), threading.Thread(target=run_youtube_download, args=(dl_id, db_provider),
daemon=True, name="yt-dl-%s" % dl_id).start() daemon=True, name="yt-dl-%s" % dl_id).start()
def requeue_orphaned_youtube(db_provider: Callable) -> int:
"""Recover YouTube downloads stuck in 'downloading' with no live worker (e.g. after a
restart killed the threads) by putting them back to 'queued' so the pump re-runs them.
A download whose worker is alive is in ``_active_worker_ids`` and is left untouched.
Returns the count recovered."""
n = 0
for d in (db_provider().get_active_video_downloads() or []):
if (d.get("source") == "youtube" and d.get("status") == "downloading"
and d.get("id") not in _active_worker_ids):
db_provider().update_video_download(d["id"], status="queued", progress=0)
n += 1
return n
def start_next_queued(db_provider: Callable) -> Any: def start_next_queued(db_provider: Callable) -> Any:
"""Claim the next queued YouTube download and start it. Returns its id, or None if the """Claim the next queued YouTube download and start it. Returns its id, or None if the
queue is empty. The wishlist pump uses this to fill slots; each finished worker calls it queue is empty. The wishlist pump uses this to fill slots; each finished worker calls it
@ -216,9 +235,11 @@ def start_next_queued(db_provider: Callable) -> Any:
def run_youtube_download(dl_id: Any, db_provider: Callable) -> None: def run_youtube_download(dl_id: Any, db_provider: Callable) -> None:
"""Production entry: fetch the row, bind real seams, fulfil it. Called on a worker """Production entry: fetch the row, bind real seams, fulfil it. Called on a worker
thread by the pump; on finish it starts the next queued download (one-out-one-in).""" thread by the pump; on finish it starts the next queued download (one-out-one-in)."""
_active_worker_ids.add(dl_id) # mark this download as having a live worker
db = db_provider() db = db_provider()
dl = db.get_video_download(dl_id) dl = db.get_video_download(dl_id)
if not dl: if not dl:
_active_worker_ids.discard(dl_id)
start_next_queued(db_provider) # keep the queue moving even on a stale id start_next_queued(db_provider) # keep the queue moving even on a stale id
return return
profile = youtube_quality.load(db) profile = youtube_quality.load(db)
@ -260,11 +281,12 @@ def run_youtube_download(dl_id: Any, db_provider: Callable) -> None:
clear_wishlist=lambda vid: db.remove_youtube_from_wishlist("video", vid), clear_wishlist=lambda vid: db.remove_youtube_from_wishlist("video", vid),
progress_hook=_progress, cookie_opts=cookie_opts, now=_now) progress_hook=_progress, cookie_opts=cookie_opts, now=_now)
finally: finally:
_active_worker_ids.discard(dl_id) # worker done — no longer protects this row
start_next_queued(db_provider) # one out, one in — drain the queue start_next_queued(db_provider) # one out, one in — drain the queue
__all__ = [ __all__ = [
"youtube_fields_from_download", "plan_destination", "ydl_download_opts", "youtube_fields_from_download", "plan_destination", "ydl_download_opts",
"download_one", "process_youtube_download", "run_youtube_download", "download_one", "process_youtube_download", "run_youtube_download",
"start_next_queued", "start_next_queued", "requeue_orphaned_youtube",
] ]

View file

@ -66,7 +66,7 @@ def _run(wanted, *, active=None, running=0, root="/yt", max_concurrent=3, start_
{"_automation_id": "a", "max_concurrent": max_concurrent}, deps, {"_automation_id": "a", "max_concurrent": max_concurrent}, deps,
youtube_root=lambda: root, fetch_wanted=lambda: wanted, youtube_root=lambda: root, fetch_wanted=lambda: wanted,
active_ids=lambda: list(active or []), running_count=lambda: running, active_ids=lambda: list(active or []), running_count=lambda: running,
enqueue=enqueue, start_next=start_next) enqueue=enqueue, start_next=start_next, reap=lambda: 0)
return res, enq, starts["n"], deps return res, enq, starts["n"], deps
@ -130,7 +130,7 @@ def test_one_bad_enqueue_does_not_stop_the_rest():
{"_automation_id": "x", "max_concurrent": 5}, _Deps(), {"_automation_id": "x", "max_concurrent": 5}, _Deps(),
youtube_root=lambda: "/yt", fetch_wanted=lambda: [_v("a"), _v("b")], youtube_root=lambda: "/yt", fetch_wanted=lambda: [_v("a"), _v("b")],
active_ids=lambda: [], running_count=lambda: 0, enqueue=enqueue, active_ids=lambda: [], running_count=lambda: 0, enqueue=enqueue,
start_next=lambda: None) start_next=lambda: None, reap=lambda: 0)
assert res["status"] == "completed" and res["queued"] == 1 # b still queued assert res["status"] == "completed" and res["queued"] == 1 # b still queued
@ -138,10 +138,23 @@ def test_top_level_error_is_caught():
def boom(): def boom():
raise RuntimeError("db down") raise RuntimeError("db down")
res = auto_video_process_youtube_wishlist({"_automation_id": "x"}, _Deps(), res = auto_video_process_youtube_wishlist({"_automation_id": "x"}, _Deps(),
youtube_root=lambda: "/yt", fetch_wanted=boom) youtube_root=lambda: "/yt", fetch_wanted=boom,
reap=lambda: 0)
assert res["status"] == "error" and "db down" in res["error"] assert res["status"] == "error" and "db down" in res["error"]
def test_reaper_runs_and_is_reported():
# the drain recovers restart-orphaned downloads before pumping, and logs the count
deps = _Deps()
res = auto_video_process_youtube_wishlist(
{"_automation_id": "a", "max_concurrent": 3}, deps,
youtube_root=lambda: "/yt", fetch_wanted=lambda: [], active_ids=lambda: [],
running_count=lambda: 0, enqueue=lambda v, r: 1, start_next=lambda: None,
reap=lambda: 2)
assert res["status"] == "completed"
assert any("Recovered 2 stalled" in (p.get("log_line") or "") for p in deps.progress)
# ── the DB queue methods ────────────────────────────────────────────────────── # ── the DB queue methods ──────────────────────────────────────────────────────
from database.video_database import VideoDatabase # noqa: E402 from database.video_database import VideoDatabase # noqa: E402

View file

@ -137,6 +137,34 @@ def test_process_failure_archives_but_keeps_the_wish():
assert calls["unwish"] == [] # wish kept so it can retry later assert calls["unwish"] == [] # wish kept so it can retry later
def test_requeue_orphaned_youtube_recovers_only_dead_downloads():
"""After a restart no worker threads survive, so any 'downloading' YouTube row is an
orphan back to 'queued'. A row whose worker is still alive (in _active_worker_ids) and
non-youtube / non-downloading rows are left alone."""
updates = []
class _DB:
def get_active_video_downloads(self):
return [
{"id": 1, "source": "youtube", "status": "downloading"}, # orphan → requeue
{"id": 2, "source": "youtube", "status": "downloading"}, # live worker → keep
{"id": 3, "source": "youtube", "status": "queued"}, # not downloading → keep
{"id": 4, "source": "soulseek", "status": "downloading"}, # not youtube → keep
]
def update_video_download(self, dl_id, **kw):
updates.append((dl_id, kw))
ytd._active_worker_ids.clear()
ytd._active_worker_ids.add(2) # id 2 has a live worker
try:
n = ytd.requeue_orphaned_youtube(lambda: _DB())
finally:
ytd._active_worker_ids.clear()
assert n == 1
assert updates == [(1, {"status": "queued", "progress": 0})] # only the orphan
def test_process_passes_the_organised_dir_to_the_downloader(): def test_process_passes_the_organised_dir_to_the_downloader():
seen = {} seen = {}