Add job cancellation to the stats dashboard

- Active jobs on /stats get a cancel button wired to the existing
  download/match cancel endpoints, showing "cancelling..." and an inline
  error when the task already finished.
- Cancelling now sets the status immediately, so a task whose runner died
  in a restart stops showing as "downloading".
- Download streams use a bounded read timeout (10 s connect / 60 s read):
  a stalled socket fails within a minute (previously it could block
  forever), and a task cancelled while stalled is marked cancelled rather
  than error.
- The stats job list reaps stale download/match tasks, so phantom jobs
  never appear on the dashboard.
This commit is contained in:
2026-09-17 21:41:54 -05:00
parent b024fc52d7
commit 27cbfd882c
6 changed files with 107 additions and 16 deletions
+19 -7
View File
@@ -144,13 +144,25 @@ def run_download_task(task_id):
)
except Exception as exc: # noqa: BLE001 - report background failures
destination.unlink(missing_ok=True)
logger.exception("Download task %s failed", task_id)
DownloadTask.objects.filter(id=task_id).update(
status=DownloadTask.STATUS_ERROR,
error=str(exc),
speed=None,
updated_at=timezone.now(),
)
if DownloadTask.objects.filter(id=task_id, cancelled=True).exists():
# Cancel was requested while the socket was stalled; the read
# timeout is what breaks the worker out of it.
DownloadTask.objects.filter(id=task_id).update(
status=DownloadTask.STATUS_CANCELLED,
progress=0,
downloaded=0,
speed=None,
error="",
updated_at=timezone.now(),
)
else:
logger.exception("Download task %s failed", task_id)
DownloadTask.objects.filter(id=task_id).update(
status=DownloadTask.STATUS_ERROR,
error=str(exc),
speed=None,
updated_at=timezone.now(),
)
finally:
# Background threads hold their own DB connection; release it so
# Gunicorn workers do not leak connections when threads finish.
+14 -3
View File
@@ -250,13 +250,24 @@ class DownloadCancelled(Exception):
def download_file(
url, destination, progress_callback=None, should_cancel=None
url,
destination,
progress_callback=None,
should_cancel=None,
read_timeout=60,
):
"""Stream a remote file into destination (used by Download to Library)."""
"""Stream a remote file into destination (used by Download to Library).
The read timeout bounds how long a stalled connection can block the
worker: without it a hung socket would keep a job "downloading" forever
and the cancel flag could never be observed.
"""
import requests
headers = {"User-Agent": settings.USER_AGENT}
with requests.get(url, headers=headers, stream=True, timeout=120) as response:
with requests.get(
url, headers=headers, stream=True, timeout=(10, read_timeout)
) as response:
response.raise_for_status()
total = int(response.headers.get("content-length") or 0)
downloaded = 0
+7 -2
View File
@@ -465,7 +465,10 @@ class DownloadTaskViewSet(
status=status.HTTP_400_BAD_REQUEST,
)
DownloadTask.objects.filter(id=task.id).update(
cancelled=True, updated_at=timezone.now()
cancelled=True,
status=DownloadTask.STATUS_CANCELLED,
speed=None,
updated_at=timezone.now(),
)
return Response({"success": True})
@@ -508,7 +511,9 @@ class MatchTaskViewSet(
status=status.HTTP_400_BAD_REQUEST,
)
MatchTask.objects.filter(id=task.id).update(
cancelled=True, updated_at=timezone.now()
cancelled=True,
status=MatchTask.STATUS_CANCELLED,
updated_at=timezone.now(),
)
return Response({"success": True})