@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.
- package/bin/cliOperations.ts +76 -12
- package/bin/run.ts +10 -0
- package/bin/status.ts +1 -1
- package/components/Application.ts +146 -83
- package/components/Scope.ts +4 -0
- package/components/componentLoader.ts +7 -0
- package/components/operations.js +21 -1
- package/config/configUtils.ts +139 -5
- package/config-app.schema.json +70 -0
- package/dataLayer/harperBridge/ResourceBridge.ts +7 -0
- package/dist/bin/cliOperations.js +76 -12
- package/dist/bin/cliOperations.js.map +1 -1
- package/dist/bin/run.js +9 -0
- package/dist/bin/run.js.map +1 -1
- package/dist/bin/status.js +1 -1
- package/dist/bin/status.js.map +1 -1
- package/dist/components/Application.d.ts +12 -5
- package/dist/components/Application.js +121 -60
- package/dist/components/Application.js.map +1 -1
- package/dist/components/Scope.d.ts +1 -0
- package/dist/components/Scope.js +4 -0
- package/dist/components/Scope.js.map +1 -1
- package/dist/components/componentLoader.js +7 -0
- package/dist/components/componentLoader.js.map +1 -1
- package/dist/components/operations.js +20 -1
- package/dist/components/operations.js.map +1 -1
- package/dist/config/configUtils.d.ts +31 -0
- package/dist/config/configUtils.js +127 -5
- package/dist/config/configUtils.js.map +1 -1
- package/dist/dataLayer/harperBridge/ResourceBridge.js +8 -0
- package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
- package/dist/resources/DatabaseTransaction.d.ts +12 -0
- package/dist/resources/DatabaseTransaction.js +97 -0
- package/dist/resources/DatabaseTransaction.js.map +1 -1
- package/dist/resources/RequestTarget.js +13 -3
- package/dist/resources/RequestTarget.js.map +1 -1
- package/dist/resources/Resource.js +21 -2
- package/dist/resources/Resource.js.map +1 -1
- package/dist/resources/Table.d.ts +3 -1
- package/dist/resources/Table.js +48 -5
- package/dist/resources/Table.js.map +1 -1
- package/dist/resources/analytics/metadata.d.ts +3 -0
- package/dist/resources/analytics/metadata.js +3 -0
- package/dist/resources/analytics/metadata.js.map +1 -1
- package/dist/resources/analytics/write.js +22 -0
- package/dist/resources/analytics/write.js.map +1 -1
- package/dist/resources/databases.js +21 -0
- package/dist/resources/databases.js.map +1 -1
- package/dist/resources/defineResource.js +20 -7
- package/dist/resources/defineResource.js.map +1 -1
- package/dist/resources/jsResource.d.ts +24 -0
- package/dist/resources/jsResource.js +58 -2
- package/dist/resources/jsResource.js.map +1 -1
- package/dist/resources/openApi.js +45 -20
- package/dist/resources/openApi.js.map +1 -1
- package/dist/resources/scheduler/CronExpression.d.ts +71 -0
- package/dist/resources/scheduler/CronExpression.js +367 -0
- package/dist/resources/scheduler/CronExpression.js.map +1 -0
- package/dist/resources/scheduler/engine.d.ts +91 -0
- package/dist/resources/scheduler/engine.js +767 -0
- package/dist/resources/scheduler/engine.js.map +1 -0
- package/dist/resources/scheduler/scheduler.d.ts +33 -0
- package/dist/resources/scheduler/scheduler.js +200 -0
- package/dist/resources/scheduler/scheduler.js.map +1 -0
- package/dist/security/auth.js +1 -0
- package/dist/security/auth.js.map +1 -1
- package/dist/security/jsLoader.js +8 -0
- package/dist/security/jsLoader.js.map +1 -1
- package/dist/security/keys.d.ts +32 -0
- package/dist/security/keys.js +147 -0
- package/dist/security/keys.js.map +1 -1
- package/dist/server/REST.js +67 -1
- package/dist/server/REST.js.map +1 -1
- package/dist/server/Server.d.ts +6 -0
- package/dist/server/Server.js.map +1 -1
- package/dist/server/http.d.ts +2 -0
- package/dist/server/http.js +139 -14
- package/dist/server/http.js.map +1 -1
- package/dist/server/operationsServer.js +3 -3
- package/dist/server/operationsServer.js.map +1 -1
- package/dist/server/serverHelpers/progressEmitter.js +5 -1
- package/dist/server/serverHelpers/progressEmitter.js.map +1 -1
- package/dist/server/threads/threadServer.js +9 -5
- package/dist/server/threads/threadServer.js.map +1 -1
- package/dist/sqlEngine/binder/bind.d.ts +72 -0
- package/dist/sqlEngine/binder/bind.js +289 -0
- package/dist/sqlEngine/binder/bind.js.map +1 -0
- package/dist/sqlEngine/config.d.ts +35 -0
- package/dist/sqlEngine/config.js +62 -0
- package/dist/sqlEngine/config.js.map +1 -0
- package/dist/sqlEngine/diff/differential.d.ts +23 -0
- package/dist/sqlEngine/diff/differential.js +90 -0
- package/dist/sqlEngine/diff/differential.js.map +1 -0
- package/dist/sqlEngine/errors.d.ts +22 -0
- package/dist/sqlEngine/errors.js +36 -0
- package/dist/sqlEngine/errors.js.map +1 -0
- package/dist/sqlEngine/executor/runMutation.d.ts +35 -0
- package/dist/sqlEngine/executor/runMutation.js +352 -0
- package/dist/sqlEngine/executor/runMutation.js.map +1 -0
- package/dist/sqlEngine/executor/runSelect.d.ts +7 -0
- package/dist/sqlEngine/executor/runSelect.js +14 -0
- package/dist/sqlEngine/executor/runSelect.js.map +1 -0
- package/dist/sqlEngine/expressions/compile.d.ts +24 -0
- package/dist/sqlEngine/expressions/compile.js +326 -0
- package/dist/sqlEngine/expressions/compile.js.map +1 -0
- package/dist/sqlEngine/functions/aggregates.d.ts +12 -0
- package/dist/sqlEngine/functions/aggregates.js +299 -0
- package/dist/sqlEngine/functions/aggregates.js.map +1 -0
- package/dist/sqlEngine/functions/registry.d.ts +36 -0
- package/dist/sqlEngine/functions/registry.js +33 -0
- package/dist/sqlEngine/functions/registry.js.map +1 -0
- package/dist/sqlEngine/functions/standard.d.ts +9 -0
- package/dist/sqlEngine/functions/standard.js +86 -0
- package/dist/sqlEngine/functions/standard.js.map +1 -0
- package/dist/sqlEngine/index.d.ts +29 -0
- package/dist/sqlEngine/index.js +62 -0
- package/dist/sqlEngine/index.js.map +1 -0
- package/dist/sqlEngine/logical/build.d.ts +20 -0
- package/dist/sqlEngine/logical/build.js +326 -0
- package/dist/sqlEngine/logical/build.js.map +1 -0
- package/dist/sqlEngine/logical/op.d.ts +105 -0
- package/dist/sqlEngine/logical/op.js +10 -0
- package/dist/sqlEngine/logical/op.js.map +1 -0
- package/dist/sqlEngine/optimizer/joinAnalysis.d.ts +37 -0
- package/dist/sqlEngine/optimizer/joinAnalysis.js +144 -0
- package/dist/sqlEngine/optimizer/joinAnalysis.js.map +1 -0
- package/dist/sqlEngine/optimizer/optimize.d.ts +13 -0
- package/dist/sqlEngine/optimizer/optimize.js +53 -0
- package/dist/sqlEngine/optimizer/optimize.js.map +1 -0
- package/dist/sqlEngine/optimizer/ruleEngine.d.ts +7 -0
- package/dist/sqlEngine/optimizer/ruleEngine.js +26 -0
- package/dist/sqlEngine/optimizer/ruleEngine.js.map +1 -0
- package/dist/sqlEngine/optimizer/rules/limitPushdown.d.ts +19 -0
- package/dist/sqlEngine/optimizer/rules/limitPushdown.js +55 -0
- package/dist/sqlEngine/optimizer/rules/limitPushdown.js.map +1 -0
- package/dist/sqlEngine/optimizer/rules/planJoins.d.ts +19 -0
- package/dist/sqlEngine/optimizer/rules/planJoins.js +62 -0
- package/dist/sqlEngine/optimizer/rules/planJoins.js.map +1 -0
- package/dist/sqlEngine/optimizer/rules/predicateNormalize.d.ts +18 -0
- package/dist/sqlEngine/optimizer/rules/predicateNormalize.js +80 -0
- package/dist/sqlEngine/optimizer/rules/predicateNormalize.js.map +1 -0
- package/dist/sqlEngine/optimizer/rules/predicatePushdown.d.ts +11 -0
- package/dist/sqlEngine/optimizer/rules/predicatePushdown.js +48 -0
- package/dist/sqlEngine/optimizer/rules/predicatePushdown.js.map +1 -0
- package/dist/sqlEngine/optimizer/rules/projectionPushdown.d.ts +23 -0
- package/dist/sqlEngine/optimizer/rules/projectionPushdown.js +160 -0
- package/dist/sqlEngine/optimizer/rules/projectionPushdown.js.map +1 -0
- package/dist/sqlEngine/optimizer/rules/sortFromIndex.d.ts +14 -0
- package/dist/sqlEngine/optimizer/rules/sortFromIndex.js +49 -0
- package/dist/sqlEngine/optimizer/rules/sortFromIndex.js.map +1 -0
- package/dist/sqlEngine/optimizer/rules/validateScannable.d.ts +15 -0
- package/dist/sqlEngine/optimizer/rules/validateScannable.js +60 -0
- package/dist/sqlEngine/optimizer/rules/validateScannable.js.map +1 -0
- package/dist/sqlEngine/optimizer/whereToConditions.d.ts +85 -0
- package/dist/sqlEngine/optimizer/whereToConditions.js +484 -0
- package/dist/sqlEngine/optimizer/whereToConditions.js.map +1 -0
- package/dist/sqlEngine/parser/ast.d.ts +139 -0
- package/dist/sqlEngine/parser/ast.js +10 -0
- package/dist/sqlEngine/parser/ast.js.map +1 -0
- package/dist/sqlEngine/parser/normalizer.d.ts +28 -0
- package/dist/sqlEngine/parser/normalizer.js +413 -0
- package/dist/sqlEngine/parser/normalizer.js.map +1 -0
- package/dist/sqlEngine/parser/parse.d.ts +14 -0
- package/dist/sqlEngine/parser/parse.js +20 -0
- package/dist/sqlEngine/parser/parse.js.map +1 -0
- package/dist/sqlEngine/physical/PhysicalDistinct.d.ts +9 -0
- package/dist/sqlEngine/physical/PhysicalDistinct.js +29 -0
- package/dist/sqlEngine/physical/PhysicalDistinct.js.map +1 -0
- package/dist/sqlEngine/physical/PhysicalFilter.d.ts +10 -0
- package/dist/sqlEngine/physical/PhysicalFilter.js +25 -0
- package/dist/sqlEngine/physical/PhysicalFilter.js.map +1 -0
- package/dist/sqlEngine/physical/PhysicalHashAggregate.d.ts +27 -0
- package/dist/sqlEngine/physical/PhysicalHashAggregate.js +100 -0
- package/dist/sqlEngine/physical/PhysicalHashAggregate.js.map +1 -0
- package/dist/sqlEngine/physical/PhysicalHashJoin.d.ts +29 -0
- package/dist/sqlEngine/physical/PhysicalHashJoin.js +93 -0
- package/dist/sqlEngine/physical/PhysicalHashJoin.js.map +1 -0
- package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.d.ts +36 -0
- package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.js +78 -0
- package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.js.map +1 -0
- package/dist/sqlEngine/physical/PhysicalIndexScan.d.ts +22 -0
- package/dist/sqlEngine/physical/PhysicalIndexScan.js +77 -0
- package/dist/sqlEngine/physical/PhysicalIndexScan.js.map +1 -0
- package/dist/sqlEngine/physical/PhysicalLimit.d.ts +5 -0
- package/dist/sqlEngine/physical/PhysicalLimit.js +28 -0
- package/dist/sqlEngine/physical/PhysicalLimit.js.map +1 -0
- package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.d.ts +20 -0
- package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.js +51 -0
- package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.js.map +1 -0
- package/dist/sqlEngine/physical/PhysicalProject.d.ts +18 -0
- package/dist/sqlEngine/physical/PhysicalProject.js +117 -0
- package/dist/sqlEngine/physical/PhysicalProject.js.map +1 -0
- package/dist/sqlEngine/physical/PhysicalQualify.d.ts +13 -0
- package/dist/sqlEngine/physical/PhysicalQualify.js +29 -0
- package/dist/sqlEngine/physical/PhysicalQualify.js.map +1 -0
- package/dist/sqlEngine/physical/PhysicalSort.d.ts +12 -0
- package/dist/sqlEngine/physical/PhysicalSort.js +59 -0
- package/dist/sqlEngine/physical/PhysicalSort.js.map +1 -0
- package/dist/sqlEngine/physical/op.d.ts +11 -0
- package/dist/sqlEngine/physical/op.js +9 -0
- package/dist/sqlEngine/physical/op.js.map +1 -0
- package/dist/sqlEngine/physical/plan.d.ts +23 -0
- package/dist/sqlEngine/physical/plan.js +162 -0
- package/dist/sqlEngine/physical/plan.js.map +1 -0
- package/dist/sqlEngine/router.d.ts +26 -0
- package/dist/sqlEngine/router.js +48 -0
- package/dist/sqlEngine/router.js.map +1 -0
- package/dist/sqlEngine/types.d.ts +37 -0
- package/dist/sqlEngine/types.js +13 -0
- package/dist/sqlEngine/types.js.map +1 -0
- package/dist/sqlTranslator/index.js +7 -1
- package/dist/sqlTranslator/index.js.map +1 -1
- package/dist/utility/common_utils.js +25 -0
- package/dist/utility/common_utils.js.map +1 -1
- package/dist/utility/install/installer.d.ts +9 -1
- package/dist/utility/install/installer.js +21 -0
- package/dist/utility/install/installer.js.map +1 -1
- package/dist/validation/configValidator.js +3 -0
- package/dist/validation/configValidator.js.map +1 -1
- package/dist/validation/deleteValidator.js +10 -2
- package/dist/validation/deleteValidator.js.map +1 -1
- package/npm-shrinkwrap.json +272 -230
- package/package.json +3 -3
- package/resources/DESIGN.md +17 -16
- package/resources/DatabaseTransaction.ts +95 -0
- package/resources/RequestTarget.ts +12 -3
- package/resources/Resource.ts +23 -2
- package/resources/Table.ts +57 -6
- package/resources/analytics/metadata.ts +3 -0
- package/resources/analytics/write.ts +23 -0
- package/resources/databases.ts +24 -0
- package/resources/defineResource.ts +17 -4
- package/resources/jsResource.ts +61 -2
- package/resources/openApi.ts +44 -19
- package/resources/scheduler/CronExpression.ts +394 -0
- package/resources/scheduler/engine.ts +812 -0
- package/resources/scheduler/scheduler.ts +236 -0
- package/security/auth.ts +1 -0
- package/security/jsLoader.ts +8 -0
- package/security/keys.ts +152 -0
- package/server/REST.ts +70 -1
- package/server/Server.ts +6 -0
- package/server/http.ts +122 -15
- package/server/operationsServer.ts +5 -3
- package/server/serverHelpers/progressEmitter.ts +5 -1
- package/server/threads/threadServer.js +9 -5
- package/sqlTranslator/index.ts +16 -6
- package/studio/web/assets/{Chat-CTjtL8Z4.js → Chat-DHP4XpID.js} +2 -2
- package/studio/web/assets/{Chat-CTjtL8Z4.js.map → Chat-DHP4XpID.js.map} +1 -1
- package/studio/web/assets/{FloatingChat-CafHR4Ur.js → FloatingChat-CJ7PssCv.js} +4 -4
- package/studio/web/assets/{FloatingChat-CafHR4Ur.js.map → FloatingChat-CJ7PssCv.js.map} +1 -1
- package/studio/web/assets/{applications-Buh_q0Vj.js → applications-DxXiGpsR.js} +2 -2
- package/studio/web/assets/{applications-Buh_q0Vj.js.map → applications-DxXiGpsR.js.map} +1 -1
- package/studio/web/assets/{index-0hXeECkS.js → index-BdbBanDP.js} +6 -6
- package/studio/web/assets/{index-0hXeECkS.js.map → index-BdbBanDP.js.map} +1 -1
- package/studio/web/assets/{index.lazy-B00B7VBT.js → index.lazy-B2eH28zD.js} +4 -4
- package/studio/web/assets/{index.lazy-B00B7VBT.js.map → index.lazy-B2eH28zD.js.map} +1 -1
- package/studio/web/assets/{profile-Cg2wwYPn.js → profile-DK5hgucv.js} +2 -2
- package/studio/web/assets/{profile-Cg2wwYPn.js.map → profile-DK5hgucv.js.map} +1 -1
- package/studio/web/assets/{setComponentFile-DCaDIvyB.js → setComponentFile-BVDWRYxx.js} +2 -2
- package/studio/web/assets/{setComponentFile-DCaDIvyB.js.map → setComponentFile-BVDWRYxx.js.map} +1 -1
- package/studio/web/assets/{setup-CAVcAQjK.js → setup-DJ9BInoK.js} +2 -2
- package/studio/web/assets/{setup-CAVcAQjK.js.map → setup-DJ9BInoK.js.map} +1 -1
- package/studio/web/assets/{status-BRXorNdD.js → status-B_qzmgfD.js} +2 -2
- package/studio/web/assets/{status-BRXorNdD.js.map → status-B_qzmgfD.js.map} +1 -1
- package/studio/web/assets/{swagger-ui-react-Dy1D62vO.js → swagger-ui-react-DOL5jCqg.js} +2 -2
- package/studio/web/assets/{swagger-ui-react-Dy1D62vO.js.map → swagger-ui-react-DOL5jCqg.js.map} +1 -1
- package/studio/web/assets/{tsMode-A8gbL74v.js → tsMode-DpxUxfTW.js} +2 -2
- package/studio/web/assets/{tsMode-A8gbL74v.js.map → tsMode-DpxUxfTW.js.map} +1 -1
- package/studio/web/assets/{useEntityRestURL-cDodrVcQ.js → useEntityRestURL-CU_lY6XW.js} +2 -2
- package/studio/web/assets/{useEntityRestURL-cDodrVcQ.js.map → useEntityRestURL-CU_lY6XW.js.map} +1 -1
- package/studio/web/index.html +1 -1
- package/utility/common_utils.ts +26 -0
- package/utility/install/installer.ts +26 -1
- package/validation/configValidator.ts +3 -0
- 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
|
+
}
|