alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
233 lines (202 loc) • 5.73 kB
text/typescript
import { $hook, $inject, Alepha } from "alepha";
import { type DateTime, DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { type Cron, parseCronExpression } from "cron-schedule";
export class CronProvider {
protected readonly dt = $inject(DateTimeProvider);
protected readonly alepha = $inject(Alepha);
protected readonly log = $logger();
protected readonly cronJobs: Array<CronJob> = [];
public getCronJobs(): Array<CronJob> {
return this.cronJobs;
}
protected readonly start = $hook({
on: "start",
handler: () => {
if (this.alepha.isServerless()) {
this.log.info("Ignoring cron jobs in serverless environment");
return;
}
for (const cron of this.cronJobs) {
if (!cron.running) {
cron.running = true;
this.log.debug(
`Starting cron task '${cron.name}' with '${cron.expression}'`,
);
this.run(cron);
}
}
},
});
protected readonly stop = $hook({
on: "stop",
handler: () => {
for (const cron of this.cronJobs) {
this.abort(cron);
}
},
});
/**
* Generic serverless cron trigger. Vercel's platform-emitted entry
* point fires `serverless:cron` with the job name; we run the matching
* job in-process. On long-running runtimes this listener is harmless
* (no one fires the event).
*/
protected readonly onServerlessCron = $hook({
on: "serverless:cron",
handler: async ({ name }) => {
await this.trigger(name);
},
});
protected boot(name: string | CronJob) {
const cron =
typeof name === "string"
? this.cronJobs.find((c) => c.name === name)
: name;
if (!cron) {
return;
}
cron.running = true;
this.log.debug(
`Starting cron task '${cron.name}' with '${cron.expression}'`,
);
this.run(cron);
}
public abort(name: string | CronJob): void {
const cron =
typeof name === "string"
? this.cronJobs.find((c) => c.name === name)
: name;
if (!cron?.running) {
return;
}
cron.running = false;
cron.abort?.abort();
this.log.debug(`Cron task '${cron.name}' stopped`);
}
/**
* Registers a cron job.
*
* It's automatically done when using the `$scheduler` primitive but can also be used manually.
*/
public createCronJob(
name: string,
expression: string,
handler: (context: { now: DateTime }) => Promise<void>,
start?: boolean,
): void {
const cron: CronJob = {
name,
cron: parseCronExpression(expression),
expression,
handler,
loop: true,
abort: new AbortController(),
};
this.cronJobs.push(cron);
if (start && this.alepha.isStarted()) {
this.boot(cron);
}
}
protected run(task: CronJob, now = this.dt.now()): void {
if (!task.running) {
return;
}
const [next] = task.cron.getNextDates(1, now.toDate());
if (!next) {
return;
}
const duration = next.getTime() - now.toDate().getTime();
const abort = new AbortController();
task.abort = abort;
this.dt
.wait(duration, {
now: now.valueOf(),
signal: abort.signal,
})
.then(() => {
if (!task.running) {
this.log.trace("Cron task stopped before execution");
return;
}
this.log.trace("Running cron task");
if (task.executing) {
this.log.warn(
`Cron task '${task.name}' is still running, skipping this invocation`,
);
if (task.loop) {
this.run(task, this.dt.of(next));
}
return;
}
task.executing = true;
task
.handler({ now: this.dt.of(next) })
.catch((err) => {
if (task.onError) {
task.onError(err);
} else {
this.log.error("Error in cron task:", err);
}
})
.finally(() => {
task.executing = false;
});
if (task.loop) {
this.run(task, this.dt.of(next));
}
})
.catch((err) => {
this.log.warn("Issue during cron waiting timer", err as Error);
});
}
/**
* Trigger a specific cron job by name.
*/
public async trigger(name: string): Promise<void> {
const job = this.cronJobs.find((j) => j.name === name);
if (!job) {
this.log.warn(`Cron job '${name}' not found`);
return;
}
await this.runJobs([job], this.dt.now());
}
/**
* Trigger all registered cron jobs.
*/
public async triggerAll(): Promise<void> {
await this.runJobs(this.cronJobs, this.dt.now());
}
/**
* Run multiple cron jobs in parallel.
*/
protected async runJobs(jobs: CronJob[], now: DateTime): Promise<void> {
const results = await Promise.allSettled(
jobs.map(async (job) => {
this.log.debug(`Running cron job '${job.name}'`);
try {
await job.handler({ now });
this.log.debug(`Cron job '${job.name}' completed`);
} catch (error) {
this.log.error(`Cron job '${job.name}' failed`, error);
throw error;
}
}),
);
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
this.log.error(`${failures.length}/${jobs.length} cron jobs failed`);
}
}
}
export interface CronJob {
name: string;
expression: string;
handler: (context: { now: DateTime }) => Promise<void>;
cron: Cron;
loop: boolean;
running?: boolean;
executing?: boolean;
onError?: (error: Error) => void;
abort?: AbortController;
}