@open-mercato/queue 0.6.8-develop.7100.1.fbf66fca35 → 0.7.0

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,11 +36,8 @@ 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
40
39
  data?: T
41
- failedReason?: string
42
40
  remove: () => Promise<void>
43
- updateData?: (data: T) => Promise<void>
44
41
  }>>
45
42
  }
46
43
 
@@ -69,66 +66,6 @@ type BullMQOtelModule = { BullMQOtel: new (tracerName: string) => object }
69
66
 
70
67
  const REMOVABLE_JOB_STATES = ['waiting', 'delayed', 'prioritized', 'paused', 'waiting-children']
71
68
 
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
-
132
69
  function payloadMatchesScope(payload: unknown, scope: QueueJobScope): boolean {
133
70
  if (!payload || typeof payload !== 'object') return false
134
71
  const scopedPayload = payload as { tenantId?: unknown; organizationId?: unknown; jobType?: unknown }
@@ -192,105 +129,11 @@ export function createAsyncQueue<T = unknown>(
192
129
  const attempts = options?.attempts ?? 3
193
130
  const lockDuration = options?.lockDuration
194
131
  const maxStalledCount = options?.maxStalledCount
195
- const onJobAbandoned = options?.onJobAbandoned
196
132
  const logger = packageLogger.child({ queue: name })
197
133
 
198
134
  let bullQueue: BullQueueInterface<QueuedJob<T>> | null = null
199
135
  let bullWorker: BullWorkerInterface | null = null
200
136
  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
- }
294
137
  // Resolved once: a BullMQOtel instance (delegate async tracing to BullMQ) or
295
138
  // undefined (use our own metadata._trace carrier instead). Memoized as the
296
139
  // in-flight promise so concurrent first-time callers share one resolution.
@@ -412,24 +255,9 @@ export function createAsyncQueue<T = unknown>(
412
255
  })
413
256
 
414
257
  bullWorker.on('failed', (job, err) => {
415
- const failedJob = job as AbandonedJobRecord | undefined
258
+ const jobWithId = job as { id?: string } | undefined
416
259
  const error = err as 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)
260
+ logger.error('Job failed', { jobId: jobWithId?.id, err: error })
433
261
  })
434
262
 
435
263
  // A stalled job is redelivered under the same id while the previous worker
@@ -447,17 +275,6 @@ export function createAsyncQueue<T = unknown>(
447
275
  logger.error('Worker error', { err: error })
448
276
  })
449
277
 
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
-
461
278
  logger.info('Worker started', { concurrency })
462
279
 
463
280
  // For async strategy, return a sentinel result indicating worker mode
@@ -494,31 +311,10 @@ export function createAsyncQueue<T = unknown>(
494
311
  }
495
312
 
496
313
  async function close(): Promise<void> {
497
- closing = true
498
- if (abandonedSweepTimer) {
499
- clearInterval(abandonedSweepTimer)
500
- abandonedSweepTimer = null
501
- }
502
314
  if (bullWorker) {
503
315
  await bullWorker.close()
504
316
  bullWorker = null
505
317
  }
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
- }
522
318
  if (bullQueue) {
523
319
  await bullQueue.close()
524
320
  bullQueue = null
package/src/types.ts CHANGED
@@ -99,43 +99,6 @@ 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
139
102
  }
140
103
 
141
104
  /**
@@ -299,26 +262,6 @@ export type WorkerMeta = {
299
262
  lockDuration?: number
300
263
  /** Number of stalled-job recoveries BullMQ permits before failing a job. */
301
264
  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']
310
- /**
311
- * Opt-in flag allowing this queue to be selected as a user-facing scheduler
312
- * target. Internal and system-only workers (webhook processors, indexers,
313
- * bulk operations) must leave it unset — they stay undiscoverable by the
314
- * scheduler job API.
315
- */
316
- schedulerSafe?: boolean
317
- /**
318
- * Creator features a principal must hold (on top of scheduler.jobs.manage)
319
- * to schedule onto this worker's queue. Only read when schedulerSafe is set.
320
- */
321
- schedulerRequiredFeatures?: string[]
322
265
  }
323
266
 
324
267
  /**
@@ -338,6 +281,4 @@ export type WorkerDescriptor<T = unknown> = {
338
281
  lockDuration?: number
339
282
  /** Number of stalled-job recoveries BullMQ permits before failing a job. */
340
283
  maxStalledCount?: number
341
- /** Called when the queue abandons one of this worker's jobs without running the handler. */
342
- onJobAbandoned?: AsyncQueueOptions['onJobAbandoned']
343
284
  }
@@ -24,8 +24,6 @@ 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']
29
27
  /** Whether to set up graceful shutdown handlers */
30
28
  gracefulShutdown?: boolean
31
29
  /** If true, don't block - return immediately after starting processing (for multi-queue mode) */
@@ -153,7 +151,6 @@ export async function runWorker<T = unknown>(
153
151
  concurrency = 1,
154
152
  lockDuration,
155
153
  maxStalledCount,
156
- onJobAbandoned,
157
154
  gracefulShutdown = true,
158
155
  background = false,
159
156
  strategy: strategyOption,
@@ -179,7 +176,6 @@ export async function runWorker<T = unknown>(
179
176
  concurrency,
180
177
  lockDuration,
181
178
  maxStalledCount,
182
- onJobAbandoned,
183
179
  })
184
180
 
185
181
  // Set up graceful shutdown