@nicnocquee/dataqueue 1.34.0 → 1.35.0-beta.20260224075710

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,28 @@ 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
+ output: parseJsonField(h.output),
165
176
  };
166
177
  }
167
178
 
179
+ /** Parse a JSON field from a Redis hash, returning null for missing/null values. */
180
+ function parseJsonField(raw: string | undefined): unknown {
181
+ if (!raw || raw === 'null') return null;
182
+ try {
183
+ return JSON.parse(raw);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
168
189
  /** Parse step data from a Redis hash field. */
169
190
  function parseStepData(
170
191
  raw: string | undefined,
@@ -181,8 +202,30 @@ export class RedisBackend implements QueueBackend {
181
202
  private client: RedisType;
182
203
  private prefix: string;
183
204
 
184
- constructor(redisConfig: RedisJobQueueConfig['redisConfig']) {
185
- // Dynamically require ioredis to avoid hard dep
205
+ /**
206
+ * Create a RedisBackend.
207
+ *
208
+ * @param configOrClient - Either `redisConfig` from the config file (the
209
+ * library creates a new ioredis client) or an existing ioredis client
210
+ * instance (bring your own).
211
+ * @param keyPrefix - Key prefix, only used when `configOrClient` is an
212
+ * external client. Ignored when `redisConfig` is passed (uses
213
+ * `redisConfig.keyPrefix` instead). Default: `'dq:'`.
214
+ */
215
+ constructor(
216
+ configOrClient: RedisJobQueueConfig['redisConfig'] | RedisType,
217
+ keyPrefix?: string,
218
+ ) {
219
+ if (configOrClient && typeof (configOrClient as any).eval === 'function') {
220
+ this.client = configOrClient as RedisType;
221
+ this.prefix = keyPrefix ?? 'dq:';
222
+ return;
223
+ }
224
+
225
+ const redisConfig = configOrClient as NonNullable<
226
+ RedisJobQueueConfig['redisConfig']
227
+ >;
228
+
186
229
  let IORedis: any;
187
230
  try {
188
231
  const _require = createRequire(import.meta.url);
@@ -260,17 +303,29 @@ export class RedisBackend implements QueueBackend {
260
303
 
261
304
  // ── Job CRUD ──────────────────────────────────────────────────────────
262
305
 
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> {
306
+ async addJob<PayloadMap, T extends JobType<PayloadMap>>(
307
+ {
308
+ jobType,
309
+ payload,
310
+ maxAttempts = 3,
311
+ priority = 0,
312
+ runAt = null,
313
+ timeoutMs = undefined,
314
+ forceKillOnTimeout = false,
315
+ tags = undefined,
316
+ idempotencyKey = undefined,
317
+ retryDelay = undefined,
318
+ retryBackoff = undefined,
319
+ retryDelayMax = undefined,
320
+ }: JobOptions<PayloadMap, T>,
321
+ options?: AddJobOptions,
322
+ ): Promise<number> {
323
+ if (options?.db) {
324
+ throw new Error(
325
+ 'The db option is not supported with the Redis backend. ' +
326
+ 'Transactional job creation is only available with PostgreSQL.',
327
+ );
328
+ }
274
329
  const now = this.nowMs();
275
330
  const runAtMs = runAt ? runAt.getTime() : 0;
276
331
 
@@ -288,6 +343,9 @@ export class RedisBackend implements QueueBackend {
288
343
  tags ? JSON.stringify(tags) : 'null',
289
344
  idempotencyKey ?? 'null',
290
345
  now,
346
+ retryDelay !== undefined ? retryDelay.toString() : 'null',
347
+ retryBackoff !== undefined ? retryBackoff.toString() : 'null',
348
+ retryDelayMax !== undefined ? retryDelayMax.toString() : 'null',
291
349
  )) as number;
292
350
 
293
351
  const jobId = Number(result);
@@ -303,6 +361,83 @@ export class RedisBackend implements QueueBackend {
303
361
  return jobId;
304
362
  }
305
363
 
364
+ /**
365
+ * Insert multiple jobs atomically via a single Lua script.
366
+ * Returns IDs in the same order as the input array.
367
+ */
368
+ async addJobs<PayloadMap, T extends JobType<PayloadMap>>(
369
+ jobs: JobOptions<PayloadMap, T>[],
370
+ options?: AddJobOptions,
371
+ ): Promise<number[]> {
372
+ if (jobs.length === 0) return [];
373
+
374
+ if (options?.db) {
375
+ throw new Error(
376
+ 'The db option is not supported with the Redis backend. ' +
377
+ 'Transactional job creation is only available with PostgreSQL.',
378
+ );
379
+ }
380
+
381
+ const now = this.nowMs();
382
+
383
+ const jobsPayload = jobs.map((job) => ({
384
+ jobType: job.jobType,
385
+ payload: JSON.stringify(job.payload),
386
+ maxAttempts: job.maxAttempts ?? 3,
387
+ priority: job.priority ?? 0,
388
+ runAtMs: job.runAt ? job.runAt.getTime() : 0,
389
+ timeoutMs:
390
+ job.timeoutMs !== undefined ? job.timeoutMs.toString() : 'null',
391
+ forceKillOnTimeout: job.forceKillOnTimeout ? 'true' : 'false',
392
+ tags: job.tags ? JSON.stringify(job.tags) : 'null',
393
+ idempotencyKey: job.idempotencyKey ?? 'null',
394
+ retryDelay:
395
+ job.retryDelay !== undefined ? job.retryDelay.toString() : 'null',
396
+ retryBackoff:
397
+ job.retryBackoff !== undefined ? job.retryBackoff.toString() : 'null',
398
+ retryDelayMax:
399
+ job.retryDelayMax !== undefined ? job.retryDelayMax.toString() : 'null',
400
+ }));
401
+
402
+ const result = (await this.client.eval(
403
+ ADD_JOBS_SCRIPT,
404
+ 1,
405
+ this.prefix,
406
+ JSON.stringify(jobsPayload),
407
+ now,
408
+ )) as number[];
409
+
410
+ const ids = result.map(Number);
411
+ log(`Batch-inserted ${jobs.length} jobs, IDs: [${ids.join(', ')}]`);
412
+
413
+ // Record events for newly inserted jobs (skip idempotency duplicates)
414
+ const existingIdempotencyIds = new Set<number>();
415
+ for (let i = 0; i < jobs.length; i++) {
416
+ if (jobs[i].idempotencyKey) {
417
+ // If the returned ID existed before this batch, it was a duplicate.
418
+ // We detect this by checking if the same ID appears for a different
419
+ // idempotency-keyed job (unlikely) or by checking if the ID was less
420
+ // than what we'd expect. The simplest approach: record events for all,
421
+ // since the Lua script returns the existing ID for duplicates but
422
+ // doesn't tell us if it was newly created. We can compare: if
423
+ // multiple jobs have the same idempotency key in the batch and got
424
+ // the same ID, only record once.
425
+ if (existingIdempotencyIds.has(ids[i])) {
426
+ continue;
427
+ }
428
+ existingIdempotencyIds.add(ids[i]);
429
+ }
430
+ await this.recordJobEvent(ids[i], JobEventType.Added, {
431
+ jobType: jobs[i].jobType,
432
+ payload: jobs[i].payload,
433
+ tags: jobs[i].tags,
434
+ idempotencyKey: jobs[i].idempotencyKey,
435
+ });
436
+ }
437
+
438
+ return ids;
439
+ }
440
+
306
441
  async getJob<PayloadMap, T extends JobType<PayloadMap>>(
307
442
  id: number,
308
443
  ): Promise<JobRecord<PayloadMap, T> | null> {
@@ -469,9 +604,18 @@ export class RedisBackend implements QueueBackend {
469
604
  return jobs;
470
605
  }
471
606
 
472
- async completeJob(jobId: number): Promise<void> {
607
+ async completeJob(jobId: number, output?: unknown): Promise<void> {
473
608
  const now = this.nowMs();
474
- await this.client.eval(COMPLETE_JOB_SCRIPT, 1, this.prefix, jobId, now);
609
+ const outputArg =
610
+ output !== undefined ? JSON.stringify(output) : '__NONE__';
611
+ await this.client.eval(
612
+ COMPLETE_JOB_SCRIPT,
613
+ 1,
614
+ this.prefix,
615
+ jobId,
616
+ now,
617
+ outputArg,
618
+ );
475
619
  await this.recordJobEvent(jobId, JobEventType.Completed);
476
620
  log(`Completed job ${jobId}`);
477
621
  }
@@ -535,6 +679,24 @@ export class RedisBackend implements QueueBackend {
535
679
  }
536
680
  }
537
681
 
682
+ // ── Output ────────────────────────────────────────────────────────────
683
+
684
+ async updateOutput(jobId: number, output: unknown): Promise<void> {
685
+ try {
686
+ const now = this.nowMs();
687
+ await this.client.hset(
688
+ `${this.prefix}job:${jobId}`,
689
+ 'output',
690
+ JSON.stringify(output),
691
+ 'updatedAt',
692
+ now.toString(),
693
+ );
694
+ log(`Updated output for job ${jobId}`);
695
+ } catch (error) {
696
+ log(`Error updating output for job ${jobId}: ${error}`);
697
+ }
698
+ }
699
+
538
700
  // ── Job management ────────────────────────────────────────────────────
539
701
 
540
702
  async retryJob(jobId: number): Promise<void> {
@@ -657,6 +819,31 @@ export class RedisBackend implements QueueBackend {
657
819
  }
658
820
  metadata.tags = updates.tags;
659
821
  }
822
+ if (updates.retryDelay !== undefined) {
823
+ fields.push(
824
+ 'retryDelay',
825
+ updates.retryDelay !== null ? updates.retryDelay.toString() : 'null',
826
+ );
827
+ metadata.retryDelay = updates.retryDelay;
828
+ }
829
+ if (updates.retryBackoff !== undefined) {
830
+ fields.push(
831
+ 'retryBackoff',
832
+ updates.retryBackoff !== null
833
+ ? updates.retryBackoff.toString()
834
+ : 'null',
835
+ );
836
+ metadata.retryBackoff = updates.retryBackoff;
837
+ }
838
+ if (updates.retryDelayMax !== undefined) {
839
+ fields.push(
840
+ 'retryDelayMax',
841
+ updates.retryDelayMax !== null
842
+ ? updates.retryDelayMax.toString()
843
+ : 'null',
844
+ );
845
+ metadata.retryDelayMax = updates.retryDelayMax;
846
+ }
660
847
 
661
848
  if (fields.length === 0) {
662
849
  log(`No fields to update for job ${jobId}`);
@@ -1236,6 +1423,18 @@ export class RedisBackend implements QueueBackend {
1236
1423
  now.toString(),
1237
1424
  'updatedAt',
1238
1425
  now.toString(),
1426
+ 'retryDelay',
1427
+ input.retryDelay !== null && input.retryDelay !== undefined
1428
+ ? input.retryDelay.toString()
1429
+ : 'null',
1430
+ 'retryBackoff',
1431
+ input.retryBackoff !== null && input.retryBackoff !== undefined
1432
+ ? input.retryBackoff.toString()
1433
+ : 'null',
1434
+ 'retryDelayMax',
1435
+ input.retryDelayMax !== null && input.retryDelayMax !== undefined
1436
+ ? input.retryDelayMax.toString()
1437
+ : 'null',
1239
1438
  ];
1240
1439
 
1241
1440
  await (this.client as any).hmset(key, ...fields);
@@ -1417,6 +1616,28 @@ export class RedisBackend implements QueueBackend {
1417
1616
  if (updates.allowOverlap !== undefined) {
1418
1617
  fields.push('allowOverlap', updates.allowOverlap ? 'true' : 'false');
1419
1618
  }
1619
+ if (updates.retryDelay !== undefined) {
1620
+ fields.push(
1621
+ 'retryDelay',
1622
+ updates.retryDelay !== null ? updates.retryDelay.toString() : 'null',
1623
+ );
1624
+ }
1625
+ if (updates.retryBackoff !== undefined) {
1626
+ fields.push(
1627
+ 'retryBackoff',
1628
+ updates.retryBackoff !== null
1629
+ ? updates.retryBackoff.toString()
1630
+ : 'null',
1631
+ );
1632
+ }
1633
+ if (updates.retryDelayMax !== undefined) {
1634
+ fields.push(
1635
+ 'retryDelayMax',
1636
+ updates.retryDelayMax !== null
1637
+ ? updates.retryDelayMax.toString()
1638
+ : 'null',
1639
+ );
1640
+ }
1420
1641
  if (nextRunAt !== undefined) {
1421
1642
  const val = nextRunAt !== null ? nextRunAt.getTime().toString() : 'null';
1422
1643
  fields.push('nextRunAt', val);
@@ -1557,6 +1778,14 @@ export class RedisBackend implements QueueBackend {
1557
1778
  nextRunAt: dateOrNull(h.nextRunAt),
1558
1779
  createdAt: new Date(Number(h.createdAt)),
1559
1780
  updatedAt: new Date(Number(h.updatedAt)),
1781
+ retryDelay: numOrNull(h.retryDelay),
1782
+ retryBackoff:
1783
+ h.retryBackoff === 'true'
1784
+ ? true
1785
+ : h.retryBackoff === 'false'
1786
+ ? false
1787
+ : null,
1788
+ retryDelayMax: numOrNull(h.retryDelayMax),
1560
1789
  };
1561
1790
  }
1562
1791
 
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;