@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.
@@ -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
- constructor(redisConfig: RedisJobQueueConfig['redisConfig']) {
185
- // Dynamically require ioredis to avoid hard dep
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
- jobType,
265
- payload,
266
- maxAttempts = 3,
267
- priority = 0,
268
- runAt = null,
269
- timeoutMs = undefined,
270
- forceKillOnTimeout = false,
271
- tags = undefined,
272
- idempotencyKey = undefined,
273
- }: JobOptions<PayloadMap, T>): Promise<number> {
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
- await this.client.eval(COMPLETE_JOB_SCRIPT, 1, this.prefix, jobId, now);
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;