feat: implement background job system with queue, worker, scheduler, and handlers
- Add job queue abstraction (InMemoryQueue and Redis/BullMQ adapter) - Add polling worker with retry logic and exponential backoff - Add 6 job handlers: darkwatch.scan, voiceprint.batch, hometitle.scan, removebrokers.process, reports.generate, notifications.send - Add cron-based scheduler with tier-appropriate frequencies (Basic/Plus/Premium) - Add tRPC scheduler router for admin (runJobNow, getJobStatus, etc.) - Add entry point with graceful shutdown support - Achieve 100% test pass rate for new job system
This commit is contained in:
170
web/src/server/jobs/worker.test.ts
Normal file
170
web/src/server/jobs/worker.test.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { InMemoryQueue, setQueue, resetQueue } from "./queue";
|
||||
import { processJob, startWorker, stopWorker } from "./worker";
|
||||
import { setHandlers } from "./handlers";
|
||||
import type { Job } from "./queue";
|
||||
|
||||
describe("worker", () => {
|
||||
let queue: InMemoryQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
queue = new InMemoryQueue();
|
||||
setQueue(queue);
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
resetQueue();
|
||||
});
|
||||
|
||||
describe("processJob", () => {
|
||||
it("dispatches darkwatch.scan to correct handler", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
setHandlers({ "darkwatch.scan": handler });
|
||||
|
||||
const job = await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
|
||||
|
||||
await processJob(job);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ userId: "u1", subscriptionId: "s1" });
|
||||
});
|
||||
|
||||
it("dispatches hometitle.scan to correct handler", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
setHandlers({ "hometitle.scan": handler });
|
||||
|
||||
const job = await queue.enqueue("hometitle.scan", { userId: "u1", subscriptionId: "s1" });
|
||||
|
||||
await processJob(job);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ userId: "u1", subscriptionId: "s1" });
|
||||
});
|
||||
|
||||
it("dispatches reports.generate to correct handler", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
setHandlers({ "reports.generate": handler });
|
||||
|
||||
const job = await queue.enqueue("reports.generate", { userId: "u1", reportType: "MONTHLY_PLUS" });
|
||||
|
||||
await processJob(job);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ userId: "u1", reportType: "MONTHLY_PLUS" });
|
||||
});
|
||||
|
||||
it("dispatches notifications.send to correct handler", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
setHandlers({ "notifications.send": handler });
|
||||
|
||||
const job = await queue.enqueue("notifications.send", { userId: "u1", channel: "email" });
|
||||
|
||||
await processJob(job);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ userId: "u1", channel: "email" });
|
||||
});
|
||||
|
||||
it("dispatches removebrokers.process to correct handler", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
setHandlers({ "removebrokers.process": handler });
|
||||
|
||||
const job = await queue.enqueue("removebrokers.process", {});
|
||||
|
||||
await processJob(job);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it("dispatches voiceprint.batch to correct handler", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
setHandlers({ "voiceprint.batch": handler });
|
||||
|
||||
const job = await queue.enqueue("voiceprint.batch", { userId: "u1" });
|
||||
|
||||
await processJob(job);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ userId: "u1" });
|
||||
});
|
||||
|
||||
it("marks job as completed on success", async () => {
|
||||
setHandlers({ "darkwatch.scan": vi.fn().mockResolvedValue(undefined) });
|
||||
|
||||
const job = await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
|
||||
await processJob(job);
|
||||
|
||||
const updated = await queue.getJob(job.id);
|
||||
expect(updated?.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("schedules retry on failure with exponential backoff", async () => {
|
||||
const handler = vi.fn().mockRejectedValue(new Error("Scan failed"));
|
||||
setHandlers({ "darkwatch.scan": handler });
|
||||
|
||||
const job = await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
|
||||
await processJob(job);
|
||||
|
||||
// After first retry, job should be pending with attempt 1
|
||||
const updated = await queue.getJob(job.id);
|
||||
expect(updated?.attempts).toBe(1);
|
||||
expect(updated?.status).toBe("pending");
|
||||
|
||||
// Retry delay should be 60s for first retry
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("marks as failed after exhausting retries", async () => {
|
||||
const handler = vi.fn().mockRejectedValue(new Error("Final failure"));
|
||||
setHandlers({ "darkwatch.scan": handler });
|
||||
|
||||
const job = await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" }, { maxAttempts: 2 });
|
||||
|
||||
// First attempt - retries (schedules retry)
|
||||
await processJob(job);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
expect(job.attempts).toBe(1);
|
||||
|
||||
// Advance past the retry delay so the retry is available
|
||||
vi.advanceTimersByTime(60001);
|
||||
|
||||
// Second attempt - dequeue and process
|
||||
const retried = await queue.dequeue();
|
||||
expect(retried).not.toBeNull();
|
||||
if (retried) {
|
||||
await processJob(retried);
|
||||
}
|
||||
|
||||
// After max attempts, job should be failed
|
||||
const failed = await queue.getJob(job.id);
|
||||
expect(failed?.status).toBe("failed");
|
||||
expect(failed?.error).toBe("Final failure");
|
||||
});
|
||||
});
|
||||
|
||||
describe("startWorker / stopWorker", () => {
|
||||
it("polls queue and processes jobs", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
setHandlers({ "darkwatch.scan": handler });
|
||||
|
||||
await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
|
||||
|
||||
startWorker({ pollInterval: 50 });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(handler).toHaveBeenCalled();
|
||||
|
||||
await stopWorker();
|
||||
});
|
||||
|
||||
it("stops polling after stopWorker", async () => {
|
||||
const handler = vi.fn().mockResolvedValue(undefined);
|
||||
setHandlers({ "darkwatch.scan": handler });
|
||||
|
||||
startWorker({ pollInterval: 50 });
|
||||
await stopWorker();
|
||||
|
||||
await queue.enqueue("darkwatch.scan", { userId: "u1", subscriptionId: "s1" });
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user