@nicnocquee/dataqueue 1.25.0 → 1.26.0-beta.20260223202259
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/ai/build-docs-content.ts +96 -0
- package/ai/build-llms-full.ts +42 -0
- package/ai/docs-content.json +284 -0
- package/ai/rules/advanced.md +150 -0
- package/ai/rules/basic.md +159 -0
- package/ai/rules/react-dashboard.md +83 -0
- package/ai/skills/dataqueue-advanced/SKILL.md +370 -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 +3236 -1237
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +697 -23
- package/dist/index.d.ts +697 -23
- package/dist/index.js +3235 -1238
- 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/1781200000004_create_cron_schedules_table.sql +33 -0
- package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
- package/package.json +24 -21
- package/src/backend.ts +170 -5
- package/src/backends/postgres.ts +992 -63
- package/src/backends/redis-scripts.ts +358 -26
- package/src/backends/redis.test.ts +1532 -0
- package/src/backends/redis.ts +993 -35
- 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 +1 -1
- package/src/index.test.ts +1034 -11
- package/src/index.ts +267 -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.ts +104 -113
- package/src/queue.test.ts +465 -0
- package/src/queue.ts +34 -252
- package/src/supervisor.test.ts +340 -0
- package/src/supervisor.ts +177 -0
- package/src/types.ts +476 -12
- package/LICENSE +0 -21
package/src/queue.ts
CHANGED
|
@@ -16,10 +16,9 @@ import {
|
|
|
16
16
|
JobEventType,
|
|
17
17
|
TagQueryMode,
|
|
18
18
|
WaitpointRecord,
|
|
19
|
+
AddJobOptions,
|
|
19
20
|
} from './types.js';
|
|
20
21
|
import { PostgresBackend } from './backends/postgres.js';
|
|
21
|
-
import { randomUUID } from 'crypto';
|
|
22
|
-
import { log } from './log-context.js';
|
|
23
22
|
|
|
24
23
|
/* Thin wrappers — every function creates a lightweight backend wrapper
|
|
25
24
|
around the given pool and forwards the call. The class itself holds
|
|
@@ -36,7 +35,14 @@ export const recordJobEvent = async (
|
|
|
36
35
|
export const addJob = async <PayloadMap, T extends keyof PayloadMap & string>(
|
|
37
36
|
pool: Pool,
|
|
38
37
|
job: JobOptions<PayloadMap, T>,
|
|
39
|
-
|
|
38
|
+
options?: AddJobOptions,
|
|
39
|
+
): Promise<number> => new PostgresBackend(pool).addJob(job, options);
|
|
40
|
+
|
|
41
|
+
export const addJobs = async <PayloadMap, T extends keyof PayloadMap & string>(
|
|
42
|
+
pool: Pool,
|
|
43
|
+
jobs: JobOptions<PayloadMap, T>[],
|
|
44
|
+
options?: AddJobOptions,
|
|
45
|
+
): Promise<number[]> => new PostgresBackend(pool).addJobs(jobs, options);
|
|
40
46
|
|
|
41
47
|
export const getJob = async <PayloadMap, T extends keyof PayloadMap & string>(
|
|
42
48
|
pool: Pool,
|
|
@@ -94,7 +100,9 @@ export const retryJob = async (pool: Pool, jobId: number): Promise<void> =>
|
|
|
94
100
|
export const cleanupOldJobs = async (
|
|
95
101
|
pool: Pool,
|
|
96
102
|
daysToKeep = 30,
|
|
97
|
-
|
|
103
|
+
batchSize = 1000,
|
|
104
|
+
): Promise<number> =>
|
|
105
|
+
new PostgresBackend(pool).cleanupOldJobs(daysToKeep, batchSize);
|
|
98
106
|
|
|
99
107
|
export const cancelJob = async (pool: Pool, jobId: number): Promise<void> =>
|
|
100
108
|
new PostgresBackend(pool).cancelJob(jobId);
|
|
@@ -109,6 +117,9 @@ export const editJob = async <PayloadMap, T extends keyof PayloadMap & string>(
|
|
|
109
117
|
runAt?: Date | null;
|
|
110
118
|
timeoutMs?: number | null;
|
|
111
119
|
tags?: string[] | null;
|
|
120
|
+
retryDelay?: number | null;
|
|
121
|
+
retryBackoff?: boolean | null;
|
|
122
|
+
retryDelayMax?: number | null;
|
|
112
123
|
},
|
|
113
124
|
): Promise<void> => new PostgresBackend(pool).editJob(jobId, updates);
|
|
114
125
|
|
|
@@ -134,6 +145,9 @@ export const editAllPendingJobs = async <
|
|
|
134
145
|
runAt?: Date | null;
|
|
135
146
|
timeoutMs?: number;
|
|
136
147
|
tags?: string[];
|
|
148
|
+
retryDelay?: number | null;
|
|
149
|
+
retryBackoff?: boolean | null;
|
|
150
|
+
retryDelayMax?: number | null;
|
|
137
151
|
},
|
|
138
152
|
): Promise<number> =>
|
|
139
153
|
new PostgresBackend(pool).editAllPendingJobs(filters, updates);
|
|
@@ -214,12 +228,9 @@ export const updateProgress = async (
|
|
|
214
228
|
progress: number,
|
|
215
229
|
): Promise<void> => new PostgresBackend(pool).updateProgress(jobId, progress);
|
|
216
230
|
|
|
217
|
-
// ── Wait support functions (
|
|
231
|
+
// ── Wait support functions (backward-compatible delegates) ────────────────────
|
|
218
232
|
|
|
219
|
-
/**
|
|
220
|
-
* Transition a job to 'waiting' status with wait_until and/or wait_token_id.
|
|
221
|
-
* Saves step_data so the handler can resume from where it left off.
|
|
222
|
-
*/
|
|
233
|
+
/** @deprecated Use backend.waitJob() directly. Delegates to PostgresBackend. */
|
|
223
234
|
export const waitJob = async (
|
|
224
235
|
pool: Pool,
|
|
225
236
|
jobId: number,
|
|
@@ -228,266 +239,37 @@ export const waitJob = async (
|
|
|
228
239
|
waitTokenId?: string;
|
|
229
240
|
stepData: Record<string, any>;
|
|
230
241
|
},
|
|
231
|
-
): Promise<void> =>
|
|
232
|
-
const client = await pool.connect();
|
|
233
|
-
try {
|
|
234
|
-
const result = await client.query(
|
|
235
|
-
`
|
|
236
|
-
UPDATE job_queue
|
|
237
|
-
SET status = 'waiting',
|
|
238
|
-
wait_until = $2,
|
|
239
|
-
wait_token_id = $3,
|
|
240
|
-
step_data = $4,
|
|
241
|
-
locked_at = NULL,
|
|
242
|
-
locked_by = NULL,
|
|
243
|
-
updated_at = NOW()
|
|
244
|
-
WHERE id = $1 AND status = 'processing'
|
|
245
|
-
`,
|
|
246
|
-
[
|
|
247
|
-
jobId,
|
|
248
|
-
options.waitUntil ?? null,
|
|
249
|
-
options.waitTokenId ?? null,
|
|
250
|
-
JSON.stringify(options.stepData),
|
|
251
|
-
],
|
|
252
|
-
);
|
|
253
|
-
if (result.rowCount === 0) {
|
|
254
|
-
log(
|
|
255
|
-
`Job ${jobId} could not be set to waiting (may have been reclaimed or is no longer processing)`,
|
|
256
|
-
);
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
await recordJobEvent(pool, jobId, JobEventType.Waiting, {
|
|
260
|
-
waitUntil: options.waitUntil?.toISOString() ?? null,
|
|
261
|
-
waitTokenId: options.waitTokenId ?? null,
|
|
262
|
-
});
|
|
263
|
-
log(`Job ${jobId} set to waiting`);
|
|
264
|
-
} catch (error) {
|
|
265
|
-
log(`Error setting job ${jobId} to waiting: ${error}`);
|
|
266
|
-
throw error;
|
|
267
|
-
} finally {
|
|
268
|
-
client.release();
|
|
269
|
-
}
|
|
270
|
-
};
|
|
242
|
+
): Promise<void> => new PostgresBackend(pool).waitJob(jobId, options);
|
|
271
243
|
|
|
272
|
-
/**
|
|
273
|
-
* Update step_data for a job. Called after each ctx.run() step completes
|
|
274
|
-
* to persist intermediate progress.
|
|
275
|
-
*/
|
|
244
|
+
/** @deprecated Use backend.updateStepData() directly. Delegates to PostgresBackend. */
|
|
276
245
|
export const updateStepData = async (
|
|
277
246
|
pool: Pool,
|
|
278
247
|
jobId: number,
|
|
279
248
|
stepData: Record<string, any>,
|
|
280
|
-
): Promise<void> =>
|
|
281
|
-
const client = await pool.connect();
|
|
282
|
-
try {
|
|
283
|
-
await client.query(
|
|
284
|
-
`UPDATE job_queue SET step_data = $2, updated_at = NOW() WHERE id = $1`,
|
|
285
|
-
[jobId, JSON.stringify(stepData)],
|
|
286
|
-
);
|
|
287
|
-
} catch (error) {
|
|
288
|
-
log(`Error updating step_data for job ${jobId}: ${error}`);
|
|
289
|
-
// Best-effort: do not throw to avoid killing the running handler
|
|
290
|
-
} finally {
|
|
291
|
-
client.release();
|
|
292
|
-
}
|
|
293
|
-
};
|
|
249
|
+
): Promise<void> => new PostgresBackend(pool).updateStepData(jobId, stepData);
|
|
294
250
|
|
|
295
|
-
/**
|
|
296
|
-
* Parse a timeout string like '10m', '1h', '24h', '7d' into milliseconds.
|
|
297
|
-
*/
|
|
298
|
-
/**
|
|
299
|
-
* Maximum allowed timeout in milliseconds (~365 days).
|
|
300
|
-
* Prevents overflow to Infinity when computing Date offsets.
|
|
301
|
-
*/
|
|
302
|
-
const MAX_TIMEOUT_MS = 365 * 24 * 60 * 60 * 1000;
|
|
303
|
-
|
|
304
|
-
function parseTimeoutString(timeout: string): number {
|
|
305
|
-
const match = timeout.match(/^(\d+)(s|m|h|d)$/);
|
|
306
|
-
if (!match) {
|
|
307
|
-
throw new Error(
|
|
308
|
-
`Invalid timeout format: "${timeout}". Expected format like "10m", "1h", "24h", "7d".`,
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
const value = parseInt(match[1], 10);
|
|
312
|
-
const unit = match[2];
|
|
313
|
-
let ms: number;
|
|
314
|
-
switch (unit) {
|
|
315
|
-
case 's':
|
|
316
|
-
ms = value * 1000;
|
|
317
|
-
break;
|
|
318
|
-
case 'm':
|
|
319
|
-
ms = value * 60 * 1000;
|
|
320
|
-
break;
|
|
321
|
-
case 'h':
|
|
322
|
-
ms = value * 60 * 60 * 1000;
|
|
323
|
-
break;
|
|
324
|
-
case 'd':
|
|
325
|
-
ms = value * 24 * 60 * 60 * 1000;
|
|
326
|
-
break;
|
|
327
|
-
default:
|
|
328
|
-
throw new Error(`Unknown timeout unit: "${unit}"`);
|
|
329
|
-
}
|
|
330
|
-
if (!Number.isFinite(ms) || ms > MAX_TIMEOUT_MS) {
|
|
331
|
-
throw new Error(
|
|
332
|
-
`Timeout value "${timeout}" is too large. Maximum allowed is 365 days.`,
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
return ms;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Create a waitpoint token in the database.
|
|
340
|
-
* The token can be used to pause a job until an external signal completes it.
|
|
341
|
-
*
|
|
342
|
-
* @param pool - The database pool
|
|
343
|
-
* @param jobId - The job ID to associate with the token (null if created outside a handler)
|
|
344
|
-
* @param options - Optional timeout and tags
|
|
345
|
-
* @returns The created waitpoint token
|
|
346
|
-
*/
|
|
251
|
+
/** @deprecated Use backend.createWaitpoint() directly. Delegates to PostgresBackend. */
|
|
347
252
|
export const createWaitpoint = async (
|
|
348
253
|
pool: Pool,
|
|
349
254
|
jobId: number | null,
|
|
350
255
|
options?: { timeout?: string; tags?: string[] },
|
|
351
|
-
): Promise<{ id: string }> =>
|
|
352
|
-
|
|
353
|
-
try {
|
|
354
|
-
const id = `wp_${randomUUID()}`;
|
|
355
|
-
let timeoutAt: Date | null = null;
|
|
356
|
-
|
|
357
|
-
if (options?.timeout) {
|
|
358
|
-
const ms = parseTimeoutString(options.timeout);
|
|
359
|
-
timeoutAt = new Date(Date.now() + ms);
|
|
360
|
-
}
|
|
256
|
+
): Promise<{ id: string }> =>
|
|
257
|
+
new PostgresBackend(pool).createWaitpoint(jobId, options);
|
|
361
258
|
|
|
362
|
-
|
|
363
|
-
`INSERT INTO waitpoints (id, job_id, status, timeout_at, tags) VALUES ($1, $2, 'waiting', $3, $4)`,
|
|
364
|
-
[id, jobId, timeoutAt, options?.tags ?? null],
|
|
365
|
-
);
|
|
366
|
-
|
|
367
|
-
log(`Created waitpoint ${id} for job ${jobId}`);
|
|
368
|
-
return { id };
|
|
369
|
-
} catch (error) {
|
|
370
|
-
log(`Error creating waitpoint: ${error}`);
|
|
371
|
-
throw error;
|
|
372
|
-
} finally {
|
|
373
|
-
client.release();
|
|
374
|
-
}
|
|
375
|
-
};
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Complete a waitpoint token, optionally providing output data.
|
|
379
|
-
* This also moves the associated job from 'waiting' back to 'pending' so
|
|
380
|
-
* it gets picked up by the polling loop.
|
|
381
|
-
*/
|
|
259
|
+
/** @deprecated Use backend.completeWaitpoint() directly. Delegates to PostgresBackend. */
|
|
382
260
|
export const completeWaitpoint = async (
|
|
383
261
|
pool: Pool,
|
|
384
262
|
tokenId: string,
|
|
385
263
|
data?: any,
|
|
386
|
-
): Promise<void> =>
|
|
387
|
-
const client = await pool.connect();
|
|
388
|
-
try {
|
|
389
|
-
await client.query('BEGIN');
|
|
390
|
-
|
|
391
|
-
// Update the waitpoint
|
|
392
|
-
const wpResult = await client.query(
|
|
393
|
-
`UPDATE waitpoints SET status = 'completed', output = $2, completed_at = NOW()
|
|
394
|
-
WHERE id = $1 AND status = 'waiting'
|
|
395
|
-
RETURNING job_id`,
|
|
396
|
-
[tokenId, data != null ? JSON.stringify(data) : null],
|
|
397
|
-
);
|
|
398
|
-
|
|
399
|
-
if (wpResult.rows.length === 0) {
|
|
400
|
-
await client.query('ROLLBACK');
|
|
401
|
-
log(`Waitpoint ${tokenId} not found or already completed`);
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const jobId = wpResult.rows[0].job_id;
|
|
406
|
-
|
|
407
|
-
// Move the associated job back to 'pending' so it gets picked up
|
|
408
|
-
if (jobId != null) {
|
|
409
|
-
await client.query(
|
|
410
|
-
`UPDATE job_queue
|
|
411
|
-
SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
|
|
412
|
-
WHERE id = $1 AND status = 'waiting'`,
|
|
413
|
-
[jobId],
|
|
414
|
-
);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
await client.query('COMMIT');
|
|
418
|
-
log(`Completed waitpoint ${tokenId} for job ${jobId}`);
|
|
419
|
-
} catch (error) {
|
|
420
|
-
await client.query('ROLLBACK');
|
|
421
|
-
log(`Error completing waitpoint ${tokenId}: ${error}`);
|
|
422
|
-
throw error;
|
|
423
|
-
} finally {
|
|
424
|
-
client.release();
|
|
425
|
-
}
|
|
426
|
-
};
|
|
264
|
+
): Promise<void> => new PostgresBackend(pool).completeWaitpoint(tokenId, data);
|
|
427
265
|
|
|
428
|
-
/**
|
|
429
|
-
* Retrieve a waitpoint token by its ID.
|
|
430
|
-
*/
|
|
266
|
+
/** @deprecated Use backend.getWaitpoint() directly. Delegates to PostgresBackend. */
|
|
431
267
|
export const getWaitpoint = async (
|
|
432
268
|
pool: Pool,
|
|
433
269
|
tokenId: string,
|
|
434
|
-
): Promise<WaitpointRecord | null> =>
|
|
435
|
-
|
|
436
|
-
try {
|
|
437
|
-
const result = await client.query(
|
|
438
|
-
`SELECT id, job_id AS "jobId", status, output, timeout_at AS "timeoutAt", created_at AS "createdAt", completed_at AS "completedAt", tags FROM waitpoints WHERE id = $1`,
|
|
439
|
-
[tokenId],
|
|
440
|
-
);
|
|
441
|
-
if (result.rows.length === 0) return null;
|
|
442
|
-
return result.rows[0] as WaitpointRecord;
|
|
443
|
-
} catch (error) {
|
|
444
|
-
log(`Error getting waitpoint ${tokenId}: ${error}`);
|
|
445
|
-
throw error;
|
|
446
|
-
} finally {
|
|
447
|
-
client.release();
|
|
448
|
-
}
|
|
449
|
-
};
|
|
450
|
-
|
|
451
|
-
/**
|
|
452
|
-
* Expire timed-out waitpoint tokens and move their associated jobs back to 'pending'.
|
|
453
|
-
* Should be called periodically (e.g., alongside reclaimStuckJobs).
|
|
454
|
-
*/
|
|
455
|
-
export const expireTimedOutWaitpoints = async (pool: Pool): Promise<number> => {
|
|
456
|
-
const client = await pool.connect();
|
|
457
|
-
try {
|
|
458
|
-
await client.query('BEGIN');
|
|
459
|
-
|
|
460
|
-
// Find and expire timed-out waitpoints
|
|
461
|
-
const result = await client.query(
|
|
462
|
-
`UPDATE waitpoints
|
|
463
|
-
SET status = 'timed_out'
|
|
464
|
-
WHERE status = 'waiting' AND timeout_at IS NOT NULL AND timeout_at <= NOW()
|
|
465
|
-
RETURNING id, job_id`,
|
|
466
|
-
);
|
|
467
|
-
|
|
468
|
-
// Move associated jobs back to 'pending'
|
|
469
|
-
for (const row of result.rows) {
|
|
470
|
-
if (row.job_id != null) {
|
|
471
|
-
await client.query(
|
|
472
|
-
`UPDATE job_queue
|
|
473
|
-
SET status = 'pending', wait_token_id = NULL, wait_until = NULL, updated_at = NOW()
|
|
474
|
-
WHERE id = $1 AND status = 'waiting'`,
|
|
475
|
-
[row.job_id],
|
|
476
|
-
);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
270
|
+
): Promise<WaitpointRecord | null> =>
|
|
271
|
+
new PostgresBackend(pool).getWaitpoint(tokenId);
|
|
479
272
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
log(`Expired ${count} timed-out waitpoints`);
|
|
484
|
-
}
|
|
485
|
-
return count;
|
|
486
|
-
} catch (error) {
|
|
487
|
-
await client.query('ROLLBACK');
|
|
488
|
-
log(`Error expiring timed-out waitpoints: ${error}`);
|
|
489
|
-
throw error;
|
|
490
|
-
} finally {
|
|
491
|
-
client.release();
|
|
492
|
-
}
|
|
493
|
-
};
|
|
273
|
+
/** @deprecated Use backend.expireTimedOutWaitpoints() directly. Delegates to PostgresBackend. */
|
|
274
|
+
export const expireTimedOutWaitpoints = async (pool: Pool): Promise<number> =>
|
|
275
|
+
new PostgresBackend(pool).expireTimedOutWaitpoints();
|
|
@@ -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
|
+
});
|