✨ New Features
- Metadata scan and DAT match jobs are now cancellable from the UI. Both job types are "external jobs" (they bypass the conversion queue and drive their own execution), and until this release were deliberately excluded from cancellation — once started, a full-volume metadata scan or DAT match had to run to completion, even if the user realised they kicked it off on the wrong volume or wanted to free the workload lane for something else. The existing per-row Cancel button and the Cancel All button now both accept these jobs. No new endpoints — the existing
DELETE /api/jobs/{id}andPOST /api/jobs/cancel-allroutes were widened. - Partial results are preserved on cancel. Any match entries already written to
dat_matches, or metadata/disc-id tags already extracted and committed tochd_metadata, stay durable. This matches the pre-existing "durable mid-run" comment in_run_match_job— cancelling mid-loop gives you a partial cache, not a rollback. - Cancel-All now cancels every kind of job.
cancel_all_jobs()no longer filters out external modes, and the matching three frontend guards (handleRequestCancelAllactiveCount,handleCancelAllJobsper-job state map,queuedJobsCount/processingJobsCount) were removed so the button's visibility, the summary counts, and the optimistic "Cancelling..." state update all apply uniformly.
🐛 Bug Fixes & Hardening
_run_match_jobreleases the match-job lock before its finalize awaits. Previously theasync with _get_match_job_lock(): _active_match_job_id = Nonecleanup was the last statement in thefinallyblock, after bothfinish_external_jobandfinish_external_job_cancelled. If either finalize call raised (e.g. a subscriber queue throwing),_active_match_job_idstayed pinned to the dead job id and every subsequent match request returned HTTP 409 until process restart. The reset now runs at the top of thefinally, so the lock is released even if finalize blows up.- Startup migrated from deprecated
@app.on_event("startup")to FastAPI lifespan context manager. Silences theDeprecationWarning: on_event is deprecated, use lifespan event handlers insteadthe test suite was emitting, and puts us on the supported path for future FastAPI upgrades. No behaviour change — same startup body, same ordering (configure_logging→ DB init → Alembic → JSON→SQLite import → background tasks → auto-sync). - "Cancelling..." string unified across backend and frontend. The new external-job cancel path briefly used a Unicode ellipsis (
"Cancelling…") while the pre-existing conversion-job path and the frontend's optimistic update used ASCII ("Cancelling..."). Because SSE delivery races the local optimistic write, the displayed string would flip between the two forms. Backend now emits ASCII everywhere.
🔧 Internal
- New cancellation primitives on
JobManager.ExternalJobCancelled— purpose-built exception raised inside external-job loops whencancel_job()has been requested. Only caught by the dedicatedexceptbranch in each loop; will never mask a realException.is_cancelled(job_id) -> bool— cheap polling check used at the top of each loop iteration in_run_match_joband both phases ofscan_metadata_task.get_cancel_event(job_id) -> asyncio.Event | None— awaitable form for future use.finish_external_job_cancelled(job_id, *, message)— terminal finalize that setsJobStatus.CANCELLED, clears cancel state, emits thecancelledSSE event, and archives the job. Symmetric withfinish_external_job(success=...).
- Tri-state
job_successin both loops (True= success,False= failure,None= cancelled). Cancelled path takesfinish_external_job_cancelled; non-cancelled path keeps the pre-existingfinish_external_job(success=...)call so DAT match's "all files errored → mark failed" signal is unchanged. - Cancel events auto-register on external-job creation.
create_external_jobnow populates_cancel_events[job_id]when a running loop is available (falls back silently under sync test setups).
🧪 Tests (+16, total 324)
test_cancel_job_signals_metadata_scan/..._signals_dat_match/..._emits_status_event_for_external_job— cancel_job on a PROCESSING external job sets the event, flips the message, returns True, and emits astatusSSE event.test_cancel_all_includes_external_jobs— Cancel-All requests both a metadata scan and a DAT match.test_cancel_job_returns_false_for_terminal_external_job— terminal external jobs are not re-cancellable.test_finish_external_job_cancelled_sets_status_and_message/..._emits_cancelled_event/..._clears_cancel_state/..._noop_for_unknown_id— terminal-finalize semantics.test_run_match_job_cancellation_ends_in_cancelled_status— mid-loop cancel flips the job to CANCELLED and clears_active_match_job_id.test_run_match_job_cancellation_keeps_partial_cache— per-file matches written before cancel stay indat_store.test_scan_metadata_cancellation_flips_to_cancelled— Phase 1 cancel path.test_scan_metadata_cancellation_during_phase2— Phase 2 (disc-id) cancel path covered independently.- Two pre-existing tests (
test_cancel_job_returns_false_for_metadata_scan,test_cancel_all_skips_metadata_scan_jobs) were rewritten to reflect the new behaviour.
324 tests pass, 0 warnings (was 323 + 2 FastAPI deprecation warnings before this release).
📁 Files Changed
app/services/job_manager.py—ExternalJobCancelled,is_cancelled/get_cancel_event,finish_external_job_cancelled, cancel-event registration increate_external_job,cancel_jobexternal-job branch,cancel_all_jobsno longer filters external modes.app/routes/dat.py—_run_match_jobgains a per-iteration cancel check, tri-statejob_success, dedicatedexcept ExternalJobCancelledbranch, and early match-lock release in thefinally.app/routes/info.py—scan_metadata_taskgains cancel checks at the top of both Phase 1 and Phase 2 loops, and a cancelled branch in thefinallythat callsfinish_external_job_cancelled.app/main.py—@app.on_event("startup")→@asynccontextmanager async def lifespan(app)passed toFastAPI(lifespan=...). Startup body unchanged.static/js/app.js— Cancel button onJobListrow is no longer suppressed for external scan modes; Cancel-All count filter, per-job state map, andqueuedJobsCount/processingJobsCountno longer exclude them.
⚠️ Upgrade Notes
- No action required. The
DELETE /api/jobs/{id}endpoint keeps its existing contract (returns 200 on success, 404 on unknown id); external jobs just no longer fall through to a silentFalsereturn. - Cancelling a metadata scan mid-run leaves partial metadata cached. If you cancel and re-trigger, the re-triggered scan skips already-refreshed entries via the normal
is_stalecheck — no duplicate work, no lost data. - Cancelling a DAT match mid-run leaves per-file matches cached. Re-matching the same paths skips the ones already in
dat_matches; size-cap and missing-file skips are re-evaluated. - "Cancel All" now cancels scans too. If you were relying on Cancel-All stopping only conversion jobs while a metadata scan kept running, use the per-row Cancel button on the conversion jobs instead.