mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
333 lines
9.5 KiB
TypeScript
333 lines
9.5 KiB
TypeScript
import { ClusterKey } from "../EnvironmentConfig";
|
|
import Dictionary from "../../Types/Dictionary";
|
|
import { JSONObject } from "../../Types/JSON";
|
|
import { Queue as BullQueue, Job, JobsOptions } from "bullmq";
|
|
import { ExpressAdapter } from "@bull-board/express";
|
|
import { createBullBoard } from "@bull-board/api";
|
|
import { BullMQAdapter } from "@bull-board/api/bullMQAdapter";
|
|
import { ExpressRouter } from "../Utils/Express";
|
|
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
import logger from "../Utils/Logger";
|
|
import Redis from "./Redis";
|
|
|
|
export enum QueueName {
|
|
Workflow = "Workflow",
|
|
Worker = "Worker",
|
|
Telemetry = "Telemetry",
|
|
IncomingRequestIngest = "IncomingRequestIngest",
|
|
ServerMonitorIngest = "ServerMonitorIngest",
|
|
ProbeIngest = "ProbeIngest",
|
|
}
|
|
|
|
export type QueueJob = Job;
|
|
type BullBoardQueues = Parameters<typeof createBullBoard>[0]["queues"];
|
|
|
|
export default class Queue {
|
|
private static queueDict: Dictionary<BullQueue> = {};
|
|
// track queues we have already run initial cleanup on
|
|
private static cleanedQueueNames: Set<string> = new Set<string>();
|
|
// store repeatable jobs to re-add on reconnect
|
|
private static repeatableJobs: Dictionary<
|
|
Dictionary<{
|
|
jobName: string;
|
|
data: JSONObject;
|
|
options: JobsOptions;
|
|
}>
|
|
> = {};
|
|
|
|
// BullMQ rejects custom IDs containing colons, so normalize them early.
|
|
private static sanitizeJobId(jobId: string): string {
|
|
return jobId.replace(/:/g, "-");
|
|
}
|
|
|
|
private static async setupReconnectListener(
|
|
queue: BullQueue,
|
|
queueName: QueueName,
|
|
): Promise<void> {
|
|
const client: Awaited<typeof queue.client> = await queue.client;
|
|
client.on("ready", async () => {
|
|
logger.debug(`Queue ${queueName} reconnected, re-adding repeatable jobs`);
|
|
const jobs:
|
|
| Dictionary<{
|
|
jobName: string;
|
|
data: JSONObject;
|
|
options: JobsOptions;
|
|
}>
|
|
| undefined = Queue.repeatableJobs[queueName];
|
|
if (jobs) {
|
|
for (const jobId in jobs) {
|
|
const job:
|
|
| { jobName: string; data: JSONObject; options: JobsOptions }
|
|
| undefined = jobs[jobId];
|
|
if (job) {
|
|
try {
|
|
logger.debug(
|
|
`Re-adding repeatable job ${job.jobName} to queue ${queueName}`,
|
|
);
|
|
await queue.add(job.jobName, job.data, job.options);
|
|
} catch (err: unknown) {
|
|
logger.error("Error re-adding repeatable job");
|
|
logger.error(err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public static getQueue(queueName: QueueName): BullQueue {
|
|
// check if the queue is already created
|
|
if (this.queueDict[queueName]) {
|
|
return this.queueDict[queueName] as BullQueue;
|
|
}
|
|
|
|
const queue: BullQueue = new BullQueue(queueName, {
|
|
connection: Redis.getRedisOptions(),
|
|
// Keep BullMQ data under control to avoid Redis bloat
|
|
defaultJobOptions: {
|
|
// keep only recent completed/failed jobs
|
|
removeOnComplete: { count: 500 }, // keep last 1000 completed jobs
|
|
removeOnFail: { count: 100 }, // keep last 500 failed jobs
|
|
},
|
|
/*
|
|
* Optionally cap the event stream length (supported in BullMQ >= v5)
|
|
* This helps prevent the :events stream from growing indefinitely
|
|
*/
|
|
streams: {
|
|
events: { maxLen: 1000 },
|
|
},
|
|
});
|
|
|
|
// save it to the dictionary
|
|
this.queueDict[queueName] = queue;
|
|
|
|
// Add event listener to re-add repeatable jobs on reconnect
|
|
this.setupReconnectListener(queue, queueName).catch((err: unknown) => {
|
|
logger.error("Error setting up reconnect listener for queue");
|
|
logger.error(err);
|
|
});
|
|
|
|
// Fire-and-forget initial cleanup for legacy/old data if not done before
|
|
if (!this.cleanedQueueNames.has(queueName)) {
|
|
this.cleanedQueueNames.add(queueName);
|
|
// Clean jobs older than 1 days to reclaim memory from historic runs
|
|
const oneDaysMs: number = 1 * 24 * 60 * 60 * 1000;
|
|
void (async () => {
|
|
try {
|
|
await queue.clean(oneDaysMs, 1000, "completed");
|
|
await queue.clean(oneDaysMs, 1000, "failed");
|
|
} catch {
|
|
// ignore cleanup errors to not impact normal flow
|
|
}
|
|
})();
|
|
}
|
|
|
|
return queue;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public static async removeJob(
|
|
queueName: QueueName,
|
|
jobId: string,
|
|
): Promise<void> {
|
|
if (!jobId) {
|
|
return;
|
|
}
|
|
|
|
const sanitizedJobId: string = this.sanitizeJobId(jobId.toString());
|
|
|
|
const job: Job | undefined =
|
|
await this.getQueue(queueName).getJob(sanitizedJobId);
|
|
|
|
if (job) {
|
|
await job.remove();
|
|
}
|
|
|
|
// remove existing repeatable job
|
|
await this.getQueue(queueName).removeRepeatableByKey(sanitizedJobId);
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public static getInspectorRoute(): string {
|
|
return "/worker/inspect/queue/:clusterKey";
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public static getQueueInspectorRouter(): ExpressRouter {
|
|
const serverAdapter: ExpressAdapter = new ExpressAdapter();
|
|
|
|
const queueAdapters: BullMQAdapter[] = Object.values(QueueName).map(
|
|
(queueName: QueueName) => {
|
|
return new BullMQAdapter(this.getQueue(queueName));
|
|
},
|
|
);
|
|
|
|
createBullBoard({
|
|
// Cast keeps compatibility until bull-board widens QueueJob.progress
|
|
queues: queueAdapters as unknown as BullBoardQueues,
|
|
serverAdapter: serverAdapter,
|
|
});
|
|
|
|
serverAdapter.setBasePath(
|
|
this.getInspectorRoute().replace(
|
|
"/:clusterKey",
|
|
"/" + ClusterKey.toString(),
|
|
),
|
|
);
|
|
|
|
return serverAdapter.getRouter();
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public static async addJob(
|
|
queueName: QueueName,
|
|
jobId: string,
|
|
jobName: string,
|
|
data: JSONObject,
|
|
options?: {
|
|
scheduleAt?: string | undefined;
|
|
repeatableKey?: string | undefined;
|
|
},
|
|
): Promise<Job> {
|
|
const sanitizedJobId: string = this.sanitizeJobId(jobId.toString());
|
|
|
|
const optionsObject: JobsOptions = {
|
|
jobId: sanitizedJobId,
|
|
};
|
|
|
|
if (options && options.scheduleAt) {
|
|
optionsObject.repeat = {
|
|
pattern: options.scheduleAt,
|
|
};
|
|
}
|
|
|
|
const job: Job | undefined =
|
|
await this.getQueue(queueName).getJob(sanitizedJobId);
|
|
|
|
if (job) {
|
|
await job.remove();
|
|
}
|
|
|
|
if (options?.repeatableKey) {
|
|
// remove existing repeatable job
|
|
await this.getQueue(queueName).removeRepeatableByKey(
|
|
options?.repeatableKey,
|
|
);
|
|
}
|
|
|
|
// Store repeatable jobs for re-adding on reconnect
|
|
if (options && options.scheduleAt) {
|
|
if (!this.repeatableJobs[queueName]) {
|
|
this.repeatableJobs[queueName] = {};
|
|
}
|
|
this.repeatableJobs[queueName]![sanitizedJobId] = {
|
|
jobName,
|
|
data,
|
|
options: optionsObject,
|
|
};
|
|
}
|
|
|
|
const jobAdded: Job = await this.getQueue(queueName).add(
|
|
jobName,
|
|
data,
|
|
optionsObject,
|
|
);
|
|
|
|
return jobAdded;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public static async getQueueSize(queueName: QueueName): Promise<number> {
|
|
const queue: BullQueue = this.getQueue(queueName);
|
|
const waitingCount: number = await queue.getWaitingCount();
|
|
const activeCount: number = await queue.getActiveCount();
|
|
const delayedCount: number = await queue.getDelayedCount();
|
|
|
|
return waitingCount + activeCount + delayedCount;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public static async getQueueStats(queueName: QueueName): Promise<{
|
|
waiting: number;
|
|
active: number;
|
|
completed: number;
|
|
failed: number;
|
|
delayed: number;
|
|
total: number;
|
|
}> {
|
|
const queue: BullQueue = this.getQueue(queueName);
|
|
const waitingCount: number = await queue.getWaitingCount();
|
|
const activeCount: number = await queue.getActiveCount();
|
|
const completedCount: number = await queue.getCompletedCount();
|
|
const failedCount: number = await queue.getFailedCount();
|
|
const delayedCount: number = await queue.getDelayedCount();
|
|
|
|
return {
|
|
waiting: waitingCount,
|
|
active: activeCount,
|
|
completed: completedCount,
|
|
failed: failedCount,
|
|
delayed: delayedCount,
|
|
total:
|
|
waitingCount +
|
|
activeCount +
|
|
completedCount +
|
|
failedCount +
|
|
delayedCount,
|
|
};
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public static async getFailedJobs(
|
|
queueName: QueueName,
|
|
options?: {
|
|
start?: number;
|
|
end?: number;
|
|
},
|
|
): Promise<
|
|
Array<{
|
|
id: string;
|
|
name: string;
|
|
data: JSONObject;
|
|
failedReason: string;
|
|
stackTrace?: string;
|
|
processedOn: Date | null;
|
|
finishedOn: Date | null;
|
|
attemptsMade: number;
|
|
}>
|
|
> {
|
|
const queue: BullQueue = this.getQueue(queueName);
|
|
const start: number = options?.start || 0;
|
|
const end: number = options?.end || 100;
|
|
const failed: Job[] = await queue.getFailed(start, end);
|
|
|
|
return failed.map((job: Job) => {
|
|
const result: {
|
|
id: string;
|
|
name: string;
|
|
data: JSONObject;
|
|
failedReason: string;
|
|
stackTrace?: string;
|
|
processedOn: Date | null;
|
|
finishedOn: Date | null;
|
|
attemptsMade: number;
|
|
} = {
|
|
id: job.id || "unknown",
|
|
name: job.name || "unknown",
|
|
data: job.data as JSONObject,
|
|
failedReason: job.failedReason || "No reason provided",
|
|
processedOn: job.processedOn ? new Date(job.processedOn) : null,
|
|
finishedOn: job.finishedOn ? new Date(job.finishedOn) : null,
|
|
attemptsMade: job.attemptsMade || 0,
|
|
};
|
|
|
|
if (job.stacktrace && job.stacktrace.length > 0) {
|
|
result.stackTrace = job.stacktrace.join("\n");
|
|
}
|
|
|
|
return result;
|
|
});
|
|
}
|
|
}
|