@orion-js/dogs 4.6.4 → 4.6.5

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
@@ -48,27 +48,169 @@ var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "
48
48
  var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method);
49
49
 
50
50
  // src/index.ts
51
- import { getInstance as getInstance2 } from "@orion-js/services";
51
+ import { getInstance as getInstance3 } from "@orion-js/services";
52
52
 
53
- // src/services/EventsService.ts
54
- import { logger as logger2 } from "@orion-js/logger";
55
- import { Inject, Service } from "@orion-js/services";
53
+ // src/repos/JobsHistoryRepo.ts
54
+ import { MongoCollection, Repository } from "@orion-js/mongodb";
55
+
56
+ // ../../node_modules/.bun/rambdax@11.3.1/node_modules/rambdax/src/_internals/isInteger.js
57
+ function _isInteger(n) {
58
+ return n << 0 === n;
59
+ }
60
+ var isInteger = Number.isInteger || _isInteger;
61
+
62
+ // ../../node_modules/.bun/rambdax@11.3.1/node_modules/rambdax/src/_internals/createPath.js
63
+ function createPath(path, delimiter = ".") {
64
+ return typeof path === "string" ? path.split(delimiter).map((x) => isInteger(x) ? Number(x) : x) : path;
65
+ }
66
+
67
+ // ../../node_modules/.bun/rambdax@11.3.1/node_modules/rambdax/src/_internals/compare.js
68
+ function compare(a, b) {
69
+ return String(a) === String(b);
70
+ }
71
+
72
+ // ../../node_modules/.bun/rambdax@11.3.1/node_modules/rambdax/src/_internals/includes.js
73
+ function includes(a, list) {
74
+ let index = -1;
75
+ const { length } = list;
76
+ while (++index < length)
77
+ if (compare(list[index], a))
78
+ return true;
79
+ return false;
80
+ }
81
+
82
+ // ../../node_modules/.bun/rambdax@11.3.1/node_modules/rambdax/src/omit.js
83
+ function omit(propsToOmit, obj) {
84
+ if (arguments.length === 1) return (_obj) => omit(propsToOmit, _obj);
85
+ if (obj === null || obj === void 0)
86
+ return void 0;
87
+ const propsToOmitValue = createPath(propsToOmit, ",");
88
+ const willReturn = {};
89
+ for (const key in obj)
90
+ if (!includes(key, propsToOmitValue))
91
+ willReturn[key] = obj[key];
92
+ return willReturn;
93
+ }
94
+
95
+ // src/types/HistoryRecord.ts
96
+ import { schemaWithName } from "@orion-js/schema";
97
+ var HistoryRecordSchema = schemaWithName("HistoryRecord", {
98
+ _id: { type: "string" },
99
+ jobId: { type: "string" },
100
+ executionId: { type: "string" },
101
+ jobName: { type: "string" },
102
+ type: { type: "string" },
103
+ priority: { type: "number" },
104
+ tries: { type: "number" },
105
+ uniqueIdentifier: { type: "string", optional: true },
106
+ startedAt: { type: "date" },
107
+ endedAt: { type: "date" },
108
+ duration: { type: "number" },
109
+ expiresAt: { type: "date", optional: true },
110
+ status: { type: "string", enum: ["success", "error", "stale"] },
111
+ errorMessage: { type: "string", optional: true },
112
+ params: { type: "blackbox", optional: true },
113
+ result: { type: "any", optional: true }
114
+ });
115
+
116
+ // src/repos/JobsHistoryRepo.ts
117
+ var _history_dec, _JobsHistoryRepo_decorators, _init;
118
+ _JobsHistoryRepo_decorators = [Repository()], _history_dec = [MongoCollection({
119
+ name: "orionjs.jobs_dogs_history",
120
+ idGeneration: "uuid",
121
+ schema: HistoryRecordSchema,
122
+ indexes: [
123
+ {
124
+ keys: {
125
+ jobName: 1,
126
+ startedAt: 1
127
+ }
128
+ },
129
+ {
130
+ keys: {
131
+ executionId: 1
132
+ }
133
+ },
134
+ {
135
+ keys: {
136
+ expiresAt: 1
137
+ },
138
+ options: {
139
+ expireAfterSeconds: 0
140
+ }
141
+ }
142
+ ]
143
+ })];
144
+ var JobsHistoryRepo = class {
145
+ constructor() {
146
+ __publicField(this, "history", __runInitializers(_init, 8, this)), __runInitializers(_init, 11, this);
147
+ }
148
+ async saveExecution(record) {
149
+ await this.history.upsert(
150
+ { executionId: record.executionId },
151
+ {
152
+ $setOnInsert: {
153
+ status: record.status
154
+ },
155
+ $set: {
156
+ ...omit(["status"], record)
157
+ }
158
+ }
159
+ );
160
+ }
161
+ async getExecutions(jobName, limit, skip) {
162
+ const cursor = this.history.find({ jobName }).sort({ startedAt: -1 });
163
+ if (skip) {
164
+ cursor.skip(skip);
165
+ }
166
+ if (limit) {
167
+ cursor.limit(limit);
168
+ }
169
+ return await cursor.toArray();
170
+ }
171
+ };
172
+ _init = __decoratorStart(null);
173
+ __decorateElement(_init, 5, "history", _history_dec, JobsHistoryRepo);
174
+ JobsHistoryRepo = __decorateElement(_init, 0, "JobsHistoryRepo", _JobsHistoryRepo_decorators, JobsHistoryRepo);
175
+ __runInitializers(_init, 1, JobsHistoryRepo);
56
176
 
57
177
  // src/repos/JobsRepo.ts
58
178
  import { generateId } from "@orion-js/helpers";
59
179
  import { logger } from "@orion-js/logger";
60
- import { MongoCollection, Repository } from "@orion-js/mongodb";
180
+ import { MongoCollection as MongoCollection2, Repository as Repository2 } from "@orion-js/mongodb";
181
+
182
+ // src/services/partitions.ts
183
+ function validatePartitionsCount(nPartitions) {
184
+ if (!Number.isInteger(nPartitions) || nPartitions <= 0) {
185
+ throw new Error("nPartitions must be a positive integer");
186
+ }
187
+ }
188
+ function getRandomPartition(nPartitions) {
189
+ return Math.floor(Math.random() * nPartitions);
190
+ }
191
+ function createShuffledPartitions(nPartitions) {
192
+ const partitions = Array.from({ length: nPartitions }, (_, partition) => partition);
193
+ for (let index = partitions.length - 1; index > 0; index--) {
194
+ const swapIndex = Math.floor(Math.random() * (index + 1));
195
+ [partitions[index], partitions[swapIndex]] = [partitions[swapIndex], partitions[index]];
196
+ }
197
+ return partitions;
198
+ }
61
199
 
62
200
  // src/types/JobRecord.ts
63
- import { createEnum, schemaWithName } from "@orion-js/schema";
201
+ import { createEnum, schemaWithName as schemaWithName2 } from "@orion-js/schema";
64
202
  var JobStatusEnum = createEnum("JobStatus", ["pending", "maxTriesReached"]);
65
- var JobRecordSchema = schemaWithName("JobRecord", {
203
+ var JobRecordSchema = schemaWithName2("JobRecord", {
66
204
  _id: { type: "string" },
67
205
  jobName: { type: "string" },
68
206
  type: { type: createEnum("JobType", ["recurrent", "event"]) },
69
207
  priority: { type: "number" },
70
208
  uniqueIdentifier: { type: "string", optional: true },
71
209
  nextRunAt: { type: "date" },
210
+ /**
211
+ * Acquisition partition. Optional so records created by previous versions can be reconciled.
212
+ */
213
+ partition: { type: "number", optional: true },
72
214
  lastRunAt: { type: "date", optional: true },
73
215
  lockedUntil: { type: "date", optional: true },
74
216
  /**
@@ -76,6 +218,11 @@ var JobRecordSchema = schemaWithName("JobRecord", {
76
218
  * this value so a stale execution cannot modify a newer claim.
77
219
  */
78
220
  lockId: { type: "string", optional: true },
221
+ /**
222
+ * Internal acquisition metadata used when MongoDB supports sorted updateOne. It preserves the
223
+ * pre-update stale state because updateOne intentionally does not return the previous document.
224
+ */
225
+ claimWasStale: { type: "boolean", optional: true },
79
226
  tries: { type: "number", optional: true },
80
227
  params: { type: "blackbox", optional: true },
81
228
  /**
@@ -100,23 +247,30 @@ var DEFAULT_MAX_TRIES_REACHED_RETENTION_MS = 1e3 * 60 * 60 * 24 * 7;
100
247
  // src/repos/JobsRepo.ts
101
248
  var JOB_ACQUISITION_HINTS = {
102
249
  byJobName: {
250
+ partition: 1,
103
251
  jobName: 1,
104
252
  priority: -1,
105
253
  nextRunAt: 1
106
254
  },
107
255
  globalPriority: {
256
+ partition: 1,
108
257
  priority: -1,
109
258
  nextRunAt: 1
110
259
  }
111
260
  };
112
261
  var INITIAL_JOB_ACQUISITION_HINT = "byJobName";
113
262
  var CANDIDATE_JOB_ACQUISITION_HINT = "globalPriority";
263
+ var SORTED_UPDATE_ONE_MIN_WIRE_VERSION = 25;
264
+ var LOCK_ID_INDEX_NAME = "jobs_dogs_records_lock_id";
265
+ var PARTITION_JOB_NAME_INDEX_NAME = "jobs_dogs_records_partition_job_name_priority_next_run";
266
+ var PARTITION_PRIORITY_INDEX_NAME = "jobs_dogs_records_partition_priority_next_run";
114
267
  var JOB_ACQUISITION_SORT = {
115
268
  priority: -1,
116
269
  nextRunAt: 1
117
270
  };
118
- function getJobAcquisitionSelector(jobNames, now) {
271
+ function getJobAcquisitionSelector(jobNames, now, partition) {
119
272
  return {
273
+ partition,
120
274
  jobName: { $in: jobNames },
121
275
  nextRunAt: { $lte: now },
122
276
  $and: [
@@ -127,17 +281,19 @@ function getJobAcquisitionSelector(jobNames, now) {
127
281
  ]
128
282
  };
129
283
  }
130
- var _jobs_dec, _JobsRepo_decorators, _init;
131
- _JobsRepo_decorators = [Repository()], _jobs_dec = [MongoCollection({
284
+ var _jobs_dec, _JobsRepo_decorators, _init2;
285
+ _JobsRepo_decorators = [Repository2()], _jobs_dec = [MongoCollection2({
132
286
  idGeneration: "uuid",
133
287
  name: "orionjs.jobs_dogs_records",
134
288
  schema: JobRecordSchema,
135
289
  indexes: [
136
290
  {
137
- keys: JOB_ACQUISITION_HINTS.byJobName
291
+ keys: JOB_ACQUISITION_HINTS.byJobName,
292
+ options: { name: PARTITION_JOB_NAME_INDEX_NAME }
138
293
  },
139
294
  {
140
- keys: JOB_ACQUISITION_HINTS.globalPriority
295
+ keys: JOB_ACQUISITION_HINTS.globalPriority,
296
+ options: { name: PARTITION_PRIORITY_INDEX_NAME }
141
297
  },
142
298
  {
143
299
  keys: {
@@ -157,6 +313,15 @@ _JobsRepo_decorators = [Repository()], _jobs_dec = [MongoCollection({
157
313
  sparse: true
158
314
  }
159
315
  },
316
+ {
317
+ keys: {
318
+ lockId: 1
319
+ },
320
+ options: {
321
+ name: LOCK_ID_INDEX_NAME,
322
+ sparse: true
323
+ }
324
+ },
160
325
  {
161
326
  keys: {
162
327
  expiresAt: 1
@@ -168,14 +333,55 @@ _JobsRepo_decorators = [Repository()], _jobs_dec = [MongoCollection({
168
333
  })];
169
334
  var JobsRepo = class {
170
335
  constructor() {
171
- __publicField(this, "jobs", __runInitializers(_init, 8, this)), __runInitializers(_init, 11, this);
336
+ __publicField(this, "jobs", __runInitializers(_init2, 8, this)), __runInitializers(_init2, 11, this);
337
+ __publicField(this, "sortedUpdateOneSupportPromise");
338
+ __publicField(this, "partitionsByJobName", /* @__PURE__ */ new Map());
339
+ }
340
+ configureJobPartitions(jobNames, nPartitions) {
341
+ for (const jobName of jobNames) {
342
+ this.partitionsByJobName.set(jobName, nPartitions);
343
+ }
344
+ }
345
+ getJobPartitionsCount(jobName) {
346
+ return this.partitionsByJobName.get(jobName);
347
+ }
348
+ async supportsSortedUpdateOne() {
349
+ if (!this.sortedUpdateOneSupportPromise) {
350
+ this.sortedUpdateOneSupportPromise = this.detectSortedUpdateOneSupport();
351
+ }
352
+ return this.sortedUpdateOneSupportPromise;
353
+ }
354
+ async detectSortedUpdateOneSupport() {
355
+ const rawCollection = await this.jobs.getRawCollection();
356
+ await this.jobs.createIndexesPromise;
357
+ const hello = await rawCollection.db.admin().command({ hello: 1 });
358
+ const supportsSortedUpdateOne = typeof hello.maxWireVersion === "number" && hello.maxWireVersion >= SORTED_UPDATE_ONE_MIN_WIRE_VERSION;
359
+ if (!supportsSortedUpdateOne) {
360
+ logger.info("MongoDB does not support sorted updateOne; using findOneAndUpdate for jobs.");
361
+ return false;
362
+ }
363
+ const hasLockIdIndex = await rawCollection.indexExists(LOCK_ID_INDEX_NAME);
364
+ if (!hasLockIdIndex) {
365
+ logger.warn(
366
+ `MongoDB supports sorted updateOne but index "${LOCK_ID_INDEX_NAME}" is unavailable; using findOneAndUpdate for jobs.`
367
+ );
368
+ return false;
369
+ }
370
+ logger.info("MongoDB supports sorted updateOne; using it for job acquisition.");
371
+ return true;
372
+ }
373
+ async getJobAndLock(jobNames, lockTime, hint = INITIAL_JOB_ACQUISITION_HINT, partition = 0) {
374
+ if (await this.supportsSortedUpdateOne()) {
375
+ return this.getJobAndLockWithSortedUpdateOne(jobNames, lockTime, hint, partition);
376
+ }
377
+ return this.getJobAndLockWithFindOneAndUpdate(jobNames, lockTime, hint, partition);
172
378
  }
173
- async getJobAndLock(jobNames, lockTime, hint = INITIAL_JOB_ACQUISITION_HINT) {
379
+ async getJobAndLockWithFindOneAndUpdate(jobNames, lockTime, hint, partition) {
174
380
  const executionId = generateId();
175
381
  const now = /* @__PURE__ */ new Date();
176
382
  const lockedUntil = new Date(now.getTime() + lockTime);
177
383
  const job = await this.jobs.findOneAndUpdate(
178
- getJobAcquisitionSelector(jobNames, now),
384
+ getJobAcquisitionSelector(jobNames, now, partition),
179
385
  {
180
386
  $set: { lockId: executionId, lockedUntil, lastRunAt: now },
181
387
  $inc: { tries: 1 }
@@ -191,6 +397,37 @@ var JobsRepo = class {
191
397
  if (!job) return;
192
398
  const tries = (job.tries || 0) + 1;
193
399
  const wasStale = Boolean(job.lockedUntil);
400
+ return this.toJobToRun(job, executionId, lockTime, tries, wasStale);
401
+ }
402
+ async getJobAndLockWithSortedUpdateOne(jobNames, lockTime, hint, partition) {
403
+ const executionId = generateId();
404
+ const now = /* @__PURE__ */ new Date();
405
+ const lockedUntil = new Date(now.getTime() + lockTime);
406
+ const rawCollection = await this.jobs.getRawCollection();
407
+ const result = await rawCollection.updateOne(
408
+ getJobAcquisitionSelector(jobNames, now, partition),
409
+ [
410
+ {
411
+ $set: {
412
+ lockId: { $literal: executionId },
413
+ lockedUntil: { $literal: lockedUntil },
414
+ lastRunAt: { $literal: now },
415
+ tries: { $add: [{ $ifNull: ["$tries", 0] }, 1] },
416
+ claimWasStale: { $eq: [{ $type: "$lockedUntil" }, "date"] }
417
+ }
418
+ }
419
+ ],
420
+ {
421
+ hint: JOB_ACQUISITION_HINTS[hint],
422
+ sort: JOB_ACQUISITION_SORT
423
+ }
424
+ );
425
+ if (result.matchedCount === 0) return;
426
+ const job = await rawCollection.findOne({ lockId: executionId });
427
+ if (!job) return;
428
+ return this.toJobToRun(job, executionId, lockTime, job.tries || 0, Boolean(job.claimWasStale));
429
+ }
430
+ toJobToRun(job, executionId, lockTime, tries, wasStale) {
194
431
  if (wasStale) {
195
432
  logger.info(`Running job "${job.jobName}" that was staled`);
196
433
  }
@@ -208,9 +445,9 @@ var JobsRepo = class {
208
445
  wasStale
209
446
  };
210
447
  }
211
- async getJobAcquisitionExecutionTime(jobNames, hint) {
448
+ async getJobAcquisitionExecutionTime(jobNames, hint, partition = 0) {
212
449
  var _a;
213
- const explain = await this.jobs.find(getJobAcquisitionSelector(jobNames, /* @__PURE__ */ new Date()), {
450
+ const explain = await this.jobs.find(getJobAcquisitionSelector(jobNames, /* @__PURE__ */ new Date(), partition), {
214
451
  hint: JOB_ACQUISITION_HINTS[hint],
215
452
  maxTimeMS: 1e3,
216
453
  readPreference: "primary"
@@ -234,7 +471,7 @@ var JobsRepo = class {
234
471
  priority: options.priority,
235
472
  ...options.resetTries ? { tries: 0 } : {}
236
473
  },
237
- $unset: { lockId: "", lockedUntil: "" }
474
+ $unset: { lockId: "", lockedUntil: "", claimWasStale: "" }
238
475
  };
239
476
  const result = await this.jobs.updateOne(
240
477
  options.lockId ? { _id: options.jobId, lockId: options.lockId } : options.jobId,
@@ -268,6 +505,7 @@ var JobsRepo = class {
268
505
  $unset: {
269
506
  lockId: "",
270
507
  lockedUntil: "",
508
+ claimWasStale: "",
271
509
  ...retentionMs === null ? { expiresAt: "" } : {}
272
510
  }
273
511
  }
@@ -324,12 +562,12 @@ var JobsRepo = class {
324
562
  lockedUntil: { $exists: true }
325
563
  },
326
564
  {
327
- $unset: { lockId: "", lockedUntil: "" }
565
+ $unset: { lockId: "", lockedUntil: "", claimWasStale: "" }
328
566
  }
329
567
  );
330
568
  return result.modifiedCount;
331
569
  }
332
- async ensureJobRecord(job) {
570
+ async ensureJobRecord(job, partition = 0) {
333
571
  const result = await this.jobs.upsert(
334
572
  {
335
573
  jobName: job.name
@@ -343,7 +581,8 @@ var JobsRepo = class {
343
581
  status: ""
344
582
  },
345
583
  $setOnInsert: {
346
- nextRunAt: /* @__PURE__ */ new Date()
584
+ nextRunAt: /* @__PURE__ */ new Date(),
585
+ partition
347
586
  }
348
587
  }
349
588
  );
@@ -361,7 +600,8 @@ var JobsRepo = class {
361
600
  params: options.params,
362
601
  nextRunAt: options.nextRunAt,
363
602
  priority: options.priority,
364
- type: "event"
603
+ type: "event",
604
+ ...options.partition === void 0 ? {} : { partition: options.partition }
365
605
  });
366
606
  } catch (error) {
367
607
  if (error.isValidationError && Object.values(error.validationErrors).includes("notUnique") && options.uniqueIdentifier) {
@@ -389,7 +629,8 @@ var JobsRepo = class {
389
629
  params: job.params,
390
630
  nextRunAt: job.nextRunAt,
391
631
  priority: job.priority,
392
- type: "event"
632
+ type: "event",
633
+ ...job.partition === void 0 ? {} : { partition: job.partition }
393
634
  });
394
635
  scheduledCount++;
395
636
  } catch (error) {
@@ -414,11 +655,55 @@ var JobsRepo = class {
414
655
  errors
415
656
  };
416
657
  }
658
+ async reconcilePartitions(options) {
659
+ if (options.jobNames.length === 0) return 0;
660
+ const rawCollection = await this.jobs.getRawCollection();
661
+ await this.jobs.createIndexesPromise;
662
+ const now = /* @__PURE__ */ new Date();
663
+ const invalidPartition = {
664
+ $or: [
665
+ { partition: { $exists: false } },
666
+ { partition: { $lt: 0 } },
667
+ { partition: { $gte: options.nPartitions } }
668
+ ]
669
+ };
670
+ const availableLock = {
671
+ $or: [{ lockedUntil: { $exists: false } }, { lockedUntil: { $lte: now } }]
672
+ };
673
+ const runnableJob = {
674
+ $or: [{ type: { $ne: "event" } }, { status: { $ne: "maxTriesReached" } }]
675
+ };
676
+ const jobs = await rawCollection.find({
677
+ jobName: { $in: options.jobNames },
678
+ $and: [invalidPartition, availableLock, runnableJob]
679
+ }).project({ _id: 1 }).limit(options.batchSize).toArray();
680
+ if (jobs.length === 0) return 0;
681
+ const result = await rawCollection.bulkWrite(
682
+ jobs.map((job) => ({
683
+ updateOne: {
684
+ filter: {
685
+ _id: job._id,
686
+ $and: [invalidPartition, availableLock, runnableJob]
687
+ },
688
+ update: { $set: { partition: getRandomPartition(options.nPartitions) } }
689
+ }
690
+ })),
691
+ { ordered: false }
692
+ );
693
+ return result.modifiedCount;
694
+ }
417
695
  };
418
- _init = __decoratorStart(null);
419
- __decorateElement(_init, 5, "jobs", _jobs_dec, JobsRepo);
420
- JobsRepo = __decorateElement(_init, 0, "JobsRepo", _JobsRepo_decorators, JobsRepo);
421
- __runInitializers(_init, 1, JobsRepo);
696
+ _init2 = __decoratorStart(null);
697
+ __decorateElement(_init2, 5, "jobs", _jobs_dec, JobsRepo);
698
+ JobsRepo = __decorateElement(_init2, 0, "JobsRepo", _JobsRepo_decorators, JobsRepo);
699
+ __runInitializers(_init2, 1, JobsRepo);
700
+
701
+ // src/schedule.ts
702
+ import { getInstance } from "@orion-js/services";
703
+
704
+ // src/services/EventsService.ts
705
+ import { logger as logger2 } from "@orion-js/logger";
706
+ import { Inject, Service } from "@orion-js/services";
422
707
 
423
708
  // src/services/getNextRunDate.ts
424
709
  import { CronExpressionParser } from "cron-parser";
@@ -448,38 +733,53 @@ var getNextRunDate = (options) => {
448
733
  };
449
734
 
450
735
  // src/services/EventsService.ts
451
- var _jobsRepo_dec, _EventsService_decorators, _init2;
736
+ var _jobsRepo_dec, _EventsService_decorators, _init3;
452
737
  _EventsService_decorators = [Service()], _jobsRepo_dec = [Inject(() => JobsRepo)];
453
738
  var EventsService = class {
454
739
  constructor() {
455
- __publicField(this, "jobsRepo", __runInitializers(_init2, 8, this)), __runInitializers(_init2, 11, this);
740
+ __publicField(this, "jobsRepo", __runInitializers(_init3, 8, this)), __runInitializers(_init3, 11, this);
456
741
  }
457
- async scheduleJob(options) {
742
+ async scheduleJob(options, nPartitions) {
458
743
  logger2.debug("Scheduling job...", options);
744
+ const configuredPartitions = nPartitions ?? this.jobsRepo.getJobPartitionsCount(options.name);
459
745
  await this.jobsRepo.scheduleJob({
460
746
  name: options.name,
461
747
  priority: options.priority || 100,
462
748
  nextRunAt: getNextRunDate(options),
463
749
  params: options.params || null,
464
- uniqueIdentifier: options.uniqueIdentifier
750
+ uniqueIdentifier: options.uniqueIdentifier,
751
+ partition: configuredPartitions === void 0 ? void 0 : getRandomPartition(configuredPartitions)
465
752
  });
466
753
  }
467
- async scheduleJobs(jobs) {
754
+ async scheduleJobs(jobs, nPartitions) {
468
755
  logger2.debug(`Scheduling ${jobs.length} jobs...`);
469
- const jobRecords = jobs.map((options) => ({
470
- name: options.name,
471
- priority: options.priority || 100,
472
- nextRunAt: getNextRunDate(options),
473
- params: options.params || null,
474
- uniqueIdentifier: options.uniqueIdentifier
475
- }));
756
+ const jobRecords = jobs.map((options) => {
757
+ const configuredPartitions = nPartitions ?? this.jobsRepo.getJobPartitionsCount(options.name);
758
+ return {
759
+ name: options.name,
760
+ priority: options.priority || 100,
761
+ nextRunAt: getNextRunDate(options),
762
+ params: options.params || null,
763
+ uniqueIdentifier: options.uniqueIdentifier,
764
+ partition: configuredPartitions === void 0 ? void 0 : getRandomPartition(configuredPartitions)
765
+ };
766
+ });
476
767
  return await this.jobsRepo.scheduleJobs(jobRecords);
477
768
  }
478
769
  };
479
- _init2 = __decoratorStart(null);
480
- __decorateElement(_init2, 5, "jobsRepo", _jobsRepo_dec, EventsService);
481
- EventsService = __decorateElement(_init2, 0, "EventsService", _EventsService_decorators, EventsService);
482
- __runInitializers(_init2, 1, EventsService);
770
+ _init3 = __decoratorStart(null);
771
+ __decorateElement(_init3, 5, "jobsRepo", _jobsRepo_dec, EventsService);
772
+ EventsService = __decorateElement(_init3, 0, "EventsService", _EventsService_decorators, EventsService);
773
+ __runInitializers(_init3, 1, EventsService);
774
+
775
+ // src/schedule.ts
776
+ var eventsService = getInstance(EventsService);
777
+ function scheduleJobInternal(options, nPartitions) {
778
+ return eventsService.scheduleJob(options, nPartitions);
779
+ }
780
+ function scheduleJobsInternal(jobs, nPartitions) {
781
+ return eventsService.scheduleJobs(jobs, nPartitions);
782
+ }
483
783
 
484
784
  // src/services/WorkerService.ts
485
785
  import { sleep } from "@orion-js/helpers";
@@ -490,132 +790,6 @@ import { Inject as Inject3, Service as Service3 } from "@orion-js/services";
490
790
  import { SpanStatusCode, trace } from "@opentelemetry/api";
491
791
  import { logger as logger3, runWithOrionAsyncContext, updateOrionAsyncContext } from "@orion-js/logger";
492
792
  import { Inject as Inject2, Service as Service2 } from "@orion-js/services";
493
-
494
- // src/repos/JobsHistoryRepo.ts
495
- import { MongoCollection as MongoCollection2, Repository as Repository2 } from "@orion-js/mongodb";
496
-
497
- // ../../node_modules/.bun/rambdax@11.3.1/node_modules/rambdax/src/_internals/isInteger.js
498
- function _isInteger(n) {
499
- return n << 0 === n;
500
- }
501
- var isInteger = Number.isInteger || _isInteger;
502
-
503
- // ../../node_modules/.bun/rambdax@11.3.1/node_modules/rambdax/src/_internals/createPath.js
504
- function createPath(path, delimiter = ".") {
505
- return typeof path === "string" ? path.split(delimiter).map((x) => isInteger(x) ? Number(x) : x) : path;
506
- }
507
-
508
- // ../../node_modules/.bun/rambdax@11.3.1/node_modules/rambdax/src/_internals/compare.js
509
- function compare(a, b) {
510
- return String(a) === String(b);
511
- }
512
-
513
- // ../../node_modules/.bun/rambdax@11.3.1/node_modules/rambdax/src/_internals/includes.js
514
- function includes(a, list) {
515
- let index = -1;
516
- const { length } = list;
517
- while (++index < length)
518
- if (compare(list[index], a))
519
- return true;
520
- return false;
521
- }
522
-
523
- // ../../node_modules/.bun/rambdax@11.3.1/node_modules/rambdax/src/omit.js
524
- function omit(propsToOmit, obj) {
525
- if (arguments.length === 1) return (_obj) => omit(propsToOmit, _obj);
526
- if (obj === null || obj === void 0)
527
- return void 0;
528
- const propsToOmitValue = createPath(propsToOmit, ",");
529
- const willReturn = {};
530
- for (const key in obj)
531
- if (!includes(key, propsToOmitValue))
532
- willReturn[key] = obj[key];
533
- return willReturn;
534
- }
535
-
536
- // src/types/HistoryRecord.ts
537
- import { schemaWithName as schemaWithName2 } from "@orion-js/schema";
538
- var HistoryRecordSchema = schemaWithName2("HistoryRecord", {
539
- _id: { type: "string" },
540
- jobId: { type: "string" },
541
- executionId: { type: "string" },
542
- jobName: { type: "string" },
543
- type: { type: "string" },
544
- priority: { type: "number" },
545
- tries: { type: "number" },
546
- uniqueIdentifier: { type: "string", optional: true },
547
- startedAt: { type: "date" },
548
- endedAt: { type: "date" },
549
- duration: { type: "number" },
550
- expiresAt: { type: "date", optional: true },
551
- status: { type: "string", enum: ["success", "error", "stale"] },
552
- errorMessage: { type: "string", optional: true },
553
- params: { type: "blackbox", optional: true },
554
- result: { type: "any", optional: true }
555
- });
556
-
557
- // src/repos/JobsHistoryRepo.ts
558
- var _history_dec, _JobsHistoryRepo_decorators, _init3;
559
- _JobsHistoryRepo_decorators = [Repository2()], _history_dec = [MongoCollection2({
560
- name: "orionjs.jobs_dogs_history",
561
- idGeneration: "uuid",
562
- schema: HistoryRecordSchema,
563
- indexes: [
564
- {
565
- keys: {
566
- jobName: 1,
567
- startedAt: 1
568
- }
569
- },
570
- {
571
- keys: {
572
- executionId: 1
573
- }
574
- },
575
- {
576
- keys: {
577
- expiresAt: 1
578
- },
579
- options: {
580
- expireAfterSeconds: 0
581
- }
582
- }
583
- ]
584
- })];
585
- var JobsHistoryRepo = class {
586
- constructor() {
587
- __publicField(this, "history", __runInitializers(_init3, 8, this)), __runInitializers(_init3, 11, this);
588
- }
589
- async saveExecution(record) {
590
- await this.history.upsert(
591
- { executionId: record.executionId },
592
- {
593
- $setOnInsert: {
594
- status: record.status
595
- },
596
- $set: {
597
- ...omit(["status"], record)
598
- }
599
- }
600
- );
601
- }
602
- async getExecutions(jobName, limit, skip) {
603
- const cursor = this.history.find({ jobName }).sort({ startedAt: -1 });
604
- if (skip) {
605
- cursor.skip(skip);
606
- }
607
- if (limit) {
608
- cursor.limit(limit);
609
- }
610
- return await cursor.toArray();
611
- }
612
- };
613
- _init3 = __decoratorStart(null);
614
- __decorateElement(_init3, 5, "history", _history_dec, JobsHistoryRepo);
615
- JobsHistoryRepo = __decorateElement(_init3, 0, "JobsHistoryRepo", _JobsHistoryRepo_decorators, JobsHistoryRepo);
616
- __runInitializers(_init3, 1, JobsHistoryRepo);
617
-
618
- // src/services/Executor.ts
619
793
  var _jobsHistoryRepo_dec, _jobsRepo_dec2, _Executor_decorators, _init4;
620
794
  _Executor_decorators = [Service2()], _jobsRepo_dec2 = [Inject2(() => JobsRepo)], _jobsHistoryRepo_dec = [Inject2(() => JobsHistoryRepo)];
621
795
  var Executor = class {
@@ -961,6 +1135,10 @@ _WorkerService_decorators = [Service3()], _jobsRepo_dec3 = [Inject3(() => JobsRe
961
1135
  var WorkerService = class {
962
1136
  constructor() {
963
1137
  __publicField(this, "jobAcquisitionHintProbeIntervalMs", 30 * 60 * 1e3);
1138
+ __publicField(this, "partitionReconcilerIntervalMs", 5 * 60 * 1e3);
1139
+ __publicField(this, "partitionReconcilerJitterMs", 30 * 1e3);
1140
+ __publicField(this, "partitionReconcilerBatchPauseMs", 250);
1141
+ __publicField(this, "partitionReconcilerBatchSize", 500);
964
1142
  __publicField(this, "jobsRepo", __runInitializers(_init5, 8, this)), __runInitializers(_init5, 11, this);
965
1143
  __publicField(this, "executor", __runInitializers(_init5, 12, this)), __runInitializers(_init5, 15, this);
966
1144
  }
@@ -1045,6 +1223,15 @@ var WorkerService = class {
1045
1223
  if (capacityPromises.length === 0) return;
1046
1224
  await Promise.race([...capacityPromises, workersInstance.stopSignal]);
1047
1225
  }
1226
+ getNextPartition(config, workersInstance) {
1227
+ const partition = workersInstance.partitionOrder[workersInstance.partitionCursor];
1228
+ workersInstance.partitionCursor++;
1229
+ if (workersInstance.partitionCursor >= config.nPartitions) {
1230
+ workersInstance.partitionOrder = createShuffledPartitions(config.nPartitions);
1231
+ workersInstance.partitionCursor = 0;
1232
+ }
1233
+ return partition;
1234
+ }
1048
1235
  async runSchedulerLoop(config, workersInstance) {
1049
1236
  const jobNames = this.getJobNames(config.jobs);
1050
1237
  const executeConfig = {
@@ -1054,6 +1241,7 @@ var WorkerService = class {
1054
1241
  onMaxTriesReached: config.onMaxTriesReached
1055
1242
  };
1056
1243
  logger4.debug(`Running scheduler loop for jobs "${jobNames.join(", ")}"...`);
1244
+ let consecutivePartitionMisses = 0;
1057
1245
  while (workersInstance.running) {
1058
1246
  let didMiss = false;
1059
1247
  let allJobNamesAtCapacity = false;
@@ -1065,21 +1253,30 @@ var WorkerService = class {
1065
1253
  break;
1066
1254
  }
1067
1255
  const jobAcquisitionHint = availableJobNames.length < jobNames.length ? FILTERED_JOB_NAMES_ACQUISITION_HINT : workersInstance.jobAcquisitionHint;
1256
+ const partition = this.getNextPartition(config, workersInstance);
1068
1257
  const jobToRun = await this.jobsRepo.getJobAndLock(
1069
1258
  availableJobNames,
1070
1259
  config.defaultLockTime,
1071
- jobAcquisitionHint
1260
+ jobAcquisitionHint,
1261
+ partition
1072
1262
  );
1073
1263
  if (!jobToRun) {
1074
- logger4.debug("No job to run");
1075
- didMiss = true;
1076
- break;
1264
+ consecutivePartitionMisses++;
1265
+ logger4.debug(`No job to run in partition ${partition}`);
1266
+ if (consecutivePartitionMisses >= config.nPartitions) {
1267
+ consecutivePartitionMisses = 0;
1268
+ didMiss = true;
1269
+ break;
1270
+ }
1271
+ continue;
1077
1272
  }
1273
+ consecutivePartitionMisses = 0;
1078
1274
  this.startExecution(executeConfig, workersInstance, jobToRun);
1079
1275
  if (!workersInstance.running) break;
1080
1276
  }
1081
1277
  } catch (error) {
1082
1278
  logger4.error("Error in job scheduler.", { error });
1279
+ consecutivePartitionMisses = 0;
1083
1280
  didMiss = true;
1084
1281
  }
1085
1282
  if (!workersInstance.running) return;
@@ -1115,11 +1312,13 @@ var WorkerService = class {
1115
1312
  workersInstance.jobAcquisitionHintProbeCycle++;
1116
1313
  try {
1117
1314
  for (let sample = 0; sample < 3; sample++) {
1315
+ const partition = getRandomPartition(config.nPartitions);
1118
1316
  for (const hint of [firstHint, secondHint]) {
1119
1317
  if (!workersInstance.running) return;
1120
1318
  const executionTimeMillis = await this.jobsRepo.getJobAcquisitionExecutionTime(
1121
1319
  jobNames,
1122
- hint
1320
+ hint,
1321
+ partition
1123
1322
  );
1124
1323
  if (!workersInstance.running) return;
1125
1324
  byHint[hint].push(executionTimeMillis);
@@ -1191,6 +1390,44 @@ var WorkerService = class {
1191
1390
  await this.waitForNextJobAcquisitionHintProbe(workersInstance);
1192
1391
  }
1193
1392
  }
1393
+ async reconcilePartitionBatch(config) {
1394
+ if (typeof this.jobsRepo.reconcilePartitions !== "function") return 0;
1395
+ try {
1396
+ return await this.jobsRepo.reconcilePartitions({
1397
+ jobNames: this.getJobNames(config.jobs),
1398
+ nPartitions: config.nPartitions,
1399
+ batchSize: this.partitionReconcilerBatchSize
1400
+ });
1401
+ } catch (error) {
1402
+ logger4.warn("Job partition reconciliation failed; it will be retried.", { error });
1403
+ return 0;
1404
+ }
1405
+ }
1406
+ async waitForNextPartitionReconcile(workersInstance, delayMs) {
1407
+ await new Promise((resolve) => {
1408
+ let resolved = false;
1409
+ const finishWait = () => {
1410
+ if (resolved) return;
1411
+ resolved = true;
1412
+ clearTimeout(workersInstance.partitionReconcilerTimer);
1413
+ workersInstance.partitionReconcilerTimer = void 0;
1414
+ workersInstance.cancelPartitionReconcilerWait = void 0;
1415
+ resolve();
1416
+ };
1417
+ workersInstance.partitionReconcilerTimer = setTimeout(finishWait, delayMs);
1418
+ workersInstance.cancelPartitionReconcilerWait = finishWait;
1419
+ });
1420
+ }
1421
+ async runPartitionReconcilerLoop(config, workersInstance, initialReconcilePromise) {
1422
+ let modifiedCount = await initialReconcilePromise;
1423
+ while (workersInstance.running) {
1424
+ const didFillBatch = modifiedCount >= this.partitionReconcilerBatchSize;
1425
+ const delayMs = didFillBatch ? this.partitionReconcilerBatchPauseMs : this.partitionReconcilerIntervalMs + Math.floor(Math.random() * (this.partitionReconcilerJitterMs + 1));
1426
+ await this.waitForNextPartitionReconcile(workersInstance, delayMs);
1427
+ if (!workersInstance.running) return;
1428
+ modifiedCount = await this.reconcilePartitionBatch(config);
1429
+ }
1430
+ }
1194
1431
  createWorkersInstanceDefinition(config) {
1195
1432
  let signalStop;
1196
1433
  const stopSignal = new Promise((resolve) => {
@@ -1206,7 +1443,10 @@ var WorkerService = class {
1206
1443
  jobAcquisitionHintProbeCycle: 0,
1207
1444
  hasCompletedInitialJobAcquisitionHintProbe: false,
1208
1445
  pendingJobAcquisitionHintWins: 0,
1446
+ partitionOrder: createShuffledPartitions(config.nPartitions),
1447
+ partitionCursor: 0,
1209
1448
  jobAcquisitionHintProbePromise: Promise.resolve(),
1449
+ partitionReconcilerPromise: Promise.resolve(),
1210
1450
  schedulerPromise: Promise.resolve(),
1211
1451
  stopSignal,
1212
1452
  signalStop,
@@ -1216,14 +1456,16 @@ var WorkerService = class {
1216
1456
  stop: async () => {
1217
1457
  if (workersInstance.stopPromise) return workersInstance.stopPromise;
1218
1458
  workersInstance.stopPromise = (async () => {
1219
- var _a;
1459
+ var _a, _b;
1220
1460
  logger4.info("Stopping job scheduler...");
1221
1461
  workersInstance.running = false;
1222
1462
  workersInstance.signalStop();
1223
1463
  (_a = workersInstance.cancelJobAcquisitionHintProbeWait) == null ? void 0 : _a.call(workersInstance);
1464
+ (_b = workersInstance.cancelPartitionReconcilerWait) == null ? void 0 : _b.call(workersInstance);
1224
1465
  await Promise.all([
1225
1466
  workersInstance.schedulerPromise,
1226
- workersInstance.jobAcquisitionHintProbePromise
1467
+ workersInstance.jobAcquisitionHintProbePromise,
1468
+ workersInstance.partitionReconcilerPromise
1227
1469
  ]);
1228
1470
  const activeCapacity = Array.from(
1229
1471
  workersInstance.activeExecutions.values(),
@@ -1241,7 +1483,7 @@ var WorkerService = class {
1241
1483
  await Promise.all(
1242
1484
  jobs.filter((job) => job.type === "recurrent").map(async (job) => {
1243
1485
  logger4.debug(`Ensuring records for job "${job.name}"...`);
1244
- await this.jobsRepo.ensureJobRecord(job);
1486
+ await this.jobsRepo.ensureJobRecord(job, getRandomPartition(config.nPartitions));
1245
1487
  })
1246
1488
  );
1247
1489
  }
@@ -1258,29 +1500,41 @@ var WorkerService = class {
1258
1500
  * @param userConfig - Configuration for the scheduler. Required field: jobs
1259
1501
  */
1260
1502
  startWorkers(userConfig) {
1503
+ var _a, _b;
1261
1504
  const maxTriesReachedRetentionMs = userConfig.maxTriesReachedRetentionMs === void 0 ? DEFAULT_MAX_TRIES_REACHED_RETENTION_MS : userConfig.maxTriesReachedRetentionMs;
1262
1505
  if (maxTriesReachedRetentionMs !== null && (!Number.isFinite(maxTriesReachedRetentionMs) || maxTriesReachedRetentionMs < 0)) {
1263
1506
  throw new Error("maxTriesReachedRetentionMs must be null, zero, or a positive number");
1264
1507
  }
1508
+ const nPartitions = userConfig.nPartitions ?? 1;
1509
+ validatePartitionsCount(nPartitions);
1265
1510
  const config = {
1266
1511
  pollInterval: 3e3,
1267
1512
  workersCount: 4,
1268
1513
  defaultLockTime: 30 * 1e3,
1269
1514
  ...userConfig,
1515
+ nPartitions,
1270
1516
  maxTriesReachedRetentionMs
1271
1517
  };
1272
- setNameToJobs(config.jobs);
1518
+ setNameToJobs(config.jobs, config.nPartitions);
1519
+ (_b = (_a = this.jobsRepo).configureJobPartitions) == null ? void 0 : _b.call(_a, this.getJobNames(config.jobs), config.nPartitions);
1273
1520
  const workersInstance = this.createWorkersInstanceDefinition(config);
1274
1521
  logger4.debug("Starting job scheduler", config);
1275
1522
  workersInstance.jobAcquisitionHintProbePromise = this.runJobAcquisitionHintProbeLoop(
1276
1523
  config,
1277
1524
  workersInstance
1278
1525
  );
1526
+ const initialReconcilePromise = this.reconcilePartitionBatch(config);
1527
+ workersInstance.partitionReconcilerPromise = this.runPartitionReconcilerLoop(
1528
+ config,
1529
+ workersInstance,
1530
+ initialReconcilePromise
1531
+ );
1279
1532
  workersInstance.schedulerPromise = this.runScheduler(config, workersInstance).catch((error) => {
1280
- var _a;
1533
+ var _a2, _b2;
1281
1534
  workersInstance.running = false;
1282
1535
  workersInstance.signalStop();
1283
- (_a = workersInstance.cancelJobAcquisitionHintProbeWait) == null ? void 0 : _a.call(workersInstance);
1536
+ (_a2 = workersInstance.cancelJobAcquisitionHintProbeWait) == null ? void 0 : _a2.call(workersInstance);
1537
+ (_b2 = workersInstance.cancelPartitionReconcilerWait) == null ? void 0 : _b2.call(workersInstance);
1284
1538
  logger4.error("Job scheduler stopped after an unexpected error.", { error });
1285
1539
  });
1286
1540
  return workersInstance;
@@ -1291,15 +1545,13 @@ __decorateElement(_init5, 5, "jobsRepo", _jobsRepo_dec3, WorkerService);
1291
1545
  __decorateElement(_init5, 5, "executor", _executor_dec, WorkerService);
1292
1546
  WorkerService = __decorateElement(_init5, 0, "WorkerService", _WorkerService_decorators, WorkerService);
1293
1547
  __runInitializers(_init5, 1, WorkerService);
1294
- function setNameToJobs(jobs) {
1548
+ function setNameToJobs(jobs, nPartitions) {
1295
1549
  for (const name of Object.keys(jobs)) {
1296
1550
  jobs[name].jobName = name;
1551
+ jobs[name].nPartitions = nPartitions;
1297
1552
  }
1298
1553
  }
1299
1554
 
1300
- // src/service/index.ts
1301
- import { getInstance, Service as Service4 } from "@orion-js/services";
1302
-
1303
1555
  // src/defineJob/index.ts
1304
1556
  import { cleanAndValidate } from "@orion-js/schema";
1305
1557
  import { CronExpressionParser as CronExpressionParser2 } from "cron-parser";
@@ -1316,11 +1568,14 @@ function createEventJob(options) {
1316
1568
  throw new Error("This job has not been registered in the workers");
1317
1569
  }
1318
1570
  const params = jobDefinition.params ? await cleanAndValidate(jobDefinition.params, scheduleOptions.params) : scheduleOptions.params;
1319
- return await scheduleJob({
1320
- ...scheduleOptions,
1321
- name: jobDefinition.jobName,
1322
- params
1323
- });
1571
+ return await scheduleJobInternal(
1572
+ {
1573
+ ...scheduleOptions,
1574
+ name: jobDefinition.jobName,
1575
+ params
1576
+ },
1577
+ jobDefinition.nPartitions
1578
+ );
1324
1579
  };
1325
1580
  jobDefinition.scheduleJobs = async (jobs) => {
1326
1581
  if (!jobDefinition.jobName) {
@@ -1336,7 +1591,7 @@ function createEventJob(options) {
1336
1591
  };
1337
1592
  })
1338
1593
  );
1339
- return await scheduleJobs(processedJobs);
1594
+ return await scheduleJobsInternal(processedJobs, jobDefinition.nPartitions);
1340
1595
  };
1341
1596
  return jobDefinition;
1342
1597
  }
@@ -1365,6 +1620,7 @@ var defineJob = (options) => {
1365
1620
  };
1366
1621
 
1367
1622
  // src/service/index.ts
1623
+ import { getInstance as getInstance2, Service as Service4 } from "@orion-js/services";
1368
1624
  var serviceMetadata = /* @__PURE__ */ new WeakMap();
1369
1625
  var jobsMetadata = /* @__PURE__ */ new Map();
1370
1626
  var jobEntriesByClass = /* @__PURE__ */ new Map();
@@ -1420,7 +1676,7 @@ function initializeJobsIfNeeded(instance) {
1420
1676
  jobsMetadata.set(instance, jobs);
1421
1677
  }
1422
1678
  function getServiceJobs(target) {
1423
- const instance = getInstance(target);
1679
+ const instance = getInstance2(target);
1424
1680
  if (!serviceMetadata.has(instance.constructor)) {
1425
1681
  throw new Error("You must pass a class decorated with @Jobs to getServiceJobs");
1426
1682
  }
@@ -1434,18 +1690,17 @@ function getServiceJobs(target) {
1434
1690
  }
1435
1691
 
1436
1692
  // src/index.ts
1437
- var workerService = getInstance2(WorkerService);
1438
- var eventsService = getInstance2(EventsService);
1439
- var jobsHistoryRepo = getInstance2(JobsHistoryRepo);
1440
- var jobsRepo = getInstance2(JobsRepo);
1693
+ var workerService = getInstance3(WorkerService);
1694
+ var jobsHistoryRepo = getInstance3(JobsHistoryRepo);
1695
+ var jobsRepo = getInstance3(JobsRepo);
1441
1696
  var startWorkers = (config) => {
1442
1697
  return workerService.startWorkers(config);
1443
1698
  };
1444
1699
  var scheduleJob = (options) => {
1445
- return eventsService.scheduleJob(options);
1700
+ return scheduleJobInternal(options);
1446
1701
  };
1447
1702
  var scheduleJobs = (jobs) => {
1448
- return eventsService.scheduleJobs(jobs);
1703
+ return scheduleJobsInternal(jobs);
1449
1704
  };
1450
1705
  export {
1451
1706
  DEFAULT_MAX_TRIES_REACHED_RETENTION_MS,