@stonyx/cron 0.2.1-beta.120 → 0.2.1-beta.121
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 +16 -2
- package/dist/main.d.ts +45 -0
- package/dist/main.js +194 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,20 +35,34 @@ 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. `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
|
+
|
|
38
48
|
> `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
|
|
39
49
|
|
|
40
50
|
## Configuration
|
|
41
51
|
|
|
42
|
-
Optionally, logging and debugging can be
|
|
52
|
+
Optionally, informational logging and debugging can be controlled through `config.cron`:
|
|
43
53
|
|
|
44
54
|
```js
|
|
45
55
|
config.cron = {
|
|
46
|
-
log: true //
|
|
56
|
+
log: true // informational cron job logs; defaults to true
|
|
47
57
|
};
|
|
48
58
|
|
|
49
59
|
config.debug = true; // optional: debug logs for job registration and execution
|
|
50
60
|
```
|
|
51
61
|
|
|
62
|
+
`config.cron.log` gates **informational** messages only. Error reports and the
|
|
63
|
+
stuck-job warning described above are never gated by it, so setting it to `false`
|
|
64
|
+
cannot make a dropped execution silent.
|
|
65
|
+
|
|
52
66
|
## License
|
|
53
67
|
|
|
54
68
|
Apache — do what you want, just keep attribution.
|
package/dist/main.d.ts
CHANGED
|
@@ -3,6 +3,24 @@ 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. Optional so the emitted `CronJob` stays assignable from a job
|
|
9
|
+
* object built by a consumer — `jobs`, `heap` and `setNextTrigger` all expose
|
|
10
|
+
* this interface structurally, so a required field is a breaking type change.
|
|
11
|
+
*
|
|
12
|
+
* A timestamp rather than a boolean, mirroring `job.state.runningAtMs` in the
|
|
13
|
+
* service tier (`markRunning` / `applyResult` / `isDue` in `src/job.ts`), and
|
|
14
|
+
* carrying the one fact a stuck-job warning needs: how long it has been stuck.
|
|
15
|
+
* `CronService.running` is a class-level re-entrancy flag and a different
|
|
16
|
+
* concept; reusing that word here would collide.
|
|
17
|
+
*/
|
|
18
|
+
runningAtMs?: number;
|
|
19
|
+
/**
|
|
20
|
+
* True once a skip has been reported for the *current* invocation. Bounds the
|
|
21
|
+
* still-running warning to one line per stuck run instead of one per tick.
|
|
22
|
+
*/
|
|
23
|
+
skipReported?: boolean;
|
|
6
24
|
}
|
|
7
25
|
export default class Cron {
|
|
8
26
|
static instance: Cron | null;
|
|
@@ -15,6 +33,33 @@ export default class Cron {
|
|
|
15
33
|
runDueJobs(): Promise<void>;
|
|
16
34
|
register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
|
|
17
35
|
unregister(key: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* The one place this class invokes a consumer callback.
|
|
38
|
+
*
|
|
39
|
+
* Never blocks the caller, catches synchronous throws and asynchronous
|
|
40
|
+
* rejections identically, and skips the invocation entirely while the job's
|
|
41
|
+
* previous invocation has not settled (fire-and-forget would otherwise let a
|
|
42
|
+
* slow job stack invocations on itself).
|
|
43
|
+
*
|
|
44
|
+
* Everything that touches the callback — including the thenable probe and the
|
|
45
|
+
* handler attachment — is inside the `try`. A callback may return an object
|
|
46
|
+
* whose `then` is a throwing getter, and reading it outside the guard would
|
|
47
|
+
* abort the drain loop before `scheduleNextRun()`, which is defect #36 again.
|
|
48
|
+
*/
|
|
49
|
+
invokeJob(job: CronJob, runOnInit?: boolean): void;
|
|
50
|
+
/**
|
|
51
|
+
* Report a scheduler-level message without ever letting the logger's own
|
|
52
|
+
* failure reach the caller.
|
|
53
|
+
*
|
|
54
|
+
* `@stonyx/logs` convenience methods return a promise and write to disk
|
|
55
|
+
* through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
|
|
56
|
+
* log volume that promise rejects; an unobserved rejection raised from inside
|
|
57
|
+
* the handler that exists to prevent unhandled rejections would re-create
|
|
58
|
+
* exactly the defect this class was fixed for.
|
|
59
|
+
*/
|
|
60
|
+
report(level: 'error' | 'warn', message: string): void;
|
|
61
|
+
/** Release a job's in-flight guard. Only ever called for the job it belongs to. */
|
|
62
|
+
release(job: CronJob): void;
|
|
18
63
|
setNextTrigger(job: CronJob): void;
|
|
19
64
|
log(text: string, key?: string | null): void;
|
|
20
65
|
}
|
package/dist/main.js
CHANGED
|
@@ -17,6 +17,68 @@ 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
|
+
/** Longest error text that may reach a log line. Anything past this is truncated. */
|
|
21
|
+
const MAX_LOGGED_ERROR_LENGTH = 512;
|
|
22
|
+
/**
|
|
23
|
+
* Flatten a value for interpolation into a single log line.
|
|
24
|
+
*
|
|
25
|
+
* `@stonyx/logs` writes `${timestamp} ${content}\n` to a newline-delimited
|
|
26
|
+
* file, so any `\r` or `\n` inside `content` ends the record early and
|
|
27
|
+
* everything after it is read back as a separate entry — including a forged
|
|
28
|
+
* `[timestamp] ...` prefix that is indistinguishable from a real one. Newlines
|
|
29
|
+
* become the literal two characters so the content survives for a reader, and
|
|
30
|
+
* the length cap keeps one pathological value from swamping the file.
|
|
31
|
+
*
|
|
32
|
+
* Kept byte-identical to the `forLog` landing in `src/service.ts` on #34: the
|
|
33
|
+
* two tiers render the same untrusted values into the same log file, and a
|
|
34
|
+
* reader diagnosing a forged record should not have to know which tier wrote
|
|
35
|
+
* it. Duplicated rather than shared because the two land on separate branches;
|
|
36
|
+
* folding them into one helper is a follow-up once both are on `dev`, not a
|
|
37
|
+
* cross-PR dependency that would make either unmergeable alone.
|
|
38
|
+
*/
|
|
39
|
+
function forLog(value, maxLength) {
|
|
40
|
+
const flattened = value.replace(/\r\n|[\r\n\u2028\u2029]/g, '\\n');
|
|
41
|
+
return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}...` : flattened;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Render an unknown thrown value as log text. Total by construction.
|
|
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
|
+
* Every read below touches a consumer-controlled value and can therefore throw:
|
|
52
|
+
* `instanceof` runs a proxy's `getPrototypeOf` trap, `stack`/`name`/`message`
|
|
53
|
+
* can be accessor properties, and `String(Object.create(null))` throws outright.
|
|
54
|
+
* This function runs *inside* `invokeJob`'s catch — the one place whose job is
|
|
55
|
+
* to stop a callback failure from reaching the scheduler — so a throw here
|
|
56
|
+
* escapes that catch and skips `scheduleNextRun()`, which is defect #36.
|
|
57
|
+
*/
|
|
58
|
+
function describeError(err) {
|
|
59
|
+
try {
|
|
60
|
+
if (err instanceof Error) {
|
|
61
|
+
return forLog(err.stack ?? `${err.name}: ${err.message}`, MAX_LOGGED_ERROR_LENGTH);
|
|
62
|
+
}
|
|
63
|
+
return forLog(String(err), MAX_LOGGED_ERROR_LENGTH);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Deliberately not re-entrant: describing the failure to describe the error
|
|
67
|
+
// would be the same read that just threw.
|
|
68
|
+
return '<thrown value could not be rendered>';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Render a consumer-supplied job key safely for log output.
|
|
73
|
+
*
|
|
74
|
+
* Keys reach the log verbatim, so a key containing a newline can forge a
|
|
75
|
+
* complete, well-formed log line (`'a:\n[FORGED] Cron::admin - all jobs
|
|
76
|
+
* healthy'`). `JSON.stringify` quotes the value and escapes the control
|
|
77
|
+
* characters, which is also how the key is rendered one tier up.
|
|
78
|
+
*/
|
|
79
|
+
function describeKey(key) {
|
|
80
|
+
return JSON.stringify(key);
|
|
81
|
+
}
|
|
20
82
|
export default class Cron {
|
|
21
83
|
static instance;
|
|
22
84
|
jobs = {};
|
|
@@ -43,28 +105,41 @@ export default class Cron {
|
|
|
43
105
|
if (!nextJob)
|
|
44
106
|
return;
|
|
45
107
|
const delay = Math.max(0, nextJob.nextTrigger - getTimestamp()) * 1000;
|
|
46
|
-
|
|
108
|
+
// Terminal catch: `runDueJobs` is async, so anything that escapes it would
|
|
109
|
+
// otherwise become an unhandled rejection raised from a bare timer callback
|
|
110
|
+
// — the very failure mode this class was fixed for.
|
|
111
|
+
this.timer = setTimeout(() => {
|
|
112
|
+
this.runDueJobs().catch((err) => {
|
|
113
|
+
this.report('error', `Cron scheduler tick failed: ${describeError(err)}`);
|
|
114
|
+
});
|
|
115
|
+
}, delay);
|
|
47
116
|
}
|
|
48
117
|
async runDueJobs() {
|
|
49
118
|
const now = getTimestamp();
|
|
50
119
|
const { heap } = this;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
120
|
+
// `finally`, not a trailing statement: `scheduleNextRun()` running on every
|
|
121
|
+
// exit from the drain loop is the invariant this whole fix is about. If
|
|
122
|
+
// anything in the loop body ever throws, the scheduler must still re-arm
|
|
123
|
+
// rather than stopping silently while `timer` still holds a fired handle.
|
|
124
|
+
try {
|
|
125
|
+
while (!heap.isEmpty()) {
|
|
126
|
+
const next = heap.peek();
|
|
127
|
+
if (!next || next.nextTrigger > now)
|
|
128
|
+
break;
|
|
129
|
+
const job = heap.pop();
|
|
130
|
+
if (config.debug)
|
|
131
|
+
this.log('job has been triggered', job.key);
|
|
132
|
+
// Reschedule before invoking: a consumer callback is never awaited here,
|
|
133
|
+
// so a callback that hangs or rejects can no longer starve the drain loop
|
|
134
|
+
// or leave the job orphaned outside the heap.
|
|
135
|
+
this.setNextTrigger(job);
|
|
136
|
+
heap.push(job);
|
|
137
|
+
this.invokeJob(job);
|
|
60
138
|
}
|
|
61
|
-
catch (err) {
|
|
62
|
-
log.error(`Cron job "${job.key}" failed:`, err);
|
|
63
|
-
}
|
|
64
|
-
this.setNextTrigger(job);
|
|
65
|
-
heap.push(job);
|
|
66
139
|
}
|
|
67
|
-
|
|
140
|
+
finally {
|
|
141
|
+
this.scheduleNextRun();
|
|
142
|
+
}
|
|
68
143
|
}
|
|
69
144
|
register(key, callback, interval, runOnInit = false) {
|
|
70
145
|
const job = { callback, interval, key, nextTrigger: 0 };
|
|
@@ -74,15 +149,18 @@ export default class Cron {
|
|
|
74
149
|
if (config.debug) {
|
|
75
150
|
this.log(`job has been registered with interval: ${interval}`, key);
|
|
76
151
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
152
|
+
// `finally`, not a trailing statement, for the same reason as `runDueJobs`:
|
|
153
|
+
// a job that is registered but never scheduled is defect #36's terminal
|
|
154
|
+
// state reached through the other entry point. `invokeJob` is total, so
|
|
155
|
+
// this guard should be unreachable — which is exactly why it is a guard and
|
|
156
|
+
// not an assumption.
|
|
157
|
+
try {
|
|
158
|
+
if (runOnInit)
|
|
159
|
+
this.invokeJob(job, true);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
this.scheduleNextRun();
|
|
84
163
|
}
|
|
85
|
-
this.scheduleNextRun();
|
|
86
164
|
}
|
|
87
165
|
unregister(key) {
|
|
88
166
|
const { heap, jobs } = this;
|
|
@@ -95,13 +173,104 @@ export default class Cron {
|
|
|
95
173
|
this.log('job has been unregistered', key);
|
|
96
174
|
this.scheduleNextRun();
|
|
97
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* The one place this class invokes a consumer callback.
|
|
178
|
+
*
|
|
179
|
+
* Never blocks the caller, catches synchronous throws and asynchronous
|
|
180
|
+
* rejections identically, and skips the invocation entirely while the job's
|
|
181
|
+
* previous invocation has not settled (fire-and-forget would otherwise let a
|
|
182
|
+
* slow job stack invocations on itself).
|
|
183
|
+
*
|
|
184
|
+
* Everything that touches the callback — including the thenable probe and the
|
|
185
|
+
* handler attachment — is inside the `try`. A callback may return an object
|
|
186
|
+
* whose `then` is a throwing getter, and reading it outside the guard would
|
|
187
|
+
* abort the drain loop before `scheduleNextRun()`, which is defect #36 again.
|
|
188
|
+
*/
|
|
189
|
+
invokeJob(job, runOnInit = false) {
|
|
190
|
+
const { key } = job;
|
|
191
|
+
const context = runOnInit ? 'failed on init:' : 'failed:';
|
|
192
|
+
// The in-flight guard lives on the job object, not in a module-level set
|
|
193
|
+
// keyed by string. Object identity is invocation identity: the only thing
|
|
194
|
+
// that clears the guard is the settle handler of the invocation that set it,
|
|
195
|
+
// and that handler closes over this exact job object, so a stale handler can
|
|
196
|
+
// never release a later invocation's guard.
|
|
197
|
+
if (job.runningAtMs !== undefined) {
|
|
198
|
+
// Bounded to one line per stuck run, not one per tick. A permanently hung
|
|
199
|
+
// job is re-pushed and re-skipped every interval forever; at the 1s
|
|
200
|
+
// interval this class's own tests use that measures 43,200 lines/day per
|
|
201
|
+
// job — a disk-fill and ingest-cost vector whose natural operator response
|
|
202
|
+
// is to silence the only signal that the job is dead.
|
|
203
|
+
if (!job.skipReported) {
|
|
204
|
+
job.skipReported = true;
|
|
205
|
+
const runningForSeconds = Math.max(0, Math.round((Date.now() - job.runningAtMs) / 1000));
|
|
206
|
+
// Ungated, deliberately, matching the sibling `CronService` handler. A
|
|
207
|
+
// skipped run is a *lost* execution, and `runDueJobs`/`register` both
|
|
208
|
+
// return `void`, so this is the legacy class's only wedged-job channel.
|
|
209
|
+
// Routing it through `this.log` would put it behind `config.cron.log`,
|
|
210
|
+
// where a permanently dead job is indistinguishable from a healthy one.
|
|
211
|
+
this.report('warn', `Cron job ${describeKey(key)} is still running after ${runningForSeconds}s; skipping this `
|
|
212
|
+
+ 'tick and any further ticks until it settles (this warning is not repeated for this run)');
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
job.runningAtMs = Date.now();
|
|
217
|
+
job.skipReported = false;
|
|
218
|
+
try {
|
|
219
|
+
const result = job.callback();
|
|
220
|
+
if (result && typeof result.then === 'function') {
|
|
221
|
+
Promise.resolve(result)
|
|
222
|
+
.catch((err) => {
|
|
223
|
+
// Braces matter: returning `report`'s value would put it back into
|
|
224
|
+
// the chain, and `.finally` passes a rejection straight through.
|
|
225
|
+
this.report('error', `Cron job ${describeKey(key)} ${context} ${describeError(err)}`);
|
|
226
|
+
})
|
|
227
|
+
.finally(() => { this.release(job); })
|
|
228
|
+
// Backstop: a throw inside the error handler or the release must not
|
|
229
|
+
// re-create the unhandled rejection this helper exists to prevent.
|
|
230
|
+
.catch(() => { });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.release(job);
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
this.release(job);
|
|
237
|
+
this.report('error', `Cron job ${describeKey(key)} ${context} ${describeError(err)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Report a scheduler-level message without ever letting the logger's own
|
|
242
|
+
* failure reach the caller.
|
|
243
|
+
*
|
|
244
|
+
* `@stonyx/logs` convenience methods return a promise and write to disk
|
|
245
|
+
* through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
|
|
246
|
+
* log volume that promise rejects; an unobserved rejection raised from inside
|
|
247
|
+
* the handler that exists to prevent unhandled rejections would re-create
|
|
248
|
+
* exactly the defect this class was fixed for.
|
|
249
|
+
*/
|
|
250
|
+
report(level, message) {
|
|
251
|
+
try {
|
|
252
|
+
const result = level === 'error' ? log.error(message) : log.warn(message);
|
|
253
|
+
void Promise.resolve(result).catch(() => { });
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Nowhere left to report to; the logger must never stop the scheduler.
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** Release a job's in-flight guard. Only ever called for the job it belongs to. */
|
|
260
|
+
release(job) {
|
|
261
|
+
job.runningAtMs = undefined;
|
|
262
|
+
job.skipReported = false;
|
|
263
|
+
}
|
|
98
264
|
setNextTrigger(job) {
|
|
99
265
|
job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
|
|
100
266
|
}
|
|
101
267
|
log(text, key = null) {
|
|
102
268
|
if (!config.cron?.log)
|
|
103
269
|
return;
|
|
104
|
-
|
|
270
|
+
// The key is consumer-controlled and reaches the log verbatim. Strip the
|
|
271
|
+
// line terminators so a key cannot forge a second, well-formed log line;
|
|
272
|
+
// the surrounding format is unchanged.
|
|
273
|
+
const tag = key ? `Cron::${key.replace(/[\r\n]+/g, ' ')}` : `Cron`;
|
|
105
274
|
log.cron(`${tag} - ${text}:`);
|
|
106
275
|
}
|
|
107
276
|
}
|