@h-ai/scheduler 0.1.0-alpha.55 → 0.1.0-alpha.57

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/dist/index.js CHANGED
@@ -2,10 +2,11 @@ import { z } from 'zod';
2
2
  import { cache } from '@h-ai/cache';
3
3
  import { core, ok, err } from '@h-ai/core';
4
4
  import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
5
- import { createHash } from 'crypto';
5
+ import { randomUUID, createHash } from 'crypto';
6
6
  import { Script } from 'vm';
7
7
  import { Worker } from 'worker_threads';
8
8
  import { Cron } from 'croner';
9
+ import { isDeepStrictEqual } from 'util';
9
10
 
10
11
  // src/scheduler-config.ts
11
12
  var SchedulerConfigSchema = z.object({
@@ -133,8 +134,8 @@ var SchedulerLogRepository = class extends BaseReldbCrudRepository {
133
134
  { fieldName: "status", columnName: "status", def: { type: "TEXT", notNull: true }, select: true, create: true, update: false },
134
135
  { fieldName: "result", columnName: "result", def: { type: "TEXT" }, select: true, create: true, update: false },
135
136
  { fieldName: "error", columnName: "error", def: { type: "TEXT" }, select: true, create: true, update: false },
136
- { fieldName: "startedAt", columnName: "started_at", def: { type: "INTEGER", notNull: true }, select: true, create: true, update: false },
137
- { fieldName: "finishedAt", columnName: "finished_at", def: { type: "INTEGER", notNull: true }, select: true, create: true, update: false },
137
+ { fieldName: "startedAt", columnName: "started_at", def: { type: "BIGINT", notNull: true }, select: true, create: true, update: false },
138
+ { fieldName: "finishedAt", columnName: "finished_at", def: { type: "BIGINT", notNull: true }, select: true, create: true, update: false },
138
139
  { fieldName: "duration", columnName: "duration", def: { type: "INTEGER", notNull: true }, select: true, create: true, update: false }
139
140
  ]
140
141
  });
@@ -215,8 +216,8 @@ var SchedulerLogRepository = class extends BaseReldbCrudRepository {
215
216
  status: row.status,
216
217
  result: row.result ?? null,
217
218
  error: row.error ?? null,
218
- startedAt: row.startedAt,
219
- finishedAt: row.finishedAt,
219
+ startedAt: Number(row.startedAt),
220
+ finishedAt: Number(row.finishedAt),
220
221
  duration: row.duration
221
222
  }))
222
223
  });
@@ -417,8 +418,29 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
417
418
  );
418
419
  }
419
420
  }
420
- async deleteTask(taskId) {
421
+ /** 删除任务;自动清理携带执行定义,在同一条 DELETE 内原子比较,保留并发编辑。 */
422
+ async deleteTask(taskId, expected) {
421
423
  try {
424
+ if (expected) {
425
+ const conditions = ["task_id = ?", "task_name = ?", "cron = ?", "COALESCE(timezone, '') = ?", "COALESCE(description, '') = ?", "enabled = ?", "delete_after_run = ?"];
426
+ const postgres = this.db.config?.type === "postgresql";
427
+ const enabled = expected.enabled !== false;
428
+ 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)];
430
+ const jsonValue = this.db.config?.type === "mysql" ? "CAST(? AS JSON)" : this.db.config?.type === "postgresql" ? "CAST(? AS JSONB)" : "?";
431
+ const jsonFields = [["retry", expected.retry], ["params", expected.params ?? {}], ["handler", expected.handler]];
432
+ for (const [column, value] of jsonFields) {
433
+ if (value === void 0 || value === null) {
434
+ conditions.push(`(${column} IS NULL OR ${column} = ${jsonValue})`);
435
+ params.push("null");
436
+ } else {
437
+ conditions.push(`${column} = ${jsonValue}`);
438
+ params.push(JSON.stringify(value));
439
+ }
440
+ }
441
+ const deleted = await this.db.sql.execute(`DELETE FROM ${SCHEDULER_TASK_TABLE} WHERE ${conditions.join(" AND ")}`, params);
442
+ return deleted.success ? ok(deleted.data.changes > 0) : deleted;
443
+ }
422
444
  const rows = await this.findAll({ where: "task_id = ?", params: [taskId], limit: 1 });
423
445
  if (!rows.success) {
424
446
  return err(
@@ -428,7 +450,7 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
428
450
  );
429
451
  }
430
452
  if (rows.data.length === 0)
431
- return ok(void 0);
453
+ return ok(false);
432
454
  const deleteResult = await this.deleteById(rows.data[0].id);
433
455
  if (!deleteResult.success) {
434
456
  logger2.error("Failed to delete task definition", { taskId, error: deleteResult.error.message });
@@ -438,7 +460,7 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
438
460
  deleteResult.error
439
461
  );
440
462
  }
441
- return ok(void 0);
463
+ return ok(deleteResult.data.changes > 0);
442
464
  } catch (error) {
443
465
  logger2.error("Failed to delete task definition", { taskId, error });
444
466
  return err(
@@ -598,8 +620,9 @@ function resolveTaskType(task, hooks) {
598
620
  return "hook";
599
621
  return "hook";
600
622
  }
601
- function createTaskContext(task, trigger) {
623
+ function createTaskContext(task, trigger, occurrenceId) {
602
624
  return {
625
+ ...occurrenceId ? { occurrenceId } : {},
603
626
  task,
604
627
  taskId: task.id,
605
628
  params: task.params ?? {},
@@ -803,10 +826,10 @@ async function executeHookTask(task, context, hooks, startedAt, signal) {
803
826
  );
804
827
  }
805
828
  }
806
- async function executeTask(task, trigger, hooks, signal = new AbortController().signal) {
829
+ async function executeTask(task, trigger, hooks, signal = new AbortController().signal, occurrenceId) {
807
830
  const startedAt = Date.now();
808
831
  const taskType = resolveTaskType(task, hooks);
809
- const context = createTaskContext(task, trigger);
832
+ const context = createTaskContext(task, trigger, occurrenceId);
810
833
  await notifyTaskStart(hooks, { task, trigger, startedAt, signal });
811
834
  let executionResult;
812
835
  let shouldNotifyInterrupted = false;
@@ -815,7 +838,7 @@ async function executeTask(task, trigger, hooks, signal = new AbortController().
815
838
  shouldNotifyInterrupted = true;
816
839
  } else if (!task.handler) {
817
840
  executionResult = await executeHookTask(task, context, hooks, startedAt, signal);
818
- shouldNotifyInterrupted = !executionResult.success;
841
+ shouldNotifyInterrupted = !hooks.onTaskExecute || !executionResult.success && executionResult.error.cause instanceof Error && executionResult.error.cause.name === "SchedulerTaskSkippedError";
819
842
  } else if (task.handler.kind === "api") {
820
843
  executionResult = await executeApiTask(task, context, signal);
821
844
  } else {
@@ -866,6 +889,7 @@ var taskRegistry = /* @__PURE__ */ new Map();
866
889
  var cronCache = /* @__PURE__ */ new Map();
867
890
  var persistedTaskIds = /* @__PURE__ */ new Set();
868
891
  var definitionEpoch = 0;
892
+ var activeRefresh;
869
893
  var currentHooks = {};
870
894
  function getTaskRegistry() {
871
895
  return taskRegistry;
@@ -904,6 +928,7 @@ function getHooks() {
904
928
  }
905
929
  function resetTaskState() {
906
930
  definitionEpoch++;
931
+ activeRefresh = void 0;
907
932
  persistedTaskIds.clear();
908
933
  taskRegistry.clear();
909
934
  cronCache.clear();
@@ -940,6 +965,18 @@ async function loadPersistedTasks(taskRepo2) {
940
965
  if (!taskRepo2)
941
966
  return ok(void 0);
942
967
  const epoch = definitionEpoch;
968
+ if (activeRefresh?.repository === taskRepo2 && activeRefresh.epoch === epoch)
969
+ return activeRefresh.promise;
970
+ const refresh = { repository: taskRepo2, epoch, promise: refreshPersistedTasks(taskRepo2, epoch) };
971
+ activeRefresh = refresh;
972
+ try {
973
+ return await refresh.promise;
974
+ } finally {
975
+ if (activeRefresh === refresh)
976
+ activeRefresh = void 0;
977
+ }
978
+ }
979
+ async function refreshPersistedTasks(taskRepo2, epoch) {
943
980
  const loadResult = await taskRepo2.loadTasks();
944
981
  if (!loadResult.success)
945
982
  return loadResult;
@@ -1006,14 +1043,10 @@ async function registerTask(task, taskRepo2) {
1006
1043
  retry: retryResult.data,
1007
1044
  params: task.params ?? {}
1008
1045
  };
1009
- setTask(task.id, normalizedTask);
1010
- setCron(task.id, cronResult.data);
1011
1046
  if (taskRepo2) {
1012
1047
  const saveResult = await taskRepo2.saveTask(normalizedTask);
1013
1048
  if (!saveResult.success) {
1014
- logger4.warn("Failed to persist task definition, rolling back in-memory registration", { taskId: task.id, error: saveResult.error.message });
1015
- deleteTask(task.id);
1016
- deleteCron(task.id);
1049
+ logger4.warn("Failed to persist task definition", { taskId: task.id, error: saveResult.error.message });
1017
1050
  return err(
1018
1051
  HaiSchedulerError.DB_SAVE_FAILED,
1019
1052
  schedulerM("scheduler_dbSaveFailed", { params: { error: saveResult.error.message } }),
@@ -1022,19 +1055,19 @@ async function registerTask(task, taskRepo2) {
1022
1055
  }
1023
1056
  persistedTaskIds.add(task.id);
1024
1057
  }
1058
+ setTask(task.id, normalizedTask);
1059
+ setCron(task.id, cronResult.data);
1025
1060
  return ok(void 0);
1026
1061
  }
1027
- async function unregisterTask(taskId, taskRepo2) {
1062
+ async function unregisterTask(taskId, taskRepo2, expected) {
1028
1063
  if (!hasTask(taskId)) {
1029
1064
  return err(
1030
1065
  HaiSchedulerError.TASK_NOT_FOUND,
1031
1066
  schedulerM("scheduler_taskNotFound", { params: { taskId } })
1032
1067
  );
1033
1068
  }
1034
- deleteTask(taskId);
1035
- deleteCron(taskId);
1036
1069
  if (taskRepo2) {
1037
- const deleteResult = await taskRepo2.deleteTask(taskId);
1070
+ const deleteResult = await taskRepo2.deleteTask(taskId, expected);
1038
1071
  if (!deleteResult.success) {
1039
1072
  logger4.warn("Failed to delete persisted task definition", { taskId, error: deleteResult.error.message });
1040
1073
  return err(
@@ -1043,7 +1076,12 @@ async function unregisterTask(taskId, taskRepo2) {
1043
1076
  deleteResult.error
1044
1077
  );
1045
1078
  }
1079
+ if (expected && !deleteResult.data)
1080
+ return ok(void 0);
1046
1081
  }
1082
+ deleteTask(taskId);
1083
+ deleteCron(taskId);
1084
+ persistedTaskIds.delete(taskId);
1047
1085
  return ok(void 0);
1048
1086
  }
1049
1087
  async function updateRegisteredTask(taskId, updates, taskRepo2) {
@@ -1054,14 +1092,14 @@ async function updateRegisteredTask(taskId, updates, taskRepo2) {
1054
1092
  schedulerM("scheduler_taskNotFound", { params: { taskId } })
1055
1093
  );
1056
1094
  }
1057
- const existingCron = getCron(taskId);
1095
+ let updatedCron = getCron(taskId);
1058
1096
  if (updates.cron !== void 0 || updates.timezone !== void 0) {
1059
1097
  const cronExpression = updates.cron ?? existingTask.cron;
1060
1098
  const timezone = updates.timezone === null ? void 0 : updates.timezone ?? existingTask.timezone;
1061
1099
  const cronResult = parseCronExpression(cronExpression, timezone);
1062
1100
  if (!cronResult.success)
1063
1101
  return cronResult;
1064
- setCron(taskId, cronResult.data);
1102
+ updatedCron = cronResult.data;
1065
1103
  }
1066
1104
  let normalizedRetry;
1067
1105
  if (updates.retry !== void 0) {
@@ -1074,7 +1112,7 @@ async function updateRegisteredTask(taskId, updates, taskRepo2) {
1074
1112
  normalizedRetry = retryResult.data;
1075
1113
  }
1076
1114
  }
1077
- const normalizedDescription = updates.description !== void 0 ? normalizeTaskDescription(updates.description) : void 0;
1115
+ const normalizedDescription = updates.description !== void 0 ? normalizeTaskDescription(updates.description) ?? "" : void 0;
1078
1116
  const normalizedUpdates = {
1079
1117
  ...updates,
1080
1118
  ...updates.description !== void 0 ? { description: normalizedDescription } : {},
@@ -1083,7 +1121,7 @@ async function updateRegisteredTask(taskId, updates, taskRepo2) {
1083
1121
  const updatedTask = {
1084
1122
  ...existingTask,
1085
1123
  ...normalizedUpdates.name !== void 0 ? { name: normalizedUpdates.name } : {},
1086
- ...normalizedUpdates.description !== void 0 ? { description: normalizedUpdates.description } : {},
1124
+ ...normalizedUpdates.description !== void 0 ? { description: normalizedUpdates.description || void 0 } : {},
1087
1125
  ...normalizedUpdates.cron !== void 0 ? { cron: normalizedUpdates.cron } : {},
1088
1126
  ...normalizedUpdates.timezone !== void 0 ? { timezone: normalizedUpdates.timezone ?? void 0 } : {},
1089
1127
  ...normalizedUpdates.enabled !== void 0 ? { enabled: normalizedUpdates.enabled } : {},
@@ -1092,14 +1130,10 @@ async function updateRegisteredTask(taskId, updates, taskRepo2) {
1092
1130
  ...normalizedUpdates.params !== void 0 ? { params: normalizedUpdates.params } : {},
1093
1131
  ...normalizedUpdates.handler !== void 0 ? { handler: normalizedUpdates.handler ?? void 0 } : {}
1094
1132
  };
1095
- setTask(taskId, updatedTask);
1096
1133
  if (taskRepo2) {
1097
1134
  const updateResult = await taskRepo2.updateTask(taskId, normalizedUpdates);
1098
1135
  if (!updateResult.success) {
1099
- logger4.warn("Failed to update persisted task definition, rolling back in-memory update", { taskId, error: updateResult.error.message });
1100
- setTask(taskId, existingTask);
1101
- if ((updates.cron !== void 0 || updates.timezone !== void 0) && existingCron)
1102
- setCron(taskId, existingCron);
1136
+ logger4.warn("Failed to update persisted task definition", { taskId, error: updateResult.error.message });
1103
1137
  return err(
1104
1138
  HaiSchedulerError.DB_SAVE_FAILED,
1105
1139
  schedulerM("scheduler_dbSaveFailed", { params: { error: updateResult.error.message } }),
@@ -1107,6 +1141,9 @@ async function updateRegisteredTask(taskId, updates, taskRepo2) {
1107
1141
  );
1108
1142
  }
1109
1143
  }
1144
+ setTask(taskId, updatedTask);
1145
+ if (updatedCron)
1146
+ setCron(taskId, updatedCron);
1110
1147
  return ok(void 0);
1111
1148
  }
1112
1149
  async function queryTaskLogs(logRepo2, options) {
@@ -1227,6 +1264,7 @@ function runTask(task, minuteTimestamp, trigger = { type: "manual", source: null
1227
1264
  async function runTaskExecution(task, minuteTimestamp, trigger, signal) {
1228
1265
  const hooks = getHooks();
1229
1266
  const repository = currentTaskRepo;
1267
+ const occurrenceId = minuteTimestamp === void 0 ? `manual:${randomUUID()}` : `scheduled:${minuteTimestamp}`;
1230
1268
  const lockKey = minuteTimestamp !== void 0 ? `hai:scheduler:${task.id}:${minuteTimestamp}` : void 0;
1231
1269
  if (cache.isInitialized && lockKey) {
1232
1270
  const lockResult = await waitWithSignal(() => cache.lock.acquire(lockKey, { ttl: currentLockTtlSec, owner: currentNodeId }), signal).catch(() => null);
@@ -1264,7 +1302,7 @@ async function runTaskExecution(task, minuteTimestamp, trigger, signal) {
1264
1302
  }
1265
1303
  runningTasks.add(task.id);
1266
1304
  try {
1267
- const maxAttempts = Math.max(1, task.retry?.maxAttempts ?? 1);
1305
+ let maxAttempts = Math.max(1, task.retry?.maxAttempts ?? 1);
1268
1306
  let log = null;
1269
1307
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1270
1308
  if (signal.aborted) {
@@ -1278,12 +1316,14 @@ async function runTaskExecution(task, minuteTimestamp, trigger, signal) {
1278
1316
  }
1279
1317
  const latest = getTask(task.id);
1280
1318
  const stillDue = minuteTimestamp === void 0 || getCron(task.id)?.match(new Date(minuteTimestamp * 6e4));
1281
- if (!latest || latest.enabled === false || !stillDue) {
1319
+ if (!latest || latest.enabled === false || !stillDue || attempt > 1 && !isDeepStrictEqual(latest, task)) {
1282
1320
  log = await interruptTask(task, trigger, schedulerM("scheduler_taskNoLongerDue", { params: { taskId: task.id } }), hooks, signal);
1283
1321
  break;
1284
1322
  }
1285
1323
  task = latest;
1286
- log = await executeTask(task, trigger, hooks, signal);
1324
+ if (attempt === 1)
1325
+ maxAttempts = Math.max(1, task.retry?.maxAttempts ?? 1);
1326
+ log = await executeTask(task, trigger, hooks, signal, occurrenceId);
1287
1327
  if (log.status !== "failed")
1288
1328
  break;
1289
1329
  if (attempt >= maxAttempts)
@@ -1312,14 +1352,17 @@ async function runTaskExecution(task, minuteTimestamp, trigger, signal) {
1312
1352
  logger5.debug("Task execution interrupted", { taskId: task.id, error: log.error });
1313
1353
  else
1314
1354
  logger5.debug("Task execution succeeded", { taskId: task.id, duration: log.duration });
1315
- if (task.deleteAfterRun === true && !signal.aborted && log.status !== "interrupted") {
1316
- const unregisterResult = await unregisterTask(task.id, repository);
1355
+ if (task.deleteAfterRun === true && !signal.aborted && log.status === "success") {
1356
+ const refreshed = await loadPersistedTasks(repository);
1357
+ if (!refreshed.success || !isDeepStrictEqual(getTask(task.id), task))
1358
+ return log;
1359
+ const unregisterResult = await unregisterTask(task.id, repository, task);
1317
1360
  if (!unregisterResult.success) {
1318
1361
  logger5.warn("Failed to auto-delete one-time task after run", {
1319
1362
  taskId: task.id,
1320
1363
  error: unregisterResult.error.message
1321
1364
  });
1322
- } else {
1365
+ } else if (!getTask(task.id)) {
1323
1366
  logger5.info("Auto-deleted one-time task after run", { taskId: task.id });
1324
1367
  }
1325
1368
  }
@@ -1437,6 +1480,12 @@ var scheduler = {
1437
1480
  initInProgress = false;
1438
1481
  }
1439
1482
  },
1483
+ /** 刷新数据库任务投影,不执行任何任务。 */
1484
+ async refreshTasks() {
1485
+ if (!currentConfig)
1486
+ return notInitialized.result();
1487
+ return loadPersistedTasks(taskRepo);
1488
+ },
1440
1489
  async register(task) {
1441
1490
  if (!currentConfig)
1442
1491
  return notInitialized.result();