Skip to content
Open
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
814 changes: 336 additions & 478 deletions docs/gitbook/bullmq-pro/changelog.md

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/classes/errors/connection-closed-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
* structural `instanceof` check rather than fragile message-substring matching.
*/
export class ConnectionClosedError extends Error {
constructor(message?: string, public readonly cause?: unknown) {
constructor(
message?: string,
public readonly cause?: unknown,
) {
super(message ?? 'Connection is closed');
this.name = 'ConnectionClosedError';
Object.setPrototypeOf(this, new.target.prototype);
Expand Down
4 changes: 1 addition & 3 deletions src/classes/node-redis-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,7 @@ export interface NodeRedisRawClient {
flushAll(): Promise<string>;
}

export function createNodeRedisClient(
client: unknown,
): IRedisClient {
export function createNodeRedisClient(client: unknown): IRedisClient {
return new NodeRedisAdapter(client as NodeRedisRawClient);
}

Expand Down
20 changes: 19 additions & 1 deletion src/classes/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { RedisConnection } from './redis-connection';
import { SpanKind, TelemetryAttributes } from '../enums';
import { JobScheduler } from './job-scheduler';
import { version } from '../version';
import { randomUUID } from '../utils';
import { randomUUID, validateKeepJobsAge } from '../utils';

export interface ObliterateOpts {
/**
Expand Down Expand Up @@ -172,6 +172,15 @@ export class Queue<

this.jobsOpts = opts?.defaultJobOptions ?? {};

validateKeepJobsAge(
this.jobsOpts.removeOnComplete,
'Queue.defaultJobOptions.removeOnComplete',
);
validateKeepJobsAge(
this.jobsOpts.removeOnFail,
'Queue.defaultJobOptions.removeOnFail',
);

this.waitUntilReady()
.then(client => {
if (!this.closing && !opts?.skipMetasUpdate) {
Expand Down Expand Up @@ -381,6 +390,15 @@ export class Queue<
jobId,
};

validateKeepJobsAge(
mergedOpts.removeOnComplete,
`Queue('${this.name}').add.removeOnComplete`,
);
validateKeepJobsAge(
mergedOpts.removeOnFail,
`Queue('${this.name}').add.removeOnFail`,
);

const job = await this.Job.create<DataType, ResultType, NameType>(
this as MinimalQueue,
name,
Expand Down
10 changes: 10 additions & 0 deletions src/classes/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isNotConnectionError,
isRedisInstance,
randomUUID,
validateKeepJobsAge,
} from '../utils';
import { QueueBase } from './queue-base';
import { Repeat } from './repeat';
Expand Down Expand Up @@ -264,6 +265,15 @@ export class Worker<
throw new Error('drainDelay must be greater than 0');
}

validateKeepJobsAge(
this.opts.removeOnComplete,
`Worker('${name}').removeOnComplete`,
);
validateKeepJobsAge(
this.opts.removeOnFail,
`Worker('${name}').removeOnFail`,
);

this.concurrency = this.opts.concurrency;

this.opts.lockRenewTime =
Expand Down
6 changes: 6 additions & 0 deletions src/interfaces/base-job-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export interface DefaultJobOptions {
* age and/or count to keep. It overrides whatever setting is used in the worker.
* Default behavior is to keep the job in the completed set.
*
* When using `age`, the value is **in seconds** (NOT milliseconds).
* For example, `{ age: 7 * 24 * 60 * 60 }` keeps jobs for 7 days.
*
* When using `age` or `count`, the eviction is evaluated on a
* best-effort basis every time a job finishes; BullMQ does not run a
* background timer, so aged jobs are only removed once another job
Expand All @@ -65,6 +68,9 @@ export interface DefaultJobOptions {
* age and/or count to keep. It overrides whatever setting is used in the worker.
* Default behavior is to keep the job in the failed set.
*
* When using `age`, the value is **in seconds** (NOT milliseconds).
* For example, `{ age: 30 * 24 * 60 * 60 }` keeps jobs for 30 days.
*
* When using `age` or `count`, the eviction is evaluated on a
* best-effort basis every time a job fails; BullMQ does not run a
* background timer, so aged jobs are only removed once another job
Expand Down
6 changes: 6 additions & 0 deletions src/interfaces/worker-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export interface WorkerOptions extends QueueBaseOptions, SandboxedOptions {
* age and/or count to keep.
* Default behavior is to keep the job in the completed set.
*
* When using `age`, the value is **in seconds** (NOT milliseconds).
* For example, `{ age: 7 * 24 * 60 * 60 }` keeps jobs for 7 days.
*
* Eviction is evaluated on a best-effort basis when a job finishes,
* so aged jobs are only removed once another job completes after
* their expiration.
Expand All @@ -91,6 +94,9 @@ export interface WorkerOptions extends QueueBaseOptions, SandboxedOptions {
* age and/or count to keep.
* Default behavior is to keep the job in the failed set.
*
* When using `age`, the value is **in seconds** (NOT milliseconds).
* For example, `{ age: 30 * 24 * 60 * 60 }` keeps jobs for 30 days.
*
* Eviction is evaluated on a best-effort basis when a job fails, so
* aged jobs are only removed once another job fails after their
* expiration.
Expand Down
15 changes: 11 additions & 4 deletions src/types/keep-jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,17 @@ export type KeepJobs =
}
| {
/**
* Maximum age in seconds for job to be kept. The cleanup is only
* evaluated when a new job of the same kind (completed or failed)
* finishes, so a job will only be removed after another job
* finishes past its expiration time.
* Maximum age **in seconds** for the job to be kept (NOT
* milliseconds). For example, to keep jobs for 7 days, use
* `7 * 24 * 60 * 60` (= 604800), not `7 * 24 * 60 * 60 * 1000`.
*
* The cleanup is only evaluated when a new job of the same kind
* (completed or failed) finishes, so a job will only be removed
* after another job finishes past its expiration time.
*
* Values larger than 10 years will trigger a runtime warning,
* since they almost always indicate a millisecond/second unit
* mix-up (see issue #3540).
*/
age: number;

Expand Down
52 changes: 52 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,58 @@ export const toString = (value: any): string => {

export const QUEUE_EVENT_SUFFIX = ':qe';

/**
* Maximum reasonable value for `KeepJobs.age` expressed in seconds.
*
* 10 years (~3.15e8 seconds) is a comfortable upper bound for any
* legitimate retention policy while still catching the common mistake
* of passing a value in milliseconds. For example, the issue #3540
* reporter used `7 * 24 * 60 * 60 * 1000` (= 6.048e8 seconds, ~19
* years if interpreted as seconds) for what was intended to be a
* 7-day retention. The correct value is `7 * 24 * 60 * 60` (= 604800
* seconds).
*/
export const MAX_REASONABLE_KEEP_JOBS_AGE_SECONDS = 10 * 365 * 24 * 60 * 60;

const warnedKeepJobsAge = new Set<string>();

/**
* Emits a one-time warning per (context) when `KeepJobs.age` looks
* suspiciously large — almost always the symptom of passing a value
* in milliseconds when BullMQ expects seconds (issue #3540).
*
* The warning is non-throwing and is intentionally lenient (a single
* threshold of 10 years) so legitimate configurations are unaffected.
*/
export function validateKeepJobsAge(keepJobs: unknown, context: string): void {
if (
!keepJobs ||
typeof keepJobs === 'boolean' ||
typeof keepJobs === 'number'
) {
return;
}

const age = (keepJobs as { age?: number }).age;
if (typeof age !== 'number' || !isFinite(age)) {
return;
}

if (age > MAX_REASONABLE_KEEP_JOBS_AGE_SECONDS) {
const key = `${context}:${age}`;
if (warnedKeepJobsAge.has(key)) {
return;
}
warnedKeepJobsAge.add(key);
console.warn(
`[BullMQ] ${context}.age is ${age} which exceeds 10 years. ` +
`The value is interpreted as SECONDS (not milliseconds). ` +
`If you intended ${age} ms, use ${Math.round(age / 1000)} ` +
`instead. See https://github.com/taskforcesh/bullmq/issues/3540`,
);
}
}

export function removeUndefinedFields<T extends Record<string, any>>(
obj: Record<string, any>,
) {
Expand Down
106 changes: 106 additions & 0 deletions tests/validate_keep_jobs_age.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
validateKeepJobsAge,
MAX_REASONABLE_KEEP_JOBS_AGE_SECONDS,
} from '../src/utils';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

// Regression test for issue #3540 — "Jobs auto-removed from queue with no
// logs". The reporter passed `removeOn*.age` values in milliseconds (e.g.
// `7 * 24 * 60 * 60 * 1000`) instead of seconds. The validator emits a
// one-time warning when the age value looks suspiciously large so the unit
// confusion is surfaced instead of silently producing nonsensical retention
// behavior.
describe('validateKeepJobsAge', () => {
let warnSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
warnSpy.mockRestore();
});

it('does not warn for sane second-based values (7 days)', () => {
validateKeepJobsAge({ age: 7 * 24 * 60 * 60 }, 'unit-test:7-days');
expect(warnSpy).not.toHaveBeenCalled();
});

it('does not warn for sane second-based values (30 days)', () => {
validateKeepJobsAge({ age: 30 * 24 * 60 * 60 }, 'unit-test:30-days');
expect(warnSpy).not.toHaveBeenCalled();
});

it('does not warn at exactly the threshold (10 years)', () => {
validateKeepJobsAge(
{ age: MAX_REASONABLE_KEEP_JOBS_AGE_SECONDS },
'unit-test:threshold',
);
expect(warnSpy).not.toHaveBeenCalled();
});

it('does not warn for a 1-year retention in seconds', () => {
validateKeepJobsAge(
{ age: 365 * 24 * 60 * 60 },
'unit-test:1-year',
);
expect(warnSpy).not.toHaveBeenCalled();
});

it('warns when `age` is provided in milliseconds (7 days as ms)', () => {
// This is the literal value from issue #3540.
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000;
validateKeepJobsAge(
{ age: sevenDaysInMs },
'unit-test:3540-removeOnComplete',
);

expect(warnSpy).toHaveBeenCalledTimes(1);
const message = warnSpy.mock.calls[0][0] as string;
expect(message).toContain('unit-test:3540-removeOnComplete');
expect(message).toContain('SECONDS');
expect(message).toContain('3540');
// It should suggest the correct value (age / 1000).
expect(message).toContain(`${sevenDaysInMs / 1000}`);
});

it('warns when `age` is provided in milliseconds (30 days as ms)', () => {
const thirtyDaysInMs = 30 * 24 * 60 * 60 * 1000;
validateKeepJobsAge(
{ age: thirtyDaysInMs },
'unit-test:3540-removeOnFail',
);
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('only warns once per (context, value) pair', () => {
const value = 7 * 24 * 60 * 60 * 1000;
validateKeepJobsAge({ age: value }, 'unit-test:dedup');
validateKeepJobsAge({ age: value }, 'unit-test:dedup');
validateKeepJobsAge({ age: value }, 'unit-test:dedup');
expect(warnSpy).toHaveBeenCalledTimes(1);
});

it('does not warn when keepJobs is undefined / boolean / number', () => {
validateKeepJobsAge(undefined, 'ctx');
validateKeepJobsAge(true, 'ctx');
validateKeepJobsAge(false, 'ctx');
validateKeepJobsAge(100, 'ctx');
expect(warnSpy).not.toHaveBeenCalled();
});

it('does not warn when keepJobs has only `count` (no age)', () => {
validateKeepJobsAge({ count: 1000 }, 'unit-test:count-only');
expect(warnSpy).not.toHaveBeenCalled();
});

it('ignores non-numeric / non-finite age values', () => {
validateKeepJobsAge({ age: NaN }, 'unit-test:nan');
validateKeepJobsAge({ age: Infinity }, 'unit-test:inf');
validateKeepJobsAge(
{ age: 'oops' as unknown as number },
'unit-test:string',
);
expect(warnSpy).not.toHaveBeenCalled();
});
});
Loading