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:
@@ -122,6 +122,7 @@ def _download_job(task):
|
||||
"downloaded": task.downloaded,
|
||||
"total": task.total,
|
||||
"detail": detail,
|
||||
"cancelled": task.cancelled,
|
||||
"created_at": task.created_at,
|
||||
"updated_at": task.updated_at,
|
||||
}
|
||||
@@ -143,6 +144,7 @@ def _match_job(task):
|
||||
f"{task.processed}/{task.total} · {task.matched} matched · "
|
||||
f"{task.not_found} not found · {task.deleted} deleted"
|
||||
),
|
||||
"cancelled": task.cancelled,
|
||||
"created_at": task.created_at,
|
||||
"updated_at": task.updated_at,
|
||||
}
|
||||
@@ -150,8 +152,14 @@ def _match_job(task):
|
||||
|
||||
def jobs_stats():
|
||||
"""Running/pending jobs plus the most recently finished ones."""
|
||||
from apps.library.downloads import reap_stale_downloads
|
||||
from apps.library.matching import reap_stale_match_tasks
|
||||
from apps.library.models import DownloadTask, MatchTask
|
||||
|
||||
# Tasks whose runner died in a restart must not linger as "downloading".
|
||||
reap_stale_downloads()
|
||||
reap_stale_match_tasks()
|
||||
|
||||
active_statuses_download = [
|
||||
DownloadTask.STATUS_PENDING,
|
||||
DownloadTask.STATUS_DOWNLOADING,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user