@blamejs/core 0.7.1 → 0.7.18
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/CHANGELOG.md +423 -389
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -282
- package/lib/api-key.js +660 -672
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -266
- package/lib/cache.js +1206 -1211
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +1136 -0
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -202
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -573
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -269
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -614
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -451
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -479
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -855
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -177
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -752
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -489
- package/lib/request-helpers.js +340 -336
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -630
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +879 -114
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -270
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -598
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/scheduler.js
CHANGED
|
@@ -1,706 +1,706 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* scheduler — cron + interval scheduler over lib/jobs (or direct fn).
|
|
4
|
-
*
|
|
5
|
-
* The framework's primitive for "run X at Y" — backed by jobs/queue
|
|
6
|
-
* for retries, audit, and cluster-aware dispatch, with a direct-fn
|
|
7
|
-
* escape hatch for the simple cases.
|
|
8
|
-
*
|
|
9
|
-
* var sched = b.scheduler.create({
|
|
10
|
-
* jobs: jobsInstance, // optional; needed for { job: "name" }
|
|
11
|
-
* cluster: b.cluster, // optional; gates fires to leader only
|
|
12
|
-
* audit: true, // default true
|
|
13
|
-
* });
|
|
14
|
-
*
|
|
15
|
-
* sched.schedule({
|
|
16
|
-
* name: "nightly-cleanup",
|
|
17
|
-
* cron: "0 2 * * *", // POSIX 5-field cron
|
|
18
|
-
* timezone: "America/New_York", // IANA name; default = server-local
|
|
19
|
-
* job: "cleanup", // dispatched via jobs.enqueue
|
|
20
|
-
* payload: { scope: "all" },
|
|
21
|
-
* });
|
|
22
|
-
*
|
|
23
|
-
* sched.schedule({
|
|
24
|
-
* name: "stats-aggregation",
|
|
25
|
-
* every: 300000, // ms between runs
|
|
26
|
-
* baseline: "00:00", // HH:MM anchor (optional)
|
|
27
|
-
* timezone: "America/New_York",
|
|
28
|
-
* job: "aggregate-stats",
|
|
29
|
-
* });
|
|
30
|
-
*
|
|
31
|
-
* sched.schedule({
|
|
32
|
-
* name: "heartbeat",
|
|
33
|
-
* every: 60000,
|
|
34
|
-
* run: async function () { … }, // direct function (no jobs needed)
|
|
35
|
-
* });
|
|
36
|
-
*
|
|
37
|
-
* await sched.start(); // arms timers
|
|
38
|
-
* await sched.stop(); // clears timers, drops pending fires
|
|
39
|
-
*
|
|
40
|
-
* sched.list(); // → [{ name, when, lastRun, nextRun, running }]
|
|
41
|
-
*
|
|
42
|
-
* Cron grammar (5 fields, space-separated):
|
|
43
|
-
*
|
|
44
|
-
* minute (0–59) hour (0–23) dom (1–31) month (1–12) dow (0–7; 0/7=Sun)
|
|
45
|
-
*
|
|
46
|
-
* Each field accepts: * N N,M,… A-B *\/N A-B/N
|
|
47
|
-
*
|
|
48
|
-
* Shorthands: @hourly @daily @midnight @weekly @monthly @yearly @annually
|
|
49
|
-
*
|
|
50
|
-
* Cluster gating: when opts.cluster is wired and the local node is not
|
|
51
|
-
* the leader, schedule fires no-op. The leader still computes nextRun
|
|
52
|
-
* locally so a leader transition picks up cleanly.
|
|
53
|
-
*
|
|
54
|
-
* Exactly-once-globally: when opts.cluster is wired, every fire first
|
|
55
|
-
* INSERTs a row into _blamejs_scheduler_ticks keyed on (taskName,
|
|
56
|
-
* scheduledAtUnix). The PRIMARY KEY race ensures that even if two
|
|
57
|
-
* nodes briefly believe they are the leader (split-brain on lease
|
|
58
|
-
* boundary), only the row-winner runs the task. The loser increments
|
|
59
|
-
* task.tickClaimLost (visible via list()) and skips silently. Task
|
|
60
|
-
* handlers should still be idempotent — operators may add jobs.enqueue
|
|
61
|
-
* dedup keys for defense-in-depth.
|
|
62
|
-
*
|
|
63
|
-
* Tick-claim retention: rows older than opts.tickRetentionMs (default
|
|
64
|
-
* 7 days) are pruned automatically — at most once per opts.pruneInterval
|
|
65
|
-
* Ms (default 60s) — by the leader on its next successful fire. Operators
|
|
66
|
-
* can also call sched.pruneTickClaims(olderThanMs?) on demand to force
|
|
67
|
-
* a sweep (e.g. from a maintenance script) and observe the count via
|
|
68
|
-
* the system.scheduler.tick.pruned audit event.
|
|
69
|
-
*
|
|
70
|
-
* Watchdog: if a fire's promise hasn't settled after MAX_JOB_MS
|
|
71
|
-
* (10min default; opts.maxJobMs to override), the running flag is
|
|
72
|
-
* force-cleared and a warning emitted, so a hung job doesn't lock out
|
|
73
|
-
* future fires.
|
|
74
|
-
*/
|
|
75
|
-
|
|
76
|
-
var lazyRequire = require("./lazy-require");
|
|
77
|
-
var audit = lazyRequire(function () { return require("./audit"); });
|
|
78
|
-
var log = lazyRequire(function () { return require("./log").boot("scheduler"); });
|
|
79
|
-
var clusterStorage = require("./cluster-storage");
|
|
80
|
-
var validateOpts = require("./validate-opts");
|
|
81
|
-
var C = require("./constants");
|
|
82
|
-
var { SchedulerError } = require("./framework-error");
|
|
83
|
-
|
|
84
|
-
var DEFAULT_MAX_JOB_MS = C.TIME.minutes(10);
|
|
85
|
-
var DEFAULT_TICK_RETENTION_MS = C.TIME.days(7);
|
|
86
|
-
var DEFAULT_TICK_PRUNE_INTERVAL_MS = C.TIME.minutes(1);
|
|
87
|
-
|
|
88
|
-
// ---- Cron parsing ----
|
|
89
|
-
|
|
90
|
-
var CRON_SHORTHANDS = {
|
|
91
|
-
"@yearly": "0 0 1 1 *",
|
|
92
|
-
"@annually": "0 0 1 1 *",
|
|
93
|
-
"@monthly": "0 0 1 * *",
|
|
94
|
-
"@weekly": "0 0 * * 0",
|
|
95
|
-
"@daily": "0 0 * * *",
|
|
96
|
-
"@midnight": "0 0 * * *",
|
|
97
|
-
"@hourly": "0 * * * *",
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
var CRON_FIELD_RANGES = [
|
|
101
|
-
{ name: "minute", min: 0, max: 59 },
|
|
102
|
-
{ name: "hour", min: 0, max: 23 },
|
|
103
|
-
{ name: "dom", min: 1, max: 31 },
|
|
104
|
-
{ name: "month", min: 1, max: 12 },
|
|
105
|
-
{ name: "dow", min: 0, max: 7 }, // 0 and 7 both mean Sunday
|
|
106
|
-
];
|
|
107
|
-
|
|
108
|
-
function _parseCronField(text, range) {
|
|
109
|
-
var parts = String(text).split(",");
|
|
110
|
-
var set = new Set();
|
|
111
|
-
for (var i = 0; i < parts.length; i++) {
|
|
112
|
-
var part = parts[i].trim();
|
|
113
|
-
if (part.length === 0) {
|
|
114
|
-
throw new SchedulerError("scheduler/invalid-cron",
|
|
115
|
-
"empty term in cron field '" + range.name + "'", true);
|
|
116
|
-
}
|
|
117
|
-
var step = 1;
|
|
118
|
-
var stepIdx = part.indexOf("/");
|
|
119
|
-
if (stepIdx !== -1) {
|
|
120
|
-
var stepStr = part.slice(stepIdx + 1);
|
|
121
|
-
step = parseInt(stepStr, 10);
|
|
122
|
-
if (!Number.isFinite(step) || step < 1) {
|
|
123
|
-
throw new SchedulerError("scheduler/invalid-cron",
|
|
124
|
-
"bad step '" + stepStr + "' in cron field '" + range.name + "'", true);
|
|
125
|
-
}
|
|
126
|
-
// Reject step > field-range, even though the for-loop below would
|
|
127
|
-
// silently produce a single-value schedule (e.g. `*/99999` for
|
|
128
|
-
// minutes degenerates to "minute 0 of every hour"). An operator
|
|
129
|
-
// typing `*/99999` clearly meant something else; silent acceptance
|
|
130
|
-
// hides the typo and produces a schedule that fires once per hour
|
|
131
|
-
// when the operator probably wanted once per N minutes for small N.
|
|
132
|
-
// The bound is `range.max - range.min + 1` so e.g. minutes (0-59)
|
|
133
|
-
// accepts step up to 60 (inclusive — `*/60` is "minute 0 of every
|
|
134
|
-
// hour" written with a redundant step).
|
|
135
|
-
var rangeSize = range.max - range.min + 1;
|
|
136
|
-
if (step > rangeSize) {
|
|
137
|
-
throw new SchedulerError("scheduler/invalid-cron",
|
|
138
|
-
"step '" + stepStr + "' exceeds field range (" + rangeSize +
|
|
139
|
-
") in cron field '" + range.name + "'", true);
|
|
140
|
-
}
|
|
141
|
-
part = part.slice(0, stepIdx);
|
|
142
|
-
}
|
|
143
|
-
var lo, hi;
|
|
144
|
-
if (part === "*") {
|
|
145
|
-
lo = range.min; hi = range.max;
|
|
146
|
-
} else if (part.indexOf("-") !== -1) {
|
|
147
|
-
var seg = part.split("-");
|
|
148
|
-
if (seg.length !== 2) {
|
|
149
|
-
throw new SchedulerError("scheduler/invalid-cron",
|
|
150
|
-
"bad range '" + part + "' in cron field '" + range.name + "'", true);
|
|
151
|
-
}
|
|
152
|
-
lo = parseInt(seg[0], 10);
|
|
153
|
-
hi = parseInt(seg[1], 10);
|
|
154
|
-
} else {
|
|
155
|
-
lo = parseInt(part, 10);
|
|
156
|
-
hi = lo;
|
|
157
|
-
}
|
|
158
|
-
if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo > hi ||
|
|
159
|
-
lo < range.min || hi > range.max) {
|
|
160
|
-
throw new SchedulerError("scheduler/invalid-cron",
|
|
161
|
-
"value '" + part + "' out of range " + range.min + "-" + range.max +
|
|
162
|
-
" in cron field '" + range.name + "'", true);
|
|
163
|
-
}
|
|
164
|
-
for (var v = lo; v <= hi; v += step) set.add(v);
|
|
165
|
-
}
|
|
166
|
-
// Normalize Sunday: dow 7 → 0 (so the matcher can use a single set)
|
|
167
|
-
if (range.name === "dow" && set.has(7)) { set.add(0); set.delete(7); }
|
|
168
|
-
return set;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function parseCron(expr) {
|
|
172
|
-
if (typeof expr !== "string" || expr.length === 0) {
|
|
173
|
-
throw new SchedulerError("scheduler/invalid-cron",
|
|
174
|
-
"cron expression must be a non-empty string", true);
|
|
175
|
-
}
|
|
176
|
-
var trimmed = expr.trim();
|
|
177
|
-
if (CRON_SHORTHANDS[trimmed.toLowerCase()]) {
|
|
178
|
-
trimmed = CRON_SHORTHANDS[trimmed.toLowerCase()];
|
|
179
|
-
}
|
|
180
|
-
var fields = trimmed.split(/\s+/);
|
|
181
|
-
if (fields.length !== 5) {
|
|
182
|
-
throw new SchedulerError("scheduler/invalid-cron",
|
|
183
|
-
"cron expression must have 5 fields (got " + fields.length + "): " + expr, true);
|
|
184
|
-
}
|
|
185
|
-
var sets = [];
|
|
186
|
-
for (var i = 0; i < 5; i++) {
|
|
187
|
-
sets.push(_parseCronField(fields[i], CRON_FIELD_RANGES[i]));
|
|
188
|
-
}
|
|
189
|
-
return {
|
|
190
|
-
expr: trimmed,
|
|
191
|
-
minute: sets[0],
|
|
192
|
-
hour: sets[1],
|
|
193
|
-
dom: sets[2],
|
|
194
|
-
month: sets[3],
|
|
195
|
-
dow: sets[4],
|
|
196
|
-
// Whether dom or dow was constrained — matters for the cron quirk
|
|
197
|
-
// where day-of-month and day-of-week are OR'd when both are set.
|
|
198
|
-
domRestricted: sets[2].size < (CRON_FIELD_RANGES[2].max - CRON_FIELD_RANGES[2].min + 1),
|
|
199
|
-
dowRestricted: sets[4].size < 7,
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ---- Timezone-aware wall-clock helpers ----
|
|
204
|
-
//
|
|
205
|
-
// We need "what's the wall clock in TZ for time T?" and "given wall
|
|
206
|
-
// clock W in TZ, what UTC instant does that correspond to?". Intl
|
|
207
|
-
// gives us the first cheaply. The second is approximated by walking
|
|
208
|
-
// minute-by-minute — accurate enough for cron schedules (DST gaps fire
|
|
209
|
-
// at the next valid wall-clock instant; overlaps fire once at the
|
|
210
|
-
// first matching instant).
|
|
211
|
-
|
|
212
|
-
function _getWallClockParts(date, timeZone) {
|
|
213
|
-
if (!timeZone) {
|
|
214
|
-
return {
|
|
215
|
-
year: date.getFullYear(),
|
|
216
|
-
month: date.getMonth() + 1,
|
|
217
|
-
day: date.getDate(),
|
|
218
|
-
hour: date.getHours(),
|
|
219
|
-
minute: date.getMinutes(),
|
|
220
|
-
dow: date.getDay(),
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
var fmt = new Intl.DateTimeFormat("en-US", {
|
|
224
|
-
timeZone: timeZone,
|
|
225
|
-
year: "numeric", month: "2-digit", day: "2-digit",
|
|
226
|
-
hour: "2-digit", minute: "2-digit", weekday: "short",
|
|
227
|
-
hour12: false,
|
|
228
|
-
});
|
|
229
|
-
var parts = {};
|
|
230
|
-
fmt.formatToParts(date).forEach(function (p) { parts[p.type] = p.value; });
|
|
231
|
-
var dowMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
232
|
-
// Some locales emit "24:00" for midnight — normalize before parseInt so
|
|
233
|
-
// the integer literal stays out of the source.
|
|
234
|
-
var hr = (parts.hour === "24") ? 0 : parseInt(parts.hour, 10);
|
|
235
|
-
return {
|
|
236
|
-
year: parseInt(parts.year, 10),
|
|
237
|
-
month: parseInt(parts.month, 10),
|
|
238
|
-
day: parseInt(parts.day, 10),
|
|
239
|
-
hour: hr,
|
|
240
|
-
minute: parseInt(parts.minute, 10),
|
|
241
|
-
dow: dowMap[parts.weekday] || 0,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function _validateTimezone(tz) {
|
|
246
|
-
if (!tz) return null;
|
|
247
|
-
try {
|
|
248
|
-
new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date());
|
|
249
|
-
return tz;
|
|
250
|
-
} catch (_e) {
|
|
251
|
-
throw new SchedulerError("scheduler/invalid-timezone",
|
|
252
|
-
"unknown IANA timezone '" + tz + "'", true);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function _matchesCron(cron, parts) {
|
|
257
|
-
if (!cron.minute.has(parts.minute)) return false;
|
|
258
|
-
if (!cron.hour.has(parts.hour)) return false;
|
|
259
|
-
if (!cron.month.has(parts.month)) return false;
|
|
260
|
-
// POSIX cron quirk: when both dom AND dow are restricted, the day
|
|
261
|
-
// matches if EITHER matches (OR). When only one is restricted,
|
|
262
|
-
// standard AND.
|
|
263
|
-
var domOk = cron.dom.has(parts.day);
|
|
264
|
-
var dowOk = cron.dow.has(parts.dow);
|
|
265
|
-
if (cron.domRestricted && cron.dowRestricted) return domOk || dowOk;
|
|
266
|
-
if (cron.domRestricted) return domOk;
|
|
267
|
-
if (cron.dowRestricted) return dowOk;
|
|
268
|
-
return true; // both fully wild
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// nextCronFire — earliest UTC ms ≥ `after` whose wall-clock in `tz`
|
|
272
|
-
// matches the cron sets. Walks minute by minute; bounded at ~530K
|
|
273
|
-
// iterations (1 year of minutes) before giving up with a clear error.
|
|
274
|
-
function nextCronFire(cron, after, timeZone) {
|
|
275
|
-
var MINUTE_MS = C.TIME.minutes(1);
|
|
276
|
-
// Round up to the next whole minute boundary
|
|
277
|
-
var t = new Date(after.getTime() + (MINUTE_MS - (after.getTime() % MINUTE_MS)) % MINUTE_MS);
|
|
278
|
-
if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + MINUTE_MS);
|
|
279
|
-
// 1 year of minute-walks plus a 1-hour DST/leap cushion.
|
|
280
|
-
var maxIters = (C.TIME.days(366) / MINUTE_MS) + (C.TIME.hours(1) / MINUTE_MS);
|
|
281
|
-
for (var i = 0; i < maxIters; i++) {
|
|
282
|
-
var parts = _getWallClockParts(t, timeZone);
|
|
283
|
-
if (_matchesCron(cron, parts)) return t.getTime();
|
|
284
|
-
t = new Date(t.getTime() + MINUTE_MS);
|
|
285
|
-
}
|
|
286
|
-
throw new SchedulerError("scheduler/cron-no-fire",
|
|
287
|
-
"cron expression '" + cron.expr + "' produced no fire within 1 year " +
|
|
288
|
-
"(impossible date constraint?)", true);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// nextBaselineFire — next UTC ms whose wall-clock in `tz` matches HH:MM.
|
|
292
|
-
function nextBaselineFire(timeOfDay, timeZone, after) {
|
|
293
|
-
var match = String(timeOfDay).match(/^(\d{1,2}):(\d{2})$/);
|
|
294
|
-
if (!match) {
|
|
295
|
-
throw new SchedulerError("scheduler/invalid-baseline",
|
|
296
|
-
"baseline must be HH:MM (got '" + timeOfDay + "')", true);
|
|
297
|
-
}
|
|
298
|
-
var hh = parseInt(match[1], 10);
|
|
299
|
-
var mm = parseInt(match[2], 10);
|
|
300
|
-
if (hh < 0 || hh > 23 || mm < 0 || mm > 59) {
|
|
301
|
-
throw new SchedulerError("scheduler/invalid-baseline",
|
|
302
|
-
"baseline '" + timeOfDay + "' is not a valid 24h time", true);
|
|
303
|
-
}
|
|
304
|
-
var MINUTE_MS = C.TIME.minutes(1);
|
|
305
|
-
var t = new Date(after.getTime() + (MINUTE_MS - (after.getTime() % MINUTE_MS)) % MINUTE_MS);
|
|
306
|
-
if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + MINUTE_MS);
|
|
307
|
-
// 1 day of minute-walks plus a 1-hour DST cushion.
|
|
308
|
-
for (var i = 0; i < (C.TIME.hours(24) / MINUTE_MS) + (C.TIME.hours(1) / MINUTE_MS); i++) {
|
|
309
|
-
var parts = _getWallClockParts(t, timeZone);
|
|
310
|
-
if (parts.hour === hh && parts.minute === mm) return t.getTime();
|
|
311
|
-
t = new Date(t.getTime() + MINUTE_MS);
|
|
312
|
-
}
|
|
313
|
-
throw new SchedulerError("scheduler/baseline-no-fire",
|
|
314
|
-
"baseline '" + timeOfDay + "' produced no fire within 24h+ (timezone bug?)", true);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// ---- Engine ----
|
|
318
|
-
|
|
319
|
-
function create(opts) {
|
|
320
|
-
opts = opts || {};
|
|
321
|
-
validateOpts(opts, [
|
|
322
|
-
"jobs", "cluster", "audit",
|
|
323
|
-
"maxJobMs", "tickRetentionMs", "pruneIntervalMs",
|
|
324
|
-
], "scheduler");
|
|
325
|
-
var jobsInstance = opts.jobs || null;
|
|
326
|
-
var clusterInstance = opts.cluster || null;
|
|
327
|
-
var auditOn = opts.audit !== false;
|
|
328
|
-
var maxJobMs = opts.maxJobMs || DEFAULT_MAX_JOB_MS;
|
|
329
|
-
var tickRetentionMs = opts.tickRetentionMs != null
|
|
330
|
-
? opts.tickRetentionMs : DEFAULT_TICK_RETENTION_MS;
|
|
331
|
-
var pruneIntervalMs = opts.pruneIntervalMs != null
|
|
332
|
-
? opts.pruneIntervalMs : DEFAULT_TICK_PRUNE_INTERVAL_MS;
|
|
333
|
-
|
|
334
|
-
// name → task
|
|
335
|
-
var tasks = new Map();
|
|
336
|
-
var timers = new Set();
|
|
337
|
-
var started = false;
|
|
338
|
-
var lastPruneAt = 0;
|
|
339
|
-
|
|
340
|
-
var _err = SchedulerError.factory;
|
|
341
|
-
|
|
342
|
-
function _emit(action, info, outcome) {
|
|
343
|
-
if (!auditOn) return;
|
|
344
|
-
audit().safeEmit({
|
|
345
|
-
action: action,
|
|
346
|
-
outcome: outcome,
|
|
347
|
-
metadata: info || {},
|
|
348
|
-
reason: info && info.reason ? info.reason : null,
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function _isLeaderHere() {
|
|
353
|
-
if (!clusterInstance) return true;
|
|
354
|
-
try {
|
|
355
|
-
if (typeof clusterInstance.isLeader === "function") return !!clusterInstance.isLeader();
|
|
356
|
-
} catch (_e) { /* treat unknown leadership state as not-leader */ }
|
|
357
|
-
return false;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function schedule(spec) {
|
|
361
|
-
if (started) {
|
|
362
|
-
throw _err("ALREADY_STARTED",
|
|
363
|
-
"scheduler.schedule: cannot register '" + (spec && spec.name) +
|
|
364
|
-
"' after start() — schedule all tasks before calling start()", true);
|
|
365
|
-
}
|
|
366
|
-
if (!spec || typeof spec !== "object") {
|
|
367
|
-
throw _err("INVALID_SPEC", "scheduler.schedule requires a spec object", true);
|
|
368
|
-
}
|
|
369
|
-
if (typeof spec.name !== "string" || spec.name.length === 0) {
|
|
370
|
-
throw _err("INVALID_NAME", "scheduler.schedule: spec.name is required", true);
|
|
371
|
-
}
|
|
372
|
-
if (tasks.has(spec.name)) {
|
|
373
|
-
throw _err("DUPLICATE_NAME",
|
|
374
|
-
"scheduler.schedule: '" + spec.name + "' is already scheduled", true);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
var hasCron = typeof spec.cron === "string";
|
|
378
|
-
var hasEvery = typeof spec.every === "number";
|
|
379
|
-
if ((hasCron && hasEvery) || (!hasCron && !hasEvery)) {
|
|
380
|
-
throw _err("INVALID_SPEC",
|
|
381
|
-
"scheduler.schedule: spec must set exactly one of cron / every (got cron=" +
|
|
382
|
-
hasCron + ", every=" + hasEvery + ")", true);
|
|
383
|
-
}
|
|
384
|
-
if (hasEvery && (!Number.isFinite(spec.every) || spec.every < C.TIME.seconds(1))) {
|
|
385
|
-
throw _err("INVALID_SPEC",
|
|
386
|
-
"scheduler.schedule: spec.every must be a number ≥ 1000 ms", true);
|
|
387
|
-
}
|
|
388
|
-
var hasJob = typeof spec.job === "string" && spec.job.length > 0;
|
|
389
|
-
var hasRun = typeof spec.run === "function";
|
|
390
|
-
if ((hasJob && hasRun) || (!hasJob && !hasRun)) {
|
|
391
|
-
throw _err("INVALID_SPEC",
|
|
392
|
-
"scheduler.schedule: spec must set exactly one of job / run", true);
|
|
393
|
-
}
|
|
394
|
-
if (hasJob && !jobsInstance) {
|
|
395
|
-
throw _err("INVALID_SPEC",
|
|
396
|
-
"scheduler.schedule: spec.job requires opts.jobs at scheduler.create — " +
|
|
397
|
-
"use spec.run for direct-function tasks when jobs is unwired", true);
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
var tz = _validateTimezone(spec.timezone || null);
|
|
401
|
-
var task = {
|
|
402
|
-
name: spec.name,
|
|
403
|
-
timezone: tz,
|
|
404
|
-
job: hasJob ? spec.job : null,
|
|
405
|
-
payload: spec.payload || null,
|
|
406
|
-
run: hasRun ? spec.run : null,
|
|
407
|
-
enqueueOpts: spec.enqueueOpts || null,
|
|
408
|
-
lastRun: null,
|
|
409
|
-
lastFinish: null,
|
|
410
|
-
lastError: null,
|
|
411
|
-
running: false,
|
|
412
|
-
runningSince: 0,
|
|
413
|
-
fires: 0,
|
|
414
|
-
misses: 0, // skipped because previous run still in-flight
|
|
415
|
-
nonLeaderSkips: 0,
|
|
416
|
-
tickClaimLost: 0, // lost the tick-claim race to another leader (cluster only)
|
|
417
|
-
};
|
|
418
|
-
if (hasCron) {
|
|
419
|
-
task.kind = "cron";
|
|
420
|
-
task.cron = parseCron(spec.cron);
|
|
421
|
-
task.exprDesc = "cron " + task.cron.expr + (tz ? " " + tz : "");
|
|
422
|
-
task.nextRun = nextCronFire(task.cron, new Date(), tz);
|
|
423
|
-
} else {
|
|
424
|
-
task.kind = "every";
|
|
425
|
-
task.every = spec.every;
|
|
426
|
-
if (spec.baseline) {
|
|
427
|
-
task.baseline = spec.baseline;
|
|
428
|
-
task.nextRun = nextBaselineFire(spec.baseline, tz, new Date());
|
|
429
|
-
} else {
|
|
430
|
-
// Initial offset: fire one full interval after start (consistent
|
|
431
|
-
// with how operators usually expect interval timers).
|
|
432
|
-
task.nextRun = Date.now() + spec.every;
|
|
433
|
-
}
|
|
434
|
-
task.exprDesc = "every " + spec.every + "ms" +
|
|
435
|
-
(spec.baseline ? " from " + spec.baseline : "") +
|
|
436
|
-
(tz ? " " + tz : "");
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
tasks.set(spec.name, task);
|
|
440
|
-
return task;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function _computeNextRun(task, after) {
|
|
444
|
-
if (task.kind === "cron") {
|
|
445
|
-
return nextCronFire(task.cron, new Date(after), task.timezone);
|
|
446
|
-
}
|
|
447
|
-
// every: anchor on baseline if set (so day-to-day drift stays
|
|
448
|
-
// bounded), otherwise pure interval from `after`.
|
|
449
|
-
if (task.baseline) {
|
|
450
|
-
return nextBaselineFire(task.baseline, task.timezone, new Date(after));
|
|
451
|
-
}
|
|
452
|
-
return after + task.every;
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function _fireOnce(task) {
|
|
456
|
-
// Skip if previous run still in flight.
|
|
457
|
-
if (task.running) {
|
|
458
|
-
// Watchdog: if we're past MAX_JOB_MS, force-clear and let this fire.
|
|
459
|
-
if (task.runningSince && (Date.now() - task.runningSince) > maxJobMs) {
|
|
460
|
-
try {
|
|
461
|
-
log().warn("[scheduler] '" + task.name + "' exceeded " +
|
|
462
|
-
(maxJobMs / C.TIME.seconds(1)) + "s — forcing reset");
|
|
463
|
-
} catch (_e) { /* logger best-effort */ }
|
|
464
|
-
_emit("system.scheduler.task.watchdog", { name: task.name }, "failure");
|
|
465
|
-
task.running = false;
|
|
466
|
-
} else {
|
|
467
|
-
task.misses++;
|
|
468
|
-
_emit("system.scheduler.task.skipped",
|
|
469
|
-
{ name: task.name, reason: "previous-run-in-flight" }, "denied");
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Cluster leader gate. Compute nextRun even when not leader so a
|
|
475
|
-
// leader transition picks up cleanly without a state reload.
|
|
476
|
-
if (!_isLeaderHere()) {
|
|
477
|
-
task.nonLeaderSkips++;
|
|
478
|
-
task.nextRun = _computeNextRun(task, Date.now());
|
|
479
|
-
return;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// Capture the nominal scheduled time for this tick before we
|
|
483
|
-
// recompute nextRun for the next firing.
|
|
484
|
-
var nominalRun = task.nextRun;
|
|
485
|
-
|
|
486
|
-
// Compute the next fire time forward from now (not from nominal
|
|
487
|
-
// nextRun) so a long-running fire doesn't queue up backlog ticks.
|
|
488
|
-
// Done before any await so _arm() reads the fresh value when it
|
|
489
|
-
// re-arms after this synchronous return.
|
|
490
|
-
task.nextRun = _computeNextRun(task, Date.now());
|
|
491
|
-
|
|
492
|
-
// Cluster mode: race for the tick-claim row. Loser of the INSERT
|
|
493
|
-
// skips silently. Single-node mode (no clusterInstance wired) fires
|
|
494
|
-
// unconditionally — there's only one process so no contention is
|
|
495
|
-
// possible.
|
|
496
|
-
if (clusterInstance) {
|
|
497
|
-
var tickKey = task.name + ":" + nominalRun;
|
|
498
|
-
var claimedBy = (typeof clusterInstance.currentNodeId === "function")
|
|
499
|
-
? clusterInstance.currentNodeId() : "unknown";
|
|
500
|
-
clusterStorage.execute(
|
|
501
|
-
"INSERT INTO _blamejs_scheduler_ticks " +
|
|
502
|
-
"(tickKey, name, scheduledAtUnix, claimedAtUnix, claimedBy) " +
|
|
503
|
-
"VALUES (?, ?, ?, ?, ?) " +
|
|
504
|
-
"ON CONFLICT (tickKey) DO NOTHING",
|
|
505
|
-
[tickKey, task.name, nominalRun, Date.now(), claimedBy]
|
|
506
|
-
).then(function (result) {
|
|
507
|
-
var won = (result && result.rowCount > 0);
|
|
508
|
-
if (won) {
|
|
509
|
-
_runFire(task);
|
|
510
|
-
} else {
|
|
511
|
-
task.tickClaimLost++;
|
|
512
|
-
_emit("system.scheduler.tick.lost", {
|
|
513
|
-
name: task.name, tickKey: tickKey, claimedBy: claimedBy,
|
|
514
|
-
}, "denied");
|
|
515
|
-
}
|
|
516
|
-
}, function (e) {
|
|
517
|
-
try {
|
|
518
|
-
log().warn("[scheduler] tick-claim failed for '" + task.name + "'",
|
|
519
|
-
{ error: (e && e.message) || String(e) });
|
|
520
|
-
} catch (_e) { /* logger best-effort */ }
|
|
521
|
-
_emit("system.scheduler.tick.error", {
|
|
522
|
-
name: task.name, tickKey: tickKey,
|
|
523
|
-
reason: (e && e.message) || String(e),
|
|
524
|
-
}, "failure");
|
|
525
|
-
});
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
_runFire(task);
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// Operator-callable prune. Deletes _blamejs_scheduler_ticks rows whose
|
|
533
|
-
// scheduledAtUnix is older than `olderThanMs` (default = retention
|
|
534
|
-
// window passed to scheduler.create). Returns a Promise that resolves
|
|
535
|
-
// to the number of rows removed. No-op if cluster wiring is absent
|
|
536
|
-
// (single-node scheduler doesn't write tick rows).
|
|
537
|
-
async function pruneTickClaims(olderThanMs) {
|
|
538
|
-
if (!clusterInstance) return 0;
|
|
539
|
-
var threshold = Date.now() - (
|
|
540
|
-
typeof olderThanMs === "number" ? olderThanMs : tickRetentionMs
|
|
541
|
-
);
|
|
542
|
-
var result = await clusterStorage.execute(
|
|
543
|
-
"DELETE FROM _blamejs_scheduler_ticks WHERE scheduledAtUnix < ?",
|
|
544
|
-
[threshold]
|
|
545
|
-
);
|
|
546
|
-
var removed = (result && result.rowCount) || 0;
|
|
547
|
-
if (removed > 0) {
|
|
548
|
-
_emit("system.scheduler.tick.pruned", {
|
|
549
|
-
rowsDeleted: removed,
|
|
550
|
-
olderThanUnix: threshold,
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
return removed;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// Rate-limited best-effort prune called after a successful tick claim.
|
|
557
|
-
// Errors are swallowed — pruning is housekeeping, not part of the fire
|
|
558
|
-
// critical path.
|
|
559
|
-
function _maybePruneTickClaims() {
|
|
560
|
-
if (!clusterInstance) return;
|
|
561
|
-
if (tickRetentionMs <= 0) return;
|
|
562
|
-
var now = Date.now();
|
|
563
|
-
if (now - lastPruneAt < pruneIntervalMs) return;
|
|
564
|
-
lastPruneAt = now;
|
|
565
|
-
pruneTickClaims().catch(function (e) {
|
|
566
|
-
try {
|
|
567
|
-
log().warn("[scheduler] tick-claim prune failed",
|
|
568
|
-
{ error: (e && e.message) || String(e) });
|
|
569
|
-
} catch (_e) { /* logger best-effort */ }
|
|
570
|
-
});
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
function _runFire(task) {
|
|
574
|
-
_maybePruneTickClaims();
|
|
575
|
-
task.fires++;
|
|
576
|
-
task.running = true;
|
|
577
|
-
task.runningSince = Date.now();
|
|
578
|
-
task.lastRun = new Date().toISOString();
|
|
579
|
-
var startedAt = Date.now();
|
|
580
|
-
|
|
581
|
-
var promise;
|
|
582
|
-
try {
|
|
583
|
-
if (task.job) {
|
|
584
|
-
promise = jobsInstance.enqueue(task.job, task.payload || {}, task.enqueueOpts || {});
|
|
585
|
-
} else {
|
|
586
|
-
promise = Promise.resolve(task.run());
|
|
587
|
-
}
|
|
588
|
-
} catch (e) {
|
|
589
|
-
promise = Promise.reject(e);
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
Promise.resolve(promise).then(function (_v) {
|
|
593
|
-
task.running = false;
|
|
594
|
-
task.runningSince = 0;
|
|
595
|
-
task.lastFinish = new Date().toISOString();
|
|
596
|
-
task.lastError = null;
|
|
597
|
-
_emit("system.scheduler.task.success", {
|
|
598
|
-
name: task.name,
|
|
599
|
-
kind: task.kind,
|
|
600
|
-
durationMs: Date.now() - startedAt,
|
|
601
|
-
viaJob: !!task.job,
|
|
602
|
-
});
|
|
603
|
-
}, function (e) {
|
|
604
|
-
task.running = false;
|
|
605
|
-
task.runningSince = 0;
|
|
606
|
-
task.lastFinish = new Date().toISOString();
|
|
607
|
-
task.lastError = (e && e.message) || String(e);
|
|
608
|
-
try {
|
|
609
|
-
log().error("[scheduler] '" + task.name + "' failed", { error: task.lastError });
|
|
610
|
-
} catch (_e) { /* logger best-effort */ }
|
|
611
|
-
_emit("system.scheduler.task.failure", {
|
|
612
|
-
name: task.name,
|
|
613
|
-
kind: task.kind,
|
|
614
|
-
durationMs: Date.now() - startedAt,
|
|
615
|
-
viaJob: !!task.job,
|
|
616
|
-
reason: task.lastError,
|
|
617
|
-
}, "failure");
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function _arm(task) {
|
|
622
|
-
var delay = Math.max(0, task.nextRun - Date.now());
|
|
623
|
-
var t = setTimeout(function () {
|
|
624
|
-
timers.delete(t);
|
|
625
|
-
if (!started) return;
|
|
626
|
-
_fireOnce(task);
|
|
627
|
-
if (!started) return;
|
|
628
|
-
_arm(task);
|
|
629
|
-
}, delay);
|
|
630
|
-
if (typeof t.unref === "function") t.unref();
|
|
631
|
-
timers.add(t);
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
async function start() {
|
|
635
|
-
if (started) return;
|
|
636
|
-
started = true;
|
|
637
|
-
tasks.forEach(function (task) { _arm(task); });
|
|
638
|
-
_emit("scheduler.start", { count: tasks.size });
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
async function stop() {
|
|
642
|
-
if (!started) return;
|
|
643
|
-
started = false;
|
|
644
|
-
timers.forEach(function (t) {
|
|
645
|
-
try { clearTimeout(t); }
|
|
646
|
-
catch (e) { log().debug("stop-cleanup-failed", { op: "clearTimeout", error: e.message }); }
|
|
647
|
-
});
|
|
648
|
-
timers.clear();
|
|
649
|
-
_emit("scheduler.stop", { count: tasks.size });
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
function list() {
|
|
653
|
-
var out = [];
|
|
654
|
-
tasks.forEach(function (task) {
|
|
655
|
-
out.push({
|
|
656
|
-
name: task.name,
|
|
657
|
-
when: task.exprDesc,
|
|
658
|
-
kind: task.kind,
|
|
659
|
-
timezone: task.timezone || null,
|
|
660
|
-
lastRun: task.lastRun,
|
|
661
|
-
lastFinish: task.lastFinish,
|
|
662
|
-
lastError: task.lastError,
|
|
663
|
-
nextRun: task.nextRun ? new Date(task.nextRun).toISOString() : null,
|
|
664
|
-
running: task.running,
|
|
665
|
-
fires: task.fires,
|
|
666
|
-
misses: task.misses,
|
|
667
|
-
nonLeaderSkips: task.nonLeaderSkips,
|
|
668
|
-
tickClaimLost: task.tickClaimLost,
|
|
669
|
-
});
|
|
670
|
-
});
|
|
671
|
-
return out;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
function _resetForTest() {
|
|
675
|
-
tasks.forEach(function (_t, _n) { /* noop — drop refs below */ });
|
|
676
|
-
timers.forEach(function (t) {
|
|
677
|
-
try { clearTimeout(t); }
|
|
678
|
-
catch (e) { log().debug("stop-cleanup-failed", { op: "clearTimeout", error: e.message }); }
|
|
679
|
-
});
|
|
680
|
-
timers.clear();
|
|
681
|
-
tasks.clear();
|
|
682
|
-
started = false;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
return {
|
|
686
|
-
schedule: schedule,
|
|
687
|
-
start: start,
|
|
688
|
-
stop: stop,
|
|
689
|
-
list: list,
|
|
690
|
-
pruneTickClaims: pruneTickClaims,
|
|
691
|
-
_fireOnce: function (name) { // test hook
|
|
692
|
-
var task = tasks.get(name);
|
|
693
|
-
if (!task) throw _err("UNKNOWN_NAME", "no task '" + name + "'", true);
|
|
694
|
-
_fireOnce(task);
|
|
695
|
-
},
|
|
696
|
-
_resetForTest: _resetForTest,
|
|
697
|
-
};
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
module.exports = {
|
|
701
|
-
create: create,
|
|
702
|
-
parseCron: parseCron,
|
|
703
|
-
nextCronFire: nextCronFire,
|
|
704
|
-
nextBaselineFire: nextBaselineFire,
|
|
705
|
-
SchedulerError: SchedulerError,
|
|
706
|
-
};
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* scheduler — cron + interval scheduler over lib/jobs (or direct fn).
|
|
4
|
+
*
|
|
5
|
+
* The framework's primitive for "run X at Y" — backed by jobs/queue
|
|
6
|
+
* for retries, audit, and cluster-aware dispatch, with a direct-fn
|
|
7
|
+
* escape hatch for the simple cases.
|
|
8
|
+
*
|
|
9
|
+
* var sched = b.scheduler.create({
|
|
10
|
+
* jobs: jobsInstance, // optional; needed for { job: "name" }
|
|
11
|
+
* cluster: b.cluster, // optional; gates fires to leader only
|
|
12
|
+
* audit: true, // default true
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* sched.schedule({
|
|
16
|
+
* name: "nightly-cleanup",
|
|
17
|
+
* cron: "0 2 * * *", // POSIX 5-field cron
|
|
18
|
+
* timezone: "America/New_York", // IANA name; default = server-local
|
|
19
|
+
* job: "cleanup", // dispatched via jobs.enqueue
|
|
20
|
+
* payload: { scope: "all" },
|
|
21
|
+
* });
|
|
22
|
+
*
|
|
23
|
+
* sched.schedule({
|
|
24
|
+
* name: "stats-aggregation",
|
|
25
|
+
* every: 300000, // ms between runs
|
|
26
|
+
* baseline: "00:00", // HH:MM anchor (optional)
|
|
27
|
+
* timezone: "America/New_York",
|
|
28
|
+
* job: "aggregate-stats",
|
|
29
|
+
* });
|
|
30
|
+
*
|
|
31
|
+
* sched.schedule({
|
|
32
|
+
* name: "heartbeat",
|
|
33
|
+
* every: 60000,
|
|
34
|
+
* run: async function () { … }, // direct function (no jobs needed)
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* await sched.start(); // arms timers
|
|
38
|
+
* await sched.stop(); // clears timers, drops pending fires
|
|
39
|
+
*
|
|
40
|
+
* sched.list(); // → [{ name, when, lastRun, nextRun, running }]
|
|
41
|
+
*
|
|
42
|
+
* Cron grammar (5 fields, space-separated):
|
|
43
|
+
*
|
|
44
|
+
* minute (0–59) hour (0–23) dom (1–31) month (1–12) dow (0–7; 0/7=Sun)
|
|
45
|
+
*
|
|
46
|
+
* Each field accepts: * N N,M,… A-B *\/N A-B/N
|
|
47
|
+
*
|
|
48
|
+
* Shorthands: @hourly @daily @midnight @weekly @monthly @yearly @annually
|
|
49
|
+
*
|
|
50
|
+
* Cluster gating: when opts.cluster is wired and the local node is not
|
|
51
|
+
* the leader, schedule fires no-op. The leader still computes nextRun
|
|
52
|
+
* locally so a leader transition picks up cleanly.
|
|
53
|
+
*
|
|
54
|
+
* Exactly-once-globally: when opts.cluster is wired, every fire first
|
|
55
|
+
* INSERTs a row into _blamejs_scheduler_ticks keyed on (taskName,
|
|
56
|
+
* scheduledAtUnix). The PRIMARY KEY race ensures that even if two
|
|
57
|
+
* nodes briefly believe they are the leader (split-brain on lease
|
|
58
|
+
* boundary), only the row-winner runs the task. The loser increments
|
|
59
|
+
* task.tickClaimLost (visible via list()) and skips silently. Task
|
|
60
|
+
* handlers should still be idempotent — operators may add jobs.enqueue
|
|
61
|
+
* dedup keys for defense-in-depth.
|
|
62
|
+
*
|
|
63
|
+
* Tick-claim retention: rows older than opts.tickRetentionMs (default
|
|
64
|
+
* 7 days) are pruned automatically — at most once per opts.pruneInterval
|
|
65
|
+
* Ms (default 60s) — by the leader on its next successful fire. Operators
|
|
66
|
+
* can also call sched.pruneTickClaims(olderThanMs?) on demand to force
|
|
67
|
+
* a sweep (e.g. from a maintenance script) and observe the count via
|
|
68
|
+
* the system.scheduler.tick.pruned audit event.
|
|
69
|
+
*
|
|
70
|
+
* Watchdog: if a fire's promise hasn't settled after MAX_JOB_MS
|
|
71
|
+
* (10min default; opts.maxJobMs to override), the running flag is
|
|
72
|
+
* force-cleared and a warning emitted, so a hung job doesn't lock out
|
|
73
|
+
* future fires.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
var lazyRequire = require("./lazy-require");
|
|
77
|
+
var audit = lazyRequire(function () { return require("./audit"); });
|
|
78
|
+
var log = lazyRequire(function () { return require("./log").boot("scheduler"); });
|
|
79
|
+
var clusterStorage = require("./cluster-storage");
|
|
80
|
+
var validateOpts = require("./validate-opts");
|
|
81
|
+
var C = require("./constants");
|
|
82
|
+
var { SchedulerError } = require("./framework-error");
|
|
83
|
+
|
|
84
|
+
var DEFAULT_MAX_JOB_MS = C.TIME.minutes(10);
|
|
85
|
+
var DEFAULT_TICK_RETENTION_MS = C.TIME.days(7);
|
|
86
|
+
var DEFAULT_TICK_PRUNE_INTERVAL_MS = C.TIME.minutes(1);
|
|
87
|
+
|
|
88
|
+
// ---- Cron parsing ----
|
|
89
|
+
|
|
90
|
+
var CRON_SHORTHANDS = {
|
|
91
|
+
"@yearly": "0 0 1 1 *",
|
|
92
|
+
"@annually": "0 0 1 1 *",
|
|
93
|
+
"@monthly": "0 0 1 * *",
|
|
94
|
+
"@weekly": "0 0 * * 0",
|
|
95
|
+
"@daily": "0 0 * * *",
|
|
96
|
+
"@midnight": "0 0 * * *",
|
|
97
|
+
"@hourly": "0 * * * *",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
var CRON_FIELD_RANGES = [
|
|
101
|
+
{ name: "minute", min: 0, max: 59 },
|
|
102
|
+
{ name: "hour", min: 0, max: 23 },
|
|
103
|
+
{ name: "dom", min: 1, max: 31 },
|
|
104
|
+
{ name: "month", min: 1, max: 12 },
|
|
105
|
+
{ name: "dow", min: 0, max: 7 }, // 0 and 7 both mean Sunday
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
function _parseCronField(text, range) {
|
|
109
|
+
var parts = String(text).split(",");
|
|
110
|
+
var set = new Set();
|
|
111
|
+
for (var i = 0; i < parts.length; i++) {
|
|
112
|
+
var part = parts[i].trim();
|
|
113
|
+
if (part.length === 0) {
|
|
114
|
+
throw new SchedulerError("scheduler/invalid-cron",
|
|
115
|
+
"empty term in cron field '" + range.name + "'", true);
|
|
116
|
+
}
|
|
117
|
+
var step = 1;
|
|
118
|
+
var stepIdx = part.indexOf("/");
|
|
119
|
+
if (stepIdx !== -1) {
|
|
120
|
+
var stepStr = part.slice(stepIdx + 1);
|
|
121
|
+
step = parseInt(stepStr, 10);
|
|
122
|
+
if (!Number.isFinite(step) || step < 1) {
|
|
123
|
+
throw new SchedulerError("scheduler/invalid-cron",
|
|
124
|
+
"bad step '" + stepStr + "' in cron field '" + range.name + "'", true);
|
|
125
|
+
}
|
|
126
|
+
// Reject step > field-range, even though the for-loop below would
|
|
127
|
+
// silently produce a single-value schedule (e.g. `*/99999` for
|
|
128
|
+
// minutes degenerates to "minute 0 of every hour"). An operator
|
|
129
|
+
// typing `*/99999` clearly meant something else; silent acceptance
|
|
130
|
+
// hides the typo and produces a schedule that fires once per hour
|
|
131
|
+
// when the operator probably wanted once per N minutes for small N.
|
|
132
|
+
// The bound is `range.max - range.min + 1` so e.g. minutes (0-59)
|
|
133
|
+
// accepts step up to 60 (inclusive — `*/60` is "minute 0 of every
|
|
134
|
+
// hour" written with a redundant step).
|
|
135
|
+
var rangeSize = range.max - range.min + 1;
|
|
136
|
+
if (step > rangeSize) {
|
|
137
|
+
throw new SchedulerError("scheduler/invalid-cron",
|
|
138
|
+
"step '" + stepStr + "' exceeds field range (" + rangeSize +
|
|
139
|
+
") in cron field '" + range.name + "'", true);
|
|
140
|
+
}
|
|
141
|
+
part = part.slice(0, stepIdx);
|
|
142
|
+
}
|
|
143
|
+
var lo, hi;
|
|
144
|
+
if (part === "*") {
|
|
145
|
+
lo = range.min; hi = range.max;
|
|
146
|
+
} else if (part.indexOf("-") !== -1) {
|
|
147
|
+
var seg = part.split("-");
|
|
148
|
+
if (seg.length !== 2) {
|
|
149
|
+
throw new SchedulerError("scheduler/invalid-cron",
|
|
150
|
+
"bad range '" + part + "' in cron field '" + range.name + "'", true);
|
|
151
|
+
}
|
|
152
|
+
lo = parseInt(seg[0], 10);
|
|
153
|
+
hi = parseInt(seg[1], 10);
|
|
154
|
+
} else {
|
|
155
|
+
lo = parseInt(part, 10);
|
|
156
|
+
hi = lo;
|
|
157
|
+
}
|
|
158
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo > hi ||
|
|
159
|
+
lo < range.min || hi > range.max) {
|
|
160
|
+
throw new SchedulerError("scheduler/invalid-cron",
|
|
161
|
+
"value '" + part + "' out of range " + range.min + "-" + range.max +
|
|
162
|
+
" in cron field '" + range.name + "'", true);
|
|
163
|
+
}
|
|
164
|
+
for (var v = lo; v <= hi; v += step) set.add(v);
|
|
165
|
+
}
|
|
166
|
+
// Normalize Sunday: dow 7 → 0 (so the matcher can use a single set)
|
|
167
|
+
if (range.name === "dow" && set.has(7)) { set.add(0); set.delete(7); }
|
|
168
|
+
return set;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseCron(expr) {
|
|
172
|
+
if (typeof expr !== "string" || expr.length === 0) {
|
|
173
|
+
throw new SchedulerError("scheduler/invalid-cron",
|
|
174
|
+
"cron expression must be a non-empty string", true);
|
|
175
|
+
}
|
|
176
|
+
var trimmed = expr.trim();
|
|
177
|
+
if (CRON_SHORTHANDS[trimmed.toLowerCase()]) {
|
|
178
|
+
trimmed = CRON_SHORTHANDS[trimmed.toLowerCase()];
|
|
179
|
+
}
|
|
180
|
+
var fields = trimmed.split(/\s+/);
|
|
181
|
+
if (fields.length !== 5) {
|
|
182
|
+
throw new SchedulerError("scheduler/invalid-cron",
|
|
183
|
+
"cron expression must have 5 fields (got " + fields.length + "): " + expr, true);
|
|
184
|
+
}
|
|
185
|
+
var sets = [];
|
|
186
|
+
for (var i = 0; i < 5; i++) {
|
|
187
|
+
sets.push(_parseCronField(fields[i], CRON_FIELD_RANGES[i]));
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
expr: trimmed,
|
|
191
|
+
minute: sets[0],
|
|
192
|
+
hour: sets[1],
|
|
193
|
+
dom: sets[2],
|
|
194
|
+
month: sets[3],
|
|
195
|
+
dow: sets[4],
|
|
196
|
+
// Whether dom or dow was constrained — matters for the cron quirk
|
|
197
|
+
// where day-of-month and day-of-week are OR'd when both are set.
|
|
198
|
+
domRestricted: sets[2].size < (CRON_FIELD_RANGES[2].max - CRON_FIELD_RANGES[2].min + 1),
|
|
199
|
+
dowRestricted: sets[4].size < 7,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- Timezone-aware wall-clock helpers ----
|
|
204
|
+
//
|
|
205
|
+
// We need "what's the wall clock in TZ for time T?" and "given wall
|
|
206
|
+
// clock W in TZ, what UTC instant does that correspond to?". Intl
|
|
207
|
+
// gives us the first cheaply. The second is approximated by walking
|
|
208
|
+
// minute-by-minute — accurate enough for cron schedules (DST gaps fire
|
|
209
|
+
// at the next valid wall-clock instant; overlaps fire once at the
|
|
210
|
+
// first matching instant).
|
|
211
|
+
|
|
212
|
+
function _getWallClockParts(date, timeZone) {
|
|
213
|
+
if (!timeZone) {
|
|
214
|
+
return {
|
|
215
|
+
year: date.getFullYear(),
|
|
216
|
+
month: date.getMonth() + 1,
|
|
217
|
+
day: date.getDate(),
|
|
218
|
+
hour: date.getHours(),
|
|
219
|
+
minute: date.getMinutes(),
|
|
220
|
+
dow: date.getDay(),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
var fmt = new Intl.DateTimeFormat("en-US", {
|
|
224
|
+
timeZone: timeZone,
|
|
225
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
226
|
+
hour: "2-digit", minute: "2-digit", weekday: "short",
|
|
227
|
+
hour12: false,
|
|
228
|
+
});
|
|
229
|
+
var parts = {};
|
|
230
|
+
fmt.formatToParts(date).forEach(function (p) { parts[p.type] = p.value; });
|
|
231
|
+
var dowMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
232
|
+
// Some locales emit "24:00" for midnight — normalize before parseInt so
|
|
233
|
+
// the integer literal stays out of the source.
|
|
234
|
+
var hr = (parts.hour === "24") ? 0 : parseInt(parts.hour, 10);
|
|
235
|
+
return {
|
|
236
|
+
year: parseInt(parts.year, 10),
|
|
237
|
+
month: parseInt(parts.month, 10),
|
|
238
|
+
day: parseInt(parts.day, 10),
|
|
239
|
+
hour: hr,
|
|
240
|
+
minute: parseInt(parts.minute, 10),
|
|
241
|
+
dow: dowMap[parts.weekday] || 0,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _validateTimezone(tz) {
|
|
246
|
+
if (!tz) return null;
|
|
247
|
+
try {
|
|
248
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date());
|
|
249
|
+
return tz;
|
|
250
|
+
} catch (_e) {
|
|
251
|
+
throw new SchedulerError("scheduler/invalid-timezone",
|
|
252
|
+
"unknown IANA timezone '" + tz + "'", true);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function _matchesCron(cron, parts) {
|
|
257
|
+
if (!cron.minute.has(parts.minute)) return false;
|
|
258
|
+
if (!cron.hour.has(parts.hour)) return false;
|
|
259
|
+
if (!cron.month.has(parts.month)) return false;
|
|
260
|
+
// POSIX cron quirk: when both dom AND dow are restricted, the day
|
|
261
|
+
// matches if EITHER matches (OR). When only one is restricted,
|
|
262
|
+
// standard AND.
|
|
263
|
+
var domOk = cron.dom.has(parts.day);
|
|
264
|
+
var dowOk = cron.dow.has(parts.dow);
|
|
265
|
+
if (cron.domRestricted && cron.dowRestricted) return domOk || dowOk;
|
|
266
|
+
if (cron.domRestricted) return domOk;
|
|
267
|
+
if (cron.dowRestricted) return dowOk;
|
|
268
|
+
return true; // both fully wild
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// nextCronFire — earliest UTC ms ≥ `after` whose wall-clock in `tz`
|
|
272
|
+
// matches the cron sets. Walks minute by minute; bounded at ~530K
|
|
273
|
+
// iterations (1 year of minutes) before giving up with a clear error.
|
|
274
|
+
function nextCronFire(cron, after, timeZone) {
|
|
275
|
+
var MINUTE_MS = C.TIME.minutes(1);
|
|
276
|
+
// Round up to the next whole minute boundary
|
|
277
|
+
var t = new Date(after.getTime() + (MINUTE_MS - (after.getTime() % MINUTE_MS)) % MINUTE_MS);
|
|
278
|
+
if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + MINUTE_MS);
|
|
279
|
+
// 1 year of minute-walks plus a 1-hour DST/leap cushion.
|
|
280
|
+
var maxIters = (C.TIME.days(366) / MINUTE_MS) + (C.TIME.hours(1) / MINUTE_MS);
|
|
281
|
+
for (var i = 0; i < maxIters; i++) {
|
|
282
|
+
var parts = _getWallClockParts(t, timeZone);
|
|
283
|
+
if (_matchesCron(cron, parts)) return t.getTime();
|
|
284
|
+
t = new Date(t.getTime() + MINUTE_MS);
|
|
285
|
+
}
|
|
286
|
+
throw new SchedulerError("scheduler/cron-no-fire",
|
|
287
|
+
"cron expression '" + cron.expr + "' produced no fire within 1 year " +
|
|
288
|
+
"(impossible date constraint?)", true);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// nextBaselineFire — next UTC ms whose wall-clock in `tz` matches HH:MM.
|
|
292
|
+
function nextBaselineFire(timeOfDay, timeZone, after) {
|
|
293
|
+
var match = String(timeOfDay).match(/^(\d{1,2}):(\d{2})$/);
|
|
294
|
+
if (!match) {
|
|
295
|
+
throw new SchedulerError("scheduler/invalid-baseline",
|
|
296
|
+
"baseline must be HH:MM (got '" + timeOfDay + "')", true);
|
|
297
|
+
}
|
|
298
|
+
var hh = parseInt(match[1], 10);
|
|
299
|
+
var mm = parseInt(match[2], 10);
|
|
300
|
+
if (hh < 0 || hh > 23 || mm < 0 || mm > 59) {
|
|
301
|
+
throw new SchedulerError("scheduler/invalid-baseline",
|
|
302
|
+
"baseline '" + timeOfDay + "' is not a valid 24h time", true);
|
|
303
|
+
}
|
|
304
|
+
var MINUTE_MS = C.TIME.minutes(1);
|
|
305
|
+
var t = new Date(after.getTime() + (MINUTE_MS - (after.getTime() % MINUTE_MS)) % MINUTE_MS);
|
|
306
|
+
if (t.getTime() <= after.getTime()) t = new Date(t.getTime() + MINUTE_MS);
|
|
307
|
+
// 1 day of minute-walks plus a 1-hour DST cushion.
|
|
308
|
+
for (var i = 0; i < (C.TIME.hours(24) / MINUTE_MS) + (C.TIME.hours(1) / MINUTE_MS); i++) {
|
|
309
|
+
var parts = _getWallClockParts(t, timeZone);
|
|
310
|
+
if (parts.hour === hh && parts.minute === mm) return t.getTime();
|
|
311
|
+
t = new Date(t.getTime() + MINUTE_MS);
|
|
312
|
+
}
|
|
313
|
+
throw new SchedulerError("scheduler/baseline-no-fire",
|
|
314
|
+
"baseline '" + timeOfDay + "' produced no fire within 24h+ (timezone bug?)", true);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---- Engine ----
|
|
318
|
+
|
|
319
|
+
function create(opts) {
|
|
320
|
+
opts = opts || {};
|
|
321
|
+
validateOpts(opts, [
|
|
322
|
+
"jobs", "cluster", "audit",
|
|
323
|
+
"maxJobMs", "tickRetentionMs", "pruneIntervalMs",
|
|
324
|
+
], "scheduler");
|
|
325
|
+
var jobsInstance = opts.jobs || null;
|
|
326
|
+
var clusterInstance = opts.cluster || null;
|
|
327
|
+
var auditOn = opts.audit !== false;
|
|
328
|
+
var maxJobMs = opts.maxJobMs || DEFAULT_MAX_JOB_MS;
|
|
329
|
+
var tickRetentionMs = opts.tickRetentionMs != null
|
|
330
|
+
? opts.tickRetentionMs : DEFAULT_TICK_RETENTION_MS;
|
|
331
|
+
var pruneIntervalMs = opts.pruneIntervalMs != null
|
|
332
|
+
? opts.pruneIntervalMs : DEFAULT_TICK_PRUNE_INTERVAL_MS;
|
|
333
|
+
|
|
334
|
+
// name → task
|
|
335
|
+
var tasks = new Map();
|
|
336
|
+
var timers = new Set();
|
|
337
|
+
var started = false;
|
|
338
|
+
var lastPruneAt = 0;
|
|
339
|
+
|
|
340
|
+
var _err = SchedulerError.factory;
|
|
341
|
+
|
|
342
|
+
function _emit(action, info, outcome) {
|
|
343
|
+
if (!auditOn) return;
|
|
344
|
+
audit().safeEmit({
|
|
345
|
+
action: action,
|
|
346
|
+
outcome: outcome,
|
|
347
|
+
metadata: info || {},
|
|
348
|
+
reason: info && info.reason ? info.reason : null,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function _isLeaderHere() {
|
|
353
|
+
if (!clusterInstance) return true;
|
|
354
|
+
try {
|
|
355
|
+
if (typeof clusterInstance.isLeader === "function") return !!clusterInstance.isLeader();
|
|
356
|
+
} catch (_e) { /* treat unknown leadership state as not-leader */ }
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function schedule(spec) {
|
|
361
|
+
if (started) {
|
|
362
|
+
throw _err("ALREADY_STARTED",
|
|
363
|
+
"scheduler.schedule: cannot register '" + (spec && spec.name) +
|
|
364
|
+
"' after start() — schedule all tasks before calling start()", true);
|
|
365
|
+
}
|
|
366
|
+
if (!spec || typeof spec !== "object") {
|
|
367
|
+
throw _err("INVALID_SPEC", "scheduler.schedule requires a spec object", true);
|
|
368
|
+
}
|
|
369
|
+
if (typeof spec.name !== "string" || spec.name.length === 0) {
|
|
370
|
+
throw _err("INVALID_NAME", "scheduler.schedule: spec.name is required", true);
|
|
371
|
+
}
|
|
372
|
+
if (tasks.has(spec.name)) {
|
|
373
|
+
throw _err("DUPLICATE_NAME",
|
|
374
|
+
"scheduler.schedule: '" + spec.name + "' is already scheduled", true);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
var hasCron = typeof spec.cron === "string";
|
|
378
|
+
var hasEvery = typeof spec.every === "number";
|
|
379
|
+
if ((hasCron && hasEvery) || (!hasCron && !hasEvery)) {
|
|
380
|
+
throw _err("INVALID_SPEC",
|
|
381
|
+
"scheduler.schedule: spec must set exactly one of cron / every (got cron=" +
|
|
382
|
+
hasCron + ", every=" + hasEvery + ")", true);
|
|
383
|
+
}
|
|
384
|
+
if (hasEvery && (!Number.isFinite(spec.every) || spec.every < C.TIME.seconds(1))) {
|
|
385
|
+
throw _err("INVALID_SPEC",
|
|
386
|
+
"scheduler.schedule: spec.every must be a number ≥ 1000 ms", true);
|
|
387
|
+
}
|
|
388
|
+
var hasJob = typeof spec.job === "string" && spec.job.length > 0;
|
|
389
|
+
var hasRun = typeof spec.run === "function";
|
|
390
|
+
if ((hasJob && hasRun) || (!hasJob && !hasRun)) {
|
|
391
|
+
throw _err("INVALID_SPEC",
|
|
392
|
+
"scheduler.schedule: spec must set exactly one of job / run", true);
|
|
393
|
+
}
|
|
394
|
+
if (hasJob && !jobsInstance) {
|
|
395
|
+
throw _err("INVALID_SPEC",
|
|
396
|
+
"scheduler.schedule: spec.job requires opts.jobs at scheduler.create — " +
|
|
397
|
+
"use spec.run for direct-function tasks when jobs is unwired", true);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
var tz = _validateTimezone(spec.timezone || null);
|
|
401
|
+
var task = {
|
|
402
|
+
name: spec.name,
|
|
403
|
+
timezone: tz,
|
|
404
|
+
job: hasJob ? spec.job : null,
|
|
405
|
+
payload: spec.payload || null,
|
|
406
|
+
run: hasRun ? spec.run : null,
|
|
407
|
+
enqueueOpts: spec.enqueueOpts || null,
|
|
408
|
+
lastRun: null,
|
|
409
|
+
lastFinish: null,
|
|
410
|
+
lastError: null,
|
|
411
|
+
running: false,
|
|
412
|
+
runningSince: 0,
|
|
413
|
+
fires: 0,
|
|
414
|
+
misses: 0, // skipped because previous run still in-flight
|
|
415
|
+
nonLeaderSkips: 0,
|
|
416
|
+
tickClaimLost: 0, // lost the tick-claim race to another leader (cluster only)
|
|
417
|
+
};
|
|
418
|
+
if (hasCron) {
|
|
419
|
+
task.kind = "cron";
|
|
420
|
+
task.cron = parseCron(spec.cron);
|
|
421
|
+
task.exprDesc = "cron " + task.cron.expr + (tz ? " " + tz : "");
|
|
422
|
+
task.nextRun = nextCronFire(task.cron, new Date(), tz);
|
|
423
|
+
} else {
|
|
424
|
+
task.kind = "every";
|
|
425
|
+
task.every = spec.every;
|
|
426
|
+
if (spec.baseline) {
|
|
427
|
+
task.baseline = spec.baseline;
|
|
428
|
+
task.nextRun = nextBaselineFire(spec.baseline, tz, new Date());
|
|
429
|
+
} else {
|
|
430
|
+
// Initial offset: fire one full interval after start (consistent
|
|
431
|
+
// with how operators usually expect interval timers).
|
|
432
|
+
task.nextRun = Date.now() + spec.every;
|
|
433
|
+
}
|
|
434
|
+
task.exprDesc = "every " + spec.every + "ms" +
|
|
435
|
+
(spec.baseline ? " from " + spec.baseline : "") +
|
|
436
|
+
(tz ? " " + tz : "");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
tasks.set(spec.name, task);
|
|
440
|
+
return task;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function _computeNextRun(task, after) {
|
|
444
|
+
if (task.kind === "cron") {
|
|
445
|
+
return nextCronFire(task.cron, new Date(after), task.timezone);
|
|
446
|
+
}
|
|
447
|
+
// every: anchor on baseline if set (so day-to-day drift stays
|
|
448
|
+
// bounded), otherwise pure interval from `after`.
|
|
449
|
+
if (task.baseline) {
|
|
450
|
+
return nextBaselineFire(task.baseline, task.timezone, new Date(after));
|
|
451
|
+
}
|
|
452
|
+
return after + task.every;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function _fireOnce(task) {
|
|
456
|
+
// Skip if previous run still in flight.
|
|
457
|
+
if (task.running) {
|
|
458
|
+
// Watchdog: if we're past MAX_JOB_MS, force-clear and let this fire.
|
|
459
|
+
if (task.runningSince && (Date.now() - task.runningSince) > maxJobMs) {
|
|
460
|
+
try {
|
|
461
|
+
log().warn("[scheduler] '" + task.name + "' exceeded " +
|
|
462
|
+
(maxJobMs / C.TIME.seconds(1)) + "s — forcing reset");
|
|
463
|
+
} catch (_e) { /* logger best-effort */ }
|
|
464
|
+
_emit("system.scheduler.task.watchdog", { name: task.name }, "failure");
|
|
465
|
+
task.running = false;
|
|
466
|
+
} else {
|
|
467
|
+
task.misses++;
|
|
468
|
+
_emit("system.scheduler.task.skipped",
|
|
469
|
+
{ name: task.name, reason: "previous-run-in-flight" }, "denied");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Cluster leader gate. Compute nextRun even when not leader so a
|
|
475
|
+
// leader transition picks up cleanly without a state reload.
|
|
476
|
+
if (!_isLeaderHere()) {
|
|
477
|
+
task.nonLeaderSkips++;
|
|
478
|
+
task.nextRun = _computeNextRun(task, Date.now());
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Capture the nominal scheduled time for this tick before we
|
|
483
|
+
// recompute nextRun for the next firing.
|
|
484
|
+
var nominalRun = task.nextRun;
|
|
485
|
+
|
|
486
|
+
// Compute the next fire time forward from now (not from nominal
|
|
487
|
+
// nextRun) so a long-running fire doesn't queue up backlog ticks.
|
|
488
|
+
// Done before any await so _arm() reads the fresh value when it
|
|
489
|
+
// re-arms after this synchronous return.
|
|
490
|
+
task.nextRun = _computeNextRun(task, Date.now());
|
|
491
|
+
|
|
492
|
+
// Cluster mode: race for the tick-claim row. Loser of the INSERT
|
|
493
|
+
// skips silently. Single-node mode (no clusterInstance wired) fires
|
|
494
|
+
// unconditionally — there's only one process so no contention is
|
|
495
|
+
// possible.
|
|
496
|
+
if (clusterInstance) {
|
|
497
|
+
var tickKey = task.name + ":" + nominalRun;
|
|
498
|
+
var claimedBy = (typeof clusterInstance.currentNodeId === "function")
|
|
499
|
+
? clusterInstance.currentNodeId() : "unknown";
|
|
500
|
+
clusterStorage.execute(
|
|
501
|
+
"INSERT INTO _blamejs_scheduler_ticks " +
|
|
502
|
+
"(tickKey, name, scheduledAtUnix, claimedAtUnix, claimedBy) " +
|
|
503
|
+
"VALUES (?, ?, ?, ?, ?) " +
|
|
504
|
+
"ON CONFLICT (tickKey) DO NOTHING",
|
|
505
|
+
[tickKey, task.name, nominalRun, Date.now(), claimedBy]
|
|
506
|
+
).then(function (result) {
|
|
507
|
+
var won = (result && result.rowCount > 0);
|
|
508
|
+
if (won) {
|
|
509
|
+
_runFire(task);
|
|
510
|
+
} else {
|
|
511
|
+
task.tickClaimLost++;
|
|
512
|
+
_emit("system.scheduler.tick.lost", {
|
|
513
|
+
name: task.name, tickKey: tickKey, claimedBy: claimedBy,
|
|
514
|
+
}, "denied");
|
|
515
|
+
}
|
|
516
|
+
}, function (e) {
|
|
517
|
+
try {
|
|
518
|
+
log().warn("[scheduler] tick-claim failed for '" + task.name + "'",
|
|
519
|
+
{ error: (e && e.message) || String(e) });
|
|
520
|
+
} catch (_e) { /* logger best-effort */ }
|
|
521
|
+
_emit("system.scheduler.tick.error", {
|
|
522
|
+
name: task.name, tickKey: tickKey,
|
|
523
|
+
reason: (e && e.message) || String(e),
|
|
524
|
+
}, "failure");
|
|
525
|
+
});
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
_runFire(task);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Operator-callable prune. Deletes _blamejs_scheduler_ticks rows whose
|
|
533
|
+
// scheduledAtUnix is older than `olderThanMs` (default = retention
|
|
534
|
+
// window passed to scheduler.create). Returns a Promise that resolves
|
|
535
|
+
// to the number of rows removed. No-op if cluster wiring is absent
|
|
536
|
+
// (single-node scheduler doesn't write tick rows).
|
|
537
|
+
async function pruneTickClaims(olderThanMs) {
|
|
538
|
+
if (!clusterInstance) return 0;
|
|
539
|
+
var threshold = Date.now() - (
|
|
540
|
+
typeof olderThanMs === "number" ? olderThanMs : tickRetentionMs
|
|
541
|
+
);
|
|
542
|
+
var result = await clusterStorage.execute(
|
|
543
|
+
"DELETE FROM _blamejs_scheduler_ticks WHERE scheduledAtUnix < ?",
|
|
544
|
+
[threshold]
|
|
545
|
+
);
|
|
546
|
+
var removed = (result && result.rowCount) || 0;
|
|
547
|
+
if (removed > 0) {
|
|
548
|
+
_emit("system.scheduler.tick.pruned", {
|
|
549
|
+
rowsDeleted: removed,
|
|
550
|
+
olderThanUnix: threshold,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return removed;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Rate-limited best-effort prune called after a successful tick claim.
|
|
557
|
+
// Errors are swallowed — pruning is housekeeping, not part of the fire
|
|
558
|
+
// critical path.
|
|
559
|
+
function _maybePruneTickClaims() {
|
|
560
|
+
if (!clusterInstance) return;
|
|
561
|
+
if (tickRetentionMs <= 0) return;
|
|
562
|
+
var now = Date.now();
|
|
563
|
+
if (now - lastPruneAt < pruneIntervalMs) return;
|
|
564
|
+
lastPruneAt = now;
|
|
565
|
+
pruneTickClaims().catch(function (e) {
|
|
566
|
+
try {
|
|
567
|
+
log().warn("[scheduler] tick-claim prune failed",
|
|
568
|
+
{ error: (e && e.message) || String(e) });
|
|
569
|
+
} catch (_e) { /* logger best-effort */ }
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function _runFire(task) {
|
|
574
|
+
_maybePruneTickClaims();
|
|
575
|
+
task.fires++;
|
|
576
|
+
task.running = true;
|
|
577
|
+
task.runningSince = Date.now();
|
|
578
|
+
task.lastRun = new Date().toISOString();
|
|
579
|
+
var startedAt = Date.now();
|
|
580
|
+
|
|
581
|
+
var promise;
|
|
582
|
+
try {
|
|
583
|
+
if (task.job) {
|
|
584
|
+
promise = jobsInstance.enqueue(task.job, task.payload || {}, task.enqueueOpts || {});
|
|
585
|
+
} else {
|
|
586
|
+
promise = Promise.resolve(task.run());
|
|
587
|
+
}
|
|
588
|
+
} catch (e) {
|
|
589
|
+
promise = Promise.reject(e);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
Promise.resolve(promise).then(function (_v) {
|
|
593
|
+
task.running = false;
|
|
594
|
+
task.runningSince = 0;
|
|
595
|
+
task.lastFinish = new Date().toISOString();
|
|
596
|
+
task.lastError = null;
|
|
597
|
+
_emit("system.scheduler.task.success", {
|
|
598
|
+
name: task.name,
|
|
599
|
+
kind: task.kind,
|
|
600
|
+
durationMs: Date.now() - startedAt,
|
|
601
|
+
viaJob: !!task.job,
|
|
602
|
+
});
|
|
603
|
+
}, function (e) {
|
|
604
|
+
task.running = false;
|
|
605
|
+
task.runningSince = 0;
|
|
606
|
+
task.lastFinish = new Date().toISOString();
|
|
607
|
+
task.lastError = (e && e.message) || String(e);
|
|
608
|
+
try {
|
|
609
|
+
log().error("[scheduler] '" + task.name + "' failed", { error: task.lastError });
|
|
610
|
+
} catch (_e) { /* logger best-effort */ }
|
|
611
|
+
_emit("system.scheduler.task.failure", {
|
|
612
|
+
name: task.name,
|
|
613
|
+
kind: task.kind,
|
|
614
|
+
durationMs: Date.now() - startedAt,
|
|
615
|
+
viaJob: !!task.job,
|
|
616
|
+
reason: task.lastError,
|
|
617
|
+
}, "failure");
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function _arm(task) {
|
|
622
|
+
var delay = Math.max(0, task.nextRun - Date.now());
|
|
623
|
+
var t = setTimeout(function () {
|
|
624
|
+
timers.delete(t);
|
|
625
|
+
if (!started) return;
|
|
626
|
+
_fireOnce(task);
|
|
627
|
+
if (!started) return;
|
|
628
|
+
_arm(task);
|
|
629
|
+
}, delay);
|
|
630
|
+
if (typeof t.unref === "function") t.unref();
|
|
631
|
+
timers.add(t);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async function start() {
|
|
635
|
+
if (started) return;
|
|
636
|
+
started = true;
|
|
637
|
+
tasks.forEach(function (task) { _arm(task); });
|
|
638
|
+
_emit("scheduler.start", { count: tasks.size });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function stop() {
|
|
642
|
+
if (!started) return;
|
|
643
|
+
started = false;
|
|
644
|
+
timers.forEach(function (t) {
|
|
645
|
+
try { clearTimeout(t); }
|
|
646
|
+
catch (e) { log().debug("stop-cleanup-failed", { op: "clearTimeout", error: e.message }); }
|
|
647
|
+
});
|
|
648
|
+
timers.clear();
|
|
649
|
+
_emit("scheduler.stop", { count: tasks.size });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function list() {
|
|
653
|
+
var out = [];
|
|
654
|
+
tasks.forEach(function (task) {
|
|
655
|
+
out.push({
|
|
656
|
+
name: task.name,
|
|
657
|
+
when: task.exprDesc,
|
|
658
|
+
kind: task.kind,
|
|
659
|
+
timezone: task.timezone || null,
|
|
660
|
+
lastRun: task.lastRun,
|
|
661
|
+
lastFinish: task.lastFinish,
|
|
662
|
+
lastError: task.lastError,
|
|
663
|
+
nextRun: task.nextRun ? new Date(task.nextRun).toISOString() : null,
|
|
664
|
+
running: task.running,
|
|
665
|
+
fires: task.fires,
|
|
666
|
+
misses: task.misses,
|
|
667
|
+
nonLeaderSkips: task.nonLeaderSkips,
|
|
668
|
+
tickClaimLost: task.tickClaimLost,
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
return out;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function _resetForTest() {
|
|
675
|
+
tasks.forEach(function (_t, _n) { /* noop — drop refs below */ });
|
|
676
|
+
timers.forEach(function (t) {
|
|
677
|
+
try { clearTimeout(t); }
|
|
678
|
+
catch (e) { log().debug("stop-cleanup-failed", { op: "clearTimeout", error: e.message }); }
|
|
679
|
+
});
|
|
680
|
+
timers.clear();
|
|
681
|
+
tasks.clear();
|
|
682
|
+
started = false;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return {
|
|
686
|
+
schedule: schedule,
|
|
687
|
+
start: start,
|
|
688
|
+
stop: stop,
|
|
689
|
+
list: list,
|
|
690
|
+
pruneTickClaims: pruneTickClaims,
|
|
691
|
+
_fireOnce: function (name) { // test hook
|
|
692
|
+
var task = tasks.get(name);
|
|
693
|
+
if (!task) throw _err("UNKNOWN_NAME", "no task '" + name + "'", true);
|
|
694
|
+
_fireOnce(task);
|
|
695
|
+
},
|
|
696
|
+
_resetForTest: _resetForTest,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
module.exports = {
|
|
701
|
+
create: create,
|
|
702
|
+
parseCron: parseCron,
|
|
703
|
+
nextCronFire: nextCronFire,
|
|
704
|
+
nextBaselineFire: nextBaselineFire,
|
|
705
|
+
SchedulerError: SchedulerError,
|
|
706
|
+
};
|