@gravito/horizon 3.2.1 → 4.0.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 +69 -10
- package/dist/index.cjs +104 -47
- package/dist/index.d.cts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +104 -46
- package/package.json +10 -6
package/README.md
CHANGED
|
@@ -4,15 +4,32 @@ Enterprise-grade distributed task scheduler for the Gravito framework.
|
|
|
4
4
|
|
|
5
5
|
`@gravito/horizon` provides a robust, fluent, and highly configurable system for managing scheduled tasks (Cron jobs) in a distributed environment. It supports multiple locking mechanisms to prevent duplicate execution, node role filtering, retries, and comprehensive monitoring hooks.
|
|
6
6
|
|
|
7
|
-
## Features
|
|
8
|
-
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
7
|
+
## ✨ Features
|
|
8
|
+
|
|
9
|
+
- 🪐 **Galaxy-Ready Distributed Scheduler**: Native integration with PlanetCore for universal task management across all Satellites.
|
|
10
|
+
- 🕒 **Fluent API**: Human-readable syntax for defining task schedules (e.g., `.daily().at('14:00')`).
|
|
11
|
+
- 🔐 **Distributed Locking**: Prevents duplicate task execution across multiple servers (supports Redis/Plasma and SQL).
|
|
12
|
+
- 🧬 **Node-Role Awareness**: Intelligently restrict tasks to specific nodes (e.g., only run on `worker` nodes) or broadcast maintenance tasks.
|
|
13
|
+
- 🛡️ **Reliability Features**: Built-in support for task timeouts and automatic retries with configurable exponential backoff.
|
|
14
|
+
- 🐚 **Shell Command Support**: Schedule raw shell commands alongside TypeScript callbacks (powered by `@gravito/nova`).
|
|
15
|
+
- 📊 **Monitoring Hooks**: Comprehensive lifecycle events for tracking task success, failure, and execution duration.
|
|
16
|
+
|
|
17
|
+
## 🌌 Role in Galaxy Architecture
|
|
18
|
+
|
|
19
|
+
In the **Gravito Galaxy Architecture**, Horizon acts as the **Clockwork of Jobs (Temporal Coordinator)**.
|
|
20
|
+
|
|
21
|
+
- **System Heartbeat**: Triggers recurring maintenance, cleanup, and synchronization tasks that keep the Galaxy running smoothly over time.
|
|
22
|
+
- **Node Coordination**: Ensures that even if you have 100 instances of a Satellite, a "Daily Report" task only runs exactly once per day across the entire cluster.
|
|
23
|
+
- **Background Orchestration**: Works with `@gravito/stream` to initiate long-running background processes at specific intervals.
|
|
24
|
+
|
|
25
|
+
```mermaid
|
|
26
|
+
graph TD
|
|
27
|
+
H[Horizon: Scheduler] -->|Time Trigger| L{Distributed Lock}
|
|
28
|
+
L -- "Win Lock" --> T[Task: Daily Cleanup]
|
|
29
|
+
L -- "Lose Lock" --> S[Skip Execution]
|
|
30
|
+
T -->|Call| Sat[Satellite: Admin]
|
|
31
|
+
T -->|Metrics| Mon[Monitor Orbit]
|
|
32
|
+
```
|
|
16
33
|
|
|
17
34
|
## Installation
|
|
18
35
|
|
|
@@ -65,13 +82,22 @@ scheduler.task('daily-cleanup', async () => {
|
|
|
65
82
|
.dailyAt('02:00')
|
|
66
83
|
.onOneServer() // Distributed lock
|
|
67
84
|
|
|
68
|
-
// Shell command execution
|
|
85
|
+
// Shell command execution (type-safe via @gravito/nova)
|
|
69
86
|
scheduler.exec('sync-storage', 'aws s3 sync ./local s3://bucket')
|
|
70
87
|
.everyFiveMinutes()
|
|
71
88
|
.onNode('worker')
|
|
72
89
|
.retry(3, 5000) // Retry 3 times with 5s delay
|
|
90
|
+
.timeout(300000) // 5 minute timeout
|
|
73
91
|
```
|
|
74
92
|
|
|
93
|
+
## 📚 Documentation
|
|
94
|
+
|
|
95
|
+
Detailed guides and references for the Galaxy Architecture:
|
|
96
|
+
|
|
97
|
+
- [🏗️ **Architecture Overview**](./README.md) — Distributed task scheduler core.
|
|
98
|
+
- [🕒 **Distributed Scheduling**](./doc/DISTRIBUTED_SCHEDULING.md) — **NEW**: Multi-server coordination and locking.
|
|
99
|
+
- [🐚 **Shell Execution**](#shell-execution-with-nova) — Type-safe shell commands via Nova.
|
|
100
|
+
|
|
75
101
|
## Scheduling API
|
|
76
102
|
|
|
77
103
|
### Frequency Methods
|
|
@@ -135,6 +161,39 @@ Poll every minute in a long-running process (ideal for Docker):
|
|
|
135
161
|
bun run gravito schedule:work
|
|
136
162
|
```
|
|
137
163
|
|
|
164
|
+
## Shell Execution with Nova
|
|
165
|
+
|
|
166
|
+
Horizon uses [@gravito/nova](../nova) for shell command execution, which provides:
|
|
167
|
+
|
|
168
|
+
- **Type-Safe Execution**: Template literal-based API prevents shell injection
|
|
169
|
+
- **Automatic Escaping**: All command arguments are automatically escaped
|
|
170
|
+
- **Consistent API**: Same Shell API used across Gravito framework
|
|
171
|
+
- **Error Handling**: Comprehensive error capture with stdout/stderr
|
|
172
|
+
|
|
173
|
+
Example with advanced shell operations:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import { Shell } from '@gravito/nova'
|
|
177
|
+
|
|
178
|
+
scheduler.task('backup-database', async () => {
|
|
179
|
+
// Use nova Shell API for custom commands
|
|
180
|
+
const result = await Shell.run`mysqldump -u ${dbUser} -p${dbPassword} ${dbName}`
|
|
181
|
+
.nothrow()
|
|
182
|
+
.run()
|
|
183
|
+
|
|
184
|
+
if (result.success) {
|
|
185
|
+
// Upload backup to S3
|
|
186
|
+
await Shell.run`aws s3 cp - s3://backups/${Date.now()}.sql`
|
|
187
|
+
.nothrow()
|
|
188
|
+
.run()
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
.dailyAt('03:00')
|
|
192
|
+
.onOneServer()
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
138
197
|
## Monitoring Hooks
|
|
139
198
|
|
|
140
199
|
Subscribe to hooks via `core.hooks` to build monitoring dashboards or alerts:
|
package/dist/index.cjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
var __create = Object.create;
|
|
3
2
|
var __defProp = Object.defineProperty;
|
|
4
3
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
@@ -7821,6 +7820,35 @@ __export(index_exports, {
|
|
|
7821
7820
|
});
|
|
7822
7821
|
module.exports = __toCommonJS(index_exports);
|
|
7823
7822
|
|
|
7823
|
+
// src/errors/HorizonError.ts
|
|
7824
|
+
var import_core = require("@gravito/core");
|
|
7825
|
+
var HorizonError = class extends import_core.SystemException {
|
|
7826
|
+
constructor(status, code, options = {}) {
|
|
7827
|
+
super(status, code, options);
|
|
7828
|
+
this.name = "HorizonError";
|
|
7829
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
7830
|
+
}
|
|
7831
|
+
};
|
|
7832
|
+
|
|
7833
|
+
// src/errors/codes.ts
|
|
7834
|
+
var HorizonErrorCodes = {
|
|
7835
|
+
// Scheduling errors
|
|
7836
|
+
CRON_PARSE_ERROR: "horizon.cron_parse_error",
|
|
7837
|
+
SCHEDULE_FAILED: "horizon.schedule_failed",
|
|
7838
|
+
COMMAND_FAILED: "horizon.command_failed",
|
|
7839
|
+
// Validation errors
|
|
7840
|
+
INVALID_TIME_FORMAT: "horizon.invalid_time_format",
|
|
7841
|
+
INVALID_MINUTE: "horizon.invalid_minute",
|
|
7842
|
+
INVALID_DAY_OF_WEEK: "horizon.invalid_day_of_week",
|
|
7843
|
+
INVALID_DAY_OF_MONTH: "horizon.invalid_day_of_month",
|
|
7844
|
+
INVALID_TIMEOUT: "horizon.invalid_timeout",
|
|
7845
|
+
INVALID_RETRY_ATTEMPTS: "horizon.invalid_retry_attempts",
|
|
7846
|
+
INVALID_RETRY_DELAY: "horizon.invalid_retry_delay",
|
|
7847
|
+
INVALID_TIMEZONE: "horizon.invalid_timezone",
|
|
7848
|
+
// Lock errors
|
|
7849
|
+
LOCK_DRIVER_REQUIRED: "horizon.lock_driver_required"
|
|
7850
|
+
};
|
|
7851
|
+
|
|
7824
7852
|
// src/SimpleCronParser.ts
|
|
7825
7853
|
var SimpleCronParser = class {
|
|
7826
7854
|
/**
|
|
@@ -7840,7 +7868,9 @@ var SimpleCronParser = class {
|
|
|
7840
7868
|
static isDue(expression, timezone = "UTC", date = /* @__PURE__ */ new Date()) {
|
|
7841
7869
|
const parts = expression.trim().split(/\s+/);
|
|
7842
7870
|
if (parts.length !== 5) {
|
|
7843
|
-
throw new
|
|
7871
|
+
throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
|
|
7872
|
+
message: `Invalid cron expression: ${expression}`
|
|
7873
|
+
});
|
|
7844
7874
|
}
|
|
7845
7875
|
const targetDate = this.getDateInTimezone(date, timezone);
|
|
7846
7876
|
const minutes = targetDate.getMinutes();
|
|
@@ -7903,11 +7933,16 @@ var SimpleCronParser = class {
|
|
|
7903
7933
|
try {
|
|
7904
7934
|
const tzDate = new Date(date.toLocaleString("en-US", { timeZone: timezone }));
|
|
7905
7935
|
if (Number.isNaN(tzDate.getTime())) {
|
|
7906
|
-
throw new
|
|
7936
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
|
|
7937
|
+
message: `Invalid timezone: ${timezone}`
|
|
7938
|
+
});
|
|
7907
7939
|
}
|
|
7908
7940
|
return tzDate;
|
|
7909
|
-
} catch {
|
|
7910
|
-
|
|
7941
|
+
} catch (err) {
|
|
7942
|
+
if (err instanceof HorizonError) throw err;
|
|
7943
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
|
|
7944
|
+
message: `Invalid timezone: ${timezone}`
|
|
7945
|
+
});
|
|
7911
7946
|
}
|
|
7912
7947
|
}
|
|
7913
7948
|
};
|
|
@@ -7944,7 +7979,9 @@ var CronParser = class {
|
|
|
7944
7979
|
});
|
|
7945
7980
|
return interval.next().toDate();
|
|
7946
7981
|
} catch (_err) {
|
|
7947
|
-
throw new
|
|
7982
|
+
throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
|
|
7983
|
+
message: `Invalid cron expression: ${expression}`
|
|
7984
|
+
});
|
|
7948
7985
|
}
|
|
7949
7986
|
}
|
|
7950
7987
|
/**
|
|
@@ -8167,7 +8204,9 @@ var LockManager = class {
|
|
|
8167
8204
|
this.store = new MemoryLockStore();
|
|
8168
8205
|
} else if (driver === "cache") {
|
|
8169
8206
|
if (!context?.cache) {
|
|
8170
|
-
throw new
|
|
8207
|
+
throw new HorizonError(422, HorizonErrorCodes.LOCK_DRIVER_REQUIRED, {
|
|
8208
|
+
message: "CacheManager is required for cache lock driver"
|
|
8209
|
+
});
|
|
8171
8210
|
}
|
|
8172
8211
|
this.store = new CacheLockStore(context.cache);
|
|
8173
8212
|
} else {
|
|
@@ -8217,24 +8256,19 @@ var LockManager = class {
|
|
|
8217
8256
|
};
|
|
8218
8257
|
|
|
8219
8258
|
// src/process/Process.ts
|
|
8220
|
-
var
|
|
8259
|
+
var import_nova = require("@gravito/nova");
|
|
8221
8260
|
async function runProcess(command) {
|
|
8222
|
-
|
|
8223
|
-
|
|
8224
|
-
|
|
8225
|
-
|
|
8226
|
-
|
|
8227
|
-
|
|
8228
|
-
|
|
8229
|
-
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
exitCode,
|
|
8234
|
-
stdout,
|
|
8235
|
-
stderr,
|
|
8236
|
-
success: exitCode === 0
|
|
8237
|
-
};
|
|
8261
|
+
try {
|
|
8262
|
+
const result = await import_nova.Shell.run`bash -c ${command}`.nothrow().run();
|
|
8263
|
+
return result;
|
|
8264
|
+
} catch (error) {
|
|
8265
|
+
return {
|
|
8266
|
+
exitCode: 1,
|
|
8267
|
+
stdout: "",
|
|
8268
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
8269
|
+
success: false
|
|
8270
|
+
};
|
|
8271
|
+
}
|
|
8238
8272
|
}
|
|
8239
8273
|
var Process = class {
|
|
8240
8274
|
/**
|
|
@@ -8253,28 +8287,38 @@ function parseTime(time) {
|
|
|
8253
8287
|
const timePattern = /^([0-2]\d):([0-5]\d)$/;
|
|
8254
8288
|
const match = time.match(timePattern);
|
|
8255
8289
|
if (!match) {
|
|
8256
|
-
throw new
|
|
8290
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIME_FORMAT, {
|
|
8291
|
+
message: `Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`
|
|
8292
|
+
});
|
|
8257
8293
|
}
|
|
8258
8294
|
const hour = Number.parseInt(match[1], 10);
|
|
8259
8295
|
const minute = Number.parseInt(match[2], 10);
|
|
8260
8296
|
if (hour > 23) {
|
|
8261
|
-
throw new
|
|
8297
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIME_FORMAT, {
|
|
8298
|
+
message: `Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`
|
|
8299
|
+
});
|
|
8262
8300
|
}
|
|
8263
8301
|
return { hour, minute };
|
|
8264
8302
|
}
|
|
8265
8303
|
function validateMinute(minute) {
|
|
8266
8304
|
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
8267
|
-
throw new
|
|
8305
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_MINUTE, {
|
|
8306
|
+
message: `Invalid minute: ${minute}. Expected integer 0-59`
|
|
8307
|
+
});
|
|
8268
8308
|
}
|
|
8269
8309
|
}
|
|
8270
8310
|
function validateDayOfWeek(dayOfWeek) {
|
|
8271
8311
|
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
|
|
8272
|
-
throw new
|
|
8312
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_DAY_OF_WEEK, {
|
|
8313
|
+
message: `Invalid day of week: ${dayOfWeek}. Expected 0-6 (Sunday=0, Saturday=6)`
|
|
8314
|
+
});
|
|
8273
8315
|
}
|
|
8274
8316
|
}
|
|
8275
8317
|
function validateDayOfMonth(dayOfMonth) {
|
|
8276
8318
|
if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
|
|
8277
|
-
throw new
|
|
8319
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_DAY_OF_MONTH, {
|
|
8320
|
+
message: `Invalid day of month: ${dayOfMonth}. Expected 1-31`
|
|
8321
|
+
});
|
|
8278
8322
|
}
|
|
8279
8323
|
}
|
|
8280
8324
|
|
|
@@ -8322,16 +8366,16 @@ var TaskSchedule = class {
|
|
|
8322
8366
|
cron(expression) {
|
|
8323
8367
|
const parts = expression.trim().split(/\s+/);
|
|
8324
8368
|
if (parts.length !== 5) {
|
|
8325
|
-
throw new
|
|
8326
|
-
`Invalid cron expression: "${expression}". Expected 5 parts (minute hour day month weekday), got ${parts.length}.`
|
|
8327
|
-
);
|
|
8369
|
+
throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
|
|
8370
|
+
message: `Invalid cron expression: "${expression}". Expected 5 parts (minute hour day month weekday), got ${parts.length}.`
|
|
8371
|
+
});
|
|
8328
8372
|
}
|
|
8329
8373
|
const pattern = /^[0-9,\-/*?L#A-Za-z]+$/;
|
|
8330
8374
|
for (let i = 0; i < 5; i++) {
|
|
8331
8375
|
if (!pattern.test(parts[i])) {
|
|
8332
|
-
throw new
|
|
8333
|
-
`Invalid cron expression: "${expression}". Part ${i + 1} ("${parts[i]}") contains invalid characters.`
|
|
8334
|
-
);
|
|
8376
|
+
throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
|
|
8377
|
+
message: `Invalid cron expression: "${expression}". Part ${i + 1} ("${parts[i]}") contains invalid characters.`
|
|
8378
|
+
});
|
|
8335
8379
|
}
|
|
8336
8380
|
}
|
|
8337
8381
|
this.task.expression = expression;
|
|
@@ -8479,9 +8523,9 @@ var TaskSchedule = class {
|
|
|
8479
8523
|
try {
|
|
8480
8524
|
(/* @__PURE__ */ new Date()).toLocaleString("en-US", { timeZone: timezone });
|
|
8481
8525
|
} catch {
|
|
8482
|
-
throw new
|
|
8483
|
-
`Invalid timezone: "${timezone}". See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid values.`
|
|
8484
|
-
);
|
|
8526
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
|
|
8527
|
+
message: `Invalid timezone: "${timezone}". See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid values.`
|
|
8528
|
+
});
|
|
8485
8529
|
}
|
|
8486
8530
|
this.task.timezone = timezone;
|
|
8487
8531
|
return this;
|
|
@@ -8567,7 +8611,9 @@ var TaskSchedule = class {
|
|
|
8567
8611
|
*/
|
|
8568
8612
|
timeout(ms) {
|
|
8569
8613
|
if (ms <= 0) {
|
|
8570
|
-
throw new
|
|
8614
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEOUT, {
|
|
8615
|
+
message: "Timeout must be a positive number"
|
|
8616
|
+
});
|
|
8571
8617
|
}
|
|
8572
8618
|
this.task.timeout = ms;
|
|
8573
8619
|
return this;
|
|
@@ -8582,10 +8628,14 @@ var TaskSchedule = class {
|
|
|
8582
8628
|
*/
|
|
8583
8629
|
retry(attempts = 3, delayMs = 1e3) {
|
|
8584
8630
|
if (attempts < 0) {
|
|
8585
|
-
throw new
|
|
8631
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_RETRY_ATTEMPTS, {
|
|
8632
|
+
message: "Retry attempts must be non-negative"
|
|
8633
|
+
});
|
|
8586
8634
|
}
|
|
8587
8635
|
if (delayMs < 0) {
|
|
8588
|
-
throw new
|
|
8636
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_RETRY_DELAY, {
|
|
8637
|
+
message: "Retry delay must be non-negative"
|
|
8638
|
+
});
|
|
8589
8639
|
}
|
|
8590
8640
|
this.task.retries = attempts;
|
|
8591
8641
|
this.task.retryDelay = delayMs;
|
|
@@ -8700,7 +8750,9 @@ var SchedulerManager = class {
|
|
|
8700
8750
|
const task = new TaskSchedule(name, async () => {
|
|
8701
8751
|
const result = await Process.run(command);
|
|
8702
8752
|
if (!result.success) {
|
|
8703
|
-
throw new
|
|
8753
|
+
throw new HorizonError(500, HorizonErrorCodes.COMMAND_FAILED, {
|
|
8754
|
+
message: `Command failed: ${result.stderr || result.stdout}`
|
|
8755
|
+
});
|
|
8704
8756
|
}
|
|
8705
8757
|
});
|
|
8706
8758
|
task.setCommand(command);
|
|
@@ -8805,14 +8857,15 @@ var SchedulerManager = class {
|
|
|
8805
8857
|
if (task.background) {
|
|
8806
8858
|
this.executeTask(task).catch((err) => {
|
|
8807
8859
|
this.logger?.error(`Background task ${task.name} failed`, err);
|
|
8808
|
-
|
|
8809
|
-
|
|
8860
|
+
return { success: false, timedOut: false };
|
|
8861
|
+
}).then(async (result) => {
|
|
8862
|
+
if (task.preventOverlapping && !result.timedOut) {
|
|
8810
8863
|
await this.lockManager.release(runningLockKey);
|
|
8811
8864
|
}
|
|
8812
8865
|
});
|
|
8813
8866
|
} else {
|
|
8814
|
-
await this.executeTask(task);
|
|
8815
|
-
if (task.preventOverlapping) {
|
|
8867
|
+
const result = await this.executeTask(task);
|
|
8868
|
+
if (task.preventOverlapping && !result.timedOut) {
|
|
8816
8869
|
await this.lockManager.release(runningLockKey);
|
|
8817
8870
|
}
|
|
8818
8871
|
}
|
|
@@ -8877,7 +8930,7 @@ var SchedulerManager = class {
|
|
|
8877
8930
|
} catch {
|
|
8878
8931
|
}
|
|
8879
8932
|
}
|
|
8880
|
-
return;
|
|
8933
|
+
return { success: true, timedOut: false };
|
|
8881
8934
|
} catch (err) {
|
|
8882
8935
|
lastError = err;
|
|
8883
8936
|
this.logger?.error(`Task ${task.name} failed (attempt ${attempt + 1})`, err);
|
|
@@ -8900,6 +8953,10 @@ var SchedulerManager = class {
|
|
|
8900
8953
|
} catch {
|
|
8901
8954
|
}
|
|
8902
8955
|
}
|
|
8956
|
+
return {
|
|
8957
|
+
success: false,
|
|
8958
|
+
timedOut: lastError?.message.includes("timed out after") ?? false
|
|
8959
|
+
};
|
|
8903
8960
|
}
|
|
8904
8961
|
};
|
|
8905
8962
|
|
package/dist/index.d.cts
CHANGED
|
@@ -674,7 +674,7 @@ declare class SchedulerManager {
|
|
|
674
674
|
* @param hooks - Optional manager for lifecycle event hooks.
|
|
675
675
|
* @param currentNodeRole - Role identifier for the local node (used for filtering).
|
|
676
676
|
*/
|
|
677
|
-
constructor(lockManager: LockManager, logger?: Logger
|
|
677
|
+
constructor(lockManager: LockManager, logger?: Logger, hooks?: HookManager, currentNodeRole?: string);
|
|
678
678
|
/**
|
|
679
679
|
* Registers a new callback-based scheduled task.
|
|
680
680
|
*
|
|
@@ -844,9 +844,9 @@ interface ProcessResult {
|
|
|
844
844
|
/**
|
|
845
845
|
* Spawns a shell command and asynchronously captures its full output.
|
|
846
846
|
*
|
|
847
|
-
* Leverages the
|
|
848
|
-
*
|
|
849
|
-
*
|
|
847
|
+
* Leverages the Nova Shell orchestration engine to ensure type-safe,
|
|
848
|
+
* shell-injection-resistant command execution. Supports pipes, redirects,
|
|
849
|
+
* and environment variables.
|
|
850
850
|
*
|
|
851
851
|
* @param command - Raw shell command string to execute.
|
|
852
852
|
* @returns Resolves to a detailed `ProcessResult` object.
|
package/dist/index.d.ts
CHANGED
|
@@ -674,7 +674,7 @@ declare class SchedulerManager {
|
|
|
674
674
|
* @param hooks - Optional manager for lifecycle event hooks.
|
|
675
675
|
* @param currentNodeRole - Role identifier for the local node (used for filtering).
|
|
676
676
|
*/
|
|
677
|
-
constructor(lockManager: LockManager, logger?: Logger
|
|
677
|
+
constructor(lockManager: LockManager, logger?: Logger, hooks?: HookManager, currentNodeRole?: string);
|
|
678
678
|
/**
|
|
679
679
|
* Registers a new callback-based scheduled task.
|
|
680
680
|
*
|
|
@@ -844,9 +844,9 @@ interface ProcessResult {
|
|
|
844
844
|
/**
|
|
845
845
|
* Spawns a shell command and asynchronously captures its full output.
|
|
846
846
|
*
|
|
847
|
-
* Leverages the
|
|
848
|
-
*
|
|
849
|
-
*
|
|
847
|
+
* Leverages the Nova Shell orchestration engine to ensure type-safe,
|
|
848
|
+
* shell-injection-resistant command execution. Supports pipes, redirects,
|
|
849
|
+
* and environment variables.
|
|
850
850
|
*
|
|
851
851
|
* @param command - Raw shell command string to execute.
|
|
852
852
|
* @returns Resolves to a detailed `ProcessResult` object.
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
import "./chunk-MCKGQKYU.js";
|
|
2
2
|
|
|
3
|
+
// src/errors/HorizonError.ts
|
|
4
|
+
import { SystemException } from "@gravito/core";
|
|
5
|
+
var HorizonError = class extends SystemException {
|
|
6
|
+
constructor(status, code, options = {}) {
|
|
7
|
+
super(status, code, options);
|
|
8
|
+
this.name = "HorizonError";
|
|
9
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// src/errors/codes.ts
|
|
14
|
+
var HorizonErrorCodes = {
|
|
15
|
+
// Scheduling errors
|
|
16
|
+
CRON_PARSE_ERROR: "horizon.cron_parse_error",
|
|
17
|
+
SCHEDULE_FAILED: "horizon.schedule_failed",
|
|
18
|
+
COMMAND_FAILED: "horizon.command_failed",
|
|
19
|
+
// Validation errors
|
|
20
|
+
INVALID_TIME_FORMAT: "horizon.invalid_time_format",
|
|
21
|
+
INVALID_MINUTE: "horizon.invalid_minute",
|
|
22
|
+
INVALID_DAY_OF_WEEK: "horizon.invalid_day_of_week",
|
|
23
|
+
INVALID_DAY_OF_MONTH: "horizon.invalid_day_of_month",
|
|
24
|
+
INVALID_TIMEOUT: "horizon.invalid_timeout",
|
|
25
|
+
INVALID_RETRY_ATTEMPTS: "horizon.invalid_retry_attempts",
|
|
26
|
+
INVALID_RETRY_DELAY: "horizon.invalid_retry_delay",
|
|
27
|
+
INVALID_TIMEZONE: "horizon.invalid_timezone",
|
|
28
|
+
// Lock errors
|
|
29
|
+
LOCK_DRIVER_REQUIRED: "horizon.lock_driver_required"
|
|
30
|
+
};
|
|
31
|
+
|
|
3
32
|
// src/SimpleCronParser.ts
|
|
4
33
|
var SimpleCronParser = class {
|
|
5
34
|
/**
|
|
@@ -19,7 +48,9 @@ var SimpleCronParser = class {
|
|
|
19
48
|
static isDue(expression, timezone = "UTC", date = /* @__PURE__ */ new Date()) {
|
|
20
49
|
const parts = expression.trim().split(/\s+/);
|
|
21
50
|
if (parts.length !== 5) {
|
|
22
|
-
throw new
|
|
51
|
+
throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
|
|
52
|
+
message: `Invalid cron expression: ${expression}`
|
|
53
|
+
});
|
|
23
54
|
}
|
|
24
55
|
const targetDate = this.getDateInTimezone(date, timezone);
|
|
25
56
|
const minutes = targetDate.getMinutes();
|
|
@@ -82,11 +113,16 @@ var SimpleCronParser = class {
|
|
|
82
113
|
try {
|
|
83
114
|
const tzDate = new Date(date.toLocaleString("en-US", { timeZone: timezone }));
|
|
84
115
|
if (Number.isNaN(tzDate.getTime())) {
|
|
85
|
-
throw new
|
|
116
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
|
|
117
|
+
message: `Invalid timezone: ${timezone}`
|
|
118
|
+
});
|
|
86
119
|
}
|
|
87
120
|
return tzDate;
|
|
88
|
-
} catch {
|
|
89
|
-
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err instanceof HorizonError) throw err;
|
|
123
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
|
|
124
|
+
message: `Invalid timezone: ${timezone}`
|
|
125
|
+
});
|
|
90
126
|
}
|
|
91
127
|
}
|
|
92
128
|
};
|
|
@@ -123,7 +159,9 @@ var CronParser = class {
|
|
|
123
159
|
});
|
|
124
160
|
return interval.next().toDate();
|
|
125
161
|
} catch (_err) {
|
|
126
|
-
throw new
|
|
162
|
+
throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
|
|
163
|
+
message: `Invalid cron expression: ${expression}`
|
|
164
|
+
});
|
|
127
165
|
}
|
|
128
166
|
}
|
|
129
167
|
/**
|
|
@@ -346,7 +384,9 @@ var LockManager = class {
|
|
|
346
384
|
this.store = new MemoryLockStore();
|
|
347
385
|
} else if (driver === "cache") {
|
|
348
386
|
if (!context?.cache) {
|
|
349
|
-
throw new
|
|
387
|
+
throw new HorizonError(422, HorizonErrorCodes.LOCK_DRIVER_REQUIRED, {
|
|
388
|
+
message: "CacheManager is required for cache lock driver"
|
|
389
|
+
});
|
|
350
390
|
}
|
|
351
391
|
this.store = new CacheLockStore(context.cache);
|
|
352
392
|
} else {
|
|
@@ -396,24 +436,19 @@ var LockManager = class {
|
|
|
396
436
|
};
|
|
397
437
|
|
|
398
438
|
// src/process/Process.ts
|
|
399
|
-
import {
|
|
439
|
+
import { Shell } from "@gravito/nova";
|
|
400
440
|
async function runProcess(command) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
exitCode,
|
|
413
|
-
stdout,
|
|
414
|
-
stderr,
|
|
415
|
-
success: exitCode === 0
|
|
416
|
-
};
|
|
441
|
+
try {
|
|
442
|
+
const result = await Shell.run`bash -c ${command}`.nothrow().run();
|
|
443
|
+
return result;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
return {
|
|
446
|
+
exitCode: 1,
|
|
447
|
+
stdout: "",
|
|
448
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
449
|
+
success: false
|
|
450
|
+
};
|
|
451
|
+
}
|
|
417
452
|
}
|
|
418
453
|
var Process = class {
|
|
419
454
|
/**
|
|
@@ -432,28 +467,38 @@ function parseTime(time) {
|
|
|
432
467
|
const timePattern = /^([0-2]\d):([0-5]\d)$/;
|
|
433
468
|
const match = time.match(timePattern);
|
|
434
469
|
if (!match) {
|
|
435
|
-
throw new
|
|
470
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIME_FORMAT, {
|
|
471
|
+
message: `Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`
|
|
472
|
+
});
|
|
436
473
|
}
|
|
437
474
|
const hour = Number.parseInt(match[1], 10);
|
|
438
475
|
const minute = Number.parseInt(match[2], 10);
|
|
439
476
|
if (hour > 23) {
|
|
440
|
-
throw new
|
|
477
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIME_FORMAT, {
|
|
478
|
+
message: `Invalid time format: "${time}". Expected HH:mm (24-hour format, 00:00-23:59)`
|
|
479
|
+
});
|
|
441
480
|
}
|
|
442
481
|
return { hour, minute };
|
|
443
482
|
}
|
|
444
483
|
function validateMinute(minute) {
|
|
445
484
|
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
446
|
-
throw new
|
|
485
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_MINUTE, {
|
|
486
|
+
message: `Invalid minute: ${minute}. Expected integer 0-59`
|
|
487
|
+
});
|
|
447
488
|
}
|
|
448
489
|
}
|
|
449
490
|
function validateDayOfWeek(dayOfWeek) {
|
|
450
491
|
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 6) {
|
|
451
|
-
throw new
|
|
492
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_DAY_OF_WEEK, {
|
|
493
|
+
message: `Invalid day of week: ${dayOfWeek}. Expected 0-6 (Sunday=0, Saturday=6)`
|
|
494
|
+
});
|
|
452
495
|
}
|
|
453
496
|
}
|
|
454
497
|
function validateDayOfMonth(dayOfMonth) {
|
|
455
498
|
if (!Number.isInteger(dayOfMonth) || dayOfMonth < 1 || dayOfMonth > 31) {
|
|
456
|
-
throw new
|
|
499
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_DAY_OF_MONTH, {
|
|
500
|
+
message: `Invalid day of month: ${dayOfMonth}. Expected 1-31`
|
|
501
|
+
});
|
|
457
502
|
}
|
|
458
503
|
}
|
|
459
504
|
|
|
@@ -501,16 +546,16 @@ var TaskSchedule = class {
|
|
|
501
546
|
cron(expression) {
|
|
502
547
|
const parts = expression.trim().split(/\s+/);
|
|
503
548
|
if (parts.length !== 5) {
|
|
504
|
-
throw new
|
|
505
|
-
`Invalid cron expression: "${expression}". Expected 5 parts (minute hour day month weekday), got ${parts.length}.`
|
|
506
|
-
);
|
|
549
|
+
throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
|
|
550
|
+
message: `Invalid cron expression: "${expression}". Expected 5 parts (minute hour day month weekday), got ${parts.length}.`
|
|
551
|
+
});
|
|
507
552
|
}
|
|
508
553
|
const pattern = /^[0-9,\-/*?L#A-Za-z]+$/;
|
|
509
554
|
for (let i = 0; i < 5; i++) {
|
|
510
555
|
if (!pattern.test(parts[i])) {
|
|
511
|
-
throw new
|
|
512
|
-
`Invalid cron expression: "${expression}". Part ${i + 1} ("${parts[i]}") contains invalid characters.`
|
|
513
|
-
);
|
|
556
|
+
throw new HorizonError(422, HorizonErrorCodes.CRON_PARSE_ERROR, {
|
|
557
|
+
message: `Invalid cron expression: "${expression}". Part ${i + 1} ("${parts[i]}") contains invalid characters.`
|
|
558
|
+
});
|
|
514
559
|
}
|
|
515
560
|
}
|
|
516
561
|
this.task.expression = expression;
|
|
@@ -658,9 +703,9 @@ var TaskSchedule = class {
|
|
|
658
703
|
try {
|
|
659
704
|
(/* @__PURE__ */ new Date()).toLocaleString("en-US", { timeZone: timezone });
|
|
660
705
|
} catch {
|
|
661
|
-
throw new
|
|
662
|
-
`Invalid timezone: "${timezone}". See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid values.`
|
|
663
|
-
);
|
|
706
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEZONE, {
|
|
707
|
+
message: `Invalid timezone: "${timezone}". See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for valid values.`
|
|
708
|
+
});
|
|
664
709
|
}
|
|
665
710
|
this.task.timezone = timezone;
|
|
666
711
|
return this;
|
|
@@ -746,7 +791,9 @@ var TaskSchedule = class {
|
|
|
746
791
|
*/
|
|
747
792
|
timeout(ms) {
|
|
748
793
|
if (ms <= 0) {
|
|
749
|
-
throw new
|
|
794
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_TIMEOUT, {
|
|
795
|
+
message: "Timeout must be a positive number"
|
|
796
|
+
});
|
|
750
797
|
}
|
|
751
798
|
this.task.timeout = ms;
|
|
752
799
|
return this;
|
|
@@ -761,10 +808,14 @@ var TaskSchedule = class {
|
|
|
761
808
|
*/
|
|
762
809
|
retry(attempts = 3, delayMs = 1e3) {
|
|
763
810
|
if (attempts < 0) {
|
|
764
|
-
throw new
|
|
811
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_RETRY_ATTEMPTS, {
|
|
812
|
+
message: "Retry attempts must be non-negative"
|
|
813
|
+
});
|
|
765
814
|
}
|
|
766
815
|
if (delayMs < 0) {
|
|
767
|
-
throw new
|
|
816
|
+
throw new HorizonError(422, HorizonErrorCodes.INVALID_RETRY_DELAY, {
|
|
817
|
+
message: "Retry delay must be non-negative"
|
|
818
|
+
});
|
|
768
819
|
}
|
|
769
820
|
this.task.retries = attempts;
|
|
770
821
|
this.task.retryDelay = delayMs;
|
|
@@ -879,7 +930,9 @@ var SchedulerManager = class {
|
|
|
879
930
|
const task = new TaskSchedule(name, async () => {
|
|
880
931
|
const result = await Process.run(command);
|
|
881
932
|
if (!result.success) {
|
|
882
|
-
throw new
|
|
933
|
+
throw new HorizonError(500, HorizonErrorCodes.COMMAND_FAILED, {
|
|
934
|
+
message: `Command failed: ${result.stderr || result.stdout}`
|
|
935
|
+
});
|
|
883
936
|
}
|
|
884
937
|
});
|
|
885
938
|
task.setCommand(command);
|
|
@@ -984,14 +1037,15 @@ var SchedulerManager = class {
|
|
|
984
1037
|
if (task.background) {
|
|
985
1038
|
this.executeTask(task).catch((err) => {
|
|
986
1039
|
this.logger?.error(`Background task ${task.name} failed`, err);
|
|
987
|
-
|
|
988
|
-
|
|
1040
|
+
return { success: false, timedOut: false };
|
|
1041
|
+
}).then(async (result) => {
|
|
1042
|
+
if (task.preventOverlapping && !result.timedOut) {
|
|
989
1043
|
await this.lockManager.release(runningLockKey);
|
|
990
1044
|
}
|
|
991
1045
|
});
|
|
992
1046
|
} else {
|
|
993
|
-
await this.executeTask(task);
|
|
994
|
-
if (task.preventOverlapping) {
|
|
1047
|
+
const result = await this.executeTask(task);
|
|
1048
|
+
if (task.preventOverlapping && !result.timedOut) {
|
|
995
1049
|
await this.lockManager.release(runningLockKey);
|
|
996
1050
|
}
|
|
997
1051
|
}
|
|
@@ -1056,7 +1110,7 @@ var SchedulerManager = class {
|
|
|
1056
1110
|
} catch {
|
|
1057
1111
|
}
|
|
1058
1112
|
}
|
|
1059
|
-
return;
|
|
1113
|
+
return { success: true, timedOut: false };
|
|
1060
1114
|
} catch (err) {
|
|
1061
1115
|
lastError = err;
|
|
1062
1116
|
this.logger?.error(`Task ${task.name} failed (attempt ${attempt + 1})`, err);
|
|
@@ -1079,6 +1133,10 @@ var SchedulerManager = class {
|
|
|
1079
1133
|
} catch {
|
|
1080
1134
|
}
|
|
1081
1135
|
}
|
|
1136
|
+
return {
|
|
1137
|
+
success: false,
|
|
1138
|
+
timedOut: lastError?.message.includes("timed out after") ?? false
|
|
1139
|
+
};
|
|
1082
1140
|
}
|
|
1083
1141
|
};
|
|
1084
1142
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravito/horizon",
|
|
3
|
-
"
|
|
3
|
+
"sideEffects": false,
|
|
4
|
+
"version": "4.0.0",
|
|
4
5
|
"description": "Distributed task scheduler for Gravito framework",
|
|
5
6
|
"main": "./dist/index.cjs",
|
|
6
7
|
"module": "./dist/index.js",
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
],
|
|
21
22
|
"scripts": {
|
|
22
23
|
"build": "bun run build.ts",
|
|
24
|
+
"build:dts": "bun run build.ts --dts-only",
|
|
23
25
|
"test": "bun test --timeout=10000",
|
|
24
26
|
"lint": "biome lint ./src",
|
|
25
27
|
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
|
|
@@ -37,18 +39,20 @@
|
|
|
37
39
|
],
|
|
38
40
|
"author": "Carl Lee <carllee0520@gmail.com>",
|
|
39
41
|
"license": "MIT",
|
|
40
|
-
"dependencies": {
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@gravito/nova": "^1.0.2"
|
|
44
|
+
},
|
|
41
45
|
"optionalDependencies": {
|
|
42
46
|
"cron-parser": "^4.9.0"
|
|
43
47
|
},
|
|
44
48
|
"peerDependencies": {
|
|
45
|
-
"@gravito/core": "^
|
|
46
|
-
"@gravito/stasis": "^
|
|
49
|
+
"@gravito/core": "^3.0.0",
|
|
50
|
+
"@gravito/stasis": "^4.0.0"
|
|
47
51
|
},
|
|
48
52
|
"devDependencies": {
|
|
49
|
-
"@gravito/stasis": "
|
|
53
|
+
"@gravito/stasis": "workspace:*",
|
|
50
54
|
"bun-types": "latest",
|
|
51
|
-
"@gravito/core": "
|
|
55
|
+
"@gravito/core": "workspace:*",
|
|
52
56
|
"tsup": "^8.5.1",
|
|
53
57
|
"typescript": "^5.9.3"
|
|
54
58
|
},
|