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