@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 +60 -34
- package/README.ja.md +44 -18
- package/README.md +60 -34
- package/dist/index.d.ts +528 -51
- package/dist/index.js +1125 -677
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
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/
|
|
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
|
-
-
|
|
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`:
|
|
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
|
|
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:
|
|
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.
|
|
208
|
-
| `HaiSchedulerError.
|
|
209
|
-
| `HaiSchedulerError.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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/
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
`
|
|
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 を終了してください。
|
|
218
228
|
|
|
219
|
-
|
|
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
|
-
|
|
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/
|
|
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` —
|
|
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
|
|
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:
|
|
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.
|
|
208
|
-
| `HaiSchedulerError.
|
|
209
|
-
| `HaiSchedulerError.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
227
|
+
领取前竞争/跳过的 interrupted 仅为观测日志,没有可补写的终态;其写入未确认时返回 unconfirmed。JSON 大载荷保存完整结果和全部重试记录,避免 MySQL 的 255 字符截断;新 schema 不自动迁移旧日志/执行表。关闭会等待在途补写,先关闭 scheduler 再关闭数据库。
|
|
218
228
|
|
|
219
|
-
|
|
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()`
|
|
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
|
-
|
|
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
|