@orion-js/dogs 4.5.1 → 4.6.2

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,6 +71,11 @@ 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
  /**
@@ -93,6 +98,35 @@ var JobRecordSchema = schemaWithName("JobRecord", {
93
98
  var DEFAULT_MAX_TRIES_REACHED_RETENTION_MS = 1e3 * 60 * 60 * 24 * 7;
94
99
 
95
100
  // src/repos/JobsRepo.ts
101
+ var JOB_ACQUISITION_HINTS = {
102
+ byJobName: {
103
+ jobName: 1,
104
+ priority: -1,
105
+ nextRunAt: 1
106
+ },
107
+ globalPriority: {
108
+ priority: -1,
109
+ nextRunAt: 1
110
+ }
111
+ };
112
+ var INITIAL_JOB_ACQUISITION_HINT = "byJobName";
113
+ var CANDIDATE_JOB_ACQUISITION_HINT = "globalPriority";
114
+ var JOB_ACQUISITION_SORT = {
115
+ priority: -1,
116
+ nextRunAt: 1
117
+ };
118
+ function getJobAcquisitionSelector(jobNames, now) {
119
+ return {
120
+ jobName: { $in: jobNames },
121
+ nextRunAt: { $lte: now },
122
+ $and: [
123
+ { $or: [{ lockedUntil: { $exists: false } }, { lockedUntil: { $lte: now } }] },
124
+ // Exclude event jobs that have reached max tries. Recurrent jobs keep running even
125
+ // if old records still have this status from previous versions.
126
+ { $or: [{ type: { $ne: "event" } }, { status: { $ne: "maxTriesReached" } }] }
127
+ ]
128
+ };
129
+ }
96
130
  var _jobs_dec, _JobsRepo_decorators, _init;
97
131
  _JobsRepo_decorators = [Repository()], _jobs_dec = [MongoCollection({
98
132
  idGeneration: "uuid",
@@ -100,11 +134,10 @@ _JobsRepo_decorators = [Repository()], _jobs_dec = [MongoCollection({
100
134
  schema: JobRecordSchema,
101
135
  indexes: [
102
136
  {
103
- keys: {
104
- jobName: 1,
105
- priority: -1,
106
- nextRunAt: 1
107
- }
137
+ keys: JOB_ACQUISITION_HINTS.byJobName
138
+ },
139
+ {
140
+ keys: JOB_ACQUISITION_HINTS.globalPriority
108
141
  },
109
142
  {
110
143
  keys: {
@@ -137,29 +170,20 @@ var JobsRepo = class {
137
170
  constructor() {
138
171
  __publicField(this, "jobs", __runInitializers(_init, 8, this)), __runInitializers(_init, 11, this);
139
172
  }
140
- async getJobAndLock(jobNames, lockTime) {
141
- const lockedUntil = new Date(Date.now() + lockTime);
173
+ async getJobAndLock(jobNames, lockTime, hint = INITIAL_JOB_ACQUISITION_HINT) {
174
+ const executionId = generateId();
175
+ const now = /* @__PURE__ */ new Date();
176
+ const lockedUntil = new Date(now.getTime() + lockTime);
142
177
  const job = await this.jobs.findOneAndUpdate(
178
+ getJobAcquisitionSelector(jobNames, now),
143
179
  {
144
- jobName: { $in: jobNames },
145
- nextRunAt: { $lte: /* @__PURE__ */ new Date() },
146
- $and: [
147
- { $or: [{ lockedUntil: { $exists: false } }, { lockedUntil: { $lte: /* @__PURE__ */ new Date() } }] },
148
- // Exclude event jobs that have reached max tries. Recurrent jobs keep running even
149
- // if old records still have this status from previous versions.
150
- { $or: [{ type: { $ne: "event" } }, { status: { $ne: "maxTriesReached" } }] }
151
- ]
152
- },
153
- {
154
- $set: { lockedUntil, lastRunAt: /* @__PURE__ */ new Date() },
180
+ $set: { lockId: executionId, lockedUntil, lastRunAt: now },
155
181
  $inc: { tries: 1 }
156
182
  },
157
183
  {
158
184
  mongoOptions: {
159
- sort: {
160
- priority: -1,
161
- nextRunAt: 1
162
- },
185
+ hint: JOB_ACQUISITION_HINTS[hint],
186
+ sort: JOB_ACQUISITION_SORT,
163
187
  returnDocument: "before"
164
188
  }
165
189
  }
@@ -172,7 +196,8 @@ var JobsRepo = class {
172
196
  }
173
197
  return {
174
198
  jobId: job._id,
175
- executionId: generateId(),
199
+ executionId,
200
+ lockId: executionId,
176
201
  name: job.jobName,
177
202
  params: job.params,
178
203
  type: job.type,
@@ -183,8 +208,24 @@ var JobsRepo = class {
183
208
  wasStale
184
209
  };
185
210
  }
186
- async setJobRecordPriority(jobId, priority) {
187
- await this.jobs.updateOne(jobId, { $set: { priority } });
211
+ async getJobAcquisitionExecutionTime(jobNames, hint) {
212
+ var _a;
213
+ const explain = await this.jobs.find(getJobAcquisitionSelector(jobNames, /* @__PURE__ */ new Date()), {
214
+ hint: JOB_ACQUISITION_HINTS[hint],
215
+ maxTimeMS: 1e3,
216
+ readPreference: "primary"
217
+ }).sort(JOB_ACQUISITION_SORT).limit(1).explain("executionStats");
218
+ const executionTimeMillis = (_a = explain.executionStats) == null ? void 0 : _a.executionTimeMillis;
219
+ if (typeof executionTimeMillis !== "number" || !Number.isFinite(executionTimeMillis)) {
220
+ throw new Error(`Explain for acquisition hint "${hint}" returned no execution time`);
221
+ }
222
+ return executionTimeMillis;
223
+ }
224
+ async setJobRecordPriority(jobId, priority, lockId) {
225
+ const result = await this.jobs.updateOne(lockId ? { _id: jobId, lockId } : jobId, {
226
+ $set: { priority }
227
+ });
228
+ return result.matchedCount > 0;
188
229
  }
189
230
  async scheduleNextRun(options) {
190
231
  const updator = {
@@ -193,22 +234,31 @@ var JobsRepo = class {
193
234
  priority: options.priority,
194
235
  ...options.resetTries ? { tries: 0 } : {}
195
236
  },
196
- $unset: { lockedUntil: "" }
237
+ $unset: { lockId: "", lockedUntil: "" }
197
238
  };
198
- await this.jobs.updateOne(options.jobId, updator);
239
+ const result = await this.jobs.updateOne(
240
+ options.lockId ? { _id: options.jobId, lockId: options.lockId } : options.jobId,
241
+ updator
242
+ );
243
+ return result.matchedCount > 0;
199
244
  }
200
- async deleteEventJob(jobId) {
201
- await this.jobs.deleteOne({ _id: jobId, type: "event" });
245
+ async deleteEventJob(jobId, lockId) {
246
+ const result = await this.jobs.deleteOne({
247
+ _id: jobId,
248
+ type: "event",
249
+ ...lockId ? { lockId } : {}
250
+ });
251
+ return result.deletedCount > 0;
202
252
  }
203
253
  /**
204
254
  * Marks an event job as having reached its maximum tries limit.
205
255
  * The job won't be picked up for execution and MongoDB will delete it after
206
256
  * the configured retention period.
207
257
  */
208
- async markJobAsMaxTriesReached(jobId, retentionMs = DEFAULT_MAX_TRIES_REACHED_RETENTION_MS) {
258
+ async markJobAsMaxTriesReached(jobId, retentionMs = DEFAULT_MAX_TRIES_REACHED_RETENTION_MS, lockId) {
209
259
  const maxTriesReachedAt = /* @__PURE__ */ new Date();
210
- await this.jobs.updateOne(
211
- { _id: jobId, type: "event" },
260
+ const result = await this.jobs.updateOne(
261
+ { _id: jobId, type: "event", ...lockId ? { lockId } : {} },
212
262
  {
213
263
  $set: {
214
264
  status: "maxTriesReached",
@@ -216,11 +266,13 @@ var JobsRepo = class {
216
266
  ...retentionMs === null ? {} : { expiresAt: new Date(maxTriesReachedAt.getTime() + retentionMs) }
217
267
  },
218
268
  $unset: {
269
+ lockId: "",
219
270
  lockedUntil: "",
220
271
  ...retentionMs === null ? { expiresAt: "" } : {}
221
272
  }
222
273
  }
223
274
  );
275
+ return result.matchedCount > 0;
224
276
  }
225
277
  /**
226
278
  * Applies the current retention configuration to existing maxTriesReached
@@ -249,23 +301,22 @@ var JobsRepo = class {
249
301
  }
250
302
  ]);
251
303
  }
252
- async extendLockTime(jobId, extraTime) {
253
- await this.updateLockTime(jobId, extraTime);
304
+ async extendLockTime(jobId, extraTime, lockId) {
305
+ return this.updateLockTime(jobId, extraTime, lockId);
254
306
  }
255
307
  /**
256
308
  * Updates the lock time for a job to the specified duration from now.
257
309
  * Can be used to both extend or shorten the lock time.
258
310
  */
259
- async updateLockTime(jobId, lockDuration) {
311
+ async updateLockTime(jobId, lockDuration, lockId) {
260
312
  const lockedUntil = new Date(Date.now() + lockDuration);
261
- await this.jobs.updateOne(
262
- {
263
- _id: jobId
264
- },
313
+ const result = await this.jobs.updateOne(
314
+ { _id: jobId, ...lockId ? { lockId } : {} },
265
315
  {
266
316
  $set: { lockedUntil }
267
317
  }
268
318
  );
319
+ return result.matchedCount > 0;
269
320
  }
270
321
  async unlockAllJobs() {
271
322
  const result = await this.jobs.updateMany(
@@ -273,7 +324,7 @@ var JobsRepo = class {
273
324
  lockedUntil: { $exists: true }
274
325
  },
275
326
  {
276
- $unset: { lockedUntil: "" }
327
+ $unset: { lockId: "", lockedUntil: "" }
277
328
  }
278
329
  );
279
330
  return result.modifiedCount;
@@ -579,11 +630,15 @@ var Executor = class {
579
630
  getEffectiveLockTime(job, jobToRun) {
580
631
  return job.lockTime ?? jobToRun.lockTime;
581
632
  }
582
- getContext(job, jobToRun, onStale) {
633
+ getContext(job, jobToRun, onStale, isExecutionActive) {
583
634
  var _a;
584
635
  const effectiveLockTime = this.getEffectiveLockTime(job, jobToRun);
585
636
  let staleTimeout = setTimeout(() => onStale(), effectiveLockTime);
586
637
  (_a = staleTimeout.unref) == null ? void 0 : _a.call(staleTimeout);
638
+ const contextLogger = logger3.addMetadata({
639
+ jobName: jobToRun.name,
640
+ jobId: jobToRun.jobId
641
+ });
587
642
  return {
588
643
  definition: job,
589
644
  record: jobToRun,
@@ -591,15 +646,26 @@ var Executor = class {
591
646
  clearStaleTimeout: () => clearTimeout(staleTimeout),
592
647
  extendLockTime: async (extraTime) => {
593
648
  var _a2;
649
+ if (!isExecutionActive()) {
650
+ contextLogger.warn(`Will not extend lock for stale execution "${jobToRun.executionId}"`);
651
+ return;
652
+ }
594
653
  clearTimeout(staleTimeout);
654
+ let didExtend = false;
655
+ try {
656
+ didExtend = await this.jobsRepo.extendLockTime(jobToRun.jobId, extraTime, jobToRun.lockId);
657
+ } catch (error) {
658
+ onStale();
659
+ throw error;
660
+ }
661
+ if (!didExtend) {
662
+ onStale();
663
+ return;
664
+ }
595
665
  staleTimeout = setTimeout(() => onStale(), extraTime);
596
666
  (_a2 = staleTimeout.unref) == null ? void 0 : _a2.call(staleTimeout);
597
- await this.jobsRepo.extendLockTime(jobToRun.jobId, extraTime);
598
667
  },
599
- logger: logger3.addMetadata({
600
- jobName: jobToRun.name,
601
- jobId: jobToRun.jobId
602
- })
668
+ logger: contextLogger
603
669
  };
604
670
  }
605
671
  getJobDefinition(jobToRun, jobs) {
@@ -631,8 +697,16 @@ var Executor = class {
631
697
  jobLogger.warn(
632
698
  `Job "${jobToRun.name}" has exceeded max tries (${jobToRun.tries}). Marking as maxTriesReached.`
633
699
  );
634
- await this.jobsRepo.markJobAsMaxTriesReached(jobToRun.jobId, retentionMs);
635
- if (!onMaxTriesReached) return;
700
+ const didMark = await this.jobsRepo.markJobAsMaxTriesReached(
701
+ jobToRun.jobId,
702
+ retentionMs,
703
+ jobToRun.lockId
704
+ );
705
+ if (!didMark) {
706
+ jobLogger.warn(`Could not mark job "${jobToRun.name}" because its lock is no longer owned`);
707
+ return false;
708
+ }
709
+ if (!onMaxTriesReached) return true;
636
710
  try {
637
711
  await onMaxTriesReached(jobToRun);
638
712
  } catch (callbackError) {
@@ -640,30 +714,33 @@ var Executor = class {
640
714
  error: callbackError
641
715
  });
642
716
  }
717
+ return true;
643
718
  }
644
719
  async onError(error, job, jobToRun, context) {
645
720
  const scheduleRecurrent = async () => {
646
721
  if (job.type === "recurrent") {
647
- await this.jobsRepo.scheduleNextRun({
722
+ return this.jobsRepo.scheduleNextRun({
648
723
  jobId: jobToRun.jobId,
649
724
  nextRunAt: getNextRunDate(job),
650
725
  resetTries: true,
651
- priority: job.priority
726
+ priority: job.priority,
727
+ lockId: jobToRun.lockId
652
728
  });
653
729
  }
730
+ return true;
654
731
  };
655
732
  const handleRetry = async (nextRunAt) => {
656
- await this.jobsRepo.scheduleNextRun({
733
+ return this.jobsRepo.scheduleNextRun({
657
734
  jobId: jobToRun.jobId,
658
735
  nextRunAt,
659
736
  resetTries: false,
660
- priority: job.type === "recurrent" ? job.priority : jobToRun.priority
737
+ priority: job.type === "recurrent" ? job.priority : jobToRun.priority,
738
+ lockId: jobToRun.lockId
661
739
  });
662
740
  };
663
741
  if (!job.onError) {
664
742
  context.logger.error(`Error executing job "${jobToRun.name}"`, { error });
665
- await scheduleRecurrent();
666
- return;
743
+ return scheduleRecurrent();
667
744
  }
668
745
  context.logger.info(`Error executing job "${jobToRun.name}"`, { error });
669
746
  const result = await job.onError(
@@ -672,12 +749,12 @@ var Executor = class {
672
749
  context
673
750
  );
674
751
  if (result.action === "dismiss") {
675
- await scheduleRecurrent();
676
- return;
752
+ return scheduleRecurrent();
677
753
  }
678
754
  if (result.action === "retry") {
679
- await handleRetry(getNextRunDate(result));
755
+ return handleRetry(getNextRunDate(result));
680
756
  }
757
+ return true;
681
758
  }
682
759
  async saveExecution(options) {
683
760
  const { startedAt, status, errorMessage, result, job, jobToRun } = options;
@@ -707,19 +784,21 @@ var Executor = class {
707
784
  async afterExecutionSuccess(job, jobToRun, context) {
708
785
  if (job.type === "recurrent") {
709
786
  context.logger.debug(`Scheduling next run for recurrent job "${jobToRun.name}"`);
710
- await this.jobsRepo.scheduleNextRun({
787
+ return this.jobsRepo.scheduleNextRun({
711
788
  jobId: jobToRun.jobId,
712
789
  nextRunAt: getNextRunDate(job),
713
790
  resetTries: true,
714
- priority: job.priority
791
+ priority: job.priority,
792
+ lockId: jobToRun.lockId
715
793
  });
716
794
  }
717
795
  if (job.type === "event") {
718
796
  context.logger.debug(`Removing event job after success "${jobToRun.name}"`);
719
- await this.jobsRepo.deleteEventJob(jobToRun.jobId);
797
+ return this.jobsRepo.deleteEventJob(jobToRun.jobId, jobToRun.lockId);
720
798
  }
799
+ return false;
721
800
  }
722
- async executeJob(config, jobToRun, respawnWorker) {
801
+ async executeJob(config, jobToRun, onExecutionStale) {
723
802
  const job = this.getJobDefinition(jobToRun, config.jobs);
724
803
  if (!job) return;
725
804
  if (job.type === "event") {
@@ -735,33 +814,64 @@ var Executor = class {
735
814
  }
736
815
  const effectiveLockTime = this.getEffectiveLockTime(job, jobToRun);
737
816
  if (effectiveLockTime !== jobToRun.lockTime) {
738
- await this.jobsRepo.updateLockTime(jobToRun.jobId, effectiveLockTime);
817
+ const didUpdateLock = await this.jobsRepo.updateLockTime(
818
+ jobToRun.jobId,
819
+ effectiveLockTime,
820
+ jobToRun.lockId
821
+ );
822
+ if (!didUpdateLock) {
823
+ logger3.warn(`Will not execute job "${jobToRun.name}" because its lock is no longer owned`);
824
+ return;
825
+ }
739
826
  }
740
827
  const tracer = trace.getTracer("orionjs.dogs", "1.0");
741
828
  await tracer.startActiveSpan(`job.${jobToRun.name}.${jobToRun.executionId}`, async (span) => {
742
829
  try {
743
830
  const startedAt = /* @__PURE__ */ new Date();
744
- const onStale = async () => {
745
- if (job.onStale) {
746
- context.logger.info(`Job "${jobToRun.name}" is stale`);
747
- job.onStale(jobToRun.params, context);
748
- } else {
749
- context.logger.error(`Job "${jobToRun.name}" is stale`);
750
- }
751
- await this.jobsRepo.setJobRecordPriority(jobToRun.jobId, 0);
752
- void respawnWorker();
753
- void this.saveExecution({
754
- startedAt,
755
- status: "stale",
756
- result: null,
757
- errorMessage: null,
758
- job,
759
- jobToRun
760
- }).catch((error) => {
761
- context.logger.error("Error saving stale execution history", { error });
831
+ let executionStatus = "running";
832
+ let staleHandlingPromise;
833
+ let context;
834
+ const markAsStale = () => {
835
+ if (executionStatus === "stale") return staleHandlingPromise;
836
+ executionStatus = "stale";
837
+ context.clearStaleTimeout();
838
+ staleHandlingPromise = (async () => {
839
+ context.logger.warn(`Job "${jobToRun.name}" is stale`);
840
+ if (job.onStale) {
841
+ void Promise.resolve(job.onStale(jobToRun.params, context)).catch((error) => {
842
+ context.logger.error(`Error in onStale callback for job "${jobToRun.name}"`, {
843
+ error
844
+ });
845
+ });
846
+ }
847
+ try {
848
+ await this.jobsRepo.setJobRecordPriority(jobToRun.jobId, 0, jobToRun.lockId);
849
+ } catch (error) {
850
+ context.logger.error(`Error lowering priority for stale job "${jobToRun.name}"`, {
851
+ error
852
+ });
853
+ } finally {
854
+ onExecutionStale();
855
+ }
856
+ await this.saveExecution({
857
+ startedAt,
858
+ status: "stale",
859
+ result: null,
860
+ errorMessage: null,
861
+ job,
862
+ jobToRun
863
+ });
864
+ })().catch((error) => {
865
+ context.logger.error("Error handling stale execution", { error });
762
866
  });
867
+ return staleHandlingPromise;
763
868
  };
764
- const context = this.getContext(job, jobToRun, onStale);
869
+ context = this.getContext(
870
+ job,
871
+ jobToRun,
872
+ () => void markAsStale(),
873
+ () => executionStatus === "running"
874
+ );
765
875
  const extraContext = {
766
876
  controllerType: "job",
767
877
  jobName: jobToRun.name,
@@ -777,6 +887,18 @@ var Executor = class {
777
887
  });
778
888
  const result = await job.resolve(jobToRun.params, context);
779
889
  context.clearStaleTimeout();
890
+ if (executionStatus === "stale") {
891
+ await staleHandlingPromise;
892
+ context.logger.warn(
893
+ `Ignoring late success from stale execution "${jobToRun.executionId}"`
894
+ );
895
+ return;
896
+ }
897
+ const stillOwnsJob = await this.afterExecutionSuccess(job, jobToRun, context);
898
+ if (!stillOwnsJob) {
899
+ await markAsStale();
900
+ return;
901
+ }
780
902
  void this.saveExecution({
781
903
  startedAt,
782
904
  status: "success",
@@ -787,9 +909,21 @@ var Executor = class {
787
909
  }).catch((error) => {
788
910
  context.logger.error("Error saving successful execution history", { error });
789
911
  });
790
- await this.afterExecutionSuccess(job, jobToRun, context);
791
912
  } catch (error) {
792
913
  context.clearStaleTimeout();
914
+ if (executionStatus === "stale") {
915
+ await staleHandlingPromise;
916
+ context.logger.warn(
917
+ `Ignoring late error from stale execution "${jobToRun.executionId}"`,
918
+ { error }
919
+ );
920
+ return;
921
+ }
922
+ const stillOwnsJob = await this.onError(error, job, jobToRun, context);
923
+ if (!stillOwnsJob) {
924
+ await markAsStale();
925
+ return;
926
+ }
793
927
  void this.saveExecution({
794
928
  startedAt,
795
929
  status: "error",
@@ -800,7 +934,6 @@ var Executor = class {
800
934
  }).catch((saveError) => {
801
935
  context.logger.error("Error saving failed execution history", { error: saveError });
802
936
  });
803
- await this.onError(error, job, jobToRun, context);
804
937
  }
805
938
  });
806
939
  } catch (error) {
@@ -826,6 +959,7 @@ var _executor_dec, _jobsRepo_dec3, _WorkerService_decorators, _init5;
826
959
  _WorkerService_decorators = [Service3()], _jobsRepo_dec3 = [Inject3(() => JobsRepo)], _executor_dec = [Inject3(() => Executor)];
827
960
  var WorkerService = class {
828
961
  constructor() {
962
+ __publicField(this, "jobAcquisitionHintProbeIntervalMs", 30 * 60 * 1e3);
829
963
  __publicField(this, "jobsRepo", __runInitializers(_init5, 8, this)), __runInitializers(_init5, 11, this);
830
964
  __publicField(this, "executor", __runInitializers(_init5, 12, this)), __runInitializers(_init5, 15, this);
831
965
  }
@@ -860,87 +994,242 @@ var WorkerService = class {
860
994
  }
861
995
  workersInstance.runningJobsByName.set(jobName, currentExecutions - 1);
862
996
  }
863
- async withJobAcquisitionLock(workersInstance, callback) {
864
- const previousLock = workersInstance.jobAcquisitionLock;
865
- let releaseLock;
866
- workersInstance.jobAcquisitionLock = new Promise((resolve) => {
867
- releaseLock = resolve;
868
- });
869
- await previousLock;
870
- try {
871
- return await callback();
872
- } finally {
873
- releaseLock();
997
+ releaseExecutionCapacity(workersInstance, execution, nextStatus) {
998
+ if (execution.status !== "running") return;
999
+ execution.status = nextStatus;
1000
+ workersInstance.activeExecutions.delete(execution.job.executionId);
1001
+ this.releaseJobExecution(workersInstance, execution.job.name);
1002
+ execution.releaseCapacity();
1003
+ if (nextStatus === "stale") {
1004
+ workersInstance.staleExecutions.set(execution.job.executionId, execution);
874
1005
  }
875
1006
  }
876
- async getJobAndReserveExecution(config, workersInstance, jobNames) {
877
- return this.withJobAcquisitionLock(workersInstance, async () => {
878
- const availableJobNames = this.getAvailableJobNames(config, workersInstance, jobNames);
879
- if (availableJobNames.length === 0) return;
880
- const jobToRun = await this.jobsRepo.getJobAndLock(availableJobNames, config.defaultLockTime);
881
- if (!jobToRun) return;
882
- this.reserveJobExecution(workersInstance, jobToRun.name);
883
- return jobToRun;
1007
+ startExecution(config, workersInstance, jobToRun) {
1008
+ let releaseCapacity;
1009
+ const capacityPromise = new Promise((resolve) => {
1010
+ releaseCapacity = resolve;
1011
+ });
1012
+ const execution = {
1013
+ job: jobToRun,
1014
+ promise: Promise.resolve(),
1015
+ capacityPromise,
1016
+ releaseCapacity,
1017
+ status: "running"
1018
+ };
1019
+ this.reserveJobExecution(workersInstance, jobToRun.name);
1020
+ workersInstance.activeExecutions.set(jobToRun.executionId, execution);
1021
+ logger4.debug(`Starting job execution "${jobToRun.executionId}"`, jobToRun);
1022
+ const executionPromise = this.executor.executeJob(config, jobToRun, () => {
1023
+ this.releaseExecutionCapacity(workersInstance, execution, "stale");
1024
+ });
1025
+ execution.promise = executionPromise;
1026
+ void executionPromise.catch((error) => {
1027
+ logger4.error(`Unhandled error executing job "${jobToRun.name}"`, { error });
1028
+ }).finally(() => {
1029
+ if (execution.status === "running") {
1030
+ this.releaseExecutionCapacity(workersInstance, execution, "finished");
1031
+ } else if (execution.status === "stale") {
1032
+ workersInstance.staleExecutions.delete(jobToRun.executionId);
1033
+ }
884
1034
  });
885
1035
  }
886
- async runWorkerLoop(config, workersInstance, workerInstance, jobNames, executeConfig) {
887
- const jobToRun = await this.getJobAndReserveExecution(config, workersInstance, jobNames);
888
- if (!jobToRun) {
889
- logger4.debug("No job to run");
890
- return false;
891
- }
892
- logger4.debug(`Got job [w${workerInstance.workerIndex}] to run:`, jobToRun);
893
- try {
894
- await this.executor.executeJob(executeConfig, jobToRun, workerInstance.respawn);
895
- } finally {
896
- this.releaseJobExecution(workersInstance, jobToRun.name);
897
- }
898
- return true;
1036
+ async waitForPollOrStop(config, workersInstance) {
1037
+ await Promise.race([sleep(config.pollInterval), workersInstance.stopSignal]);
899
1038
  }
900
- async startWorker(config, workersInstance, workerInstance) {
901
- const names = this.getJobNames(config.jobs);
902
- logger4.debug(
903
- `Running worker loop [w${workerInstance.workerIndex}] for jobs "${names.join(", ")}"...`
1039
+ async waitForCapacityOrStop(workersInstance) {
1040
+ const capacityPromises = Array.from(
1041
+ workersInstance.activeExecutions.values(),
1042
+ (execution) => execution.capacityPromise
904
1043
  );
1044
+ if (capacityPromises.length === 0) return;
1045
+ await Promise.race([...capacityPromises, workersInstance.stopSignal]);
1046
+ }
1047
+ async runSchedulerLoop(config, workersInstance) {
1048
+ const jobNames = this.getJobNames(config.jobs);
905
1049
  const executeConfig = {
906
1050
  jobs: config.jobs,
907
1051
  maxTries: config.maxTries,
908
1052
  maxTriesReachedRetentionMs: config.maxTriesReachedRetentionMs,
909
1053
  onMaxTriesReached: config.onMaxTriesReached
910
1054
  };
911
- while (true) {
912
- if (!workerInstance.running) {
913
- logger4.info(`Got signal to stop. Stopping worker [w${workerInstance.workerIndex}]...`);
914
- return;
915
- }
1055
+ logger4.debug(`Running scheduler loop for jobs "${jobNames.join(", ")}"...`);
1056
+ while (workersInstance.running) {
1057
+ let didMiss = false;
1058
+ let allJobNamesAtCapacity = false;
916
1059
  try {
917
- const didRun = await this.runWorkerLoop(
918
- config,
919
- workersInstance,
920
- workerInstance,
921
- names,
922
- executeConfig
923
- );
924
- if (!didRun) await sleep(config.pollInterval);
925
- if (didRun) await sleep(config.cooldownPeriod);
1060
+ while (workersInstance.activeExecutions.size < config.workersCount) {
1061
+ const availableJobNames = this.getAvailableJobNames(config, workersInstance, jobNames);
1062
+ if (availableJobNames.length === 0) {
1063
+ allJobNamesAtCapacity = workersInstance.activeExecutions.size > 0;
1064
+ break;
1065
+ }
1066
+ const jobToRun = await this.jobsRepo.getJobAndLock(
1067
+ availableJobNames,
1068
+ config.defaultLockTime,
1069
+ workersInstance.jobAcquisitionHint
1070
+ );
1071
+ if (!jobToRun) {
1072
+ logger4.debug("No job to run");
1073
+ didMiss = true;
1074
+ break;
1075
+ }
1076
+ this.startExecution(executeConfig, workersInstance, jobToRun);
1077
+ if (!workersInstance.running) break;
1078
+ }
926
1079
  } catch (error) {
927
- logger4.error("Error in job runner.", { error });
928
- await sleep(config.pollInterval);
1080
+ logger4.error("Error in job scheduler.", { error });
1081
+ didMiss = true;
1082
+ }
1083
+ if (!workersInstance.running) return;
1084
+ const concurrencyIsFull = workersInstance.activeExecutions.size >= config.workersCount && workersInstance.activeExecutions.size > 0;
1085
+ if (concurrencyIsFull || allJobNamesAtCapacity) {
1086
+ await this.waitForCapacityOrStop(workersInstance);
1087
+ } else if (didMiss || workersInstance.activeExecutions.size === 0) {
1088
+ await this.waitForPollOrStop(config, workersInstance);
1089
+ }
1090
+ }
1091
+ }
1092
+ getOtherJobAcquisitionHint(hint) {
1093
+ return hint === INITIAL_JOB_ACQUISITION_HINT ? CANDIDATE_JOB_ACQUISITION_HINT : INITIAL_JOB_ACQUISITION_HINT;
1094
+ }
1095
+ getMedian(values) {
1096
+ const sortedValues = [...values].sort((left, right) => left - right);
1097
+ return sortedValues[Math.floor(sortedValues.length / 2)];
1098
+ }
1099
+ resetPendingJobAcquisitionHint(workersInstance) {
1100
+ workersInstance.pendingJobAcquisitionHint = void 0;
1101
+ workersInstance.pendingJobAcquisitionHintWins = 0;
1102
+ }
1103
+ async runJobAcquisitionHintProbe(config, workersInstance) {
1104
+ const jobNames = this.getJobNames(config.jobs);
1105
+ if (jobNames.length === 0 || !workersInstance.running) return;
1106
+ const byHint = {
1107
+ [INITIAL_JOB_ACQUISITION_HINT]: [],
1108
+ [CANDIDATE_JOB_ACQUISITION_HINT]: []
1109
+ };
1110
+ const startsWithInitialHint = workersInstance.jobAcquisitionHintProbeCycle % 2 === 0;
1111
+ const firstHint = startsWithInitialHint ? INITIAL_JOB_ACQUISITION_HINT : CANDIDATE_JOB_ACQUISITION_HINT;
1112
+ const secondHint = this.getOtherJobAcquisitionHint(firstHint);
1113
+ workersInstance.jobAcquisitionHintProbeCycle++;
1114
+ try {
1115
+ for (let sample = 0; sample < 3; sample++) {
1116
+ for (const hint of [firstHint, secondHint]) {
1117
+ if (!workersInstance.running) return;
1118
+ const executionTimeMillis = await this.jobsRepo.getJobAcquisitionExecutionTime(
1119
+ jobNames,
1120
+ hint
1121
+ );
1122
+ if (!workersInstance.running) return;
1123
+ byHint[hint].push(executionTimeMillis);
1124
+ }
929
1125
  }
1126
+ } catch (error) {
1127
+ this.resetPendingJobAcquisitionHint(workersInstance);
1128
+ if (workersInstance.running) {
1129
+ logger4.warn("Job acquisition hint probe failed; keeping the current hint.", { error });
1130
+ }
1131
+ return;
1132
+ }
1133
+ const currentHint = workersInstance.jobAcquisitionHint;
1134
+ const otherHint = this.getOtherJobAcquisitionHint(currentHint);
1135
+ const currentMedian = this.getMedian(byHint[currentHint]);
1136
+ const otherMedian = this.getMedian(byHint[otherHint]);
1137
+ const winner = otherMedian < currentMedian ? otherHint : currentHint;
1138
+ if (!workersInstance.hasCompletedInitialJobAcquisitionHintProbe) {
1139
+ workersInstance.hasCompletedInitialJobAcquisitionHintProbe = true;
1140
+ workersInstance.jobAcquisitionHint = winner;
1141
+ this.resetPendingJobAcquisitionHint(workersInstance);
1142
+ logger4.info(`Selected job acquisition hint "${winner}" after startup probe.`);
1143
+ return;
1144
+ }
1145
+ if (winner === currentHint) {
1146
+ this.resetPendingJobAcquisitionHint(workersInstance);
1147
+ logger4.debug(`Keeping job acquisition hint "${currentHint}" after probe.`);
1148
+ return;
1149
+ }
1150
+ if (workersInstance.pendingJobAcquisitionHint === winner) {
1151
+ workersInstance.pendingJobAcquisitionHintWins++;
1152
+ } else {
1153
+ workersInstance.pendingJobAcquisitionHint = winner;
1154
+ workersInstance.pendingJobAcquisitionHintWins = 1;
1155
+ }
1156
+ if (workersInstance.pendingJobAcquisitionHintWins < 2) {
1157
+ logger4.debug(
1158
+ `Keeping job acquisition hint "${currentHint}"; "${winner}" has one consecutive probe win.`
1159
+ );
1160
+ return;
1161
+ }
1162
+ workersInstance.jobAcquisitionHint = winner;
1163
+ this.resetPendingJobAcquisitionHint(workersInstance);
1164
+ logger4.info(`Changed job acquisition hint from "${currentHint}" to "${winner}".`);
1165
+ }
1166
+ async waitForNextJobAcquisitionHintProbe(workersInstance) {
1167
+ await new Promise((resolve) => {
1168
+ let resolved = false;
1169
+ const finishWait = () => {
1170
+ if (resolved) return;
1171
+ resolved = true;
1172
+ clearTimeout(workersInstance.jobAcquisitionHintProbeTimer);
1173
+ workersInstance.jobAcquisitionHintProbeTimer = void 0;
1174
+ workersInstance.cancelJobAcquisitionHintProbeWait = void 0;
1175
+ resolve();
1176
+ };
1177
+ workersInstance.jobAcquisitionHintProbeTimer = setTimeout(
1178
+ finishWait,
1179
+ this.jobAcquisitionHintProbeIntervalMs
1180
+ );
1181
+ workersInstance.cancelJobAcquisitionHintProbeWait = finishWait;
1182
+ });
1183
+ }
1184
+ async runJobAcquisitionHintProbeLoop(config, workersInstance) {
1185
+ if (this.getJobNames(config.jobs).length === 0) return;
1186
+ while (workersInstance.running) {
1187
+ await this.runJobAcquisitionHintProbe(config, workersInstance);
1188
+ if (!workersInstance.running) return;
1189
+ await this.waitForNextJobAcquisitionHintProbe(workersInstance);
930
1190
  }
931
1191
  }
932
1192
  createWorkersInstanceDefinition(config) {
1193
+ let signalStop;
1194
+ const stopSignal = new Promise((resolve) => {
1195
+ signalStop = resolve;
1196
+ });
933
1197
  const workersInstance = {
934
1198
  running: true,
935
1199
  workersCount: config.workersCount,
936
- workers: [],
1200
+ activeExecutions: /* @__PURE__ */ new Map(),
1201
+ staleExecutions: /* @__PURE__ */ new Map(),
937
1202
  runningJobsByName: /* @__PURE__ */ new Map(),
938
- jobAcquisitionLock: Promise.resolve(),
1203
+ jobAcquisitionHint: INITIAL_JOB_ACQUISITION_HINT,
1204
+ jobAcquisitionHintProbeCycle: 0,
1205
+ hasCompletedInitialJobAcquisitionHintProbe: false,
1206
+ pendingJobAcquisitionHintWins: 0,
1207
+ jobAcquisitionHintProbePromise: Promise.resolve(),
1208
+ schedulerPromise: Promise.resolve(),
1209
+ stopSignal,
1210
+ signalStop,
1211
+ get runningExecutions() {
1212
+ return this.activeExecutions.size;
1213
+ },
939
1214
  stop: async () => {
940
- logger4.info("Stopping workers...");
941
- workersInstance.running = false;
942
- const stopingPromises = workersInstance.workers.map((worker) => worker.stop());
943
- await Promise.all(stopingPromises);
1215
+ if (workersInstance.stopPromise) return workersInstance.stopPromise;
1216
+ workersInstance.stopPromise = (async () => {
1217
+ var _a;
1218
+ logger4.info("Stopping job scheduler...");
1219
+ workersInstance.running = false;
1220
+ workersInstance.signalStop();
1221
+ (_a = workersInstance.cancelJobAcquisitionHintProbeWait) == null ? void 0 : _a.call(workersInstance);
1222
+ await Promise.all([
1223
+ workersInstance.schedulerPromise,
1224
+ workersInstance.jobAcquisitionHintProbePromise
1225
+ ]);
1226
+ const activeCapacity = Array.from(
1227
+ workersInstance.activeExecutions.values(),
1228
+ (execution) => execution.capacityPromise
1229
+ );
1230
+ await Promise.all(activeCapacity);
1231
+ })();
1232
+ return workersInstance.stopPromise;
944
1233
  }
945
1234
  };
946
1235
  return workersInstance;
@@ -954,44 +1243,17 @@ var WorkerService = class {
954
1243
  })
955
1244
  );
956
1245
  }
957
- async startANewWorker(config, workersInstance, workerIndex = workersInstance.workers.length) {
958
- if (!workersInstance.running) {
959
- return;
960
- }
961
- const workerInstance = {
962
- running: true,
963
- workerIndex,
964
- stop: async () => {
965
- logger4.info(`Stopping worker [w${workerIndex}]...`);
966
- workerInstance.running = false;
967
- await workerInstance.promise;
968
- },
969
- respawn: async () => {
970
- logger4.info(`Respawning worker [w${workerIndex}]...`);
971
- workerInstance.running = false;
972
- await this.startANewWorker(config, workersInstance, workerIndex);
973
- }
974
- };
975
- const workerPromise = this.startWorker(config, workersInstance, workerInstance);
976
- workerInstance.promise = workerPromise;
977
- workersInstance.workers[workerIndex] = workerInstance;
978
- }
979
- async runWorkers(config, workersInstance) {
1246
+ async runScheduler(config, workersInstance) {
980
1247
  logger4.debug("Will ensure maxTriesReached retention and TTL index");
981
- const ensureRetentionPromise = this.jobsRepo.ensureMaxTriesReachedRetention(config.maxTriesReachedRetentionMs).catch((error) => logger4.error("Error ensuring maxTriesReached retention", { error }));
1248
+ void this.jobsRepo.ensureMaxTriesReachedRetention(config.maxTriesReachedRetentionMs).catch((error) => logger4.error("Error ensuring maxTriesReached retention", { error }));
982
1249
  logger4.debug("Will ensure records for recurrent jobs");
983
1250
  await this.ensureRecords(config);
984
- const workersCount = config.workersCount;
985
- const workerWord = workersCount === 1 ? "worker" : "workers";
986
- logger4.info(`Starting ${workersCount} ${workerWord}`);
987
- for (let workerIndex = 0; workerIndex < workersCount; workerIndex++) {
988
- this.startANewWorker(config, workersInstance, workerIndex);
989
- }
990
- await ensureRetentionPromise;
1251
+ logger4.info(`Starting job scheduler with concurrency ${config.workersCount}`);
1252
+ await this.runSchedulerLoop(config, workersInstance);
991
1253
  }
992
1254
  /**
993
- * Starts the job workers with the provided configuration.
994
- * @param userConfig - Configuration for the workers. Required field: jobs
1255
+ * Starts one job scheduler with the provided concurrency configuration.
1256
+ * @param userConfig - Configuration for the scheduler. Required field: jobs
995
1257
  */
996
1258
  startWorkers(userConfig) {
997
1259
  const maxTriesReachedRetentionMs = userConfig.maxTriesReachedRetentionMs === void 0 ? DEFAULT_MAX_TRIES_REACHED_RETENTION_MS : userConfig.maxTriesReachedRetentionMs;
@@ -999,7 +1261,6 @@ var WorkerService = class {
999
1261
  throw new Error("maxTriesReachedRetentionMs must be null, zero, or a positive number");
1000
1262
  }
1001
1263
  const config = {
1002
- cooldownPeriod: 100,
1003
1264
  pollInterval: 3e3,
1004
1265
  workersCount: 4,
1005
1266
  defaultLockTime: 30 * 1e3,
@@ -1008,8 +1269,18 @@ var WorkerService = class {
1008
1269
  };
1009
1270
  setNameToJobs(config.jobs);
1010
1271
  const workersInstance = this.createWorkersInstanceDefinition(config);
1011
- logger4.debug("Starting workers", config);
1012
- this.runWorkers(config, workersInstance);
1272
+ logger4.debug("Starting job scheduler", config);
1273
+ workersInstance.jobAcquisitionHintProbePromise = this.runJobAcquisitionHintProbeLoop(
1274
+ config,
1275
+ workersInstance
1276
+ );
1277
+ workersInstance.schedulerPromise = this.runScheduler(config, workersInstance).catch((error) => {
1278
+ var _a;
1279
+ workersInstance.running = false;
1280
+ workersInstance.signalStop();
1281
+ (_a = workersInstance.cancelJobAcquisitionHintProbeWait) == null ? void 0 : _a.call(workersInstance);
1282
+ logger4.error("Job scheduler stopped after an unexpected error.", { error });
1283
+ });
1013
1284
  return workersInstance;
1014
1285
  }
1015
1286
  };