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