alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
224 lines (196 loc) • 5.48 kB
text/typescript
import {
$atom,
$inject,
$pipeline,
$state,
Alepha,
type Async,
createPrimitive,
KIND,
Primitive,
type Static,
z,
} from "alepha";
import {
type DateTime,
DateTimeProvider,
type DurationLike,
} from "alepha/datetime";
import { $lock, LockAcquireError } from "alepha/lock";
import { $logger } from "alepha/logger";
import { CronProvider } from "../providers/CronProvider.ts";
/**
* Scheduler primitive.
*/
export const $scheduler = (
options: SchedulerPrimitiveOptions,
): SchedulerPrimitive => {
return createPrimitive(SchedulerPrimitive, options);
};
// ---------------------------------------------------------------------------------------------------------------------
export type SchedulerPrimitiveOptions = {
/**
* Function to run on schedule.
*/
handler: (args: SchedulerHandlerArguments) => Async<void>;
/**
* Name of the scheduler. Defaults to the function name.
*/
name?: string;
/**
* Optional description of the scheduler.
*/
description?: string;
/**
* Cron expression or interval to run the scheduler.
*/
cron?: string;
/**
* Cron expression or interval to run the scheduler.
*/
interval?: DurationLike;
/**
* If true, the scheduler will be locked and only one instance will run at a time.
* You probably need to import {@link AlephaLockRedis} for distributed locking.
*
* @default true
*/
lock?: boolean;
};
// ---------------------------------------------------------------------------------------------------------------------
/**
* Scheduler configuration atom.
*/
export const schedulerOptions = $atom({
name: "alepha.scheduler.options",
schema: z.object({
prefix: z
.text({
description: "Prefix for scheduler lock keys.",
})
.optional(),
}),
default: {},
});
export type SchedulerAtomOptions = Static<typeof schedulerOptions.schema>;
declare module "alepha" {
interface State {
[schedulerOptions.key]: SchedulerAtomOptions;
}
}
export class SchedulerPrimitive extends Primitive<SchedulerPrimitiveOptions> {
protected readonly log = $logger();
protected readonly settings = $state(schedulerOptions);
protected readonly alepha = $inject(Alepha);
protected readonly dateTimeProvider = $inject(DateTimeProvider);
protected readonly cronProvider = $inject(CronProvider);
public get name(): string {
return (
this.options.name ??
`${this.config.service.name}.${this.config.propertyKey}`
);
}
protected onInit() {
if (this.options.interval) {
this.dateTimeProvider.createInterval(
() => this.trigger(),
this.options.interval,
);
}
if (this.options.cron) {
this.cronProvider.createCronJob(this.name, this.options.cron, () =>
this.trigger(),
);
}
}
public async trigger(): Promise<void> {
if (!this.alepha.isStarted()) {
return;
}
const context = this.alepha.context.createContextId();
await this.alepha.context.run(
async () => {
try {
const now = this.dateTimeProvider.now();
await this.alepha.events.emit("scheduler:begin", {
name: this.name,
now,
context,
});
if (this.options.lock !== false) {
try {
await this.schedulerLock.run({ now });
} catch (error) {
// A held lock means another instance (or an overlapping run) is
// already handling this tick — that is exactly what the lock is
// for (dedup), not a failure. Skip this tick quietly rather than
// surfacing it as a `scheduler:error`. Other errors still bubble.
if (!(error instanceof LockAcquireError)) {
throw error;
}
this.log.debug(
`Scheduler '${this.name}' tick skipped — lock held by another runner`,
);
}
} else {
await this.options.handler({ now });
}
await this.alepha.events.emit(
"scheduler:success",
{
name: this.name,
context,
},
{
catch: true,
},
);
} catch (error) {
await this.alepha.events.emit(
"scheduler:error",
{
name: this.name,
error: error as Error,
context,
},
{
catch: true,
},
);
this.log.error("Error running scheduler:", error);
}
await this.alepha.events.emit(
"scheduler:end",
{
name: this.name,
context,
},
{
catch: true,
},
);
},
{
context,
},
);
}
protected schedulerLock = $pipeline({
use: [
$lock({
name: () => {
const prefix = this.settings.prefix ? `${this.settings.prefix}:` : "";
return `${prefix}scheduler:${this.name}`;
},
}),
],
handler: async (args: SchedulerHandlerArguments) => {
await this.options.handler(args);
},
});
}
$scheduler[KIND] = SchedulerPrimitive;
// ---------------------------------------------------------------------------------------------------------------------
export interface SchedulerHandlerArguments {
now: DateTime;
}