@stonyx/cron 0.2.1-beta.80 → 0.2.1-beta.82
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 +8 -0
- package/dist/main.d.ts +58 -0
- package/dist/main.js +193 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,14 @@ When a job is executed, its next trigger time is updated, and it is re-inserted
|
|
|
35
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. |
|
|
36
36
|
| `unregister` | `key: string` | Remove a previously registered job. |
|
|
37
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**, and the value must be *wholly* numeric. `register` throws a `TypeError` on anything else — including **partially** numeric values: `'1h'`, `'30s'` and `'5m'` are rejected outright, **not** read as 1, 30 and 5 seconds. Cron expressions are rejected for the same reason; use `CronService` (`@stonyx/cron/service`) for those, and an empty string is rejected too. The interval is read with `Number()`, so exponent notation and surrounding whitespace resolve at full value (`'1e3'` is 1000 seconds, `' 60 '` is 60). A value that *is* wholly numeric but below `1` (`'0'`, `'-5'`) is interpretable as “as often as possible” and is clamped to `1` second with a warning rather than rejected. Intervals longer than `setTimeout`'s range (~24.9 days) are scheduled in hops rather than overflowing the timer, so they run on schedule.
|
|
45
|
+
|
|
38
46
|
> `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
|
|
39
47
|
|
|
40
48
|
## Configuration
|
package/dist/main.d.ts
CHANGED
|
@@ -3,6 +3,17 @@ interface CronJob extends HeapItem {
|
|
|
3
3
|
callback: () => void | Promise<void>;
|
|
4
4
|
interval: string;
|
|
5
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;
|
|
6
17
|
}
|
|
7
18
|
export default class Cron {
|
|
8
19
|
static instance: Cron | null;
|
|
@@ -13,8 +24,55 @@ export default class Cron {
|
|
|
13
24
|
init(): Promise<void>;
|
|
14
25
|
scheduleNextRun(): void;
|
|
15
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;
|
|
16
49
|
register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
|
|
17
50
|
unregister(key: string): void;
|
|
51
|
+
/**
|
|
52
|
+
* Read a job interval (whole seconds, as a string) as a number, WITHOUT
|
|
53
|
+
* applying the floor. Returns `null` when the value is not wholly numeric.
|
|
54
|
+
*
|
|
55
|
+
* `Number()` rather than `parseInt`, deliberately. `parseInt` stops at the
|
|
56
|
+
* first non-numeric character and so fails in the dangerous direction: it
|
|
57
|
+
* reads `'1h'` as 1, `'30s'` as 30 and `'5m'` as 5 — intervals 3600x, 120x and
|
|
58
|
+
* 60x faster than written, scheduled with no error attached to them. A `NaN`
|
|
59
|
+
* check catches a cron expression but not those, and those are the likelier
|
|
60
|
+
* typo: `stonyx-orm` hands `DB_SAVE_INTERVAL` straight through from the
|
|
61
|
+
* environment as a string. `Number()` reads the whole value or none of it, and
|
|
62
|
+
* also gets `'1e3'` (1000, not 1) and `' 60 '` (60) right.
|
|
63
|
+
*
|
|
64
|
+
* An empty or whitespace-only string is rejected rather than read as
|
|
65
|
+
* `Number('')` === 0, so a missing value is a loud error and not a job silently
|
|
66
|
+
* clamped to the floor.
|
|
67
|
+
*/
|
|
68
|
+
toSeconds(interval: string): number | null;
|
|
69
|
+
/**
|
|
70
|
+
* Parse a job interval into a positive integer at or above the floor.
|
|
71
|
+
*
|
|
72
|
+
* Returns `null` when the value cannot be parsed at all, so callers can choose
|
|
73
|
+
* between failing fast (`register`) and falling back (`setNextTrigger`).
|
|
74
|
+
*/
|
|
75
|
+
parseInterval(interval: string): number | null;
|
|
18
76
|
setNextTrigger(job: CronJob): void;
|
|
19
77
|
log(text: string, key?: string | null): void;
|
|
20
78
|
}
|
package/dist/main.js
CHANGED
|
@@ -17,6 +17,42 @@ import config from 'stonyx/config';
|
|
|
17
17
|
import log from 'stonyx/log';
|
|
18
18
|
import { getTimestamp } from '@stonyx/utils/date';
|
|
19
19
|
import MinHeap from './min-heap.js';
|
|
20
|
+
/**
|
|
21
|
+
* Floor for a job interval, in whole seconds.
|
|
22
|
+
*
|
|
23
|
+
* `runDueJobs` no longer awaits the callback, so `next.nextTrigger > now` is the
|
|
24
|
+
* drain loop's only exit condition *and* the loop has no suspension point left.
|
|
25
|
+
* An interval that fails to advance `nextTrigger` therefore spins the loop
|
|
26
|
+
* forever and blocks the event loop, rather than merely scheduling too often.
|
|
27
|
+
*/
|
|
28
|
+
const MIN_INTERVAL_SECONDS = 1;
|
|
29
|
+
/**
|
|
30
|
+
* Ceiling for a single `setTimeout` delay, in milliseconds (2^31 - 1, ~24.9
|
|
31
|
+
* days).
|
|
32
|
+
*
|
|
33
|
+
* Node stores a timer's delay in a 32-bit signed int. A larger value overflows,
|
|
34
|
+
* is truncated to 1 ms and emits a `TimeoutOverflowWarning`, so `scheduleNextRun`
|
|
35
|
+
* would re-arm every millisecond while the job never came due — measured at
|
|
36
|
+
* `'86400000'` (a day expressed in *milliseconds*, the plausible typo): ~790
|
|
37
|
+
* wakeups a second and ~8 GB of stderr a day from a job that never runs.
|
|
38
|
+
*
|
|
39
|
+
* Long intervals are clamped and re-armed rather than rejected, so they work
|
|
40
|
+
* instead of merely failing loudly.
|
|
41
|
+
*/
|
|
42
|
+
const TIMEOUT_MAX_MS = 2_147_483_647;
|
|
43
|
+
/**
|
|
44
|
+
* Render an unknown thrown value as log text.
|
|
45
|
+
*
|
|
46
|
+
* `@stonyx/logs` reads a second argument as `logToFile`, not as a format
|
|
47
|
+
* argument, so `log.error(message, err)` discards the error entirely *and*
|
|
48
|
+
* forces a disk write on every failure. The error has to be interpolated into
|
|
49
|
+
* the message instead — the shape `CronService.executeJob` already uses.
|
|
50
|
+
*/
|
|
51
|
+
function describeError(err) {
|
|
52
|
+
if (err instanceof Error)
|
|
53
|
+
return err.stack ?? `${err.name}: ${err.message}`;
|
|
54
|
+
return String(err);
|
|
55
|
+
}
|
|
20
56
|
export default class Cron {
|
|
21
57
|
static instance;
|
|
22
58
|
jobs = {};
|
|
@@ -42,7 +78,11 @@ export default class Cron {
|
|
|
42
78
|
const nextJob = heap.peek();
|
|
43
79
|
if (!nextJob)
|
|
44
80
|
return;
|
|
45
|
-
|
|
81
|
+
// Clamped to `setTimeout`'s range. `runDueJobs` finds nothing due when the
|
|
82
|
+
// clamp fires, breaks out of the drain loop, and re-arms the remainder — so
|
|
83
|
+
// an interval past the ceiling costs one extra wakeup every ~24.9 days
|
|
84
|
+
// instead of one every millisecond, forever.
|
|
85
|
+
const delay = Math.min(Math.max(0, nextJob.nextTrigger - getTimestamp()) * 1000, TIMEOUT_MAX_MS);
|
|
46
86
|
this.timer = setTimeout(() => this.runDueJobs(), delay);
|
|
47
87
|
}
|
|
48
88
|
async runDueJobs() {
|
|
@@ -55,18 +95,118 @@ export default class Cron {
|
|
|
55
95
|
const job = heap.pop();
|
|
56
96
|
if (config.debug)
|
|
57
97
|
this.log('job has been triggered', job.key);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
log.error(`Cron job "${job.key}" failed:`, err);
|
|
63
|
-
}
|
|
98
|
+
// Reschedule *before* invoking. The callback's result is not used by this
|
|
99
|
+
// class (`runDueJobs` returns void), so awaiting it bought nothing and
|
|
100
|
+
// cost the scheduler: a callback that never settled left the job absent
|
|
101
|
+
// from the heap and stopped the timer from ever re-arming.
|
|
64
102
|
this.setNextTrigger(job);
|
|
65
103
|
heap.push(job);
|
|
104
|
+
this.safeInvoke(job);
|
|
66
105
|
}
|
|
67
106
|
this.scheduleNextRun();
|
|
68
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The one safe way this class invokes a consumer callback.
|
|
110
|
+
*
|
|
111
|
+
* Never blocks the caller, catches synchronous throws and asynchronous
|
|
112
|
+
* rejections alike, and skips the invocation entirely when the job's previous
|
|
113
|
+
* invocation has not settled yet (fire-and-forget would otherwise let a slow
|
|
114
|
+
* job stack invocations on itself).
|
|
115
|
+
*/
|
|
116
|
+
safeInvoke(job, runOnInit = false) {
|
|
117
|
+
const { key } = job;
|
|
118
|
+
const context = runOnInit ? 'failed on init:' : 'failed:';
|
|
119
|
+
// The in-flight guard lives on the job object, not in a module-level set
|
|
120
|
+
// keyed by string. That is what gives each invocation an identity: the only
|
|
121
|
+
// thing that ever clears the flag is the settle handler of the invocation
|
|
122
|
+
// that set it, and that handler closes over this exact job object. A stale
|
|
123
|
+
// handler therefore cannot release a *later* invocation's guard. It also
|
|
124
|
+
// matches the in-repo idiom one tier up (`job.state.runningAtMs`).
|
|
125
|
+
//
|
|
126
|
+
// `unregister` needs no explicit clear as a result: the flag is dropped with
|
|
127
|
+
// the job object, so a re-registered key gets a fresh object and runs
|
|
128
|
+
// immediately, while the abandoned invocation can only ever release itself.
|
|
129
|
+
if (job.runningAtMs !== undefined) {
|
|
130
|
+
// Bounded: one line per stuck run, not one per tick. A permanently hung
|
|
131
|
+
// job is re-pushed and re-skipped every interval forever, which at the
|
|
132
|
+
// 1s interval this class's own tests use is ~86k log lines a day, per job
|
|
133
|
+
// — a disk-fill and ingest-cost vector on any deployment capturing stdout.
|
|
134
|
+
if (!job.skipReported) {
|
|
135
|
+
job.skipReported = true;
|
|
136
|
+
const runningForSeconds = Math.max(0, Math.round((Date.now() - job.runningAtMs) / 1000));
|
|
137
|
+
this.report('warn', `Cron job ${JSON.stringify(key)} is still running after ${runningForSeconds}s; skipping this `
|
|
138
|
+
+ 'tick and any further ticks until it settles (this warning is not repeated for this run)');
|
|
139
|
+
}
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
job.runningAtMs = Date.now();
|
|
143
|
+
job.skipReported = false;
|
|
144
|
+
try {
|
|
145
|
+
const result = job.callback();
|
|
146
|
+
if (result && typeof result.then === 'function') {
|
|
147
|
+
Promise.resolve(result)
|
|
148
|
+
.catch((err) => {
|
|
149
|
+
// Braces matter: returning `report`'s value would put it back into
|
|
150
|
+
// the chain, and `.finally` passes a rejection straight through.
|
|
151
|
+
this.report('error', `Cron job ${JSON.stringify(key)} ${context} ${describeError(err)}`);
|
|
152
|
+
})
|
|
153
|
+
.finally(() => { this.release(job); })
|
|
154
|
+
// Backstop: a throw inside the error handler or the release must not
|
|
155
|
+
// re-create the unhandled rejection this helper exists to prevent.
|
|
156
|
+
.catch(() => { });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
this.release(job);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
this.release(job);
|
|
163
|
+
this.report('error', `Cron job ${JSON.stringify(key)} ${context} ${describeError(err)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Report a scheduler-level message without ever letting the logger's own
|
|
168
|
+
* failure reach the caller.
|
|
169
|
+
*
|
|
170
|
+
* `@stonyx/logs` convenience methods return a promise and write to disk
|
|
171
|
+
* through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
|
|
172
|
+
* log volume that promise rejects; an unobserved rejection raised from inside
|
|
173
|
+
* the handler that exists to prevent unhandled rejections would re-create
|
|
174
|
+
* exactly the defect this class was fixed for (measured: exit code 1).
|
|
175
|
+
*/
|
|
176
|
+
report(level, message) {
|
|
177
|
+
try {
|
|
178
|
+
const result = level === 'error' ? log.error(message) : log.warn(message);
|
|
179
|
+
void Promise.resolve(result).catch(() => { });
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// Nowhere left to report to; the logger must never stop the scheduler.
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Release a job's in-flight guard. Only ever called for the job it belongs to. */
|
|
186
|
+
release(job) {
|
|
187
|
+
job.runningAtMs = undefined;
|
|
188
|
+
job.skipReported = false;
|
|
189
|
+
}
|
|
69
190
|
register(key, callback, interval, runOnInit = false) {
|
|
191
|
+
const seconds = this.toSeconds(interval);
|
|
192
|
+
// Fail fast rather than clamp. An interval that is not wholly a number is a
|
|
193
|
+
// programming error — a cron expression handed to the legacy class, or a
|
|
194
|
+
// duration with a unit on it (`'1h'`, `'30s'`) — and clamping or truncating
|
|
195
|
+
// it would silently run a job intended for every hour once per second,
|
|
196
|
+
// hammering whatever the callback talks to. Throwing surfaces it at the call
|
|
197
|
+
// site, at boot, before anything is scheduled. A degenerate-but-numeric
|
|
198
|
+
// interval (`'0'`, `'-5'`) is a different case: it is interpretable as "as
|
|
199
|
+
// often as possible" and is clamped to the floor with one warning.
|
|
200
|
+
if (seconds === null) {
|
|
201
|
+
throw new TypeError(`Cron job ${JSON.stringify(key)} has an invalid interval ${JSON.stringify(interval)}: `
|
|
202
|
+
+ 'expected a value that is wholly a whole-second count (e.g. \'30\'). Units are not '
|
|
203
|
+
+ 'accepted — \'1h\' is rejected, not read as 1. The legacy Cron class does not accept '
|
|
204
|
+
+ 'cron expressions — use CronService for those.');
|
|
205
|
+
}
|
|
206
|
+
if (seconds < MIN_INTERVAL_SECONDS) {
|
|
207
|
+
this.report('warn', `Cron job ${JSON.stringify(key)} interval ${JSON.stringify(interval)} is below the `
|
|
208
|
+
+ `${MIN_INTERVAL_SECONDS}s floor; clamping to ${MIN_INTERVAL_SECONDS}s`);
|
|
209
|
+
}
|
|
70
210
|
const job = { callback, interval, key, nextTrigger: 0 };
|
|
71
211
|
this.jobs[key] = job;
|
|
72
212
|
this.setNextTrigger(job);
|
|
@@ -74,14 +214,8 @@ export default class Cron {
|
|
|
74
214
|
if (config.debug) {
|
|
75
215
|
this.log(`job has been registered with interval: ${interval}`, key);
|
|
76
216
|
}
|
|
77
|
-
if (runOnInit)
|
|
78
|
-
|
|
79
|
-
callback();
|
|
80
|
-
}
|
|
81
|
-
catch (err) {
|
|
82
|
-
log.error(`Cron job "${key}" failed on init:`, err);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
217
|
+
if (runOnInit)
|
|
218
|
+
this.safeInvoke(job, true);
|
|
85
219
|
this.scheduleNextRun();
|
|
86
220
|
}
|
|
87
221
|
unregister(key) {
|
|
@@ -95,8 +229,51 @@ export default class Cron {
|
|
|
95
229
|
this.log('job has been unregistered', key);
|
|
96
230
|
this.scheduleNextRun();
|
|
97
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Read a job interval (whole seconds, as a string) as a number, WITHOUT
|
|
234
|
+
* applying the floor. Returns `null` when the value is not wholly numeric.
|
|
235
|
+
*
|
|
236
|
+
* `Number()` rather than `parseInt`, deliberately. `parseInt` stops at the
|
|
237
|
+
* first non-numeric character and so fails in the dangerous direction: it
|
|
238
|
+
* reads `'1h'` as 1, `'30s'` as 30 and `'5m'` as 5 — intervals 3600x, 120x and
|
|
239
|
+
* 60x faster than written, scheduled with no error attached to them. A `NaN`
|
|
240
|
+
* check catches a cron expression but not those, and those are the likelier
|
|
241
|
+
* typo: `stonyx-orm` hands `DB_SAVE_INTERVAL` straight through from the
|
|
242
|
+
* environment as a string. `Number()` reads the whole value or none of it, and
|
|
243
|
+
* also gets `'1e3'` (1000, not 1) and `' 60 '` (60) right.
|
|
244
|
+
*
|
|
245
|
+
* An empty or whitespace-only string is rejected rather than read as
|
|
246
|
+
* `Number('')` === 0, so a missing value is a loud error and not a job silently
|
|
247
|
+
* clamped to the floor.
|
|
248
|
+
*/
|
|
249
|
+
toSeconds(interval) {
|
|
250
|
+
const trimmed = String(interval ?? '').trim();
|
|
251
|
+
if (!trimmed)
|
|
252
|
+
return null;
|
|
253
|
+
const seconds = Number(trimmed);
|
|
254
|
+
if (!Number.isFinite(seconds))
|
|
255
|
+
return null;
|
|
256
|
+
// Whole seconds; a fractional value truncates as it always has.
|
|
257
|
+
return Math.trunc(seconds);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Parse a job interval into a positive integer at or above the floor.
|
|
261
|
+
*
|
|
262
|
+
* Returns `null` when the value cannot be parsed at all, so callers can choose
|
|
263
|
+
* between failing fast (`register`) and falling back (`setNextTrigger`).
|
|
264
|
+
*/
|
|
265
|
+
parseInterval(interval) {
|
|
266
|
+
const seconds = this.toSeconds(interval);
|
|
267
|
+
if (seconds === null)
|
|
268
|
+
return null;
|
|
269
|
+
return Math.max(MIN_INTERVAL_SECONDS, seconds);
|
|
270
|
+
}
|
|
98
271
|
setNextTrigger(job) {
|
|
99
|
-
|
|
272
|
+
// `register` rejects an unparseable interval up front; this floor is the
|
|
273
|
+
// backstop for a job object mutated after registration (`cron.jobs` is
|
|
274
|
+
// public, mutable state) and is what actually guarantees the drain loop
|
|
275
|
+
// terminates. Never let `nextTrigger` land on `NaN` or on `now`.
|
|
276
|
+
job.nextTrigger = getTimestamp() + (this.parseInterval(job.interval) ?? MIN_INTERVAL_SECONDS);
|
|
100
277
|
}
|
|
101
278
|
log(text, key = null) {
|
|
102
279
|
if (!config.cron?.log)
|