@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/dist/index.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { cache } from '@h-ai/cache';
|
|
3
2
|
import { core, ok, err } from '@h-ai/core';
|
|
4
3
|
import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
|
|
5
4
|
import { randomUUID, createHash } from 'crypto';
|
|
5
|
+
import { isDeepStrictEqual } from 'util';
|
|
6
|
+
import { Cron } from 'croner';
|
|
6
7
|
import { Script } from 'vm';
|
|
7
8
|
import { Worker } from 'worker_threads';
|
|
8
|
-
import { Cron } from 'croner';
|
|
9
|
-
import { isDeepStrictEqual } from 'util';
|
|
10
9
|
|
|
11
10
|
// src/scheduler-config.ts
|
|
12
11
|
var SchedulerConfigSchema = z.object({
|
|
13
|
-
/**
|
|
14
|
-
enableDb: z.
|
|
12
|
+
/** 必须使用共享数据库保存执行状态;false 不再允许,需要先初始化 @h-ai/reldb。 */
|
|
13
|
+
enableDb: z.literal(true).default(true),
|
|
15
14
|
/** 调度检查间隔,单位毫秒(默认 1000,即每秒检查一次) */
|
|
16
15
|
tickInterval: z.number().int().min(100).default(1e3),
|
|
17
|
-
/**
|
|
18
|
-
|
|
16
|
+
/** 执行租约毫秒数(默认 300000,范围 1000–86400000),在途每三分之一周期续期。 */
|
|
17
|
+
leaseMs: z.number().int().min(1e3).max(864e5).default(3e5),
|
|
19
18
|
/** 节点标识(默认自动生成 UUID,多节点部署时建议设置固定标识以便审计) */
|
|
20
19
|
nodeId: z.string().min(1).optional(),
|
|
21
20
|
/** 执行日志最大保留条数(启用 DB 时生效) */
|
|
@@ -23,6 +22,14 @@ var SchedulerConfigSchema = z.object({
|
|
|
23
22
|
/** 执行日志保留天数(启用 DB 时生效) */
|
|
24
23
|
retentionDays: z.number().int().min(1).optional()
|
|
25
24
|
});
|
|
25
|
+
var TriggerTaskSchema = z.object({
|
|
26
|
+
source: z.string().max(255).optional(),
|
|
27
|
+
idempotencyKey: z.string().min(1).max(128).optional()
|
|
28
|
+
});
|
|
29
|
+
var ExecutionResolutionSchema = z.object({
|
|
30
|
+
status: z.enum(["success", "discarded"]),
|
|
31
|
+
expectedUpdatedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER - 1)
|
|
32
|
+
});
|
|
26
33
|
|
|
27
34
|
// messages/en-US.json
|
|
28
35
|
var en_US_default = {
|
|
@@ -49,13 +56,55 @@ var en_US_default = {
|
|
|
49
56
|
scheduler_invalidPersistedTask: "Invalid persisted task {taskId}: {error}",
|
|
50
57
|
scheduler_taskRunning: "Task is currently running: {taskId}",
|
|
51
58
|
scheduler_invalidApiConfig: "Invalid api_config for task: {taskId}",
|
|
52
|
-
scheduler_lockAcquireFailed: "Failed to acquire distributed lock, another node already holds it: {taskId}",
|
|
53
59
|
scheduler_jsCompileFailed: "Failed to compile JS task: {error}",
|
|
54
60
|
scheduler_hookExecutionFailed: "Task lifecycle hook execution failed: {error}",
|
|
55
61
|
scheduler_taskHandlerMissing: "Task has no executable handler and no global execute hook is configured: {taskId}",
|
|
56
62
|
scheduler_invalidHandlerConfig: "Invalid task handler config: {taskId}",
|
|
57
63
|
scheduler_invalidRetryConfig: "Invalid retry policy config for task: {taskId}",
|
|
58
|
-
scheduler_reldbNotInitialized: "enableDb is true but @h-ai/reldb is not initialized, please call reldb.init() first"
|
|
64
|
+
scheduler_reldbNotInitialized: "enableDb is true but @h-ai/reldb is not initialized, please call reldb.init() first",
|
|
65
|
+
scheduler_executionUnknown: "Execution outcome is unknown; stop the old runner and verify external effects before resolving",
|
|
66
|
+
scheduler_executionStateFailed: "Shared execution state unavailable or owner lease lost",
|
|
67
|
+
scheduler_executionInputInvalid: "Invalid execution identifier or administration input",
|
|
68
|
+
scheduler_occurrenceHandled: "Occurrence already handled for task: {taskId}",
|
|
69
|
+
scheduler_logPersistFailed: "Execution log persistence is not confirmed. Repair it by receipt without rerunning the business task"
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// messages/ja-JP.json
|
|
73
|
+
var ja_JP_default = {
|
|
74
|
+
$schema: "https://inlang.com/schema/inlang-message-format",
|
|
75
|
+
scheduler_notInitialized: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u304C\u521D\u671F\u5316\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u5148\u306Bscheduler.init()\u3092\u547C\u3073\u51FA\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
76
|
+
scheduler_initFailed: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u306E\u521D\u671F\u5316\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
77
|
+
scheduler_alreadyRunning: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u306F\u65E2\u306B\u5B9F\u884C\u4E2D\u3067\u3059",
|
|
78
|
+
scheduler_notRunning: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u304C\u5B9F\u884C\u3055\u308C\u3066\u3044\u307E\u305B\u3093",
|
|
79
|
+
scheduler_closing: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u3092\u7D42\u4E86\u3057\u3066\u3044\u307E\u3059\u3002\u30BF\u30B9\u30AF\u306E\u5B9F\u884C\u3092\u30AD\u30E3\u30F3\u30BB\u30EB\u3057\u307E\u3057\u305F",
|
|
80
|
+
scheduler_definitionsChanged: "\u66F4\u65B0\u4E2D\u306B\u30BF\u30B9\u30AF\u5B9A\u7FA9\u304C\u5909\u66F4\u3055\u308C\u307E\u3057\u305F\u3002\u64CD\u4F5C\u3092\u518D\u8A66\u884C\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
81
|
+
scheduler_taskNoLongerDue: "\u30BF\u30B9\u30AF{taskId}\u304C\u524A\u9664\u3001\u7121\u52B9\u5316\u3001\u307E\u305F\u306F\u518D\u30B9\u30B1\u30B8\u30E5\u30FC\u30EB\u3055\u308C\u307E\u3057\u305F",
|
|
82
|
+
scheduler_taskNotFound: "\u30BF\u30B9\u30AF\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: {taskId}",
|
|
83
|
+
scheduler_taskAlreadyExists: "\u30BF\u30B9\u30AF\u306F\u65E2\u306B\u5B58\u5728\u3057\u307E\u3059: {taskId}",
|
|
84
|
+
scheduler_invalidCron: "cron\u5F0F\u304C\u7121\u52B9\u3067\u3059: {expression}",
|
|
85
|
+
scheduler_executionFailed: "\u30BF\u30B9\u30AF\u306E\u5B9F\u884C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
86
|
+
scheduler_jsExecutionFailed: "JS\u30B3\u30FC\u30C9\u306E\u5B9F\u884C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
87
|
+
scheduler_jsTimedOut: "JS\u30BF\u30B9\u30AF\u304C{timeout}ms\u3067\u30BF\u30A4\u30E0\u30A2\u30A6\u30C8\u3057\u307E\u3057\u305F",
|
|
88
|
+
scheduler_apiExecutionFailed: "API\u547C\u3073\u51FA\u3057\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
89
|
+
scheduler_apiResponseTooLarge: "API\u30EC\u30B9\u30DD\u30F3\u30B9\u304C{maxResponseBytes}\u30D0\u30A4\u30C8\u306E\u5236\u9650\u3092\u8D85\u3048\u3066\u3044\u307E\u3059",
|
|
90
|
+
scheduler_dbSaveFailed: "\u5B9F\u884C\u30ED\u30B0\u306E\u4FDD\u5B58\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
91
|
+
scheduler_dbNotInitialized: "\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u304C\u521D\u671F\u5316\u3055\u308C\u3066\u3044\u306A\u3044\u305F\u3081\u3001\u5B9F\u884C\u30ED\u30B0\u3092\u4FDD\u5B58\u3067\u304D\u307E\u305B\u3093",
|
|
92
|
+
scheduler_configError: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u8A2D\u5B9A\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
93
|
+
scheduler_taskIdEmpty: "\u30BF\u30B9\u30AFID\u3092\u7A7A\u306B\u3059\u308B\u3053\u3068\u306F\u3067\u304D\u307E\u305B\u3093",
|
|
94
|
+
scheduler_invalidPersistedTask: "\u4FDD\u5B58\u3055\u308C\u305F\u30BF\u30B9\u30AF{taskId}\u304C\u7121\u52B9\u3067\u3059: {error}",
|
|
95
|
+
scheduler_taskRunning: "\u30BF\u30B9\u30AF\u306F\u73FE\u5728\u5B9F\u884C\u4E2D\u3067\u3059: {taskId}",
|
|
96
|
+
scheduler_invalidApiConfig: "\u30BF\u30B9\u30AF\u306Eapi_config\u304C\u7121\u52B9\u3067\u3059: {taskId}",
|
|
97
|
+
scheduler_jsCompileFailed: "JS\u30BF\u30B9\u30AF\u306E\u30B3\u30F3\u30D1\u30A4\u30EB\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
98
|
+
scheduler_hookExecutionFailed: "\u30BF\u30B9\u30AF\u306E\u30E9\u30A4\u30D5\u30B5\u30A4\u30AF\u30EB\u30D5\u30C3\u30AF\u306E\u5B9F\u884C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
|
|
99
|
+
scheduler_taskHandlerMissing: "\u5B9F\u884C\u53EF\u80FD\u306A\u30CF\u30F3\u30C9\u30E9\u30FC\u3082\u30B0\u30ED\u30FC\u30D0\u30EBexecute\u30D5\u30C3\u30AF\u3082\u8A2D\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093: {taskId}",
|
|
100
|
+
scheduler_invalidHandlerConfig: "\u30BF\u30B9\u30AF\u30CF\u30F3\u30C9\u30E9\u30FC\u306E\u8A2D\u5B9A\u304C\u7121\u52B9\u3067\u3059: {taskId}",
|
|
101
|
+
scheduler_invalidRetryConfig: "\u30BF\u30B9\u30AF\u306E\u518D\u8A66\u884C\u30DD\u30EA\u30B7\u30FC\u306E\u8A2D\u5B9A\u304C\u7121\u52B9\u3067\u3059: {taskId}",
|
|
102
|
+
scheduler_reldbNotInitialized: "enableDb\u306Ftrue\u3067\u3059\u304C\u3001@h-ai/reldb\u304C\u521D\u671F\u5316\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u5148\u306Breldb.init()\u3092\u547C\u3073\u51FA\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
103
|
+
scheduler_executionUnknown: "\u5B9F\u884C\u7D50\u679C\u306F\u4E0D\u660E\u3067\u3059\u3002\u624B\u52D5\u89E3\u6C7A\u3059\u308B\u524D\u306B\u53E4\u3044\u5B9F\u884C\u30D7\u30ED\u30BB\u30B9\u3092\u505C\u6B62\u3057\u3001\u5916\u90E8\u3078\u306E\u5F71\u97FF\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044",
|
|
104
|
+
scheduler_executionStateFailed: "\u5171\u6709\u5B9F\u884C\u72B6\u614B\u3092\u5229\u7528\u3067\u304D\u306A\u3044\u304B\u3001\u6240\u6709\u8005\u30EA\u30FC\u30B9\u304C\u5931\u308F\u308C\u307E\u3057\u305F",
|
|
105
|
+
scheduler_executionInputInvalid: "\u5B9F\u884C\u8B58\u5225\u5B50\u307E\u305F\u306F\u7BA1\u7406\u64CD\u4F5C\u306E\u5165\u529B\u304C\u7121\u52B9\u3067\u3059",
|
|
106
|
+
scheduler_occurrenceHandled: "\u30BF\u30B9\u30AF {taskId} \u306E\u4ECA\u56DE\u306E\u30B9\u30B1\u30B8\u30E5\u30FC\u30EB\u306F\u65E2\u306B\u51E6\u7406\u6E08\u307F\u3067\u3059",
|
|
107
|
+
scheduler_logPersistFailed: "\u5B9F\u884C\u30ED\u30B0\u306E\u6C38\u7D9A\u5316\u306F\u672A\u78BA\u8A8D\u3067\u3059\u3002\u53D7\u9818\u8A18\u9332\u306B\u57FA\u3065\u3044\u3066\u30ED\u30B0\u3092\u88DC\u5B8C\u3057\u3001\u696D\u52D9\u30BF\u30B9\u30AF\u3092\u518D\u5B9F\u884C\u3057\u306A\u3044\u3067\u304F\u3060\u3055\u3044"
|
|
59
108
|
};
|
|
60
109
|
|
|
61
110
|
// messages/zh-CN.json
|
|
@@ -83,19 +132,24 @@ var zh_CN_default = {
|
|
|
83
132
|
scheduler_invalidPersistedTask: "\u6301\u4E45\u5316\u4EFB\u52A1 {taskId} \u65E0\u6548\uFF1A{error}",
|
|
84
133
|
scheduler_taskRunning: "\u4EFB\u52A1\u6B63\u5728\u6267\u884C\u4E2D: {taskId}",
|
|
85
134
|
scheduler_invalidApiConfig: "\u4EFB\u52A1 api_config \u683C\u5F0F\u65E0\u6548: {taskId}",
|
|
86
|
-
scheduler_lockAcquireFailed: "\u83B7\u53D6\u5206\u5E03\u5F0F\u9501\u5931\u8D25\uFF0C\u5176\u4ED6\u8282\u70B9\u5DF2\u83B7\u9501: {taskId}",
|
|
87
135
|
scheduler_jsCompileFailed: "JS \u4EFB\u52A1\u7F16\u8BD1\u5931\u8D25: {error}",
|
|
88
136
|
scheduler_hookExecutionFailed: "\u4EFB\u52A1\u751F\u547D\u5468\u671F\u56DE\u8C03\u6267\u884C\u5931\u8D25: {error}",
|
|
89
137
|
scheduler_taskHandlerMissing: "\u4EFB\u52A1\u7F3A\u5C11\u53EF\u6267\u884C handler\uFF0C\u4E14\u672A\u914D\u7F6E\u5168\u5C40\u6267\u884C\u56DE\u8C03: {taskId}",
|
|
90
138
|
scheduler_invalidHandlerConfig: "\u4EFB\u52A1 handler \u914D\u7F6E\u65E0\u6548: {taskId}",
|
|
91
139
|
scheduler_invalidRetryConfig: "\u4EFB\u52A1\u91CD\u8BD5\u7B56\u7565\u914D\u7F6E\u65E0\u6548: {taskId}",
|
|
92
|
-
scheduler_reldbNotInitialized: "enableDb \u4E3A true \u4F46 @h-ai/reldb \u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u5148\u8C03\u7528 reldb.init()"
|
|
140
|
+
scheduler_reldbNotInitialized: "enableDb \u4E3A true \u4F46 @h-ai/reldb \u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u5148\u8C03\u7528 reldb.init()",
|
|
141
|
+
scheduler_executionUnknown: "\u6267\u884C\u7ED3\u679C\u672A\u77E5\uFF1B\u8BF7\u5148\u7EC8\u6B62\u65E7 runner \u5E76\u6838\u5BF9\u5916\u90E8\u526F\u4F5C\u7528\u540E\u88C1\u5B9A",
|
|
142
|
+
scheduler_executionStateFailed: "\u5171\u4EAB\u6267\u884C\u72B6\u6001\u4E0D\u53EF\u7528\u6216 owner \u79DF\u7EA6\u5DF2\u5931\u6548",
|
|
143
|
+
scheduler_executionInputInvalid: "\u6267\u884C\u6807\u8BC6\u6216\u8FD0\u7EF4\u8F93\u5165\u4E0D\u5408\u6CD5",
|
|
144
|
+
scheduler_occurrenceHandled: "\u672C\u6B21\u8C03\u5EA6\u5DF2\u5904\u7406: {taskId}",
|
|
145
|
+
scheduler_logPersistFailed: "\u6267\u884C\u65E5\u5FD7\u5C1A\u672A\u786E\u8BA4\u6301\u4E45\u5316\uFF0C\u8BF7\u6309\u6267\u884C\u56DE\u6267\u8865\u5199\uFF1B\u4E0D\u8981\u91CD\u65B0\u6267\u884C\u4E1A\u52A1"
|
|
93
146
|
};
|
|
94
147
|
|
|
95
148
|
// src/scheduler-i18n.ts
|
|
96
149
|
var schedulerM = core.i18n.createMessageGetter({
|
|
97
150
|
"zh-CN": zh_CN_default,
|
|
98
|
-
"en-US": en_US_default
|
|
151
|
+
"en-US": en_US_default,
|
|
152
|
+
"ja-JP": ja_JP_default
|
|
99
153
|
});
|
|
100
154
|
var SchedulerErrorInfo = {
|
|
101
155
|
NOT_INITIALIZED: "010:500",
|
|
@@ -110,13 +164,264 @@ var SchedulerErrorInfo = {
|
|
|
110
164
|
DB_SAVE_FAILED: "026:500",
|
|
111
165
|
ALREADY_RUNNING: "027:409",
|
|
112
166
|
NOT_RUNNING: "028:400",
|
|
113
|
-
LOCK_ACQUIRE_FAILED: "029:409",
|
|
114
167
|
JS_COMPILE_FAILED: "030:500",
|
|
115
|
-
HOOK_EXECUTION_FAILED: "031:500"
|
|
168
|
+
HOOK_EXECUTION_FAILED: "031:500",
|
|
169
|
+
LOG_PERSIST_FAILED: "032:500"
|
|
116
170
|
};
|
|
117
171
|
var HaiSchedulerError = core.error.buildHaiErrorsDef("scheduler", SchedulerErrorInfo);
|
|
118
172
|
|
|
119
|
-
// src/repositories/scheduler-
|
|
173
|
+
// src/repositories/scheduler-repository-execution.ts
|
|
174
|
+
var TABLE = "hai_scheduler_executions";
|
|
175
|
+
function executionId(taskId, type, key) {
|
|
176
|
+
return createHash("sha256").update(JSON.stringify([taskId, type, key])).digest("hex");
|
|
177
|
+
}
|
|
178
|
+
function executionEntry(row) {
|
|
179
|
+
return { occurrenceId: row.occurrenceId, taskId: row.taskId, status: row.status, sequence: row.sequence, attemptCount: row.attemptCount, updatedAt: row.updatedAt, logPersistence: row.logPersistence };
|
|
180
|
+
}
|
|
181
|
+
function failed(cause) {
|
|
182
|
+
return err(HaiSchedulerError.DB_SAVE_FAILED, schedulerM("scheduler_executionStateFailed"), cause);
|
|
183
|
+
}
|
|
184
|
+
var SchedulerExecutionRepository = class extends BaseReldbCrudRepository {
|
|
185
|
+
constructor(db) {
|
|
186
|
+
super(db, {
|
|
187
|
+
table: TABLE,
|
|
188
|
+
idColumn: "sequence",
|
|
189
|
+
idField: "sequence",
|
|
190
|
+
fields: [
|
|
191
|
+
{ fieldName: "sequence", columnName: "sequence", def: { type: "INTEGER", primaryKey: true, autoIncrement: true }, select: true, create: false, update: false },
|
|
192
|
+
...[
|
|
193
|
+
["occurrenceId", "occurrence_id", true, true],
|
|
194
|
+
["taskId", "task_id", true, false],
|
|
195
|
+
["activeTask", "active_task", false, true],
|
|
196
|
+
["status", "status", true, false],
|
|
197
|
+
["owner", "owner", false, false],
|
|
198
|
+
["snapshot", "snapshot", true, false],
|
|
199
|
+
["triggerJson", "trigger_json", true, false],
|
|
200
|
+
["logJson", "log_json", false, false],
|
|
201
|
+
["logsJson", "logs_json", false, false],
|
|
202
|
+
["logPersistence", "log_persistence", true, false]
|
|
203
|
+
].map(([fieldName, columnName, notNull, unique]) => ({
|
|
204
|
+
fieldName: String(fieldName),
|
|
205
|
+
columnName: String(columnName),
|
|
206
|
+
def: { type: ["snapshot", "triggerJson", "logJson", "logsJson"].includes(String(fieldName)) ? "JSON" : "TEXT", notNull: Boolean(notNull), unique: Boolean(unique) },
|
|
207
|
+
select: true,
|
|
208
|
+
create: true,
|
|
209
|
+
update: true
|
|
210
|
+
})),
|
|
211
|
+
...[
|
|
212
|
+
["leasedUntil", "leased_until"],
|
|
213
|
+
["attemptCount", "attempt_count"],
|
|
214
|
+
["maxAttempts", "max_attempts"],
|
|
215
|
+
["updatedAt", "updated_at"],
|
|
216
|
+
["createdAt", "created_at"]
|
|
217
|
+
].map(([fieldName, columnName]) => ({
|
|
218
|
+
fieldName,
|
|
219
|
+
columnName,
|
|
220
|
+
def: { type: "BIGINT", notNull: true },
|
|
221
|
+
select: true,
|
|
222
|
+
create: true,
|
|
223
|
+
update: true
|
|
224
|
+
})),
|
|
225
|
+
{ fieldName: "idempotent", columnName: "idempotent", def: { type: "BOOLEAN", notNull: true }, select: true, create: true, update: true }
|
|
226
|
+
]
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* 只按已初始化的数据库类型选择存储端毫秒时钟。
|
|
231
|
+
* PostgreSQL/MySQL 使用执行时钟,不能使用固定在事务/语句开始处的时间快照。
|
|
232
|
+
* 期限表达式直接进入有 owner 条件的 SQL;先 SELECT 时间再写入仍有等待窗口。
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* ```ts
|
|
236
|
+
* // 模块内部上下文:当前仓库实例仅生成固定白名单 SQL,不接收调用方表达式。
|
|
237
|
+
* const deadlinePredicate = `leased_until > ${this.databaseClock()}`
|
|
238
|
+
* ```
|
|
239
|
+
*/
|
|
240
|
+
databaseClock() {
|
|
241
|
+
if (this.db.config?.type === "postgresql")
|
|
242
|
+
return "CAST(FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) AS BIGINT)";
|
|
243
|
+
if (this.db.config?.type === "mysql")
|
|
244
|
+
return "CAST(UNIX_TIMESTAMP(SYSDATE(3)) * 1000 AS UNSIGNED)";
|
|
245
|
+
return "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
|
|
246
|
+
}
|
|
247
|
+
/** 同毫秒内的持久状态版本仍单调增加,日志确认/人工裁定不依赖节点墙钟。 */
|
|
248
|
+
nextUpdatedAt() {
|
|
249
|
+
const clock = this.databaseClock();
|
|
250
|
+
return `CASE WHEN updated_at >= ${clock} THEN updated_at + 1 ELSE ${clock} END`;
|
|
251
|
+
}
|
|
252
|
+
/** 存储端毫秒时钟供 cron 计算共享分钟身份;读取失败停止本轮调度,不回退节点时钟。 */
|
|
253
|
+
async currentTime() {
|
|
254
|
+
const result = await this.db.sql.get(`SELECT ${this.databaseClock()} AS now`);
|
|
255
|
+
if (!result.success)
|
|
256
|
+
return failed(result.error);
|
|
257
|
+
const now = Number(result.data?.now);
|
|
258
|
+
return Number.isSafeInteger(now) && now >= 0 ? ok(now) : failed();
|
|
259
|
+
}
|
|
260
|
+
/** 读取统一校验 BIGINT,不能把 PG 字符串时间或舍入的序号用于 owner 判断。 */
|
|
261
|
+
normalize(row) {
|
|
262
|
+
const value = { ...row };
|
|
263
|
+
for (const field of ["snapshot", "triggerJson", "logJson", "logsJson"]) {
|
|
264
|
+
if (row[field] !== void 0 && row[field] !== null && typeof row[field] !== "string")
|
|
265
|
+
value[field] = JSON.stringify(row[field]);
|
|
266
|
+
}
|
|
267
|
+
for (const field of ["sequence", "leasedUntil", "attemptCount", "maxAttempts", "updatedAt", "createdAt"]) {
|
|
268
|
+
const number = Number(row[field]);
|
|
269
|
+
if (!Number.isSafeInteger(number) || number < 0)
|
|
270
|
+
return failed();
|
|
271
|
+
value[field] = number;
|
|
272
|
+
}
|
|
273
|
+
return ok(value);
|
|
274
|
+
}
|
|
275
|
+
/** 按稳定回执读取。@example await repository.byOccurrence(id) */
|
|
276
|
+
async byOccurrence(id) {
|
|
277
|
+
const rows = await this.findAll({ where: "occurrence_id = ?", params: [id], limit: 1 });
|
|
278
|
+
if (!rows.success)
|
|
279
|
+
return failed(rows.error);
|
|
280
|
+
return rows.data[0] ? this.normalize(rows.data[0]) : ok(null);
|
|
281
|
+
}
|
|
282
|
+
/** 有界读取过期执行,跨分钟恢复原 occurrence,而不是创建新的副作用身份。@example await repository.expired() */
|
|
283
|
+
async expired() {
|
|
284
|
+
return this.rows(`status IN (?, ?) AND leased_until <= ${this.databaseClock()}`, ["claimed", "running"], 100, 0);
|
|
285
|
+
}
|
|
286
|
+
/** 任务被删除或触发元数据损坏时,过期记录隔离为 unknown,允许可信人工核对而非永久悬挂。 */
|
|
287
|
+
async isolateExpired(row) {
|
|
288
|
+
const result = await this.db.sql.execute(
|
|
289
|
+
`UPDATE ${TABLE} SET status = ?, owner = NULL, leased_until = 0, updated_at = ${this.nextUpdatedAt()}
|
|
290
|
+
WHERE occurrence_id = ? AND owner = ? AND leased_until <= ${this.databaseClock()} AND status IN (?, ?)`,
|
|
291
|
+
["unknown", row.occurrenceId, row.owner, "claimed", "running"]
|
|
292
|
+
);
|
|
293
|
+
return result.success && result.data.changes === 1 ? ok(void 0) : failed();
|
|
294
|
+
}
|
|
295
|
+
/** 有界读取待人工核对记录。@example await repository.unknown(50, 0) */
|
|
296
|
+
async unknown(limit, offset) {
|
|
297
|
+
return this.rows("status = ?", ["unknown"], limit, offset);
|
|
298
|
+
}
|
|
299
|
+
/** 仅查询已发布终态且日志待补写的回执。@example await repository.pendingLogs(50, 0) */
|
|
300
|
+
async pendingLogs(limit, offset) {
|
|
301
|
+
return this.rows("log_persistence = ? AND status NOT IN (?, ?)", ["pending", "claimed", "running"], limit, offset);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* 确认相同终态快照的日志投影;不变更业务状态,不重新领取任务。
|
|
305
|
+
* @example await repository.confirmLogs(row)
|
|
306
|
+
*/
|
|
307
|
+
async confirmLogs(row) {
|
|
308
|
+
if (!row.logJson || !row.logsJson)
|
|
309
|
+
return failed();
|
|
310
|
+
const log = JSON.parse(row.logJson);
|
|
311
|
+
const result = await this.db.sql.execute(
|
|
312
|
+
`UPDATE ${TABLE} SET log_persistence = ?, log_json = ?, updated_at = ${this.nextUpdatedAt()}
|
|
313
|
+
WHERE occurrence_id = ? AND updated_at = ? AND log_persistence = ? AND status NOT IN (?, ?)`,
|
|
314
|
+
["persisted", JSON.stringify({ ...log, persistence: { status: "persisted" } }), row.occurrenceId, row.updatedAt, "pending", "claimed", "running"]
|
|
315
|
+
);
|
|
316
|
+
if (result.success && result.data.changes === 1)
|
|
317
|
+
return ok(void 0);
|
|
318
|
+
const current = await this.byOccurrence(row.occurrenceId);
|
|
319
|
+
return current.success && current.data?.logPersistence === "persisted" && current.data.logsJson === row.logsJson ? ok(void 0) : failed();
|
|
320
|
+
}
|
|
321
|
+
async rows(where, params, limit, offset) {
|
|
322
|
+
const result = await this.findAll({ where, params, limit, offset, orderBy: "sequence ASC" });
|
|
323
|
+
if (!result.success)
|
|
324
|
+
return failed(result.error);
|
|
325
|
+
const rows = [];
|
|
326
|
+
for (const row of result.data) {
|
|
327
|
+
const normalized = this.normalize(row);
|
|
328
|
+
if (!normalized.success)
|
|
329
|
+
return normalized;
|
|
330
|
+
rows.push(normalized.data);
|
|
331
|
+
}
|
|
332
|
+
return ok(rows);
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* 唯一 active_task 防止不同 occurrence 并行;同 occurrence 只可恢复安全的过期记录。
|
|
336
|
+
* @example await repository.claim(task, id, trigger, nodeId, leaseMs)
|
|
337
|
+
*/
|
|
338
|
+
async claim(task, id, trigger, nodeId, leaseMs) {
|
|
339
|
+
const ready = await this.ensureReady();
|
|
340
|
+
if (!ready.success)
|
|
341
|
+
return failed(ready.error);
|
|
342
|
+
const clock = this.databaseClock();
|
|
343
|
+
const owner = `${nodeId}:${randomUUID()}`;
|
|
344
|
+
let created;
|
|
345
|
+
try {
|
|
346
|
+
created = await this.db.sql.execute(
|
|
347
|
+
`INSERT INTO ${TABLE} (occurrence_id, task_id, active_task, status, owner, leased_until, attempt_count, max_attempts, idempotent, snapshot, trigger_json, log_persistence, updated_at, created_at)
|
|
348
|
+
VALUES (?, ?, ?, ?, ?, ${clock} + ?, 0, ?, ?, ?, ?, ?, ${clock}, ${clock})`,
|
|
349
|
+
[id, task.id, task.id, "claimed", owner, leaseMs, task.retry?.maxAttempts ?? 1, this.db.config?.type === "postgresql" ? task.idempotent === true : Number(task.idempotent === true), JSON.stringify(task), JSON.stringify(trigger), "unconfirmed"]
|
|
350
|
+
);
|
|
351
|
+
} catch (cause) {
|
|
352
|
+
return failed(cause);
|
|
353
|
+
}
|
|
354
|
+
const existing = await this.byOccurrence(id);
|
|
355
|
+
if (!existing.success)
|
|
356
|
+
return existing;
|
|
357
|
+
if (!existing.data) {
|
|
358
|
+
const active = await this.findAll({ where: "active_task = ?", params: [task.id], limit: 1 });
|
|
359
|
+
return active.success && active.data.length > 0 ? ok(null) : failed(created.success ? void 0 : created.error);
|
|
360
|
+
}
|
|
361
|
+
const row = existing.data;
|
|
362
|
+
if (created.success || ["success", "failed", "interrupted", "discarded"].includes(row.status))
|
|
363
|
+
return ok(row);
|
|
364
|
+
if (row.status === "unknown")
|
|
365
|
+
return ok(null);
|
|
366
|
+
if (row.status === "running" && (!row.idempotent || row.attemptCount >= row.maxAttempts)) {
|
|
367
|
+
const isolated = await this.db.sql.execute(
|
|
368
|
+
`UPDATE ${TABLE} SET status = ?, owner = NULL, leased_until = 0, updated_at = ${this.nextUpdatedAt()}
|
|
369
|
+
WHERE occurrence_id = ? AND owner = ? AND leased_until <= ${clock} AND status = ?`,
|
|
370
|
+
["unknown", id, row.owner, "running"]
|
|
371
|
+
);
|
|
372
|
+
return isolated.success ? ok(null) : failed(isolated.error);
|
|
373
|
+
}
|
|
374
|
+
const recovered = await this.db.sql.execute(
|
|
375
|
+
`UPDATE ${TABLE} SET status = ?, owner = ?, leased_until = ${clock} + ?, updated_at = ${this.nextUpdatedAt()}
|
|
376
|
+
WHERE occurrence_id = ? AND owner = ? AND leased_until <= ${clock} AND status IN (?, ?)`,
|
|
377
|
+
["claimed", owner, leaseMs, id, row.owner, "claimed", "running"]
|
|
378
|
+
);
|
|
379
|
+
if (!recovered.success)
|
|
380
|
+
return failed(recovered.error);
|
|
381
|
+
return recovered.data.changes === 1 ? this.byOccurrence(id) : ok(null);
|
|
382
|
+
}
|
|
383
|
+
/** 外部副作用前的条件门闩,并原子递增持久尝试次数。@example await repository.begin(row, leaseMs) */
|
|
384
|
+
async begin(row, leaseMs) {
|
|
385
|
+
const clock = this.databaseClock();
|
|
386
|
+
const result = await this.db.sql.execute(
|
|
387
|
+
`UPDATE ${TABLE} SET status = ?, attempt_count = attempt_count + 1, leased_until = ${clock} + ?, updated_at = ${this.nextUpdatedAt()}
|
|
388
|
+
WHERE occurrence_id = ? AND owner = ? AND leased_until > ${clock} AND status IN (?, ?) AND attempt_count < max_attempts`,
|
|
389
|
+
["running", leaseMs, row.occurrenceId, row.owner, "claimed", "running"]
|
|
390
|
+
);
|
|
391
|
+
if (!result.success || result.data.changes !== 1)
|
|
392
|
+
return failed(result.success ? void 0 : result.error);
|
|
393
|
+
row.status = "running";
|
|
394
|
+
row.attemptCount += 1;
|
|
395
|
+
return ok(void 0);
|
|
396
|
+
}
|
|
397
|
+
/** 只续租仍然有效的 owner;过期凭据不能复活。@example await repository.renew(row, leaseMs) */
|
|
398
|
+
async renew(row, leaseMs) {
|
|
399
|
+
const clock = this.databaseClock();
|
|
400
|
+
const result = await this.db.sql.execute(
|
|
401
|
+
`UPDATE ${TABLE} SET leased_until = ${clock} + ?, updated_at = ${this.nextUpdatedAt()} WHERE occurrence_id = ? AND owner = ? AND leased_until > ${clock} AND status IN (?, ?)`,
|
|
402
|
+
[leaseMs, row.occurrenceId, row.owner, "claimed", "running"]
|
|
403
|
+
);
|
|
404
|
+
return result.success && result.data.changes === 1 ? ok(void 0) : failed();
|
|
405
|
+
}
|
|
406
|
+
/** 有效 owner 确认终态;unknown 保留串行槽,阻止下一 cron/manual 与旧副作用并行。@example await repository.finish(row, log) */
|
|
407
|
+
async finish(row, log, logs = [log]) {
|
|
408
|
+
const clock = this.databaseClock();
|
|
409
|
+
const result = await this.db.sql.execute(
|
|
410
|
+
`UPDATE ${TABLE} SET status = ?, active_task = ?, owner = NULL, leased_until = 0, log_json = ?, logs_json = ?, log_persistence = ?, updated_at = ${this.nextUpdatedAt()}
|
|
411
|
+
WHERE occurrence_id = ? AND owner = ? AND leased_until > ${clock} AND status IN (?, ?)`,
|
|
412
|
+
[log.status, log.status === "unknown" ? row.taskId : null, JSON.stringify(log), JSON.stringify(logs), "pending", row.occurrenceId, row.owner, "claimed", "running"]
|
|
413
|
+
);
|
|
414
|
+
return result.success && result.data.changes === 1 ? ok(void 0) : failed();
|
|
415
|
+
}
|
|
416
|
+
/** 人工隔离旧 runner 后条件裁定;不重排旧 occurrence,只有成功裁定才释放任务槽。@example await repository.resolve(id, 'discarded', updatedAt) */
|
|
417
|
+
async resolve(id, status, expectedUpdatedAt) {
|
|
418
|
+
const result = await this.db.sql.execute(
|
|
419
|
+
`UPDATE ${TABLE} SET status = ?, active_task = NULL, updated_at = ${this.nextUpdatedAt()} WHERE occurrence_id = ? AND status = ? AND updated_at = ?`,
|
|
420
|
+
[status, id, "unknown", expectedUpdatedAt]
|
|
421
|
+
);
|
|
422
|
+
return result.success && result.data.changes === 1 ? ok(void 0) : failed();
|
|
423
|
+
}
|
|
424
|
+
};
|
|
120
425
|
var logger = core.logger.child({ module: "scheduler", scope: "log-repository" });
|
|
121
426
|
var SCHEDULER_LOG_TABLE = "hai_scheduler_logs";
|
|
122
427
|
var SchedulerLogRepository = class extends BaseReldbCrudRepository {
|
|
@@ -125,41 +430,53 @@ var SchedulerLogRepository = class extends BaseReldbCrudRepository {
|
|
|
125
430
|
table: SCHEDULER_LOG_TABLE,
|
|
126
431
|
idColumn: "id",
|
|
127
432
|
fields: [
|
|
128
|
-
{ fieldName: "id", columnName: "id", def: { type: "
|
|
433
|
+
{ fieldName: "id", columnName: "id", def: { type: "TEXT", primaryKey: true }, select: true, create: true, update: false },
|
|
434
|
+
{ fieldName: "occurrenceId", columnName: "occurrence_id", def: { type: "TEXT" }, select: true, create: true, update: false },
|
|
129
435
|
{ fieldName: "taskId", columnName: "task_id", def: { type: "TEXT", notNull: true }, select: true, create: true, update: false },
|
|
130
436
|
{ fieldName: "taskName", columnName: "task_name", def: { type: "TEXT", notNull: true }, select: true, create: true, update: false },
|
|
131
437
|
{ fieldName: "taskType", columnName: "task_type", def: { type: "TEXT", notNull: true }, select: true, create: true, update: false },
|
|
132
438
|
{ fieldName: "triggerType", columnName: "trigger_type", def: { type: "TEXT", notNull: true }, select: true, create: true, update: false },
|
|
133
439
|
{ fieldName: "triggerSource", columnName: "trigger_source", def: { type: "TEXT" }, select: true, create: true, update: false },
|
|
134
440
|
{ fieldName: "status", columnName: "status", def: { type: "TEXT", notNull: true }, select: true, create: true, update: false },
|
|
135
|
-
{ fieldName: "
|
|
136
|
-
{ fieldName: "error", columnName: "error", def: { type: "TEXT" }, select: true, create: true, update: false },
|
|
441
|
+
{ fieldName: "payload", columnName: "payload", def: { type: "JSON", notNull: true }, select: true, create: true, update: false },
|
|
137
442
|
{ fieldName: "startedAt", columnName: "started_at", def: { type: "BIGINT", notNull: true }, select: true, create: true, update: false },
|
|
138
443
|
{ fieldName: "finishedAt", columnName: "finished_at", def: { type: "BIGINT", notNull: true }, select: true, create: true, update: false },
|
|
139
444
|
{ fieldName: "duration", columnName: "duration", def: { type: "INTEGER", notNull: true }, select: true, create: true, update: false }
|
|
140
445
|
]
|
|
141
446
|
});
|
|
142
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* 按固定 UUID 写入完整日志;写入失败后重读相同主键,只有真实内容相同才视为已保存。
|
|
450
|
+
* @example `const saved = await repository.saveLog(log); if (!saved.success) return saved`
|
|
451
|
+
*/
|
|
143
452
|
async saveLog(log) {
|
|
453
|
+
const payload = { ...log, persistence: { status: "persisted" } };
|
|
144
454
|
try {
|
|
145
|
-
const
|
|
455
|
+
const created = await this.create({
|
|
456
|
+
id: log.id,
|
|
457
|
+
occurrenceId: log.occurrenceId,
|
|
146
458
|
taskId: log.taskId,
|
|
147
459
|
taskName: log.taskName,
|
|
148
460
|
taskType: log.taskType,
|
|
149
461
|
triggerType: log.triggerType,
|
|
150
462
|
triggerSource: log.triggerSource,
|
|
151
463
|
status: log.status,
|
|
152
|
-
|
|
153
|
-
error: log.error,
|
|
464
|
+
payload,
|
|
154
465
|
startedAt: log.startedAt,
|
|
155
466
|
finishedAt: log.finishedAt,
|
|
156
467
|
duration: log.duration
|
|
157
468
|
});
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
} catch
|
|
161
|
-
|
|
469
|
+
if (created.success)
|
|
470
|
+
return ok(void 0);
|
|
471
|
+
} catch {
|
|
472
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
const existing = await this.findById(log.id);
|
|
475
|
+
if (existing.success && existing.data && isDeepStrictEqual(existing.data.payload, payload))
|
|
476
|
+
return ok(void 0);
|
|
477
|
+
} catch {
|
|
162
478
|
}
|
|
479
|
+
return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
|
|
163
480
|
}
|
|
164
481
|
async queryLogs(options) {
|
|
165
482
|
const { taskId, status, triggerType, triggerSource, startedAfter, startedBefore, pagination } = options ?? {};
|
|
@@ -193,7 +510,7 @@ var SchedulerLogRepository = class extends BaseReldbCrudRepository {
|
|
|
193
510
|
const pageResult = await this.findPage({
|
|
194
511
|
where: conditions.length > 0 ? conditions.join(" AND ") : void 0,
|
|
195
512
|
params: params.length > 0 ? params : void 0,
|
|
196
|
-
orderBy: "id DESC",
|
|
513
|
+
orderBy: "started_at DESC, id DESC",
|
|
197
514
|
pagination,
|
|
198
515
|
overrides: { defaultPageSize: 20, maxPageSize: 200 }
|
|
199
516
|
});
|
|
@@ -206,20 +523,7 @@ var SchedulerLogRepository = class extends BaseReldbCrudRepository {
|
|
|
206
523
|
}
|
|
207
524
|
return ok({
|
|
208
525
|
...pageResult.data,
|
|
209
|
-
items: pageResult.data.items.map((row) => ({
|
|
210
|
-
id: row.id,
|
|
211
|
-
taskId: row.taskId,
|
|
212
|
-
taskName: row.taskName,
|
|
213
|
-
taskType: row.taskType,
|
|
214
|
-
triggerType: row.triggerType,
|
|
215
|
-
triggerSource: row.triggerSource ?? null,
|
|
216
|
-
status: row.status,
|
|
217
|
-
result: row.result ?? null,
|
|
218
|
-
error: row.error ?? null,
|
|
219
|
-
startedAt: Number(row.startedAt),
|
|
220
|
-
finishedAt: Number(row.finishedAt),
|
|
221
|
-
duration: row.duration
|
|
222
|
-
}))
|
|
526
|
+
items: pageResult.data.items.map((row) => ({ ...row.payload, persistence: { status: "persisted" } }))
|
|
223
527
|
});
|
|
224
528
|
} catch (error) {
|
|
225
529
|
logger.error("Failed to query execution logs", { error });
|
|
@@ -289,7 +593,7 @@ var logger2 = core.logger.child({ module: "scheduler", scope: "task-repository"
|
|
|
289
593
|
var SCHEDULER_TASK_TABLE = "hai_scheduler_tasks";
|
|
290
594
|
var TaskParamsSchema = z.record(z.string(), z.unknown());
|
|
291
595
|
var TaskRetryPolicySchema = z.object({
|
|
292
|
-
maxAttempts: z.number().int().min(1),
|
|
596
|
+
maxAttempts: z.number().int().min(1).max(100),
|
|
293
597
|
backoffMs: z.array(z.number().int().min(0)).optional()
|
|
294
598
|
});
|
|
295
599
|
var ApiTaskConfigSchema = z.object({
|
|
@@ -298,7 +602,8 @@ var ApiTaskConfigSchema = z.object({
|
|
|
298
602
|
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(),
|
|
299
603
|
headers: z.record(z.string(), z.string()).optional(),
|
|
300
604
|
body: z.unknown().optional(),
|
|
301
|
-
timeout: z.number().int().positive().optional()
|
|
605
|
+
timeout: z.number().int().positive().optional(),
|
|
606
|
+
maxResponseBytes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional()
|
|
302
607
|
});
|
|
303
608
|
var JsTaskConfigSchema = z.object({
|
|
304
609
|
kind: z.literal("js"),
|
|
@@ -319,6 +624,7 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
|
|
|
319
624
|
{ fieldName: "cron", columnName: "cron", def: { type: "TEXT", notNull: true }, select: true, create: true, update: true },
|
|
320
625
|
{ fieldName: "timezone", columnName: "timezone", def: { type: "TEXT" }, select: true, create: true, update: true },
|
|
321
626
|
{ fieldName: "enabled", columnName: "enabled", def: { type: "BOOLEAN", notNull: true, defaultValue: true }, select: true, create: true, update: true },
|
|
627
|
+
{ fieldName: "idempotent", columnName: "idempotent", def: { type: "BOOLEAN", notNull: true, defaultValue: false }, select: true, create: true, update: true },
|
|
322
628
|
{ fieldName: "deleteAfterRun", columnName: "delete_after_run", def: { type: "BOOLEAN", notNull: true, defaultValue: false }, select: true, create: true, update: true },
|
|
323
629
|
{ fieldName: "retry", columnName: "retry", def: { type: "JSON" }, select: true, create: true, update: true },
|
|
324
630
|
{ fieldName: "params", columnName: "params", def: { type: "JSON" }, select: true, create: true, update: true },
|
|
@@ -337,6 +643,7 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
|
|
|
337
643
|
cron: task.cron,
|
|
338
644
|
timezone: task.timezone ?? null,
|
|
339
645
|
enabled: task.enabled !== false,
|
|
646
|
+
idempotent: task.idempotent === true,
|
|
340
647
|
deleteAfterRun: task.deleteAfterRun === true,
|
|
341
648
|
retry: task.retry ?? null,
|
|
342
649
|
params: task.params ?? {},
|
|
@@ -386,6 +693,8 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
|
|
|
386
693
|
data.timezone = updates.timezone ?? null;
|
|
387
694
|
if (updates.enabled !== void 0)
|
|
388
695
|
data.enabled = updates.enabled;
|
|
696
|
+
if (updates.idempotent !== void 0)
|
|
697
|
+
data.idempotent = updates.idempotent;
|
|
389
698
|
if (updates.deleteAfterRun !== void 0)
|
|
390
699
|
data.deleteAfterRun = updates.deleteAfterRun;
|
|
391
700
|
if (updates.retry !== void 0)
|
|
@@ -422,11 +731,11 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
|
|
|
422
731
|
async deleteTask(taskId, expected) {
|
|
423
732
|
try {
|
|
424
733
|
if (expected) {
|
|
425
|
-
const conditions = ["task_id = ?", "task_name = ?", "cron = ?", "COALESCE(timezone, '') = ?", "COALESCE(description, '') = ?", "enabled = ?", "delete_after_run = ?"];
|
|
734
|
+
const conditions = ["task_id = ?", "task_name = ?", "cron = ?", "COALESCE(timezone, '') = ?", "COALESCE(description, '') = ?", "enabled = ?", "delete_after_run = ?", "idempotent = ?"];
|
|
426
735
|
const postgres = this.db.config?.type === "postgresql";
|
|
427
736
|
const enabled = expected.enabled !== false;
|
|
428
737
|
const deleteAfterRun = expected.deleteAfterRun === true;
|
|
429
|
-
const params = [taskId, expected.name, expected.cron, expected.timezone ?? "", expected.description ?? "", postgres ? enabled : Number(enabled), postgres ? deleteAfterRun : Number(deleteAfterRun)];
|
|
738
|
+
const params = [taskId, expected.name, expected.cron, expected.timezone ?? "", expected.description ?? "", postgres ? enabled : Number(enabled), postgres ? deleteAfterRun : Number(deleteAfterRun), postgres ? expected.idempotent === true : Number(expected.idempotent === true)];
|
|
430
739
|
const jsonValue = this.db.config?.type === "mysql" ? "CAST(? AS JSON)" : this.db.config?.type === "postgresql" ? "CAST(? AS JSONB)" : "?";
|
|
431
740
|
const jsonFields = [["retry", expected.retry], ["params", expected.params ?? {}], ["handler", expected.handler]];
|
|
432
741
|
for (const [column, value] of jsonFields) {
|
|
@@ -503,6 +812,7 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
|
|
|
503
812
|
cron: row.cron,
|
|
504
813
|
...row.timezone ? { timezone: row.timezone } : {},
|
|
505
814
|
enabled: row.enabled,
|
|
815
|
+
idempotent: row.idempotent,
|
|
506
816
|
deleteAfterRun: row.deleteAfterRun,
|
|
507
817
|
...retryResult.data ? { retry: retryResult.data } : {},
|
|
508
818
|
params: paramsResult.data,
|
|
@@ -522,429 +832,80 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
|
|
|
522
832
|
}
|
|
523
833
|
}
|
|
524
834
|
};
|
|
525
|
-
|
|
526
|
-
// src/scheduler-cancellation.ts
|
|
527
|
-
async function waitWithSignal(operation, signal) {
|
|
528
|
-
signal.throwIfAborted();
|
|
529
|
-
return new Promise((resolve, reject) => {
|
|
530
|
-
const aborted = () => reject(signal.reason);
|
|
531
|
-
signal.addEventListener("abort", aborted, { once: true });
|
|
532
|
-
Promise.resolve().then(operation).then(resolve, reject).finally(() => {
|
|
533
|
-
signal.removeEventListener("abort", aborted);
|
|
534
|
-
});
|
|
535
|
-
});
|
|
536
|
-
}
|
|
537
|
-
var compiledHandlerCache = /* @__PURE__ */ new Map();
|
|
538
|
-
function createCacheKey(config) {
|
|
539
|
-
const keySource = `${config.code}:${config.timeout ?? 0}`;
|
|
540
|
-
return createHash("sha256").update(keySource).digest("hex");
|
|
541
|
-
}
|
|
542
|
-
function clearJsTaskHandlerCache() {
|
|
543
|
-
compiledHandlerCache.clear();
|
|
544
|
-
}
|
|
545
|
-
function compileJsTaskHandler(config) {
|
|
546
|
-
const cacheKey = createCacheKey(config);
|
|
547
|
-
const cachedHandler = compiledHandlerCache.get(cacheKey);
|
|
548
|
-
if (cachedHandler)
|
|
549
|
-
return ok(cachedHandler);
|
|
835
|
+
function parseCronExpression(expression, timezone) {
|
|
550
836
|
try {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
return await new Promise((resolve, reject) => {
|
|
555
|
-
const timeout = config.timeout ?? 3e4;
|
|
556
|
-
const worker = new Worker(new URL("./scheduler-js-worker.js", import.meta.url), {
|
|
557
|
-
workerData: { code: config.code, context },
|
|
558
|
-
execArgv: []
|
|
559
|
-
});
|
|
560
|
-
let settled = false;
|
|
561
|
-
let timer;
|
|
562
|
-
const aborted = () => finish(new Error(schedulerM("scheduler_closing")));
|
|
563
|
-
function finish(error, result) {
|
|
564
|
-
if (settled)
|
|
565
|
-
return;
|
|
566
|
-
settled = true;
|
|
567
|
-
clearTimeout(timer);
|
|
568
|
-
signal?.removeEventListener("abort", aborted);
|
|
569
|
-
void worker.terminate().then(() => error ? reject(error) : resolve(result), reject);
|
|
570
|
-
}
|
|
571
|
-
timer = setTimeout(() => finish(new Error(schedulerM("scheduler_jsTimedOut", { params: { timeout } }))), timeout);
|
|
572
|
-
signal?.addEventListener("abort", aborted, { once: true });
|
|
573
|
-
worker.once("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
|
|
574
|
-
worker.once("exit", (code) => finish(new Error(`JS worker exited before returning a result (${code})`)));
|
|
575
|
-
worker.once("message", (message) => {
|
|
576
|
-
if (message.success)
|
|
577
|
-
finish(void 0, message.data);
|
|
578
|
-
else
|
|
579
|
-
finish(new Error(message.message));
|
|
580
|
-
});
|
|
581
|
-
});
|
|
582
|
-
};
|
|
583
|
-
compiledHandlerCache.set(cacheKey, handler);
|
|
584
|
-
return ok(handler);
|
|
585
|
-
} catch (error) {
|
|
837
|
+
const cron = new Cron(expression, { legacyMode: false, timezone: timezone ?? "UTC" });
|
|
838
|
+
return ok(cron);
|
|
839
|
+
} catch {
|
|
586
840
|
return err(
|
|
587
|
-
HaiSchedulerError.
|
|
588
|
-
schedulerM("
|
|
589
|
-
params: { error: error instanceof Error ? error.message : String(error) }
|
|
590
|
-
}),
|
|
591
|
-
error
|
|
841
|
+
HaiSchedulerError.INVALID_CRON,
|
|
842
|
+
schedulerM("scheduler_invalidCron", { params: { expression } })
|
|
592
843
|
);
|
|
593
844
|
}
|
|
594
845
|
}
|
|
595
846
|
|
|
596
|
-
// src/scheduler-
|
|
597
|
-
var logger3 = core.logger.child({ module: "scheduler", scope: "
|
|
598
|
-
var
|
|
599
|
-
var
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
847
|
+
// src/scheduler-functions.ts
|
|
848
|
+
var logger3 = core.logger.child({ module: "scheduler", scope: "functions" });
|
|
849
|
+
var taskRegistry = /* @__PURE__ */ new Map();
|
|
850
|
+
var cronCache = /* @__PURE__ */ new Map();
|
|
851
|
+
var persistedTaskIds = /* @__PURE__ */ new Set();
|
|
852
|
+
var definitionEpoch = 0;
|
|
853
|
+
var activeRefresh;
|
|
854
|
+
var currentHooks = {};
|
|
855
|
+
function getTaskRegistry() {
|
|
856
|
+
return taskRegistry;
|
|
603
857
|
}
|
|
604
|
-
function
|
|
605
|
-
|
|
606
|
-
const parsedUrl = new URL(url);
|
|
607
|
-
if (parsedUrl.password)
|
|
608
|
-
parsedUrl.password = "***";
|
|
609
|
-
if (parsedUrl.username)
|
|
610
|
-
parsedUrl.username = "***";
|
|
611
|
-
return parsedUrl.toString();
|
|
612
|
-
} catch {
|
|
613
|
-
return "(invalid url)";
|
|
614
|
-
}
|
|
858
|
+
function getTask(taskId) {
|
|
859
|
+
return taskRegistry.get(taskId);
|
|
615
860
|
}
|
|
616
|
-
function
|
|
617
|
-
|
|
618
|
-
return task.handler.kind;
|
|
619
|
-
if (hooks.onTaskExecute)
|
|
620
|
-
return "hook";
|
|
621
|
-
return "hook";
|
|
861
|
+
function hasTask(taskId) {
|
|
862
|
+
return taskRegistry.has(taskId);
|
|
622
863
|
}
|
|
623
|
-
function
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
task,
|
|
627
|
-
taskId: task.id,
|
|
628
|
-
params: task.params ?? {},
|
|
629
|
-
trigger
|
|
630
|
-
};
|
|
864
|
+
function setTask(taskId, task) {
|
|
865
|
+
definitionEpoch++;
|
|
866
|
+
taskRegistry.set(taskId, task);
|
|
631
867
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
try {
|
|
636
|
-
await waitWithSignal(() => hooks.onTaskStart(event), event.signal);
|
|
637
|
-
} catch (error) {
|
|
638
|
-
logger3.warn("Task start hook failed", { taskId: event.task.id, error });
|
|
639
|
-
}
|
|
868
|
+
function deleteTask(taskId) {
|
|
869
|
+
definitionEpoch++;
|
|
870
|
+
taskRegistry.delete(taskId);
|
|
640
871
|
}
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
return;
|
|
644
|
-
try {
|
|
645
|
-
await waitWithSignal(() => hooks.onTaskInterrupted(event), event.signal);
|
|
646
|
-
} catch (error) {
|
|
647
|
-
logger3.warn("Task interrupted hook failed", { taskId: event.task.id, error });
|
|
648
|
-
}
|
|
872
|
+
function getCron(taskId) {
|
|
873
|
+
return cronCache.get(taskId);
|
|
649
874
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
return;
|
|
653
|
-
try {
|
|
654
|
-
await waitWithSignal(() => hooks.onTaskFinish(event), event.signal);
|
|
655
|
-
} catch (error) {
|
|
656
|
-
logger3.warn("Task finish hook failed", { taskId: event.task.id, error });
|
|
657
|
-
}
|
|
875
|
+
function setCron(taskId, cron) {
|
|
876
|
+
cronCache.set(taskId, cron);
|
|
658
877
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
id: 0,
|
|
662
|
-
taskId: task.id,
|
|
663
|
-
taskName: task.name,
|
|
664
|
-
taskType,
|
|
665
|
-
triggerType: trigger.type,
|
|
666
|
-
triggerSource: trigger.source,
|
|
667
|
-
status,
|
|
668
|
-
result,
|
|
669
|
-
error,
|
|
670
|
-
startedAt,
|
|
671
|
-
finishedAt,
|
|
672
|
-
duration: finishedAt - startedAt
|
|
673
|
-
};
|
|
674
|
-
if (!currentLogRepo)
|
|
675
|
-
return log;
|
|
676
|
-
await currentLogRepo.saveLog(log);
|
|
677
|
-
const cleanupResult = await currentLogRepo.cleanupLogs(currentCleanupPolicy);
|
|
678
|
-
if (!cleanupResult.success) {
|
|
679
|
-
logger3.warn("Failed to cleanup execution logs", {
|
|
680
|
-
taskId: task.id,
|
|
681
|
-
error: cleanupResult.error.message,
|
|
682
|
-
maxLogs: currentCleanupPolicy.maxLogs,
|
|
683
|
-
retentionDays: currentCleanupPolicy.retentionDays
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
return log;
|
|
878
|
+
function deleteCron(taskId) {
|
|
879
|
+
cronCache.delete(taskId);
|
|
687
880
|
}
|
|
688
|
-
|
|
689
|
-
|
|
881
|
+
function setHooks(hooks) {
|
|
882
|
+
currentHooks = { ...hooks };
|
|
690
883
|
}
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
const taskType = resolveTaskType(task, hooks);
|
|
694
|
-
await notifyTaskStart(hooks, { task, trigger, startedAt, signal });
|
|
695
|
-
const finishedAt = Date.now();
|
|
696
|
-
const interruptedLog = await saveInterruptedTaskLog(task, trigger, reason, taskType, startedAt, finishedAt);
|
|
697
|
-
await notifyTaskInterrupted(hooks, {
|
|
698
|
-
task,
|
|
699
|
-
trigger,
|
|
700
|
-
startedAt,
|
|
701
|
-
interruptedAt: finishedAt,
|
|
702
|
-
signal,
|
|
703
|
-
reason
|
|
704
|
-
});
|
|
705
|
-
return interruptedLog;
|
|
884
|
+
function clearHooks() {
|
|
885
|
+
currentHooks = {};
|
|
706
886
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
887
|
+
function getHooks() {
|
|
888
|
+
return currentHooks;
|
|
889
|
+
}
|
|
890
|
+
function resetTaskState() {
|
|
891
|
+
definitionEpoch++;
|
|
892
|
+
activeRefresh = void 0;
|
|
893
|
+
persistedTaskIds.clear();
|
|
894
|
+
taskRegistry.clear();
|
|
895
|
+
cronCache.clear();
|
|
896
|
+
currentHooks = {};
|
|
897
|
+
}
|
|
898
|
+
function normalizeTaskDescription(description) {
|
|
899
|
+
if (description === void 0)
|
|
900
|
+
return void 0;
|
|
901
|
+
const trimmed = description.trim();
|
|
902
|
+
return trimmed.length > 0 ? trimmed : void 0;
|
|
903
|
+
}
|
|
904
|
+
function validateRetryPolicy(taskId, retry) {
|
|
905
|
+
if (!Number.isInteger(retry.maxAttempts) || retry.maxAttempts < 1 || retry.maxAttempts > 100) {
|
|
710
906
|
return err(
|
|
711
907
|
HaiSchedulerError.EXECUTION_FAILED,
|
|
712
|
-
schedulerM("
|
|
713
|
-
);
|
|
714
|
-
}
|
|
715
|
-
const compileResult = compileJsTaskHandler(handler);
|
|
716
|
-
if (!compileResult.success)
|
|
717
|
-
return compileResult;
|
|
718
|
-
try {
|
|
719
|
-
const result = await compileResult.data(context, signal);
|
|
720
|
-
return ok(result !== void 0 ? JSON.stringify(result) : null);
|
|
721
|
-
} catch (error) {
|
|
722
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
723
|
-
logger3.error("JS task execution failed", { taskId: task.id, error: message });
|
|
724
|
-
return err(
|
|
725
|
-
HaiSchedulerError.JS_EXECUTION_FAILED,
|
|
726
|
-
schedulerM("scheduler_jsExecutionFailed", { params: { error: message } }),
|
|
727
|
-
error
|
|
728
|
-
);
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
async function executeApiTask(task, context, signal = new AbortController().signal) {
|
|
732
|
-
const handler = task.handler;
|
|
733
|
-
if (!handler || handler.kind !== "api") {
|
|
734
|
-
return err(
|
|
735
|
-
HaiSchedulerError.EXECUTION_FAILED,
|
|
736
|
-
schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } })
|
|
737
|
-
);
|
|
738
|
-
}
|
|
739
|
-
const { url, method = "GET", headers, body, timeout = 3e4, maxResponseBytes = 1024 * 1024 } = handler;
|
|
740
|
-
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0)
|
|
741
|
-
return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } }));
|
|
742
|
-
const controller = new AbortController();
|
|
743
|
-
const timer = setTimeout(() => controller.abort(), timeout);
|
|
744
|
-
try {
|
|
745
|
-
const response = await fetch(url, {
|
|
746
|
-
method,
|
|
747
|
-
headers: {
|
|
748
|
-
...headers,
|
|
749
|
-
...body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
750
|
-
},
|
|
751
|
-
signal: AbortSignal.any([signal, controller.signal]),
|
|
752
|
-
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
753
|
-
});
|
|
754
|
-
let receivedBytes = 0;
|
|
755
|
-
let responseText = "";
|
|
756
|
-
const reader = response.body?.getReader();
|
|
757
|
-
if (reader) {
|
|
758
|
-
const decoder = new TextDecoder();
|
|
759
|
-
try {
|
|
760
|
-
while (true) {
|
|
761
|
-
const chunk = await reader.read();
|
|
762
|
-
if (chunk.done)
|
|
763
|
-
break;
|
|
764
|
-
receivedBytes += chunk.value.byteLength;
|
|
765
|
-
if (receivedBytes > maxResponseBytes) {
|
|
766
|
-
controller.abort();
|
|
767
|
-
return err(HaiSchedulerError.API_EXECUTION_FAILED, schedulerM("scheduler_apiResponseTooLarge", { params: { maxResponseBytes } }));
|
|
768
|
-
}
|
|
769
|
-
responseText += decoder.decode(chunk.value, { stream: true });
|
|
770
|
-
}
|
|
771
|
-
responseText += decoder.decode();
|
|
772
|
-
} finally {
|
|
773
|
-
reader.releaseLock();
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
if (!response.ok) {
|
|
777
|
-
logger3.error("API task returned non-OK status", {
|
|
778
|
-
taskId: context.taskId,
|
|
779
|
-
status: response.status,
|
|
780
|
-
url: sanitizeUrl(url)
|
|
781
|
-
});
|
|
782
|
-
return err(
|
|
783
|
-
HaiSchedulerError.API_EXECUTION_FAILED,
|
|
784
|
-
schedulerM("scheduler_apiExecutionFailed", {
|
|
785
|
-
params: { error: `HTTP ${response.status}: ${responseText.slice(0, 200)}` }
|
|
786
|
-
})
|
|
787
|
-
);
|
|
788
|
-
}
|
|
789
|
-
return ok(responseText || null);
|
|
790
|
-
} catch (error) {
|
|
791
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
792
|
-
logger3.error("API task execution failed", { taskId: context.taskId, error: message, url: sanitizeUrl(url) });
|
|
793
|
-
return err(
|
|
794
|
-
HaiSchedulerError.API_EXECUTION_FAILED,
|
|
795
|
-
schedulerM("scheduler_apiExecutionFailed", { params: { error: message } }),
|
|
796
|
-
error
|
|
797
|
-
);
|
|
798
|
-
} finally {
|
|
799
|
-
clearTimeout(timer);
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
async function executeHookTask(task, context, hooks, startedAt, signal) {
|
|
803
|
-
if (!hooks.onTaskExecute) {
|
|
804
|
-
return err(
|
|
805
|
-
HaiSchedulerError.EXECUTION_FAILED,
|
|
806
|
-
schedulerM("scheduler_taskHandlerMissing", { params: { taskId: task.id } })
|
|
807
|
-
);
|
|
808
|
-
}
|
|
809
|
-
const executeEvent = {
|
|
810
|
-
task,
|
|
811
|
-
trigger: context.trigger,
|
|
812
|
-
startedAt,
|
|
813
|
-
context,
|
|
814
|
-
signal
|
|
815
|
-
};
|
|
816
|
-
try {
|
|
817
|
-
const result = await waitWithSignal(() => hooks.onTaskExecute(executeEvent), signal);
|
|
818
|
-
return ok(result !== void 0 ? JSON.stringify(result) : null);
|
|
819
|
-
} catch (error) {
|
|
820
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
821
|
-
logger3.error("Task execute hook failed", { taskId: task.id, error: message });
|
|
822
|
-
return err(
|
|
823
|
-
HaiSchedulerError.HOOK_EXECUTION_FAILED,
|
|
824
|
-
schedulerM("scheduler_hookExecutionFailed", { params: { error: message } }),
|
|
825
|
-
error
|
|
826
|
-
);
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
async function executeTask(task, trigger, hooks, signal = new AbortController().signal, occurrenceId) {
|
|
830
|
-
const startedAt = Date.now();
|
|
831
|
-
const taskType = resolveTaskType(task, hooks);
|
|
832
|
-
const context = createTaskContext(task, trigger, occurrenceId);
|
|
833
|
-
await notifyTaskStart(hooks, { task, trigger, startedAt, signal });
|
|
834
|
-
let executionResult;
|
|
835
|
-
let shouldNotifyInterrupted = false;
|
|
836
|
-
if (signal.aborted) {
|
|
837
|
-
executionResult = err(HaiSchedulerError.EXECUTION_FAILED, schedulerM("scheduler_closing"));
|
|
838
|
-
shouldNotifyInterrupted = true;
|
|
839
|
-
} else if (!task.handler) {
|
|
840
|
-
executionResult = await executeHookTask(task, context, hooks, startedAt, signal);
|
|
841
|
-
shouldNotifyInterrupted = !hooks.onTaskExecute || !executionResult.success && executionResult.error.cause instanceof Error && executionResult.error.cause.name === "SchedulerTaskSkippedError";
|
|
842
|
-
} else if (task.handler.kind === "api") {
|
|
843
|
-
executionResult = await executeApiTask(task, context, signal);
|
|
844
|
-
} else {
|
|
845
|
-
executionResult = await executeJsTask(task, context, signal);
|
|
846
|
-
}
|
|
847
|
-
shouldNotifyInterrupted ||= signal.aborted;
|
|
848
|
-
const finishedAt = Date.now();
|
|
849
|
-
const status = executionResult.success ? "success" : shouldNotifyInterrupted ? "interrupted" : "failed";
|
|
850
|
-
const log = await saveExecutionLog(
|
|
851
|
-
task,
|
|
852
|
-
trigger,
|
|
853
|
-
taskType,
|
|
854
|
-
status,
|
|
855
|
-
startedAt,
|
|
856
|
-
finishedAt,
|
|
857
|
-
executionResult.success ? executionResult.data : null,
|
|
858
|
-
executionResult.success ? null : executionResult.error.message
|
|
859
|
-
);
|
|
860
|
-
if (!executionResult.success && shouldNotifyInterrupted) {
|
|
861
|
-
await notifyTaskInterrupted(hooks, {
|
|
862
|
-
task,
|
|
863
|
-
trigger,
|
|
864
|
-
startedAt,
|
|
865
|
-
interruptedAt: finishedAt,
|
|
866
|
-
signal,
|
|
867
|
-
reason: executionResult.error.message
|
|
868
|
-
});
|
|
869
|
-
}
|
|
870
|
-
if (status !== "interrupted")
|
|
871
|
-
await notifyTaskFinish(hooks, { task, trigger, startedAt, finishedAt, log, signal });
|
|
872
|
-
return log;
|
|
873
|
-
}
|
|
874
|
-
function parseCronExpression(expression, timezone) {
|
|
875
|
-
try {
|
|
876
|
-
const cron = new Cron(expression, { legacyMode: false, ...timezone ? { timezone } : {} });
|
|
877
|
-
return ok(cron);
|
|
878
|
-
} catch {
|
|
879
|
-
return err(
|
|
880
|
-
HaiSchedulerError.INVALID_CRON,
|
|
881
|
-
schedulerM("scheduler_invalidCron", { params: { expression } })
|
|
882
|
-
);
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// src/scheduler-functions.ts
|
|
887
|
-
var logger4 = core.logger.child({ module: "scheduler", scope: "functions" });
|
|
888
|
-
var taskRegistry = /* @__PURE__ */ new Map();
|
|
889
|
-
var cronCache = /* @__PURE__ */ new Map();
|
|
890
|
-
var persistedTaskIds = /* @__PURE__ */ new Set();
|
|
891
|
-
var definitionEpoch = 0;
|
|
892
|
-
var activeRefresh;
|
|
893
|
-
var currentHooks = {};
|
|
894
|
-
function getTaskRegistry() {
|
|
895
|
-
return taskRegistry;
|
|
896
|
-
}
|
|
897
|
-
function getTask(taskId) {
|
|
898
|
-
return taskRegistry.get(taskId);
|
|
899
|
-
}
|
|
900
|
-
function hasTask(taskId) {
|
|
901
|
-
return taskRegistry.has(taskId);
|
|
902
|
-
}
|
|
903
|
-
function setTask(taskId, task) {
|
|
904
|
-
definitionEpoch++;
|
|
905
|
-
taskRegistry.set(taskId, task);
|
|
906
|
-
}
|
|
907
|
-
function deleteTask(taskId) {
|
|
908
|
-
definitionEpoch++;
|
|
909
|
-
taskRegistry.delete(taskId);
|
|
910
|
-
}
|
|
911
|
-
function getCron(taskId) {
|
|
912
|
-
return cronCache.get(taskId);
|
|
913
|
-
}
|
|
914
|
-
function setCron(taskId, cron) {
|
|
915
|
-
cronCache.set(taskId, cron);
|
|
916
|
-
}
|
|
917
|
-
function deleteCron(taskId) {
|
|
918
|
-
cronCache.delete(taskId);
|
|
919
|
-
}
|
|
920
|
-
function setHooks(hooks) {
|
|
921
|
-
currentHooks = { ...hooks };
|
|
922
|
-
}
|
|
923
|
-
function clearHooks() {
|
|
924
|
-
currentHooks = {};
|
|
925
|
-
}
|
|
926
|
-
function getHooks() {
|
|
927
|
-
return currentHooks;
|
|
928
|
-
}
|
|
929
|
-
function resetTaskState() {
|
|
930
|
-
definitionEpoch++;
|
|
931
|
-
activeRefresh = void 0;
|
|
932
|
-
persistedTaskIds.clear();
|
|
933
|
-
taskRegistry.clear();
|
|
934
|
-
cronCache.clear();
|
|
935
|
-
currentHooks = {};
|
|
936
|
-
}
|
|
937
|
-
function normalizeTaskDescription(description) {
|
|
938
|
-
if (description === void 0)
|
|
939
|
-
return void 0;
|
|
940
|
-
const trimmed = description.trim();
|
|
941
|
-
return trimmed.length > 0 ? trimmed : void 0;
|
|
942
|
-
}
|
|
943
|
-
function validateRetryPolicy(taskId, retry) {
|
|
944
|
-
if (!Number.isInteger(retry.maxAttempts) || retry.maxAttempts < 1) {
|
|
945
|
-
return err(
|
|
946
|
-
HaiSchedulerError.EXECUTION_FAILED,
|
|
947
|
-
schedulerM("scheduler_invalidRetryConfig", { params: { taskId } })
|
|
908
|
+
schedulerM("scheduler_invalidRetryConfig", { params: { taskId } })
|
|
948
909
|
);
|
|
949
910
|
}
|
|
950
911
|
if (retry.backoffMs !== void 0) {
|
|
@@ -1016,6 +977,9 @@ async function loadConfigTasks(tasks) {
|
|
|
1016
977
|
return ok(void 0);
|
|
1017
978
|
}
|
|
1018
979
|
async function registerTask(task, taskRepo2) {
|
|
980
|
+
if (task.handler && !TaskHandlerConfigSchema.safeParse(task.handler).success || task.idempotent !== void 0 && typeof task.idempotent !== "boolean") {
|
|
981
|
+
return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } }));
|
|
982
|
+
}
|
|
1019
983
|
if (!task.id) {
|
|
1020
984
|
return err(
|
|
1021
985
|
HaiSchedulerError.EXECUTION_FAILED,
|
|
@@ -1039,6 +1003,7 @@ async function registerTask(task, taskRepo2) {
|
|
|
1039
1003
|
description: normalizeTaskDescription(task.description),
|
|
1040
1004
|
timezone: task.timezone,
|
|
1041
1005
|
enabled: task.enabled !== false,
|
|
1006
|
+
idempotent: task.idempotent === true,
|
|
1042
1007
|
deleteAfterRun: task.deleteAfterRun === true,
|
|
1043
1008
|
retry: retryResult.data,
|
|
1044
1009
|
params: task.params ?? {}
|
|
@@ -1046,7 +1011,7 @@ async function registerTask(task, taskRepo2) {
|
|
|
1046
1011
|
if (taskRepo2) {
|
|
1047
1012
|
const saveResult = await taskRepo2.saveTask(normalizedTask);
|
|
1048
1013
|
if (!saveResult.success) {
|
|
1049
|
-
|
|
1014
|
+
logger3.warn("Failed to persist task definition", { taskId: task.id, error: saveResult.error.message });
|
|
1050
1015
|
return err(
|
|
1051
1016
|
HaiSchedulerError.DB_SAVE_FAILED,
|
|
1052
1017
|
schedulerM("scheduler_dbSaveFailed", { params: { error: saveResult.error.message } }),
|
|
@@ -1062,131 +1027,525 @@ async function registerTask(task, taskRepo2) {
|
|
|
1062
1027
|
async function unregisterTask(taskId, taskRepo2, expected) {
|
|
1063
1028
|
if (!hasTask(taskId)) {
|
|
1064
1029
|
return err(
|
|
1065
|
-
HaiSchedulerError.TASK_NOT_FOUND,
|
|
1066
|
-
schedulerM("scheduler_taskNotFound", { params: { taskId } })
|
|
1030
|
+
HaiSchedulerError.TASK_NOT_FOUND,
|
|
1031
|
+
schedulerM("scheduler_taskNotFound", { params: { taskId } })
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
if (taskRepo2) {
|
|
1035
|
+
const deleteResult = await taskRepo2.deleteTask(taskId, expected);
|
|
1036
|
+
if (!deleteResult.success) {
|
|
1037
|
+
logger3.warn("Failed to delete persisted task definition", { taskId, error: deleteResult.error.message });
|
|
1038
|
+
return err(
|
|
1039
|
+
HaiSchedulerError.DB_SAVE_FAILED,
|
|
1040
|
+
schedulerM("scheduler_dbSaveFailed", { params: { error: deleteResult.error.message } }),
|
|
1041
|
+
deleteResult.error
|
|
1042
|
+
);
|
|
1043
|
+
}
|
|
1044
|
+
if (expected && !deleteResult.data)
|
|
1045
|
+
return ok(void 0);
|
|
1046
|
+
}
|
|
1047
|
+
deleteTask(taskId);
|
|
1048
|
+
deleteCron(taskId);
|
|
1049
|
+
persistedTaskIds.delete(taskId);
|
|
1050
|
+
return ok(void 0);
|
|
1051
|
+
}
|
|
1052
|
+
async function updateRegisteredTask(taskId, updates, taskRepo2) {
|
|
1053
|
+
const existingTask = getTask(taskId);
|
|
1054
|
+
if (!existingTask) {
|
|
1055
|
+
return err(
|
|
1056
|
+
HaiSchedulerError.TASK_NOT_FOUND,
|
|
1057
|
+
schedulerM("scheduler_taskNotFound", { params: { taskId } })
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
let updatedCron = getCron(taskId);
|
|
1061
|
+
if (updates.cron !== void 0 || updates.timezone !== void 0) {
|
|
1062
|
+
const cronExpression = updates.cron ?? existingTask.cron;
|
|
1063
|
+
const timezone = updates.timezone === null ? void 0 : updates.timezone ?? existingTask.timezone;
|
|
1064
|
+
const cronResult = parseCronExpression(cronExpression, timezone);
|
|
1065
|
+
if (!cronResult.success)
|
|
1066
|
+
return cronResult;
|
|
1067
|
+
updatedCron = cronResult.data;
|
|
1068
|
+
}
|
|
1069
|
+
let normalizedRetry;
|
|
1070
|
+
if (updates.retry !== void 0) {
|
|
1071
|
+
if (updates.retry === null) {
|
|
1072
|
+
normalizedRetry = null;
|
|
1073
|
+
} else {
|
|
1074
|
+
const retryResult = validateRetryPolicy(taskId, updates.retry);
|
|
1075
|
+
if (!retryResult.success)
|
|
1076
|
+
return retryResult;
|
|
1077
|
+
normalizedRetry = retryResult.data;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
const normalizedDescription = updates.description !== void 0 ? normalizeTaskDescription(updates.description) ?? "" : void 0;
|
|
1081
|
+
const normalizedUpdates = {
|
|
1082
|
+
...updates,
|
|
1083
|
+
...updates.description !== void 0 ? { description: normalizedDescription } : {},
|
|
1084
|
+
...updates.retry !== void 0 ? { retry: normalizedRetry } : {}
|
|
1085
|
+
};
|
|
1086
|
+
if (updates.handler && !TaskHandlerConfigSchema.safeParse(updates.handler).success || updates.idempotent !== void 0 && typeof updates.idempotent !== "boolean") {
|
|
1087
|
+
return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidHandlerConfig", { params: { taskId } }));
|
|
1088
|
+
}
|
|
1089
|
+
const updatedTask = {
|
|
1090
|
+
...existingTask,
|
|
1091
|
+
...normalizedUpdates.name !== void 0 ? { name: normalizedUpdates.name } : {},
|
|
1092
|
+
...normalizedUpdates.description !== void 0 ? { description: normalizedUpdates.description || void 0 } : {},
|
|
1093
|
+
...normalizedUpdates.cron !== void 0 ? { cron: normalizedUpdates.cron } : {},
|
|
1094
|
+
...normalizedUpdates.timezone !== void 0 ? { timezone: normalizedUpdates.timezone ?? void 0 } : {},
|
|
1095
|
+
...normalizedUpdates.enabled !== void 0 ? { enabled: normalizedUpdates.enabled } : {},
|
|
1096
|
+
...normalizedUpdates.idempotent !== void 0 ? { idempotent: normalizedUpdates.idempotent } : {},
|
|
1097
|
+
...normalizedUpdates.deleteAfterRun !== void 0 ? { deleteAfterRun: normalizedUpdates.deleteAfterRun } : {},
|
|
1098
|
+
...normalizedUpdates.retry !== void 0 ? { retry: normalizedUpdates.retry ?? void 0 } : {},
|
|
1099
|
+
...normalizedUpdates.params !== void 0 ? { params: normalizedUpdates.params } : {},
|
|
1100
|
+
...normalizedUpdates.handler !== void 0 ? { handler: normalizedUpdates.handler ?? void 0 } : {}
|
|
1101
|
+
};
|
|
1102
|
+
if (taskRepo2) {
|
|
1103
|
+
const updateResult = await taskRepo2.updateTask(taskId, normalizedUpdates);
|
|
1104
|
+
if (!updateResult.success) {
|
|
1105
|
+
logger3.warn("Failed to update persisted task definition", { taskId, error: updateResult.error.message });
|
|
1106
|
+
return err(
|
|
1107
|
+
HaiSchedulerError.DB_SAVE_FAILED,
|
|
1108
|
+
schedulerM("scheduler_dbSaveFailed", { params: { error: updateResult.error.message } }),
|
|
1109
|
+
updateResult.error
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
setTask(taskId, updatedTask);
|
|
1114
|
+
if (updatedCron)
|
|
1115
|
+
setCron(taskId, updatedCron);
|
|
1116
|
+
return ok(void 0);
|
|
1117
|
+
}
|
|
1118
|
+
async function queryTaskLogs(logRepo2, options) {
|
|
1119
|
+
if (!logRepo2) {
|
|
1120
|
+
return err(
|
|
1121
|
+
HaiSchedulerError.DB_SAVE_FAILED,
|
|
1122
|
+
schedulerM("scheduler_dbNotInitialized")
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
return logRepo2.queryLogs(options);
|
|
1126
|
+
}
|
|
1127
|
+
var compiledHandlerCache = /* @__PURE__ */ new Map();
|
|
1128
|
+
function createCacheKey(config) {
|
|
1129
|
+
const keySource = `${config.code}:${config.timeout ?? 0}`;
|
|
1130
|
+
return createHash("sha256").update(keySource).digest("hex");
|
|
1131
|
+
}
|
|
1132
|
+
function clearJsTaskHandlerCache() {
|
|
1133
|
+
compiledHandlerCache.clear();
|
|
1134
|
+
}
|
|
1135
|
+
function compileJsTaskHandler(config) {
|
|
1136
|
+
const cacheKey = createCacheKey(config);
|
|
1137
|
+
const cachedHandler = compiledHandlerCache.get(cacheKey);
|
|
1138
|
+
if (cachedHandler)
|
|
1139
|
+
return ok(cachedHandler);
|
|
1140
|
+
try {
|
|
1141
|
+
void new Script(`(${config.code})`);
|
|
1142
|
+
const handler = async (context, signal) => {
|
|
1143
|
+
signal?.throwIfAborted();
|
|
1144
|
+
return await new Promise((resolve, reject) => {
|
|
1145
|
+
const timeout = config.timeout ?? 3e4;
|
|
1146
|
+
const worker = new Worker(new URL("./scheduler-js-worker.js", import.meta.url), {
|
|
1147
|
+
workerData: { code: config.code, context },
|
|
1148
|
+
execArgv: []
|
|
1149
|
+
});
|
|
1150
|
+
let settled = false;
|
|
1151
|
+
let timer;
|
|
1152
|
+
const aborted = () => finish(new Error(schedulerM("scheduler_closing")));
|
|
1153
|
+
function finish(error, result) {
|
|
1154
|
+
if (settled)
|
|
1155
|
+
return;
|
|
1156
|
+
settled = true;
|
|
1157
|
+
clearTimeout(timer);
|
|
1158
|
+
signal?.removeEventListener("abort", aborted);
|
|
1159
|
+
void worker.terminate().then(() => error ? reject(error) : resolve(result), reject);
|
|
1160
|
+
}
|
|
1161
|
+
timer = setTimeout(() => finish(new Error(schedulerM("scheduler_jsTimedOut", { params: { timeout } }))), timeout);
|
|
1162
|
+
signal?.addEventListener("abort", aborted, { once: true });
|
|
1163
|
+
worker.once("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
|
|
1164
|
+
worker.once("exit", (code) => finish(new Error(`JS worker exited before returning a result (${code})`)));
|
|
1165
|
+
worker.once("message", (message) => {
|
|
1166
|
+
if (message.success)
|
|
1167
|
+
finish(void 0, message.data);
|
|
1168
|
+
else
|
|
1169
|
+
finish(new Error(message.message));
|
|
1170
|
+
});
|
|
1171
|
+
});
|
|
1172
|
+
};
|
|
1173
|
+
compiledHandlerCache.set(cacheKey, handler);
|
|
1174
|
+
return ok(handler);
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
return err(
|
|
1177
|
+
HaiSchedulerError.JS_COMPILE_FAILED,
|
|
1178
|
+
schedulerM("scheduler_jsCompileFailed", {
|
|
1179
|
+
params: { error: error instanceof Error ? error.message : String(error) }
|
|
1180
|
+
}),
|
|
1181
|
+
error
|
|
1182
|
+
);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/scheduler-cancellation.ts
|
|
1187
|
+
async function waitWithSignal(operation, signal) {
|
|
1188
|
+
signal.throwIfAborted();
|
|
1189
|
+
return new Promise((resolve, reject) => {
|
|
1190
|
+
const aborted = () => reject(signal.reason);
|
|
1191
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
1192
|
+
Promise.resolve().then(operation).then(resolve, reject).finally(() => {
|
|
1193
|
+
signal.removeEventListener("abort", aborted);
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// src/scheduler-executor.ts
|
|
1199
|
+
var logger4 = core.logger.child({ module: "scheduler", scope: "executor" });
|
|
1200
|
+
function resolveTaskType(task, hooks) {
|
|
1201
|
+
if (task.handler)
|
|
1202
|
+
return task.handler.kind;
|
|
1203
|
+
if (hooks.onTaskExecute)
|
|
1204
|
+
return "hook";
|
|
1205
|
+
return "hook";
|
|
1206
|
+
}
|
|
1207
|
+
function createTaskContext(task, trigger, execution) {
|
|
1208
|
+
return {
|
|
1209
|
+
...execution,
|
|
1210
|
+
task,
|
|
1211
|
+
taskId: task.id,
|
|
1212
|
+
params: task.params ?? {},
|
|
1213
|
+
trigger
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
async function notifyTaskStart(hooks, event) {
|
|
1217
|
+
if (!hooks.onTaskStart)
|
|
1218
|
+
return;
|
|
1219
|
+
try {
|
|
1220
|
+
await waitWithSignal(() => hooks.onTaskStart(event), event.signal);
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
logger4.warn("Task start hook failed", { taskId: event.task.id, error });
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
async function notifyTaskInterrupted(hooks, event) {
|
|
1226
|
+
if (!hooks.onTaskInterrupted)
|
|
1227
|
+
return;
|
|
1228
|
+
try {
|
|
1229
|
+
await waitWithSignal(() => hooks.onTaskInterrupted(event), event.signal);
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
logger4.warn("Task interrupted hook failed", { taskId: event.task.id, error });
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
async function notifyTaskFinish(hooks, event) {
|
|
1235
|
+
if (!hooks.onTaskFinish)
|
|
1236
|
+
return;
|
|
1237
|
+
try {
|
|
1238
|
+
await waitWithSignal(() => hooks.onTaskFinish(event), event.signal);
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
logger4.warn("Task finish hook failed", { taskId: event.task.id, error });
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
function createExecutionLog(task, trigger, taskType, status, startedAt, finishedAt, result, error, occurrenceId) {
|
|
1244
|
+
const log = {
|
|
1245
|
+
id: crypto.randomUUID(),
|
|
1246
|
+
persistence: { status: "unconfirmed" },
|
|
1247
|
+
taskId: task.id,
|
|
1248
|
+
taskName: task.name,
|
|
1249
|
+
taskType,
|
|
1250
|
+
triggerType: trigger.type,
|
|
1251
|
+
triggerSource: trigger.source,
|
|
1252
|
+
status,
|
|
1253
|
+
result,
|
|
1254
|
+
error,
|
|
1255
|
+
startedAt,
|
|
1256
|
+
finishedAt,
|
|
1257
|
+
duration: finishedAt - startedAt,
|
|
1258
|
+
...occurrenceId ? { occurrenceId } : {}
|
|
1259
|
+
};
|
|
1260
|
+
return log;
|
|
1261
|
+
}
|
|
1262
|
+
async function createInterruptedTaskLog(task, trigger, reason, taskType, startedAt, finishedAt) {
|
|
1263
|
+
return createExecutionLog(task, trigger, taskType, "interrupted", startedAt, finishedAt, null, reason);
|
|
1264
|
+
}
|
|
1265
|
+
async function interruptTask(task, trigger, reason, hooks, signal = new AbortController().signal) {
|
|
1266
|
+
const startedAt = Date.now();
|
|
1267
|
+
const taskType = resolveTaskType(task, hooks);
|
|
1268
|
+
await notifyTaskStart(hooks, { task, trigger, startedAt, signal });
|
|
1269
|
+
const finishedAt = Date.now();
|
|
1270
|
+
const interruptedLog = await createInterruptedTaskLog(task, trigger, reason, taskType, startedAt, finishedAt);
|
|
1271
|
+
await notifyTaskInterrupted(hooks, {
|
|
1272
|
+
task,
|
|
1273
|
+
trigger,
|
|
1274
|
+
startedAt,
|
|
1275
|
+
interruptedAt: finishedAt,
|
|
1276
|
+
signal,
|
|
1277
|
+
reason
|
|
1278
|
+
});
|
|
1279
|
+
return interruptedLog;
|
|
1280
|
+
}
|
|
1281
|
+
async function executeJsTask(task, context, signal = new AbortController().signal) {
|
|
1282
|
+
const handler = task.handler;
|
|
1283
|
+
if (!handler || handler.kind !== "js") {
|
|
1284
|
+
return err(
|
|
1285
|
+
HaiSchedulerError.EXECUTION_FAILED,
|
|
1286
|
+
schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } })
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
const compileResult = compileJsTaskHandler(handler);
|
|
1290
|
+
if (!compileResult.success)
|
|
1291
|
+
return compileResult;
|
|
1292
|
+
try {
|
|
1293
|
+
const result = await compileResult.data(context, signal);
|
|
1294
|
+
return ok(result !== void 0 ? JSON.stringify(result) : null);
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1297
|
+
logger4.error("JS task execution failed", { taskId: task.id, error: message });
|
|
1298
|
+
return err(
|
|
1299
|
+
HaiSchedulerError.JS_EXECUTION_FAILED,
|
|
1300
|
+
schedulerM("scheduler_jsExecutionFailed", { params: { error: message } }),
|
|
1301
|
+
error
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
async function executeApiTask(task, context, signal = new AbortController().signal) {
|
|
1306
|
+
const handler = task.handler;
|
|
1307
|
+
if (!handler || handler.kind !== "api") {
|
|
1308
|
+
return err(
|
|
1309
|
+
HaiSchedulerError.EXECUTION_FAILED,
|
|
1310
|
+
schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } })
|
|
1067
1311
|
);
|
|
1068
1312
|
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1313
|
+
const { url, method = "GET", headers, body, timeout = 3e4, maxResponseBytes = 1024 * 1024 } = handler;
|
|
1314
|
+
if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0)
|
|
1315
|
+
return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } }));
|
|
1316
|
+
const controller = new AbortController();
|
|
1317
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
1318
|
+
try {
|
|
1319
|
+
const requestHeaders = new Headers(headers);
|
|
1320
|
+
if (body !== void 0)
|
|
1321
|
+
requestHeaders.set("Content-Type", "application/json");
|
|
1322
|
+
if (context.occurrenceId) {
|
|
1323
|
+
requestHeaders.set("Idempotency-Key", context.occurrenceId);
|
|
1324
|
+
requestHeaders.set("X-Scheduler-Sequence", String(context.sequence));
|
|
1325
|
+
requestHeaders.set("X-Scheduler-Attempt", String(context.attempt));
|
|
1326
|
+
requestHeaders.set("X-Scheduler-Task", encodeURIComponent(context.taskId));
|
|
1327
|
+
}
|
|
1328
|
+
const response = await fetch(url, {
|
|
1329
|
+
method,
|
|
1330
|
+
headers: requestHeaders,
|
|
1331
|
+
signal: AbortSignal.any([signal, controller.signal]),
|
|
1332
|
+
...body !== void 0 ? { body: JSON.stringify(body) } : {}
|
|
1333
|
+
});
|
|
1334
|
+
let receivedBytes = 0;
|
|
1335
|
+
let responseText = "";
|
|
1336
|
+
const reader = response.body?.getReader();
|
|
1337
|
+
if (reader) {
|
|
1338
|
+
const decoder = new TextDecoder();
|
|
1339
|
+
try {
|
|
1340
|
+
while (true) {
|
|
1341
|
+
const chunk = await reader.read();
|
|
1342
|
+
if (chunk.done)
|
|
1343
|
+
break;
|
|
1344
|
+
receivedBytes += chunk.value.byteLength;
|
|
1345
|
+
if (receivedBytes > maxResponseBytes) {
|
|
1346
|
+
controller.abort();
|
|
1347
|
+
return err(HaiSchedulerError.API_EXECUTION_FAILED, schedulerM("scheduler_apiResponseTooLarge", { params: { maxResponseBytes } }));
|
|
1348
|
+
}
|
|
1349
|
+
responseText += decoder.decode(chunk.value, { stream: true });
|
|
1350
|
+
}
|
|
1351
|
+
responseText += decoder.decode();
|
|
1352
|
+
} finally {
|
|
1353
|
+
reader.releaseLock();
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
if (!response.ok) {
|
|
1357
|
+
logger4.error("API task returned non-OK status", {
|
|
1358
|
+
taskId: context.taskId,
|
|
1359
|
+
status: response.status,
|
|
1360
|
+
url: core.sanitize.sanitizeUrl(url)
|
|
1361
|
+
});
|
|
1073
1362
|
return err(
|
|
1074
|
-
HaiSchedulerError.
|
|
1075
|
-
schedulerM("
|
|
1076
|
-
|
|
1363
|
+
HaiSchedulerError.API_EXECUTION_FAILED,
|
|
1364
|
+
schedulerM("scheduler_apiExecutionFailed", {
|
|
1365
|
+
params: { error: `HTTP ${response.status}: ${responseText.slice(0, 200)}` }
|
|
1366
|
+
})
|
|
1077
1367
|
);
|
|
1078
1368
|
}
|
|
1079
|
-
|
|
1080
|
-
|
|
1369
|
+
return ok(responseText || null);
|
|
1370
|
+
} catch (error) {
|
|
1371
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1372
|
+
logger4.error("API task execution failed", { taskId: context.taskId, error: message, url: core.sanitize.sanitizeUrl(url) });
|
|
1373
|
+
return err(
|
|
1374
|
+
HaiSchedulerError.API_EXECUTION_FAILED,
|
|
1375
|
+
schedulerM("scheduler_apiExecutionFailed", { params: { error: message } }),
|
|
1376
|
+
error
|
|
1377
|
+
);
|
|
1378
|
+
} finally {
|
|
1379
|
+
clearTimeout(timer);
|
|
1081
1380
|
}
|
|
1082
|
-
deleteTask(taskId);
|
|
1083
|
-
deleteCron(taskId);
|
|
1084
|
-
persistedTaskIds.delete(taskId);
|
|
1085
|
-
return ok(void 0);
|
|
1086
1381
|
}
|
|
1087
|
-
async function
|
|
1088
|
-
|
|
1089
|
-
if (!existingTask) {
|
|
1382
|
+
async function executeHookTask(task, context, hooks, startedAt, signal) {
|
|
1383
|
+
if (!hooks.onTaskExecute) {
|
|
1090
1384
|
return err(
|
|
1091
|
-
HaiSchedulerError.
|
|
1092
|
-
schedulerM("
|
|
1385
|
+
HaiSchedulerError.EXECUTION_FAILED,
|
|
1386
|
+
schedulerM("scheduler_taskHandlerMissing", { params: { taskId: task.id } })
|
|
1093
1387
|
);
|
|
1094
1388
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
return cronResult;
|
|
1102
|
-
updatedCron = cronResult.data;
|
|
1103
|
-
}
|
|
1104
|
-
let normalizedRetry;
|
|
1105
|
-
if (updates.retry !== void 0) {
|
|
1106
|
-
if (updates.retry === null) {
|
|
1107
|
-
normalizedRetry = null;
|
|
1108
|
-
} else {
|
|
1109
|
-
const retryResult = validateRetryPolicy(taskId, updates.retry);
|
|
1110
|
-
if (!retryResult.success)
|
|
1111
|
-
return retryResult;
|
|
1112
|
-
normalizedRetry = retryResult.data;
|
|
1113
|
-
}
|
|
1114
|
-
}
|
|
1115
|
-
const normalizedDescription = updates.description !== void 0 ? normalizeTaskDescription(updates.description) ?? "" : void 0;
|
|
1116
|
-
const normalizedUpdates = {
|
|
1117
|
-
...updates,
|
|
1118
|
-
...updates.description !== void 0 ? { description: normalizedDescription } : {},
|
|
1119
|
-
...updates.retry !== void 0 ? { retry: normalizedRetry } : {}
|
|
1120
|
-
};
|
|
1121
|
-
const updatedTask = {
|
|
1122
|
-
...existingTask,
|
|
1123
|
-
...normalizedUpdates.name !== void 0 ? { name: normalizedUpdates.name } : {},
|
|
1124
|
-
...normalizedUpdates.description !== void 0 ? { description: normalizedUpdates.description || void 0 } : {},
|
|
1125
|
-
...normalizedUpdates.cron !== void 0 ? { cron: normalizedUpdates.cron } : {},
|
|
1126
|
-
...normalizedUpdates.timezone !== void 0 ? { timezone: normalizedUpdates.timezone ?? void 0 } : {},
|
|
1127
|
-
...normalizedUpdates.enabled !== void 0 ? { enabled: normalizedUpdates.enabled } : {},
|
|
1128
|
-
...normalizedUpdates.deleteAfterRun !== void 0 ? { deleteAfterRun: normalizedUpdates.deleteAfterRun } : {},
|
|
1129
|
-
...normalizedUpdates.retry !== void 0 ? { retry: normalizedUpdates.retry ?? void 0 } : {},
|
|
1130
|
-
...normalizedUpdates.params !== void 0 ? { params: normalizedUpdates.params } : {},
|
|
1131
|
-
...normalizedUpdates.handler !== void 0 ? { handler: normalizedUpdates.handler ?? void 0 } : {}
|
|
1389
|
+
const executeEvent = {
|
|
1390
|
+
task,
|
|
1391
|
+
trigger: context.trigger,
|
|
1392
|
+
startedAt,
|
|
1393
|
+
context,
|
|
1394
|
+
signal
|
|
1132
1395
|
};
|
|
1133
|
-
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
schedulerM("scheduler_dbSaveFailed", { params: { error: updateResult.error.message } }),
|
|
1140
|
-
updateResult.error
|
|
1141
|
-
);
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
setTask(taskId, updatedTask);
|
|
1145
|
-
if (updatedCron)
|
|
1146
|
-
setCron(taskId, updatedCron);
|
|
1147
|
-
return ok(void 0);
|
|
1148
|
-
}
|
|
1149
|
-
async function queryTaskLogs(logRepo2, options) {
|
|
1150
|
-
if (!logRepo2) {
|
|
1396
|
+
try {
|
|
1397
|
+
const result = await waitWithSignal(() => hooks.onTaskExecute(executeEvent), signal);
|
|
1398
|
+
return ok(result !== void 0 ? JSON.stringify(result) : null);
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1401
|
+
logger4.error("Task execute hook failed", { taskId: task.id, error: message });
|
|
1151
1402
|
return err(
|
|
1152
|
-
HaiSchedulerError.
|
|
1153
|
-
schedulerM("
|
|
1403
|
+
HaiSchedulerError.HOOK_EXECUTION_FAILED,
|
|
1404
|
+
schedulerM("scheduler_hookExecutionFailed", { params: { error: message } }),
|
|
1405
|
+
error
|
|
1154
1406
|
);
|
|
1155
1407
|
}
|
|
1156
|
-
return logRepo2.queryLogs(options);
|
|
1157
1408
|
}
|
|
1409
|
+
async function executeTask(task, trigger, hooks, signal = new AbortController().signal, execution) {
|
|
1410
|
+
const startedAt = Date.now();
|
|
1411
|
+
const taskType = resolveTaskType(task, hooks);
|
|
1412
|
+
const context = createTaskContext(task, trigger, execution);
|
|
1413
|
+
await notifyTaskStart(hooks, { task, trigger, startedAt, signal, context });
|
|
1414
|
+
let executionResult;
|
|
1415
|
+
let shouldNotifyInterrupted = false;
|
|
1416
|
+
if (signal.aborted) {
|
|
1417
|
+
executionResult = err(HaiSchedulerError.EXECUTION_FAILED, schedulerM("scheduler_closing"));
|
|
1418
|
+
shouldNotifyInterrupted = true;
|
|
1419
|
+
} else if (!task.handler) {
|
|
1420
|
+
executionResult = await executeHookTask(task, context, hooks, startedAt, signal);
|
|
1421
|
+
shouldNotifyInterrupted = !hooks.onTaskExecute || !executionResult.success && executionResult.error.cause instanceof Error && executionResult.error.cause.name === "SchedulerTaskSkippedError";
|
|
1422
|
+
} else if (task.handler.kind === "api") {
|
|
1423
|
+
executionResult = await executeApiTask(task, context, signal);
|
|
1424
|
+
} else {
|
|
1425
|
+
executionResult = await executeJsTask(task, context, signal);
|
|
1426
|
+
}
|
|
1427
|
+
shouldNotifyInterrupted ||= signal.aborted;
|
|
1428
|
+
const finishedAt = Date.now();
|
|
1429
|
+
let status = executionResult.success ? "success" : shouldNotifyInterrupted ? "interrupted" : "failed";
|
|
1430
|
+
if (execution?.executionToken && !task.idempotent && !executionResult.success)
|
|
1431
|
+
status = "unknown";
|
|
1432
|
+
const log = createExecutionLog(
|
|
1433
|
+
task,
|
|
1434
|
+
trigger,
|
|
1435
|
+
taskType,
|
|
1436
|
+
status,
|
|
1437
|
+
startedAt,
|
|
1438
|
+
finishedAt,
|
|
1439
|
+
executionResult.success ? executionResult.data : null,
|
|
1440
|
+
executionResult.success ? null : executionResult.error.message,
|
|
1441
|
+
context.occurrenceId
|
|
1442
|
+
);
|
|
1443
|
+
if (!executionResult.success && shouldNotifyInterrupted) {
|
|
1444
|
+
await notifyTaskInterrupted(hooks, {
|
|
1445
|
+
task,
|
|
1446
|
+
trigger,
|
|
1447
|
+
startedAt,
|
|
1448
|
+
interruptedAt: finishedAt,
|
|
1449
|
+
signal,
|
|
1450
|
+
reason: executionResult.error.message,
|
|
1451
|
+
context
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
if (status !== "interrupted" && !signal.aborted)
|
|
1455
|
+
await notifyTaskFinish(hooks, { task, trigger, startedAt, finishedAt, log, signal, context });
|
|
1456
|
+
return log;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// src/scheduler-runner.ts
|
|
1158
1460
|
var logger5 = core.logger.child({ module: "scheduler", scope: "runner" });
|
|
1159
1461
|
var runningTasks = /* @__PURE__ */ new Set();
|
|
1160
1462
|
var activeRuns = /* @__PURE__ */ new Map();
|
|
1463
|
+
var activeRepairs = /* @__PURE__ */ new Set();
|
|
1161
1464
|
var tickTimer = null;
|
|
1162
|
-
var tickInProgress =
|
|
1465
|
+
var tickInProgress = null;
|
|
1163
1466
|
var lastTickMinute = -1;
|
|
1164
1467
|
var currentNodeId = "";
|
|
1165
|
-
var
|
|
1468
|
+
var currentLeaseMs = 3e5;
|
|
1166
1469
|
var currentTaskRepo = null;
|
|
1167
|
-
|
|
1470
|
+
var currentExecutionRepo = null;
|
|
1471
|
+
var currentLogRepo = null;
|
|
1472
|
+
var currentCleanupPolicy = {};
|
|
1473
|
+
function setLogRepository(repo, policy = {}) {
|
|
1474
|
+
currentLogRepo = repo;
|
|
1475
|
+
currentCleanupPolicy = { ...policy };
|
|
1476
|
+
}
|
|
1477
|
+
var publishedLogSchema = z.object({
|
|
1478
|
+
id: z.uuid(),
|
|
1479
|
+
occurrenceId: z.string().regex(/^[a-f\d]{64}$/),
|
|
1480
|
+
taskId: z.string(),
|
|
1481
|
+
taskName: z.string(),
|
|
1482
|
+
taskType: z.enum(["api", "js", "hook"]),
|
|
1483
|
+
triggerType: z.enum(["manual", "scheduled"]),
|
|
1484
|
+
triggerSource: z.string().nullable(),
|
|
1485
|
+
status: z.enum(["success", "failed", "interrupted", "unknown"]),
|
|
1486
|
+
result: z.string().nullable(),
|
|
1487
|
+
error: z.string().nullable(),
|
|
1488
|
+
startedAt: z.number().safe(),
|
|
1489
|
+
finishedAt: z.number().safe(),
|
|
1490
|
+
duration: z.number().safe(),
|
|
1491
|
+
persistence: z.object({ status: z.enum(["pending", "persisted", "unconfirmed"]), error: z.object({ code: z.string(), message: z.string() }).optional() })
|
|
1492
|
+
});
|
|
1493
|
+
function repairExecutionLogs(id) {
|
|
1494
|
+
const operation = projectExecutionLogs(id);
|
|
1495
|
+
activeRepairs.add(operation);
|
|
1496
|
+
void operation.finally(() => activeRepairs.delete(operation)).catch(() => {
|
|
1497
|
+
});
|
|
1498
|
+
return operation;
|
|
1499
|
+
}
|
|
1500
|
+
async function projectExecutionLogs(id) {
|
|
1501
|
+
const execution = currentExecutionRepo;
|
|
1502
|
+
const logs = currentLogRepo;
|
|
1503
|
+
const cleanupPolicy = currentCleanupPolicy;
|
|
1504
|
+
if (!execution || !logs)
|
|
1505
|
+
return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
|
|
1506
|
+
try {
|
|
1507
|
+
const found = await execution.byOccurrence(id);
|
|
1508
|
+
if (!found.success || !found.data || ["claimed", "running"].includes(found.data.status))
|
|
1509
|
+
return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
|
|
1510
|
+
const row = found.data;
|
|
1511
|
+
if (row.logPersistence === "persisted")
|
|
1512
|
+
return ok(void 0);
|
|
1513
|
+
const parsed = z.array(publishedLogSchema).min(1).max(101).safeParse(JSON.parse(row.logsJson ?? "null"));
|
|
1514
|
+
if (!parsed.success || parsed.data.some((log) => log.occurrenceId !== id || log.taskId !== row.taskId))
|
|
1515
|
+
return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
|
|
1516
|
+
for (const log of parsed.data) {
|
|
1517
|
+
const saved = await logs.saveLog(log);
|
|
1518
|
+
if (!saved.success)
|
|
1519
|
+
return saved;
|
|
1520
|
+
}
|
|
1521
|
+
const confirmed = await execution.confirmLogs(row);
|
|
1522
|
+
if (!confirmed.success)
|
|
1523
|
+
return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
|
|
1524
|
+
const cleanup = await logs.cleanupLogs(cleanupPolicy);
|
|
1525
|
+
if (!cleanup.success)
|
|
1526
|
+
logger5.warn("Execution logs persisted but retention cleanup failed", { occurrenceId: id, errorCode: cleanup.error.code });
|
|
1527
|
+
return ok(void 0);
|
|
1528
|
+
} catch {
|
|
1529
|
+
return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
function configureExecution(repo, nodeId = "", leaseMs = 3e5) {
|
|
1533
|
+
currentExecutionRepo = repo;
|
|
1168
1534
|
currentNodeId = nodeId;
|
|
1169
|
-
|
|
1535
|
+
currentLeaseMs = leaseMs;
|
|
1170
1536
|
}
|
|
1171
1537
|
function setTaskRepository(repo) {
|
|
1172
1538
|
currentTaskRepo = repo;
|
|
1173
1539
|
}
|
|
1174
|
-
function isTaskRunning(taskId) {
|
|
1175
|
-
return runningTasks.has(taskId);
|
|
1176
|
-
}
|
|
1177
1540
|
function isTimerRunning() {
|
|
1178
1541
|
return tickTimer !== null;
|
|
1179
1542
|
}
|
|
1180
1543
|
function resetRunner() {
|
|
1181
1544
|
runningTasks.clear();
|
|
1182
|
-
|
|
1183
|
-
clearInterval(tickTimer);
|
|
1184
|
-
tickTimer = null;
|
|
1185
|
-
}
|
|
1545
|
+
stopTimer();
|
|
1186
1546
|
lastTickMinute = -1;
|
|
1187
|
-
currentNodeId = "";
|
|
1188
|
-
currentLockTtlSec = 300;
|
|
1189
1547
|
currentTaskRepo = null;
|
|
1548
|
+
configureExecution(null);
|
|
1190
1549
|
}
|
|
1191
1550
|
async function sleep(ms, signal) {
|
|
1192
1551
|
if (ms <= 0 || signal.aborted)
|
|
@@ -1207,167 +1566,215 @@ async function drainRunner() {
|
|
|
1207
1566
|
for (const controller of activeRuns.values())
|
|
1208
1567
|
controller.abort(new Error(schedulerM("scheduler_closing")));
|
|
1209
1568
|
await Promise.allSettled([...activeRuns.keys()]);
|
|
1210
|
-
|
|
1211
|
-
function resolveRetryBackoffMs(task, attempt) {
|
|
1212
|
-
const backoff = task.retry?.backoffMs;
|
|
1213
|
-
if (!backoff || backoff.length === 0)
|
|
1214
|
-
return 0;
|
|
1215
|
-
const index = Math.min(Math.max(attempt - 2, 0), backoff.length - 1);
|
|
1216
|
-
return backoff[index] ?? 0;
|
|
1569
|
+
await Promise.allSettled([...activeRepairs]);
|
|
1217
1570
|
}
|
|
1218
1571
|
async function tick() {
|
|
1219
|
-
|
|
1572
|
+
const timer = tickTimer;
|
|
1573
|
+
const repository = currentTaskRepo;
|
|
1574
|
+
const executionRepo2 = currentExecutionRepo;
|
|
1575
|
+
if (!timer || tickInProgress === timer)
|
|
1220
1576
|
return;
|
|
1221
|
-
tickInProgress =
|
|
1577
|
+
tickInProgress = timer;
|
|
1578
|
+
const isCurrent = () => tickTimer === timer && currentTaskRepo === repository && currentExecutionRepo === executionRepo2;
|
|
1222
1579
|
try {
|
|
1223
|
-
const refreshed = await loadPersistedTasks(
|
|
1224
|
-
if (!refreshed.success)
|
|
1225
|
-
logger5.warn("Task definition refresh failed; skipping scheduling", { error: refreshed.error.message });
|
|
1580
|
+
const refreshed = await loadPersistedTasks(repository);
|
|
1581
|
+
if (!refreshed.success || !isCurrent())
|
|
1226
1582
|
return;
|
|
1227
|
-
|
|
1228
|
-
if (!
|
|
1583
|
+
const expired = await executionRepo2?.expired();
|
|
1584
|
+
if (!isCurrent())
|
|
1229
1585
|
return;
|
|
1230
|
-
|
|
1586
|
+
if (expired?.success) {
|
|
1587
|
+
for (const row of expired.data) {
|
|
1588
|
+
const task = getTask(row.taskId);
|
|
1589
|
+
if (!task) {
|
|
1590
|
+
await executionRepo2?.isolateExpired(row);
|
|
1591
|
+
if (!isCurrent())
|
|
1592
|
+
return;
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
if (task && !runningTasks.has(task.id)) {
|
|
1596
|
+
let trigger;
|
|
1597
|
+
try {
|
|
1598
|
+
trigger = JSON.parse(row.triggerJson);
|
|
1599
|
+
} catch {
|
|
1600
|
+
await executionRepo2?.isolateExpired(row);
|
|
1601
|
+
if (!isCurrent())
|
|
1602
|
+
return;
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
void runTask(task, trigger.minute, trigger, void 0, row).catch((error) => logger5.warn("Execution recovery failed", { taskId: task.id, error }));
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
const now = await executionRepo2?.currentTime();
|
|
1610
|
+
if (now?.success && isCurrent())
|
|
1611
|
+
scheduleDueTasks(now.data);
|
|
1231
1612
|
} finally {
|
|
1232
|
-
tickInProgress
|
|
1613
|
+
if (tickInProgress === timer)
|
|
1614
|
+
tickInProgress = null;
|
|
1233
1615
|
}
|
|
1234
1616
|
}
|
|
1235
|
-
function scheduleDueTasks() {
|
|
1236
|
-
const
|
|
1237
|
-
const currentMinute = Math.floor(now.getTime() / 6e4);
|
|
1617
|
+
function scheduleDueTasks(databaseNow) {
|
|
1618
|
+
const currentMinute = Math.floor(databaseNow / 6e4);
|
|
1238
1619
|
if (currentMinute === lastTickMinute)
|
|
1239
1620
|
return;
|
|
1240
1621
|
lastTickMinute = currentMinute;
|
|
1241
1622
|
for (const [taskId, task] of getTaskRegistry()) {
|
|
1242
|
-
if (task.enabled === false)
|
|
1623
|
+
if (task.enabled === false || runningTasks.has(taskId))
|
|
1243
1624
|
continue;
|
|
1244
|
-
|
|
1245
|
-
if (!cron || !cron.match(new Date(currentMinute * 6e4)))
|
|
1625
|
+
if (!getCron(taskId)?.match(new Date(currentMinute * 6e4)))
|
|
1246
1626
|
continue;
|
|
1247
|
-
|
|
1248
|
-
logger5.debug("Skipping task already running", { taskId, taskName: task.name });
|
|
1249
|
-
continue;
|
|
1250
|
-
}
|
|
1251
|
-
void runTask(task, currentMinute, { type: "scheduled", source: null }).catch((error) => {
|
|
1252
|
-
logger5.error("Unhandled task execution error", { taskId, error });
|
|
1253
|
-
});
|
|
1627
|
+
void runTask(task, currentMinute, { type: "scheduled", source: null }).catch((error) => logger5.error("Unhandled task execution error", { taskId, error }));
|
|
1254
1628
|
}
|
|
1255
1629
|
}
|
|
1256
|
-
function runTask(task, minuteTimestamp, trigger = { type: "manual", source: null }) {
|
|
1630
|
+
function runTask(task, minuteTimestamp, trigger = { type: "manual", source: null }, options, recovery) {
|
|
1257
1631
|
const controller = new AbortController();
|
|
1258
|
-
const run = runTaskExecution(task, minuteTimestamp, trigger, controller
|
|
1632
|
+
const run = runTaskExecution(task, minuteTimestamp, trigger, controller, options, recovery);
|
|
1259
1633
|
activeRuns.set(run, controller);
|
|
1260
1634
|
void run.finally(() => activeRuns.delete(run)).catch(() => {
|
|
1261
1635
|
});
|
|
1262
1636
|
return run;
|
|
1263
1637
|
}
|
|
1264
|
-
async function runTaskExecution(task,
|
|
1265
|
-
const hooks = getHooks();
|
|
1638
|
+
async function runTaskExecution(task, minute, trigger, controller, options, recovery) {
|
|
1266
1639
|
const repository = currentTaskRepo;
|
|
1267
|
-
const
|
|
1268
|
-
const
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
trigger,
|
|
1278
|
-
schedulerM("scheduler_lockAcquireFailed", { params: { taskId: task.id } }),
|
|
1279
|
-
hooks,
|
|
1280
|
-
signal
|
|
1281
|
-
);
|
|
1640
|
+
const executionRepo2 = currentExecutionRepo;
|
|
1641
|
+
const logRepository = currentLogRepo;
|
|
1642
|
+
const hooks = getHooks();
|
|
1643
|
+
const leaseMs = currentLeaseMs;
|
|
1644
|
+
const occurrenceId = recovery?.occurrenceId ?? executionId(task.id, trigger.type, minute === void 0 ? options?.idempotencyKey ?? randomUUID() : String(minute));
|
|
1645
|
+
const interrupted = async (reason, observationOnly = false) => {
|
|
1646
|
+
const log = { ...await interruptTask(task, trigger, reason, {}, controller.signal), occurrenceId };
|
|
1647
|
+
if (observationOnly && logRepository) {
|
|
1648
|
+
const saved = await logRepository.saveLog(log);
|
|
1649
|
+
log.persistence = saved.success ? { status: "persisted" } : { status: "unconfirmed", error: { code: String(saved.error.code), message: saved.error.message } };
|
|
1282
1650
|
}
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1651
|
+
return log;
|
|
1652
|
+
};
|
|
1653
|
+
if (!executionRepo2)
|
|
1654
|
+
return interrupted(schedulerM("scheduler_executionStateFailed"), true);
|
|
1655
|
+
const claimed = await executionRepo2.claim(task, occurrenceId, { ...trigger, ...minute !== void 0 ? { minute } : {} }, currentNodeId, leaseMs);
|
|
1656
|
+
if (!claimed.success)
|
|
1657
|
+
return interrupted(claimed.error.message, true);
|
|
1658
|
+
if (!claimed.data)
|
|
1659
|
+
return interrupted(schedulerM("scheduler_taskRunning", { params: { taskId: task.id } }), true);
|
|
1660
|
+
const row = claimed.data;
|
|
1661
|
+
if (!["claimed", "running"].includes(row.status)) {
|
|
1662
|
+
if (minute === void 0) {
|
|
1663
|
+
try {
|
|
1664
|
+
const log = row.logJson ? JSON.parse(row.logJson) : {
|
|
1665
|
+
id: randomUUID(),
|
|
1666
|
+
persistence: { status: "unconfirmed" },
|
|
1667
|
+
taskId: row.taskId,
|
|
1668
|
+
taskName: task.name,
|
|
1669
|
+
taskType: task.handler?.kind ?? "hook",
|
|
1670
|
+
triggerType: trigger.type,
|
|
1671
|
+
triggerSource: trigger.source,
|
|
1672
|
+
status: "interrupted",
|
|
1673
|
+
result: null,
|
|
1674
|
+
error: null,
|
|
1675
|
+
startedAt: row.createdAt,
|
|
1676
|
+
finishedAt: row.updatedAt,
|
|
1677
|
+
duration: row.updatedAt - row.createdAt
|
|
1678
|
+
};
|
|
1679
|
+
const status = row.status === "discarded" ? "interrupted" : row.status;
|
|
1680
|
+
return { ...log, status, error: status === "success" ? null : log.error, occurrenceId };
|
|
1681
|
+
} catch {
|
|
1682
|
+
return interrupted(schedulerM("scheduler_executionStateFailed"));
|
|
1683
|
+
}
|
|
1292
1684
|
}
|
|
1293
|
-
|
|
1294
|
-
if (runningTasks.has(task.id)) {
|
|
1295
|
-
return interruptTask(
|
|
1296
|
-
task,
|
|
1297
|
-
trigger,
|
|
1298
|
-
schedulerM("scheduler_taskRunning", { params: { taskId: task.id } }),
|
|
1299
|
-
hooks,
|
|
1300
|
-
signal
|
|
1301
|
-
);
|
|
1685
|
+
return interrupted(schedulerM("scheduler_occurrenceHandled", { params: { taskId: task.id } }), true);
|
|
1302
1686
|
}
|
|
1303
1687
|
runningTasks.add(task.id);
|
|
1688
|
+
let timer;
|
|
1689
|
+
let renewing;
|
|
1690
|
+
let stopped = false;
|
|
1691
|
+
let lost = false;
|
|
1692
|
+
const renew = async () => {
|
|
1693
|
+
try {
|
|
1694
|
+
const result = await executionRepo2.renew(row, leaseMs);
|
|
1695
|
+
lost = !result.success;
|
|
1696
|
+
} catch {
|
|
1697
|
+
lost = true;
|
|
1698
|
+
}
|
|
1699
|
+
if (lost) {
|
|
1700
|
+
controller.abort(new Error(schedulerM("scheduler_executionStateFailed")));
|
|
1701
|
+
} else if (!stopped) {
|
|
1702
|
+
timer = setTimeout(() => {
|
|
1703
|
+
renewing = renew();
|
|
1704
|
+
}, Math.max(1, Math.floor(leaseMs / 3)));
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
timer = setTimeout(() => {
|
|
1708
|
+
renewing = renew();
|
|
1709
|
+
}, Math.max(1, Math.floor(leaseMs / 3)));
|
|
1304
1710
|
try {
|
|
1305
|
-
let
|
|
1306
|
-
|
|
1307
|
-
for (
|
|
1308
|
-
if (signal.aborted)
|
|
1309
|
-
log = await interruptTask(task, trigger, schedulerM("scheduler_closing"), {}, signal);
|
|
1711
|
+
let log;
|
|
1712
|
+
const attempts = [];
|
|
1713
|
+
for (; row.attemptCount < row.maxAttempts; ) {
|
|
1714
|
+
if (controller.signal.aborted)
|
|
1310
1715
|
break;
|
|
1311
|
-
}
|
|
1312
1716
|
const refreshed = await loadPersistedTasks(repository);
|
|
1313
|
-
if (!refreshed.success) {
|
|
1314
|
-
log = await interruptTask(task, trigger, refreshed.error.message, hooks, signal);
|
|
1315
|
-
break;
|
|
1316
|
-
}
|
|
1317
1717
|
const latest = getTask(task.id);
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1718
|
+
let unchanged = false;
|
|
1719
|
+
try {
|
|
1720
|
+
unchanged = latest !== void 0 && isDeepStrictEqual(JSON.parse(JSON.stringify(latest)), JSON.parse(row.snapshot));
|
|
1721
|
+
} catch {
|
|
1722
|
+
}
|
|
1723
|
+
if (!refreshed.success || !latest || latest.enabled === false || !unchanged) {
|
|
1724
|
+
log = await interrupted(schedulerM("scheduler_taskNoLongerDue", { params: { taskId: task.id } }));
|
|
1725
|
+
if (row.attemptCount > 0)
|
|
1726
|
+
log.status = "unknown";
|
|
1321
1727
|
break;
|
|
1322
1728
|
}
|
|
1323
1729
|
task = latest;
|
|
1324
|
-
if (
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1730
|
+
if (!task.handler && !hooks.onTaskExecute) {
|
|
1731
|
+
log = { ...await interruptTask(task, trigger, schedulerM("scheduler_taskHandlerMissing", { params: { taskId: task.id } }), hooks, controller.signal), occurrenceId };
|
|
1732
|
+
break;
|
|
1733
|
+
}
|
|
1734
|
+
const begun = await executionRepo2.begin(row, leaseMs);
|
|
1735
|
+
if (!begun.success) {
|
|
1736
|
+
lost = true;
|
|
1328
1737
|
break;
|
|
1329
|
-
|
|
1738
|
+
}
|
|
1739
|
+
log = await executeTask(task, trigger, hooks, controller.signal, { occurrenceId, executionToken: row.owner, sequence: row.sequence, attempt: row.attemptCount });
|
|
1740
|
+
log.occurrenceId = occurrenceId;
|
|
1741
|
+
attempts.push(log);
|
|
1742
|
+
if (log.status !== "failed" || !row.idempotent || row.attemptCount >= row.maxAttempts)
|
|
1330
1743
|
break;
|
|
1331
|
-
const
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
attempt,
|
|
1335
|
-
maxAttempts,
|
|
1336
|
-
backoffMs
|
|
1337
|
-
});
|
|
1338
|
-
await sleep(backoffMs, signal);
|
|
1744
|
+
const delays = task.retry?.backoffMs ?? [];
|
|
1745
|
+
const delay = delays[Math.min(row.attemptCount - 1, Math.max(0, delays.length - 1))] ?? 0;
|
|
1746
|
+
await sleep(delay, controller.signal);
|
|
1339
1747
|
}
|
|
1340
|
-
if (!log)
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
schedulerM("scheduler_executionFailed", { params: { error: "Task execution produced no log" } }),
|
|
1345
|
-
hooks,
|
|
1346
|
-
signal
|
|
1347
|
-
);
|
|
1748
|
+
if (!log)
|
|
1749
|
+
log = await interrupted(schedulerM("scheduler_executionStateFailed"));
|
|
1750
|
+
if (lost) {
|
|
1751
|
+
return { ...log, status: "unknown", persistence: { status: "unconfirmed" }, error: schedulerM("scheduler_executionUnknown"), occurrenceId };
|
|
1348
1752
|
}
|
|
1349
|
-
if (
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1753
|
+
if (controller.signal.aborted && row.attemptCount > 0)
|
|
1754
|
+
log = { ...log, status: "unknown", error: schedulerM("scheduler_executionUnknown") };
|
|
1755
|
+
const currentAttempt = attempts.findIndex((attempt) => attempt.id === log.id);
|
|
1756
|
+
if (currentAttempt >= 0)
|
|
1757
|
+
attempts[currentAttempt] = log;
|
|
1353
1758
|
else
|
|
1354
|
-
|
|
1355
|
-
|
|
1759
|
+
attempts.push(log);
|
|
1760
|
+
const published = attempts.map((attempt) => ({ ...attempt, persistence: { status: "pending" } }));
|
|
1761
|
+
const finished = await executionRepo2.finish(row, { ...log, persistence: { status: "pending" } }, published);
|
|
1762
|
+
if (!finished.success)
|
|
1763
|
+
return { ...log, status: "unknown", persistence: { status: "unconfirmed" }, error: finished.error.message, occurrenceId };
|
|
1764
|
+
const projected = await repairExecutionLogs(occurrenceId);
|
|
1765
|
+
log.persistence = projected.success ? { status: "persisted" } : { status: "pending", error: { code: String(projected.error.code), message: projected.error.message } };
|
|
1766
|
+
if (!projected.success)
|
|
1767
|
+
logger5.warn("Execution completed with pending log persistence", { taskId: task.id, occurrenceId, errorCode: projected.error.code });
|
|
1768
|
+
if (task.deleteAfterRun && log.status === "success") {
|
|
1356
1769
|
const refreshed = await loadPersistedTasks(repository);
|
|
1357
|
-
if (
|
|
1358
|
-
|
|
1359
|
-
const unregisterResult = await unregisterTask(task.id, repository, task);
|
|
1360
|
-
if (!unregisterResult.success) {
|
|
1361
|
-
logger5.warn("Failed to auto-delete one-time task after run", {
|
|
1362
|
-
taskId: task.id,
|
|
1363
|
-
error: unregisterResult.error.message
|
|
1364
|
-
});
|
|
1365
|
-
} else if (!getTask(task.id)) {
|
|
1366
|
-
logger5.info("Auto-deleted one-time task after run", { taskId: task.id });
|
|
1367
|
-
}
|
|
1770
|
+
if (refreshed.success && isDeepStrictEqual(getTask(task.id), task))
|
|
1771
|
+
await unregisterTask(task.id, repository, task);
|
|
1368
1772
|
}
|
|
1369
1773
|
return log;
|
|
1370
1774
|
} finally {
|
|
1775
|
+
stopped = true;
|
|
1776
|
+
clearTimeout(timer);
|
|
1777
|
+
await renewing;
|
|
1371
1778
|
runningTasks.delete(task.id);
|
|
1372
1779
|
}
|
|
1373
1780
|
}
|
|
@@ -1376,9 +1783,8 @@ function startTimer(tickInterval) {
|
|
|
1376
1783
|
tickTimer = setInterval(tick, tickInterval);
|
|
1377
1784
|
}
|
|
1378
1785
|
function stopTimer() {
|
|
1379
|
-
if (
|
|
1380
|
-
|
|
1381
|
-
clearInterval(tickTimer);
|
|
1786
|
+
if (tickTimer)
|
|
1787
|
+
clearInterval(tickTimer);
|
|
1382
1788
|
tickTimer = null;
|
|
1383
1789
|
}
|
|
1384
1790
|
|
|
@@ -1389,31 +1795,32 @@ var initInProgress = false;
|
|
|
1389
1795
|
var closeInProgress = null;
|
|
1390
1796
|
var taskRepo = null;
|
|
1391
1797
|
var logRepo = null;
|
|
1798
|
+
var executionRepo = null;
|
|
1799
|
+
var executionIdSchema = z.string().regex(/^[a-f\d]{64}$/);
|
|
1800
|
+
var executionListSchema = z.object({ limit: z.number().int().min(1).max(100).default(50), offset: z.number().int().min(0).max(1e6).default(0) });
|
|
1392
1801
|
var notInitialized = core.module.createNotInitializedKit(
|
|
1393
1802
|
HaiSchedulerError.NOT_INITIALIZED,
|
|
1394
1803
|
() => schedulerM("scheduler_notInitialized")
|
|
1395
1804
|
);
|
|
1396
1805
|
async function initDatabase(parsed) {
|
|
1397
|
-
if (!parsed.enableDb)
|
|
1398
|
-
return parsed;
|
|
1399
1806
|
if (!reldb.isInitialized) {
|
|
1400
1807
|
logger6.error("enableDb is true but @h-ai/reldb is not initialized");
|
|
1401
1808
|
throw new Error(schedulerM("scheduler_reldbNotInitialized"));
|
|
1402
1809
|
}
|
|
1403
1810
|
taskRepo = new SchedulerTaskRepository(reldb);
|
|
1404
1811
|
logRepo = new SchedulerLogRepository(reldb);
|
|
1812
|
+
executionRepo = new SchedulerExecutionRepository(reldb);
|
|
1813
|
+
const ready = await executionRepo.count();
|
|
1814
|
+
if (!ready.success)
|
|
1815
|
+
throw new Error(schedulerM("scheduler_executionStateFailed"));
|
|
1405
1816
|
setTaskRepository(taskRepo);
|
|
1406
1817
|
setLogRepository(logRepo, {
|
|
1407
1818
|
maxLogs: parsed.maxLogs,
|
|
1408
1819
|
retentionDays: parsed.retentionDays
|
|
1409
1820
|
});
|
|
1410
1821
|
const nodeId = parsed.nodeId ?? crypto.randomUUID();
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
if (cache.isInitialized)
|
|
1414
|
-
logger6.info("Distributed lock configured (cache-based)", { nodeId, lockTtlSec });
|
|
1415
|
-
else
|
|
1416
|
-
logger6.warn("Cache module not initialized, distributed lock will be enabled when cache becomes available");
|
|
1822
|
+
configureExecution(executionRepo, nodeId, parsed.leaseMs);
|
|
1823
|
+
logger6.info("Shared execution repository configured", { nodeId, leaseMs: parsed.leaseMs });
|
|
1417
1824
|
return { ...parsed, nodeId };
|
|
1418
1825
|
}
|
|
1419
1826
|
function normalizeTriggerInput(options) {
|
|
@@ -1423,6 +1830,49 @@ function normalizeTriggerInput(options) {
|
|
|
1423
1830
|
};
|
|
1424
1831
|
}
|
|
1425
1832
|
var scheduler = {
|
|
1833
|
+
execution: {
|
|
1834
|
+
async get(id) {
|
|
1835
|
+
if (!currentConfig || !executionRepo)
|
|
1836
|
+
return notInitialized.result();
|
|
1837
|
+
if (!executionIdSchema.safeParse(id).success)
|
|
1838
|
+
return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
|
|
1839
|
+
const row = await executionRepo.byOccurrence(id);
|
|
1840
|
+
return row.success ? ok(row.data ? executionEntry(row.data) : null) : row;
|
|
1841
|
+
},
|
|
1842
|
+
async listUnknown(options) {
|
|
1843
|
+
if (!currentConfig || !executionRepo)
|
|
1844
|
+
return notInitialized.result();
|
|
1845
|
+
const parsed = executionListSchema.safeParse(options ?? {});
|
|
1846
|
+
if (!parsed.success)
|
|
1847
|
+
return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
|
|
1848
|
+
const rows = await executionRepo.unknown(parsed.data.limit, parsed.data.offset);
|
|
1849
|
+
return rows.success ? ok(rows.data.map(executionEntry)) : rows;
|
|
1850
|
+
},
|
|
1851
|
+
async listLogFailures(options) {
|
|
1852
|
+
if (!currentConfig || !executionRepo)
|
|
1853
|
+
return notInitialized.result();
|
|
1854
|
+
const parsed = executionListSchema.safeParse(options ?? {});
|
|
1855
|
+
if (!parsed.success)
|
|
1856
|
+
return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
|
|
1857
|
+
const rows = await executionRepo.pendingLogs(parsed.data.limit, parsed.data.offset);
|
|
1858
|
+
return rows.success ? ok(rows.data.map(executionEntry)) : rows;
|
|
1859
|
+
},
|
|
1860
|
+
async repairLog(id) {
|
|
1861
|
+
if (!currentConfig || !executionRepo || closeInProgress)
|
|
1862
|
+
return notInitialized.result();
|
|
1863
|
+
if (!executionIdSchema.safeParse(id).success)
|
|
1864
|
+
return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
|
|
1865
|
+
return repairExecutionLogs(id);
|
|
1866
|
+
},
|
|
1867
|
+
async resolveUnknown(id, resolution) {
|
|
1868
|
+
if (!currentConfig || !executionRepo)
|
|
1869
|
+
return notInitialized.result();
|
|
1870
|
+
const parsed = ExecutionResolutionSchema.safeParse(resolution);
|
|
1871
|
+
if (!executionIdSchema.safeParse(id).success || !parsed.success)
|
|
1872
|
+
return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
|
|
1873
|
+
return executionRepo.resolve(id, parsed.data.status, parsed.data.expectedUpdatedAt);
|
|
1874
|
+
}
|
|
1875
|
+
},
|
|
1426
1876
|
async init(config) {
|
|
1427
1877
|
if (initInProgress) {
|
|
1428
1878
|
logger6.warn("Scheduler init already in progress, skipping concurrent call");
|
|
@@ -1558,6 +2008,9 @@ var scheduler = {
|
|
|
1558
2008
|
async trigger(taskId, options) {
|
|
1559
2009
|
if (!currentConfig)
|
|
1560
2010
|
return notInitialized.result();
|
|
2011
|
+
const parsed = TriggerTaskSchema.safeParse(options ?? {});
|
|
2012
|
+
if (!parsed.success)
|
|
2013
|
+
return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
|
|
1561
2014
|
const config = currentConfig;
|
|
1562
2015
|
const refreshed = await loadPersistedTasks(taskRepo);
|
|
1563
2016
|
if (!refreshed.success)
|
|
@@ -1571,13 +2024,7 @@ var scheduler = {
|
|
|
1571
2024
|
schedulerM("scheduler_taskNotFound", { params: { taskId } })
|
|
1572
2025
|
);
|
|
1573
2026
|
}
|
|
1574
|
-
|
|
1575
|
-
return err(
|
|
1576
|
-
HaiSchedulerError.EXECUTION_FAILED,
|
|
1577
|
-
schedulerM("scheduler_taskRunning", { params: { taskId } })
|
|
1578
|
-
);
|
|
1579
|
-
}
|
|
1580
|
-
const log = await runTask(task, void 0, normalizeTriggerInput(options));
|
|
2027
|
+
const log = await runTask(task, void 0, normalizeTriggerInput(parsed.data), parsed.data);
|
|
1581
2028
|
return ok(log);
|
|
1582
2029
|
},
|
|
1583
2030
|
async getLogs(options) {
|
|
@@ -1627,6 +2074,7 @@ var scheduler = {
|
|
|
1627
2074
|
setTaskRepository(null);
|
|
1628
2075
|
taskRepo = null;
|
|
1629
2076
|
logRepo = null;
|
|
2077
|
+
executionRepo = null;
|
|
1630
2078
|
logger6.info("Scheduler module closed");
|
|
1631
2079
|
})();
|
|
1632
2080
|
try {
|
|
@@ -1637,6 +2085,6 @@ var scheduler = {
|
|
|
1637
2085
|
}
|
|
1638
2086
|
};
|
|
1639
2087
|
|
|
1640
|
-
export { HaiSchedulerError, SchedulerConfigSchema, scheduler };
|
|
2088
|
+
export { ExecutionResolutionSchema, HaiSchedulerError, SchedulerConfigSchema, TriggerTaskSchema, scheduler };
|
|
1641
2089
|
//# sourceMappingURL=index.js.map
|
|
1642
2090
|
//# sourceMappingURL=index.js.map
|