@checkstack/queue-memory-backend 0.2.4 → 0.3.1
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 +37 -0
- package/package.json +4 -3
- package/src/cron-scheduling.test.ts +289 -0
- package/src/memory-queue.ts +119 -53
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# @checkstack/queue-memory-backend
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [db1f56f]
|
|
8
|
+
- @checkstack/common@0.6.0
|
|
9
|
+
- @checkstack/backend-api@0.5.1
|
|
10
|
+
- @checkstack/queue-memory-common@0.1.4
|
|
11
|
+
- @checkstack/queue-api@0.2.1
|
|
12
|
+
|
|
13
|
+
## 0.3.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 2c0822d: ### Queue System
|
|
18
|
+
|
|
19
|
+
- Added cron pattern support to `scheduleRecurring()` - accepts either `intervalSeconds` or `cronPattern`
|
|
20
|
+
- BullMQ backend uses native cron scheduling via `pattern` option
|
|
21
|
+
- InMemoryQueue implements wall-clock cron scheduling with `cron-parser`
|
|
22
|
+
|
|
23
|
+
### Maintenance Backend
|
|
24
|
+
|
|
25
|
+
- Auto status transitions now use cron pattern `* * * * *` for precise second-0 scheduling
|
|
26
|
+
- User notifications are now sent for auto-started and auto-completed maintenances
|
|
27
|
+
- Refactored to call `addUpdate` RPC for status changes, centralizing hook/signal/notification logic
|
|
28
|
+
|
|
29
|
+
### UI
|
|
30
|
+
|
|
31
|
+
- DateTimePicker now resets seconds and milliseconds to 0 when time is changed
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- Updated dependencies [2c0822d]
|
|
36
|
+
- Updated dependencies [66a3963]
|
|
37
|
+
- @checkstack/queue-api@0.2.0
|
|
38
|
+
- @checkstack/backend-api@0.5.0
|
|
39
|
+
|
|
3
40
|
## 0.2.4
|
|
4
41
|
|
|
5
42
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/queue-memory-backend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
-
"
|
|
16
|
-
"
|
|
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
|
+
});
|
package/src/memory-queue.ts
CHANGED
|
@@ -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
|
-
*
|
|
67
|
+
* Maximum setTimeout delay (~24.8 days) to avoid overflow
|
|
66
68
|
*/
|
|
67
|
-
|
|
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;
|
|
73
|
-
|
|
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,
|
|
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
|
|
234
|
-
if (existingMetadata.
|
|
235
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
335
|
+
timerId,
|
|
278
336
|
});
|
|
279
337
|
|
|
280
|
-
// Schedule first execution
|
|
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
|
|
299
|
-
if (metadata.
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
|
512
|
+
// Clear all recurring job timers (both cron and interval)
|
|
451
513
|
for (const metadata of this.recurringJobs.values()) {
|
|
452
|
-
if (metadata.
|
|
453
|
-
|
|
454
|
-
|
|
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
|
}
|