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
6 changes: 6 additions & 0 deletions packages/backends/backend-test/src/createNewJob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export default function defineCreateNewJobTestSuite() {
expect(insertedJob.timeout).toBe(null);
expect(insertedJob.unique_digest).toBe(null);
expect(insertedJob.uniqueness_config).toBe(null);
expect(insertedJob.retry_delay).toBe(null);
expect(insertedJob.backoff_strategy).toBe("exponential");
});

it("should insert a new job with all optionals", async () => {
Expand All @@ -62,6 +64,8 @@ export default function defineCreateNewJobTestSuite() {
uniqueness_config: {
type: "alive",
},
backoff_strategy: "fixed",
retry_delay: 15,
};

const insertedJob = await backend.createNewJob(job);
Expand All @@ -87,6 +91,8 @@ export default function defineCreateNewJobTestSuite() {
expect(insertedJob.timeout).toBe(10);
expect(insertedJob.unique_digest).toBe("test");
expect(insertedJob.uniqueness_config).toMatchObject({ type: "alive" });
expect(insertedJob.retry_delay).toBe(15);
expect(insertedJob.backoff_strategy).toBe("fixed");
});

it("should not insert two jobs with the same unique digest", async () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/backends/backend-test/src/updateJob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export default function defineUpdateJobTestSuite() {
result: "test_result",
script: "test2.js",
state: "canceled",
backoff_strategy: "fixed",
retry_delay: 5000,
};
const updatedJob = await backend.updateJob(newData);
expect(updatedJob).toMatchObject(newData);
Expand Down
13 changes: 12 additions & 1 deletion packages/backends/backend/src/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@ import { JobData, JobState, QueueConfig } from "@sidequest/core";
* Data required to create a new job.
*/
export type NewJobData = Pick<JobData, "queue" | "script" | "class" | "args" | "constructor_args"> &
Partial<Pick<JobData, "max_attempts" | "available_at" | "timeout" | "unique_digest" | "uniqueness_config">> & {
Partial<
Pick<
JobData,
| "max_attempts"
| "available_at"
| "timeout"
| "unique_digest"
| "uniqueness_config"
| "retry_delay"
| "backoff_strategy"
>
> & {
/** The job state, always 'waiting' for new jobs. */
state: "waiting";
/** The attempt number, always 0 for new jobs. */
Expand Down
4 changes: 4 additions & 0 deletions packages/backends/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export const JOB_FALLBACK: Required<
| "canceled_at"
| "claimed_at"
| "claimed_by"
| "backoff_strategy"
| "retry_delay"
>
> = {
args: [],
Expand All @@ -52,6 +54,8 @@ export const JOB_FALLBACK: Required<
canceled_at: null,
claimed_at: null,
claimed_by: null,
backoff_strategy: "exponential",
retry_delay: null,
};

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/backends/backend/src/sql-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export abstract class SQLBackend implements Backend {
unique_digest: job.unique_digest ?? JOB_FALLBACK.unique_digest,
uniqueness_config: job.uniqueness_config ? JSON.stringify(job.uniqueness_config) : JOB_FALLBACK.uniqueness_config,
inserted_at: new Date(),
backoff_strategy: job.backoff_strategy ?? JOB_FALLBACK.backoff_strategy,
retry_delay: job.retry_delay ?? JOB_FALLBACK.retry_delay,
};
logger("Backend").debug(`Creating new job: ${inspect(data)}`);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
exports.up = async function(knex) {
await knex.schema.table('sidequest_jobs', function(table) {
table.integer('retry_delay').nullable();
table.text('backoff_strategy').notNullable().defaultTo('exponential');
});
};

exports.down = function(knex) {
return knex.schema.table('sidequest_jobs', function(table) {
table.dropColumn('retry_delay');
table.dropColumn('backoff_strategy');
});
};
2 changes: 2 additions & 0 deletions packages/backends/mysql/src/mysql-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export default class MysqlBackend extends SQLBackend {
unique_digest: job.unique_digest ?? JOB_FALLBACK.unique_digest,
uniqueness_config: job.uniqueness_config ? JSON.stringify(job.uniqueness_config) : JOB_FALLBACK.uniqueness_config,
inserted_at: new Date(),
backoff_strategy: job.backoff_strategy ?? JOB_FALLBACK.backoff_strategy,
retry_delay: job.retry_delay ?? JOB_FALLBACK.retry_delay,
};
logger("Backend").debug(`Creating new job: ${inspect(data)}`);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
exports.up = async function(knex) {
await knex.schema.table('sidequest_jobs', function(table) {
table.integer('retry_delay').nullable();
table.text('backoff_strategy').notNullable().defaultTo('exponential');
});
};

exports.down = function(knex) {
return knex.schema.table('sidequest_jobs', function(table) {
table.dropColumn('retry_delay');
table.dropColumn('backoff_strategy');
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
exports.up = async function(knex) {
await knex.schema.table('sidequest_jobs', function(table) {
table.integer('retry_delay').nullable();
table.text('backoff_strategy').notNullable().defaultTo('exponential');
});
};

exports.down = function(knex) {
return knex.schema.table('sidequest_jobs', function(table) {
table.dropColumn('retry_delay');
table.dropColumn('backoff_strategy');
});
};
4 changes: 3 additions & 1 deletion packages/core/src/job/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { access } from "fs/promises";
import path from "path";
import { pathToFileURL } from "url";
import { logger } from "../logger";
import { ErrorData, JobData, JobState } from "../schema";
import { BackoffStrategy, ErrorData, JobData, JobState } from "../schema";
import { toErrorData } from "../tools";
import { CompletedResult, FailedResult, isJobResult, JobResult, RetryResult, SnoozeResult } from "../transitions";
import { UniquenessConfig } from "../uniquiness";
Expand Down Expand Up @@ -75,6 +75,8 @@ export abstract class Job implements JobData {
readonly claimed_by!: string | null;
readonly unique_digest!: string | null;
readonly uniqueness_config!: UniquenessConfig | null;
readonly backoff_strategy!: BackoffStrategy;
readonly retry_delay!: number | null;

/**
* Initializes the job and resolves its script path.
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/schema/job-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export type JobState =
| "completed" // Finished successfully
| "canceled"; // Manually canceled

/**
* Defines the strategy for calculating backoff delays between job retry attempts.
*
* - `"exponential"`: Delays increase exponentially with each attempt, often with added jitter to prevent thundering herd problems.
* - `"fixed"`: Delays remain constant for each attempt, providing a predictable retry interval.
*/
export type BackoffStrategy = "exponential" | "fixed";

// #region JobData
/**
* Represents the data structure for a job in the queue system.
Expand Down Expand Up @@ -141,5 +149,19 @@ export interface JobData {
* Configuration object for job uniqueness, or null if not set.
*/
uniqueness_config: UniquenessConfig | null;

/**
* Delay before retrying a failed job, in milliseconds. Null if not set.
*
* If "backoff_strategy" is "exponential", this value is used as the base delay for calculating exponential backoff.
*/
retry_delay: number | null;

/**
* Strategy used for calculating backoff delays between retries.
* - "exponential": Delays increase exponentially with each attempt.
* - "fixed": Delays remain constant for each attempt.
*/
backoff_strategy: BackoffStrategy;
}
// #endregion JobData
130 changes: 130 additions & 0 deletions packages/core/src/transitions/retry-transition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ describe("RetryTransition", () => {
max_attempts: 10,
claimed_by: "node@worker",
attempted_at: new Date(),
retry_delay: null,
backoff_strategy: "fixed",
} as unknown as JobData;
});

Expand All @@ -41,6 +43,8 @@ describe("RetryTransition", () => {
message: "custom failed message",
},
],
backoff_strategy: "fixed",
retry_delay: null,
});
expect(result.available_at?.getTime()).toBeGreaterThan(now.getTime());
});
Expand Down Expand Up @@ -71,6 +75,8 @@ describe("RetryTransition", () => {
level: expect.any(String) as string,
},
],
backoff_strategy: "fixed",
retry_delay: null,
});
expect(result.available_at?.getTime()).toBeGreaterThan(now.getTime());
});
Expand Down Expand Up @@ -102,6 +108,8 @@ describe("RetryTransition", () => {
level: expect.any(String) as string,
},
],
backoff_strategy: "fixed",
retry_delay: null,
});
expect(result.available_at?.getTime()).toBeGreaterThanOrEqual(inAlmostOneHour.getTime());
});
Expand Down Expand Up @@ -130,8 +138,130 @@ describe("RetryTransition", () => {
message: "max attempts reached",
},
],
backoff_strategy: "fixed",
retry_delay: null,
});

expect(result.failed_at?.getTime()).toBeLessThanOrEqual(Date.now());
});

describe("backoff strategy and retry_delay", () => {
beforeEach(() => {
// Reset jobData for backoff tests
jobData.attempt = 2; // Second attempt to see backoff effect
jobData.retry_delay = null;
jobData.backoff_strategy = "fixed";
});

it("uses default delay (1000ms) when no retry_delay and strategy is exponential", () => {
jobData.retry_delay = null;
jobData.backoff_strategy = "exponential";

const result = new RetryTransition("test error").apply(jobData);

expect(result.state).toBe("waiting");
expect(result.available_at).toBeInstanceOf(Date);

// For exponential backoff with attempt=2, baseDelay=1000:
// Expected range: 1000 * 2^(2-1) * jitter = 2000 * (0.5 to 1.5) = 1000ms to 3000ms
const delay = result.available_at.getTime() - Date.now();
expect(delay).toBeGreaterThanOrEqual(900); // Allow some tolerance
expect(delay).toBeLessThanOrEqual(3100); // Allow some tolerance
});

it("uses default delay (1000ms) when no retry_delay and strategy is fixed", () => {
jobData.retry_delay = null;
jobData.backoff_strategy = "fixed";

const result = new RetryTransition("test error").apply(jobData);

expect(result.state).toBe("waiting");
expect(result.available_at).toBeInstanceOf(Date);

// For fixed strategy, should use default 1000ms
const delay = result.available_at.getTime() - Date.now();
expect(delay).toBeGreaterThanOrEqual(900); // Allow some tolerance
expect(delay).toBeLessThanOrEqual(1100); // Allow some tolerance
});

it("uses job retry_delay with exponential strategy", () => {
jobData.retry_delay = 500;
jobData.backoff_strategy = "exponential";

const result = new RetryTransition("test error").apply(jobData);

expect(result.state).toBe("waiting");
expect(result.available_at).toBeInstanceOf(Date);

// For exponential backoff with attempt=2, baseDelay=500:
// Expected range: 500 * 2^(2-1) * jitter = 1000 * (0.5 to 1.5) = 500ms to 1500ms
const delay = result.available_at.getTime() - Date.now();
expect(delay).toBeGreaterThanOrEqual(450); // Allow some tolerance
expect(delay).toBeLessThanOrEqual(1550); // Allow some tolerance
});

it("uses job retry_delay with fixed strategy", () => {
jobData.retry_delay = 2000;
jobData.backoff_strategy = "fixed";

const result = new RetryTransition("test error").apply(jobData);

expect(result.state).toBe("waiting");
expect(result.available_at).toBeInstanceOf(Date);

// For fixed strategy, should use exact retry_delay
const delay = result.available_at.getTime() - Date.now();
expect(delay).toBeGreaterThanOrEqual(1900); // Allow some tolerance
expect(delay).toBeLessThanOrEqual(2100); // Allow some tolerance
});

it("prioritizes constructor delay over job retry_delay", () => {
jobData.retry_delay = 2000;
jobData.backoff_strategy = "fixed";

const constructorDelay = 5000;
const result = new RetryTransition("test error", constructorDelay).apply(jobData);

expect(result.state).toBe("waiting");
expect(result.available_at).toBeInstanceOf(Date);

// Constructor delay should take priority over job retry_delay
const delay = result.available_at.getTime() - Date.now();
expect(delay).toBeGreaterThanOrEqual(4900); // Allow some tolerance
expect(delay).toBeLessThanOrEqual(5100); // Allow some tolerance
});

it("applies exponential backoff correctly for higher attempts", () => {
jobData.attempt = 4; // Fourth attempt
jobData.retry_delay = 100;
jobData.backoff_strategy = "exponential";

const result = new RetryTransition("test error").apply(jobData);

expect(result.state).toBe("waiting");
expect(result.available_at).toBeInstanceOf(Date);

// For exponential backoff with attempt=4, baseDelay=100:
// Expected range: 100 * 2^(4-1) * jitter = 800 * (0.5 to 1.5) = 400ms to 1200ms
const delay = result.available_at.getTime() - Date.now();
expect(delay).toBeGreaterThanOrEqual(350); // Allow some tolerance
expect(delay).toBeLessThanOrEqual(1250); // Allow some tolerance
});

it("respects maximum delay cap in exponential backoff", () => {
jobData.max_attempts = 30;
jobData.attempt = 20; // Very high attempt number
jobData.retry_delay = 1000;
jobData.backoff_strategy = "exponential";

const result = new RetryTransition("test error").apply(jobData);

expect(result.state).toBe("waiting");
expect(result.available_at).toBeInstanceOf(Date);

// Should be capped at maxDelay (3,600,000ms = 1 hour)
const delay = result.available_at.getTime() - Date.now();
expect(delay).toBeLessThanOrEqual(3600000); // Should not exceed 1 hour
});
});
});
8 changes: 7 additions & 1 deletion packages/core/src/transitions/retry-transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ export class RetryTransition extends JobTransition {
logger("Core").error(this.reason);
const reason = toErrorData(this.reason);

const delay = this.delay ?? this.calculateBackoff(job.attempt);
// We use the provided delay (priority), or the job's retry_delay (next priority), or default to 1000ms
let delay: number = this.delay ?? job.retry_delay ?? 1000;
// If the job uses exponential backoff, we use the base delay to compute it
if (job.backoff_strategy === "exponential") {
logger("Core").debug(`Calculating exponential backoff for job #${job.id} - ${job.class}`);
delay = this.calculateBackoff(job.attempt, delay);
}
logger("Core").info(`Retrying failed job #${job.id} - ${job.class} in ${delay}ms`);

const errData = {
Expand Down
14 changes: 2 additions & 12 deletions packages/docs/engine/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,18 +190,8 @@ await Sidequest.start({
| `logger.level` | Minimum log level (`debug`, `info`, `warn`, `error`) | `info` |
| `logger.json` | Whether to output logs in JSON format | `false` |
| `gracefulShutdown` | Whether to enable graceful shutdown handling | `true` |
| `jobDefaults.queue` | Default queue name for new jobs | `default` |
| `jobDefaults.timeout` | Default timeout (milliseconds) for job execution | none |
| `jobDefaults.maxAttempts` | Default maximum retry attempts for failed jobs | `5` |
| `jobDefaults.availableAt` | Default delay before job becomes available for execution | Current time (immediate) |
| `jobDefaults.uniqueness` | Default uniqueness constraint for jobs | `false` (no uniqueness) |
| `queueDefaults.concurrency` | Default concurrency limit for auto-created queues | `10` |
| `queueDefaults.priority` | Default priority for auto-created queues | `0` |
| `queueDefaults.state` | Default state for auto-created queues (`active` or `paused`) | `active` |
| `dashboard.enabled` | Enable or disable the dashboard UI | `true` |
| `dashboard.port` | Port where the dashboard runs | `8678` |
| `dashboard.auth.user` | Username for dashboard basic authentication | Not set (no auth) |
| `dashboard.auth.password` | Password for dashboard basic authentication | Not set (no auth) |
| `jobDefaults` | Default values for new jobs. Used while enqueueing | `undefined` |
| `queueDefaults` | Default values for auto-created queues | `undefined` |

::: danger
If `auth` is not configured and `dashboard: true` is enabled in production, the dashboard will be publicly accessible. This is a security risk and **not recommended**.
Expand Down
Loading
Loading