@gravito/horizon 3.0.1 → 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/README.md +93 -80
- package/README.zh-TW.md +102 -8
- package/dist/index.cjs +603 -140
- package/dist/index.d.cts +563 -188
- package/dist/index.d.ts +563 -188
- package/dist/index.js +603 -140
- package/package.json +7 -5
package/dist/index.js
CHANGED
|
@@ -3,7 +3,18 @@ import "./chunk-MCKGQKYU.js";
|
|
|
3
3
|
// src/SimpleCronParser.ts
|
|
4
4
|
var SimpleCronParser = class {
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
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
|
+
* ```
|
|
7
18
|
*/
|
|
8
19
|
static isDue(expression, timezone = "UTC", date = /* @__PURE__ */ new Date()) {
|
|
9
20
|
const parts = expression.trim().split(/\s+/);
|
|
@@ -18,8 +29,22 @@ var SimpleCronParser = class {
|
|
|
18
29
|
const dayOfWeek = targetDate.getDay();
|
|
19
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);
|
|
20
31
|
}
|
|
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
|
+
*/
|
|
21
44
|
static match(pattern, value, _min, _max, isDayOfWeek = false) {
|
|
22
|
-
if (pattern === "*")
|
|
45
|
+
if (pattern === "*") {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
23
48
|
if (pattern.includes(",")) {
|
|
24
49
|
return pattern.split(",").some((p) => this.match(p, value, _min, _max, isDayOfWeek));
|
|
25
50
|
}
|
|
@@ -27,7 +52,9 @@ var SimpleCronParser = class {
|
|
|
27
52
|
if (stepMatch) {
|
|
28
53
|
const range = stepMatch[1];
|
|
29
54
|
const step = parseInt(stepMatch[3], 10);
|
|
30
|
-
if (range === "*")
|
|
55
|
+
if (range === "*") {
|
|
56
|
+
return value % step === 0;
|
|
57
|
+
}
|
|
31
58
|
const [rMin, rMax] = range.split("-").map((n) => parseInt(n, 10));
|
|
32
59
|
return value >= rMin && value <= rMax && (value - rMin) % step === 0;
|
|
33
60
|
}
|
|
@@ -36,13 +63,27 @@ var SimpleCronParser = class {
|
|
|
36
63
|
return value >= rMin && value <= rMax;
|
|
37
64
|
}
|
|
38
65
|
const patternVal = parseInt(pattern, 10);
|
|
39
|
-
if (isDayOfWeek && patternVal === 7 && value === 0)
|
|
66
|
+
if (isDayOfWeek && patternVal === 7 && value === 0) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
40
69
|
return patternVal === value;
|
|
41
70
|
}
|
|
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
|
+
*/
|
|
42
81
|
static getDateInTimezone(date, timezone) {
|
|
43
82
|
try {
|
|
44
83
|
const tzDate = new Date(date.toLocaleString("en-US", { timeZone: timezone }));
|
|
45
|
-
if (isNaN(tzDate.getTime()))
|
|
84
|
+
if (Number.isNaN(tzDate.getTime())) {
|
|
85
|
+
throw new Error();
|
|
86
|
+
}
|
|
46
87
|
return tzDate;
|
|
47
88
|
} catch {
|
|
48
89
|
throw new Error(`Invalid timezone: ${timezone}`);
|
|
@@ -52,8 +93,26 @@ var SimpleCronParser = class {
|
|
|
52
93
|
|
|
53
94
|
// src/CronParser.ts
|
|
54
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;
|
|
55
101
|
/**
|
|
56
|
-
*
|
|
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.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* ```typescript
|
|
114
|
+
* const next = await CronParser.nextDate('0 0 * * *');
|
|
115
|
+
* ```
|
|
57
116
|
*/
|
|
58
117
|
static async nextDate(expression, timezone = "UTC", currentDate = /* @__PURE__ */ new Date()) {
|
|
59
118
|
try {
|
|
@@ -68,9 +127,47 @@ var CronParser = class {
|
|
|
68
127
|
}
|
|
69
128
|
}
|
|
70
129
|
/**
|
|
71
|
-
*
|
|
130
|
+
* Determines if a task is due for execution at the specified time.
|
|
131
|
+
*
|
|
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.
|
|
72
139
|
*/
|
|
73
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) {
|
|
74
171
|
try {
|
|
75
172
|
return SimpleCronParser.isDue(expression, timezone, currentDate);
|
|
76
173
|
} catch (_e) {
|
|
@@ -88,6 +185,33 @@ var CronParser = class {
|
|
|
88
185
|
return false;
|
|
89
186
|
}
|
|
90
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
|
+
*/
|
|
91
215
|
static minuteMatches(date1, date2) {
|
|
92
216
|
return date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate() && date1.getHours() === date2.getHours() && date1.getMinutes() === date2.getMinutes();
|
|
93
217
|
}
|
|
@@ -95,22 +219,55 @@ var CronParser = class {
|
|
|
95
219
|
|
|
96
220
|
// src/locks/CacheLockStore.ts
|
|
97
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
|
+
*/
|
|
98
228
|
constructor(cache, prefix = "scheduler:lock:") {
|
|
99
229
|
this.cache = cache;
|
|
100
230
|
this.prefix = prefix;
|
|
101
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Computes the fully qualified cache key.
|
|
234
|
+
*
|
|
235
|
+
* @internal
|
|
236
|
+
*/
|
|
102
237
|
getKey(key) {
|
|
103
238
|
return this.prefix + key;
|
|
104
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Atomic 'add' operation ensures only one node succeeds.
|
|
242
|
+
*
|
|
243
|
+
* @param key - Lock key.
|
|
244
|
+
* @param ttlSeconds - Expiration.
|
|
245
|
+
*/
|
|
105
246
|
async acquire(key, ttlSeconds) {
|
|
106
247
|
return this.cache.add(this.getKey(key), "LOCKED", ttlSeconds);
|
|
107
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Removes the lock key from cache.
|
|
251
|
+
*
|
|
252
|
+
* @param key - Lock key.
|
|
253
|
+
*/
|
|
108
254
|
async release(key) {
|
|
109
255
|
await this.cache.forget(this.getKey(key));
|
|
110
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Overwrites the lock key, effectively resetting the TTL.
|
|
259
|
+
*
|
|
260
|
+
* @param key - Lock key.
|
|
261
|
+
* @param ttlSeconds - New expiration.
|
|
262
|
+
*/
|
|
111
263
|
async forceAcquire(key, ttlSeconds) {
|
|
112
264
|
await this.cache.put(this.getKey(key), "LOCKED", ttlSeconds);
|
|
113
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Validates if the lock key is present in the cache.
|
|
268
|
+
*
|
|
269
|
+
* @param key - Lock key.
|
|
270
|
+
*/
|
|
114
271
|
async exists(key) {
|
|
115
272
|
return this.cache.has(this.getKey(key));
|
|
116
273
|
}
|
|
@@ -118,7 +275,14 @@ var CacheLockStore = class {
|
|
|
118
275
|
|
|
119
276
|
// src/locks/MemoryLockStore.ts
|
|
120
277
|
var MemoryLockStore = class {
|
|
278
|
+
/** Map of lock keys to their expiration timestamps (ms). */
|
|
121
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
|
+
*/
|
|
122
286
|
async acquire(key, ttlSeconds) {
|
|
123
287
|
const NOW = Date.now();
|
|
124
288
|
const expiresAt = this.locks.get(key);
|
|
@@ -128,12 +292,30 @@ var MemoryLockStore = class {
|
|
|
128
292
|
this.locks.set(key, NOW + ttlSeconds * 1e3);
|
|
129
293
|
return true;
|
|
130
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Deletes the lock from local memory.
|
|
297
|
+
*
|
|
298
|
+
* @param key - Lock identifier.
|
|
299
|
+
*/
|
|
131
300
|
async release(key) {
|
|
132
301
|
this.locks.delete(key);
|
|
133
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* Sets or overwrites a local lock.
|
|
305
|
+
*
|
|
306
|
+
* @param key - Lock identifier.
|
|
307
|
+
* @param ttlSeconds - Expiration.
|
|
308
|
+
*/
|
|
134
309
|
async forceAcquire(key, ttlSeconds) {
|
|
135
310
|
this.locks.set(key, Date.now() + ttlSeconds * 1e3);
|
|
136
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
|
+
*/
|
|
137
319
|
async exists(key) {
|
|
138
320
|
const expiresAt = this.locks.get(key);
|
|
139
321
|
if (!expiresAt) {
|
|
@@ -150,6 +332,13 @@ var MemoryLockStore = class {
|
|
|
150
332
|
// src/locks/LockManager.ts
|
|
151
333
|
var LockManager = class {
|
|
152
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
|
+
*/
|
|
153
342
|
constructor(driver, context) {
|
|
154
343
|
if (typeof driver === "object") {
|
|
155
344
|
this.store = driver;
|
|
@@ -164,15 +353,43 @@ var LockManager = class {
|
|
|
164
353
|
this.store = new MemoryLockStore();
|
|
165
354
|
}
|
|
166
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
|
+
*/
|
|
167
363
|
async acquire(key, ttlSeconds) {
|
|
168
364
|
return this.store.acquire(key, ttlSeconds);
|
|
169
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
|
+
*/
|
|
170
372
|
async release(key) {
|
|
171
373
|
return this.store.release(key);
|
|
172
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
|
+
*/
|
|
173
384
|
async forceAcquire(key, ttlSeconds) {
|
|
174
385
|
return this.store.forceAcquire(key, ttlSeconds);
|
|
175
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
|
+
*/
|
|
176
393
|
async exists(key) {
|
|
177
394
|
return this.store.exists(key);
|
|
178
395
|
}
|
|
@@ -199,19 +416,55 @@ async function runProcess(command) {
|
|
|
199
416
|
};
|
|
200
417
|
}
|
|
201
418
|
var Process = class {
|
|
419
|
+
/**
|
|
420
|
+
* Static alias for `runProcess`.
|
|
421
|
+
*
|
|
422
|
+
* @param command - Command to execute.
|
|
423
|
+
* @returns Process outcome.
|
|
424
|
+
*/
|
|
202
425
|
static async run(command) {
|
|
203
426
|
return runProcess(command);
|
|
204
427
|
}
|
|
205
428
|
};
|
|
206
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
|
+
|
|
207
460
|
// src/TaskSchedule.ts
|
|
208
461
|
var TaskSchedule = class {
|
|
209
462
|
task;
|
|
210
463
|
/**
|
|
211
|
-
*
|
|
464
|
+
* Initializes a new schedule instance with default settings.
|
|
212
465
|
*
|
|
213
|
-
* @param name -
|
|
214
|
-
* @param callback -
|
|
466
|
+
* @param name - Unique task name used for identification and locking.
|
|
467
|
+
* @param callback - Logic to execute on schedule.
|
|
215
468
|
*/
|
|
216
469
|
constructor(name, callback) {
|
|
217
470
|
this.task = {
|
|
@@ -225,168 +478,217 @@ var TaskSchedule = class {
|
|
|
225
478
|
// 5 minutes default
|
|
226
479
|
background: false,
|
|
227
480
|
// Wait for task to finish by default
|
|
481
|
+
preventOverlapping: false,
|
|
482
|
+
overlappingExpiresAt: 3600,
|
|
483
|
+
// 1 hour default
|
|
228
484
|
onSuccessCallbacks: [],
|
|
229
485
|
onFailureCallbacks: []
|
|
230
486
|
};
|
|
231
487
|
}
|
|
232
488
|
// --- Frequency Methods ---
|
|
233
489
|
/**
|
|
234
|
-
*
|
|
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.
|
|
235
495
|
*
|
|
236
|
-
* @
|
|
237
|
-
*
|
|
496
|
+
* @example
|
|
497
|
+
* ```typescript
|
|
498
|
+
* schedule.cron('0 9-17 * * 1-5'); // 9-5 on weekdays
|
|
499
|
+
* ```
|
|
238
500
|
*/
|
|
239
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
|
+
}
|
|
240
516
|
this.task.expression = expression;
|
|
241
517
|
return this;
|
|
242
518
|
}
|
|
243
519
|
/**
|
|
244
|
-
*
|
|
520
|
+
* Schedules execution every minute.
|
|
245
521
|
*
|
|
246
|
-
* @returns The TaskSchedule instance.
|
|
522
|
+
* @returns The TaskSchedule instance for chaining.
|
|
247
523
|
*/
|
|
248
524
|
everyMinute() {
|
|
249
525
|
return this.cron("* * * * *");
|
|
250
526
|
}
|
|
251
527
|
/**
|
|
252
|
-
*
|
|
528
|
+
* Schedules execution every five minutes.
|
|
253
529
|
*
|
|
254
|
-
* @returns The TaskSchedule instance.
|
|
530
|
+
* @returns The TaskSchedule instance for chaining.
|
|
255
531
|
*/
|
|
256
532
|
everyFiveMinutes() {
|
|
257
533
|
return this.cron("*/5 * * * *");
|
|
258
534
|
}
|
|
259
535
|
/**
|
|
260
|
-
*
|
|
536
|
+
* Schedules execution every ten minutes.
|
|
261
537
|
*
|
|
262
|
-
* @returns The TaskSchedule instance.
|
|
538
|
+
* @returns The TaskSchedule instance for chaining.
|
|
263
539
|
*/
|
|
264
540
|
everyTenMinutes() {
|
|
265
541
|
return this.cron("*/10 * * * *");
|
|
266
542
|
}
|
|
267
543
|
/**
|
|
268
|
-
*
|
|
544
|
+
* Schedules execution every fifteen minutes.
|
|
269
545
|
*
|
|
270
|
-
* @returns The TaskSchedule instance.
|
|
546
|
+
* @returns The TaskSchedule instance for chaining.
|
|
271
547
|
*/
|
|
272
548
|
everyFifteenMinutes() {
|
|
273
549
|
return this.cron("*/15 * * * *");
|
|
274
550
|
}
|
|
275
551
|
/**
|
|
276
|
-
*
|
|
552
|
+
* Schedules execution every thirty minutes.
|
|
277
553
|
*
|
|
278
|
-
* @returns The TaskSchedule instance.
|
|
554
|
+
* @returns The TaskSchedule instance for chaining.
|
|
279
555
|
*/
|
|
280
556
|
everyThirtyMinutes() {
|
|
281
557
|
return this.cron("0,30 * * * *");
|
|
282
558
|
}
|
|
283
559
|
/**
|
|
284
|
-
*
|
|
560
|
+
* Schedules execution hourly at the top of the hour.
|
|
285
561
|
*
|
|
286
|
-
* @returns The TaskSchedule instance.
|
|
562
|
+
* @returns The TaskSchedule instance for chaining.
|
|
287
563
|
*/
|
|
288
564
|
hourly() {
|
|
289
565
|
return this.cron("0 * * * *");
|
|
290
566
|
}
|
|
291
567
|
/**
|
|
292
|
-
*
|
|
568
|
+
* Schedules execution hourly at a specific minute.
|
|
293
569
|
*
|
|
294
|
-
* @param minute -
|
|
295
|
-
* @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.
|
|
296
573
|
*/
|
|
297
574
|
hourlyAt(minute) {
|
|
575
|
+
validateMinute(minute);
|
|
298
576
|
return this.cron(`${minute} * * * *`);
|
|
299
577
|
}
|
|
300
578
|
/**
|
|
301
|
-
*
|
|
579
|
+
* Schedules execution daily at midnight.
|
|
302
580
|
*
|
|
303
|
-
* @returns The TaskSchedule instance.
|
|
581
|
+
* @returns The TaskSchedule instance for chaining.
|
|
304
582
|
*/
|
|
305
583
|
daily() {
|
|
306
584
|
return this.cron("0 0 * * *");
|
|
307
585
|
}
|
|
308
586
|
/**
|
|
309
|
-
*
|
|
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.
|
|
310
592
|
*
|
|
311
|
-
* @
|
|
312
|
-
*
|
|
593
|
+
* @example
|
|
594
|
+
* ```typescript
|
|
595
|
+
* schedule.dailyAt('14:30');
|
|
596
|
+
* ```
|
|
313
597
|
*/
|
|
314
598
|
dailyAt(time) {
|
|
315
|
-
const
|
|
316
|
-
return this.cron(`${
|
|
599
|
+
const { hour, minute } = parseTime(time);
|
|
600
|
+
return this.cron(`${minute} ${hour} * * *`);
|
|
317
601
|
}
|
|
318
602
|
/**
|
|
319
|
-
*
|
|
603
|
+
* Schedules execution weekly on Sunday at midnight.
|
|
320
604
|
*
|
|
321
|
-
* @returns The TaskSchedule instance.
|
|
605
|
+
* @returns The TaskSchedule instance for chaining.
|
|
322
606
|
*/
|
|
323
607
|
weekly() {
|
|
324
608
|
return this.cron("0 0 * * 0");
|
|
325
609
|
}
|
|
326
610
|
/**
|
|
327
|
-
*
|
|
611
|
+
* Schedules execution weekly on a specific day and time.
|
|
328
612
|
*
|
|
329
|
-
* @param day - Day
|
|
330
|
-
* @param time -
|
|
331
|
-
* @returns The TaskSchedule instance.
|
|
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.
|
|
617
|
+
*
|
|
618
|
+
* @example
|
|
619
|
+
* ```typescript
|
|
620
|
+
* schedule.weeklyOn(1, '09:00'); // Mondays at 9 AM
|
|
621
|
+
* ```
|
|
332
622
|
*/
|
|
333
623
|
weeklyOn(day, time = "00:00") {
|
|
334
|
-
|
|
335
|
-
|
|
624
|
+
validateDayOfWeek(day);
|
|
625
|
+
const { hour, minute } = parseTime(time);
|
|
626
|
+
return this.cron(`${minute} ${hour} * * ${day}`);
|
|
336
627
|
}
|
|
337
628
|
/**
|
|
338
|
-
*
|
|
629
|
+
* Schedules execution monthly on the first day at midnight.
|
|
339
630
|
*
|
|
340
|
-
* @returns The TaskSchedule instance.
|
|
631
|
+
* @returns The TaskSchedule instance for chaining.
|
|
341
632
|
*/
|
|
342
633
|
monthly() {
|
|
343
634
|
return this.cron("0 0 1 * *");
|
|
344
635
|
}
|
|
345
636
|
/**
|
|
346
|
-
*
|
|
637
|
+
* Schedules execution monthly on a specific day and time.
|
|
347
638
|
*
|
|
348
|
-
* @param day - Day of month (1-31)
|
|
349
|
-
* @param time -
|
|
350
|
-
* @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.
|
|
351
643
|
*/
|
|
352
644
|
monthlyOn(day, time = "00:00") {
|
|
353
|
-
|
|
354
|
-
|
|
645
|
+
validateDayOfMonth(day);
|
|
646
|
+
const { hour, minute } = parseTime(time);
|
|
647
|
+
return this.cron(`${minute} ${hour} ${day} * *`);
|
|
355
648
|
}
|
|
356
649
|
// --- Constraints ---
|
|
357
650
|
/**
|
|
358
|
-
*
|
|
651
|
+
* Specifies the timezone for evaluating schedule frequency.
|
|
359
652
|
*
|
|
360
|
-
* @param timezone -
|
|
361
|
-
* @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.
|
|
362
656
|
*/
|
|
363
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
|
+
}
|
|
364
665
|
this.task.timezone = timezone;
|
|
365
666
|
return this;
|
|
366
667
|
}
|
|
367
668
|
/**
|
|
368
|
-
*
|
|
369
|
-
*
|
|
669
|
+
* Modifies the execution time of the existing frequency.
|
|
670
|
+
* Typically used after frequency methods like daily() or weekly().
|
|
370
671
|
*
|
|
371
|
-
* @param time -
|
|
372
|
-
* @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.
|
|
373
675
|
*/
|
|
374
676
|
at(time) {
|
|
375
|
-
const
|
|
677
|
+
const { hour, minute } = parseTime(time);
|
|
376
678
|
const parts = this.task.expression.split(" ");
|
|
377
679
|
if (parts.length >= 5) {
|
|
378
|
-
parts[0] = String(
|
|
379
|
-
parts[1] = String(
|
|
680
|
+
parts[0] = String(minute);
|
|
681
|
+
parts[1] = String(hour);
|
|
380
682
|
this.task.expression = parts.join(" ");
|
|
381
683
|
}
|
|
382
684
|
return this;
|
|
383
685
|
}
|
|
384
686
|
/**
|
|
385
|
-
*
|
|
386
|
-
*
|
|
687
|
+
* Enables distributed locking to ensure only one instance runs globally.
|
|
688
|
+
* Prevents duplicate execution when multiple servers are polling the same schedule.
|
|
387
689
|
*
|
|
388
|
-
* @param lockTtlSeconds -
|
|
389
|
-
* @returns The TaskSchedule instance.
|
|
690
|
+
* @param lockTtlSeconds - Duration in seconds to hold the window lock (default 300).
|
|
691
|
+
* @returns The TaskSchedule instance for chaining.
|
|
390
692
|
*/
|
|
391
693
|
onOneServer(lockTtlSeconds = 300) {
|
|
392
694
|
this.task.shouldRunOnOneServer = true;
|
|
@@ -394,41 +696,85 @@ var TaskSchedule = class {
|
|
|
394
696
|
return this;
|
|
395
697
|
}
|
|
396
698
|
/**
|
|
397
|
-
*
|
|
398
|
-
*
|
|
699
|
+
* Prevents a new task instance from starting if the previous one is still running.
|
|
700
|
+
*
|
|
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.
|
|
399
703
|
*
|
|
400
|
-
* @param expiresAt -
|
|
401
|
-
* @returns The TaskSchedule instance.
|
|
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
|
+
* ```
|
|
402
714
|
*/
|
|
403
|
-
withoutOverlapping(expiresAt =
|
|
404
|
-
|
|
715
|
+
withoutOverlapping(expiresAt = 3600) {
|
|
716
|
+
this.task.preventOverlapping = true;
|
|
717
|
+
this.task.overlappingExpiresAt = expiresAt;
|
|
718
|
+
return this;
|
|
405
719
|
}
|
|
406
720
|
/**
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
* 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.
|
|
410
723
|
*
|
|
411
|
-
* @returns The TaskSchedule instance.
|
|
724
|
+
* @returns The TaskSchedule instance for chaining.
|
|
412
725
|
*/
|
|
413
726
|
runInBackground() {
|
|
414
727
|
this.task.background = true;
|
|
415
728
|
return this;
|
|
416
729
|
}
|
|
417
730
|
/**
|
|
418
|
-
*
|
|
731
|
+
* Restricts task execution to nodes with a specific role.
|
|
419
732
|
*
|
|
420
|
-
* @param role -
|
|
421
|
-
* @returns The TaskSchedule instance.
|
|
733
|
+
* @param role - Target role identifier (e.g., "worker").
|
|
734
|
+
* @returns The TaskSchedule instance for chaining.
|
|
422
735
|
*/
|
|
423
736
|
onNode(role) {
|
|
424
737
|
this.task.nodeRole = role;
|
|
425
738
|
return this;
|
|
426
739
|
}
|
|
427
740
|
/**
|
|
428
|
-
*
|
|
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.
|
|
429
775
|
*
|
|
430
776
|
* @param command - The command string.
|
|
431
|
-
* @returns The TaskSchedule instance.
|
|
777
|
+
* @returns The TaskSchedule instance for chaining.
|
|
432
778
|
* @internal
|
|
433
779
|
*/
|
|
434
780
|
setCommand(command) {
|
|
@@ -437,39 +783,39 @@ var TaskSchedule = class {
|
|
|
437
783
|
}
|
|
438
784
|
// --- Hooks ---
|
|
439
785
|
/**
|
|
440
|
-
*
|
|
786
|
+
* Registers a callback to execute upon successful task completion.
|
|
441
787
|
*
|
|
442
|
-
* @param callback -
|
|
443
|
-
* @returns The TaskSchedule instance.
|
|
788
|
+
* @param callback - Function to run on success.
|
|
789
|
+
* @returns The TaskSchedule instance for chaining.
|
|
444
790
|
*/
|
|
445
791
|
onSuccess(callback) {
|
|
446
792
|
this.task.onSuccessCallbacks.push(callback);
|
|
447
793
|
return this;
|
|
448
794
|
}
|
|
449
795
|
/**
|
|
450
|
-
*
|
|
796
|
+
* Registers a callback to execute when the task fails.
|
|
451
797
|
*
|
|
452
|
-
* @param callback -
|
|
453
|
-
* @returns The TaskSchedule instance.
|
|
798
|
+
* @param callback - Function to run on failure.
|
|
799
|
+
* @returns The TaskSchedule instance for chaining.
|
|
454
800
|
*/
|
|
455
801
|
onFailure(callback) {
|
|
456
802
|
this.task.onFailureCallbacks.push(callback);
|
|
457
803
|
return this;
|
|
458
804
|
}
|
|
459
805
|
/**
|
|
460
|
-
*
|
|
806
|
+
* Attaches a human-readable description to the task.
|
|
461
807
|
*
|
|
462
|
-
* @param _text -
|
|
463
|
-
* @returns The TaskSchedule instance.
|
|
808
|
+
* @param _text - Description text.
|
|
809
|
+
* @returns The TaskSchedule instance for chaining.
|
|
464
810
|
*/
|
|
465
811
|
description(_text) {
|
|
466
812
|
return this;
|
|
467
813
|
}
|
|
468
814
|
// --- Accessor ---
|
|
469
815
|
/**
|
|
470
|
-
*
|
|
816
|
+
* Returns the final task configuration.
|
|
471
817
|
*
|
|
472
|
-
* @returns The ScheduledTask object.
|
|
818
|
+
* @returns The constructed ScheduledTask object.
|
|
473
819
|
*/
|
|
474
820
|
getTask() {
|
|
475
821
|
return this.task;
|
|
@@ -478,6 +824,14 @@ var TaskSchedule = class {
|
|
|
478
824
|
|
|
479
825
|
// src/SchedulerManager.ts
|
|
480
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
|
+
*/
|
|
481
835
|
constructor(lockManager, logger, hooks, currentNodeRole) {
|
|
482
836
|
this.lockManager = lockManager;
|
|
483
837
|
this.logger = logger;
|
|
@@ -486,11 +840,18 @@ var SchedulerManager = class {
|
|
|
486
840
|
}
|
|
487
841
|
tasks = [];
|
|
488
842
|
/**
|
|
489
|
-
*
|
|
843
|
+
* Registers a new callback-based scheduled task.
|
|
490
844
|
*
|
|
491
|
-
* @param name - Unique name for
|
|
492
|
-
* @param callback -
|
|
493
|
-
* @returns
|
|
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.
|
|
848
|
+
*
|
|
849
|
+
* @example
|
|
850
|
+
* ```typescript
|
|
851
|
+
* scheduler.task('process-queues', async () => {
|
|
852
|
+
* await queue.process();
|
|
853
|
+
* }).everyMinute();
|
|
854
|
+
* ```
|
|
494
855
|
*/
|
|
495
856
|
task(name, callback) {
|
|
496
857
|
const task = new TaskSchedule(name, callback);
|
|
@@ -498,11 +859,21 @@ var SchedulerManager = class {
|
|
|
498
859
|
return task;
|
|
499
860
|
}
|
|
500
861
|
/**
|
|
501
|
-
*
|
|
862
|
+
* Registers a shell command as a scheduled task.
|
|
863
|
+
*
|
|
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.
|
|
502
870
|
*
|
|
503
|
-
* @
|
|
504
|
-
*
|
|
505
|
-
*
|
|
871
|
+
* @example
|
|
872
|
+
* ```typescript
|
|
873
|
+
* scheduler.exec('log-rotate', 'logrotate /etc/logrotate.conf')
|
|
874
|
+
* .daily()
|
|
875
|
+
* .onNode('worker');
|
|
876
|
+
* ```
|
|
506
877
|
*/
|
|
507
878
|
exec(name, command) {
|
|
508
879
|
const task = new TaskSchedule(name, async () => {
|
|
@@ -516,27 +887,29 @@ var SchedulerManager = class {
|
|
|
516
887
|
return task;
|
|
517
888
|
}
|
|
518
889
|
/**
|
|
519
|
-
*
|
|
890
|
+
* Injects a pre-configured TaskSchedule instance into the registry.
|
|
520
891
|
*
|
|
521
|
-
* @param schedule -
|
|
892
|
+
* @param schedule - Configured task schedule to register.
|
|
522
893
|
*/
|
|
523
894
|
add(schedule) {
|
|
524
895
|
this.tasks.push(schedule);
|
|
525
896
|
}
|
|
526
897
|
/**
|
|
527
|
-
*
|
|
898
|
+
* Exports all registered tasks for external inspection or serialization.
|
|
528
899
|
*
|
|
529
|
-
* @returns An array of
|
|
900
|
+
* @returns An array of raw task configurations.
|
|
530
901
|
*/
|
|
531
902
|
getTasks() {
|
|
532
903
|
return this.tasks.map((t) => t.getTask());
|
|
533
904
|
}
|
|
534
905
|
/**
|
|
535
|
-
*
|
|
536
|
-
* This is typically called every minute by a system cron or worker loop.
|
|
906
|
+
* Main evaluation loop that triggers tasks due for execution.
|
|
537
907
|
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
908
|
+
* Should be invoked every minute by a system timer (systemd/cron) or daemon.
|
|
909
|
+
* Performs frequency checks, role filtering, and parallel execution.
|
|
910
|
+
*
|
|
911
|
+
* @param date - Reference time for cron evaluation (default: current time).
|
|
912
|
+
* @returns Resolves when all due tasks have been initiated.
|
|
540
913
|
*/
|
|
541
914
|
async run(date = /* @__PURE__ */ new Date()) {
|
|
542
915
|
await this.hooks?.doAction("scheduler:run:start", { date });
|
|
@@ -548,6 +921,14 @@ var SchedulerManager = class {
|
|
|
548
921
|
}
|
|
549
922
|
}
|
|
550
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
|
+
});
|
|
551
932
|
}
|
|
552
933
|
for (const task of dueTasks) {
|
|
553
934
|
this.runTask(task, date).catch((err) => {
|
|
@@ -557,21 +938,45 @@ var SchedulerManager = class {
|
|
|
557
938
|
await this.hooks?.doAction("scheduler:run:complete", { date, dueCount: dueTasks.length });
|
|
558
939
|
}
|
|
559
940
|
/**
|
|
560
|
-
*
|
|
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.
|
|
561
948
|
*
|
|
562
|
-
* @param task - The task to execute.
|
|
563
949
|
* @internal
|
|
564
950
|
*/
|
|
565
951
|
async runTask(task, date = /* @__PURE__ */ new Date()) {
|
|
566
952
|
if (task.nodeRole && this.currentNodeRole && task.nodeRole !== this.currentNodeRole) {
|
|
567
953
|
return;
|
|
568
954
|
}
|
|
569
|
-
|
|
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;
|
|
570
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")}`;
|
|
571
|
-
const
|
|
973
|
+
const timeLockKey = `task:${task.name}:${timestamp}`;
|
|
572
974
|
if (task.shouldRunOnOneServer) {
|
|
573
|
-
|
|
574
|
-
if (!
|
|
975
|
+
acquiredTimeLock = await this.lockManager.acquire(timeLockKey, task.lockTtl);
|
|
976
|
+
if (!acquiredTimeLock) {
|
|
977
|
+
if (task.preventOverlapping) {
|
|
978
|
+
await this.lockManager.release(runningLockKey);
|
|
979
|
+
}
|
|
575
980
|
return;
|
|
576
981
|
}
|
|
577
982
|
}
|
|
@@ -579,60 +984,118 @@ var SchedulerManager = class {
|
|
|
579
984
|
if (task.background) {
|
|
580
985
|
this.executeTask(task).catch((err) => {
|
|
581
986
|
this.logger?.error(`Background task ${task.name} failed`, err);
|
|
987
|
+
}).finally(async () => {
|
|
988
|
+
if (task.preventOverlapping) {
|
|
989
|
+
await this.lockManager.release(runningLockKey);
|
|
990
|
+
}
|
|
582
991
|
});
|
|
583
992
|
} else {
|
|
584
993
|
await this.executeTask(task);
|
|
994
|
+
if (task.preventOverlapping) {
|
|
995
|
+
await this.lockManager.release(runningLockKey);
|
|
996
|
+
}
|
|
585
997
|
}
|
|
586
998
|
} catch (err) {
|
|
587
|
-
if (
|
|
588
|
-
await this.lockManager.release(
|
|
999
|
+
if (task.preventOverlapping) {
|
|
1000
|
+
await this.lockManager.release(runningLockKey);
|
|
1001
|
+
}
|
|
1002
|
+
if (acquiredTimeLock) {
|
|
1003
|
+
await this.lockManager.release(timeLockKey);
|
|
589
1004
|
}
|
|
590
1005
|
throw err;
|
|
591
1006
|
}
|
|
592
1007
|
}
|
|
593
1008
|
/**
|
|
594
|
-
*
|
|
1009
|
+
* Internal wrapper for executing task logic with reliability controls.
|
|
1010
|
+
*
|
|
1011
|
+
* Handles timeouts, retries, and emits lifecycle hooks for monitoring.
|
|
595
1012
|
*
|
|
596
|
-
* @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
|
|
597
1017
|
*/
|
|
598
1018
|
async executeTask(task) {
|
|
599
1019
|
const startTime = Date.now();
|
|
600
1020
|
await this.hooks?.doAction("scheduler:task:start", { name: task.name, startTime });
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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));
|
|
610
1038
|
}
|
|
611
|
-
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
error: err,
|
|
617
|
-
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);
|
|
618
1044
|
});
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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);
|
|
623
1066
|
}
|
|
624
1067
|
}
|
|
625
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
|
+
}
|
|
626
1082
|
}
|
|
627
1083
|
};
|
|
628
1084
|
|
|
629
1085
|
// src/OrbitHorizon.ts
|
|
630
1086
|
var OrbitHorizon = class {
|
|
631
1087
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
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.
|
|
634
1097
|
*
|
|
635
|
-
* @
|
|
1098
|
+
* @throws {Error} If the cache driver is explicitly requested but `CacheManager` is unavailable.
|
|
636
1099
|
*/
|
|
637
1100
|
install(core) {
|
|
638
1101
|
const config = core.config.get("scheduler", {});
|