@open-mercato/queue 0.6.7-develop.6862.1.c11a64ce0a → 0.6.7
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 +1 -4
- package/dist/factory.js.map +2 -2
- package/dist/pending-probe.js +2 -4
- package/dist/pending-probe.js.map +2 -2
- package/dist/strategies/async.js +10 -50
- package/dist/strategies/async.js.map +2 -2
- package/dist/strategies/local.js +8 -15
- package/dist/strategies/local.js.map +2 -2
- package/dist/worker/runner.js +1 -18
- package/dist/worker/runner.js.map +2 -2
- package/jest.config.cjs +0 -1
- package/package.json +6 -12
- package/src/__tests__/async.strategy.test.ts +5 -62
- package/src/__tests__/factory.test.ts +1 -5
- package/src/__tests__/pending-probe.test.ts +1 -40
- package/src/factory.ts +1 -4
- package/src/pending-probe.ts +2 -4
- package/src/strategies/async.ts +13 -83
- package/src/strategies/local.ts +10 -24
- package/src/types.ts +0 -16
- package/src/worker/runner.ts +0 -32
- package/dist/tracing.js +0 -27
- package/dist/tracing.js.map +0 -7
- package/src/__tests__/async.telemetry.test.ts +0 -123
- package/src/__tests__/tracing.test.ts +0 -123
- package/src/__tests__/worker-shutdown-telemetry.test.ts +0 -80
- package/src/tracing.ts +0 -58
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/queue",
|
|
3
|
-
"version": "0.6.7
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Multi-strategy job queue with local and BullMQ support",
|
|
6
6
|
"type": "module",
|
|
@@ -34,35 +34,29 @@
|
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"bullmq": "^5.0.0
|
|
38
|
-
"bullmq-otel": "^1.3.0"
|
|
37
|
+
"bullmq": "^5.0.0"
|
|
39
38
|
},
|
|
40
39
|
"peerDependenciesMeta": {
|
|
41
40
|
"bullmq": {
|
|
42
41
|
"optional": true
|
|
43
|
-
},
|
|
44
|
-
"bullmq-otel": {
|
|
45
|
-
"optional": true
|
|
46
42
|
}
|
|
47
43
|
},
|
|
48
44
|
"devDependencies": {
|
|
49
45
|
"@types/jest": "^30.0.0",
|
|
50
|
-
"@types/node": "^26.1
|
|
46
|
+
"@types/node": "^26.0.1",
|
|
51
47
|
"jest": "^30.4.2",
|
|
52
|
-
"ts-jest": "^29.4.
|
|
48
|
+
"ts-jest": "^29.4.11",
|
|
53
49
|
"typescript": "7.0.2"
|
|
54
50
|
},
|
|
55
51
|
"publishConfig": {
|
|
56
52
|
"access": "public"
|
|
57
53
|
},
|
|
58
54
|
"dependencies": {
|
|
59
|
-
"@open-mercato/shared": "0.6.7
|
|
60
|
-
"@open-mercato/telemetry": "0.6.7-develop.6862.1.c11a64ce0a"
|
|
55
|
+
"@open-mercato/shared": "0.6.7"
|
|
61
56
|
},
|
|
62
57
|
"repository": {
|
|
63
58
|
"type": "git",
|
|
64
59
|
"url": "https://github.com/open-mercato/open-mercato",
|
|
65
60
|
"directory": "packages/queue"
|
|
66
|
-
}
|
|
67
|
-
"stableVersion": "0.6.6"
|
|
61
|
+
}
|
|
68
62
|
}
|
|
@@ -18,7 +18,6 @@ const workerOn = jest.fn()
|
|
|
18
18
|
|
|
19
19
|
jest.mock('@open-mercato/shared/lib/redis/connection', () => ({
|
|
20
20
|
getRedisUrlOrThrow: jest.fn(),
|
|
21
|
-
parseRedisUrl: jest.requireActual('@open-mercato/shared/lib/redis/connection').parseRedisUrl,
|
|
22
21
|
}))
|
|
23
22
|
|
|
24
23
|
jest.mock('bullmq', () => {
|
|
@@ -61,7 +60,7 @@ describe('Queue - async strategy', () => {
|
|
|
61
60
|
getRedisUrlOrThrowMock.mockReturnValue('rediss://default:secret@example.com:6380/1')
|
|
62
61
|
})
|
|
63
62
|
|
|
64
|
-
it('passes
|
|
63
|
+
it('passes the full Redis URL to BullMQ when using env-based async config', async () => {
|
|
65
64
|
const queue = createQueue<{ value: number }>('test-queue', 'async', {
|
|
66
65
|
concurrency: 3,
|
|
67
66
|
})
|
|
@@ -70,35 +69,19 @@ describe('Queue - async strategy', () => {
|
|
|
70
69
|
await queue.process(async () => {})
|
|
71
70
|
|
|
72
71
|
expect(queueCtor).toHaveBeenCalledWith('test-queue', {
|
|
73
|
-
connection: {
|
|
74
|
-
host: 'example.com',
|
|
75
|
-
port: 6380,
|
|
76
|
-
username: 'default',
|
|
77
|
-
password: 'secret',
|
|
78
|
-
db: 1,
|
|
79
|
-
tls: {},
|
|
80
|
-
family: undefined,
|
|
81
|
-
},
|
|
72
|
+
connection: { url: 'rediss://default:secret@example.com:6380/1' },
|
|
82
73
|
})
|
|
83
74
|
expect(workerCtor).toHaveBeenCalledWith(
|
|
84
75
|
'test-queue',
|
|
85
76
|
expect.any(Function),
|
|
86
77
|
{
|
|
87
|
-
connection: {
|
|
88
|
-
host: 'example.com',
|
|
89
|
-
port: 6380,
|
|
90
|
-
username: 'default',
|
|
91
|
-
password: 'secret',
|
|
92
|
-
db: 1,
|
|
93
|
-
tls: {},
|
|
94
|
-
family: undefined,
|
|
95
|
-
},
|
|
78
|
+
connection: { url: 'rediss://default:secret@example.com:6380/1' },
|
|
96
79
|
concurrency: 3,
|
|
97
80
|
},
|
|
98
81
|
)
|
|
99
82
|
})
|
|
100
83
|
|
|
101
|
-
it('preserves
|
|
84
|
+
it('preserves an explicit Redis URL without converting it to host/port fields', async () => {
|
|
102
85
|
const queue = createQueue<{ value: number }>('test-queue', 'async', {
|
|
103
86
|
connection: {
|
|
104
87
|
url: 'rediss://user:secret@example.com:6380/4?family=6',
|
|
@@ -108,15 +91,7 @@ describe('Queue - async strategy', () => {
|
|
|
108
91
|
await queue.enqueue({ value: 42 })
|
|
109
92
|
|
|
110
93
|
expect(queueCtor).toHaveBeenCalledWith('test-queue', {
|
|
111
|
-
connection: {
|
|
112
|
-
host: 'example.com',
|
|
113
|
-
port: 6380,
|
|
114
|
-
username: 'user',
|
|
115
|
-
password: 'secret',
|
|
116
|
-
db: 4,
|
|
117
|
-
tls: {},
|
|
118
|
-
family: 6,
|
|
119
|
-
},
|
|
94
|
+
connection: { url: 'rediss://user:secret@example.com:6380/4?family=6' },
|
|
120
95
|
})
|
|
121
96
|
})
|
|
122
97
|
|
|
@@ -139,38 +114,6 @@ describe('Queue - async strategy', () => {
|
|
|
139
114
|
await queue.close()
|
|
140
115
|
})
|
|
141
116
|
|
|
142
|
-
it('threads queue retry, lock-duration and stalled-job options to BullMQ', async () => {
|
|
143
|
-
const queue = createQueue<{ value: number }>('test-queue', 'async', {
|
|
144
|
-
attempts: 5,
|
|
145
|
-
lockDuration: 120_000,
|
|
146
|
-
maxStalledCount: 10,
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
await queue.enqueue({ value: 42 })
|
|
150
|
-
await queue.process(async () => {})
|
|
151
|
-
|
|
152
|
-
expect(queueAdd).toHaveBeenCalledWith(
|
|
153
|
-
expect.any(String),
|
|
154
|
-
expect.objectContaining({ payload: { value: 42 } }),
|
|
155
|
-
expect.objectContaining({ attempts: 5 }),
|
|
156
|
-
)
|
|
157
|
-
expect(workerCtor).toHaveBeenCalledWith(
|
|
158
|
-
'test-queue',
|
|
159
|
-
expect.any(Function),
|
|
160
|
-
expect.objectContaining({ lockDuration: 120_000, maxStalledCount: 10 }),
|
|
161
|
-
)
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
it('leaves BullMQ on its own lock and stall defaults when the options are unset', async () => {
|
|
165
|
-
const queue = createQueue<{ value: number }>('test-queue', 'async', {})
|
|
166
|
-
|
|
167
|
-
await queue.process(async () => {})
|
|
168
|
-
|
|
169
|
-
const workerOptions = workerCtor.mock.calls[0]?.[2] as Record<string, unknown>
|
|
170
|
-
expect(workerOptions).not.toHaveProperty('lockDuration')
|
|
171
|
-
expect(workerOptions).not.toHaveProperty('maxStalledCount')
|
|
172
|
-
})
|
|
173
|
-
|
|
174
117
|
it('removeQueuedJobsByScope removes only queued jobs matching tenant scope', async () => {
|
|
175
118
|
const removeMatching = jest.fn(async () => {})
|
|
176
119
|
const removeAutoIndex = jest.fn(async () => {})
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { resolveQueueStrategy, createModuleQueue } from '../factory'
|
|
2
|
-
import { getRedisUrlOrThrow
|
|
2
|
+
import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
|
|
3
3
|
|
|
4
4
|
jest.mock('@open-mercato/shared/lib/redis/connection', () => ({
|
|
5
5
|
getRedisUrlOrThrow: jest.fn(),
|
|
6
|
-
parseRedisUrl: jest.fn(),
|
|
7
6
|
}))
|
|
8
7
|
|
|
9
8
|
jest.mock('bullmq', () => {
|
|
@@ -59,12 +58,10 @@ describe('resolveQueueStrategy', () => {
|
|
|
59
58
|
describe('createModuleQueue', () => {
|
|
60
59
|
const originalEnv = process.env.QUEUE_STRATEGY
|
|
61
60
|
const getRedisUrlOrThrowMock = getRedisUrlOrThrow as jest.MockedFunction<typeof getRedisUrlOrThrow>
|
|
62
|
-
const parseRedisUrlMock = parseRedisUrl as jest.MockedFunction<typeof parseRedisUrl>
|
|
63
61
|
|
|
64
62
|
beforeEach(() => {
|
|
65
63
|
jest.clearAllMocks()
|
|
66
64
|
getRedisUrlOrThrowMock.mockReturnValue('redis://localhost:6379')
|
|
67
|
-
parseRedisUrlMock.mockReturnValue({ host: 'localhost', port: 6379 })
|
|
68
65
|
})
|
|
69
66
|
|
|
70
67
|
afterEach(() => {
|
|
@@ -88,7 +85,6 @@ describe('createModuleQueue', () => {
|
|
|
88
85
|
expect(queue.strategy).toBe('async')
|
|
89
86
|
expect(queue.name).toBe('test-queue')
|
|
90
87
|
expect(getRedisUrlOrThrowMock).toHaveBeenCalledWith('QUEUE')
|
|
91
|
-
expect(parseRedisUrlMock).toHaveBeenCalledWith('redis://localhost:6379')
|
|
92
88
|
})
|
|
93
89
|
|
|
94
90
|
it('passes concurrency to local strategy', () => {
|
|
@@ -2,22 +2,7 @@ import fs from 'node:fs'
|
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { createQueue } from '../factory'
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
const asyncQueueConstructor = jest.fn()
|
|
8
|
-
const asyncQueueClose = jest.fn(async () => {})
|
|
9
|
-
const asyncQueueGetJobCounts = jest.fn(async () => ({ waiting: 1, delayed: 0, active: 0 }))
|
|
10
|
-
|
|
11
|
-
jest.mock('bullmq', () => ({
|
|
12
|
-
Queue: class MockQueue {
|
|
13
|
-
constructor(name: string, options: unknown) {
|
|
14
|
-
asyncQueueConstructor(name, options)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
getJobCounts = asyncQueueGetJobCounts
|
|
18
|
-
close = asyncQueueClose
|
|
19
|
-
},
|
|
20
|
-
}))
|
|
5
|
+
import { getQueuePendingProbe } from '../pending-probe'
|
|
21
6
|
|
|
22
7
|
describe('getQueuePendingProbe — local strategy', () => {
|
|
23
8
|
const origCwd = process.cwd()
|
|
@@ -110,11 +95,6 @@ describe('getQueuePendingProbe — local strategy', () => {
|
|
|
110
95
|
})
|
|
111
96
|
|
|
112
97
|
describe('getQueuePendingProbe — async strategy', () => {
|
|
113
|
-
beforeEach(() => {
|
|
114
|
-
jest.clearAllMocks()
|
|
115
|
-
__resetPendingProbeBullMQCache()
|
|
116
|
-
})
|
|
117
|
-
|
|
118
98
|
it('reports an error when QUEUE Redis URL is unset and no connection override is provided', async () => {
|
|
119
99
|
const original = process.env.QUEUE_REDIS_URL
|
|
120
100
|
const fallback = process.env.REDIS_URL
|
|
@@ -129,23 +109,4 @@ describe('getQueuePendingProbe — async strategy', () => {
|
|
|
129
109
|
if (fallback !== undefined) process.env.REDIS_URL = fallback
|
|
130
110
|
}
|
|
131
111
|
})
|
|
132
|
-
|
|
133
|
-
it('converts a URL override to BullMQ connection fields', async () => {
|
|
134
|
-
const probe = await getQueuePendingProbe('async-probe', 'async', {
|
|
135
|
-
connection: { url: 'rediss://probe:secret@example.com:6380/3?family=6' },
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
expect(probe).toEqual(expect.objectContaining({ error: false, ready: 1 }))
|
|
139
|
-
expect(asyncQueueConstructor).toHaveBeenCalledWith('async-probe', {
|
|
140
|
-
connection: {
|
|
141
|
-
host: 'example.com',
|
|
142
|
-
port: 6380,
|
|
143
|
-
username: 'probe',
|
|
144
|
-
password: 'secret',
|
|
145
|
-
db: 3,
|
|
146
|
-
tls: {},
|
|
147
|
-
family: 6,
|
|
148
|
-
},
|
|
149
|
-
})
|
|
150
|
-
})
|
|
151
112
|
})
|
package/src/factory.ts
CHANGED
|
@@ -82,16 +82,13 @@ export function resolveQueueStrategy(): QueueStrategyType {
|
|
|
82
82
|
*/
|
|
83
83
|
export function createModuleQueue<T = unknown>(
|
|
84
84
|
name: string,
|
|
85
|
-
options?:
|
|
85
|
+
options?: { concurrency?: number },
|
|
86
86
|
): Queue<T> {
|
|
87
87
|
const strategy = resolveQueueStrategy()
|
|
88
88
|
if (strategy === 'async') {
|
|
89
89
|
return createAsyncQueue<T>(name, {
|
|
90
90
|
connection: { url: getRedisUrlOrThrow('QUEUE') },
|
|
91
91
|
concurrency: options?.concurrency,
|
|
92
|
-
attempts: options?.attempts,
|
|
93
|
-
lockDuration: options?.lockDuration,
|
|
94
|
-
maxStalledCount: options?.maxStalledCount,
|
|
95
92
|
})
|
|
96
93
|
}
|
|
97
94
|
return createLocalQueue<T>(name, { concurrency: options?.concurrency })
|
package/src/pending-probe.ts
CHANGED
|
@@ -149,16 +149,14 @@ async function probeAsyncQueue(
|
|
|
149
149
|
return errorResult(queueName, 'async', new Error('bullmq is not installed'))
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
const { getRedisUrl
|
|
152
|
+
const { getRedisUrl } = 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 =
|
|
160
|
-
} else if (connection.url) {
|
|
161
|
-
connection = parseRedisUrl(connection.url)
|
|
159
|
+
connection = { url }
|
|
162
160
|
}
|
|
163
161
|
|
|
164
162
|
let queue: InstanceType<BullMQModuleShape['Queue']> | null = null
|
package/src/strategies/async.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { Queue, QueuedJob, JobHandler, AsyncQueueOptions, ProcessResult, EnqueueOptions, QueueJobScope } from '../types'
|
|
2
|
-
import { getRedisUrlOrThrow
|
|
3
|
-
import { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'
|
|
4
|
-
import { attachTraceMetadata, runJobInTrace } from '../tracing'
|
|
2
|
+
import { getRedisUrlOrThrow } from '@open-mercato/shared/lib/redis/connection'
|
|
5
3
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
4
|
|
|
7
5
|
const packageLogger = createLogger('queue')
|
|
@@ -9,13 +7,13 @@ const packageLogger = createLogger('queue')
|
|
|
9
7
|
// BullMQ interface types - we define the shape we use to maintain type safety
|
|
10
8
|
// while keeping bullmq as an optional peer dependency
|
|
11
9
|
type ConnectionOptions = {
|
|
10
|
+
url?: string
|
|
12
11
|
host?: string
|
|
13
12
|
port?: number
|
|
14
13
|
username?: string
|
|
15
14
|
password?: string
|
|
16
15
|
db?: number
|
|
17
16
|
tls?: Record<string, unknown>
|
|
18
|
-
family?: number
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
interface BullQueueInterface<T> {
|
|
@@ -45,23 +43,14 @@ interface BullWorkerInterface {
|
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
interface BullMQModule {
|
|
48
|
-
Queue: new <T>(name: string, opts: { connection: ConnectionOptions
|
|
46
|
+
Queue: new <T>(name: string, opts: { connection: ConnectionOptions }) => BullQueueInterface<T>
|
|
49
47
|
Worker: new <T>(
|
|
50
48
|
name: string,
|
|
51
49
|
processor: (job: { id?: string; data: T; attemptsMade: number }) => Promise<void>,
|
|
52
|
-
opts: {
|
|
53
|
-
connection: ConnectionOptions
|
|
54
|
-
concurrency: number
|
|
55
|
-
telemetry?: unknown
|
|
56
|
-
lockDuration?: number
|
|
57
|
-
maxStalledCount?: number
|
|
58
|
-
}
|
|
50
|
+
opts: { connection: ConnectionOptions; concurrency: number }
|
|
59
51
|
) => BullWorkerInterface
|
|
60
52
|
}
|
|
61
53
|
|
|
62
|
-
/** The `bullmq-otel` package (optional). Loaded only when an OTLP backend is active. */
|
|
63
|
-
type BullMQOtelModule = { BullMQOtel: new (tracerName: string) => object }
|
|
64
|
-
|
|
65
54
|
const REMOVABLE_JOB_STATES = ['waiting', 'delayed', 'prioritized', 'paused', 'waiting-children']
|
|
66
55
|
|
|
67
56
|
function payloadMatchesScope(payload: unknown, scope: QueueJobScope): boolean {
|
|
@@ -80,13 +69,13 @@ function payloadMatchesScope(payload: unknown, scope: QueueJobScope): boolean {
|
|
|
80
69
|
/**
|
|
81
70
|
* Resolves Redis connection options from various sources.
|
|
82
71
|
*
|
|
83
|
-
* BullMQ expects ioredis connection
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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.
|
|
86
75
|
*/
|
|
87
76
|
function resolveConnection(options?: AsyncQueueOptions['connection']): ConnectionOptions {
|
|
88
77
|
if (options?.url) {
|
|
89
|
-
return
|
|
78
|
+
return { url: options.url }
|
|
90
79
|
}
|
|
91
80
|
|
|
92
81
|
if (options?.host) {
|
|
@@ -97,11 +86,10 @@ function resolveConnection(options?: AsyncQueueOptions['connection']): Connectio
|
|
|
97
86
|
password: options.password,
|
|
98
87
|
db: options.db,
|
|
99
88
|
tls: options.tls,
|
|
100
|
-
family: options.family,
|
|
101
89
|
}
|
|
102
90
|
}
|
|
103
91
|
|
|
104
|
-
return
|
|
92
|
+
return { url: getRedisUrlOrThrow('QUEUE') }
|
|
105
93
|
}
|
|
106
94
|
|
|
107
95
|
/**
|
|
@@ -123,18 +111,11 @@ export function createAsyncQueue<T = unknown>(
|
|
|
123
111
|
): Queue<T> {
|
|
124
112
|
const connection = resolveConnection(options?.connection)
|
|
125
113
|
const concurrency = options?.concurrency ?? 1
|
|
126
|
-
const attempts = options?.attempts ?? 3
|
|
127
|
-
const lockDuration = options?.lockDuration
|
|
128
|
-
const maxStalledCount = options?.maxStalledCount
|
|
129
114
|
const logger = packageLogger.child({ queue: name })
|
|
130
115
|
|
|
131
116
|
let bullQueue: BullQueueInterface<QueuedJob<T>> | null = null
|
|
132
117
|
let bullWorker: BullWorkerInterface | null = null
|
|
133
118
|
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
|
|
138
119
|
|
|
139
120
|
// -------------------------------------------------------------------------
|
|
140
121
|
// Lazy BullMQ initialization
|
|
@@ -153,35 +134,10 @@ export function createAsyncQueue<T = unknown>(
|
|
|
153
134
|
return bullmqModule
|
|
154
135
|
}
|
|
155
136
|
|
|
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
|
-
|
|
180
137
|
async function getQueue(): Promise<BullQueueInterface<QueuedJob<T>>> {
|
|
181
138
|
if (!bullQueue) {
|
|
182
139
|
const { Queue: BullQueueClass } = await getBullMQ()
|
|
183
|
-
|
|
184
|
-
bullQueue = new BullQueueClass<QueuedJob<T>>(name, { connection, ...(telemetry ? { telemetry } : {}) })
|
|
140
|
+
bullQueue = new BullQueueClass<QueuedJob<T>>(name, { connection })
|
|
185
141
|
}
|
|
186
142
|
return bullQueue
|
|
187
143
|
}
|
|
@@ -192,21 +148,17 @@ export function createAsyncQueue<T = unknown>(
|
|
|
192
148
|
|
|
193
149
|
async function enqueue(data: T, options?: EnqueueOptions): Promise<string> {
|
|
194
150
|
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)
|
|
198
151
|
const jobData: QueuedJob<T> = {
|
|
199
152
|
id: crypto.randomUUID(),
|
|
200
153
|
payload: data,
|
|
201
154
|
createdAt: new Date().toISOString(),
|
|
202
|
-
...(metadata ? { metadata } : {}),
|
|
203
155
|
}
|
|
204
156
|
|
|
205
157
|
const job = await queue.add(jobData.id, jobData, {
|
|
206
158
|
delay: options?.delayMs && options.delayMs > 0 ? options.delayMs : undefined,
|
|
207
159
|
removeOnComplete: true,
|
|
208
160
|
removeOnFail: 1000,
|
|
209
|
-
attempts,
|
|
161
|
+
attempts: 3,
|
|
210
162
|
backoff: { type: 'exponential', delay: 1000 },
|
|
211
163
|
})
|
|
212
164
|
|
|
@@ -215,33 +167,21 @@ export function createAsyncQueue<T = unknown>(
|
|
|
215
167
|
|
|
216
168
|
async function process(handler: JobHandler<T>): Promise<ProcessResult> {
|
|
217
169
|
const { Worker } = await getBullMQ()
|
|
218
|
-
const telemetry = await getQueueTelemetry()
|
|
219
170
|
|
|
220
171
|
// Create worker that processes jobs
|
|
221
172
|
bullWorker = new Worker<QueuedJob<T>>(
|
|
222
173
|
name,
|
|
223
174
|
async (job) => {
|
|
224
175
|
const jobData = job.data
|
|
225
|
-
|
|
176
|
+
await handler(jobData, {
|
|
226
177
|
jobId: job.id ?? jobData.id,
|
|
227
178
|
attemptNumber: job.attemptsMade + 1,
|
|
228
179
|
queueName: name,
|
|
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
|
-
}
|
|
180
|
+
})
|
|
238
181
|
},
|
|
239
182
|
{
|
|
240
183
|
connection,
|
|
241
184
|
concurrency,
|
|
242
|
-
...(telemetry ? { telemetry } : {}),
|
|
243
|
-
...(lockDuration !== undefined ? { lockDuration } : {}),
|
|
244
|
-
...(maxStalledCount !== undefined ? { maxStalledCount } : {}),
|
|
245
185
|
}
|
|
246
186
|
)
|
|
247
187
|
|
|
@@ -257,16 +197,6 @@ export function createAsyncQueue<T = unknown>(
|
|
|
257
197
|
logger.error('Job failed', { jobId: jobWithId?.id, err: error })
|
|
258
198
|
})
|
|
259
199
|
|
|
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
|
-
|
|
270
200
|
bullWorker.on('error', (err) => {
|
|
271
201
|
const error = err as Error
|
|
272
202
|
logger.error('Worker error', { err: error })
|
package/src/strategies/local.ts
CHANGED
|
@@ -3,7 +3,6 @@ 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'
|
|
7
6
|
|
|
8
7
|
const packageLogger = createLogger('queue')
|
|
9
8
|
|
|
@@ -50,18 +49,9 @@ const fsp = fs.promises
|
|
|
50
49
|
* - Jobs are processed sequentially (concurrency option is for logging/compatibility only)
|
|
51
50
|
* - Not suitable for production or multi-process environments
|
|
52
51
|
*
|
|
53
|
-
* Failed jobs are retried up to `DEFAULT_MAX_ATTEMPTS` times with exponential
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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.
|
|
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).
|
|
65
55
|
*
|
|
66
56
|
* All file I/O is asynchronous (`fs.promises.*`) so queue operations do not
|
|
67
57
|
* block the Node.js event loop. A per-queue promise chain serializes
|
|
@@ -206,13 +196,11 @@ export function createLocalQueue<T = unknown>(
|
|
|
206
196
|
const availableAt = options?.delayMs && options.delayMs > 0
|
|
207
197
|
? new Date(Date.now() + options.delayMs).toISOString()
|
|
208
198
|
: undefined
|
|
209
|
-
const metadata = attachTraceMetadata(undefined)
|
|
210
199
|
const job: StoredJob<T> = {
|
|
211
200
|
id: generateId(),
|
|
212
201
|
payload: data,
|
|
213
202
|
createdAt: new Date().toISOString(),
|
|
214
203
|
...(availableAt ? { availableAt } : {}),
|
|
215
|
-
...(metadata ? { metadata } : {}),
|
|
216
204
|
}
|
|
217
205
|
await withFileLock(async () => {
|
|
218
206
|
const jobs = await readQueue()
|
|
@@ -258,14 +246,12 @@ export function createLocalQueue<T = unknown>(
|
|
|
258
246
|
for (const job of jobsToProcess) {
|
|
259
247
|
const attemptNumber = (job.attemptCount ?? 0) + 1
|
|
260
248
|
try {
|
|
261
|
-
await
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
})
|
|
268
|
-
)
|
|
249
|
+
await Promise.resolve(
|
|
250
|
+
handler(job, {
|
|
251
|
+
jobId: job.id,
|
|
252
|
+
attemptNumber,
|
|
253
|
+
queueName: name,
|
|
254
|
+
})
|
|
269
255
|
)
|
|
270
256
|
processed++
|
|
271
257
|
lastJobId = job.id
|
|
@@ -276,7 +262,7 @@ export function createLocalQueue<T = unknown>(
|
|
|
276
262
|
failed++
|
|
277
263
|
lastJobId = job.id
|
|
278
264
|
if (attemptNumber >= DEFAULT_MAX_ATTEMPTS) {
|
|
279
|
-
logger.error('Job exhausted all attempts
|
|
265
|
+
logger.error('Job exhausted all attempts, moving to dead letter', { jobId: job.id, maxAttempts: DEFAULT_MAX_ATTEMPTS })
|
|
280
266
|
deadJobIds.add(job.id)
|
|
281
267
|
} else {
|
|
282
268
|
const backoffMs = RETRY_BACKOFF_BASE_MS * Math.pow(2, attemptNumber - 1)
|
package/src/types.ts
CHANGED
|
@@ -81,8 +81,6 @@ 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
|
|
86
84
|
}
|
|
87
85
|
|
|
88
86
|
/**
|
|
@@ -93,12 +91,6 @@ export type AsyncQueueOptions = {
|
|
|
93
91
|
connection?: RedisConnectionOptions
|
|
94
92
|
/** Number of concurrent job processors. Defaults to 1 */
|
|
95
93
|
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
|
|
102
94
|
}
|
|
103
95
|
|
|
104
96
|
/**
|
|
@@ -258,10 +250,6 @@ export type WorkerMeta = {
|
|
|
258
250
|
id?: string
|
|
259
251
|
/** Worker concurrency (default: 1) */
|
|
260
252
|
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
|
|
265
253
|
}
|
|
266
254
|
|
|
267
255
|
/**
|
|
@@ -277,8 +265,4 @@ export type WorkerDescriptor<T = unknown> = {
|
|
|
277
265
|
handler: JobHandler<T>
|
|
278
266
|
/** Concurrency level */
|
|
279
267
|
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
|
|
284
268
|
}
|