@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 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
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/base.json"
3
+ }