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:
2026-05-25 17:16:21 -04:00
parent 659ab9b71a
commit eb8e57c674
19 changed files with 1429 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import { hometitleRouter } from "./routers/hometitle";
import { removebrokersRouter } from "./routers/removebrokers";
import { correlationRouter } from "./routers/correlation";
import { reportsRouter } from "./routers/reports";
import { schedulerRouter } from "./routers/scheduler";
import { createTRPCRouter } from "./utils";
export const appRouter = createTRPCRouter({
@@ -23,6 +24,7 @@ export const appRouter = createTRPCRouter({
removebrokers: removebrokersRouter,
correlation: correlationRouter,
reports: reportsRouter,
scheduler: schedulerRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -0,0 +1,82 @@
import { wrap } from "@typeschema/valibot";
import { object, string } from "valibot";
import { createTRPCRouter, adminProcedure, publicProcedure } from "../utils";
import { RunJobNowSchema, JobStatusSchema } from "../schemas/scheduler";
import { getQueue } from "~/server/jobs";
import { registerSchedules, scheduleForSubscription, removeSchedulesForSubscription, getCronOverview } from "~/server/jobs";
import { JOB_TYPES, type JobType } from "~/server/jobs/queue";
export const schedulerRouter = createTRPCRouter({
getCronOverview: adminProcedure.query(async () => {
return { overview: getCronOverview() };
}),
runJobNow: adminProcedure
.input(wrap(RunJobNowSchema))
.mutation(async ({ input }) => {
const { type, payload } = input;
if (!JOB_TYPES.includes(type as JobType)) {
throw new Error(`Invalid job type: ${type}. Must be one of: ${JOB_TYPES.join(", ")}`);
}
const queue = getQueue();
const job = await queue.enqueue(type as JobType, payload as any);
return { jobId: job.id, status: job.status };
}),
getJobStatus: adminProcedure
.input(wrap(JobStatusSchema))
.query(async ({ input }) => {
const queue = getQueue();
const job = await queue.getJob(input.jobId);
if (!job) {
throw new Error(`Job not found: ${input.jobId}`);
}
return {
id: job.id,
type: job.type,
status: job.status,
attempts: job.attempts,
maxAttempts: job.maxAttempts,
error: job.error ?? null,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
};
}),
listJobs: adminProcedure
.query(async () => {
const queue = getQueue();
const jobs = await queue.getJobs();
return jobs.map((j) => ({
id: j.id,
type: j.type,
status: j.status,
attempts: j.attempts,
maxAttempts: j.maxAttempts,
error: j.error ?? null,
createdAt: j.createdAt,
updatedAt: j.updatedAt,
}));
}),
reloadSchedules: adminProcedure.mutation(async () => {
await registerSchedules();
return { success: true };
}),
scheduleForTier: adminProcedure
.input(wrap(object({ subscriptionId: string(), userId: string(), tier: string() })))
.mutation(async ({ input }) => {
scheduleForSubscription(input);
return { success: true };
}),
removeSchedules: adminProcedure
.input(wrap(object({ subscriptionId: string() })))
.mutation(async ({ input }) => {
removeSchedulesForSubscription(input.subscriptionId);
return { success: true };
}),
});

View File

@@ -0,0 +1,19 @@
import { object, string, optional, enumType } from "valibot";
export const RunJobNowSchema = object({
type: string(),
payload: object({
userId: optional(string()),
subscriptionId: optional(string()),
requestId: optional(string()),
reportType: optional(string()),
reportScheduleId: optional(string()),
channel: optional(string()),
alertId: optional(string()),
jobId: optional(string()),
}),
});
export const JobStatusSchema = object({
jobId: string(),
});