@cadence-mq/core
Version:
Modern, type-safe, and performant task queue for Node.js
409 lines (395 loc) • 10.5 kB
JavaScript
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