@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.cjs CHANGED
@@ -115,6 +115,11 @@ var JobRecordSchema = (0, import_schema.schemaWithName)("JobRecord", {
115
115
  nextRunAt: { type: "date" },
116
116
  lastRunAt: { type: "date", optional: true },
117
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 },
118
123
  tries: { type: "number", optional: true },
119
124
  params: { type: "blackbox", optional: true },
120
125
  /**
@@ -137,6 +142,35 @@ var JobRecordSchema = (0, import_schema.schemaWithName)("JobRecord", {
137
142
  var DEFAULT_MAX_TRIES_REACHED_RETENTION_MS = 1e3 * 60 * 60 * 24 * 7;
138
143
 
139
144
  // src/repos/JobsRepo.ts
145
+ var JOB_ACQUISITION_HINTS = {
146
+ byJobName: {
147
+ jobName: 1,
148
+ priority: -1,
149
+ nextRunAt: 1
150
+ },
151
+ globalPriority: {
152
+ priority: -1,
153
+ nextRunAt: 1
154
+ }
155
+ };
156
+ var INITIAL_JOB_ACQUISITION_HINT = "byJobName";
157
+ var CANDIDATE_JOB_ACQUISITION_HINT = "globalPriority";
158
+ var JOB_ACQUISITION_SORT = {
159
+ priority: -1,
160
+ nextRunAt: 1
161
+ };
162
+ function getJobAcquisitionSelector(jobNames, now) {
163
+ return {
164
+ jobName: { $in: jobNames },
165
+ nextRunAt: { $lte: now },
166
+ $and: [
167
+ { $or: [{ lockedUntil: { $exists: false } }, { lockedUntil: { $lte: now } }] },
168
+ // Exclude event jobs that have reached max tries. Recurrent jobs keep running even
169
+ // if old records still have this status from previous versions.
170
+ { $or: [{ type: { $ne: "event" } }, { status: { $ne: "maxTriesReached" } }] }
171
+ ]
172
+ };
173
+ }
140
174
  var _jobs_dec, _JobsRepo_decorators, _init;
141
175
  _JobsRepo_decorators = [(0, import_mongodb.Repository)()], _jobs_dec = [(0, import_mongodb.MongoCollection)({
142
176
  idGeneration: "uuid",
@@ -144,11 +178,10 @@ _JobsRepo_decorators = [(0, import_mongodb.Repository)()], _jobs_dec = [(0, impo
144
178
  schema: JobRecordSchema,
145
179
  indexes: [
146
180
  {
147
- keys: {
148
- jobName: 1,
149
- priority: -1,
150
- nextRunAt: 1
151
- }
181
+ keys: JOB_ACQUISITION_HINTS.byJobName
182
+ },
183
+ {
184
+ keys: JOB_ACQUISITION_HINTS.globalPriority
152
185
  },
153
186
  {
154
187
  keys: {
@@ -181,29 +214,20 @@ var JobsRepo = class {
181
214
  constructor() {
182
215
  __publicField(this, "jobs", __runInitializers(_init, 8, this)), __runInitializers(_init, 11, this);
183
216
  }
184
- async getJobAndLock(jobNames, lockTime) {
185
- const lockedUntil = new Date(Date.now() + lockTime);
217
+ async getJobAndLock(jobNames, lockTime, hint = INITIAL_JOB_ACQUISITION_HINT) {
218
+ const executionId = (0, import_helpers.generateId)();
219
+ const now = /* @__PURE__ */ new Date();
220
+ const lockedUntil = new Date(now.getTime() + lockTime);
186
221
  const job = await this.jobs.findOneAndUpdate(
222
+ getJobAcquisitionSelector(jobNames, now),
187
223
  {
188
- jobName: { $in: jobNames },
189
- nextRunAt: { $lte: /* @__PURE__ */ new Date() },
190
- $and: [
191
- { $or: [{ lockedUntil: { $exists: false } }, { lockedUntil: { $lte: /* @__PURE__ */ new Date() } }] },
192
- // Exclude event jobs that have reached max tries. Recurrent jobs keep running even
193
- // if old records still have this status from previous versions.
194
- { $or: [{ type: { $ne: "event" } }, { status: { $ne: "maxTriesReached" } }] }
195
- ]
196
- },
197
- {
198
- $set: { lockedUntil, lastRunAt: /* @__PURE__ */ new Date() },
224
+ $set: { lockId: executionId, lockedUntil, lastRunAt: now },
199
225
  $inc: { tries: 1 }
200
226
  },
201
227
  {
202
228
  mongoOptions: {
203
- sort: {
204
- priority: -1,
205
- nextRunAt: 1
206
- },
229
+ hint: JOB_ACQUISITION_HINTS[hint],
230
+ sort: JOB_ACQUISITION_SORT,
207
231
  returnDocument: "before"
208
232
  }
209
233
  }
@@ -216,7 +240,8 @@ var JobsRepo = class {
216
240
  }
217
241
  return {
218
242
  jobId: job._id,
219
- executionId: (0, import_helpers.generateId)(),
243
+ executionId,
244
+ lockId: executionId,
220
245
  name: job.jobName,
221
246
  params: job.params,
222
247
  type: job.type,
@@ -227,8 +252,24 @@ var JobsRepo = class {
227
252
  wasStale
228
253
  };
229
254
  }
230
- async setJobRecordPriority(jobId, priority) {
231
- await this.jobs.updateOne(jobId, { $set: { priority } });
255
+ async getJobAcquisitionExecutionTime(jobNames, hint) {
256
+ var _a;
257
+ const explain = await this.jobs.find(getJobAcquisitionSelector(jobNames, /* @__PURE__ */ new Date()), {
258
+ hint: JOB_ACQUISITION_HINTS[hint],
259
+ maxTimeMS: 1e3,
260
+ readPreference: "primary"
261
+ }).sort(JOB_ACQUISITION_SORT).limit(1).explain("executionStats");
262
+ const executionTimeMillis = (_a = explain.executionStats) == null ? void 0 : _a.executionTimeMillis;
263
+ if (typeof executionTimeMillis !== "number" || !Number.isFinite(executionTimeMillis)) {
264
+ throw new Error(`Explain for acquisition hint "${hint}" returned no execution time`);
265
+ }
266
+ return executionTimeMillis;
267
+ }
268
+ async setJobRecordPriority(jobId, priority, lockId) {
269
+ const result = await this.jobs.updateOne(lockId ? { _id: jobId, lockId } : jobId, {
270
+ $set: { priority }
271
+ });
272
+ return result.matchedCount > 0;
232
273
  }
233
274
  async scheduleNextRun(options) {
234
275
  const updator = {
@@ -237,22 +278,31 @@ var JobsRepo = class {
237
278
  priority: options.priority,
238
279
  ...options.resetTries ? { tries: 0 } : {}
239
280
  },
240
- $unset: { lockedUntil: "" }
281
+ $unset: { lockId: "", lockedUntil: "" }
241
282
  };
242
- await this.jobs.updateOne(options.jobId, updator);
283
+ const result = await this.jobs.updateOne(
284
+ options.lockId ? { _id: options.jobId, lockId: options.lockId } : options.jobId,
285
+ updator
286
+ );
287
+ return result.matchedCount > 0;
243
288
  }
244
- async deleteEventJob(jobId) {
245
- await this.jobs.deleteOne({ _id: jobId, type: "event" });
289
+ async deleteEventJob(jobId, lockId) {
290
+ const result = await this.jobs.deleteOne({
291
+ _id: jobId,
292
+ type: "event",
293
+ ...lockId ? { lockId } : {}
294
+ });
295
+ return result.deletedCount > 0;
246
296
  }
247
297
  /**
248
298
  * Marks an event job as having reached its maximum tries limit.
249
299
  * The job won't be picked up for execution and MongoDB will delete it after
250
300
  * the configured retention period.
251
301
  */
252
- async markJobAsMaxTriesReached(jobId, retentionMs = DEFAULT_MAX_TRIES_REACHED_RETENTION_MS) {
302
+ async markJobAsMaxTriesReached(jobId, retentionMs = DEFAULT_MAX_TRIES_REACHED_RETENTION_MS, lockId) {
253
303
  const maxTriesReachedAt = /* @__PURE__ */ new Date();
254
- await this.jobs.updateOne(
255
- { _id: jobId, type: "event" },
304
+ const result = await this.jobs.updateOne(
305
+ { _id: jobId, type: "event", ...lockId ? { lockId } : {} },
256
306
  {
257
307
  $set: {
258
308
  status: "maxTriesReached",
@@ -260,11 +310,13 @@ var JobsRepo = class {
260
310
  ...retentionMs === null ? {} : { expiresAt: new Date(maxTriesReachedAt.getTime() + retentionMs) }
261
311
  },
262
312
  $unset: {
313
+ lockId: "",
263
314
  lockedUntil: "",
264
315
  ...retentionMs === null ? { expiresAt: "" } : {}
265
316
  }
266
317
  }
267
318
  );
319
+ return result.matchedCount > 0;
268
320
  }
269
321
  /**
270
322
  * Applies the current retention configuration to existing maxTriesReached
@@ -293,23 +345,22 @@ var JobsRepo = class {
293
345
  }
294
346
  ]);
295
347
  }
296
- async extendLockTime(jobId, extraTime) {
297
- await this.updateLockTime(jobId, extraTime);
348
+ async extendLockTime(jobId, extraTime, lockId) {
349
+ return this.updateLockTime(jobId, extraTime, lockId);
298
350
  }
299
351
  /**
300
352
  * Updates the lock time for a job to the specified duration from now.
301
353
  * Can be used to both extend or shorten the lock time.
302
354
  */
303
- async updateLockTime(jobId, lockDuration) {
355
+ async updateLockTime(jobId, lockDuration, lockId) {
304
356
  const lockedUntil = new Date(Date.now() + lockDuration);
305
- await this.jobs.updateOne(
306
- {
307
- _id: jobId
308
- },
357
+ const result = await this.jobs.updateOne(
358
+ { _id: jobId, ...lockId ? { lockId } : {} },
309
359
  {
310
360
  $set: { lockedUntil }
311
361
  }
312
362
  );
363
+ return result.matchedCount > 0;
313
364
  }
314
365
  async unlockAllJobs() {
315
366
  const result = await this.jobs.updateMany(
@@ -317,7 +368,7 @@ var JobsRepo = class {
317
368
  lockedUntil: { $exists: true }
318
369
  },
319
370
  {
320
- $unset: { lockedUntil: "" }
371
+ $unset: { lockId: "", lockedUntil: "" }
321
372
  }
322
373
  );
323
374
  return result.modifiedCount;
@@ -623,11 +674,15 @@ var Executor = class {
623
674
  getEffectiveLockTime(job, jobToRun) {
624
675
  return job.lockTime ?? jobToRun.lockTime;
625
676
  }
626
- getContext(job, jobToRun, onStale) {
677
+ getContext(job, jobToRun, onStale, isExecutionActive) {
627
678
  var _a;
628
679
  const effectiveLockTime = this.getEffectiveLockTime(job, jobToRun);
629
680
  let staleTimeout = setTimeout(() => onStale(), effectiveLockTime);
630
681
  (_a = staleTimeout.unref) == null ? void 0 : _a.call(staleTimeout);
682
+ const contextLogger = import_logger3.logger.addMetadata({
683
+ jobName: jobToRun.name,
684
+ jobId: jobToRun.jobId
685
+ });
631
686
  return {
632
687
  definition: job,
633
688
  record: jobToRun,
@@ -635,15 +690,26 @@ var Executor = class {
635
690
  clearStaleTimeout: () => clearTimeout(staleTimeout),
636
691
  extendLockTime: async (extraTime) => {
637
692
  var _a2;
693
+ if (!isExecutionActive()) {
694
+ contextLogger.warn(`Will not extend lock for stale execution "${jobToRun.executionId}"`);
695
+ return;
696
+ }
638
697
  clearTimeout(staleTimeout);
698
+ let didExtend = false;
699
+ try {
700
+ didExtend = await this.jobsRepo.extendLockTime(jobToRun.jobId, extraTime, jobToRun.lockId);
701
+ } catch (error) {
702
+ onStale();
703
+ throw error;
704
+ }
705
+ if (!didExtend) {
706
+ onStale();
707
+ return;
708
+ }
639
709
  staleTimeout = setTimeout(() => onStale(), extraTime);
640
710
  (_a2 = staleTimeout.unref) == null ? void 0 : _a2.call(staleTimeout);
641
- await this.jobsRepo.extendLockTime(jobToRun.jobId, extraTime);
642
711
  },
643
- logger: import_logger3.logger.addMetadata({
644
- jobName: jobToRun.name,
645
- jobId: jobToRun.jobId
646
- })
712
+ logger: contextLogger
647
713
  };
648
714
  }
649
715
  getJobDefinition(jobToRun, jobs) {
@@ -675,8 +741,16 @@ var Executor = class {
675
741
  jobLogger.warn(
676
742
  `Job "${jobToRun.name}" has exceeded max tries (${jobToRun.tries}). Marking as maxTriesReached.`
677
743
  );
678
- await this.jobsRepo.markJobAsMaxTriesReached(jobToRun.jobId, retentionMs);
679
- if (!onMaxTriesReached) return;
744
+ const didMark = await this.jobsRepo.markJobAsMaxTriesReached(
745
+ jobToRun.jobId,
746
+ retentionMs,
747
+ jobToRun.lockId
748
+ );
749
+ if (!didMark) {
750
+ jobLogger.warn(`Could not mark job "${jobToRun.name}" because its lock is no longer owned`);
751
+ return false;
752
+ }
753
+ if (!onMaxTriesReached) return true;
680
754
  try {
681
755
  await onMaxTriesReached(jobToRun);
682
756
  } catch (callbackError) {
@@ -684,30 +758,33 @@ var Executor = class {
684
758
  error: callbackError
685
759
  });
686
760
  }
761
+ return true;
687
762
  }
688
763
  async onError(error, job, jobToRun, context) {
689
764
  const scheduleRecurrent = async () => {
690
765
  if (job.type === "recurrent") {
691
- await this.jobsRepo.scheduleNextRun({
766
+ return this.jobsRepo.scheduleNextRun({
692
767
  jobId: jobToRun.jobId,
693
768
  nextRunAt: getNextRunDate(job),
694
769
  resetTries: true,
695
- priority: job.priority
770
+ priority: job.priority,
771
+ lockId: jobToRun.lockId
696
772
  });
697
773
  }
774
+ return true;
698
775
  };
699
776
  const handleRetry = async (nextRunAt) => {
700
- await this.jobsRepo.scheduleNextRun({
777
+ return this.jobsRepo.scheduleNextRun({
701
778
  jobId: jobToRun.jobId,
702
779
  nextRunAt,
703
780
  resetTries: false,
704
- priority: job.type === "recurrent" ? job.priority : jobToRun.priority
781
+ priority: job.type === "recurrent" ? job.priority : jobToRun.priority,
782
+ lockId: jobToRun.lockId
705
783
  });
706
784
  };
707
785
  if (!job.onError) {
708
786
  context.logger.error(`Error executing job "${jobToRun.name}"`, { error });
709
- await scheduleRecurrent();
710
- return;
787
+ return scheduleRecurrent();
711
788
  }
712
789
  context.logger.info(`Error executing job "${jobToRun.name}"`, { error });
713
790
  const result = await job.onError(
@@ -716,12 +793,12 @@ var Executor = class {
716
793
  context
717
794
  );
718
795
  if (result.action === "dismiss") {
719
- await scheduleRecurrent();
720
- return;
796
+ return scheduleRecurrent();
721
797
  }
722
798
  if (result.action === "retry") {
723
- await handleRetry(getNextRunDate(result));
799
+ return handleRetry(getNextRunDate(result));
724
800
  }
801
+ return true;
725
802
  }
726
803
  async saveExecution(options) {
727
804
  const { startedAt, status, errorMessage, result, job, jobToRun } = options;
@@ -751,19 +828,21 @@ var Executor = class {
751
828
  async afterExecutionSuccess(job, jobToRun, context) {
752
829
  if (job.type === "recurrent") {
753
830
  context.logger.debug(`Scheduling next run for recurrent job "${jobToRun.name}"`);
754
- await this.jobsRepo.scheduleNextRun({
831
+ return this.jobsRepo.scheduleNextRun({
755
832
  jobId: jobToRun.jobId,
756
833
  nextRunAt: getNextRunDate(job),
757
834
  resetTries: true,
758
- priority: job.priority
835
+ priority: job.priority,
836
+ lockId: jobToRun.lockId
759
837
  });
760
838
  }
761
839
  if (job.type === "event") {
762
840
  context.logger.debug(`Removing event job after success "${jobToRun.name}"`);
763
- await this.jobsRepo.deleteEventJob(jobToRun.jobId);
841
+ return this.jobsRepo.deleteEventJob(jobToRun.jobId, jobToRun.lockId);
764
842
  }
843
+ return false;
765
844
  }
766
- async executeJob(config, jobToRun, respawnWorker) {
845
+ async executeJob(config, jobToRun, onExecutionStale) {
767
846
  const job = this.getJobDefinition(jobToRun, config.jobs);
768
847
  if (!job) return;
769
848
  if (job.type === "event") {
@@ -779,33 +858,64 @@ var Executor = class {
779
858
  }
780
859
  const effectiveLockTime = this.getEffectiveLockTime(job, jobToRun);
781
860
  if (effectiveLockTime !== jobToRun.lockTime) {
782
- await this.jobsRepo.updateLockTime(jobToRun.jobId, effectiveLockTime);
861
+ const didUpdateLock = await this.jobsRepo.updateLockTime(
862
+ jobToRun.jobId,
863
+ effectiveLockTime,
864
+ jobToRun.lockId
865
+ );
866
+ if (!didUpdateLock) {
867
+ import_logger3.logger.warn(`Will not execute job "${jobToRun.name}" because its lock is no longer owned`);
868
+ return;
869
+ }
783
870
  }
784
871
  const tracer = import_api.trace.getTracer("orionjs.dogs", "1.0");
785
872
  await tracer.startActiveSpan(`job.${jobToRun.name}.${jobToRun.executionId}`, async (span) => {
786
873
  try {
787
874
  const startedAt = /* @__PURE__ */ new Date();
788
- const onStale = async () => {
789
- if (job.onStale) {
790
- context.logger.info(`Job "${jobToRun.name}" is stale`);
791
- job.onStale(jobToRun.params, context);
792
- } else {
793
- context.logger.error(`Job "${jobToRun.name}" is stale`);
794
- }
795
- await this.jobsRepo.setJobRecordPriority(jobToRun.jobId, 0);
796
- void respawnWorker();
797
- void this.saveExecution({
798
- startedAt,
799
- status: "stale",
800
- result: null,
801
- errorMessage: null,
802
- job,
803
- jobToRun
804
- }).catch((error) => {
805
- context.logger.error("Error saving stale execution history", { error });
875
+ let executionStatus = "running";
876
+ let staleHandlingPromise;
877
+ let context;
878
+ const markAsStale = () => {
879
+ if (executionStatus === "stale") return staleHandlingPromise;
880
+ executionStatus = "stale";
881
+ context.clearStaleTimeout();
882
+ staleHandlingPromise = (async () => {
883
+ context.logger.warn(`Job "${jobToRun.name}" is stale`);
884
+ if (job.onStale) {
885
+ void Promise.resolve(job.onStale(jobToRun.params, context)).catch((error) => {
886
+ context.logger.error(`Error in onStale callback for job "${jobToRun.name}"`, {
887
+ error
888
+ });
889
+ });
890
+ }
891
+ try {
892
+ await this.jobsRepo.setJobRecordPriority(jobToRun.jobId, 0, jobToRun.lockId);
893
+ } catch (error) {
894
+ context.logger.error(`Error lowering priority for stale job "${jobToRun.name}"`, {
895
+ error
896
+ });
897
+ } finally {
898
+ onExecutionStale();
899
+ }
900
+ await this.saveExecution({
901
+ startedAt,
902
+ status: "stale",
903
+ result: null,
904
+ errorMessage: null,
905
+ job,
906
+ jobToRun
907
+ });
908
+ })().catch((error) => {
909
+ context.logger.error("Error handling stale execution", { error });
806
910
  });
911
+ return staleHandlingPromise;
807
912
  };
808
- const context = this.getContext(job, jobToRun, onStale);
913
+ context = this.getContext(
914
+ job,
915
+ jobToRun,
916
+ () => void markAsStale(),
917
+ () => executionStatus === "running"
918
+ );
809
919
  const extraContext = {
810
920
  controllerType: "job",
811
921
  jobName: jobToRun.name,
@@ -821,6 +931,18 @@ var Executor = class {
821
931
  });
822
932
  const result = await job.resolve(jobToRun.params, context);
823
933
  context.clearStaleTimeout();
934
+ if (executionStatus === "stale") {
935
+ await staleHandlingPromise;
936
+ context.logger.warn(
937
+ `Ignoring late success from stale execution "${jobToRun.executionId}"`
938
+ );
939
+ return;
940
+ }
941
+ const stillOwnsJob = await this.afterExecutionSuccess(job, jobToRun, context);
942
+ if (!stillOwnsJob) {
943
+ await markAsStale();
944
+ return;
945
+ }
824
946
  void this.saveExecution({
825
947
  startedAt,
826
948
  status: "success",
@@ -831,9 +953,21 @@ var Executor = class {
831
953
  }).catch((error) => {
832
954
  context.logger.error("Error saving successful execution history", { error });
833
955
  });
834
- await this.afterExecutionSuccess(job, jobToRun, context);
835
956
  } catch (error) {
836
957
  context.clearStaleTimeout();
958
+ if (executionStatus === "stale") {
959
+ await staleHandlingPromise;
960
+ context.logger.warn(
961
+ `Ignoring late error from stale execution "${jobToRun.executionId}"`,
962
+ { error }
963
+ );
964
+ return;
965
+ }
966
+ const stillOwnsJob = await this.onError(error, job, jobToRun, context);
967
+ if (!stillOwnsJob) {
968
+ await markAsStale();
969
+ return;
970
+ }
837
971
  void this.saveExecution({
838
972
  startedAt,
839
973
  status: "error",
@@ -844,7 +978,6 @@ var Executor = class {
844
978
  }).catch((saveError) => {
845
979
  context.logger.error("Error saving failed execution history", { error: saveError });
846
980
  });
847
- await this.onError(error, job, jobToRun, context);
848
981
  }
849
982
  });
850
983
  } catch (error) {
@@ -870,6 +1003,7 @@ var _executor_dec, _jobsRepo_dec3, _WorkerService_decorators, _init5;
870
1003
  _WorkerService_decorators = [(0, import_services3.Service)()], _jobsRepo_dec3 = [(0, import_services3.Inject)(() => JobsRepo)], _executor_dec = [(0, import_services3.Inject)(() => Executor)];
871
1004
  var WorkerService = class {
872
1005
  constructor() {
1006
+ __publicField(this, "jobAcquisitionHintProbeIntervalMs", 30 * 60 * 1e3);
873
1007
  __publicField(this, "jobsRepo", __runInitializers(_init5, 8, this)), __runInitializers(_init5, 11, this);
874
1008
  __publicField(this, "executor", __runInitializers(_init5, 12, this)), __runInitializers(_init5, 15, this);
875
1009
  }
@@ -904,87 +1038,242 @@ var WorkerService = class {
904
1038
  }
905
1039
  workersInstance.runningJobsByName.set(jobName, currentExecutions - 1);
906
1040
  }
907
- async withJobAcquisitionLock(workersInstance, callback) {
908
- const previousLock = workersInstance.jobAcquisitionLock;
909
- let releaseLock;
910
- workersInstance.jobAcquisitionLock = new Promise((resolve) => {
911
- releaseLock = resolve;
912
- });
913
- await previousLock;
914
- try {
915
- return await callback();
916
- } finally {
917
- releaseLock();
1041
+ releaseExecutionCapacity(workersInstance, execution, nextStatus) {
1042
+ if (execution.status !== "running") return;
1043
+ execution.status = nextStatus;
1044
+ workersInstance.activeExecutions.delete(execution.job.executionId);
1045
+ this.releaseJobExecution(workersInstance, execution.job.name);
1046
+ execution.releaseCapacity();
1047
+ if (nextStatus === "stale") {
1048
+ workersInstance.staleExecutions.set(execution.job.executionId, execution);
918
1049
  }
919
1050
  }
920
- async getJobAndReserveExecution(config, workersInstance, jobNames) {
921
- return this.withJobAcquisitionLock(workersInstance, async () => {
922
- const availableJobNames = this.getAvailableJobNames(config, workersInstance, jobNames);
923
- if (availableJobNames.length === 0) return;
924
- const jobToRun = await this.jobsRepo.getJobAndLock(availableJobNames, config.defaultLockTime);
925
- if (!jobToRun) return;
926
- this.reserveJobExecution(workersInstance, jobToRun.name);
927
- return jobToRun;
1051
+ startExecution(config, workersInstance, jobToRun) {
1052
+ let releaseCapacity;
1053
+ const capacityPromise = new Promise((resolve) => {
1054
+ releaseCapacity = resolve;
1055
+ });
1056
+ const execution = {
1057
+ job: jobToRun,
1058
+ promise: Promise.resolve(),
1059
+ capacityPromise,
1060
+ releaseCapacity,
1061
+ status: "running"
1062
+ };
1063
+ this.reserveJobExecution(workersInstance, jobToRun.name);
1064
+ workersInstance.activeExecutions.set(jobToRun.executionId, execution);
1065
+ import_logger4.logger.debug(`Starting job execution "${jobToRun.executionId}"`, jobToRun);
1066
+ const executionPromise = this.executor.executeJob(config, jobToRun, () => {
1067
+ this.releaseExecutionCapacity(workersInstance, execution, "stale");
1068
+ });
1069
+ execution.promise = executionPromise;
1070
+ void executionPromise.catch((error) => {
1071
+ import_logger4.logger.error(`Unhandled error executing job "${jobToRun.name}"`, { error });
1072
+ }).finally(() => {
1073
+ if (execution.status === "running") {
1074
+ this.releaseExecutionCapacity(workersInstance, execution, "finished");
1075
+ } else if (execution.status === "stale") {
1076
+ workersInstance.staleExecutions.delete(jobToRun.executionId);
1077
+ }
928
1078
  });
929
1079
  }
930
- async runWorkerLoop(config, workersInstance, workerInstance, jobNames, executeConfig) {
931
- const jobToRun = await this.getJobAndReserveExecution(config, workersInstance, jobNames);
932
- if (!jobToRun) {
933
- import_logger4.logger.debug("No job to run");
934
- return false;
935
- }
936
- import_logger4.logger.debug(`Got job [w${workerInstance.workerIndex}] to run:`, jobToRun);
937
- try {
938
- await this.executor.executeJob(executeConfig, jobToRun, workerInstance.respawn);
939
- } finally {
940
- this.releaseJobExecution(workersInstance, jobToRun.name);
941
- }
942
- return true;
1080
+ async waitForPollOrStop(config, workersInstance) {
1081
+ await Promise.race([(0, import_helpers2.sleep)(config.pollInterval), workersInstance.stopSignal]);
943
1082
  }
944
- async startWorker(config, workersInstance, workerInstance) {
945
- const names = this.getJobNames(config.jobs);
946
- import_logger4.logger.debug(
947
- `Running worker loop [w${workerInstance.workerIndex}] for jobs "${names.join(", ")}"...`
1083
+ async waitForCapacityOrStop(workersInstance) {
1084
+ const capacityPromises = Array.from(
1085
+ workersInstance.activeExecutions.values(),
1086
+ (execution) => execution.capacityPromise
948
1087
  );
1088
+ if (capacityPromises.length === 0) return;
1089
+ await Promise.race([...capacityPromises, workersInstance.stopSignal]);
1090
+ }
1091
+ async runSchedulerLoop(config, workersInstance) {
1092
+ const jobNames = this.getJobNames(config.jobs);
949
1093
  const executeConfig = {
950
1094
  jobs: config.jobs,
951
1095
  maxTries: config.maxTries,
952
1096
  maxTriesReachedRetentionMs: config.maxTriesReachedRetentionMs,
953
1097
  onMaxTriesReached: config.onMaxTriesReached
954
1098
  };
955
- while (true) {
956
- if (!workerInstance.running) {
957
- import_logger4.logger.info(`Got signal to stop. Stopping worker [w${workerInstance.workerIndex}]...`);
958
- return;
959
- }
1099
+ import_logger4.logger.debug(`Running scheduler loop for jobs "${jobNames.join(", ")}"...`);
1100
+ while (workersInstance.running) {
1101
+ let didMiss = false;
1102
+ let allJobNamesAtCapacity = false;
960
1103
  try {
961
- const didRun = await this.runWorkerLoop(
962
- config,
963
- workersInstance,
964
- workerInstance,
965
- names,
966
- executeConfig
967
- );
968
- if (!didRun) await (0, import_helpers2.sleep)(config.pollInterval);
969
- if (didRun) await (0, import_helpers2.sleep)(config.cooldownPeriod);
1104
+ while (workersInstance.activeExecutions.size < config.workersCount) {
1105
+ const availableJobNames = this.getAvailableJobNames(config, workersInstance, jobNames);
1106
+ if (availableJobNames.length === 0) {
1107
+ allJobNamesAtCapacity = workersInstance.activeExecutions.size > 0;
1108
+ break;
1109
+ }
1110
+ const jobToRun = await this.jobsRepo.getJobAndLock(
1111
+ availableJobNames,
1112
+ config.defaultLockTime,
1113
+ workersInstance.jobAcquisitionHint
1114
+ );
1115
+ if (!jobToRun) {
1116
+ import_logger4.logger.debug("No job to run");
1117
+ didMiss = true;
1118
+ break;
1119
+ }
1120
+ this.startExecution(executeConfig, workersInstance, jobToRun);
1121
+ if (!workersInstance.running) break;
1122
+ }
970
1123
  } catch (error) {
971
- import_logger4.logger.error("Error in job runner.", { error });
972
- await (0, import_helpers2.sleep)(config.pollInterval);
1124
+ import_logger4.logger.error("Error in job scheduler.", { error });
1125
+ didMiss = true;
1126
+ }
1127
+ if (!workersInstance.running) return;
1128
+ const concurrencyIsFull = workersInstance.activeExecutions.size >= config.workersCount && workersInstance.activeExecutions.size > 0;
1129
+ if (concurrencyIsFull || allJobNamesAtCapacity) {
1130
+ await this.waitForCapacityOrStop(workersInstance);
1131
+ } else if (didMiss || workersInstance.activeExecutions.size === 0) {
1132
+ await this.waitForPollOrStop(config, workersInstance);
1133
+ }
1134
+ }
1135
+ }
1136
+ getOtherJobAcquisitionHint(hint) {
1137
+ return hint === INITIAL_JOB_ACQUISITION_HINT ? CANDIDATE_JOB_ACQUISITION_HINT : INITIAL_JOB_ACQUISITION_HINT;
1138
+ }
1139
+ getMedian(values) {
1140
+ const sortedValues = [...values].sort((left, right) => left - right);
1141
+ return sortedValues[Math.floor(sortedValues.length / 2)];
1142
+ }
1143
+ resetPendingJobAcquisitionHint(workersInstance) {
1144
+ workersInstance.pendingJobAcquisitionHint = void 0;
1145
+ workersInstance.pendingJobAcquisitionHintWins = 0;
1146
+ }
1147
+ async runJobAcquisitionHintProbe(config, workersInstance) {
1148
+ const jobNames = this.getJobNames(config.jobs);
1149
+ if (jobNames.length === 0 || !workersInstance.running) return;
1150
+ const byHint = {
1151
+ [INITIAL_JOB_ACQUISITION_HINT]: [],
1152
+ [CANDIDATE_JOB_ACQUISITION_HINT]: []
1153
+ };
1154
+ const startsWithInitialHint = workersInstance.jobAcquisitionHintProbeCycle % 2 === 0;
1155
+ const firstHint = startsWithInitialHint ? INITIAL_JOB_ACQUISITION_HINT : CANDIDATE_JOB_ACQUISITION_HINT;
1156
+ const secondHint = this.getOtherJobAcquisitionHint(firstHint);
1157
+ workersInstance.jobAcquisitionHintProbeCycle++;
1158
+ try {
1159
+ for (let sample = 0; sample < 3; sample++) {
1160
+ for (const hint of [firstHint, secondHint]) {
1161
+ if (!workersInstance.running) return;
1162
+ const executionTimeMillis = await this.jobsRepo.getJobAcquisitionExecutionTime(
1163
+ jobNames,
1164
+ hint
1165
+ );
1166
+ if (!workersInstance.running) return;
1167
+ byHint[hint].push(executionTimeMillis);
1168
+ }
973
1169
  }
1170
+ } catch (error) {
1171
+ this.resetPendingJobAcquisitionHint(workersInstance);
1172
+ if (workersInstance.running) {
1173
+ import_logger4.logger.warn("Job acquisition hint probe failed; keeping the current hint.", { error });
1174
+ }
1175
+ return;
1176
+ }
1177
+ const currentHint = workersInstance.jobAcquisitionHint;
1178
+ const otherHint = this.getOtherJobAcquisitionHint(currentHint);
1179
+ const currentMedian = this.getMedian(byHint[currentHint]);
1180
+ const otherMedian = this.getMedian(byHint[otherHint]);
1181
+ const winner = otherMedian < currentMedian ? otherHint : currentHint;
1182
+ if (!workersInstance.hasCompletedInitialJobAcquisitionHintProbe) {
1183
+ workersInstance.hasCompletedInitialJobAcquisitionHintProbe = true;
1184
+ workersInstance.jobAcquisitionHint = winner;
1185
+ this.resetPendingJobAcquisitionHint(workersInstance);
1186
+ import_logger4.logger.info(`Selected job acquisition hint "${winner}" after startup probe.`);
1187
+ return;
1188
+ }
1189
+ if (winner === currentHint) {
1190
+ this.resetPendingJobAcquisitionHint(workersInstance);
1191
+ import_logger4.logger.debug(`Keeping job acquisition hint "${currentHint}" after probe.`);
1192
+ return;
1193
+ }
1194
+ if (workersInstance.pendingJobAcquisitionHint === winner) {
1195
+ workersInstance.pendingJobAcquisitionHintWins++;
1196
+ } else {
1197
+ workersInstance.pendingJobAcquisitionHint = winner;
1198
+ workersInstance.pendingJobAcquisitionHintWins = 1;
1199
+ }
1200
+ if (workersInstance.pendingJobAcquisitionHintWins < 2) {
1201
+ import_logger4.logger.debug(
1202
+ `Keeping job acquisition hint "${currentHint}"; "${winner}" has one consecutive probe win.`
1203
+ );
1204
+ return;
1205
+ }
1206
+ workersInstance.jobAcquisitionHint = winner;
1207
+ this.resetPendingJobAcquisitionHint(workersInstance);
1208
+ import_logger4.logger.info(`Changed job acquisition hint from "${currentHint}" to "${winner}".`);
1209
+ }
1210
+ async waitForNextJobAcquisitionHintProbe(workersInstance) {
1211
+ await new Promise((resolve) => {
1212
+ let resolved = false;
1213
+ const finishWait = () => {
1214
+ if (resolved) return;
1215
+ resolved = true;
1216
+ clearTimeout(workersInstance.jobAcquisitionHintProbeTimer);
1217
+ workersInstance.jobAcquisitionHintProbeTimer = void 0;
1218
+ workersInstance.cancelJobAcquisitionHintProbeWait = void 0;
1219
+ resolve();
1220
+ };
1221
+ workersInstance.jobAcquisitionHintProbeTimer = setTimeout(
1222
+ finishWait,
1223
+ this.jobAcquisitionHintProbeIntervalMs
1224
+ );
1225
+ workersInstance.cancelJobAcquisitionHintProbeWait = finishWait;
1226
+ });
1227
+ }
1228
+ async runJobAcquisitionHintProbeLoop(config, workersInstance) {
1229
+ if (this.getJobNames(config.jobs).length === 0) return;
1230
+ while (workersInstance.running) {
1231
+ await this.runJobAcquisitionHintProbe(config, workersInstance);
1232
+ if (!workersInstance.running) return;
1233
+ await this.waitForNextJobAcquisitionHintProbe(workersInstance);
974
1234
  }
975
1235
  }
976
1236
  createWorkersInstanceDefinition(config) {
1237
+ let signalStop;
1238
+ const stopSignal = new Promise((resolve) => {
1239
+ signalStop = resolve;
1240
+ });
977
1241
  const workersInstance = {
978
1242
  running: true,
979
1243
  workersCount: config.workersCount,
980
- workers: [],
1244
+ activeExecutions: /* @__PURE__ */ new Map(),
1245
+ staleExecutions: /* @__PURE__ */ new Map(),
981
1246
  runningJobsByName: /* @__PURE__ */ new Map(),
982
- jobAcquisitionLock: Promise.resolve(),
1247
+ jobAcquisitionHint: INITIAL_JOB_ACQUISITION_HINT,
1248
+ jobAcquisitionHintProbeCycle: 0,
1249
+ hasCompletedInitialJobAcquisitionHintProbe: false,
1250
+ pendingJobAcquisitionHintWins: 0,
1251
+ jobAcquisitionHintProbePromise: Promise.resolve(),
1252
+ schedulerPromise: Promise.resolve(),
1253
+ stopSignal,
1254
+ signalStop,
1255
+ get runningExecutions() {
1256
+ return this.activeExecutions.size;
1257
+ },
983
1258
  stop: async () => {
984
- import_logger4.logger.info("Stopping workers...");
985
- workersInstance.running = false;
986
- const stopingPromises = workersInstance.workers.map((worker) => worker.stop());
987
- await Promise.all(stopingPromises);
1259
+ if (workersInstance.stopPromise) return workersInstance.stopPromise;
1260
+ workersInstance.stopPromise = (async () => {
1261
+ var _a;
1262
+ import_logger4.logger.info("Stopping job scheduler...");
1263
+ workersInstance.running = false;
1264
+ workersInstance.signalStop();
1265
+ (_a = workersInstance.cancelJobAcquisitionHintProbeWait) == null ? void 0 : _a.call(workersInstance);
1266
+ await Promise.all([
1267
+ workersInstance.schedulerPromise,
1268
+ workersInstance.jobAcquisitionHintProbePromise
1269
+ ]);
1270
+ const activeCapacity = Array.from(
1271
+ workersInstance.activeExecutions.values(),
1272
+ (execution) => execution.capacityPromise
1273
+ );
1274
+ await Promise.all(activeCapacity);
1275
+ })();
1276
+ return workersInstance.stopPromise;
988
1277
  }
989
1278
  };
990
1279
  return workersInstance;
@@ -998,44 +1287,17 @@ var WorkerService = class {
998
1287
  })
999
1288
  );
1000
1289
  }
1001
- async startANewWorker(config, workersInstance, workerIndex = workersInstance.workers.length) {
1002
- if (!workersInstance.running) {
1003
- return;
1004
- }
1005
- const workerInstance = {
1006
- running: true,
1007
- workerIndex,
1008
- stop: async () => {
1009
- import_logger4.logger.info(`Stopping worker [w${workerIndex}]...`);
1010
- workerInstance.running = false;
1011
- await workerInstance.promise;
1012
- },
1013
- respawn: async () => {
1014
- import_logger4.logger.info(`Respawning worker [w${workerIndex}]...`);
1015
- workerInstance.running = false;
1016
- await this.startANewWorker(config, workersInstance, workerIndex);
1017
- }
1018
- };
1019
- const workerPromise = this.startWorker(config, workersInstance, workerInstance);
1020
- workerInstance.promise = workerPromise;
1021
- workersInstance.workers[workerIndex] = workerInstance;
1022
- }
1023
- async runWorkers(config, workersInstance) {
1290
+ async runScheduler(config, workersInstance) {
1024
1291
  import_logger4.logger.debug("Will ensure maxTriesReached retention and TTL index");
1025
- const ensureRetentionPromise = this.jobsRepo.ensureMaxTriesReachedRetention(config.maxTriesReachedRetentionMs).catch((error) => import_logger4.logger.error("Error ensuring maxTriesReached retention", { error }));
1292
+ void this.jobsRepo.ensureMaxTriesReachedRetention(config.maxTriesReachedRetentionMs).catch((error) => import_logger4.logger.error("Error ensuring maxTriesReached retention", { error }));
1026
1293
  import_logger4.logger.debug("Will ensure records for recurrent jobs");
1027
1294
  await this.ensureRecords(config);
1028
- const workersCount = config.workersCount;
1029
- const workerWord = workersCount === 1 ? "worker" : "workers";
1030
- import_logger4.logger.info(`Starting ${workersCount} ${workerWord}`);
1031
- for (let workerIndex = 0; workerIndex < workersCount; workerIndex++) {
1032
- this.startANewWorker(config, workersInstance, workerIndex);
1033
- }
1034
- await ensureRetentionPromise;
1295
+ import_logger4.logger.info(`Starting job scheduler with concurrency ${config.workersCount}`);
1296
+ await this.runSchedulerLoop(config, workersInstance);
1035
1297
  }
1036
1298
  /**
1037
- * Starts the job workers with the provided configuration.
1038
- * @param userConfig - Configuration for the workers. Required field: jobs
1299
+ * Starts one job scheduler with the provided concurrency configuration.
1300
+ * @param userConfig - Configuration for the scheduler. Required field: jobs
1039
1301
  */
1040
1302
  startWorkers(userConfig) {
1041
1303
  const maxTriesReachedRetentionMs = userConfig.maxTriesReachedRetentionMs === void 0 ? DEFAULT_MAX_TRIES_REACHED_RETENTION_MS : userConfig.maxTriesReachedRetentionMs;
@@ -1043,7 +1305,6 @@ var WorkerService = class {
1043
1305
  throw new Error("maxTriesReachedRetentionMs must be null, zero, or a positive number");
1044
1306
  }
1045
1307
  const config = {
1046
- cooldownPeriod: 100,
1047
1308
  pollInterval: 3e3,
1048
1309
  workersCount: 4,
1049
1310
  defaultLockTime: 30 * 1e3,
@@ -1052,8 +1313,18 @@ var WorkerService = class {
1052
1313
  };
1053
1314
  setNameToJobs(config.jobs);
1054
1315
  const workersInstance = this.createWorkersInstanceDefinition(config);
1055
- import_logger4.logger.debug("Starting workers", config);
1056
- this.runWorkers(config, workersInstance);
1316
+ import_logger4.logger.debug("Starting job scheduler", config);
1317
+ workersInstance.jobAcquisitionHintProbePromise = this.runJobAcquisitionHintProbeLoop(
1318
+ config,
1319
+ workersInstance
1320
+ );
1321
+ workersInstance.schedulerPromise = this.runScheduler(config, workersInstance).catch((error) => {
1322
+ var _a;
1323
+ workersInstance.running = false;
1324
+ workersInstance.signalStop();
1325
+ (_a = workersInstance.cancelJobAcquisitionHintProbeWait) == null ? void 0 : _a.call(workersInstance);
1326
+ import_logger4.logger.error("Job scheduler stopped after an unexpected error.", { error });
1327
+ });
1057
1328
  return workersInstance;
1058
1329
  }
1059
1330
  };