@checkstack/queue-bullmq-backend 0.0.2
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/CHANGELOG.md +53 -0
- package/package.json +25 -0
- package/src/bullmq-queue.ts +281 -0
- package/src/index.ts +26 -0
- package/src/plugin-metadata.ts +9 -0
- package/src/plugin.ts +36 -0
- package/tsconfig.json +3 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @checkstack/queue-bullmq-backend
|
|
2
|
+
|
|
3
|
+
## 0.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
|
|
8
|
+
- Updated dependencies [d20d274]
|
|
9
|
+
- @checkstack/backend-api@0.0.2
|
|
10
|
+
- @checkstack/common@0.0.2
|
|
11
|
+
- @checkstack/queue-api@0.0.2
|
|
12
|
+
- @checkstack/queue-bullmq-common@0.0.2
|
|
13
|
+
|
|
14
|
+
## 0.2.1
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [b4eb432]
|
|
19
|
+
- Updated dependencies [a65e002]
|
|
20
|
+
- @checkstack/backend-api@1.1.0
|
|
21
|
+
- @checkstack/common@0.2.0
|
|
22
|
+
- @checkstack/queue-api@1.0.1
|
|
23
|
+
- @checkstack/queue-bullmq-common@0.2.1
|
|
24
|
+
|
|
25
|
+
## 0.2.0
|
|
26
|
+
|
|
27
|
+
### Minor Changes
|
|
28
|
+
|
|
29
|
+
- e4d83fc: Add BullMQ queue plugin with orphaned job cleanup
|
|
30
|
+
|
|
31
|
+
- **queue-api**: Added `listRecurringJobs()` method to Queue interface for detecting orphaned jobs
|
|
32
|
+
- **queue-bullmq-backend**: New plugin implementing BullMQ (Redis) queue backend with job schedulers, consumer groups, and distributed job persistence
|
|
33
|
+
- **queue-bullmq-common**: New common package with queue permissions
|
|
34
|
+
- **queue-memory-backend**: Implemented `listRecurringJobs()` for in-memory queue
|
|
35
|
+
- **healthcheck-backend**: Enhanced `bootstrapHealthChecks` to clean up orphaned job schedulers using `listRecurringJobs()`
|
|
36
|
+
- **test-utils-backend**: Added `listRecurringJobs()` to mock queue factory
|
|
37
|
+
|
|
38
|
+
This enables production-ready distributed queue processing with Redis persistence and automatic cleanup of orphaned jobs when health checks are deleted.
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- Updated dependencies [ffc28f6]
|
|
43
|
+
- Updated dependencies [e4d83fc]
|
|
44
|
+
- Updated dependencies [71275dd]
|
|
45
|
+
- Updated dependencies [ae19ff6]
|
|
46
|
+
- Updated dependencies [b55fae6]
|
|
47
|
+
- Updated dependencies [b354ab3]
|
|
48
|
+
- Updated dependencies [8e889b4]
|
|
49
|
+
- Updated dependencies [81f3f85]
|
|
50
|
+
- @checkstack/common@0.1.0
|
|
51
|
+
- @checkstack/backend-api@1.0.0
|
|
52
|
+
- @checkstack/queue-api@1.0.0
|
|
53
|
+
- @checkstack/queue-bullmq-common@0.2.0
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkstack/queue-bullmq-backend",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"lint": "bun run lint:code",
|
|
9
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@checkstack/backend-api": "workspace:*",
|
|
13
|
+
"@checkstack/queue-api": "workspace:*",
|
|
14
|
+
"@checkstack/queue-bullmq-common": "workspace:*",
|
|
15
|
+
"bullmq": "^5.66.4",
|
|
16
|
+
"zod": "^4.0.0",
|
|
17
|
+
"@checkstack/common": "workspace:*"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/bun": "latest",
|
|
21
|
+
"@checkstack/tsconfig": "workspace:*",
|
|
22
|
+
"@checkstack/scripts": "workspace:*",
|
|
23
|
+
"ioredis-mock": "^8.9.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Queue,
|
|
3
|
+
QueueJob,
|
|
4
|
+
QueueConsumer,
|
|
5
|
+
QueueStats,
|
|
6
|
+
ConsumeOptions,
|
|
7
|
+
RecurringJobDetails,
|
|
8
|
+
} from "@checkstack/queue-api";
|
|
9
|
+
import { Queue as BullQueue, Worker, JobsOptions } from "bullmq";
|
|
10
|
+
import type { BullMQConfig } from "./plugin";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Consumer group state tracking
|
|
14
|
+
*/
|
|
15
|
+
interface ConsumerGroupState {
|
|
16
|
+
worker: Worker;
|
|
17
|
+
consumerCount: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* BullMQ-based queue implementation
|
|
22
|
+
*/
|
|
23
|
+
export class BullMQQueue<T = unknown> implements Queue<T> {
|
|
24
|
+
private queue: BullQueue;
|
|
25
|
+
private consumerGroups = new Map<string, ConsumerGroupState>();
|
|
26
|
+
private stopped = false;
|
|
27
|
+
|
|
28
|
+
constructor(private name: string, private config: BullMQConfig) {
|
|
29
|
+
// Initialize BullMQ Queue with Redis connection
|
|
30
|
+
this.queue = new BullQueue(name, {
|
|
31
|
+
connection: {
|
|
32
|
+
host: config.host,
|
|
33
|
+
port: config.port,
|
|
34
|
+
password: config.password,
|
|
35
|
+
db: config.db,
|
|
36
|
+
// Disable automatic reconnection and retries for immediate failure
|
|
37
|
+
// eslint-disable-next-line unicorn/no-null
|
|
38
|
+
retryStrategy: () => null, // Don't retry, fail immediately
|
|
39
|
+
maxRetriesPerRequest: 1,
|
|
40
|
+
enableReadyCheck: true,
|
|
41
|
+
connectTimeout: 5000, // 5 second connection timeout
|
|
42
|
+
},
|
|
43
|
+
prefix: config.keyPrefix,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Test Redis connection by attempting a simple operation
|
|
49
|
+
* @throws Error if connection fails
|
|
50
|
+
*/
|
|
51
|
+
async testConnection(): Promise<void> {
|
|
52
|
+
try {
|
|
53
|
+
// Try to get job counts - this will fail if Redis is not accessible
|
|
54
|
+
await this.queue.getJobCounts();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Failed to connect to Redis at ${this.config.host}:${this.config.port}: ${message}`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async enqueue(
|
|
64
|
+
data: T,
|
|
65
|
+
options?: {
|
|
66
|
+
priority?: number;
|
|
67
|
+
startDelay?: number;
|
|
68
|
+
jobId?: string;
|
|
69
|
+
}
|
|
70
|
+
): Promise<string> {
|
|
71
|
+
if (this.stopped) {
|
|
72
|
+
throw new Error("Queue has been stopped");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const jobOptions: JobsOptions = {};
|
|
76
|
+
|
|
77
|
+
if (options?.priority !== undefined) {
|
|
78
|
+
jobOptions.priority = options.priority;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (options?.startDelay !== undefined) {
|
|
82
|
+
// Convert seconds to milliseconds
|
|
83
|
+
jobOptions.delay = options.startDelay * 1000;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (options?.jobId) {
|
|
87
|
+
jobOptions.jobId = options.jobId;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const job = await this.queue.add(this.name, data, jobOptions);
|
|
91
|
+
return job.id!;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async consume(
|
|
95
|
+
consumer: QueueConsumer<T>,
|
|
96
|
+
options: ConsumeOptions
|
|
97
|
+
): Promise<void> {
|
|
98
|
+
if (this.stopped) {
|
|
99
|
+
throw new Error("Queue has been stopped");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { consumerGroup, maxRetries = 3 } = options;
|
|
103
|
+
|
|
104
|
+
// Check if worker already exists for this consumer group
|
|
105
|
+
let groupState = this.consumerGroups.get(consumerGroup);
|
|
106
|
+
|
|
107
|
+
if (groupState) {
|
|
108
|
+
// Increment consumer count for existing group
|
|
109
|
+
groupState.consumerCount++;
|
|
110
|
+
} else {
|
|
111
|
+
// Create new worker for this consumer group
|
|
112
|
+
const worker = new Worker(
|
|
113
|
+
this.name,
|
|
114
|
+
async (job) => {
|
|
115
|
+
const queueJob: QueueJob<T> = {
|
|
116
|
+
id: job.id!,
|
|
117
|
+
data: job.data as T,
|
|
118
|
+
priority: job.opts.priority,
|
|
119
|
+
timestamp: new Date(job.timestamp),
|
|
120
|
+
attempts: job.attemptsMade,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
await consumer(queueJob);
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
connection: {
|
|
127
|
+
host: this.config.host,
|
|
128
|
+
port: this.config.port,
|
|
129
|
+
password: this.config.password,
|
|
130
|
+
db: this.config.db,
|
|
131
|
+
},
|
|
132
|
+
prefix: this.config.keyPrefix,
|
|
133
|
+
concurrency: this.config.concurrency,
|
|
134
|
+
// BullMQ's built-in retry mechanism
|
|
135
|
+
settings: {
|
|
136
|
+
backoffStrategy: (attemptsMade: number) => {
|
|
137
|
+
// Exponential backoff: 2^attemptsMade * 1000ms
|
|
138
|
+
return Math.pow(2, attemptsMade) * 1000;
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Configure retries at job level via job options
|
|
145
|
+
worker.on("failed", async (job, err) => {
|
|
146
|
+
if (job && job.attemptsMade >= maxRetries) {
|
|
147
|
+
// Max retries exhausted
|
|
148
|
+
console.debug(
|
|
149
|
+
`Job ${job.id} exhausted retries (${job.attemptsMade}/${maxRetries}):`,
|
|
150
|
+
err
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
groupState = {
|
|
156
|
+
worker,
|
|
157
|
+
consumerCount: 1,
|
|
158
|
+
};
|
|
159
|
+
this.consumerGroups.set(consumerGroup, groupState);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async scheduleRecurring(
|
|
164
|
+
data: T,
|
|
165
|
+
options: {
|
|
166
|
+
jobId: string;
|
|
167
|
+
intervalSeconds: number;
|
|
168
|
+
startDelay?: number;
|
|
169
|
+
priority?: number;
|
|
170
|
+
}
|
|
171
|
+
): Promise<string> {
|
|
172
|
+
if (this.stopped) {
|
|
173
|
+
throw new Error("Queue has been stopped");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const { jobId, intervalSeconds, startDelay, priority } = options;
|
|
177
|
+
|
|
178
|
+
// Use upsertJobScheduler for create-or-update semantics
|
|
179
|
+
await this.queue.upsertJobScheduler(
|
|
180
|
+
jobId,
|
|
181
|
+
{
|
|
182
|
+
every: intervalSeconds * 1000,
|
|
183
|
+
startDate: startDelay
|
|
184
|
+
? new Date(Date.now() + startDelay * 1000)
|
|
185
|
+
: undefined,
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: this.name,
|
|
189
|
+
data,
|
|
190
|
+
opts: {
|
|
191
|
+
priority,
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
return jobId;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async cancelRecurring(jobId: string): Promise<void> {
|
|
200
|
+
if (this.stopped) {
|
|
201
|
+
throw new Error("Queue has been stopped");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await this.queue.removeJobScheduler(jobId);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async listRecurringJobs(): Promise<string[]> {
|
|
208
|
+
if (this.stopped) {
|
|
209
|
+
throw new Error("Queue has been stopped");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const schedulers = await this.queue.getJobSchedulers();
|
|
213
|
+
return schedulers.map((scheduler) => scheduler.key);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async getRecurringJobDetails(
|
|
217
|
+
jobId: string
|
|
218
|
+
): Promise<RecurringJobDetails<T> | undefined> {
|
|
219
|
+
if (this.stopped) {
|
|
220
|
+
throw new Error("Queue has been stopped");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const schedulers = await this.queue.getJobSchedulers();
|
|
224
|
+
const scheduler = schedulers.find((s) => s.key === jobId);
|
|
225
|
+
|
|
226
|
+
if (!scheduler) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// BullMQ scheduler template contains the data
|
|
231
|
+
return {
|
|
232
|
+
jobId,
|
|
233
|
+
data: scheduler.template?.data as T,
|
|
234
|
+
intervalSeconds: scheduler.every ? scheduler.every / 1000 : 0,
|
|
235
|
+
priority: scheduler.template?.opts?.priority,
|
|
236
|
+
nextRunAt: scheduler.next ? new Date(scheduler.next) : undefined,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async getInFlightCount(): Promise<number> {
|
|
241
|
+
const counts = await this.queue.getJobCounts("active");
|
|
242
|
+
return counts.active || 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async stop(): Promise<void> {
|
|
246
|
+
if (this.stopped) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
this.stopped = true;
|
|
251
|
+
|
|
252
|
+
// Close all workers gracefully
|
|
253
|
+
const closePromises: Promise<void>[] = [];
|
|
254
|
+
for (const groupState of this.consumerGroups.values()) {
|
|
255
|
+
closePromises.push(groupState.worker.close());
|
|
256
|
+
}
|
|
257
|
+
await Promise.all(closePromises);
|
|
258
|
+
|
|
259
|
+
// Close queue connection
|
|
260
|
+
await this.queue.close();
|
|
261
|
+
|
|
262
|
+
this.consumerGroups.clear();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async getStats(): Promise<QueueStats> {
|
|
266
|
+
const counts = await this.queue.getJobCounts(
|
|
267
|
+
"waiting",
|
|
268
|
+
"active",
|
|
269
|
+
"completed",
|
|
270
|
+
"failed"
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
pending: counts.waiting || 0,
|
|
275
|
+
processing: counts.active || 0,
|
|
276
|
+
completed: counts.completed || 0,
|
|
277
|
+
failed: counts.failed || 0,
|
|
278
|
+
consumerGroups: this.consumerGroups.size,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBackendPlugin,
|
|
3
|
+
coreServices,
|
|
4
|
+
} from "@checkstack/backend-api";
|
|
5
|
+
import { BullMQPlugin } from "./plugin";
|
|
6
|
+
import { permissionList } from "@checkstack/queue-bullmq-common";
|
|
7
|
+
import { pluginMetadata } from "./plugin-metadata";
|
|
8
|
+
|
|
9
|
+
export default createBackendPlugin({
|
|
10
|
+
metadata: pluginMetadata,
|
|
11
|
+
register(env) {
|
|
12
|
+
env.registerPermissions(permissionList);
|
|
13
|
+
|
|
14
|
+
env.registerInit({
|
|
15
|
+
deps: {
|
|
16
|
+
queuePluginRegistry: coreServices.queuePluginRegistry,
|
|
17
|
+
logger: coreServices.logger,
|
|
18
|
+
},
|
|
19
|
+
init: async ({ queuePluginRegistry, logger }) => {
|
|
20
|
+
logger.debug("🔌 Registering BullMQ Queue Plugin...");
|
|
21
|
+
const plugin = new BullMQPlugin();
|
|
22
|
+
queuePluginRegistry.register(plugin);
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { definePluginMetadata } from "@checkstack/common";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plugin metadata for the Queue BullMQ backend.
|
|
5
|
+
* This is the single source of truth for the plugin ID.
|
|
6
|
+
*/
|
|
7
|
+
export const pluginMetadata = definePluginMetadata({
|
|
8
|
+
pluginId: "queue-bullmq",
|
|
9
|
+
});
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { QueuePlugin, Queue } from "@checkstack/queue-api";
|
|
2
|
+
import { configString, configNumber } from "@checkstack/backend-api";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { BullMQQueue } from "./bullmq-queue";
|
|
5
|
+
|
|
6
|
+
const configSchema = z.object({
|
|
7
|
+
host: z.string().default("localhost").describe("Redis host"),
|
|
8
|
+
port: z.number().min(1).max(65_535).default(6379).describe("Redis port"),
|
|
9
|
+
password: configString({ "x-secret": true })
|
|
10
|
+
.describe("Redis password (optional)")
|
|
11
|
+
.optional(),
|
|
12
|
+
db: configNumber({}).min(0).default(0).describe("Redis database number"),
|
|
13
|
+
keyPrefix: configString({})
|
|
14
|
+
.default("checkstack:")
|
|
15
|
+
.describe("Key prefix for queue names"),
|
|
16
|
+
concurrency: configNumber({})
|
|
17
|
+
.min(1)
|
|
18
|
+
.max(100)
|
|
19
|
+
.default(10)
|
|
20
|
+
.describe("Maximum number of concurrent jobs to process"),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export type BullMQConfig = z.infer<typeof configSchema>;
|
|
24
|
+
|
|
25
|
+
export class BullMQPlugin implements QueuePlugin<BullMQConfig> {
|
|
26
|
+
id = "bullmq";
|
|
27
|
+
displayName = "BullMQ (Redis)";
|
|
28
|
+
description =
|
|
29
|
+
"Production-grade distributed queue with Redis backend supporting multi-instance deployments";
|
|
30
|
+
configVersion = 1;
|
|
31
|
+
configSchema = configSchema;
|
|
32
|
+
|
|
33
|
+
createQueue<T>(name: string, config: BullMQConfig): Queue<T> {
|
|
34
|
+
return new BullMQQueue<T>(name, config);
|
|
35
|
+
}
|
|
36
|
+
}
|
package/tsconfig.json
ADDED