@stonyx/cron 0.2.1-beta.13 → 0.2.1-beta.130
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 +104 -2
- 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 +66 -0
- package/dist/main.js +292 -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 +182 -0
- package/dist/service.js +746 -0
- package/package.json +53 -9
- 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/.git/config +0 -18
- package/.github/workflows/ci.yml +0 -16
- package/.github/workflows/publish.yml +0 -51
- package/.gitignore +0 -16
- package/.npmignore +0 -5
- package/logs/error.log +0 -2
- package/pnpm-lock.yaml +0 -370
- package/src/main.js +0 -112
- package/src/min-heap.js +0 -73
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,20 +35,118 @@ 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. `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, even though the scheduler stays healthy and the job stays visible in `jobs` and in the heap. Other jobs are unaffected.
|
|
41
|
+
>
|
|
42
|
+
> One warning is emitted per stuck run (not per tick), including how long the invocation has been running. That warning goes to `log.warn` and is **not** gated by `config.cron.log` — a dropped execution reported on a channel a config flag can silence would be indistinguishable from a healthy scheduler.
|
|
43
|
+
>
|
|
44
|
+
> The same-job guarantee holds for the lifetime of a **registration**, not of a key: `unregister` followed by `register` on a key whose invocation is still in flight builds a fresh job object with a fresh guard, so the replacement can run alongside the abandoned invocation. That is also the only way to recover a permanently stuck job.
|
|
45
|
+
>
|
|
46
|
+
> 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. Note that a rejection which previously escaped `register()` as an unhandled rejection — process-fatal under Node's default — is now swallowed into `log.error`.
|
|
47
|
+
|
|
34
48
|
> `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
|
|
35
49
|
|
|
50
|
+
## CronService
|
|
51
|
+
|
|
52
|
+
The default export above is `Cron`: a fire-and-forget interval registry. `@stonyx/cron/service` is a separate, heavier class for jobs that need CRUD, persistence, a run log and error backoff. It is not a drop-in replacement and the two do not share a scheduler.
|
|
53
|
+
|
|
54
|
+
The two classes agree on the guarantee — the same job is never run concurrently with itself, and different jobs may overlap — but not on the mechanism or on what you can observe. `Cron` invokes callbacks fire-and-forget and reports a skipped run only as an ungated `log.warn` line, one per stuck run, never as a value. `CronService` **awaits** `onJobDue`, its return value shapes `status`/`error`/`summary`, and a refused run comes back to the caller as a value that no log setting can suppress — it is not logged. The two therefore agree on one more thing than the contrast suggests: neither can have a *refused* run made silent by `config.cron.log` — `Cron`'s skip warning is ungated, and `CronService`'s refusal is a return value rather than a log line. A job *failure* is the opposite case, and there the two classes genuinely diverge; see [Configuration](#configuration).
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import CronService from '@stonyx/cron/service';
|
|
58
|
+
|
|
59
|
+
const service = new CronService();
|
|
60
|
+
service.onJobDue = async (job) => ({ status: 'ok', summary: 'done' });
|
|
61
|
+
|
|
62
|
+
await service.start();
|
|
63
|
+
const job = await service.add({ name: 'Nightly', schedule: { kind: 'every', everyMs: 86_400_000 }, payload: { kind: 'agentTurn', message: 'go' } });
|
|
64
|
+
|
|
65
|
+
const result = await service.run(job.id, 'force');
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### The `run()` contract
|
|
69
|
+
|
|
70
|
+
`run(id, mode)` resolves with an `ExecuteResult`. It **never** invokes the callback twice for one job, and it will refuse rather than queue:
|
|
71
|
+
|
|
72
|
+
| `status` | `reason` | Meaning |
|
|
73
|
+
| :---------- | :------------------ | :----------------------------------------------------------------------------- |
|
|
74
|
+
| `'ok'` | — | The callback resolved. `summary` and `durationMs` are set. |
|
|
75
|
+
| `'error'` | — | The callback threw or rejected. `error` carries the message; backoff is applied. |
|
|
76
|
+
| `'skipped'` | `'not due'` | `mode` was `'due'` and the job's next run time has not arrived. Use `'force'` to run anyway. |
|
|
77
|
+
| `'skipped'` | `'already running'` | A previous invocation of **this** job has not settled. The call is refused, not queued, and nothing is logged. |
|
|
78
|
+
| `'skipped'` | `'removed'` | The job was removed between the lookup and the claim. The callback did not fire. |
|
|
79
|
+
|
|
80
|
+
`run()` throws (rather than returning a result) when `id` is not a registered job: `Error: Job not found: <id>`.
|
|
81
|
+
|
|
82
|
+
**Concurrency.** A job is bounded to one in-flight invocation on every path — manual `run()` and the timer both claim it first. **Different** jobs are not bounded: the callback is deliberately invoked outside the internal lock, so N concurrent `run()` calls on N distinct jobs produce N concurrent callbacks. The scheduler itself never generates that fan-out (its timer path invokes a due batch sequentially); only a caller can. If you drive `run()` from a request handler, bound it on your side. Taking the callback out of the lock is what stops a callback that never settles from blocking `add`/`update`/`remove`; restoring the bound by putting it back would restore that deadlock.
|
|
83
|
+
|
|
84
|
+
**The residual, stated plainly.** Taking the callback out of the lock fixes
|
|
85
|
+
`add`/`update`/`remove`; it does **not** bound the callback. A callback that
|
|
86
|
+
never settles still holds its job's claim forever, and because the timer's
|
|
87
|
+
re-entrancy latch is only released when the batch settles, it also **stops the
|
|
88
|
+
timer loop for every other job** — silently, with `status()` still reporting
|
|
89
|
+
`started: true`. There is no execution timeout by design, so bounding your own
|
|
90
|
+
callback is your responsibility, exactly as it is for `Cron`. Only a real
|
|
91
|
+
process restart recovers it: `start()` releases a claim whose owner is provably
|
|
92
|
+
gone, but an object still held in this process is deliberately left alone.
|
|
93
|
+
Tracked as #35.
|
|
94
|
+
|
|
95
|
+
### Breaking changes in this line
|
|
96
|
+
|
|
97
|
+
Five consumer-visible changes landed with the phase split (#34). Only the first
|
|
98
|
+
two are visible in the emitted `dist/service.d.ts`; the rest are runtime
|
|
99
|
+
behaviour and a type-checker will not find them for you. The measured
|
|
100
|
+
`dist/service.d.ts` delta against the previous release is exactly four things:
|
|
101
|
+
the class gained `#private;`, `reason` narrowed, five type declarations gained
|
|
102
|
+
`export` (`JobDueResult`, `ExecuteResult`, `ServiceStatus`, `ListOptions` and
|
|
103
|
+
`OnJobDueCallback`), and `SkipReason` was added as a new exported type — it did
|
|
104
|
+
not exist in the previous release, so it gained nothing. `HeapEntry` remains
|
|
105
|
+
unexported. `dist/main.d.ts` is unchanged.
|
|
106
|
+
|
|
107
|
+
1. **`ExecuteResult.reason` narrowed** from `string` to `'not due' | 'already running' | 'removed'`, and gained the `'removed'` member. Comparing it against a literal outside the union, or `switch`ing on one, is now a compile error (`TS2367` / `TS2678`). Assigning it into `string | undefined` and spreading it are unaffected. The type is exported as `SkipReason`.
|
|
108
|
+
2. **`CronService` is nominally typed.** It carries ECMAScript hard-private members, so the declarations emit `#private;` and a structurally hand-built test double no longer assigns to `CronService` (`TS2741: Property '#private' is missing`). The break is one-directional: `class X extends CronService` still compiles, and assigning a real `CronService` to your own hand-written interface still compiles. **Migration:** declare your own interface and depend on that instead of a `CronService`-typed mock.
|
|
109
|
+
3. **`claimJob`, `settleJob` and `executeClaimed` are not published.** Not a change against the previous release — these members did not exist there at all. Listed because they are new internals that look like API and are deliberately unreachable: a claim taken without its matching settle strands the job permanently in-process. Guarded by `test/unit/publish-surface-test.ts`.
|
|
110
|
+
4. **`run()` no longer serializes across jobs** — a runtime change, not a d.ts one. See the concurrency note above.
|
|
111
|
+
5. **`start()` now throws on a row it cannot use.** A `Job` whose `state` is missing or frozen — `structuredClone` + `Object.freeze` is an ordinary defensive rehydration — makes `start()` reject where the previous release resolved and carried on. Measured: previous release resolves and leaves the stale claim in place; this line throws `TypeError: Cannot assign to read only property 'runningAtMs'`. The timer is still armed for the rows loaded before the throw, so this surfaces at your `await` instead of as an unhandled rejection from inside a timer callback later. **Migration:** if your store hands back frozen rows, thaw `state` before passing them, or catch at the `start()` call site.
|
|
112
|
+
|
|
113
|
+
`SkipReason`, `ExecuteResult`, `JobDueResult`, `ServiceStatus`, `ListOptions` and `OnJobDueCallback` are all exported from `@stonyx/cron/service`, so an exhaustive handler over `reason` is expressible.
|
|
114
|
+
|
|
36
115
|
## Configuration
|
|
37
116
|
|
|
38
|
-
Optionally, logging and debugging can be
|
|
117
|
+
Optionally, informational logging and debugging can be controlled through `config.cron`:
|
|
39
118
|
|
|
40
119
|
```js
|
|
41
120
|
config.cron = {
|
|
42
|
-
log: true //
|
|
121
|
+
log: true // informational cron job logs; defaults to true
|
|
43
122
|
};
|
|
44
123
|
|
|
45
124
|
config.debug = true; // optional: debug logs for job registration and execution
|
|
46
125
|
```
|
|
47
126
|
|
|
127
|
+
`config.cron.log` gates the `log.cron` channel only. **The two classes route job
|
|
128
|
+
failures differently, so what `log: false` costs you depends on which one you are
|
|
129
|
+
running.** Measured on this build, with a callback that throws:
|
|
130
|
+
|
|
131
|
+
| | Failure channel | Records with `log: false` |
|
|
132
|
+
| :--- | :--- | :--- |
|
|
133
|
+
| `Cron` (default export) | ungated `log.error` | emitted — unchanged |
|
|
134
|
+
| `CronService` | **gated** `log.cron` | **none** |
|
|
135
|
+
|
|
136
|
+
- **`Cron`** reports a callback's throw or rejection through `log.error`, and the
|
|
137
|
+
stuck-job warning described above through `log.warn`. Neither is gated, so
|
|
138
|
+
setting `log` to `false` cannot make a dropped or failed execution silent.
|
|
139
|
+
- **`CronService`** reports a job whose `onJobDue` callback throws through the
|
|
140
|
+
**gated** channel, so `log: false` yields **zero** log records for that
|
|
141
|
+
failure. It stays observable as `ExecuteResult.error` and as a run-log row —
|
|
142
|
+
but a job driven off the timer rather than `run()` has no caller to read that
|
|
143
|
+
return value, so the failure is visible only in the run log. Only the timer
|
|
144
|
+
path's *unexpected* internal throw (a fault in the scheduler itself, not in
|
|
145
|
+
your callback) is reported on the ungated `log.error` channel.
|
|
146
|
+
|
|
147
|
+
If you set `log: false` in production and rely on `CronService`, read failures
|
|
148
|
+
from the run log or from `ExecuteResult`, not from the log file.
|
|
149
|
+
|
|
48
150
|
## License
|
|
49
151
|
|
|
50
152
|
Apache — do what you want, just keep attribution.
|
|
@@ -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
|
+
}
|