@nicnocquee/dataqueue 1.30.0 → 1.32.0

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/src/queue.ts CHANGED
@@ -18,8 +18,6 @@ import {
18
18
  WaitpointRecord,
19
19
  } from './types.js';
20
20
  import { PostgresBackend } from './backends/postgres.js';
21
- import { randomUUID } from 'crypto';
22
- import { log } from './log-context.js';
23
21
 
24
22
  /* Thin wrappers — every function creates a lightweight backend wrapper
25
23
  around the given pool and forwards the call. The class itself holds
@@ -94,7 +92,9 @@ export const retryJob = async (pool: Pool, jobId: number): Promise<void> =>
94
92
  export const cleanupOldJobs = async (
95
93
  pool: Pool,
96
94
  daysToKeep = 30,
97
- ): Promise<number> => new PostgresBackend(pool).cleanupOldJobs(daysToKeep);
95
+ batchSize = 1000,
96
+ ): Promise<number> =>
97
+ new PostgresBackend(pool).cleanupOldJobs(daysToKeep, batchSize);
98
98
 
99
99
  export const cancelJob = async (pool: Pool, jobId: number): Promise<void> =>
100
100
  new PostgresBackend(pool).cancelJob(jobId);
@@ -214,12 +214,9 @@ export const updateProgress = async (
214
214
  progress: number,
215
215
  ): Promise<void> => new PostgresBackend(pool).updateProgress(jobId, progress);
216
216
 
217
- // ── Wait support functions (PostgreSQL-only) ─────────────────────────────────
217
+ // ── Wait support functions (backward-compatible delegates) ────────────────────
218
218
 
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
- */
219
+ /** @deprecated Use backend.waitJob() directly. Delegates to PostgresBackend. */
223
220
  export const waitJob = async (
224
221
  pool: Pool,
225
222
  jobId: number,
@@ -228,266 +225,37 @@ export const waitJob = async (
228
225
  waitTokenId?: string;
229
226
  stepData: Record<string, any>;
230
227
  },
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
- };
228
+ ): Promise<void> => new PostgresBackend(pool).waitJob(jobId, options);
271
229
 
272
- /**
273
- * Update step_data for a job. Called after each ctx.run() step completes
274
- * to persist intermediate progress.
275
- */
230
+ /** @deprecated Use backend.updateStepData() directly. Delegates to PostgresBackend. */
276
231
  export const updateStepData = async (
277
232
  pool: Pool,
278
233
  jobId: number,
279
234
  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
- };
294
-
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;
235
+ ): Promise<void> => new PostgresBackend(pool).updateStepData(jobId, stepData);
303
236
 
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
- */
237
+ /** @deprecated Use backend.createWaitpoint() directly. Delegates to PostgresBackend. */
347
238
  export const createWaitpoint = async (
348
239
  pool: Pool,
349
240
  jobId: number | null,
350
241
  options?: { timeout?: string; tags?: string[] },
351
- ): Promise<{ id: string }> => {
352
- const client = await pool.connect();
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
- }
361
-
362
- await client.query(
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
- );
242
+ ): Promise<{ id: string }> =>
243
+ new PostgresBackend(pool).createWaitpoint(jobId, options);
366
244
 
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
- */
245
+ /** @deprecated Use backend.completeWaitpoint() directly. Delegates to PostgresBackend. */
382
246
  export const completeWaitpoint = async (
383
247
  pool: Pool,
384
248
  tokenId: string,
385
249
  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
- };
250
+ ): Promise<void> => new PostgresBackend(pool).completeWaitpoint(tokenId, data);
427
251
 
428
- /**
429
- * Retrieve a waitpoint token by its ID.
430
- */
252
+ /** @deprecated Use backend.getWaitpoint() directly. Delegates to PostgresBackend. */
431
253
  export const getWaitpoint = async (
432
254
  pool: Pool,
433
255
  tokenId: string,
434
- ): Promise<WaitpointRecord | null> => {
435
- const client = await pool.connect();
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
- }
256
+ ): Promise<WaitpointRecord | null> =>
257
+ new PostgresBackend(pool).getWaitpoint(tokenId);
479
258
 
480
- await client.query('COMMIT');
481
- const count = result.rowCount || 0;
482
- if (count > 0) {
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
- };
259
+ /** @deprecated Use backend.expireTimedOutWaitpoints() directly. Delegates to PostgresBackend. */
260
+ export const expireTimedOutWaitpoints = async (pool: Pool): Promise<number> =>
261
+ new PostgresBackend(pool).expireTimedOutWaitpoints();
package/src/types.ts CHANGED
@@ -485,6 +485,23 @@ export interface PostgresJobQueueConfig {
485
485
  user?: string;
486
486
  password?: string;
487
487
  ssl?: DatabaseSSLConfig;
488
+ /**
489
+ * Maximum number of clients in the pool (default: 10).
490
+ * Increase when running multiple processors in the same process.
491
+ */
492
+ max?: number;
493
+ /**
494
+ * Minimum number of idle clients in the pool (default: 0).
495
+ */
496
+ min?: number;
497
+ /**
498
+ * Milliseconds a client must sit idle before being closed (default: 10000).
499
+ */
500
+ idleTimeoutMillis?: number;
501
+ /**
502
+ * Milliseconds to wait for a connection before throwing (default: 0, no timeout).
503
+ */
504
+ connectionTimeoutMillis?: number;
488
505
  };
489
506
  verbose?: boolean;
490
507
  }
@@ -533,6 +550,90 @@ export type JobQueueConfigLegacy = PostgresJobQueueConfig;
533
550
 
534
551
  export type TagQueryMode = 'exact' | 'all' | 'any' | 'none';
535
552
 
553
+ // ── Cron schedule types ──────────────────────────────────────────────
554
+
555
+ /**
556
+ * Status of a cron schedule.
557
+ */
558
+ export type CronScheduleStatus = 'active' | 'paused';
559
+
560
+ /**
561
+ * Options for creating a recurring cron schedule.
562
+ * Each schedule defines a recurring job that is automatically enqueued
563
+ * when its cron expression matches.
564
+ */
565
+ export interface CronScheduleOptions<
566
+ PayloadMap,
567
+ T extends JobType<PayloadMap>,
568
+ > {
569
+ /** Unique human-readable name for the schedule. */
570
+ scheduleName: string;
571
+ /** Standard cron expression (5 fields, e.g. "0 * * * *"). */
572
+ cronExpression: string;
573
+ /** Job type from the PayloadMap. */
574
+ jobType: T;
575
+ /** Payload for each job instance. */
576
+ payload: PayloadMap[T];
577
+ /** Maximum retry attempts for each job instance (default: 3). */
578
+ maxAttempts?: number;
579
+ /** Priority for each job instance (default: 0). */
580
+ priority?: number;
581
+ /** Timeout in milliseconds for each job instance. */
582
+ timeoutMs?: number;
583
+ /** Whether to force-kill the job on timeout (default: false). */
584
+ forceKillOnTimeout?: boolean;
585
+ /** Tags for each job instance. */
586
+ tags?: string[];
587
+ /** IANA timezone string for cron evaluation (default: "UTC"). */
588
+ timezone?: string;
589
+ /**
590
+ * Whether to allow overlapping job instances (default: false).
591
+ * When false, a new job will not be enqueued if the previous instance
592
+ * is still pending, processing, or waiting.
593
+ */
594
+ allowOverlap?: boolean;
595
+ }
596
+
597
+ /**
598
+ * A persisted cron schedule record.
599
+ */
600
+ export interface CronScheduleRecord {
601
+ id: number;
602
+ scheduleName: string;
603
+ cronExpression: string;
604
+ jobType: string;
605
+ payload: any;
606
+ maxAttempts: number;
607
+ priority: number;
608
+ timeoutMs: number | null;
609
+ forceKillOnTimeout: boolean;
610
+ tags: string[] | undefined;
611
+ timezone: string;
612
+ allowOverlap: boolean;
613
+ status: CronScheduleStatus;
614
+ lastEnqueuedAt: Date | null;
615
+ lastJobId: number | null;
616
+ nextRunAt: Date | null;
617
+ createdAt: Date;
618
+ updatedAt: Date;
619
+ }
620
+
621
+ /**
622
+ * Options for editing an existing cron schedule.
623
+ * All fields are optional; only provided fields are updated.
624
+ */
625
+ export interface EditCronScheduleOptions {
626
+ cronExpression?: string;
627
+ payload?: any;
628
+ maxAttempts?: number;
629
+ priority?: number;
630
+ timeoutMs?: number | null;
631
+ forceKillOnTimeout?: boolean;
632
+ tags?: string[] | null;
633
+ timezone?: string;
634
+ allowOverlap?: boolean;
635
+ }
636
+
536
637
  export interface JobQueue<PayloadMap> {
537
638
  /**
538
639
  * Add a job to the job queue.
@@ -606,12 +707,21 @@ export interface JobQueue<PayloadMap> {
606
707
  retryJob: (jobId: number) => Promise<void>;
607
708
  /**
608
709
  * Cleanup jobs that are older than the specified number of days.
710
+ * Deletes in batches for scale safety.
711
+ * @param daysToKeep - Number of days to retain completed jobs (default 30).
712
+ * @param batchSize - Number of rows to delete per batch (default 1000 for PostgreSQL, 200 for Redis).
609
713
  */
610
- cleanupOldJobs: (daysToKeep?: number) => Promise<number>;
714
+ cleanupOldJobs: (daysToKeep?: number, batchSize?: number) => Promise<number>;
611
715
  /**
612
716
  * Cleanup job events that are older than the specified number of days.
717
+ * Deletes in batches for scale safety.
718
+ * @param daysToKeep - Number of days to retain events (default 30).
719
+ * @param batchSize - Number of rows to delete per batch (default 1000).
613
720
  */
614
- cleanupOldJobEvents: (daysToKeep?: number) => Promise<number>;
721
+ cleanupOldJobEvents: (
722
+ daysToKeep?: number,
723
+ batchSize?: number,
724
+ ) => Promise<number>;
615
725
  /**
616
726
  * Cancel a job given its ID.
617
727
  * - This will set the job status to 'cancelled' and clear the locked_at and locked_by.
@@ -695,8 +805,6 @@ export interface JobQueue<PayloadMap> {
695
805
  * Tokens can be completed externally to resume a waiting job.
696
806
  * Can be called outside of handlers (e.g., from an API route).
697
807
  *
698
- * **PostgreSQL backend only.** Throws if the backend is Redis.
699
- *
700
808
  * @param options - Optional token configuration (timeout, tags).
701
809
  * @returns A token object with `id`.
702
810
  */
@@ -706,8 +814,6 @@ export interface JobQueue<PayloadMap> {
706
814
  * Complete a waitpoint token, resuming the associated waiting job.
707
815
  * Can be called from anywhere (API routes, external services, etc.).
708
816
  *
709
- * **PostgreSQL backend only.** Throws if the backend is Redis.
710
- *
711
817
  * @param tokenId - The ID of the token to complete.
712
818
  * @param data - Optional data to pass to the waiting handler.
713
819
  */
@@ -716,8 +822,6 @@ export interface JobQueue<PayloadMap> {
716
822
  /**
717
823
  * Retrieve a waitpoint token by its ID.
718
824
  *
719
- * **PostgreSQL backend only.** Throws if the backend is Redis.
720
- *
721
825
  * @param tokenId - The ID of the token to retrieve.
722
826
  * @returns The token record, or null if not found.
723
827
  */
@@ -727,12 +831,75 @@ export interface JobQueue<PayloadMap> {
727
831
  * Expire timed-out waitpoint tokens and resume their associated jobs.
728
832
  * Call this periodically (e.g., alongside `reclaimStuckJobs`).
729
833
  *
730
- * **PostgreSQL backend only.** Throws if the backend is Redis.
731
- *
732
834
  * @returns The number of tokens that were expired.
733
835
  */
734
836
  expireTimedOutTokens: () => Promise<number>;
735
837
 
838
+ // ── Cron schedule operations ────────────────────────────────────────
839
+
840
+ /**
841
+ * Add a recurring cron schedule. The processor automatically enqueues
842
+ * due cron jobs before each batch, so no manual triggering is needed.
843
+ *
844
+ * @returns The ID of the created schedule.
845
+ * @throws If the cron expression is invalid or the schedule name is already taken.
846
+ */
847
+ addCronJob: <T extends JobType<PayloadMap>>(
848
+ options: CronScheduleOptions<PayloadMap, T>,
849
+ ) => Promise<number>;
850
+
851
+ /**
852
+ * Get a cron schedule by its ID.
853
+ */
854
+ getCronJob: (id: number) => Promise<CronScheduleRecord | null>;
855
+
856
+ /**
857
+ * Get a cron schedule by its unique name.
858
+ */
859
+ getCronJobByName: (name: string) => Promise<CronScheduleRecord | null>;
860
+
861
+ /**
862
+ * List all cron schedules, optionally filtered by status.
863
+ */
864
+ listCronJobs: (status?: CronScheduleStatus) => Promise<CronScheduleRecord[]>;
865
+
866
+ /**
867
+ * Remove a cron schedule by its ID. Does not cancel any already-enqueued jobs.
868
+ */
869
+ removeCronJob: (id: number) => Promise<void>;
870
+
871
+ /**
872
+ * Pause a cron schedule. Paused schedules are skipped by `enqueueDueCronJobs()`.
873
+ */
874
+ pauseCronJob: (id: number) => Promise<void>;
875
+
876
+ /**
877
+ * Resume a paused cron schedule.
878
+ */
879
+ resumeCronJob: (id: number) => Promise<void>;
880
+
881
+ /**
882
+ * Edit an existing cron schedule. Only provided fields are updated.
883
+ * If `cronExpression` or `timezone` changes, `nextRunAt` is recalculated.
884
+ */
885
+ editCronJob: (id: number, updates: EditCronScheduleOptions) => Promise<void>;
886
+
887
+ /**
888
+ * Check all active cron schedules and enqueue jobs for any whose
889
+ * `nextRunAt` has passed. When `allowOverlap` is false (the default),
890
+ * a new job is not enqueued if the previous instance is still
891
+ * pending, processing, or waiting.
892
+ *
893
+ * **Note:** The processor calls this automatically before each batch,
894
+ * so you typically do not need to call it yourself. It is exposed for
895
+ * manual use in tests or one-off scripts.
896
+ *
897
+ * @returns The number of jobs that were enqueued.
898
+ */
899
+ enqueueDueCronJobs: () => Promise<number>;
900
+
901
+ // ── Advanced access ───────────────────────────────────────────────────
902
+
736
903
  /**
737
904
  * Get the PostgreSQL database pool.
738
905
  * Throws if the backend is not PostgreSQL.