@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/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';
@@ -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
- const jobPromise = handler(job.payload, controller.signal);
53
- if (timeoutMs && timeoutMs > 0) {
54
- await Promise.race([
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
- await jobPromise;
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);