@stonyx/cron 0.2.1-alpha.13 → 0.2.1-alpha.14
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 +0 -2
- package/dist/main.d.ts +0 -11
- package/dist/main.js +14 -41
- package/dist/service.d.ts +29 -1
- package/dist/service.js +152 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,8 +35,6 @@ 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
|
-
> Callbacks are invoked fire-and-forget: `Cron` never waits for one to settle. Two *different* jobs that fall due on the same tick may therefore overlap, and a job that is still running when it next falls due is skipped for that tick (a warning is logged). Both synchronous throws and asynchronous rejections are caught and logged; neither can stop the scheduler.
|
|
39
|
-
|
|
40
38
|
> `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
|
|
41
39
|
|
|
42
40
|
## Configuration
|
package/dist/main.d.ts
CHANGED
|
@@ -9,21 +9,10 @@ export default class Cron {
|
|
|
9
9
|
jobs: Record<string, CronJob>;
|
|
10
10
|
heap: MinHeap<CronJob>;
|
|
11
11
|
timer: ReturnType<typeof setTimeout> | null;
|
|
12
|
-
/** Keys whose previous invocation has not settled yet. */
|
|
13
|
-
inFlight: Set<string>;
|
|
14
12
|
constructor();
|
|
15
13
|
init(): Promise<void>;
|
|
16
14
|
scheduleNextRun(): void;
|
|
17
15
|
runDueJobs(): Promise<void>;
|
|
18
|
-
/**
|
|
19
|
-
* The one safe way this class invokes a consumer callback.
|
|
20
|
-
*
|
|
21
|
-
* Never blocks the caller, catches synchronous throws and asynchronous
|
|
22
|
-
* rejections alike, and skips the invocation entirely when the job's previous
|
|
23
|
-
* invocation has not settled yet (fire-and-forget would otherwise let a slow
|
|
24
|
-
* job stack invocations on itself).
|
|
25
|
-
*/
|
|
26
|
-
safeInvoke(job: CronJob, runOnInit?: boolean): void;
|
|
27
16
|
register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
|
|
28
17
|
unregister(key: string): void;
|
|
29
18
|
setNextTrigger(job: CronJob): void;
|
package/dist/main.js
CHANGED
|
@@ -22,8 +22,6 @@ export default class Cron {
|
|
|
22
22
|
jobs = {};
|
|
23
23
|
heap = new MinHeap();
|
|
24
24
|
timer = null;
|
|
25
|
-
/** Keys whose previous invocation has not settled yet. */
|
|
26
|
-
inFlight = new Set();
|
|
27
25
|
constructor() {
|
|
28
26
|
if (Cron.instance)
|
|
29
27
|
return Cron.instance;
|
|
@@ -57,47 +55,17 @@ export default class Cron {
|
|
|
57
55
|
const job = heap.pop();
|
|
58
56
|
if (config.debug)
|
|
59
57
|
this.log('job has been triggered', job.key);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
try {
|
|
59
|
+
await job.callback();
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
log.error(`Cron job "${job.key}" failed:`, err);
|
|
63
|
+
}
|
|
64
64
|
this.setNextTrigger(job);
|
|
65
65
|
heap.push(job);
|
|
66
|
-
this.safeInvoke(job);
|
|
67
66
|
}
|
|
68
67
|
this.scheduleNextRun();
|
|
69
68
|
}
|
|
70
|
-
/**
|
|
71
|
-
* The one safe way this class invokes a consumer callback.
|
|
72
|
-
*
|
|
73
|
-
* Never blocks the caller, catches synchronous throws and asynchronous
|
|
74
|
-
* rejections alike, and skips the invocation entirely when the job's previous
|
|
75
|
-
* invocation has not settled yet (fire-and-forget would otherwise let a slow
|
|
76
|
-
* job stack invocations on itself).
|
|
77
|
-
*/
|
|
78
|
-
safeInvoke(job, runOnInit = false) {
|
|
79
|
-
const { key } = job;
|
|
80
|
-
const context = runOnInit ? 'failed on init:' : 'failed:';
|
|
81
|
-
if (this.inFlight.has(key)) {
|
|
82
|
-
log.warn(`Cron job "${key}" is still running; skipping this tick`);
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
this.inFlight.add(key);
|
|
86
|
-
try {
|
|
87
|
-
const result = job.callback();
|
|
88
|
-
if (result && typeof result.then === 'function') {
|
|
89
|
-
Promise.resolve(result)
|
|
90
|
-
.catch(err => log.error(`Cron job "${key}" ${context}`, err))
|
|
91
|
-
.finally(() => this.inFlight.delete(key));
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
this.inFlight.delete(key);
|
|
95
|
-
}
|
|
96
|
-
catch (err) {
|
|
97
|
-
this.inFlight.delete(key);
|
|
98
|
-
log.error(`Cron job "${key}" ${context}`, err);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
69
|
register(key, callback, interval, runOnInit = false) {
|
|
102
70
|
const job = { callback, interval, key, nextTrigger: 0 };
|
|
103
71
|
this.jobs[key] = job;
|
|
@@ -106,8 +74,14 @@ export default class Cron {
|
|
|
106
74
|
if (config.debug) {
|
|
107
75
|
this.log(`job has been registered with interval: ${interval}`, key);
|
|
108
76
|
}
|
|
109
|
-
if (runOnInit)
|
|
110
|
-
|
|
77
|
+
if (runOnInit) {
|
|
78
|
+
try {
|
|
79
|
+
callback();
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
log.error(`Cron job "${key}" failed on init:`, err);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
111
85
|
this.scheduleNextRun();
|
|
112
86
|
}
|
|
113
87
|
unregister(key) {
|
|
@@ -117,7 +91,6 @@ export default class Cron {
|
|
|
117
91
|
return;
|
|
118
92
|
delete jobs[key];
|
|
119
93
|
heap.remove(job);
|
|
120
|
-
this.inFlight.delete(key);
|
|
121
94
|
if (config.debug)
|
|
122
95
|
this.log('job has been unregistered', key);
|
|
123
96
|
this.scheduleNextRun();
|
package/dist/service.d.ts
CHANGED
|
@@ -78,7 +78,35 @@ export default class CronService {
|
|
|
78
78
|
armTimer(): void;
|
|
79
79
|
onTimer(): Promise<void>;
|
|
80
80
|
findDueJobs(nowMs: number): Job[];
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Execute a job in three phases:
|
|
83
|
+
*
|
|
84
|
+
* 1. claim (locked) - take ownership of the job, detach it from the heap
|
|
85
|
+
* 2. invoke (UNLOCKED) - await the consumer callback
|
|
86
|
+
* 3. settle (locked) - apply the result, log it, re-insert into the heap
|
|
87
|
+
*
|
|
88
|
+
* The critical section deliberately excludes phase 2. `onJobDue` is
|
|
89
|
+
* arbitrary, unbounded consumer code; awaiting it under the module-global
|
|
90
|
+
* lock is what wedged every subsequent `locked()` call (add/update/remove)
|
|
91
|
+
* when a callback never settled.
|
|
92
|
+
*
|
|
93
|
+
* `alreadyClaimed` is passed by `onTimer`, which performs the batch claim
|
|
94
|
+
* (findDueJobs + markRunning) for all due jobs under a single lock.
|
|
95
|
+
*/
|
|
96
|
+
executeJob(job: Job, alreadyClaimed?: boolean): Promise<ExecuteResult>;
|
|
97
|
+
/**
|
|
98
|
+
* Phase 1 - claim. Must be called while holding the lock.
|
|
99
|
+
*
|
|
100
|
+
* Returns false if the job is already running, so a second `run()` reports
|
|
101
|
+
* "already running" instead of launching a concurrent invocation. Detaching
|
|
102
|
+
* from the heap here (rather than relying on phase 3 to push a fresh entry)
|
|
103
|
+
* is what keeps manual runs from permanently duplicating heap entries.
|
|
104
|
+
*/
|
|
105
|
+
claimJob(job: Job): boolean;
|
|
106
|
+
/**
|
|
107
|
+
* Phase 3 - settle. Must be called while holding the lock.
|
|
108
|
+
*/
|
|
109
|
+
settleJob(job: Job, status: string, error: string | undefined, summary: string | undefined, startMs: number, durationMs: number): ExecuteResult;
|
|
82
110
|
removeFromHeap(id: string): void;
|
|
83
111
|
log(message: string): void;
|
|
84
112
|
}
|
package/dist/service.js
CHANGED
|
@@ -12,6 +12,24 @@ import { locked } from './locked.js';
|
|
|
12
12
|
import { normalizeJobInput, recoverFlatParams } from './normalize.js';
|
|
13
13
|
import RunLog from './run-log.js';
|
|
14
14
|
const MAX_TIMER_DELAY_MS = 60_000;
|
|
15
|
+
/**
|
|
16
|
+
* Describe a thrown value without ever throwing.
|
|
17
|
+
*
|
|
18
|
+
* `String(err)` is not total: a null-prototype object, or any object whose
|
|
19
|
+
* `toString`/`Symbol.toPrimitive` throws, raises "Cannot convert object to
|
|
20
|
+
* primitive value". Consumer callbacks throw arbitrary values, so the error
|
|
21
|
+
* handler itself must not be a second failure source.
|
|
22
|
+
*/
|
|
23
|
+
function describeError(err) {
|
|
24
|
+
if (err instanceof Error)
|
|
25
|
+
return err.message;
|
|
26
|
+
try {
|
|
27
|
+
return String(err);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return 'unknown error';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
15
33
|
export default class CronService {
|
|
16
34
|
jobs;
|
|
17
35
|
heap;
|
|
@@ -144,6 +162,10 @@ export default class CronService {
|
|
|
144
162
|
if (mode === 'due' && !isDue(job, Date.now())) {
|
|
145
163
|
return { status: 'skipped', reason: 'not due' };
|
|
146
164
|
}
|
|
165
|
+
// Deliberately NOT wrapped in locked(): executeJob takes the lock itself
|
|
166
|
+
// for its claim and settle phases only. Wrapping here would re-create the
|
|
167
|
+
// wedge through a second door, since the callback would again be awaited
|
|
168
|
+
// while a lock is held.
|
|
147
169
|
return this.executeJob(job);
|
|
148
170
|
}
|
|
149
171
|
/**
|
|
@@ -172,16 +194,42 @@ export default class CronService {
|
|
|
172
194
|
}
|
|
173
195
|
this.running = true;
|
|
174
196
|
try {
|
|
175
|
-
|
|
197
|
+
// Phase 1 - claim (locked). Collecting due jobs pops them off the heap
|
|
198
|
+
// and marking them running makes them un-collectable by anyone else, so
|
|
199
|
+
// both must happen under the same lock.
|
|
200
|
+
const dueJobs = await locked(() => {
|
|
176
201
|
const nowMs = Date.now();
|
|
177
|
-
const
|
|
178
|
-
for (const job of
|
|
202
|
+
const due = this.findDueJobs(nowMs);
|
|
203
|
+
for (const job of due) {
|
|
179
204
|
markRunning(job);
|
|
180
205
|
}
|
|
181
|
-
|
|
182
|
-
await this.executeJob(job);
|
|
183
|
-
}
|
|
206
|
+
return due;
|
|
184
207
|
});
|
|
208
|
+
// Phases 2 and 3 run outside the claim lock. The consumer callback is
|
|
209
|
+
// awaited here holding no lock at all, so a callback that never settles
|
|
210
|
+
// cannot poison the lock chain.
|
|
211
|
+
for (const job of dueJobs) {
|
|
212
|
+
try {
|
|
213
|
+
await this.executeJob(job, true);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
// One job's unexpected throw must not abort the batch. Every job in
|
|
217
|
+
// `dueJobs` is already claimed - marked running and detached from the
|
|
218
|
+
// heap - and only its own settle releases it, so aborting here would
|
|
219
|
+
// strand every sibling permanently un-due.
|
|
220
|
+
//
|
|
221
|
+
// This is the outermost handler on the timer path, so it is the one
|
|
222
|
+
// that must not be able to throw. `log()` is public, overridable and
|
|
223
|
+
// can reach a file transport, so its own failure is swallowed here
|
|
224
|
+
// rather than being allowed to take the batch down.
|
|
225
|
+
try {
|
|
226
|
+
this.log(`Job "${job.name}" (${job.id}) execution failed unexpectedly: ${describeError(err)}`);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// Nothing left to report to.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
185
233
|
}
|
|
186
234
|
finally {
|
|
187
235
|
this.running = false;
|
|
@@ -202,29 +250,91 @@ export default class CronService {
|
|
|
202
250
|
}
|
|
203
251
|
return due;
|
|
204
252
|
}
|
|
205
|
-
|
|
253
|
+
/**
|
|
254
|
+
* Execute a job in three phases:
|
|
255
|
+
*
|
|
256
|
+
* 1. claim (locked) - take ownership of the job, detach it from the heap
|
|
257
|
+
* 2. invoke (UNLOCKED) - await the consumer callback
|
|
258
|
+
* 3. settle (locked) - apply the result, log it, re-insert into the heap
|
|
259
|
+
*
|
|
260
|
+
* The critical section deliberately excludes phase 2. `onJobDue` is
|
|
261
|
+
* arbitrary, unbounded consumer code; awaiting it under the module-global
|
|
262
|
+
* lock is what wedged every subsequent `locked()` call (add/update/remove)
|
|
263
|
+
* when a callback never settled.
|
|
264
|
+
*
|
|
265
|
+
* `alreadyClaimed` is passed by `onTimer`, which performs the batch claim
|
|
266
|
+
* (findDueJobs + markRunning) for all due jobs under a single lock.
|
|
267
|
+
*/
|
|
268
|
+
async executeJob(job, alreadyClaimed = false) {
|
|
269
|
+
// -- Phase 1: claim (locked) --
|
|
270
|
+
if (!alreadyClaimed) {
|
|
271
|
+
const claimed = await locked(() => this.claimJob(job));
|
|
272
|
+
if (!claimed)
|
|
273
|
+
return { status: 'skipped', reason: 'already running' };
|
|
274
|
+
}
|
|
206
275
|
const startMs = Date.now();
|
|
207
276
|
let status = 'ok';
|
|
208
277
|
let error;
|
|
209
278
|
let summary;
|
|
279
|
+
let settled;
|
|
280
|
+
// The claim above marked the job running and detached it from the heap.
|
|
281
|
+
// Phase 3 is the ONLY thing that undoes either, so it must survive every
|
|
282
|
+
// non-local exit from phase 2 - including a throw from the catch handler
|
|
283
|
+
// itself. A claim with no matching settle is not a degraded state, it is a
|
|
284
|
+
// permanently dead job: `runningAtMs` set, no heap entry, `isDue` false
|
|
285
|
+
// forever and `run()` refused forever.
|
|
210
286
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
287
|
+
// -- Phase 2: invoke (NOT locked) --
|
|
288
|
+
try {
|
|
289
|
+
if (this.onJobDue) {
|
|
290
|
+
const result = await this.onJobDue(job);
|
|
291
|
+
if (result) {
|
|
292
|
+
status = result.status || 'ok';
|
|
293
|
+
error = result.error;
|
|
294
|
+
summary = result.summary;
|
|
295
|
+
}
|
|
217
296
|
}
|
|
218
297
|
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
status = 'error';
|
|
300
|
+
error = describeError(err);
|
|
301
|
+
this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
|
|
302
|
+
}
|
|
219
303
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
|
|
304
|
+
finally {
|
|
305
|
+
// -- Phase 3: settle (locked) --
|
|
306
|
+
settled = await locked(() => this.settleJob(job, status, error, summary, startMs, Date.now() - startMs));
|
|
224
307
|
}
|
|
225
|
-
|
|
308
|
+
return settled;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Phase 1 - claim. Must be called while holding the lock.
|
|
312
|
+
*
|
|
313
|
+
* Returns false if the job is already running, so a second `run()` reports
|
|
314
|
+
* "already running" instead of launching a concurrent invocation. Detaching
|
|
315
|
+
* from the heap here (rather than relying on phase 3 to push a fresh entry)
|
|
316
|
+
* is what keeps manual runs from permanently duplicating heap entries.
|
|
317
|
+
*/
|
|
318
|
+
claimJob(job) {
|
|
319
|
+
if (job.state.runningAtMs)
|
|
320
|
+
return false;
|
|
321
|
+
markRunning(job);
|
|
322
|
+
this.removeFromHeap(job.id);
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Phase 3 - settle. Must be called while holding the lock.
|
|
327
|
+
*/
|
|
328
|
+
settleJob(job, status, error, summary, startMs, durationMs) {
|
|
226
329
|
const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
|
|
227
330
|
applyResult(job, validStatus, error, durationMs);
|
|
331
|
+
// The callback ran unlocked, so it may have removed this job while it was
|
|
332
|
+
// in flight. Do not resurrect a removed job's heap entry or run log.
|
|
333
|
+
if (this.jobs.get(job.id) !== job) {
|
|
334
|
+
this.removeFromHeap(job.id);
|
|
335
|
+
this.armTimer();
|
|
336
|
+
return { status, error, summary, durationMs };
|
|
337
|
+
}
|
|
228
338
|
// Log the run
|
|
229
339
|
this.runLog.record({
|
|
230
340
|
jobId: job.id,
|
|
@@ -238,13 +348,22 @@ export default class CronService {
|
|
|
238
348
|
// Handle one-shot auto-delete
|
|
239
349
|
if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
|
|
240
350
|
this.jobs.delete(job.id);
|
|
351
|
+
this.removeFromHeap(job.id);
|
|
241
352
|
this.runLog.removeJob(job.id);
|
|
353
|
+
this.armTimer();
|
|
242
354
|
return { status, summary, deleted: true };
|
|
243
355
|
}
|
|
244
|
-
// Re-insert into heap if still active
|
|
356
|
+
// Re-insert into heap if still active. The callback ran unlocked, so it
|
|
357
|
+
// may itself have added a heap entry for this job (via add/update); drop
|
|
358
|
+
// any such entry first to preserve one-entry-per-key.
|
|
359
|
+
this.removeFromHeap(job.id);
|
|
245
360
|
if (job.enabled && job.state.nextRunAtMs) {
|
|
246
361
|
this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
|
|
247
362
|
}
|
|
363
|
+
// The claim phase detached this job from the heap, so a timer that fired
|
|
364
|
+
// during the unlocked invoke would have seen it missing. Re-arm here so a
|
|
365
|
+
// manual run() can never leave the scheduler without a pending wake.
|
|
366
|
+
this.armTimer();
|
|
248
367
|
return { status, error, summary, durationMs };
|
|
249
368
|
}
|
|
250
369
|
// -- Helpers ---------------------------------------------------------
|
|
@@ -266,6 +385,19 @@ export default class CronService {
|
|
|
266
385
|
log(message) {
|
|
267
386
|
if (!config.cron?.log)
|
|
268
387
|
return;
|
|
269
|
-
log.cron
|
|
388
|
+
// `log.cron` is created by `log.defineType`, which runs in `Cron.init()`
|
|
389
|
+
// (src/main.ts) - a DIFFERENT class. A consumer wiring CronService directly
|
|
390
|
+
// never runs it, while `config/environment.js` defaults `cron.log` to true,
|
|
391
|
+
// so an unguarded call throws `log.cron is not a function`. That throw
|
|
392
|
+
// escapes executeJob's catch, and the error-reporting path must never be
|
|
393
|
+
// the thing that kills the scheduler. `src/types/stonyx.d.ts:19` declares
|
|
394
|
+
// `cron()` unconditionally, so the type system will not catch this.
|
|
395
|
+
const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
|
|
396
|
+
if (typeof log[logMethod] !== 'function')
|
|
397
|
+
log.defineType(logMethod, logColor);
|
|
398
|
+
const method = log[logMethod];
|
|
399
|
+
if (typeof method !== 'function')
|
|
400
|
+
return;
|
|
401
|
+
method.call(log, `Cron — ${message}`);
|
|
270
402
|
}
|
|
271
403
|
}
|