@open-mercato/queue 0.6.7 → 0.6.8-develop.6875.1.871a4afc94

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.
@@ -149,14 +149,16 @@ async function probeAsyncQueue(
149
149
  return errorResult(queueName, 'async', new Error('bullmq is not installed'))
150
150
  }
151
151
 
152
- const { getRedisUrl } = await import('@open-mercato/shared/lib/redis/connection')
152
+ const { getRedisUrl, parseRedisUrl } = await import('@open-mercato/shared/lib/redis/connection')
153
153
  let connection = options?.connection
154
154
  if (!connection) {
155
155
  const url = getRedisUrl('QUEUE')
156
156
  if (!url) {
157
157
  return errorResult(queueName, 'async', new Error('QUEUE Redis URL is not configured'))
158
158
  }
159
- connection = { url }
159
+ connection = parseRedisUrl(url)
160
+ } else if (connection.url) {
161
+ connection = parseRedisUrl(connection.url)
160
162
  }
161
163
 
162
164
  let queue: InstanceType<BullMQModuleShape['Queue']> | null = null
@@ -1,5 +1,7 @@
1
1
  import type { Queue, QueuedJob, JobHandler, AsyncQueueOptions, ProcessResult, EnqueueOptions, QueueJobScope } from '../types'
2
- import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
2
+ import { getRedisUrlOrThrow, parseRedisUrl } from '@open-mercato/shared/lib/redis/connection'
3
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
4
+ import { attachTraceMetadata, runJobInTrace } from '../tracing'
3
5
  import { createLogger } from '@open-mercato/shared/lib/logger'
4
6
 
5
7
  const packageLogger = createLogger('queue')
@@ -7,13 +9,13 @@ const packageLogger = createLogger('queue')
7
9
  // BullMQ interface types - we define the shape we use to maintain type safety
8
10
  // while keeping bullmq as an optional peer dependency
9
11
  type ConnectionOptions = {
10
- url?: string
11
12
  host?: string
12
13
  port?: number
13
14
  username?: string
14
15
  password?: string
15
16
  db?: number
16
17
  tls?: Record<string, unknown>
18
+ family?: number
17
19
  }
18
20
 
19
21
  interface BullQueueInterface<T> {
@@ -43,14 +45,23 @@ interface BullWorkerInterface {
43
45
  }
44
46
 
45
47
  interface BullMQModule {
46
- Queue: new <T>(name: string, opts: { connection: ConnectionOptions }) => BullQueueInterface<T>
48
+ Queue: new <T>(name: string, opts: { connection: ConnectionOptions; telemetry?: unknown }) => BullQueueInterface<T>
47
49
  Worker: new <T>(
48
50
  name: string,
49
51
  processor: (job: { id?: string; data: T; attemptsMade: number }) => Promise<void>,
50
- opts: { connection: ConnectionOptions; concurrency: number }
52
+ opts: {
53
+ connection: ConnectionOptions
54
+ concurrency: number
55
+ telemetry?: unknown
56
+ lockDuration?: number
57
+ maxStalledCount?: number
58
+ }
51
59
  ) => BullWorkerInterface
52
60
  }
53
61
 
62
+ /** The `bullmq-otel` package (optional). Loaded only when an OTLP backend is active. */
63
+ type BullMQOtelModule = { BullMQOtel: new (tracerName: string) => object }
64
+
54
65
  const REMOVABLE_JOB_STATES = ['waiting', 'delayed', 'prioritized', 'paused', 'waiting-children']
55
66
 
56
67
  function payloadMatchesScope(payload: unknown, scope: QueueJobScope): boolean {
@@ -69,13 +80,13 @@ function payloadMatchesScope(payload: unknown, scope: QueueJobScope): boolean {
69
80
  /**
70
81
  * Resolves Redis connection options from various sources.
71
82
  *
72
- * BullMQ expects an ioredis-compatible connection object. Preserve the full
73
- * Redis URL under the `url` key so rediss://, username, database, and query
74
- * params are not lost in translation.
83
+ * BullMQ expects ioredis connection fields rather than a nested URL string.
84
+ * Parse URL-based configuration at this boundary while keeping the public
85
+ * queue API compatible with existing `{ url }` callers.
75
86
  */
76
87
  function resolveConnection(options?: AsyncQueueOptions['connection']): ConnectionOptions {
77
88
  if (options?.url) {
78
- return { url: options.url }
89
+ return parseRedisUrl(options.url)
79
90
  }
80
91
 
81
92
  if (options?.host) {
@@ -86,10 +97,11 @@ function resolveConnection(options?: AsyncQueueOptions['connection']): Connectio
86
97
  password: options.password,
87
98
  db: options.db,
88
99
  tls: options.tls,
100
+ family: options.family,
89
101
  }
90
102
  }
91
103
 
92
- return { url: getRedisUrlOrThrow('QUEUE') }
104
+ return parseRedisUrl(getRedisUrlOrThrow('QUEUE'))
93
105
  }
94
106
 
95
107
  /**
@@ -111,11 +123,18 @@ export function createAsyncQueue<T = unknown>(
111
123
  ): Queue<T> {
112
124
  const connection = resolveConnection(options?.connection)
113
125
  const concurrency = options?.concurrency ?? 1
126
+ const attempts = options?.attempts ?? 3
127
+ const lockDuration = options?.lockDuration
128
+ const maxStalledCount = options?.maxStalledCount
114
129
  const logger = packageLogger.child({ queue: name })
115
130
 
116
131
  let bullQueue: BullQueueInterface<QueuedJob<T>> | null = null
117
132
  let bullWorker: BullWorkerInterface | null = null
118
133
  let bullmqModule: BullMQModule | null = null
134
+ // Resolved once: a BullMQOtel instance (delegate async tracing to BullMQ) or
135
+ // undefined (use our own metadata._trace carrier instead). Memoized as the
136
+ // in-flight promise so concurrent first-time callers share one resolution.
137
+ let telemetryPromise: Promise<object | undefined> | null = null
119
138
 
120
139
  // -------------------------------------------------------------------------
121
140
  // Lazy BullMQ initialization
@@ -134,10 +153,35 @@ export function createAsyncQueue<T = unknown>(
134
153
  return bullmqModule
135
154
  }
136
155
 
156
+ /**
157
+ * When an OTLP backend is active, delegate async-queue tracing to `bullmq-otel`
158
+ * (richer BullMQ-internal spans: add / process / wait / attempts). Returns
159
+ * `undefined` — meaning "use our own `metadata._trace` carrier" — when telemetry
160
+ * is off, a non-OTEL backend is selected, or `bullmq-otel` isn't installed. (The
161
+ * `local` strategy always uses our carrier; it isn't BullMQ, so `bullmq-otel`
162
+ * cannot instrument it.)
163
+ */
164
+ async function getQueueTelemetry(): Promise<object | undefined> {
165
+ if (!telemetryPromise) {
166
+ telemetryPromise = (async () => {
167
+ if (!getTelemetryRuntime()?.canUseGlobalTracePropagation()) return undefined
168
+ try {
169
+ const mod = (await import('bullmq-otel')) as unknown as BullMQOtelModule
170
+ return new mod.BullMQOtel('open-mercato')
171
+ } catch {
172
+ packageLogger.warn('bullmq-otel not available; using built-in trace carrier', { queue: name })
173
+ return undefined
174
+ }
175
+ })()
176
+ }
177
+ return telemetryPromise
178
+ }
179
+
137
180
  async function getQueue(): Promise<BullQueueInterface<QueuedJob<T>>> {
138
181
  if (!bullQueue) {
139
182
  const { Queue: BullQueueClass } = await getBullMQ()
140
- bullQueue = new BullQueueClass<QueuedJob<T>>(name, { connection })
183
+ const telemetry = await getQueueTelemetry()
184
+ bullQueue = new BullQueueClass<QueuedJob<T>>(name, { connection, ...(telemetry ? { telemetry } : {}) })
141
185
  }
142
186
  return bullQueue
143
187
  }
@@ -148,17 +192,21 @@ export function createAsyncQueue<T = unknown>(
148
192
 
149
193
  async function enqueue(data: T, options?: EnqueueOptions): Promise<string> {
150
194
  const queue = await getQueue()
195
+ // When bullmq-otel handles propagation, don't also attach our carrier.
196
+ const telemetry = await getQueueTelemetry()
197
+ const metadata = telemetry ? undefined : attachTraceMetadata(undefined)
151
198
  const jobData: QueuedJob<T> = {
152
199
  id: crypto.randomUUID(),
153
200
  payload: data,
154
201
  createdAt: new Date().toISOString(),
202
+ ...(metadata ? { metadata } : {}),
155
203
  }
156
204
 
157
205
  const job = await queue.add(jobData.id, jobData, {
158
206
  delay: options?.delayMs && options.delayMs > 0 ? options.delayMs : undefined,
159
207
  removeOnComplete: true,
160
208
  removeOnFail: 1000,
161
- attempts: 3,
209
+ attempts,
162
210
  backoff: { type: 'exponential', delay: 1000 },
163
211
  })
164
212
 
@@ -167,21 +215,33 @@ export function createAsyncQueue<T = unknown>(
167
215
 
168
216
  async function process(handler: JobHandler<T>): Promise<ProcessResult> {
169
217
  const { Worker } = await getBullMQ()
218
+ const telemetry = await getQueueTelemetry()
170
219
 
171
220
  // Create worker that processes jobs
172
221
  bullWorker = new Worker<QueuedJob<T>>(
173
222
  name,
174
223
  async (job) => {
175
224
  const jobData = job.data
176
- await handler(jobData, {
225
+ const ctx = {
177
226
  jobId: job.id ?? jobData.id,
178
227
  attemptNumber: job.attemptsMade + 1,
179
228
  queueName: name,
180
- })
229
+ }
230
+ // With bullmq-otel active, BullMQ owns the process span and active
231
+ // context (the handler's pg/undici spans nest under it). Otherwise
232
+ // continue the trace from our own carrier.
233
+ if (telemetry) {
234
+ await handler(jobData, ctx)
235
+ } else {
236
+ await runJobInTrace(name, jobData.metadata, () => handler(jobData, ctx))
237
+ }
181
238
  },
182
239
  {
183
240
  connection,
184
241
  concurrency,
242
+ ...(telemetry ? { telemetry } : {}),
243
+ ...(lockDuration !== undefined ? { lockDuration } : {}),
244
+ ...(maxStalledCount !== undefined ? { maxStalledCount } : {}),
185
245
  }
186
246
  )
187
247
 
@@ -197,6 +257,16 @@ export function createAsyncQueue<T = unknown>(
197
257
  logger.error('Job failed', { jobId: jobWithId?.id, err: error })
198
258
  })
199
259
 
260
+ // A stalled job is redelivered under the same id while the previous worker
261
+ // may still be running it, so this is the signal that a handler is about to
262
+ // be executed twice. BullMQ's docs require surfacing it: without this line
263
+ // duplicate processing is invisible.
264
+ bullWorker.on('stalled', (jobId) => {
265
+ logger.warn('Job stalled and will be redelivered — the handler may run concurrently with a previous delivery', {
266
+ jobId: typeof jobId === 'string' ? jobId : null,
267
+ })
268
+ })
269
+
200
270
  bullWorker.on('error', (err) => {
201
271
  const error = err as Error
202
272
  logger.error('Worker error', { err: error })
@@ -3,6 +3,7 @@ import path from 'node:path'
3
3
  import crypto from 'node:crypto'
4
4
  import { createLogger } from '@open-mercato/shared/lib/logger'
5
5
  import type { Queue, QueuedJob, JobHandler, LocalQueueOptions, ProcessOptions, ProcessResult, EnqueueOptions, QueueJobScope } from '../types'
6
+ import { attachTraceMetadata, runJobInTrace } from '../tracing'
6
7
 
7
8
  const packageLogger = createLogger('queue')
8
9
 
@@ -49,9 +50,18 @@ const fsp = fs.promises
49
50
  * - Jobs are processed sequentially (concurrency option is for logging/compatibility only)
50
51
  * - Not suitable for production or multi-process environments
51
52
  *
52
- * Failed jobs are retried up to `DEFAULT_MAX_ATTEMPTS` times with exponential
53
- * backoff and moved to a dead-letter store once attempts are exhausted (see the
54
- * retry handling in `process()` below).
53
+ * Failed jobs are retried up to `DEFAULT_MAX_ATTEMPTS` times with exponential backoff.
54
+ * **This strategy keeps no failed-job store**: once attempts are exhausted the job is
55
+ * removed from `queue.json` and only counted in `state.failedCount`, so the payload is
56
+ * lost and the failure survives solely as an error log line. The `async` strategy keeps
57
+ * only a bounded inspection window: `removeOnFail: 1000` retains the most recent 1000
58
+ * failures and removes older ones as later failures arrive. Workflows that require
59
+ * no-loss persistence must write their own durable record before enqueueing, regardless
60
+ * of strategy.
61
+ *
62
+ * `DEFAULT_MAX_ATTEMPTS` is a module constant, not a per-job option — callers cannot
63
+ * request a different attempt count. (`async` likewise hard-codes `attempts: 3`.)
64
+ * See the retry handling in `process()` below.
55
65
  *
56
66
  * All file I/O is asynchronous (`fs.promises.*`) so queue operations do not
57
67
  * block the Node.js event loop. A per-queue promise chain serializes
@@ -196,11 +206,13 @@ export function createLocalQueue<T = unknown>(
196
206
  const availableAt = options?.delayMs && options.delayMs > 0
197
207
  ? new Date(Date.now() + options.delayMs).toISOString()
198
208
  : undefined
209
+ const metadata = attachTraceMetadata(undefined)
199
210
  const job: StoredJob<T> = {
200
211
  id: generateId(),
201
212
  payload: data,
202
213
  createdAt: new Date().toISOString(),
203
214
  ...(availableAt ? { availableAt } : {}),
215
+ ...(metadata ? { metadata } : {}),
204
216
  }
205
217
  await withFileLock(async () => {
206
218
  const jobs = await readQueue()
@@ -246,12 +258,14 @@ export function createLocalQueue<T = unknown>(
246
258
  for (const job of jobsToProcess) {
247
259
  const attemptNumber = (job.attemptCount ?? 0) + 1
248
260
  try {
249
- await Promise.resolve(
250
- handler(job, {
251
- jobId: job.id,
252
- attemptNumber,
253
- queueName: name,
254
- })
261
+ await runJobInTrace(name, job.metadata, () =>
262
+ Promise.resolve(
263
+ handler(job, {
264
+ jobId: job.id,
265
+ attemptNumber,
266
+ queueName: name,
267
+ })
268
+ )
255
269
  )
256
270
  processed++
257
271
  lastJobId = job.id
@@ -262,7 +276,7 @@ export function createLocalQueue<T = unknown>(
262
276
  failed++
263
277
  lastJobId = job.id
264
278
  if (attemptNumber >= DEFAULT_MAX_ATTEMPTS) {
265
- logger.error('Job exhausted all attempts, moving to dead letter', { jobId: job.id, maxAttempts: DEFAULT_MAX_ATTEMPTS })
279
+ logger.error('Job exhausted all attempts; dropping it (no dead-letter store)', { jobId: job.id, maxAttempts: DEFAULT_MAX_ATTEMPTS })
266
280
  deadJobIds.add(job.id)
267
281
  } else {
268
282
  const backoffMs = RETRY_BACKOFF_BASE_MS * Math.pow(2, attemptNumber - 1)
package/src/tracing.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
2
+
3
+ /**
4
+ * Distributed-trace propagation across the enqueue → worker boundary.
5
+ *
6
+ * The W3C trace carrier rides on the job's `metadata._trace` (a first-class
7
+ * metadata channel, not the user payload). Both halves are automatic — the
8
+ * strategies call `attachTraceMetadata` at enqueue and `runJobInTrace` at
9
+ * dispatch — so a worker joins the enqueuing request's trace with no per-worker
10
+ * code. Everything here is a cheap no-op when telemetry is off.
11
+ *
12
+ * This also covers anything that rides the queue: persistent event subscribers
13
+ * (the event bus enqueues) and outbound webhook delivery (queued) become part of
14
+ * the originating request's trace for free.
15
+ */
16
+ const TRACE_META_KEY = '_trace'
17
+
18
+ /**
19
+ * Attach the active trace context to a job's metadata. Returns `metadata`
20
+ * unchanged when telemetry is off (no active span → empty carrier), so jobs stay
21
+ * clean unless tracing is active.
22
+ */
23
+ export function attachTraceMetadata(
24
+ metadata: Record<string, unknown> | undefined,
25
+ ): Record<string, unknown> | undefined {
26
+ const carrier = getTelemetryRuntime()?.captureTraceContext() ?? {}
27
+ if (Object.keys(carrier).length === 0) return metadata
28
+ return { ...(metadata ?? {}), [TRACE_META_KEY]: carrier }
29
+ }
30
+
31
+ /**
32
+ * Run a job handler inside a span (`queue.<queueName>`) that continues the
33
+ * producer's trace from the carrier on `metadata`. With no carrier (or telemetry
34
+ * off) it runs `fn` under a fresh root span — and a no-op when off. The span
35
+ * ends when `fn` settles (sync or async).
36
+ */
37
+ export function runJobInTrace<T>(
38
+ queueName: string,
39
+ metadata: Record<string, unknown> | undefined,
40
+ fn: () => T,
41
+ ): T {
42
+ const runtime = getTelemetryRuntime()
43
+ if (!runtime) return fn()
44
+ return runtime.continueTrace(
45
+ readTraceCarrier(metadata),
46
+ `queue.${queueName}`,
47
+ fn,
48
+ { kind: 'consumer' },
49
+ )
50
+ }
51
+
52
+ function readTraceCarrier(
53
+ metadata: Record<string, unknown> | undefined,
54
+ ): Record<string, string> | undefined {
55
+ const raw = metadata?.[TRACE_META_KEY]
56
+ if (!raw || typeof raw !== 'object') return undefined
57
+ return raw as Record<string, string>
58
+ }
package/src/types.ts CHANGED
@@ -81,6 +81,8 @@ export type RedisConnectionOptions = {
81
81
  db?: number
82
82
  /** TLS configuration for rediss / encrypted Redis */
83
83
  tls?: Record<string, unknown>
84
+ /** IP family used by Redis DNS resolution */
85
+ family?: number
84
86
  }
85
87
 
86
88
  /**
@@ -91,6 +93,12 @@ export type AsyncQueueOptions = {
91
93
  connection?: RedisConnectionOptions
92
94
  /** Number of concurrent job processors. Defaults to 1 */
93
95
  concurrency?: number
96
+ /** Number of attempts for newly enqueued jobs. Defaults to 3. */
97
+ attempts?: number
98
+ /** How long a job lock is held before the job counts as stalled, in ms. Defaults to 30000. */
99
+ lockDuration?: number
100
+ /** Number of stalled-job recoveries BullMQ permits before failing a job. Defaults to 1. */
101
+ maxStalledCount?: number
94
102
  }
95
103
 
96
104
  /**
@@ -250,6 +258,10 @@ export type WorkerMeta = {
250
258
  id?: string
251
259
  /** Worker concurrency (default: 1) */
252
260
  concurrency?: number
261
+ /** How long a job lock is held before the job counts as stalled, in ms. */
262
+ lockDuration?: number
263
+ /** Number of stalled-job recoveries BullMQ permits before failing a job. */
264
+ maxStalledCount?: number
253
265
  }
254
266
 
255
267
  /**
@@ -265,4 +277,8 @@ export type WorkerDescriptor<T = unknown> = {
265
277
  handler: JobHandler<T>
266
278
  /** Concurrency level */
267
279
  concurrency: number
280
+ /** How long a job lock is held before the job counts as stalled, in ms. */
281
+ lockDuration?: number
282
+ /** Number of stalled-job recoveries BullMQ permits before failing a job. */
283
+ maxStalledCount?: number
268
284
  }
@@ -1,5 +1,9 @@
1
1
  import { createQueue } from '../factory'
2
2
  import { createLogger } from '@open-mercato/shared/lib/logger'
3
+ import {
4
+ getTelemetryRuntime,
5
+ isTelemetryBackendEnabled,
6
+ } from '@open-mercato/shared/lib/telemetry/runtime'
3
7
  import type { Queue, JobHandler, AsyncQueueOptions, QueueStrategyType } from '../types'
4
8
 
5
9
  const logger = createLogger('queue').child({ component: 'worker' })
@@ -16,6 +20,10 @@ export type WorkerRunnerOptions<T = unknown> = {
16
20
  connection?: AsyncQueueOptions['connection']
17
21
  /** Number of concurrent jobs to process */
18
22
  concurrency?: number
23
+ /** How long a job lock is held before the job counts as stalled, in ms. */
24
+ lockDuration?: number
25
+ /** Number of stalled-job recoveries BullMQ permits before failing a job. */
26
+ maxStalledCount?: number
19
27
  /** Whether to set up graceful shutdown handlers */
20
28
  gracefulShutdown?: boolean
21
29
  /** If true, don't block - return immediately after starting processing (for multi-queue mode) */
@@ -67,6 +75,17 @@ function registerShutdownHandlers(): void {
67
75
  unregisterShutdownHandlers(sigtermHandler, sigintHandler)
68
76
  shutdownInProgress = false
69
77
 
78
+ // Flush buffered spans/logs before the process dies. A worker never returns
79
+ // from run(), so bin.ts's post-run shutdownTelemetry() is unreachable on this
80
+ // path — without this, the BatchSpanProcessor's ~5s tail is dropped on every
81
+ // restart/redeploy. Idempotent and a no-op when telemetry is off; a flush
82
+ // failure must not turn a clean shutdown into a failed one.
83
+ try {
84
+ await getTelemetryRuntime()?.shutdown()
85
+ } catch (error) {
86
+ logger.error('Error flushing telemetry during shutdown', { err: error })
87
+ }
88
+
70
89
  if (!hasError) {
71
90
  logger.info('Worker closed successfully')
72
91
  }
@@ -130,11 +149,22 @@ export async function runWorker<T = unknown>(
130
149
  handler,
131
150
  connection,
132
151
  concurrency = 1,
152
+ lockDuration,
153
+ maxStalledCount,
133
154
  gracefulShutdown = true,
134
155
  background = false,
135
156
  strategy: strategyOption,
136
157
  } = options
137
158
 
159
+ // Worker processes don't run Next's instrumentation hook, so initialize
160
+ // telemetry here — this is the single bootstrap every standalone worker passes
161
+ // through. Import the telemetry package only for an explicit enabled backend;
162
+ // with the default/unset backend the worker never evaluates the package.
163
+ if (!getTelemetryRuntime() && isTelemetryBackendEnabled()) {
164
+ const { initTelemetry } = await import('@open-mercato/telemetry')
165
+ await initTelemetry()
166
+ }
167
+
138
168
  // Determine queue strategy from option, env var, or default to 'local'
139
169
  const strategy: QueueStrategyType = strategyOption
140
170
  ?? (process.env.QUEUE_STRATEGY === 'async' ? 'async' : 'local')
@@ -144,6 +174,8 @@ export async function runWorker<T = unknown>(
144
174
  const queue = createQueue<T>(queueName, strategy, {
145
175
  connection,
146
176
  concurrency,
177
+ lockDuration,
178
+ maxStalledCount,
147
179
  })
148
180
 
149
181
  // Set up graceful shutdown