@h-ai/scheduler 0.1.0-alpha.69 → 0.1.0-alpha.71
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 +5 -1
- package/README.ja.md +5 -1
- package/README.md +5 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.js +79 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/README.en.md
CHANGED
|
@@ -224,7 +224,7 @@ Repair shrinks the pending list. Drain batches repeatedly at offset=0 instead of
|
|
|
224
224
|
|
|
225
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
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.
|
|
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. Supported old logs are archived and copied by module migrations; execution ledgers remain separate. Closing waits for in-flight repairs; close scheduler before the database.
|
|
228
228
|
|
|
229
229
|
```ts
|
|
230
230
|
const result = await scheduler.trigger('cleanup', { idempotencyKey: 'cleanup-2026-10-23' })
|
|
@@ -254,6 +254,10 @@ Claim, begin, renewal, completion, expiration scans and isolation calculate/chec
|
|
|
254
254
|
|
|
255
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
256
|
|
|
257
|
+
## Legacy database upgrades
|
|
258
|
+
|
|
259
|
+
After RelDB init, `migrateScheduler({ dryRun: true })` previews changes; scheduler.init applies supported migrations before repositories. Existing tasks retain data with idempotent=false. Legacy logs are archived as hai_scheduler_logs_alpha65 and copied with stable legacy:<id> keys, preserving complete snapshots without re-executing tasks or duplicating history after interruption. SQLite/PostgreSQL roll back transactions; MySQL resumes from actual structure with old writers stopped. Back up first and restore matching database/binaries for rollback.
|
|
260
|
+
|
|
257
261
|
## Development and testing
|
|
258
262
|
|
|
259
263
|
```bash
|
package/README.ja.md
CHANGED
|
@@ -224,7 +224,7 @@ const logs = await scheduler.getLogs({
|
|
|
224
224
|
|
|
225
225
|
信頼されたサーバーは `execution.listLogFailures({ limit, offset })`(listUnknown と同じ上限)で pending を監視し、`execution.repairLog(occurrenceId)` で共有 journal から補完します。別ノード・再起動後・同時呼び出しも可能で、固定主キーと全内容の照合によりコミット後の応答消失を排除します。`LOG_PERSIST_FAILED`(032)は pending を維持して安全な警告を出します。終態の保存前に DB 全体が停止した場合は unknown の外部効果を照合し、復元可能な結果があると仮定しないでください。補完と保持処理は業務 Hook を呼ばず、保持失敗は警告のみです。`onTaskFinish` は未確定のローカル業務終了通知で、保存確認は trigger の戻り値と execution.get.logPersistence を使います。
|
|
226
226
|
|
|
227
|
-
取得前の競合・スキップの interrupted は観測ログで、補完できる確定終態はありません。応答が確認できなければ unconfirmed です。JSON 列で MySQL の 255
|
|
227
|
+
取得前の競合・スキップの interrupted は観測ログで、補完できる確定終態はありません。応答が確認できなければ unconfirmed です。JSON 列で MySQL の 255 文字制限を避け、完全な結果と全再試行を保持します。対応する旧ログはモジュール移行で保存・コピーし、実行台帳は独立します。終了は補完処理を待つため、DB より先に scheduler を終了してください。
|
|
228
228
|
|
|
229
229
|
```ts
|
|
230
230
|
const result = await scheduler.trigger('cleanup', { idempotencyKey: 'cleanup-2026-10-23' })
|
|
@@ -254,6 +254,10 @@ cron は分単位で照合し、同じ分から永続 occurrenceId を生成し
|
|
|
254
254
|
|
|
255
255
|
Reach 通知を実行するタスクでは、調度ロックやタスク完了だけで外部配信の一意性は保証できません。[Reach キューの意味](../reach/README.ja.md) に従い、永続化された queueId を使ってください。HTTP 受信側は副作用と重複排除を原子的に保存し、SMTP/SMS の配信中の結果不明は手動照合します。スケジューラーの再試行で無条件に再送しないでください。
|
|
256
256
|
|
|
257
|
+
## 旧データベースの更新
|
|
258
|
+
|
|
259
|
+
RelDB 初期化後に `migrateScheduler({ dryRun: true })` で確認し、scheduler.init がリポジトリー前に対応する移行を実行します。旧タスクを保持し idempotent=false を追加します。旧ログを hai_scheduler_logs_alpha65 に保存し、安定した legacy:<id> で完全なスナップショットを冪等コピーします。中断後も履歴を重複せず旧タスクを実行しません。SQLite/PostgreSQL はロールバック、MySQL は旧書込停止下で実構造から再開します。事前バックアップと互換 DB/コードによる復旧が必要です。
|
|
260
|
+
|
|
257
261
|
## 開発とテスト
|
|
258
262
|
|
|
259
263
|
```bash
|
package/README.md
CHANGED
|
@@ -224,7 +224,7 @@ const logs = await scheduler.getLogs({
|
|
|
224
224
|
|
|
225
225
|
可信服务端用 `execution.listLogFailures({ limit, offset })` 监控 pending(范围与 listUnknown 相同),用 `execution.repairLog(occurrenceId)` 从共享 journal 幂等补写;可跨节点、重启及并发调用。写入已提交但回执丢失时通过固定主键和完整内容核对去重。补写返回 `LOG_PERSIST_FAILED`(032)时继续保留 pending,并输出安全告警;全部数据库不可用且终态未提交时仍按 unknown 核对外部效果,不能假定存在可恢复结果。补写与日志保留均不调用业务 Hook。保留策略失败仅告警,不撤销已保存日志。`onTaskFinish` 是本地业务完成通知,其 unconfirmed 状态不是持久化确认;以 trigger 返回值及 execution.get 的 logPersistence 为准。
|
|
226
226
|
|
|
227
|
-
领取前竞争/跳过的 interrupted 仅为观测日志,没有可补写的终态;其写入未确认时返回 unconfirmed。JSON 大载荷保存完整结果和全部重试记录,避免 MySQL 的 255
|
|
227
|
+
领取前竞争/跳过的 interrupted 仅为观测日志,没有可补写的终态;其写入未确认时返回 unconfirmed。JSON 大载荷保存完整结果和全部重试记录,避免 MySQL 的 255 字符截断;支持的旧日志由模块版本迁移归档并复制;执行账本保持独立。关闭会等待在途补写,先关闭 scheduler 再关闭数据库。
|
|
228
228
|
|
|
229
229
|
```ts
|
|
230
230
|
const result = await scheduler.trigger('cleanup', { idempotencyKey: 'cleanup-2026-10-23' })
|
|
@@ -254,6 +254,10 @@ API 任务的 `timeout`(默认 30000 毫秒)覆盖请求和完整响应体
|
|
|
254
254
|
|
|
255
255
|
调度任务触发 Reach 通知时,调度锁或任务完成不等于外部消息只投递一次。按 [Reach 队列语义](../reach/README.md) 使用持久 queueId;HTTP 接收端须原子持久去重,SMTP/短信等在途未知结果须人工核对,不能由调度重试直接重发。
|
|
256
256
|
|
|
257
|
+
## 旧库升级
|
|
258
|
+
|
|
259
|
+
RelDB 初始化后调用 `migrateScheduler({ dryRun: true })` 查看变化,scheduler.init 在创建仓库前执行支持的版本迁移。旧任务保留,补 idempotent=false;旧日志表归档为 hai_scheduler_logs_alpha65,按稳定 legacy:<id> 幂等复制完整快照,超过一批和中断重试不重复历史,也不执行旧任务。SQLite/PostgreSQL 事务回滚;MySQL 按真实结构继续,需保持旧写入停止。升级前备份,回退恢复匹配数据库与代码,执行账本与旧历史不混为同一发生记录。
|
|
260
|
+
|
|
257
261
|
## 开发测试
|
|
258
262
|
|
|
259
263
|
```bash
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import * as _h_ai_core from '@h-ai/core';
|
|
3
3
|
import { PaginationOptionsInput, HaiResult, PaginatedResult } from '@h-ai/core';
|
|
4
|
+
import * as _h_ai_reldb from '@h-ai/reldb';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* @h-ai/scheduler — 配置 Schema
|
|
@@ -854,4 +855,16 @@ interface SchedulerFunctions {
|
|
|
854
855
|
*/
|
|
855
856
|
declare const scheduler: SchedulerFunctions;
|
|
856
857
|
|
|
857
|
-
|
|
858
|
+
/**
|
|
859
|
+
* 预检或升级调度器表;旧日志归档并幂等复制,新版执行账本与旧历史隔离。
|
|
860
|
+
* @example
|
|
861
|
+
* ```ts
|
|
862
|
+
* import { migrateScheduler } from '@h-ai/scheduler'
|
|
863
|
+
* const plan = await migrateScheduler({ dryRun: true })
|
|
864
|
+
* ```
|
|
865
|
+
*/
|
|
866
|
+
declare function migrateScheduler(options?: {
|
|
867
|
+
dryRun?: boolean;
|
|
868
|
+
}): Promise<_h_ai_core.HaiResult<_h_ai_reldb.SchemaMigrationPlan>>;
|
|
869
|
+
|
|
870
|
+
export { type ApiTaskConfig, ExecutionResolutionSchema, type ExecutionStatus, HaiSchedulerError, type JsTaskConfig, type JsTaskHandler, type LogQueryOptions, type SchedulerConfig, type SchedulerConfigInput, SchedulerConfigSchema, type SchedulerExecutionEntry, type SchedulerExecutionOperations, type SchedulerExecutionState, type SchedulerFunctions, type SchedulerInitInput, type SchedulerLogCleanupPolicy, type SchedulerTaskContext, type SchedulerTaskExecuteEvent, type SchedulerTaskFinishEvent, type SchedulerTaskHooks, type SchedulerTaskInterruptedEvent, type SchedulerTaskStartEvent, type TaskDefinition, type TaskExecutionLog, type TaskExecutionTargetType, type TaskHandlerConfig, type TaskHandlerKind, type TaskLogPersistence, type TaskParams, type TaskRetryPolicy, type TaskTriggerInfo, type TaskTriggerType, type TaskUpdateInput, type TriggerTaskInput, TriggerTaskSchema, migrateScheduler, scheduler };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { core, ok, err } from '@h-ai/core';
|
|
3
|
-
import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
2
|
+
import { core, ok, err, HaiCommonError } from '@h-ai/core';
|
|
3
|
+
import { migrateSchema, reldb, schemaColumns, BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
4
4
|
import { randomUUID, createHash } from 'crypto';
|
|
5
5
|
import { isDeepStrictEqual } from 'util';
|
|
6
6
|
import { Cron } from 'croner';
|
|
@@ -1182,6 +1182,79 @@ function compileJsTaskHandler(config) {
|
|
|
1182
1182
|
);
|
|
1183
1183
|
}
|
|
1184
1184
|
}
|
|
1185
|
+
function migrateScheduler(options = {}) {
|
|
1186
|
+
const steps = [{
|
|
1187
|
+
version: 1,
|
|
1188
|
+
name: "explicit-task-idempotency",
|
|
1189
|
+
async needed(sql) {
|
|
1190
|
+
const columns = await schemaColumns(reldb, "hai_scheduler_tasks", sql);
|
|
1191
|
+
return columns.success ? ok(columns.data.length > 0 && !columns.data.includes("idempotent")) : columns;
|
|
1192
|
+
},
|
|
1193
|
+
async apply(sql) {
|
|
1194
|
+
return sql.execute("ALTER TABLE hai_scheduler_tasks ADD COLUMN idempotent BOOLEAN NOT NULL DEFAULT FALSE");
|
|
1195
|
+
}
|
|
1196
|
+
}, {
|
|
1197
|
+
version: 2,
|
|
1198
|
+
name: "execution-history-snapshots",
|
|
1199
|
+
async needed(sql) {
|
|
1200
|
+
const columns = await schemaColumns(reldb, "hai_scheduler_logs", sql);
|
|
1201
|
+
if (!columns.success)
|
|
1202
|
+
return columns;
|
|
1203
|
+
if (columns.data.length && !columns.data.includes("payload"))
|
|
1204
|
+
return ok(true);
|
|
1205
|
+
const archive = await schemaColumns(reldb, "hai_scheduler_logs_alpha65", sql);
|
|
1206
|
+
if (!archive.success)
|
|
1207
|
+
return archive;
|
|
1208
|
+
if (!archive.data.length)
|
|
1209
|
+
return ok(false);
|
|
1210
|
+
if (!columns.data.length)
|
|
1211
|
+
return ok(true);
|
|
1212
|
+
const identity = reldb.config?.type === "mysql" ? "CAST(n.id AS BINARY)=CAST(CONCAT('legacy:',CAST(a.id AS CHAR)) AS BINARY)" : "n.id='legacy:' || CAST(a.id AS TEXT)";
|
|
1213
|
+
const missing = await sql.get(`SELECT id FROM hai_scheduler_logs_alpha65 a WHERE NOT EXISTS (SELECT 1 FROM hai_scheduler_logs n WHERE ${identity}) LIMIT 1`);
|
|
1214
|
+
return missing.success ? ok(Boolean(missing.data)) : missing;
|
|
1215
|
+
},
|
|
1216
|
+
async apply(sql) {
|
|
1217
|
+
const columns = await schemaColumns(reldb, "hai_scheduler_logs", sql);
|
|
1218
|
+
if (!columns.success)
|
|
1219
|
+
return columns;
|
|
1220
|
+
if (columns.data.length && !columns.data.includes("payload")) {
|
|
1221
|
+
const archive = await schemaColumns(reldb, "hai_scheduler_logs_alpha65", sql);
|
|
1222
|
+
if (!archive.success)
|
|
1223
|
+
return archive;
|
|
1224
|
+
if (archive.data.length)
|
|
1225
|
+
return err(HaiCommonError.INIT_FAILED, "Scheduler archive already exists");
|
|
1226
|
+
const renamed = await sql.execute("ALTER TABLE hai_scheduler_logs RENAME TO hai_scheduler_logs_alpha65");
|
|
1227
|
+
if (!renamed.success)
|
|
1228
|
+
return renamed;
|
|
1229
|
+
}
|
|
1230
|
+
const text = reldb.config?.type === "mysql" ? "VARCHAR(255)" : "TEXT";
|
|
1231
|
+
const json = reldb.config?.type === "postgresql" ? "JSONB" : reldb.config?.type === "mysql" ? "JSON" : "TEXT";
|
|
1232
|
+
const created = await sql.execute(`CREATE TABLE IF NOT EXISTS hai_scheduler_logs (id ${text} NOT NULL,occurrence_id ${text},task_id ${text} NOT NULL,task_name ${text} NOT NULL,task_type ${text} NOT NULL,trigger_type ${text} NOT NULL,trigger_source ${text},status ${text} NOT NULL,payload ${json} NOT NULL,started_at BIGINT NOT NULL,finished_at BIGINT NOT NULL,duration INTEGER NOT NULL,CONSTRAINT pk_hai_scheduler_log_v2 PRIMARY KEY(id))`);
|
|
1233
|
+
if (!created.success)
|
|
1234
|
+
return created;
|
|
1235
|
+
for (let offset = 0; ; offset += 100) {
|
|
1236
|
+
const rows = await sql.query(`SELECT CAST(id AS ${reldb.config?.type === "mysql" ? "CHAR" : "TEXT"}) AS id,task_id,task_name,task_type,trigger_type,trigger_source,status,result,error,started_at,finished_at,duration FROM hai_scheduler_logs_alpha65 ORDER BY id LIMIT 100 OFFSET ?`, [offset]);
|
|
1237
|
+
if (!rows.success)
|
|
1238
|
+
return rows;
|
|
1239
|
+
for (const row of rows.data) {
|
|
1240
|
+
const id = `legacy:${row.id}`;
|
|
1241
|
+
const existing = await sql.get("SELECT id FROM hai_scheduler_logs WHERE id=?", [id]);
|
|
1242
|
+
if (!existing.success)
|
|
1243
|
+
return existing;
|
|
1244
|
+
if (existing.data)
|
|
1245
|
+
continue;
|
|
1246
|
+
const payload = { id, taskId: row.task_id, taskName: row.task_name, taskType: row.task_type, triggerType: row.trigger_type, triggerSource: row.trigger_source, status: row.status, result: row.result, error: row.error, startedAt: Number(row.started_at), finishedAt: Number(row.finished_at), duration: Number(row.duration), persistence: { status: "persisted" } };
|
|
1247
|
+
const copied = await sql.execute("INSERT INTO hai_scheduler_logs(id,task_id,task_name,task_type,trigger_type,trigger_source,status,payload,started_at,finished_at,duration) VALUES(?,?,?,?,?,?,?,?,?,?,?)", [id, row.task_id, row.task_name, row.task_type, row.trigger_type, row.trigger_source, row.status, JSON.stringify(payload), row.started_at, row.finished_at, row.duration]);
|
|
1248
|
+
if (!copied.success)
|
|
1249
|
+
return copied;
|
|
1250
|
+
}
|
|
1251
|
+
if (rows.data.length < 100)
|
|
1252
|
+
return ok(void 0);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}];
|
|
1256
|
+
return migrateSchema(reldb, "scheduler", steps, options);
|
|
1257
|
+
}
|
|
1185
1258
|
|
|
1186
1259
|
// src/scheduler-cancellation.ts
|
|
1187
1260
|
async function waitWithSignal(operation, signal) {
|
|
@@ -1807,6 +1880,9 @@ async function initDatabase(parsed) {
|
|
|
1807
1880
|
logger6.error("enableDb is true but @h-ai/reldb is not initialized");
|
|
1808
1881
|
throw new Error(schedulerM("scheduler_reldbNotInitialized"));
|
|
1809
1882
|
}
|
|
1883
|
+
const migration = await migrateScheduler();
|
|
1884
|
+
if (!migration.success)
|
|
1885
|
+
throw migration.error;
|
|
1810
1886
|
taskRepo = new SchedulerTaskRepository(reldb);
|
|
1811
1887
|
logRepo = new SchedulerLogRepository(reldb);
|
|
1812
1888
|
executionRepo = new SchedulerExecutionRepository(reldb);
|
|
@@ -2085,6 +2161,6 @@ var scheduler = {
|
|
|
2085
2161
|
}
|
|
2086
2162
|
};
|
|
2087
2163
|
|
|
2088
|
-
export { ExecutionResolutionSchema, HaiSchedulerError, SchedulerConfigSchema, TriggerTaskSchema, scheduler };
|
|
2164
|
+
export { ExecutionResolutionSchema, HaiSchedulerError, SchedulerConfigSchema, TriggerTaskSchema, migrateScheduler, scheduler };
|
|
2089
2165
|
//# sourceMappingURL=index.js.map
|
|
2090
2166
|
//# sourceMappingURL=index.js.map
|