@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.js CHANGED
@@ -71,15 +71,32 @@ var JobRecordSchema = schemaWithName("JobRecord", {
71
71
  nextRunAt: { type: "date" },
72
72
  lastRunAt: { type: "date", optional: true },
73
73
  lockedUntil: { type: "date", optional: true },
74
+ /**
75
+ * Identifies the execution that currently owns the lock. Mutations from an execution must match
76
+ * this value so a stale execution cannot modify a newer claim.
77
+ */
78
+ lockId: { type: "string", optional: true },
74
79
  tries: { type: "number", optional: true },
75
80
  params: { type: "blackbox", optional: true },
76
81
  /**
77
82
  * Status of the job. Optional for backwards compatibility with existing records.
78
83
  * Records without this field are treated as 'pending'.
79
84
  */
80
- status: { type: JobStatusEnum, optional: true }
85
+ status: { type: JobStatusEnum, optional: true },
86
+ /**
87
+ * Date when the job reached its maximum tries. Used as the stable anchor when
88
+ * maxTriesReachedRetentionMs changes between worker restarts.
89
+ */
90
+ maxTriesReachedAt: { type: "date", optional: true },
91
+ /**
92
+ * Date when MongoDB should remove a terminal event job through the TTL index.
93
+ */
94
+ expiresAt: { type: "date", optional: true }
81
95
  });
82
96
 
97
+ // src/types/StartConfig.ts
98
+ var DEFAULT_MAX_TRIES_REACHED_RETENTION_MS = 1e3 * 60 * 60 * 24 * 7;
99
+
83
100
  // src/repos/JobsRepo.ts
84
101
  var _jobs_dec, _JobsRepo_decorators, _init;
85
102
  _JobsRepo_decorators = [Repository()], _jobs_dec = [MongoCollection({
@@ -111,6 +128,13 @@ _JobsRepo_decorators = [Repository()], _jobs_dec = [MongoCollection({
111
128
  unique: true,
112
129
  sparse: true
113
130
  }
131
+ },
132
+ {
133
+ keys: {
134
+ expiresAt: 1
135
+ },
136
+ name: "jobs_dogs_records_expires_at_ttl",
137
+ expireAfterSeconds: 0
114
138
  }
115
139
  ]
116
140
  })];
@@ -119,6 +143,7 @@ var JobsRepo = class {
119
143
  __publicField(this, "jobs", __runInitializers(_init, 8, this)), __runInitializers(_init, 11, this);
120
144
  }
121
145
  async getJobAndLock(jobNames, lockTime) {
146
+ const executionId = generateId();
122
147
  const lockedUntil = new Date(Date.now() + lockTime);
123
148
  const job = await this.jobs.findOneAndUpdate(
124
149
  {
@@ -132,7 +157,7 @@ var JobsRepo = class {
132
157
  ]
133
158
  },
134
159
  {
135
- $set: { lockedUntil, lastRunAt: /* @__PURE__ */ new Date() },
160
+ $set: { lockId: executionId, lockedUntil, lastRunAt: /* @__PURE__ */ new Date() },
136
161
  $inc: { tries: 1 }
137
162
  },
138
163
  {
@@ -153,7 +178,8 @@ var JobsRepo = class {
153
178
  }
154
179
  return {
155
180
  jobId: job._id,
156
- executionId: generateId(),
181
+ executionId,
182
+ lockId: executionId,
157
183
  name: job.jobName,
158
184
  params: job.params,
159
185
  type: job.type,
@@ -164,8 +190,11 @@ var JobsRepo = class {
164
190
  wasStale
165
191
  };
166
192
  }
167
- async setJobRecordPriority(jobId, priority) {
168
- await this.jobs.updateOne(jobId, { $set: { priority } });
193
+ async setJobRecordPriority(jobId, priority, lockId) {
194
+ const result = await this.jobs.updateOne(lockId ? { _id: jobId, lockId } : jobId, {
195
+ $set: { priority }
196
+ });
197
+ return result.matchedCount > 0;
169
198
  }
170
199
  async scheduleNextRun(options) {
171
200
  const updator = {
@@ -174,43 +203,89 @@ var JobsRepo = class {
174
203
  priority: options.priority,
175
204
  ...options.resetTries ? { tries: 0 } : {}
176
205
  },
177
- $unset: { lockedUntil: "" }
206
+ $unset: { lockId: "", lockedUntil: "" }
178
207
  };
179
- await this.jobs.updateOne(options.jobId, updator);
208
+ const result = await this.jobs.updateOne(
209
+ options.lockId ? { _id: options.jobId, lockId: options.lockId } : options.jobId,
210
+ updator
211
+ );
212
+ return result.matchedCount > 0;
180
213
  }
181
- async deleteEventJob(jobId) {
182
- await this.jobs.deleteOne({ _id: jobId, type: "event" });
214
+ async deleteEventJob(jobId, lockId) {
215
+ const result = await this.jobs.deleteOne({
216
+ _id: jobId,
217
+ type: "event",
218
+ ...lockId ? { lockId } : {}
219
+ });
220
+ return result.deletedCount > 0;
183
221
  }
184
222
  /**
185
223
  * Marks an event job as having reached its maximum tries limit.
186
- * The job will remain in the database but won't be picked up for execution.
224
+ * The job won't be picked up for execution and MongoDB will delete it after
225
+ * the configured retention period.
187
226
  */
188
- async markJobAsMaxTriesReached(jobId) {
189
- await this.jobs.updateOne(
190
- { _id: jobId, type: "event" },
227
+ async markJobAsMaxTriesReached(jobId, retentionMs = DEFAULT_MAX_TRIES_REACHED_RETENTION_MS, lockId) {
228
+ const maxTriesReachedAt = /* @__PURE__ */ new Date();
229
+ const result = await this.jobs.updateOne(
230
+ { _id: jobId, type: "event", ...lockId ? { lockId } : {} },
191
231
  {
192
- $set: { status: "maxTriesReached" },
193
- $unset: { lockedUntil: "" }
232
+ $set: {
233
+ status: "maxTriesReached",
234
+ maxTriesReachedAt,
235
+ ...retentionMs === null ? {} : { expiresAt: new Date(maxTriesReachedAt.getTime() + retentionMs) }
236
+ },
237
+ $unset: {
238
+ lockId: "",
239
+ lockedUntil: "",
240
+ ...retentionMs === null ? { expiresAt: "" } : {}
241
+ }
194
242
  }
195
243
  );
244
+ return result.matchedCount > 0;
196
245
  }
197
- async extendLockTime(jobId, extraTime) {
198
- await this.updateLockTime(jobId, extraTime);
246
+ /**
247
+ * Applies the current retention configuration to existing maxTriesReached
248
+ * event jobs. The collection definition ensures the TTL index. Legacy records
249
+ * use lastRunAt as their retention anchor when available.
250
+ */
251
+ async ensureMaxTriesReachedRetention(retentionMs) {
252
+ const selector = {
253
+ type: "event",
254
+ status: "maxTriesReached"
255
+ };
256
+ if (retentionMs === null) {
257
+ await this.jobs.updateMany(selector, { $unset: { expiresAt: "" } });
258
+ return;
259
+ }
260
+ const now = /* @__PURE__ */ new Date();
261
+ const retentionAnchor = {
262
+ $ifNull: ["$maxTriesReachedAt", { $ifNull: ["$lastRunAt", now] }]
263
+ };
264
+ await this.jobs.rawCollection.updateMany(selector, [
265
+ {
266
+ $set: {
267
+ maxTriesReachedAt: retentionAnchor,
268
+ expiresAt: { $add: [retentionAnchor, retentionMs] }
269
+ }
270
+ }
271
+ ]);
272
+ }
273
+ async extendLockTime(jobId, extraTime, lockId) {
274
+ return this.updateLockTime(jobId, extraTime, lockId);
199
275
  }
200
276
  /**
201
277
  * Updates the lock time for a job to the specified duration from now.
202
278
  * Can be used to both extend or shorten the lock time.
203
279
  */
204
- async updateLockTime(jobId, lockDuration) {
280
+ async updateLockTime(jobId, lockDuration, lockId) {
205
281
  const lockedUntil = new Date(Date.now() + lockDuration);
206
- await this.jobs.updateOne(
207
- {
208
- _id: jobId
209
- },
282
+ const result = await this.jobs.updateOne(
283
+ { _id: jobId, ...lockId ? { lockId } : {} },
210
284
  {
211
285
  $set: { lockedUntil }
212
286
  }
213
287
  );
288
+ return result.matchedCount > 0;
214
289
  }
215
290
  async unlockAllJobs() {
216
291
  const result = await this.jobs.updateMany(
@@ -218,7 +293,7 @@ var JobsRepo = class {
218
293
  lockedUntil: { $exists: true }
219
294
  },
220
295
  {
221
- $unset: { lockedUntil: "" }
296
+ $unset: { lockId: "", lockedUntil: "" }
222
297
  }
223
298
  );
224
299
  return result.modifiedCount;
@@ -524,11 +599,15 @@ var Executor = class {
524
599
  getEffectiveLockTime(job, jobToRun) {
525
600
  return job.lockTime ?? jobToRun.lockTime;
526
601
  }
527
- getContext(job, jobToRun, onStale) {
602
+ getContext(job, jobToRun, onStale, isExecutionActive) {
528
603
  var _a;
529
604
  const effectiveLockTime = this.getEffectiveLockTime(job, jobToRun);
530
605
  let staleTimeout = setTimeout(() => onStale(), effectiveLockTime);
531
606
  (_a = staleTimeout.unref) == null ? void 0 : _a.call(staleTimeout);
607
+ const contextLogger = logger3.addMetadata({
608
+ jobName: jobToRun.name,
609
+ jobId: jobToRun.jobId
610
+ });
532
611
  return {
533
612
  definition: job,
534
613
  record: jobToRun,
@@ -536,15 +615,26 @@ var Executor = class {
536
615
  clearStaleTimeout: () => clearTimeout(staleTimeout),
537
616
  extendLockTime: async (extraTime) => {
538
617
  var _a2;
618
+ if (!isExecutionActive()) {
619
+ contextLogger.warn(`Will not extend lock for stale execution "${jobToRun.executionId}"`);
620
+ return;
621
+ }
539
622
  clearTimeout(staleTimeout);
623
+ let didExtend = false;
624
+ try {
625
+ didExtend = await this.jobsRepo.extendLockTime(jobToRun.jobId, extraTime, jobToRun.lockId);
626
+ } catch (error) {
627
+ onStale();
628
+ throw error;
629
+ }
630
+ if (!didExtend) {
631
+ onStale();
632
+ return;
633
+ }
540
634
  staleTimeout = setTimeout(() => onStale(), extraTime);
541
635
  (_a2 = staleTimeout.unref) == null ? void 0 : _a2.call(staleTimeout);
542
- await this.jobsRepo.extendLockTime(jobToRun.jobId, extraTime);
543
636
  },
544
- logger: logger3.addMetadata({
545
- jobName: jobToRun.name,
546
- jobId: jobToRun.jobId
547
- })
637
+ logger: contextLogger
548
638
  };
549
639
  }
550
640
  getJobDefinition(jobToRun, jobs) {
@@ -568,7 +658,7 @@ var Executor = class {
568
658
  * Handles when a job has reached its maximum retry attempts.
569
659
  * Marks the job in the database and invokes the onMaxTriesReached callback when provided.
570
660
  */
571
- async handleMaxTriesReached(jobToRun, onMaxTriesReached) {
661
+ async handleMaxTriesReached(jobToRun, retentionMs, onMaxTriesReached) {
572
662
  const jobLogger = logger3.addMetadata({
573
663
  jobName: jobToRun.name,
574
664
  jobId: jobToRun.jobId
@@ -576,8 +666,16 @@ var Executor = class {
576
666
  jobLogger.warn(
577
667
  `Job "${jobToRun.name}" has exceeded max tries (${jobToRun.tries}). Marking as maxTriesReached.`
578
668
  );
579
- await this.jobsRepo.markJobAsMaxTriesReached(jobToRun.jobId);
580
- if (!onMaxTriesReached) return;
669
+ const didMark = await this.jobsRepo.markJobAsMaxTriesReached(
670
+ jobToRun.jobId,
671
+ retentionMs,
672
+ jobToRun.lockId
673
+ );
674
+ if (!didMark) {
675
+ jobLogger.warn(`Could not mark job "${jobToRun.name}" because its lock is no longer owned`);
676
+ return false;
677
+ }
678
+ if (!onMaxTriesReached) return true;
581
679
  try {
582
680
  await onMaxTriesReached(jobToRun);
583
681
  } catch (callbackError) {
@@ -585,30 +683,33 @@ var Executor = class {
585
683
  error: callbackError
586
684
  });
587
685
  }
686
+ return true;
588
687
  }
589
688
  async onError(error, job, jobToRun, context) {
590
689
  const scheduleRecurrent = async () => {
591
690
  if (job.type === "recurrent") {
592
- await this.jobsRepo.scheduleNextRun({
691
+ return this.jobsRepo.scheduleNextRun({
593
692
  jobId: jobToRun.jobId,
594
693
  nextRunAt: getNextRunDate(job),
595
694
  resetTries: true,
596
- priority: job.priority
695
+ priority: job.priority,
696
+ lockId: jobToRun.lockId
597
697
  });
598
698
  }
699
+ return true;
599
700
  };
600
701
  const handleRetry = async (nextRunAt) => {
601
- await this.jobsRepo.scheduleNextRun({
702
+ return this.jobsRepo.scheduleNextRun({
602
703
  jobId: jobToRun.jobId,
603
704
  nextRunAt,
604
705
  resetTries: false,
605
- priority: job.type === "recurrent" ? job.priority : jobToRun.priority
706
+ priority: job.type === "recurrent" ? job.priority : jobToRun.priority,
707
+ lockId: jobToRun.lockId
606
708
  });
607
709
  };
608
710
  if (!job.onError) {
609
711
  context.logger.error(`Error executing job "${jobToRun.name}"`, { error });
610
- await scheduleRecurrent();
611
- return;
712
+ return scheduleRecurrent();
612
713
  }
613
714
  context.logger.info(`Error executing job "${jobToRun.name}"`, { error });
614
715
  const result = await job.onError(
@@ -617,12 +718,12 @@ var Executor = class {
617
718
  context
618
719
  );
619
720
  if (result.action === "dismiss") {
620
- await scheduleRecurrent();
621
- return;
721
+ return scheduleRecurrent();
622
722
  }
623
723
  if (result.action === "retry") {
624
- await handleRetry(getNextRunDate(result));
724
+ return handleRetry(getNextRunDate(result));
625
725
  }
726
+ return true;
626
727
  }
627
728
  async saveExecution(options) {
628
729
  const { startedAt, status, errorMessage, result, job, jobToRun } = options;
@@ -652,57 +753,94 @@ var Executor = class {
652
753
  async afterExecutionSuccess(job, jobToRun, context) {
653
754
  if (job.type === "recurrent") {
654
755
  context.logger.debug(`Scheduling next run for recurrent job "${jobToRun.name}"`);
655
- await this.jobsRepo.scheduleNextRun({
756
+ return this.jobsRepo.scheduleNextRun({
656
757
  jobId: jobToRun.jobId,
657
758
  nextRunAt: getNextRunDate(job),
658
759
  resetTries: true,
659
- priority: job.priority
760
+ priority: job.priority,
761
+ lockId: jobToRun.lockId
660
762
  });
661
763
  }
662
764
  if (job.type === "event") {
663
765
  context.logger.debug(`Removing event job after success "${jobToRun.name}"`);
664
- await this.jobsRepo.deleteEventJob(jobToRun.jobId);
766
+ return this.jobsRepo.deleteEventJob(jobToRun.jobId, jobToRun.lockId);
665
767
  }
768
+ return false;
666
769
  }
667
- async executeJob(config, jobToRun, respawnWorker) {
770
+ async executeJob(config, jobToRun, onExecutionStale) {
668
771
  const job = this.getJobDefinition(jobToRun, config.jobs);
669
772
  if (!job) return;
670
773
  if (job.type === "event") {
671
774
  const effectiveMaxTries = this.getEffectiveMaxTries(job, config.maxTries);
672
775
  if (typeof effectiveMaxTries === "number" && jobToRun.tries > effectiveMaxTries) {
673
- await this.handleMaxTriesReached(jobToRun, config.onMaxTriesReached);
776
+ await this.handleMaxTriesReached(
777
+ jobToRun,
778
+ config.maxTriesReachedRetentionMs,
779
+ config.onMaxTriesReached
780
+ );
674
781
  return;
675
782
  }
676
783
  }
677
784
  const effectiveLockTime = this.getEffectiveLockTime(job, jobToRun);
678
785
  if (effectiveLockTime !== jobToRun.lockTime) {
679
- await this.jobsRepo.updateLockTime(jobToRun.jobId, effectiveLockTime);
786
+ const didUpdateLock = await this.jobsRepo.updateLockTime(
787
+ jobToRun.jobId,
788
+ effectiveLockTime,
789
+ jobToRun.lockId
790
+ );
791
+ if (!didUpdateLock) {
792
+ logger3.warn(`Will not execute job "${jobToRun.name}" because its lock is no longer owned`);
793
+ return;
794
+ }
680
795
  }
681
796
  const tracer = trace.getTracer("orionjs.dogs", "1.0");
682
797
  await tracer.startActiveSpan(`job.${jobToRun.name}.${jobToRun.executionId}`, async (span) => {
683
798
  try {
684
799
  const startedAt = /* @__PURE__ */ new Date();
685
- const onStale = async () => {
686
- if (job.onStale) {
687
- context.logger.info(`Job "${jobToRun.name}" is stale`);
688
- job.onStale(jobToRun.params, context);
689
- } else {
690
- context.logger.error(`Job "${jobToRun.name}" is stale`);
691
- }
692
- await this.jobsRepo.setJobRecordPriority(jobToRun.jobId, 0);
693
- void respawnWorker();
694
- void this.saveExecution({
695
- startedAt,
696
- status: "stale",
697
- result: null,
698
- errorMessage: null,
699
- job,
700
- jobToRun
701
- }).catch((error) => {
702
- context.logger.error("Error saving stale execution history", { error });
800
+ let executionStatus = "running";
801
+ let staleHandlingPromise;
802
+ let context;
803
+ const markAsStale = () => {
804
+ if (executionStatus === "stale") return staleHandlingPromise;
805
+ executionStatus = "stale";
806
+ context.clearStaleTimeout();
807
+ staleHandlingPromise = (async () => {
808
+ context.logger.warn(`Job "${jobToRun.name}" is stale`);
809
+ if (job.onStale) {
810
+ void Promise.resolve(job.onStale(jobToRun.params, context)).catch((error) => {
811
+ context.logger.error(`Error in onStale callback for job "${jobToRun.name}"`, {
812
+ error
813
+ });
814
+ });
815
+ }
816
+ try {
817
+ await this.jobsRepo.setJobRecordPriority(jobToRun.jobId, 0, jobToRun.lockId);
818
+ } catch (error) {
819
+ context.logger.error(`Error lowering priority for stale job "${jobToRun.name}"`, {
820
+ error
821
+ });
822
+ } finally {
823
+ onExecutionStale();
824
+ }
825
+ await this.saveExecution({
826
+ startedAt,
827
+ status: "stale",
828
+ result: null,
829
+ errorMessage: null,
830
+ job,
831
+ jobToRun
832
+ });
833
+ })().catch((error) => {
834
+ context.logger.error("Error handling stale execution", { error });
703
835
  });
836
+ return staleHandlingPromise;
704
837
  };
705
- const context = this.getContext(job, jobToRun, onStale);
838
+ context = this.getContext(
839
+ job,
840
+ jobToRun,
841
+ () => void markAsStale(),
842
+ () => executionStatus === "running"
843
+ );
706
844
  const extraContext = {
707
845
  controllerType: "job",
708
846
  jobName: jobToRun.name,
@@ -718,6 +856,18 @@ var Executor = class {
718
856
  });
719
857
  const result = await job.resolve(jobToRun.params, context);
720
858
  context.clearStaleTimeout();
859
+ if (executionStatus === "stale") {
860
+ await staleHandlingPromise;
861
+ context.logger.warn(
862
+ `Ignoring late success from stale execution "${jobToRun.executionId}"`
863
+ );
864
+ return;
865
+ }
866
+ const stillOwnsJob = await this.afterExecutionSuccess(job, jobToRun, context);
867
+ if (!stillOwnsJob) {
868
+ await markAsStale();
869
+ return;
870
+ }
721
871
  void this.saveExecution({
722
872
  startedAt,
723
873
  status: "success",
@@ -728,9 +878,21 @@ var Executor = class {
728
878
  }).catch((error) => {
729
879
  context.logger.error("Error saving successful execution history", { error });
730
880
  });
731
- await this.afterExecutionSuccess(job, jobToRun, context);
732
881
  } catch (error) {
733
882
  context.clearStaleTimeout();
883
+ if (executionStatus === "stale") {
884
+ await staleHandlingPromise;
885
+ context.logger.warn(
886
+ `Ignoring late error from stale execution "${jobToRun.executionId}"`,
887
+ { error }
888
+ );
889
+ return;
890
+ }
891
+ const stillOwnsJob = await this.onError(error, job, jobToRun, context);
892
+ if (!stillOwnsJob) {
893
+ await markAsStale();
894
+ return;
895
+ }
734
896
  void this.saveExecution({
735
897
  startedAt,
736
898
  status: "error",
@@ -741,7 +903,6 @@ var Executor = class {
741
903
  }).catch((saveError) => {
742
904
  context.logger.error("Error saving failed execution history", { error: saveError });
743
905
  });
744
- await this.onError(error, job, jobToRun, context);
745
906
  }
746
907
  });
747
908
  } catch (error) {
@@ -801,86 +962,131 @@ var WorkerService = class {
801
962
  }
802
963
  workersInstance.runningJobsByName.set(jobName, currentExecutions - 1);
803
964
  }
804
- async withJobAcquisitionLock(workersInstance, callback) {
805
- const previousLock = workersInstance.jobAcquisitionLock;
806
- let releaseLock;
807
- workersInstance.jobAcquisitionLock = new Promise((resolve) => {
808
- releaseLock = resolve;
809
- });
810
- await previousLock;
811
- try {
812
- return await callback();
813
- } finally {
814
- releaseLock();
965
+ releaseExecutionCapacity(workersInstance, execution, nextStatus) {
966
+ if (execution.status !== "running") return;
967
+ execution.status = nextStatus;
968
+ workersInstance.activeExecutions.delete(execution.job.executionId);
969
+ this.releaseJobExecution(workersInstance, execution.job.name);
970
+ execution.releaseCapacity();
971
+ if (nextStatus === "stale") {
972
+ workersInstance.staleExecutions.set(execution.job.executionId, execution);
815
973
  }
816
974
  }
817
- async getJobAndReserveExecution(config, workersInstance, jobNames) {
818
- return this.withJobAcquisitionLock(workersInstance, async () => {
819
- const availableJobNames = this.getAvailableJobNames(config, workersInstance, jobNames);
820
- if (availableJobNames.length === 0) return;
821
- const jobToRun = await this.jobsRepo.getJobAndLock(availableJobNames, config.defaultLockTime);
822
- if (!jobToRun) return;
823
- this.reserveJobExecution(workersInstance, jobToRun.name);
824
- return jobToRun;
975
+ startExecution(config, workersInstance, jobToRun) {
976
+ let releaseCapacity;
977
+ const capacityPromise = new Promise((resolve) => {
978
+ releaseCapacity = resolve;
979
+ });
980
+ const execution = {
981
+ job: jobToRun,
982
+ promise: Promise.resolve(),
983
+ capacityPromise,
984
+ releaseCapacity,
985
+ status: "running"
986
+ };
987
+ this.reserveJobExecution(workersInstance, jobToRun.name);
988
+ workersInstance.activeExecutions.set(jobToRun.executionId, execution);
989
+ logger4.debug(`Starting job execution "${jobToRun.executionId}"`, jobToRun);
990
+ const executionPromise = this.executor.executeJob(config, jobToRun, () => {
991
+ this.releaseExecutionCapacity(workersInstance, execution, "stale");
992
+ });
993
+ execution.promise = executionPromise;
994
+ void executionPromise.catch((error) => {
995
+ logger4.error(`Unhandled error executing job "${jobToRun.name}"`, { error });
996
+ }).finally(() => {
997
+ if (execution.status === "running") {
998
+ this.releaseExecutionCapacity(workersInstance, execution, "finished");
999
+ } else if (execution.status === "stale") {
1000
+ workersInstance.staleExecutions.delete(jobToRun.executionId);
1001
+ }
825
1002
  });
826
1003
  }
827
- async runWorkerLoop(config, workersInstance, workerInstance, jobNames, executeConfig) {
828
- const jobToRun = await this.getJobAndReserveExecution(config, workersInstance, jobNames);
829
- if (!jobToRun) {
830
- logger4.debug("No job to run");
831
- return false;
832
- }
833
- logger4.debug(`Got job [w${workerInstance.workerIndex}] to run:`, jobToRun);
834
- try {
835
- await this.executor.executeJob(executeConfig, jobToRun, workerInstance.respawn);
836
- } finally {
837
- this.releaseJobExecution(workersInstance, jobToRun.name);
838
- }
839
- return true;
1004
+ async waitForPollOrStop(config, workersInstance) {
1005
+ await Promise.race([sleep(config.pollInterval), workersInstance.stopSignal]);
840
1006
  }
841
- async startWorker(config, workersInstance, workerInstance) {
842
- const names = this.getJobNames(config.jobs);
843
- logger4.debug(
844
- `Running worker loop [w${workerInstance.workerIndex}] for jobs "${names.join(", ")}"...`
1007
+ async waitForCapacityOrStop(workersInstance) {
1008
+ const capacityPromises = Array.from(
1009
+ workersInstance.activeExecutions.values(),
1010
+ (execution) => execution.capacityPromise
845
1011
  );
1012
+ if (capacityPromises.length === 0) return;
1013
+ await Promise.race([...capacityPromises, workersInstance.stopSignal]);
1014
+ }
1015
+ async runSchedulerLoop(config, workersInstance) {
1016
+ const jobNames = this.getJobNames(config.jobs);
846
1017
  const executeConfig = {
847
1018
  jobs: config.jobs,
848
1019
  maxTries: config.maxTries,
1020
+ maxTriesReachedRetentionMs: config.maxTriesReachedRetentionMs,
849
1021
  onMaxTriesReached: config.onMaxTriesReached
850
1022
  };
851
- while (true) {
852
- if (!workerInstance.running) {
853
- logger4.info(`Got signal to stop. Stopping worker [w${workerInstance.workerIndex}]...`);
854
- return;
855
- }
1023
+ logger4.debug(`Running scheduler loop for jobs "${jobNames.join(", ")}"...`);
1024
+ while (workersInstance.running) {
1025
+ let didMiss = false;
1026
+ let allJobNamesAtCapacity = false;
856
1027
  try {
857
- const didRun = await this.runWorkerLoop(
858
- config,
859
- workersInstance,
860
- workerInstance,
861
- names,
862
- executeConfig
863
- );
864
- if (!didRun) await sleep(config.pollInterval);
865
- if (didRun) await sleep(config.cooldownPeriod);
1028
+ while (workersInstance.activeExecutions.size < config.workersCount) {
1029
+ const availableJobNames = this.getAvailableJobNames(config, workersInstance, jobNames);
1030
+ if (availableJobNames.length === 0) {
1031
+ allJobNamesAtCapacity = workersInstance.activeExecutions.size > 0;
1032
+ break;
1033
+ }
1034
+ const jobToRun = await this.jobsRepo.getJobAndLock(
1035
+ availableJobNames,
1036
+ config.defaultLockTime
1037
+ );
1038
+ if (!jobToRun) {
1039
+ logger4.debug("No job to run");
1040
+ didMiss = true;
1041
+ break;
1042
+ }
1043
+ this.startExecution(executeConfig, workersInstance, jobToRun);
1044
+ if (!workersInstance.running) break;
1045
+ }
866
1046
  } catch (error) {
867
- logger4.error("Error in job runner.", { error });
868
- await sleep(config.pollInterval);
1047
+ logger4.error("Error in job scheduler.", { error });
1048
+ didMiss = true;
1049
+ }
1050
+ if (!workersInstance.running) return;
1051
+ const concurrencyIsFull = workersInstance.activeExecutions.size >= config.workersCount && workersInstance.activeExecutions.size > 0;
1052
+ if (concurrencyIsFull || allJobNamesAtCapacity) {
1053
+ await this.waitForCapacityOrStop(workersInstance);
1054
+ } else if (didMiss || workersInstance.activeExecutions.size === 0) {
1055
+ await this.waitForPollOrStop(config, workersInstance);
869
1056
  }
870
1057
  }
871
1058
  }
872
1059
  createWorkersInstanceDefinition(config) {
1060
+ let signalStop;
1061
+ const stopSignal = new Promise((resolve) => {
1062
+ signalStop = resolve;
1063
+ });
873
1064
  const workersInstance = {
874
1065
  running: true,
875
1066
  workersCount: config.workersCount,
876
- workers: [],
1067
+ activeExecutions: /* @__PURE__ */ new Map(),
1068
+ staleExecutions: /* @__PURE__ */ new Map(),
877
1069
  runningJobsByName: /* @__PURE__ */ new Map(),
878
- jobAcquisitionLock: Promise.resolve(),
1070
+ schedulerPromise: Promise.resolve(),
1071
+ stopSignal,
1072
+ signalStop,
1073
+ get runningExecutions() {
1074
+ return this.activeExecutions.size;
1075
+ },
879
1076
  stop: async () => {
880
- logger4.info("Stopping workers...");
881
- workersInstance.running = false;
882
- const stopingPromises = workersInstance.workers.map((worker) => worker.stop());
883
- await Promise.all(stopingPromises);
1077
+ if (workersInstance.stopPromise) return workersInstance.stopPromise;
1078
+ workersInstance.stopPromise = (async () => {
1079
+ logger4.info("Stopping job scheduler...");
1080
+ workersInstance.running = false;
1081
+ workersInstance.signalStop();
1082
+ await workersInstance.schedulerPromise;
1083
+ const activeCapacity = Array.from(
1084
+ workersInstance.activeExecutions.values(),
1085
+ (execution) => execution.capacityPromise
1086
+ );
1087
+ await Promise.all(activeCapacity);
1088
+ })();
1089
+ return workersInstance.stopPromise;
884
1090
  }
885
1091
  };
886
1092
  return workersInstance;
@@ -894,54 +1100,37 @@ var WorkerService = class {
894
1100
  })
895
1101
  );
896
1102
  }
897
- async startANewWorker(config, workersInstance, workerIndex = workersInstance.workers.length) {
898
- if (!workersInstance.running) {
899
- return;
900
- }
901
- const workerInstance = {
902
- running: true,
903
- workerIndex,
904
- stop: async () => {
905
- logger4.info(`Stopping worker [w${workerIndex}]...`);
906
- workerInstance.running = false;
907
- await workerInstance.promise;
908
- },
909
- respawn: async () => {
910
- logger4.info(`Respawning worker [w${workerIndex}]...`);
911
- workerInstance.running = false;
912
- await this.startANewWorker(config, workersInstance, workerIndex);
913
- }
914
- };
915
- const workerPromise = this.startWorker(config, workersInstance, workerInstance);
916
- workerInstance.promise = workerPromise;
917
- workersInstance.workers[workerIndex] = workerInstance;
918
- }
919
- async runWorkers(config, workersInstance) {
1103
+ async runScheduler(config, workersInstance) {
1104
+ logger4.debug("Will ensure maxTriesReached retention and TTL index");
1105
+ void this.jobsRepo.ensureMaxTriesReachedRetention(config.maxTriesReachedRetentionMs).catch((error) => logger4.error("Error ensuring maxTriesReached retention", { error }));
920
1106
  logger4.debug("Will ensure records for recurrent jobs");
921
1107
  await this.ensureRecords(config);
922
- const workersCount = config.workersCount;
923
- const workerWord = workersCount === 1 ? "worker" : "workers";
924
- logger4.info(`Starting ${workersCount} ${workerWord}`);
925
- for (let workerIndex = 0; workerIndex < workersCount; workerIndex++) {
926
- this.startANewWorker(config, workersInstance, workerIndex);
927
- }
1108
+ logger4.info(`Starting job scheduler with concurrency ${config.workersCount}`);
1109
+ await this.runSchedulerLoop(config, workersInstance);
928
1110
  }
929
1111
  /**
930
- * Starts the job workers with the provided configuration.
931
- * @param userConfig - Configuration for the workers. Required field: jobs
1112
+ * Starts one job scheduler with the provided concurrency configuration.
1113
+ * @param userConfig - Configuration for the scheduler. Required field: jobs
932
1114
  */
933
1115
  startWorkers(userConfig) {
1116
+ const maxTriesReachedRetentionMs = userConfig.maxTriesReachedRetentionMs === void 0 ? DEFAULT_MAX_TRIES_REACHED_RETENTION_MS : userConfig.maxTriesReachedRetentionMs;
1117
+ if (maxTriesReachedRetentionMs !== null && (!Number.isFinite(maxTriesReachedRetentionMs) || maxTriesReachedRetentionMs < 0)) {
1118
+ throw new Error("maxTriesReachedRetentionMs must be null, zero, or a positive number");
1119
+ }
934
1120
  const config = {
935
- cooldownPeriod: 100,
936
1121
  pollInterval: 3e3,
937
1122
  workersCount: 4,
938
1123
  defaultLockTime: 30 * 1e3,
939
- ...userConfig
1124
+ ...userConfig,
1125
+ maxTriesReachedRetentionMs
940
1126
  };
941
1127
  setNameToJobs(config.jobs);
942
1128
  const workersInstance = this.createWorkersInstanceDefinition(config);
943
- logger4.debug("Starting workers", config);
944
- this.runWorkers(config, workersInstance);
1129
+ logger4.debug("Starting job scheduler", config);
1130
+ workersInstance.schedulerPromise = this.runScheduler(config, workersInstance).catch((error) => {
1131
+ workersInstance.running = false;
1132
+ logger4.error("Job scheduler stopped after an unexpected error.", { error });
1133
+ });
945
1134
  return workersInstance;
946
1135
  }
947
1136
  };
@@ -1107,6 +1296,7 @@ var scheduleJobs = (jobs) => {
1107
1296
  return eventsService.scheduleJobs(jobs);
1108
1297
  };
1109
1298
  export {
1299
+ DEFAULT_MAX_TRIES_REACHED_RETENTION_MS,
1110
1300
  EventJob,
1111
1301
  HistoryRecordSchema,
1112
1302
  JobRecordSchema,