@h-ai/scheduler 0.1.0-alpha.67 → 0.1.0-alpha.69

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 CHANGED
@@ -2,7 +2,7 @@
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/cache](../cache/README.en.md) · [@h-ai/core](../core/README.en.md) · [@h-ai/reldb](../reldb/README.en.md)
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
7
  ## Introduction
8
8
 
@@ -12,24 +12,21 @@ A scheduled task module with a unified task model, cron scheduling, JS function
12
12
 
13
13
  - Run tasks on a cron schedule or trigger them from application code.
14
14
  - Execute HTTP requests, trusted JavaScript and application hooks, with retry and cancellation.
15
- - Optionally persist tasks and logs; use shared cache for scheduling locks across nodes.
15
+ - Persist tasks, execution journals and renewable leases in a shared database, with unknown-outcome isolation.
16
16
 
17
17
  ## Getting started
18
18
 
19
19
  ### Dependencies
20
20
 
21
- - `@h-ai/reldb`: task and execution log persistence; initialize first when `enableDb: true`.
22
- - `@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.
23
22
 
24
23
  ### Quick start
25
24
 
26
25
  ```ts
27
- import { cache } from '@h-ai/cache'
28
26
  import { reldb } from '@h-ai/reldb'
29
27
  import { scheduler } from '@h-ai/scheduler'
30
28
 
31
29
  await reldb.init({ type: 'sqlite', database: './scheduler.db' })
32
- await cache.init({ type: 'memory' }) // 可选
33
30
 
34
31
  await scheduler.init({
35
32
  enableDb: true,
@@ -48,7 +45,6 @@ await scheduler.register({
48
45
  description: '每 5 分钟执行一次系统健康检查',
49
46
  cron: '*/5 * * * *',
50
47
  params: { channel: 'ops' },
51
- retry: { maxAttempts: 3, backoffMs: [1000, 5000] },
52
48
  handler: {
53
49
  kind: 'api',
54
50
  url: 'https://api.example.com/health',
@@ -91,9 +87,10 @@ interface TaskDefinition {
91
87
  name: string
92
88
  description?: string
93
89
  cron: string
94
- timezone?: string // IANA 时区,例如 Asia/Shanghai;按该时区解释 cron 墙上时间
90
+ timezone?: string // IANA 时区,例如 Asia/Shanghai;省略时为 UTC,不读取节点 TZ
95
91
  enabled?: boolean
96
92
  deleteAfterRun?: boolean
93
+ idempotent?: boolean
97
94
  retry?: {
98
95
  maxAttempts: number
99
96
  backoffMs?: number[]
@@ -108,6 +105,7 @@ interface ApiTaskConfig {
108
105
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
109
106
  headers?: Record<string, string>
110
107
  body?: unknown
108
+ maxResponseBytes?: number
111
109
  timeout?: number
112
110
  }
113
111
 
@@ -132,7 +130,7 @@ Provide hooks at initialization or set them at runtime with `setHooks()`:
132
130
 
133
131
  ```ts
134
132
  await scheduler.init({
135
- enableDb: false,
133
+ enableDb: true,
136
134
  hooks: {
137
135
  onTaskStart(event) {
138
136
  void event
@@ -178,8 +176,10 @@ Execution logs support automatic cleanup policies configured at initialization:
178
176
  - `updateTask(taskId, updates)`
179
177
  - `refreshTasks()`
180
178
  - `start()` / `stop()`
181
- - `trigger(taskId, { source? })`
179
+ - `trigger(taskId, { source?, idempotencyKey? })`
182
180
  - `getLogs(options?)`
181
+ - `execution.listLogFailures(options?)` / `execution.repairLog(occurrenceId)`
182
+ - `execution.get(occurrenceId)` / `execution.listUnknown(options?)` / `execution.resolveUnknown(occurrenceId, input)`
183
183
  - `setHooks(hooks)` / `clearHooks()`
184
184
  - `tasks` / `hooks` / `config` / `isInitialized` / `isRunning`
185
185
  - `close()`
@@ -190,45 +190,69 @@ All public APIs except `close()` return `HaiResult<T>`.
190
190
 
191
191
  Common error codes:
192
192
 
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.LOCK_ACQUIRE_FAILED` | `hai:scheduler:029` | Lock acquisition failed |
208
- | `HaiSchedulerError.JS_COMPILE_FAILED` | `hai:scheduler:030` | JS compilation failed |
209
- | `HaiSchedulerError.HOOK_EXECUTION_FAILED` | `hai:scheduler:031` | Hook execution failed |
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
210
 
211
211
  ### Runtime behavior and limits
212
212
 
213
- 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.
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.
214
224
 
215
- 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.
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.
216
226
 
217
- 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.
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.
218
228
 
219
- 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`.
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
+ ```
220
240
 
221
- `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.
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.
222
242
 
223
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.
224
244
 
225
- 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.
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.
226
246
 
227
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`.
228
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
+
229
253
  ### Optional integrations
230
254
 
231
- 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.
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.
232
256
 
233
257
  ## Development and testing
234
258
 
@@ -236,6 +260,8 @@ Cron is matched by minute, and distributed lock keys use that same minute. Start
236
260
  pnpm --filter @h-ai/scheduler test
237
261
  ```
238
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
+
239
265
  ## License
240
266
 
241
267
  Apache-2.0
package/README.ja.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
4
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)
5
+ [フレームワークのホーム](../../README.ja.md) · 関連モジュール:[@h-ai/core](../core/README.ja.md) · [@h-ai/reldb](../reldb/README.ja.md)
6
6
 
7
7
  ## 概要
8
8
 
@@ -12,24 +12,21 @@
12
12
 
13
13
  - cron による定期実行とアプリケーションからの手動実行。
14
14
  - HTTP、信頼できる JavaScript、アプリ Hook に対応し、再試行とキャンセルを提供。
15
- - タスク・ログの永続化と、共有キャッシュによるノード間の実行ロック。
15
+ - 共有 DB による定義・実行状態・更新可能リースの永続化と結果不明の隔離。
16
16
 
17
17
  ## 使い始める
18
18
 
19
19
  ### 依存関係
20
20
 
21
- - `@h-ai/reldb`:タスク定義と実行ログの永続化。`enableDb: true` の場合は先に初期化。
22
- - `@h-ai/cache`:任意の分散ロック。初期化済みの場合、定時実行時のロック取得に自動参加。
21
+ - `@h-ai/reldb`:先に共有 DB を初期化し、タスク・journal・リースを保存します。`enableDb` は true のみです。
23
22
 
24
23
  ### クイックスタート
25
24
 
26
25
  ```ts
27
- import { cache } from '@h-ai/cache'
28
26
  import { reldb } from '@h-ai/reldb'
29
27
  import { scheduler } from '@h-ai/scheduler'
30
28
 
31
29
  await reldb.init({ type: 'sqlite', database: './scheduler.db' })
32
- await cache.init({ type: 'memory' }) // 可选
33
30
 
34
31
  await scheduler.init({
35
32
  enableDb: true,
@@ -48,7 +45,6 @@ await scheduler.register({
48
45
  description: '每 5 分钟执行一次系统健康检查',
49
46
  cron: '*/5 * * * *',
50
47
  params: { channel: 'ops' },
51
- retry: { maxAttempts: 3, backoffMs: [1000, 5000] },
52
48
  handler: {
53
49
  kind: 'api',
54
50
  url: 'https://api.example.com/health',
@@ -91,9 +87,10 @@ interface TaskDefinition {
91
87
  name: string
92
88
  description?: string
93
89
  cron: string
94
- timezone?: string // IANA 时区,例如 Asia/Shanghai;按该时区解释 cron 墙上时间
90
+ timezone?: string // IANA 时区,例如 Asia/Shanghai;省略时为 UTC,不读取节点 TZ
95
91
  enabled?: boolean
96
92
  deleteAfterRun?: boolean
93
+ idempotent?: boolean
97
94
  retry?: {
98
95
  maxAttempts: number
99
96
  backoffMs?: number[]
@@ -108,6 +105,7 @@ interface ApiTaskConfig {
108
105
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
109
106
  headers?: Record<string, string>
110
107
  body?: unknown
108
+ maxResponseBytes?: number
111
109
  timeout?: number
112
110
  }
113
111
 
@@ -132,7 +130,7 @@ interface JsTaskConfig {
132
130
 
133
131
  ```ts
134
132
  await scheduler.init({
135
- enableDb: false,
133
+ enableDb: true,
136
134
  hooks: {
137
135
  onTaskStart(event) {
138
136
  void event
@@ -178,8 +176,10 @@ const logs = await scheduler.getLogs({
178
176
  - `updateTask(taskId, updates)`
179
177
  - `refreshTasks()`
180
178
  - `start()` / `stop()`
181
- - `trigger(taskId, { source? })`
179
+ - `trigger(taskId, { source?, idempotencyKey? })`
182
180
  - `getLogs(options?)`
181
+ - `execution.listLogFailures(options?)` / `execution.repairLog(occurrenceId)`
182
+ - `execution.get(occurrenceId)` / `execution.listUnknown(options?)` / `execution.resolveUnknown(occurrenceId, input)`
183
183
  - `setHooks(hooks)` / `clearHooks()`
184
184
  - `tasks` / `hooks` / `config` / `isInitialized` / `isRunning`
185
185
  - `close()`
@@ -204,31 +204,55 @@ const logs = await scheduler.getLogs({
204
204
  | `HaiSchedulerError.DB_SAVE_FAILED` | `hai:scheduler:026` | DB 保存に失敗 |
205
205
  | `HaiSchedulerError.ALREADY_RUNNING` | `hai:scheduler:027` | 実行中 |
206
206
  | `HaiSchedulerError.NOT_RUNNING` | `hai:scheduler:028` | 実行していない |
207
- | `HaiSchedulerError.LOCK_ACQUIRE_FAILED` | `hai:scheduler:029` | ロック取得に失敗 |
208
207
  | `HaiSchedulerError.JS_COMPILE_FAILED` | `hai:scheduler:030` | JS コンパイルに失敗 |
209
208
  | `HaiSchedulerError.HOOK_EXECUTION_FAILED` | `hai:scheduler:031` | Hook 実行に失敗 |
209
+ | `HaiSchedulerError.LOG_PERSIST_FAILED` | `hai:scheduler:032` | ログ保存未確認 |
210
210
 
211
211
  ### 実行時の動作と制約
212
212
 
213
- DB を有効にすると、各ノードは `tickInterval`(既定 1000 ms)ごとに保存された定義を更新し、`tasks` は直近の成功したスナップショットになります。登録・更新・削除・手動実行、およびロック取得後の各試行で定義を再読します。削除・無効化・再スケジュールされた古いタスクは実行せず、読み取り失敗時は試行を中断します。開始済みの副作用は遠隔の変更で自動ロールバックしません。`init({ tasks })` はノード固有設定です。ノード間の動的タスクには永続化する `register()`、重複防止には共有 Redis を使います。メモリーキャッシュは単一プロセスのみです。
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 のバッチを繰り返し取得してください。
214
224
 
215
- `await scheduler.refreshTasks()` でタスクを実行せず定義を更新できます。同じスナップショットの並行更新は 1 回の読み取りを共有します。登録・更新・削除は永続化成功後にメモリーへ反映し、全体更新の検証失敗では cron を変更しません。再試行や組み込み handler の解除には `retry: null`、`handler: null` を渡します。
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
- Hook が投げる業務エラーは `failed` として記録し `retry` を適用します。handler の不足、キャンセル、ホストが投げる `SchedulerTaskSkippedError` は `interrupted` です。`event.context.occurrenceId` は 1 回の発生を識別し、同じスケジュール分ではノード間で共通、手動実行では毎回独立、再試行では元の ID を共有します。ホストはこの ID で冪等性の受領記録を保存できます。周期をまたぐ長時間タスクの排他制御にはホスト側の継続リースが必要です。`deleteAfterRun` は成功時のみ削除します。再試行前と成功後の削除前に現在の定義を確認し、DB の削除は 1 つの DELETE 内で実行定義をアトミックに照合して並行更新を保持します。
227
+ 取得前の競合・スキップの interrupted は観測ログで、補完できる確定終態はありません。応答が確認できなければ unconfirmed です。JSON 列で MySQL の 255 文字制限を避け、完全な結果と全再試行を保持します。旧ログ・実行テーブルの自動移行はしません。終了は補完処理を待つため、DB より先に scheduler を終了してください。
218
228
 
219
- 複数ノードの回帰テストは現在のソースをビルドし、SQLite と Testcontainers Redis を共有する実際の Node プロセスを 2 つ起動して、定義の収束と同じ分のロック競合を検証します。`pnpm --filter @h-ai/scheduler test` の前に、利用可能な Docker/Podman サービスが必要です。
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
+ ```
220
240
 
221
- `await scheduler.close()` は受付を止め、HTTP、JS Worker、再試行待機をキャンセルし、実行チェーンの終了後に状態を解放します。再初期化は終了完了を待ちます。すべてのイベントが `signal` を提供し、Hook はキャンセルを監視して外部への副作用を停止する必要があります。同じスレッドの業務コードは強制終了できないため、Hook でイベントループをブロックしないでください。キャンセルされた旧実行は再試行・自動削除・新インスタンスの Hook 呼び出しを行いません。scheduler の終了完了まで DB とキャッシュを維持し、その後に依存モジュールを終了します。
241
+ `await scheduler.close()` は受付を止め、HTTP、JS Worker、再試行待機をキャンセルし、実行チェーンの終了後に状態を解放します。再初期化は終了完了を待ちます。すべてのイベントが `signal` を提供し、Hook はキャンセルを監視して外部への副作用を停止する必要があります。同じスレッドの業務コードは強制終了できないため、Hook でイベントループをブロックしないでください。キャンセルされた旧実行は再試行・自動削除・新インスタンスの Hook 呼び出しを行いません。scheduler の終了完了まで DB を維持し、その後に依存モジュールを終了します。
222
242
 
223
243
  JS 文字列は呼び出しごとの独立 Worker で実行します。`timeout` は起動を含めて既定 30000 ms です。時間超過時はスレッドを終了し、終了を待ってから失敗記録・再試行を行います。同期の無限ループでもメインイベントループをブロックしません。コンテキストと戻り値は構造化クローンに対応する必要があります。Worker 内の `vm` も安全なサンドボックスではなく、信頼できるサーバー側コードのみを使用します。
224
244
 
225
- API タスクの `timeout`(既定 30000 ms)はリクエストと応答本文全体の読み取りを含みます。`ApiTaskConfig.maxResponseBytes` は受信バイト上限で、正の安全な整数、既定は 1048576(1 MiB)です。`maxResponseBytes: 10 * 1024 * 1024` なら最大 10 MiB を許可し、不正値は送信前に失敗します。タイムアウト・上限超過・非成功 HTTP 応答は失敗として記録し、タスクの再試行方針を適用します。
245
+ API タスクの `timeout`(既定 30000 ms)はリクエストと応答本文全体の読み取りを含みます。`ApiTaskConfig.maxResponseBytes` は受信バイト上限で、正の安全な整数、既定は 1048576(1 MiB)です。`maxResponseBytes: 10 * 1024 * 1024` なら最大 10 MiB を許可し、不正値は送信前に失敗します。タイムアウト・上限超過・非成功 HTTP 応答は既定では unknown となり、冪等性を宣言した場合だけ有限回再試行します。
226
246
 
227
247
  `init()` は全タスクの読み込み完了後にのみ成功します。空 ID、重複 ID(保存済みタスクとの重複を含む)、不正な cron、DB 読み取り失敗、保存タスクの解析失敗では失敗を返し、初期化状態を消去します。設定・データを修正してから再初期化し、既存の DB タスクを `tasks` に重複宣言しないでください。
228
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
+
229
253
  ### 任意の連携
230
254
 
231
- スケジュールループは分単位で cron を照合し、同じ分から分散ロックのキーを生成します。起動や tick が 0 秒を過ぎても現在の分を確認し、同じ実行中に毎 tick 重複してスケジュールしません。
255
+ Reach 通知を実行するタスクでは、調度ロックやタスク完了だけで外部配信の一意性は保証できません。[Reach キューの意味](../reach/README.ja.md) に従い、永続化された queueId を使ってください。HTTP 受信側は副作用と重複排除を原子的に保存し、SMTP/SMS の配信中の結果不明は手動照合します。スケジューラーの再試行で無条件に再送しないでください。
232
256
 
233
257
  ## 開発とテスト
234
258
 
@@ -236,6 +260,8 @@ API タスクの `timeout`(既定 30000 ms)はリクエストと応答本文
236
260
  pnpm --filter @h-ai/scheduler test
237
261
  ```
238
262
 
263
+ 宣言の JSDoc 例は実在する公開入口から import します。内部リポジトリ、runner、コンパイラ、cron ツールは「模块内部上下文」と明記し、パッケージルートの公開 API を意味しません。HaiResult、明示的な取消しの reject、close の Promise<void> を区別し、業務 status とログ persistence も別に確認します。ログ修復で業務を再実行しません。保守時は `node scripts/check-module-jsdoc.mjs --package scheduler` で宣言網羅性と実際の型を検証します。例のコンパイルは共有 DB・独立プロセス検証を代替しません。
264
+
239
265
  ## ライセンス
240
266
 
241
267
  Apache-2.0
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [简体中文](./README.md) | [English](./README.en.md) | [日本語](./README.ja.md)
4
4
 
5
- [框架首页](../../README.md) · 相关模块:[@h-ai/cache](../cache/README.md) · [@h-ai/core](../core/README.md) · [@h-ai/reldb](../reldb/README.md)
5
+ [框架首页](../../README.md) · 相关模块:[@h-ai/core](../core/README.md) · [@h-ai/reldb](../reldb/README.md)
6
6
 
7
7
  ## 简介
8
8
 
@@ -12,24 +12,21 @@
12
12
 
13
13
  - 按 cron 定时执行,也可从业务代码手动触发。
14
14
  - 支持 HTTP、受信任的 JavaScript 和应用 Hook,提供重试与取消。
15
- - 可持久化任务和执行日志;共享缓存用于多节点抢锁。
15
+ - 共享数据库持久化任务、执行状态与可续期租约,支持未知结果隔离。
16
16
 
17
17
  ## 使用入门
18
18
 
19
19
  ### 依赖
20
20
 
21
- - `@h-ai/reldb` — 任务定义与执行日志持久化,`enableDb: true` 时需先初始化
22
- - `@h-ai/cache` — 分布式锁,可选;初始化后自动参与定时触发抢锁
21
+ - `@h-ai/reldb` — 必须先初始化共享数据库,保存任务、执行 journal 与自动续租;`enableDb` 只能为 true。
23
22
 
24
23
  ### 快速开始
25
24
 
26
25
  ```ts
27
- import { cache } from '@h-ai/cache'
28
26
  import { reldb } from '@h-ai/reldb'
29
27
  import { scheduler } from '@h-ai/scheduler'
30
28
 
31
29
  await reldb.init({ type: 'sqlite', database: './scheduler.db' })
32
- await cache.init({ type: 'memory' }) // 可选
33
30
 
34
31
  await scheduler.init({
35
32
  enableDb: true,
@@ -48,7 +45,6 @@ await scheduler.register({
48
45
  description: '每 5 分钟执行一次系统健康检查',
49
46
  cron: '*/5 * * * *',
50
47
  params: { channel: 'ops' },
51
- retry: { maxAttempts: 3, backoffMs: [1000, 5000] },
52
48
  handler: {
53
49
  kind: 'api',
54
50
  url: 'https://api.example.com/health',
@@ -91,9 +87,10 @@ interface TaskDefinition {
91
87
  name: string
92
88
  description?: string
93
89
  cron: string
94
- timezone?: string // IANA 时区,例如 Asia/Shanghai;按该时区解释 cron 墙上时间
90
+ timezone?: string // IANA 时区,例如 Asia/Shanghai;省略时为 UTC,不读取节点 TZ
95
91
  enabled?: boolean
96
92
  deleteAfterRun?: boolean
93
+ idempotent?: boolean
97
94
  retry?: {
98
95
  maxAttempts: number
99
96
  backoffMs?: number[]
@@ -108,6 +105,7 @@ interface ApiTaskConfig {
108
105
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
109
106
  headers?: Record<string, string>
110
107
  body?: unknown
108
+ maxResponseBytes?: number
111
109
  timeout?: number
112
110
  }
113
111
 
@@ -132,7 +130,7 @@ interface JsTaskConfig {
132
130
 
133
131
  ```ts
134
132
  await scheduler.init({
135
- enableDb: false,
133
+ enableDb: true,
136
134
  hooks: {
137
135
  onTaskStart(event) {
138
136
  void event
@@ -178,8 +176,10 @@ const logs = await scheduler.getLogs({
178
176
  - `updateTask(taskId, updates)`
179
177
  - `refreshTasks()`
180
178
  - `start()` / `stop()`
181
- - `trigger(taskId, { source? })`
179
+ - `trigger(taskId, { source?, idempotencyKey? })`
182
180
  - `getLogs(options?)`
181
+ - `execution.listLogFailures(options?)` / `execution.repairLog(occurrenceId)`
182
+ - `execution.get(occurrenceId)` / `execution.listUnknown(options?)` / `execution.resolveUnknown(occurrenceId, input)`
183
183
  - `setHooks(hooks)` / `clearHooks()`
184
184
  - `tasks` / `hooks` / `config` / `isInitialized` / `isRunning`
185
185
  - `close()`
@@ -190,45 +190,69 @@ const logs = await scheduler.getLogs({
190
190
 
191
191
  常用错误码:
192
192
 
193
- | 错误码 | code | 说明 |
194
- | ----------------------------------------- | ------------------- | --------------- |
195
- | `HaiSchedulerError.NOT_INITIALIZED` | `hai:scheduler:010` | 未初始化 |
196
- | `HaiSchedulerError.INIT_FAILED` | `hai:scheduler:011` | 初始化失败 |
197
- | `HaiSchedulerError.CONFIG_ERROR` | `hai:scheduler:012` | 配置错误 |
198
- | `HaiSchedulerError.TASK_NOT_FOUND` | `hai:scheduler:020` | 任务不存在 |
199
- | `HaiSchedulerError.TASK_ALREADY_EXISTS` | `hai:scheduler:021` | 任务已存在 |
200
- | `HaiSchedulerError.INVALID_CRON` | `hai:scheduler:022` | Cron 表达式无效 |
201
- | `HaiSchedulerError.EXECUTION_FAILED` | `hai:scheduler:023` | 执行失败 |
202
- | `HaiSchedulerError.JS_EXECUTION_FAILED` | `hai:scheduler:024` | JS 执行失败 |
203
- | `HaiSchedulerError.API_EXECUTION_FAILED` | `hai:scheduler:025` | API 执行失败 |
204
- | `HaiSchedulerError.DB_SAVE_FAILED` | `hai:scheduler:026` | DB 保存失败 |
205
- | `HaiSchedulerError.ALREADY_RUNNING` | `hai:scheduler:027` | 已在运行 |
206
- | `HaiSchedulerError.NOT_RUNNING` | `hai:scheduler:028` | 未在运行 |
207
- | `HaiSchedulerError.LOCK_ACQUIRE_FAILED` | `hai:scheduler:029` | 锁获取失败 |
208
- | `HaiSchedulerError.JS_COMPILE_FAILED` | `hai:scheduler:030` | JS 编译失败 |
209
- | `HaiSchedulerError.HOOK_EXECUTION_FAILED` | `hai:scheduler:031` | Hook 执行失败 |
193
+ | 错误码 | code | 说明 |
194
+ | ----------------------------------------- | ------------------- | ---------------- |
195
+ | `HaiSchedulerError.NOT_INITIALIZED` | `hai:scheduler:010` | 未初始化 |
196
+ | `HaiSchedulerError.INIT_FAILED` | `hai:scheduler:011` | 初始化失败 |
197
+ | `HaiSchedulerError.CONFIG_ERROR` | `hai:scheduler:012` | 配置错误 |
198
+ | `HaiSchedulerError.TASK_NOT_FOUND` | `hai:scheduler:020` | 任务不存在 |
199
+ | `HaiSchedulerError.TASK_ALREADY_EXISTS` | `hai:scheduler:021` | 任务已存在 |
200
+ | `HaiSchedulerError.INVALID_CRON` | `hai:scheduler:022` | Cron 表达式无效 |
201
+ | `HaiSchedulerError.EXECUTION_FAILED` | `hai:scheduler:023` | 执行失败 |
202
+ | `HaiSchedulerError.JS_EXECUTION_FAILED` | `hai:scheduler:024` | JS 执行失败 |
203
+ | `HaiSchedulerError.API_EXECUTION_FAILED` | `hai:scheduler:025` | API 执行失败 |
204
+ | `HaiSchedulerError.DB_SAVE_FAILED` | `hai:scheduler:026` | DB 保存失败 |
205
+ | `HaiSchedulerError.ALREADY_RUNNING` | `hai:scheduler:027` | 已在运行 |
206
+ | `HaiSchedulerError.NOT_RUNNING` | `hai:scheduler:028` | 未在运行 |
207
+ | `HaiSchedulerError.JS_COMPILE_FAILED` | `hai:scheduler:030` | JS 编译失败 |
208
+ | `HaiSchedulerError.HOOK_EXECUTION_FAILED` | `hai:scheduler:031` | Hook 执行失败 |
209
+ | `HaiSchedulerError.LOG_PERSIST_FAILED` | `hai:scheduler:032` | 日志持久化未确认 |
210
210
 
211
211
  ### 运行行为与边界
212
212
 
213
- 启用数据库时,运行中的节点每个 `tickInterval`(默认 1000 毫秒)刷新持久化定义;`tasks` 是最近一次成功刷新的快照。注册、修改、删除、手工触发和获得调度锁后的每次尝试均重新读取定义,删除/禁用/已改调度时间的旧任务不会继续执行,读取失败则中断本次尝试。已经开始的业务副作用不因远端定义变化自动回滚。`init({ tasks })` 仍是节点本地配置,跨节点动态任务请用持久化的 `register()` 管理;跨节点防重需要共享 Redis,内存缓存仅限单进程。
213
+ 所有节点必须先初始化同一共享 RelDB(跨主机生产使用 PostgreSQL/MySQL;SQLite 文件只适合可正确共享同一文件的本机进程)。数据库保存任务、执行 journal、全局任务占位和租约,`enableDb` 只能为 true;不再依赖缓存。`leaseMs` 默认 300000,范围 1000~86400000 毫秒,执行中自动续期。cron、手动和跨周期执行均争用同一任务槽,过期 owner 不能续活或提交。节点每个 `tickInterval` 刷新定义,`tasks` 只是最近成功快照;定义读取失败、删除、禁用、改期均中断旧尝试,已发生副作用不会回滚。
214
+
215
+ `context.occurrenceId` 是持久发生标识;同一 cron 分钟跨节点一致,手动可传 1~128 字符 `idempotencyKey` 重用原记录,省略时每次独立。`executionToken` 每次领取变化,`sequence` / `attempt` 提供执行代信息。API 会覆盖 `Idempotency-Key`、`X-Scheduler-Sequence`、`X-Scheduler-Attempt`、`X-Scheduler-Task`(任务 ID 使用 percent-encoding)。Hook 及生命周期回调已领取时的 `event.context` 使用同一发生标识;观测回调若产生副作用也须去重。
216
+
217
+ `idempotent` 默认为 false;只有接收端已将幂等账本和全部副作用原子持久提交,才声明 true。租约过期的已执行任务仅在 true 时以同 occurrenceId 接管;非幂等或耗尽本 occurrence 保存的 retry.maxAttempts(默认 1,范围 1–100,包含首次执行及恢复)时进入 `unknown` 并继续占住任务槽。租约无法强制撤销暂停进程已发出的外部操作,单有 HTTP 头不构成去重实现。重试同样受幂等声明约束。
218
+
219
+ `scheduler.execution.get(occurrenceId)` 查询权威状态;`listUnknown({ limit, offset })` 分页获取未知结果,limit 为 1~100,offset 为 0~1000000。可信人工先核实外部效果、确认旧 runner 已终止,再按 `expectedUpdatedAt` CAS 调用 `resolveUnknown(id, { status: 'success' | 'discarded', expectedUpdatedAt })` 释放占位;不能直接把 unknown 重排。`log.occurrenceId` 可作查询回执;`getLogs` 仅为尝试观测日志,日志保留策略不删除 journal/幂等历史。
220
+
221
+ 日志的 `id` 是每次尝试固定的 UUID,`persistence.status` 与业务 `status` 独立:`persisted` 表示日志及确认回执已持久化;`pending` 表示业务终态已提交但日志待补写;`unconfirmed` 表示没有已确认的业务终态,不能据此补写成功。只有 owner 与有效租约条件提交的终态会发布完整尝试列表,随后才投影日志;失租节点不能投影成功。日志失败不会把业务成功改成失败,也不会因此重跑业务。
222
+
223
+ 补写会缩小 pending 列表;批量补写应反复读取 offset=0,避免一边补写一边增加 offset 跳过记录。
214
224
 
215
- 可调用 `await scheduler.refreshTasks()` 主动刷新而不执行任务;同一快照的并发刷新共用一次读取。注册、更新与删除成功持久化后才更新内存投影,整份更新验证失败不改变 cron。清除重试或内置处理器时分别传 `retry: null`、`handler: null`。
225
+ 可信服务端用 `execution.listLogFailures({ limit, offset })` 监控 pending(范围与 listUnknown 相同),用 `execution.repairLog(occurrenceId)` 从共享 journal 幂等补写;可跨节点、重启及并发调用。写入已提交但回执丢失时通过固定主键和完整内容核对去重。补写返回 `LOG_PERSIST_FAILED`(032)时继续保留 pending,并输出安全告警;全部数据库不可用且终态未提交时仍按 unknown 核对外部效果,不能假定存在可恢复结果。补写与日志保留均不调用业务 Hook。保留策略失败仅告警,不撤销已保存日志。`onTaskFinish` 是本地业务完成通知,其 unconfirmed 状态不是持久化确认;以 trigger 返回值及 execution.get 的 logPersistence 为准。
216
226
 
217
- Hook 抛出的业务失败记录为 `failed` 并应用 `retry`;缺少处理器、取消或宿主抛出的 `SchedulerTaskSkippedError` 记录为 `interrupted`。`event.context.occurrenceId` 标识一次发生:同一调度分钟跨节点一致,手工触发每次独立,重试共享原标识。宿主可据此持久化业务幂等回执;跨周期的长任务互斥仍需宿主持续租约。`deleteAfterRun` 仅在成功后删除,失败任务保留;重试前与成功清理前核对当前定义,数据库清理在同一条 DELETE 内原子匹配执行定义,保留期间提交的并发更新。
227
+ 领取前竞争/跳过的 interrupted 仅为观测日志,没有可补写的终态;其写入未确认时返回 unconfirmed。JSON 大载荷保存完整结果和全部重试记录,避免 MySQL 的 255 字符截断;新 schema 不自动迁移旧日志/执行表。关闭会等待在途补写,先关闭 scheduler 再关闭数据库。
218
228
 
219
- 多节点回归测试会构建当前源码、启动两个真实 Node 进程共享 SQLite 和 Testcontainers Redis,验证定义收敛及同分钟锁竞争;运行 `pnpm --filter @h-ai/scheduler test` 前须有可用的 Docker/Podman 服务。
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
+ ```
220
240
 
221
- `await scheduler.close()` 先停止接收任务,取消 HTTP、JS Worker 与重试等待,再等待执行链退出后清理状态。重新初始化等待关闭完成。所有生命周期事件提供 `signal`;Hook 必须监听取消并停止自己的外部副作用(框架无法强制终止同一线程中的业务代码),不要在 Hook 中阻塞事件循环。取消后的旧执行不再重试、不自动删除任务,也不会调用新实例的 Hook。关闭期间保持数据库和缓存可用,待 scheduler 关闭后再关闭依赖。
241
+ `await scheduler.close()` 停止接收并取消 HTTP、JS Worker 与重试等待,再等待执行链退出后清理状态。重新初始化等待关闭完成。所有生命周期事件提供 `signal`;Hook 必须监听取消并停止自己的外部副作用(框架无法强制终止同一线程中的业务代码),不要在 Hook 中阻塞事件循环。取消后的旧执行不再重试、不自动删除任务,也不会调用新实例的 Hook。关闭期间保持数据库可用,待 scheduler 关闭后再关闭依赖。
222
242
 
223
243
  JS 字符串在每次调用的独立 Worker 中执行,`timeout` 默认 30000 毫秒(含启动)。到期后终止并等待线程退出,再记录失败或重试;同步死循环也不会阻塞主事件循环。上下文及返回值必须支持结构化克隆。Worker 中的 `vm` 仍不是安全沙箱,代码必须来自受信任的服务端来源。
224
244
 
225
- API 任务的 `timeout`(默认 30000 毫秒)覆盖请求和完整响应体读取。`ApiTaskConfig.maxResponseBytes` 指定响应体实际接收字节上限(正安全整数,默认 1048576 即 1 MiB);例如 `maxResponseBytes: 10 * 1024 * 1024` 允许最大 10 MiB,非法值在请求前失败;超时、超限或非成功 HTTP 状态均记录失败并应用任务重试策略。
245
+ API 任务的 `timeout`(默认 30000 毫秒)覆盖请求和完整响应体读取。`ApiTaskConfig.maxResponseBytes` 指定响应体实际接收字节上限(正安全整数,默认 1048576 即 1 MiB);例如 `maxResponseBytes: 10 * 1024 * 1024` 允许最大 10 MiB,非法值在请求前失败;超时、超限或非成功 HTTP 状态在默认非幂等模式进入 unknown;仅声明幂等能力后才按有限重试策略处理。
226
246
 
227
247
  `init()` 必须完整加载全部任务才返回成功。配置中的空 ID、重复 ID(含与持久化任务重复)、无效 cron,以及数据库读取或持久化任务解析失败,均返回失败并清空初始化状态;修正配置或数据后重新初始化。不要同时在 `tasks` 中重复声明已有的数据库任务。
228
248
 
249
+ 调度循环按分钟匹配 cron,并从同一分钟生成持久 occurrenceId;延迟 tick 仍检查当前分钟,运行中每分钟只检查一次。
250
+
251
+ 共享租约的首次领取、开始、续租、完成、过期扫描和隔离,均在同一条数据库 SQL 内计算或核验存储端时间。PostgreSQL 使用 clock_timestamp、MySQL 使用 SYSDATE(3)、SQLite 使用存储端 now;先查时间再写入不能替代最终 deadline 条件。cron 也读取 DB 时间选定分钟 occurrence 并匹配表达式,省略 timezone 时固定 UTC;显式 IANA 时区仅改变 cron 的墙上时间解释,节点 TZ 不改变身份或匹配。节点 Date.now 仅用于耗时诊断,不授权 owner;两个节点相差数小时也不会提前接管或延长过期凭据。行锁等待结束后仍须通过期限条件,失租结果不能投影成功日志。数据库服务自身的时间须由运维保持可信;未知外部副作用仍须核对,不能自动重放。
252
+
229
253
  ### 可选集成
230
254
 
231
- 调度循环按分钟时间点匹配 cron,并用同一分钟生成分布式锁键;启动或 tick 延迟至非零秒仍检查当前分钟,同一轮运行不会每个 tick 重复调度。
255
+ 调度任务触发 Reach 通知时,调度锁或任务完成不等于外部消息只投递一次。按 [Reach 队列语义](../reach/README.md) 使用持久 queueId;HTTP 接收端须原子持久去重,SMTP/短信等在途未知结果须人工核对,不能由调度重试直接重发。
232
256
 
233
257
  ## 开发测试
234
258
 
@@ -236,6 +260,8 @@ API 任务的 `timeout`(默认 30000 毫秒)覆盖请求和完整响应体
236
260
  pnpm --filter @h-ai/scheduler test
237
261
  ```
238
262
 
263
+ 声明旁的 JSDoc 示例从真实公开入口导入,内部仓库、runner、编译器和 cron 工具则明确标注“模块内部上下文”,不代表可从包根导入。示例区分 HaiResult、显式取消异常和 close 的 Promise<void>,并检查业务 status 与日志 persistence;补写日志不会重做业务。维护时运行 `node scripts/check-module-jsdoc.mjs --package scheduler`,同时校验声明覆盖与真实类型;示例编译不代替共享数据库/独立进程验收。
264
+
239
265
  ## 许可证
240
266
 
241
267
  Apache-2.0