Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 3 additions & 1 deletion .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
nodeLinker: node-modules
enableScripts: true

nodeLinker: node-modules
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,5 @@
"vitepress": "^1.6.4",
"vitest": "^4.0.18"
},
"packageManager": "yarn@4.13.0"
"packageManager": "yarn@4.14.1"
}
40 changes: 40 additions & 0 deletions packages/docs/engine/enqueue.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,46 @@ await Sidequest.build(EmailJob).enqueue("user@example.com", "Hello", "Message bo

**Default**: `[]` (no run method arguments)

### `.scheduleOptions(options: TaskOptions)`

Configures [node-cron](https://www.npmjs.com/package/node-cron) options used when calling `.schedule()`. The most common use case is setting a `timezone` so recurring jobs fire relative to a specific region rather than the server's local time.

```typescript
// Fire at 9 AM Brisbane time regardless of server timezone
await Sidequest.build(DailyReportJob).scheduleOptions({ timezone: "Australia/Brisbane" }).schedule("0 9 * * *");

// Multiple options
await Sidequest.build(MyJob)
.scheduleOptions({ timezone: "America/New_York", noOverlap: false })
.schedule("*/5 * * * *");
```

**Default:** `{ noOverlap: true }`

**Available options** (from node-cron's `TaskOptions`):

| Option | Type | Description |
| ---------------- | --------- | --------------------------------------------------------------------------- |
| `timezone` | `string` | IANA timezone name (e.g. `"Europe/Berlin"`, `"America/New_York"`). |
| `noOverlap` | `boolean` | Skip a tick if the previous execution is still running. Defaults to `true`. |
| `name` | `string` | Optional label for the task in node-cron's registry. |
| `maxExecutions` | `number` | Stop the task after this many executions. |
| `maxRandomDelay` | `number` | Add a random delay (ms) to each execution. |

::: tip Setting a default via configuration
You can set a default `scheduleOptions` for all jobs by passing it through `jobDefaults` in `Sidequest.configure()` or `Sidequest.start()`:

```typescript
await Sidequest.start({
jobDefaults: {
scheduleOptions: { timezone: "Australia/Brisbane" },
},
});
```

The per-call `.scheduleOptions()` method always overrides the default.
:::

### `.schedule(cronExpression: string, ...args?: unknown[])`

For recurring jobs, you can use the `.schedule()` method instead of `.enqueue()`:
Expand Down
26 changes: 25 additions & 1 deletion packages/docs/jobs/recurring.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,31 @@ Sidequest.build(MyJob).schedule("*/5 * * * * *", "foo"); // Every 5 seconds with
#### 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.
- **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. You can override this via `.scheduleOptions({ noOverlap: false })`.

## Timezone Support

By default, cron expressions are evaluated in the server's local time. Use `.scheduleOptions()` to specify an [IANA timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) so jobs fire on local business hours regardless of where the server is running.

```ts
// Fire at 9 AM every weekday in Brisbane
Sidequest.build(MyJob).scheduleOptions({ timezone: "Australia/Brisbane" }).schedule("0 9 * * 1-5");

// Pass run-method arguments alongside timezone
Sidequest.build(ReportJob)
.scheduleOptions({ timezone: "America/New_York" })
.schedule("0 8 * * *", { region: "us-east" });
```

`.scheduleOptions()` accepts any option supported by node-cron's `TaskOptions`:

| Option | Type | Description |
| ---------------- | --------- | -------------------------------------------------------------------------- |
| `timezone` | `string` | IANA timezone name (e.g. `"Europe/Berlin"`). |
| `noOverlap` | `boolean` | Skip a tick if the previous run is still in progress. **Default: `true`**. |
| `name` | `string` | Optional label for the task in node-cron's registry. |
| `maxExecutions` | `number` | Stop the task after N executions. |
| `maxRandomDelay` | `number` | Add a random delay (ms) to each execution. |

## Limitations and Recommendations

Expand Down
3 changes: 3 additions & 0 deletions packages/engine/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ export class Engine {
availableAt: config?.jobDefaults?.availableAt,
timeout: config?.jobDefaults?.timeout ?? JOB_BUILDER_FALLBACK.timeout!,
uniqueness: config?.jobDefaults?.uniqueness ?? JOB_BUILDER_FALLBACK.uniqueness!,
backoffStrategy: config?.jobDefaults?.backoffStrategy ?? JOB_BUILDER_FALLBACK.backoffStrategy!,
retryDelay: config?.jobDefaults?.retryDelay ?? JOB_BUILDER_FALLBACK.retryDelay!,
scheduleOptions: config?.jobDefaults?.scheduleOptions ?? JOB_BUILDER_FALLBACK.scheduleOptions!,
},
queueDefaults: {
concurrency: config?.queueDefaults?.concurrency ?? QUEUE_FALLBACK.concurrency,
Expand Down
1 change: 1 addition & 0 deletions packages/engine/src/job/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export const JOB_BUILDER_FALLBACK: JobBuilderDefaults & { constructorArgs: unkno
constructorArgs: [],
retryDelay: 1000,
backoffStrategy: JOB_FALLBACK.backoff_strategy,
scheduleOptions: { noOverlap: true },
};
72 changes: 72 additions & 0 deletions packages/engine/src/job/job-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,78 @@ describe("JobBuilder", () => {

await expect(() => jobBuilder.schedule("invalid-cron")).rejects.toThrow("Invalid cron expression invalid-cron");
});

describe("scheduleOptions", () => {
it("passes default scheduleOptions { noOverlap: true } to node-cron when no options set", async () => {
const createNewJobMock = vi.fn().mockResolvedValue({} as JobData);
const backendMock = { createNewJob: createNewJobMock } as unknown as Backend;
jobBuilder = new JobBuilder(backendMock, DummyJob);

await jobBuilder.schedule("* * * * *");

const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown];
expect(options).toEqual({ noOverlap: true });
});

it("passes custom timezone via scheduleOptions to node-cron", async () => {
const createNewJobMock = vi.fn().mockResolvedValue({} as JobData);
const backendMock = { createNewJob: createNewJobMock } as unknown as Backend;
jobBuilder = new JobBuilder(backendMock, DummyJob);

await jobBuilder.scheduleOptions({ timezone: "Australia/Brisbane" }).schedule("0 9 * * *");

const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown];
expect(options).toEqual({ timezone: "Australia/Brisbane" });
});

it("allows overriding noOverlap to false via scheduleOptions", async () => {
const createNewJobMock = vi.fn().mockResolvedValue({} as JobData);
const backendMock = { createNewJob: createNewJobMock } as unknown as Backend;
jobBuilder = new JobBuilder(backendMock, DummyJob);

await jobBuilder.scheduleOptions({ noOverlap: false }).schedule("* * * * *");

const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown];
expect(options).toEqual({ noOverlap: false });
});

it("allows combining timezone and noOverlap via scheduleOptions", async () => {
const createNewJobMock = vi.fn().mockResolvedValue({} as JobData);
const backendMock = { createNewJob: createNewJobMock } as unknown as Backend;
jobBuilder = new JobBuilder(backendMock, DummyJob);

await jobBuilder.scheduleOptions({ timezone: "Australia/Brisbane", noOverlap: true }).schedule("0 9 * * *");

const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown];
expect(options).toEqual({ timezone: "Australia/Brisbane", noOverlap: true });
});

it("uses scheduleOptions from constructor defaults", async () => {
const createNewJobMock = vi.fn().mockResolvedValue({} as JobData);
const backendMock = { createNewJob: createNewJobMock } as unknown as Backend;
jobBuilder = new JobBuilder(backendMock, DummyJob, {
scheduleOptions: { timezone: "Europe/Rome", noOverlap: false },
});

await jobBuilder.schedule("* * * * *");

const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown];
expect(options).toEqual({ timezone: "Europe/Rome", noOverlap: false });
});

it("scheduleOptions() method overrides constructor defaults", async () => {
const createNewJobMock = vi.fn().mockResolvedValue({} as JobData);
const backendMock = { createNewJob: createNewJobMock } as unknown as Backend;
jobBuilder = new JobBuilder(backendMock, DummyJob, {
scheduleOptions: { timezone: "Europe/Rome" },
});

await jobBuilder.scheduleOptions({ timezone: "Australia/Brisbane" }).schedule("* * * * *");

const [, , options] = scheduleMock.mock.calls[0] as [string, unknown, unknown];
expect(options).toEqual({ timezone: "Australia/Brisbane" });
});
});
});

describe("manualJobResolution", () => {
Expand Down
29 changes: 27 additions & 2 deletions packages/engine/src/job/job-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
UniquenessConfig,
UniquenessFactory,
} from "@sidequest/core";
import nodeCron, { ScheduledTask } from "node-cron";
import nodeCron, { ScheduledTask, TaskOptions } from "node-cron";
import { inspect } from "node:util";
import { MANUAL_SCRIPT_TAG } from "../shared-runner";
import { JOB_BUILDER_FALLBACK } from "./constants";
Expand Down Expand Up @@ -62,6 +62,8 @@ export interface JobBuilderDefaults {
retryDelay?: number;
/** Default backoff strategy for jobs built with the JobBuilder */
backoffStrategy?: BackoffStrategy;
/** Default node-cron schedule options (e.g. timezone) for jobs built with the JobBuilder */
scheduleOptions?: TaskOptions;
}

/**
Expand All @@ -77,6 +79,7 @@ export class JobBuilder<T extends JobClassType> {
protected jobAvailableAt?: Date;
protected jobRetryDelay?: number;
protected jobBackoffStrategy?: BackoffStrategy;
protected jobScheduleOptions?: TaskOptions;

/**
* Creates a new JobBuilder for the given job class.
Expand All @@ -96,6 +99,7 @@ export class JobBuilder<T extends JobClassType> {
this.with(...(JOB_BUILDER_FALLBACK.constructorArgs as unknown as ConstructorParameters<T>));
this.retryDelay(this.defaults?.retryDelay ?? JOB_BUILDER_FALLBACK.retryDelay!);
this.backoffStrategy(this.defaults?.backoffStrategy ?? JOB_BUILDER_FALLBACK.backoffStrategy!);
this.scheduleOptions(this.defaults?.scheduleOptions ?? JOB_BUILDER_FALLBACK.scheduleOptions!);
}

/**
Expand Down Expand Up @@ -209,6 +213,27 @@ export class JobBuilder<T extends JobClassType> {
return this;
}

/**
* Sets node-cron schedule options used by {@link schedule}.
*
* @remarks
* `noOverlap` defaults to `true` if not explicitly set here or in the builder defaults.
*
* @param options node-cron `TaskOptions` (e.g. `timezone`, `noOverlap`).
* @returns This builder instance.
*
* @example
* ```typescript
* Sidequest.build(MyJob)
* .scheduleOptions({ timezone: "Australia/Brisbane" })
* .schedule("0 9 * * *");
* ```
*/
scheduleOptions(options: TaskOptions): this {
this.jobScheduleOptions = options;
return this;
}

protected async build(...args: Parameters<InstanceType<T>["run"]>): Promise<NewJobData> {
const job = new this.JobClass(...this.constructorArgs!);

Expand Down Expand Up @@ -313,7 +338,7 @@ export class JobBuilder<T extends JobClassType> {
);
return this.backend.createNewJob(jobData);
},
{ noOverlap: true },
this.jobScheduleOptions,
);

// Register the scheduled task with the ScheduledJobRegistry for proper later cleanup
Expand Down
20 changes: 15 additions & 5 deletions tests/integration/shared-test-suite.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,11 @@ export function createIntegrationTestSuite(Sidequest, jobs, moduleType = "ESM")
});

const startTime = Date.now();
const jobData = await Sidequest.build(RetryJob).maxAttempts(3).enqueue("retry-with-delay");
const jobData = await Sidequest.build(RetryJob)
.maxAttempts(3)
.backoffStrategy("exponential")
.retryDelay(500)
.enqueue("retry-with-delay");

// Wait for job to complete (should take some time due to retry delays)
await vi.waitUntil(async () => {
Expand Down Expand Up @@ -607,10 +611,16 @@ export function createIntegrationTestSuite(Sidequest, jobs, moduleType = "ESM")
await Sidequest.build(SuccessJob).schedule("*/2 * * * * *", "job-2");

let currentJobs;
await vi.waitUntil(async () => {
currentJobs = await Sidequest.job.list();
return currentJobs.length >= 3 && currentJobs.every((job) => job.state === "completed");
}, 5000);
await vi.waitUntil(
async () => {
currentJobs = await Sidequest.job.list();
return currentJobs.length >= 3 && currentJobs.every((job) => job.state === "completed");
},
{
interval: 100,
timeout: 5000,
},
);

const job1Executions = currentJobs.filter((job) => job.args[0] === "job-1");
const job2Executions = currentJobs.filter((job) => job.args[0] === "job-2");
Expand Down
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Manual changes might be lost - proceed with caution!

__metadata:
version: 8
version: 9
cacheKey: 10c0

"@actions/core@npm:^2.0.0":
Expand Down
Loading