@rebasepro/server 0.12.0 → 0.12.1-canary.gdfba2a1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/GCSStorageController-BEmDYFKc.js +216 -0
- package/dist/GCSStorageController-BEmDYFKc.js.map +1 -0
- package/dist/S3StorageController-CK7r5yZb.js +243 -0
- package/dist/S3StorageController-CK7r5yZb.js.map +1 -0
- package/dist/admin_block-BGQFSAuV.js +123 -0
- package/dist/admin_block-BGQFSAuV.js.map +1 -0
- package/dist/auth-Bb7XFCKN.js +8898 -0
- package/dist/auth-Bb7XFCKN.js.map +1 -0
- package/dist/backend-CIxN4FVm.js +15 -0
- package/dist/backend-CIxN4FVm.js.map +1 -0
- package/dist/backup-C3Bah7XZ.js +163 -0
- package/dist/backup-C3Bah7XZ.js.map +1 -0
- package/dist/boot/boot.d.ts +20 -0
- package/dist/boot/fetch-bundle.d.ts +43 -0
- package/dist/contract-routes-Dj8i5AiM.js +264 -0
- package/dist/contract-routes-Dj8i5AiM.js.map +1 -0
- package/dist/cron/cron-scheduler.d.ts +34 -0
- package/dist/cron-loader-B1S2MCSl.js +63 -0
- package/dist/cron-loader-B1S2MCSl.js.map +1 -0
- package/dist/cron-routes-Do325hDt.js +62 -0
- package/dist/cron-routes-Do325hDt.js.map +1 -0
- package/dist/cron-scheduler-B3RFt0HS.js +647 -0
- package/dist/cron-scheduler-B3RFt0HS.js.map +1 -0
- package/dist/cron-store-BywZsyfZ.js +164 -0
- package/dist/cron-store-BywZsyfZ.js.map +1 -0
- package/dist/dynamic-import-Dvh-K5fl.js.map +1 -1
- package/dist/errors-BYAQztMf.js +222 -0
- package/dist/errors-BYAQztMf.js.map +1 -0
- package/dist/function-loader-B_1fYfUY.js +86 -0
- package/dist/function-loader-B_1fYfUY.js.map +1 -0
- package/dist/function-routes-C0cLIy3N.js +28 -0
- package/dist/function-routes-C0cLIy3N.js.map +1 -0
- package/dist/index.es.js +7480 -17861
- package/dist/index.es.js.map +1 -1
- package/dist/{jwt-D-eI6TTu.js → jwt-DD6EtpGj.js} +32 -13
- package/dist/{jwt-D-eI6TTu.js.map → jwt-DD6EtpGj.js.map} +1 -1
- package/dist/logger-BYU66ENZ.js.map +1 -1
- package/dist/logs-routes-BYA72C_C.js +100 -0
- package/dist/logs-routes-BYA72C_C.js.map +1 -0
- package/dist/{openapi-generator-Bjzmb5cn.js → openapi-generator-DFDS7kVk.js} +9 -3
- package/dist/openapi-generator-DFDS7kVk.js.map +1 -0
- package/dist/{schema-editor-routes-DDxfOIid.js → schema-editor-routes-CZVW2iBr.js} +4 -3
- package/dist/{schema-editor-routes-DDxfOIid.js.map → schema-editor-routes-CZVW2iBr.js.map} +1 -1
- package/dist/{src-CoOAMnBh.js → src-DymRyxdb.js} +196 -65
- package/dist/src-DymRyxdb.js.map +1 -0
- package/dist/src-_qQ3RNCK.js +279 -0
- package/dist/src-_qQ3RNCK.js.map +1 -0
- package/dist/types-DSnOC4mF.js +38 -0
- package/dist/types-DSnOC4mF.js.map +1 -0
- package/package.json +10 -10
- package/dist/openapi-generator-Bjzmb5cn.js.map +0 -1
- package/dist/src-CoOAMnBh.js.map +0 -1
- package/dist/src-Ivjud8jD.js +0 -957
- package/dist/src-Ivjud8jD.js.map +0 -1
- /package/dist/{chunk-DSJWtz9O.js → rolldown-runtime-DSJWtz9O.js} +0 -0
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "module";
|
|
2
|
+
import process from "process";
|
|
3
|
+
__createRequire(import.meta.url);
|
|
4
|
+
import { n as __exportAll } from "./rolldown-runtime-DSJWtz9O.js";
|
|
5
|
+
import { t as logger } from "./logger-BYU66ENZ.js";
|
|
6
|
+
//#region src/cron/scale-to-zero.ts
|
|
7
|
+
/**
|
|
8
|
+
* Scale-to-zero detection for the cron scheduler.
|
|
9
|
+
*
|
|
10
|
+
* The scheduler drives jobs with in-process `setTimeout`. That works on any
|
|
11
|
+
* always-running instance, but on a platform that freezes or evicts the
|
|
12
|
+
* container between requests (Cloud Run with `--min-instances=0`, AWS Lambda,
|
|
13
|
+
* Vercel functions) the timers simply never fire — the process boots, logs the
|
|
14
|
+
* jobs as registered, and silently runs nothing.
|
|
15
|
+
*
|
|
16
|
+
* None of these platforms expose their scaling floor to the container, so this
|
|
17
|
+
* detection is a heuristic: it identifies the *platform*, not the setting. It
|
|
18
|
+
* is a warning only — it must never influence boot.
|
|
19
|
+
*
|
|
20
|
+
* Environment variables used here were verified against vendor documentation:
|
|
21
|
+
* - `K_SERVICE` / `K_REVISION` / `K_CONFIGURATION` — Cloud Run services
|
|
22
|
+
* (Cloud Run container contract; no variable exposes min-instances).
|
|
23
|
+
* - `CLOUD_RUN_JOB` — Cloud Run jobs (same contract).
|
|
24
|
+
* - `AWS_LAMBDA_FUNCTION_NAME` — reserved AWS Lambda runtime variable.
|
|
25
|
+
* - `VERCEL=1` — Vercel system environment variable, available at runtime.
|
|
26
|
+
* - `KUBERNETES_SERVICE_HOST` — injected into every pod by the kubelet. Used
|
|
27
|
+
* as an *exclusion*: a Deployment pod runs continuously, and Knative on
|
|
28
|
+
* Kubernetes also sets `K_SERVICE`, so a pod is never warned about.
|
|
29
|
+
*/
|
|
30
|
+
/** Environment variable that permanently silences the scale-to-zero warning. */
|
|
31
|
+
var CRON_ALWAYS_ON_ENV = "REBASE_CRON_ALWAYS_ON";
|
|
32
|
+
/** Accepts the usual truthy spellings; anything else (including "") is false. */
|
|
33
|
+
function isTruthy(value) {
|
|
34
|
+
if (!value) return false;
|
|
35
|
+
const normalised = value.trim().toLowerCase();
|
|
36
|
+
return normalised === "1" || normalised === "true" || normalised === "yes" || normalised === "on";
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Identify a runtime whose instances can be frozen or torn down between
|
|
40
|
+
* requests. Returns `undefined` when the platform is unknown or known to run
|
|
41
|
+
* continuously.
|
|
42
|
+
*/
|
|
43
|
+
function detectFreezableRuntime(env = process.env) {
|
|
44
|
+
if (env.KUBERNETES_SERVICE_HOST) return void 0;
|
|
45
|
+
if (env.K_SERVICE) {
|
|
46
|
+
const signals = ["K_SERVICE"];
|
|
47
|
+
if (env.K_REVISION) signals.push("K_REVISION");
|
|
48
|
+
if (env.K_CONFIGURATION) signals.push("K_CONFIGURATION");
|
|
49
|
+
return {
|
|
50
|
+
platform: "Cloud Run",
|
|
51
|
+
signals
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (env.CLOUD_RUN_JOB) return {
|
|
55
|
+
platform: "Cloud Run Jobs",
|
|
56
|
+
signals: ["CLOUD_RUN_JOB"]
|
|
57
|
+
};
|
|
58
|
+
if (env.AWS_LAMBDA_FUNCTION_NAME) return {
|
|
59
|
+
platform: "AWS Lambda",
|
|
60
|
+
signals: ["AWS_LAMBDA_FUNCTION_NAME"]
|
|
61
|
+
};
|
|
62
|
+
if (env.VERCEL === "1") return {
|
|
63
|
+
platform: "Vercel",
|
|
64
|
+
signals: ["VERCEL"]
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** How many job ids to name before collapsing the rest into "+N more". */
|
|
68
|
+
var MAX_NAMED_JOBS = 10;
|
|
69
|
+
/**
|
|
70
|
+
* Build the boot-time warning, or `undefined` when it does not apply.
|
|
71
|
+
*
|
|
72
|
+
* Fires only when all of the following hold:
|
|
73
|
+
* 1. `NODE_ENV=production` — a laptop or CI run is not at risk.
|
|
74
|
+
* 2. At least one *enabled* job is registered — nothing to lose otherwise.
|
|
75
|
+
* 3. The environment looks like a freezable platform (see above).
|
|
76
|
+
* 4. `REBASE_CRON_ALWAYS_ON` is not set to a truthy value.
|
|
77
|
+
*/
|
|
78
|
+
function buildScaleToZeroWarning(jobs, env = process.env) {
|
|
79
|
+
if (env.NODE_ENV !== "production") return void 0;
|
|
80
|
+
if (isTruthy(env["REBASE_CRON_ALWAYS_ON"])) return void 0;
|
|
81
|
+
const enabled = jobs.filter((job) => job.enabled).map((job) => job.id);
|
|
82
|
+
if (enabled.length === 0) return void 0;
|
|
83
|
+
const runtime = detectFreezableRuntime(env);
|
|
84
|
+
if (!runtime) return void 0;
|
|
85
|
+
const named = enabled.slice(0, MAX_NAMED_JOBS);
|
|
86
|
+
const list = enabled.length > named.length ? `${named.join(", ")} (+${enabled.length - named.length} more)` : named.join(", ");
|
|
87
|
+
return {
|
|
88
|
+
message: `[cron] ${runtime.platform} detected — in-process timers do not fire while an instance is frozen or scaled to zero, so ${enabled.length} enabled job(s) may never run: ${list}; drive them from an external scheduler instead (POST /api/cron/:id/trigger, e.g. Cloud Scheduler). ${runtime.platform} does not expose its scaling floor to the container, so an always-warm deployment cannot be confirmed from inside the process — set ${CRON_ALWAYS_ON_ENV}=1 to silence this if at least one instance is pinned warm.`,
|
|
89
|
+
data: {
|
|
90
|
+
platform: runtime.platform,
|
|
91
|
+
signals: runtime.signals,
|
|
92
|
+
jobs: enabled
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/cron/cron-scheduler.ts
|
|
98
|
+
var cron_scheduler_exports = /* @__PURE__ */ __exportAll({
|
|
99
|
+
CronScheduler: () => CronScheduler,
|
|
100
|
+
findMostRecentSlot: () => findMostRecentSlot,
|
|
101
|
+
validateCronExpression: () => validateCronExpression
|
|
102
|
+
});
|
|
103
|
+
/**
|
|
104
|
+
* Expand a single cron field into an ordered array of allowed values.
|
|
105
|
+
* Supports: `*`, `N`, `N-M`, `N/S`, `N-M/S`, `*\/S`, and comma-separated combinations.
|
|
106
|
+
*/
|
|
107
|
+
function expandCronField(field, min, max) {
|
|
108
|
+
const results = /* @__PURE__ */ new Set();
|
|
109
|
+
for (const segment of field.split(",")) {
|
|
110
|
+
const trimmed = segment.trim();
|
|
111
|
+
if (trimmed === "*") for (let i = min; i <= max; i++) results.add(i);
|
|
112
|
+
else if (trimmed.includes("/")) {
|
|
113
|
+
const [rangeStr, stepStr] = trimmed.split("/");
|
|
114
|
+
const step = parseInt(stepStr, 10);
|
|
115
|
+
if (isNaN(step) || step <= 0) throw new Error(`Invalid step value "${stepStr}" in cron field "${field}"`);
|
|
116
|
+
let start = min;
|
|
117
|
+
let end = max;
|
|
118
|
+
if (rangeStr !== "*") if (rangeStr.includes("-")) {
|
|
119
|
+
const [a, b] = rangeStr.split("-").map(Number);
|
|
120
|
+
start = a;
|
|
121
|
+
end = b;
|
|
122
|
+
} else start = parseInt(rangeStr, 10);
|
|
123
|
+
for (let i = start; i <= end; i += step) results.add(i);
|
|
124
|
+
} else if (trimmed.includes("-")) {
|
|
125
|
+
const [a, b] = trimmed.split("-").map(Number);
|
|
126
|
+
for (let i = a; i <= b; i++) results.add(i);
|
|
127
|
+
} else {
|
|
128
|
+
const val = parseInt(trimmed, 10);
|
|
129
|
+
if (isNaN(val)) throw new Error(`Invalid value "${trimmed}" in cron field "${field}"`);
|
|
130
|
+
results.add(val);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return [...results].sort((a, b) => a - b);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Validates a standard 5-field cron expression structurally and semantically.
|
|
137
|
+
* Returns `{ valid: true }` or `{ valid: false, reason: string }`.
|
|
138
|
+
*/
|
|
139
|
+
function validateCronExpression(schedule) {
|
|
140
|
+
if (!schedule || typeof schedule !== "string") return {
|
|
141
|
+
valid: false,
|
|
142
|
+
reason: "Schedule must be a non-empty string"
|
|
143
|
+
};
|
|
144
|
+
const parts = schedule.trim().split(/\s+/);
|
|
145
|
+
if (parts.length !== 5) return {
|
|
146
|
+
valid: false,
|
|
147
|
+
reason: `Expected 5 fields, got ${parts.length}`
|
|
148
|
+
};
|
|
149
|
+
const fieldRanges = [
|
|
150
|
+
[
|
|
151
|
+
"minute",
|
|
152
|
+
0,
|
|
153
|
+
59
|
|
154
|
+
],
|
|
155
|
+
[
|
|
156
|
+
"hour",
|
|
157
|
+
0,
|
|
158
|
+
23
|
|
159
|
+
],
|
|
160
|
+
[
|
|
161
|
+
"day of month",
|
|
162
|
+
1,
|
|
163
|
+
31
|
|
164
|
+
],
|
|
165
|
+
[
|
|
166
|
+
"month",
|
|
167
|
+
1,
|
|
168
|
+
12
|
|
169
|
+
],
|
|
170
|
+
[
|
|
171
|
+
"day of week",
|
|
172
|
+
0,
|
|
173
|
+
6
|
|
174
|
+
]
|
|
175
|
+
];
|
|
176
|
+
for (let i = 0; i < 5; i++) {
|
|
177
|
+
const [name, min, max] = fieldRanges[i];
|
|
178
|
+
try {
|
|
179
|
+
const values = expandCronField(parts[i], min, max);
|
|
180
|
+
if (values.length === 0) return {
|
|
181
|
+
valid: false,
|
|
182
|
+
reason: `${name} field "${parts[i]}" produces no values`
|
|
183
|
+
};
|
|
184
|
+
for (const v of values) if (v < min || v > max) return {
|
|
185
|
+
valid: false,
|
|
186
|
+
reason: `${name} field value ${v} out of range [${min}–${max}]`
|
|
187
|
+
};
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return {
|
|
190
|
+
valid: false,
|
|
191
|
+
reason: `${name} field: ${err instanceof Error ? err.message : String(err)}`
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { valid: true };
|
|
196
|
+
}
|
|
197
|
+
/** Expand all five fields of an expression. Throws on invalid expressions. */
|
|
198
|
+
function parseCronFields(expression) {
|
|
199
|
+
const parts = expression.trim().split(/\s+/);
|
|
200
|
+
if (parts.length < 5) throw new Error(`Invalid cron expression: "${expression}". Expected 5 fields.`);
|
|
201
|
+
const [minField, hourField, domField, monField, dowField] = parts;
|
|
202
|
+
return {
|
|
203
|
+
minutes: expandCronField(minField, 0, 59),
|
|
204
|
+
hours: expandCronField(hourField, 0, 23),
|
|
205
|
+
doms: expandCronField(domField, 1, 31),
|
|
206
|
+
months: expandCronField(monField, 1, 12),
|
|
207
|
+
dows: expandCronField(dowField, 0, 6)
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/** Whether a minute-precision instant matches every field of the expression. */
|
|
211
|
+
function matchesCronFields(candidate, fields) {
|
|
212
|
+
return fields.months.includes(candidate.getMonth() + 1) && fields.doms.includes(candidate.getDate()) && fields.dows.includes(candidate.getDay()) && fields.hours.includes(candidate.getHours()) && fields.minutes.includes(candidate.getMinutes());
|
|
213
|
+
}
|
|
214
|
+
/** ~1 year in minutes — the walk bound for both search directions. */
|
|
215
|
+
var MAX_SLOT_SEARCH_MINUTES = 525960;
|
|
216
|
+
/**
|
|
217
|
+
* Calculate the next Date after `after` that matches the cron expression.
|
|
218
|
+
* Throws on invalid expressions.
|
|
219
|
+
*/
|
|
220
|
+
function parseCronExpression(expression, after) {
|
|
221
|
+
const fields = parseCronFields(expression);
|
|
222
|
+
const candidate = new Date(after);
|
|
223
|
+
candidate.setSeconds(0, 0);
|
|
224
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
225
|
+
for (let i = 0; i < MAX_SLOT_SEARCH_MINUTES; i++) {
|
|
226
|
+
if (matchesCronFields(candidate, fields)) return candidate;
|
|
227
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
228
|
+
}
|
|
229
|
+
const fallback = new Date(after);
|
|
230
|
+
fallback.setMinutes(fallback.getMinutes() + 1);
|
|
231
|
+
return fallback;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The latest slot matching `expression` within the inclusive window
|
|
235
|
+
* `[from, to]`, or `undefined` when the expression has no slot in it.
|
|
236
|
+
*
|
|
237
|
+
* Walks backwards a minute at a time from `to`, so the first hit is already
|
|
238
|
+
* the answer — in the common case (a job that ran normally moments ago) that
|
|
239
|
+
* is a handful of iterations, not a scan of the whole window.
|
|
240
|
+
*
|
|
241
|
+
* `to`'s own minute is included: an instance booting at 06:00:30 has *not* run
|
|
242
|
+
* the 06:00 slot — `parseCronExpression` already skipped past it to tomorrow —
|
|
243
|
+
* so that slot is genuinely missed and must be a candidate.
|
|
244
|
+
*
|
|
245
|
+
* Seconds and milliseconds are zeroed to match how `parseCronExpression`
|
|
246
|
+
* builds a slot, so the same wall-clock slot serialises to a byte-identical
|
|
247
|
+
* ISO string down either path. The claim key depends on that.
|
|
248
|
+
*/
|
|
249
|
+
function findMostRecentSlot(expression, from, to) {
|
|
250
|
+
const fields = parseCronFields(expression);
|
|
251
|
+
const candidate = new Date(to);
|
|
252
|
+
candidate.setSeconds(0, 0);
|
|
253
|
+
for (let i = 0; i < MAX_SLOT_SEARCH_MINUTES && candidate.getTime() >= from.getTime(); i++) {
|
|
254
|
+
if (matchesCronFields(candidate, fields)) return candidate;
|
|
255
|
+
candidate.setMinutes(candidate.getMinutes() - 1);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
var MAX_LOGS_PER_JOB = 50;
|
|
259
|
+
/**
|
|
260
|
+
* Minimum milliseconds between scheduled executions of the same job.
|
|
261
|
+
* Prevents tight re-execution loops caused by jitter or clock drift.
|
|
262
|
+
*/
|
|
263
|
+
var MIN_SCHEDULE_INTERVAL_MS = 5e3;
|
|
264
|
+
var CronScheduler = class {
|
|
265
|
+
jobs = /* @__PURE__ */ new Map();
|
|
266
|
+
started = false;
|
|
267
|
+
store;
|
|
268
|
+
client;
|
|
269
|
+
/**
|
|
270
|
+
* Set the RebaseClient instance to make it available to cron job handlers.
|
|
271
|
+
*/
|
|
272
|
+
setClient(client) {
|
|
273
|
+
this.client = client;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Attach a persistence store for cron logs.
|
|
277
|
+
* When set, execution logs are written to the database after each run,
|
|
278
|
+
* and counters are seeded from the database on start.
|
|
279
|
+
*/
|
|
280
|
+
setStore(store) {
|
|
281
|
+
this.store = store;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Register a batch of loaded cron jobs.
|
|
285
|
+
*
|
|
286
|
+
* If the scheduler is already started, newly registered jobs are
|
|
287
|
+
* automatically scheduled (so late-registered jobs don't sit idle).
|
|
288
|
+
*
|
|
289
|
+
* Validates the cron schedule on registration — invalid schedules
|
|
290
|
+
* are rejected with a warning and the job is NOT registered.
|
|
291
|
+
*/
|
|
292
|
+
registerJobs(loadedJobs) {
|
|
293
|
+
for (const loaded of loadedJobs) {
|
|
294
|
+
const validation = validateCronExpression(loaded.definition.schedule);
|
|
295
|
+
if (!validation.valid) {
|
|
296
|
+
logger.error(`[cron] Rejecting job "${loaded.id}": invalid schedule "${loaded.definition.schedule}" — ${validation.reason}`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
if (this.jobs.get(loaded.id)) {
|
|
300
|
+
logger.warn(`[cron] Duplicate cron job id: "${loaded.id}". Overwriting.`);
|
|
301
|
+
this.stopJob(loaded.id);
|
|
302
|
+
}
|
|
303
|
+
const enabled = loaded.definition.enabled !== false;
|
|
304
|
+
this.jobs.set(loaded.id, {
|
|
305
|
+
id: loaded.id,
|
|
306
|
+
definition: loaded.definition,
|
|
307
|
+
enabled,
|
|
308
|
+
state: enabled ? "idle" : "disabled",
|
|
309
|
+
totalRuns: 0,
|
|
310
|
+
totalFailures: 0,
|
|
311
|
+
logs: [],
|
|
312
|
+
executing: false
|
|
313
|
+
});
|
|
314
|
+
if (this.started && enabled) this.scheduleNext(loaded.id);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Start the scheduler — begins ticking all enabled jobs.
|
|
319
|
+
*/
|
|
320
|
+
start() {
|
|
321
|
+
if (this.started) return;
|
|
322
|
+
this.started = true;
|
|
323
|
+
if (this.store) this.store.fetchJobStats().then((stats) => {
|
|
324
|
+
for (const [jobId, data] of stats) {
|
|
325
|
+
const job = this.jobs.get(jobId);
|
|
326
|
+
if (job) {
|
|
327
|
+
job.totalRuns = data.totalRuns;
|
|
328
|
+
job.totalFailures = data.totalFailures;
|
|
329
|
+
if (data.lastRunAt) job.lastRunAt = new Date(data.lastRunAt);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}).catch((err) => {
|
|
333
|
+
logger.warn("[cron] Failed to seed job stats from database", { error: err });
|
|
334
|
+
});
|
|
335
|
+
for (const [id, job] of this.jobs) if (job.enabled) this.scheduleNext(id);
|
|
336
|
+
if (!this.store) logger.warn("[cron] No cron store attached — runs are uncoordinated; with multiple app instances every instance will execute every job");
|
|
337
|
+
this.warnIfScaleToZero();
|
|
338
|
+
this.catchUpMissedSlots();
|
|
339
|
+
logger.info(`⏰ Cron scheduler started with ${this.jobs.size} job(s)`);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Stop the scheduler and clear all timers.
|
|
343
|
+
*
|
|
344
|
+
* Currently-executing handlers run to completion (they are async),
|
|
345
|
+
* but no further scheduling occurs after stop.
|
|
346
|
+
*/
|
|
347
|
+
stop() {
|
|
348
|
+
this.started = false;
|
|
349
|
+
for (const [id] of this.jobs) this.stopJob(id);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* List all registered jobs with their current status.
|
|
353
|
+
*/
|
|
354
|
+
listJobs() {
|
|
355
|
+
return [...this.jobs.values()].map((job) => this.toStatus(job));
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Get a single job status by ID.
|
|
359
|
+
*/
|
|
360
|
+
getJob(id) {
|
|
361
|
+
const job = this.jobs.get(id);
|
|
362
|
+
return job ? this.toStatus(job) : void 0;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Get log entries for a job.
|
|
366
|
+
*/
|
|
367
|
+
getJobLogs(id, limit) {
|
|
368
|
+
const job = this.jobs.get(id);
|
|
369
|
+
if (!job) return [];
|
|
370
|
+
const logs = [...job.logs].reverse();
|
|
371
|
+
return limit ? logs.slice(0, limit) : logs;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Get log entries for a job from the database (if store is available).
|
|
375
|
+
* Falls back to in-memory logs if no store is configured.
|
|
376
|
+
*/
|
|
377
|
+
async getJobLogsFromDb(id, limit) {
|
|
378
|
+
if (this.store) {
|
|
379
|
+
const dbLogs = await this.store.fetchLogs(id, limit);
|
|
380
|
+
if (dbLogs.length > 0) return dbLogs;
|
|
381
|
+
}
|
|
382
|
+
return this.getJobLogs(id, limit);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Enable or disable a job at runtime.
|
|
386
|
+
*/
|
|
387
|
+
setJobEnabled(id, enabled) {
|
|
388
|
+
const job = this.jobs.get(id);
|
|
389
|
+
if (!job) return void 0;
|
|
390
|
+
job.enabled = enabled;
|
|
391
|
+
if (enabled && this.started) {
|
|
392
|
+
job.state = "idle";
|
|
393
|
+
this.scheduleNext(id);
|
|
394
|
+
} else if (!enabled) {
|
|
395
|
+
this.stopJob(id);
|
|
396
|
+
job.state = "disabled";
|
|
397
|
+
}
|
|
398
|
+
return this.toStatus(job);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Manually trigger a job execution immediately.
|
|
402
|
+
*
|
|
403
|
+
* Returns `undefined` if the job doesn't exist.
|
|
404
|
+
* If the job is currently executing, returns the log entry with
|
|
405
|
+
* a `skipped: true` result rather than running concurrently.
|
|
406
|
+
*/
|
|
407
|
+
async triggerJob(id) {
|
|
408
|
+
const job = this.jobs.get(id);
|
|
409
|
+
if (!job) return void 0;
|
|
410
|
+
if (job.executing) {
|
|
411
|
+
logger.warn(`[cron] Skipping manual trigger of "${id}" — already executing`);
|
|
412
|
+
const logEntry = {
|
|
413
|
+
jobId: id,
|
|
414
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
415
|
+
finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
416
|
+
durationMs: 0,
|
|
417
|
+
success: true,
|
|
418
|
+
result: {
|
|
419
|
+
skipped: true,
|
|
420
|
+
reason: "already_executing"
|
|
421
|
+
},
|
|
422
|
+
logs: ["Skipped: job is already running"],
|
|
423
|
+
manual: true
|
|
424
|
+
};
|
|
425
|
+
job.logs.push(logEntry);
|
|
426
|
+
if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
|
|
427
|
+
return logEntry;
|
|
428
|
+
}
|
|
429
|
+
return this.executeJob(job, true);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Warn once at start when the process looks like it is running on a
|
|
433
|
+
* platform that freezes or evicts instances between requests, where the
|
|
434
|
+
* in-process timers this scheduler relies on never fire.
|
|
435
|
+
*
|
|
436
|
+
* Advisory only: any failure here is swallowed so a detection bug can
|
|
437
|
+
* never take a production boot down.
|
|
438
|
+
*/
|
|
439
|
+
warnIfScaleToZero() {
|
|
440
|
+
try {
|
|
441
|
+
const warning = buildScaleToZeroWarning([...this.jobs.values()].map((job) => ({
|
|
442
|
+
id: job.id,
|
|
443
|
+
enabled: job.enabled
|
|
444
|
+
})));
|
|
445
|
+
if (warning) logger.warn(warning.message, warning.data);
|
|
446
|
+
} catch {}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Schedule the next execution for a job.
|
|
450
|
+
*
|
|
451
|
+
* Safety guarantees:
|
|
452
|
+
* 1. Clears any existing timer first (prevents leaked/duplicate timers)
|
|
453
|
+
* 2. Enforces a minimum delay to prevent tight loops from jitter
|
|
454
|
+
* 3. Unref's the timer so it doesn't prevent process exit
|
|
455
|
+
* 4. Re-checks enabled & started state before executing
|
|
456
|
+
* 5. Concurrency guard prevents overlapping handler executions
|
|
457
|
+
*/
|
|
458
|
+
scheduleNext(id) {
|
|
459
|
+
const job = this.jobs.get(id);
|
|
460
|
+
if (!job || !job.enabled || !this.started) return;
|
|
461
|
+
this.stopJob(id);
|
|
462
|
+
try {
|
|
463
|
+
const now = /* @__PURE__ */ new Date();
|
|
464
|
+
const nextRun = parseCronExpression(job.definition.schedule, now);
|
|
465
|
+
job.nextRunAt = nextRun;
|
|
466
|
+
const rawDelay = nextRun.getTime() - now.getTime();
|
|
467
|
+
const timer = setTimeout(async () => {
|
|
468
|
+
if (!job.enabled || !this.started) return;
|
|
469
|
+
if (job.executing) {
|
|
470
|
+
logger.warn(`[cron] Skipping scheduled run of "${id}" — still executing from previous run`);
|
|
471
|
+
this.scheduleNext(id);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (this.store?.tryClaimRun) {
|
|
475
|
+
let claimed = true;
|
|
476
|
+
try {
|
|
477
|
+
claimed = await this.store.tryClaimRun(id, nextRun.toISOString());
|
|
478
|
+
} catch (err) {
|
|
479
|
+
logger.warn(`[cron] Claim check threw for "${id}" — running uncoordinated`, { error: err });
|
|
480
|
+
}
|
|
481
|
+
if (!claimed) {
|
|
482
|
+
logger.info(`[cron] Slot ${nextRun.toISOString()} for "${id}" claimed by another instance — skipping`);
|
|
483
|
+
if (this.started && job.enabled) this.scheduleNext(id);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
await this.executeJob(job, false);
|
|
488
|
+
if (this.started && job.enabled) this.scheduleNext(id);
|
|
489
|
+
}, Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS));
|
|
490
|
+
if (timer && typeof timer === "object" && "unref" in timer) timer.unref();
|
|
491
|
+
job.timerId = timer;
|
|
492
|
+
} catch (err) {
|
|
493
|
+
logger.error(`[cron] Failed to schedule "${id}"`, { error: err });
|
|
494
|
+
job.state = "error";
|
|
495
|
+
job.lastError = err instanceof Error ? err.message : String(err);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Run any slot that elapsed while no instance was holding a timer for it.
|
|
500
|
+
*
|
|
501
|
+
* Only jobs that opted in via `catchUpWindowSeconds` are considered, and
|
|
502
|
+
* only their single most recent missed slot — see the field's docs for why
|
|
503
|
+
* both limits are deliberate.
|
|
504
|
+
*
|
|
505
|
+
* The claim is what makes this safe. In the ordinary case — an instance
|
|
506
|
+
* restarting minutes after a slot ran normally — the most recent slot is
|
|
507
|
+
* already claimed, so this costs one `tryClaimRun` per job per boot and
|
|
508
|
+
* does nothing. A slot is only executed when no instance, past or present,
|
|
509
|
+
* ever claimed it.
|
|
510
|
+
*
|
|
511
|
+
* Never throws: a failure here must not take down a scheduler that is
|
|
512
|
+
* otherwise ticking correctly.
|
|
513
|
+
*/
|
|
514
|
+
async catchUpMissedSlots() {
|
|
515
|
+
const candidates = [...this.jobs.values()].filter((job) => job.enabled && (job.definition.catchUpWindowSeconds ?? 0) > 0);
|
|
516
|
+
if (candidates.length === 0) return;
|
|
517
|
+
if (!this.store?.tryClaimRun) {
|
|
518
|
+
logger.warn(`[cron] Catch-up is configured on ${candidates.length} job(s) but no claims-capable store is attached — skipping. Without claims a restart cannot tell an unrun slot from one the previous instance already ran.`);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const now = /* @__PURE__ */ new Date();
|
|
522
|
+
for (const job of candidates) try {
|
|
523
|
+
if (!this.started || !job.enabled || job.executing) continue;
|
|
524
|
+
const windowSeconds = job.definition.catchUpWindowSeconds;
|
|
525
|
+
const from = /* @__PURE__ */ new Date(now.getTime() - windowSeconds * 1e3);
|
|
526
|
+
const slot = findMostRecentSlot(job.definition.schedule, from, now);
|
|
527
|
+
if (!slot) continue;
|
|
528
|
+
const slotIso = slot.toISOString();
|
|
529
|
+
let claimed;
|
|
530
|
+
try {
|
|
531
|
+
claimed = await this.store.tryClaimRun(job.id, slotIso);
|
|
532
|
+
} catch (err) {
|
|
533
|
+
logger.warn(`[cron] Catch-up claim threw for "${job.id}" — skipping catch-up`, { error: err });
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (!claimed) continue;
|
|
537
|
+
const lateBy = Math.round((now.getTime() - slot.getTime()) / 1e3);
|
|
538
|
+
logger.info(`[cron] Catching up missed slot ${slotIso} for "${job.id}" (${lateBy}s late)`);
|
|
539
|
+
await this.executeJob(job, false, `⏰ Catch-up run for missed slot ${slotIso} (${lateBy}s late)`);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
logger.error(`[cron] Catch-up failed for "${job.id}"`, { error: err });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Stop a single job's timer and clear its next run state.
|
|
546
|
+
*/
|
|
547
|
+
stopJob(id) {
|
|
548
|
+
const job = this.jobs.get(id);
|
|
549
|
+
if (job?.timerId) {
|
|
550
|
+
clearTimeout(job.timerId);
|
|
551
|
+
job.timerId = void 0;
|
|
552
|
+
job.nextRunAt = void 0;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Execute a job's handler with full isolation and safety.
|
|
557
|
+
*
|
|
558
|
+
* - Sets a concurrency flag to prevent overlapping runs
|
|
559
|
+
* - Wraps handler in a timeout race
|
|
560
|
+
* - Captures all logs, errors, and results
|
|
561
|
+
* - Persists to store (non-blocking) if available
|
|
562
|
+
* - Always restores state even on catastrophic errors
|
|
563
|
+
*/
|
|
564
|
+
async executeJob(job, manual, seedLog) {
|
|
565
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
566
|
+
const capturedLogs = seedLog ? [seedLog] : [];
|
|
567
|
+
job.executing = true;
|
|
568
|
+
const ctx = {
|
|
569
|
+
jobId: job.id,
|
|
570
|
+
scheduledAt: startedAt,
|
|
571
|
+
log: (...args) => {
|
|
572
|
+
const line = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
|
|
573
|
+
capturedLogs.push(line);
|
|
574
|
+
},
|
|
575
|
+
client: this.client
|
|
576
|
+
};
|
|
577
|
+
job.state = "running";
|
|
578
|
+
job.lastRunAt = startedAt;
|
|
579
|
+
job.totalRuns++;
|
|
580
|
+
let success = true;
|
|
581
|
+
let error;
|
|
582
|
+
let result;
|
|
583
|
+
try {
|
|
584
|
+
const timeout = (job.definition.timeoutSeconds ?? 300) * 1e3;
|
|
585
|
+
const handlerPromise = Promise.resolve(job.definition.handler(ctx));
|
|
586
|
+
let timeoutHandle;
|
|
587
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
588
|
+
timeoutHandle = setTimeout(() => reject(/* @__PURE__ */ new Error(`Cron job "${job.id}" timed out after ${timeout}ms`)), timeout);
|
|
589
|
+
});
|
|
590
|
+
try {
|
|
591
|
+
result = await Promise.race([handlerPromise, timeoutPromise]);
|
|
592
|
+
} finally {
|
|
593
|
+
clearTimeout(timeoutHandle);
|
|
594
|
+
}
|
|
595
|
+
} catch (err) {
|
|
596
|
+
success = false;
|
|
597
|
+
error = err instanceof Error ? err.message : String(err);
|
|
598
|
+
job.totalFailures++;
|
|
599
|
+
} finally {
|
|
600
|
+
job.executing = false;
|
|
601
|
+
}
|
|
602
|
+
const finishedAt = /* @__PURE__ */ new Date();
|
|
603
|
+
const durationMs = finishedAt.getTime() - startedAt.getTime();
|
|
604
|
+
job.state = success ? job.enabled ? "idle" : "disabled" : "error";
|
|
605
|
+
job.lastDurationMs = durationMs;
|
|
606
|
+
job.lastError = error;
|
|
607
|
+
const logEntry = {
|
|
608
|
+
jobId: job.id,
|
|
609
|
+
startedAt: startedAt.toISOString(),
|
|
610
|
+
finishedAt: finishedAt.toISOString(),
|
|
611
|
+
durationMs,
|
|
612
|
+
success,
|
|
613
|
+
error,
|
|
614
|
+
result: result !== void 0 ? result : void 0,
|
|
615
|
+
logs: capturedLogs,
|
|
616
|
+
manual
|
|
617
|
+
};
|
|
618
|
+
job.logs.push(logEntry);
|
|
619
|
+
if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
|
|
620
|
+
if (this.store) this.store.insertLog(logEntry).catch((persistErr) => {
|
|
621
|
+
logger.error(`[cron] Failed to persist log for "${job.id}"`, { error: persistErr });
|
|
622
|
+
});
|
|
623
|
+
if (success) logger.info(`✅ [cron] "${job.id}" completed in ${durationMs}ms`);
|
|
624
|
+
else logger.error(`❌ [cron] "${job.id}" failed in ${durationMs}ms: ${error}`);
|
|
625
|
+
return logEntry;
|
|
626
|
+
}
|
|
627
|
+
toStatus(job) {
|
|
628
|
+
return {
|
|
629
|
+
id: job.id,
|
|
630
|
+
name: job.definition.name,
|
|
631
|
+
description: job.definition.description,
|
|
632
|
+
schedule: job.definition.schedule,
|
|
633
|
+
enabled: job.enabled,
|
|
634
|
+
state: job.state,
|
|
635
|
+
lastRunAt: job.lastRunAt?.toISOString(),
|
|
636
|
+
nextRunAt: job.nextRunAt?.toISOString(),
|
|
637
|
+
lastDurationMs: job.lastDurationMs,
|
|
638
|
+
lastError: job.lastError,
|
|
639
|
+
totalRuns: job.totalRuns,
|
|
640
|
+
totalFailures: job.totalFailures
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
//#endregion
|
|
645
|
+
export { cron_scheduler_exports as n, validateCronExpression as r, CronScheduler as t };
|
|
646
|
+
|
|
647
|
+
//# sourceMappingURL=cron-scheduler-B3RFt0HS.js.map
|