@h-ai/scheduler 0.1.0-alpha.66 → 0.1.0-alpha.68
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 +90 -50
- package/README.ja.md +74 -34
- package/README.md +91 -51
- package/dist/index.d.ts +528 -51
- package/dist/index.js +1125 -677
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/README.en.md
CHANGED
|
@@ -2,40 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
4
|
|
|
5
|
-
[Framework home](../../README.en.md) · Related modules: [@h-ai/
|
|
5
|
+
[Framework home](../../README.en.md) · Related modules: [@h-ai/core](../core/README.en.md) · [@h-ai/reldb](../reldb/README.en.md)
|
|
6
6
|
|
|
7
|
-
|
|
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`.
|
|
7
|
+
## Introduction
|
|
16
8
|
|
|
17
|
-
|
|
9
|
+
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.
|
|
18
10
|
|
|
19
|
-
|
|
11
|
+
## Core features
|
|
20
12
|
|
|
21
|
-
|
|
13
|
+
- Run tasks on a cron schedule or trigger them from application code.
|
|
14
|
+
- Execute HTTP requests, trusted JavaScript and application hooks, with retry and cancellation.
|
|
15
|
+
- Persist tasks, execution journals and renewable leases in a shared database, with unknown-outcome isolation.
|
|
22
16
|
|
|
23
|
-
|
|
17
|
+
## Getting started
|
|
24
18
|
|
|
25
|
-
|
|
19
|
+
### Dependencies
|
|
26
20
|
|
|
27
|
-
- `@h-ai/reldb`:
|
|
28
|
-
- `@h-ai/cache`: optional distributed locks; automatically participates in scheduled lock acquisition when initialized.
|
|
21
|
+
- `@h-ai/reldb`: initialize the shared database first; tasks, execution journals and leases are mandatory, and `enableDb` accepts only true.
|
|
29
22
|
|
|
30
|
-
|
|
23
|
+
### Quick start
|
|
31
24
|
|
|
32
25
|
```ts
|
|
33
|
-
import { cache } from '@h-ai/cache'
|
|
34
26
|
import { reldb } from '@h-ai/reldb'
|
|
35
27
|
import { scheduler } from '@h-ai/scheduler'
|
|
36
28
|
|
|
37
29
|
await reldb.init({ type: 'sqlite', database: './scheduler.db' })
|
|
38
|
-
await cache.init({ type: 'memory' }) // 可选
|
|
39
30
|
|
|
40
31
|
await scheduler.init({
|
|
41
32
|
enableDb: true,
|
|
@@ -54,7 +45,6 @@ await scheduler.register({
|
|
|
54
45
|
description: '每 5 分钟执行一次系统健康检查',
|
|
55
46
|
cron: '*/5 * * * *',
|
|
56
47
|
params: { channel: 'ops' },
|
|
57
|
-
retry: { maxAttempts: 3, backoffMs: [1000, 5000] },
|
|
58
48
|
handler: {
|
|
59
49
|
kind: 'api',
|
|
60
50
|
url: 'https://api.example.com/health',
|
|
@@ -89,7 +79,7 @@ scheduler.stop()
|
|
|
89
79
|
await scheduler.close()
|
|
90
80
|
```
|
|
91
81
|
|
|
92
|
-
|
|
82
|
+
### Unified task model
|
|
93
83
|
|
|
94
84
|
```ts
|
|
95
85
|
interface TaskDefinition {
|
|
@@ -97,9 +87,10 @@ interface TaskDefinition {
|
|
|
97
87
|
name: string
|
|
98
88
|
description?: string
|
|
99
89
|
cron: string
|
|
100
|
-
timezone?: string // IANA 时区,例如 Asia/Shanghai
|
|
90
|
+
timezone?: string // IANA 时区,例如 Asia/Shanghai;省略时为 UTC,不读取节点 TZ
|
|
101
91
|
enabled?: boolean
|
|
102
92
|
deleteAfterRun?: boolean
|
|
93
|
+
idempotent?: boolean
|
|
103
94
|
retry?: {
|
|
104
95
|
maxAttempts: number
|
|
105
96
|
backoffMs?: number[]
|
|
@@ -114,6 +105,7 @@ interface ApiTaskConfig {
|
|
|
114
105
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
115
106
|
headers?: Record<string, string>
|
|
116
107
|
body?: unknown
|
|
108
|
+
maxResponseBytes?: number
|
|
117
109
|
timeout?: number
|
|
118
110
|
}
|
|
119
111
|
|
|
@@ -132,13 +124,13 @@ Notes:
|
|
|
132
124
|
|
|
133
125
|
> ⚠️ **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
126
|
|
|
135
|
-
|
|
127
|
+
### Task lifecycle hooks
|
|
136
128
|
|
|
137
129
|
Provide hooks at initialization or set them at runtime with `setHooks()`:
|
|
138
130
|
|
|
139
131
|
```ts
|
|
140
132
|
await scheduler.init({
|
|
141
|
-
enableDb:
|
|
133
|
+
enableDb: true,
|
|
142
134
|
hooks: {
|
|
143
135
|
onTaskStart(event) {
|
|
144
136
|
void event
|
|
@@ -156,7 +148,7 @@ await scheduler.init({
|
|
|
156
148
|
})
|
|
157
149
|
```
|
|
158
150
|
|
|
159
|
-
|
|
151
|
+
### Execution logs
|
|
160
152
|
|
|
161
153
|
Filter logs by trigger source:
|
|
162
154
|
|
|
@@ -176,7 +168,7 @@ Execution logs support automatic cleanup policies configured at initialization:
|
|
|
176
168
|
- `maxLogs`: retain at most N logs.
|
|
177
169
|
- `retentionDays`: retain at most the last N days.
|
|
178
170
|
|
|
179
|
-
|
|
171
|
+
### API overview
|
|
180
172
|
|
|
181
173
|
- `init(config?)`
|
|
182
174
|
- `register(task)`
|
|
@@ -184,44 +176,92 @@ Execution logs support automatic cleanup policies configured at initialization:
|
|
|
184
176
|
- `updateTask(taskId, updates)`
|
|
185
177
|
- `refreshTasks()`
|
|
186
178
|
- `start()` / `stop()`
|
|
187
|
-
- `trigger(taskId, { source? })`
|
|
179
|
+
- `trigger(taskId, { source?, idempotencyKey? })`
|
|
188
180
|
- `getLogs(options?)`
|
|
181
|
+
- `execution.listLogFailures(options?)` / `execution.repairLog(occurrenceId)`
|
|
182
|
+
- `execution.get(occurrenceId)` / `execution.listUnknown(options?)` / `execution.resolveUnknown(occurrenceId, input)`
|
|
189
183
|
- `setHooks(hooks)` / `clearHooks()`
|
|
190
184
|
- `tasks` / `hooks` / `config` / `isInitialized` / `isRunning`
|
|
191
185
|
- `close()`
|
|
192
186
|
|
|
193
|
-
|
|
187
|
+
### Error handling
|
|
194
188
|
|
|
195
189
|
All public APIs except `close()` return `HaiResult<T>`.
|
|
196
190
|
|
|
197
191
|
Common error codes:
|
|
198
192
|
|
|
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.
|
|
214
|
-
| `HaiSchedulerError.
|
|
215
|
-
| `HaiSchedulerError.
|
|
216
|
-
|
|
217
|
-
|
|
193
|
+
| Error code | code | Description |
|
|
194
|
+
| ----------------------------------------- | ------------------- | ----------------------------- |
|
|
195
|
+
| `HaiSchedulerError.NOT_INITIALIZED` | `hai:scheduler:010` | Not initialized |
|
|
196
|
+
| `HaiSchedulerError.INIT_FAILED` | `hai:scheduler:011` | Initialization failed |
|
|
197
|
+
| `HaiSchedulerError.CONFIG_ERROR` | `hai:scheduler:012` | Invalid configuration |
|
|
198
|
+
| `HaiSchedulerError.TASK_NOT_FOUND` | `hai:scheduler:020` | Task not found |
|
|
199
|
+
| `HaiSchedulerError.TASK_ALREADY_EXISTS` | `hai:scheduler:021` | Task already exists |
|
|
200
|
+
| `HaiSchedulerError.INVALID_CRON` | `hai:scheduler:022` | Invalid cron expression |
|
|
201
|
+
| `HaiSchedulerError.EXECUTION_FAILED` | `hai:scheduler:023` | Execution failed |
|
|
202
|
+
| `HaiSchedulerError.JS_EXECUTION_FAILED` | `hai:scheduler:024` | JS execution failed |
|
|
203
|
+
| `HaiSchedulerError.API_EXECUTION_FAILED` | `hai:scheduler:025` | API execution failed |
|
|
204
|
+
| `HaiSchedulerError.DB_SAVE_FAILED` | `hai:scheduler:026` | Database save failed |
|
|
205
|
+
| `HaiSchedulerError.ALREADY_RUNNING` | `hai:scheduler:027` | Already running |
|
|
206
|
+
| `HaiSchedulerError.NOT_RUNNING` | `hai:scheduler:028` | Not running |
|
|
207
|
+
| `HaiSchedulerError.JS_COMPILE_FAILED` | `hai:scheduler:030` | JS compilation failed |
|
|
208
|
+
| `HaiSchedulerError.HOOK_EXECUTION_FAILED` | `hai:scheduler:031` | Hook execution failed |
|
|
209
|
+
| `HaiSchedulerError.LOG_PERSIST_FAILED` | `hai:scheduler:032` | Log persistence not confirmed |
|
|
210
|
+
|
|
211
|
+
### Runtime behavior and limits
|
|
212
|
+
|
|
213
|
+
All nodes must initialize the same shared RelDB first (PostgreSQL/MySQL for production across hosts; SQLite only for local processes that correctly share the same file). Task definitions, execution journals, active task slots and leases are persisted; `enableDb` accepts only true. Cache is no longer required. `leaseMs` defaults to 300000 and accepts 1000–86400000 milliseconds, with renewal during execution. Cron, manual and later periods share one active task slot; an expired owner cannot revive or commit. Nodes refresh definitions every `tickInterval`; `tasks` is the last successful snapshot. Failed reads, deletion, disabling or rescheduling interrupt stale attempts, without rolling back external effects.
|
|
214
|
+
|
|
215
|
+
`context.occurrenceId` persists across retries and nodes for a cron minute. Manual triggers accept a 1–128 character `idempotencyKey` to reuse the same record, otherwise each is independent. `executionToken` changes per claim; `sequence` and `attempt` identify the execution generation. API jobs override `Idempotency-Key`, `X-Scheduler-Sequence`, `X-Scheduler-Attempt` and `X-Scheduler-Task` (percent-encoded task ID). Claimed lifecycle callbacks expose the same ID in `event.context`; any callback side effects also need deduplication.
|
|
216
|
+
|
|
217
|
+
`idempotent` defaults to false. Set true only when the receiver atomically persists its deduplication ledger with every side effect. Expired running executions can be recovered with the same occurrenceId only under this declaration; non-idempotent jobs or executions that exhaust the stored retry.maxAttempts budget (default 1, range 1–100 including initial attempts and recovery) become `unknown` and retain the active task slot. A lease cannot revoke an external request already sent by a paused process; headers alone do not implement deduplication. Retries also follow this declaration.
|
|
218
|
+
|
|
219
|
+
Use `scheduler.execution.get(occurrenceId)` for authoritative state and `listUnknown({ limit, offset })` with limit 1–100 and offset 0–1000000. Trusted operators must confirm external effects and terminate the old runner before releasing its slot through `resolveUnknown(id, { status: 'success' | 'discarded', expectedUpdatedAt })`, which uses CAS. Never requeue unknown executions directly. `log.occurrenceId` is the receipt; `getLogs` contains observational attempt logs. Log retention does not delete the journal or idempotency history.
|
|
220
|
+
|
|
221
|
+
Each attempt has a stable UUID log `id`. `persistence.status` is independent of business `status`: `persisted` confirms both log storage and its receipt, `pending` means a committed business outcome still needs log projection, and `unconfirmed` means no committed outcome can be assumed. Only an owner with a valid lease can publish the terminal outcome and all attempt logs before projection. A stale owner cannot project success. Log failure never turns business success into failure or reruns the task.
|
|
222
|
+
|
|
223
|
+
Repair shrinks the pending list. Drain batches repeatedly at offset=0 instead of advancing the offset while repairing.
|
|
224
|
+
|
|
225
|
+
Trusted server operators monitor `execution.listLogFailures({ limit, offset })` with the same bounds as listUnknown and call `execution.repairLog(occurrenceId)` to replay logs from the shared journal across nodes, restarts, or concurrent calls. Stable primary keys and complete payload comparison handle committed writes with lost acknowledgements. `LOG_PERSIST_FAILED` (032) retains pending state and emits a safe warning. If the entire database is unavailable before the terminal commit, reconcile unknown effects; do not assume a recoverable outcome exists. Repair and retention never call business hooks. Retention failure only warns. `onTaskFinish` reports local business completion with unconfirmed persistence; use the trigger result and execution.get.logPersistence for confirmation.
|
|
226
|
+
|
|
227
|
+
Pre-claim interrupted events are observational logs without a committed outcome to repair; unacknowledged writes remain unconfirmed. JSON payloads preserve full results and all retries beyond MySQL's 255-character text mapping. Old log/execution tables are not migrated automatically. Closing waits for in-flight repairs; close scheduler before the database.
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
const result = await scheduler.trigger('cleanup', { idempotencyKey: 'cleanup-2026-10-23' })
|
|
231
|
+
if (!result.success)
|
|
232
|
+
return result
|
|
233
|
+
if (result.data.persistence.status === 'pending' && result.data.occurrenceId) {
|
|
234
|
+
const repaired = await scheduler.execution.repairLog(result.data.occurrenceId)
|
|
235
|
+
if (!repaired.success)
|
|
236
|
+
return repaired
|
|
237
|
+
}
|
|
238
|
+
// 不用日志补写失败重新 trigger 业务;定期按页扫描 listLogFailures。
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`await scheduler.close()` stops 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 available until scheduler closure completes, then close dependencies.
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
245
|
+
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 become unknown by default; bounded retry applies only when idempotency is declared.
|
|
246
|
+
|
|
247
|
+
`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`.
|
|
248
|
+
|
|
249
|
+
Cron is matched by minute; the same minute determines a persistent occurrenceId. Delayed ticks still check the current minute once per running scheduler.
|
|
250
|
+
|
|
251
|
+
Claim, begin, renewal, completion, expiration scans and isolation calculate/check lease deadlines inside the database statement using its clock: PostgreSQL clock_timestamp, MySQL SYSDATE(3), or SQLite now. Reading a time first cannot replace the final atomic deadline condition. Cron uses DB time for both the minute occurrence and expression matching. An omitted timezone means UTC; an explicit IANA timezone changes only wall-time interpretation, while node TZ does not change matching or identity. Node Date.now is diagnostic only; opposing node clocks cannot take over early or revive expired ownership. A write waiting on a row lock must still pass its deadline before publishing success. Keep the database service clock trustworthy; unknown external effects still require reconciliation rather than automatic replay.
|
|
252
|
+
|
|
253
|
+
### Optional integrations
|
|
254
|
+
|
|
255
|
+
When a scheduled task sends Reach notifications, a scheduler lock or task completion does not guarantee one external delivery. Follow [Reach queue semantics](../reach/README.en.md) with persistent queueId keys. HTTP receivers must atomically persist deduplication with their side effects. Review unknown in-flight SMTP/SMS outcomes manually; scheduler retries must not blindly send them again.
|
|
256
|
+
|
|
257
|
+
## Development and testing
|
|
218
258
|
|
|
219
259
|
```bash
|
|
220
260
|
pnpm --filter @h-ai/scheduler test
|
|
221
261
|
```
|
|
222
262
|
|
|
263
|
+
Declaration JSDoc examples import real public entries. Internal repositories, the runner, compiler and cron helpers are marked as internal module context and are not root exports. Examples distinguish HaiResult, explicit cancellation rejection, and close returning Promise<void>; business status and log persistence remain separate, and log repair never reruns business work. Maintainers can run `node scripts/check-module-jsdoc.mjs --package scheduler` to check declaration coverage and real types. Compiling examples does not replace shared-database and independent-process validation.
|
|
264
|
+
|
|
223
265
|
## License
|
|
224
266
|
|
|
225
267
|
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
CHANGED
|
@@ -2,40 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
[简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
|
|
4
4
|
|
|
5
|
-
[フレームワークのホーム](../../README.ja.md) · 関連モジュール:[@h-ai/
|
|
5
|
+
[フレームワークのホーム](../../README.ja.md) · 関連モジュール:[@h-ai/core](../core/README.ja.md) · [@h-ai/reldb](../reldb/README.ja.md)
|
|
6
6
|
|
|
7
|
-
|
|
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 サービスが必要です。
|
|
7
|
+
## 概要
|
|
16
8
|
|
|
17
|
-
|
|
9
|
+
共通のタスクモデル、cron スケジュール、JS 関数文字列・HTTP API の実行、永続化、実行ログ検索、全体のライフサイクル Hook を提供するスケジューラーモジュールです。
|
|
18
10
|
|
|
19
|
-
|
|
11
|
+
## 主な機能
|
|
20
12
|
|
|
21
|
-
|
|
13
|
+
- cron による定期実行とアプリケーションからの手動実行。
|
|
14
|
+
- HTTP、信頼できる JavaScript、アプリ Hook に対応し、再試行とキャンセルを提供。
|
|
15
|
+
- 共有 DB による定義・実行状態・更新可能リースの永続化と結果不明の隔離。
|
|
22
16
|
|
|
23
|
-
|
|
17
|
+
## 使い始める
|
|
24
18
|
|
|
25
|
-
|
|
19
|
+
### 依存関係
|
|
26
20
|
|
|
27
|
-
- `@h-ai/reldb
|
|
28
|
-
- `@h-ai/cache`:任意の分散ロック。初期化済みの場合、定時実行時のロック取得に自動参加。
|
|
21
|
+
- `@h-ai/reldb`:先に共有 DB を初期化し、タスク・journal・リースを保存します。`enableDb` は true のみです。
|
|
29
22
|
|
|
30
|
-
|
|
23
|
+
### クイックスタート
|
|
31
24
|
|
|
32
25
|
```ts
|
|
33
|
-
import { cache } from '@h-ai/cache'
|
|
34
26
|
import { reldb } from '@h-ai/reldb'
|
|
35
27
|
import { scheduler } from '@h-ai/scheduler'
|
|
36
28
|
|
|
37
29
|
await reldb.init({ type: 'sqlite', database: './scheduler.db' })
|
|
38
|
-
await cache.init({ type: 'memory' }) // 可选
|
|
39
30
|
|
|
40
31
|
await scheduler.init({
|
|
41
32
|
enableDb: true,
|
|
@@ -54,7 +45,6 @@ await scheduler.register({
|
|
|
54
45
|
description: '每 5 分钟执行一次系统健康检查',
|
|
55
46
|
cron: '*/5 * * * *',
|
|
56
47
|
params: { channel: 'ops' },
|
|
57
|
-
retry: { maxAttempts: 3, backoffMs: [1000, 5000] },
|
|
58
48
|
handler: {
|
|
59
49
|
kind: 'api',
|
|
60
50
|
url: 'https://api.example.com/health',
|
|
@@ -89,7 +79,7 @@ scheduler.stop()
|
|
|
89
79
|
await scheduler.close()
|
|
90
80
|
```
|
|
91
81
|
|
|
92
|
-
|
|
82
|
+
### 共通タスクモデル
|
|
93
83
|
|
|
94
84
|
```ts
|
|
95
85
|
interface TaskDefinition {
|
|
@@ -97,9 +87,10 @@ interface TaskDefinition {
|
|
|
97
87
|
name: string
|
|
98
88
|
description?: string
|
|
99
89
|
cron: string
|
|
100
|
-
timezone?: string // IANA 时区,例如 Asia/Shanghai
|
|
90
|
+
timezone?: string // IANA 时区,例如 Asia/Shanghai;省略时为 UTC,不读取节点 TZ
|
|
101
91
|
enabled?: boolean
|
|
102
92
|
deleteAfterRun?: boolean
|
|
93
|
+
idempotent?: boolean
|
|
103
94
|
retry?: {
|
|
104
95
|
maxAttempts: number
|
|
105
96
|
backoffMs?: number[]
|
|
@@ -114,6 +105,7 @@ interface ApiTaskConfig {
|
|
|
114
105
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
115
106
|
headers?: Record<string, string>
|
|
116
107
|
body?: unknown
|
|
108
|
+
maxResponseBytes?: number
|
|
117
109
|
timeout?: number
|
|
118
110
|
}
|
|
119
111
|
|
|
@@ -132,13 +124,13 @@ interface JsTaskConfig {
|
|
|
132
124
|
|
|
133
125
|
> ⚠️ **セキュリティ注意**:`kind: 'js'` は信頼できるサーバー側コード専用です。Node.js の `vm` による実行は**安全なサンドボックスではありません**。ユーザー、テナント、管理画面から任意の JS 文字列を直接受け付けないでください。設定可能なタスクには `kind: 'api'` または `hooks.onTaskExecute` を優先してください。
|
|
134
126
|
|
|
135
|
-
|
|
127
|
+
### タスクのライフサイクル Hook
|
|
136
128
|
|
|
137
129
|
初期化時に渡すか、実行時に `setHooks()` で設定します:
|
|
138
130
|
|
|
139
131
|
```ts
|
|
140
132
|
await scheduler.init({
|
|
141
|
-
enableDb:
|
|
133
|
+
enableDb: true,
|
|
142
134
|
hooks: {
|
|
143
135
|
onTaskStart(event) {
|
|
144
136
|
void event
|
|
@@ -156,7 +148,7 @@ await scheduler.init({
|
|
|
156
148
|
})
|
|
157
149
|
```
|
|
158
150
|
|
|
159
|
-
|
|
151
|
+
### 実行ログ
|
|
160
152
|
|
|
161
153
|
実行元でログを絞り込めます:
|
|
162
154
|
|
|
@@ -176,7 +168,7 @@ const logs = await scheduler.getLogs({
|
|
|
176
168
|
- `maxLogs`:最大 N 件を保持。
|
|
177
169
|
- `retentionDays`:直近 N 日分のみを保持。
|
|
178
170
|
|
|
179
|
-
|
|
171
|
+
### API 一覧
|
|
180
172
|
|
|
181
173
|
- `init(config?)`
|
|
182
174
|
- `register(task)`
|
|
@@ -184,13 +176,15 @@ const logs = await scheduler.getLogs({
|
|
|
184
176
|
- `updateTask(taskId, updates)`
|
|
185
177
|
- `refreshTasks()`
|
|
186
178
|
- `start()` / `stop()`
|
|
187
|
-
- `trigger(taskId, { source? })`
|
|
179
|
+
- `trigger(taskId, { source?, idempotencyKey? })`
|
|
188
180
|
- `getLogs(options?)`
|
|
181
|
+
- `execution.listLogFailures(options?)` / `execution.repairLog(occurrenceId)`
|
|
182
|
+
- `execution.get(occurrenceId)` / `execution.listUnknown(options?)` / `execution.resolveUnknown(occurrenceId, input)`
|
|
189
183
|
- `setHooks(hooks)` / `clearHooks()`
|
|
190
184
|
- `tasks` / `hooks` / `config` / `isInitialized` / `isRunning`
|
|
191
185
|
- `close()`
|
|
192
186
|
|
|
193
|
-
|
|
187
|
+
### エラー処理
|
|
194
188
|
|
|
195
189
|
`close()` を除くすべての公開 API は `HaiResult<T>` を返します。
|
|
196
190
|
|
|
@@ -210,18 +204,64 @@ const logs = await scheduler.getLogs({
|
|
|
210
204
|
| `HaiSchedulerError.DB_SAVE_FAILED` | `hai:scheduler:026` | DB 保存に失敗 |
|
|
211
205
|
| `HaiSchedulerError.ALREADY_RUNNING` | `hai:scheduler:027` | 実行中 |
|
|
212
206
|
| `HaiSchedulerError.NOT_RUNNING` | `hai:scheduler:028` | 実行していない |
|
|
213
|
-
| `HaiSchedulerError.LOCK_ACQUIRE_FAILED` | `hai:scheduler:029` | ロック取得に失敗 |
|
|
214
207
|
| `HaiSchedulerError.JS_COMPILE_FAILED` | `hai:scheduler:030` | JS コンパイルに失敗 |
|
|
215
208
|
| `HaiSchedulerError.HOOK_EXECUTION_FAILED` | `hai:scheduler:031` | Hook 実行に失敗 |
|
|
209
|
+
| `HaiSchedulerError.LOG_PERSIST_FAILED` | `hai:scheduler:032` | ログ保存未確認 |
|
|
210
|
+
|
|
211
|
+
### 実行時の動作と制約
|
|
212
|
+
|
|
213
|
+
各ノードは同じ共有 RelDB を先に初期化します。ホスト間の本番運用は PostgreSQL/MySQL、SQLite は同じファイルを正しく共有するローカルプロセス向けです。定義、実行 journal、タスク占有、リースを永続化し、`enableDb` は true のみです。キャッシュは不要です。`leaseMs` は既定 300000、範囲 1000~86400000 ミリ秒で実行中に更新します。cron、手動、次周期は同じタスク枠を使い、期限切れ owner は復活・コミットできません。`tasks` は最後に成功した定義スナップショットで、読取失敗や削除・無効化・変更は旧試行を中断します。外部副作用は自動で戻りません。
|
|
214
|
+
|
|
215
|
+
`context.occurrenceId` は永続化された発生 ID で、同じ cron 分と再試行で共通です。手動では 1~128 文字の `idempotencyKey` で元の記録を再利用し、省略時は別発生になります。`executionToken` は取得ごとに変わり、`sequence` / `attempt` が世代を示します。API は `Idempotency-Key`、`X-Scheduler-Sequence`、`X-Scheduler-Attempt`、`X-Scheduler-Task`(ID を percent-encoding)を上書きします。取得済みのライフサイクルイベントも `event.context` を提供し、副作用には同じキーによる排除が必要です。
|
|
216
|
+
|
|
217
|
+
`idempotent` の既定は false。受信側が排除台帳と全副作用をアトミックに永続化する場合だけ true にします。期限切れ実行は true のとき同 occurrenceId で引継ぎ、非冪等実行、または保存済み retry.maxAttempts(既定 1、範囲 1–100、初回と復旧を含む)の予算を使い切った場合は `unknown` となりタスク枠を保持します。停止中の旧プロセスが送った外部操作をリースで撤回できず、ヘッダーだけでは排除できません。再試行もこの宣言に従います。
|
|
218
|
+
|
|
219
|
+
正規状態は `scheduler.execution.get(occurrenceId)`、不明一覧は `listUnknown({ limit, offset })`(limit 1~100、offset 0~1000000)で確認します。信頼された担当者が外部効果を照合し旧 runner の終了を確認してから、`resolveUnknown(id, { status: 'success' | 'discarded', expectedUpdatedAt })` の CAS で枠を解放します。不明実行を直接再投入しないでください。`log.occurrenceId` が照会用受領記録で、`getLogs` は試行の観測ログです。ログ保持は journal と排除履歴を削除しません。
|
|
220
|
+
|
|
221
|
+
各試行のログ `id` は固定 UUID です。`persistence.status` は業務 `status` と独立し、`persisted` はログと確認記録の保存済み、`pending` は業務終態の確定後にログ補完待ち、`unconfirmed` は確定済み業務結果を前提にできない状態です。有効な owner とリースの条件で終態と全試行を保存してからログへ投影し、失効ノードは成功を投影できません。ログ失敗を理由に業務成功を失敗へ変更したり、業務を再実行したりしません。
|
|
222
|
+
|
|
223
|
+
補完後は pending 一覧が縮小するため、処理中に offset を進めず、offset=0 のバッチを繰り返し取得してください。
|
|
224
|
+
|
|
225
|
+
信頼されたサーバーは `execution.listLogFailures({ limit, offset })`(listUnknown と同じ上限)で pending を監視し、`execution.repairLog(occurrenceId)` で共有 journal から補完します。別ノード・再起動後・同時呼び出しも可能で、固定主キーと全内容の照合によりコミット後の応答消失を排除します。`LOG_PERSIST_FAILED`(032)は pending を維持して安全な警告を出します。終態の保存前に DB 全体が停止した場合は unknown の外部効果を照合し、復元可能な結果があると仮定しないでください。補完と保持処理は業務 Hook を呼ばず、保持失敗は警告のみです。`onTaskFinish` は未確定のローカル業務終了通知で、保存確認は trigger の戻り値と execution.get.logPersistence を使います。
|
|
216
226
|
|
|
217
|
-
|
|
227
|
+
取得前の競合・スキップの interrupted は観測ログで、補完できる確定終態はありません。応答が確認できなければ unconfirmed です。JSON 列で MySQL の 255 文字制限を避け、完全な結果と全再試行を保持します。旧ログ・実行テーブルの自動移行はしません。終了は補完処理を待つため、DB より先に scheduler を終了してください。
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
const result = await scheduler.trigger('cleanup', { idempotencyKey: 'cleanup-2026-10-23' })
|
|
231
|
+
if (!result.success)
|
|
232
|
+
return result
|
|
233
|
+
if (result.data.persistence.status === 'pending' && result.data.occurrenceId) {
|
|
234
|
+
const repaired = await scheduler.execution.repairLog(result.data.occurrenceId)
|
|
235
|
+
if (!repaired.success)
|
|
236
|
+
return repaired
|
|
237
|
+
}
|
|
238
|
+
// 不用日志补写失败重新 trigger 业务;定期按页扫描 listLogFailures。
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`await scheduler.close()` は受付を止め、HTTP、JS Worker、再試行待機をキャンセルし、実行チェーンの終了後に状態を解放します。再初期化は終了完了を待ちます。すべてのイベントが `signal` を提供し、Hook はキャンセルを監視して外部への副作用を停止する必要があります。同じスレッドの業務コードは強制終了できないため、Hook でイベントループをブロックしないでください。キャンセルされた旧実行は再試行・自動削除・新インスタンスの Hook 呼び出しを行いません。scheduler の終了完了まで DB を維持し、その後に依存モジュールを終了します。
|
|
242
|
+
|
|
243
|
+
JS 文字列は呼び出しごとの独立 Worker で実行します。`timeout` は起動を含めて既定 30000 ms です。時間超過時はスレッドを終了し、終了を待ってから失敗記録・再試行を行います。同期の無限ループでもメインイベントループをブロックしません。コンテキストと戻り値は構造化クローンに対応する必要があります。Worker 内の `vm` も安全なサンドボックスではなく、信頼できるサーバー側コードのみを使用します。
|
|
244
|
+
|
|
245
|
+
API タスクの `timeout`(既定 30000 ms)はリクエストと応答本文全体の読み取りを含みます。`ApiTaskConfig.maxResponseBytes` は受信バイト上限で、正の安全な整数、既定は 1048576(1 MiB)です。`maxResponseBytes: 10 * 1024 * 1024` なら最大 10 MiB を許可し、不正値は送信前に失敗します。タイムアウト・上限超過・非成功 HTTP 応答は既定では unknown となり、冪等性を宣言した場合だけ有限回再試行します。
|
|
246
|
+
|
|
247
|
+
`init()` は全タスクの読み込み完了後にのみ成功します。空 ID、重複 ID(保存済みタスクとの重複を含む)、不正な cron、DB 読み取り失敗、保存タスクの解析失敗では失敗を返し、初期化状態を消去します。設定・データを修正してから再初期化し、既存の DB タスクを `tasks` に重複宣言しないでください。
|
|
248
|
+
|
|
249
|
+
cron は分単位で照合し、同じ分から永続 occurrenceId を生成します。遅延 tick でも現在の分を実行中に一度確認します。
|
|
250
|
+
|
|
251
|
+
取得・開始・更新・完了・期限切れ検索・隔離の期限は、同じ SQL 文内でデータベース時刻から計算・検証します。PostgreSQL は clock_timestamp、MySQL は SYSDATE(3)、SQLite はストレージ側 now を使用し、先に時刻を読むだけでは最終条件を代替できません。cron の分 occurrence と式の照合も DB 時刻を使用します。timezone の省略時は UTC、明示した IANA 時区は壁時計の解釈だけを変更し、ノードの TZ は識別子や照合を変えません。Node の Date.now は診断専用です。ノード間で数時間の差があっても早期引継ぎや失効 owner の復活を許可せず、行ロック待機後も期限を検証します。DB 自体の時刻は運用側で信頼性を保ち、外部効果が不明な場合は自動再実行せず照合してください。
|
|
252
|
+
|
|
253
|
+
### 任意の連携
|
|
254
|
+
|
|
255
|
+
Reach 通知を実行するタスクでは、調度ロックやタスク完了だけで外部配信の一意性は保証できません。[Reach キューの意味](../reach/README.ja.md) に従い、永続化された queueId を使ってください。HTTP 受信側は副作用と重複排除を原子的に保存し、SMTP/SMS の配信中の結果不明は手動照合します。スケジューラーの再試行で無条件に再送しないでください。
|
|
256
|
+
|
|
257
|
+
## 開発とテスト
|
|
218
258
|
|
|
219
259
|
```bash
|
|
220
260
|
pnpm --filter @h-ai/scheduler test
|
|
221
261
|
```
|
|
222
262
|
|
|
223
|
-
|
|
263
|
+
宣言の JSDoc 例は実在する公開入口から import します。内部リポジトリ、runner、コンパイラ、cron ツールは「模块内部上下文」と明記し、パッケージルートの公開 API を意味しません。HaiResult、明示的な取消しの reject、close の Promise<void> を区別し、業務 status とログ persistence も別に確認します。ログ修復で業務を再実行しません。保守時は `node scripts/check-module-jsdoc.mjs --package scheduler` で宣言網羅性と実際の型を検証します。例のコンパイルは共有 DB・独立プロセス検証を代替しません。
|
|
224
264
|
|
|
225
|
-
|
|
265
|
+
## ライセンス
|
|
226
266
|
|
|
227
|
-
|
|
267
|
+
Apache-2.0
|