@stonyx/cron 0.2.1-alpha.2 → 0.2.1-alpha.20
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/README.md +12 -0
- package/dist/cron-parser.d.ts +30 -0
- package/dist/cron-parser.js +200 -0
- package/dist/job.d.ts +72 -0
- package/dist/job.js +172 -0
- package/dist/locked.d.ts +13 -0
- package/dist/locked.js +27 -0
- package/dist/main.d.ts +61 -0
- package/dist/main.js +238 -0
- package/dist/min-heap.d.ts +13 -0
- package/dist/min-heap.js +67 -0
- package/dist/normalize.d.ts +49 -0
- package/dist/normalize.js +148 -0
- package/dist/run-log.d.ts +44 -0
- package/dist/run-log.js +60 -0
- package/dist/schedule.d.ts +23 -0
- package/dist/schedule.js +65 -0
- package/dist/service.d.ts +85 -0
- package/dist/service.js +271 -0
- package/package.json +53 -16
- package/.claude/architecture.md +0 -215
- package/.claude/extension-guide.md +0 -291
- package/.claude/improvements.md +0 -53
- package/.claude/project-structure.md +0 -139
- package/.claude/testing.md +0 -85
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/.gitignore +0 -16
- package/.npmignore +0 -7
- package/logs/error.log +0 -4
- package/pnpm-lock.yaml +0 -370
- package/src/cron-parser.js +0 -246
- package/src/job.js +0 -200
- package/src/locked.js +0 -34
- package/src/main.js +0 -112
- package/src/min-heap.js +0 -73
- package/src/normalize.js +0 -163
- package/src/run-log.js +0 -79
- package/src/schedule.js +0 -81
- package/src/service.js +0 -303
- package/stonyx-bootstrap.cjs +0 -9
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
[](https://github.com/abofs/stonyx-cron/actions/workflows/ci.yml)
|
|
2
|
+
[](https://www.npmjs.com/package/@stonyx/cron)
|
|
3
|
+
[](https://opensource.org/licenses/Apache-2.0)
|
|
4
|
+
|
|
1
5
|
# stonyx-cron
|
|
2
6
|
|
|
3
7
|
A small, lightweight cron/job scheduling utility for asynchronous jobs. Designed to schedule, run, and automatically re-schedule jobs at precise intervals with optional debug logging.
|
|
@@ -31,6 +35,14 @@ When a job is executed, its next trigger time is updated, and it is re-inserted
|
|
|
31
35
|
| `register` | `key: string, callback: Function, interval: number, runOnInit?: boolean` | Register a new job with a given interval in seconds. If `runOnInit` is true, the job runs immediately upon registration. |
|
|
32
36
|
| `unregister` | `key: string` | Remove a previously registered job. |
|
|
33
37
|
|
|
38
|
+
> **Callback semantics.** Callbacks are invoked fire-and-forget: `Cron` never waits for one to settle, and reschedules a job *before* invoking it. Two *different* jobs that fall due on the same tick may therefore overlap.
|
|
39
|
+
>
|
|
40
|
+
> A job that is still running when it next falls due is skipped — and **keeps** being skipped until that invocation settles. One warning is logged per stuck run (not per tick), including how long the invocation has been running. `Cron` provides no timeout by design, so **bounding your own callback is your responsibility**: a promise that never settles means that job never runs again for the lifetime of the process. Other jobs are unaffected.
|
|
41
|
+
>
|
|
42
|
+
> Synchronous throws and asynchronous rejections are both caught and reported through `log.error`, with the error's stack interpolated into the message. Neither can stop the scheduler.
|
|
43
|
+
>
|
|
44
|
+
> `interval` is **whole seconds, as a string**. `register` throws a `TypeError` on a value it cannot parse — in particular, this class does not accept cron expressions; use `CronService` (`@stonyx/cron/service`) for those. Values below `1` are clamped to `1` second with a warning.
|
|
45
|
+
|
|
34
46
|
> `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
|
|
35
47
|
|
|
36
48
|
## Configuration
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 5-field cron expression parser with next-occurrence computation.
|
|
3
|
+
* No external dependencies - built for stonyx-cron.
|
|
4
|
+
*
|
|
5
|
+
* Fields: minute(0-59) hour(0-23) day-of-month(1-31) month(1-12) day-of-week(0-6)
|
|
6
|
+
* Supports: wildcards(*), ranges(1-5), steps(* /5), lists(1,3,5), names(jan-dec, sun-sat)
|
|
7
|
+
*/
|
|
8
|
+
export interface ParsedCronExpression {
|
|
9
|
+
minutes: number[];
|
|
10
|
+
hours: number[];
|
|
11
|
+
daysOfMonth: number[];
|
|
12
|
+
months: number[];
|
|
13
|
+
daysOfWeek: number[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Parse a single cron field into a sorted array of allowed values.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseField(field: string, fieldIndex: number): number[];
|
|
19
|
+
/**
|
|
20
|
+
* Parse a 5-field cron expression into field arrays.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseCronExpression(expr: string): ParsedCronExpression;
|
|
23
|
+
/**
|
|
24
|
+
* Compute the next occurrence of a cron expression after a given timestamp.
|
|
25
|
+
*/
|
|
26
|
+
export declare function nextOccurrence(expr: string, afterMs: number, tz?: string): number | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Validate a cron expression without computing next occurrence.
|
|
29
|
+
*/
|
|
30
|
+
export declare function validateCronExpression(expr: string): void;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 5-field cron expression parser with next-occurrence computation.
|
|
3
|
+
* No external dependencies - built for stonyx-cron.
|
|
4
|
+
*
|
|
5
|
+
* Fields: minute(0-59) hour(0-23) day-of-month(1-31) month(1-12) day-of-week(0-6)
|
|
6
|
+
* Supports: wildcards(*), ranges(1-5), steps(* /5), lists(1,3,5), names(jan-dec, sun-sat)
|
|
7
|
+
*/
|
|
8
|
+
const MONTH_NAMES = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12 };
|
|
9
|
+
const DAY_NAMES = { sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6 };
|
|
10
|
+
const FIELD_RANGES = [
|
|
11
|
+
{ min: 0, max: 59 }, // minute
|
|
12
|
+
{ min: 0, max: 23 }, // hour
|
|
13
|
+
{ min: 1, max: 31 }, // day of month
|
|
14
|
+
{ min: 1, max: 12 }, // month
|
|
15
|
+
{ min: 0, max: 6 }, // day of week
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* Parse a single cron field into a sorted array of allowed values.
|
|
19
|
+
*/
|
|
20
|
+
export function parseField(field, fieldIndex) {
|
|
21
|
+
const { min, max } = FIELD_RANGES[fieldIndex];
|
|
22
|
+
const names = fieldIndex === 3 ? MONTH_NAMES : fieldIndex === 4 ? DAY_NAMES : null;
|
|
23
|
+
const resolveToken = (token) => {
|
|
24
|
+
if (names) {
|
|
25
|
+
const lower = token.toLowerCase();
|
|
26
|
+
if (lower in names)
|
|
27
|
+
return names[lower];
|
|
28
|
+
}
|
|
29
|
+
const n = Number(token);
|
|
30
|
+
if (!Number.isInteger(n))
|
|
31
|
+
throw new Error(`Invalid cron value: "${token}" in field ${fieldIndex}`);
|
|
32
|
+
// Normalize day-of-week 7 -> 0 (both mean Sunday)
|
|
33
|
+
if (fieldIndex === 4 && n === 7)
|
|
34
|
+
return 0;
|
|
35
|
+
return n;
|
|
36
|
+
};
|
|
37
|
+
const results = new Set();
|
|
38
|
+
for (const part of field.split(',')) {
|
|
39
|
+
const trimmed = part.trim();
|
|
40
|
+
const [rangeStr, stepStr] = trimmed.split('/');
|
|
41
|
+
const step = stepStr !== undefined ? Number(stepStr) : 1;
|
|
42
|
+
if (!Number.isInteger(step) || step < 1) {
|
|
43
|
+
throw new Error(`Invalid step "${stepStr}" in cron field ${fieldIndex}`);
|
|
44
|
+
}
|
|
45
|
+
let start, end;
|
|
46
|
+
if (rangeStr === '*') {
|
|
47
|
+
start = min;
|
|
48
|
+
end = max;
|
|
49
|
+
}
|
|
50
|
+
else if (rangeStr.includes('-')) {
|
|
51
|
+
const [lo, hi] = rangeStr.split('-');
|
|
52
|
+
start = resolveToken(lo);
|
|
53
|
+
end = resolveToken(hi);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
start = resolveToken(rangeStr);
|
|
57
|
+
end = stepStr !== undefined ? max : start;
|
|
58
|
+
}
|
|
59
|
+
if (start < min || start > max || end < min || end > max) {
|
|
60
|
+
throw new Error(`Value out of range [${min}-${max}] in cron field ${fieldIndex}: "${trimmed}"`);
|
|
61
|
+
}
|
|
62
|
+
for (let v = start; v <= end; v += step) {
|
|
63
|
+
results.add(v);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return [...results].sort((a, b) => a - b);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Parse a 5-field cron expression into field arrays.
|
|
70
|
+
*/
|
|
71
|
+
export function parseCronExpression(expr) {
|
|
72
|
+
const fields = expr.trim().split(/\s+/);
|
|
73
|
+
if (fields.length !== 5) {
|
|
74
|
+
throw new Error(`Cron expression must have exactly 5 fields, got ${fields.length}: "${expr}"`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
minutes: parseField(fields[0], 0),
|
|
78
|
+
hours: parseField(fields[1], 1),
|
|
79
|
+
daysOfMonth: parseField(fields[2], 2),
|
|
80
|
+
months: parseField(fields[3], 3),
|
|
81
|
+
daysOfWeek: parseField(fields[4], 4),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get the number of days in a given month/year.
|
|
86
|
+
*/
|
|
87
|
+
function daysInMonth(_year, month) {
|
|
88
|
+
return new Date(_year, month, 0).getDate();
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Check if a day-of-month + day-of-week pair matches the parsed expression.
|
|
92
|
+
*/
|
|
93
|
+
function dayMatches(parsed, domWild, dowWild, dayOfMonth, dayOfWeek) {
|
|
94
|
+
const domMatch = parsed.daysOfMonth.includes(dayOfMonth);
|
|
95
|
+
const dowMatch = parsed.daysOfWeek.includes(dayOfWeek);
|
|
96
|
+
if (domWild && dowWild)
|
|
97
|
+
return true;
|
|
98
|
+
if (domWild)
|
|
99
|
+
return dowMatch;
|
|
100
|
+
if (dowWild)
|
|
101
|
+
return domMatch;
|
|
102
|
+
return domMatch || dowMatch; // Both restricted -> OR
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Compute the next occurrence of a cron expression after a given timestamp.
|
|
106
|
+
*/
|
|
107
|
+
export function nextOccurrence(expr, afterMs, tz) {
|
|
108
|
+
const parsed = parseCronExpression(expr);
|
|
109
|
+
const exprFields = expr.trim().split(/\s+/);
|
|
110
|
+
const domWild = exprFields[2] === '*';
|
|
111
|
+
const dowWild = exprFields[4] === '*';
|
|
112
|
+
// Start from the next whole minute after afterMs
|
|
113
|
+
const startDate = new Date(afterMs);
|
|
114
|
+
startDate.setSeconds(0, 0);
|
|
115
|
+
startDate.setMinutes(startDate.getMinutes() + 1);
|
|
116
|
+
// Convert to target timezone for field matching
|
|
117
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
118
|
+
timeZone: tz || undefined,
|
|
119
|
+
year: 'numeric', month: 'numeric', day: 'numeric',
|
|
120
|
+
hour: 'numeric', minute: 'numeric', hour12: false,
|
|
121
|
+
weekday: 'short',
|
|
122
|
+
});
|
|
123
|
+
const dayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
124
|
+
// Parse formatted date parts in the target timezone
|
|
125
|
+
function getLocalParts(date) {
|
|
126
|
+
const parts = {};
|
|
127
|
+
for (const { type, value } of formatter.formatToParts(date)) {
|
|
128
|
+
parts[type] = value;
|
|
129
|
+
}
|
|
130
|
+
const hourStr = parts.hour ?? '0';
|
|
131
|
+
return {
|
|
132
|
+
year: Number(parts.year ?? '0'),
|
|
133
|
+
month: Number(parts.month ?? '0'),
|
|
134
|
+
day: Number(parts.day ?? '0'),
|
|
135
|
+
hour: Number(hourStr === '24' ? '0' : hourStr),
|
|
136
|
+
minute: Number(parts.minute ?? '0'),
|
|
137
|
+
weekday: dayMap[parts.weekday] ?? 0,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// Search limit: 4 years of minutes
|
|
141
|
+
const maxMs = afterMs + 4 * 365.25 * 24 * 60 * 60 * 1000;
|
|
142
|
+
const candidate = new Date(startDate);
|
|
143
|
+
while (candidate.getTime() <= maxMs) {
|
|
144
|
+
const p = getLocalParts(candidate);
|
|
145
|
+
// Check month
|
|
146
|
+
if (!parsed.months.includes(p.month)) {
|
|
147
|
+
const nextMonth = parsed.months.find(m => m > p.month);
|
|
148
|
+
if (nextMonth) {
|
|
149
|
+
advanceToMonth(candidate, p.year, nextMonth);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
advanceToMonth(candidate, p.year + 1, parsed.months[0]);
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
// Check day (dom + dow)
|
|
157
|
+
if (!dayMatches(parsed, domWild, dowWild, p.day, p.weekday)) {
|
|
158
|
+
candidate.setMinutes(candidate.getMinutes() + (24 * 60 - p.hour * 60 - p.minute));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// Check hour
|
|
162
|
+
if (!parsed.hours.includes(p.hour)) {
|
|
163
|
+
const nextHour = parsed.hours.find(h => h > p.hour);
|
|
164
|
+
if (nextHour) {
|
|
165
|
+
candidate.setMinutes(candidate.getMinutes() + ((nextHour - p.hour) * 60 - p.minute));
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
candidate.setMinutes(candidate.getMinutes() + ((24 - p.hour) * 60 - p.minute));
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
// Check minute
|
|
173
|
+
if (!parsed.minutes.includes(p.minute)) {
|
|
174
|
+
const nextMin = parsed.minutes.find(m => m > p.minute);
|
|
175
|
+
if (nextMin) {
|
|
176
|
+
candidate.setMinutes(candidate.getMinutes() + (nextMin - p.minute));
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
candidate.setMinutes(candidate.getMinutes() + (60 - p.minute));
|
|
180
|
+
}
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
// All fields match
|
|
184
|
+
return candidate.getTime();
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Advance a Date to the start of a specific month in a specific year.
|
|
190
|
+
*/
|
|
191
|
+
function advanceToMonth(current, year, month) {
|
|
192
|
+
current.setFullYear(year, month - 1, 1);
|
|
193
|
+
current.setHours(0, 0, 0, 0);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Validate a cron expression without computing next occurrence.
|
|
197
|
+
*/
|
|
198
|
+
export function validateCronExpression(expr) {
|
|
199
|
+
parseCronExpression(expr);
|
|
200
|
+
}
|
package/dist/job.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job data model and state machine for the advanced scheduling system.
|
|
3
|
+
*/
|
|
4
|
+
import { type Schedule } from './schedule.js';
|
|
5
|
+
export interface JobState {
|
|
6
|
+
nextRunAtMs: number | undefined;
|
|
7
|
+
runningAtMs: number | undefined;
|
|
8
|
+
lastRunAtMs: number | undefined;
|
|
9
|
+
lastStatus: 'ok' | 'error' | 'skipped' | undefined;
|
|
10
|
+
lastError: string | undefined;
|
|
11
|
+
lastDurationMs: number | undefined;
|
|
12
|
+
consecutiveErrors: number;
|
|
13
|
+
scheduleErrorCount: number;
|
|
14
|
+
}
|
|
15
|
+
export interface Job {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
description: string | undefined;
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
deleteAfterRun: boolean;
|
|
21
|
+
createdAtMs: number;
|
|
22
|
+
updatedAtMs: number;
|
|
23
|
+
schedule: Schedule;
|
|
24
|
+
sessionTarget: string;
|
|
25
|
+
wakeMode: string;
|
|
26
|
+
payload: Record<string, unknown>;
|
|
27
|
+
delivery: Record<string, unknown> | undefined;
|
|
28
|
+
state: JobState;
|
|
29
|
+
}
|
|
30
|
+
export interface JobInput {
|
|
31
|
+
name: string;
|
|
32
|
+
schedule: Schedule;
|
|
33
|
+
payload: Record<string, unknown>;
|
|
34
|
+
description?: string;
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
deleteAfterRun?: boolean;
|
|
37
|
+
sessionTarget?: string;
|
|
38
|
+
wakeMode?: string;
|
|
39
|
+
delivery?: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
export interface JobPatch {
|
|
42
|
+
name?: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
schedule?: Schedule;
|
|
45
|
+
payload?: Record<string, unknown>;
|
|
46
|
+
delivery?: Record<string, unknown> | null;
|
|
47
|
+
enabled?: boolean;
|
|
48
|
+
deleteAfterRun?: boolean;
|
|
49
|
+
sessionTarget?: string;
|
|
50
|
+
wakeMode?: string;
|
|
51
|
+
}
|
|
52
|
+
export declare function errorBackoffMs(consecutiveErrors: number): number;
|
|
53
|
+
/**
|
|
54
|
+
* Create a new job object from input.
|
|
55
|
+
*/
|
|
56
|
+
export declare function createJob(input: JobInput): Job;
|
|
57
|
+
/**
|
|
58
|
+
* Apply an update patch to a job.
|
|
59
|
+
*/
|
|
60
|
+
export declare function updateJob(job: Job, patch: JobPatch): Job;
|
|
61
|
+
/**
|
|
62
|
+
* Mark a job as started (running).
|
|
63
|
+
*/
|
|
64
|
+
export declare function markRunning(job: Job): void;
|
|
65
|
+
/**
|
|
66
|
+
* Apply the result of a job execution.
|
|
67
|
+
*/
|
|
68
|
+
export declare function applyResult(job: Job, status: 'ok' | 'error' | 'skipped', error?: string, durationMs?: number): void;
|
|
69
|
+
/**
|
|
70
|
+
* Check if a job is due to run.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isDue(job: Job, nowMs: number): boolean;
|
package/dist/job.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job data model and state machine for the advanced scheduling system.
|
|
3
|
+
*/
|
|
4
|
+
import { computeNextRunAtMs, validateSchedule } from './schedule.js';
|
|
5
|
+
/**
|
|
6
|
+
* Error backoff table (milliseconds).
|
|
7
|
+
* Applied after consecutive errors to prevent hammering.
|
|
8
|
+
*/
|
|
9
|
+
const ERROR_BACKOFF_MS = [30_000, 60_000, 300_000, 900_000, 3_600_000];
|
|
10
|
+
export function errorBackoffMs(consecutiveErrors) {
|
|
11
|
+
if (consecutiveErrors < 1)
|
|
12
|
+
return 0;
|
|
13
|
+
return ERROR_BACKOFF_MS[Math.min(consecutiveErrors - 1, ERROR_BACKOFF_MS.length - 1)];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Create a new job object from input.
|
|
17
|
+
*/
|
|
18
|
+
export function createJob(input) {
|
|
19
|
+
validateSchedule(input.schedule);
|
|
20
|
+
const nowMs = Date.now();
|
|
21
|
+
const enabled = input.enabled !== false;
|
|
22
|
+
const deleteAfterRun = input.deleteAfterRun ?? (input.schedule.kind === 'at');
|
|
23
|
+
const job = {
|
|
24
|
+
id: crypto.randomUUID(),
|
|
25
|
+
name: input.name,
|
|
26
|
+
description: input.description || undefined,
|
|
27
|
+
enabled,
|
|
28
|
+
deleteAfterRun,
|
|
29
|
+
createdAtMs: nowMs,
|
|
30
|
+
updatedAtMs: nowMs,
|
|
31
|
+
schedule: { ...input.schedule },
|
|
32
|
+
sessionTarget: input.sessionTarget || 'isolated',
|
|
33
|
+
wakeMode: input.wakeMode || 'now',
|
|
34
|
+
payload: { ...input.payload },
|
|
35
|
+
delivery: input.delivery ? { ...input.delivery } : undefined,
|
|
36
|
+
state: {
|
|
37
|
+
nextRunAtMs: undefined,
|
|
38
|
+
runningAtMs: undefined,
|
|
39
|
+
lastRunAtMs: undefined,
|
|
40
|
+
lastStatus: undefined,
|
|
41
|
+
lastError: undefined,
|
|
42
|
+
lastDurationMs: undefined,
|
|
43
|
+
consecutiveErrors: 0,
|
|
44
|
+
scheduleErrorCount: 0,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
// Compute initial next run
|
|
48
|
+
if (enabled) {
|
|
49
|
+
try {
|
|
50
|
+
job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
job.state.scheduleErrorCount = 1;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return job;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Apply an update patch to a job.
|
|
60
|
+
*/
|
|
61
|
+
export function updateJob(job, patch) {
|
|
62
|
+
const nowMs = Date.now();
|
|
63
|
+
if (patch.name !== undefined)
|
|
64
|
+
job.name = patch.name;
|
|
65
|
+
if (patch.description !== undefined)
|
|
66
|
+
job.description = patch.description || undefined;
|
|
67
|
+
if (patch.deleteAfterRun !== undefined)
|
|
68
|
+
job.deleteAfterRun = patch.deleteAfterRun;
|
|
69
|
+
if (patch.sessionTarget !== undefined)
|
|
70
|
+
job.sessionTarget = patch.sessionTarget;
|
|
71
|
+
if (patch.wakeMode !== undefined)
|
|
72
|
+
job.wakeMode = patch.wakeMode;
|
|
73
|
+
if (patch.payload !== undefined)
|
|
74
|
+
job.payload = { ...patch.payload };
|
|
75
|
+
if (patch.delivery !== undefined)
|
|
76
|
+
job.delivery = patch.delivery ? { ...patch.delivery } : undefined;
|
|
77
|
+
if (patch.schedule !== undefined) {
|
|
78
|
+
validateSchedule(patch.schedule);
|
|
79
|
+
job.schedule = { ...patch.schedule };
|
|
80
|
+
job.state.scheduleErrorCount = 0;
|
|
81
|
+
// Recompute next run
|
|
82
|
+
if (job.enabled) {
|
|
83
|
+
try {
|
|
84
|
+
job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
job.state.scheduleErrorCount = 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (patch.enabled !== undefined) {
|
|
92
|
+
job.enabled = patch.enabled;
|
|
93
|
+
if (job.enabled && !job.state.nextRunAtMs) {
|
|
94
|
+
try {
|
|
95
|
+
job.state.nextRunAtMs = computeNextRunAtMs(job.schedule, nowMs);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
job.state.scheduleErrorCount++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!job.enabled) {
|
|
102
|
+
job.state.nextRunAtMs = undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
job.updatedAtMs = nowMs;
|
|
106
|
+
return job;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Mark a job as started (running).
|
|
110
|
+
*/
|
|
111
|
+
export function markRunning(job) {
|
|
112
|
+
job.state.runningAtMs = Date.now();
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Apply the result of a job execution.
|
|
116
|
+
*/
|
|
117
|
+
export function applyResult(job, status, error, durationMs) {
|
|
118
|
+
const nowMs = Date.now();
|
|
119
|
+
job.state.lastRunAtMs = job.state.runningAtMs || nowMs;
|
|
120
|
+
job.state.runningAtMs = undefined;
|
|
121
|
+
job.state.lastStatus = status;
|
|
122
|
+
job.state.lastError = status === 'error' ? error : undefined;
|
|
123
|
+
job.state.lastDurationMs = durationMs;
|
|
124
|
+
if (status === 'error') {
|
|
125
|
+
job.state.consecutiveErrors = (job.state.consecutiveErrors || 0) + 1;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
job.state.consecutiveErrors = 0;
|
|
129
|
+
}
|
|
130
|
+
// One-shot jobs: disable after any terminal status
|
|
131
|
+
if (job.schedule.kind === 'at') {
|
|
132
|
+
job.enabled = false;
|
|
133
|
+
job.state.nextRunAtMs = undefined;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Recurring jobs: compute next run with backoff
|
|
137
|
+
if (job.enabled) {
|
|
138
|
+
try {
|
|
139
|
+
const normalNext = computeNextRunAtMs(job.schedule, nowMs);
|
|
140
|
+
if (normalNext === undefined) {
|
|
141
|
+
job.enabled = false;
|
|
142
|
+
job.state.nextRunAtMs = undefined;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (status === 'error' && job.state.consecutiveErrors > 0) {
|
|
146
|
+
const backoff = errorBackoffMs(job.state.consecutiveErrors);
|
|
147
|
+
job.state.nextRunAtMs = Math.max(normalNext, nowMs + backoff);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
job.state.nextRunAtMs = normalNext;
|
|
151
|
+
}
|
|
152
|
+
job.state.scheduleErrorCount = 0;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
job.state.scheduleErrorCount = (job.state.scheduleErrorCount || 0) + 1;
|
|
156
|
+
// Auto-disable after 3 consecutive schedule computation errors
|
|
157
|
+
if (job.state.scheduleErrorCount >= 3) {
|
|
158
|
+
job.enabled = false;
|
|
159
|
+
job.state.nextRunAtMs = undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Check if a job is due to run.
|
|
166
|
+
*/
|
|
167
|
+
export function isDue(job, nowMs) {
|
|
168
|
+
return job.enabled
|
|
169
|
+
&& !job.state.runningAtMs
|
|
170
|
+
&& job.state.nextRunAtMs !== undefined
|
|
171
|
+
&& job.state.nextRunAtMs <= nowMs;
|
|
172
|
+
}
|
package/dist/locked.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async locking mechanism to serialize state mutations.
|
|
3
|
+
* Prevents concurrent operations from corrupting job state.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Execute a function with exclusive access to cron state.
|
|
7
|
+
* Operations queue behind each other - no concurrent mutations.
|
|
8
|
+
*/
|
|
9
|
+
export declare function locked<T>(fn: () => T | Promise<T>): Promise<T>;
|
|
10
|
+
/**
|
|
11
|
+
* Reset the lock chain. Only for testing.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resetLock(): void;
|
package/dist/locked.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async locking mechanism to serialize state mutations.
|
|
3
|
+
* Prevents concurrent operations from corrupting job state.
|
|
4
|
+
*/
|
|
5
|
+
let chain = Promise.resolve();
|
|
6
|
+
/**
|
|
7
|
+
* Execute a function with exclusive access to cron state.
|
|
8
|
+
* Operations queue behind each other - no concurrent mutations.
|
|
9
|
+
*/
|
|
10
|
+
export async function locked(fn) {
|
|
11
|
+
let resolve;
|
|
12
|
+
const prev = chain;
|
|
13
|
+
chain = new Promise(r => { resolve = r; });
|
|
14
|
+
await prev;
|
|
15
|
+
try {
|
|
16
|
+
return await fn();
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
resolve();
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reset the lock chain. Only for testing.
|
|
24
|
+
*/
|
|
25
|
+
export function resetLock() {
|
|
26
|
+
chain = Promise.resolve();
|
|
27
|
+
}
|
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import MinHeap, { type HeapItem } from './min-heap.js';
|
|
2
|
+
interface CronJob extends HeapItem {
|
|
3
|
+
callback: () => void | Promise<void>;
|
|
4
|
+
interval: string;
|
|
5
|
+
key: string;
|
|
6
|
+
/**
|
|
7
|
+
* Timestamp (ms) at which the current invocation started; `undefined` when the
|
|
8
|
+
* job is idle. Mirrors `job.state.runningAtMs` in the service tier
|
|
9
|
+
* (`src/job.ts` `markRunning`/`applyResult`/`isDue`).
|
|
10
|
+
*/
|
|
11
|
+
runningAtMs?: number;
|
|
12
|
+
/**
|
|
13
|
+
* True once a skip has been reported for the *current* invocation. Bounds the
|
|
14
|
+
* still-running warning to one line per stuck run instead of one per tick.
|
|
15
|
+
*/
|
|
16
|
+
skipReported?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export default class Cron {
|
|
19
|
+
static instance: Cron | null;
|
|
20
|
+
jobs: Record<string, CronJob>;
|
|
21
|
+
heap: MinHeap<CronJob>;
|
|
22
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
23
|
+
constructor();
|
|
24
|
+
init(): Promise<void>;
|
|
25
|
+
scheduleNextRun(): void;
|
|
26
|
+
runDueJobs(): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* The one safe way this class invokes a consumer callback.
|
|
29
|
+
*
|
|
30
|
+
* Never blocks the caller, catches synchronous throws and asynchronous
|
|
31
|
+
* rejections alike, and skips the invocation entirely when the job's previous
|
|
32
|
+
* invocation has not settled yet (fire-and-forget would otherwise let a slow
|
|
33
|
+
* job stack invocations on itself).
|
|
34
|
+
*/
|
|
35
|
+
safeInvoke(job: CronJob, runOnInit?: boolean): void;
|
|
36
|
+
/**
|
|
37
|
+
* Report a scheduler-level message without ever letting the logger's own
|
|
38
|
+
* failure reach the caller.
|
|
39
|
+
*
|
|
40
|
+
* `@stonyx/logs` convenience methods return a promise and write to disk
|
|
41
|
+
* through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
|
|
42
|
+
* log volume that promise rejects; an unobserved rejection raised from inside
|
|
43
|
+
* the handler that exists to prevent unhandled rejections would re-create
|
|
44
|
+
* exactly the defect this class was fixed for (measured: exit code 1).
|
|
45
|
+
*/
|
|
46
|
+
report(level: 'error' | 'warn', message: string): void;
|
|
47
|
+
/** Release a job's in-flight guard. Only ever called for the job it belongs to. */
|
|
48
|
+
release(job: CronJob): void;
|
|
49
|
+
register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
|
|
50
|
+
unregister(key: string): void;
|
|
51
|
+
/**
|
|
52
|
+
* Parse a job interval (whole seconds, as a string) into a positive integer.
|
|
53
|
+
*
|
|
54
|
+
* Returns `null` when the value cannot be parsed at all, so callers can choose
|
|
55
|
+
* between failing fast (`register`) and falling back (`setNextTrigger`).
|
|
56
|
+
*/
|
|
57
|
+
parseInterval(interval: string): number | null;
|
|
58
|
+
setNextTrigger(job: CronJob): void;
|
|
59
|
+
log(text: string, key?: string | null): void;
|
|
60
|
+
}
|
|
61
|
+
export {};
|