@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/dist/index.cjs +2531 -1283
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +367 -17
- package/dist/index.d.ts +367 -17
- package/dist/index.js +2530 -1284
- package/dist/index.js.map +1 -1
- package/migrations/1781200000004_create_cron_schedules_table.sql +33 -0
- package/package.json +3 -2
- package/src/backend.ts +139 -4
- package/src/backends/postgres.ts +676 -30
- package/src/backends/redis-scripts.ts +197 -22
- package/src/backends/redis.test.ts +971 -0
- package/src/backends/redis.ts +789 -22
- package/src/cron.test.ts +126 -0
- package/src/cron.ts +40 -0
- package/src/index.test.ts +361 -0
- package/src/index.ts +165 -29
- package/src/processor.ts +36 -97
- package/src/queue.test.ts +29 -0
- package/src/queue.ts +19 -251
- package/src/types.ts +177 -10
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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) =>
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
) =>
|
|
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 (
|
|
219
|
+
// Wait / Token support (works with all backends)
|
|
169
220
|
createToken: withLogContext(
|
|
170
221
|
(options?: import('./types.js').CreateTokenOptions) =>
|
|
171
|
-
createWaitpoint(
|
|
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(
|
|
230
|
+
(tokenId: string) => backend.getWaitpoint(tokenId),
|
|
181
231
|
config.verbose ?? false,
|
|
182
232
|
),
|
|
183
233
|
expireTimedOutTokens: withLogContext(
|
|
184
|
-
() => expireTimedOutWaitpoints(
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
602
|
-
await resolveCompletedWaits(
|
|
536
|
+
if (hasStepHistory) {
|
|
537
|
+
await resolveCompletedWaits(backend, stepData);
|
|
603
538
|
// Persist the resolved step data
|
|
604
|
-
await updateStepData(
|
|
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
|
|
689
|
-
const ctx =
|
|
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(
|
|
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
|
-
* @
|
|
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',
|