UNPKG

@cadence-mq/core

Version:

Modern, type-safe, and performant task queue for Node.js

409 lines (395 loc) 10.5 kB
import { createId } from "@paralleldrive/cuid2"; import { CronExpressionParser } from "cron-parser"; //#region src/errors/errors.models.ts function serializeError({ error }) { if (error instanceof Error) return `${error.message}\n\n${error.stack}`; if (typeof error === "object" && error !== null) return JSON.stringify(error); return String(error); } function castError(error) { if (error instanceof Error) return error; return new Error(String(error)); } function safely(fn) { return fn.then((result) => [void 0, result]).catch((error) => [castError(error), void 0]); } var CadenceError = class extends Error { isCadenceError = true; code; constructor({ message, cause, code }) { super(message); this.cause = cause; this.code = code; this.name = "CadenceError"; } }; function isCadenceError(error) { return error instanceof CadenceError; } function createError({ message, cause, code }) { return new CadenceError({ message, cause, code }); } function createErrorFactory(factorySettings) { return (instanceSettings = {}) => createError({ message: instanceSettings.message ?? factorySettings.message, cause: instanceSettings.cause, code: instanceSettings.code ?? factorySettings.code }); } //#endregion //#region src/errors/errors.definitions.ts const createJobWithSameIdExistsError = createErrorFactory({ code: "jobs.unique-id-constraint-violation", message: "A job with the same id already exists" }); const createJobNotFoundError = createErrorFactory({ code: "jobs.not-found", message: "Job not found" }); const createInvalidCronExpressionError = createErrorFactory({ code: "jobs.invalid-cron-expression", message: "Invalid cron expression" }); //#endregion //#region src/shared/date.ts function getNextExecutionDate({ cron, relativeTo = /* @__PURE__ */ new Date() }) { if (cron.trim() === "") throw createInvalidCronExpressionError(); try { const interval = CronExpressionParser.parse(cron, { currentDate: relativeTo }); const nextDate = interval.next().toDate(); return { nextDate }; } catch (error) { throw createInvalidCronExpressionError({ cause: error }); } } //#endregion //#region src/tasks/tasks.usecases.ts async function validateTaskDefinitionData({ taskDefinition, data }) { const schema = taskDefinition.schema?.data; if (!schema) return data; const result = await schema["~standard"].validate(data); if (result?.issues) throw new Error(`Invalid data for task ${taskDefinition.taskName}: ${JSON.stringify(result.issues.map((issue) => issue.message).join(", "))}`); return result?.value; } async function validateTaskData({ taskRegistry, taskName, data }) { if (!taskRegistry) return data; const { taskDefinition } = taskRegistry.getTask({ taskName }); if (!taskDefinition) return data; return await validateTaskDefinitionData({ taskDefinition, data }); } //#endregion //#region src/scheduler/scheduler.ts async function scheduleJob({ taskName, data, now = /* @__PURE__ */ new Date(), scheduledAt = now, maxRetries, deleteJobOnCompletion = false, driver, taskRegistry, generateJobId = createId }) { const validatedData = await validateTaskData({ taskRegistry, taskName, data }); const job = { id: generateJobId(), taskName, data: validatedData, scheduledAt, status: "pending", maxRetries, deleteJobOnCompletion, createdAt: now }; await driver.saveJob({ job, now }); return { jobId: job.id }; } async function schedulePeriodicJob({ scheduleId: jobId, cron, taskName, data, now = /* @__PURE__ */ new Date(), maxRetries, driver, taskRegistry, immediate = false }) { const validatedData = await validateTaskData({ taskRegistry, taskName, data }); const { nextDate } = getNextExecutionDate({ cron, relativeTo: now }); const { job: existingJob } = await driver.getJob({ jobId }); const job = { id: jobId, taskName, data: validatedData, scheduledAt: immediate ? now : nextDate, status: "pending", maxRetries, cron, createdAt: now, deleteJobOnCompletion: false }; if (existingJob) { await driver.updateJob({ jobId, values: job, now }); return { jobId }; } await driver.saveJob({ job, now }); return { jobId }; } function createScheduler({ driver, taskRegistry, generateJobId }) { return (args) => scheduleJob({ ...args, driver, taskRegistry, generateJobId }); } function createPeriodicJobScheduler({ driver, taskRegistry }) { return (args) => schedulePeriodicJob({ ...args, driver, taskRegistry }); } //#endregion //#region src/tasks/task-definition.registry.ts function createTaskRegistry() { const taskDefinitions = /* @__PURE__ */ new Map(); return { registerTask: (taskDefinition) => { if (taskDefinitions.has(taskDefinition.taskName)) throw new Error(`Task definition already exists: ${taskDefinition.taskName}`); taskDefinitions.set(taskDefinition.taskName, taskDefinition); }, getTask: ({ taskName }) => { const taskDefinition = taskDefinitions.get(taskName); return { taskDefinition }; }, getTaskOrThrow: ({ taskName }) => { const taskDefinition = taskDefinitions.get(taskName); if (!taskDefinition) throw new Error(`Task definition not found: ${taskName}`); return { taskDefinition }; } }; } //#endregion //#region src/events/event-emitter.ts function createEventEmitter() { const listeners = /* @__PURE__ */ new Map(); return { emit: (event, data) => { listeners.get(event)?.forEach((listener) => listener(data)); }, on: (event, listener) => { listeners.set(event, [...listeners.get(event) ?? [], listener]); }, off: (event, listener) => { listeners.set(event, listeners.get(event)?.filter((l) => l !== listener) ?? []); } }; } //#endregion //#region src/shared/retry.ts async function retry(fn, { maxRetries = 0 } = {}) { if (maxRetries < 0) throw new TypeError("maxRetries must be greater than 0"); let attempts = 0; let lastError; while (attempts < maxRetries + 1) try { return await fn(); } catch (error) { attempts++; lastError = error; } throw lastError; } //#endregion //#region src/jobs/jobs.usecases.ts async function processJob({ job, taskDefinition, taskExecutionContext }) { const maxRetries = job.maxRetries ?? taskDefinition.options?.maxRetries ?? 0; return await retry(() => taskDefinition.handler({ data: job.data, context: taskExecutionContext }), { maxRetries }).then((result) => result ?? void 0); } //#endregion //#region src/tasks/tasks.models.ts function createTaskExecutionContext({ workerId }) { return { taskExecutionContext: { workerId } }; } //#endregion //#region src/workers/workers.usecases.ts async function consumeJob({ driver, taskRegistry, workerId, abortSignal, now, eventEmitter, throwOnTaskNotFound = false, logger }) { const { job } = await driver.getNextJobAndMarkAsProcessing({ abortSignal, now }); const { id: jobId, taskName } = job; logger?.debug?.({ jobId, taskName }, "Consuming job"); const { taskDefinition } = taskRegistry.getTask({ taskName }); if (!taskDefinition && throwOnTaskNotFound) throw createJobNotFoundError(); if (!taskDefinition) { eventEmitter.emit("task.not-found", { taskName }); return; } const { taskExecutionContext } = createTaskExecutionContext({ workerId }); eventEmitter.emit("job.started", { jobId }); const [error, result] = await safely(processJob({ job, taskDefinition, taskExecutionContext })); if (job.cron) { const { nextDate } = getNextExecutionDate({ cron: job.cron, relativeTo: job.scheduledAt }); await driver.updateJob({ jobId, values: { scheduledAt: nextDate, status: "pending", error: error ? serializeError({ error }) : void 0, completedAt: now } }); eventEmitter.emit("job.rescheduled", { jobId, nextDate, error }); logger?.info?.({ jobId, taskName, nextDate }, "Job rescheduled"); return; } if (error) { await driver.updateJob({ jobId, values: { error: serializeError({ error }), status: "failed", completedAt: now }, now }); eventEmitter.emit("job.failed", { jobId, error }); logger?.error?.({ jobId, taskName, error }, "Job failed"); return; } await driver.updateJob({ jobId, values: { result, status: "completed", completedAt: now }, now }); eventEmitter.emit("job.completed", { jobId, result }); logger?.info?.({ jobId, taskName }, "Job completed"); if (job.deleteJobOnCompletion && !job.cron) { await driver.deleteJob({ jobId }); eventEmitter.emit("job.deleted", { jobId }); logger?.info?.({ jobId, taskName }, "Job deleted"); } } function startConsumingJobs({ driver, taskRegistry, workerId, abortSignal, getNow, eventEmitter, logger }) { setImmediate(async () => { while (true) await consumeJob({ driver, taskRegistry, workerId, abortSignal, now: getNow?.(), eventEmitter, logger }); }); } //#endregion //#region src/workers/workers.factory.ts function createWorker({ driver, taskRegistry, workerId, getNow = () => /* @__PURE__ */ new Date(), logger }) { let status = "stopped"; const abortController = new AbortController(); const eventEmitter = createEventEmitter(); return { start: () => { status = "running"; logger?.info?.({ workerId }, "Worker started"); startConsumingJobs({ driver, taskRegistry, workerId, abortSignal: abortController.signal, getNow, eventEmitter, logger }); }, getStatus: () => status, on: eventEmitter.on }; } function createWorkerFactory(factoryArgs) { return (instanceArgs) => createWorker({ ...factoryArgs, ...instanceArgs }); } //#endregion //#region src/cadence/cadence.factory.ts function createCadence({ driver, generateJobId, taskRegistry = createTaskRegistry(), logger }) { return { createWorker: createWorkerFactory({ driver, taskRegistry, logger }), scheduleJob: createScheduler({ driver, taskRegistry, generateJobId }), schedulePeriodicJob: createPeriodicJobScheduler({ driver, taskRegistry }), registerTask: taskRegistry.registerTask, getJob: driver.getJob, getJobCount: driver.getJobCount, deleteJob: driver.deleteJob, getTaskRegistry: () => taskRegistry, getDriver: () => driver }; } //#endregion export { createCadence, createError, createErrorFactory, createJobNotFoundError, createJobWithSameIdExistsError, createScheduler, createTaskRegistry, createWorker, isCadenceError, scheduleJob }; //# sourceMappingURL=index.js.map