Skip to content

Commit afc0982

Browse files
committed
fix(backoff): accept a per-attempt backoff schedule in JobOptions
`JobOptions.backoff` only described the `{ type, delay }` strategy, so the per-attempt array a job class declares via `withBackoff()` had nowhere to go: every path that turned a job class into job options collapsed it to `{ type: 'fixed', delay: backoff[0] }`, silently dropping the rest of the schedule. Callers that wanted the array had to cast, and the cast compiled to a `backoff` the worker could not read at all -- it only ever looked at `.type` and `.delay`, so an array meant a zero delay and an immediate retry. Widen the type to `BackoffOptions` and resolve both forms through one shared `backoffDelay()` helper, which clamps an exhausted schedule to its last entry instead of falling through to no wait.
1 parent 214d73c commit afc0982

7 files changed

Lines changed: 118 additions & 21 deletions

File tree

docs/guide/jobs.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,9 @@ await queue.add(
5050
interface JobOptions {
5151
delay?: number // Delay before processing (ms)
5252
attempts?: number // Max retry attempts
53-
backoff?: {
54-
type: 'fixed' | 'exponential'
55-
delay: number // Base delay in ms
56-
}
53+
backoff?: // Retry backoff, in ms
54+
| { type: 'fixed' | 'exponential', delay: number }
55+
| number[] // Explicit per-attempt schedule
5756
removeOnComplete?: boolean | number // Remove after completion (or keep N jobs)
5857
removeOnFail?: boolean | number // Remove after failure (or keep N jobs)
5958
priority?: number // Higher = processed first
@@ -122,8 +121,22 @@ await queue.add(
122121
}
123122
}
124123
)
124+
125+
// Explicit schedule: 1s, then 5s, then 30s for every attempt after that.
126+
// A schedule shorter than `attempts` clamps to its last entry rather than
127+
// dropping to no wait at all.
128+
await queue.add(
129+
{ task: 'api-call' },
130+
{
131+
attempts: 5,
132+
backoff: [1000, 5000, 30000]
133+
}
134+
)
125135
```
126136

137+
This is the same schedule a job class declares with `withBackoff()`, so a job
138+
class dispatched through a plain `queue.add()` keeps its full retry timing.
139+
127140
## Job Management
128141

129142
### Get Job by ID

packages/bun-queue/src/job-base.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export abstract class JobBase implements JobContract, Queueable, Dispatchable, I
9292
delay: this.delay,
9393
attempts: this.tries,
9494
timeout: this.timeout,
95-
backoff: this.backoff ? { type: 'fixed', delay: this.backoff[0] } : undefined,
95+
backoff: this.backoff,
9696
jobId: this.uniqueId?.(),
9797
}
9898

packages/bun-queue/src/queue.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1031,7 +1031,7 @@ export class Queue<T = any> {
10311031
delay: job.delay,
10321032
attempts: job.tries,
10331033
timeout: job.timeout,
1034-
backoff: job.backoff ? { type: 'fixed', delay: job.backoff[0] } : undefined,
1034+
backoff: job.backoff,
10351035
jobId: job.uniqueId?.(),
10361036
removeOnComplete: true,
10371037
removeOnFail: false,
@@ -1078,7 +1078,7 @@ export class Queue<T = any> {
10781078
delay: job.delay,
10791079
attempts: job.tries,
10801080
timeout: job.timeout,
1081-
backoff: job.backoff ? { type: 'fixed', delay: job.backoff[0] } : undefined,
1081+
backoff: job.backoff,
10821082
jobId: job.uniqueId?.() ? `${batchId}_${job.uniqueId()}` : undefined,
10831083
}
10841084

packages/bun-queue/src/types.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,29 @@ export type { RedisClient }
6060

6161
export type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed' | 'paused'
6262

63-
export interface JobOptions {
64-
delay?: number
65-
attempts?: number
66-
backoff?: {
63+
/**
64+
* Retry backoff, in milliseconds.
65+
*
66+
* The object form describes a strategy: `fixed` waits `delay` before every
67+
* retry, `exponential` doubles it each attempt (`delay * 2 ** (n - 1)`).
68+
*
69+
* The array form gives an explicit per-attempt schedule — `[1000, 5000, 30000]`
70+
* waits 1s after the first failure, 5s after the second, and 30s after the
71+
* third and every one after it. This is what job classes express through
72+
* `withBackoff()`, and it is accepted here so the same schedule survives a
73+
* plain `queue.add()`.
74+
*/
75+
export type BackoffOptions =
76+
| {
6777
type: 'fixed' | 'exponential'
6878
delay: number
6979
}
80+
| number[]
81+
82+
export interface JobOptions {
83+
delay?: number
84+
attempts?: number
85+
backoff?: BackoffOptions
7086
removeOnComplete?: boolean | number
7187
removeOnFail?: boolean | number
7288
priority?: number

packages/bun-queue/src/utils.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { JobOptions, QueueConfig } from './types'
1+
import type { BackoffOptions, JobOptions, QueueConfig } from './types'
22
import { RedisClient } from 'bun'
33
import process from 'node:process'
44
import { config } from './config'
@@ -34,6 +34,34 @@ export function mergeOptions(options?: JobOptions): JobOptions {
3434
}
3535
}
3636

37+
/**
38+
* Resolves a backoff setting into the delay, in milliseconds, to wait before
39+
* the next attempt.
40+
*
41+
* `attemptsMade` is the number of attempts that have already failed, so it is
42+
* 1 when the first attempt has just failed. An explicit per-attempt array is
43+
* indexed by that attempt (`backoff[attemptsMade - 1]`) and clamps to its last
44+
* entry once the schedule runs out, which is what makes `[1000, 5000]` mean
45+
* "1s, then 5s from here on" rather than "1s, 5s, then no wait at all".
46+
*/
47+
export function backoffDelay(backoff: BackoffOptions | undefined, attemptsMade: number): number {
48+
if (!backoff)
49+
return 0
50+
51+
if (Array.isArray(backoff)) {
52+
if (backoff.length === 0)
53+
return 0
54+
55+
const index = Math.min(Math.max(attemptsMade - 1, 0), backoff.length - 1)
56+
return Number(backoff[index]) || 0
57+
}
58+
59+
if (backoff.type === 'exponential')
60+
return backoff.delay * 2 ** Math.max(attemptsMade - 1, 0)
61+
62+
return backoff.delay
63+
}
64+
3765
/**
3866
* Gets key with prefix
3967
*/

packages/bun-queue/src/worker.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Job } from './job'
22
import type { Queue } from './queue'
3+
import { backoffDelay } from './utils'
34

45
export class Worker<T = any> {
56
queue: Queue<T>
@@ -200,15 +201,7 @@ export class Worker<T = any> {
200201

201202
if (job.attemptsMade < maxAttempts) {
202203
// Calculate delay for retry based on backoff strategy
203-
let delay = 0
204-
if (job.opts.backoff) {
205-
if (job.opts.backoff.type === 'fixed') {
206-
delay = job.opts.backoff.delay
207-
}
208-
else if (job.opts.backoff.type === 'exponential') {
209-
delay = job.opts.backoff.delay * 2 ** (job.attemptsMade - 1)
210-
}
211-
}
204+
const delay = backoffDelay(job.opts.backoff, job.attemptsMade)
212205

213206
if (delay > 0) {
214207
// Add to delayed set
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { backoffDelay } from '../src/utils'
3+
4+
describe('backoffDelay', () => {
5+
test('returns no delay when backoff is unset', () => {
6+
expect(backoffDelay(undefined, 1)).toBe(0)
7+
})
8+
9+
test('fixed backoff waits the same delay on every attempt', () => {
10+
const backoff = { type: 'fixed', delay: 5000 } as const
11+
12+
expect(backoffDelay(backoff, 1)).toBe(5000)
13+
expect(backoffDelay(backoff, 2)).toBe(5000)
14+
expect(backoffDelay(backoff, 9)).toBe(5000)
15+
})
16+
17+
test('exponential backoff doubles from the base delay', () => {
18+
const backoff = { type: 'exponential', delay: 1000 } as const
19+
20+
expect(backoffDelay(backoff, 1)).toBe(1000)
21+
expect(backoffDelay(backoff, 2)).toBe(2000)
22+
expect(backoffDelay(backoff, 3)).toBe(4000)
23+
expect(backoffDelay(backoff, 4)).toBe(8000)
24+
})
25+
26+
test('an array is an explicit per-attempt schedule in milliseconds', () => {
27+
const backoff = [1000, 5000, 30000]
28+
29+
expect(backoffDelay(backoff, 1)).toBe(1000)
30+
expect(backoffDelay(backoff, 2)).toBe(5000)
31+
expect(backoffDelay(backoff, 3)).toBe(30000)
32+
})
33+
34+
test('an exhausted schedule clamps to its last entry', () => {
35+
expect(backoffDelay([1000, 5000], 3)).toBe(5000)
36+
expect(backoffDelay([1000, 5000], 100)).toBe(5000)
37+
})
38+
39+
test('an empty schedule retries immediately', () => {
40+
expect(backoffDelay([], 1)).toBe(0)
41+
})
42+
43+
test('a zero or negative attempt count reads the first entry', () => {
44+
expect(backoffDelay([1000, 5000], 0)).toBe(1000)
45+
expect(backoffDelay({ type: 'exponential', delay: 1000 }, 0)).toBe(1000)
46+
})
47+
})

0 commit comments

Comments
 (0)