@compas/store
Version:
Postgres & S3-compatible wrappers for common things
645 lines (586 loc) • 19.2 kB
JavaScript
import { setTimeout } from "node:timers/promises";
import {
_compasSentryExport,
AppError,
eventStart,
eventStop,
isNil,
asyncLocalStorageLogger,
newEvent,
newLogger,
} from "@compas/stdlib";
import cron from "cron-parser";
import { jobWhere } from "./generated/database/job.js";
import { validateStoreJob } from "./generated/store/validators.js";
import { queries } from "./generated.js";
import { query } from "./query.js";
/**
* @typedef {(
* event: import("@compas/stdlib").InsightEvent,
* sql: import("postgres").Sql<{}>,
* job: import("./generated/common/types.d.ts").StoreJob,
* ) => (void | Promise<void>)} QueueWorkerHandler
*/
/**
* @typedef {object} QueueWorkerOptions
* @property {Record<
* string,
* QueueWorkerHandler | {
* handler: QueueWorkerHandler,
* timeout: number
* }
* >} handler
* Specify handler based on job name, optionally adding a timeout. If no timeout for a
* specific handler is provided, the handlerTimeout value is used. The timeout should
* be in milliseconds.
* @property {number} [pollInterval] Determine the poll interval in
* milliseconds if the queue did not have available jobs. Defaults to 1500 ms.
* @property {number} [parallelCount] Set the amount of parallel jobs to
* process. Defaults to 1. Make sure it is not higher than the number of Postgres
* connections in the pool. Note that if you set a higher number than 1 that some jobs
* may run in parallel, so make sure your code expects that.
* @property {number} [maxRetryCount] The worker will automatically catch any
* errors thrown by the handler, and retry the job at a later stage. This property
* defines the max number of retries before forcing the job to be completed. Defaults
* to 2 retries.
* @property {number} [handlerTimeout] Maximum time the handler could take to
* fulfill a job in milliseconds. Defaults to 30 seconds.
* @property {Array<string>} [includedNames] Included job names for this job worker,
* ignores all other jobs.
* @property {Array<string>} [excludedNames] Excluded job names for this job worker,
* picks up all other jobs.
* @property {boolean} [unsafeIgnoreSorting] Improve job throughput by ignoring
* the 'priority' and 'scheduledAt' sort when picking up jobs. Reducing query times if
* a lot of jobs are in the queue. This still only picks up jobs that are eligible to
* be picked up. However, it doesn't guarantee any order. This property is also not
* bound to any SemVer versioning of this package.
* @property {boolean} [deleteJobOnCompletion] The default queue behavior is to keep jobs
* that have been processed and marking them complete. On high-volume queues it may be
* more efficient to automatically remove jobs after completion.
*/
/**
* @typedef {Required<QueueWorkerOptions> & {
* isQueueEnabled: boolean,
* timeout?: number,
* }} QueueWorkerInternalOptions
*/
/**
* @typedef {object} QueueWorkerCronOptions
* @property {Array<{
* name: string,
* priority?: number,
* cronExpression: string,
* }>} jobs Specify all needed cron jobs. You can still use the 'includedNames' and
* 'excludedNames' of the {@link QueueWorkerOptions} so your jobs are handled by a
* specific worker. The default priority is '4'.
*/
const queryParts = {
/**
* @param {import("./generated/common/types.d.ts").StoreJobWhere} where
* @param {import("../types/advanced-types.d.ts").QueryPart<unknown>} [orderBy]
* @returns {import("../types/advanced-types.d.ts").QueryPart<any>}
*/
getJobAndUpdate(where, orderBy) {
return query`
UPDATE "job"
SET
"isComplete" = TRUE,
"updatedAt" = now()
WHERE
id = (
SELECT "id"
FROM "job" j
WHERE
${jobWhere(where, { skipValidator: true, shortName: "j." })}
AND NOT "isComplete"
AND "scheduledAt" < now() ${
orderBy ?
query`ORDER BY
${orderBy}`
: query``
} FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *
`;
},
/**
* @param {import("./generated/common/types.d.ts").StoreJobWhere} where
* @param {import("../types/advanced-types.d.ts").QueryPart<unknown>} [orderBy]
* @returns {import("../types/advanced-types.d.ts").QueryPart<any>}
*/
getJobAndDelete(where, orderBy) {
return query`
DELETE
FROM "job"
WHERE
id = (
SELECT "id"
FROM "job" j
WHERE
${jobWhere(where, { skipValidator: true, shortName: "j." })}
AND NOT "isComplete"
AND "scheduledAt" < now() ${
orderBy ?
query`ORDER BY
${orderBy}`
: query``
} FOR UPDATE SKIP LOCKED
LIMIT 1
)
RETURNING *
`;
},
};
const JOB_TYPE_CRON = "compas.queue.cronJob";
/**
* Add a new job to the queue. Use {@link queueWorkerCreate} for more information about
* the behavior of the queue. Use {@link queueWorkerRegisterCronJobs} to specify
* recurring jobs.
*
* @param {import("../index.js").Postgres} sql
* @param {{
* name: string,
* priority?: number,
* scheduledAt?: Date,
* handlerTimeout?: number,
* data?: Record<string, any>,
* }} options
* @returns {Promise<number>}
*/
export async function queueWorkerAddJob(
sql,
{ name, priority, scheduledAt, handlerTimeout, data },
) {
if (isNil(name) || name.length === 0) {
throw AppError.serverError({
message: `'name' should be set.`,
});
}
if (!isNil(priority) && typeof priority !== "number") {
throw AppError.serverError({
message: `If 'priority' is provided, it should be a number.`,
});
}
if (!isNil(scheduledAt) && !(scheduledAt instanceof Date)) {
throw AppError.serverError({
message: `If 'scheduledAt' is provided, it should be a Date.`,
});
}
if (!isNil(handlerTimeout) && typeof handlerTimeout !== "number") {
throw AppError.serverError({
message: `If 'handlerTimeout' is provided, it should be a number.`,
});
}
const [result] = await queries.jobInsert(sql, {
name,
priority: priority ?? 5,
scheduledAt: scheduledAt ?? new Date(),
handlerTimeout,
data: data ?? {},
});
return Number(result?.id);
}
/**
* Register cron jobs to the queue. Any existing cron job not in this definition will be
* removed from the queue, even if pending jobs exist. When the cron expression of a job
* is changed, it takes effect immediately. The system won't ever upgrade an existing
* normal job to a cron job. Note that your job may not be executed on time. Use
* `job.data.cronLastCompletedAt` and `job.data.cronExpression` to decide if you still
* need to execute your logic. The provided `cronExpression` is evaluated in 'utc' mode.
*
* The default priority for these jobs is '4'.
*
* [cron-parser]{@link https://www.npmjs.com/package/cron-parser} is used for parsing the
* `cronExpression`. If you need a different type of scheduler, use
* {@link queueWorkerAddJob} manually in your job handler.
*
* @param {import("@compas/stdlib").InsightEvent} event
* @param {import("postgres").Sql<{}>} sql
* @param {QueueWorkerCronOptions} options
* @returns {Promise<void>}
*/
export async function queueWorkerRegisterCronJobs(event, sql, { jobs }) {
eventStart(event, "queueWorker.registerCronJobs");
await sql.begin(async (sql) => {
// Obtain an exclusive lock for the live time of this transaction. This ensures that
// processes trying to execute this lock will have to wait till this sync is done,
// preventing conflicts and unnecessary inserts and updates.
await query`SELECT pg_advisory_xact_lock(-9871233452)`.exec(sql);
await queueWorkerRemoveUnknownCronJobs(sql, jobs);
for (const job of jobs) {
await queueWorkerUpsertCronJob(sql, job);
}
});
eventStop(event);
}
/**
* The queue system is based on 'static' units of work to be done in the background.
* It supports the following:
* - Job priority's. Lower value means higher priority.
* - Scheduling jobs at a set time
* - Customizable handler timeouts
* - Recurring job handling
* - Concurrent workers pulling from the same queue
* - Specific workers for a specific job
*
* When to use which function of adding a job:
* - {@link queueWorkerAddJob}: use the queue as background processing of defined units.
* Like converting a file to different formats, sending async or scheduled notifications.
* Jobs created will have a priority of '5'.
* - {@link queueWorkerRegisterCronJobs}: use the queue for scheduled recurring jobs
* based on the specific `cronExpression`. Jos created will have a default priority of
* '4'.
*
* Every job runs with a timeout. It is determined in the following order:
* - Timeout of the specific job, via `handlerTimeout` property. Should be used
* sporadically
* - Timeout of a specific handler as provided by the `handler` property.
* - The `handlerTimeout` property of the QueueWorker
*
* Jobs are picked up if the following criteria are met:
* - The job is not complete yet
* - The job's 'scheduledAt' property is in the past
* - The job's 'retryCount' value is lower than the `maxRetryCount` option.
*
* Eligible jobs are sorted in the following order:
* - By priority ascending, so a lower priority value job will run first
* - By scheduledAt ascending, so an earlier scheduled job will be picked before a later
* scheduled job.
*
* If a job fails, by throwing an error, other jobs may run first before
* any retries happen, based on the above ordering.
*
* @param {import("postgres").Sql<{}>} sql
* @param {QueueWorkerOptions} options
*/
export function queueWorkerCreate(sql, options) {
/** @type {QueueWorkerInternalOptions} */
// @ts-expect-error
const opts = { ...options, isQueueEnabled: true };
opts.pollInterval = options.pollInterval ?? 1500;
opts.parallelCount = options.parallelCount ?? 1;
opts.maxRetryCount = options.maxRetryCount ?? 2;
opts.handlerTimeout = options.handlerTimeout ?? 30 * 1000;
opts.unsafeIgnoreSorting = options.unsafeIgnoreSorting ?? false;
opts.deleteJobOnCompletion = options.deleteJobOnCompletion ?? false;
const logger = newLogger({
ctx: {
type: "queue",
},
});
const where = {};
if (opts.includedNames) {
where.nameIn = opts.includedNames;
} else if (opts.excludedNames) {
where.nameNotIn = opts.excludedNames;
}
const orderBy =
opts.unsafeIgnoreSorting ? undefined : query`"priority", "scheduledAt"`;
const jobTodoQuery =
opts.deleteJobOnCompletion ?
queryParts.getJobAndDelete
: queryParts.getJobAndUpdate;
const workers = Array.from({ length: opts.parallelCount }).map(() => ({
currentPromise: Promise.resolve(),
}));
return {
start() {
logger.info({
message: "Starting queue",
workers: workers.length,
pollInterval: opts.pollInterval,
});
opts.isQueueEnabled = true;
workers.map((it) =>
queueWorkerRun(logger, sql, opts, jobTodoQuery, where, orderBy, it),
);
},
async stop() {
logger.info({
message: "Stopping queue",
workers: workers.length,
});
opts.isQueueEnabled = false;
await Promise.all(workers.map((it) => it.currentPromise));
},
};
}
/**
* @param {import("../index.js").Postgres} sql
* @param {QueueWorkerCronOptions["jobs"]} jobs
*/
async function queueWorkerRemoveUnknownCronJobs(sql, jobs) {
await queries.jobDelete(sql, {
isComplete: false,
$raw: query`j.data->>'jobType' =
${JOB_TYPE_CRON}`,
nameNotIn: jobs.map((it) => it.name),
});
}
/**
* Try to update a cron job with the new expression and priority. Creates a new job if no
* record is updated.
*
* @param {import("../index.js").Postgres} sql
* @param {QueueWorkerCronOptions["jobs"][0]} job
* @returns {Promise<void>}
*/
async function queueWorkerUpsertCronJob(sql, job) {
const nextValue = cron
.parseExpression(job.cronExpression, {
utc: true,
})
.next()
.toDate();
const updatedJobs = await queries.jobUpdate(sql, {
update: {
data: {
$set: {
path: ["cronExpression"],
value: job.cronExpression,
},
},
scheduledAt: nextValue,
priority: job.priority ?? 4,
},
where: {
name: job.name,
isComplete: false,
},
returning: ["id"],
});
if (updatedJobs.length > 1) {
// If a finished cron job is manually restarted, it will schedule a new job again
// without checking if it is necessary. On application startup we correct this by
// removing the extra jobs.
await queries.jobDelete(sql, {
idIn: updatedJobs.slice(1).map((it) => it.id),
});
} else if (updatedJobs.length === 0) {
await queries.jobInsert(sql, {
name: job.name,
priority: job.priority ?? 4,
scheduledAt: nextValue,
data: {
jobType: JOB_TYPE_CRON,
cronExpression: job.cronExpression,
cronLastCompletedAt: new Date(),
},
});
}
}
/**
* @param {import("@compas/stdlib").Logger} logger
* @param {import("postgres").Sql<{}>} sql
* @param {QueueWorkerInternalOptions} options
* @param {(where: import("./generated/common/types.d.ts").StoreJobWhere, orderBy:
* import("../types/advanced-types.d.ts").QueryPart|undefined) =>
* import("../types/advanced-types.d.ts").QueryPart} jobTodoQuery
* @param {import("./generated/common/types.d.ts").StoreJobWhere} where
* @param {import("../types/advanced-types.d.ts").QueryPart|undefined} orderBy
* @param {{currentPromise: Promise<void>}} worker
*/
function queueWorkerRun(
logger,
sql,
options,
jobTodoQuery,
where,
orderBy,
worker,
) {
if (!options.isQueueEnabled) {
return;
}
Promise.resolve(worker.currentPromise).then(() => {
worker.currentPromise = sql
.begin(async (sql) => {
const [job] = await jobTodoQuery(where, orderBy).exec(sql);
if (!job?.id) {
return {
didHandleJob: false,
};
}
const { value, error } = validateStoreJob(job);
if (error) {
throw AppError.serverError({
message: "Job is invalid",
job,
error,
});
}
await queueWorkerExecuteJob(logger, sql, options, value);
return {
didHandleJob: true,
};
})
.catch((e) => {
if (_compasSentryExport) {
_compasSentryExport.captureException(e);
}
logger.error({
type: "job_unhandled_error",
error: AppError.format(e),
});
return {
// It tried to but failed. Slow down intentionally, before reattempting the next pick up.
didHandleJob: false,
};
})
.then(({ didHandleJob }) => {
if (!didHandleJob) {
worker.currentPromise = setTimeout(options.pollInterval);
}
return queueWorkerRun(
logger,
sql,
options,
jobTodoQuery,
where,
orderBy,
worker,
);
});
});
}
/**
* @param {import("@compas/stdlib").Logger} logger
* @param {import("../index.js").Postgres} sql
* @param {QueueWorkerInternalOptions} options
* @param {import("./generated/common/types.d.ts").StoreJob} job
*/
async function queueWorkerExecuteJob(logger, sql, options, job) {
const isCronJob = job.data?.jobType === JOB_TYPE_CRON;
let handler = options.handler[job.name];
const timeout =
job.handlerTimeout ??
(typeof handler === "function" ?
options.handlerTimeout
: (handler?.timeout ?? options.handlerTimeout));
let isJobComplete = false;
// @ts-expect-error
if (handler?.handler) {
// @ts-expect-error
handler = handler.handler;
}
if (!handler) {
const log = isCronJob ? logger.error : logger.info;
log({
message: "No handler registered for the job.",
job,
});
if (_compasSentryExport && isCronJob) {
_compasSentryExport.captureException(
AppError.serverError({
message: "No handler registered for the job.",
job,
}),
);
}
return;
}
if (_compasSentryExport) {
const _sentry = _compasSentryExport;
await _sentry.withIsolationScope(async () => {
return await _sentry.startSpan(
{
op: "queue.task",
name: job.name,
forceTransaction: true,
},
async () => {
return await exec();
},
);
});
} else {
await exec();
}
async function exec() {
const event = newEvent(
newLogger({
ctx: {
type: "queue_handler",
id: job.id,
},
}),
AbortSignal.timeout(timeout),
);
event.log.info({
job,
});
try {
// @ts-expect-error
await sql.savepoint(async (sql) => {
await asyncLocalStorageLogger.run(
{
log: event.log,
},
async () => {
// @ts-expect-error
await handler(event, sql, job);
},
);
});
isJobComplete = true;
} catch (e) {
if (_compasSentryExport) {
_compasSentryExport.captureException(e);
}
event.log.error({
type: "job_error",
name: job.name,
scheduledAt: job.scheduledAt,
retryCount: job.retryCount,
error: AppError.format(e),
});
isJobComplete = job.retryCount + 1 >= options.maxRetryCount;
if (options.deleteJobOnCompletion && !isJobComplete) {
// Re insert the job, since this transaction did remove the job.
await queries.jobInsert(sql, {
...job,
isComplete: false,
retryCount: job.retryCount + 1,
});
} else {
await queries.jobUpdate(sql, {
update: {
isComplete: isJobComplete,
retryCount: job.retryCount + 1,
},
where: {
id: job.id,
},
});
}
}
if (isNil(event.span.stopTime)) {
// Stop the root event, so even if the job failed or forgot to call eventStop. We still have
// a event callstack
eventStop(event);
}
if (isCronJob && isJobComplete) {
const nextValue = cron
.parseExpression(job.data.cronExpression, {
utc: true,
})
.next()
.toDate();
// This causes an extra insert if a finished cron job is manually restarted. We don't
// correct for this behaviour here, since we are kinda in a hot loop. It will be
// autocorrected on the next call of `queueWorkerRegisterCronJobs` (at queue
// startup).
await queries.jobInsert(sql, {
name: job.name,
priority: job.priority,
scheduledAt: nextValue,
data: {
jobType: JOB_TYPE_CRON,
cronExpression: job.data.cronExpression,
cronLastCompletedAt: new Date(),
},
});
}
}
}