@open-mercato/queue 0.6.8-develop.7015.1.af90a2ddc7 → 0.6.8-develop.7019.1.f4c01c4b5c

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,8 +36,11 @@ interface BullQueueInterface<T> {
36
36
  close: () => Promise<void>
37
37
  getJobCounts: (...states: string[]) => Promise<Record<string, number>>
38
38
  getJobs: (types: string[], start?: number, end?: number) => Promise<Array<{
39
+ id?: string
39
40
  data?: T
41
+ failedReason?: string
40
42
  remove: () => Promise<void>
43
+ updateData?: (data: T) => Promise<void>
41
44
  }>>
42
45
  }
43
46
 
@@ -66,6 +69,66 @@ type BullMQOtelModule = { BullMQOtel: new (tracerName: string) => object }
66
69
 
67
70
  const REMOVABLE_JOB_STATES = ['waiting', 'delayed', 'prioritized', 'paused', 'waiting-children']
68
71
 
72
+ /**
73
+ * The failures BullMQ records when it gives up on a job *before* handing it to the processor.
74
+ *
75
+ * Both are written as a `defa` (deferred failure) marker on the job, after which the next worker
76
+ * short-circuits in `Worker.processJob` via `getUnrecoverableErrorMessage` and fails the job without
77
+ * calling the handler. The first comes from the stalled-job script once a job's cumulative stall
78
+ * count passes `maxStalledCount`; the second from `maxStartedAttempts`.
79
+ *
80
+ * Matching the reason is what tells "the queue abandoned this" from "the handler ran and threw", and
81
+ * it is deliberately stateless: the alternative — tracking which jobs this process has entered — can
82
+ * only answer "did the handler run *here*", which is the wrong question the moment more than one
83
+ * worker is running. `bullmq-abandoned-reasons.test.ts` asserts these strings still exist in the
84
+ * installed BullMQ, so an upgrade that renames them fails loudly instead of silently disabling the
85
+ * callback.
86
+ */
87
+ export const ABANDONED_JOB_REASONS = [
88
+ 'job stalled more than allowable limit',
89
+ 'job started more than allowable limit',
90
+ ] as const
91
+
92
+ function isAbandonedJobReason(message: string): boolean {
93
+ return (ABANDONED_JOB_REASONS as readonly string[]).includes(message)
94
+ }
95
+
96
+ /**
97
+ * Metadata key written onto the stored job once `onJobAbandoned` has completed for it.
98
+ *
99
+ * BullMQ's failed set is the durable record of abandoned jobs (`removeOnFail` keeps them), so it
100
+ * doubles as the dead-letter queue for reports: the sweep re-delivers any abandoned job that does not
101
+ * carry this marker. The marker — not `job.remove()` — is the acknowledgement, so the failed job
102
+ * itself survives for diagnosis.
103
+ */
104
+ const ABANDON_REPORT_ACK_KEY = 'abandonReportedAt'
105
+ // NOTE for anyone adding a retry action: the marker lives inside the job's own payload envelope, so a
106
+ // job retried from admin tooling carries it into its next life and a second abandonment of that job
107
+ // would never be reported. A retry path must clear `metadata.abandonReportedAt` when it re-enqueues.
108
+
109
+ /**
110
+ * How often a worker re-sweeps the failed set for unacknowledged abandoned jobs.
111
+ *
112
+ * Override with `QUEUE_ABANDONED_SWEEP_INTERVAL_MS` to trade recovery latency against Redis chatter.
113
+ */
114
+ export const ABANDONED_JOB_SWEEP_INTERVAL_MS = 5 * 60 * 1000
115
+
116
+ /**
117
+ * How long `close()` waits for in-flight reports before giving up on them.
118
+ *
119
+ * Bounded on purpose: the hook reaches a database, and a shutdown during an infrastructure incident
120
+ * is exactly when that write can hang rather than fail. An unbounded wait would turn a graceful
121
+ * shutdown into a SIGKILL and skip the telemetry flush that follows it. Abandoning the wait is safe
122
+ * because delivery is at-least-once — an unacknowledged report is re-delivered by the next worker's
123
+ * start-up sweep, the same path that covers a process which died mid-report.
124
+ */
125
+ export const ABANDONED_JOB_DRAIN_TIMEOUT_MS = 5000
126
+
127
+ function resolveSweepIntervalMs(): number {
128
+ const configured = Number.parseInt(process.env.QUEUE_ABANDONED_SWEEP_INTERVAL_MS ?? '', 10)
129
+ return Number.isFinite(configured) && configured > 0 ? configured : ABANDONED_JOB_SWEEP_INTERVAL_MS
130
+ }
131
+
69
132
  function payloadMatchesScope(payload: unknown, scope: QueueJobScope): boolean {
70
133
  if (!payload || typeof payload !== 'object') return false
71
134
  const scopedPayload = payload as { tenantId?: unknown; organizationId?: unknown; jobType?: unknown }
@@ -129,11 +192,105 @@ export function createAsyncQueue<T = unknown>(
129
192
  const attempts = options?.attempts ?? 3
130
193
  const lockDuration = options?.lockDuration
131
194
  const maxStalledCount = options?.maxStalledCount
195
+ const onJobAbandoned = options?.onJobAbandoned
132
196
  const logger = packageLogger.child({ queue: name })
133
197
 
134
198
  let bullQueue: BullQueueInterface<QueuedJob<T>> | null = null
135
199
  let bullWorker: BullWorkerInterface | null = null
136
200
  let bullmqModule: BullMQModule | null = null
201
+ let abandonedSweepTimer: ReturnType<typeof setInterval> | null = null
202
+ let closing = false
203
+
204
+ // In-flight `onJobAbandoned` calls. Detached from the caller that started them (the 'failed'
205
+ // listener or the sweep), so `close()` drains them rather than letting a deploy truncate a repair
206
+ // mid-write. The id set stops the two callers from double-reporting a job inside one process.
207
+ const pendingAbandonedReports = new Set<Promise<void>>()
208
+ const inFlightAbandonedJobIds = new Set<string>()
209
+
210
+ type AbandonedJobRecord = {
211
+ id?: string
212
+ data?: QueuedJob<T>
213
+ updateData?: (data: QueuedJob<T>) => Promise<void>
214
+ }
215
+
216
+ // The acknowledgement that makes delivery at-least-once: written only after the callback returns,
217
+ // so a callback that threw or a process that died mid-report leaves the job unmarked and a later
218
+ // sweep retries it. Requires the driver to expose `updateData`; without it the report simply stays
219
+ // unacknowledged and repeats, which the idempotency contract permits.
220
+ async function acknowledgeAbandonedReport(job: AbandonedJobRecord): Promise<void> {
221
+ if (!job.data || typeof job.updateData !== 'function') return
222
+ await job.updateData({
223
+ ...job.data,
224
+ metadata: { ...(job.data.metadata ?? {}), [ABANDON_REPORT_ACK_KEY]: new Date().toISOString() },
225
+ })
226
+ }
227
+
228
+ function reportAbandonedJob(job: AbandonedJobRecord, reason: string): Promise<void> | null {
229
+ if (!onJobAbandoned) return null
230
+ const payload = job.data
231
+ if (!payload) return null
232
+ if (payload.metadata && payload.metadata[ABANDON_REPORT_ACK_KEY]) return null
233
+ if (inFlightAbandonedJobIds.has(payload.id)) return null
234
+ inFlightAbandonedJobIds.add(payload.id)
235
+
236
+ const jobId = job.id ?? null
237
+ logger.warn('Job abandoned by the queue without running its handler', { jobId, reason })
238
+ const report = (async () => {
239
+ try {
240
+ await onJobAbandoned(payload, { jobId, reason })
241
+ } catch (hookError) {
242
+ logger.error('onJobAbandoned handler threw; the report stays unacknowledged and the sweep will retry it', {
243
+ jobId,
244
+ err: hookError as Error,
245
+ })
246
+ return
247
+ }
248
+ try {
249
+ await acknowledgeAbandonedReport(job)
250
+ } catch (ackError) {
251
+ logger.error('Failed to acknowledge an abandoned-job report; the sweep may repeat it', {
252
+ jobId,
253
+ err: ackError as Error,
254
+ })
255
+ }
256
+ })().finally(() => {
257
+ inFlightAbandonedJobIds.delete(payload.id)
258
+ })
259
+ pendingAbandonedReports.add(report)
260
+ void report.then(() => pendingAbandonedReports.delete(report))
261
+ return report
262
+ }
263
+
264
+ // The failed set is this strategy's dead-letter queue for abandoned jobs. Enumerating it on worker
265
+ // start and on an interval, and re-delivering anything unacknowledged, is what upgrades the
266
+ // 'failed'-listener fast path from at-most-once to at-least-once: a report lost to a crash is
267
+ // simply still unmarked when the next sweep looks. Residual loss: `removeOnFail` caps the set, so
268
+ // a job evicted before any sweep sees it is gone for good.
269
+ async function sweepAbandonedJobs(): Promise<void> {
270
+ if (!onJobAbandoned || closing) return
271
+ try {
272
+ const queue = await getQueue()
273
+ const failedJobs = await queue.getJobs(['failed'], 0, -1)
274
+ // Re-checked after the awaits: a sweep already past its guard when `close()` ran would
275
+ // otherwise start a report the drain has stopped waiting for. The next start-up sweep
276
+ // re-delivers it, so stopping here loses nothing.
277
+ if (closing) return
278
+ for (const failedJob of failedJobs) {
279
+ // Re-checked every iteration for the same reason: a long fan-out must not outlive the drain.
280
+ if (closing) return
281
+ const reason = failedJob.failedReason ?? ''
282
+ if (!isAbandonedJobReason(reason)) continue
283
+ // Awaited one at a time. Each report opens a request container and writes to the database, and
284
+ // the worst case for this loop is the first worker start after the feature ships, on a
285
+ // deployment that has been accumulating abandoned jobs — the largest backlog, on the process
286
+ // least able to absorb it. The sweep is a recovery path with no latency requirement (five
287
+ // minutes late is its normal mode), so pacing costs nothing worth having.
288
+ await reportAbandonedJob(failedJob, reason)
289
+ }
290
+ } catch (sweepError) {
291
+ logger.error('Abandoned-job sweep failed', { err: sweepError as Error })
292
+ }
293
+ }
137
294
  // Resolved once: a BullMQOtel instance (delegate async tracing to BullMQ) or
138
295
  // undefined (use our own metadata._trace carrier instead). Memoized as the
139
296
  // in-flight promise so concurrent first-time callers share one resolution.
@@ -255,9 +412,24 @@ export function createAsyncQueue<T = unknown>(
255
412
  })
256
413
 
257
414
  bullWorker.on('failed', (job, err) => {
258
- const jobWithId = job as { id?: string } | undefined
415
+ const failedJob = job as AbandonedJobRecord | undefined
259
416
  const error = err as Error
260
- logger.error('Job failed', { jobId: jobWithId?.id, err: error })
417
+ logger.error('Job failed', { jobId: failedJob?.id, err: error })
418
+
419
+ if (!onJobAbandoned) return
420
+ // Any other reason means a handler ran and threw. That failure is the handler's own and it has
421
+ // already had its chance to record it.
422
+ if (!isAbandonedJobReason(error?.message ?? '')) return
423
+ // No payload means the queue could not give us the job at all. There is nothing to hand the
424
+ // callback and nothing it could repair, so reporting could only ever be a false alarm — the
425
+ // 'Job failed' line above still records it.
426
+ if (!failedJob?.data) return
427
+
428
+ // The fast path: report the moment the abandonment is observed. `reportAbandonedJob` runs the
429
+ // callback detached with its own try/catch — this is an EventEmitter, where an unhandled
430
+ // rejection is fatal to the process — and acknowledges the job only afterwards, so a report
431
+ // lost here is retried by the sweep.
432
+ reportAbandonedJob(failedJob, error.message)
261
433
  })
262
434
 
263
435
  // A stalled job is redelivered under the same id while the previous worker
@@ -275,6 +447,17 @@ export function createAsyncQueue<T = unknown>(
275
447
  logger.error('Worker error', { err: error })
276
448
  })
277
449
 
450
+ if (onJobAbandoned) {
451
+ // Sweep immediately so reports lost to a previous process's crash are re-delivered as soon as
452
+ // a worker is back, then keep re-sweeping for anything the fast path loses while running.
453
+ // The timer is unref'd so it never holds the process open.
454
+ void sweepAbandonedJobs()
455
+ abandonedSweepTimer = setInterval(() => {
456
+ void sweepAbandonedJobs()
457
+ }, resolveSweepIntervalMs())
458
+ ;(abandonedSweepTimer as unknown as { unref?: () => void }).unref?.()
459
+ }
460
+
278
461
  logger.info('Worker started', { concurrency })
279
462
 
280
463
  // For async strategy, return a sentinel result indicating worker mode
@@ -311,10 +494,31 @@ export function createAsyncQueue<T = unknown>(
311
494
  }
312
495
 
313
496
  async function close(): Promise<void> {
497
+ closing = true
498
+ if (abandonedSweepTimer) {
499
+ clearInterval(abandonedSweepTimer)
500
+ abandonedSweepTimer = null
501
+ }
314
502
  if (bullWorker) {
315
503
  await bullWorker.close()
316
504
  bullWorker = null
317
505
  }
506
+ // Drain any abandonment report still in flight, so a deploy-time shutdown cannot cut off the very
507
+ // repair the callback exists to perform. Bounded: the hook writes to a database, and a shutdown
508
+ // during an incident is exactly when that write can hang instead of failing. Giving up costs
509
+ // nothing permanent — an unacknowledged report is re-delivered by the next start-up sweep.
510
+ if (pendingAbandonedReports.size) {
511
+ const drained = Promise.all([...pendingAbandonedReports]).then(() => true)
512
+ const expired = new Promise<boolean>((resolve) => {
513
+ const timer = setTimeout(() => resolve(false), ABANDONED_JOB_DRAIN_TIMEOUT_MS)
514
+ ;(timer as unknown as { unref?: () => void }).unref?.()
515
+ })
516
+ if (!(await Promise.race([drained, expired]))) {
517
+ logger.warn('Abandoned-job reports still in flight at shutdown; the sweep will retry them', {
518
+ pending: pendingAbandonedReports.size,
519
+ })
520
+ }
521
+ }
318
522
  if (bullQueue) {
319
523
  await bullQueue.close()
320
524
  bullQueue = null
package/src/types.ts CHANGED
@@ -99,6 +99,43 @@ export type AsyncQueueOptions = {
99
99
  lockDuration?: number
100
100
  /** Number of stalled-job recoveries BullMQ permits before failing a job. Defaults to 1. */
101
101
  maxStalledCount?: number
102
+ /**
103
+ * Called when the queue permanently gives up on a job WITHOUT its handler having run.
104
+ *
105
+ * Why this cannot be left to the handler: a queue may destroy a job before ever calling the
106
+ * processor — the usual cause is a job redelivered once too often, past `maxStalledCount`. The
107
+ * handler then never runs, never throws and never learns, and any state it created on enqueue (a
108
+ * run row, a progress record) is orphaned in whatever "in progress" state it was left in, with
109
+ * nothing to correct it.
110
+ *
111
+ * Contract:
112
+ * - Fires **only** for a job the queue abandoned before its handler ran. It is deliberately not a
113
+ * general "job failed" hook: a handler that ran and threw owns its own outcome and has already
114
+ * had the chance to record it. Reporting both would double-report and would hide the difference
115
+ * between "the work failed" and "the work never started".
116
+ * - **At-least-once, where the backend allows it.** The strategy reports as soon as it observes
117
+ * the abandonment, and also sweeps the backend's dead-job records — on worker start, then
118
+ * periodically — for reports that were never acknowledged. A report is acknowledged only after
119
+ * this callback returns, so a callback that threw or a process that died mid-report is retried
120
+ * by a later sweep. The callback MUST therefore be idempotent, and MUST tolerate a payload it
121
+ * does not recognise. Residual loss is still possible when the backend evicts its dead-job
122
+ * records before any sweep sees them; state that absolutely must never be stranded should also
123
+ * have a staleness check of its own at the domain level.
124
+ * - Not every strategy can implement it — the local strategy runs handlers in-process, so no queue
125
+ * outlives a handler to abandon its job.
126
+ *
127
+ * Backend specifics (which failures count as abandonment, and how they are detected) belong to the
128
+ * strategy; see `strategies/async.ts`.
129
+ */
130
+ onJobAbandoned?: (payload: unknown, info: AbandonedJobInfo) => void | Promise<void>
131
+ }
132
+
133
+ /** What the queue can say about a job it gave up on. */
134
+ export type AbandonedJobInfo = {
135
+ /** The queue driver's own job id, or null when it did not supply one. */
136
+ jobId: string | null
137
+ /** The failure the queue recorded, e.g. 'job stalled more than allowable limit'. */
138
+ reason: string
102
139
  }
103
140
 
104
141
  /**
@@ -262,6 +299,14 @@ export type WorkerMeta = {
262
299
  lockDuration?: number
263
300
  /** Number of stalled-job recoveries BullMQ permits before failing a job. */
264
301
  maxStalledCount?: number
302
+ /**
303
+ * Called when the queue abandons one of this worker's jobs without running the handler.
304
+ *
305
+ * Declared here rather than on the producing queue because the worker is the side that must hear
306
+ * it: the event being reported is a worker restart, and the process that restarts never
307
+ * constructs the enqueueing queue. See `AsyncQueueOptions.onJobAbandoned` for the contract.
308
+ */
309
+ onJobAbandoned?: AsyncQueueOptions['onJobAbandoned']
265
310
  }
266
311
 
267
312
  /**
@@ -281,4 +326,6 @@ export type WorkerDescriptor<T = unknown> = {
281
326
  lockDuration?: number
282
327
  /** Number of stalled-job recoveries BullMQ permits before failing a job. */
283
328
  maxStalledCount?: number
329
+ /** Called when the queue abandons one of this worker's jobs without running the handler. */
330
+ onJobAbandoned?: AsyncQueueOptions['onJobAbandoned']
284
331
  }
@@ -24,6 +24,8 @@ export type WorkerRunnerOptions<T = unknown> = {
24
24
  lockDuration?: number
25
25
  /** Number of stalled-job recoveries BullMQ permits before failing a job. */
26
26
  maxStalledCount?: number
27
+ /** Called when the queue abandons a job without running the handler. */
28
+ onJobAbandoned?: AsyncQueueOptions['onJobAbandoned']
27
29
  /** Whether to set up graceful shutdown handlers */
28
30
  gracefulShutdown?: boolean
29
31
  /** If true, don't block - return immediately after starting processing (for multi-queue mode) */
@@ -151,6 +153,7 @@ export async function runWorker<T = unknown>(
151
153
  concurrency = 1,
152
154
  lockDuration,
153
155
  maxStalledCount,
156
+ onJobAbandoned,
154
157
  gracefulShutdown = true,
155
158
  background = false,
156
159
  strategy: strategyOption,
@@ -176,6 +179,7 @@ export async function runWorker<T = unknown>(
176
179
  concurrency,
177
180
  lockDuration,
178
181
  maxStalledCount,
182
+ onJobAbandoned,
179
183
  })
180
184
 
181
185
  // Set up graceful shutdown