@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/README.md +93 -80
- package/README.zh-TW.md +102 -8
- package/dist/index.cjs +634 -243
- package/dist/index.d.cts +661 -137
- package/dist/index.d.ts +661 -137
- package/dist/index.js +637 -246
- package/package.json +7 -5
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
const end = parseInt(endStr, 10);
|
|
109
|
-
return value >= start && value <= end;
|
|
69
|
+
return patternVal === value;
|
|
110
70
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
*
|
|
130
|
+
* Determines if a task is due for execution at the specified time.
|
|
141
131
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
-
*
|
|
464
|
+
* Initializes a new schedule instance with default settings.
|
|
285
465
|
*
|
|
286
|
-
* @param name -
|
|
287
|
-
* @param callback -
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
310
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
568
|
+
* Schedules execution hourly at a specific minute.
|
|
366
569
|
*
|
|
367
|
-
* @param minute -
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
385
|
-
*
|
|
593
|
+
* @example
|
|
594
|
+
* ```typescript
|
|
595
|
+
* schedule.dailyAt('14:30');
|
|
596
|
+
* ```
|
|
386
597
|
*/
|
|
387
598
|
dailyAt(time) {
|
|
388
|
-
const
|
|
389
|
-
return this.cron(`${
|
|
599
|
+
const { hour, minute } = parseTime(time);
|
|
600
|
+
return this.cron(`${minute} ${hour} * * *`);
|
|
390
601
|
}
|
|
391
602
|
/**
|
|
392
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
403
|
-
*
|
|
404
|
-
*
|
|
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
|
-
|
|
408
|
-
|
|
624
|
+
validateDayOfWeek(day);
|
|
625
|
+
const { hour, minute } = parseTime(time);
|
|
626
|
+
return this.cron(`${minute} ${hour} * * ${day}`);
|
|
409
627
|
}
|
|
410
628
|
/**
|
|
411
|
-
*
|
|
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
|
-
*
|
|
637
|
+
* Schedules execution monthly on a specific day and time.
|
|
420
638
|
*
|
|
421
|
-
* @param day - Day of month (1-31)
|
|
422
|
-
* @param time -
|
|
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
|
-
|
|
427
|
-
|
|
645
|
+
validateDayOfMonth(day);
|
|
646
|
+
const { hour, minute } = parseTime(time);
|
|
647
|
+
return this.cron(`${minute} ${hour} ${day} * *`);
|
|
428
648
|
}
|
|
429
649
|
// --- Constraints ---
|
|
430
650
|
/**
|
|
431
|
-
*
|
|
651
|
+
* Specifies the timezone for evaluating schedule frequency.
|
|
432
652
|
*
|
|
433
|
-
* @param timezone -
|
|
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
|
-
*
|
|
442
|
-
*
|
|
669
|
+
* Modifies the execution time of the existing frequency.
|
|
670
|
+
* Typically used after frequency methods like daily() or weekly().
|
|
443
671
|
*
|
|
444
|
-
* @param time -
|
|
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
|
|
677
|
+
const { hour, minute } = parseTime(time);
|
|
449
678
|
const parts = this.task.expression.split(" ");
|
|
450
679
|
if (parts.length >= 5) {
|
|
451
|
-
parts[0] = String(
|
|
452
|
-
parts[1] = String(
|
|
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
|
-
*
|
|
459
|
-
*
|
|
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 -
|
|
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
|
-
*
|
|
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
|
-
*
|
|
474
|
-
*
|
|
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 =
|
|
477
|
-
|
|
715
|
+
withoutOverlapping(expiresAt = 3600) {
|
|
716
|
+
this.task.preventOverlapping = true;
|
|
717
|
+
this.task.overlappingExpiresAt = expiresAt;
|
|
718
|
+
return this;
|
|
478
719
|
}
|
|
479
720
|
/**
|
|
480
|
-
*
|
|
481
|
-
*
|
|
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
|
-
*
|
|
731
|
+
* Restricts task execution to nodes with a specific role.
|
|
492
732
|
*
|
|
493
|
-
* @param role -
|
|
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
|
-
*
|
|
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
|
-
*
|
|
786
|
+
* Registers a callback to execute upon successful task completion.
|
|
514
787
|
*
|
|
515
|
-
* @param callback -
|
|
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
|
-
*
|
|
796
|
+
* Registers a callback to execute when the task fails.
|
|
524
797
|
*
|
|
525
|
-
* @param callback -
|
|
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
|
-
*
|
|
806
|
+
* Attaches a human-readable description to the task.
|
|
534
807
|
*
|
|
535
|
-
* @param _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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
565
|
-
*
|
|
566
|
-
*
|
|
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
|
-
*
|
|
862
|
+
* Registers a shell command as a scheduled task.
|
|
575
863
|
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
* @
|
|
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
|
-
*
|
|
890
|
+
* Injects a pre-configured TaskSchedule instance into the registry.
|
|
593
891
|
*
|
|
594
|
-
* @param schedule -
|
|
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
|
-
*
|
|
898
|
+
* Exports all registered tasks for external inspection or serialization.
|
|
601
899
|
*
|
|
602
|
-
* @returns An array of
|
|
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
|
-
*
|
|
609
|
-
*
|
|
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 -
|
|
612
|
-
* @returns
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
973
|
+
const timeLockKey = `task:${task.name}:${timestamp}`;
|
|
645
974
|
if (task.shouldRunOnOneServer) {
|
|
646
|
-
|
|
647
|
-
if (!
|
|
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 (
|
|
661
|
-
await this.lockManager.release(
|
|
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
|
-
*
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
-
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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.
|
|
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.
|
|
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();
|