Skip to content

Commit 2f29588

Browse files
committed
bound startup connect, durable pushAndWait delivery, task abort signal
1 parent 70f57c4 commit 2f29588

3 files changed

Lines changed: 245 additions & 21 deletions

File tree

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const queue = new Queue({
4747
delay: '100ms', // pause between tasks (string or ms)
4848
timeout: '30s', // max task duration
4949
maxRetries: 3, // attempts before failing
50+
connectTimeout: '10s', // max wait for the initial Redis connection (string or ms)
5051

5152
groups: {
5253
concurrency: 1, // max concurrent tasks per group
@@ -108,6 +109,15 @@ queue.process(async (payload, task) => {
108109

109110
Throw an error to trigger retry. After `maxRetries`, the task fails permanently.
110111

112+
The handler receives an `AbortSignal` as its third argument (also available as `task.signal`) that aborts when the per-task `timeout` fires. Pass it to anything that supports cancellation so the work stops instead of running detached while a retry begins.
113+
114+
```js
115+
queue.process(async (payload, task, signal) => {
116+
const res = await fetch(payload.url, { signal })
117+
return await res.json()
118+
})
119+
```
120+
111121
## Grouped Queues
112122

113123
Isolated concurrency per key - perfect for per-tenant throttling. Pass `{ group }` as the second argument to `push` or `pushAndWait`.
@@ -151,6 +161,8 @@ const result = await queue.pushAndWait(
151161

152162
Throws if the task fails (after retries are exhausted) or if the timeout is reached. Retries are transparent - if the handler fails twice then succeeds on the third attempt, `pushAndWait` resolves with the successful result.
153163

164+
Cross-instance delivery is at-least-once. The result is sent over Redis pub/sub for low latency and also written to a short-lived key the waiter reads as a fallback, so a dropped pub/sub message does not turn a completed task into a spurious timeout.
165+
154166
## Events
155167

156168
```js
@@ -169,7 +181,8 @@ queue.on('drain', () => {})
169181
payload: any,
170182
createdAt: number,
171183
group?: string, // present when pushed with { group }
172-
attempts: number
184+
attempts: number,
185+
signal?: AbortSignal // aborts when the per-task timeout fires
173186
}
174187
```
175188

@@ -277,6 +290,14 @@ Both queue and realtime use the same Redis instance. No key conflicts (`queue:*`
277290
278291
Multiple servers can push to the same queue. Redis coordinates via atomic operations - no duplicate processing. Use `globalConcurrency` to enforce a hard limit across all instances.
279292
293+
## Startup
294+
295+
```js
296+
await queue.ready()
297+
```
298+
299+
`ready()` resolves once connected. If Redis is unreachable, it rejects after `connectTimeout` (default 10 seconds) instead of hanging, so a process that gates startup on `await queue.ready()` gets a real error to handle. Set `connectTimeout: 0` to wait indefinitely. Once connected, the client follows Redis's default reconnect behavior and rides out transient outages.
300+
280301
## Cleanup
281302
282303
```js

src/queue.js

Lines changed: 89 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { semaphore as createSemaphore } from "@prsm/lock"
1414
* @property {{concurrency?: number, delay?: number|string, timeout?: number|string, maxRetries?: number}} [groups] - overrides for grouped queues
1515
* @property {{url?: string, host?: string, port?: number, password?: string}} [redisOptions]
1616
* @property {number} [cleanupInterval] - ms between empty group cleanup (default 30000, 0 to disable)
17+
* @property {number|string} [connectTimeout] - max time to wait for a Redis connection before ready() rejects, ms or string like "10s" (default "10s", 0 to wait forever)
1718
*/
1819

1920
/**
@@ -23,18 +24,22 @@ import { semaphore as createSemaphore } from "@prsm/lock"
2324
* @property {number} createdAt
2425
* @property {string} [group]
2526
* @property {number} attempts
27+
* @property {AbortSignal} [signal] - aborts when the per-task timeout fires
2628
*/
2729

2830
/**
2931
* @callback TaskHandler
3032
* @param {any} payload
3133
* @param {Task} task
34+
* @param {AbortSignal} signal - aborts when the per-task timeout fires; also available as task.signal
3235
* @returns {Promise<any>|any}
3336
*/
3437

3538
const LEASE_TTL = 60000
3639
const HEARTBEAT_INTERVAL = 15000
3740
const CLOSE_TIMEOUT = 5000
41+
const RESULT_TTL = 60000
42+
const RESULT_POLL_INTERVAL = 1000
3843

3944
class LocalSemaphore {
4045
constructor(max) {
@@ -84,6 +89,7 @@ export default class Queue extends EventEmitter {
8489
},
8590
redisOptions: options.redisOptions ?? {},
8691
cleanupInterval: options.cleanupInterval ?? 30000,
92+
connectTimeout: ms(options.connectTimeout ?? "10s"),
8793
}
8894

8995
this._tracer = options.tracer ?? null
@@ -117,6 +123,9 @@ export default class Queue extends EventEmitter {
117123
this._subClient = null
118124
this._groupNotifyClient = null
119125
this._readyPromise = this._initialize()
126+
// ready() callers see this rejection; the no-op keeps an unawaited queue
127+
// from emitting an unhandledRejection when the initial connect fails
128+
this._readyPromise.catch(() => {})
120129
}
121130

122131
/** @returns {Promise<void>} */
@@ -282,8 +291,8 @@ export default class Queue extends EventEmitter {
282291
async pushAndWait(payload, { group, timeout = 0 } = {}) {
283292
if (this._closed) throw new Error("Queue is closed")
284293
const task = group
285-
? { uuid: randomUUID(), payload, createdAt: Date.now(), group, attempts: 0 }
286-
: { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0 }
294+
? { uuid: randomUUID(), payload, createdAt: Date.now(), group, attempts: 0, awaitResult: true }
295+
: { uuid: randomUUID(), payload, createdAt: Date.now(), attempts: 0, awaitResult: true }
287296
const tpSpan = this._tracer?.startSpan('queue.pushAndWait', { 'queue.group': group ?? null, 'task.uuid': task.uuid }, { kind: 'producer' })
288297
if (tpSpan) {
289298
task.traceparent = this._tracer.toTraceparent(tpSpan.context)
@@ -331,10 +340,37 @@ export default class Queue extends EventEmitter {
331340
if (this._subClient) return this._subClient
332341
this._subClient = this._redis.duplicate()
333342
this._subClient.on("error", () => {})
334-
await this._subClient.connect()
343+
await this._connectWithDeadline(this._subClient, "sub")
335344
return this._subClient
336345
}
337346

347+
// node-redis keeps retrying a refused connection forever, so connect() never
348+
// settles when redis is down at startup. bound it so ready() rejects instead
349+
// of hanging, while leaving the default infinite reconnect for an already
350+
// connected client (survives transient outages and failovers)
351+
/** @private */
352+
async _connectWithDeadline(client, label) {
353+
const deadline = this._options.connectTimeout
354+
if (!deadline || deadline <= 0) return client.connect()
355+
const connectP = client.connect()
356+
connectP.catch(() => {})
357+
let timer
358+
try {
359+
await Promise.race([
360+
connectP,
361+
new Promise((_, reject) => {
362+
timer = setTimeout(() => reject(new Error(`Redis connection timed out after ${deadline}ms (${label})`)), deadline)
363+
timer.unref?.()
364+
}),
365+
])
366+
} catch (err) {
367+
try { if (client.isOpen) await client.disconnect() } catch {}
368+
throw err
369+
} finally {
370+
clearTimeout(timer)
371+
}
372+
}
373+
338374
/** @private */
339375
_awaitTask(uuid, timeout = 0) {
340376
const ms_ = ms(timeout)
@@ -345,6 +381,7 @@ export default class Queue extends EventEmitter {
345381

346382
const promise = new Promise((resolve, reject) => {
347383
let timer
384+
let pollTimer
348385
let settled = false
349386

350387
const settle = (fn, value) => {
@@ -354,6 +391,11 @@ export default class Queue extends EventEmitter {
354391
fn(value)
355392
}
356393

394+
const settleFromPayload = ({ status, result, error }) => {
395+
if (status === "complete") settle(resolve, result)
396+
else settle(reject, error ? Object.assign(new Error(error.message), error) : new Error("Task failed"))
397+
}
398+
357399
const onLocal = (event) => ({ task, result, error }) => {
358400
if (task.uuid !== uuid) return
359401
if (event === "complete") settle(resolve, result)
@@ -363,29 +405,41 @@ export default class Queue extends EventEmitter {
363405
const onComplete = onLocal("complete")
364406
const onFailed = onLocal("failed")
365407

408+
// safety net for a dropped pub/sub message: read the durable result key
409+
const consumeDurable = async () => {
410+
if (settled || !this._redis?.isOpen) return
411+
let raw
412+
try { raw = await this._redis.get(channel) } catch { return }
413+
if (!raw || settled) return
414+
try { settleFromPayload(JSON.parse(raw)) } catch {}
415+
}
416+
366417
const cleanup = () => {
367418
if (timer) clearTimeout(timer)
419+
if (pollTimer) clearInterval(pollTimer)
368420
this.off("complete", onComplete)
369421
this.off("failed", onFailed)
370422
this._subClient?.unsubscribe(channel).catch(() => {})
423+
this._redis?.del(channel).catch(() => {})
371424
}
372425

373426
if (ms_ > 0) {
374-
timer = setTimeout(() => settle(reject, new Error("pushAndWait timed out")), ms_)
427+
timer = setTimeout(() => {
428+
consumeDurable().finally(() => settle(reject, new Error("pushAndWait timed out")))
429+
}, ms_)
375430
timer.unref?.()
376431
}
377432

433+
pollTimer = setInterval(() => { consumeDurable() }, RESULT_POLL_INTERVAL)
434+
pollTimer.unref?.()
435+
378436
this.on("complete", onComplete)
379437
this.on("failed", onFailed)
380438

381439
this._ensureSubClient().then((sub) => {
382440
if (settled) { resolveReady(); return }
383441
sub.subscribe(channel, (message) => {
384-
try {
385-
const { status, result, error } = JSON.parse(message)
386-
if (status === "complete") settle(resolve, result)
387-
else settle(reject, error ? Object.assign(new Error(error.message), error) : new Error("Task failed"))
388-
} catch {}
442+
try { settleFromPayload(JSON.parse(message)) } catch {}
389443
}).then(() => resolveReady()).catch(() => resolveReady())
390444
}).catch(() => resolveReady())
391445
})
@@ -441,7 +495,7 @@ export default class Queue extends EventEmitter {
441495
}
442496

443497
async _initialize() {
444-
await this._redis.connect()
498+
await this._connectWithDeadline(this._redis, "main")
445499
if (this._semaphore) await this._semaphore.peek("queue:active").catch(() => {})
446500
await this._startWorkers()
447501
if (this._options.concurrency > 0) {
@@ -457,7 +511,7 @@ export default class Queue extends EventEmitter {
457511
async _subscribeToGroupNotifications() {
458512
this._groupNotifyClient = this._redis.duplicate()
459513
this._groupNotifyClient.on("error", () => {})
460-
await this._groupNotifyClient.connect()
514+
await this._connectWithDeadline(this._groupNotifyClient, "notify")
461515
await this._groupNotifyClient.subscribe("queue:group:notify", (group) => {
462516
this._ensureGroupWorkers(group)
463517
})
@@ -474,7 +528,7 @@ export default class Queue extends EventEmitter {
474528
async _createWorkerClient() {
475529
const client = this._redis.duplicate()
476530
client.on("error", () => {})
477-
await client.connect()
531+
await this._connectWithDeadline(client, "worker")
478532
this._workerClients.push(client)
479533
return client
480534
}
@@ -644,11 +698,21 @@ export default class Queue extends EventEmitter {
644698
kind: 'consumer',
645699
parent: parent ? { traceId: parent.traceId, spanId: parent.parentSpanId, sampled: parent.sampled } : null,
646700
})
701+
const controller = new AbortController()
702+
// exposed as task.signal too, non-enumerable so it never gets serialized
703+
// onto the task when it is re-queued for a retry
704+
Object.defineProperty(task, "signal", { value: controller.signal, configurable: true, enumerable: false })
647705
const runHandler = async () => {
648706
const timeoutPromise = opts.timeout > 0
649-
? new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Task timeout")), opts.timeout) })
707+
? new Promise((_, reject) => {
708+
timer = setTimeout(() => {
709+
const err = new Error("Task timeout")
710+
controller.abort(err)
711+
reject(err)
712+
}, opts.timeout)
713+
})
650714
: null
651-
const workPromise = Promise.resolve(this._handler(task.payload, task))
715+
const workPromise = Promise.resolve(this._handler(task.payload, task, controller.signal))
652716
result = timeoutPromise ? await Promise.race([workPromise, timeoutPromise]) : await workPromise
653717
succeeded = true
654718
}
@@ -671,7 +735,7 @@ export default class Queue extends EventEmitter {
671735

672736
if (succeeded) {
673737
this._settle()
674-
this._publishResult(task.uuid, { status: "complete", result })
738+
this._publishResult(task, { status: "complete", result })
675739
try { this.emit("complete", { task, result }) } finally { this._emitDrain() }
676740
} else if (task.attempts < opts.maxRetries && !this._closed) {
677741
let retried = false
@@ -684,12 +748,12 @@ export default class Queue extends EventEmitter {
684748
this.emit("retry", { task, error: handlerError, attempt: task.attempts })
685749
} else {
686750
this._settle()
687-
this._publishResult(task.uuid, { status: "failed", error: { message: handlerError?.message, code: handlerError?.code, name: handlerError?.name } })
751+
this._publishResult(task, { status: "failed", error: { message: handlerError?.message, code: handlerError?.code, name: handlerError?.name } })
688752
try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
689753
}
690754
} else {
691755
this._settle()
692-
this._publishResult(task.uuid, { status: "failed", error: { message: handlerError?.message, code: handlerError?.code, name: handlerError?.name } })
756+
this._publishResult(task, { status: "failed", error: { message: handlerError?.message, code: handlerError?.code, name: handlerError?.name } })
693757
try { this.emit("failed", { task, error: handlerError }) } finally { this._emitDrain() }
694758
}
695759
}
@@ -721,10 +785,15 @@ export default class Queue extends EventEmitter {
721785
try { await this._redis.del(`queue:inflight:${uuid}`) } catch {}
722786
}
723787

724-
_publishResult(uuid, payload) {
788+
// pub/sub is at-most-once: a waiter on another instance loses the result if
789+
// the message drops. for waited-on tasks we also persist it to a short-lived
790+
// key the waiter can read, making delivery at-least-once
791+
_publishResult(task, payload) {
725792
if (!this._redis.isOpen) return
726-
const channel = `queue:result:${uuid}`
727-
this._redis.publish(channel, JSON.stringify(payload)).catch(() => {})
793+
const key = `queue:result:${task.uuid}`
794+
const data = JSON.stringify(payload)
795+
this._redis.publish(key, data).catch(() => {})
796+
if (task.awaitResult) this._redis.set(key, data, { PX: RESULT_TTL }).catch(() => {})
728797
}
729798

730799
_settle() {

0 commit comments

Comments
 (0)