@motiadev/adapter-bullmq-events 0.13.0-beta.162-717198
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/LICENSE +93 -0
- package/README.md +222 -0
- package/dist/bullmq-event-adapter.d.ts +27 -0
- package/dist/bullmq-event-adapter.d.ts.map +1 -0
- package/dist/bullmq-event-adapter.js +75 -0
- package/dist/config-builder.d.ts +6 -0
- package/dist/config-builder.d.ts.map +1 -0
- package/dist/config-builder.js +29 -0
- package/dist/connection-manager.d.ts +10 -0
- package/dist/connection-manager.d.ts.map +1 -0
- package/dist/connection-manager.js +39 -0
- package/dist/constants.d.ts +14 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +16 -0
- package/dist/dlq-manager.d.ts +22 -0
- package/dist/dlq-manager.d.ts.map +1 -0
- package/dist/dlq-manager.js +112 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +35 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/queue-manager.d.ts +20 -0
- package/dist/queue-manager.d.ts.map +1 -0
- package/dist/queue-manager.js +85 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/worker-manager.d.ts +38 -0
- package/dist/worker-manager.d.ts.map +1 -0
- package/dist/worker-manager.js +136 -0
- package/package.json +25 -0
- package/src/bullmq-event-adapter.ts +105 -0
- package/src/config-builder.ts +41 -0
- package/src/connection-manager.ts +40 -0
- package/src/constants.ts +14 -0
- package/src/dlq-manager.ts +151 -0
- package/src/errors.ts +33 -0
- package/src/index.ts +6 -0
- package/src/queue-manager.ts +107 -0
- package/src/types.ts +24 -0
- package/src/worker-manager.ts +200 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_ATTEMPTS,
|
|
3
|
+
DEFAULT_BACKOFF_DELAY,
|
|
4
|
+
DEFAULT_CONCURRENCY,
|
|
5
|
+
DEFAULT_DLQ_SUFFIX,
|
|
6
|
+
DEFAULT_DLQ_TTL,
|
|
7
|
+
DEFAULT_PREFIX,
|
|
8
|
+
DEFAULT_REMOVE_ON_COMPLETE_COUNT,
|
|
9
|
+
DEFAULT_REMOVE_ON_FAIL_COUNT,
|
|
10
|
+
} from './constants'
|
|
11
|
+
import type { BullMQEventAdapterConfig } from './types'
|
|
12
|
+
|
|
13
|
+
export type MergedConfig = Required<Pick<BullMQEventAdapterConfig, 'defaultJobOptions' | 'prefix' | 'concurrency'>> & {
|
|
14
|
+
dlq: Required<NonNullable<BullMQEventAdapterConfig['dlq']>>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildConfig(config: BullMQEventAdapterConfig): MergedConfig {
|
|
18
|
+
return {
|
|
19
|
+
concurrency: config.concurrency ?? DEFAULT_CONCURRENCY,
|
|
20
|
+
defaultJobOptions: {
|
|
21
|
+
attempts: DEFAULT_ATTEMPTS,
|
|
22
|
+
backoff: {
|
|
23
|
+
type: 'fixed',
|
|
24
|
+
delay: DEFAULT_BACKOFF_DELAY,
|
|
25
|
+
},
|
|
26
|
+
removeOnComplete: {
|
|
27
|
+
count: DEFAULT_REMOVE_ON_COMPLETE_COUNT,
|
|
28
|
+
},
|
|
29
|
+
removeOnFail: {
|
|
30
|
+
count: DEFAULT_REMOVE_ON_FAIL_COUNT,
|
|
31
|
+
},
|
|
32
|
+
...config.defaultJobOptions,
|
|
33
|
+
},
|
|
34
|
+
prefix: config.prefix ?? DEFAULT_PREFIX,
|
|
35
|
+
dlq: {
|
|
36
|
+
enabled: config.dlq?.enabled ?? true,
|
|
37
|
+
ttl: config.dlq?.ttl ?? DEFAULT_DLQ_TTL,
|
|
38
|
+
suffix: config.dlq?.suffix ?? DEFAULT_DLQ_SUFFIX,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import IORedis, { type Redis } from 'ioredis'
|
|
2
|
+
import { ConnectionError } from './errors'
|
|
3
|
+
import type { BullMQConnectionConfig } from './types'
|
|
4
|
+
|
|
5
|
+
export class ConnectionManager {
|
|
6
|
+
readonly connection: Redis
|
|
7
|
+
readonly ownsConnection: boolean
|
|
8
|
+
|
|
9
|
+
constructor(config: BullMQConnectionConfig) {
|
|
10
|
+
if (config instanceof IORedis) {
|
|
11
|
+
this.connection = config
|
|
12
|
+
this.ownsConnection = false
|
|
13
|
+
} else {
|
|
14
|
+
this.connection = new IORedis({
|
|
15
|
+
maxRetriesPerRequest: null,
|
|
16
|
+
...config,
|
|
17
|
+
})
|
|
18
|
+
this.ownsConnection = true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
this.setupEventHandlers()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private setupEventHandlers(): void {
|
|
25
|
+
this.connection.on('error', (err: Error) => {
|
|
26
|
+
const error = new ConnectionError(err.message, err)
|
|
27
|
+
console.error('[BullMQ] Connection error:', error)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
this.connection.on('close', () => {
|
|
31
|
+
console.warn('[BullMQ] Connection closed')
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async close(): Promise<void> {
|
|
36
|
+
if (this.ownsConnection && this.connection.status !== 'end') {
|
|
37
|
+
await this.connection.quit()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const DEFAULT_CONCURRENCY = 5
|
|
2
|
+
export const DEFAULT_ATTEMPTS = 3
|
|
3
|
+
export const DEFAULT_BACKOFF_DELAY = 2000
|
|
4
|
+
export const DEFAULT_REMOVE_ON_COMPLETE_COUNT = 1000
|
|
5
|
+
export const DEFAULT_REMOVE_ON_FAIL_COUNT = 5000
|
|
6
|
+
export const DEFAULT_PREFIX = 'motia'
|
|
7
|
+
export const FIFO_CONCURRENCY = 1
|
|
8
|
+
export const MILLISECONDS_PER_SECOND = 1000
|
|
9
|
+
export const SECONDS_PER_DAY = 86400
|
|
10
|
+
export const DEFAULT_DLQ_TTL = 30 * SECONDS_PER_DAY
|
|
11
|
+
export const DEFAULT_DLQ_SUFFIX = '.dlq'
|
|
12
|
+
export const DLQ_JOB_PREFIX = 'dlq-'
|
|
13
|
+
|
|
14
|
+
export const LOG_PREFIX = '[BullMQ]'
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { Event } from '@motiadev/core'
|
|
2
|
+
import { Queue } from 'bullmq'
|
|
3
|
+
import type { Redis } from 'ioredis'
|
|
4
|
+
import type { MergedConfig } from './config-builder'
|
|
5
|
+
import { DLQ_JOB_PREFIX, LOG_PREFIX, MILLISECONDS_PER_SECOND } from './constants'
|
|
6
|
+
import { QueueCreationError } from './errors'
|
|
7
|
+
|
|
8
|
+
type DLQJobData<TData> = {
|
|
9
|
+
originalEvent: Event<TData>
|
|
10
|
+
failureReason: string
|
|
11
|
+
failureTimestamp: number
|
|
12
|
+
attemptsMade: number
|
|
13
|
+
originalJobId?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class DLQManager {
|
|
17
|
+
private readonly dlqQueues: Map<string, Queue> = new Map()
|
|
18
|
+
private readonly connection: Redis
|
|
19
|
+
private readonly config: MergedConfig
|
|
20
|
+
|
|
21
|
+
constructor(connection: Redis, config: MergedConfig) {
|
|
22
|
+
this.connection = connection
|
|
23
|
+
this.config = config
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getDLQQueueName(topic: string, stepName: string): string {
|
|
27
|
+
const baseQueueName = `${topic}.${stepName}`
|
|
28
|
+
return `${baseQueueName}${this.config.dlq.suffix}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private getOrCreateDLQQueue(queueName: string): Queue {
|
|
32
|
+
if (!this.dlqQueues.has(queueName)) {
|
|
33
|
+
const ttlMs = this.config.dlq.ttl * MILLISECONDS_PER_SECOND
|
|
34
|
+
const queue = new Queue(queueName, {
|
|
35
|
+
connection: this.connection,
|
|
36
|
+
prefix: this.config.prefix,
|
|
37
|
+
defaultJobOptions: {
|
|
38
|
+
removeOnComplete: {
|
|
39
|
+
age: ttlMs,
|
|
40
|
+
},
|
|
41
|
+
removeOnFail: {
|
|
42
|
+
age: ttlMs,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
queue.on('error', (err: Error) => {
|
|
48
|
+
console.error(`${LOG_PREFIX} DLQ error for ${queueName}:`, err)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
this.dlqQueues.set(queueName, queue)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const queue = this.dlqQueues.get(queueName)
|
|
55
|
+
if (!queue) {
|
|
56
|
+
throw new QueueCreationError(queueName)
|
|
57
|
+
}
|
|
58
|
+
return queue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async moveToDLQ<TData>(
|
|
62
|
+
topic: string,
|
|
63
|
+
stepName: string,
|
|
64
|
+
event: Event<TData>,
|
|
65
|
+
error: Error,
|
|
66
|
+
attemptsMade: number,
|
|
67
|
+
originalJobId?: string,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
if (!this.config.dlq!.enabled) {
|
|
70
|
+
console.warn(`${LOG_PREFIX} DLQ is disabled, skipping move to DLQ for topic ${topic}, step ${stepName}`)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const dlqQueueName = this.getDLQQueueName(topic, stepName)
|
|
76
|
+
const dlqQueue = this.getOrCreateDLQQueue(dlqQueueName)
|
|
77
|
+
|
|
78
|
+
const sanitizedEvent = {
|
|
79
|
+
topic: event.topic,
|
|
80
|
+
data: event.data,
|
|
81
|
+
traceId: event.traceId || 'unknown',
|
|
82
|
+
...(event.flows && { flows: event.flows }),
|
|
83
|
+
...(event.messageGroupId && { messageGroupId: event.messageGroupId }),
|
|
84
|
+
} as Event<TData>
|
|
85
|
+
|
|
86
|
+
const dlqJobData: DLQJobData<TData> = {
|
|
87
|
+
originalEvent: sanitizedEvent,
|
|
88
|
+
failureReason: error.message || 'Unknown error',
|
|
89
|
+
failureTimestamp: Date.now(),
|
|
90
|
+
attemptsMade,
|
|
91
|
+
...(originalJobId && { originalJobId }),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const jobOptions = originalJobId ? { jobId: `${DLQ_JOB_PREFIX}${originalJobId}` } : {}
|
|
95
|
+
|
|
96
|
+
await dlqQueue.add(`${topic}.dlq`, dlqJobData, jobOptions)
|
|
97
|
+
|
|
98
|
+
console.warn(`${LOG_PREFIX} Moved failed job to DLQ: ${dlqQueueName}`, {
|
|
99
|
+
topic,
|
|
100
|
+
stepName,
|
|
101
|
+
attemptsMade,
|
|
102
|
+
error: error.message,
|
|
103
|
+
})
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const dlqError = err instanceof Error ? err : new Error(String(err))
|
|
106
|
+
console.error(`${LOG_PREFIX} Failed to move job to DLQ for topic ${topic}, step ${stepName}:`, dlqError)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async closeDLQQueue(queueName: string): Promise<void> {
|
|
111
|
+
const queue = this.dlqQueues.get(queueName)
|
|
112
|
+
if (queue) {
|
|
113
|
+
await queue.close()
|
|
114
|
+
this.dlqQueues.delete(queueName)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async closeAll(): Promise<void> {
|
|
119
|
+
const promises = Array.from(this.dlqQueues.values()).map((queue) =>
|
|
120
|
+
queue.close().catch((err) => {
|
|
121
|
+
console.error(`${LOG_PREFIX} Error closing DLQ queue:`, err)
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
await Promise.allSettled(promises)
|
|
125
|
+
this.dlqQueues.clear()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
getDLQQueue(queueName: string): Queue | undefined {
|
|
129
|
+
return this.dlqQueues.get(queueName)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getOrCreateDLQ(queueName: string): Queue {
|
|
133
|
+
return this.getOrCreateDLQQueue(queueName)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
listDLQQueueNames(): string[] {
|
|
137
|
+
return Array.from(this.dlqQueues.keys())
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
getDLQSuffix(): string {
|
|
141
|
+
return this.config.dlq.suffix
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
getPrefix(): string {
|
|
145
|
+
return this.config.prefix
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
getConnection(): Redis {
|
|
149
|
+
return this.connection
|
|
150
|
+
}
|
|
151
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class BullMQAdapterError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
message: string,
|
|
4
|
+
public readonly cause?: Error,
|
|
5
|
+
) {
|
|
6
|
+
super(message)
|
|
7
|
+
this.name = 'BullMQAdapterError'
|
|
8
|
+
if (cause) {
|
|
9
|
+
this.stack = `${this.stack}\nCaused by: ${cause.stack}`
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class QueueCreationError extends BullMQAdapterError {
|
|
15
|
+
constructor(queueName: string, cause?: Error) {
|
|
16
|
+
super(`Failed to create queue: ${queueName}`, cause)
|
|
17
|
+
this.name = 'QueueCreationError'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class WorkerCreationError extends BullMQAdapterError {
|
|
22
|
+
constructor(topic: string, stepName: string, cause?: Error) {
|
|
23
|
+
super(`Failed to create worker for topic ${topic}, step ${stepName}`, cause)
|
|
24
|
+
this.name = 'WorkerCreationError'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ConnectionError extends BullMQAdapterError {
|
|
29
|
+
constructor(message: string, cause?: Error) {
|
|
30
|
+
super(`Connection error: ${message}`, cause)
|
|
31
|
+
this.name = 'ConnectionError'
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { BullMQEventAdapter } from './bullmq-event-adapter'
|
|
2
|
+
export { DLQManager } from './dlq-manager'
|
|
3
|
+
export { QueueManager } from './queue-manager'
|
|
4
|
+
export type { BullMQConnectionConfig, BullMQEventAdapterConfig } from './types'
|
|
5
|
+
export type { SubscriberInfo } from './worker-manager'
|
|
6
|
+
export { WorkerManager } from './worker-manager'
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Event } from '@motiadev/core'
|
|
2
|
+
import { Queue } from 'bullmq'
|
|
3
|
+
import type { Redis } from 'ioredis'
|
|
4
|
+
import type { MergedConfig } from './config-builder'
|
|
5
|
+
import { MILLISECONDS_PER_SECOND } from './constants'
|
|
6
|
+
import { QueueCreationError } from './errors'
|
|
7
|
+
import type { SubscriberInfo } from './worker-manager'
|
|
8
|
+
|
|
9
|
+
export class QueueManager {
|
|
10
|
+
private readonly queues: Map<string, Queue> = new Map()
|
|
11
|
+
private readonly connection: Redis
|
|
12
|
+
private readonly config: MergedConfig
|
|
13
|
+
|
|
14
|
+
constructor(connection: Redis, config: MergedConfig) {
|
|
15
|
+
this.connection = connection
|
|
16
|
+
this.config = config
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
getQueue(queueName: string): Queue {
|
|
20
|
+
if (!this.queues.has(queueName)) {
|
|
21
|
+
const queue = new Queue(queueName, {
|
|
22
|
+
connection: this.connection,
|
|
23
|
+
prefix: this.config.prefix,
|
|
24
|
+
defaultJobOptions: this.config.defaultJobOptions,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
queue.on('error', (err: Error) => {
|
|
28
|
+
console.error(`[BullMQ] Queue error for ${queueName}:`, err)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
this.queues.set(queueName, queue)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const queue = this.queues.get(queueName)
|
|
35
|
+
if (!queue) {
|
|
36
|
+
throw new QueueCreationError(queueName)
|
|
37
|
+
}
|
|
38
|
+
return queue
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
getQueueName(topic: string, stepName: string): string {
|
|
42
|
+
return `${topic}.${stepName}`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async enqueueToAll<TData>(event: Event<TData>, subscribers: SubscriberInfo[]): Promise<void> {
|
|
46
|
+
const promises = subscribers.map((subscriber) => {
|
|
47
|
+
const queueName = this.getQueueName(subscriber.topic, subscriber.stepName)
|
|
48
|
+
const queue = this.getQueue(queueName)
|
|
49
|
+
const jobId = event.messageGroupId ? `${queueName}.${event.messageGroupId}` : undefined
|
|
50
|
+
|
|
51
|
+
const jobData = {
|
|
52
|
+
topic: event.topic,
|
|
53
|
+
data: event.data,
|
|
54
|
+
traceId: event.traceId,
|
|
55
|
+
flows: event.flows,
|
|
56
|
+
messageGroupId: event.messageGroupId,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const maxRetries = subscriber.queueConfig?.maxRetries
|
|
60
|
+
const attempts = maxRetries != null ? maxRetries + 1 : this.config.defaultJobOptions.attempts
|
|
61
|
+
const delay = subscriber.queueConfig?.delaySeconds
|
|
62
|
+
? subscriber.queueConfig.delaySeconds * MILLISECONDS_PER_SECOND
|
|
63
|
+
: undefined
|
|
64
|
+
|
|
65
|
+
const jobOptions = {
|
|
66
|
+
jobId,
|
|
67
|
+
attempts,
|
|
68
|
+
backoff: this.config.defaultJobOptions.backoff,
|
|
69
|
+
delay,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return queue.add(event.topic, jobData, jobOptions).then(() => undefined)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
await Promise.all(promises)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async closeQueue(queueName: string): Promise<void> {
|
|
79
|
+
const queue = this.queues.get(queueName)
|
|
80
|
+
if (queue) {
|
|
81
|
+
await queue.close()
|
|
82
|
+
this.queues.delete(queueName)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async closeAll(): Promise<void> {
|
|
87
|
+
const promises = Array.from(this.queues.values()).map((queue) =>
|
|
88
|
+
queue.close().catch((err) => {
|
|
89
|
+
console.error(`[BullMQ] Error closing queue:`, err)
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
await Promise.allSettled(promises)
|
|
93
|
+
this.queues.clear()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
listQueueNames(): string[] {
|
|
97
|
+
return Array.from(this.queues.keys())
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getPrefix(): string {
|
|
101
|
+
return this.config.prefix
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getConnection(): Redis {
|
|
105
|
+
return this.connection
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { KeepJobs } from 'bullmq'
|
|
2
|
+
import type { Redis, RedisOptions } from 'ioredis'
|
|
3
|
+
|
|
4
|
+
export type BullMQConnectionConfig = Redis | RedisOptions
|
|
5
|
+
|
|
6
|
+
export interface BullMQEventAdapterConfig {
|
|
7
|
+
connection: BullMQConnectionConfig
|
|
8
|
+
concurrency?: number
|
|
9
|
+
defaultJobOptions?: {
|
|
10
|
+
attempts?: number
|
|
11
|
+
backoff?: {
|
|
12
|
+
type: 'fixed' | 'exponential'
|
|
13
|
+
delay: number
|
|
14
|
+
}
|
|
15
|
+
removeOnComplete?: KeepJobs
|
|
16
|
+
removeOnFail?: KeepJobs
|
|
17
|
+
}
|
|
18
|
+
prefix?: string
|
|
19
|
+
dlq?: {
|
|
20
|
+
enabled?: boolean
|
|
21
|
+
ttl?: number
|
|
22
|
+
suffix?: string
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { Event, QueueConfig, SubscriptionHandle } from '@motiadev/core'
|
|
2
|
+
import { type Job, Worker } from 'bullmq'
|
|
3
|
+
import type { Redis } from 'ioredis'
|
|
4
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
5
|
+
import type { MergedConfig } from './config-builder'
|
|
6
|
+
import { FIFO_CONCURRENCY, MILLISECONDS_PER_SECOND } from './constants'
|
|
7
|
+
import type { DLQManager } from './dlq-manager'
|
|
8
|
+
import { WorkerCreationError } from './errors'
|
|
9
|
+
|
|
10
|
+
export type SubscriberInfo = {
|
|
11
|
+
topic: string
|
|
12
|
+
stepName: string
|
|
13
|
+
queueConfig?: QueueConfig
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type WorkerInfo = {
|
|
17
|
+
worker: Worker
|
|
18
|
+
topic: string
|
|
19
|
+
stepName: string
|
|
20
|
+
handle: SubscriptionHandle
|
|
21
|
+
queueConfig?: QueueConfig
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type JobData<TData> = {
|
|
25
|
+
topic: string
|
|
26
|
+
data: TData
|
|
27
|
+
traceId: string
|
|
28
|
+
flows?: string[]
|
|
29
|
+
messageGroupId?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class WorkerManager {
|
|
33
|
+
private readonly workers: Map<string, WorkerInfo> = new Map()
|
|
34
|
+
private readonly topicSubscriptions: Map<string, Set<string>> = new Map()
|
|
35
|
+
private readonly connection: Redis
|
|
36
|
+
private readonly config: MergedConfig
|
|
37
|
+
private readonly getQueueName: (topic: string, stepName: string) => string
|
|
38
|
+
private readonly dlqManager: DLQManager | null
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
connection: Redis,
|
|
42
|
+
config: MergedConfig,
|
|
43
|
+
getQueueName: (topic: string, stepName: string) => string,
|
|
44
|
+
dlqManager?: DLQManager,
|
|
45
|
+
) {
|
|
46
|
+
this.connection = connection
|
|
47
|
+
this.config = config
|
|
48
|
+
this.getQueueName = getQueueName
|
|
49
|
+
this.dlqManager = dlqManager ?? null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
createWorker<TData>(
|
|
53
|
+
topic: string,
|
|
54
|
+
stepName: string,
|
|
55
|
+
handler: (event: Event<TData>) => void | Promise<void>,
|
|
56
|
+
options?: QueueConfig,
|
|
57
|
+
): SubscriptionHandle {
|
|
58
|
+
const id = uuidv4()
|
|
59
|
+
const queueName = this.getQueueName(topic, stepName)
|
|
60
|
+
|
|
61
|
+
this.addTopicSubscription(topic, id)
|
|
62
|
+
|
|
63
|
+
const concurrency = options?.type === 'fifo' ? FIFO_CONCURRENCY : this.config.concurrency
|
|
64
|
+
const attempts = options?.maxRetries != null ? options.maxRetries + 1 : this.config.defaultJobOptions.attempts
|
|
65
|
+
const lockDuration = options?.visibilityTimeout ? options.visibilityTimeout * MILLISECONDS_PER_SECOND : undefined
|
|
66
|
+
|
|
67
|
+
const worker = new Worker(
|
|
68
|
+
queueName,
|
|
69
|
+
async (job: Job<JobData<TData>>) => {
|
|
70
|
+
const eventData = job.data
|
|
71
|
+
const event = {
|
|
72
|
+
topic: eventData.topic,
|
|
73
|
+
data: eventData.data,
|
|
74
|
+
traceId: eventData.traceId,
|
|
75
|
+
flows: eventData.flows,
|
|
76
|
+
messageGroupId: eventData.messageGroupId,
|
|
77
|
+
} as Event<TData>
|
|
78
|
+
await handler(event)
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
connection: this.connection,
|
|
82
|
+
prefix: this.config.prefix,
|
|
83
|
+
concurrency,
|
|
84
|
+
lockDuration,
|
|
85
|
+
removeOnComplete: this.config.defaultJobOptions.removeOnComplete,
|
|
86
|
+
removeOnFail: this.config.defaultJobOptions.removeOnFail,
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
this.setupWorkerHandlers(worker, topic, stepName, attempts ?? 3)
|
|
91
|
+
|
|
92
|
+
const handle: SubscriptionHandle = {
|
|
93
|
+
topic,
|
|
94
|
+
id,
|
|
95
|
+
unsubscribe: async () => {
|
|
96
|
+
await this.removeWorker(handle.id)
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const workerInfo: WorkerInfo = {
|
|
101
|
+
worker,
|
|
102
|
+
topic,
|
|
103
|
+
stepName,
|
|
104
|
+
handle,
|
|
105
|
+
queueConfig: options,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.workers.set(id, workerInfo)
|
|
109
|
+
return handle
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
getSubscribers(topic: string): SubscriberInfo[] {
|
|
113
|
+
const subscriptionIds = this.topicSubscriptions.get(topic)
|
|
114
|
+
if (!subscriptionIds || subscriptionIds.size === 0) {
|
|
115
|
+
return []
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return Array.from(subscriptionIds)
|
|
119
|
+
.map((id) => this.workers.get(id))
|
|
120
|
+
.filter((info): info is WorkerInfo => info !== undefined)
|
|
121
|
+
.map((info) => ({ topic: info.topic, stepName: info.stepName, queueConfig: info.queueConfig }))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
getWorkerInfo(id: string): WorkerInfo | undefined {
|
|
125
|
+
return this.workers.get(id)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async removeWorker(id: string): Promise<void> {
|
|
129
|
+
const workerInfo = this.workers.get(id)
|
|
130
|
+
if (!workerInfo) {
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.removeTopicSubscription(workerInfo.topic, id)
|
|
135
|
+
await workerInfo.worker.close()
|
|
136
|
+
this.workers.delete(id)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async closeAll(): Promise<void> {
|
|
140
|
+
const promises = Array.from(this.workers.values()).map((info) =>
|
|
141
|
+
info.worker.close().catch((err) => {
|
|
142
|
+
console.error(`[BullMQ] Error closing worker for topic ${info.topic}, step ${info.stepName}:`, err)
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
await Promise.allSettled(promises)
|
|
146
|
+
this.workers.clear()
|
|
147
|
+
this.topicSubscriptions.clear()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
getSubscriptionCount(topic: string): number {
|
|
151
|
+
return Array.from(this.workers.values()).filter((w) => w.topic === topic).length
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
listTopics(): string[] {
|
|
155
|
+
return Array.from(new Set(Array.from(this.workers.values()).map((w) => w.topic)))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private addTopicSubscription(topic: string, id: string): void {
|
|
159
|
+
if (!this.topicSubscriptions.has(topic)) {
|
|
160
|
+
this.topicSubscriptions.set(topic, new Set())
|
|
161
|
+
}
|
|
162
|
+
this.topicSubscriptions.get(topic)?.add(id)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private removeTopicSubscription(topic: string, id: string): void {
|
|
166
|
+
const subscriptions = this.topicSubscriptions.get(topic)
|
|
167
|
+
if (subscriptions) {
|
|
168
|
+
subscriptions.delete(id)
|
|
169
|
+
if (subscriptions.size === 0) {
|
|
170
|
+
this.topicSubscriptions.delete(topic)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private setupWorkerHandlers(worker: Worker, topic: string, stepName: string, attempts: number): void {
|
|
176
|
+
worker.on('error', (err: Error) => {
|
|
177
|
+
const error = new WorkerCreationError(topic, stepName, err)
|
|
178
|
+
console.error(`[BullMQ] Worker error for topic ${topic}, step ${stepName}:`, error)
|
|
179
|
+
})
|
|
180
|
+
worker.on('failed', async (job: Job<JobData<unknown>> | undefined, err: Error) => {
|
|
181
|
+
if (job) {
|
|
182
|
+
const attemptsMade = job.attemptsMade || 0
|
|
183
|
+
if (attemptsMade >= attempts) {
|
|
184
|
+
if (this.dlqManager) {
|
|
185
|
+
const eventData = job.data
|
|
186
|
+
const event = {
|
|
187
|
+
topic: eventData.topic || topic,
|
|
188
|
+
data: eventData.data,
|
|
189
|
+
traceId: eventData.traceId || 'unknown',
|
|
190
|
+
...(eventData.flows && { flows: eventData.flows }),
|
|
191
|
+
...(eventData.messageGroupId && { messageGroupId: eventData.messageGroupId }),
|
|
192
|
+
} as Event<unknown>
|
|
193
|
+
|
|
194
|
+
await this.dlqManager.moveToDLQ(topic, stepName, event, err, attemptsMade, job.id)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2020"],
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"outDir": "./dist",
|
|
9
|
+
"rootDir": "./src",
|
|
10
|
+
"strict": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"forceConsistentCasingInFileNames": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"moduleResolution": "node"
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|