@harperfast/harper 5.2.0-alpha.6 → 5.2.0-beta.2

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.
Files changed (276) hide show
  1. package/bin/cliOperations.ts +76 -12
  2. package/bin/run.ts +10 -0
  3. package/bin/status.ts +1 -1
  4. package/components/Application.ts +146 -83
  5. package/components/Scope.ts +4 -0
  6. package/components/componentLoader.ts +7 -0
  7. package/components/operations.js +21 -1
  8. package/config/configUtils.ts +139 -5
  9. package/config-app.schema.json +70 -0
  10. package/dataLayer/harperBridge/ResourceBridge.ts +7 -0
  11. package/dist/bin/cliOperations.js +76 -12
  12. package/dist/bin/cliOperations.js.map +1 -1
  13. package/dist/bin/run.js +9 -0
  14. package/dist/bin/run.js.map +1 -1
  15. package/dist/bin/status.js +1 -1
  16. package/dist/bin/status.js.map +1 -1
  17. package/dist/components/Application.d.ts +12 -5
  18. package/dist/components/Application.js +121 -60
  19. package/dist/components/Application.js.map +1 -1
  20. package/dist/components/Scope.d.ts +1 -0
  21. package/dist/components/Scope.js +4 -0
  22. package/dist/components/Scope.js.map +1 -1
  23. package/dist/components/componentLoader.js +7 -0
  24. package/dist/components/componentLoader.js.map +1 -1
  25. package/dist/components/operations.js +20 -1
  26. package/dist/components/operations.js.map +1 -1
  27. package/dist/config/configUtils.d.ts +31 -0
  28. package/dist/config/configUtils.js +127 -5
  29. package/dist/config/configUtils.js.map +1 -1
  30. package/dist/dataLayer/harperBridge/ResourceBridge.js +8 -0
  31. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  32. package/dist/resources/DatabaseTransaction.d.ts +12 -0
  33. package/dist/resources/DatabaseTransaction.js +97 -0
  34. package/dist/resources/DatabaseTransaction.js.map +1 -1
  35. package/dist/resources/RequestTarget.js +13 -3
  36. package/dist/resources/RequestTarget.js.map +1 -1
  37. package/dist/resources/Resource.js +21 -2
  38. package/dist/resources/Resource.js.map +1 -1
  39. package/dist/resources/Table.d.ts +3 -1
  40. package/dist/resources/Table.js +48 -5
  41. package/dist/resources/Table.js.map +1 -1
  42. package/dist/resources/analytics/metadata.d.ts +3 -0
  43. package/dist/resources/analytics/metadata.js +3 -0
  44. package/dist/resources/analytics/metadata.js.map +1 -1
  45. package/dist/resources/analytics/write.js +22 -0
  46. package/dist/resources/analytics/write.js.map +1 -1
  47. package/dist/resources/databases.js +21 -0
  48. package/dist/resources/databases.js.map +1 -1
  49. package/dist/resources/defineResource.js +20 -7
  50. package/dist/resources/defineResource.js.map +1 -1
  51. package/dist/resources/jsResource.d.ts +24 -0
  52. package/dist/resources/jsResource.js +58 -2
  53. package/dist/resources/jsResource.js.map +1 -1
  54. package/dist/resources/openApi.js +45 -20
  55. package/dist/resources/openApi.js.map +1 -1
  56. package/dist/resources/scheduler/CronExpression.d.ts +71 -0
  57. package/dist/resources/scheduler/CronExpression.js +367 -0
  58. package/dist/resources/scheduler/CronExpression.js.map +1 -0
  59. package/dist/resources/scheduler/engine.d.ts +91 -0
  60. package/dist/resources/scheduler/engine.js +767 -0
  61. package/dist/resources/scheduler/engine.js.map +1 -0
  62. package/dist/resources/scheduler/scheduler.d.ts +33 -0
  63. package/dist/resources/scheduler/scheduler.js +200 -0
  64. package/dist/resources/scheduler/scheduler.js.map +1 -0
  65. package/dist/security/auth.js +1 -0
  66. package/dist/security/auth.js.map +1 -1
  67. package/dist/security/jsLoader.js +8 -0
  68. package/dist/security/jsLoader.js.map +1 -1
  69. package/dist/security/keys.d.ts +32 -0
  70. package/dist/security/keys.js +147 -0
  71. package/dist/security/keys.js.map +1 -1
  72. package/dist/server/REST.js +67 -1
  73. package/dist/server/REST.js.map +1 -1
  74. package/dist/server/Server.d.ts +6 -0
  75. package/dist/server/Server.js.map +1 -1
  76. package/dist/server/http.d.ts +2 -0
  77. package/dist/server/http.js +139 -14
  78. package/dist/server/http.js.map +1 -1
  79. package/dist/server/operationsServer.js +3 -3
  80. package/dist/server/operationsServer.js.map +1 -1
  81. package/dist/server/serverHelpers/progressEmitter.js +5 -1
  82. package/dist/server/serverHelpers/progressEmitter.js.map +1 -1
  83. package/dist/server/threads/threadServer.js +9 -5
  84. package/dist/server/threads/threadServer.js.map +1 -1
  85. package/dist/sqlEngine/binder/bind.d.ts +72 -0
  86. package/dist/sqlEngine/binder/bind.js +289 -0
  87. package/dist/sqlEngine/binder/bind.js.map +1 -0
  88. package/dist/sqlEngine/config.d.ts +35 -0
  89. package/dist/sqlEngine/config.js +62 -0
  90. package/dist/sqlEngine/config.js.map +1 -0
  91. package/dist/sqlEngine/diff/differential.d.ts +23 -0
  92. package/dist/sqlEngine/diff/differential.js +90 -0
  93. package/dist/sqlEngine/diff/differential.js.map +1 -0
  94. package/dist/sqlEngine/errors.d.ts +22 -0
  95. package/dist/sqlEngine/errors.js +36 -0
  96. package/dist/sqlEngine/errors.js.map +1 -0
  97. package/dist/sqlEngine/executor/runMutation.d.ts +35 -0
  98. package/dist/sqlEngine/executor/runMutation.js +352 -0
  99. package/dist/sqlEngine/executor/runMutation.js.map +1 -0
  100. package/dist/sqlEngine/executor/runSelect.d.ts +7 -0
  101. package/dist/sqlEngine/executor/runSelect.js +14 -0
  102. package/dist/sqlEngine/executor/runSelect.js.map +1 -0
  103. package/dist/sqlEngine/expressions/compile.d.ts +24 -0
  104. package/dist/sqlEngine/expressions/compile.js +326 -0
  105. package/dist/sqlEngine/expressions/compile.js.map +1 -0
  106. package/dist/sqlEngine/functions/aggregates.d.ts +12 -0
  107. package/dist/sqlEngine/functions/aggregates.js +299 -0
  108. package/dist/sqlEngine/functions/aggregates.js.map +1 -0
  109. package/dist/sqlEngine/functions/registry.d.ts +36 -0
  110. package/dist/sqlEngine/functions/registry.js +33 -0
  111. package/dist/sqlEngine/functions/registry.js.map +1 -0
  112. package/dist/sqlEngine/functions/standard.d.ts +9 -0
  113. package/dist/sqlEngine/functions/standard.js +86 -0
  114. package/dist/sqlEngine/functions/standard.js.map +1 -0
  115. package/dist/sqlEngine/index.d.ts +29 -0
  116. package/dist/sqlEngine/index.js +62 -0
  117. package/dist/sqlEngine/index.js.map +1 -0
  118. package/dist/sqlEngine/logical/build.d.ts +20 -0
  119. package/dist/sqlEngine/logical/build.js +326 -0
  120. package/dist/sqlEngine/logical/build.js.map +1 -0
  121. package/dist/sqlEngine/logical/op.d.ts +105 -0
  122. package/dist/sqlEngine/logical/op.js +10 -0
  123. package/dist/sqlEngine/logical/op.js.map +1 -0
  124. package/dist/sqlEngine/optimizer/joinAnalysis.d.ts +37 -0
  125. package/dist/sqlEngine/optimizer/joinAnalysis.js +144 -0
  126. package/dist/sqlEngine/optimizer/joinAnalysis.js.map +1 -0
  127. package/dist/sqlEngine/optimizer/optimize.d.ts +13 -0
  128. package/dist/sqlEngine/optimizer/optimize.js +53 -0
  129. package/dist/sqlEngine/optimizer/optimize.js.map +1 -0
  130. package/dist/sqlEngine/optimizer/ruleEngine.d.ts +7 -0
  131. package/dist/sqlEngine/optimizer/ruleEngine.js +26 -0
  132. package/dist/sqlEngine/optimizer/ruleEngine.js.map +1 -0
  133. package/dist/sqlEngine/optimizer/rules/limitPushdown.d.ts +19 -0
  134. package/dist/sqlEngine/optimizer/rules/limitPushdown.js +55 -0
  135. package/dist/sqlEngine/optimizer/rules/limitPushdown.js.map +1 -0
  136. package/dist/sqlEngine/optimizer/rules/planJoins.d.ts +19 -0
  137. package/dist/sqlEngine/optimizer/rules/planJoins.js +62 -0
  138. package/dist/sqlEngine/optimizer/rules/planJoins.js.map +1 -0
  139. package/dist/sqlEngine/optimizer/rules/predicateNormalize.d.ts +18 -0
  140. package/dist/sqlEngine/optimizer/rules/predicateNormalize.js +80 -0
  141. package/dist/sqlEngine/optimizer/rules/predicateNormalize.js.map +1 -0
  142. package/dist/sqlEngine/optimizer/rules/predicatePushdown.d.ts +11 -0
  143. package/dist/sqlEngine/optimizer/rules/predicatePushdown.js +48 -0
  144. package/dist/sqlEngine/optimizer/rules/predicatePushdown.js.map +1 -0
  145. package/dist/sqlEngine/optimizer/rules/projectionPushdown.d.ts +23 -0
  146. package/dist/sqlEngine/optimizer/rules/projectionPushdown.js +160 -0
  147. package/dist/sqlEngine/optimizer/rules/projectionPushdown.js.map +1 -0
  148. package/dist/sqlEngine/optimizer/rules/sortFromIndex.d.ts +14 -0
  149. package/dist/sqlEngine/optimizer/rules/sortFromIndex.js +49 -0
  150. package/dist/sqlEngine/optimizer/rules/sortFromIndex.js.map +1 -0
  151. package/dist/sqlEngine/optimizer/rules/validateScannable.d.ts +15 -0
  152. package/dist/sqlEngine/optimizer/rules/validateScannable.js +60 -0
  153. package/dist/sqlEngine/optimizer/rules/validateScannable.js.map +1 -0
  154. package/dist/sqlEngine/optimizer/whereToConditions.d.ts +85 -0
  155. package/dist/sqlEngine/optimizer/whereToConditions.js +484 -0
  156. package/dist/sqlEngine/optimizer/whereToConditions.js.map +1 -0
  157. package/dist/sqlEngine/parser/ast.d.ts +139 -0
  158. package/dist/sqlEngine/parser/ast.js +10 -0
  159. package/dist/sqlEngine/parser/ast.js.map +1 -0
  160. package/dist/sqlEngine/parser/normalizer.d.ts +28 -0
  161. package/dist/sqlEngine/parser/normalizer.js +413 -0
  162. package/dist/sqlEngine/parser/normalizer.js.map +1 -0
  163. package/dist/sqlEngine/parser/parse.d.ts +14 -0
  164. package/dist/sqlEngine/parser/parse.js +20 -0
  165. package/dist/sqlEngine/parser/parse.js.map +1 -0
  166. package/dist/sqlEngine/physical/PhysicalDistinct.d.ts +9 -0
  167. package/dist/sqlEngine/physical/PhysicalDistinct.js +29 -0
  168. package/dist/sqlEngine/physical/PhysicalDistinct.js.map +1 -0
  169. package/dist/sqlEngine/physical/PhysicalFilter.d.ts +10 -0
  170. package/dist/sqlEngine/physical/PhysicalFilter.js +25 -0
  171. package/dist/sqlEngine/physical/PhysicalFilter.js.map +1 -0
  172. package/dist/sqlEngine/physical/PhysicalHashAggregate.d.ts +27 -0
  173. package/dist/sqlEngine/physical/PhysicalHashAggregate.js +100 -0
  174. package/dist/sqlEngine/physical/PhysicalHashAggregate.js.map +1 -0
  175. package/dist/sqlEngine/physical/PhysicalHashJoin.d.ts +29 -0
  176. package/dist/sqlEngine/physical/PhysicalHashJoin.js +93 -0
  177. package/dist/sqlEngine/physical/PhysicalHashJoin.js.map +1 -0
  178. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.d.ts +36 -0
  179. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.js +78 -0
  180. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.js.map +1 -0
  181. package/dist/sqlEngine/physical/PhysicalIndexScan.d.ts +22 -0
  182. package/dist/sqlEngine/physical/PhysicalIndexScan.js +77 -0
  183. package/dist/sqlEngine/physical/PhysicalIndexScan.js.map +1 -0
  184. package/dist/sqlEngine/physical/PhysicalLimit.d.ts +5 -0
  185. package/dist/sqlEngine/physical/PhysicalLimit.js +28 -0
  186. package/dist/sqlEngine/physical/PhysicalLimit.js.map +1 -0
  187. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.d.ts +20 -0
  188. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.js +51 -0
  189. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.js.map +1 -0
  190. package/dist/sqlEngine/physical/PhysicalProject.d.ts +18 -0
  191. package/dist/sqlEngine/physical/PhysicalProject.js +117 -0
  192. package/dist/sqlEngine/physical/PhysicalProject.js.map +1 -0
  193. package/dist/sqlEngine/physical/PhysicalQualify.d.ts +13 -0
  194. package/dist/sqlEngine/physical/PhysicalQualify.js +29 -0
  195. package/dist/sqlEngine/physical/PhysicalQualify.js.map +1 -0
  196. package/dist/sqlEngine/physical/PhysicalSort.d.ts +12 -0
  197. package/dist/sqlEngine/physical/PhysicalSort.js +59 -0
  198. package/dist/sqlEngine/physical/PhysicalSort.js.map +1 -0
  199. package/dist/sqlEngine/physical/op.d.ts +11 -0
  200. package/dist/sqlEngine/physical/op.js +9 -0
  201. package/dist/sqlEngine/physical/op.js.map +1 -0
  202. package/dist/sqlEngine/physical/plan.d.ts +23 -0
  203. package/dist/sqlEngine/physical/plan.js +162 -0
  204. package/dist/sqlEngine/physical/plan.js.map +1 -0
  205. package/dist/sqlEngine/router.d.ts +26 -0
  206. package/dist/sqlEngine/router.js +48 -0
  207. package/dist/sqlEngine/router.js.map +1 -0
  208. package/dist/sqlEngine/types.d.ts +37 -0
  209. package/dist/sqlEngine/types.js +13 -0
  210. package/dist/sqlEngine/types.js.map +1 -0
  211. package/dist/sqlTranslator/index.js +7 -1
  212. package/dist/sqlTranslator/index.js.map +1 -1
  213. package/dist/utility/common_utils.js +25 -0
  214. package/dist/utility/common_utils.js.map +1 -1
  215. package/dist/utility/install/installer.d.ts +9 -1
  216. package/dist/utility/install/installer.js +21 -0
  217. package/dist/utility/install/installer.js.map +1 -1
  218. package/dist/validation/configValidator.js +3 -0
  219. package/dist/validation/configValidator.js.map +1 -1
  220. package/dist/validation/deleteValidator.js +10 -2
  221. package/dist/validation/deleteValidator.js.map +1 -1
  222. package/npm-shrinkwrap.json +272 -230
  223. package/package.json +3 -3
  224. package/resources/DESIGN.md +17 -16
  225. package/resources/DatabaseTransaction.ts +95 -0
  226. package/resources/RequestTarget.ts +12 -3
  227. package/resources/Resource.ts +23 -2
  228. package/resources/Table.ts +57 -6
  229. package/resources/analytics/metadata.ts +3 -0
  230. package/resources/analytics/write.ts +23 -0
  231. package/resources/databases.ts +24 -0
  232. package/resources/defineResource.ts +17 -4
  233. package/resources/jsResource.ts +61 -2
  234. package/resources/openApi.ts +44 -19
  235. package/resources/scheduler/CronExpression.ts +394 -0
  236. package/resources/scheduler/engine.ts +812 -0
  237. package/resources/scheduler/scheduler.ts +236 -0
  238. package/security/auth.ts +1 -0
  239. package/security/jsLoader.ts +8 -0
  240. package/security/keys.ts +152 -0
  241. package/server/REST.ts +70 -1
  242. package/server/Server.ts +6 -0
  243. package/server/http.ts +122 -15
  244. package/server/operationsServer.ts +5 -3
  245. package/server/serverHelpers/progressEmitter.ts +5 -1
  246. package/server/threads/threadServer.js +9 -5
  247. package/sqlTranslator/index.ts +16 -6
  248. package/studio/web/assets/{Chat-CTjtL8Z4.js → Chat-DHP4XpID.js} +2 -2
  249. package/studio/web/assets/{Chat-CTjtL8Z4.js.map → Chat-DHP4XpID.js.map} +1 -1
  250. package/studio/web/assets/{FloatingChat-CafHR4Ur.js → FloatingChat-CJ7PssCv.js} +4 -4
  251. package/studio/web/assets/{FloatingChat-CafHR4Ur.js.map → FloatingChat-CJ7PssCv.js.map} +1 -1
  252. package/studio/web/assets/{applications-Buh_q0Vj.js → applications-DxXiGpsR.js} +2 -2
  253. package/studio/web/assets/{applications-Buh_q0Vj.js.map → applications-DxXiGpsR.js.map} +1 -1
  254. package/studio/web/assets/{index-0hXeECkS.js → index-BdbBanDP.js} +6 -6
  255. package/studio/web/assets/{index-0hXeECkS.js.map → index-BdbBanDP.js.map} +1 -1
  256. package/studio/web/assets/{index.lazy-B00B7VBT.js → index.lazy-B2eH28zD.js} +4 -4
  257. package/studio/web/assets/{index.lazy-B00B7VBT.js.map → index.lazy-B2eH28zD.js.map} +1 -1
  258. package/studio/web/assets/{profile-Cg2wwYPn.js → profile-DK5hgucv.js} +2 -2
  259. package/studio/web/assets/{profile-Cg2wwYPn.js.map → profile-DK5hgucv.js.map} +1 -1
  260. package/studio/web/assets/{setComponentFile-DCaDIvyB.js → setComponentFile-BVDWRYxx.js} +2 -2
  261. package/studio/web/assets/{setComponentFile-DCaDIvyB.js.map → setComponentFile-BVDWRYxx.js.map} +1 -1
  262. package/studio/web/assets/{setup-CAVcAQjK.js → setup-DJ9BInoK.js} +2 -2
  263. package/studio/web/assets/{setup-CAVcAQjK.js.map → setup-DJ9BInoK.js.map} +1 -1
  264. package/studio/web/assets/{status-BRXorNdD.js → status-B_qzmgfD.js} +2 -2
  265. package/studio/web/assets/{status-BRXorNdD.js.map → status-B_qzmgfD.js.map} +1 -1
  266. package/studio/web/assets/{swagger-ui-react-Dy1D62vO.js → swagger-ui-react-DOL5jCqg.js} +2 -2
  267. package/studio/web/assets/{swagger-ui-react-Dy1D62vO.js.map → swagger-ui-react-DOL5jCqg.js.map} +1 -1
  268. package/studio/web/assets/{tsMode-A8gbL74v.js → tsMode-DpxUxfTW.js} +2 -2
  269. package/studio/web/assets/{tsMode-A8gbL74v.js.map → tsMode-DpxUxfTW.js.map} +1 -1
  270. package/studio/web/assets/{useEntityRestURL-cDodrVcQ.js → useEntityRestURL-CU_lY6XW.js} +2 -2
  271. package/studio/web/assets/{useEntityRestURL-cDodrVcQ.js.map → useEntityRestURL-CU_lY6XW.js.map} +1 -1
  272. package/studio/web/index.html +1 -1
  273. package/utility/common_utils.ts +26 -0
  274. package/utility/install/installer.ts +26 -1
  275. package/validation/configValidator.ts +3 -0
  276. package/validation/deleteValidator.ts +11 -2
@@ -0,0 +1,812 @@
1
+ import { hostname } from 'node:os';
2
+ import { table } from '../databases.ts';
3
+ import { server } from '../../server/Server.ts';
4
+ import harperLogger from '../../utility/logging/harper_logger.ts';
5
+ import { CronExpression, getSystemTimezone } from './CronExpression.ts';
6
+
7
+ const schedulerLogger = harperLogger.forComponent('scheduler');
8
+
9
+ // One row per scheduled job (run state) plus a singleton leader-lease row.
10
+ // Lives in the system database and replicates (auditing enabled) so every node
11
+ // sees the current leader's heartbeat and each job's last run — that shared
12
+ // view is what makes "fires once per cluster" and failover catch-up work.
13
+ const SCHEDULER_STATE_TABLE = 'hdb_scheduler_state';
14
+ const LEADER_ROW_ID = 'leader';
15
+
16
+ // Timing constants are env-overridable (same pattern as liveSubscriptionAuth's
17
+ // sweep interval) primarily so multi-node integration tests can exercise
18
+ // failover without waiting out the production thresholds
19
+ function timingFromEnv(name: string, defaultMs: number): number {
20
+ const value = Number(process.env[name]);
21
+ return Number.isFinite(value) && value > 0 ? value : defaultMs;
22
+ }
23
+ export const HEARTBEAT_INTERVAL_MS = timingFromEnv('HARPER_SCHEDULER_HEARTBEAT_INTERVAL_MS', 60_000);
24
+ const rawStaleThreshold = timingFromEnv('HARPER_SCHEDULER_STALE_THRESHOLD_MS', 5 * 60 * 1000);
25
+ // A stale threshold at or below the heartbeat interval makes every healthy
26
+ // leader look dead (leadership flaps, each promotion re-running catch-up);
27
+ // clamp the misconfiguration rather than honor it (audit finding)
28
+ export const STALE_THRESHOLD_MS =
29
+ rawStaleThreshold > HEARTBEAT_INTERVAL_MS ? rawStaleThreshold : HEARTBEAT_INTERVAL_MS * 5;
30
+ export const FAILOVER_WATCHER_INTERVAL_MS = timingFromEnv('HARPER_SCHEDULER_FAILOVER_WATCHER_INTERVAL_MS', 75_000);
31
+ // setTimeout clamps to a 32-bit signed int; longer delays wrap to ~1ms and busy-loop
32
+ const MAX_TIMEOUT_MS = 0x7fffffff;
33
+ const MAX_STORED_ERROR_LENGTH = 500;
34
+
35
+ export interface JobRunContext {
36
+ jobName: string;
37
+ // The time this run was scheduled to fire (for catch-up runs, the missed occurrence)
38
+ scheduledAt: Date;
39
+ // True when this run is making up a missed occurrence after downtime or failover
40
+ catchUp: boolean;
41
+ }
42
+
43
+ export interface ScheduledJob {
44
+ name: string;
45
+ componentName: string;
46
+ cron?: CronExpression;
47
+ intervalMs?: number;
48
+ timezone?: string;
49
+ handler: (context: JobRunContext) => unknown;
50
+ }
51
+
52
+ interface RegisteredJob extends ScheduledJob {
53
+ timer?: NodeJS.Timeout;
54
+ running: boolean;
55
+ // In-memory anchor for interval scheduling: survives state-write failures so
56
+ // a frozen persisted lastRunAt cannot time-travel the schedule into a hot
57
+ // loop (review finding: continuous refire under ENOSPC-style write errors)
58
+ lastAttemptAt?: number;
59
+ // In-flight scheduling computation, used to coalesce concurrent
60
+ // scheduleNextRun calls (see scheduleNextRun)
61
+ scheduling?: Promise<void>;
62
+ }
63
+
64
+ type EngineRole = 'inactive' | 'follower' | 'leader';
65
+
66
+ let engineStarted = false;
67
+ let role: EngineRole = 'inactive';
68
+ let heartbeatTimer: NodeJS.Timeout | undefined;
69
+ let failoverWatcherTimer: NodeJS.Timeout | undefined;
70
+ let catchUpRunning = false;
71
+ // First time this follower observed the cluster leaderless (no row or stale
72
+ // heartbeat); drives the promotion escalation ladder in failoverCheck
73
+ let leaderlessSince: number | undefined;
74
+ // When this node became leader — fallback for the lease row's initializedAt
75
+ // when a heartbeat renews after a failed read
76
+ let leaderInitializedAt: string | undefined;
77
+ // The in-flight (or settled) election kicked off by startSchedulerEngine —
78
+ // held so tests can await the role decision instead of polling
79
+ let electionPromise: Promise<void> | undefined;
80
+ // Bumped by stopSchedulerEngine: async transitions (electRole, becomeLeader)
81
+ // capture it before their awaits and abandon themselves if it moved — an
82
+ // in-flight election must not resurrect timers after a stop (review finding)
83
+ let engineEpoch = 0;
84
+ const jobsByComponent = new Map<string, Map<string, RegisteredJob>>();
85
+
86
+ let _stateTable: any;
87
+ function getStateTable() {
88
+ _stateTable ??= table({
89
+ database: 'system',
90
+ table: SCHEDULER_STATE_TABLE,
91
+ // The cluster-once guarantee depends on every node seeing this table:
92
+ // declare replication POSITIVELY rather than relying on absence from the
93
+ // non-replicating list, so an operator-scoped `replication.databases`
94
+ // config cannot silently exclude it (review finding — that degradation
95
+ // mode is N independent leaders running every job N times)
96
+ replicate: true,
97
+ // Replication of system tables requires auditing
98
+ audit: true,
99
+ attributes: [
100
+ // 'leader' for the lease row; 'job:<component>:<name>' for run state
101
+ { name: 'id', type: 'string', isPrimaryKey: true },
102
+ { name: 'leaderNode', type: 'string' },
103
+ { name: 'lastHeartbeat', type: 'string' },
104
+ { name: 'initializedAt', type: 'string' },
105
+ { name: 'firstSeenAt', type: 'string' },
106
+ { name: 'lastRunAt', type: 'string' },
107
+ { name: 'lastStatus', type: 'string' },
108
+ { name: 'lastError', type: 'string' },
109
+ { name: 'lastDurationMs', type: 'number' },
110
+ ],
111
+ });
112
+ return _stateTable;
113
+ }
114
+
115
+ function jobRowId(job: ScheduledJob): string {
116
+ return `job:${job.componentName}:${job.name}`;
117
+ }
118
+
119
+ // Every state-table operation is bounded: observed empirically (2-node
120
+ // failover) that a read/write issued on a freshly promoted leader — while the
121
+ // replication link to the dead peer is still churning — can stall
122
+ // indefinitely, wedging the promotion pipeline behind its await while the
123
+ // already-started heartbeat keeps renewing the lease. The result was a
124
+ // healthy-looking leader running zero jobs, with no error anywhere. A bounded
125
+ // operation instead fails into the existing degraded paths (schedule from
126
+ // now / skip this sweep / retry next heartbeat).
127
+ const STATE_OPERATION_TIMEOUT_MS = 10_000;
128
+
129
+ function withStateTimeout<T>(operation: Promise<T>, what: string): Promise<T> {
130
+ return new Promise<T>((resolve, reject) => {
131
+ const timer = setTimeout(
132
+ () => reject(new Error(`${what} timed out after ${STATE_OPERATION_TIMEOUT_MS}ms`)),
133
+ STATE_OPERATION_TIMEOUT_MS
134
+ );
135
+ timer.unref();
136
+ operation.then(
137
+ (value) => {
138
+ clearTimeout(timer);
139
+ resolve(value);
140
+ },
141
+ (error) => {
142
+ clearTimeout(timer);
143
+ reject(error);
144
+ }
145
+ );
146
+ });
147
+ }
148
+
149
+ /**
150
+ * Job handlers and storage drivers can throw anything — including objects
151
+ * whose property getters themselves throw — so even reading .message is
152
+ * guarded here (surfaced by review).
153
+ */
154
+ export function safeErrorMessage(error: unknown): string {
155
+ try {
156
+ return (error as any)?.message || String(error);
157
+ } catch {
158
+ try {
159
+ return String(error);
160
+ } catch {
161
+ return 'unknown error';
162
+ }
163
+ }
164
+ }
165
+
166
+ function currentNodeName(): string {
167
+ // server.hostname is the node's replication identity; hostname() is only a
168
+ // local-dev fallback where there is no cluster and the name is just a label
169
+ return (server as any).hostname || hostname();
170
+ }
171
+
172
+ function nodeRoster(): string[] {
173
+ // server.nodes lists PEER nodes only (populated by the replication
174
+ // component); the current node is not included and the list is absent
175
+ // entirely on a single standalone instance
176
+ const peers = ((server as any).nodes || []).map((node: any) => node?.name).filter(Boolean);
177
+ return [...new Set([...peers, currentNodeName()])].sort() as string[];
178
+ }
179
+
180
+ /**
181
+ * Deterministic election: the alphabetically-first node wins, excluding a
182
+ * stale leader so a wedged node cannot immediately re-elect itself. Every node
183
+ * evaluates this identically against the same replicated state, so no
184
+ * consensus round is needed.
185
+ */
186
+ export function pickNextLeader(roster: string[], staleLeader: string | null): string | null {
187
+ if (roster.length === 0) return null;
188
+ if (!staleLeader) return roster[0];
189
+ const eligible = roster.filter((name) => name !== staleLeader);
190
+ return eligible.length > 0 ? eligible[0] : roster[0];
191
+ }
192
+
193
+ export function isHeartbeatStale(lastHeartbeat: string | undefined, now: number = Date.now()): boolean {
194
+ if (!lastHeartbeat) return true;
195
+ const heartbeatTime = Date.parse(lastHeartbeat);
196
+ return Number.isNaN(heartbeatTime) || now - heartbeatTime > STALE_THRESHOLD_MS;
197
+ }
198
+
199
+ // Escalation ladder for leaderless promotion: each successive fallback node
200
+ // waits this much longer before claiming leadership, giving preferred nodes
201
+ // (which check every FAILOVER_WATCHER_INTERVAL_MS) time to claim it first.
202
+ export const PROMOTION_ESCALATION_MS = 2 * FAILOVER_WATCHER_INTERVAL_MS;
203
+
204
+ /**
205
+ * How long this node should observe a leaderless cluster before promoting
206
+ * itself. The preferred (alphabetically-first eligible) node promotes
207
+ * immediately; each subsequent node adds one escalation interval, so a dead or
208
+ * never-started preferred node cannot deadlock the cluster — the next node in
209
+ * line claims leadership one rung later, and sticky leadership plus the
210
+ * heartbeat takeover check heal any race between rungs.
211
+ */
212
+ export function promotionWaitMs(roster: string[], self: string, staleLeader: string | null): number {
213
+ const eligible = staleLeader ? roster.filter((name) => name !== staleLeader) : roster;
214
+ const queue = eligible.length > 0 ? eligible : roster;
215
+ const queueIndex = queue.indexOf(self);
216
+ // A node not in the queue (it IS the stale leader) goes to the back
217
+ const rung = queueIndex < 0 ? queue.length : queueIndex;
218
+ return rung * PROMOTION_ESCALATION_MS;
219
+ }
220
+
221
+ /**
222
+ * The cron occurrence that should have fired but didn't, or null if the job is
223
+ * up to date. `baseline` is the job's last run (or when it was first seen, so
224
+ * a newly-deployed job waits for its first scheduled time instead of firing
225
+ * immediately).
226
+ */
227
+ export function findMissedCronOccurrence(
228
+ cron: CronExpression,
229
+ timezone: string | undefined,
230
+ baseline: Date,
231
+ now: Date
232
+ ): Date | null {
233
+ const previousOccurrence = cron.previousDate(now, timezone ?? getSystemTimezone());
234
+ if (previousOccurrence && previousOccurrence.getTime() > baseline.getTime()) return previousOccurrence;
235
+ return null;
236
+ }
237
+
238
+ /**
239
+ * Register (or replace) the scheduled jobs for a component. Called from the
240
+ * scheduler plugin's handleApplication on the scheduling worker; safe to call
241
+ * repeatedly — a reload or redeploy replaces the component's whole job set.
242
+ */
243
+ export function registerComponentJobs(componentName: string, jobs: ScheduledJob[]): void {
244
+ unregisterComponentJobs(componentName);
245
+ const jobMap = new Map<string, RegisteredJob>();
246
+ for (const job of jobs) {
247
+ jobMap.set(job.name, { ...job, running: false });
248
+ }
249
+ jobsByComponent.set(componentName, jobMap);
250
+ if (role === 'leader') {
251
+ // Fire-and-forget: scheduling reads run state from the DB and must not
252
+ // block handleApplication (which holds a cross-thread load lock)
253
+ scheduleComponentJobs(componentName).catch((error) => {
254
+ schedulerLogger.error?.(`Failed to schedule jobs for ${componentName}`, error);
255
+ });
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Cancel timers and forget the jobs of a component (its scope is closing —
261
+ * worker shutdown, redeploy, or a discarded deploy-validation load).
262
+ *
263
+ * Leadership is deliberately retained even if this empties the job set: the
264
+ * common cause is a reload that re-registers moments later, and stepping down
265
+ * here would leave the engine unable to re-elect (startSchedulerEngine is
266
+ * one-shot per worker). An idle leader heartbeating a zero-job cluster is
267
+ * harmless and resolves on the next worker restart.
268
+ */
269
+ export function unregisterComponentJobs(componentName: string): void {
270
+ const jobMap = jobsByComponent.get(componentName);
271
+ if (!jobMap) return;
272
+ for (const job of jobMap.values()) {
273
+ if (job.timer) clearTimeout(job.timer);
274
+ }
275
+ jobsByComponent.delete(componentName);
276
+ }
277
+
278
+ /**
279
+ * Start the engine's cluster role (leader or follower). Idempotent; called
280
+ * once per scheduling worker regardless of how many components declare jobs.
281
+ * All the real work happens asynchronously so the caller (handleApplication,
282
+ * which holds a cross-thread load lock with a 30s timeout) returns fast.
283
+ */
284
+ export function startSchedulerEngine(): void {
285
+ if (engineStarted) return;
286
+ engineStarted = true;
287
+ electionPromise = electRole().catch((error) => {
288
+ schedulerLogger.error?.('Scheduler engine failed to start', error);
289
+ });
290
+ }
291
+
292
+ /** @internal — testing only: resolves when the initial election has settled */
293
+ export function electionSettledForTests(): Promise<void> {
294
+ return electionPromise ?? Promise.resolve();
295
+ }
296
+
297
+ /**
298
+ * @internal — testing only: run one heartbeat tick immediately (same body the
299
+ * interval runs), so tests can drive lease renewal / takeover checks without
300
+ * waiting out HEARTBEAT_INTERVAL_MS. Same seam pattern as
301
+ * setCoolingFunctionForTests in transactionLogCooling.
302
+ */
303
+ export function runHeartbeatForTests(): Promise<void> {
304
+ return heartbeat();
305
+ }
306
+
307
+ /** @internal — testing only: run one failover-watcher tick immediately */
308
+ export function runFailoverCheckForTests(): Promise<void> {
309
+ return failoverCheck();
310
+ }
311
+
312
+ /** Reset all engine state and timers. Intended for tests. */
313
+ export function stopSchedulerEngine(): void {
314
+ for (const componentName of [...jobsByComponent.keys()]) {
315
+ unregisterComponentJobs(componentName);
316
+ }
317
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
318
+ if (failoverWatcherTimer) clearInterval(failoverWatcherTimer);
319
+ heartbeatTimer = undefined;
320
+ failoverWatcherTimer = undefined;
321
+ role = 'inactive';
322
+ engineStarted = false;
323
+ catchUpRunning = false;
324
+ leaderlessSince = undefined;
325
+ leaderInitializedAt = undefined;
326
+ electionPromise = undefined;
327
+ engineEpoch++;
328
+ _stateTable = undefined;
329
+ }
330
+
331
+ export function getEngineRole(): EngineRole {
332
+ return role;
333
+ }
334
+
335
+ /** @internal — testing only */
336
+ export function getRegisteredJobNames(componentName: string): string[] {
337
+ return [...(jobsByComponent.get(componentName)?.keys() ?? [])];
338
+ }
339
+
340
+ async function electRole(): Promise<void> {
341
+ const self = currentNodeName();
342
+ const epoch = engineEpoch;
343
+ let leaderRow: any;
344
+ try {
345
+ leaderRow = await withStateTimeout(getStateTable().get(LEADER_ROW_ID), 'leader state read');
346
+ } catch (error) {
347
+ if (epoch !== engineEpoch) return; // stopped while electing
348
+ // Fail toward followership: electing ourselves while the state table is
349
+ // unreadable risks a second leader. The failover watcher keeps checking
350
+ // and promotes once reads succeed and show a leaderless cluster.
351
+ schedulerLogger.warn?.(`Could not read scheduler leader state, defaulting to follower: ${safeErrorMessage(error)}`);
352
+ becomeFollower();
353
+ return;
354
+ }
355
+ if (epoch !== engineEpoch) return; // stopped while electing
356
+ // Sticky leadership: a node (re)starting while another node is actively
357
+ // leading defers to it rather than seizing leadership back
358
+ if (leaderRow && leaderRow.leaderNode !== self && !isHeartbeatStale(leaderRow.lastHeartbeat)) {
359
+ schedulerLogger.info?.(
360
+ `Scheduler leader is ${leaderRow.leaderNode} (heartbeat fresh); ${self} watching for failover`
361
+ );
362
+ becomeFollower();
363
+ return;
364
+ }
365
+ // A fresh lease naming THIS node means a prior same-node incarnation was
366
+ // just leading (overlapping worker restart) — skip the promotion catch-up
367
+ // so its still-committing runs are not immediately re-fired
368
+ const previousIncarnationActive =
369
+ leaderRow != null && leaderRow.leaderNode === self && !isHeartbeatStale(leaderRow.lastHeartbeat);
370
+ const roster = nodeRoster();
371
+ if (roster.length <= 1 || pickNextLeader(roster, null) === self) {
372
+ await becomeLeader(previousIncarnationActive);
373
+ } else {
374
+ schedulerLogger.info?.(`Scheduler leader election chose ${pickNextLeader(roster, null)}; ${self} is a follower`);
375
+ becomeFollower();
376
+ }
377
+ }
378
+
379
+ async function becomeLeader(skipInitialCatchUp = false): Promise<void> {
380
+ const self = currentNodeName();
381
+ const epoch = engineEpoch;
382
+ role = 'leader';
383
+ if (failoverWatcherTimer) {
384
+ clearInterval(failoverWatcherTimer);
385
+ failoverWatcherTimer = undefined;
386
+ }
387
+ // A second promotion (e.g. two failover checks in flight) must not orphan
388
+ // the first heartbeat interval — an orphaned heartbeat would keep renewing
389
+ // the lease after step-down with no timers armed, silently stopping all
390
+ // jobs cluster-wide (audit finding)
391
+ if (heartbeatTimer) {
392
+ clearInterval(heartbeatTimer);
393
+ heartbeatTimer = undefined;
394
+ }
395
+ schedulerLogger.info?.(`Scheduler leader started on ${self}`);
396
+ if (getStateTable().replicate === false) {
397
+ // The cluster-once guarantee is void if this table stops replicating;
398
+ // fail loudly instead of silently multiplying job executions
399
+ schedulerLogger.error?.(
400
+ `${SCHEDULER_STATE_TABLE} is not replicating — scheduled jobs may run on every node instead of once per cluster`
401
+ );
402
+ }
403
+ const now = new Date().toISOString();
404
+ leaderInitializedAt = now;
405
+ await putStateRow({ id: LEADER_ROW_ID, leaderNode: self, lastHeartbeat: now, initializedAt: now });
406
+ // A stop while the lease write was in flight must not resurrect the
407
+ // heartbeat interval (review finding: in-flight transitions surviving
408
+ // stopSchedulerEngine)
409
+ if (epoch !== engineEpoch) return;
410
+ // The heartbeat interval must be beating BEFORE the promotion catch-up
411
+ // pass: catch-up runs user handlers serially and can exceed the stale
412
+ // threshold, and a leader that stops renewing mid-catch-up looks dead —
413
+ // the next follower would promote and re-run the same occurrences
414
+ // (review finding). runCatchUp is single-flight, so the first heartbeat
415
+ // tick overlapping the promotion pass skips its own sweep.
416
+ heartbeatTimer = setInterval(() => {
417
+ heartbeat().catch((error) => schedulerLogger.error?.('Scheduler heartbeat failed', error));
418
+ }, HEARTBEAT_INTERVAL_MS);
419
+ heartbeatTimer.unref();
420
+ for (const componentName of jobsByComponent.keys()) {
421
+ await scheduleComponentJobs(componentName);
422
+ }
423
+ // Skipped when a prior same-node incarnation was just leading (overlapping
424
+ // worker restart): its runs may still be committing, and an immediate
425
+ // catch-up pass would re-fire the occurrence it is mid-executing. The
426
+ // heartbeat sweep still catches genuinely missed occurrences within 60s.
427
+ if (!skipInitialCatchUp) await runCatchUp();
428
+ }
429
+
430
+ function becomeFollower(): void {
431
+ role = 'follower';
432
+ leaderlessSince = undefined;
433
+ if (heartbeatTimer) {
434
+ clearInterval(heartbeatTimer);
435
+ heartbeatTimer = undefined;
436
+ }
437
+ for (const jobMap of jobsByComponent.values()) {
438
+ for (const job of jobMap.values()) {
439
+ if (job.timer) {
440
+ clearTimeout(job.timer);
441
+ job.timer = undefined;
442
+ }
443
+ }
444
+ }
445
+ if (failoverWatcherTimer) return;
446
+ failoverWatcherTimer = setInterval(() => {
447
+ failoverCheck().catch((error) => schedulerLogger.error?.('Scheduler failover check failed', error));
448
+ }, FAILOVER_WATCHER_INTERVAL_MS);
449
+ failoverWatcherTimer.unref();
450
+ }
451
+
452
+ async function heartbeat(): Promise<void> {
453
+ // A tick from a stale interval (already stepped down / re-promoted) must
454
+ // not renew the lease
455
+ if (role !== 'leader') return;
456
+ const self = currentNodeName();
457
+ let leaderRow: any;
458
+ try {
459
+ leaderRow = await withStateTimeout(getStateTable().get(LEADER_ROW_ID), 'leader state read');
460
+ } catch (error) {
461
+ // A failed read must NOT abort the tick: skipping renewal makes a live
462
+ // leader look stale (a follower would promote and dual-execute), and
463
+ // skipping the takeover check breaks the path that heals dual
464
+ // leadership. Renew blind; the step-down check runs on the next tick.
465
+ schedulerLogger.warn?.(`Failed to read leader state during heartbeat: ${safeErrorMessage(error)}`);
466
+ }
467
+ // Split-brain healing: if another node has taken over with a fresh
468
+ // heartbeat (e.g. we were partitioned long enough to be considered stale),
469
+ // step down instead of dueling over the lease row
470
+ if (leaderRow != null && leaderRow.leaderNode !== self && !isHeartbeatStale(leaderRow.lastHeartbeat)) {
471
+ schedulerLogger.info?.(`Scheduler leadership was taken over by ${leaderRow.leaderNode}; ${self} stepping down`);
472
+ becomeFollower();
473
+ return;
474
+ }
475
+ await putStateRow({
476
+ id: LEADER_ROW_ID,
477
+ leaderNode: self,
478
+ lastHeartbeat: new Date().toISOString(),
479
+ initializedAt: leaderRow?.initializedAt ?? leaderInitializedAt,
480
+ });
481
+ // Periodic missed-run sweep: catches occurrences lost to DST transitions,
482
+ // worker restarts, and anything else that slipped past the timers
483
+ await runCatchUp();
484
+ }
485
+
486
+ async function failoverCheck(): Promise<void> {
487
+ if (role !== 'follower') return;
488
+ const self = currentNodeName();
489
+ const epoch = engineEpoch;
490
+ let leaderRow: any;
491
+ try {
492
+ leaderRow = await withStateTimeout(getStateTable().get(LEADER_ROW_ID), 'leader state read');
493
+ } catch (error) {
494
+ // Can't tell whether a leader exists; skip this tick (and don't let the
495
+ // leaderless clock run) rather than risk promoting into a split brain
496
+ schedulerLogger.warn?.(`Failed to read leader state during failover check: ${safeErrorMessage(error)}`);
497
+ leaderlessSince = undefined;
498
+ return;
499
+ }
500
+ if (leaderRow != null && !isHeartbeatStale(leaderRow.lastHeartbeat)) {
501
+ leaderlessSince = undefined;
502
+ return;
503
+ }
504
+ // Re-check after the await: a concurrent (stalled) check may have promoted
505
+ // this node already (audit finding), or the engine may have been stopped
506
+ // while the read was in flight (review finding) — either way, do not promote
507
+ if (role !== 'follower' || epoch !== engineEpoch) return;
508
+ const now = Date.now();
509
+ leaderlessSince ??= now;
510
+ const roster = nodeRoster();
511
+ const staleLeader = leaderRow?.leaderNode ?? null;
512
+ const waitMs = promotionWaitMs(roster, self, staleLeader);
513
+ if (now - leaderlessSince >= waitMs) {
514
+ schedulerLogger.info?.(
515
+ staleLeader
516
+ ? `Scheduler leader ${staleLeader} heartbeat is stale; ${self} promoting itself`
517
+ : `No active scheduler leader; ${self} promoting itself`
518
+ );
519
+ leaderlessSince = undefined;
520
+ await becomeLeader();
521
+ } else {
522
+ schedulerLogger.trace?.(
523
+ `Scheduler leader is stale or absent; ${self} promotes in ${Math.round((waitMs - (now - leaderlessSince)) / 1000)}s unless a preferred node claims leadership`
524
+ );
525
+ }
526
+ }
527
+
528
+ async function putStateRow(row: Record<string, unknown>): Promise<void> {
529
+ try {
530
+ await withStateTimeout(getStateTable().put(row), `state write ${row.id}`);
531
+ } catch (error) {
532
+ // State persistence failures must never take the scheduler down; the
533
+ // next heartbeat retries
534
+ schedulerLogger.warn?.(`Failed to persist scheduler state row ${row.id}: ${safeErrorMessage(error)}`);
535
+ }
536
+ }
537
+
538
+ async function scheduleComponentJobs(componentName: string): Promise<void> {
539
+ const jobMap = jobsByComponent.get(componentName);
540
+ if (!jobMap || role !== 'leader') return;
541
+ for (const job of jobMap.values()) {
542
+ await scheduleNextRun(job);
543
+ }
544
+ }
545
+
546
+ // A job is live only while it is the EXACT object the registry holds:
547
+ // registerComponentJobs replaces objects under the same names, so a name-only
548
+ // check would let an in-flight chain re-arm a replaced job forever (review
549
+ // finding: persistent double-fire after redeploy-during-run)
550
+ function isRegistered(job: RegisteredJob): boolean {
551
+ return jobsByComponent.get(job.componentName)?.get(job.name) === job;
552
+ }
553
+
554
+ /**
555
+ * Compute the job's next fire time and arm its timer. Interval jobs anchor to
556
+ * their persisted last run so cadence survives restarts and failover; cron
557
+ * jobs fire at the next matching wall-clock time (missed occurrences are
558
+ * handled by the catch-up sweep instead).
559
+ *
560
+ * Concurrent calls for the same job coalesce onto one computation: the
561
+ * callers (becomeLeader's initial loop, a registration's fire-and-forget, a
562
+ * post-run reschedule, the heartbeat sweep) can otherwise interleave across
563
+ * the state read below, each pass the no-timer check, and each arm a timer —
564
+ * with only the last handle retained, leaving an uncancellable live timer
565
+ * (review finding).
566
+ */
567
+ function scheduleNextRun(job: RegisteredJob): Promise<void> {
568
+ job.scheduling ??= computeAndArmNextRun(job).finally(() => {
569
+ job.scheduling = undefined;
570
+ });
571
+ return job.scheduling;
572
+ }
573
+
574
+ async function computeAndArmNextRun(job: RegisteredJob): Promise<void> {
575
+ if (role !== 'leader' || !isRegistered(job)) return;
576
+ if (job.timer) clearTimeout(job.timer);
577
+ const now = new Date();
578
+ let fireAt: Date;
579
+ if (job.cron) {
580
+ const next = job.cron.nextDate(now, job.timezone ?? getSystemTimezone());
581
+ if (!next) {
582
+ // Transient (e.g. a DST-window computation edge): the catch-up sweep
583
+ // re-arms unarmed cron jobs every heartbeat, so this self-heals
584
+ schedulerLogger.warn?.(`Job ${jobRowId(job)} has no computable next occurrence; retrying at next heartbeat`);
585
+ return;
586
+ }
587
+ fireAt = next;
588
+ } else {
589
+ const stateRow = await getJobStateRow(job);
590
+ const persistedLastRun = stateRow?.lastRunAt ? Date.parse(stateRow.lastRunAt) : NaN;
591
+ // Anchor to the LATEST of the persisted run and the in-memory attempt:
592
+ // the persisted value survives restarts/failover (overdue intervals fire
593
+ // immediately — the interval catch-up path), while the in-memory value
594
+ // survives state-WRITE failures so a frozen persisted row cannot cause
595
+ // an immediate-refire hot loop (review finding). Clamped to now: a
596
+ // FUTURE timestamp from a clock-skewed leader must not wedge the job
597
+ // (audit finding).
598
+ const anchor = Math.min(
599
+ Math.max(Number.isNaN(persistedLastRun) ? 0 : persistedLastRun, job.lastAttemptAt ?? 0),
600
+ now.getTime()
601
+ );
602
+ fireAt = anchor > 0 ? new Date(anchor + job.intervalMs) : new Date(now.getTime() + job.intervalMs);
603
+ }
604
+ // Re-check after the await: the registry (or our role) may have changed
605
+ // while reading job state, and arming a timer for a stale object leaks a
606
+ // timer nothing can cancel
607
+ if (role !== 'leader' || !isRegistered(job)) return;
608
+ armTimer(job, fireAt);
609
+ }
610
+
611
+ function armTimer(job: RegisteredJob, fireAt: Date): void {
612
+ const delay = fireAt.getTime() - Date.now();
613
+ // Fail closed on a non-finite target: setTimeout coerces NaN to ~1ms,
614
+ // which would hot-loop the job (review finding). Cron jobs re-arm via the
615
+ // heartbeat sweep; interval bounds are validated at config load.
616
+ if (!Number.isFinite(delay)) {
617
+ schedulerLogger.error?.(`Job ${jobRowId(job)} computed a non-finite fire time; not arming`);
618
+ return;
619
+ }
620
+ if (delay > MAX_TIMEOUT_MS) {
621
+ // Beyond setTimeout's 32-bit range: sleep the maximum and re-arm
622
+ job.timer = setTimeout(() => {
623
+ job.timer = undefined;
624
+ if (role === 'leader' && isRegistered(job)) armTimer(job, fireAt);
625
+ }, MAX_TIMEOUT_MS);
626
+ } else {
627
+ job.timer = setTimeout(
628
+ () => {
629
+ // Cleared at fire time so "unarmed" is observable: the heartbeat
630
+ // catch-up sweep re-arms cron jobs whose timer is missing
631
+ job.timer = undefined;
632
+ executeJob(job, fireAt, false)
633
+ .catch((error) => schedulerLogger.error?.(`Job ${jobRowId(job)} execution failed unexpectedly`, error))
634
+ .finally(() => {
635
+ scheduleNextRun(job).catch((error) =>
636
+ schedulerLogger.error?.(`Failed to reschedule job ${jobRowId(job)}`, error)
637
+ );
638
+ });
639
+ },
640
+ Math.max(delay, 0)
641
+ );
642
+ }
643
+ job.timer?.unref();
644
+ }
645
+
646
+ // The persisted error replicates cluster-wide; strip filesystem paths (which
647
+ // leak node-local layout) and bound the length
648
+ export function sanitizeStoredError(message: string): string {
649
+ return message
650
+ .replace(/\/(?:Users|home|var|tmp|opt|etc|root|srv|data|mnt)\/[^\s:)]+/g, '[path]')
651
+ .replace(/[A-Za-z]:\\[^\s:)]+/g, '[path]')
652
+ .slice(0, MAX_STORED_ERROR_LENGTH);
653
+ }
654
+
655
+ // Swallow-to-undefined is deliberate for the timer-arming path (an unreadable
656
+ // row degrades to "schedule from now"); the catch-up sweep must NOT use this —
657
+ // it needs to distinguish "row absent" from "read failed", because treating a
658
+ // transient read error as "job never seen" previously triggered a destructive
659
+ // first-seen overwrite of the whole run-state row (audit finding)
660
+ async function getJobStateRow(job: ScheduledJob): Promise<any> {
661
+ try {
662
+ return await withStateTimeout(getStateTable().get(jobRowId(job)), `job state read ${jobRowId(job)}`);
663
+ } catch (error) {
664
+ schedulerLogger.warn?.(`Failed to read state for job ${jobRowId(job)}: ${safeErrorMessage(error)}`);
665
+ return undefined;
666
+ }
667
+ }
668
+
669
+ async function executeJob(job: RegisteredJob, scheduledAt: Date, catchUp: boolean): Promise<void> {
670
+ // The registration re-check matters as much as the role check: a timer can
671
+ // fire after its component's scope closed or after a reload replaced the
672
+ // job object, and a discarded component's handler must not run against
673
+ // production state (review finding)
674
+ if (role !== 'leader' || !isRegistered(job)) return;
675
+ // In-memory dedup for catch-up: the sweep decides from a state snapshot
676
+ // that can predate a run which started (or finished) while the snapshot
677
+ // read was in flight; lastAttemptAt is set synchronously at run start on
678
+ // this thread, so it cannot be stale (audit finding)
679
+ if (catchUp && job.lastAttemptAt !== undefined && job.lastAttemptAt >= scheduledAt.getTime()) {
680
+ return;
681
+ }
682
+ if (job.running) {
683
+ // Single-flight: a run that outlasts its own cadence is not stacked
684
+ schedulerLogger.debug?.(`Job ${jobRowId(job)} is still running; skipping this occurrence`);
685
+ return;
686
+ }
687
+ job.running = true;
688
+ const startedAt = new Date();
689
+ // The attempt anchor and persisted lastRunAt cover the occurrence, not
690
+ // just the wall clock: if a clock step-back lands the run before its own
691
+ // fireAt, recording the bare start time would make the next sweep
692
+ // re-deliver the occurrence (audit finding). Catch-up runs (scheduledAt in
693
+ // the past) keep startedAt. Set synchronously, before any await.
694
+ job.lastAttemptAt = Math.max(startedAt.getTime(), scheduledAt.getTime());
695
+ const lastRunAt = new Date(job.lastAttemptAt).toISOString();
696
+ const existingRow = await getJobStateRow(job);
697
+ schedulerLogger.trace?.(`Running job ${jobRowId(job)}${catchUp ? ' (catch-up)' : ''}`);
698
+ try {
699
+ await job.handler({ jobName: job.name, scheduledAt, catchUp });
700
+ const durationMs = Date.now() - startedAt.getTime();
701
+ schedulerLogger.trace?.(`Job ${jobRowId(job)} completed in ${durationMs}ms`);
702
+ await putStateRow({
703
+ id: jobRowId(job),
704
+ firstSeenAt: existingRow?.firstSeenAt ?? startedAt.toISOString(),
705
+ lastRunAt,
706
+ lastStatus: 'success',
707
+ lastError: undefined,
708
+ lastDurationMs: durationMs,
709
+ });
710
+ } catch (error) {
711
+ const durationMs = Date.now() - startedAt.getTime();
712
+ const errorMessage = safeErrorMessage(error);
713
+ schedulerLogger.warn?.(`Job ${jobRowId(job)} failed after ${durationMs}ms: ${errorMessage}`);
714
+ await putStateRow({
715
+ id: jobRowId(job),
716
+ firstSeenAt: existingRow?.firstSeenAt ?? startedAt.toISOString(),
717
+ lastRunAt,
718
+ lastStatus: 'error',
719
+ lastError: sanitizeStoredError(errorMessage),
720
+ lastDurationMs: durationMs,
721
+ });
722
+ } finally {
723
+ job.running = false;
724
+ }
725
+ }
726
+
727
+ /**
728
+ * Fire one catch-up run for every cron job whose most recent occurrence was
729
+ * missed (leader was down, worker restarted, DST skipped the slot, …). Runs on
730
+ * promotion and on every heartbeat; single-flight so a slow catch-up pass
731
+ * never overlaps the next one.
732
+ */
733
+ async function runCatchUp(): Promise<void> {
734
+ if (catchUpRunning || role !== 'leader') return;
735
+ // TODO(#951): reap orphaned job:<component>:<name> rows whose job no longer
736
+ // exists in any registered component, so renamed/removed jobs don't
737
+ // accumulate state rows forever
738
+ catchUpRunning = true;
739
+ try {
740
+ const now = new Date();
741
+ // Snapshot: each await below yields, and a component reload can mutate
742
+ // jobsByComponent mid-sweep (surfaced by review)
743
+ const cronJobs: RegisteredJob[] = [];
744
+ for (const jobMap of jobsByComponent.values()) {
745
+ for (const job of jobMap.values()) {
746
+ if (job.cron) cronJobs.push(job); // interval jobs self-correct in scheduleNextRun
747
+ }
748
+ }
749
+ for (const job of cronJobs) {
750
+ // A job unregistered (or replaced) while the sweep was underway must
751
+ // not fire
752
+ if (!isRegistered(job)) continue;
753
+ try {
754
+ // Unlike the timer-arming path, a read failure here must THROW
755
+ // (the per-job catch below logs and skips) — conflating it with
756
+ // "row absent" fed the destructive re-seed below (audit finding)
757
+ const stateRow = await withStateTimeout<any>(
758
+ getStateTable().get(jobRowId(job)),
759
+ `job state read ${jobRowId(job)}`
760
+ );
761
+ // Baseline = the newest of the persisted timestamps and the
762
+ // in-memory attempt anchor. Including lastAttemptAt closes two
763
+ // duplicate-execution paths the persisted row alone cannot: a
764
+ // sustained state-WRITE outage freezing lastRunAt (the sweep
765
+ // would re-fire a completed occurrence every heartbeat), and a
766
+ // fast run completing while this sweep's read was in flight.
767
+ // Clamped to now so a clock-skewed future timestamp cannot
768
+ // suppress catch-up (audit findings).
769
+ const parsedBaseline = Math.max(
770
+ Date.parse(stateRow?.lastRunAt ?? '') || 0,
771
+ Date.parse(stateRow?.firstSeenAt ?? '') || 0,
772
+ job.lastAttemptAt ?? 0
773
+ );
774
+ if (parsedBaseline === 0) {
775
+ // Row absent, or timestamps missing/unparseable: (re)seed the
776
+ // baseline without firing. put is full-record replacement, so
777
+ // preserve whatever fields exist, and skip while a run is in
778
+ // flight so the seed cannot clobber its just-committed result
779
+ if (!job.running) {
780
+ await putStateRow({
781
+ id: jobRowId(job),
782
+ firstSeenAt: now.toISOString(),
783
+ lastRunAt: stateRow?.lastRunAt,
784
+ lastStatus: stateRow?.lastStatus,
785
+ lastError: stateRow?.lastError,
786
+ lastDurationMs: stateRow?.lastDurationMs,
787
+ });
788
+ }
789
+ } else {
790
+ const baseline = new Date(Math.min(parsedBaseline, now.getTime()));
791
+ const missed = findMissedCronOccurrence(job.cron, job.timezone, baseline, now);
792
+ if (missed) {
793
+ schedulerLogger.info?.(
794
+ `Job ${jobRowId(job)} missed its ${missed.toISOString()} occurrence; running catch-up`
795
+ );
796
+ await executeJob(job, missed, true);
797
+ }
798
+ }
799
+ // Self-heal: a cron job left unarmed (nextDate returned null during
800
+ // a DST window, or a reschedule failed) gets its timer re-armed here
801
+ // every heartbeat instead of staying degraded until failover
802
+ if (!job.running && !job.timer && isRegistered(job)) {
803
+ await scheduleNextRun(job);
804
+ }
805
+ } catch (error) {
806
+ schedulerLogger.warn?.(`Catch-up check failed for job ${jobRowId(job)}: ${safeErrorMessage(error)}`);
807
+ }
808
+ }
809
+ } finally {
810
+ catchUpRunning = false;
811
+ }
812
+ }