@nicnocquee/dataqueue 1.24.0 → 1.26.0-beta.20260223195940
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/README.md +44 -0
- package/ai/build-docs-content.ts +96 -0
- package/ai/build-llms-full.ts +42 -0
- package/ai/docs-content.json +278 -0
- package/ai/rules/advanced.md +132 -0
- package/ai/rules/basic.md +159 -0
- package/ai/rules/react-dashboard.md +83 -0
- package/ai/skills/dataqueue-advanced/SKILL.md +320 -0
- package/ai/skills/dataqueue-core/SKILL.md +234 -0
- package/ai/skills/dataqueue-react/SKILL.md +189 -0
- package/dist/cli.cjs +1149 -14
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.d.cts +66 -1
- package/dist/cli.d.ts +66 -1
- package/dist/cli.js +1146 -13
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +4630 -928
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1033 -15
- package/dist/index.d.ts +1033 -15
- package/dist/index.js +4626 -929
- package/dist/index.js.map +1 -1
- package/dist/mcp-server.cjs +186 -0
- package/dist/mcp-server.cjs.map +1 -0
- package/dist/mcp-server.d.cts +32 -0
- package/dist/mcp-server.d.ts +32 -0
- package/dist/mcp-server.js +175 -0
- package/dist/mcp-server.js.map +1 -0
- package/migrations/1751131910825_add_timeout_seconds_to_job_queue.sql +2 -2
- package/migrations/1751186053000_add_job_events_table.sql +12 -8
- package/migrations/1751984773000_add_tags_to_job_queue.sql +1 -1
- package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +1 -1
- package/migrations/1771100000000_add_idempotency_key_to_job_queue.sql +7 -0
- package/migrations/1781200000000_add_wait_support.sql +12 -0
- package/migrations/1781200000001_create_waitpoints_table.sql +18 -0
- package/migrations/1781200000002_add_performance_indexes.sql +34 -0
- package/migrations/1781200000003_add_progress_to_job_queue.sql +7 -0
- package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
- package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
- package/package.json +40 -23
- package/src/backend.ts +328 -0
- package/src/backends/postgres.ts +2040 -0
- package/src/backends/redis-scripts.ts +865 -0
- package/src/backends/redis.test.ts +1906 -0
- package/src/backends/redis.ts +1792 -0
- package/src/cli.test.ts +82 -6
- package/src/cli.ts +73 -10
- package/src/cron.test.ts +126 -0
- package/src/cron.ts +40 -0
- package/src/db-util.ts +4 -2
- package/src/index.test.ts +688 -1
- package/src/index.ts +277 -39
- package/src/init-command.test.ts +449 -0
- package/src/init-command.ts +709 -0
- package/src/install-mcp-command.test.ts +216 -0
- package/src/install-mcp-command.ts +185 -0
- package/src/install-rules-command.test.ts +218 -0
- package/src/install-rules-command.ts +233 -0
- package/src/install-skills-command.test.ts +176 -0
- package/src/install-skills-command.ts +124 -0
- package/src/mcp-server.test.ts +162 -0
- package/src/mcp-server.ts +231 -0
- package/src/processor.test.ts +559 -18
- package/src/processor.ts +456 -49
- package/src/queue.test.ts +682 -6
- package/src/queue.ts +135 -944
- package/src/supervisor.test.ts +340 -0
- package/src/supervisor.ts +162 -0
- package/src/test-util.ts +32 -0
- package/src/types.ts +726 -17
- package/src/wait.test.ts +698 -0
- package/LICENSE +0 -21
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { createSupervisor } from './supervisor.js';
|
|
3
|
+
import type { QueueBackend } from './backend.js';
|
|
4
|
+
import type { SupervisorOptions } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Builds a fake {@link QueueBackend} with only the methods the supervisor
|
|
8
|
+
* calls, each backed by a vi.fn() that resolves to 0 by default.
|
|
9
|
+
*/
|
|
10
|
+
function createFakeBackend(overrides: Partial<QueueBackend> = {}) {
|
|
11
|
+
return {
|
|
12
|
+
reclaimStuckJobs: vi.fn().mockResolvedValue(0),
|
|
13
|
+
cleanupOldJobs: vi.fn().mockResolvedValue(0),
|
|
14
|
+
cleanupOldJobEvents: vi.fn().mockResolvedValue(0),
|
|
15
|
+
expireTimedOutWaitpoints: vi.fn().mockResolvedValue(0),
|
|
16
|
+
...overrides,
|
|
17
|
+
} as unknown as QueueBackend;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('createSupervisor', () => {
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
vi.useRealTimers();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('start (one-shot)', () => {
|
|
27
|
+
it('runs all maintenance tasks and returns results', async () => {
|
|
28
|
+
// Setup
|
|
29
|
+
const backend = createFakeBackend({
|
|
30
|
+
reclaimStuckJobs: vi.fn().mockResolvedValue(3),
|
|
31
|
+
cleanupOldJobs: vi.fn().mockResolvedValue(15),
|
|
32
|
+
cleanupOldJobEvents: vi.fn().mockResolvedValue(7),
|
|
33
|
+
expireTimedOutWaitpoints: vi.fn().mockResolvedValue(2),
|
|
34
|
+
});
|
|
35
|
+
const supervisor = createSupervisor(backend);
|
|
36
|
+
|
|
37
|
+
// Act
|
|
38
|
+
const result = await supervisor.start();
|
|
39
|
+
|
|
40
|
+
// Assert
|
|
41
|
+
expect(result).toEqual({
|
|
42
|
+
reclaimedJobs: 3,
|
|
43
|
+
cleanedUpJobs: 15,
|
|
44
|
+
cleanedUpEvents: 7,
|
|
45
|
+
expiredTokens: 2,
|
|
46
|
+
});
|
|
47
|
+
expect(backend.reclaimStuckJobs).toHaveBeenCalledWith(10);
|
|
48
|
+
expect(backend.cleanupOldJobs).toHaveBeenCalledWith(30, 1000);
|
|
49
|
+
expect(backend.cleanupOldJobEvents).toHaveBeenCalledWith(30, 1000);
|
|
50
|
+
expect(backend.expireTimedOutWaitpoints).toHaveBeenCalledOnce();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('passes custom option values to backend methods', async () => {
|
|
54
|
+
// Setup
|
|
55
|
+
const backend = createFakeBackend();
|
|
56
|
+
const supervisor = createSupervisor(backend, {
|
|
57
|
+
stuckJobsTimeoutMinutes: 20,
|
|
58
|
+
cleanupJobsDaysToKeep: 60,
|
|
59
|
+
cleanupEventsDaysToKeep: 14,
|
|
60
|
+
cleanupBatchSize: 500,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Act
|
|
64
|
+
await supervisor.start();
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect(backend.reclaimStuckJobs).toHaveBeenCalledWith(20);
|
|
68
|
+
expect(backend.cleanupOldJobs).toHaveBeenCalledWith(60, 500);
|
|
69
|
+
expect(backend.cleanupOldJobEvents).toHaveBeenCalledWith(14, 500);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('skips reclaimStuckJobs when disabled', async () => {
|
|
73
|
+
// Setup
|
|
74
|
+
const backend = createFakeBackend();
|
|
75
|
+
const supervisor = createSupervisor(backend, {
|
|
76
|
+
reclaimStuckJobs: false,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Act
|
|
80
|
+
const result = await supervisor.start();
|
|
81
|
+
|
|
82
|
+
// Assert
|
|
83
|
+
expect(backend.reclaimStuckJobs).not.toHaveBeenCalled();
|
|
84
|
+
expect(result.reclaimedJobs).toBe(0);
|
|
85
|
+
expect(backend.cleanupOldJobs).toHaveBeenCalledOnce();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('skips cleanupOldJobs when cleanupJobsDaysToKeep is 0', async () => {
|
|
89
|
+
// Setup
|
|
90
|
+
const backend = createFakeBackend();
|
|
91
|
+
const supervisor = createSupervisor(backend, {
|
|
92
|
+
cleanupJobsDaysToKeep: 0,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Act
|
|
96
|
+
const result = await supervisor.start();
|
|
97
|
+
|
|
98
|
+
// Assert
|
|
99
|
+
expect(backend.cleanupOldJobs).not.toHaveBeenCalled();
|
|
100
|
+
expect(result.cleanedUpJobs).toBe(0);
|
|
101
|
+
expect(backend.reclaimStuckJobs).toHaveBeenCalledOnce();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('skips cleanupOldJobEvents when cleanupEventsDaysToKeep is 0', async () => {
|
|
105
|
+
// Setup
|
|
106
|
+
const backend = createFakeBackend();
|
|
107
|
+
const supervisor = createSupervisor(backend, {
|
|
108
|
+
cleanupEventsDaysToKeep: 0,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Act
|
|
112
|
+
const result = await supervisor.start();
|
|
113
|
+
|
|
114
|
+
// Assert
|
|
115
|
+
expect(backend.cleanupOldJobEvents).not.toHaveBeenCalled();
|
|
116
|
+
expect(result.cleanedUpEvents).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('skips expireTimedOutTokens when disabled', async () => {
|
|
120
|
+
// Setup
|
|
121
|
+
const backend = createFakeBackend();
|
|
122
|
+
const supervisor = createSupervisor(backend, {
|
|
123
|
+
expireTimedOutTokens: false,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Act
|
|
127
|
+
const result = await supervisor.start();
|
|
128
|
+
|
|
129
|
+
// Assert
|
|
130
|
+
expect(backend.expireTimedOutWaitpoints).not.toHaveBeenCalled();
|
|
131
|
+
expect(result.expiredTokens).toBe(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('calls onError and continues when a task throws', async () => {
|
|
135
|
+
// Setup
|
|
136
|
+
const onError = vi.fn();
|
|
137
|
+
const taskError = new Error('reclaim failed');
|
|
138
|
+
const backend = createFakeBackend({
|
|
139
|
+
reclaimStuckJobs: vi.fn().mockRejectedValue(taskError),
|
|
140
|
+
cleanupOldJobs: vi.fn().mockResolvedValue(5),
|
|
141
|
+
cleanupOldJobEvents: vi
|
|
142
|
+
.fn()
|
|
143
|
+
.mockRejectedValue(new Error('events boom')),
|
|
144
|
+
expireTimedOutWaitpoints: vi.fn().mockResolvedValue(1),
|
|
145
|
+
});
|
|
146
|
+
const supervisor = createSupervisor(backend, { onError });
|
|
147
|
+
|
|
148
|
+
// Act
|
|
149
|
+
const result = await supervisor.start();
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
152
|
+
expect(onError).toHaveBeenCalledTimes(2);
|
|
153
|
+
expect(onError).toHaveBeenCalledWith(taskError);
|
|
154
|
+
expect(result.reclaimedJobs).toBe(0);
|
|
155
|
+
expect(result.cleanedUpJobs).toBe(5);
|
|
156
|
+
expect(result.cleanedUpEvents).toBe(0);
|
|
157
|
+
expect(result.expiredTokens).toBe(1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('wraps non-Error throws in an Error object', async () => {
|
|
161
|
+
// Setup
|
|
162
|
+
const onError = vi.fn();
|
|
163
|
+
const backend = createFakeBackend({
|
|
164
|
+
reclaimStuckJobs: vi.fn().mockRejectedValue('string error'),
|
|
165
|
+
});
|
|
166
|
+
const supervisor = createSupervisor(backend, { onError });
|
|
167
|
+
|
|
168
|
+
// Act
|
|
169
|
+
await supervisor.start();
|
|
170
|
+
|
|
171
|
+
// Assert
|
|
172
|
+
expect(onError).toHaveBeenCalledWith(expect.any(Error));
|
|
173
|
+
expect(onError.mock.calls[0][0].message).toBe('string error');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('startInBackground / stop', () => {
|
|
178
|
+
it('polls on interval and can be stopped', async () => {
|
|
179
|
+
// Setup
|
|
180
|
+
vi.useFakeTimers();
|
|
181
|
+
const backend = createFakeBackend();
|
|
182
|
+
const supervisor = createSupervisor(backend, { intervalMs: 1000 });
|
|
183
|
+
|
|
184
|
+
// Act
|
|
185
|
+
supervisor.startInBackground();
|
|
186
|
+
expect(supervisor.isRunning()).toBe(true);
|
|
187
|
+
|
|
188
|
+
// First run is immediate (microtask)
|
|
189
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
190
|
+
expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(1);
|
|
191
|
+
|
|
192
|
+
// Advance to trigger second run
|
|
193
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
194
|
+
expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(2);
|
|
195
|
+
|
|
196
|
+
// Advance to trigger third run
|
|
197
|
+
await vi.advanceTimersByTimeAsync(1000);
|
|
198
|
+
expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(3);
|
|
199
|
+
|
|
200
|
+
// Stop
|
|
201
|
+
supervisor.stop();
|
|
202
|
+
expect(supervisor.isRunning()).toBe(false);
|
|
203
|
+
|
|
204
|
+
// No more runs after stop
|
|
205
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
206
|
+
expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(3);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('does nothing when called while already running', async () => {
|
|
210
|
+
// Setup
|
|
211
|
+
vi.useFakeTimers();
|
|
212
|
+
const backend = createFakeBackend();
|
|
213
|
+
const supervisor = createSupervisor(backend, { intervalMs: 1000 });
|
|
214
|
+
|
|
215
|
+
// Act
|
|
216
|
+
supervisor.startInBackground();
|
|
217
|
+
supervisor.startInBackground(); // second call should be ignored
|
|
218
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
219
|
+
|
|
220
|
+
// Assert -- only one loop running, single call
|
|
221
|
+
expect(backend.reclaimStuckJobs).toHaveBeenCalledTimes(1);
|
|
222
|
+
|
|
223
|
+
supervisor.stop();
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('stopAndDrain', () => {
|
|
228
|
+
it('waits for current maintenance run to finish', async () => {
|
|
229
|
+
// Setup
|
|
230
|
+
vi.useFakeTimers();
|
|
231
|
+
let resolveTask!: () => void;
|
|
232
|
+
const slowTask = new Promise<number>((resolve) => {
|
|
233
|
+
resolveTask = () => resolve(2);
|
|
234
|
+
});
|
|
235
|
+
const backend = createFakeBackend({
|
|
236
|
+
reclaimStuckJobs: vi.fn().mockReturnValue(slowTask),
|
|
237
|
+
});
|
|
238
|
+
const supervisor = createSupervisor(backend, { intervalMs: 1000 });
|
|
239
|
+
|
|
240
|
+
// Act
|
|
241
|
+
supervisor.startInBackground();
|
|
242
|
+
// Let the loop start (but reclaimStuckJobs is blocked)
|
|
243
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
244
|
+
|
|
245
|
+
let drained = false;
|
|
246
|
+
const drainPromise = supervisor.stopAndDrain().then(() => {
|
|
247
|
+
drained = true;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Assert -- not drained yet because slowTask is still pending
|
|
251
|
+
expect(drained).toBe(false);
|
|
252
|
+
expect(supervisor.isRunning()).toBe(false);
|
|
253
|
+
|
|
254
|
+
// Resolve the slow task
|
|
255
|
+
resolveTask();
|
|
256
|
+
await drainPromise;
|
|
257
|
+
|
|
258
|
+
// Assert -- now drained
|
|
259
|
+
expect(drained).toBe(true);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('resolves immediately when no maintenance run is in progress', async () => {
|
|
263
|
+
// Setup
|
|
264
|
+
const backend = createFakeBackend();
|
|
265
|
+
const supervisor = createSupervisor(backend);
|
|
266
|
+
|
|
267
|
+
// Act & Assert
|
|
268
|
+
await expect(supervisor.stopAndDrain()).resolves.toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('resolves after timeout if maintenance run hangs', async () => {
|
|
272
|
+
// Setup
|
|
273
|
+
vi.useFakeTimers();
|
|
274
|
+
const neverResolve = new Promise<number>(() => {});
|
|
275
|
+
const backend = createFakeBackend({
|
|
276
|
+
reclaimStuckJobs: vi.fn().mockReturnValue(neverResolve),
|
|
277
|
+
});
|
|
278
|
+
const supervisor = createSupervisor(backend, { intervalMs: 5000 });
|
|
279
|
+
|
|
280
|
+
// Act
|
|
281
|
+
supervisor.startInBackground();
|
|
282
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
283
|
+
|
|
284
|
+
let drained = false;
|
|
285
|
+
const drainPromise = supervisor.stopAndDrain(500).then(() => {
|
|
286
|
+
drained = true;
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Assert -- not drained yet
|
|
290
|
+
expect(drained).toBe(false);
|
|
291
|
+
|
|
292
|
+
// Advance past the drain timeout
|
|
293
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
294
|
+
await drainPromise;
|
|
295
|
+
|
|
296
|
+
// Assert -- drained by timeout
|
|
297
|
+
expect(drained).toBe(true);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe('isRunning', () => {
|
|
302
|
+
it('returns false before start', () => {
|
|
303
|
+
// Setup
|
|
304
|
+
const backend = createFakeBackend();
|
|
305
|
+
const supervisor = createSupervisor(backend);
|
|
306
|
+
|
|
307
|
+
// Assert
|
|
308
|
+
expect(supervisor.isRunning()).toBe(false);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('returns true after startInBackground', async () => {
|
|
312
|
+
// Setup
|
|
313
|
+
vi.useFakeTimers();
|
|
314
|
+
const backend = createFakeBackend();
|
|
315
|
+
const supervisor = createSupervisor(backend);
|
|
316
|
+
|
|
317
|
+
// Act
|
|
318
|
+
supervisor.startInBackground();
|
|
319
|
+
|
|
320
|
+
// Assert
|
|
321
|
+
expect(supervisor.isRunning()).toBe(true);
|
|
322
|
+
|
|
323
|
+
supervisor.stop();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('returns false after stop', async () => {
|
|
327
|
+
// Setup
|
|
328
|
+
vi.useFakeTimers();
|
|
329
|
+
const backend = createFakeBackend();
|
|
330
|
+
const supervisor = createSupervisor(backend);
|
|
331
|
+
|
|
332
|
+
// Act
|
|
333
|
+
supervisor.startInBackground();
|
|
334
|
+
supervisor.stop();
|
|
335
|
+
|
|
336
|
+
// Assert
|
|
337
|
+
expect(supervisor.isRunning()).toBe(false);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { QueueBackend } from './backend.js';
|
|
2
|
+
import { setLogContext, log } from './log-context.js';
|
|
3
|
+
import { Supervisor, SupervisorOptions, SupervisorRunResult } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a background supervisor that periodically runs maintenance tasks:
|
|
7
|
+
* reclaiming stuck jobs, cleaning up old jobs/events, and expiring
|
|
8
|
+
* timed-out waitpoint tokens.
|
|
9
|
+
*
|
|
10
|
+
* @param backend - The queue backend (Postgres or Redis) to run maintenance against.
|
|
11
|
+
* @param options - Configuration for intervals, retention, and feature toggles.
|
|
12
|
+
* @returns A {@link Supervisor} with `start`, `startInBackground`, `stop`,
|
|
13
|
+
* `stopAndDrain`, and `isRunning` methods.
|
|
14
|
+
*/
|
|
15
|
+
export const createSupervisor = (
|
|
16
|
+
backend: QueueBackend,
|
|
17
|
+
options: SupervisorOptions = {},
|
|
18
|
+
): Supervisor => {
|
|
19
|
+
const {
|
|
20
|
+
intervalMs = 60_000,
|
|
21
|
+
stuckJobsTimeoutMinutes = 10,
|
|
22
|
+
cleanupJobsDaysToKeep = 30,
|
|
23
|
+
cleanupEventsDaysToKeep = 30,
|
|
24
|
+
cleanupBatchSize = 1000,
|
|
25
|
+
reclaimStuckJobs = true,
|
|
26
|
+
expireTimedOutTokens = true,
|
|
27
|
+
onError = (error: Error) =>
|
|
28
|
+
console.error('Supervisor maintenance error:', error),
|
|
29
|
+
verbose = false,
|
|
30
|
+
} = options;
|
|
31
|
+
|
|
32
|
+
let running = false;
|
|
33
|
+
let timeoutId: NodeJS.Timeout | null = null;
|
|
34
|
+
let currentRunPromise: Promise<SupervisorRunResult> | null = null;
|
|
35
|
+
|
|
36
|
+
setLogContext(verbose);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Executes every maintenance task once, isolating failures so one
|
|
40
|
+
* broken task does not prevent the others from running.
|
|
41
|
+
*/
|
|
42
|
+
const runOnce = async (): Promise<SupervisorRunResult> => {
|
|
43
|
+
setLogContext(verbose);
|
|
44
|
+
|
|
45
|
+
const result: SupervisorRunResult = {
|
|
46
|
+
reclaimedJobs: 0,
|
|
47
|
+
cleanedUpJobs: 0,
|
|
48
|
+
cleanedUpEvents: 0,
|
|
49
|
+
expiredTokens: 0,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (reclaimStuckJobs) {
|
|
53
|
+
try {
|
|
54
|
+
result.reclaimedJobs = await backend.reclaimStuckJobs(
|
|
55
|
+
stuckJobsTimeoutMinutes,
|
|
56
|
+
);
|
|
57
|
+
if (result.reclaimedJobs > 0) {
|
|
58
|
+
log(`Supervisor: reclaimed ${result.reclaimedJobs} stuck jobs`);
|
|
59
|
+
}
|
|
60
|
+
} catch (e) {
|
|
61
|
+
onError(e instanceof Error ? e : new Error(String(e)));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (cleanupJobsDaysToKeep > 0) {
|
|
66
|
+
try {
|
|
67
|
+
result.cleanedUpJobs = await backend.cleanupOldJobs(
|
|
68
|
+
cleanupJobsDaysToKeep,
|
|
69
|
+
cleanupBatchSize,
|
|
70
|
+
);
|
|
71
|
+
if (result.cleanedUpJobs > 0) {
|
|
72
|
+
log(`Supervisor: cleaned up ${result.cleanedUpJobs} old jobs`);
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
onError(e instanceof Error ? e : new Error(String(e)));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (cleanupEventsDaysToKeep > 0) {
|
|
80
|
+
try {
|
|
81
|
+
result.cleanedUpEvents = await backend.cleanupOldJobEvents(
|
|
82
|
+
cleanupEventsDaysToKeep,
|
|
83
|
+
cleanupBatchSize,
|
|
84
|
+
);
|
|
85
|
+
if (result.cleanedUpEvents > 0) {
|
|
86
|
+
log(
|
|
87
|
+
`Supervisor: cleaned up ${result.cleanedUpEvents} old job events`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
onError(e instanceof Error ? e : new Error(String(e)));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (expireTimedOutTokens) {
|
|
96
|
+
try {
|
|
97
|
+
result.expiredTokens = await backend.expireTimedOutWaitpoints();
|
|
98
|
+
if (result.expiredTokens > 0) {
|
|
99
|
+
log(`Supervisor: expired ${result.expiredTokens} timed-out tokens`);
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
onError(e instanceof Error ? e : new Error(String(e)));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
start: async () => {
|
|
111
|
+
return runOnce();
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
startInBackground: () => {
|
|
115
|
+
if (running) return;
|
|
116
|
+
log('Supervisor: starting background maintenance loop');
|
|
117
|
+
running = true;
|
|
118
|
+
|
|
119
|
+
const loop = async () => {
|
|
120
|
+
if (!running) return;
|
|
121
|
+
currentRunPromise = runOnce();
|
|
122
|
+
await currentRunPromise;
|
|
123
|
+
currentRunPromise = null;
|
|
124
|
+
if (running) {
|
|
125
|
+
timeoutId = setTimeout(loop, intervalMs);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
loop();
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
stop: () => {
|
|
133
|
+
running = false;
|
|
134
|
+
if (timeoutId !== null) {
|
|
135
|
+
clearTimeout(timeoutId);
|
|
136
|
+
timeoutId = null;
|
|
137
|
+
}
|
|
138
|
+
log('Supervisor: stopped');
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
stopAndDrain: async (timeoutMs = 30_000) => {
|
|
142
|
+
running = false;
|
|
143
|
+
if (timeoutId !== null) {
|
|
144
|
+
clearTimeout(timeoutId);
|
|
145
|
+
timeoutId = null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (currentRunPromise) {
|
|
149
|
+
log('Supervisor: draining current maintenance run…');
|
|
150
|
+
await Promise.race([
|
|
151
|
+
currentRunPromise,
|
|
152
|
+
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
|
|
153
|
+
]);
|
|
154
|
+
currentRunPromise = null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
log('Supervisor: drained and stopped');
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
isRunning: () => running,
|
|
161
|
+
};
|
|
162
|
+
};
|
package/src/test-util.ts
CHANGED
|
@@ -65,3 +65,35 @@ export async function destroyTestDb(dbName: string) {
|
|
|
65
65
|
await adminPool.query(`DROP DATABASE IF EXISTS ${dbName}`);
|
|
66
66
|
await adminPool.end();
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a Redis test setup with a unique prefix to isolate tests.
|
|
71
|
+
* Returns the prefix and a cleanup function.
|
|
72
|
+
*/
|
|
73
|
+
export function createRedisTestPrefix(): string {
|
|
74
|
+
return `test_${randomUUID().replace(/-/g, '').slice(0, 12)}:`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Flush all keys with the given prefix from Redis.
|
|
79
|
+
*/
|
|
80
|
+
export async function cleanupRedisPrefix(
|
|
81
|
+
redisClient: any,
|
|
82
|
+
prefix: string,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
// Use SCAN to find all keys with the prefix and delete them
|
|
85
|
+
let cursor = '0';
|
|
86
|
+
do {
|
|
87
|
+
const [nextCursor, keys] = await redisClient.scan(
|
|
88
|
+
cursor,
|
|
89
|
+
'MATCH',
|
|
90
|
+
`${prefix}*`,
|
|
91
|
+
'COUNT',
|
|
92
|
+
100,
|
|
93
|
+
);
|
|
94
|
+
cursor = nextCursor;
|
|
95
|
+
if (keys.length > 0) {
|
|
96
|
+
await redisClient.del(...keys);
|
|
97
|
+
}
|
|
98
|
+
} while (cursor !== '0');
|
|
99
|
+
}
|