@gravito/horizon 3.0.0 → 3.2.0

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
@@ -1,128 +1,118 @@
1
1
  import "./chunk-MCKGQKYU.js";
2
2
 
3
3
  // src/SimpleCronParser.ts
4
- var SimpleCronParser = {
5
- /**
6
- * Check if a cron expression matches the given date.
7
- * Only supports standard 5-field cron expressions.
8
- *
9
- * Fields: minute hour day-of-month month day-of-week
10
- * Values:
11
- * - * (any)
12
- * - numbers (0-59, 1-31, 0-11, 0-7)
13
- * - ranges (1-5)
14
- * - lists (1,2,3)
15
- * - steps (*\/5, 1-10/2)
16
- */
17
- isDue(expression, timezone, date) {
18
- const fields = expression.trim().split(/\s+/);
19
- if (fields.length !== 5) {
20
- throw new Error("SimpleCronParser only supports 5-field cron expressions");
21
- }
22
- let targetDate = date;
23
- if (timezone && timezone !== "UTC") {
24
- try {
25
- const parts = new Intl.DateTimeFormat("en-US", {
26
- timeZone: timezone,
27
- year: "numeric",
28
- month: "numeric",
29
- day: "numeric",
30
- hour: "numeric",
31
- minute: "numeric",
32
- second: "numeric",
33
- hour12: false
34
- }).formatToParts(date);
35
- const partMap = {};
36
- parts.forEach((p) => {
37
- partMap[p.type] = p.value;
38
- });
39
- targetDate = new Date(
40
- parseInt(partMap.year ?? "0", 10),
41
- parseInt(partMap.month ?? "1", 10) - 1,
42
- parseInt(partMap.day ?? "1", 10),
43
- parseInt(partMap.hour ?? "0", 10) === 24 ? 0 : parseInt(partMap.hour ?? "0", 10),
44
- parseInt(partMap.minute ?? "0", 10),
45
- 0
46
- );
47
- } catch (_e) {
48
- throw new Error(`Invalid timezone: ${timezone}`);
49
- }
50
- } else if (timezone === "UTC") {
51
- targetDate = new Date(
52
- date.getUTCFullYear(),
53
- date.getUTCMonth(),
54
- date.getUTCDate(),
55
- date.getUTCHours(),
56
- date.getUTCMinutes(),
57
- 0
58
- );
59
- }
60
- const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
61
- if (minute === void 0 || hour === void 0 || dayOfMonth === void 0 || month === void 0 || dayOfWeek === void 0) {
62
- throw new Error("Invalid cron expression");
4
+ var SimpleCronParser = class {
5
+ /**
6
+ * Evaluates if a cron expression matches the specified date and timezone.
7
+ *
8
+ * @param expression - Standard 5-part cron expression.
9
+ * @param timezone - Target timezone for comparison (default: "UTC").
10
+ * @param date - Reference date to check (default: now).
11
+ * @returns True if the expression is due at the given minute.
12
+ * @throws {Error} If the expression is malformed or the timezone is invalid.
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const isDue = SimpleCronParser.isDue('0 * * * *', 'Asia/Taipei');
17
+ * ```
18
+ */
19
+ static isDue(expression, timezone = "UTC", date = /* @__PURE__ */ new Date()) {
20
+ const parts = expression.trim().split(/\s+/);
21
+ if (parts.length !== 5) {
22
+ throw new Error(`Invalid cron expression: ${expression}`);
63
23
  }
64
- const matchMinute = matchField(minute, targetDate.getMinutes(), 0, 59);
65
- const matchHour = matchField(hour, targetDate.getHours(), 0, 23);
66
- const matchDayOfMonth = matchField(dayOfMonth, targetDate.getDate(), 1, 31);
67
- const matchMonth = matchField(month, targetDate.getMonth() + 1, 1, 12);
68
- const matchDayOfWeek = matchField(dayOfWeek, targetDate.getDay(), 0, 7);
69
- return matchMinute && matchHour && matchDayOfMonth && matchMonth && matchDayOfWeek;
70
- }
71
- };
72
- function matchField(pattern, value, min, max) {
73
- if (pattern === "*") {
74
- return true;
24
+ const targetDate = this.getDateInTimezone(date, timezone);
25
+ const minutes = targetDate.getMinutes();
26
+ const hours = targetDate.getHours();
27
+ const dayOfMonth = targetDate.getDate();
28
+ const month = targetDate.getMonth() + 1;
29
+ const dayOfWeek = targetDate.getDay();
30
+ return this.match(parts[0], minutes, 0, 59) && this.match(parts[1], hours, 0, 23) && this.match(parts[2], dayOfMonth, 1, 31) && this.match(parts[3], month, 1, 12) && this.match(parts[4], dayOfWeek, 0, 6, true);
75
31
  }
76
- if (pattern.includes("/")) {
77
- const [range, stepStr] = pattern.split("/");
78
- if (range === void 0 || stepStr === void 0) {
79
- return false;
32
+ /**
33
+ * Internal pattern matching logic for individual cron fields.
34
+ *
35
+ * @param pattern - Cron sub-expression (e.g., "1-5").
36
+ * @param value - Extracted date component value.
37
+ * @param _min - Field minimum boundary.
38
+ * @param _max - Field maximum boundary.
39
+ * @param isDayOfWeek - Special handling for Sunday (0 and 7).
40
+ * @returns Boolean indicating a match.
41
+ *
42
+ * @internal
43
+ */
44
+ static match(pattern, value, _min, _max, isDayOfWeek = false) {
45
+ if (pattern === "*") {
46
+ return true;
80
47
  }
81
- const step = parseInt(stepStr, 10);
82
- if (range === "*") {
83
- return (value - min) % step === 0;
48
+ if (pattern.includes(",")) {
49
+ return pattern.split(",").some((p) => this.match(p, value, _min, _max, isDayOfWeek));
84
50
  }
85
- if (range.includes("-")) {
86
- const [startStr, endStr] = range.split("-");
87
- if (startStr === void 0 || endStr === void 0) {
88
- return false;
89
- }
90
- const start = parseInt(startStr, 10);
91
- const end = parseInt(endStr, 10);
92
- if (value >= start && value <= end) {
93
- return (value - start) % step === 0;
51
+ const stepMatch = pattern.match(/^(\*|\d+(-\d+)?)\/(\d+)$/);
52
+ if (stepMatch) {
53
+ const range = stepMatch[1];
54
+ const step = parseInt(stepMatch[3], 10);
55
+ if (range === "*") {
56
+ return value % step === 0;
94
57
  }
95
- return false;
58
+ const [rMin, rMax] = range.split("-").map((n) => parseInt(n, 10));
59
+ return value >= rMin && value <= rMax && (value - rMin) % step === 0;
96
60
  }
97
- }
98
- if (pattern.includes(",")) {
99
- const parts = pattern.split(",");
100
- return parts.some((part) => matchField(part, value, min, max));
101
- }
102
- if (pattern.includes("-")) {
103
- const [startStr, endStr] = pattern.split("-");
104
- if (startStr === void 0 || endStr === void 0) {
105
- return false;
61
+ if (pattern.includes("-")) {
62
+ const [rMin, rMax] = pattern.split("-").map((n) => parseInt(n, 10));
63
+ return value >= rMin && value <= rMax;
64
+ }
65
+ const patternVal = parseInt(pattern, 10);
66
+ if (isDayOfWeek && patternVal === 7 && value === 0) {
67
+ return true;
106
68
  }
107
- const start = parseInt(startStr, 10);
108
- const end = parseInt(endStr, 10);
109
- return value >= start && value <= end;
69
+ return patternVal === value;
110
70
  }
111
- const expected = parseInt(pattern, 10);
112
- if (max === 7 && expected === 7 && value === 0) {
113
- return true;
71
+ /**
72
+ * Resolves a Date object to the specified timezone.
73
+ *
74
+ * @param date - Source UTC date.
75
+ * @param timezone - Target timezone string.
76
+ * @returns Localized Date object.
77
+ * @throws {Error} If the timezone is invalid.
78
+ *
79
+ * @internal
80
+ */
81
+ static getDateInTimezone(date, timezone) {
82
+ try {
83
+ const tzDate = new Date(date.toLocaleString("en-US", { timeZone: timezone }));
84
+ if (Number.isNaN(tzDate.getTime())) {
85
+ throw new Error();
86
+ }
87
+ return tzDate;
88
+ } catch {
89
+ throw new Error(`Invalid timezone: ${timezone}`);
90
+ }
114
91
  }
115
- return value === expected;
116
- }
92
+ };
117
93
 
118
94
  // src/CronParser.ts
119
95
  var CronParser = class {
96
+ static cache = /* @__PURE__ */ new Map();
97
+ /** Cache duration in milliseconds (1 minute). */
98
+ static CACHE_TTL = 6e4;
99
+ /** Maximum number of unique expression/timezone combinations to store. */
100
+ static MAX_CACHE_SIZE = 500;
120
101
  /**
121
- * Get the next execution date based on a cron expression.
102
+ * Calculates the next occurrence of a cron expression.
103
+ *
104
+ * Dynamically loads `cron-parser` to minimize initial bundle size and memory footprint.
105
+ *
106
+ * @param expression - Valid 5-part cron expression.
107
+ * @param timezone - Target timezone for evaluation (default: "UTC").
108
+ * @param currentDate - Reference time to calculate from (default: now).
109
+ * @returns Resolves to the next execution Date object.
110
+ * @throws {Error} If the expression format is invalid.
122
111
  *
123
- * @param expression - Cron expression
124
- * @param timezone - Timezone identifier
125
- * @param currentDate - Reference date
112
+ * @example
113
+ * ```typescript
114
+ * const next = await CronParser.nextDate('0 0 * * *');
115
+ * ```
126
116
  */
127
117
  static async nextDate(expression, timezone = "UTC", currentDate = /* @__PURE__ */ new Date()) {
128
118
  try {
@@ -137,13 +127,47 @@ var CronParser = class {
137
127
  }
138
128
  }
139
129
  /**
140
- * Check if the cron expression is due to run at the current time (minute precision).
130
+ * Determines if a task is due for execution at the specified time.
141
131
  *
142
- * @param expression - Cron expression
143
- * @param timezone - Timezone identifier
144
- * @param currentDate - Reference date
132
+ * Uses minute-precision caching to optimize repeated evaluations within
133
+ * the same scheduling window. Implements LRU eviction to maintain memory efficiency.
134
+ *
135
+ * @param expression - Cron expression to evaluate.
136
+ * @param timezone - Execution timezone.
137
+ * @param currentDate - Reference time for the check.
138
+ * @returns True if the expression matches the reference time.
145
139
  */
146
140
  static async isDue(expression, timezone = "UTC", currentDate = /* @__PURE__ */ new Date()) {
141
+ const minuteKey = `${expression}:${timezone}:${Math.floor(currentDate.getTime() / 6e4)}`;
142
+ const now = Date.now();
143
+ const cached = this.cache.get(minuteKey);
144
+ if (cached) {
145
+ if (now - cached.timestamp < this.CACHE_TTL) {
146
+ this.cache.delete(minuteKey);
147
+ this.cache.set(minuteKey, cached);
148
+ return cached.result;
149
+ }
150
+ this.cache.delete(minuteKey);
151
+ }
152
+ const result = await this.computeIsDue(expression, timezone, currentDate);
153
+ this.cache.set(minuteKey, {
154
+ result,
155
+ timestamp: now
156
+ });
157
+ this.cleanupCache();
158
+ return result;
159
+ }
160
+ /**
161
+ * Internal logic for tiered cron evaluation.
162
+ *
163
+ * @param expression - Cron expression.
164
+ * @param timezone - Target timezone.
165
+ * @param currentDate - Current time.
166
+ * @returns Boolean indicating if the task is due.
167
+ *
168
+ * @internal
169
+ */
170
+ static async computeIsDue(expression, timezone, currentDate) {
147
171
  try {
148
172
  return SimpleCronParser.isDue(expression, timezone, currentDate);
149
173
  } catch (_e) {
@@ -161,6 +185,33 @@ var CronParser = class {
161
185
  return false;
162
186
  }
163
187
  }
188
+ /**
189
+ * Evicts the oldest entry from the cache when capacity is reached.
190
+ *
191
+ * @internal
192
+ */
193
+ static cleanupCache() {
194
+ if (this.cache.size <= this.MAX_CACHE_SIZE) {
195
+ return;
196
+ }
197
+ const iterator = this.cache.keys();
198
+ const oldestKey = iterator.next().value;
199
+ if (oldestKey) {
200
+ this.cache.delete(oldestKey);
201
+ }
202
+ }
203
+ /**
204
+ * Purges all entries from the internal cache.
205
+ * Useful for testing or when global timezone settings change.
206
+ */
207
+ static clearCache() {
208
+ this.cache.clear();
209
+ }
210
+ /**
211
+ * Compares two dates with minute precision.
212
+ *
213
+ * @internal
214
+ */
164
215
  static minuteMatches(date1, date2) {
165
216
  return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate() && date1.getHours() === date2.getHours() && date1.getMinutes() === date2.getMinutes();
166
217
  }
@@ -168,22 +219,55 @@ var CronParser = class {
168
219
 
169
220
  // src/locks/CacheLockStore.ts
170
221
  var CacheLockStore = class {
222
+ /**
223
+ * Initializes the store with a cache manager.
224
+ *
225
+ * @param cache - The Stasis cache instance.
226
+ * @param prefix - Key prefix to avoid collisions in the shared namespace.
227
+ */
171
228
  constructor(cache, prefix = "scheduler:lock:") {
172
229
  this.cache = cache;
173
230
  this.prefix = prefix;
174
231
  }
232
+ /**
233
+ * Computes the fully qualified cache key.
234
+ *
235
+ * @internal
236
+ */
175
237
  getKey(key) {
176
238
  return this.prefix + key;
177
239
  }
240
+ /**
241
+ * Atomic 'add' operation ensures only one node succeeds.
242
+ *
243
+ * @param key - Lock key.
244
+ * @param ttlSeconds - Expiration.
245
+ */
178
246
  async acquire(key, ttlSeconds) {
179
247
  return this.cache.add(this.getKey(key), "LOCKED", ttlSeconds);
180
248
  }
249
+ /**
250
+ * Removes the lock key from cache.
251
+ *
252
+ * @param key - Lock key.
253
+ */
181
254
  async release(key) {
182
255
  await this.cache.forget(this.getKey(key));
183
256
  }
257
+ /**
258
+ * Overwrites the lock key, effectively resetting the TTL.
259
+ *
260
+ * @param key - Lock key.
261
+ * @param ttlSeconds - New expiration.
262
+ */
184
263
  async forceAcquire(key, ttlSeconds) {
185
264
  await this.cache.put(this.getKey(key), "LOCKED", ttlSeconds);
186
265
  }
266
+ /**
267
+ * Validates if the lock key is present in the cache.
268
+ *
269
+ * @param key - Lock key.
270
+ */
187
271
  async exists(key) {
188
272
  return this.cache.has(this.getKey(key));
189
273
  }
@@ -191,7 +275,14 @@ var CacheLockStore = class {
191
275
 
192
276
  // src/locks/MemoryLockStore.ts
193
277
  var MemoryLockStore = class {
278
+ /** Map of lock keys to their expiration timestamps (ms). */
194
279
  locks = /* @__PURE__ */ new Map();
280
+ /**
281
+ * Acquires a local lock if the key is not already active.
282
+ *
283
+ * @param key - Lock identifier.
284
+ * @param ttlSeconds - Expiration duration.
285
+ */
195
286
  async acquire(key, ttlSeconds) {
196
287
  const NOW = Date.now();
197
288
  const expiresAt = this.locks.get(key);
@@ -201,12 +292,30 @@ var MemoryLockStore = class {
201
292
  this.locks.set(key, NOW + ttlSeconds * 1e3);
202
293
  return true;
203
294
  }
295
+ /**
296
+ * Deletes the lock from local memory.
297
+ *
298
+ * @param key - Lock identifier.
299
+ */
204
300
  async release(key) {
205
301
  this.locks.delete(key);
206
302
  }
303
+ /**
304
+ * Sets or overwrites a local lock.
305
+ *
306
+ * @param key - Lock identifier.
307
+ * @param ttlSeconds - Expiration.
308
+ */
207
309
  async forceAcquire(key, ttlSeconds) {
208
310
  this.locks.set(key, Date.now() + ttlSeconds * 1e3);
209
311
  }
312
+ /**
313
+ * Checks if a local lock is present and hasn't expired.
314
+ *
315
+ * Automatically purges expired locks upon checking.
316
+ *
317
+ * @param key - Lock identifier.
318
+ */
210
319
  async exists(key) {
211
320
  const expiresAt = this.locks.get(key);
212
321
  if (!expiresAt) {
@@ -223,6 +332,13 @@ var MemoryLockStore = class {
223
332
  // src/locks/LockManager.ts
224
333
  var LockManager = class {
225
334
  store;
335
+ /**
336
+ * Initializes the manager with a specific storage driver.
337
+ *
338
+ * @param driver - Strategy identifier or a custom `LockStore` implementation.
339
+ * @param context - Dependencies required by certain drivers (e.g., CacheManager).
340
+ * @throws {Error} If 'cache' driver is selected but no `CacheManager` is provided.
341
+ */
226
342
  constructor(driver, context) {
227
343
  if (typeof driver === "object") {
228
344
  this.store = driver;
@@ -237,15 +353,43 @@ var LockManager = class {
237
353
  this.store = new MemoryLockStore();
238
354
  }
239
355
  }
356
+ /**
357
+ * Attempts to acquire a lock. Fails if the key is already locked.
358
+ *
359
+ * @param key - Unique identifier for the lock.
360
+ * @param ttlSeconds - Time-to-live in seconds before the lock expires.
361
+ * @returns True if the lock was successfully acquired.
362
+ */
240
363
  async acquire(key, ttlSeconds) {
241
364
  return this.store.acquire(key, ttlSeconds);
242
365
  }
366
+ /**
367
+ * Explicitly releases a held lock.
368
+ *
369
+ * @param key - The lock identifier to remove.
370
+ * @returns Resolves when the lock is deleted.
371
+ */
243
372
  async release(key) {
244
373
  return this.store.release(key);
245
374
  }
375
+ /**
376
+ * Forcibly acquires or overwrites an existing lock.
377
+ *
378
+ * Used for execution locks where the latest attempt should take precedence
379
+ * if the previous one is deemed expired.
380
+ *
381
+ * @param key - Lock identifier.
382
+ * @param ttlSeconds - Expiration duration.
383
+ */
246
384
  async forceAcquire(key, ttlSeconds) {
247
385
  return this.store.forceAcquire(key, ttlSeconds);
248
386
  }
387
+ /**
388
+ * Checks if a specific lock currently exists and is not expired.
389
+ *
390
+ * @param key - Lock identifier.
391
+ * @returns True if the key is locked and active.
392
+ */
249
393
  async exists(key) {
250
394
  return this.store.exists(key);
251
395
  }
@@ -272,19 +416,55 @@ async function runProcess(command) {
272
416
  };
273
417
  }
274
418
  var Process = class {
419
+ /**
420
+ * Static alias for `runProcess`.
421
+ *
422
+ * @param command - Command to execute.
423
+ * @returns Process outcome.
424
+ */
275
425
  static async run(command) {
276
426
  return runProcess(command);
277
427
  }
278
428
  };
279
429
 
430
+ // src/utils/validation.ts
431
+ function parseTime(time) {
432
+ const timePattern = /^([0-2]\d):([0-5]\d)$/;
433
+ const match = time.match(timePattern);
434
+ if (!match) {
435
+ throw new Error(`Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`);
436
+ }
437
+ const hour = Number.parseInt(match[1], 10);
438
+ const minute = Number.parseInt(match[2], 10);
439
+ if (hour > 23) {
440
+ throw new Error(`Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`);
441
+ }
442
+ return { hour, minute };
443
+ }
444
+ function validateMinute(minute) {
445
+ if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
446
+ throw new Error(`Invalid minute: ${minute}. Expected integer 0-59`);
447
+ }
448
+ }
449
+ function validateDayOfWeek(dayOfWeek) {
450
+ if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
451
+ throw new Error(`Invalid day of week: ${dayOfWeek}. Expected 0-6 (Sunday=0, Saturday=6)`);
452
+ }
453
+ }
454
+ function validateDayOfMonth(dayOfMonth) {
455
+ if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
456
+ throw new Error(`Invalid day of month: ${dayOfMonth}. Expected 1-31`);
457
+ }
458
+ }
459
+
280
460
  // src/TaskSchedule.ts
281
461
  var TaskSchedule = class {
282
462
  task;
283
463
  /**
284
- * Create a new TaskSchedule instance.
464
+ * Initializes a new schedule instance with default settings.
285
465
  *
286
- * @param name - The unique name of the task.
287
- * @param callback - The function to execute.
466
+ * @param name - Unique task name used for identification and locking.
467
+ * @param callback - Logic to execute on schedule.
288
468
  */
289
469
  constructor(name, callback) {
290
470
  this.task = {
@@ -298,168 +478,217 @@ var TaskSchedule = class {
298
478
  // 5 minutes default
299
479
  background: false,
300
480
  // Wait for task to finish by default
481
+ preventOverlapping: false,
482
+ overlappingExpiresAt: 3600,
483
+ // 1 hour default
301
484
  onSuccessCallbacks: [],
302
485
  onFailureCallbacks: []
303
486
  };
304
487
  }
305
488
  // --- Frequency Methods ---
306
489
  /**
307
- * Set a custom cron expression.
490
+ * Configures a raw 5-part cron expression.
491
+ *
492
+ * @param expression - Cron string (e.g., "0 0 * * *").
493
+ * @returns The TaskSchedule instance for chaining.
494
+ * @throws {Error} If expression format is invalid or contains forbidden characters.
308
495
  *
309
- * @param expression - Standard cron expression (e.g., "* * * * *")
310
- * @returns The TaskSchedule instance.
496
+ * @example
497
+ * ```typescript
498
+ * schedule.cron('0 9-17 * * 1-5'); // 9-5 on weekdays
499
+ * ```
311
500
  */
312
501
  cron(expression) {
502
+ const parts = expression.trim().split(/\s+/);
503
+ if (parts.length !== 5) {
504
+ throw new Error(
505
+ `Invalid cron expression: "${expression}". Expected 5 parts (minute hour day month weekday), got ${parts.length}.`
506
+ );
507
+ }
508
+ const pattern = /^[0-9,\-/*?L#A-Za-z]+$/;
509
+ for (let i = 0; i < 5; i++) {
510
+ if (!pattern.test(parts[i])) {
511
+ throw new Error(
512
+ `Invalid cron expression: "${expression}". Part ${i + 1} ("${parts[i]}") contains invalid characters.`
513
+ );
514
+ }
515
+ }
313
516
  this.task.expression = expression;
314
517
  return this;
315
518
  }
316
519
  /**
317
- * Run the task every minute.
520
+ * Schedules execution every minute.
318
521
  *
319
- * @returns The TaskSchedule instance.
522
+ * @returns The TaskSchedule instance for chaining.
320
523
  */
321
524
  everyMinute() {
322
525
  return this.cron("* * * * *");
323
526
  }
324
527
  /**
325
- * Run the task every 5 minutes.
528
+ * Schedules execution every five minutes.
326
529
  *
327
- * @returns The TaskSchedule instance.
530
+ * @returns The TaskSchedule instance for chaining.
328
531
  */
329
532
  everyFiveMinutes() {
330
533
  return this.cron("*/5 * * * *");
331
534
  }
332
535
  /**
333
- * Run the task every 10 minutes.
536
+ * Schedules execution every ten minutes.
334
537
  *
335
- * @returns The TaskSchedule instance.
538
+ * @returns The TaskSchedule instance for chaining.
336
539
  */
337
540
  everyTenMinutes() {
338
541
  return this.cron("*/10 * * * *");
339
542
  }
340
543
  /**
341
- * Run the task every 15 minutes.
544
+ * Schedules execution every fifteen minutes.
342
545
  *
343
- * @returns The TaskSchedule instance.
546
+ * @returns The TaskSchedule instance for chaining.
344
547
  */
345
548
  everyFifteenMinutes() {
346
549
  return this.cron("*/15 * * * *");
347
550
  }
348
551
  /**
349
- * Run the task every 30 minutes.
552
+ * Schedules execution every thirty minutes.
350
553
  *
351
- * @returns The TaskSchedule instance.
554
+ * @returns The TaskSchedule instance for chaining.
352
555
  */
353
556
  everyThirtyMinutes() {
354
557
  return this.cron("0,30 * * * *");
355
558
  }
356
559
  /**
357
- * Run the task hourly (at minute 0).
560
+ * Schedules execution hourly at the top of the hour.
358
561
  *
359
- * @returns The TaskSchedule instance.
562
+ * @returns The TaskSchedule instance for chaining.
360
563
  */
361
564
  hourly() {
362
565
  return this.cron("0 * * * *");
363
566
  }
364
567
  /**
365
- * Run the task hourly at a specific minute.
568
+ * Schedules execution hourly at a specific minute.
366
569
  *
367
- * @param minute - Minute (0-59)
368
- * @returns The TaskSchedule instance.
570
+ * @param minute - Target minute (0-59).
571
+ * @returns The TaskSchedule instance for chaining.
572
+ * @throws {Error} If minute is outside 0-59 range.
369
573
  */
370
574
  hourlyAt(minute) {
575
+ validateMinute(minute);
371
576
  return this.cron(`${minute} * * * *`);
372
577
  }
373
578
  /**
374
- * Run the task daily at midnight (00:00).
579
+ * Schedules execution daily at midnight.
375
580
  *
376
- * @returns The TaskSchedule instance.
581
+ * @returns The TaskSchedule instance for chaining.
377
582
  */
378
583
  daily() {
379
584
  return this.cron("0 0 * * *");
380
585
  }
381
586
  /**
382
- * Run the task daily at a specific time.
587
+ * Schedules execution daily at a specific time.
588
+ *
589
+ * @param time - 24-hour time string in "HH:mm" format.
590
+ * @returns The TaskSchedule instance for chaining.
591
+ * @throws {Error} If time format is invalid or values are out of range.
383
592
  *
384
- * @param time - Time in "HH:mm" format (24h)
385
- * @returns The TaskSchedule instance.
593
+ * @example
594
+ * ```typescript
595
+ * schedule.dailyAt('14:30');
596
+ * ```
386
597
  */
387
598
  dailyAt(time) {
388
- const [hour, minute] = time.split(":");
389
- return this.cron(`${Number(minute)} ${Number(hour)} * * *`);
599
+ const { hour, minute } = parseTime(time);
600
+ return this.cron(`${minute} ${hour} * * *`);
390
601
  }
391
602
  /**
392
- * Run the task weekly on Sunday at midnight.
603
+ * Schedules execution weekly on Sunday at midnight.
393
604
  *
394
- * @returns The TaskSchedule instance.
605
+ * @returns The TaskSchedule instance for chaining.
395
606
  */
396
607
  weekly() {
397
608
  return this.cron("0 0 * * 0");
398
609
  }
399
610
  /**
400
- * Run the task weekly on a specific day and time.
611
+ * Schedules execution weekly on a specific day and time.
612
+ *
613
+ * @param day - Day index (0-6, where 0 is Sunday).
614
+ * @param time - Optional 24-hour time string "HH:mm" (default "00:00").
615
+ * @returns The TaskSchedule instance for chaining.
616
+ * @throws {Error} If day index or time format is invalid.
401
617
  *
402
- * @param day - Day of week (0-7, 0 or 7 is Sunday)
403
- * @param time - Time in "HH:mm" format (default "00:00")
404
- * @returns The TaskSchedule instance.
618
+ * @example
619
+ * ```typescript
620
+ * schedule.weeklyOn(1, '09:00'); // Mondays at 9 AM
621
+ * ```
405
622
  */
406
623
  weeklyOn(day, time = "00:00") {
407
- const [hour, minute] = time.split(":");
408
- return this.cron(`${Number(minute)} ${Number(hour)} * * ${day}`);
624
+ validateDayOfWeek(day);
625
+ const { hour, minute } = parseTime(time);
626
+ return this.cron(`${minute} ${hour} * * ${day}`);
409
627
  }
410
628
  /**
411
- * Run the task monthly on the 1st day at midnight.
629
+ * Schedules execution monthly on the first day at midnight.
412
630
  *
413
- * @returns The TaskSchedule instance.
631
+ * @returns The TaskSchedule instance for chaining.
414
632
  */
415
633
  monthly() {
416
634
  return this.cron("0 0 1 * *");
417
635
  }
418
636
  /**
419
- * Run the task monthly on a specific day and time.
637
+ * Schedules execution monthly on a specific day and time.
420
638
  *
421
- * @param day - Day of month (1-31)
422
- * @param time - Time in "HH:mm" format (default "00:00")
423
- * @returns The TaskSchedule instance.
639
+ * @param day - Day of month (1-31).
640
+ * @param time - Optional 24-hour time string "HH:mm" (default "00:00").
641
+ * @returns The TaskSchedule instance for chaining.
642
+ * @throws {Error} If day or time format is invalid.
424
643
  */
425
644
  monthlyOn(day, time = "00:00") {
426
- const [hour, minute] = time.split(":");
427
- return this.cron(`${Number(minute)} ${Number(hour)} ${day} * *`);
645
+ validateDayOfMonth(day);
646
+ const { hour, minute } = parseTime(time);
647
+ return this.cron(`${minute} ${hour} ${day} * *`);
428
648
  }
429
649
  // --- Constraints ---
430
650
  /**
431
- * Set the timezone for the task execution.
651
+ * Specifies the timezone for evaluating schedule frequency.
432
652
  *
433
- * @param timezone - Timezone identifier (e.g., "Asia/Taipei", "UTC")
434
- * @returns The TaskSchedule instance.
653
+ * @param timezone - IANA timezone identifier (e.g., "America/New_York").
654
+ * @returns The TaskSchedule instance for chaining.
655
+ * @throws {Error} If the timezone identifier is not recognized by the system.
435
656
  */
436
657
  timezone(timezone) {
658
+ try {
659
+ (/* @__PURE__ */ new Date()).toLocaleString("en-US", { timeZone: timezone });
660
+ } catch {
661
+ throw new Error(
662
+ `Invalid timezone: "${timezone}". See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid values.`
663
+ );
664
+ }
437
665
  this.task.timezone = timezone;
438
666
  return this;
439
667
  }
440
668
  /**
441
- * Set the time of execution for the current frequency.
442
- * Useful when chaining with daily(), weekly(), etc.
669
+ * Modifies the execution time of the existing frequency.
670
+ * Typically used after frequency methods like daily() or weekly().
443
671
  *
444
- * @param time - Time in "HH:mm" format
445
- * @returns The TaskSchedule instance.
672
+ * @param time - 24-hour time string in "HH:mm" format.
673
+ * @returns The TaskSchedule instance for chaining.
674
+ * @throws {Error} If time format is invalid.
446
675
  */
447
676
  at(time) {
448
- const [hour, minute] = time.split(":");
677
+ const { hour, minute } = parseTime(time);
449
678
  const parts = this.task.expression.split(" ");
450
679
  if (parts.length >= 5) {
451
- parts[0] = String(Number(minute));
452
- parts[1] = String(Number(hour));
680
+ parts[0] = String(minute);
681
+ parts[1] = String(hour);
453
682
  this.task.expression = parts.join(" ");
454
683
  }
455
684
  return this;
456
685
  }
457
686
  /**
458
- * Ensure task runs on only one server at a time (Distributed Locking).
459
- * Requires a configured LockStore (Cache or Redis).
687
+ * Enables distributed locking to ensure only one instance runs globally.
688
+ * Prevents duplicate execution when multiple servers are polling the same schedule.
460
689
  *
461
- * @param lockTtlSeconds - Time in seconds to hold the lock (default 300)
462
- * @returns The TaskSchedule instance.
690
+ * @param lockTtlSeconds - Duration in seconds to hold the window lock (default 300).
691
+ * @returns The TaskSchedule instance for chaining.
463
692
  */
464
693
  onOneServer(lockTtlSeconds = 300) {
465
694
  this.task.shouldRunOnOneServer = true;
@@ -467,41 +696,85 @@ var TaskSchedule = class {
467
696
  return this;
468
697
  }
469
698
  /**
470
- * Alias for onOneServer.
471
- * Prevents overlapping executions of the same task.
699
+ * Prevents a new task instance from starting if the previous one is still running.
472
700
  *
473
- * @param expiresAt - Lock TTL in seconds
474
- * @returns The TaskSchedule instance.
701
+ * Unlike `onOneServer()` which uses a time-window lock, this uses an execution lock
702
+ * to handle long-running tasks that might span multiple scheduling intervals.
703
+ *
704
+ * @param expiresAt - Max duration in seconds to keep the execution lock (default 3600).
705
+ * @returns The TaskSchedule instance for chaining.
706
+ *
707
+ * @example
708
+ * ```typescript
709
+ * // Prevents overlapping if the heavy operation takes > 1 minute
710
+ * scheduler.task('data-sync', heavySync)
711
+ * .everyMinute()
712
+ * .withoutOverlapping();
713
+ * ```
475
714
  */
476
- withoutOverlapping(expiresAt = 300) {
477
- return this.onOneServer(expiresAt);
715
+ withoutOverlapping(expiresAt = 3600) {
716
+ this.task.preventOverlapping = true;
717
+ this.task.overlappingExpiresAt = expiresAt;
718
+ return this;
478
719
  }
479
720
  /**
480
- * Run task in background (do not wait for completion in the loop).
481
- * Note: In Node.js non-blocking environment this is largely semantic,
482
- * but affects how we handle error catching and lock release.
721
+ * Executes the task in background mode.
722
+ * The scheduler will not wait for this task to finish before proceeding to the next.
483
723
  *
484
- * @returns The TaskSchedule instance.
724
+ * @returns The TaskSchedule instance for chaining.
485
725
  */
486
726
  runInBackground() {
487
727
  this.task.background = true;
488
728
  return this;
489
729
  }
490
730
  /**
491
- * Restrict task execution to a specific node role.
731
+ * Restricts task execution to nodes with a specific role.
492
732
  *
493
- * @param role - The required node role (e.g., 'api', 'worker')
494
- * @returns The TaskSchedule instance.
733
+ * @param role - Target role identifier (e.g., "worker").
734
+ * @returns The TaskSchedule instance for chaining.
495
735
  */
496
736
  onNode(role) {
497
737
  this.task.nodeRole = role;
498
738
  return this;
499
739
  }
500
740
  /**
501
- * Set the command string for exec tasks.
741
+ * Defines a maximum execution time for the task.
742
+ *
743
+ * @param ms - Timeout duration in milliseconds.
744
+ * @returns The TaskSchedule instance for chaining.
745
+ * @throws {Error} If timeout is not a positive number.
746
+ */
747
+ timeout(ms) {
748
+ if (ms <= 0) {
749
+ throw new Error("Timeout must be a positive number");
750
+ }
751
+ this.task.timeout = ms;
752
+ return this;
753
+ }
754
+ /**
755
+ * Configures automatic retry behavior for failed executions.
756
+ *
757
+ * @param attempts - Max number of retries (default 3).
758
+ * @param delayMs - Wait time between retries in milliseconds (default 1000).
759
+ * @returns The TaskSchedule instance for chaining.
760
+ * @throws {Error} If attempts or delay are negative.
761
+ */
762
+ retry(attempts = 3, delayMs = 1e3) {
763
+ if (attempts < 0) {
764
+ throw new Error("Retry attempts must be non-negative");
765
+ }
766
+ if (delayMs < 0) {
767
+ throw new Error("Retry delay must be non-negative");
768
+ }
769
+ this.task.retries = attempts;
770
+ this.task.retryDelay = delayMs;
771
+ return this;
772
+ }
773
+ /**
774
+ * Sets the shell command for execution-based tasks.
502
775
  *
503
776
  * @param command - The command string.
504
- * @returns The TaskSchedule instance.
777
+ * @returns The TaskSchedule instance for chaining.
505
778
  * @internal
506
779
  */
507
780
  setCommand(command) {
@@ -510,39 +783,39 @@ var TaskSchedule = class {
510
783
  }
511
784
  // --- Hooks ---
512
785
  /**
513
- * Register a callback to run on task success.
786
+ * Registers a callback to execute upon successful task completion.
514
787
  *
515
- * @param callback - The callback function.
516
- * @returns The TaskSchedule instance.
788
+ * @param callback - Function to run on success.
789
+ * @returns The TaskSchedule instance for chaining.
517
790
  */
518
791
  onSuccess(callback) {
519
792
  this.task.onSuccessCallbacks.push(callback);
520
793
  return this;
521
794
  }
522
795
  /**
523
- * Register a callback to run on task failure.
796
+ * Registers a callback to execute when the task fails.
524
797
  *
525
- * @param callback - The callback function.
526
- * @returns The TaskSchedule instance.
798
+ * @param callback - Function to run on failure.
799
+ * @returns The TaskSchedule instance for chaining.
527
800
  */
528
801
  onFailure(callback) {
529
802
  this.task.onFailureCallbacks.push(callback);
530
803
  return this;
531
804
  }
532
805
  /**
533
- * Set a description for the task (useful for listing).
806
+ * Attaches a human-readable description to the task.
534
807
  *
535
- * @param _text - The description text.
536
- * @returns The TaskSchedule instance.
808
+ * @param _text - Description text.
809
+ * @returns The TaskSchedule instance for chaining.
537
810
  */
538
811
  description(_text) {
539
812
  return this;
540
813
  }
541
814
  // --- Accessor ---
542
815
  /**
543
- * Get the underlying task configuration.
816
+ * Returns the final task configuration.
544
817
  *
545
- * @returns The ScheduledTask object.
818
+ * @returns The constructed ScheduledTask object.
546
819
  */
547
820
  getTask() {
548
821
  return this.task;
@@ -551,6 +824,14 @@ var TaskSchedule = class {
551
824
 
552
825
  // src/SchedulerManager.ts
553
826
  var SchedulerManager = class {
827
+ /**
828
+ * Initializes the scheduler engine.
829
+ *
830
+ * @param lockManager - Backend for distributed locking.
831
+ * @param logger - Optional logger for operational visibility.
832
+ * @param hooks - Optional manager for lifecycle event hooks.
833
+ * @param currentNodeRole - Role identifier for the local node (used for filtering).
834
+ */
554
835
  constructor(lockManager, logger, hooks, currentNodeRole) {
555
836
  this.lockManager = lockManager;
556
837
  this.logger = logger;
@@ -559,11 +840,18 @@ var SchedulerManager = class {
559
840
  }
560
841
  tasks = [];
561
842
  /**
562
- * Define a new scheduled task.
843
+ * Registers a new callback-based scheduled task.
844
+ *
845
+ * @param name - Unique task name used for identification and locking.
846
+ * @param callback - Asynchronous function containing the task logic.
847
+ * @returns A fluent TaskSchedule instance for further configuration.
563
848
  *
564
- * @param name - Unique name for the task
565
- * @param callback - Function to execute
566
- * @returns The newly created TaskSchedule.
849
+ * @example
850
+ * ```typescript
851
+ * scheduler.task('process-queues', async () => {
852
+ * await queue.process();
853
+ * }).everyMinute();
854
+ * ```
567
855
  */
568
856
  task(name, callback) {
569
857
  const task = new TaskSchedule(name, callback);
@@ -571,11 +859,21 @@ var SchedulerManager = class {
571
859
  return task;
572
860
  }
573
861
  /**
574
- * Define a new scheduled command execution task.
862
+ * Registers a shell command as a scheduled task.
575
863
  *
576
- * @param name - Unique name for the task
577
- * @param command - Shell command to execute
578
- * @returns The newly created TaskSchedule.
864
+ * Executes the command via `sh -c` on matching nodes.
865
+ *
866
+ * @param name - Unique identifier for the command task.
867
+ * @param command - Raw shell command string.
868
+ * @returns A fluent TaskSchedule instance.
869
+ * @throws {Error} If the shell command returns a non-zero exit code during execution.
870
+ *
871
+ * @example
872
+ * ```typescript
873
+ * scheduler.exec('log-rotate', 'logrotate /etc/logrotate.conf')
874
+ * .daily()
875
+ * .onNode('worker');
876
+ * ```
579
877
  */
580
878
  exec(name, command) {
581
879
  const task = new TaskSchedule(name, async () => {
@@ -589,27 +887,29 @@ var SchedulerManager = class {
589
887
  return task;
590
888
  }
591
889
  /**
592
- * Add a pre-configured task schedule object.
890
+ * Injects a pre-configured TaskSchedule instance into the registry.
593
891
  *
594
- * @param schedule - The task schedule to add.
892
+ * @param schedule - Configured task schedule to register.
595
893
  */
596
894
  add(schedule) {
597
895
  this.tasks.push(schedule);
598
896
  }
599
897
  /**
600
- * Get all registered task definitions.
898
+ * Exports all registered tasks for external inspection or serialization.
601
899
  *
602
- * @returns An array of scheduled tasks.
900
+ * @returns An array of raw task configurations.
603
901
  */
604
902
  getTasks() {
605
903
  return this.tasks.map((t) => t.getTask());
606
904
  }
607
905
  /**
608
- * Trigger the scheduler to check and run due tasks.
609
- * This is typically called every minute by a system cron or worker loop.
906
+ * Main evaluation loop that triggers tasks due for execution.
907
+ *
908
+ * Should be invoked every minute by a system timer (systemd/cron) or daemon.
909
+ * Performs frequency checks, role filtering, and parallel execution.
610
910
  *
611
- * @param date - The current reference date (default: now)
612
- * @returns A promise that resolves when the scheduler run is complete.
911
+ * @param date - Reference time for cron evaluation (default: current time).
912
+ * @returns Resolves when all due tasks have been initiated.
613
913
  */
614
914
  async run(date = /* @__PURE__ */ new Date()) {
615
915
  await this.hooks?.doAction("scheduler:run:start", { date });
@@ -621,6 +921,14 @@ var SchedulerManager = class {
621
921
  }
622
922
  }
623
923
  if (dueTasks.length > 0) {
924
+ this.logger?.debug(`[Horizon] Found ${dueTasks.length} due task(s) to execute`, {
925
+ tasks: dueTasks.map((t) => ({
926
+ name: t.name,
927
+ expression: t.expression,
928
+ background: t.background,
929
+ oneServer: t.shouldRunOnOneServer
930
+ }))
931
+ });
624
932
  }
625
933
  for (const task of dueTasks) {
626
934
  this.runTask(task, date).catch((err) => {
@@ -630,21 +938,45 @@ var SchedulerManager = class {
630
938
  await this.hooks?.doAction("scheduler:run:complete", { date, dueCount: dueTasks.length });
631
939
  }
632
940
  /**
633
- * Execute a specific task with locking logic.
941
+ * Executes an individual task after validating execution constraints.
942
+ *
943
+ * Evaluates node roles, overlapping prevention, and distributed time-window locks
944
+ * before initiating the actual task logic.
945
+ *
946
+ * @param task - Target task configuration.
947
+ * @param date - Reference time used for lock key generation.
634
948
  *
635
- * @param task - The task to execute.
636
949
  * @internal
637
950
  */
638
951
  async runTask(task, date = /* @__PURE__ */ new Date()) {
639
952
  if (task.nodeRole && this.currentNodeRole && task.nodeRole !== this.currentNodeRole) {
640
953
  return;
641
954
  }
642
- let acquiredLock = false;
955
+ const runningLockKey = `task:running:${task.name}`;
956
+ if (task.preventOverlapping) {
957
+ const isRunning = await this.lockManager.exists(runningLockKey);
958
+ if (isRunning) {
959
+ this.logger?.debug(
960
+ `[Horizon] Task "${task.name}" is still running, skipping this execution`
961
+ );
962
+ await this.hooks?.doAction("scheduler:task:skipped", {
963
+ name: task.name,
964
+ reason: "overlapping",
965
+ timestamp: date
966
+ });
967
+ return;
968
+ }
969
+ await this.lockManager.forceAcquire(runningLockKey, task.overlappingExpiresAt);
970
+ }
971
+ let acquiredTimeLock = false;
643
972
  const timestamp = `${date.getFullYear()}${(date.getMonth() + 1).toString().padStart(2, "0")}${date.getDate().toString().padStart(2, "0")}${date.getHours().toString().padStart(2, "0")}${date.getMinutes().toString().padStart(2, "0")}`;
644
- const lockKey = `task:${task.name}:${timestamp}`;
973
+ const timeLockKey = `task:${task.name}:${timestamp}`;
645
974
  if (task.shouldRunOnOneServer) {
646
- acquiredLock = await this.lockManager.acquire(lockKey, task.lockTtl);
647
- if (!acquiredLock) {
975
+ acquiredTimeLock = await this.lockManager.acquire(timeLockKey, task.lockTtl);
976
+ if (!acquiredTimeLock) {
977
+ if (task.preventOverlapping) {
978
+ await this.lockManager.release(runningLockKey);
979
+ }
648
980
  return;
649
981
  }
650
982
  }
@@ -652,59 +984,118 @@ var SchedulerManager = class {
652
984
  if (task.background) {
653
985
  this.executeTask(task).catch((err) => {
654
986
  this.logger?.error(`Background task ${task.name} failed`, err);
987
+ }).finally(async () => {
988
+ if (task.preventOverlapping) {
989
+ await this.lockManager.release(runningLockKey);
990
+ }
655
991
  });
656
992
  } else {
657
993
  await this.executeTask(task);
994
+ if (task.preventOverlapping) {
995
+ await this.lockManager.release(runningLockKey);
996
+ }
658
997
  }
659
998
  } catch (err) {
660
- if (acquiredLock) {
661
- await this.lockManager.release(lockKey);
999
+ if (task.preventOverlapping) {
1000
+ await this.lockManager.release(runningLockKey);
1001
+ }
1002
+ if (acquiredTimeLock) {
1003
+ await this.lockManager.release(timeLockKey);
662
1004
  }
663
1005
  throw err;
664
1006
  }
665
1007
  }
666
1008
  /**
667
- * Execute the task callback and handle hooks.
1009
+ * Internal wrapper for executing task logic with reliability controls.
1010
+ *
1011
+ * Handles timeouts, retries, and emits lifecycle hooks for monitoring.
668
1012
  *
669
- * @param task - The task to execute.
1013
+ * @param task - The scheduled task to execute.
1014
+ * @returns Resolves when the task (and its retries) completes or fails permanently.
1015
+ *
1016
+ * @internal
670
1017
  */
671
1018
  async executeTask(task) {
672
1019
  const startTime = Date.now();
673
1020
  await this.hooks?.doAction("scheduler:task:start", { name: task.name, startTime });
674
- try {
675
- await task.callback();
676
- const duration = Date.now() - startTime;
677
- await this.hooks?.doAction("scheduler:task:success", { name: task.name, duration });
678
- for (const cb of task.onSuccessCallbacks) {
679
- try {
680
- await cb({ name: task.name });
681
- } catch {
682
- }
1021
+ const timeout = task.timeout || 36e5;
1022
+ const maxRetries = task.retries ?? 0;
1023
+ const retryDelay = task.retryDelay ?? 1e3;
1024
+ let lastError;
1025
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1026
+ if (attempt > 0) {
1027
+ this.logger?.info(
1028
+ `[Horizon] Retrying task "${task.name}" (attempt ${attempt}/${maxRetries})...`
1029
+ );
1030
+ await this.hooks?.doAction("scheduler:task:retry", {
1031
+ name: task.name,
1032
+ attempt,
1033
+ maxRetries,
1034
+ error: lastError,
1035
+ delay: retryDelay
1036
+ });
1037
+ await new Promise((resolve) => setTimeout(resolve, retryDelay));
683
1038
  }
684
- } catch (err) {
685
- const duration = Date.now() - startTime;
686
- this.logger?.error(`Task ${task.name} failed`, err);
687
- await this.hooks?.doAction("scheduler:task:failure", {
688
- name: task.name,
689
- error: err,
690
- duration
1039
+ let timeoutId;
1040
+ const timeoutPromise = new Promise((_, reject) => {
1041
+ timeoutId = setTimeout(() => {
1042
+ reject(new Error(`Task "${task.name}" timed out after ${timeout}ms`));
1043
+ }, timeout);
691
1044
  });
692
- for (const cb of task.onFailureCallbacks) {
693
- try {
694
- await cb(err);
695
- } catch {
1045
+ try {
1046
+ await Promise.race([task.callback(), timeoutPromise]);
1047
+ const duration2 = Date.now() - startTime;
1048
+ await this.hooks?.doAction("scheduler:task:success", {
1049
+ name: task.name,
1050
+ duration: duration2,
1051
+ attempts: attempt + 1
1052
+ });
1053
+ for (const cb of task.onSuccessCallbacks) {
1054
+ try {
1055
+ await cb({ name: task.name });
1056
+ } catch {
1057
+ }
1058
+ }
1059
+ return;
1060
+ } catch (err) {
1061
+ lastError = err;
1062
+ this.logger?.error(`Task ${task.name} failed (attempt ${attempt + 1})`, err);
1063
+ } finally {
1064
+ if (timeoutId) {
1065
+ clearTimeout(timeoutId);
696
1066
  }
697
1067
  }
698
1068
  }
1069
+ const duration = Date.now() - startTime;
1070
+ await this.hooks?.doAction("scheduler:task:failure", {
1071
+ name: task.name,
1072
+ error: lastError,
1073
+ duration,
1074
+ attempts: maxRetries + 1
1075
+ });
1076
+ for (const cb of task.onFailureCallbacks) {
1077
+ try {
1078
+ await cb(lastError);
1079
+ } catch {
1080
+ }
1081
+ }
699
1082
  }
700
1083
  };
701
1084
 
702
1085
  // src/OrbitHorizon.ts
703
1086
  var OrbitHorizon = class {
704
1087
  /**
705
- * Install the Horizon Orbit into PlanetCore.
1088
+ * Integrates the Horizon scheduler into the PlanetCore lifecycle.
1089
+ *
1090
+ * Orchestrates the initialization sequence:
1091
+ * 1. Resolves lock backend (memory or cache-based).
1092
+ * 2. Instantiates SchedulerManager with global dependencies (logger, hooks).
1093
+ * 3. Registers the scheduler in the IoC container for CLI and global access.
1094
+ * 4. Injects the scheduler into the request context via middleware.
1095
+ *
1096
+ * @param core - PlanetCore instance providing configuration and service container.
706
1097
  *
707
- * @param core - The PlanetCore instance.
1098
+ * @throws {Error} If the cache driver is explicitly requested but `CacheManager` is unavailable.
708
1099
  */
709
1100
  install(core) {
710
1101
  const config = core.config.get("scheduler", {});
@@ -713,7 +1104,7 @@ var OrbitHorizon = class {
713
1104
  const nodeRole = config.nodeRole;
714
1105
  let lockManager;
715
1106
  if (lockDriver === "cache") {
716
- const cacheManager = core.services.get("cache");
1107
+ const cacheManager = core.container.make("cache");
717
1108
  if (!cacheManager) {
718
1109
  core.logger.warn(
719
1110
  "[OrbitHorizon] Cache driver requested but cache service not found (ensure orbit-cache is loaded first). Falling back to Memory lock."
@@ -726,7 +1117,7 @@ var OrbitHorizon = class {
726
1117
  lockManager = new LockManager("memory");
727
1118
  }
728
1119
  const scheduler = new SchedulerManager(lockManager, core.logger, core.hooks, nodeRole);
729
- core.services.set(exposeAs, scheduler);
1120
+ core.container.instance(exposeAs, scheduler);
730
1121
  core.adapter.use("*", async (c, next) => {
731
1122
  c.set("scheduler", scheduler);
732
1123
  return await next();