@monque/core 1.6.0 → 1.7.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/CHANGELOG.md +30 -0
- package/dist/index.cjs +281 -124
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +62 -4
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +62 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +279 -125
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +3 -0
- package/src/scheduler/monque.ts +87 -26
- package/src/scheduler/services/index.ts +1 -1
- package/src/scheduler/services/job-manager.ts +151 -117
- package/src/scheduler/services/job-processor.ts +67 -49
- package/src/scheduler/services/job-scheduler.ts +24 -5
- package/src/scheduler/services/lifecycle-manager.ts +6 -1
- package/src/scheduler/services/types.ts +3 -0
- package/src/shared/errors.ts +29 -0
- package/src/shared/index.ts +3 -0
- package/src/shared/utils/index.ts +1 -0
- package/src/shared/utils/job-identifiers.ts +71 -0
|
@@ -19,20 +19,16 @@ export class JobProcessor {
|
|
|
19
19
|
/** Flag to request a re-poll after the current poll finishes */
|
|
20
20
|
private _repollRequested = false;
|
|
21
21
|
|
|
22
|
-
constructor(private readonly ctx: SchedulerContext) {}
|
|
23
|
-
|
|
24
22
|
/**
|
|
25
|
-
*
|
|
23
|
+
* O(1) counter tracking the total number of active jobs across all workers.
|
|
26
24
|
*
|
|
27
|
-
*
|
|
25
|
+
* Incremented when a job is added to `worker.activeJobs` in `_doPoll`,
|
|
26
|
+
* decremented in the `processJob` finally block. Replaces the previous
|
|
27
|
+
* O(workers) loop in `getTotalActiveJobs()` for instance-level throttling.
|
|
28
28
|
*/
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
total += worker.activeJobs.size;
|
|
33
|
-
}
|
|
34
|
-
return total;
|
|
35
|
-
}
|
|
29
|
+
private _totalActiveJobs = 0;
|
|
30
|
+
|
|
31
|
+
constructor(private readonly ctx: SchedulerContext) {}
|
|
36
32
|
|
|
37
33
|
/**
|
|
38
34
|
* Get the number of available slots considering the global instanceConcurrency limit.
|
|
@@ -47,8 +43,7 @@ export class JobProcessor {
|
|
|
47
43
|
return workerAvailableSlots;
|
|
48
44
|
}
|
|
49
45
|
|
|
50
|
-
const
|
|
51
|
-
const globalAvailable = instanceConcurrency - totalActive;
|
|
46
|
+
const globalAvailable = instanceConcurrency - this._totalActiveJobs;
|
|
52
47
|
|
|
53
48
|
return Math.min(workerAvailableSlots, globalAvailable);
|
|
54
49
|
}
|
|
@@ -100,7 +95,7 @@ export class JobProcessor {
|
|
|
100
95
|
// Early exit if global instanceConcurrency is reached
|
|
101
96
|
const { instanceConcurrency } = this.ctx.options;
|
|
102
97
|
|
|
103
|
-
if (instanceConcurrency !== undefined && this.
|
|
98
|
+
if (instanceConcurrency !== undefined && this._totalActiveJobs >= instanceConcurrency) {
|
|
104
99
|
return;
|
|
105
100
|
}
|
|
106
101
|
|
|
@@ -125,31 +120,57 @@ export class JobProcessor {
|
|
|
125
120
|
return;
|
|
126
121
|
}
|
|
127
122
|
|
|
128
|
-
// Try to acquire jobs up to available slots
|
|
123
|
+
// Try to acquire jobs up to available slots in parallel
|
|
124
|
+
if (!this.ctx.isRunning()) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const acquisitionPromises: Promise<void>[] = [];
|
|
129
129
|
for (let i = 0; i < availableSlots; i++) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
130
|
+
acquisitionPromises.push(
|
|
131
|
+
this.acquireJob(name)
|
|
132
|
+
.then(async (job) => {
|
|
133
|
+
if (!job) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (this.ctx.isRunning()) {
|
|
138
|
+
// Add to activeJobs immediately to correctly track concurrency
|
|
139
|
+
worker.activeJobs.set(job._id.toString(), job);
|
|
140
|
+
this._totalActiveJobs++;
|
|
141
|
+
|
|
142
|
+
this.processJob(job, worker).catch((error: unknown) => {
|
|
143
|
+
this.ctx.emit('job:error', { error: toError(error), job });
|
|
144
|
+
});
|
|
145
|
+
} else {
|
|
146
|
+
// Revert claim if shut down while acquiring
|
|
147
|
+
try {
|
|
148
|
+
await this.ctx.collection.updateOne(
|
|
149
|
+
{ _id: job._id, status: JobStatus.PROCESSING, claimedBy: this.ctx.instanceId },
|
|
150
|
+
{
|
|
151
|
+
$set: {
|
|
152
|
+
status: JobStatus.PENDING,
|
|
153
|
+
updatedAt: new Date(),
|
|
154
|
+
},
|
|
155
|
+
$unset: {
|
|
156
|
+
lockedAt: '',
|
|
157
|
+
claimedBy: '',
|
|
158
|
+
lastHeartbeat: '',
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
this.ctx.emit('job:error', { error: toError(error) });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
.catch((error: unknown) => {
|
|
168
|
+
this.ctx.emit('job:error', { error: toError(error) });
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
152
171
|
}
|
|
172
|
+
|
|
173
|
+
await Promise.allSettled(acquisitionPromises);
|
|
153
174
|
}
|
|
154
175
|
}
|
|
155
176
|
|
|
@@ -197,10 +218,6 @@ export class JobProcessor {
|
|
|
197
218
|
},
|
|
198
219
|
);
|
|
199
220
|
|
|
200
|
-
if (!this.ctx.isRunning()) {
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
221
|
if (!result) {
|
|
205
222
|
return null;
|
|
206
223
|
}
|
|
@@ -248,6 +265,8 @@ export class JobProcessor {
|
|
|
248
265
|
}
|
|
249
266
|
} finally {
|
|
250
267
|
worker.activeJobs.delete(jobId);
|
|
268
|
+
this._totalActiveJobs--;
|
|
269
|
+
this.ctx.notifyJobFinished();
|
|
251
270
|
}
|
|
252
271
|
}
|
|
253
272
|
|
|
@@ -271,6 +290,8 @@ export class JobProcessor {
|
|
|
271
290
|
return null;
|
|
272
291
|
}
|
|
273
292
|
|
|
293
|
+
const now = new Date();
|
|
294
|
+
|
|
274
295
|
if (job.repeatInterval) {
|
|
275
296
|
// Recurring job - schedule next run
|
|
276
297
|
const nextRunAt = getNextCronDate(job.repeatInterval);
|
|
@@ -281,13 +302,12 @@ export class JobProcessor {
|
|
|
281
302
|
status: JobStatus.PENDING,
|
|
282
303
|
nextRunAt,
|
|
283
304
|
failCount: 0,
|
|
284
|
-
updatedAt:
|
|
305
|
+
updatedAt: now,
|
|
285
306
|
},
|
|
286
307
|
$unset: {
|
|
287
308
|
lockedAt: '',
|
|
288
309
|
claimedBy: '',
|
|
289
310
|
lastHeartbeat: '',
|
|
290
|
-
heartbeatInterval: '',
|
|
291
311
|
failReason: '',
|
|
292
312
|
},
|
|
293
313
|
},
|
|
@@ -309,13 +329,12 @@ export class JobProcessor {
|
|
|
309
329
|
{
|
|
310
330
|
$set: {
|
|
311
331
|
status: JobStatus.COMPLETED,
|
|
312
|
-
updatedAt:
|
|
332
|
+
updatedAt: now,
|
|
313
333
|
},
|
|
314
334
|
$unset: {
|
|
315
335
|
lockedAt: '',
|
|
316
336
|
claimedBy: '',
|
|
317
337
|
lastHeartbeat: '',
|
|
318
|
-
heartbeatInterval: '',
|
|
319
338
|
failReason: '',
|
|
320
339
|
},
|
|
321
340
|
},
|
|
@@ -353,6 +372,7 @@ export class JobProcessor {
|
|
|
353
372
|
return null;
|
|
354
373
|
}
|
|
355
374
|
|
|
375
|
+
const now = new Date();
|
|
356
376
|
const newFailCount = job.failCount + 1;
|
|
357
377
|
|
|
358
378
|
if (newFailCount >= this.ctx.options.maxRetries) {
|
|
@@ -364,13 +384,12 @@ export class JobProcessor {
|
|
|
364
384
|
status: JobStatus.FAILED,
|
|
365
385
|
failCount: newFailCount,
|
|
366
386
|
failReason: error.message,
|
|
367
|
-
updatedAt:
|
|
387
|
+
updatedAt: now,
|
|
368
388
|
},
|
|
369
389
|
$unset: {
|
|
370
390
|
lockedAt: '',
|
|
371
391
|
claimedBy: '',
|
|
372
392
|
lastHeartbeat: '',
|
|
373
|
-
heartbeatInterval: '',
|
|
374
393
|
},
|
|
375
394
|
},
|
|
376
395
|
{ returnDocument: 'after' },
|
|
@@ -394,13 +413,12 @@ export class JobProcessor {
|
|
|
394
413
|
failCount: newFailCount,
|
|
395
414
|
failReason: error.message,
|
|
396
415
|
nextRunAt,
|
|
397
|
-
updatedAt:
|
|
416
|
+
updatedAt: now,
|
|
398
417
|
},
|
|
399
418
|
$unset: {
|
|
400
419
|
lockedAt: '',
|
|
401
420
|
claimedBy: '',
|
|
402
421
|
lastHeartbeat: '',
|
|
403
|
-
heartbeatInterval: '',
|
|
404
422
|
},
|
|
405
423
|
},
|
|
406
424
|
{ returnDocument: 'after' },
|
|
@@ -7,7 +7,14 @@ import {
|
|
|
7
7
|
type PersistedJob,
|
|
8
8
|
type ScheduleOptions,
|
|
9
9
|
} from '@/jobs';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
ConnectionError,
|
|
12
|
+
getNextCronDate,
|
|
13
|
+
MonqueError,
|
|
14
|
+
PayloadTooLargeError,
|
|
15
|
+
validateJobName,
|
|
16
|
+
validateUniqueKey,
|
|
17
|
+
} from '@/shared';
|
|
11
18
|
|
|
12
19
|
import type { SchedulerContext } from './types.js';
|
|
13
20
|
|
|
@@ -22,6 +29,14 @@ import type { SchedulerContext } from './types.js';
|
|
|
22
29
|
export class JobScheduler {
|
|
23
30
|
constructor(private readonly ctx: SchedulerContext) {}
|
|
24
31
|
|
|
32
|
+
private validateJobIdentifiers(name: string, uniqueKey?: string): void {
|
|
33
|
+
validateJobName(name);
|
|
34
|
+
|
|
35
|
+
if (uniqueKey !== undefined) {
|
|
36
|
+
validateUniqueKey(uniqueKey);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Validate that the job data payload does not exceed the configured maximum BSON byte size.
|
|
27
42
|
*
|
|
@@ -74,6 +89,7 @@ export class JobScheduler {
|
|
|
74
89
|
* @param data - Job payload, will be passed to the worker handler
|
|
75
90
|
* @param options - Scheduling and deduplication options
|
|
76
91
|
* @returns Promise resolving to the created or existing job document
|
|
92
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
77
93
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
78
94
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
79
95
|
*
|
|
@@ -103,6 +119,7 @@ export class JobScheduler {
|
|
|
103
119
|
* ```
|
|
104
120
|
*/
|
|
105
121
|
async enqueue<T>(name: string, data: T, options: EnqueueOptions = {}): Promise<PersistedJob<T>> {
|
|
122
|
+
this.validateJobIdentifiers(name, options.uniqueKey);
|
|
106
123
|
this.validatePayloadSize(data);
|
|
107
124
|
const now = new Date();
|
|
108
125
|
const job: Omit<Job<T>, '_id'> = {
|
|
@@ -115,12 +132,12 @@ export class JobScheduler {
|
|
|
115
132
|
updatedAt: now,
|
|
116
133
|
};
|
|
117
134
|
|
|
118
|
-
if (options.uniqueKey) {
|
|
135
|
+
if (options.uniqueKey !== undefined) {
|
|
119
136
|
job.uniqueKey = options.uniqueKey;
|
|
120
137
|
}
|
|
121
138
|
|
|
122
139
|
try {
|
|
123
|
-
if (options.uniqueKey) {
|
|
140
|
+
if (options.uniqueKey !== undefined) {
|
|
124
141
|
// Use upsert with $setOnInsert for deduplication (scoped by name + uniqueKey)
|
|
125
142
|
const result = await this.ctx.collection.findOneAndUpdate(
|
|
126
143
|
{
|
|
@@ -216,6 +233,7 @@ export class JobScheduler {
|
|
|
216
233
|
* @param data - Job payload, will be passed to the worker handler on each run
|
|
217
234
|
* @param options - Scheduling options (uniqueKey for deduplication)
|
|
218
235
|
* @returns Promise resolving to the created job document with `repeatInterval` set
|
|
236
|
+
* @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
|
|
219
237
|
* @throws {InvalidCronError} If cron expression is invalid
|
|
220
238
|
* @throws {ConnectionError} If database operation fails or scheduler not initialized
|
|
221
239
|
* @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
|
|
@@ -249,6 +267,7 @@ export class JobScheduler {
|
|
|
249
267
|
data: T,
|
|
250
268
|
options: ScheduleOptions = {},
|
|
251
269
|
): Promise<PersistedJob<T>> {
|
|
270
|
+
this.validateJobIdentifiers(name, options.uniqueKey);
|
|
252
271
|
this.validatePayloadSize(data);
|
|
253
272
|
|
|
254
273
|
// Validate cron and get next run date (throws InvalidCronError if invalid)
|
|
@@ -266,12 +285,12 @@ export class JobScheduler {
|
|
|
266
285
|
updatedAt: now,
|
|
267
286
|
};
|
|
268
287
|
|
|
269
|
-
if (options.uniqueKey) {
|
|
288
|
+
if (options.uniqueKey !== undefined) {
|
|
270
289
|
job.uniqueKey = options.uniqueKey;
|
|
271
290
|
}
|
|
272
291
|
|
|
273
292
|
try {
|
|
274
|
-
if (options.uniqueKey) {
|
|
293
|
+
if (options.uniqueKey !== undefined) {
|
|
275
294
|
// Use upsert with $setOnInsert for deduplication (scoped by name + uniqueKey)
|
|
276
295
|
const result = await this.ctx.collection.findOneAndUpdate(
|
|
277
296
|
{
|
|
@@ -10,6 +10,11 @@ import type { SchedulerContext } from './types.js';
|
|
|
10
10
|
*/
|
|
11
11
|
const DEFAULT_RETENTION_INTERVAL = 3600_000;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Statuses that are eligible for cleanup by the retention policy.
|
|
15
|
+
*/
|
|
16
|
+
export const CLEANUP_STATUSES = [JobStatus.COMPLETED, JobStatus.FAILED] as const;
|
|
17
|
+
|
|
13
18
|
/**
|
|
14
19
|
* Callbacks for timer-driven operations.
|
|
15
20
|
*
|
|
@@ -133,7 +138,7 @@ export class LifecycleManager {
|
|
|
133
138
|
.catch((error: unknown) => {
|
|
134
139
|
this.ctx.emit('job:error', { error: toError(error) });
|
|
135
140
|
})
|
|
136
|
-
.
|
|
141
|
+
.finally(() => {
|
|
137
142
|
this.scheduleNextPoll();
|
|
138
143
|
});
|
|
139
144
|
}
|
|
@@ -63,6 +63,9 @@ export interface SchedulerContext {
|
|
|
63
63
|
/** Notify the local scheduler about a pending job transition */
|
|
64
64
|
notifyPendingJob: (name: string, nextRunAt: Date) => void;
|
|
65
65
|
|
|
66
|
+
/** Notify that a job has finished processing (for reactive shutdown drain) */
|
|
67
|
+
notifyJobFinished: () => void;
|
|
68
|
+
|
|
66
69
|
/** Convert MongoDB document to typed PersistedJob */
|
|
67
70
|
documentToPersistedJob: <T>(doc: WithId<Document>) => PersistedJob<T>;
|
|
68
71
|
}
|
package/src/shared/errors.ts
CHANGED
|
@@ -199,6 +199,35 @@ export class InvalidCursorError extends MonqueError {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Error thrown when a public job identifier fails validation.
|
|
204
|
+
*
|
|
205
|
+
* @example
|
|
206
|
+
* ```typescript
|
|
207
|
+
* try {
|
|
208
|
+
* await monque.enqueue('invalid job name', {});
|
|
209
|
+
* } catch (error) {
|
|
210
|
+
* if (error instanceof InvalidJobIdentifierError) {
|
|
211
|
+
* console.error(`Invalid ${error.field}: ${error.message}`);
|
|
212
|
+
* }
|
|
213
|
+
* }
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
export class InvalidJobIdentifierError extends MonqueError {
|
|
217
|
+
constructor(
|
|
218
|
+
public readonly field: 'name' | 'uniqueKey',
|
|
219
|
+
public readonly value: string,
|
|
220
|
+
message: string,
|
|
221
|
+
) {
|
|
222
|
+
super(message);
|
|
223
|
+
this.name = 'InvalidJobIdentifierError';
|
|
224
|
+
/* istanbul ignore next -- @preserve captureStackTrace is always available in Node.js */
|
|
225
|
+
if (Error.captureStackTrace) {
|
|
226
|
+
Error.captureStackTrace(this, InvalidJobIdentifierError);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
202
231
|
/**
|
|
203
232
|
* Error thrown when a statistics aggregation times out.
|
|
204
233
|
*
|
package/src/shared/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
ConnectionError,
|
|
4
4
|
InvalidCronError,
|
|
5
5
|
InvalidCursorError,
|
|
6
|
+
InvalidJobIdentifierError,
|
|
6
7
|
JobStateError,
|
|
7
8
|
MonqueError,
|
|
8
9
|
PayloadTooLargeError,
|
|
@@ -17,4 +18,6 @@ export {
|
|
|
17
18
|
getNextCronDate,
|
|
18
19
|
toError,
|
|
19
20
|
validateCronExpression,
|
|
21
|
+
validateJobName,
|
|
22
|
+
validateUniqueKey,
|
|
20
23
|
} from './utils/index.js';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { InvalidJobIdentifierError } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
const JOB_NAME_PATTERN = /^[^\s\p{Cc}]+$/u;
|
|
4
|
+
const CONTROL_CHARACTER_PATTERN = /\p{Cc}/u;
|
|
5
|
+
|
|
6
|
+
const MAX_JOB_NAME_LENGTH = 255;
|
|
7
|
+
const MAX_UNIQUE_KEY_LENGTH = 1024;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validate a public job name before it is registered or scheduled.
|
|
11
|
+
*
|
|
12
|
+
* @param name - The job name to validate
|
|
13
|
+
* @throws {InvalidJobIdentifierError} If the job name is empty, too long, or contains unsupported characters
|
|
14
|
+
*/
|
|
15
|
+
export function validateJobName(name: string): void {
|
|
16
|
+
if (name.length === 0 || name.trim().length === 0) {
|
|
17
|
+
throw new InvalidJobIdentifierError(
|
|
18
|
+
'name',
|
|
19
|
+
name,
|
|
20
|
+
'Job name cannot be empty or whitespace only.',
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (name.length > MAX_JOB_NAME_LENGTH) {
|
|
25
|
+
throw new InvalidJobIdentifierError(
|
|
26
|
+
'name',
|
|
27
|
+
name,
|
|
28
|
+
`Job name cannot exceed ${MAX_JOB_NAME_LENGTH} characters.`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!JOB_NAME_PATTERN.test(name)) {
|
|
33
|
+
throw new InvalidJobIdentifierError(
|
|
34
|
+
'name',
|
|
35
|
+
name,
|
|
36
|
+
'Job name cannot contain whitespace or control characters.',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate a deduplication key before it is stored or used in a unique query.
|
|
43
|
+
*
|
|
44
|
+
* @param uniqueKey - The unique key to validate
|
|
45
|
+
* @throws {InvalidJobIdentifierError} If the key is empty, too long, or contains control characters
|
|
46
|
+
*/
|
|
47
|
+
export function validateUniqueKey(uniqueKey: string): void {
|
|
48
|
+
if (uniqueKey.length === 0 || uniqueKey.trim().length === 0) {
|
|
49
|
+
throw new InvalidJobIdentifierError(
|
|
50
|
+
'uniqueKey',
|
|
51
|
+
uniqueKey,
|
|
52
|
+
'Unique key cannot be empty or whitespace only.',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (uniqueKey.length > MAX_UNIQUE_KEY_LENGTH) {
|
|
57
|
+
throw new InvalidJobIdentifierError(
|
|
58
|
+
'uniqueKey',
|
|
59
|
+
uniqueKey,
|
|
60
|
+
`Unique key cannot exceed ${MAX_UNIQUE_KEY_LENGTH} characters.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (CONTROL_CHARACTER_PATTERN.test(uniqueKey)) {
|
|
65
|
+
throw new InvalidJobIdentifierError(
|
|
66
|
+
'uniqueKey',
|
|
67
|
+
uniqueKey,
|
|
68
|
+
'Unique key cannot contain control characters.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|