@orion-js/dogs 4.5.0 → 4.6.1

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
@@ -74,6 +74,7 @@ var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "acce
74
74
  // src/index.ts
75
75
  var index_exports = {};
76
76
  __export(index_exports, {
77
+ DEFAULT_MAX_TRIES_REACHED_RETENTION_MS: () => DEFAULT_MAX_TRIES_REACHED_RETENTION_MS,
77
78
  EventJob: () => EventJob,
78
79
  HistoryRecordSchema: () => HistoryRecordSchema,
79
80
  JobRecordSchema: () => JobRecordSchema,
@@ -114,15 +115,32 @@ var JobRecordSchema = (0, import_schema.schemaWithName)("JobRecord", {
114
115
  nextRunAt: { type: "date" },
115
116
  lastRunAt: { type: "date", optional: true },
116
117
  lockedUntil: { type: "date", optional: true },
118
+ /**
119
+ * Identifies the execution that currently owns the lock. Mutations from an execution must match
120
+ * this value so a stale execution cannot modify a newer claim.
121
+ */
122
+ lockId: { type: "string", optional: true },
117
123
  tries: { type: "number", optional: true },
118
124
  params: { type: "blackbox", optional: true },
119
125
  /**
120
126
  * Status of the job. Optional for backwards compatibility with existing records.
121
127
  * Records without this field are treated as 'pending'.
122
128
  */
123
- status: { type: JobStatusEnum, optional: true }
129
+ status: { type: JobStatusEnum, optional: true },
130
+ /**
131
+ * Date when the job reached its maximum tries. Used as the stable anchor when
132
+ * maxTriesReachedRetentionMs changes between worker restarts.
133
+ */
134
+ maxTriesReachedAt: { type: "date", optional: true },
135
+ /**
136
+ * Date when MongoDB should remove a terminal event job through the TTL index.
137
+ */
138
+ expiresAt: { type: "date", optional: true }
124
139
  });
125
140
 
141
+ // src/types/StartConfig.ts
142
+ var DEFAULT_MAX_TRIES_REACHED_RETENTION_MS = 1e3 * 60 * 60 * 24 * 7;
143
+
126
144
  // src/repos/JobsRepo.ts
127
145
  var _jobs_dec, _JobsRepo_decorators, _init;
128
146
  _JobsRepo_decorators = [(0, import_mongodb.Repository)()], _jobs_dec = [(0, import_mongodb.MongoCollection)({
@@ -154,6 +172,13 @@ _JobsRepo_decorators = [(0, import_mongodb.Repository)()], _jobs_dec = [(0, impo
154
172
  unique: true,
155
173
  sparse: true
156
174
  }
175
+ },
176
+ {
177
+ keys: {
178
+ expiresAt: 1
179
+ },
180
+ name: "jobs_dogs_records_expires_at_ttl",
181
+ expireAfterSeconds: 0
157
182
  }
158
183
  ]
159
184
  })];
@@ -162,6 +187,7 @@ var JobsRepo = class {
162
187
  __publicField(this, "jobs", __runInitializers(_init, 8, this)), __runInitializers(_init, 11, this);
163
188
  }
164
189
  async getJobAndLock(jobNames, lockTime) {
190
+ const executionId = (0, import_helpers.generateId)();
165
191
  const lockedUntil = new Date(Date.now() + lockTime);
166
192
  const job = await this.jobs.findOneAndUpdate(
167
193
  {
@@ -175,7 +201,7 @@ var JobsRepo = class {
175
201
  ]
176
202
  },
177
203
  {
178
- $set: { lockedUntil, lastRunAt: /* @__PURE__ */ new Date() },
204
+ $set: { lockId: executionId, lockedUntil, lastRunAt: /* @__PURE__ */ new Date() },
179
205
  $inc: { tries: 1 }
180
206
  },
181
207
  {
@@ -196,7 +222,8 @@ var JobsRepo = class {
196
222
  }
197
223
  return {
198
224
  jobId: job._id,
199
- executionId: (0, import_helpers.generateId)(),
225
+ executionId,
226
+ lockId: executionId,
200
227
  name: job.jobName,
201
228
  params: job.params,
202
229
  type: job.type,
@@ -207,8 +234,11 @@ var JobsRepo = class {
207
234
  wasStale
208
235
  };
209
236
  }
210
- async setJobRecordPriority(jobId, priority) {
211
- await this.jobs.updateOne(jobId, { $set: { priority } });
237
+ async setJobRecordPriority(jobId, priority, lockId) {
238
+ const result = await this.jobs.updateOne(lockId ? { _id: jobId, lockId } : jobId, {
239
+ $set: { priority }
240
+ });
241
+ return result.matchedCount > 0;
212
242
  }
213
243
  async scheduleNextRun(options) {
214
244
  const updator = {
@@ -217,43 +247,89 @@ var JobsRepo = class {
217
247
  priority: options.priority,
218
248
  ...options.resetTries ? { tries: 0 } : {}
219
249
  },
220
- $unset: { lockedUntil: "" }
250
+ $unset: { lockId: "", lockedUntil: "" }
221
251
  };
222
- await this.jobs.updateOne(options.jobId, updator);
252
+ const result = await this.jobs.updateOne(
253
+ options.lockId ? { _id: options.jobId, lockId: options.lockId } : options.jobId,
254
+ updator
255
+ );
256
+ return result.matchedCount > 0;
223
257
  }
224
- async deleteEventJob(jobId) {
225
- await this.jobs.deleteOne({ _id: jobId, type: "event" });
258
+ async deleteEventJob(jobId, lockId) {
259
+ const result = await this.jobs.deleteOne({
260
+ _id: jobId,
261
+ type: "event",
262
+ ...lockId ? { lockId } : {}
263
+ });
264
+ return result.deletedCount > 0;
226
265
  }
227
266
  /**
228
267
  * Marks an event job as having reached its maximum tries limit.
229
- * The job will remain in the database but won't be picked up for execution.
268
+ * The job won't be picked up for execution and MongoDB will delete it after
269
+ * the configured retention period.
230
270
  */
231
- async markJobAsMaxTriesReached(jobId) {
232
- await this.jobs.updateOne(
233
- { _id: jobId, type: "event" },
271
+ async markJobAsMaxTriesReached(jobId, retentionMs = DEFAULT_MAX_TRIES_REACHED_RETENTION_MS, lockId) {
272
+ const maxTriesReachedAt = /* @__PURE__ */ new Date();
273
+ const result = await this.jobs.updateOne(
274
+ { _id: jobId, type: "event", ...lockId ? { lockId } : {} },
234
275
  {
235
- $set: { status: "maxTriesReached" },
236
- $unset: { lockedUntil: "" }
276
+ $set: {
277
+ status: "maxTriesReached",
278
+ maxTriesReachedAt,
279
+ ...retentionMs === null ? {} : { expiresAt: new Date(maxTriesReachedAt.getTime() + retentionMs) }
280
+ },
281
+ $unset: {
282
+ lockId: "",
283
+ lockedUntil: "",
284
+ ...retentionMs === null ? { expiresAt: "" } : {}
285
+ }
237
286
  }
238
287
  );
288
+ return result.matchedCount > 0;
239
289
  }
240
- async extendLockTime(jobId, extraTime) {
241
- await this.updateLockTime(jobId, extraTime);
290
+ /**
291
+ * Applies the current retention configuration to existing maxTriesReached
292
+ * event jobs. The collection definition ensures the TTL index. Legacy records
293
+ * use lastRunAt as their retention anchor when available.
294
+ */
295
+ async ensureMaxTriesReachedRetention(retentionMs) {
296
+ const selector = {
297
+ type: "event",
298
+ status: "maxTriesReached"
299
+ };
300
+ if (retentionMs === null) {
301
+ await this.jobs.updateMany(selector, { $unset: { expiresAt: "" } });
302
+ return;
303
+ }
304
+ const now = /* @__PURE__ */ new Date();
305
+ const retentionAnchor = {
306
+ $ifNull: ["$maxTriesReachedAt", { $ifNull: ["$lastRunAt", now] }]
307
+ };
308
+ await this.jobs.rawCollection.updateMany(selector, [
309
+ {
310
+ $set: {
311
+ maxTriesReachedAt: retentionAnchor,
312
+ expiresAt: { $add: [retentionAnchor, retentionMs] }
313
+ }
314
+ }
315
+ ]);
316
+ }
317
+ async extendLockTime(jobId, extraTime, lockId) {
318
+ return this.updateLockTime(jobId, extraTime, lockId);
242
319
  }
243
320
  /**
244
321
  * Updates the lock time for a job to the specified duration from now.
245
322
  * Can be used to both extend or shorten the lock time.
246
323
  */
247
- async updateLockTime(jobId, lockDuration) {
324
+ async updateLockTime(jobId, lockDuration, lockId) {
248
325
  const lockedUntil = new Date(Date.now() + lockDuration);
249
- await this.jobs.updateOne(
250
- {
251
- _id: jobId
252
- },
326
+ const result = await this.jobs.updateOne(
327
+ { _id: jobId, ...lockId ? { lockId } : {} },
253
328
  {
254
329
  $set: { lockedUntil }
255
330
  }
256
331
  );
332
+ return result.matchedCount > 0;
257
333
  }
258
334
  async unlockAllJobs() {
259
335
  const result = await this.jobs.updateMany(
@@ -261,7 +337,7 @@ var JobsRepo = class {
261
337
  lockedUntil: { $exists: true }
262
338
  },
263
339
  {
264
- $unset: { lockedUntil: "" }
340
+ $unset: { lockId: "", lockedUntil: "" }
265
341
  }
266
342
  );
267
343
  return result.modifiedCount;
@@ -567,11 +643,15 @@ var Executor = class {
567
643
  getEffectiveLockTime(job, jobToRun) {
568
644
  return job.lockTime ?? jobToRun.lockTime;
569
645
  }
570
- getContext(job, jobToRun, onStale) {
646
+ getContext(job, jobToRun, onStale, isExecutionActive) {
571
647
  var _a;
572
648
  const effectiveLockTime = this.getEffectiveLockTime(job, jobToRun);
573
649
  let staleTimeout = setTimeout(() => onStale(), effectiveLockTime);
574
650
  (_a = staleTimeout.unref) == null ? void 0 : _a.call(staleTimeout);
651
+ const contextLogger = import_logger3.logger.addMetadata({
652
+ jobName: jobToRun.name,
653
+ jobId: jobToRun.jobId
654
+ });
575
655
  return {
576
656
  definition: job,
577
657
  record: jobToRun,
@@ -579,15 +659,26 @@ var Executor = class {
579
659
  clearStaleTimeout: () => clearTimeout(staleTimeout),
580
660
  extendLockTime: async (extraTime) => {
581
661
  var _a2;
662
+ if (!isExecutionActive()) {
663
+ contextLogger.warn(`Will not extend lock for stale execution "${jobToRun.executionId}"`);
664
+ return;
665
+ }
582
666
  clearTimeout(staleTimeout);
667
+ let didExtend = false;
668
+ try {
669
+ didExtend = await this.jobsRepo.extendLockTime(jobToRun.jobId, extraTime, jobToRun.lockId);
670
+ } catch (error) {
671
+ onStale();
672
+ throw error;
673
+ }
674
+ if (!didExtend) {
675
+ onStale();
676
+ return;
677
+ }
583
678
  staleTimeout = setTimeout(() => onStale(), extraTime);
584
679
  (_a2 = staleTimeout.unref) == null ? void 0 : _a2.call(staleTimeout);
585
- await this.jobsRepo.extendLockTime(jobToRun.jobId, extraTime);
586
680
  },
587
- logger: import_logger3.logger.addMetadata({
588
- jobName: jobToRun.name,
589
- jobId: jobToRun.jobId
590
- })
681
+ logger: contextLogger
591
682
  };
592
683
  }
593
684
  getJobDefinition(jobToRun, jobs) {
@@ -611,7 +702,7 @@ var Executor = class {
611
702
  * Handles when a job has reached its maximum retry attempts.
612
703
  * Marks the job in the database and invokes the onMaxTriesReached callback when provided.
613
704
  */
614
- async handleMaxTriesReached(jobToRun, onMaxTriesReached) {
705
+ async handleMaxTriesReached(jobToRun, retentionMs, onMaxTriesReached) {
615
706
  const jobLogger = import_logger3.logger.addMetadata({
616
707
  jobName: jobToRun.name,
617
708
  jobId: jobToRun.jobId
@@ -619,8 +710,16 @@ var Executor = class {
619
710
  jobLogger.warn(
620
711
  `Job "${jobToRun.name}" has exceeded max tries (${jobToRun.tries}). Marking as maxTriesReached.`
621
712
  );
622
- await this.jobsRepo.markJobAsMaxTriesReached(jobToRun.jobId);
623
- if (!onMaxTriesReached) return;
713
+ const didMark = await this.jobsRepo.markJobAsMaxTriesReached(
714
+ jobToRun.jobId,
715
+ retentionMs,
716
+ jobToRun.lockId
717
+ );
718
+ if (!didMark) {
719
+ jobLogger.warn(`Could not mark job "${jobToRun.name}" because its lock is no longer owned`);
720
+ return false;
721
+ }
722
+ if (!onMaxTriesReached) return true;
624
723
  try {
625
724
  await onMaxTriesReached(jobToRun);
626
725
  } catch (callbackError) {
@@ -628,30 +727,33 @@ var Executor = class {
628
727
  error: callbackError
629
728
  });
630
729
  }
730
+ return true;
631
731
  }
632
732
  async onError(error, job, jobToRun, context) {
633
733
  const scheduleRecurrent = async () => {
634
734
  if (job.type === "recurrent") {
635
- await this.jobsRepo.scheduleNextRun({
735
+ return this.jobsRepo.scheduleNextRun({
636
736
  jobId: jobToRun.jobId,
637
737
  nextRunAt: getNextRunDate(job),
638
738
  resetTries: true,
639
- priority: job.priority
739
+ priority: job.priority,
740
+ lockId: jobToRun.lockId
640
741
  });
641
742
  }
743
+ return true;
642
744
  };
643
745
  const handleRetry = async (nextRunAt) => {
644
- await this.jobsRepo.scheduleNextRun({
746
+ return this.jobsRepo.scheduleNextRun({
645
747
  jobId: jobToRun.jobId,
646
748
  nextRunAt,
647
749
  resetTries: false,
648
- priority: job.type === "recurrent" ? job.priority : jobToRun.priority
750
+ priority: job.type === "recurrent" ? job.priority : jobToRun.priority,
751
+ lockId: jobToRun.lockId
649
752
  });
650
753
  };
651
754
  if (!job.onError) {
652
755
  context.logger.error(`Error executing job "${jobToRun.name}"`, { error });
653
- await scheduleRecurrent();
654
- return;
756
+ return scheduleRecurrent();
655
757
  }
656
758
  context.logger.info(`Error executing job "${jobToRun.name}"`, { error });
657
759
  const result = await job.onError(
@@ -660,12 +762,12 @@ var Executor = class {
660
762
  context
661
763
  );
662
764
  if (result.action === "dismiss") {
663
- await scheduleRecurrent();
664
- return;
765
+ return scheduleRecurrent();
665
766
  }
666
767
  if (result.action === "retry") {
667
- await handleRetry(getNextRunDate(result));
768
+ return handleRetry(getNextRunDate(result));
668
769
  }
770
+ return true;
669
771
  }
670
772
  async saveExecution(options) {
671
773
  const { startedAt, status, errorMessage, result, job, jobToRun } = options;
@@ -695,57 +797,94 @@ var Executor = class {
695
797
  async afterExecutionSuccess(job, jobToRun, context) {
696
798
  if (job.type === "recurrent") {
697
799
  context.logger.debug(`Scheduling next run for recurrent job "${jobToRun.name}"`);
698
- await this.jobsRepo.scheduleNextRun({
800
+ return this.jobsRepo.scheduleNextRun({
699
801
  jobId: jobToRun.jobId,
700
802
  nextRunAt: getNextRunDate(job),
701
803
  resetTries: true,
702
- priority: job.priority
804
+ priority: job.priority,
805
+ lockId: jobToRun.lockId
703
806
  });
704
807
  }
705
808
  if (job.type === "event") {
706
809
  context.logger.debug(`Removing event job after success "${jobToRun.name}"`);
707
- await this.jobsRepo.deleteEventJob(jobToRun.jobId);
810
+ return this.jobsRepo.deleteEventJob(jobToRun.jobId, jobToRun.lockId);
708
811
  }
812
+ return false;
709
813
  }
710
- async executeJob(config, jobToRun, respawnWorker) {
814
+ async executeJob(config, jobToRun, onExecutionStale) {
711
815
  const job = this.getJobDefinition(jobToRun, config.jobs);
712
816
  if (!job) return;
713
817
  if (job.type === "event") {
714
818
  const effectiveMaxTries = this.getEffectiveMaxTries(job, config.maxTries);
715
819
  if (typeof effectiveMaxTries === "number" && jobToRun.tries > effectiveMaxTries) {
716
- await this.handleMaxTriesReached(jobToRun, config.onMaxTriesReached);
820
+ await this.handleMaxTriesReached(
821
+ jobToRun,
822
+ config.maxTriesReachedRetentionMs,
823
+ config.onMaxTriesReached
824
+ );
717
825
  return;
718
826
  }
719
827
  }
720
828
  const effectiveLockTime = this.getEffectiveLockTime(job, jobToRun);
721
829
  if (effectiveLockTime !== jobToRun.lockTime) {
722
- await this.jobsRepo.updateLockTime(jobToRun.jobId, effectiveLockTime);
830
+ const didUpdateLock = await this.jobsRepo.updateLockTime(
831
+ jobToRun.jobId,
832
+ effectiveLockTime,
833
+ jobToRun.lockId
834
+ );
835
+ if (!didUpdateLock) {
836
+ import_logger3.logger.warn(`Will not execute job "${jobToRun.name}" because its lock is no longer owned`);
837
+ return;
838
+ }
723
839
  }
724
840
  const tracer = import_api.trace.getTracer("orionjs.dogs", "1.0");
725
841
  await tracer.startActiveSpan(`job.${jobToRun.name}.${jobToRun.executionId}`, async (span) => {
726
842
  try {
727
843
  const startedAt = /* @__PURE__ */ new Date();
728
- const onStale = async () => {
729
- if (job.onStale) {
730
- context.logger.info(`Job "${jobToRun.name}" is stale`);
731
- job.onStale(jobToRun.params, context);
732
- } else {
733
- context.logger.error(`Job "${jobToRun.name}" is stale`);
734
- }
735
- await this.jobsRepo.setJobRecordPriority(jobToRun.jobId, 0);
736
- void respawnWorker();
737
- void this.saveExecution({
738
- startedAt,
739
- status: "stale",
740
- result: null,
741
- errorMessage: null,
742
- job,
743
- jobToRun
744
- }).catch((error) => {
745
- context.logger.error("Error saving stale execution history", { error });
844
+ let executionStatus = "running";
845
+ let staleHandlingPromise;
846
+ let context;
847
+ const markAsStale = () => {
848
+ if (executionStatus === "stale") return staleHandlingPromise;
849
+ executionStatus = "stale";
850
+ context.clearStaleTimeout();
851
+ staleHandlingPromise = (async () => {
852
+ context.logger.warn(`Job "${jobToRun.name}" is stale`);
853
+ if (job.onStale) {
854
+ void Promise.resolve(job.onStale(jobToRun.params, context)).catch((error) => {
855
+ context.logger.error(`Error in onStale callback for job "${jobToRun.name}"`, {
856
+ error
857
+ });
858
+ });
859
+ }
860
+ try {
861
+ await this.jobsRepo.setJobRecordPriority(jobToRun.jobId, 0, jobToRun.lockId);
862
+ } catch (error) {
863
+ context.logger.error(`Error lowering priority for stale job "${jobToRun.name}"`, {
864
+ error
865
+ });
866
+ } finally {
867
+ onExecutionStale();
868
+ }
869
+ await this.saveExecution({
870
+ startedAt,
871
+ status: "stale",
872
+ result: null,
873
+ errorMessage: null,
874
+ job,
875
+ jobToRun
876
+ });
877
+ })().catch((error) => {
878
+ context.logger.error("Error handling stale execution", { error });
746
879
  });
880
+ return staleHandlingPromise;
747
881
  };
748
- const context = this.getContext(job, jobToRun, onStale);
882
+ context = this.getContext(
883
+ job,
884
+ jobToRun,
885
+ () => void markAsStale(),
886
+ () => executionStatus === "running"
887
+ );
749
888
  const extraContext = {
750
889
  controllerType: "job",
751
890
  jobName: jobToRun.name,
@@ -761,6 +900,18 @@ var Executor = class {
761
900
  });
762
901
  const result = await job.resolve(jobToRun.params, context);
763
902
  context.clearStaleTimeout();
903
+ if (executionStatus === "stale") {
904
+ await staleHandlingPromise;
905
+ context.logger.warn(
906
+ `Ignoring late success from stale execution "${jobToRun.executionId}"`
907
+ );
908
+ return;
909
+ }
910
+ const stillOwnsJob = await this.afterExecutionSuccess(job, jobToRun, context);
911
+ if (!stillOwnsJob) {
912
+ await markAsStale();
913
+ return;
914
+ }
764
915
  void this.saveExecution({
765
916
  startedAt,
766
917
  status: "success",
@@ -771,9 +922,21 @@ var Executor = class {
771
922
  }).catch((error) => {
772
923
  context.logger.error("Error saving successful execution history", { error });
773
924
  });
774
- await this.afterExecutionSuccess(job, jobToRun, context);
775
925
  } catch (error) {
776
926
  context.clearStaleTimeout();
927
+ if (executionStatus === "stale") {
928
+ await staleHandlingPromise;
929
+ context.logger.warn(
930
+ `Ignoring late error from stale execution "${jobToRun.executionId}"`,
931
+ { error }
932
+ );
933
+ return;
934
+ }
935
+ const stillOwnsJob = await this.onError(error, job, jobToRun, context);
936
+ if (!stillOwnsJob) {
937
+ await markAsStale();
938
+ return;
939
+ }
777
940
  void this.saveExecution({
778
941
  startedAt,
779
942
  status: "error",
@@ -784,7 +947,6 @@ var Executor = class {
784
947
  }).catch((saveError) => {
785
948
  context.logger.error("Error saving failed execution history", { error: saveError });
786
949
  });
787
- await this.onError(error, job, jobToRun, context);
788
950
  }
789
951
  });
790
952
  } catch (error) {
@@ -844,86 +1006,131 @@ var WorkerService = class {
844
1006
  }
845
1007
  workersInstance.runningJobsByName.set(jobName, currentExecutions - 1);
846
1008
  }
847
- async withJobAcquisitionLock(workersInstance, callback) {
848
- const previousLock = workersInstance.jobAcquisitionLock;
849
- let releaseLock;
850
- workersInstance.jobAcquisitionLock = new Promise((resolve) => {
851
- releaseLock = resolve;
852
- });
853
- await previousLock;
854
- try {
855
- return await callback();
856
- } finally {
857
- releaseLock();
1009
+ releaseExecutionCapacity(workersInstance, execution, nextStatus) {
1010
+ if (execution.status !== "running") return;
1011
+ execution.status = nextStatus;
1012
+ workersInstance.activeExecutions.delete(execution.job.executionId);
1013
+ this.releaseJobExecution(workersInstance, execution.job.name);
1014
+ execution.releaseCapacity();
1015
+ if (nextStatus === "stale") {
1016
+ workersInstance.staleExecutions.set(execution.job.executionId, execution);
858
1017
  }
859
1018
  }
860
- async getJobAndReserveExecution(config, workersInstance, jobNames) {
861
- return this.withJobAcquisitionLock(workersInstance, async () => {
862
- const availableJobNames = this.getAvailableJobNames(config, workersInstance, jobNames);
863
- if (availableJobNames.length === 0) return;
864
- const jobToRun = await this.jobsRepo.getJobAndLock(availableJobNames, config.defaultLockTime);
865
- if (!jobToRun) return;
866
- this.reserveJobExecution(workersInstance, jobToRun.name);
867
- return jobToRun;
1019
+ startExecution(config, workersInstance, jobToRun) {
1020
+ let releaseCapacity;
1021
+ const capacityPromise = new Promise((resolve) => {
1022
+ releaseCapacity = resolve;
1023
+ });
1024
+ const execution = {
1025
+ job: jobToRun,
1026
+ promise: Promise.resolve(),
1027
+ capacityPromise,
1028
+ releaseCapacity,
1029
+ status: "running"
1030
+ };
1031
+ this.reserveJobExecution(workersInstance, jobToRun.name);
1032
+ workersInstance.activeExecutions.set(jobToRun.executionId, execution);
1033
+ import_logger4.logger.debug(`Starting job execution "${jobToRun.executionId}"`, jobToRun);
1034
+ const executionPromise = this.executor.executeJob(config, jobToRun, () => {
1035
+ this.releaseExecutionCapacity(workersInstance, execution, "stale");
1036
+ });
1037
+ execution.promise = executionPromise;
1038
+ void executionPromise.catch((error) => {
1039
+ import_logger4.logger.error(`Unhandled error executing job "${jobToRun.name}"`, { error });
1040
+ }).finally(() => {
1041
+ if (execution.status === "running") {
1042
+ this.releaseExecutionCapacity(workersInstance, execution, "finished");
1043
+ } else if (execution.status === "stale") {
1044
+ workersInstance.staleExecutions.delete(jobToRun.executionId);
1045
+ }
868
1046
  });
869
1047
  }
870
- async runWorkerLoop(config, workersInstance, workerInstance, jobNames, executeConfig) {
871
- const jobToRun = await this.getJobAndReserveExecution(config, workersInstance, jobNames);
872
- if (!jobToRun) {
873
- import_logger4.logger.debug("No job to run");
874
- return false;
875
- }
876
- import_logger4.logger.debug(`Got job [w${workerInstance.workerIndex}] to run:`, jobToRun);
877
- try {
878
- await this.executor.executeJob(executeConfig, jobToRun, workerInstance.respawn);
879
- } finally {
880
- this.releaseJobExecution(workersInstance, jobToRun.name);
881
- }
882
- return true;
1048
+ async waitForPollOrStop(config, workersInstance) {
1049
+ await Promise.race([(0, import_helpers2.sleep)(config.pollInterval), workersInstance.stopSignal]);
883
1050
  }
884
- async startWorker(config, workersInstance, workerInstance) {
885
- const names = this.getJobNames(config.jobs);
886
- import_logger4.logger.debug(
887
- `Running worker loop [w${workerInstance.workerIndex}] for jobs "${names.join(", ")}"...`
1051
+ async waitForCapacityOrStop(workersInstance) {
1052
+ const capacityPromises = Array.from(
1053
+ workersInstance.activeExecutions.values(),
1054
+ (execution) => execution.capacityPromise
888
1055
  );
1056
+ if (capacityPromises.length === 0) return;
1057
+ await Promise.race([...capacityPromises, workersInstance.stopSignal]);
1058
+ }
1059
+ async runSchedulerLoop(config, workersInstance) {
1060
+ const jobNames = this.getJobNames(config.jobs);
889
1061
  const executeConfig = {
890
1062
  jobs: config.jobs,
891
1063
  maxTries: config.maxTries,
1064
+ maxTriesReachedRetentionMs: config.maxTriesReachedRetentionMs,
892
1065
  onMaxTriesReached: config.onMaxTriesReached
893
1066
  };
894
- while (true) {
895
- if (!workerInstance.running) {
896
- import_logger4.logger.info(`Got signal to stop. Stopping worker [w${workerInstance.workerIndex}]...`);
897
- return;
898
- }
1067
+ import_logger4.logger.debug(`Running scheduler loop for jobs "${jobNames.join(", ")}"...`);
1068
+ while (workersInstance.running) {
1069
+ let didMiss = false;
1070
+ let allJobNamesAtCapacity = false;
899
1071
  try {
900
- const didRun = await this.runWorkerLoop(
901
- config,
902
- workersInstance,
903
- workerInstance,
904
- names,
905
- executeConfig
906
- );
907
- if (!didRun) await (0, import_helpers2.sleep)(config.pollInterval);
908
- if (didRun) await (0, import_helpers2.sleep)(config.cooldownPeriod);
1072
+ while (workersInstance.activeExecutions.size < config.workersCount) {
1073
+ const availableJobNames = this.getAvailableJobNames(config, workersInstance, jobNames);
1074
+ if (availableJobNames.length === 0) {
1075
+ allJobNamesAtCapacity = workersInstance.activeExecutions.size > 0;
1076
+ break;
1077
+ }
1078
+ const jobToRun = await this.jobsRepo.getJobAndLock(
1079
+ availableJobNames,
1080
+ config.defaultLockTime
1081
+ );
1082
+ if (!jobToRun) {
1083
+ import_logger4.logger.debug("No job to run");
1084
+ didMiss = true;
1085
+ break;
1086
+ }
1087
+ this.startExecution(executeConfig, workersInstance, jobToRun);
1088
+ if (!workersInstance.running) break;
1089
+ }
909
1090
  } catch (error) {
910
- import_logger4.logger.error("Error in job runner.", { error });
911
- await (0, import_helpers2.sleep)(config.pollInterval);
1091
+ import_logger4.logger.error("Error in job scheduler.", { error });
1092
+ didMiss = true;
1093
+ }
1094
+ if (!workersInstance.running) return;
1095
+ const concurrencyIsFull = workersInstance.activeExecutions.size >= config.workersCount && workersInstance.activeExecutions.size > 0;
1096
+ if (concurrencyIsFull || allJobNamesAtCapacity) {
1097
+ await this.waitForCapacityOrStop(workersInstance);
1098
+ } else if (didMiss || workersInstance.activeExecutions.size === 0) {
1099
+ await this.waitForPollOrStop(config, workersInstance);
912
1100
  }
913
1101
  }
914
1102
  }
915
1103
  createWorkersInstanceDefinition(config) {
1104
+ let signalStop;
1105
+ const stopSignal = new Promise((resolve) => {
1106
+ signalStop = resolve;
1107
+ });
916
1108
  const workersInstance = {
917
1109
  running: true,
918
1110
  workersCount: config.workersCount,
919
- workers: [],
1111
+ activeExecutions: /* @__PURE__ */ new Map(),
1112
+ staleExecutions: /* @__PURE__ */ new Map(),
920
1113
  runningJobsByName: /* @__PURE__ */ new Map(),
921
- jobAcquisitionLock: Promise.resolve(),
1114
+ schedulerPromise: Promise.resolve(),
1115
+ stopSignal,
1116
+ signalStop,
1117
+ get runningExecutions() {
1118
+ return this.activeExecutions.size;
1119
+ },
922
1120
  stop: async () => {
923
- import_logger4.logger.info("Stopping workers...");
924
- workersInstance.running = false;
925
- const stopingPromises = workersInstance.workers.map((worker) => worker.stop());
926
- await Promise.all(stopingPromises);
1121
+ if (workersInstance.stopPromise) return workersInstance.stopPromise;
1122
+ workersInstance.stopPromise = (async () => {
1123
+ import_logger4.logger.info("Stopping job scheduler...");
1124
+ workersInstance.running = false;
1125
+ workersInstance.signalStop();
1126
+ await workersInstance.schedulerPromise;
1127
+ const activeCapacity = Array.from(
1128
+ workersInstance.activeExecutions.values(),
1129
+ (execution) => execution.capacityPromise
1130
+ );
1131
+ await Promise.all(activeCapacity);
1132
+ })();
1133
+ return workersInstance.stopPromise;
927
1134
  }
928
1135
  };
929
1136
  return workersInstance;
@@ -937,54 +1144,37 @@ var WorkerService = class {
937
1144
  })
938
1145
  );
939
1146
  }
940
- async startANewWorker(config, workersInstance, workerIndex = workersInstance.workers.length) {
941
- if (!workersInstance.running) {
942
- return;
943
- }
944
- const workerInstance = {
945
- running: true,
946
- workerIndex,
947
- stop: async () => {
948
- import_logger4.logger.info(`Stopping worker [w${workerIndex}]...`);
949
- workerInstance.running = false;
950
- await workerInstance.promise;
951
- },
952
- respawn: async () => {
953
- import_logger4.logger.info(`Respawning worker [w${workerIndex}]...`);
954
- workerInstance.running = false;
955
- await this.startANewWorker(config, workersInstance, workerIndex);
956
- }
957
- };
958
- const workerPromise = this.startWorker(config, workersInstance, workerInstance);
959
- workerInstance.promise = workerPromise;
960
- workersInstance.workers[workerIndex] = workerInstance;
961
- }
962
- async runWorkers(config, workersInstance) {
1147
+ async runScheduler(config, workersInstance) {
1148
+ import_logger4.logger.debug("Will ensure maxTriesReached retention and TTL index");
1149
+ void this.jobsRepo.ensureMaxTriesReachedRetention(config.maxTriesReachedRetentionMs).catch((error) => import_logger4.logger.error("Error ensuring maxTriesReached retention", { error }));
963
1150
  import_logger4.logger.debug("Will ensure records for recurrent jobs");
964
1151
  await this.ensureRecords(config);
965
- const workersCount = config.workersCount;
966
- const workerWord = workersCount === 1 ? "worker" : "workers";
967
- import_logger4.logger.info(`Starting ${workersCount} ${workerWord}`);
968
- for (let workerIndex = 0; workerIndex < workersCount; workerIndex++) {
969
- this.startANewWorker(config, workersInstance, workerIndex);
970
- }
1152
+ import_logger4.logger.info(`Starting job scheduler with concurrency ${config.workersCount}`);
1153
+ await this.runSchedulerLoop(config, workersInstance);
971
1154
  }
972
1155
  /**
973
- * Starts the job workers with the provided configuration.
974
- * @param userConfig - Configuration for the workers. Required field: jobs
1156
+ * Starts one job scheduler with the provided concurrency configuration.
1157
+ * @param userConfig - Configuration for the scheduler. Required field: jobs
975
1158
  */
976
1159
  startWorkers(userConfig) {
1160
+ const maxTriesReachedRetentionMs = userConfig.maxTriesReachedRetentionMs === void 0 ? DEFAULT_MAX_TRIES_REACHED_RETENTION_MS : userConfig.maxTriesReachedRetentionMs;
1161
+ if (maxTriesReachedRetentionMs !== null && (!Number.isFinite(maxTriesReachedRetentionMs) || maxTriesReachedRetentionMs < 0)) {
1162
+ throw new Error("maxTriesReachedRetentionMs must be null, zero, or a positive number");
1163
+ }
977
1164
  const config = {
978
- cooldownPeriod: 100,
979
1165
  pollInterval: 3e3,
980
1166
  workersCount: 4,
981
1167
  defaultLockTime: 30 * 1e3,
982
- ...userConfig
1168
+ ...userConfig,
1169
+ maxTriesReachedRetentionMs
983
1170
  };
984
1171
  setNameToJobs(config.jobs);
985
1172
  const workersInstance = this.createWorkersInstanceDefinition(config);
986
- import_logger4.logger.debug("Starting workers", config);
987
- this.runWorkers(config, workersInstance);
1173
+ import_logger4.logger.debug("Starting job scheduler", config);
1174
+ workersInstance.schedulerPromise = this.runScheduler(config, workersInstance).catch((error) => {
1175
+ workersInstance.running = false;
1176
+ import_logger4.logger.error("Job scheduler stopped after an unexpected error.", { error });
1177
+ });
988
1178
  return workersInstance;
989
1179
  }
990
1180
  };
@@ -1151,6 +1341,7 @@ var scheduleJobs = (jobs) => {
1151
1341
  };
1152
1342
  // Annotate the CommonJS export names for ESM import in node:
1153
1343
  0 && (module.exports = {
1344
+ DEFAULT_MAX_TRIES_REACHED_RETENTION_MS,
1154
1345
  EventJob,
1155
1346
  HistoryRecordSchema,
1156
1347
  JobRecordSchema,