@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/index.ts CHANGED
@@ -1,9 +1,3 @@
1
- import {
2
- createWaitpoint,
3
- completeWaitpoint,
4
- getWaitpoint,
5
- expireTimedOutWaitpoints,
6
- } from './queue.js';
7
1
  import { createProcessor } from './processor.js';
8
2
  import {
9
3
  JobQueueConfig,
@@ -14,12 +8,16 @@ import {
14
8
  JobType,
15
9
  PostgresJobQueueConfig,
16
10
  RedisJobQueueConfig,
11
+ CronScheduleOptions,
12
+ CronScheduleStatus,
13
+ EditCronScheduleOptions,
17
14
  } from './types.js';
18
- import { QueueBackend } from './backend.js';
15
+ import { QueueBackend, CronScheduleInput } from './backend.js';
19
16
  import { setLogContext } from './log-context.js';
20
17
  import { createPool } from './db-util.js';
21
18
  import { PostgresBackend } from './backends/postgres.js';
22
19
  import { RedisBackend } from './backends/redis.js';
20
+ import { getNextCronOccurrence, validateCronExpression } from './cron.js';
23
21
 
24
22
  /**
25
23
  * Initialize the job queue system.
@@ -33,27 +31,76 @@ export const initJobQueue = <PayloadMap = any>(
33
31
  setLogContext(config.verbose ?? false);
34
32
 
35
33
  let backend: QueueBackend;
36
- let pool: import('pg').Pool | undefined;
37
34
 
38
35
  if (backendType === 'postgres') {
39
36
  const pgConfig = config as PostgresJobQueueConfig;
40
- pool = createPool(pgConfig.databaseConfig);
37
+ const pool = createPool(pgConfig.databaseConfig);
41
38
  backend = new PostgresBackend(pool);
42
39
  } else if (backendType === 'redis') {
43
40
  const redisConfig = (config as RedisJobQueueConfig).redisConfig;
44
- // RedisBackend constructor will throw if ioredis is not installed
45
41
  backend = new RedisBackend(redisConfig);
46
42
  } else {
47
43
  throw new Error(`Unknown backend: ${backendType}`);
48
44
  }
49
45
 
50
- const requirePool = () => {
51
- if (!pool) {
52
- throw new Error(
53
- 'Wait/Token features require the PostgreSQL backend. Configure with backend: "postgres" to use these features.',
46
+ /**
47
+ * Enqueue due cron jobs. Shared by the public API and the processor hook.
48
+ */
49
+ const enqueueDueCronJobsImpl = async (): Promise<number> => {
50
+ const dueSchedules = await backend.getDueCronSchedules();
51
+ let count = 0;
52
+
53
+ for (const schedule of dueSchedules) {
54
+ // Overlap check: skip if allowOverlap is false and last job is still active
55
+ if (!schedule.allowOverlap && schedule.lastJobId !== null) {
56
+ const lastJob = await backend.getJob(schedule.lastJobId);
57
+ if (
58
+ lastJob &&
59
+ (lastJob.status === 'pending' ||
60
+ lastJob.status === 'processing' ||
61
+ lastJob.status === 'waiting')
62
+ ) {
63
+ // Still active — advance nextRunAt but don't enqueue
64
+ const nextRunAt = getNextCronOccurrence(
65
+ schedule.cronExpression,
66
+ schedule.timezone,
67
+ );
68
+ await backend.updateCronScheduleAfterEnqueue(
69
+ schedule.id,
70
+ new Date(),
71
+ schedule.lastJobId,
72
+ nextRunAt,
73
+ );
74
+ continue;
75
+ }
76
+ }
77
+
78
+ // Enqueue a new job instance
79
+ const jobId = await backend.addJob<any, any>({
80
+ jobType: schedule.jobType,
81
+ payload: schedule.payload,
82
+ maxAttempts: schedule.maxAttempts,
83
+ priority: schedule.priority,
84
+ timeoutMs: schedule.timeoutMs ?? undefined,
85
+ forceKillOnTimeout: schedule.forceKillOnTimeout,
86
+ tags: schedule.tags,
87
+ });
88
+
89
+ // Advance to next occurrence
90
+ const nextRunAt = getNextCronOccurrence(
91
+ schedule.cronExpression,
92
+ schedule.timezone,
93
+ );
94
+ await backend.updateCronScheduleAfterEnqueue(
95
+ schedule.id,
96
+ new Date(),
97
+ jobId,
98
+ nextRunAt,
54
99
  );
100
+ count++;
55
101
  }
56
- return pool;
102
+
103
+ return count;
57
104
  };
58
105
 
59
106
  // Return the job queue API
@@ -94,9 +141,10 @@ export const initJobQueue = <PayloadMap = any>(
94
141
  config.verbose ?? false,
95
142
  ),
96
143
  retryJob: (jobId: number) => backend.retryJob(jobId),
97
- cleanupOldJobs: (daysToKeep?: number) => backend.cleanupOldJobs(daysToKeep),
98
- cleanupOldJobEvents: (daysToKeep?: number) =>
99
- backend.cleanupOldJobEvents(daysToKeep),
144
+ cleanupOldJobs: (daysToKeep?: number, batchSize?: number) =>
145
+ backend.cleanupOldJobs(daysToKeep, batchSize),
146
+ cleanupOldJobEvents: (daysToKeep?: number, batchSize?: number) =>
147
+ backend.cleanupOldJobEvents(daysToKeep, batchSize),
100
148
  cancelJob: withLogContext(
101
149
  (jobId: number) => backend.cancelJob(jobId),
102
150
  config.verbose ?? false,
@@ -153,11 +201,14 @@ export const initJobQueue = <PayloadMap = any>(
153
201
  config.verbose ?? false,
154
202
  ),
155
203
 
156
- // Job processing
204
+ // Job processing — automatically enqueues due cron jobs before each batch
157
205
  createProcessor: (
158
206
  handlers: JobHandlers<PayloadMap>,
159
207
  options?: ProcessorOptions,
160
- ) => createProcessor<PayloadMap>(backend, handlers, options),
208
+ ) =>
209
+ createProcessor<PayloadMap>(backend, handlers, options, async () => {
210
+ await enqueueDueCronJobsImpl();
211
+ }),
161
212
 
162
213
  // Job events
163
214
  getJobEvents: withLogContext(
@@ -165,34 +216,118 @@ export const initJobQueue = <PayloadMap = any>(
165
216
  config.verbose ?? false,
166
217
  ),
167
218
 
168
- // Wait / Token support (PostgreSQL-only for now)
219
+ // Wait / Token support (works with all backends)
169
220
  createToken: withLogContext(
170
221
  (options?: import('./types.js').CreateTokenOptions) =>
171
- createWaitpoint(requirePool(), null, options),
222
+ backend.createWaitpoint(null, options),
172
223
  config.verbose ?? false,
173
224
  ),
174
225
  completeToken: withLogContext(
175
- (tokenId: string, data?: any) =>
176
- completeWaitpoint(requirePool(), tokenId, data),
226
+ (tokenId: string, data?: any) => backend.completeWaitpoint(tokenId, data),
177
227
  config.verbose ?? false,
178
228
  ),
179
229
  getToken: withLogContext(
180
- (tokenId: string) => getWaitpoint(requirePool(), tokenId),
230
+ (tokenId: string) => backend.getWaitpoint(tokenId),
181
231
  config.verbose ?? false,
182
232
  ),
183
233
  expireTimedOutTokens: withLogContext(
184
- () => expireTimedOutWaitpoints(requirePool()),
234
+ () => backend.expireTimedOutWaitpoints(),
235
+ config.verbose ?? false,
236
+ ),
237
+
238
+ // Cron schedule operations
239
+ addCronJob: withLogContext(
240
+ <T extends JobType<PayloadMap>>(
241
+ options: CronScheduleOptions<PayloadMap, T>,
242
+ ) => {
243
+ if (!validateCronExpression(options.cronExpression)) {
244
+ return Promise.reject(
245
+ new Error(`Invalid cron expression: "${options.cronExpression}"`),
246
+ );
247
+ }
248
+ const nextRunAt = getNextCronOccurrence(
249
+ options.cronExpression,
250
+ options.timezone ?? 'UTC',
251
+ );
252
+ const input: CronScheduleInput = {
253
+ scheduleName: options.scheduleName,
254
+ cronExpression: options.cronExpression,
255
+ jobType: options.jobType as string,
256
+ payload: options.payload,
257
+ maxAttempts: options.maxAttempts ?? 3,
258
+ priority: options.priority ?? 0,
259
+ timeoutMs: options.timeoutMs ?? null,
260
+ forceKillOnTimeout: options.forceKillOnTimeout ?? false,
261
+ tags: options.tags,
262
+ timezone: options.timezone ?? 'UTC',
263
+ allowOverlap: options.allowOverlap ?? false,
264
+ nextRunAt,
265
+ };
266
+ return backend.addCronSchedule(input);
267
+ },
268
+ config.verbose ?? false,
269
+ ),
270
+ getCronJob: withLogContext(
271
+ (id: number) => backend.getCronSchedule(id),
272
+ config.verbose ?? false,
273
+ ),
274
+ getCronJobByName: withLogContext(
275
+ (name: string) => backend.getCronScheduleByName(name),
276
+ config.verbose ?? false,
277
+ ),
278
+ listCronJobs: withLogContext(
279
+ (status?: CronScheduleStatus) => backend.listCronSchedules(status),
280
+ config.verbose ?? false,
281
+ ),
282
+ removeCronJob: withLogContext(
283
+ (id: number) => backend.removeCronSchedule(id),
284
+ config.verbose ?? false,
285
+ ),
286
+ pauseCronJob: withLogContext(
287
+ (id: number) => backend.pauseCronSchedule(id),
288
+ config.verbose ?? false,
289
+ ),
290
+ resumeCronJob: withLogContext(
291
+ (id: number) => backend.resumeCronSchedule(id),
292
+ config.verbose ?? false,
293
+ ),
294
+ editCronJob: withLogContext(
295
+ async (id: number, updates: EditCronScheduleOptions) => {
296
+ if (
297
+ updates.cronExpression !== undefined &&
298
+ !validateCronExpression(updates.cronExpression)
299
+ ) {
300
+ throw new Error(
301
+ `Invalid cron expression: "${updates.cronExpression}"`,
302
+ );
303
+ }
304
+ let nextRunAt: Date | null | undefined;
305
+ if (
306
+ updates.cronExpression !== undefined ||
307
+ updates.timezone !== undefined
308
+ ) {
309
+ const existing = await backend.getCronSchedule(id);
310
+ const expr = updates.cronExpression ?? existing?.cronExpression ?? '';
311
+ const tz = updates.timezone ?? existing?.timezone ?? 'UTC';
312
+ nextRunAt = getNextCronOccurrence(expr, tz);
313
+ }
314
+ await backend.editCronSchedule(id, updates, nextRunAt);
315
+ },
316
+ config.verbose ?? false,
317
+ ),
318
+ enqueueDueCronJobs: withLogContext(
319
+ () => enqueueDueCronJobsImpl(),
185
320
  config.verbose ?? false,
186
321
  ),
187
322
 
188
323
  // Advanced access
189
324
  getPool: () => {
190
- if (backendType !== 'postgres') {
325
+ if (!(backend instanceof PostgresBackend)) {
191
326
  throw new Error(
192
327
  'getPool() is only available with the PostgreSQL backend.',
193
328
  );
194
329
  }
195
- return (backend as PostgresBackend).getPool();
330
+ return backend.getPool();
196
331
  },
197
332
  getRedisClient: () => {
198
333
  if (backendType !== 'redis') {
@@ -213,9 +348,10 @@ const withLogContext =
213
348
  };
214
349
 
215
350
  export * from './types.js';
216
- export { QueueBackend } from './backend.js';
351
+ export { QueueBackend, CronScheduleInput } from './backend.js';
217
352
  export { PostgresBackend } from './backends/postgres.js';
218
353
  export {
219
354
  validateHandlerSerializable,
220
355
  testHandlerSerialization,
221
356
  } from './handler-validation.js';
357
+ export { getNextCronOccurrence, validateCronExpression } from './cron.js';
package/src/processor.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { Worker } from 'worker_threads';
2
- import { Pool } from 'pg';
3
2
  import {
4
3
  JobRecord,
5
4
  ProcessorOptions,
@@ -15,69 +14,8 @@ import {
15
14
  WaitTokenResult,
16
15
  } from './types.js';
17
16
  import { QueueBackend } from './backend.js';
18
- import { PostgresBackend } from './backends/postgres.js';
19
- import {
20
- waitJob,
21
- updateStepData,
22
- createWaitpoint,
23
- getWaitpoint,
24
- } from './queue.js';
25
17
  import { log, setLogContext } from './log-context.js';
26
18
 
27
- /**
28
- * Try to extract the underlying pg Pool from a QueueBackend.
29
- * Returns null for non-PostgreSQL backends.
30
- */
31
- function tryExtractPool(backend: QueueBackend): Pool | null {
32
- if (backend instanceof PostgresBackend) {
33
- return backend.getPool();
34
- }
35
- return null;
36
- }
37
-
38
- /**
39
- * Build a JobContext without wait support (for non-PostgreSQL backends).
40
- * prolong/onTimeout work normally; wait-related methods throw helpful errors.
41
- */
42
- function buildBasicContext(
43
- backend: QueueBackend,
44
- jobId: number,
45
- baseCtx: {
46
- prolong: JobContext['prolong'];
47
- onTimeout: JobContext['onTimeout'];
48
- },
49
- ): JobContext {
50
- const waitError = () =>
51
- new Error(
52
- 'Wait features (waitFor, waitUntil, createToken, waitForToken, ctx.run) are currently only supported with the PostgreSQL backend.',
53
- );
54
- return {
55
- prolong: baseCtx.prolong,
56
- onTimeout: baseCtx.onTimeout,
57
- run: async <T>(_stepName: string, fn: () => Promise<T>): Promise<T> => {
58
- // Without PostgreSQL, just execute the function directly (no persistence)
59
- return fn();
60
- },
61
- waitFor: async () => {
62
- throw waitError();
63
- },
64
- waitUntil: async () => {
65
- throw waitError();
66
- },
67
- createToken: async () => {
68
- throw waitError();
69
- },
70
- waitForToken: async () => {
71
- throw waitError();
72
- },
73
- setProgress: async (percent: number) => {
74
- if (percent < 0 || percent > 100)
75
- throw new Error('Progress must be between 0 and 100');
76
- await backend.updateProgress(jobId, Math.round(percent));
77
- },
78
- };
79
- }
80
-
81
19
  /**
82
20
  * Validates that a handler can be serialized for worker thread execution.
83
21
  * Throws an error with helpful message if serialization fails.
@@ -388,7 +326,7 @@ function createNoOpContext(
388
326
  * Marks pending waits as completed and fetches token outputs.
389
327
  */
390
328
  async function resolveCompletedWaits(
391
- pool: Pool,
329
+ backend: QueueBackend,
392
330
  stepData: Record<string, any>,
393
331
  ): Promise<void> {
394
332
  for (const key of Object.keys(stepData)) {
@@ -401,7 +339,7 @@ async function resolveCompletedWaits(
401
339
  stepData[key] = { ...entry, completed: true };
402
340
  } else if (entry.type === 'token' && entry.tokenId) {
403
341
  // Token-based wait -- fetch the waitpoint result
404
- const wp = await getWaitpoint(pool, entry.tokenId);
342
+ const wp = await backend.getWaitpoint(entry.tokenId);
405
343
  if (wp && wp.status === 'completed') {
406
344
  stepData[key] = {
407
345
  ...entry,
@@ -422,10 +360,10 @@ async function resolveCompletedWaits(
422
360
 
423
361
  /**
424
362
  * Build the extended JobContext with step tracking and wait support.
363
+ * Works with any QueueBackend (Postgres or Redis).
425
364
  */
426
365
  function buildWaitContext(
427
366
  backend: QueueBackend,
428
- pool: Pool,
429
367
  jobId: number,
430
368
  stepData: Record<string, any>,
431
369
  baseCtx: {
@@ -455,7 +393,7 @@ function buildWaitContext(
455
393
 
456
394
  // Persist step result
457
395
  stepData[stepName] = { __completed: true, result };
458
- await updateStepData(pool, jobId, stepData);
396
+ await backend.updateStepData(jobId, stepData);
459
397
 
460
398
  return result;
461
399
  },
@@ -498,7 +436,7 @@ function buildWaitContext(
498
436
  },
499
437
 
500
438
  createToken: async (options?) => {
501
- const token = await createWaitpoint(pool, jobId, options);
439
+ const token = await backend.createWaitpoint(jobId, options);
502
440
  return token;
503
441
  },
504
442
 
@@ -517,7 +455,7 @@ function buildWaitContext(
517
455
  }
518
456
 
519
457
  // Check if the token is already completed (e.g., completed while job was still processing)
520
- const wp = await getWaitpoint(pool, tokenId);
458
+ const wp = await backend.getWaitpoint(tokenId);
521
459
  if (wp && wp.status === 'completed') {
522
460
  const result: WaitTokenResult<T> = {
523
461
  ok: true,
@@ -529,7 +467,7 @@ function buildWaitContext(
529
467
  completed: true,
530
468
  result,
531
469
  };
532
- await updateStepData(pool, jobId, stepData);
470
+ await backend.updateStepData(jobId, stepData);
533
471
  return result;
534
472
  }
535
473
  if (wp && wp.status === 'timed_out') {
@@ -543,7 +481,7 @@ function buildWaitContext(
543
481
  completed: true,
544
482
  result,
545
483
  };
546
- await updateStepData(pool, jobId, stepData);
484
+ await backend.updateStepData(jobId, stepData);
547
485
  return result;
548
486
  }
549
487
 
@@ -591,17 +529,14 @@ export async function processJobWithHandlers<
591
529
  // Load step data (may contain completed steps from previous invocations)
592
530
  const stepData: Record<string, any> = { ...(job.stepData || {}) };
593
531
 
594
- // Try to get pool for wait features (PostgreSQL-only)
595
- const pool = tryExtractPool(backend);
596
-
597
532
  // If resuming from a wait, resolve any pending wait entries
598
533
  const hasStepHistory = Object.keys(stepData).some((k) =>
599
534
  k.startsWith('__wait_'),
600
535
  );
601
- if (hasStepHistory && pool) {
602
- await resolveCompletedWaits(pool, stepData);
536
+ if (hasStepHistory) {
537
+ await resolveCompletedWaits(backend, stepData);
603
538
  // Persist the resolved step data
604
- await updateStepData(pool, job.id, stepData);
539
+ await backend.updateStepData(job.id, stepData);
605
540
  }
606
541
 
607
542
  // Per-job timeout logic
@@ -685,10 +620,8 @@ export async function processJobWithHandlers<
685
620
  },
686
621
  };
687
622
 
688
- // Build context: full wait support for PostgreSQL, basic for others
689
- const ctx = pool
690
- ? buildWaitContext(backend, pool, job.id, stepData, baseCtx)
691
- : buildBasicContext(backend, job.id, baseCtx);
623
+ // Build context: full wait support for all backends
624
+ const ctx = buildWaitContext(backend, job.id, stepData, baseCtx);
692
625
 
693
626
  // If forceKillOnTimeout was set but timeoutMs was missing, warn
694
627
  if (forceKillOnTimeout && !hasTimeout) {
@@ -720,22 +653,10 @@ export async function processJobWithHandlers<
720
653
 
721
654
  // Check if this is a WaitSignal (not a real error)
722
655
  if (error instanceof WaitSignal) {
723
- if (!pool) {
724
- // Wait signals should never happen with non-PostgreSQL backends
725
- // since the context methods throw, but guard just in case
726
- await backend.failJob(
727
- job.id,
728
- new Error(
729
- 'WaitSignal received but wait features require the PostgreSQL backend.',
730
- ),
731
- FailureReason.HandlerError,
732
- );
733
- return;
734
- }
735
656
  log(
736
657
  `Job ${job.id} entering wait: type=${error.type}, waitUntil=${error.waitUntil?.toISOString() ?? 'none'}, tokenId=${error.tokenId ?? 'none'}`,
737
658
  );
738
- await waitJob(pool, job.id, {
659
+ await backend.waitJob(job.id, {
739
660
  waitUntil: error.waitUntil,
740
661
  waitTokenId: error.tokenId,
741
662
  stepData: error.stepData,
@@ -818,16 +739,18 @@ export async function processBatchWithHandlers<PayloadMap>(
818
739
  }
819
740
 
820
741
  /**
821
- * Start a job processor that continuously processes jobs
822
- * @param backend - The queue backend
823
- * @param handlers - The job handlers for this processor instance
742
+ * Start a job processor that continuously processes jobs.
743
+ * @param backend - The queue backend.
744
+ * @param handlers - The job handlers for this processor instance.
824
745
  * @param options - The processor options. Leave pollInterval empty to run only once. Use jobType to filter jobs by type.
825
- * @returns {Processor} The processor instance
746
+ * @param onBeforeBatch - Optional callback invoked before each batch. Used internally to enqueue due cron jobs.
747
+ * @returns {Processor} The processor instance.
826
748
  */
827
749
  export const createProcessor = <PayloadMap = any>(
828
750
  backend: QueueBackend,
829
751
  handlers: JobHandlers<PayloadMap>,
830
752
  options: ProcessorOptions = {},
753
+ onBeforeBatch?: () => Promise<void>,
831
754
  ): Processor => {
832
755
  const {
833
756
  workerId = `worker-${Math.random().toString(36).substring(2, 9)}`,
@@ -847,6 +770,22 @@ export const createProcessor = <PayloadMap = any>(
847
770
  const processJobs = async (): Promise<number> => {
848
771
  if (!running) return 0;
849
772
 
773
+ // Run pre-batch hook (e.g. enqueue due cron jobs) before processing
774
+ if (onBeforeBatch) {
775
+ try {
776
+ await onBeforeBatch();
777
+ } catch (hookError) {
778
+ log(`onBeforeBatch hook error: ${hookError}`);
779
+ if (onError) {
780
+ onError(
781
+ hookError instanceof Error
782
+ ? hookError
783
+ : new Error(String(hookError)),
784
+ );
785
+ }
786
+ }
787
+ }
788
+
850
789
  log(
851
790
  `Processing jobs with workerId: ${workerId}${jobType ? ` and jobType: ${Array.isArray(jobType) ? jobType.join(',') : jobType}` : ''}`,
852
791
  );
package/src/queue.test.ts CHANGED
@@ -141,6 +141,35 @@ describe('queue integration', () => {
141
141
  expect(job).toBeNull();
142
142
  });
143
143
 
144
+ it('should cleanup old completed jobs in batches', async () => {
145
+ // Add and complete 5 jobs
146
+ const ids: number[] = [];
147
+ for (let i = 0; i < 5; i++) {
148
+ const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(
149
+ pool,
150
+ {
151
+ jobType: 'email',
152
+ payload: { to: `batch-${i}@example.com` },
153
+ },
154
+ );
155
+ await queue.getNextBatch(pool, 'worker-batch-cleanup', 1);
156
+ await queue.completeJob(pool, jobId);
157
+ ids.push(jobId);
158
+ }
159
+ // Manually backdate all 5
160
+ await pool.query(
161
+ `UPDATE job_queue SET updated_at = NOW() - INTERVAL '31 days' WHERE id = ANY($1::int[])`,
162
+ [ids],
163
+ );
164
+ // Cleanup with batchSize=2 so it takes multiple iterations
165
+ const deleted = await queue.cleanupOldJobs(pool, 30, 2);
166
+ expect(deleted).toBe(5);
167
+ for (const id of ids) {
168
+ const job = await queue.getJob(pool, id);
169
+ expect(job).toBeNull();
170
+ }
171
+ });
172
+
144
173
  it('should cancel a scheduled job', async () => {
145
174
  const jobId = await queue.addJob<{ email: { to: string } }, 'email'>(pool, {
146
175
  jobType: 'email',