From 27cbfd882c67c9bed3d30bee6555bd7a660ad083 Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Thu, 17 Sep 2026 21:41:54 -0500 Subject: [PATCH] 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. --- backend/apps/core/stats.py | 8 +++ backend/apps/library/downloads.py | 26 +++++++--- backend/apps/library/services.py | 17 +++++-- backend/apps/library/views.py | 9 +++- frontend/src/features/stats/StatsPage.tsx | 62 +++++++++++++++++++++-- frontend/src/lib/types.ts | 1 + 6 files changed, 107 insertions(+), 16 deletions(-) diff --git a/backend/apps/core/stats.py b/backend/apps/core/stats.py index 76c948f..b49406b 100644 --- a/backend/apps/core/stats.py +++ b/backend/apps/core/stats.py @@ -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, diff --git a/backend/apps/library/downloads.py b/backend/apps/library/downloads.py index e9a93a7..2ea0984 100644 --- a/backend/apps/library/downloads.py +++ b/backend/apps/library/downloads.py @@ -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. diff --git a/backend/apps/library/services.py b/backend/apps/library/services.py index 05a5381..43b4d5a 100644 --- a/backend/apps/library/services.py +++ b/backend/apps/library/services.py @@ -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 diff --git a/backend/apps/library/views.py b/backend/apps/library/views.py index a9e9b9c..46aa83a 100644 --- a/backend/apps/library/views.py +++ b/backend/apps/library/views.py @@ -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}) diff --git a/frontend/src/features/stats/StatsPage.tsx b/frontend/src/features/stats/StatsPage.tsx index 0a4fac8..023b44c 100644 --- a/frontend/src/features/stats/StatsPage.tsx +++ b/frontend/src/features/stats/StatsPage.tsx @@ -1,9 +1,9 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Activity, Download, Gauge, HardDrive, MemoryStick, ScanSearch, Terminal } from "lucide-react"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { EmptyState, Spinner } from "@/components/ui"; -import { api } from "@/lib/api"; +import { api, errorMessage } from "@/lib/api"; import { cn } from "@/lib/cn"; import { formatBytes } from "@/lib/format"; import type { StatJob, SystemStats } from "@/lib/types"; @@ -77,7 +77,19 @@ function jobStatusClass(status: string): string { return "text-ctp-blue"; } -function JobRow({ job, showSummary }: { job: StatJob; showSummary?: boolean }) { +function JobRow({ + job, + showSummary, + onCancel, + cancelling, + cancelError, +}: { + job: StatJob; + showSummary?: boolean; + onCancel?: () => void; + cancelling?: boolean; + cancelError?: string | null; +}) { const Icon = job.kind === "download" ? Download : ScanSearch; return (
  • @@ -87,7 +99,21 @@ function JobRow({ job, showSummary }: { job: StatJob; showSummary?: boolean }) { {job.status} + {onCancel ? ( + + ) : null} + {cancelError ? ( +

    {cancelError}

    + ) : null} {showSummary ? (

    {job.summary ?? job.detail} @@ -114,6 +140,7 @@ function logLineClass(line: string): string { export default function StatsPage() { const user = useAuth((state) => state.user); + const queryClient = useQueryClient(); const staff = Boolean( user && (user.is_staff || user.is_superuser || user.role === "staff"), @@ -124,6 +151,19 @@ export default function StatsPage() { enabled: staff, refetchInterval: staff ? REFRESH_MS : false, }); + const cancelMutation = useMutation({ + mutationFn: (job: StatJob) => + api( + job.kind === "download" + ? `/api/online/downloads/${job.id}/cancel/` + : `/api/matches/${job.id}/cancel/`, + { method: "POST" }, + ), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["stats"] }); + void queryClient.invalidateQueries({ queryKey: ["status"] }); + }, + }); const [autoScroll, setAutoScroll] = useState(true); const logRef = useRef(null); const data = query.data; @@ -321,7 +361,21 @@ export default function StatsPage() { ) : (

    )} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d6c478b..0b29a39 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -338,6 +338,7 @@ export interface StatJob { progress: number; detail: string; summary?: string; + cancelled?: boolean; created_at: string; updated_at: string; }