@nicnocquee/dataqueue 1.19.3 → 1.21.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 +225 -12
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +53 -3
- package/dist/index.d.ts +53 -3
- package/dist/index.js +225 -12
- package/dist/index.js.map +1 -1
- package/migrations/1751984773000_add_tags_to_job_queue.sql +7 -0
- package/package.json +3 -3
- package/src/index.test.ts +23 -0
- package/src/index.ts +30 -2
- package/src/queue.test.ts +698 -2
- package/src/queue.ts +268 -11
- package/src/types.ts +40 -2
package/src/queue.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
FailureReason,
|
|
6
6
|
JobEvent,
|
|
7
7
|
JobEventType,
|
|
8
|
+
TagQueryMode,
|
|
8
9
|
} from './types.js';
|
|
9
10
|
import { log } from './log-context.js';
|
|
10
11
|
|
|
@@ -43,6 +44,7 @@ export const addJob = async <PayloadMap, T extends keyof PayloadMap & string>(
|
|
|
43
44
|
priority = 0,
|
|
44
45
|
runAt = null,
|
|
45
46
|
timeoutMs = undefined,
|
|
47
|
+
tags = undefined,
|
|
46
48
|
}: JobOptions<PayloadMap, T>,
|
|
47
49
|
): Promise<number> => {
|
|
48
50
|
const client = await pool.connect();
|
|
@@ -51,29 +53,45 @@ export const addJob = async <PayloadMap, T extends keyof PayloadMap & string>(
|
|
|
51
53
|
if (runAt) {
|
|
52
54
|
result = await client.query(
|
|
53
55
|
`INSERT INTO job_queue
|
|
54
|
-
(job_type, payload, max_attempts, priority, run_at, timeout_ms)
|
|
55
|
-
VALUES ($1, $2, $3, $4, $5, $6)
|
|
56
|
+
(job_type, payload, max_attempts, priority, run_at, timeout_ms, tags)
|
|
57
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
56
58
|
RETURNING id`,
|
|
57
|
-
[
|
|
59
|
+
[
|
|
60
|
+
jobType,
|
|
61
|
+
payload,
|
|
62
|
+
maxAttempts,
|
|
63
|
+
priority,
|
|
64
|
+
runAt,
|
|
65
|
+
timeoutMs ?? null,
|
|
66
|
+
tags ?? null,
|
|
67
|
+
],
|
|
58
68
|
);
|
|
59
69
|
log(
|
|
60
|
-
`Added job ${result.rows[0].id}: payload ${JSON.stringify(payload)}, runAt ${runAt.toISOString()}, priority ${priority}, maxAttempts ${maxAttempts} jobType ${jobType}`,
|
|
70
|
+
`Added job ${result.rows[0].id}: payload ${JSON.stringify(payload)}, runAt ${runAt.toISOString()}, priority ${priority}, maxAttempts ${maxAttempts} jobType ${jobType}, tags ${JSON.stringify(tags)}`,
|
|
61
71
|
);
|
|
62
72
|
} else {
|
|
63
73
|
result = await client.query(
|
|
64
74
|
`INSERT INTO job_queue
|
|
65
|
-
(job_type, payload, max_attempts, priority, timeout_ms)
|
|
66
|
-
VALUES ($1, $2, $3, $4, $5)
|
|
75
|
+
(job_type, payload, max_attempts, priority, timeout_ms, tags)
|
|
76
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
67
77
|
RETURNING id`,
|
|
68
|
-
[
|
|
78
|
+
[
|
|
79
|
+
jobType,
|
|
80
|
+
payload,
|
|
81
|
+
maxAttempts,
|
|
82
|
+
priority,
|
|
83
|
+
timeoutMs ?? null,
|
|
84
|
+
tags ?? null,
|
|
85
|
+
],
|
|
69
86
|
);
|
|
70
87
|
log(
|
|
71
|
-
`Added job ${result.rows[0].id}: payload ${JSON.stringify(payload)}, priority ${priority}, maxAttempts ${maxAttempts} jobType ${jobType}`,
|
|
88
|
+
`Added job ${result.rows[0].id}: payload ${JSON.stringify(payload)}, priority ${priority}, maxAttempts ${maxAttempts} jobType ${jobType}, tags ${JSON.stringify(tags)}`,
|
|
72
89
|
);
|
|
73
90
|
}
|
|
74
91
|
await recordJobEvent(pool, result.rows[0].id, JobEventType.Added, {
|
|
75
92
|
jobType,
|
|
76
93
|
payload,
|
|
94
|
+
tags,
|
|
77
95
|
});
|
|
78
96
|
return result.rows[0].id;
|
|
79
97
|
} catch (error) {
|
|
@@ -400,7 +418,12 @@ export const cancelJob = async (pool: Pool, jobId: number): Promise<void> => {
|
|
|
400
418
|
*/
|
|
401
419
|
export const cancelAllUpcomingJobs = async (
|
|
402
420
|
pool: Pool,
|
|
403
|
-
filters?: {
|
|
421
|
+
filters?: {
|
|
422
|
+
jobType?: string;
|
|
423
|
+
priority?: number;
|
|
424
|
+
runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
|
|
425
|
+
tags?: { values: string[]; mode?: TagQueryMode };
|
|
426
|
+
},
|
|
404
427
|
): Promise<number> => {
|
|
405
428
|
const client = await pool.connect();
|
|
406
429
|
try {
|
|
@@ -420,8 +443,61 @@ export const cancelAllUpcomingJobs = async (
|
|
|
420
443
|
params.push(filters.priority);
|
|
421
444
|
}
|
|
422
445
|
if (filters.runAt) {
|
|
423
|
-
|
|
424
|
-
|
|
446
|
+
if (filters.runAt instanceof Date) {
|
|
447
|
+
query += ` AND run_at = $${paramIdx++}`;
|
|
448
|
+
params.push(filters.runAt);
|
|
449
|
+
} else if (typeof filters.runAt === 'object') {
|
|
450
|
+
const ops = filters.runAt;
|
|
451
|
+
if (ops.gt) {
|
|
452
|
+
query += ` AND run_at > $${paramIdx++}`;
|
|
453
|
+
params.push(ops.gt);
|
|
454
|
+
}
|
|
455
|
+
if (ops.gte) {
|
|
456
|
+
query += ` AND run_at >= $${paramIdx++}`;
|
|
457
|
+
params.push(ops.gte);
|
|
458
|
+
}
|
|
459
|
+
if (ops.lt) {
|
|
460
|
+
query += ` AND run_at < $${paramIdx++}`;
|
|
461
|
+
params.push(ops.lt);
|
|
462
|
+
}
|
|
463
|
+
if (ops.lte) {
|
|
464
|
+
query += ` AND run_at <= $${paramIdx++}`;
|
|
465
|
+
params.push(ops.lte);
|
|
466
|
+
}
|
|
467
|
+
if (ops.eq) {
|
|
468
|
+
query += ` AND run_at = $${paramIdx++}`;
|
|
469
|
+
params.push(ops.eq);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (
|
|
474
|
+
filters.tags &&
|
|
475
|
+
filters.tags.values &&
|
|
476
|
+
filters.tags.values.length > 0
|
|
477
|
+
) {
|
|
478
|
+
const mode = filters.tags.mode || 'all';
|
|
479
|
+
const tagValues = filters.tags.values;
|
|
480
|
+
switch (mode) {
|
|
481
|
+
case 'exact':
|
|
482
|
+
query += ` AND tags = $${paramIdx++}`;
|
|
483
|
+
params.push(tagValues);
|
|
484
|
+
break;
|
|
485
|
+
case 'all':
|
|
486
|
+
query += ` AND tags @> $${paramIdx++}`;
|
|
487
|
+
params.push(tagValues);
|
|
488
|
+
break;
|
|
489
|
+
case 'any':
|
|
490
|
+
query += ` AND tags && $${paramIdx++}`;
|
|
491
|
+
params.push(tagValues);
|
|
492
|
+
break;
|
|
493
|
+
case 'none':
|
|
494
|
+
query += ` AND NOT (tags && $${paramIdx++})`;
|
|
495
|
+
params.push(tagValues);
|
|
496
|
+
break;
|
|
497
|
+
default:
|
|
498
|
+
query += ` AND tags @> $${paramIdx++}`;
|
|
499
|
+
params.push(tagValues);
|
|
500
|
+
}
|
|
425
501
|
}
|
|
426
502
|
}
|
|
427
503
|
query += '\nRETURNING id';
|
|
@@ -548,3 +624,184 @@ export const getJobEvents = async (
|
|
|
548
624
|
client.release();
|
|
549
625
|
}
|
|
550
626
|
};
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Get jobs by tags (matches all specified tags)
|
|
630
|
+
*/
|
|
631
|
+
export const getJobsByTags = async <
|
|
632
|
+
PayloadMap,
|
|
633
|
+
T extends keyof PayloadMap & string,
|
|
634
|
+
>(
|
|
635
|
+
pool: Pool,
|
|
636
|
+
tags: string[],
|
|
637
|
+
mode: TagQueryMode = 'all',
|
|
638
|
+
limit = 100,
|
|
639
|
+
offset = 0,
|
|
640
|
+
): Promise<JobRecord<PayloadMap, T>[]> => {
|
|
641
|
+
const client = await pool.connect();
|
|
642
|
+
try {
|
|
643
|
+
let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags
|
|
644
|
+
FROM job_queue`;
|
|
645
|
+
let params: any[] = [];
|
|
646
|
+
switch (mode) {
|
|
647
|
+
case 'exact':
|
|
648
|
+
query += ' WHERE tags = $1';
|
|
649
|
+
params = [tags];
|
|
650
|
+
break;
|
|
651
|
+
case 'all':
|
|
652
|
+
query += ' WHERE tags @> $1';
|
|
653
|
+
params = [tags];
|
|
654
|
+
break;
|
|
655
|
+
case 'any':
|
|
656
|
+
query += ' WHERE tags && $1';
|
|
657
|
+
params = [tags];
|
|
658
|
+
break;
|
|
659
|
+
case 'none':
|
|
660
|
+
query += ' WHERE NOT (tags && $1)';
|
|
661
|
+
params = [tags];
|
|
662
|
+
break;
|
|
663
|
+
default:
|
|
664
|
+
query += ' WHERE tags @> $1';
|
|
665
|
+
params = [tags];
|
|
666
|
+
}
|
|
667
|
+
query += ' ORDER BY created_at DESC LIMIT $2 OFFSET $3';
|
|
668
|
+
params.push(limit, offset);
|
|
669
|
+
const result = await client.query(query, params);
|
|
670
|
+
log(
|
|
671
|
+
`Found ${result.rows.length} jobs by tags ${JSON.stringify(tags)} (mode: ${mode})`,
|
|
672
|
+
);
|
|
673
|
+
return result.rows.map((job) => ({
|
|
674
|
+
...job,
|
|
675
|
+
payload: job.payload,
|
|
676
|
+
timeoutMs: job.timeoutMs,
|
|
677
|
+
failureReason: job.failureReason,
|
|
678
|
+
}));
|
|
679
|
+
} catch (error) {
|
|
680
|
+
log(
|
|
681
|
+
`Error getting jobs by tags ${JSON.stringify(tags)} (mode: ${mode}): ${error}`,
|
|
682
|
+
);
|
|
683
|
+
throw error;
|
|
684
|
+
} finally {
|
|
685
|
+
client.release();
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
export const getJobs = async <PayloadMap, T extends keyof PayloadMap & string>(
|
|
690
|
+
pool: Pool,
|
|
691
|
+
filters?: {
|
|
692
|
+
jobType?: string;
|
|
693
|
+
priority?: number;
|
|
694
|
+
runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
|
|
695
|
+
tags?: { values: string[]; mode?: TagQueryMode };
|
|
696
|
+
},
|
|
697
|
+
limit = 100,
|
|
698
|
+
offset = 0,
|
|
699
|
+
): Promise<JobRecord<PayloadMap, T>[]> => {
|
|
700
|
+
const client = await pool.connect();
|
|
701
|
+
try {
|
|
702
|
+
let query = `SELECT id, job_type AS "jobType", payload, status, max_attempts AS "maxAttempts", attempts, priority, run_at AS "runAt", timeout_ms AS "timeoutMs", created_at AS "createdAt", updated_at AS "updatedAt", started_at AS "startedAt", completed_at AS "completedAt", last_failed_at AS "lastFailedAt", locked_at AS "lockedAt", locked_by AS "lockedBy", error_history AS "errorHistory", failure_reason AS "failureReason", next_attempt_at AS "nextAttemptAt", last_failed_at AS "lastFailedAt", last_retried_at AS "lastRetriedAt", last_cancelled_at AS "lastCancelledAt", pending_reason AS "pendingReason", tags FROM job_queue`;
|
|
703
|
+
const params: any[] = [];
|
|
704
|
+
let where: string[] = [];
|
|
705
|
+
let paramIdx = 1;
|
|
706
|
+
if (filters) {
|
|
707
|
+
if (filters.jobType) {
|
|
708
|
+
where.push(`job_type = $${paramIdx++}`);
|
|
709
|
+
params.push(filters.jobType);
|
|
710
|
+
}
|
|
711
|
+
if (filters.priority !== undefined) {
|
|
712
|
+
where.push(`priority = $${paramIdx++}`);
|
|
713
|
+
params.push(filters.priority);
|
|
714
|
+
}
|
|
715
|
+
if (filters.runAt) {
|
|
716
|
+
if (filters.runAt instanceof Date) {
|
|
717
|
+
where.push(`run_at = $${paramIdx++}`);
|
|
718
|
+
params.push(filters.runAt);
|
|
719
|
+
} else if (
|
|
720
|
+
typeof filters.runAt === 'object' &&
|
|
721
|
+
(filters.runAt.gt !== undefined ||
|
|
722
|
+
filters.runAt.gte !== undefined ||
|
|
723
|
+
filters.runAt.lt !== undefined ||
|
|
724
|
+
filters.runAt.lte !== undefined ||
|
|
725
|
+
filters.runAt.eq !== undefined)
|
|
726
|
+
) {
|
|
727
|
+
const ops = filters.runAt as {
|
|
728
|
+
gt?: Date;
|
|
729
|
+
gte?: Date;
|
|
730
|
+
lt?: Date;
|
|
731
|
+
lte?: Date;
|
|
732
|
+
eq?: Date;
|
|
733
|
+
};
|
|
734
|
+
if (ops.gt) {
|
|
735
|
+
where.push(`run_at > $${paramIdx++}`);
|
|
736
|
+
params.push(ops.gt);
|
|
737
|
+
}
|
|
738
|
+
if (ops.gte) {
|
|
739
|
+
where.push(`run_at >= $${paramIdx++}`);
|
|
740
|
+
params.push(ops.gte);
|
|
741
|
+
}
|
|
742
|
+
if (ops.lt) {
|
|
743
|
+
where.push(`run_at < $${paramIdx++}`);
|
|
744
|
+
params.push(ops.lt);
|
|
745
|
+
}
|
|
746
|
+
if (ops.lte) {
|
|
747
|
+
where.push(`run_at <= $${paramIdx++}`);
|
|
748
|
+
params.push(ops.lte);
|
|
749
|
+
}
|
|
750
|
+
if (ops.eq) {
|
|
751
|
+
where.push(`run_at = $${paramIdx++}`);
|
|
752
|
+
params.push(ops.eq);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (
|
|
757
|
+
filters.tags &&
|
|
758
|
+
filters.tags.values &&
|
|
759
|
+
filters.tags.values.length > 0
|
|
760
|
+
) {
|
|
761
|
+
const mode = filters.tags.mode || 'all';
|
|
762
|
+
const tagValues = filters.tags.values;
|
|
763
|
+
switch (mode) {
|
|
764
|
+
case 'exact':
|
|
765
|
+
where.push(`tags = $${paramIdx++}`);
|
|
766
|
+
params.push(tagValues);
|
|
767
|
+
break;
|
|
768
|
+
case 'all':
|
|
769
|
+
where.push(`tags @> $${paramIdx++}`);
|
|
770
|
+
params.push(tagValues);
|
|
771
|
+
break;
|
|
772
|
+
case 'any':
|
|
773
|
+
where.push(`tags && $${paramIdx++}`);
|
|
774
|
+
params.push(tagValues);
|
|
775
|
+
break;
|
|
776
|
+
case 'none':
|
|
777
|
+
where.push(`NOT (tags && $${paramIdx++})`);
|
|
778
|
+
params.push(tagValues);
|
|
779
|
+
break;
|
|
780
|
+
default:
|
|
781
|
+
where.push(`tags @> $${paramIdx++}`);
|
|
782
|
+
params.push(tagValues);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (where.length > 0) {
|
|
787
|
+
query += ` WHERE ${where.join(' AND ')}`;
|
|
788
|
+
}
|
|
789
|
+
// Always add LIMIT and OFFSET as the last parameters
|
|
790
|
+
paramIdx = params.length + 1;
|
|
791
|
+
query += ` ORDER BY created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx}`;
|
|
792
|
+
params.push(limit, offset);
|
|
793
|
+
const result = await client.query(query, params);
|
|
794
|
+
log(`Found ${result.rows.length} jobs`);
|
|
795
|
+
return result.rows.map((job) => ({
|
|
796
|
+
...job,
|
|
797
|
+
payload: job.payload,
|
|
798
|
+
timeoutMs: job.timeoutMs,
|
|
799
|
+
failureReason: job.failureReason,
|
|
800
|
+
}));
|
|
801
|
+
} catch (error) {
|
|
802
|
+
log(`Error getting jobs: ${error}`);
|
|
803
|
+
throw error;
|
|
804
|
+
} finally {
|
|
805
|
+
client.release();
|
|
806
|
+
}
|
|
807
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface JobOptions<PayloadMap, T extends JobType<PayloadMap>> {
|
|
|
13
13
|
* Timeout for this job in milliseconds. If not set, uses the processor default or unlimited.
|
|
14
14
|
*/
|
|
15
15
|
timeoutMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Tags for this job. Used for grouping, searching, or batch operations.
|
|
18
|
+
*/
|
|
19
|
+
tags?: string[];
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export enum JobEventType {
|
|
@@ -89,6 +93,10 @@ export interface JobRecord<PayloadMap, T extends JobType<PayloadMap>> {
|
|
|
89
93
|
* The time the job was last cancelled.
|
|
90
94
|
*/
|
|
91
95
|
lastCancelledAt: Date | null;
|
|
96
|
+
/**
|
|
97
|
+
* Tags for this job. Used for grouping, searching, or batch operations.
|
|
98
|
+
*/
|
|
99
|
+
tags?: string[];
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
export type JobHandler<PayloadMap, T extends keyof PayloadMap> = (
|
|
@@ -169,6 +177,8 @@ export interface JobQueueConfig {
|
|
|
169
177
|
verbose?: boolean;
|
|
170
178
|
}
|
|
171
179
|
|
|
180
|
+
export type TagQueryMode = 'exact' | 'all' | 'any' | 'none';
|
|
181
|
+
|
|
172
182
|
export interface JobQueue<PayloadMap> {
|
|
173
183
|
/**
|
|
174
184
|
* Add a job to the job queue.
|
|
@@ -193,6 +203,21 @@ export interface JobQueue<PayloadMap> {
|
|
|
193
203
|
limit?: number,
|
|
194
204
|
offset?: number,
|
|
195
205
|
) => Promise<JobRecord<PayloadMap, T>[]>;
|
|
206
|
+
/**
|
|
207
|
+
* Get jobs by tag(s).
|
|
208
|
+
* - Modes:
|
|
209
|
+
* - 'exact': Jobs with exactly the same tags (no more, no less)
|
|
210
|
+
* - 'all': Jobs that have all the given tags (can have more)
|
|
211
|
+
* - 'any': Jobs that have at least one of the given tags
|
|
212
|
+
* - 'none': Jobs that have none of the given tags
|
|
213
|
+
* - Default mode is 'all'.
|
|
214
|
+
*/
|
|
215
|
+
getJobsByTags: <T extends JobType<PayloadMap>>(
|
|
216
|
+
tags: string[],
|
|
217
|
+
mode?: TagQueryMode,
|
|
218
|
+
limit?: number,
|
|
219
|
+
offset?: number,
|
|
220
|
+
) => Promise<JobRecord<PayloadMap, T>[]>;
|
|
196
221
|
/**
|
|
197
222
|
* Get all jobs.
|
|
198
223
|
*/
|
|
@@ -200,6 +225,17 @@ export interface JobQueue<PayloadMap> {
|
|
|
200
225
|
limit?: number,
|
|
201
226
|
offset?: number,
|
|
202
227
|
) => Promise<JobRecord<PayloadMap, T>[]>;
|
|
228
|
+
/**
|
|
229
|
+
* Get jobs by filters.
|
|
230
|
+
/**
|
|
231
|
+
* Get jobs by filters.
|
|
232
|
+
*/
|
|
233
|
+
getJobs: <T extends JobType<PayloadMap>>(filters?: {
|
|
234
|
+
jobType?: string;
|
|
235
|
+
priority?: number;
|
|
236
|
+
runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
|
|
237
|
+
tags?: { values: string[]; mode?: TagQueryMode };
|
|
238
|
+
}) => Promise<JobRecord<PayloadMap, T>[]>;
|
|
203
239
|
/**
|
|
204
240
|
* Retry a job given its ID.
|
|
205
241
|
* - This will set the job status back to 'pending', clear the locked_at and locked_by, and allow it to be picked up by other workers.
|
|
@@ -228,12 +264,14 @@ export interface JobQueue<PayloadMap> {
|
|
|
228
264
|
* - The filters are:
|
|
229
265
|
* - jobType: The job type to cancel.
|
|
230
266
|
* - priority: The priority of the job to cancel.
|
|
231
|
-
* - runAt: The time the job is scheduled to run at.
|
|
267
|
+
* - runAt: The time the job is scheduled to run at (now supports gt/gte/lt/lte/eq).
|
|
268
|
+
* - tags: An object with 'values' (string[]) and 'mode' (TagQueryMode) for tag-based cancellation.
|
|
232
269
|
*/
|
|
233
270
|
cancelAllUpcomingJobs: (filters?: {
|
|
234
271
|
jobType?: string;
|
|
235
272
|
priority?: number;
|
|
236
|
-
runAt?: Date;
|
|
273
|
+
runAt?: Date | { gt?: Date; gte?: Date; lt?: Date; lte?: Date; eq?: Date };
|
|
274
|
+
tags?: { values: string[]; mode?: TagQueryMode };
|
|
237
275
|
}) => Promise<number>;
|
|
238
276
|
/**
|
|
239
277
|
* Create a job processor. Handlers must be provided per-processor.
|