@stonyx/cron 0.2.1-alpha.12 → 0.2.1-alpha.13
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 +2 -0
- package/dist/main.d.ts +11 -0
- package/dist/main.js +41 -14
- package/dist/service.d.ts +1 -29
- package/dist/service.js +8 -79
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@ 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
|
+
|
|
38
40
|
> `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
|
|
39
41
|
|
|
40
42
|
## Configuration
|
package/dist/main.d.ts
CHANGED
|
@@ -9,10 +9,21 @@ 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>;
|
|
12
14
|
constructor();
|
|
13
15
|
init(): Promise<void>;
|
|
14
16
|
scheduleNextRun(): void;
|
|
15
17
|
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;
|
|
16
27
|
register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
|
|
17
28
|
unregister(key: string): void;
|
|
18
29
|
setNextTrigger(job: CronJob): void;
|
package/dist/main.js
CHANGED
|
@@ -22,6 +22,8 @@ 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();
|
|
25
27
|
constructor() {
|
|
26
28
|
if (Cron.instance)
|
|
27
29
|
return Cron.instance;
|
|
@@ -55,17 +57,47 @@ export default class Cron {
|
|
|
55
57
|
const job = heap.pop();
|
|
56
58
|
if (config.debug)
|
|
57
59
|
this.log('job has been triggered', job.key);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
log.error(`Cron job "${job.key}" failed:`, err);
|
|
63
|
-
}
|
|
60
|
+
// Reschedule *before* invoking. The callback's result is not used by this
|
|
61
|
+
// class (`runDueJobs` returns void), so awaiting it bought nothing and
|
|
62
|
+
// cost the scheduler: a callback that never settled left the job absent
|
|
63
|
+
// from the heap and stopped the timer from ever re-arming.
|
|
64
64
|
this.setNextTrigger(job);
|
|
65
65
|
heap.push(job);
|
|
66
|
+
this.safeInvoke(job);
|
|
66
67
|
}
|
|
67
68
|
this.scheduleNextRun();
|
|
68
69
|
}
|
|
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
|
+
}
|
|
69
101
|
register(key, callback, interval, runOnInit = false) {
|
|
70
102
|
const job = { callback, interval, key, nextTrigger: 0 };
|
|
71
103
|
this.jobs[key] = job;
|
|
@@ -74,14 +106,8 @@ export default class Cron {
|
|
|
74
106
|
if (config.debug) {
|
|
75
107
|
this.log(`job has been registered with interval: ${interval}`, key);
|
|
76
108
|
}
|
|
77
|
-
if (runOnInit)
|
|
78
|
-
|
|
79
|
-
callback();
|
|
80
|
-
}
|
|
81
|
-
catch (err) {
|
|
82
|
-
log.error(`Cron job "${key}" failed on init:`, err);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
109
|
+
if (runOnInit)
|
|
110
|
+
this.safeInvoke(job, true);
|
|
85
111
|
this.scheduleNextRun();
|
|
86
112
|
}
|
|
87
113
|
unregister(key) {
|
|
@@ -91,6 +117,7 @@ export default class Cron {
|
|
|
91
117
|
return;
|
|
92
118
|
delete jobs[key];
|
|
93
119
|
heap.remove(job);
|
|
120
|
+
this.inFlight.delete(key);
|
|
94
121
|
if (config.debug)
|
|
95
122
|
this.log('job has been unregistered', key);
|
|
96
123
|
this.scheduleNextRun();
|
package/dist/service.d.ts
CHANGED
|
@@ -78,35 +78,7 @@ export default class CronService {
|
|
|
78
78
|
armTimer(): void;
|
|
79
79
|
onTimer(): Promise<void>;
|
|
80
80
|
findDueJobs(nowMs: number): Job[];
|
|
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;
|
|
81
|
+
executeJob(job: Job): Promise<ExecuteResult>;
|
|
110
82
|
removeFromHeap(id: string): void;
|
|
111
83
|
log(message: string): void;
|
|
112
84
|
}
|
package/dist/service.js
CHANGED
|
@@ -144,10 +144,6 @@ export default class CronService {
|
|
|
144
144
|
if (mode === 'due' && !isDue(job, Date.now())) {
|
|
145
145
|
return { status: 'skipped', reason: 'not due' };
|
|
146
146
|
}
|
|
147
|
-
// Deliberately NOT wrapped in locked(): executeJob takes the lock itself
|
|
148
|
-
// for its claim and settle phases only. Wrapping here would re-create the
|
|
149
|
-
// wedge through a second door, since the callback would again be awaited
|
|
150
|
-
// while a lock is held.
|
|
151
147
|
return this.executeJob(job);
|
|
152
148
|
}
|
|
153
149
|
/**
|
|
@@ -176,23 +172,16 @@ export default class CronService {
|
|
|
176
172
|
}
|
|
177
173
|
this.running = true;
|
|
178
174
|
try {
|
|
179
|
-
|
|
180
|
-
// and marking them running makes them un-collectable by anyone else, so
|
|
181
|
-
// both must happen under the same lock.
|
|
182
|
-
const dueJobs = await locked(() => {
|
|
175
|
+
await locked(async () => {
|
|
183
176
|
const nowMs = Date.now();
|
|
184
|
-
const
|
|
185
|
-
for (const job of
|
|
177
|
+
const dueJobs = this.findDueJobs(nowMs);
|
|
178
|
+
for (const job of dueJobs) {
|
|
186
179
|
markRunning(job);
|
|
187
180
|
}
|
|
188
|
-
|
|
181
|
+
for (const job of dueJobs) {
|
|
182
|
+
await this.executeJob(job);
|
|
183
|
+
}
|
|
189
184
|
});
|
|
190
|
-
// Phases 2 and 3 run outside the claim lock. The consumer callback is
|
|
191
|
-
// awaited here holding no lock at all, so a callback that never settles
|
|
192
|
-
// cannot poison the lock chain.
|
|
193
|
-
for (const job of dueJobs) {
|
|
194
|
-
await this.executeJob(job, true);
|
|
195
|
-
}
|
|
196
185
|
}
|
|
197
186
|
finally {
|
|
198
187
|
this.running = false;
|
|
@@ -213,29 +202,7 @@ export default class CronService {
|
|
|
213
202
|
}
|
|
214
203
|
return due;
|
|
215
204
|
}
|
|
216
|
-
|
|
217
|
-
* Execute a job in three phases:
|
|
218
|
-
*
|
|
219
|
-
* 1. claim (locked) - take ownership of the job, detach it from the heap
|
|
220
|
-
* 2. invoke (UNLOCKED) - await the consumer callback
|
|
221
|
-
* 3. settle (locked) - apply the result, log it, re-insert into the heap
|
|
222
|
-
*
|
|
223
|
-
* The critical section deliberately excludes phase 2. `onJobDue` is
|
|
224
|
-
* arbitrary, unbounded consumer code; awaiting it under the module-global
|
|
225
|
-
* lock is what wedged every subsequent `locked()` call (add/update/remove)
|
|
226
|
-
* when a callback never settled.
|
|
227
|
-
*
|
|
228
|
-
* `alreadyClaimed` is passed by `onTimer`, which performs the batch claim
|
|
229
|
-
* (findDueJobs + markRunning) for all due jobs under a single lock.
|
|
230
|
-
*/
|
|
231
|
-
async executeJob(job, alreadyClaimed = false) {
|
|
232
|
-
// -- Phase 1: claim (locked) --
|
|
233
|
-
if (!alreadyClaimed) {
|
|
234
|
-
const claimed = await locked(() => this.claimJob(job));
|
|
235
|
-
if (!claimed)
|
|
236
|
-
return { status: 'skipped', reason: 'already running' };
|
|
237
|
-
}
|
|
238
|
-
// -- Phase 2: invoke (NOT locked) --
|
|
205
|
+
async executeJob(job) {
|
|
239
206
|
const startMs = Date.now();
|
|
240
207
|
let status = 'ok';
|
|
241
208
|
let error;
|
|
@@ -256,37 +223,8 @@ export default class CronService {
|
|
|
256
223
|
this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
|
|
257
224
|
}
|
|
258
225
|
const durationMs = Date.now() - startMs;
|
|
259
|
-
// -- Phase 3: settle (locked) --
|
|
260
|
-
return locked(() => this.settleJob(job, status, error, summary, startMs, durationMs));
|
|
261
|
-
}
|
|
262
|
-
/**
|
|
263
|
-
* Phase 1 - claim. Must be called while holding the lock.
|
|
264
|
-
*
|
|
265
|
-
* Returns false if the job is already running, so a second `run()` reports
|
|
266
|
-
* "already running" instead of launching a concurrent invocation. Detaching
|
|
267
|
-
* from the heap here (rather than relying on phase 3 to push a fresh entry)
|
|
268
|
-
* is what keeps manual runs from permanently duplicating heap entries.
|
|
269
|
-
*/
|
|
270
|
-
claimJob(job) {
|
|
271
|
-
if (job.state.runningAtMs)
|
|
272
|
-
return false;
|
|
273
|
-
markRunning(job);
|
|
274
|
-
this.removeFromHeap(job.id);
|
|
275
|
-
return true;
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Phase 3 - settle. Must be called while holding the lock.
|
|
279
|
-
*/
|
|
280
|
-
settleJob(job, status, error, summary, startMs, durationMs) {
|
|
281
226
|
const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
|
|
282
227
|
applyResult(job, validStatus, error, durationMs);
|
|
283
|
-
// The callback ran unlocked, so it may have removed this job while it was
|
|
284
|
-
// in flight. Do not resurrect a removed job's heap entry or run log.
|
|
285
|
-
if (this.jobs.get(job.id) !== job) {
|
|
286
|
-
this.removeFromHeap(job.id);
|
|
287
|
-
this.armTimer();
|
|
288
|
-
return { status, error, summary, durationMs };
|
|
289
|
-
}
|
|
290
228
|
// Log the run
|
|
291
229
|
this.runLog.record({
|
|
292
230
|
jobId: job.id,
|
|
@@ -300,22 +238,13 @@ export default class CronService {
|
|
|
300
238
|
// Handle one-shot auto-delete
|
|
301
239
|
if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
|
|
302
240
|
this.jobs.delete(job.id);
|
|
303
|
-
this.removeFromHeap(job.id);
|
|
304
241
|
this.runLog.removeJob(job.id);
|
|
305
|
-
this.armTimer();
|
|
306
242
|
return { status, summary, deleted: true };
|
|
307
243
|
}
|
|
308
|
-
// Re-insert into heap if still active
|
|
309
|
-
// may itself have added a heap entry for this job (via add/update); drop
|
|
310
|
-
// any such entry first to preserve one-entry-per-key.
|
|
311
|
-
this.removeFromHeap(job.id);
|
|
244
|
+
// Re-insert into heap if still active
|
|
312
245
|
if (job.enabled && job.state.nextRunAtMs) {
|
|
313
246
|
this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
|
|
314
247
|
}
|
|
315
|
-
// The claim phase detached this job from the heap, so a timer that fired
|
|
316
|
-
// during the unlocked invoke would have seen it missing. Re-arm here so a
|
|
317
|
-
// manual run() can never leave the scheduler without a pending wake.
|
|
318
|
-
this.armTimer();
|
|
319
248
|
return { status, error, summary, durationMs };
|
|
320
249
|
}
|
|
321
250
|
// -- Helpers ---------------------------------------------------------
|