@nicnocquee/dataqueue 1.22.0 → 1.24.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 +486 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +151 -2
- package/dist/index.d.ts +151 -2
- package/dist/index.js +485 -30
- package/dist/index.js.map +1 -1
- package/migrations/1765809419000_add_force_kill_on_timeout_to_job_queue.sql +6 -0
- package/package.json +1 -1
- package/src/handler-validation.test.ts +414 -0
- package/src/handler-validation.ts +168 -0
- package/src/index.test.ts +224 -0
- package/src/index.ts +33 -0
- package/src/processor.test.ts +55 -0
- package/src/processor.ts +261 -17
- package/src/queue.test.ts +522 -0
- package/src/queue.ts +286 -9
- package/src/types.ts +102 -0
package/src/index.test.ts
CHANGED
|
@@ -228,6 +228,87 @@ describe('index integration', () => {
|
|
|
228
228
|
expect(job2?.status).toBe('cancelled');
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
+
it('should edit all pending jobs via JobQueue API', async () => {
|
|
232
|
+
// Add three pending jobs
|
|
233
|
+
const jobId1 = await jobQueue.addJob({
|
|
234
|
+
jobType: 'email',
|
|
235
|
+
payload: { to: 'batch1@example.com' },
|
|
236
|
+
priority: 0,
|
|
237
|
+
});
|
|
238
|
+
const jobId2 = await jobQueue.addJob({
|
|
239
|
+
jobType: 'email',
|
|
240
|
+
payload: { to: 'batch2@example.com' },
|
|
241
|
+
priority: 0,
|
|
242
|
+
});
|
|
243
|
+
const jobId3 = await jobQueue.addJob({
|
|
244
|
+
jobType: 'email',
|
|
245
|
+
payload: { to: 'batch3@example.com' },
|
|
246
|
+
priority: 0,
|
|
247
|
+
});
|
|
248
|
+
// Add a completed job
|
|
249
|
+
const jobId4 = await jobQueue.addJob({
|
|
250
|
+
jobType: 'email',
|
|
251
|
+
payload: { to: 'done@example.com' },
|
|
252
|
+
priority: 0,
|
|
253
|
+
});
|
|
254
|
+
await pool.query(
|
|
255
|
+
`UPDATE job_queue SET status = 'completed' WHERE id = $1`,
|
|
256
|
+
[jobId4],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// Edit all pending jobs
|
|
260
|
+
const editedCount = await jobQueue.editAllPendingJobs(undefined, {
|
|
261
|
+
priority: 10,
|
|
262
|
+
});
|
|
263
|
+
expect(editedCount).toBeGreaterThanOrEqual(3);
|
|
264
|
+
|
|
265
|
+
// Check that all pending jobs are updated
|
|
266
|
+
const job1 = await jobQueue.getJob(jobId1);
|
|
267
|
+
const job2 = await jobQueue.getJob(jobId2);
|
|
268
|
+
const job3 = await jobQueue.getJob(jobId3);
|
|
269
|
+
expect(job1?.priority).toBe(10);
|
|
270
|
+
expect(job2?.priority).toBe(10);
|
|
271
|
+
expect(job3?.priority).toBe(10);
|
|
272
|
+
|
|
273
|
+
// Completed job should remain unchanged
|
|
274
|
+
const completedJob = await jobQueue.getJob(jobId4);
|
|
275
|
+
expect(completedJob?.priority).toBe(0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should edit pending jobs with filters via JobQueue API', async () => {
|
|
279
|
+
const emailJobId1 = await jobQueue.addJob({
|
|
280
|
+
jobType: 'email',
|
|
281
|
+
payload: { to: 'email1@example.com' },
|
|
282
|
+
priority: 0,
|
|
283
|
+
});
|
|
284
|
+
const emailJobId2 = await jobQueue.addJob({
|
|
285
|
+
jobType: 'email',
|
|
286
|
+
payload: { to: 'email2@example.com' },
|
|
287
|
+
priority: 0,
|
|
288
|
+
});
|
|
289
|
+
const smsJobId = await jobQueue.addJob({
|
|
290
|
+
jobType: 'sms',
|
|
291
|
+
payload: { to: 'sms@example.com' },
|
|
292
|
+
priority: 0,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Edit only email jobs
|
|
296
|
+
const editedCount = await jobQueue.editAllPendingJobs(
|
|
297
|
+
{ jobType: 'email' },
|
|
298
|
+
{
|
|
299
|
+
priority: 5,
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
expect(editedCount).toBeGreaterThanOrEqual(2);
|
|
303
|
+
|
|
304
|
+
const emailJob1 = await jobQueue.getJob(emailJobId1);
|
|
305
|
+
const emailJob2 = await jobQueue.getJob(emailJobId2);
|
|
306
|
+
const smsJob = await jobQueue.getJob(smsJobId);
|
|
307
|
+
expect(emailJob1?.priority).toBe(5);
|
|
308
|
+
expect(emailJob2?.priority).toBe(5);
|
|
309
|
+
expect(smsJob?.priority).toBe(0);
|
|
310
|
+
});
|
|
311
|
+
|
|
231
312
|
it('should cancel all upcoming jobs by runAt', async () => {
|
|
232
313
|
const runAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour in future
|
|
233
314
|
const jobId1 = await jobQueue.addJob({
|
|
@@ -302,4 +383,147 @@ describe('index integration', () => {
|
|
|
302
383
|
expect(job1?.status).toBe('cancelled');
|
|
303
384
|
expect(job2?.status).toBe('pending');
|
|
304
385
|
});
|
|
386
|
+
|
|
387
|
+
it('should edit a pending job via JobQueue API', async () => {
|
|
388
|
+
const jobId = await jobQueue.addJob({
|
|
389
|
+
jobType: 'email',
|
|
390
|
+
payload: { to: 'original@example.com' },
|
|
391
|
+
priority: 0,
|
|
392
|
+
maxAttempts: 3,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
await jobQueue.editJob(jobId, {
|
|
396
|
+
payload: { to: 'updated@example.com' },
|
|
397
|
+
priority: 10,
|
|
398
|
+
maxAttempts: 5,
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const job = await jobQueue.getJob(jobId);
|
|
402
|
+
expect(job?.payload).toEqual({ to: 'updated@example.com' });
|
|
403
|
+
expect(job?.priority).toBe(10);
|
|
404
|
+
expect(job?.maxAttempts).toBe(5);
|
|
405
|
+
expect(job?.status).toBe('pending');
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('should edit a job and then process it correctly', async () => {
|
|
409
|
+
const handler = vi.fn(async (payload: { foo: string }, _signal) => {
|
|
410
|
+
expect(payload.foo).toBe('updated@example.com');
|
|
411
|
+
});
|
|
412
|
+
const jobId = await jobQueue.addJob({
|
|
413
|
+
jobType: 'test',
|
|
414
|
+
payload: { foo: 'original@example.com' },
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Edit the job before processing
|
|
418
|
+
await jobQueue.editJob(jobId, {
|
|
419
|
+
payload: { foo: 'updated@example.com' },
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const processor = jobQueue.createProcessor(
|
|
423
|
+
{
|
|
424
|
+
email: vi.fn(async () => {}),
|
|
425
|
+
sms: vi.fn(async () => {}),
|
|
426
|
+
test: handler,
|
|
427
|
+
},
|
|
428
|
+
{ pollInterval: 100 },
|
|
429
|
+
);
|
|
430
|
+
processor.start();
|
|
431
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
432
|
+
processor.stop();
|
|
433
|
+
|
|
434
|
+
expect(handler).toHaveBeenCalledWith(
|
|
435
|
+
{ foo: 'updated@example.com' },
|
|
436
|
+
expect.any(Object),
|
|
437
|
+
);
|
|
438
|
+
const job = await jobQueue.getJob(jobId);
|
|
439
|
+
expect(job?.status).toBe('completed');
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it('should silently fail when editing non-pending jobs', async () => {
|
|
443
|
+
// Try to edit a completed job
|
|
444
|
+
const jobId1 = await jobQueue.addJob({
|
|
445
|
+
jobType: 'email',
|
|
446
|
+
payload: { to: 'original@example.com' },
|
|
447
|
+
});
|
|
448
|
+
const processor = jobQueue.createProcessor(
|
|
449
|
+
{
|
|
450
|
+
email: vi.fn(async () => {}),
|
|
451
|
+
sms: vi.fn(async () => {}),
|
|
452
|
+
test: vi.fn(async () => {}),
|
|
453
|
+
},
|
|
454
|
+
{ pollInterval: 100 },
|
|
455
|
+
);
|
|
456
|
+
processor.start();
|
|
457
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
458
|
+
processor.stop();
|
|
459
|
+
|
|
460
|
+
const originalJob = await jobQueue.getJob(jobId1);
|
|
461
|
+
expect(originalJob?.status).toBe('completed');
|
|
462
|
+
|
|
463
|
+
await jobQueue.editJob(jobId1, {
|
|
464
|
+
payload: { to: 'updated@example.com' },
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const job = await jobQueue.getJob(jobId1);
|
|
468
|
+
expect(job?.status).toBe('completed');
|
|
469
|
+
expect(job?.payload).toEqual({ to: 'original@example.com' });
|
|
470
|
+
|
|
471
|
+
// Try to edit a processing job
|
|
472
|
+
// Use a handler that takes longer to ensure job stays in processing state
|
|
473
|
+
const slowHandler = vi.fn(async (payload: { to: string }, _signal) => {
|
|
474
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
475
|
+
});
|
|
476
|
+
const slowHandlerTest = vi.fn(async (payload: { foo: string }, _signal) => {
|
|
477
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
478
|
+
});
|
|
479
|
+
const processor2 = jobQueue.createProcessor(
|
|
480
|
+
{
|
|
481
|
+
email: slowHandler,
|
|
482
|
+
sms: slowHandler,
|
|
483
|
+
test: slowHandlerTest,
|
|
484
|
+
},
|
|
485
|
+
{ pollInterval: 100 },
|
|
486
|
+
);
|
|
487
|
+
const jobId2 = await jobQueue.addJob({
|
|
488
|
+
jobType: 'email',
|
|
489
|
+
payload: { to: 'processing@example.com' },
|
|
490
|
+
});
|
|
491
|
+
processor2.start();
|
|
492
|
+
// Wait a bit for job to be picked up
|
|
493
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
494
|
+
// Job should be processing now
|
|
495
|
+
const processingJob = await jobQueue.getJob(jobId2);
|
|
496
|
+
if (processingJob?.status === 'processing') {
|
|
497
|
+
await jobQueue.editJob(jobId2, {
|
|
498
|
+
payload: { to: 'updated@example.com' },
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const job2 = await jobQueue.getJob(jobId2);
|
|
502
|
+
// If still processing, payload should be unchanged
|
|
503
|
+
if (job2?.status === 'processing') {
|
|
504
|
+
expect(job2?.payload).toEqual({ to: 'processing@example.com' });
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
processor2.stop();
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('should record edited event when editing via JobQueue API', async () => {
|
|
511
|
+
const jobId = await jobQueue.addJob({
|
|
512
|
+
jobType: 'email',
|
|
513
|
+
payload: { to: 'original@example.com' },
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
await jobQueue.editJob(jobId, {
|
|
517
|
+
payload: { to: 'updated@example.com' },
|
|
518
|
+
priority: 10,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const events = await jobQueue.getJobEvents(jobId);
|
|
522
|
+
const editEvent = events.find((e) => e.eventType === 'edited');
|
|
523
|
+
expect(editEvent).not.toBeUndefined();
|
|
524
|
+
expect(editEvent?.metadata).toMatchObject({
|
|
525
|
+
payload: { to: 'updated@example.com' },
|
|
526
|
+
priority: 10,
|
|
527
|
+
});
|
|
528
|
+
});
|
|
305
529
|
});
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
getJobEvents,
|
|
12
12
|
getJobsByTags,
|
|
13
13
|
getJobs,
|
|
14
|
+
editJob,
|
|
15
|
+
editAllPendingJobs,
|
|
14
16
|
} from './queue.js';
|
|
15
17
|
import { createProcessor } from './processor.js';
|
|
16
18
|
import {
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
JobOptions,
|
|
20
22
|
ProcessorOptions,
|
|
21
23
|
JobHandlers,
|
|
24
|
+
JobType,
|
|
22
25
|
} from './types.js';
|
|
23
26
|
import { setLogContext } from './log-context.js';
|
|
24
27
|
import { createPool } from './db-util.js';
|
|
@@ -78,6 +81,32 @@ export const initJobQueue = <PayloadMap = any>(
|
|
|
78
81
|
(jobId: number) => cancelJob(pool, jobId),
|
|
79
82
|
config.verbose ?? false,
|
|
80
83
|
),
|
|
84
|
+
editJob: withLogContext(
|
|
85
|
+
<T extends JobType<PayloadMap>>(
|
|
86
|
+
jobId: number,
|
|
87
|
+
updates: import('./types.js').EditJobOptions<PayloadMap, T>,
|
|
88
|
+
) => editJob<PayloadMap, T>(pool, jobId, updates as any),
|
|
89
|
+
config.verbose ?? false,
|
|
90
|
+
),
|
|
91
|
+
editAllPendingJobs: withLogContext(
|
|
92
|
+
<T extends JobType<PayloadMap>>(
|
|
93
|
+
filters:
|
|
94
|
+
| {
|
|
95
|
+
jobType?: string;
|
|
96
|
+
priority?: number;
|
|
97
|
+
runAt?:
|
|
98
|
+
| Date
|
|
99
|
+
| { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
|
|
100
|
+
tags?: {
|
|
101
|
+
values: string[];
|
|
102
|
+
mode?: import('./types.js').TagQueryMode;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
| undefined,
|
|
106
|
+
updates: import('./types.js').EditJobOptions<PayloadMap, T>,
|
|
107
|
+
) => editAllPendingJobs<PayloadMap, T>(pool, filters, updates as any),
|
|
108
|
+
config.verbose ?? false,
|
|
109
|
+
),
|
|
81
110
|
cancelAllUpcomingJobs: withLogContext(
|
|
82
111
|
(filters?: {
|
|
83
112
|
jobType?: string;
|
|
@@ -123,3 +152,7 @@ const withLogContext =
|
|
|
123
152
|
};
|
|
124
153
|
|
|
125
154
|
export * from './types.js';
|
|
155
|
+
export {
|
|
156
|
+
validateHandlerSerializable,
|
|
157
|
+
testHandlerSerialization,
|
|
158
|
+
} from './handler-validation.js';
|
package/src/processor.test.ts
CHANGED
|
@@ -475,4 +475,59 @@ describe('per-job timeout', () => {
|
|
|
475
475
|
const completed = await queue.getJob(pool, jobId);
|
|
476
476
|
expect(completed?.status).toBe('completed');
|
|
477
477
|
});
|
|
478
|
+
|
|
479
|
+
it('should forcefully terminate job when forceKillOnTimeout is true', async () => {
|
|
480
|
+
// Create a handler that ignores the abort signal (simulating a handler that doesn't check signal.aborted)
|
|
481
|
+
// Note: We use a real function (not vi.fn) because vi.fn doesn't serialize properly for worker threads
|
|
482
|
+
const handler: JobHandler<{ test: {} }, 'test'> = async (
|
|
483
|
+
_payload,
|
|
484
|
+
_signal,
|
|
485
|
+
) => {
|
|
486
|
+
// This handler will run indefinitely, ignoring the abort signal
|
|
487
|
+
await new Promise((resolve) => {
|
|
488
|
+
setTimeout(resolve, 1000); // Will never complete in time
|
|
489
|
+
});
|
|
490
|
+
};
|
|
491
|
+
const handlers: { test: JobHandler<{ test: {} }, 'test'> } = {
|
|
492
|
+
test: handler,
|
|
493
|
+
};
|
|
494
|
+
const jobId = await queue.addJob<{ test: {} }, 'test'>(pool, {
|
|
495
|
+
jobType: 'test',
|
|
496
|
+
payload: {},
|
|
497
|
+
timeoutMs: 50, // 50ms timeout
|
|
498
|
+
forceKillOnTimeout: true, // Force kill on timeout
|
|
499
|
+
});
|
|
500
|
+
const job = await queue.getJob<{ test: {} }, 'test'>(pool, jobId);
|
|
501
|
+
expect(job).not.toBeNull();
|
|
502
|
+
expect(job?.forceKillOnTimeout).toBe(true);
|
|
503
|
+
await processJobWithHandlers(pool, job!, handlers);
|
|
504
|
+
const failed = await queue.getJob(pool, jobId);
|
|
505
|
+
expect(failed?.status).toBe('failed');
|
|
506
|
+
expect(failed?.errorHistory?.[0]?.message).toContain('timed out');
|
|
507
|
+
expect(failed?.failureReason).toBe(FailureReason.Timeout);
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('should complete job with forceKillOnTimeout if handler finishes before timeout', async () => {
|
|
511
|
+
// Note: We use a real function (not vi.fn) because vi.fn doesn't serialize properly for worker threads
|
|
512
|
+
const handler: JobHandler<{ test: {} }, 'test'> = async (
|
|
513
|
+
_payload,
|
|
514
|
+
_signal,
|
|
515
|
+
) => {
|
|
516
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
517
|
+
};
|
|
518
|
+
const handlers: { test: JobHandler<{ test: {} }, 'test'> } = {
|
|
519
|
+
test: handler,
|
|
520
|
+
};
|
|
521
|
+
const jobId = await queue.addJob<{ test: {} }, 'test'>(pool, {
|
|
522
|
+
jobType: 'test',
|
|
523
|
+
payload: {},
|
|
524
|
+
timeoutMs: 200, // 200ms
|
|
525
|
+
forceKillOnTimeout: true,
|
|
526
|
+
});
|
|
527
|
+
const job = await queue.getJob<{ test: {} }, 'test'>(pool, jobId);
|
|
528
|
+
expect(job).not.toBeNull();
|
|
529
|
+
await processJobWithHandlers(pool, job!, handlers);
|
|
530
|
+
const completed = await queue.getJob(pool, jobId);
|
|
531
|
+
expect(completed?.status).toBe('completed');
|
|
532
|
+
});
|
|
478
533
|
});
|
package/src/processor.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Pool } from 'pg';
|
|
2
|
+
import { Worker } from 'worker_threads';
|
|
2
3
|
import {
|
|
3
4
|
JobRecord,
|
|
4
5
|
ProcessorOptions,
|
|
@@ -16,6 +17,242 @@ import {
|
|
|
16
17
|
} from './queue.js';
|
|
17
18
|
import { log, setLogContext } from './log-context.js';
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Validates that a handler can be serialized for worker thread execution.
|
|
22
|
+
* Throws an error with helpful message if serialization fails.
|
|
23
|
+
*/
|
|
24
|
+
function validateHandlerSerializable<
|
|
25
|
+
PayloadMap,
|
|
26
|
+
T extends keyof PayloadMap & string,
|
|
27
|
+
>(handler: JobHandler<PayloadMap, T>, jobType: string): void {
|
|
28
|
+
try {
|
|
29
|
+
const handlerString = handler.toString();
|
|
30
|
+
|
|
31
|
+
// Check for common patterns that indicate non-serializable handlers
|
|
32
|
+
// 1. Arrow functions that capture 'this' (indicated by 'this' in the function body but not in parameters)
|
|
33
|
+
if (
|
|
34
|
+
handlerString.includes('this.') &&
|
|
35
|
+
!handlerString.match(/\([^)]*this[^)]*\)/)
|
|
36
|
+
) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Handler for job type "${jobType}" uses 'this' context which cannot be serialized. ` +
|
|
39
|
+
`Use a regular function or avoid 'this' references when forceKillOnTimeout is enabled.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. Check if handler string looks like it might have closures
|
|
44
|
+
// This is a heuristic - we can't perfectly detect closures, but we can warn about common patterns
|
|
45
|
+
if (handlerString.includes('[native code]')) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Handler for job type "${jobType}" contains native code which cannot be serialized. ` +
|
|
48
|
+
`Ensure your handler is a plain function when forceKillOnTimeout is enabled.`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 3. Try to create a function from the string to validate it's parseable
|
|
53
|
+
// This will catch syntax errors early
|
|
54
|
+
try {
|
|
55
|
+
new Function('return ' + handlerString);
|
|
56
|
+
} catch (parseError) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Handler for job type "${jobType}" cannot be serialized: ${parseError instanceof Error ? parseError.message : String(parseError)}. ` +
|
|
59
|
+
`When using forceKillOnTimeout, handlers must be serializable functions without closures over external variables.`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error instanceof Error) {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Failed to validate handler serialization for job type "${jobType}": ${String(error)}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Run a handler in a worker thread for force-kill capability.
|
|
74
|
+
*
|
|
75
|
+
* **IMPORTANT**: The handler must be serializable for this to work. This means:
|
|
76
|
+
* - The handler should be a standalone function or arrow function
|
|
77
|
+
* - It should not capture variables from outer scopes (closures) that reference external dependencies
|
|
78
|
+
* - It should not use 'this' context unless it's a bound method
|
|
79
|
+
* - All dependencies must be importable in the worker thread context
|
|
80
|
+
*
|
|
81
|
+
* If your handler doesn't meet these requirements, use the default graceful shutdown
|
|
82
|
+
* (forceKillOnTimeout: false) and ensure your handler checks signal.aborted.
|
|
83
|
+
*
|
|
84
|
+
* @throws {Error} If the handler cannot be serialized
|
|
85
|
+
*/
|
|
86
|
+
async function runHandlerInWorker<
|
|
87
|
+
PayloadMap,
|
|
88
|
+
T extends keyof PayloadMap & string,
|
|
89
|
+
>(
|
|
90
|
+
handler: JobHandler<PayloadMap, T>,
|
|
91
|
+
payload: PayloadMap[T],
|
|
92
|
+
timeoutMs: number,
|
|
93
|
+
jobType: string,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
// Validate handler can be serialized before attempting to run in worker
|
|
96
|
+
validateHandlerSerializable(handler, jobType);
|
|
97
|
+
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
// Use inline worker code for better compatibility
|
|
100
|
+
// Note: This requires the handler to be serializable (no closures with external dependencies)
|
|
101
|
+
// Wrap in IIFE to allow return statements
|
|
102
|
+
const workerCode = `
|
|
103
|
+
(function() {
|
|
104
|
+
const { parentPort, workerData } = require('worker_threads');
|
|
105
|
+
const { handlerCode, payload, timeoutMs } = workerData;
|
|
106
|
+
|
|
107
|
+
// Create an AbortController for the handler
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
const signal = controller.signal;
|
|
110
|
+
|
|
111
|
+
// Set up timeout
|
|
112
|
+
const timeoutId = setTimeout(() => {
|
|
113
|
+
controller.abort();
|
|
114
|
+
parentPort.postMessage({ type: 'timeout' });
|
|
115
|
+
}, timeoutMs);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
// Execute the handler
|
|
119
|
+
// Note: This uses Function constructor which requires the handler to be serializable.
|
|
120
|
+
// The handler should be validated before reaching this point.
|
|
121
|
+
let handlerFn;
|
|
122
|
+
try {
|
|
123
|
+
// Wrap handlerCode in parentheses to ensure it's treated as an expression
|
|
124
|
+
// This handles both arrow functions and regular functions
|
|
125
|
+
const wrappedCode = handlerCode.trim().startsWith('async') || handlerCode.trim().startsWith('function')
|
|
126
|
+
? handlerCode
|
|
127
|
+
: '(' + handlerCode + ')';
|
|
128
|
+
handlerFn = new Function('return ' + wrappedCode)();
|
|
129
|
+
} catch (parseError) {
|
|
130
|
+
clearTimeout(timeoutId);
|
|
131
|
+
parentPort.postMessage({
|
|
132
|
+
type: 'error',
|
|
133
|
+
error: {
|
|
134
|
+
message: 'Handler cannot be deserialized in worker thread. ' +
|
|
135
|
+
'Ensure your handler is a standalone function without closures over external variables. ' +
|
|
136
|
+
'Original error: ' + (parseError instanceof Error ? parseError.message : String(parseError)),
|
|
137
|
+
stack: parseError instanceof Error ? parseError.stack : undefined,
|
|
138
|
+
name: 'SerializationError',
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Ensure handlerFn is actually a function
|
|
145
|
+
if (typeof handlerFn !== 'function') {
|
|
146
|
+
clearTimeout(timeoutId);
|
|
147
|
+
parentPort.postMessage({
|
|
148
|
+
type: 'error',
|
|
149
|
+
error: {
|
|
150
|
+
message: 'Handler deserialization did not produce a function. ' +
|
|
151
|
+
'Ensure your handler is a valid function when forceKillOnTimeout is enabled.',
|
|
152
|
+
name: 'SerializationError',
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
handlerFn(payload, signal)
|
|
159
|
+
.then(() => {
|
|
160
|
+
clearTimeout(timeoutId);
|
|
161
|
+
parentPort.postMessage({ type: 'success' });
|
|
162
|
+
})
|
|
163
|
+
.catch((error) => {
|
|
164
|
+
clearTimeout(timeoutId);
|
|
165
|
+
parentPort.postMessage({
|
|
166
|
+
type: 'error',
|
|
167
|
+
error: {
|
|
168
|
+
message: error.message,
|
|
169
|
+
stack: error.stack,
|
|
170
|
+
name: error.name,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
} catch (error) {
|
|
175
|
+
clearTimeout(timeoutId);
|
|
176
|
+
parentPort.postMessage({
|
|
177
|
+
type: 'error',
|
|
178
|
+
error: {
|
|
179
|
+
message: error.message,
|
|
180
|
+
stack: error.stack,
|
|
181
|
+
name: error.name,
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
})();
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
const worker = new Worker(workerCode, {
|
|
189
|
+
eval: true,
|
|
190
|
+
workerData: {
|
|
191
|
+
handlerCode: handler.toString(),
|
|
192
|
+
payload,
|
|
193
|
+
timeoutMs,
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
let resolved = false;
|
|
198
|
+
|
|
199
|
+
worker.on('message', (message: { type: string; error?: any }) => {
|
|
200
|
+
if (resolved) return;
|
|
201
|
+
resolved = true;
|
|
202
|
+
|
|
203
|
+
if (message.type === 'success') {
|
|
204
|
+
resolve();
|
|
205
|
+
} else if (message.type === 'timeout') {
|
|
206
|
+
const timeoutError = new Error(
|
|
207
|
+
`Job timed out after ${timeoutMs} ms and was forcefully terminated`,
|
|
208
|
+
);
|
|
209
|
+
// @ts-ignore
|
|
210
|
+
timeoutError.failureReason = FailureReason.Timeout;
|
|
211
|
+
reject(timeoutError);
|
|
212
|
+
} else if (message.type === 'error') {
|
|
213
|
+
const error = new Error(message.error.message);
|
|
214
|
+
error.stack = message.error.stack;
|
|
215
|
+
error.name = message.error.name;
|
|
216
|
+
reject(error);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
worker.on('error', (error) => {
|
|
221
|
+
if (resolved) return;
|
|
222
|
+
resolved = true;
|
|
223
|
+
reject(error);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
worker.on('exit', (code) => {
|
|
227
|
+
if (resolved) return;
|
|
228
|
+
if (code !== 0) {
|
|
229
|
+
resolved = true;
|
|
230
|
+
reject(new Error(`Worker stopped with exit code ${code}`));
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Force terminate worker on timeout
|
|
235
|
+
setTimeout(() => {
|
|
236
|
+
if (!resolved) {
|
|
237
|
+
resolved = true;
|
|
238
|
+
worker
|
|
239
|
+
.terminate()
|
|
240
|
+
.then(() => {
|
|
241
|
+
const timeoutError = new Error(
|
|
242
|
+
`Job timed out after ${timeoutMs} ms and was forcefully terminated`,
|
|
243
|
+
);
|
|
244
|
+
// @ts-ignore
|
|
245
|
+
timeoutError.failureReason = FailureReason.Timeout;
|
|
246
|
+
reject(timeoutError);
|
|
247
|
+
})
|
|
248
|
+
.catch((err) => {
|
|
249
|
+
reject(err);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}, timeoutMs + 100); // Small buffer to ensure timeout is handled
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
19
256
|
/**
|
|
20
257
|
* Process a single job using the provided handler map
|
|
21
258
|
*/
|
|
@@ -46,27 +283,34 @@ export async function processJobWithHandlers<
|
|
|
46
283
|
|
|
47
284
|
// Per-job timeout logic
|
|
48
285
|
const timeoutMs = job.timeoutMs ?? undefined;
|
|
286
|
+
const forceKillOnTimeout = job.forceKillOnTimeout ?? false;
|
|
49
287
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
50
288
|
const controller = new AbortController();
|
|
51
289
|
try {
|
|
52
|
-
|
|
53
|
-
if (timeoutMs && timeoutMs > 0) {
|
|
54
|
-
await
|
|
55
|
-
jobPromise,
|
|
56
|
-
new Promise((_, reject) => {
|
|
57
|
-
timeoutId = setTimeout(() => {
|
|
58
|
-
controller.abort();
|
|
59
|
-
const timeoutError = new Error(
|
|
60
|
-
`Job timed out after ${timeoutMs} ms`,
|
|
61
|
-
);
|
|
62
|
-
// @ts-ignore
|
|
63
|
-
timeoutError.failureReason = FailureReason.Timeout;
|
|
64
|
-
reject(timeoutError);
|
|
65
|
-
}, timeoutMs);
|
|
66
|
-
}),
|
|
67
|
-
]);
|
|
290
|
+
// If forceKillOnTimeout is true, run handler in a worker thread
|
|
291
|
+
if (forceKillOnTimeout && timeoutMs && timeoutMs > 0) {
|
|
292
|
+
await runHandlerInWorker(handler, job.payload, timeoutMs, job.jobType);
|
|
68
293
|
} else {
|
|
69
|
-
|
|
294
|
+
// Default: graceful shutdown with AbortController
|
|
295
|
+
const jobPromise = handler(job.payload, controller.signal);
|
|
296
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
297
|
+
await Promise.race([
|
|
298
|
+
jobPromise,
|
|
299
|
+
new Promise((_, reject) => {
|
|
300
|
+
timeoutId = setTimeout(() => {
|
|
301
|
+
controller.abort();
|
|
302
|
+
const timeoutError = new Error(
|
|
303
|
+
`Job timed out after ${timeoutMs} ms`,
|
|
304
|
+
);
|
|
305
|
+
// @ts-ignore
|
|
306
|
+
timeoutError.failureReason = FailureReason.Timeout;
|
|
307
|
+
reject(timeoutError);
|
|
308
|
+
}, timeoutMs);
|
|
309
|
+
}),
|
|
310
|
+
]);
|
|
311
|
+
} else {
|
|
312
|
+
await jobPromise;
|
|
313
|
+
}
|
|
70
314
|
}
|
|
71
315
|
if (timeoutId) clearTimeout(timeoutId);
|
|
72
316
|
await completeJob(pool, job.id);
|