Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export default defineConfig({
text: "Convenience Methods",
link: "/convenience-methods",
},
{
text: "Recurring Jobs",
link: "/recurring",
},
{
text: "Logging",
link: "/logging",
Expand Down
72 changes: 72 additions & 0 deletions packages/docs/jobs/recurring.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions packages/engine/src/job/job-builder.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -183,4 +196,39 @@ describe("JobBuilder", () => {
expect(jobData.unique_digest).toBeTruthy(); // uniqueness is enabled, so digest should exist
});
});

describe("schedule", () => {
let jobBuilder: JobBuilder<typeof DummyJob>;

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 enqueueSpy = vi.spyOn(jobBuilder, "enqueue").mockResolvedValue({} as JobData);

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(enqueueSpy).toHaveBeenCalledWith("foo", "bar");
});

it("throws error if cron expression is invalid", () => {
validateMock.mockReturnValueOnce(false);

expect(() => jobBuilder.schedule("invalid-cron")).toThrow("Invalid cron expression invalid-cron");
});
});
});
31 changes: 31 additions & 0 deletions packages/engine/src/job/job-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
UniquenessConfig,
UniquenessFactory,
} from "@sidequest/core";
import nodeCron from "node-cron";
import { JOB_BUILDER_FALLBACK } from "./constants";
import { JobClassType } from "./job";

Expand Down Expand Up @@ -209,4 +210,34 @@ export class JobBuilder<T extends JobClassType> {

return this.backend.createNewJob(jobData);
}

/**
* Schedules the job to be enqueued automatically according to a cron expression.
*
* This sets up an in-memory recurring schedule that enqueues the job with the specified arguments
* every time the cron expression triggers.
*
* @remarks
* - The schedule is **not persisted** to the database. If the process is restarted, all scheduled jobs will be lost and must be re-registered.
* - You must call this method during your application initialization or startup to ensure the schedule is active.
* - Uses node-cron's `noOverlap: true` to prevent overlapping runs.
*
* @param cronExpression - A valid cron expression, compatible with node-cron, defining when the job should be enqueued.
* @param args - Arguments to pass to the job's `run` method each time it is enqueued.
*
* @throws {Error} If the provided cron expression is invalid.
*/
schedule(cronExpression: string, ...args: Parameters<InstanceType<T>["run"]>) {
if (!nodeCron.validate(cronExpression)) {
throw new Error(`Invalid cron expression ${cronExpression}`);
}

nodeCron.schedule(
cronExpression,
async () => {
await this.enqueue(...args);
},
{ noOverlap: true },
);
}
}
Loading