Files
homarr/packages/cron-jobs-core/src/creator.ts
2025-12-16 23:37:44 +01:00

155 lines
5.2 KiB
TypeScript

import { createTask, validate } from "node-cron";
import { Stopwatch } from "@homarr/common";
import type { MaybePromise } from "@homarr/common/types";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db } from "@homarr/db";
import type { Logger } from "./logger";
import type { ValidateCron } from "./validation";
export interface CreateCronJobCreatorOptions<TAllowedNames extends string> {
beforeCallback?: (name: TAllowedNames) => MaybePromise<void>;
onCallbackSuccess?: (name: TAllowedNames) => MaybePromise<void>;
onCallbackError?: (name: TAllowedNames, error: unknown) => MaybePromise<void>;
timezone?: string;
logger: Logger;
}
interface CreateCronJobOptions {
runOnStart?: boolean;
preventManualExecution?: boolean;
expectedMaximumDurationInMillis?: number;
beforeStart?: () => MaybePromise<void>;
}
const createCallback = <TAllowedNames extends string, TName extends TAllowedNames>(
name: TName,
defaultCronExpression: string,
options: CreateCronJobOptions,
creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>,
) => {
const expectedMaximumDurationInMillis = options.expectedMaximumDurationInMillis ?? 2500;
return (callback: () => MaybePromise<void>) => {
const catchingCallbackAsync = async () => {
try {
creatorOptions.logger.logDebug("The callback of cron job started", {
name,
});
const stopwatch = new Stopwatch();
await creatorOptions.beforeCallback?.(name);
const beforeCallbackTook = stopwatch.getElapsedInHumanWords();
await callback();
const callbackTook = stopwatch.getElapsedInHumanWords();
creatorOptions.logger.logDebug("The callback of cron job succeeded", {
name,
beforeCallbackTook,
callbackTook,
});
const durationInMillis = stopwatch.getElapsedInMilliseconds();
if (durationInMillis > expectedMaximumDurationInMillis) {
creatorOptions.logger.logWarning("The callback of cron job took longer than expected", {
name,
durationInMillis,
expectedMaximumDurationInMillis,
});
}
await creatorOptions.onCallbackSuccess?.(name);
} catch (error) {
creatorOptions.logger.logError(
new ErrorWithMetadata(
"The callback of cron job failed",
{
name,
},
{ cause: error },
),
);
await creatorOptions.onCallbackError?.(name, error);
}
};
return {
name,
cronExpression: defaultCronExpression,
async createTaskAsync() {
const configuration = await db.query.cronJobConfigurations.findFirst({
where: (cronJobConfigurations, { eq }) => eq(cronJobConfigurations.name, name),
});
const scheduledTask = createTask(
configuration?.cronExpression ?? defaultCronExpression,
() => void catchingCallbackAsync(),
{
name,
timezone: creatorOptions.timezone,
},
);
creatorOptions.logger.logDebug("The scheduled task for cron job was created", {
name,
cronExpression: defaultCronExpression,
timezone: creatorOptions.timezone,
runOnStart: options.runOnStart,
});
return scheduledTask;
},
async onStartAsync() {
if (options.beforeStart) {
creatorOptions.logger.logDebug("Running beforeStart for job", {
name,
});
await options.beforeStart();
}
if (!options.runOnStart) return;
creatorOptions.logger.logDebug("The cron job is configured to run on start, executing callback", {
name,
});
await catchingCallbackAsync();
},
async executeAsync() {
await catchingCallbackAsync();
},
preventManualExecution: options.preventManualExecution ?? false,
};
};
};
export type JobCallback<TAllowedNames extends string, TName extends TAllowedNames> = ReturnType<
typeof createCallback<TAllowedNames, TName>
>;
export const createCronJobCreator = <TAllowedNames extends string = string>(
creatorOptions: CreateCronJobCreatorOptions<TAllowedNames>,
) => {
return <TName extends TAllowedNames, TExpression extends string>(
name: TName,
defaultCronExpression: TExpression,
options: CreateCronJobOptions = { runOnStart: false },
) => {
creatorOptions.logger.logDebug("Validating cron expression for cron job", {
name,
cronExpression: defaultCronExpression,
});
if (!validate(defaultCronExpression)) {
throw new Error(`Invalid cron expression '${defaultCronExpression}' for job '${name}'`);
}
creatorOptions.logger.logDebug("Cron job expression for cron job is valid", {
name,
cronExpression: defaultCronExpression,
});
const returnValue = {
withCallback: createCallback<TAllowedNames, TName>(name, defaultCronExpression, options, creatorOptions),
};
// This is a type guard to check if the cron expression is valid and give the user a type hint
return returnValue as unknown as ValidateCron<TExpression> extends true
? typeof returnValue
: "Invalid cron expression";
};
};