@rebasepro/server-core 0.0.1-canary.94dff14 → 0.0.1-canary.bbcb8b4
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/index.es.js +185 -45
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +185 -45
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/cron/cron-scheduler.d.ts +45 -0
- package/dist/server-core/src/cron/index.d.ts +1 -1
- package/package.json +8 -8
- package/src/cron/cron-scheduler.test.ts +301 -175
- package/src/cron/cron-scheduler.ts +220 -57
- package/src/cron/index.ts +1 -1
|
@@ -9,23 +9,95 @@ import type { RebaseClient } from "@rebasepro/client";
|
|
|
9
9
|
import type { LoadedCronJob } from "./cron-loader";
|
|
10
10
|
import type { CronStore } from "./cron-store";
|
|
11
11
|
|
|
12
|
+
// ─── Cron expression parser (minimal, no external dependency) ────────
|
|
13
|
+
// Supports standard 5-field cron (minute hour dom month dow).
|
|
14
|
+
// Returns the next Date after `after` that matches the expression.
|
|
15
|
+
|
|
12
16
|
/**
|
|
13
|
-
*
|
|
17
|
+
* Expand a single cron field into an ordered array of allowed values.
|
|
18
|
+
* Supports: `*`, `N`, `N-M`, `N/S`, `N-M/S`, `*/S`, and comma-separated combinations.
|
|
14
19
|
*/
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
function expandCronField(field: string, min: number, max: number): number[] {
|
|
21
|
+
const results = new Set<number>();
|
|
22
|
+
for (const segment of field.split(",")) {
|
|
23
|
+
const trimmed = segment.trim();
|
|
24
|
+
if (trimmed === "*") {
|
|
25
|
+
for (let i = min; i <= max; i++) results.add(i);
|
|
26
|
+
} else if (trimmed.includes("/")) {
|
|
27
|
+
const [rangeStr, stepStr] = trimmed.split("/");
|
|
28
|
+
const step = parseInt(stepStr, 10);
|
|
29
|
+
if (isNaN(step) || step <= 0) {
|
|
30
|
+
throw new Error(`Invalid step value "${stepStr}" in cron field "${field}"`);
|
|
31
|
+
}
|
|
32
|
+
let start = min;
|
|
33
|
+
let end = max;
|
|
34
|
+
if (rangeStr !== "*") {
|
|
35
|
+
if (rangeStr.includes("-")) {
|
|
36
|
+
const [a, b] = rangeStr.split("-").map(Number);
|
|
37
|
+
start = a;
|
|
38
|
+
end = b;
|
|
39
|
+
} else {
|
|
40
|
+
start = parseInt(rangeStr, 10);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (let i = start; i <= end; i += step) results.add(i);
|
|
44
|
+
} else if (trimmed.includes("-")) {
|
|
45
|
+
const [a, b] = trimmed.split("-").map(Number);
|
|
46
|
+
for (let i = a; i <= b; i++) results.add(i);
|
|
47
|
+
} else {
|
|
48
|
+
const val = parseInt(trimmed, 10);
|
|
49
|
+
if (isNaN(val)) {
|
|
50
|
+
throw new Error(`Invalid value "${trimmed}" in cron field "${field}"`);
|
|
51
|
+
}
|
|
52
|
+
results.add(val);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return [...results].sort((a, b) => a - b);
|
|
20
56
|
}
|
|
21
57
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Validates a standard 5-field cron expression structurally and semantically.
|
|
60
|
+
* Returns `{ valid: true }` or `{ valid: false, reason: string }`.
|
|
61
|
+
*/
|
|
62
|
+
export function validateCronExpression(schedule: string): { valid: true } | { valid: false; reason: string } {
|
|
63
|
+
if (!schedule || typeof schedule !== "string") {
|
|
64
|
+
return { valid: false, reason: "Schedule must be a non-empty string" };
|
|
65
|
+
}
|
|
66
|
+
const parts = schedule.trim().split(/\s+/);
|
|
67
|
+
if (parts.length !== 5) {
|
|
68
|
+
return { valid: false, reason: `Expected 5 fields, got ${parts.length}` };
|
|
69
|
+
}
|
|
70
|
+
const fieldRanges: [string, number, number][] = [
|
|
71
|
+
["minute", 0, 59],
|
|
72
|
+
["hour", 0, 23],
|
|
73
|
+
["day of month", 1, 31],
|
|
74
|
+
["month", 1, 12],
|
|
75
|
+
["day of week", 0, 6],
|
|
76
|
+
];
|
|
77
|
+
for (let i = 0; i < 5; i++) {
|
|
78
|
+
const [name, min, max] = fieldRanges[i];
|
|
79
|
+
try {
|
|
80
|
+
const values = expandCronField(parts[i], min, max);
|
|
81
|
+
if (values.length === 0) {
|
|
82
|
+
return { valid: false, reason: `${name} field "${parts[i]}" produces no values` };
|
|
83
|
+
}
|
|
84
|
+
for (const v of values) {
|
|
85
|
+
if (v < min || v > max) {
|
|
86
|
+
return { valid: false, reason: `${name} field value ${v} out of range [${min}–${max}]` };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return { valid: false, reason: `${name} field: ${err instanceof Error ? err.message : String(err)}` };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { valid: true };
|
|
94
|
+
}
|
|
25
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Calculate the next Date after `after` that matches the cron expression.
|
|
98
|
+
* Throws on invalid expressions.
|
|
99
|
+
*/
|
|
26
100
|
function parseCronExpression(expression: string, after: Date): Date {
|
|
27
|
-
// We implement a simple forward-search. For production-grade parsing
|
|
28
|
-
// one would use a library, but we avoid adding dependencies.
|
|
29
101
|
const parts = expression.trim().split(/\s+/);
|
|
30
102
|
if (parts.length < 5) {
|
|
31
103
|
throw new Error(`Invalid cron expression: "${expression}". Expected 5 fields.`);
|
|
@@ -33,41 +105,11 @@ function parseCronExpression(expression: string, after: Date): Date {
|
|
|
33
105
|
|
|
34
106
|
const [minField, hourField, domField, monField, dowField] = parts;
|
|
35
107
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
} else if (segment.includes("/")) {
|
|
42
|
-
const [rangeStr, stepStr] = segment.split("/");
|
|
43
|
-
const step = parseInt(stepStr, 10);
|
|
44
|
-
let start = min;
|
|
45
|
-
let end = max;
|
|
46
|
-
if (rangeStr !== "*") {
|
|
47
|
-
if (rangeStr.includes("-")) {
|
|
48
|
-
const [a, b] = rangeStr.split("-").map(Number);
|
|
49
|
-
start = a;
|
|
50
|
-
end = b;
|
|
51
|
-
} else {
|
|
52
|
-
start = parseInt(rangeStr, 10);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
for (let i = start; i <= end; i += step) results.add(i);
|
|
56
|
-
} else if (segment.includes("-")) {
|
|
57
|
-
const [a, b] = segment.split("-").map(Number);
|
|
58
|
-
for (let i = a; i <= b; i++) results.add(i);
|
|
59
|
-
} else {
|
|
60
|
-
results.add(parseInt(segment, 10));
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return [...results].sort((a, b) => a - b);
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const minutes = expand(minField, 0, 59);
|
|
67
|
-
const hours = expand(hourField, 0, 23);
|
|
68
|
-
const doms = expand(domField, 1, 31);
|
|
69
|
-
const months = expand(monField, 1, 12);
|
|
70
|
-
const dows = expand(dowField, 0, 6); // 0=Sunday
|
|
108
|
+
const minutes = expandCronField(minField, 0, 59);
|
|
109
|
+
const hours = expandCronField(hourField, 0, 23);
|
|
110
|
+
const doms = expandCronField(domField, 1, 31);
|
|
111
|
+
const months = expandCronField(monField, 1, 12);
|
|
112
|
+
const dows = expandCronField(dowField, 0, 6); // 0=Sunday
|
|
71
113
|
|
|
72
114
|
// Forward-search from `after + 1 minute`
|
|
73
115
|
const candidate = new Date(after);
|
|
@@ -104,6 +146,12 @@ function parseCronExpression(expression: string, after: Date): Date {
|
|
|
104
146
|
|
|
105
147
|
const MAX_LOGS_PER_JOB = 50;
|
|
106
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Minimum milliseconds between scheduled executions of the same job.
|
|
151
|
+
* Prevents tight re-execution loops caused by jitter or clock drift.
|
|
152
|
+
*/
|
|
153
|
+
const MIN_SCHEDULE_INTERVAL_MS = 5_000; // 5 seconds
|
|
154
|
+
|
|
107
155
|
// ─── CronScheduler ───────────────────────────────────────────────────
|
|
108
156
|
|
|
109
157
|
interface RegisteredJob {
|
|
@@ -119,6 +167,8 @@ interface RegisteredJob {
|
|
|
119
167
|
totalFailures: number;
|
|
120
168
|
timerId?: ReturnType<typeof setTimeout>;
|
|
121
169
|
logs: CronJobLogEntry[];
|
|
170
|
+
/** True while a handler is actively executing (prevents concurrent runs). */
|
|
171
|
+
executing: boolean;
|
|
122
172
|
}
|
|
123
173
|
|
|
124
174
|
export class CronScheduler {
|
|
@@ -145,24 +195,47 @@ export class CronScheduler {
|
|
|
145
195
|
|
|
146
196
|
/**
|
|
147
197
|
* Register a batch of loaded cron jobs.
|
|
198
|
+
*
|
|
199
|
+
* If the scheduler is already started, newly registered jobs are
|
|
200
|
+
* automatically scheduled (so late-registered jobs don't sit idle).
|
|
201
|
+
*
|
|
202
|
+
* Validates the cron schedule on registration — invalid schedules
|
|
203
|
+
* are rejected with a warning and the job is NOT registered.
|
|
148
204
|
*/
|
|
149
205
|
registerJobs(loadedJobs: LoadedCronJob[]): void {
|
|
150
206
|
for (const loaded of loadedJobs) {
|
|
207
|
+
// Validate schedule up-front — reject invalid schedules
|
|
208
|
+
const validation = validateCronExpression(loaded.definition.schedule);
|
|
209
|
+
if (!validation.valid) {
|
|
210
|
+
console.error(
|
|
211
|
+
`[cron] Rejecting job "${loaded.id}": invalid schedule "${loaded.definition.schedule}" — ${validation.reason}`
|
|
212
|
+
);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
151
216
|
const existing = this.jobs.get(loaded.id);
|
|
152
217
|
if (existing) {
|
|
153
218
|
console.warn(`[cron] Duplicate cron job id: "${loaded.id}". Overwriting.`);
|
|
154
219
|
this.stopJob(loaded.id);
|
|
155
220
|
}
|
|
156
221
|
|
|
222
|
+
const enabled = loaded.definition.enabled !== false;
|
|
223
|
+
|
|
157
224
|
this.jobs.set(loaded.id, {
|
|
158
225
|
id: loaded.id,
|
|
159
226
|
definition: loaded.definition,
|
|
160
|
-
enabled
|
|
161
|
-
state:
|
|
227
|
+
enabled,
|
|
228
|
+
state: enabled ? "idle" : "disabled",
|
|
162
229
|
totalRuns: 0,
|
|
163
230
|
totalFailures: 0,
|
|
164
|
-
logs: []
|
|
231
|
+
logs: [],
|
|
232
|
+
executing: false
|
|
165
233
|
});
|
|
234
|
+
|
|
235
|
+
// If the scheduler is already running, auto-schedule new jobs
|
|
236
|
+
if (this.started && enabled) {
|
|
237
|
+
this.scheduleNext(loaded.id);
|
|
238
|
+
}
|
|
166
239
|
}
|
|
167
240
|
}
|
|
168
241
|
|
|
@@ -201,6 +274,9 @@ export class CronScheduler {
|
|
|
201
274
|
|
|
202
275
|
/**
|
|
203
276
|
* Stop the scheduler and clear all timers.
|
|
277
|
+
*
|
|
278
|
+
* Currently-executing handlers run to completion (they are async),
|
|
279
|
+
* but no further scheduling occurs after stop.
|
|
204
280
|
*/
|
|
205
281
|
stop(): void {
|
|
206
282
|
this.started = false;
|
|
@@ -269,32 +345,93 @@ export class CronScheduler {
|
|
|
269
345
|
|
|
270
346
|
/**
|
|
271
347
|
* Manually trigger a job execution immediately.
|
|
348
|
+
*
|
|
349
|
+
* Returns `undefined` if the job doesn't exist.
|
|
350
|
+
* If the job is currently executing, returns the log entry with
|
|
351
|
+
* a `skipped: true` result rather than running concurrently.
|
|
272
352
|
*/
|
|
273
353
|
async triggerJob(id: string): Promise<CronJobLogEntry | undefined> {
|
|
274
354
|
const job = this.jobs.get(id);
|
|
275
355
|
if (!job) return undefined;
|
|
356
|
+
|
|
357
|
+
// Concurrency guard — don't run two instances simultaneously
|
|
358
|
+
if (job.executing) {
|
|
359
|
+
console.warn(`[cron] Skipping manual trigger of "${id}" — already executing`);
|
|
360
|
+
const logEntry: CronJobLogEntry = {
|
|
361
|
+
jobId: id,
|
|
362
|
+
startedAt: new Date().toISOString(),
|
|
363
|
+
finishedAt: new Date().toISOString(),
|
|
364
|
+
durationMs: 0,
|
|
365
|
+
success: true,
|
|
366
|
+
result: { skipped: true, reason: "already_executing" },
|
|
367
|
+
logs: ["Skipped: job is already running"],
|
|
368
|
+
manual: true
|
|
369
|
+
};
|
|
370
|
+
job.logs.push(logEntry);
|
|
371
|
+
if (job.logs.length > MAX_LOGS_PER_JOB) job.logs.shift();
|
|
372
|
+
return logEntry;
|
|
373
|
+
}
|
|
374
|
+
|
|
276
375
|
return this.executeJob(job, true);
|
|
277
376
|
}
|
|
278
377
|
|
|
279
378
|
// ─── Internal ────────────────────────────────────────────────────
|
|
280
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Schedule the next execution for a job.
|
|
382
|
+
*
|
|
383
|
+
* Safety guarantees:
|
|
384
|
+
* 1. Clears any existing timer first (prevents leaked/duplicate timers)
|
|
385
|
+
* 2. Enforces a minimum delay to prevent tight loops from jitter
|
|
386
|
+
* 3. Unref's the timer so it doesn't prevent process exit
|
|
387
|
+
* 4. Re-checks enabled & started state before executing
|
|
388
|
+
* 5. Concurrency guard prevents overlapping handler executions
|
|
389
|
+
*/
|
|
281
390
|
private scheduleNext(id: string): void {
|
|
282
391
|
const job = this.jobs.get(id);
|
|
283
392
|
if (!job || !job.enabled || !this.started) return;
|
|
284
393
|
|
|
394
|
+
// Clear any previously scheduled timer to prevent double-firing
|
|
395
|
+
this.stopJob(id);
|
|
396
|
+
|
|
285
397
|
try {
|
|
286
398
|
const now = new Date();
|
|
287
399
|
const nextRun = parseCronExpression(job.definition.schedule, now);
|
|
288
400
|
job.nextRunAt = nextRun;
|
|
289
401
|
|
|
290
|
-
const
|
|
402
|
+
const rawDelay = nextRun.getTime() - now.getTime();
|
|
403
|
+
// Enforce a minimum delay to prevent tight re-execution loops
|
|
404
|
+
// from event loop jitter or near-zero setTimeout drift
|
|
405
|
+
const delay = Math.max(rawDelay, MIN_SCHEDULE_INTERVAL_MS);
|
|
291
406
|
|
|
292
|
-
|
|
407
|
+
const timer = setTimeout(async () => {
|
|
408
|
+
// Re-check state: scheduler may have been stopped or job disabled
|
|
409
|
+
// between when we scheduled and when we fire
|
|
293
410
|
if (!job.enabled || !this.started) return;
|
|
411
|
+
|
|
412
|
+
// Concurrency guard: if somehow we're already executing, skip
|
|
413
|
+
if (job.executing) {
|
|
414
|
+
console.warn(`[cron] Skipping scheduled run of "${id}" — still executing from previous run`);
|
|
415
|
+
// Re-schedule to try again later
|
|
416
|
+
this.scheduleNext(id);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
294
420
|
await this.executeJob(job, false);
|
|
295
|
-
|
|
296
|
-
|
|
421
|
+
|
|
422
|
+
// Schedule the next tick (only if still started + enabled)
|
|
423
|
+
if (this.started && job.enabled) {
|
|
424
|
+
this.scheduleNext(id);
|
|
425
|
+
}
|
|
297
426
|
}, delay);
|
|
427
|
+
|
|
428
|
+
// Unref the timer so it doesn't prevent Node.js from exiting
|
|
429
|
+
// during graceful shutdown
|
|
430
|
+
if (timer && typeof timer === "object" && "unref" in timer) {
|
|
431
|
+
timer.unref();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
job.timerId = timer;
|
|
298
435
|
} catch (err: unknown) {
|
|
299
436
|
console.error(`[cron] Failed to schedule "${id}":`, err);
|
|
300
437
|
job.state = "error";
|
|
@@ -302,6 +439,9 @@ export class CronScheduler {
|
|
|
302
439
|
}
|
|
303
440
|
}
|
|
304
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Stop a single job's timer and clear its next run state.
|
|
444
|
+
*/
|
|
305
445
|
private stopJob(id: string): void {
|
|
306
446
|
const job = this.jobs.get(id);
|
|
307
447
|
if (job?.timerId) {
|
|
@@ -311,6 +451,15 @@ export class CronScheduler {
|
|
|
311
451
|
}
|
|
312
452
|
}
|
|
313
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Execute a job's handler with full isolation and safety.
|
|
456
|
+
*
|
|
457
|
+
* - Sets a concurrency flag to prevent overlapping runs
|
|
458
|
+
* - Wraps handler in a timeout race
|
|
459
|
+
* - Captures all logs, errors, and results
|
|
460
|
+
* - Persists to store (non-blocking) if available
|
|
461
|
+
* - Always restores state even on catastrophic errors
|
|
462
|
+
*/
|
|
314
463
|
private async executeJob(
|
|
315
464
|
job: RegisteredJob,
|
|
316
465
|
manual: boolean
|
|
@@ -318,6 +467,9 @@ export class CronScheduler {
|
|
|
318
467
|
const startedAt = new Date();
|
|
319
468
|
const capturedLogs: string[] = [];
|
|
320
469
|
|
|
470
|
+
// Set executing flag — prevents concurrent runs
|
|
471
|
+
job.executing = true;
|
|
472
|
+
|
|
321
473
|
const ctx: CronJobContext = {
|
|
322
474
|
jobId: job.id,
|
|
323
475
|
scheduledAt: startedAt,
|
|
@@ -342,15 +494,26 @@ export class CronScheduler {
|
|
|
342
494
|
// Race with timeout
|
|
343
495
|
const timeout = (job.definition.timeoutSeconds ?? 300) * 1000;
|
|
344
496
|
const handlerPromise = Promise.resolve(job.definition.handler(ctx));
|
|
497
|
+
let timeoutHandle: ReturnType<typeof setTimeout>;
|
|
345
498
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
346
|
-
|
|
499
|
+
timeoutHandle = setTimeout(
|
|
500
|
+
() => reject(new Error(`Cron job "${job.id}" timed out after ${timeout}ms`)),
|
|
501
|
+
timeout
|
|
502
|
+
);
|
|
347
503
|
});
|
|
348
504
|
|
|
349
|
-
|
|
505
|
+
try {
|
|
506
|
+
result = await Promise.race([handlerPromise, timeoutPromise]);
|
|
507
|
+
} finally {
|
|
508
|
+
clearTimeout(timeoutHandle!);
|
|
509
|
+
}
|
|
350
510
|
} catch (err: unknown) {
|
|
351
511
|
success = false;
|
|
352
512
|
error = err instanceof Error ? err.message : String(err);
|
|
353
513
|
job.totalFailures++;
|
|
514
|
+
} finally {
|
|
515
|
+
// Always clear executing flag — even on catastrophic errors
|
|
516
|
+
job.executing = false;
|
|
354
517
|
}
|
|
355
518
|
|
|
356
519
|
const finishedAt = new Date();
|
|
@@ -380,8 +543,8 @@ export class CronScheduler {
|
|
|
380
543
|
|
|
381
544
|
// Persist to database (non-blocking)
|
|
382
545
|
if (this.store) {
|
|
383
|
-
this.store.insertLog(logEntry).catch((
|
|
384
|
-
console.error(`[cron] Failed to persist log for "${job.id}":`,
|
|
546
|
+
this.store.insertLog(logEntry).catch((persistErr) => {
|
|
547
|
+
console.error(`[cron] Failed to persist log for "${job.id}":`, persistErr);
|
|
385
548
|
});
|
|
386
549
|
}
|
|
387
550
|
|
package/src/cron/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { loadCronJobsFromDirectory } from "./cron-loader";
|
|
2
2
|
export type { LoadedCronJob } from "./cron-loader";
|
|
3
|
-
export { CronScheduler } from "./cron-scheduler";
|
|
3
|
+
export { CronScheduler, validateCronExpression } from "./cron-scheduler";
|
|
4
4
|
export { createCronRoutes } from "./cron-routes";
|
|
5
5
|
export { createCronStore } from "./cron-store";
|
|
6
6
|
export type { CronStore } from "./cron-store";
|