@checkstack/queue-memory-backend 0.2.4 → 0.3.0

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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # @checkstack/queue-memory-backend
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2c0822d: ### Queue System
8
+
9
+ - Added cron pattern support to `scheduleRecurring()` - accepts either `intervalSeconds` or `cronPattern`
10
+ - BullMQ backend uses native cron scheduling via `pattern` option
11
+ - InMemoryQueue implements wall-clock cron scheduling with `cron-parser`
12
+
13
+ ### Maintenance Backend
14
+
15
+ - Auto status transitions now use cron pattern `* * * * *` for precise second-0 scheduling
16
+ - User notifications are now sent for auto-started and auto-completed maintenances
17
+ - Refactored to call `addUpdate` RPC for status changes, centralizing hook/signal/notification logic
18
+
19
+ ### UI
20
+
21
+ - DateTimePicker now resets seconds and milliseconds to 0 when time is changed
22
+
23
+ ### Patch Changes
24
+
25
+ - Updated dependencies [2c0822d]
26
+ - Updated dependencies [66a3963]
27
+ - @checkstack/queue-api@0.2.0
28
+ - @checkstack/backend-api@0.5.0
29
+
3
30
  ## 0.2.4
4
31
 
5
32
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/queue-memory-backend",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -12,8 +12,9 @@
12
12
  "@checkstack/backend-api": "workspace:*",
13
13
  "@checkstack/queue-api": "workspace:*",
14
14
  "@checkstack/queue-memory-common": "workspace:*",
15
- "zod": "^4.2.1",
16
- "@checkstack/common": "workspace:*"
15
+ "@checkstack/common": "workspace:*",
16
+ "cron-parser": "^4.9.0",
17
+ "zod": "^4.2.1"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@types/bun": "latest",
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Cron Scheduling Tests for InMemoryQueue
3
+ *
4
+ * Uses Bun's Jest-compatible fake timers (`jest.useFakeTimers`, `jest.advanceTimersByTime`)
5
+ * to test cron scheduling without waiting for real time to pass.
6
+ */
7
+
8
+ import {
9
+ describe,
10
+ it,
11
+ expect,
12
+ beforeEach,
13
+ afterEach,
14
+ jest,
15
+ setSystemTime,
16
+ mock,
17
+ } from "bun:test";
18
+ import { InMemoryQueue } from "./memory-queue";
19
+ import type { Logger } from "@checkstack/backend-api";
20
+
21
+ const mockError = mock(() => {});
22
+ const testLogger: Logger = {
23
+ debug: () => {},
24
+ info: () => {},
25
+ warn: () => {},
26
+ error: mockError,
27
+ };
28
+
29
+ function createTestQueue(name: string) {
30
+ return new InMemoryQueue<string>(
31
+ name,
32
+ {
33
+ concurrency: 10,
34
+ maxQueueSize: 100,
35
+ delayMultiplier: 1, // Use real timing for cron tests
36
+ heartbeatIntervalMs: 100,
37
+ },
38
+ testLogger,
39
+ );
40
+ }
41
+
42
+ describe("InMemoryQueue Cron Scheduling", () => {
43
+ let queue: InMemoryQueue<string> | undefined;
44
+
45
+ beforeEach(() => {
46
+ // Enable fake timers with initial time
47
+ jest.useFakeTimers();
48
+ setSystemTime(new Date("2026-01-18T10:00:00Z"));
49
+ mockError.mockClear();
50
+ });
51
+
52
+ afterEach(async () => {
53
+ jest.useRealTimers();
54
+ if (queue) {
55
+ await queue.stop();
56
+ queue = undefined;
57
+ }
58
+ });
59
+
60
+ describe("Basic cron execution", () => {
61
+ it("should execute cron job at correct wall-clock time", async () => {
62
+ queue = createTestQueue("test-cron-execute");
63
+
64
+ let executionCount = 0;
65
+ await queue.consume(
66
+ async () => {
67
+ executionCount++;
68
+ },
69
+ { consumerGroup: "test", maxRetries: 0 },
70
+ );
71
+
72
+ // Schedule to run every minute at second 0
73
+ await queue.scheduleRecurring("payload", {
74
+ jobId: "cron-every-minute",
75
+ cronPattern: "* * * * *",
76
+ });
77
+
78
+ // Advance time by 61 seconds to trigger cron at 10:01:00
79
+ jest.advanceTimersByTime(61_000);
80
+ await Promise.resolve(); // Allow microtasks to flush
81
+
82
+ expect(executionCount).toBeGreaterThanOrEqual(1);
83
+ });
84
+
85
+ it("should reschedule cron job after execution", async () => {
86
+ queue = createTestQueue("test-cron-reschedule");
87
+
88
+ let executionCount = 0;
89
+ await queue.consume(
90
+ async () => {
91
+ executionCount++;
92
+ },
93
+ { consumerGroup: "test", maxRetries: 0 },
94
+ );
95
+
96
+ await queue.scheduleRecurring("payload", {
97
+ jobId: "cron-reschedule",
98
+ cronPattern: "* * * * *",
99
+ });
100
+
101
+ // Advance 2 minutes to trigger two executions
102
+ jest.advanceTimersByTime(60_000);
103
+ await Promise.resolve();
104
+
105
+ jest.advanceTimersByTime(60_000);
106
+ await Promise.resolve();
107
+
108
+ expect(executionCount).toBeGreaterThanOrEqual(2);
109
+ });
110
+ });
111
+
112
+ describe("Cron cancellation", () => {
113
+ it("should cancel pending cron jobs on stop()", async () => {
114
+ queue = createTestQueue("test-cron-stop");
115
+
116
+ let executionCount = 0;
117
+ await queue.consume(
118
+ async () => {
119
+ executionCount++;
120
+ },
121
+ { consumerGroup: "test", maxRetries: 0 },
122
+ );
123
+
124
+ await queue.scheduleRecurring("payload", {
125
+ jobId: "cron-stop-test",
126
+ cronPattern: "* * * * *",
127
+ });
128
+
129
+ // Stop the queue before cron fires
130
+ await queue.stop();
131
+ queue = undefined;
132
+
133
+ // Advance time past when cron should have fired
134
+ jest.advanceTimersByTime(120_000);
135
+ await Promise.resolve();
136
+
137
+ // Should NOT have executed any jobs since queue was stopped
138
+ expect(executionCount).toBe(0);
139
+ });
140
+
141
+ it("should stop cron scheduling when cancelled", async () => {
142
+ queue = createTestQueue("test-cron-cancel");
143
+
144
+ let executionCount = 0;
145
+ await queue.consume(
146
+ async () => {
147
+ executionCount++;
148
+ },
149
+ { consumerGroup: "test", maxRetries: 0 },
150
+ );
151
+
152
+ await queue.scheduleRecurring("payload", {
153
+ jobId: "cron-cancel-test",
154
+ cronPattern: "* * * * *",
155
+ });
156
+
157
+ // Execute once
158
+ jest.advanceTimersByTime(60_000);
159
+ await Promise.resolve();
160
+ const countBeforeCancel = executionCount;
161
+
162
+ // Cancel the job
163
+ await queue.cancelRecurring("cron-cancel-test");
164
+
165
+ // Advance time - should NOT execute
166
+ jest.advanceTimersByTime(60_000);
167
+ await Promise.resolve();
168
+
169
+ expect(executionCount).toBe(countBeforeCancel);
170
+ });
171
+ });
172
+
173
+ describe("Cron job details", () => {
174
+ it("should return cronPattern in getRecurringJobDetails", async () => {
175
+ queue = createTestQueue("test-cron-details");
176
+
177
+ await queue.scheduleRecurring("payload", {
178
+ jobId: "cron-details-test",
179
+ cronPattern: "0 9 * * 1-5",
180
+ });
181
+
182
+ const details = await queue.getRecurringJobDetails("cron-details-test");
183
+
184
+ expect(details).toBeDefined();
185
+ expect(details?.jobId).toBe("cron-details-test");
186
+ expect("cronPattern" in details!).toBe(true);
187
+ if ("cronPattern" in details!) {
188
+ expect(details.cronPattern).toBe("0 9 * * 1-5");
189
+ }
190
+ });
191
+
192
+ it("should return intervalSeconds for interval-based jobs", async () => {
193
+ queue = createTestQueue("test-interval-details");
194
+
195
+ await queue.scheduleRecurring("payload", {
196
+ jobId: "interval-details-test",
197
+ intervalSeconds: 60,
198
+ });
199
+
200
+ const details = await queue.getRecurringJobDetails(
201
+ "interval-details-test",
202
+ );
203
+
204
+ expect(details).toBeDefined();
205
+ expect(details?.jobId).toBe("interval-details-test");
206
+ expect("intervalSeconds" in details!).toBe(true);
207
+ if ("intervalSeconds" in details!) {
208
+ expect(details.intervalSeconds).toBe(60);
209
+ }
210
+ });
211
+ });
212
+
213
+ describe("MAX_TIMEOUT handling", () => {
214
+ it("should handle long delays by chunking timeouts", async () => {
215
+ queue = createTestQueue("test-max-timeout");
216
+
217
+ let executionCount = 0;
218
+ await queue.consume(
219
+ async () => {
220
+ executionCount++;
221
+ },
222
+ { consumerGroup: "test", maxRetries: 0 },
223
+ );
224
+
225
+ // Schedule monthly cron (1st of each month at midnight)
226
+ // Starting from Jan 18, next run is Feb 1 = ~14 days
227
+ await queue.scheduleRecurring("payload", {
228
+ jobId: "monthly-cron",
229
+ cronPattern: "0 0 1 * *",
230
+ });
231
+
232
+ // Advance 10 days - should not have executed yet
233
+ jest.advanceTimersByTime(10 * 24 * 60 * 60 * 1000);
234
+ await Promise.resolve();
235
+ expect(executionCount).toBe(0);
236
+
237
+ // Advance 5 more days to pass Feb 1 (total 15 days)
238
+ jest.advanceTimersByTime(5 * 24 * 60 * 60 * 1000);
239
+ await Promise.resolve();
240
+
241
+ // Now it should have executed on Feb 1
242
+ expect(executionCount).toBeGreaterThanOrEqual(1);
243
+ });
244
+
245
+ it("should chunk timeouts longer than MAX_TIMEOUT", async () => {
246
+ queue = createTestQueue("test-max-timeout-chunk");
247
+
248
+ let executionCount = 0;
249
+ await queue.consume(
250
+ async () => {
251
+ executionCount++;
252
+ },
253
+ { consumerGroup: "test", maxRetries: 0 },
254
+ );
255
+
256
+ // Schedule cron for ~30 days from now (past MAX_TIMEOUT of ~24.8 days)
257
+ // Feb 18 at midnight = 31 days from Jan 18
258
+ await queue.scheduleRecurring("payload", {
259
+ jobId: "far-cron",
260
+ cronPattern: "0 0 18 2 *",
261
+ });
262
+
263
+ // Advance 25 days (past MAX_TIMEOUT) - requires chunking internally
264
+ jest.advanceTimersByTime(25 * 24 * 60 * 60 * 1000);
265
+ await Promise.resolve();
266
+ expect(executionCount).toBe(0); // Feb 12 - still not Feb 18
267
+
268
+ // Advance 7 more days to Feb 18
269
+ jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000);
270
+ await Promise.resolve();
271
+
272
+ expect(executionCount).toBeGreaterThanOrEqual(1);
273
+ });
274
+ });
275
+
276
+ describe("Invalid cron patterns", () => {
277
+ it("should log error for invalid cron pattern", async () => {
278
+ queue = createTestQueue("test-invalid-cron");
279
+
280
+ await queue.scheduleRecurring("payload", {
281
+ jobId: "invalid-cron",
282
+ cronPattern: "invalid-pattern",
283
+ });
284
+
285
+ // The error should be logged immediately during scheduleNextCronRun
286
+ expect(mockError).toHaveBeenCalled();
287
+ });
288
+ });
289
+ });
@@ -5,9 +5,11 @@ import {
5
5
  QueueStats,
6
6
  ConsumeOptions,
7
7
  RecurringJobDetails,
8
+ RecurringSchedule,
8
9
  } from "@checkstack/queue-api";
9
10
  import type { Logger } from "@checkstack/backend-api";
10
11
  import { InMemoryQueueConfig } from "./plugin";
12
+ import parser from "cron-parser";
11
13
 
12
14
  /**
13
15
  * Extended queue job with availability tracking for delayed jobs
@@ -62,16 +64,20 @@ interface ConsumerGroupState<T> {
62
64
  }
63
65
 
64
66
  /**
65
- * Recurring job metadata
67
+ * Maximum setTimeout delay (~24.8 days) to avoid overflow
66
68
  */
67
- interface RecurringJobMetadata<T> {
69
+ const MAX_TIMEOUT = 2_147_483_647;
70
+
71
+ /**
72
+ * Recurring job metadata - supports both interval and cron patterns
73
+ */
74
+ type RecurringJobMetadata<T> = {
68
75
  jobId: string;
69
- intervalSeconds: number;
70
76
  payload: T;
71
77
  priority: number;
72
- enabled: boolean; // For cancellation
73
- intervalId?: ReturnType<typeof setInterval>; // For wall-clock scheduling
74
- }
78
+ enabled: boolean;
79
+ timerId?: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>;
80
+ } & RecurringSchedule;
75
81
 
76
82
  /**
77
83
  * In-memory queue implementation with consumer group support
@@ -94,7 +100,7 @@ export class InMemoryQueue<T> implements Queue<T> {
94
100
  constructor(
95
101
  private name: string,
96
102
  private config: InMemoryQueueConfig,
97
- logger: Logger
103
+ logger: Logger,
98
104
  ) {
99
105
  this.semaphore = new Semaphore(config.concurrency);
100
106
  this.logger = logger;
@@ -126,11 +132,11 @@ export class InMemoryQueue<T> implements Queue<T> {
126
132
 
127
133
  async enqueue(
128
134
  data: T,
129
- options?: { priority?: number; startDelay?: number; jobId?: string }
135
+ options?: { priority?: number; startDelay?: number; jobId?: string },
130
136
  ): Promise<string> {
131
137
  if (this.jobs.length >= this.config.maxQueueSize) {
132
138
  throw new Error(
133
- `Queue '${this.name}' is full (max: ${this.config.maxQueueSize})`
139
+ `Queue '${this.name}' is full (max: ${this.config.maxQueueSize})`,
134
140
  );
135
141
  }
136
142
 
@@ -160,7 +166,7 @@ export class InMemoryQueue<T> implements Queue<T> {
160
166
 
161
167
  // Insert job in priority order (higher priority first)
162
168
  const insertIndex = this.jobs.findIndex(
163
- (existingJob) => existingJob.priority! < job.priority!
169
+ (existingJob) => existingJob.priority! < job.priority!,
164
170
  );
165
171
 
166
172
  if (insertIndex === -1) {
@@ -188,7 +194,7 @@ export class InMemoryQueue<T> implements Queue<T> {
188
194
 
189
195
  async consume(
190
196
  consumer: QueueConsumer<T>,
191
- options: ConsumeOptions
197
+ options: ConsumeOptions,
192
198
  ): Promise<void> {
193
199
  const { consumerGroup, maxRetries = 3 } = options;
194
200
 
@@ -220,50 +226,102 @@ export class InMemoryQueue<T> implements Queue<T> {
220
226
  data: T,
221
227
  options: {
222
228
  jobId: string;
223
- intervalSeconds: number;
224
- startDelay?: number;
225
229
  priority?: number;
226
- }
230
+ } & RecurringSchedule,
227
231
  ): Promise<string> {
228
- const { jobId, intervalSeconds, startDelay = 0, priority = 0 } = options;
232
+ const { jobId, priority = 0 } = options;
229
233
 
230
234
  // Check if this is an update to an existing recurring job
231
235
  const existingMetadata = this.recurringJobs.get(jobId);
232
236
  if (existingMetadata) {
233
- // UPDATE CASE: Clear existing interval and pending executions
234
- if (existingMetadata.intervalId) {
235
- clearInterval(existingMetadata.intervalId);
237
+ // UPDATE CASE: Clear existing timer and pending executions
238
+ if (existingMetadata.timerId) {
239
+ if ("cronPattern" in existingMetadata) {
240
+ clearTimeout(existingMetadata.timerId);
241
+ } else {
242
+ clearInterval(existingMetadata.timerId);
243
+ }
236
244
  }
237
245
 
238
246
  // Find and remove any pending jobs for this recurring job
239
247
  this.jobs = this.jobs.filter((job) => {
240
- // Check if this job belongs to the recurring job being updated
241
248
  if (job.id.startsWith(jobId + ":")) {
242
- // Remove from processed sets to prevent orphaned references
243
249
  for (const group of this.consumerGroups.values()) {
244
250
  group.processedJobIds.delete(job.id);
245
251
  }
246
- return false; // Remove this job
252
+ return false;
253
+ }
254
+ return true;
255
+ });
256
+ }
257
+
258
+ // Handle cron-based scheduling
259
+ if ("cronPattern" in options && options.cronPattern) {
260
+ const cronPattern = options.cronPattern;
261
+
262
+ // Wall-clock cron scheduling with MAX_TIMEOUT handling
263
+ const scheduleNextCronRun = () => {
264
+ if (this.stopped) return;
265
+
266
+ const metadata = this.recurringJobs.get(jobId);
267
+ if (!metadata || !metadata.enabled) return;
268
+
269
+ try {
270
+ const interval = parser.parseExpression(cronPattern);
271
+ const nextRun = interval.next().toDate();
272
+ const delayMs = nextRun.getTime() - Date.now();
273
+
274
+ if (delayMs > MAX_TIMEOUT) {
275
+ // Chunk long delays - wake up periodically to recalculate
276
+ metadata.timerId = setTimeout(scheduleNextCronRun, MAX_TIMEOUT);
277
+ return;
278
+ }
279
+
280
+ metadata.timerId = setTimeout(
281
+ () => {
282
+ if (!this.stopped && metadata.enabled) {
283
+ const uniqueId = `${jobId}:${Date.now()}-${Math.random()
284
+ .toString(36)
285
+ .slice(2, 8)}`;
286
+ void this.enqueue(data, { jobId: uniqueId, priority });
287
+ scheduleNextCronRun(); // Reschedule for next cron time
288
+ }
289
+ },
290
+ Math.max(0, delayMs),
291
+ );
292
+ } catch (error) {
293
+ this.logger.error(`Invalid cron pattern "${cronPattern}":`, error);
247
294
  }
248
- return true; // Keep other jobs
295
+ };
296
+
297
+ // Store recurring job metadata
298
+ this.recurringJobs.set(jobId, {
299
+ jobId,
300
+ cronPattern,
301
+ payload: data,
302
+ priority,
303
+ enabled: true,
249
304
  });
305
+
306
+ // Start cron scheduling
307
+ scheduleNextCronRun();
308
+
309
+ return jobId;
250
310
  }
251
311
 
252
- // Calculate interval in ms with delay multiplier
312
+ // Handle interval-based scheduling (original behavior)
313
+ // TypeScript XOR pattern doesn't narrow well, but intervalSeconds is guaranteed here
314
+ const intervalSeconds = options.intervalSeconds!;
253
315
  const intervalMs =
254
316
  intervalSeconds * 1000 * (this.config.delayMultiplier ?? 1);
255
317
 
256
318
  // Create interval for wall-clock scheduling
257
- const intervalId = setInterval(() => {
319
+ const timerId = setInterval(() => {
258
320
  if (!this.stopped) {
259
- // Add random suffix to ensure unique job IDs
260
321
  const uniqueId = `${jobId}:${Date.now()}-${Math.random()
261
322
  .toString(36)
262
323
  .slice(2, 8)}`;
263
- void this.enqueue(data, {
264
- jobId: uniqueId,
265
- priority,
266
- });
324
+ void this.enqueue(data, { jobId: uniqueId, priority });
267
325
  }
268
326
  }, intervalMs);
269
327
 
@@ -274,18 +332,14 @@ export class InMemoryQueue<T> implements Queue<T> {
274
332
  payload: data,
275
333
  priority,
276
334
  enabled: true,
277
- intervalId,
335
+ timerId,
278
336
  });
279
337
 
280
- // Schedule first execution (with optional startDelay)
338
+ // Schedule first execution immediately for interval-based jobs
281
339
  const firstJobId = `${jobId}:${Date.now()}-${Math.random()
282
340
  .toString(36)
283
341
  .slice(2, 8)}`;
284
- await this.enqueue(data, {
285
- jobId: firstJobId,
286
- startDelay,
287
- priority,
288
- });
342
+ await this.enqueue(data, { jobId: firstJobId, priority });
289
343
 
290
344
  return jobId;
291
345
  }
@@ -295,16 +349,19 @@ export class InMemoryQueue<T> implements Queue<T> {
295
349
  if (metadata) {
296
350
  metadata.enabled = false; // Mark as disabled
297
351
 
298
- // Clear the interval timer
299
- if (metadata.intervalId) {
300
- clearInterval(metadata.intervalId);
301
- metadata.intervalId = undefined;
352
+ // Clear the timer (works for both setTimeout and setInterval)
353
+ if (metadata.timerId) {
354
+ if ("cronPattern" in metadata) {
355
+ clearTimeout(metadata.timerId);
356
+ } else {
357
+ clearInterval(metadata.timerId);
358
+ }
359
+ metadata.timerId = undefined;
302
360
  }
303
361
 
304
362
  // Also cancel any pending jobs
305
363
  this.jobs = this.jobs.filter((job) => {
306
364
  if (job.id.startsWith(jobId + ":")) {
307
- // Remove from processed sets
308
365
  for (const group of this.consumerGroups.values()) {
309
366
  group.processedJobIds.delete(job.id);
310
367
  }
@@ -320,18 +377,23 @@ export class InMemoryQueue<T> implements Queue<T> {
320
377
  }
321
378
 
322
379
  async getRecurringJobDetails(
323
- jobId: string
380
+ jobId: string,
324
381
  ): Promise<RecurringJobDetails<T> | undefined> {
325
382
  const metadata = this.recurringJobs.get(jobId);
326
383
  if (!metadata || !metadata.enabled) {
327
384
  return undefined;
328
385
  }
329
- return {
386
+
387
+ const baseDetails = {
330
388
  jobId: metadata.jobId,
331
389
  data: metadata.payload,
332
- intervalSeconds: metadata.intervalSeconds,
333
390
  priority: metadata.priority,
334
391
  };
392
+
393
+ if ("cronPattern" in metadata && metadata.cronPattern) {
394
+ return { ...baseDetails, cronPattern: metadata.cronPattern };
395
+ }
396
+ return { ...baseDetails, intervalSeconds: metadata.intervalSeconds! };
335
397
  }
336
398
 
337
399
  async getInFlightCount(): Promise<number> {
@@ -351,7 +413,7 @@ export class InMemoryQueue<T> implements Queue<T> {
351
413
 
352
414
  // Find next unprocessed job for this group that is available
353
415
  const job = this.jobs.find(
354
- (j) => !groupState.processedJobIds.has(j.id) && j.availableAt <= now
416
+ (j) => !groupState.processedJobIds.has(j.id) && j.availableAt <= now,
355
417
  );
356
418
 
357
419
  if (!job) continue;
@@ -373,7 +435,7 @@ export class InMemoryQueue<T> implements Queue<T> {
373
435
  this.jobs = this.jobs.filter((job) => {
374
436
  // Job is done if all groups have processed it
375
437
  return ![...this.consumerGroups.values()].every((group) =>
376
- group.processedJobIds.has(job.id)
438
+ group.processedJobIds.has(job.id),
377
439
  );
378
440
  });
379
441
  }
@@ -382,7 +444,7 @@ export class InMemoryQueue<T> implements Queue<T> {
382
444
  job: InternalQueueJob<T>,
383
445
  consumer: ConsumerGroupState<T>["consumers"][0],
384
446
  groupId: string,
385
- groupState: ConsumerGroupState<T>
447
+ groupState: ConsumerGroupState<T>,
386
448
  ): Promise<void> {
387
449
  await this.semaphore.acquire();
388
450
  this.processing++;
@@ -395,7 +457,7 @@ export class InMemoryQueue<T> implements Queue<T> {
395
457
  } catch (error) {
396
458
  this.logger.error(
397
459
  `Job ${job.id} failed in group ${groupId} (attempt ${job.attempts}):`,
398
- error
460
+ error,
399
461
  );
400
462
 
401
463
  // Retry logic
@@ -408,7 +470,7 @@ export class InMemoryQueue<T> implements Queue<T> {
408
470
 
409
471
  // Re-add job to queue for retry (with priority to process soon, preserving availableAt)
410
472
  const insertIndex = this.jobs.findIndex(
411
- (existingJob) => existingJob.priority! < (job.priority ?? 0)
473
+ (existingJob) => existingJob.priority! < (job.priority ?? 0),
412
474
  );
413
475
  if (insertIndex === -1) {
414
476
  this.jobs.push(job);
@@ -447,11 +509,15 @@ export class InMemoryQueue<T> implements Queue<T> {
447
509
  this.heartbeatInterval = undefined;
448
510
  }
449
511
 
450
- // Clear all recurring job intervals
512
+ // Clear all recurring job timers (both cron and interval)
451
513
  for (const metadata of this.recurringJobs.values()) {
452
- if (metadata.intervalId) {
453
- clearInterval(metadata.intervalId);
454
- metadata.intervalId = undefined;
514
+ if (metadata.timerId) {
515
+ if ("cronPattern" in metadata) {
516
+ clearTimeout(metadata.timerId);
517
+ } else {
518
+ clearInterval(metadata.timerId);
519
+ }
520
+ metadata.timerId = undefined;
455
521
  }
456
522
  metadata.enabled = false;
457
523
  }