@nicnocquee/dataqueue 1.34.0 → 1.35.0-beta.20260224110011
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/docs-content.json +27 -15
- package/ai/rules/advanced.md +78 -1
- package/ai/rules/basic.md +73 -3
- package/ai/rules/react-dashboard.md +5 -1
- package/ai/skills/dataqueue-advanced/SKILL.md +181 -0
- package/ai/skills/dataqueue-core/SKILL.md +109 -3
- package/ai/skills/dataqueue-react/SKILL.md +19 -7
- package/dist/index.cjs +1168 -173
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +394 -13
- package/dist/index.d.ts +394 -13
- package/dist/index.js +1168 -173
- package/dist/index.js.map +1 -1
- package/migrations/1781200000005_add_retry_config_to_job_queue.sql +17 -0
- package/migrations/1781200000006_add_output_to_job_queue.sql +3 -0
- package/migrations/1781200000007_add_group_fields_to_job_queue.sql +16 -0
- package/package.json +1 -1
- package/src/backend.ts +37 -3
- package/src/backends/postgres.ts +458 -76
- package/src/backends/redis-scripts.ts +273 -37
- package/src/backends/redis.test.ts +753 -0
- package/src/backends/redis.ts +253 -15
- package/src/db-util.ts +1 -1
- package/src/index.test.ts +811 -12
- package/src/index.ts +106 -14
- package/src/processor.test.ts +18 -0
- package/src/processor.ts +147 -49
- package/src/queue.test.ts +584 -0
- package/src/queue.ts +22 -3
- package/src/supervisor.test.ts +340 -0
- package/src/supervisor.ts +177 -0
- package/src/types.ts +353 -3
package/src/backends/redis.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
EditCronScheduleOptions,
|
|
15
15
|
WaitpointRecord,
|
|
16
16
|
CreateTokenOptions,
|
|
17
|
+
AddJobOptions,
|
|
17
18
|
} from '../types.js';
|
|
18
19
|
import {
|
|
19
20
|
QueueBackend,
|
|
@@ -61,6 +62,7 @@ function parseTimeoutString(timeout: string): number {
|
|
|
61
62
|
}
|
|
62
63
|
import {
|
|
63
64
|
ADD_JOB_SCRIPT,
|
|
65
|
+
ADD_JOBS_SCRIPT,
|
|
64
66
|
GET_NEXT_BATCH_SCRIPT,
|
|
65
67
|
COMPLETE_JOB_SCRIPT,
|
|
66
68
|
FAIL_JOB_SCRIPT,
|
|
@@ -162,9 +164,30 @@ function deserializeJob<PayloadMap, T extends JobType<PayloadMap>>(
|
|
|
162
164
|
waitUntil: dateOrNull(h.waitUntil),
|
|
163
165
|
waitTokenId: nullish(h.waitTokenId) as string | null | undefined,
|
|
164
166
|
stepData: parseStepData(h.stepData),
|
|
167
|
+
retryDelay: numOrNull(h.retryDelay),
|
|
168
|
+
retryBackoff:
|
|
169
|
+
h.retryBackoff === 'true'
|
|
170
|
+
? true
|
|
171
|
+
: h.retryBackoff === 'false'
|
|
172
|
+
? false
|
|
173
|
+
: null,
|
|
174
|
+
retryDelayMax: numOrNull(h.retryDelayMax),
|
|
175
|
+
groupId: nullish(h.groupId) as string | null | undefined,
|
|
176
|
+
groupTier: nullish(h.groupTier) as string | null | undefined,
|
|
177
|
+
output: parseJsonField(h.output),
|
|
165
178
|
};
|
|
166
179
|
}
|
|
167
180
|
|
|
181
|
+
/** Parse a JSON field from a Redis hash, returning null for missing/null values. */
|
|
182
|
+
function parseJsonField(raw: string | undefined): unknown {
|
|
183
|
+
if (!raw || raw === 'null') return null;
|
|
184
|
+
try {
|
|
185
|
+
return JSON.parse(raw);
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
168
191
|
/** Parse step data from a Redis hash field. */
|
|
169
192
|
function parseStepData(
|
|
170
193
|
raw: string | undefined,
|
|
@@ -181,8 +204,30 @@ export class RedisBackend implements QueueBackend {
|
|
|
181
204
|
private client: RedisType;
|
|
182
205
|
private prefix: string;
|
|
183
206
|
|
|
184
|
-
|
|
185
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Create a RedisBackend.
|
|
209
|
+
*
|
|
210
|
+
* @param configOrClient - Either `redisConfig` from the config file (the
|
|
211
|
+
* library creates a new ioredis client) or an existing ioredis client
|
|
212
|
+
* instance (bring your own).
|
|
213
|
+
* @param keyPrefix - Key prefix, only used when `configOrClient` is an
|
|
214
|
+
* external client. Ignored when `redisConfig` is passed (uses
|
|
215
|
+
* `redisConfig.keyPrefix` instead). Default: `'dq:'`.
|
|
216
|
+
*/
|
|
217
|
+
constructor(
|
|
218
|
+
configOrClient: RedisJobQueueConfig['redisConfig'] | RedisType,
|
|
219
|
+
keyPrefix?: string,
|
|
220
|
+
) {
|
|
221
|
+
if (configOrClient && typeof (configOrClient as any).eval === 'function') {
|
|
222
|
+
this.client = configOrClient as RedisType;
|
|
223
|
+
this.prefix = keyPrefix ?? 'dq:';
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const redisConfig = configOrClient as NonNullable<
|
|
228
|
+
RedisJobQueueConfig['redisConfig']
|
|
229
|
+
>;
|
|
230
|
+
|
|
186
231
|
let IORedis: any;
|
|
187
232
|
try {
|
|
188
233
|
const _require = createRequire(import.meta.url);
|
|
@@ -260,17 +305,30 @@ export class RedisBackend implements QueueBackend {
|
|
|
260
305
|
|
|
261
306
|
// ── Job CRUD ──────────────────────────────────────────────────────────
|
|
262
307
|
|
|
263
|
-
async addJob<PayloadMap, T extends JobType<PayloadMap>>(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
308
|
+
async addJob<PayloadMap, T extends JobType<PayloadMap>>(
|
|
309
|
+
{
|
|
310
|
+
jobType,
|
|
311
|
+
payload,
|
|
312
|
+
maxAttempts = 3,
|
|
313
|
+
priority = 0,
|
|
314
|
+
runAt = null,
|
|
315
|
+
timeoutMs = undefined,
|
|
316
|
+
forceKillOnTimeout = false,
|
|
317
|
+
tags = undefined,
|
|
318
|
+
idempotencyKey = undefined,
|
|
319
|
+
retryDelay = undefined,
|
|
320
|
+
retryBackoff = undefined,
|
|
321
|
+
retryDelayMax = undefined,
|
|
322
|
+
group = undefined,
|
|
323
|
+
}: JobOptions<PayloadMap, T>,
|
|
324
|
+
options?: AddJobOptions,
|
|
325
|
+
): Promise<number> {
|
|
326
|
+
if (options?.db) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
'The db option is not supported with the Redis backend. ' +
|
|
329
|
+
'Transactional job creation is only available with PostgreSQL.',
|
|
330
|
+
);
|
|
331
|
+
}
|
|
274
332
|
const now = this.nowMs();
|
|
275
333
|
const runAtMs = runAt ? runAt.getTime() : 0;
|
|
276
334
|
|
|
@@ -288,6 +346,11 @@ export class RedisBackend implements QueueBackend {
|
|
|
288
346
|
tags ? JSON.stringify(tags) : 'null',
|
|
289
347
|
idempotencyKey ?? 'null',
|
|
290
348
|
now,
|
|
349
|
+
retryDelay !== undefined ? retryDelay.toString() : 'null',
|
|
350
|
+
retryBackoff !== undefined ? retryBackoff.toString() : 'null',
|
|
351
|
+
retryDelayMax !== undefined ? retryDelayMax.toString() : 'null',
|
|
352
|
+
group?.id ?? 'null',
|
|
353
|
+
group?.tier ?? 'null',
|
|
291
354
|
)) as number;
|
|
292
355
|
|
|
293
356
|
const jobId = Number(result);
|
|
@@ -303,6 +366,85 @@ export class RedisBackend implements QueueBackend {
|
|
|
303
366
|
return jobId;
|
|
304
367
|
}
|
|
305
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Insert multiple jobs atomically via a single Lua script.
|
|
371
|
+
* Returns IDs in the same order as the input array.
|
|
372
|
+
*/
|
|
373
|
+
async addJobs<PayloadMap, T extends JobType<PayloadMap>>(
|
|
374
|
+
jobs: JobOptions<PayloadMap, T>[],
|
|
375
|
+
options?: AddJobOptions,
|
|
376
|
+
): Promise<number[]> {
|
|
377
|
+
if (jobs.length === 0) return [];
|
|
378
|
+
|
|
379
|
+
if (options?.db) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
'The db option is not supported with the Redis backend. ' +
|
|
382
|
+
'Transactional job creation is only available with PostgreSQL.',
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const now = this.nowMs();
|
|
387
|
+
|
|
388
|
+
const jobsPayload = jobs.map((job) => ({
|
|
389
|
+
jobType: job.jobType,
|
|
390
|
+
payload: JSON.stringify(job.payload),
|
|
391
|
+
maxAttempts: job.maxAttempts ?? 3,
|
|
392
|
+
priority: job.priority ?? 0,
|
|
393
|
+
runAtMs: job.runAt ? job.runAt.getTime() : 0,
|
|
394
|
+
timeoutMs:
|
|
395
|
+
job.timeoutMs !== undefined ? job.timeoutMs.toString() : 'null',
|
|
396
|
+
forceKillOnTimeout: job.forceKillOnTimeout ? 'true' : 'false',
|
|
397
|
+
tags: job.tags ? JSON.stringify(job.tags) : 'null',
|
|
398
|
+
idempotencyKey: job.idempotencyKey ?? 'null',
|
|
399
|
+
retryDelay:
|
|
400
|
+
job.retryDelay !== undefined ? job.retryDelay.toString() : 'null',
|
|
401
|
+
retryBackoff:
|
|
402
|
+
job.retryBackoff !== undefined ? job.retryBackoff.toString() : 'null',
|
|
403
|
+
retryDelayMax:
|
|
404
|
+
job.retryDelayMax !== undefined ? job.retryDelayMax.toString() : 'null',
|
|
405
|
+
groupId: job.group?.id ?? 'null',
|
|
406
|
+
groupTier: job.group?.tier ?? 'null',
|
|
407
|
+
}));
|
|
408
|
+
|
|
409
|
+
const result = (await this.client.eval(
|
|
410
|
+
ADD_JOBS_SCRIPT,
|
|
411
|
+
1,
|
|
412
|
+
this.prefix,
|
|
413
|
+
JSON.stringify(jobsPayload),
|
|
414
|
+
now,
|
|
415
|
+
)) as number[];
|
|
416
|
+
|
|
417
|
+
const ids = result.map(Number);
|
|
418
|
+
log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(', ')}]`);
|
|
419
|
+
|
|
420
|
+
// Record events for newly inserted jobs (skip idempotency duplicates)
|
|
421
|
+
const existingIdempotencyIds = new Set<number>();
|
|
422
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
423
|
+
if (jobs[i].idempotencyKey) {
|
|
424
|
+
// If the returned ID existed before this batch, it was a duplicate.
|
|
425
|
+
// We detect this by checking if the same ID appears for a different
|
|
426
|
+
// idempotency-keyed job (unlikely) or by checking if the ID was less
|
|
427
|
+
// than what we'd expect. The simplest approach: record events for all,
|
|
428
|
+
// since the Lua script returns the existing ID for duplicates but
|
|
429
|
+
// doesn't tell us if it was newly created. We can compare: if
|
|
430
|
+
// multiple jobs have the same idempotency key in the batch and got
|
|
431
|
+
// the same ID, only record once.
|
|
432
|
+
if (existingIdempotencyIds.has(ids[i])) {
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
existingIdempotencyIds.add(ids[i]);
|
|
436
|
+
}
|
|
437
|
+
await this.recordJobEvent(ids[i], JobEventType.Added, {
|
|
438
|
+
jobType: jobs[i].jobType,
|
|
439
|
+
payload: jobs[i].payload,
|
|
440
|
+
tags: jobs[i].tags,
|
|
441
|
+
idempotencyKey: jobs[i].idempotencyKey,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return ids;
|
|
446
|
+
}
|
|
447
|
+
|
|
306
448
|
async getJob<PayloadMap, T extends JobType<PayloadMap>>(
|
|
307
449
|
id: number,
|
|
308
450
|
): Promise<JobRecord<PayloadMap, T> | null> {
|
|
@@ -420,6 +562,7 @@ export class RedisBackend implements QueueBackend {
|
|
|
420
562
|
workerId: string,
|
|
421
563
|
batchSize = 10,
|
|
422
564
|
jobType?: string | string[],
|
|
565
|
+
groupConcurrency?: number,
|
|
423
566
|
): Promise<JobRecord<PayloadMap, T>[]> {
|
|
424
567
|
const now = this.nowMs();
|
|
425
568
|
const jobTypeFilter =
|
|
@@ -437,6 +580,7 @@ export class RedisBackend implements QueueBackend {
|
|
|
437
580
|
batchSize,
|
|
438
581
|
now,
|
|
439
582
|
jobTypeFilter,
|
|
583
|
+
groupConcurrency !== undefined ? groupConcurrency : 'null',
|
|
440
584
|
)) as string[];
|
|
441
585
|
|
|
442
586
|
if (!result || result.length === 0) {
|
|
@@ -469,9 +613,18 @@ export class RedisBackend implements QueueBackend {
|
|
|
469
613
|
return jobs;
|
|
470
614
|
}
|
|
471
615
|
|
|
472
|
-
async completeJob(jobId: number): Promise<void> {
|
|
616
|
+
async completeJob(jobId: number, output?: unknown): Promise<void> {
|
|
473
617
|
const now = this.nowMs();
|
|
474
|
-
|
|
618
|
+
const outputArg =
|
|
619
|
+
output !== undefined ? JSON.stringify(output) : '__NONE__';
|
|
620
|
+
await this.client.eval(
|
|
621
|
+
COMPLETE_JOB_SCRIPT,
|
|
622
|
+
1,
|
|
623
|
+
this.prefix,
|
|
624
|
+
jobId,
|
|
625
|
+
now,
|
|
626
|
+
outputArg,
|
|
627
|
+
);
|
|
475
628
|
await this.recordJobEvent(jobId, JobEventType.Completed);
|
|
476
629
|
log(`Completed job ${jobId}`);
|
|
477
630
|
}
|
|
@@ -535,6 +688,24 @@ export class RedisBackend implements QueueBackend {
|
|
|
535
688
|
}
|
|
536
689
|
}
|
|
537
690
|
|
|
691
|
+
// ── Output ────────────────────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
async updateOutput(jobId: number, output: unknown): Promise<void> {
|
|
694
|
+
try {
|
|
695
|
+
const now = this.nowMs();
|
|
696
|
+
await this.client.hset(
|
|
697
|
+
`${this.prefix}job:${jobId}`,
|
|
698
|
+
'output',
|
|
699
|
+
JSON.stringify(output),
|
|
700
|
+
'updatedAt',
|
|
701
|
+
now.toString(),
|
|
702
|
+
);
|
|
703
|
+
log(`Updated output for job ${jobId}`);
|
|
704
|
+
} catch (error) {
|
|
705
|
+
log(`Error updating output for job ${jobId}: ${error}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
538
709
|
// ── Job management ────────────────────────────────────────────────────
|
|
539
710
|
|
|
540
711
|
async retryJob(jobId: number): Promise<void> {
|
|
@@ -657,6 +828,31 @@ export class RedisBackend implements QueueBackend {
|
|
|
657
828
|
}
|
|
658
829
|
metadata.tags = updates.tags;
|
|
659
830
|
}
|
|
831
|
+
if (updates.retryDelay !== undefined) {
|
|
832
|
+
fields.push(
|
|
833
|
+
'retryDelay',
|
|
834
|
+
updates.retryDelay !== null ? updates.retryDelay.toString() : 'null',
|
|
835
|
+
);
|
|
836
|
+
metadata.retryDelay = updates.retryDelay;
|
|
837
|
+
}
|
|
838
|
+
if (updates.retryBackoff !== undefined) {
|
|
839
|
+
fields.push(
|
|
840
|
+
'retryBackoff',
|
|
841
|
+
updates.retryBackoff !== null
|
|
842
|
+
? updates.retryBackoff.toString()
|
|
843
|
+
: 'null',
|
|
844
|
+
);
|
|
845
|
+
metadata.retryBackoff = updates.retryBackoff;
|
|
846
|
+
}
|
|
847
|
+
if (updates.retryDelayMax !== undefined) {
|
|
848
|
+
fields.push(
|
|
849
|
+
'retryDelayMax',
|
|
850
|
+
updates.retryDelayMax !== null
|
|
851
|
+
? updates.retryDelayMax.toString()
|
|
852
|
+
: 'null',
|
|
853
|
+
);
|
|
854
|
+
metadata.retryDelayMax = updates.retryDelayMax;
|
|
855
|
+
}
|
|
660
856
|
|
|
661
857
|
if (fields.length === 0) {
|
|
662
858
|
log(`No fields to update for job ${jobId}`);
|
|
@@ -1236,6 +1432,18 @@ export class RedisBackend implements QueueBackend {
|
|
|
1236
1432
|
now.toString(),
|
|
1237
1433
|
'updatedAt',
|
|
1238
1434
|
now.toString(),
|
|
1435
|
+
'retryDelay',
|
|
1436
|
+
input.retryDelay !== null && input.retryDelay !== undefined
|
|
1437
|
+
? input.retryDelay.toString()
|
|
1438
|
+
: 'null',
|
|
1439
|
+
'retryBackoff',
|
|
1440
|
+
input.retryBackoff !== null && input.retryBackoff !== undefined
|
|
1441
|
+
? input.retryBackoff.toString()
|
|
1442
|
+
: 'null',
|
|
1443
|
+
'retryDelayMax',
|
|
1444
|
+
input.retryDelayMax !== null && input.retryDelayMax !== undefined
|
|
1445
|
+
? input.retryDelayMax.toString()
|
|
1446
|
+
: 'null',
|
|
1239
1447
|
];
|
|
1240
1448
|
|
|
1241
1449
|
await (this.client as any).hmset(key, ...fields);
|
|
@@ -1417,6 +1625,28 @@ export class RedisBackend implements QueueBackend {
|
|
|
1417
1625
|
if (updates.allowOverlap !== undefined) {
|
|
1418
1626
|
fields.push('allowOverlap', updates.allowOverlap ? 'true' : 'false');
|
|
1419
1627
|
}
|
|
1628
|
+
if (updates.retryDelay !== undefined) {
|
|
1629
|
+
fields.push(
|
|
1630
|
+
'retryDelay',
|
|
1631
|
+
updates.retryDelay !== null ? updates.retryDelay.toString() : 'null',
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
if (updates.retryBackoff !== undefined) {
|
|
1635
|
+
fields.push(
|
|
1636
|
+
'retryBackoff',
|
|
1637
|
+
updates.retryBackoff !== null
|
|
1638
|
+
? updates.retryBackoff.toString()
|
|
1639
|
+
: 'null',
|
|
1640
|
+
);
|
|
1641
|
+
}
|
|
1642
|
+
if (updates.retryDelayMax !== undefined) {
|
|
1643
|
+
fields.push(
|
|
1644
|
+
'retryDelayMax',
|
|
1645
|
+
updates.retryDelayMax !== null
|
|
1646
|
+
? updates.retryDelayMax.toString()
|
|
1647
|
+
: 'null',
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1420
1650
|
if (nextRunAt !== undefined) {
|
|
1421
1651
|
const val = nextRunAt !== null ? nextRunAt.getTime().toString() : 'null';
|
|
1422
1652
|
fields.push('nextRunAt', val);
|
|
@@ -1557,6 +1787,14 @@ export class RedisBackend implements QueueBackend {
|
|
|
1557
1787
|
nextRunAt: dateOrNull(h.nextRunAt),
|
|
1558
1788
|
createdAt: new Date(Number(h.createdAt)),
|
|
1559
1789
|
updatedAt: new Date(Number(h.updatedAt)),
|
|
1790
|
+
retryDelay: numOrNull(h.retryDelay),
|
|
1791
|
+
retryBackoff:
|
|
1792
|
+
h.retryBackoff === 'true'
|
|
1793
|
+
? true
|
|
1794
|
+
: h.retryBackoff === 'false'
|
|
1795
|
+
? false
|
|
1796
|
+
: null,
|
|
1797
|
+
retryDelayMax: numOrNull(h.retryDelayMax),
|
|
1560
1798
|
};
|
|
1561
1799
|
}
|
|
1562
1800
|
|
package/src/db-util.ts
CHANGED
|
@@ -27,7 +27,7 @@ function loadPemOrFile(value?: string): string | undefined {
|
|
|
27
27
|
* }
|
|
28
28
|
*/
|
|
29
29
|
export const createPool = (
|
|
30
|
-
config: PostgresJobQueueConfig['databaseConfig']
|
|
30
|
+
config: NonNullable<PostgresJobQueueConfig['databaseConfig']>,
|
|
31
31
|
): Pool => {
|
|
32
32
|
let searchPath: string | undefined;
|
|
33
33
|
let ssl: any = undefined;
|