@open-mercato/queue 0.6.7 → 0.6.8-develop.6874.1.982d6097d8
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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/factory.js +4 -1
- package/dist/factory.js.map +2 -2
- package/dist/pending-probe.js +4 -2
- package/dist/pending-probe.js.map +2 -2
- package/dist/strategies/async.js +50 -10
- package/dist/strategies/async.js.map +2 -2
- package/dist/strategies/local.js +15 -8
- package/dist/strategies/local.js.map +2 -2
- package/dist/tracing.js +27 -0
- package/dist/tracing.js.map +7 -0
- package/dist/worker/runner.js +18 -1
- package/dist/worker/runner.js.map +2 -2
- package/jest.config.cjs +1 -0
- package/package.json +12 -6
- package/src/__tests__/async.strategy.test.ts +62 -5
- package/src/__tests__/async.telemetry.test.ts +123 -0
- package/src/__tests__/factory.test.ts +5 -1
- package/src/__tests__/pending-probe.test.ts +40 -1
- package/src/__tests__/tracing.test.ts +123 -0
- package/src/__tests__/worker-shutdown-telemetry.test.ts +80 -0
- package/src/factory.ts +4 -1
- package/src/pending-probe.ts +4 -2
- package/src/strategies/async.ts +83 -13
- package/src/strategies/local.ts +24 -10
- package/src/tracing.ts +58 -0
- package/src/types.ts +16 -0
- package/src/worker/runner.ts +32 -0
package/src/pending-probe.ts
CHANGED
|
@@ -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 =
|
|
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
|
package/src/strategies/async.ts
CHANGED
|
@@ -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: {
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 })
|
package/src/strategies/local.ts
CHANGED
|
@@ -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
|
-
*
|
|
54
|
-
*
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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
|
}
|
package/src/worker/runner.ts
CHANGED
|
@@ -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
|