@h-ai/scheduler 0.1.0-alpha.56 → 0.1.0-alpha.57
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.en.md +227 -0
- package/README.ja.md +227 -0
- package/README.md +9 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +81 -32
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/README.en.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# @h-ai/scheduler
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[Framework home](../../README.en.md) · Related modules: [@h-ai/cache](../cache/README.en.md) · [@h-ai/core](../core/README.en.md) · [@h-ai/reldb](../reldb/README.en.md)
|
|
6
|
+
|
|
7
|
+
A scheduled task module with a unified task model, cron scheduling, JS function strings / HTTP API execution, persistence, execution log queries, and global task lifecycle hooks.
|
|
8
|
+
|
|
9
|
+
With the database enabled, nodes refresh persisted definitions every `tickInterval` (default 1000 ms); `tasks` is the latest successful snapshot. Registration, updates, deletion, manual triggers, and each attempt after acquiring a scheduling lock reread definitions. Deleted, disabled, or rescheduled old tasks stop executing; read failures abort the attempt. Already-started business side effects are not rolled back by remote changes. `init({ tasks })` remains node-local; use persisted `register()` for dynamic tasks across nodes. Cross-node deduplication requires shared Redis; memory cache is single-process only.
|
|
10
|
+
|
|
11
|
+
Call `await scheduler.refreshTasks()` to refresh without execution; concurrent refreshes of the same snapshot share one read. Registration, updates, and deletion change memory only after successful persistence. Invalid full updates leave cron unchanged. Pass `retry: null` or `handler: null` to clear retries or built-in handlers.
|
|
12
|
+
|
|
13
|
+
Hook business errors are recorded as `failed` and apply `retry`; missing handlers, cancellation, or host-thrown `SchedulerTaskSkippedError` are `interrupted`. `event.context.occurrenceId` identifies an occurrence: the same scheduled minute shares it across nodes, each manual trigger is independent, and retries reuse the original. Hosts can persist idempotency receipts with this ID; long tasks spanning periods still need a host-maintained lease for mutual exclusion. `deleteAfterRun` deletes only after success. Definitions are checked before retry and successful cleanup; one database DELETE atomically matches the executed definition, preserving concurrent updates.
|
|
14
|
+
|
|
15
|
+
Multi-node regression tests build current source and start two real Node processes sharing SQLite and Testcontainers Redis, validating definition convergence and same-minute lock contention. A working Docker/Podman service is required before `pnpm --filter @h-ai/scheduler test`.
|
|
16
|
+
|
|
17
|
+
`await scheduler.close()` stops task intake, cancels HTTP, JS workers, and retry waits, then awaits execution chain exit before clearing state. Reinitialization waits for closure. All lifecycle events expose `signal`; hooks must observe cancellation and stop external side effects. The framework cannot terminate business code on the same thread; do not block the event loop in hooks. Cancelled old executions no longer retry, delete tasks, or call new-instance hooks. Keep the database and cache available until scheduler closure completes, then close dependencies.
|
|
18
|
+
|
|
19
|
+
Each JS string runs in its own worker; `timeout` defaults to 30000 ms including startup. On timeout, terminate the thread and await exit before logging failure or retrying. Synchronous infinite loops cannot block the main event loop. Context and results must support structured cloning. A worker's `vm` is still not a security sandbox; code must come from trusted server-side sources.
|
|
20
|
+
|
|
21
|
+
API task `timeout` (default 30000 ms) covers the request and full response body read. `ApiTaskConfig.maxResponseBytes` limits received body bytes (positive safe integer; default 1048576, or 1 MiB). `maxResponseBytes: 10 * 1024 * 1024` allows 10 MiB. Invalid values fail before the request. Timeouts, size limits, and non-success HTTP statuses are logged as failures and apply task retries.
|
|
22
|
+
|
|
23
|
+
`init()` succeeds only after loading all tasks. Empty or duplicate IDs (including persisted duplicates), invalid cron, database read failures, and persisted-task parse failures return failure and clear initialization state. Correct configuration or data before retrying. Do not redeclare existing database tasks in `tasks`.
|
|
24
|
+
|
|
25
|
+
## Dependencies
|
|
26
|
+
|
|
27
|
+
- `@h-ai/reldb`: task and execution log persistence; initialize first when `enableDb: true`.
|
|
28
|
+
- `@h-ai/cache`: optional distributed locks; automatically participates in scheduled lock acquisition when initialized.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { cache } from '@h-ai/cache'
|
|
34
|
+
import { reldb } from '@h-ai/reldb'
|
|
35
|
+
import { scheduler } from '@h-ai/scheduler'
|
|
36
|
+
|
|
37
|
+
await reldb.init({ type: 'sqlite', database: './scheduler.db' })
|
|
38
|
+
await cache.init({ type: 'memory' }) // 可选
|
|
39
|
+
|
|
40
|
+
await scheduler.init({
|
|
41
|
+
enableDb: true,
|
|
42
|
+
maxLogs: 1000,
|
|
43
|
+
retentionDays: 30,
|
|
44
|
+
hooks: {
|
|
45
|
+
onTaskStart(event) {
|
|
46
|
+
void event
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
await scheduler.register({
|
|
52
|
+
id: 'health-check',
|
|
53
|
+
name: '健康检查',
|
|
54
|
+
description: '每 5 分钟执行一次系统健康检查',
|
|
55
|
+
cron: '*/5 * * * *',
|
|
56
|
+
params: { channel: 'ops' },
|
|
57
|
+
retry: { maxAttempts: 3, backoffMs: [1000, 5000] },
|
|
58
|
+
handler: {
|
|
59
|
+
kind: 'api',
|
|
60
|
+
url: 'https://api.example.com/health',
|
|
61
|
+
method: 'GET',
|
|
62
|
+
timeout: 10000,
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
await scheduler.register({
|
|
67
|
+
id: 'cleanup',
|
|
68
|
+
name: '清理过期数据',
|
|
69
|
+
description: '每日凌晨执行过期数据清理',
|
|
70
|
+
cron: '0 2 * * *',
|
|
71
|
+
deleteAfterRun: true,
|
|
72
|
+
params: { source: 'nightly' },
|
|
73
|
+
handler: {
|
|
74
|
+
kind: 'js',
|
|
75
|
+
code: '(context) => ({ taskId: context.taskId, params: context.params })',
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
scheduler.start()
|
|
80
|
+
|
|
81
|
+
const manualResult = await scheduler.trigger('cleanup', { source: 'admin-console' })
|
|
82
|
+
const logs = await scheduler.getLogs({
|
|
83
|
+
triggerType: 'manual',
|
|
84
|
+
triggerSource: 'admin-console',
|
|
85
|
+
startedAfter: Date.now() - 24 * 60 * 60 * 1000,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
scheduler.stop()
|
|
89
|
+
await scheduler.close()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Unified task model
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
interface TaskDefinition {
|
|
96
|
+
id: string
|
|
97
|
+
name: string
|
|
98
|
+
description?: string
|
|
99
|
+
cron: string
|
|
100
|
+
timezone?: string // IANA 时区,例如 Asia/Shanghai;按该时区解释 cron 墙上时间
|
|
101
|
+
enabled?: boolean
|
|
102
|
+
deleteAfterRun?: boolean
|
|
103
|
+
retry?: {
|
|
104
|
+
maxAttempts: number
|
|
105
|
+
backoffMs?: number[]
|
|
106
|
+
}
|
|
107
|
+
params?: Record<string, unknown>
|
|
108
|
+
handler?: ApiTaskConfig | JsTaskConfig
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ApiTaskConfig {
|
|
112
|
+
kind: 'api'
|
|
113
|
+
url: string
|
|
114
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
115
|
+
headers?: Record<string, string>
|
|
116
|
+
body?: unknown
|
|
117
|
+
timeout?: number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface JsTaskConfig {
|
|
121
|
+
kind: 'js'
|
|
122
|
+
code: string // JS 函数字符串
|
|
123
|
+
timeout?: number
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Notes:
|
|
128
|
+
|
|
129
|
+
- All tasks can be persisted, including `kind: 'js'` tasks.
|
|
130
|
+
- JS tasks are compiled at runtime and cached by source to avoid recompilation on every trigger.
|
|
131
|
+
- `handler` can be omitted; use global `hooks.onTaskExecute` for execution in that case.
|
|
132
|
+
|
|
133
|
+
> ⚠️ **Security warning**: `kind: 'js'` is only for trusted server-side code. Node.js `vm` provides convenient execution and **is not a security sandbox**. Never accept arbitrary JS strings from users, tenants, or admin inputs. Prefer `kind: 'api'` or `hooks.onTaskExecute` for configurable tasks.
|
|
134
|
+
|
|
135
|
+
## Task lifecycle hooks
|
|
136
|
+
|
|
137
|
+
Provide hooks at initialization or set them at runtime with `setHooks()`:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
await scheduler.init({
|
|
141
|
+
enableDb: false,
|
|
142
|
+
hooks: {
|
|
143
|
+
onTaskStart(event) {
|
|
144
|
+
void event
|
|
145
|
+
},
|
|
146
|
+
async onTaskExecute(event) {
|
|
147
|
+
return { via: 'hook', source: event.context.trigger.source }
|
|
148
|
+
},
|
|
149
|
+
onTaskInterrupted(event) {
|
|
150
|
+
void event
|
|
151
|
+
},
|
|
152
|
+
onTaskFinish(event) {
|
|
153
|
+
void event
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Execution logs
|
|
160
|
+
|
|
161
|
+
Filter logs by trigger source:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
const logs = await scheduler.getLogs({
|
|
165
|
+
taskId: 'cleanup',
|
|
166
|
+
triggerType: 'manual',
|
|
167
|
+
triggerSource: 'admin-console',
|
|
168
|
+
startedAfter: Date.now() - 24 * 60 * 60 * 1000,
|
|
169
|
+
startedBefore: Date.now(),
|
|
170
|
+
pagination: { page: 1, pageSize: 20 },
|
|
171
|
+
})
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Execution logs support automatic cleanup policies configured at initialization:
|
|
175
|
+
|
|
176
|
+
- `maxLogs`: retain at most N logs.
|
|
177
|
+
- `retentionDays`: retain at most the last N days.
|
|
178
|
+
|
|
179
|
+
## API overview
|
|
180
|
+
|
|
181
|
+
- `init(config?)`
|
|
182
|
+
- `register(task)`
|
|
183
|
+
- `unregister(taskId)`
|
|
184
|
+
- `updateTask(taskId, updates)`
|
|
185
|
+
- `refreshTasks()`
|
|
186
|
+
- `start()` / `stop()`
|
|
187
|
+
- `trigger(taskId, { source? })`
|
|
188
|
+
- `getLogs(options?)`
|
|
189
|
+
- `setHooks(hooks)` / `clearHooks()`
|
|
190
|
+
- `tasks` / `hooks` / `config` / `isInitialized` / `isRunning`
|
|
191
|
+
- `close()`
|
|
192
|
+
|
|
193
|
+
## Error handling
|
|
194
|
+
|
|
195
|
+
All public APIs except `close()` return `HaiResult<T>`.
|
|
196
|
+
|
|
197
|
+
Common error codes:
|
|
198
|
+
|
|
199
|
+
| Error code | code | Description |
|
|
200
|
+
| ----------------------------------------- | ------------------- | ----------------------- |
|
|
201
|
+
| `HaiSchedulerError.NOT_INITIALIZED` | `hai:scheduler:010` | Not initialized |
|
|
202
|
+
| `HaiSchedulerError.INIT_FAILED` | `hai:scheduler:011` | Initialization failed |
|
|
203
|
+
| `HaiSchedulerError.CONFIG_ERROR` | `hai:scheduler:012` | Invalid configuration |
|
|
204
|
+
| `HaiSchedulerError.TASK_NOT_FOUND` | `hai:scheduler:020` | Task not found |
|
|
205
|
+
| `HaiSchedulerError.TASK_ALREADY_EXISTS` | `hai:scheduler:021` | Task already exists |
|
|
206
|
+
| `HaiSchedulerError.INVALID_CRON` | `hai:scheduler:022` | Invalid cron expression |
|
|
207
|
+
| `HaiSchedulerError.EXECUTION_FAILED` | `hai:scheduler:023` | Execution failed |
|
|
208
|
+
| `HaiSchedulerError.JS_EXECUTION_FAILED` | `hai:scheduler:024` | JS execution failed |
|
|
209
|
+
| `HaiSchedulerError.API_EXECUTION_FAILED` | `hai:scheduler:025` | API execution failed |
|
|
210
|
+
| `HaiSchedulerError.DB_SAVE_FAILED` | `hai:scheduler:026` | Database save failed |
|
|
211
|
+
| `HaiSchedulerError.ALREADY_RUNNING` | `hai:scheduler:027` | Already running |
|
|
212
|
+
| `HaiSchedulerError.NOT_RUNNING` | `hai:scheduler:028` | Not running |
|
|
213
|
+
| `HaiSchedulerError.LOCK_ACQUIRE_FAILED` | `hai:scheduler:029` | Lock acquisition failed |
|
|
214
|
+
| `HaiSchedulerError.JS_COMPILE_FAILED` | `hai:scheduler:030` | JS compilation failed |
|
|
215
|
+
| `HaiSchedulerError.HOOK_EXECUTION_FAILED` | `hai:scheduler:031` | Hook execution failed |
|
|
216
|
+
|
|
217
|
+
## Tests
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
pnpm --filter @h-ai/scheduler test
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
Apache-2.0
|
|
226
|
+
|
|
227
|
+
Cron is matched by minute, and distributed lock keys use that same minute. Startup or ticks delayed beyond second zero still check the current minute; a running scheduler does not reschedule it on every tick.
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# @h-ai/scheduler
|
|
2
|
+
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[フレームワークのホーム](../../README.ja.md) · 関連モジュール:[@h-ai/cache](../cache/README.ja.md) · [@h-ai/core](../core/README.ja.md) · [@h-ai/reldb](../reldb/README.ja.md)
|
|
6
|
+
|
|
7
|
+
共通のタスクモデル、cron スケジュール、JS 関数文字列・HTTP API の実行、永続化、実行ログ検索、全体のライフサイクル Hook を提供するスケジューラーモジュールです。
|
|
8
|
+
|
|
9
|
+
DB を有効にすると、各ノードは `tickInterval`(既定 1000 ms)ごとに保存された定義を更新し、`tasks` は直近の成功したスナップショットになります。登録・更新・削除・手動実行、およびロック取得後の各試行で定義を再読します。削除・無効化・再スケジュールされた古いタスクは実行せず、読み取り失敗時は試行を中断します。開始済みの副作用は遠隔の変更で自動ロールバックしません。`init({ tasks })` はノード固有設定です。ノード間の動的タスクには永続化する `register()`、重複防止には共有 Redis を使います。メモリーキャッシュは単一プロセスのみです。
|
|
10
|
+
|
|
11
|
+
`await scheduler.refreshTasks()` でタスクを実行せず定義を更新できます。同じスナップショットの並行更新は 1 回の読み取りを共有します。登録・更新・削除は永続化成功後にメモリーへ反映し、全体更新の検証失敗では cron を変更しません。再試行や組み込み handler の解除には `retry: null`、`handler: null` を渡します。
|
|
12
|
+
|
|
13
|
+
Hook が投げる業務エラーは `failed` として記録し `retry` を適用します。handler の不足、キャンセル、ホストが投げる `SchedulerTaskSkippedError` は `interrupted` です。`event.context.occurrenceId` は 1 回の発生を識別し、同じスケジュール分ではノード間で共通、手動実行では毎回独立、再試行では元の ID を共有します。ホストはこの ID で冪等性の受領記録を保存できます。周期をまたぐ長時間タスクの排他制御にはホスト側の継続リースが必要です。`deleteAfterRun` は成功時のみ削除します。再試行前と成功後の削除前に現在の定義を確認し、DB の削除は 1 つの DELETE 内で実行定義をアトミックに照合して並行更新を保持します。
|
|
14
|
+
|
|
15
|
+
複数ノードの回帰テストは現在のソースをビルドし、SQLite と Testcontainers Redis を共有する実際の Node プロセスを 2 つ起動して、定義の収束と同じ分のロック競合を検証します。`pnpm --filter @h-ai/scheduler test` の前に、利用可能な Docker/Podman サービスが必要です。
|
|
16
|
+
|
|
17
|
+
`await scheduler.close()` は受付を止め、HTTP、JS Worker、再試行待機をキャンセルし、実行チェーンの終了後に状態を解放します。再初期化は終了完了を待ちます。すべてのイベントが `signal` を提供し、Hook はキャンセルを監視して外部への副作用を停止する必要があります。同じスレッドの業務コードは強制終了できないため、Hook でイベントループをブロックしないでください。キャンセルされた旧実行は再試行・自動削除・新インスタンスの Hook 呼び出しを行いません。scheduler の終了完了まで DB とキャッシュを維持し、その後に依存モジュールを終了します。
|
|
18
|
+
|
|
19
|
+
JS 文字列は呼び出しごとの独立 Worker で実行します。`timeout` は起動を含めて既定 30000 ms です。時間超過時はスレッドを終了し、終了を待ってから失敗記録・再試行を行います。同期の無限ループでもメインイベントループをブロックしません。コンテキストと戻り値は構造化クローンに対応する必要があります。Worker 内の `vm` も安全なサンドボックスではなく、信頼できるサーバー側コードのみを使用します。
|
|
20
|
+
|
|
21
|
+
API タスクの `timeout`(既定 30000 ms)はリクエストと応答本文全体の読み取りを含みます。`ApiTaskConfig.maxResponseBytes` は受信バイト上限で、正の安全な整数、既定は 1048576(1 MiB)です。`maxResponseBytes: 10 * 1024 * 1024` なら最大 10 MiB を許可し、不正値は送信前に失敗します。タイムアウト・上限超過・非成功 HTTP 応答は失敗として記録し、タスクの再試行方針を適用します。
|
|
22
|
+
|
|
23
|
+
`init()` は全タスクの読み込み完了後にのみ成功します。空 ID、重複 ID(保存済みタスクとの重複を含む)、不正な cron、DB 読み取り失敗、保存タスクの解析失敗では失敗を返し、初期化状態を消去します。設定・データを修正してから再初期化し、既存の DB タスクを `tasks` に重複宣言しないでください。
|
|
24
|
+
|
|
25
|
+
## 依存関係
|
|
26
|
+
|
|
27
|
+
- `@h-ai/reldb`:タスク定義と実行ログの永続化。`enableDb: true` の場合は先に初期化。
|
|
28
|
+
- `@h-ai/cache`:任意の分散ロック。初期化済みの場合、定時実行時のロック取得に自動参加。
|
|
29
|
+
|
|
30
|
+
## クイックスタート
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { cache } from '@h-ai/cache'
|
|
34
|
+
import { reldb } from '@h-ai/reldb'
|
|
35
|
+
import { scheduler } from '@h-ai/scheduler'
|
|
36
|
+
|
|
37
|
+
await reldb.init({ type: 'sqlite', database: './scheduler.db' })
|
|
38
|
+
await cache.init({ type: 'memory' }) // 可选
|
|
39
|
+
|
|
40
|
+
await scheduler.init({
|
|
41
|
+
enableDb: true,
|
|
42
|
+
maxLogs: 1000,
|
|
43
|
+
retentionDays: 30,
|
|
44
|
+
hooks: {
|
|
45
|
+
onTaskStart(event) {
|
|
46
|
+
void event
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
await scheduler.register({
|
|
52
|
+
id: 'health-check',
|
|
53
|
+
name: '健康检查',
|
|
54
|
+
description: '每 5 分钟执行一次系统健康检查',
|
|
55
|
+
cron: '*/5 * * * *',
|
|
56
|
+
params: { channel: 'ops' },
|
|
57
|
+
retry: { maxAttempts: 3, backoffMs: [1000, 5000] },
|
|
58
|
+
handler: {
|
|
59
|
+
kind: 'api',
|
|
60
|
+
url: 'https://api.example.com/health',
|
|
61
|
+
method: 'GET',
|
|
62
|
+
timeout: 10000,
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
await scheduler.register({
|
|
67
|
+
id: 'cleanup',
|
|
68
|
+
name: '清理过期数据',
|
|
69
|
+
description: '每日凌晨执行过期数据清理',
|
|
70
|
+
cron: '0 2 * * *',
|
|
71
|
+
deleteAfterRun: true,
|
|
72
|
+
params: { source: 'nightly' },
|
|
73
|
+
handler: {
|
|
74
|
+
kind: 'js',
|
|
75
|
+
code: '(context) => ({ taskId: context.taskId, params: context.params })',
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
scheduler.start()
|
|
80
|
+
|
|
81
|
+
const manualResult = await scheduler.trigger('cleanup', { source: 'admin-console' })
|
|
82
|
+
const logs = await scheduler.getLogs({
|
|
83
|
+
triggerType: 'manual',
|
|
84
|
+
triggerSource: 'admin-console',
|
|
85
|
+
startedAfter: Date.now() - 24 * 60 * 60 * 1000,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
scheduler.stop()
|
|
89
|
+
await scheduler.close()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 共通タスクモデル
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
interface TaskDefinition {
|
|
96
|
+
id: string
|
|
97
|
+
name: string
|
|
98
|
+
description?: string
|
|
99
|
+
cron: string
|
|
100
|
+
timezone?: string // IANA 时区,例如 Asia/Shanghai;按该时区解释 cron 墙上时间
|
|
101
|
+
enabled?: boolean
|
|
102
|
+
deleteAfterRun?: boolean
|
|
103
|
+
retry?: {
|
|
104
|
+
maxAttempts: number
|
|
105
|
+
backoffMs?: number[]
|
|
106
|
+
}
|
|
107
|
+
params?: Record<string, unknown>
|
|
108
|
+
handler?: ApiTaskConfig | JsTaskConfig
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ApiTaskConfig {
|
|
112
|
+
kind: 'api'
|
|
113
|
+
url: string
|
|
114
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
115
|
+
headers?: Record<string, string>
|
|
116
|
+
body?: unknown
|
|
117
|
+
timeout?: number
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface JsTaskConfig {
|
|
121
|
+
kind: 'js'
|
|
122
|
+
code: string // JS 函数字符串
|
|
123
|
+
timeout?: number
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
補足:
|
|
128
|
+
|
|
129
|
+
- `kind: 'js'` を含むすべてのタスクを DB に永続化できます。
|
|
130
|
+
- JS タスクは実行時にコンパイルし、ソース単位でキャッシュして毎回の再コンパイルを避けます。
|
|
131
|
+
- `handler` は省略可能で、その場合は共通の `hooks.onTaskExecute` で実行します。
|
|
132
|
+
|
|
133
|
+
> ⚠️ **セキュリティ注意**:`kind: 'js'` は信頼できるサーバー側コード専用です。Node.js の `vm` による実行は**安全なサンドボックスではありません**。ユーザー、テナント、管理画面から任意の JS 文字列を直接受け付けないでください。設定可能なタスクには `kind: 'api'` または `hooks.onTaskExecute` を優先してください。
|
|
134
|
+
|
|
135
|
+
## タスクのライフサイクル Hook
|
|
136
|
+
|
|
137
|
+
初期化時に渡すか、実行時に `setHooks()` で設定します:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
await scheduler.init({
|
|
141
|
+
enableDb: false,
|
|
142
|
+
hooks: {
|
|
143
|
+
onTaskStart(event) {
|
|
144
|
+
void event
|
|
145
|
+
},
|
|
146
|
+
async onTaskExecute(event) {
|
|
147
|
+
return { via: 'hook', source: event.context.trigger.source }
|
|
148
|
+
},
|
|
149
|
+
onTaskInterrupted(event) {
|
|
150
|
+
void event
|
|
151
|
+
},
|
|
152
|
+
onTaskFinish(event) {
|
|
153
|
+
void event
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## 実行ログ
|
|
160
|
+
|
|
161
|
+
実行元でログを絞り込めます:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
const logs = await scheduler.getLogs({
|
|
165
|
+
taskId: 'cleanup',
|
|
166
|
+
triggerType: 'manual',
|
|
167
|
+
triggerSource: 'admin-console',
|
|
168
|
+
startedAfter: Date.now() - 24 * 60 * 60 * 1000,
|
|
169
|
+
startedBefore: Date.now(),
|
|
170
|
+
pagination: { page: 1, pageSize: 20 },
|
|
171
|
+
})
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
実行ログは初期化時に自動削除方針を設定できます:
|
|
175
|
+
|
|
176
|
+
- `maxLogs`:最大 N 件を保持。
|
|
177
|
+
- `retentionDays`:直近 N 日分のみを保持。
|
|
178
|
+
|
|
179
|
+
## API 一覧
|
|
180
|
+
|
|
181
|
+
- `init(config?)`
|
|
182
|
+
- `register(task)`
|
|
183
|
+
- `unregister(taskId)`
|
|
184
|
+
- `updateTask(taskId, updates)`
|
|
185
|
+
- `refreshTasks()`
|
|
186
|
+
- `start()` / `stop()`
|
|
187
|
+
- `trigger(taskId, { source? })`
|
|
188
|
+
- `getLogs(options?)`
|
|
189
|
+
- `setHooks(hooks)` / `clearHooks()`
|
|
190
|
+
- `tasks` / `hooks` / `config` / `isInitialized` / `isRunning`
|
|
191
|
+
- `close()`
|
|
192
|
+
|
|
193
|
+
## エラー処理
|
|
194
|
+
|
|
195
|
+
`close()` を除くすべての公開 API は `HaiResult<T>` を返します。
|
|
196
|
+
|
|
197
|
+
主なエラーコード:
|
|
198
|
+
|
|
199
|
+
| エラーコード | code | 説明 |
|
|
200
|
+
| ----------------------------------------- | ------------------- | -------------------- |
|
|
201
|
+
| `HaiSchedulerError.NOT_INITIALIZED` | `hai:scheduler:010` | 未初期化 |
|
|
202
|
+
| `HaiSchedulerError.INIT_FAILED` | `hai:scheduler:011` | 初期化に失敗 |
|
|
203
|
+
| `HaiSchedulerError.CONFIG_ERROR` | `hai:scheduler:012` | 設定エラー |
|
|
204
|
+
| `HaiSchedulerError.TASK_NOT_FOUND` | `hai:scheduler:020` | タスクが存在しない |
|
|
205
|
+
| `HaiSchedulerError.TASK_ALREADY_EXISTS` | `hai:scheduler:021` | タスクが既に存在する |
|
|
206
|
+
| `HaiSchedulerError.INVALID_CRON` | `hai:scheduler:022` | 無効な Cron 式 |
|
|
207
|
+
| `HaiSchedulerError.EXECUTION_FAILED` | `hai:scheduler:023` | 実行に失敗 |
|
|
208
|
+
| `HaiSchedulerError.JS_EXECUTION_FAILED` | `hai:scheduler:024` | JS 実行に失敗 |
|
|
209
|
+
| `HaiSchedulerError.API_EXECUTION_FAILED` | `hai:scheduler:025` | API 実行に失敗 |
|
|
210
|
+
| `HaiSchedulerError.DB_SAVE_FAILED` | `hai:scheduler:026` | DB 保存に失敗 |
|
|
211
|
+
| `HaiSchedulerError.ALREADY_RUNNING` | `hai:scheduler:027` | 実行中 |
|
|
212
|
+
| `HaiSchedulerError.NOT_RUNNING` | `hai:scheduler:028` | 実行していない |
|
|
213
|
+
| `HaiSchedulerError.LOCK_ACQUIRE_FAILED` | `hai:scheduler:029` | ロック取得に失敗 |
|
|
214
|
+
| `HaiSchedulerError.JS_COMPILE_FAILED` | `hai:scheduler:030` | JS コンパイルに失敗 |
|
|
215
|
+
| `HaiSchedulerError.HOOK_EXECUTION_FAILED` | `hai:scheduler:031` | Hook 実行に失敗 |
|
|
216
|
+
|
|
217
|
+
## テスト
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
pnpm --filter @h-ai/scheduler test
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
Apache-2.0
|
|
226
|
+
|
|
227
|
+
スケジュールループは分単位で cron を照合し、同じ分から分散ロックのキーを生成します。起動や tick が 0 秒を過ぎても現在の分を確認し、同じ実行中に毎 tick 重複してスケジュールしません。
|
package/README.md
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
# @h-ai/scheduler
|
|
2
2
|
|
|
3
|
+
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
|
+
|
|
5
|
+
[框架首页](../../README.md) · 相关模块:[@h-ai/cache](../cache/README.md) · [@h-ai/core](../core/README.md) · [@h-ai/reldb](../reldb/README.md)
|
|
6
|
+
|
|
3
7
|
定时任务调度模块,支持统一任务模型、cron 调度、JS 函数字符串 / HTTP API 执行、任务持久化、执行日志查询,以及全局任务生命周期回调。
|
|
4
8
|
|
|
5
9
|
启用数据库时,运行中的节点每个 `tickInterval`(默认 1000 毫秒)刷新持久化定义;`tasks` 是最近一次成功刷新的快照。注册、修改、删除、手工触发和获得调度锁后的每次尝试均重新读取定义,删除/禁用/已改调度时间的旧任务不会继续执行,读取失败则中断本次尝试。已经开始的业务副作用不因远端定义变化自动回滚。`init({ tasks })` 仍是节点本地配置,跨节点动态任务请用持久化的 `register()` 管理;跨节点防重需要共享 Redis,内存缓存仅限单进程。
|
|
6
10
|
|
|
11
|
+
可调用 `await scheduler.refreshTasks()` 主动刷新而不执行任务;同一快照的并发刷新共用一次读取。注册、更新与删除成功持久化后才更新内存投影,整份更新验证失败不改变 cron。清除重试或内置处理器时分别传 `retry: null`、`handler: null`。
|
|
12
|
+
|
|
13
|
+
Hook 抛出的业务失败记录为 `failed` 并应用 `retry`;缺少处理器、取消或宿主抛出的 `SchedulerTaskSkippedError` 记录为 `interrupted`。`event.context.occurrenceId` 标识一次发生:同一调度分钟跨节点一致,手工触发每次独立,重试共享原标识。宿主可据此持久化业务幂等回执;跨周期的长任务互斥仍需宿主持续租约。`deleteAfterRun` 仅在成功后删除,失败任务保留;重试前与成功清理前核对当前定义,数据库清理在同一条 DELETE 内原子匹配执行定义,保留期间提交的并发更新。
|
|
14
|
+
|
|
7
15
|
多节点回归测试会构建当前源码、启动两个真实 Node 进程共享 SQLite 和 Testcontainers Redis,验证定义收敛及同分钟锁竞争;运行 `pnpm --filter @h-ai/scheduler test` 前须有可用的 Docker/Podman 服务。
|
|
8
16
|
|
|
9
17
|
`await scheduler.close()` 先停止接收任务,取消 HTTP、JS Worker 与重试等待,再等待执行链退出后清理状态。重新初始化等待关闭完成。所有生命周期事件提供 `signal`;Hook 必须监听取消并停止自己的外部副作用(框架无法强制终止同一线程中的业务代码),不要在 Hook 中阻塞事件循环。取消后的旧执行不再重试、不自动删除任务,也不会调用新实例的 Hook。关闭期间保持数据库和缓存可用,待 scheduler 关闭后再关闭依赖。
|
|
@@ -174,6 +182,7 @@ const logs = await scheduler.getLogs({
|
|
|
174
182
|
- `register(task)`
|
|
175
183
|
- `unregister(taskId)`
|
|
176
184
|
- `updateTask(taskId, updates)`
|
|
185
|
+
- `refreshTasks()`
|
|
177
186
|
- `start()` / `stop()`
|
|
178
187
|
- `trigger(taskId, { source? })`
|
|
179
188
|
- `getLogs(options?)`
|
package/dist/index.d.ts
CHANGED
|
@@ -169,6 +169,8 @@ interface TaskTriggerInfo {
|
|
|
169
169
|
* 任务执行上下文
|
|
170
170
|
*/
|
|
171
171
|
interface SchedulerTaskContext {
|
|
172
|
+
/** 一次调度发生的稳定标识;所有重试共享,供宿主构造业务幂等键。 */
|
|
173
|
+
occurrenceId?: string;
|
|
172
174
|
/** 当前任务 */
|
|
173
175
|
task: TaskDefinition;
|
|
174
176
|
/** 任务 ID */
|
|
@@ -329,6 +331,8 @@ interface SchedulerInitInput extends SchedulerConfigInput {
|
|
|
329
331
|
* 调度器函数接口
|
|
330
332
|
*/
|
|
331
333
|
interface SchedulerFunctions {
|
|
334
|
+
/** 刷新共享数据库中的任务定义,用于跨实例配置读取。 */
|
|
335
|
+
refreshTasks: () => Promise<HaiResult<void>>;
|
|
332
336
|
/** 初始化调度器 */
|
|
333
337
|
init: (config?: SchedulerInitInput) => Promise<HaiResult<void>>;
|
|
334
338
|
/** 注册任务 */
|