@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/index.cjs CHANGED
@@ -455,6 +455,30 @@ var InvalidCursorError = class InvalidCursorError extends MonqueError {
455
455
  }
456
456
  };
457
457
  /**
458
+ * Error thrown when a public job identifier fails validation.
459
+ *
460
+ * @example
461
+ * ```typescript
462
+ * try {
463
+ * await monque.enqueue('invalid job name', {});
464
+ * } catch (error) {
465
+ * if (error instanceof InvalidJobIdentifierError) {
466
+ * console.error(`Invalid ${error.field}: ${error.message}`);
467
+ * }
468
+ * }
469
+ * ```
470
+ */
471
+ var InvalidJobIdentifierError = class InvalidJobIdentifierError extends MonqueError {
472
+ constructor(field, value, message) {
473
+ super(message);
474
+ this.field = field;
475
+ this.value = value;
476
+ this.name = "InvalidJobIdentifierError";
477
+ /* istanbul ignore next -- @preserve captureStackTrace is always available in Node.js */
478
+ if (Error.captureStackTrace) Error.captureStackTrace(this, InvalidJobIdentifierError);
479
+ }
480
+ };
481
+ /**
458
482
  * Error thrown when a statistics aggregation times out.
459
483
  *
460
484
  * @example
@@ -648,6 +672,34 @@ function toError(value) {
648
672
  }
649
673
  }
650
674
  //#endregion
675
+ //#region src/shared/utils/job-identifiers.ts
676
+ const JOB_NAME_PATTERN = /^[^\s\p{Cc}]+$/u;
677
+ const CONTROL_CHARACTER_PATTERN = /\p{Cc}/u;
678
+ const MAX_JOB_NAME_LENGTH = 255;
679
+ const MAX_UNIQUE_KEY_LENGTH = 1024;
680
+ /**
681
+ * Validate a public job name before it is registered or scheduled.
682
+ *
683
+ * @param name - The job name to validate
684
+ * @throws {InvalidJobIdentifierError} If the job name is empty, too long, or contains unsupported characters
685
+ */
686
+ function validateJobName(name) {
687
+ if (name.length === 0 || name.trim().length === 0) throw new InvalidJobIdentifierError("name", name, "Job name cannot be empty or whitespace only.");
688
+ if (name.length > MAX_JOB_NAME_LENGTH) throw new InvalidJobIdentifierError("name", name, `Job name cannot exceed ${MAX_JOB_NAME_LENGTH} characters.`);
689
+ if (!JOB_NAME_PATTERN.test(name)) throw new InvalidJobIdentifierError("name", name, "Job name cannot contain whitespace or control characters.");
690
+ }
691
+ /**
692
+ * Validate a deduplication key before it is stored or used in a unique query.
693
+ *
694
+ * @param uniqueKey - The unique key to validate
695
+ * @throws {InvalidJobIdentifierError} If the key is empty, too long, or contains control characters
696
+ */
697
+ function validateUniqueKey(uniqueKey) {
698
+ if (uniqueKey.length === 0 || uniqueKey.trim().length === 0) throw new InvalidJobIdentifierError("uniqueKey", uniqueKey, "Unique key cannot be empty or whitespace only.");
699
+ if (uniqueKey.length > MAX_UNIQUE_KEY_LENGTH) throw new InvalidJobIdentifierError("uniqueKey", uniqueKey, `Unique key cannot exceed ${MAX_UNIQUE_KEY_LENGTH} characters.`);
700
+ if (CONTROL_CHARACTER_PATTERN.test(uniqueKey)) throw new InvalidJobIdentifierError("uniqueKey", uniqueKey, "Unique key cannot contain control characters.");
701
+ }
702
+ //#endregion
651
703
  //#region src/scheduler/helpers.ts
652
704
  /**
653
705
  * Build a MongoDB query filter from a JobSelector.
@@ -1012,21 +1064,28 @@ var JobManager = class {
1012
1064
  async cancelJob(jobId) {
1013
1065
  if (!mongodb.ObjectId.isValid(jobId)) return null;
1014
1066
  const _id = new mongodb.ObjectId(jobId);
1015
- const jobDoc = await this.ctx.collection.findOne({ _id });
1016
- if (!jobDoc) return null;
1017
- if (jobDoc["status"] === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(jobDoc);
1018
- if (jobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot cancel job in status '${jobDoc["status"]}'`, jobId, jobDoc["status"], "cancel");
1019
- const result = await this.ctx.collection.findOneAndUpdate({
1020
- _id,
1021
- status: JobStatus.PENDING
1022
- }, { $set: {
1023
- status: JobStatus.CANCELLED,
1024
- updatedAt: /* @__PURE__ */ new Date()
1025
- } }, { returnDocument: "after" });
1026
- if (!result) throw new JobStateError("Job status changed during cancellation attempt", jobId, "unknown", "cancel");
1027
- const job = this.ctx.documentToPersistedJob(result);
1028
- this.ctx.emit("job:cancelled", { job });
1029
- return job;
1067
+ try {
1068
+ const now = /* @__PURE__ */ new Date();
1069
+ const result = await this.ctx.collection.findOneAndUpdate({
1070
+ _id,
1071
+ status: JobStatus.PENDING
1072
+ }, { $set: {
1073
+ status: JobStatus.CANCELLED,
1074
+ updatedAt: now
1075
+ } }, { returnDocument: "after" });
1076
+ if (!result) {
1077
+ const jobDoc = await this.ctx.collection.findOne({ _id });
1078
+ if (!jobDoc) return null;
1079
+ if (jobDoc["status"] === JobStatus.CANCELLED) return this.ctx.documentToPersistedJob(jobDoc);
1080
+ throw new JobStateError(`Cannot cancel job in status '${jobDoc["status"]}'`, jobId, jobDoc["status"], "cancel");
1081
+ }
1082
+ const job = this.ctx.documentToPersistedJob(result);
1083
+ this.ctx.emit("job:cancelled", { job });
1084
+ return job;
1085
+ } catch (error) {
1086
+ if (error instanceof MonqueError) throw error;
1087
+ throw new ConnectionError(`Failed to cancel job: ${error instanceof Error ? error.message : "Unknown error during cancelJob"}`, error instanceof Error ? { cause: error } : void 0);
1088
+ }
1030
1089
  }
1031
1090
  /**
1032
1091
  * Retry a failed or cancelled job.
@@ -1049,36 +1108,51 @@ var JobManager = class {
1049
1108
  async retryJob(jobId) {
1050
1109
  if (!mongodb.ObjectId.isValid(jobId)) return null;
1051
1110
  const _id = new mongodb.ObjectId(jobId);
1052
- const currentJob = await this.ctx.collection.findOne({ _id });
1053
- if (!currentJob) return null;
1054
- if (currentJob["status"] !== JobStatus.FAILED && currentJob["status"] !== JobStatus.CANCELLED) throw new JobStateError(`Cannot retry job in status '${currentJob["status"]}'`, jobId, currentJob["status"], "retry");
1055
- const previousStatus = currentJob["status"];
1056
- const result = await this.ctx.collection.findOneAndUpdate({
1057
- _id,
1058
- status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] }
1059
- }, {
1060
- $set: {
1061
- status: JobStatus.PENDING,
1062
- failCount: 0,
1063
- nextRunAt: /* @__PURE__ */ new Date(),
1064
- updatedAt: /* @__PURE__ */ new Date()
1065
- },
1066
- $unset: {
1067
- failReason: "",
1068
- lockedAt: "",
1069
- claimedBy: "",
1070
- lastHeartbeat: "",
1071
- heartbeatInterval: ""
1111
+ try {
1112
+ const now = /* @__PURE__ */ new Date();
1113
+ const result = await this.ctx.collection.findOneAndUpdate({
1114
+ _id,
1115
+ status: { $in: [JobStatus.FAILED, JobStatus.CANCELLED] }
1116
+ }, {
1117
+ $set: {
1118
+ status: JobStatus.PENDING,
1119
+ failCount: 0,
1120
+ nextRunAt: now,
1121
+ updatedAt: now
1122
+ },
1123
+ $unset: {
1124
+ failReason: "",
1125
+ lockedAt: "",
1126
+ claimedBy: "",
1127
+ lastHeartbeat: ""
1128
+ }
1129
+ }, { returnDocument: "before" });
1130
+ if (!result) {
1131
+ const currentJob = await this.ctx.collection.findOne({ _id });
1132
+ if (!currentJob) return null;
1133
+ throw new JobStateError(`Cannot retry job in status '${currentJob["status"]}'`, jobId, currentJob["status"], "retry");
1072
1134
  }
1073
- }, { returnDocument: "after" });
1074
- if (!result) throw new JobStateError("Job status changed during retry attempt", jobId, "unknown", "retry");
1075
- const job = this.ctx.documentToPersistedJob(result);
1076
- this.ctx.notifyPendingJob(job.name, job.nextRunAt);
1077
- this.ctx.emit("job:retried", {
1078
- job,
1079
- previousStatus
1080
- });
1081
- return job;
1135
+ const previousStatus = result["status"];
1136
+ const updatedDoc = { ...result };
1137
+ updatedDoc["status"] = JobStatus.PENDING;
1138
+ updatedDoc["failCount"] = 0;
1139
+ updatedDoc["nextRunAt"] = now;
1140
+ updatedDoc["updatedAt"] = now;
1141
+ delete updatedDoc["failReason"];
1142
+ delete updatedDoc["lockedAt"];
1143
+ delete updatedDoc["claimedBy"];
1144
+ delete updatedDoc["lastHeartbeat"];
1145
+ const job = this.ctx.documentToPersistedJob(updatedDoc);
1146
+ this.ctx.notifyPendingJob(job.name, job.nextRunAt);
1147
+ this.ctx.emit("job:retried", {
1148
+ job,
1149
+ previousStatus
1150
+ });
1151
+ return job;
1152
+ } catch (error) {
1153
+ if (error instanceof MonqueError) throw error;
1154
+ throw new ConnectionError(`Failed to retry job: ${error instanceof Error ? error.message : "Unknown error during retryJob"}`, error instanceof Error ? { cause: error } : void 0);
1155
+ }
1082
1156
  }
1083
1157
  /**
1084
1158
  * Reschedule a pending job to run at a different time.
@@ -1099,20 +1173,27 @@ var JobManager = class {
1099
1173
  async rescheduleJob(jobId, runAt) {
1100
1174
  if (!mongodb.ObjectId.isValid(jobId)) return null;
1101
1175
  const _id = new mongodb.ObjectId(jobId);
1102
- const currentJobDoc = await this.ctx.collection.findOne({ _id });
1103
- if (!currentJobDoc) return null;
1104
- if (currentJobDoc["status"] !== JobStatus.PENDING) throw new JobStateError(`Cannot reschedule job in status '${currentJobDoc["status"]}'`, jobId, currentJobDoc["status"], "reschedule");
1105
- const result = await this.ctx.collection.findOneAndUpdate({
1106
- _id,
1107
- status: JobStatus.PENDING
1108
- }, { $set: {
1109
- nextRunAt: runAt,
1110
- updatedAt: /* @__PURE__ */ new Date()
1111
- } }, { returnDocument: "after" });
1112
- if (!result) throw new JobStateError("Job status changed during reschedule attempt", jobId, "unknown", "reschedule");
1113
- const job = this.ctx.documentToPersistedJob(result);
1114
- this.ctx.notifyPendingJob(job.name, job.nextRunAt);
1115
- return job;
1176
+ try {
1177
+ const now = /* @__PURE__ */ new Date();
1178
+ const result = await this.ctx.collection.findOneAndUpdate({
1179
+ _id,
1180
+ status: JobStatus.PENDING
1181
+ }, { $set: {
1182
+ nextRunAt: runAt,
1183
+ updatedAt: now
1184
+ } }, { returnDocument: "after" });
1185
+ if (!result) {
1186
+ const currentJobDoc = await this.ctx.collection.findOne({ _id });
1187
+ if (!currentJobDoc) return null;
1188
+ throw new JobStateError(`Cannot reschedule job in status '${currentJobDoc["status"]}'`, jobId, currentJobDoc["status"], "reschedule");
1189
+ }
1190
+ const job = this.ctx.documentToPersistedJob(result);
1191
+ this.ctx.notifyPendingJob(job.name, job.nextRunAt);
1192
+ return job;
1193
+ } catch (error) {
1194
+ if (error instanceof MonqueError) throw error;
1195
+ throw new ConnectionError(`Failed to reschedule job: ${error instanceof Error ? error.message : "Unknown error during rescheduleJob"}`, error instanceof Error ? { cause: error } : void 0);
1196
+ }
1116
1197
  }
1117
1198
  /**
1118
1199
  * Permanently delete a job.
@@ -1134,11 +1215,16 @@ var JobManager = class {
1134
1215
  async deleteJob(jobId) {
1135
1216
  if (!mongodb.ObjectId.isValid(jobId)) return false;
1136
1217
  const _id = new mongodb.ObjectId(jobId);
1137
- if ((await this.ctx.collection.deleteOne({ _id })).deletedCount > 0) {
1138
- this.ctx.emit("job:deleted", { jobId });
1139
- return true;
1218
+ try {
1219
+ if ((await this.ctx.collection.deleteOne({ _id })).deletedCount > 0) {
1220
+ this.ctx.emit("job:deleted", { jobId });
1221
+ return true;
1222
+ }
1223
+ return false;
1224
+ } catch (error) {
1225
+ if (error instanceof MonqueError) throw error;
1226
+ throw new ConnectionError(`Failed to delete job: ${error instanceof Error ? error.message : "Unknown error during deleteJob"}`, error instanceof Error ? { cause: error } : void 0);
1140
1227
  }
1141
- return false;
1142
1228
  }
1143
1229
  /**
1144
1230
  * Cancel multiple jobs matching the given filter via a single updateMany call.
@@ -1170,9 +1256,10 @@ var JobManager = class {
1170
1256
  }
1171
1257
  query["status"] = JobStatus.PENDING;
1172
1258
  try {
1259
+ const now = /* @__PURE__ */ new Date();
1173
1260
  const count = (await this.ctx.collection.updateMany(query, { $set: {
1174
1261
  status: JobStatus.CANCELLED,
1175
- updatedAt: /* @__PURE__ */ new Date()
1262
+ updatedAt: now
1176
1263
  } })).modifiedCount;
1177
1264
  if (count > 0) this.ctx.emit("jobs:cancelled", { count });
1178
1265
  return {
@@ -1216,17 +1303,17 @@ var JobManager = class {
1216
1303
  } else query["status"] = { $in: retryable };
1217
1304
  const spreadWindowMs = 3e4;
1218
1305
  try {
1306
+ const now = /* @__PURE__ */ new Date();
1219
1307
  const count = (await this.ctx.collection.updateMany(query, [{ $set: {
1220
1308
  status: JobStatus.PENDING,
1221
1309
  failCount: 0,
1222
- nextRunAt: { $add: [/* @__PURE__ */ new Date(), { $multiply: [{ $rand: {} }, spreadWindowMs] }] },
1223
- updatedAt: /* @__PURE__ */ new Date()
1310
+ nextRunAt: { $add: [now, { $multiply: [{ $rand: {} }, spreadWindowMs] }] },
1311
+ updatedAt: now
1224
1312
  } }, { $unset: [
1225
1313
  "failReason",
1226
1314
  "lockedAt",
1227
1315
  "claimedBy",
1228
- "lastHeartbeat",
1229
- "heartbeatInterval"
1316
+ "lastHeartbeat"
1230
1317
  ] }])).modifiedCount;
1231
1318
  if (count > 0) this.ctx.emit("jobs:retried", { count });
1232
1319
  return {
@@ -1288,18 +1375,16 @@ var JobProcessor = class {
1288
1375
  _isPolling = false;
1289
1376
  /** Flag to request a re-poll after the current poll finishes */
1290
1377
  _repollRequested = false;
1291
- constructor(ctx) {
1292
- this.ctx = ctx;
1293
- }
1294
1378
  /**
1295
- * Get the total number of active jobs across all workers.
1379
+ * O(1) counter tracking the total number of active jobs across all workers.
1296
1380
  *
1297
- * Used for instance-level throttling when `instanceConcurrency` is configured.
1381
+ * Incremented when a job is added to `worker.activeJobs` in `_doPoll`,
1382
+ * decremented in the `processJob` finally block. Replaces the previous
1383
+ * O(workers) loop in `getTotalActiveJobs()` for instance-level throttling.
1298
1384
  */
1299
- getTotalActiveJobs() {
1300
- let total = 0;
1301
- for (const worker of this.ctx.workers.values()) total += worker.activeJobs.size;
1302
- return total;
1385
+ _totalActiveJobs = 0;
1386
+ constructor(ctx) {
1387
+ this.ctx = ctx;
1303
1388
  }
1304
1389
  /**
1305
1390
  * Get the number of available slots considering the global instanceConcurrency limit.
@@ -1310,7 +1395,7 @@ var JobProcessor = class {
1310
1395
  getGloballyAvailableSlots(workerAvailableSlots) {
1311
1396
  const { instanceConcurrency } = this.ctx.options;
1312
1397
  if (instanceConcurrency === void 0) return workerAvailableSlots;
1313
- const globalAvailable = instanceConcurrency - this.getTotalActiveJobs();
1398
+ const globalAvailable = instanceConcurrency - this._totalActiveJobs;
1314
1399
  return Math.min(workerAvailableSlots, globalAvailable);
1315
1400
  }
1316
1401
  /**
@@ -1350,27 +1435,49 @@ var JobProcessor = class {
1350
1435
  */
1351
1436
  async _doPoll(targetNames) {
1352
1437
  const { instanceConcurrency } = this.ctx.options;
1353
- if (instanceConcurrency !== void 0 && this.getTotalActiveJobs() >= instanceConcurrency) return;
1438
+ if (instanceConcurrency !== void 0 && this._totalActiveJobs >= instanceConcurrency) return;
1354
1439
  for (const [name, worker] of this.ctx.workers) {
1355
1440
  if (targetNames && !targetNames.has(name)) continue;
1356
1441
  const workerAvailableSlots = worker.concurrency - worker.activeJobs.size;
1357
1442
  if (workerAvailableSlots <= 0) continue;
1358
1443
  const availableSlots = this.getGloballyAvailableSlots(workerAvailableSlots);
1359
1444
  if (availableSlots <= 0) return;
1360
- for (let i = 0; i < availableSlots; i++) {
1361
- if (!this.ctx.isRunning()) return;
1362
- if (instanceConcurrency !== void 0 && this.getTotalActiveJobs() >= instanceConcurrency) return;
1363
- const job = await this.acquireJob(name);
1364
- if (job) {
1445
+ if (!this.ctx.isRunning()) return;
1446
+ const acquisitionPromises = [];
1447
+ for (let i = 0; i < availableSlots; i++) acquisitionPromises.push(this.acquireJob(name).then(async (job) => {
1448
+ if (!job) return;
1449
+ if (this.ctx.isRunning()) {
1365
1450
  worker.activeJobs.set(job._id.toString(), job);
1451
+ this._totalActiveJobs++;
1366
1452
  this.processJob(job, worker).catch((error) => {
1367
1453
  this.ctx.emit("job:error", {
1368
1454
  error: toError(error),
1369
1455
  job
1370
1456
  });
1371
1457
  });
1372
- } else break;
1373
- }
1458
+ } else try {
1459
+ await this.ctx.collection.updateOne({
1460
+ _id: job._id,
1461
+ status: JobStatus.PROCESSING,
1462
+ claimedBy: this.ctx.instanceId
1463
+ }, {
1464
+ $set: {
1465
+ status: JobStatus.PENDING,
1466
+ updatedAt: /* @__PURE__ */ new Date()
1467
+ },
1468
+ $unset: {
1469
+ lockedAt: "",
1470
+ claimedBy: "",
1471
+ lastHeartbeat: ""
1472
+ }
1473
+ });
1474
+ } catch (error) {
1475
+ this.ctx.emit("job:error", { error: toError(error) });
1476
+ }
1477
+ }).catch((error) => {
1478
+ this.ctx.emit("job:error", { error: toError(error) });
1479
+ }));
1480
+ await Promise.allSettled(acquisitionPromises);
1374
1481
  }
1375
1482
  }
1376
1483
  /**
@@ -1406,7 +1513,6 @@ var JobProcessor = class {
1406
1513
  sort: { nextRunAt: 1 },
1407
1514
  returnDocument: "after"
1408
1515
  });
1409
- if (!this.ctx.isRunning()) return null;
1410
1516
  if (!result) return null;
1411
1517
  return this.ctx.documentToPersistedJob(result);
1412
1518
  }
@@ -1449,6 +1555,8 @@ var JobProcessor = class {
1449
1555
  }
1450
1556
  } finally {
1451
1557
  worker.activeJobs.delete(jobId);
1558
+ this._totalActiveJobs--;
1559
+ this.ctx.notifyJobFinished();
1452
1560
  }
1453
1561
  }
1454
1562
  /**
@@ -1468,6 +1576,7 @@ var JobProcessor = class {
1468
1576
  */
1469
1577
  async completeJob(job) {
1470
1578
  if (!isPersistedJob(job)) return null;
1579
+ const now = /* @__PURE__ */ new Date();
1471
1580
  if (job.repeatInterval) {
1472
1581
  const nextRunAt = getNextCronDate(job.repeatInterval);
1473
1582
  const result = await this.ctx.collection.findOneAndUpdate({
@@ -1479,13 +1588,12 @@ var JobProcessor = class {
1479
1588
  status: JobStatus.PENDING,
1480
1589
  nextRunAt,
1481
1590
  failCount: 0,
1482
- updatedAt: /* @__PURE__ */ new Date()
1591
+ updatedAt: now
1483
1592
  },
1484
1593
  $unset: {
1485
1594
  lockedAt: "",
1486
1595
  claimedBy: "",
1487
1596
  lastHeartbeat: "",
1488
- heartbeatInterval: "",
1489
1597
  failReason: ""
1490
1598
  }
1491
1599
  }, { returnDocument: "after" });
@@ -1501,13 +1609,12 @@ var JobProcessor = class {
1501
1609
  }, {
1502
1610
  $set: {
1503
1611
  status: JobStatus.COMPLETED,
1504
- updatedAt: /* @__PURE__ */ new Date()
1612
+ updatedAt: now
1505
1613
  },
1506
1614
  $unset: {
1507
1615
  lockedAt: "",
1508
1616
  claimedBy: "",
1509
1617
  lastHeartbeat: "",
1510
- heartbeatInterval: "",
1511
1618
  failReason: ""
1512
1619
  }
1513
1620
  }, { returnDocument: "after" });
@@ -1534,6 +1641,7 @@ var JobProcessor = class {
1534
1641
  */
1535
1642
  async failJob(job, error) {
1536
1643
  if (!isPersistedJob(job)) return null;
1644
+ const now = /* @__PURE__ */ new Date();
1537
1645
  const newFailCount = job.failCount + 1;
1538
1646
  if (newFailCount >= this.ctx.options.maxRetries) {
1539
1647
  const result = await this.ctx.collection.findOneAndUpdate({
@@ -1545,13 +1653,12 @@ var JobProcessor = class {
1545
1653
  status: JobStatus.FAILED,
1546
1654
  failCount: newFailCount,
1547
1655
  failReason: error.message,
1548
- updatedAt: /* @__PURE__ */ new Date()
1656
+ updatedAt: now
1549
1657
  },
1550
1658
  $unset: {
1551
1659
  lockedAt: "",
1552
1660
  claimedBy: "",
1553
- lastHeartbeat: "",
1554
- heartbeatInterval: ""
1661
+ lastHeartbeat: ""
1555
1662
  }
1556
1663
  }, { returnDocument: "after" });
1557
1664
  return result ? this.ctx.documentToPersistedJob(result) : null;
@@ -1567,13 +1674,12 @@ var JobProcessor = class {
1567
1674
  failCount: newFailCount,
1568
1675
  failReason: error.message,
1569
1676
  nextRunAt,
1570
- updatedAt: /* @__PURE__ */ new Date()
1677
+ updatedAt: now
1571
1678
  },
1572
1679
  $unset: {
1573
1680
  lockedAt: "",
1574
1681
  claimedBy: "",
1575
- lastHeartbeat: "",
1576
- heartbeatInterval: ""
1682
+ lastHeartbeat: ""
1577
1683
  }
1578
1684
  }, { returnDocument: "after" });
1579
1685
  return result ? this.ctx.documentToPersistedJob(result) : null;
@@ -1916,6 +2022,10 @@ var JobScheduler = class {
1916
2022
  constructor(ctx) {
1917
2023
  this.ctx = ctx;
1918
2024
  }
2025
+ validateJobIdentifiers(name, uniqueKey) {
2026
+ validateJobName(name);
2027
+ if (uniqueKey !== void 0) validateUniqueKey(uniqueKey);
2028
+ }
1919
2029
  /**
1920
2030
  * Validate that the job data payload does not exceed the configured maximum BSON byte size.
1921
2031
  *
@@ -1953,6 +2063,7 @@ var JobScheduler = class {
1953
2063
  * @param data - Job payload, will be passed to the worker handler
1954
2064
  * @param options - Scheduling and deduplication options
1955
2065
  * @returns Promise resolving to the created or existing job document
2066
+ * @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
1956
2067
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
1957
2068
  * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
1958
2069
  *
@@ -1982,6 +2093,7 @@ var JobScheduler = class {
1982
2093
  * ```
1983
2094
  */
1984
2095
  async enqueue(name, data, options = {}) {
2096
+ this.validateJobIdentifiers(name, options.uniqueKey);
1985
2097
  this.validatePayloadSize(data);
1986
2098
  const now = /* @__PURE__ */ new Date();
1987
2099
  const job = {
@@ -1993,9 +2105,9 @@ var JobScheduler = class {
1993
2105
  createdAt: now,
1994
2106
  updatedAt: now
1995
2107
  };
1996
- if (options.uniqueKey) job.uniqueKey = options.uniqueKey;
2108
+ if (options.uniqueKey !== void 0) job.uniqueKey = options.uniqueKey;
1997
2109
  try {
1998
- if (options.uniqueKey) {
2110
+ if (options.uniqueKey !== void 0) {
1999
2111
  const result = await this.ctx.collection.findOneAndUpdate({
2000
2112
  name,
2001
2113
  uniqueKey: options.uniqueKey,
@@ -2070,6 +2182,7 @@ var JobScheduler = class {
2070
2182
  * @param data - Job payload, will be passed to the worker handler on each run
2071
2183
  * @param options - Scheduling options (uniqueKey for deduplication)
2072
2184
  * @returns Promise resolving to the created job document with `repeatInterval` set
2185
+ * @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
2073
2186
  * @throws {InvalidCronError} If cron expression is invalid
2074
2187
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
2075
2188
  * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
@@ -2098,6 +2211,7 @@ var JobScheduler = class {
2098
2211
  * ```
2099
2212
  */
2100
2213
  async schedule(cron, name, data, options = {}) {
2214
+ this.validateJobIdentifiers(name, options.uniqueKey);
2101
2215
  this.validatePayloadSize(data);
2102
2216
  const nextRunAt = getNextCronDate(cron);
2103
2217
  const now = /* @__PURE__ */ new Date();
@@ -2111,9 +2225,9 @@ var JobScheduler = class {
2111
2225
  createdAt: now,
2112
2226
  updatedAt: now
2113
2227
  };
2114
- if (options.uniqueKey) job.uniqueKey = options.uniqueKey;
2228
+ if (options.uniqueKey !== void 0) job.uniqueKey = options.uniqueKey;
2115
2229
  try {
2116
- if (options.uniqueKey) {
2230
+ if (options.uniqueKey !== void 0) {
2117
2231
  const result = await this.ctx.collection.findOneAndUpdate({
2118
2232
  name,
2119
2233
  uniqueKey: options.uniqueKey,
@@ -2147,6 +2261,10 @@ var JobScheduler = class {
2147
2261
  */
2148
2262
  const DEFAULT_RETENTION_INTERVAL = 36e5;
2149
2263
  /**
2264
+ * Statuses that are eligible for cleanup by the retention policy.
2265
+ */
2266
+ const CLEANUP_STATUSES = [JobStatus.COMPLETED, JobStatus.FAILED];
2267
+ /**
2150
2268
  * Manages scheduler lifecycle timers and job cleanup.
2151
2269
  *
2152
2270
  * Owns poll scheduling, heartbeat interval, cleanup interval, and the
@@ -2231,7 +2349,7 @@ var LifecycleManager = class {
2231
2349
  if (!this.callbacks) return;
2232
2350
  this.callbacks.poll().catch((error) => {
2233
2351
  this.ctx.emit("job:error", { error: toError(error) });
2234
- }).then(() => {
2352
+ }).finally(() => {
2235
2353
  this.scheduleNextPoll();
2236
2354
  });
2237
2355
  }
@@ -2373,6 +2491,14 @@ var Monque = class extends node_events.EventEmitter {
2373
2491
  workers = /* @__PURE__ */ new Map();
2374
2492
  isRunning = false;
2375
2493
  isInitialized = false;
2494
+ /**
2495
+ * Resolve function for the reactive shutdown drain promise.
2496
+ * Set during stop() when active jobs need to finish; called by
2497
+ * onJobFinished() when the last active job completes.
2498
+ *
2499
+ * @private
2500
+ */
2501
+ _drainResolve = null;
2376
2502
  _scheduler = null;
2377
2503
  _manager = null;
2378
2504
  _query = null;
@@ -2381,6 +2507,7 @@ var Monque = class extends node_events.EventEmitter {
2381
2507
  _lifecycleManager = null;
2382
2508
  constructor(db, options = {}) {
2383
2509
  super();
2510
+ this.setMaxListeners(20);
2384
2511
  this.db = db;
2385
2512
  this.options = {
2386
2513
  collectionName: options.collectionName ?? DEFAULTS.collectionName,
@@ -2401,6 +2528,8 @@ var Monque = class extends node_events.EventEmitter {
2401
2528
  maxPayloadSize: options.maxPayloadSize,
2402
2529
  statsCacheTtlMs: options.statsCacheTtlMs ?? 5e3
2403
2530
  };
2531
+ if (options.defaultConcurrency !== void 0) console.warn("[@monque/core] \"defaultConcurrency\" is deprecated and will be removed in a future major version. Use \"workerConcurrency\" instead.");
2532
+ if (options.maxConcurrency !== void 0) console.warn("[@monque/core] \"maxConcurrency\" is deprecated and will be removed in a future major version. Use \"instanceConcurrency\" instead.");
2404
2533
  }
2405
2534
  /**
2406
2535
  * Initialize the scheduler by setting up the MongoDB collection and indexes.
@@ -2457,6 +2586,10 @@ var Monque = class extends node_events.EventEmitter {
2457
2586
  if (!this._lifecycleManager) throw new ConnectionError("Monque not initialized. Call initialize() first.");
2458
2587
  return this._lifecycleManager;
2459
2588
  }
2589
+ validateSchedulingIdentifiers(name, uniqueKey) {
2590
+ validateJobName(name);
2591
+ if (uniqueKey !== void 0) validateUniqueKey(uniqueKey);
2592
+ }
2460
2593
  /**
2461
2594
  * Handle a change-stream-triggered poll and reset the safety poll timer.
2462
2595
  *
@@ -2484,6 +2617,7 @@ var Monque = class extends node_events.EventEmitter {
2484
2617
  if (!this.isRunning || !this._changeStreamHandler) return;
2485
2618
  this._changeStreamHandler.notifyPendingJob(name, nextRunAt);
2486
2619
  },
2620
+ notifyJobFinished: () => this.onJobFinished(),
2487
2621
  documentToPersistedJob: (doc) => documentToPersistedJob(doc)
2488
2622
  };
2489
2623
  }
@@ -2557,7 +2691,18 @@ var Monque = class extends node_events.EventEmitter {
2557
2691
  lastHeartbeat: 1
2558
2692
  },
2559
2693
  background: true
2560
- }
2694
+ },
2695
+ ...this.options.jobRetention ? [{
2696
+ key: {
2697
+ status: 1,
2698
+ updatedAt: 1
2699
+ },
2700
+ background: true,
2701
+ partialFilterExpression: {
2702
+ status: { $in: CLEANUP_STATUSES },
2703
+ updatedAt: { $exists: true }
2704
+ }
2705
+ }] : []
2561
2706
  ]);
2562
2707
  }
2563
2708
  /**
@@ -2579,8 +2724,7 @@ var Monque = class extends node_events.EventEmitter {
2579
2724
  $unset: {
2580
2725
  lockedAt: "",
2581
2726
  claimedBy: "",
2582
- lastHeartbeat: "",
2583
- heartbeatInterval: ""
2727
+ lastHeartbeat: ""
2584
2728
  }
2585
2729
  });
2586
2730
  if (result.modifiedCount > 0) this.emit("stale:recovered", { count: result.modifiedCount });
@@ -2621,6 +2765,7 @@ var Monque = class extends node_events.EventEmitter {
2621
2765
  * @param data - Job payload, will be passed to the worker handler
2622
2766
  * @param options - Scheduling and deduplication options
2623
2767
  * @returns Promise resolving to the created or existing job document
2768
+ * @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
2624
2769
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
2625
2770
  * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
2626
2771
  *
@@ -2653,6 +2798,7 @@ var Monque = class extends node_events.EventEmitter {
2653
2798
  */
2654
2799
  async enqueue(name, data, options = {}) {
2655
2800
  this.ensureInitialized();
2801
+ this.validateSchedulingIdentifiers(name, options.uniqueKey);
2656
2802
  return this.scheduler.enqueue(name, data, options);
2657
2803
  }
2658
2804
  /**
@@ -2665,6 +2811,7 @@ var Monque = class extends node_events.EventEmitter {
2665
2811
  * @param name - Job type identifier, must match a registered worker
2666
2812
  * @param data - Job payload, will be passed to the worker handler
2667
2813
  * @returns Promise resolving to the created job document
2814
+ * @throws {InvalidJobIdentifierError} If `name` fails public identifier validation
2668
2815
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
2669
2816
  *
2670
2817
  * @example Send email immediately
@@ -2687,6 +2834,7 @@ var Monque = class extends node_events.EventEmitter {
2687
2834
  */
2688
2835
  async now(name, data) {
2689
2836
  this.ensureInitialized();
2837
+ validateJobName(name);
2690
2838
  return this.scheduler.now(name, data);
2691
2839
  }
2692
2840
  /**
@@ -2707,6 +2855,7 @@ var Monque = class extends node_events.EventEmitter {
2707
2855
  * @param data - Job payload, will be passed to the worker handler on each run
2708
2856
  * @param options - Scheduling options (uniqueKey for deduplication)
2709
2857
  * @returns Promise resolving to the created job document with `repeatInterval` set
2858
+ * @throws {InvalidJobIdentifierError} If `name` or `uniqueKey` fails public identifier validation
2710
2859
  * @throws {InvalidCronError} If cron expression is invalid
2711
2860
  * @throws {ConnectionError} If database operation fails or scheduler not initialized
2712
2861
  * @throws {PayloadTooLargeError} If payload exceeds configured `maxPayloadSize`
@@ -2738,6 +2887,7 @@ var Monque = class extends node_events.EventEmitter {
2738
2887
  */
2739
2888
  async schedule(cron, name, data, options = {}) {
2740
2889
  this.ensureInitialized();
2890
+ this.validateSchedulingIdentifiers(name, options.uniqueKey);
2741
2891
  return this.scheduler.schedule(cron, name, data, options);
2742
2892
  }
2743
2893
  /**
@@ -3083,6 +3233,7 @@ var Monque = class extends node_events.EventEmitter {
3083
3233
  * @param options - Worker configuration
3084
3234
  * @param options.concurrency - Maximum concurrent jobs for this worker (default: `defaultConcurrency`)
3085
3235
  * @param options.replace - When `true`, replace existing worker instead of throwing error
3236
+ * @throws {InvalidJobIdentifierError} If `name` fails public identifier validation
3086
3237
  * @throws {WorkerRegistrationError} When a worker is already registered for `name` and `replace` is not `true`
3087
3238
  *
3088
3239
  * @example Basic email worker
@@ -3126,6 +3277,7 @@ var Monque = class extends node_events.EventEmitter {
3126
3277
  * ```
3127
3278
  */
3128
3279
  register(name, handler, options = {}) {
3280
+ validateJobName(name);
3129
3281
  const concurrency = options.concurrency ?? this.options.workerConcurrency;
3130
3282
  if (this.workers.has(name) && options.replace !== true) throw new WorkerRegistrationError(`Worker already registered for job name "${name}". Use { replace: true } to replace.`, name);
3131
3283
  this.workers.set(name, {
@@ -3229,25 +3381,15 @@ var Monque = class extends node_events.EventEmitter {
3229
3381
  try {
3230
3382
  await this.changeStreamHandler.close();
3231
3383
  } catch {}
3232
- if (this.getActiveJobs().length === 0) return;
3233
- let checkInterval;
3384
+ if (this.getActiveJobCount() === 0) return;
3234
3385
  const waitForJobs = new Promise((resolve) => {
3235
- checkInterval = setInterval(() => {
3236
- if (this.getActiveJobs().length === 0) {
3237
- clearInterval(checkInterval);
3238
- resolve(void 0);
3239
- }
3240
- }, 100);
3386
+ this._drainResolve = () => resolve(void 0);
3241
3387
  });
3242
3388
  const timeout = new Promise((resolve) => {
3243
3389
  setTimeout(() => resolve("timeout"), this.options.shutdownTimeout);
3244
3390
  });
3245
- let result;
3246
- try {
3247
- result = await Promise.race([waitForJobs, timeout]);
3248
- } finally {
3249
- if (checkInterval) clearInterval(checkInterval);
3250
- }
3391
+ const result = await Promise.race([waitForJobs, timeout]);
3392
+ this._drainResolve = null;
3251
3393
  if (result === "timeout") {
3252
3394
  const incompleteJobs = this.getActiveJobsList();
3253
3395
  const error = new ShutdownTimeoutError(`Shutdown timed out after ${this.options.shutdownTimeout}ms with ${incompleteJobs.length} incomplete jobs`, incompleteJobs);
@@ -3304,6 +3446,15 @@ var Monque = class extends node_events.EventEmitter {
3304
3446
  return this.isRunning && this.isInitialized && this.collection !== null;
3305
3447
  }
3306
3448
  /**
3449
+ * Called when a job finishes processing. If a shutdown drain is pending
3450
+ * and no active jobs remain, resolves the drain promise.
3451
+ *
3452
+ * @private
3453
+ */
3454
+ onJobFinished() {
3455
+ if (this._drainResolve && this.getActiveJobCount() === 0) this._drainResolve();
3456
+ }
3457
+ /**
3307
3458
  * Ensure the scheduler is initialized before operations.
3308
3459
  *
3309
3460
  * @private
@@ -3313,15 +3464,18 @@ var Monque = class extends node_events.EventEmitter {
3313
3464
  if (!this.isInitialized || !this.collection) throw new ConnectionError("Monque not initialized. Call initialize() first.");
3314
3465
  }
3315
3466
  /**
3316
- * Get array of active job IDs across all workers.
3467
+ * Get total count of active jobs across all workers.
3468
+ *
3469
+ * Returns only the count (O(workers)) instead of allocating
3470
+ * a throw-away array of IDs, since callers only need `.length`.
3317
3471
  *
3318
3472
  * @private
3319
- * @returns Array of job ID strings currently being processed
3473
+ * @returns Number of jobs currently being processed
3320
3474
  */
3321
- getActiveJobs() {
3322
- const activeJobs = [];
3323
- for (const worker of this.workers.values()) activeJobs.push(...worker.activeJobs.keys());
3324
- return activeJobs;
3475
+ getActiveJobCount() {
3476
+ let count = 0;
3477
+ for (const worker of this.workers.values()) count += worker.activeJobs.size;
3478
+ return count;
3325
3479
  }
3326
3480
  /**
3327
3481
  * Get list of active job documents (for shutdown timeout error).
@@ -3358,6 +3512,7 @@ exports.DEFAULT_BASE_INTERVAL = DEFAULT_BASE_INTERVAL;
3358
3512
  exports.DEFAULT_MAX_BACKOFF_DELAY = DEFAULT_MAX_BACKOFF_DELAY;
3359
3513
  exports.InvalidCronError = InvalidCronError;
3360
3514
  exports.InvalidCursorError = InvalidCursorError;
3515
+ exports.InvalidJobIdentifierError = InvalidJobIdentifierError;
3361
3516
  exports.JobStateError = JobStateError;
3362
3517
  exports.JobStatus = JobStatus;
3363
3518
  exports.Monque = Monque;
@@ -3377,5 +3532,7 @@ exports.isProcessingJob = isProcessingJob;
3377
3532
  exports.isRecurringJob = isRecurringJob;
3378
3533
  exports.isValidJobStatus = isValidJobStatus;
3379
3534
  exports.validateCronExpression = validateCronExpression;
3535
+ exports.validateJobName = validateJobName;
3536
+ exports.validateUniqueKey = validateUniqueKey;
3380
3537
 
3381
3538
  //# sourceMappingURL=index.cjs.map