Files
bookhoard/internal/services/worker_cancel_test.go
T
John O'Keefe b2220756e2 feat(worker): real per-job cancellation for the background job pool
CancelJob existed but was dead code: it only flipped a status in the
in-memory results map - it never cancelled anything, and processJob's
unconditional completion write overwrote the marker anyway.

- derive a per-job context in processJob (parent is the handler ctx
  for manual scans, Background for auto-scans) and register its
  cancel func under the job id; previously every manual scan shared
  the handler's process-lifetime context, so no targeted cancel was
  possible without killing everything
- skip execution at dequeue for jobs cancelled while still queued
- record a pending JobResult on BOTH enqueue paths (Enqueue used by
  auto-scans, EnqueueJob used by the API): queued jobs were invisible
  to status polling (404 until a worker picked them up) and could not
  be cancelled before execution started
- cancelled jobs keep their last real progress instead of a fake 100%
  and drop the meaningless "context canceled" error text
- broadcast the new scan_cancelled websocket event for cancelled scan
  jobs instead of scan_complete, so clients reset their scanning
  indicators rather than reading the abort as a finished scan
- sentinel errors (ErrJobNotFound, ErrJobNotCancellable) so HTTP
  handlers map outcomes to 404/409 without string matching

Unit tests (worker_cancel_test.go) pin: queued-cancel never executes,
running-cancel invokes the context, error mapping, pending recording
on both enqueue paths.
2026-10-18 18:02:11 -04:00

158 lines
4.8 KiB
Go

package services
import (
"context"
"errors"
"testing"
)
// A job cancelled while still queued must never execute. processJob
// sees the pending-cancel marker at dequeue and leaves the result
// untouched; a scan without params would otherwise fail with
// "library_id required" and flip the status to failed — so an empty
// error field proves the job body never ran.
func TestCancelQueuedJobNeverExecutes(t *testing.T) {
// Zero workers: the job stays queued until processJob is driven by
// hand below.
w := NewWorkerWithConfig(0, 10, nil)
job := &Job{
ID: "queued-cancel",
Type: JobTypeScan,
Context: context.Background(),
Params: map[string]interface{}{},
}
if err := w.EnqueueJob(job); err != nil {
t.Fatalf("enqueue failed: %v", err)
}
// The pending result must exist before any worker picks the job up,
// so status polling can see (and cancel) queued jobs.
result, ok := w.GetJobStatus("queued-cancel")
if !ok {
t.Fatal("queued job has no result entry")
}
if result.Status != JobStatusPending {
t.Fatalf("expected pending status, got %q", result.Status)
}
if err := w.CancelJob("queued-cancel"); err != nil {
t.Fatalf("CancelJob failed: %v", err)
}
// Simulate the dequeue that races the cancellation.
w.processJob(job)
result, _ = w.GetJobStatus("queued-cancel")
if result.Status != JobStatusCancelled {
t.Fatalf("expected cancelled status after dequeue, got %q", result.Status)
}
if result.Error != "" {
t.Errorf("skipped job must carry no processing error, got %q", result.Error)
}
}
// Cancelling a running job must invoke its per-job context cancel func
// — the old CancelJob only flipped a map status and the job kept
// running to completion.
func TestCancelRunningJobCancelsContext(t *testing.T) {
w := NewWorker(1, nil)
ctx, cancel := context.WithCancel(context.Background())
job := &Job{ID: "running-cancel", Type: JobTypeScan, Context: ctx}
// Seed the state processJob establishes while a job runs.
w.mu.Lock()
w.results[job.ID] = &JobResult{JobID: job.ID, Status: JobStatusRunning}
w.cancels[job.ID] = cancel
w.mu.Unlock()
if err := w.CancelJob(job.ID); err != nil {
t.Fatalf("CancelJob failed: %v", err)
}
if ctx.Err() == nil {
t.Fatal("CancelJob must cancel the job's context")
}
result, _ := w.GetJobStatus(job.ID)
if result.Status != JobStatusCancelled {
t.Fatalf("expected cancelled status, got %q", result.Status)
}
}
func TestCancelJobErrors(t *testing.T) {
w := NewWorker(1, nil)
if err := w.CancelJob("missing-job"); !errors.Is(err, ErrJobNotFound) {
t.Errorf("unknown job: expected ErrJobNotFound, got %v", err)
}
w.mu.Lock()
w.results["done-job"] = &JobResult{JobID: "done-job", Status: JobStatusCompleted}
w.results["failed-job"] = &JobResult{JobID: "failed-job", Status: JobStatusFailed}
w.results["cancelled-job"] = &JobResult{JobID: "cancelled-job", Status: JobStatusCancelled}
w.mu.Unlock()
for _, id := range []string{"done-job", "failed-job", "cancelled-job"} {
if err := w.CancelJob(id); !errors.Is(err, ErrJobNotCancellable) {
t.Errorf("terminal job %q: expected ErrJobNotCancellable, got %v", id, err)
}
}
}
// Both enqueue paths must register a pending result: auto-scans arrive
// via Enqueue (fire-and-forget) and manual scans via EnqueueJob, and a
// queued job must be visible to status polling and cancellable before
// a worker dequeues it.
func TestEnqueueRecordsPendingResult(t *testing.T) {
w := NewWorkerWithConfig(0, 10, nil)
fireAndForget := &Job{
ID: "via-enqueue",
Type: JobTypeScan,
Context: context.Background(),
Params: map[string]interface{}{"library_id": "lib-auto"},
}
w.Enqueue(fireAndForget)
tracked := &Job{
ID: "via-enqueue-job",
Type: JobTypeScan,
Context: context.Background(),
Params: map[string]interface{}{"library_id": "lib-manual"},
}
if err := w.EnqueueJob(tracked); err != nil {
t.Fatalf("EnqueueJob failed: %v", err)
}
for id, libID := range map[string]string{
"via-enqueue": "lib-auto",
"via-enqueue-job": "lib-manual",
} {
result, ok := w.GetJobStatus(id)
if !ok {
t.Fatalf("%s: no pending result recorded", id)
}
if result.Status != JobStatusPending {
t.Errorf("%s: expected pending, got %q", id, result.Status)
}
if result.LibraryID != libID {
t.Errorf("%s: expected library_id %q, got %q", id, libID, result.LibraryID)
}
}
}
// A scan whose context is already cancelled must abort immediately
// with context.Canceled — before counting, walking, or running the
// archive pass. processJob classifies that error as a cancelled job.
func TestScanFoldersCancelledContext(t *testing.T) {
s := NewMediaScanner(nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := s.ScanFolders(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}