diff --git a/packages/docs/.vitepress/config.mts b/packages/docs/.vitepress/config.mts index 867457e..c4ab8bf 100644 --- a/packages/docs/.vitepress/config.mts +++ b/packages/docs/.vitepress/config.mts @@ -52,6 +52,10 @@ export default defineConfig({ text: "Convenience Methods", link: "/convenience-methods", }, + { + text: "Recurring Jobs", + link: "/recurring", + }, { text: "Logging", link: "/logging", diff --git a/packages/docs/jobs/recurring.md b/packages/docs/jobs/recurring.md new file mode 100644 index 0000000..80d6a6b --- /dev/null +++ b/packages/docs/jobs/recurring.md @@ -0,0 +1,72 @@ +--- +outline: deep +title: Recurring Jobs +description: How to schedule recurring jobs with Sidequest.js using cron expressions. +--- + +# Recurring Jobs (Scheduling with Cron) + +Sidequest supports scheduling jobs to run automatically at recurring intervals using cron expressions. This allows you to trigger background jobs on a fixed schedule—without manual intervention—directly from your code, with just one line. + +## Quick Example + +```ts +import { Sidequest } from "sidequest"; +import { MyJob } from "./jobs/my-job"; + +// Schedule MyJob to run every 10 seconds +Sidequest.build(MyJob).schedule("*/10 * * * * *"); +``` + +This schedules a new instance of `MyJob` to be enqueued every 10 seconds. + +## How Scheduling Works + +- Scheduling is **in-memory** only: schedules are not persisted to the database. +- Schedules must be registered each time your application starts. Typically, you should place schedule calls in your application bootstrap/startup logic. +- Each time the cron expression matches, a new job is enqueued with the arguments you specify (if any). +- By default, jobs are enqueued in the "default" queue unless configured otherwise. +- Sidequest uses [`node-cron`](https://www.npmjs.com/package/node-cron) for robust cron parsing and execution. + +## When Should I Use Scheduling? + +Use recurring jobs for tasks like: + +- Periodic data synchronization +- Sending notifications or reminders +- Cleanup and maintenance routines +- Any background process that needs to run on a schedule + +## Arguments and Examples + +### `schedule(cronExpression: string, ...args: any[]): void` + +Schedules the job to be enqueued automatically according to a cron expression. + +#### Parameters + +- `cronExpression` (`string`): A valid cron expression (see [node-cron docs](https://www.npmjs.com/package/node-cron#cron-syntax)), e.g. `'0 * * * *'` for every hour or `'*/10 * * * * *'` for every 10 seconds. +- `...args`: Arguments passed to the job's `run` method each time it is enqueued. + +#### Example + +```ts +Sidequest.build(MyJob).schedule("0 * * * *"); // Every hour +Sidequest.build(MyJob).schedule("*/5 * * * * *", "foo"); // Every 5 seconds with argument +``` + +#### Throws + +- Throws an error if the cron expression is invalid. + +#### Notes + +- **In-memory only:** Scheduled tasks are NOT persisted in the database. You must re-register schedules on every app startup. +- **No overlap:** Sidequest uses `noOverlap: true` by default—if a previous run is still in progress, a new job will not be enqueued for that tick. + +## Limitations and Recommendations + +- **Persistence:** If your application restarts, any scheduled jobs must be re-scheduled via code. (This is by design and similar to other popular job libraries.) +- **Clustering:** In a multi-instance environment, each instance will create its own scheduled jobs unless you coordinate or restrict scheduling to a single node. + To avoid duplicate executions, we recommend enabling job uniqueness with a period window (e.g., “unique per hour” or “unique per minute”). + This ensures that even if multiple nodes schedule the same job, only one will actually run for each interval. diff --git a/packages/engine/src/job/job-builder.test.ts b/packages/engine/src/job/job-builder.test.ts index 2acf31f..b80bdd3 100644 --- a/packages/engine/src/job/job-builder.test.ts +++ b/packages/engine/src/job/job-builder.test.ts @@ -1,7 +1,20 @@ import { sidequestTest } from "@/tests/fixture"; +import { Backend } from "@sidequest/backend"; +import { JobData } from "@sidequest/core"; +import nodeCron from "node-cron"; import { DummyJob } from "../test-jobs/dummy-job"; import { JobBuilder } from "./job-builder"; +vi.mock("node-cron", () => ({ + default: { + validate: vi.fn(() => true), + schedule: vi.fn(), + }, +})); + +const scheduleMock = vi.mocked(nodeCron.schedule); +const validateMock = vi.mocked(nodeCron.validate); + describe("JobBuilder", () => { sidequestTest("enqueues a job at default queue", async ({ backend }) => { const jobData = await new JobBuilder(backend, DummyJob).enqueue(); @@ -183,4 +196,43 @@ describe("JobBuilder", () => { expect(jobData.unique_digest).toBeTruthy(); // uniqueness is enabled, so digest should exist }); }); + + describe("schedule", () => { + let jobBuilder: JobBuilder; + + beforeEach(() => { + vi.clearAllMocks(); + jobBuilder = new JobBuilder({} as Backend, DummyJob); + }); + + it("calls node-cron with correct cron expression and enqueues job with args", async () => { + const cronExpression = "* * * * *"; + + const createNewJobMock = vi.fn().mockResolvedValue({} as JobData); + const backendMock = { createNewJob: createNewJobMock } as unknown as Backend; + jobBuilder = new JobBuilder(backendMock, DummyJob); + + await jobBuilder.schedule(cronExpression, "foo", "bar"); + + expect(nodeCron.validate).toHaveBeenCalledWith(cronExpression); + expect(nodeCron.schedule).toHaveBeenCalled(); + + const [calledExpression, callback] = scheduleMock.mock.calls[0] as [ + string, + (...args: unknown[]) => unknown, + unknown?, + ]; + expect(calledExpression).toBe(cronExpression); + + await callback(); + + expect(createNewJobMock).toHaveBeenCalled(); + }); + + it("throws error if cron expression is invalid", async () => { + validateMock.mockReturnValueOnce(false); + + await expect(() => jobBuilder.schedule("invalid-cron")).rejects.toThrow("Invalid cron expression invalid-cron"); + }); + }); }); diff --git a/packages/engine/src/job/job-builder.ts b/packages/engine/src/job/job-builder.ts index d3687e0..596cde1 100644 --- a/packages/engine/src/job/job-builder.ts +++ b/packages/engine/src/job/job-builder.ts @@ -12,6 +12,7 @@ import { UniquenessConfig, UniquenessFactory, } from "@sidequest/core"; +import nodeCron, { ScheduledTask } from "node-cron"; import { JOB_BUILDER_FALLBACK } from "./constants"; import { JobClassType } from "./job"; @@ -169,12 +170,7 @@ export class JobBuilder { return this; } - /** - * Enqueues the job with the specified arguments. - * @param args Arguments to pass to the job's run method. - * @returns A promise resolving to the created job data. - */ - async enqueue(...args: Parameters["run"]>) { + private async build(...args: Parameters["run"]>): Promise { const job = new this.JobClass(...this.constructorArgs!); await job.ready(); @@ -196,17 +192,78 @@ export class JobBuilder { timeout: this.jobTimeout!, uniqueness_config: this.uniquenessConfig!, }; - logger("JobBuilder").debug( - `Enqueuing job ${job.className} with args: ${JSON.stringify(args)} - and constructor args: ${JSON.stringify(this.constructorArgs)}`, - ); if (this.uniquenessConfig) { const uniqueness = UniquenessFactory.create(this.uniquenessConfig); jobData.unique_digest = uniqueness.digest(jobData as JobData); - logger("JobBuilder").debug(`Job ${job.className} uniqueness digest: ${jobData.unique_digest}`); + logger("JobBuilder").debug(`Job ${jobData.class} uniqueness digest: ${jobData.unique_digest}`); } + return jobData; + } + + /** + * Enqueues the job with the specified arguments. + * @param args Arguments to pass to the job's run method. + * @returns A promise resolving to the created job data. + */ + async enqueue(...args: Parameters["run"]>) { + const jobData = await this.build(...args); + + logger("JobBuilder").debug( + `Enqueuing job ${jobData.class} with args: ${JSON.stringify(args)} + and constructor args: ${JSON.stringify(this.constructorArgs)}`, + ); return this.backend.createNewJob(jobData); } + + /** + * Registers a recurring schedule to enqueue the job automatically based on a cron expression. + * + * This sets up an in-memory schedule that enqueues the job with the provided arguments + * every time the cron expression is triggered. + * + * @remarks + * - The schedule is **not persisted** to any database. It will be lost if the process restarts and must be re-registered at startup. + * - You must call this method during application initialization to ensure the job is scheduled correctly. + * - Uses node-cron’s `noOverlap: true` option to prevent concurrent executions. + * + * @param cronExpression - A valid cron expression (node-cron compatible) that defines when the job should be enqueued. + * @param args - Arguments to be passed to the job’s `run` method on each scheduled execution. + * + * @returns The underlying `ScheduledTask` instance created by node-cron. + * + * @throws {Error} If the cron expression is invalid. + */ + async schedule(cronExpression: string, ...args: Parameters["run"]>): Promise { + if (!nodeCron.validate(cronExpression)) { + throw new Error(`Invalid cron expression ${cronExpression}`); + } + + // Build the job data using the provided arguments, + // this ensures the scheduled state is going to be respected in cases where the builder was reused. + // Includes class name, queue, timeout, uniqueness, etc. + const jobData = await this.build(...args); + + // Freeze the job data to prevent future modifications. + // Ensures the same payload is used on every scheduled execution. + Object.freeze(jobData); + + logger("JobBuilder").debug( + `Scheduling job ${jobData.class} with cron: "${cronExpression}", args: ${JSON.stringify(args)}, ` + + `constructor args: ${JSON.stringify(this.constructorArgs)}`, + ); + + return nodeCron.schedule( + cronExpression, + async () => { + const newJobData: NewJobData = Object.assign({}, jobData); + logger("JobBuilder").debug( + `Cron triggered for job ${newJobData.class} at ${newJobData.available_at!.toISOString()} with args: ${JSON.stringify(args)}`, + ); + return this.backend.createNewJob(jobData); + }, + { noOverlap: true }, + ); + } }