Files
Checkmate/server/src/worker/worker.db-queue.ts
T

290 lines
10 KiB
TypeScript
Raw Normal View History

2026-06-23 11:22:37 -07:00
import { supportsGeoCheck } from "@/domain/monitors/monitor.types.js";
import { IQueueWorker } from "@/worker/worker.interface.js";
2026-06-15 12:05:15 -07:00
import { type Job, type JobSeed, type JobType, jobId, LOCK_MS } from "@/domain/jobs/job.type.js";
2026-06-12 15:43:36 -07:00
import { ILogger } from "@/utils/logger.js";
import { IJobsRepository } from "@/domain/jobs/job.repository.interface.js";
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
import { IChecksRepository } from "@/domain/checks/check.repository.interface.js";
import { ICheckService } from "@/domain/checks/check.service.js";
import { ICheckProducer } from "@/worker/worker.check-producer.js";
import { ICheckEvaluator } from "@/worker/worker.check-evaluator.js";
import { ICheckPipeline } from "@/worker/worker.check-pipeline.js";
import { IReactorDispatcher } from "@/worker/reactors/reactor.dispatcher.js";
import { IWorkerHelper } from "@/worker/worker.helper.js";
import { IQueueWorkersRepository } from "@/domain/queue-workers/queue-worker.repository.interface.js";
import { WORKER_TTL_SECONDS } from "@/domain/queue-workers/queue-worker.model.js";
import { QueueMode } from "@/domain/app-settings/app-settings.type.js";
2026-06-23 11:22:37 -07:00
import { JobScheduler } from "@/worker/worker.job-scheduler.js";
2026-06-12 15:43:36 -07:00
const SERVICE_NAME = "JobQueue";
2026-06-17 11:35:15 -07:00
const POLL_MS = 250; // base poll interval while a loop is actively claiming work
const POLL_MAX_MS = 5000; // back off poll interval to 5s if no jobs
const WORKER_STALE_MS = WORKER_TTL_SECONDS * 1000; // a worker counts as alive if seen within this window
const HEARTBEAT_MS = WORKER_STALE_MS / 3; // Worker can miss two beats without being considered stale
2026-06-15 12:05:15 -07:00
const LOCK_RENEW_MS = LOCK_MS / 3; // renew an in-flight job's lock at 1/3 the lease, so it survives two missed renewals
2026-06-12 15:43:36 -07:00
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000;
const CONCURRENCY: Record<JobType, number> = {
2026-06-12 16:04:31 -07:00
check: 500,
2026-06-12 15:43:36 -07:00
"geo-check": 20,
evaluate: 20,
"cleanup-orphaned": 1,
"cleanup-retention": 1,
};
2026-06-23 11:22:37 -07:00
export class DBQueueWorker extends JobScheduler implements IQueueWorker {
2026-06-12 15:43:36 -07:00
static SERVICE_NAME = SERVICE_NAME;
private inFlight: Record<JobType, number> = {
check: 0,
"geo-check": 0,
evaluate: 0,
"cleanup-orphaned": 0,
"cleanup-retention": 0,
};
constructor(
private logger: ILogger,
2026-06-23 11:22:37 -07:00
jobsRepository: IJobsRepository, // ← no modifier: forwarded to super
2026-06-12 15:43:36 -07:00
private monitorsRepository: IMonitorsRepository,
private checksRepository: IChecksRepository,
2026-06-23 11:22:37 -07:00
private checkService: ICheckService,
2026-06-12 15:43:36 -07:00
private checkProducer: ICheckProducer,
private checkEvaluator: ICheckEvaluator,
private geoCheckPipeline: ICheckPipeline,
private dispatcher: IReactorDispatcher,
2026-06-15 10:39:48 -07:00
private helper: IWorkerHelper,
2026-06-23 11:22:37 -07:00
queueWorkersRepository: IQueueWorkersRepository, // ← no modifier: forwarded
private queueMode: QueueMode,
2026-06-15 12:21:36 -07:00
private queuePrimaryProcesses: boolean,
2026-06-23 11:22:37 -07:00
workerId: string
) {
super(jobsRepository, queueWorkersRepository, workerId);
}
2026-06-12 15:43:36 -07:00
get serviceName() {
return DBQueueWorker.SERVICE_NAME;
}
static async create(
logger: ILogger,
jobsRepository: IJobsRepository,
monitorsRepository: IMonitorsRepository,
checksRepository: IChecksRepository,
checkService: ICheckService,
checkProducer: ICheckProducer,
checkEvaluator: ICheckEvaluator,
geoCheckPipeline: ICheckPipeline,
dispatcher: IReactorDispatcher,
2026-06-15 10:39:48 -07:00
helper: IWorkerHelper,
queueWorkersRepository: IQueueWorkersRepository,
queueMode: QueueMode,
2026-06-15 12:21:36 -07:00
queuePrimaryProcesses: boolean,
workerId: string
2026-06-12 15:43:36 -07:00
): Promise<DBQueueWorker> {
const instance = new DBQueueWorker(
logger,
jobsRepository,
monitorsRepository,
checksRepository,
checkService,
checkProducer,
checkEvaluator,
geoCheckPipeline,
dispatcher,
2026-06-15 10:39:48 -07:00
helper,
queueWorkersRepository,
queueMode,
2026-06-15 12:21:36 -07:00
queuePrimaryProcesses,
workerId
2026-06-12 15:43:36 -07:00
);
await instance.init();
return instance;
}
private toCleanupJob = (type: "cleanup-orphaned" | "cleanup-retention", now: number): JobSeed => ({
id: jobId(type, null),
type,
refId: null,
isActive: true,
nextScheduledAt: now,
intervalMs: CLEANUP_INTERVAL_MS,
});
// ********************
// Stage 1: This can eventually be offloaded to a separate service
// ********************
private runCheck = async (job: Job) => {
if (!job.refId) return;
2026-06-18 11:05:57 -07:00
const monitor = await this.monitorsRepository.findByIdLean(job.refId); // job row has no teamId
2026-06-12 15:43:36 -07:00
if (!monitor) return;
2026-06-18 09:56:56 -07:00
await this.checkProducer.produce(monitor);
2026-06-12 15:43:36 -07:00
};
// ********************
// Stage 2: Evaluator loop stays here
// ********************
private runEvaluate = async (job: Job) => {
if (!job.refId) return;
2026-06-18 11:05:57 -07:00
const monitor = await this.monitorsRepository.findByIdLean(job.refId); // job row has no teamId
2026-06-12 15:43:36 -07:00
if (!monitor) return;
const checks = await this.checksRepository.findUnevaluatedByMonitorId(job.refId, monitor.lastEvaluatedAt);
2026-06-18 11:05:57 -07:00
let current = monitor;
2026-06-12 15:43:36 -07:00
for (const check of checks) {
const status = this.checkService.toStatusResponse(check);
2026-06-18 11:05:57 -07:00
const evaluation = await this.checkEvaluator.evaluate(status, check, current);
2026-06-12 15:43:36 -07:00
await this.dispatcher.dispatch(evaluation); // Handle incidents and notifications
2026-06-18 11:05:57 -07:00
await this.monitorsRepository.updateById(job.refId, current.teamId, { lastEvaluatedAt: this.checkService.toLastEvaluatedAt(check) });
current = evaluation.statusChange.monitor; // fresh statusWindow/status/counters for the next check
2026-06-12 15:43:36 -07:00
}
};
private runGeoCheck = async (job: Job) => {
2026-06-18 11:05:57 -07:00
const monitor = await this.monitorsRepository.findByIdLean(job.refId!);
2026-06-12 15:43:36 -07:00
if (monitor) await this.geoCheckPipeline.run(monitor); // returns null; no evaluate handoff
};
2026-06-15 12:05:15 -07:00
// Extend the lock while a job runs so a slow-but-alive job is never reclaimed.
private renewLock = async (job: Job): Promise<boolean> => {
try {
const held = await this.jobsRepository.renewLocks([job.id], Date.now());
if (held === 0) {
this.logger.warn({ message: `Lost lock on job ${job.id}, stopping renewal`, service: SERVICE_NAME, method: "renewLock" });
return false;
}
return true;
} catch (error: unknown) {
this.logger.warn({ message: error instanceof Error ? error.message : String(error), service: SERVICE_NAME, method: "renewLock" });
return true;
}
};
2026-06-12 15:43:36 -07:00
private runJob = async (job: Job) => {
2026-06-15 12:05:15 -07:00
const renewTimer = setInterval(async () => {
const renewLock = await this.renewLock(job);
if (!renewLock) clearInterval(renewTimer);
}, LOCK_RENEW_MS);
2026-06-12 15:43:36 -07:00
try {
switch (job.type) {
case "check":
await this.runCheck(job);
break;
case "evaluate":
await this.runEvaluate(job);
break;
case "geo-check":
await this.runGeoCheck(job);
break;
case "cleanup-orphaned":
await this.helper.getCleanupOrphanedJob()(); // Get job and execute
break;
case "cleanup-retention":
await this.helper.getCleanupRetentionJob()(); // Get job and execute
break;
}
if (job.intervalMs === null) {
// One-shot job
await this.jobsRepository.recordOneShot(job.id, Date.now());
} else {
await this.jobsRepository.recordSuccess(job.id, job.nextScheduledAt, job.intervalMs, Date.now());
}
} catch (error: unknown) {
await this.jobsRepository.recordFailure(job.id, error, Date.now()); // Record failure and release lock
this.logger.warn({
message: error instanceof Error ? error.message : String(error),
service: SERVICE_NAME,
method: `runJob:${job.type}`,
});
2026-06-15 12:05:15 -07:00
} finally {
clearInterval(renewTimer);
2026-06-12 15:43:36 -07:00
}
};
private startLoop = (type: JobType) => {
2026-06-19 10:25:59 -07:00
this.pollMs[type] = POLL_MS;
2026-06-12 15:43:36 -07:00
const tick = async () => {
if (this.stopped) return;
2026-06-19 10:25:59 -07:00
this.ticking[type] = true;
2026-06-12 15:43:36 -07:00
try {
2026-06-12 16:04:31 -07:00
// Claim a batch sized to the free capacity
const capacity = CONCURRENCY[type] - this.inFlight[type];
const jobs = await this.jobsRepository.claimDueBatch(type, capacity, Date.now());
for (const job of jobs) {
this.inFlight[type]++; // stay within concurrency limits
2026-06-12 15:43:36 -07:00
this.runJob(job).finally(() => {
2026-06-12 16:04:31 -07:00
this.inFlight[type]--; // job done, free the slot
2026-06-12 15:43:36 -07:00
});
}
2026-06-19 10:25:59 -07:00
// Back off if no jobs found, reset to base if jobs found
this.pollMs[type] = capacity > 0 && jobs.length === 0 ? Math.min(this.pollMs[type] * 2, POLL_MAX_MS) : POLL_MS;
2026-06-12 15:43:36 -07:00
} catch (error: unknown) {
this.logger.error({
message: error instanceof Error ? error.message : String(error),
service: SERVICE_NAME,
method: `loop:${type}`,
});
}
2026-06-19 10:25:59 -07:00
// Clearing the flag and re-arming run synchronously (no await between), so wake() can never interleave here.
this.ticking[type] = false;
if (!this.stopped) this.timers.set(type, setTimeout(tick, this.pollMs[type])); // re-arm unless shutting down
2026-06-12 15:43:36 -07:00
};
2026-06-19 10:25:59 -07:00
this.tickFns.set(type, tick);
2026-06-12 15:43:36 -07:00
tick();
};
2026-06-19 10:25:59 -07:00
// Wake a worker loop after adding a job that's due immediately.
// Otherwise the loop could be delayed by POLL_MAX_MS if it is already fully backed off
2026-06-12 15:43:36 -07:00
// Seed queue
private reconcile = async () => {
const now = Date.now();
const monitors = (await this.monitorsRepository.findAll()) ?? [];
for (const monitor of monitors) {
await this.jobsRepository.upsertJob(this.toCheckJob(monitor, now));
if (supportsGeoCheck(monitor.type) && monitor.geoCheckEnabled) {
await this.jobsRepository.upsertJob(this.toGeoCheckJob(monitor, now));
}
}
// Both cleanup jobs run immediately on every startup, then once a day
await this.jobsRepository.upsertCleanupJob(this.toCleanupJob("cleanup-orphaned", now));
await this.jobsRepository.upsertCleanupJob(this.toCleanupJob("cleanup-retention", now));
2026-06-12 15:43:36 -07:00
};
// Register worker
private heartbeat = async () => {
try {
await this.queueWorkersRepository.upsert(this.workerId, this.queueMode);
} catch (error: unknown) {
this.logger.warn({
message: error instanceof Error ? error.message : String(error),
service: SERVICE_NAME,
method: "heartbeat",
});
}
};
2026-06-12 15:43:36 -07:00
init = async () => {
this.stopped = false;
await this.heartbeat();
this.timers.set("heartbeat", setInterval(this.heartbeat, HEARTBEAT_MS));
if (this.queueMode === "primary") {
2026-06-15 10:39:48 -07:00
await this.reconcile();
}
if (this.queuePrimaryProcesses === true || this.queueMode === "worker") {
2026-06-15 10:39:48 -07:00
for (const type of Object.keys(this.inFlight) as JobType[]) {
this.startLoop(type);
}
2026-06-12 15:43:36 -07:00
}
return true;
};
2026-06-23 11:22:37 -07:00
override flushQueues = async () => {
2026-06-12 15:43:36 -07:00
await this.shutdown();
const ok = await this.init();
return { success: ok };
};
}