@stonyx/cron 0.2.1-alpha.42 → 0.2.1-alpha.43

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 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
- > Job callbacks are invoked fire-and-forget: two *different* jobs due in the same tick may overlap, and a job whose previous invocation has not settled is skipped (and logged) when it next comes due rather than running concurrently with itself. A callback that throws — synchronously or by rejecting — is logged, never propagated.
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
@@ -3,7 +3,6 @@ interface CronJob extends HeapItem {
3
3
  callback: () => void | Promise<void>;
4
4
  interval: string;
5
5
  key: string;
6
- running: boolean;
7
6
  }
8
7
  export default class Cron {
9
8
  static instance: Cron | null;
@@ -16,15 +15,6 @@ export default class Cron {
16
15
  runDueJobs(): Promise<void>;
17
16
  register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
18
17
  unregister(key: string): void;
19
- /**
20
- * Invoke a consumer callback without ever blocking the caller.
21
- *
22
- * Synchronous throws and rejected thenables are both routed to `log.error`
23
- * with `errorMessage`; a callback that never settles simply never clears its
24
- * in-flight flag. The job is skipped (and logged) while a previous invocation
25
- * is still running, so fire-and-forget cannot stack invocations on one job.
26
- */
27
- invokeJob(job: CronJob, errorMessage: string): void;
28
18
  setNextTrigger(job: CronJob): void;
29
19
  log(text: string, key?: string | null): void;
30
20
  }
package/dist/main.js CHANGED
@@ -55,17 +55,19 @@ export default class Cron {
55
55
  const job = heap.pop();
56
56
  if (config.debug)
57
57
  this.log('job has been triggered', job.key);
58
- // Reschedule before invoking: a consumer callback is never awaited here,
59
- // so a callback that hangs or rejects can no longer starve the drain loop
60
- // or leave the job orphaned outside the heap.
58
+ try {
59
+ await job.callback();
60
+ }
61
+ catch (err) {
62
+ log.error(`Cron job "${job.key}" failed:`, err);
63
+ }
61
64
  this.setNextTrigger(job);
62
65
  heap.push(job);
63
- this.invokeJob(job, `Cron job "${job.key}" failed:`);
64
66
  }
65
67
  this.scheduleNextRun();
66
68
  }
67
69
  register(key, callback, interval, runOnInit = false) {
68
- const job = { callback, interval, key, nextTrigger: 0, running: false };
70
+ const job = { callback, interval, key, nextTrigger: 0 };
69
71
  this.jobs[key] = job;
70
72
  this.setNextTrigger(job);
71
73
  this.heap.push(job);
@@ -73,7 +75,12 @@ export default class Cron {
73
75
  this.log(`job has been registered with interval: ${interval}`, key);
74
76
  }
75
77
  if (runOnInit) {
76
- this.invokeJob(job, `Cron job "${key}" failed on init:`);
78
+ try {
79
+ callback();
80
+ }
81
+ catch (err) {
82
+ log.error(`Cron job "${key}" failed on init:`, err);
83
+ }
77
84
  }
78
85
  this.scheduleNextRun();
79
86
  }
@@ -88,39 +95,6 @@ export default class Cron {
88
95
  this.log('job has been unregistered', key);
89
96
  this.scheduleNextRun();
90
97
  }
91
- /**
92
- * Invoke a consumer callback without ever blocking the caller.
93
- *
94
- * Synchronous throws and rejected thenables are both routed to `log.error`
95
- * with `errorMessage`; a callback that never settles simply never clears its
96
- * in-flight flag. The job is skipped (and logged) while a previous invocation
97
- * is still running, so fire-and-forget cannot stack invocations on one job.
98
- */
99
- invokeJob(job, errorMessage) {
100
- if (job.running) {
101
- this.log('job is still running, skipping this run', job.key);
102
- return;
103
- }
104
- job.running = true;
105
- const settle = () => { job.running = false; };
106
- let result;
107
- try {
108
- result = job.callback();
109
- }
110
- catch (err) {
111
- log.error(errorMessage, err);
112
- settle();
113
- return;
114
- }
115
- if (!result || typeof result.then !== 'function') {
116
- settle();
117
- return;
118
- }
119
- result.then(settle, (err) => {
120
- log.error(errorMessage, err);
121
- settle();
122
- });
123
- }
124
98
  setNextTrigger(job) {
125
99
  job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
126
100
  }
package/dist/service.d.ts CHANGED
@@ -15,7 +15,8 @@ interface ExecuteResult {
15
15
  summary?: string;
16
16
  durationMs?: number;
17
17
  deleted?: boolean;
18
- reason?: string;
18
+ /** Only set when `status` is `'skipped'`. */
19
+ reason?: 'not due' | 'already running' | 'removed';
19
20
  }
20
21
  interface ServiceStatus {
21
22
  started: boolean;
@@ -27,6 +28,7 @@ interface ListOptions {
27
28
  }
28
29
  type OnJobDueCallback = (job: Job) => Promise<JobDueResult | void> | JobDueResult | void;
29
30
  export default class CronService {
31
+ #private;
30
32
  jobs: Map<string, Job>;
31
33
  heap: MinHeap<HeapEntry>;
32
34
  timer: ReturnType<typeof setTimeout> | null;
@@ -69,6 +71,21 @@ export default class CronService {
69
71
  remove(id: string): Promise<void>;
70
72
  /**
71
73
  * Manually trigger a job.
74
+ *
75
+ * Returns `{ status: 'skipped', reason }` without invoking the callback when
76
+ * the job is not due (`mode: 'due'`), is already in flight
77
+ * (`'already running'`), or was removed before the claim landed
78
+ * (`'removed'`). Before the phase split, a forced run against an in-flight
79
+ * job launched a second concurrent invocation.
80
+ *
81
+ * CONCURRENCY: the same job is bounded to one in-flight invocation on every
82
+ * path, and the timer path invokes due jobs one at a time. `run()` fan-out
83
+ * across DIFFERENT jobs is deliberately unbounded — N concurrent `run()`
84
+ * calls produce N concurrent consumer callbacks. Before the phase split
85
+ * these serialized behind the module-global lock; that serialization was the
86
+ * bug rather than the feature (one hung callback wedged every other caller),
87
+ * so it is not restored here. The fan-out is caller-driven and the scheduler
88
+ * never produces it on its own.
72
89
  */
73
90
  run(id: string, mode?: 'due' | 'force'): Promise<ExecuteResult>;
74
91
  /**
@@ -78,6 +95,25 @@ export default class CronService {
78
95
  armTimer(): void;
79
96
  onTimer(): Promise<void>;
80
97
  findDueJobs(nowMs: number): Job[];
98
+ /**
99
+ * Execute a job in three phases:
100
+ *
101
+ * 1. claim (locked) — take ownership of the job, detach it from the heap
102
+ * 2. invoke (UNLOCKED) — await the consumer callback
103
+ * 3. settle (locked) — apply the result, log it, re-insert into the heap
104
+ *
105
+ * The critical section deliberately excludes phase 2. `onJobDue` is
106
+ * arbitrary, unbounded consumer code; awaiting it under the module-global
107
+ * lock is what wedged every subsequent `locked()` call (add/update/remove)
108
+ * when a callback never settled.
109
+ *
110
+ * `onTimer` performs the batch claim (`findDueJobs` + `markRunning`) for all
111
+ * due jobs under a single lock and then enters at phase 2 via
112
+ * `#executeClaimed`. That entry point is `#private` rather than a parameter
113
+ * on this method: as a published `alreadyClaimed` flag it would be a
114
+ * supported way to skip phase 1 entirely, defeating the claim guard and
115
+ * allowing concurrent `onJobDue` invocations for the same job.
116
+ */
81
117
  executeJob(job: Job): Promise<ExecuteResult>;
82
118
  removeFromHeap(id: string): void;
83
119
  log(message: string): void;
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 must not become a second failure source of its own.
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;
@@ -136,6 +154,21 @@ export default class CronService {
136
154
  }
137
155
  /**
138
156
  * Manually trigger a job.
157
+ *
158
+ * Returns `{ status: 'skipped', reason }` without invoking the callback when
159
+ * the job is not due (`mode: 'due'`), is already in flight
160
+ * (`'already running'`), or was removed before the claim landed
161
+ * (`'removed'`). Before the phase split, a forced run against an in-flight
162
+ * job launched a second concurrent invocation.
163
+ *
164
+ * CONCURRENCY: the same job is bounded to one in-flight invocation on every
165
+ * path, and the timer path invokes due jobs one at a time. `run()` fan-out
166
+ * across DIFFERENT jobs is deliberately unbounded — N concurrent `run()`
167
+ * calls produce N concurrent consumer callbacks. Before the phase split
168
+ * these serialized behind the module-global lock; that serialization was the
169
+ * bug rather than the feature (one hung callback wedged every other caller),
170
+ * so it is not restored here. The fan-out is caller-driven and the scheduler
171
+ * never produces it on its own.
139
172
  */
140
173
  async run(id, mode = 'force') {
141
174
  const job = this.jobs.get(id);
@@ -144,6 +177,10 @@ export default class CronService {
144
177
  if (mode === 'due' && !isDue(job, Date.now())) {
145
178
  return { status: 'skipped', reason: 'not due' };
146
179
  }
180
+ // Deliberately NOT wrapped in locked(): executeJob takes the lock itself,
181
+ // for its claim and settle phases only. Wrapping here would re-create the
182
+ // wedge through a second door, because the consumer callback would once
183
+ // again be awaited while a lock is held.
147
184
  return this.executeJob(job);
148
185
  }
149
186
  /**
@@ -172,16 +209,50 @@ export default class CronService {
172
209
  }
173
210
  this.running = true;
174
211
  try {
175
- await locked(async () => {
212
+ // -- Phase 1: claim (locked), batched --
213
+ // Collecting due jobs pops them off the heap, and marking them running
214
+ // makes them un-claimable by anyone else. Both must happen under the
215
+ // same lock turn, or a concurrent run() could claim a job this batch has
216
+ // already detached.
217
+ const dueJobs = await locked(() => {
176
218
  const nowMs = Date.now();
177
- const dueJobs = this.findDueJobs(nowMs);
178
- for (const job of dueJobs) {
219
+ const due = this.findDueJobs(nowMs);
220
+ for (const job of due) {
179
221
  markRunning(job);
180
222
  }
181
- for (const job of dueJobs) {
182
- await this.executeJob(job);
183
- }
223
+ return due;
184
224
  });
225
+ // Phases 2 and 3 run OUTSIDE the claim lock. The consumer callback is
226
+ // awaited here holding no lock at all, so a callback that never settles
227
+ // cannot poison the lock chain and wedge add/update/remove.
228
+ for (const job of dueJobs) {
229
+ try {
230
+ await this.#executeClaimed(job);
231
+ }
232
+ catch (err) {
233
+ // One job's unexpected throw must not abort the batch. Every job in
234
+ // `dueJobs` is already claimed — marked running and detached from
235
+ // the heap — and only its own settle releases it, so aborting here
236
+ // would strand every sibling permanently un-due.
237
+ //
238
+ // Reported on an UNGATED channel. `this.log()` returns early when
239
+ // `config.cron.log` is false, which is a supported production
240
+ // setting, and a failure here permanently unschedules a job while
241
+ // `status()` keeps reporting the service healthy. Silent-and-healthy
242
+ // is exactly the failure class this split exists to remove.
243
+ //
244
+ // This is the outermost handler on the timer path, so it is the one
245
+ // that must not be able to throw: `log` is a shared singleton whose
246
+ // transports can reach the filesystem, so its own failure is
247
+ // swallowed rather than allowed to take the batch down.
248
+ try {
249
+ log.error(`Cron — Job "${job.name}" (${job.id}) execution failed unexpectedly: ${describeError(err)}`);
250
+ }
251
+ catch {
252
+ // Nothing left to report to.
253
+ }
254
+ }
255
+ }
185
256
  }
186
257
  finally {
187
258
  this.running = false;
@@ -202,50 +273,190 @@ export default class CronService {
202
273
  }
203
274
  return due;
204
275
  }
276
+ /**
277
+ * Execute a job in three phases:
278
+ *
279
+ * 1. claim (locked) — take ownership of the job, detach it from the heap
280
+ * 2. invoke (UNLOCKED) — await the consumer callback
281
+ * 3. settle (locked) — apply the result, log it, re-insert into the heap
282
+ *
283
+ * The critical section deliberately excludes phase 2. `onJobDue` is
284
+ * arbitrary, unbounded consumer code; awaiting it under the module-global
285
+ * lock is what wedged every subsequent `locked()` call (add/update/remove)
286
+ * when a callback never settled.
287
+ *
288
+ * `onTimer` performs the batch claim (`findDueJobs` + `markRunning`) for all
289
+ * due jobs under a single lock and then enters at phase 2 via
290
+ * `#executeClaimed`. That entry point is `#private` rather than a parameter
291
+ * on this method: as a published `alreadyClaimed` flag it would be a
292
+ * supported way to skip phase 1 entirely, defeating the claim guard and
293
+ * allowing concurrent `onJobDue` invocations for the same job.
294
+ */
205
295
  async executeJob(job) {
296
+ // -- Phase 1: claim (locked) --
297
+ const refusal = await locked(() => this.#claimJob(job));
298
+ if (refusal)
299
+ return { status: 'skipped', reason: refusal };
300
+ return this.#executeClaimed(job);
301
+ }
302
+ /**
303
+ * Phases 2 and 3 for a job that has already been claimed — either by
304
+ * `executeJob` above or by `onTimer`'s batch claim.
305
+ *
306
+ * Private: reaching this without a claim would run the consumer callback for
307
+ * a job nobody owns, and would leave nothing to release the claim.
308
+ */
309
+ async #executeClaimed(job) {
310
+ // Membership re-check. The claim and the invoke are no longer in the same
311
+ // critical section, and sibling callbacks run unlocked, so a `remove()` can
312
+ // now land in between AND RESOLVE — it used to deadlock. A resolved
313
+ // `remove()` must keep meaning "this callback will not fire"; the identity
314
+ // guard in `#settleJob` only cleans up afterwards, by which point the side
315
+ // effect has already happened. Identity, not id, so a removed-then-replaced
316
+ // key is caught too. Deliberately synchronous with the `onJobDue` call
317
+ // below — nothing can interleave between this check and the invocation.
318
+ //
319
+ // This is the one early return after a claim, so it is the one that has to
320
+ // release the claim by hand. Skipping settle is right — re-inserting or
321
+ // run-logging a removed job is the resurrection `#settleJob` refuses, and
322
+ // the heap entry is already gone. But the claim must still come off,
323
+ // because the detached object is NOT unreachable: it is the object `add()`
324
+ // returned and `get()`/`list()` hand out, and `start(initialJobs)`
325
+ // re-registers those objects verbatim, `state` included. A leftover
326
+ // `runningAtMs` rehydrates a permanently dead job — `isDue` false forever,
327
+ // `run()` refused forever, `status()` reporting it healthy.
328
+ //
329
+ // Assigned directly rather than via `applyResult`: this releases the claim
330
+ // and nothing else. No run-log row, no heap entry, no `lastStatus`, no
331
+ // recomputed `nextRunAtMs` — the job did not run.
332
+ if (this.jobs.get(job.id) !== job) {
333
+ job.state.runningAtMs = undefined;
334
+ return { status: 'skipped', reason: 'removed' };
335
+ }
206
336
  const startMs = Date.now();
207
337
  let status = 'ok';
208
338
  let error;
209
339
  let summary;
340
+ let settled;
341
+ // The claim marked the job running and detached it from the heap. Phase 3
342
+ // is the ONLY thing that undoes either, so it must survive every non-local
343
+ // exit from phase 2 — including a throw from the catch handler itself
344
+ // (`this.log` is public and overridable and reaches a transport). A claim
345
+ // with no matching settle is not a degraded state, it is a permanently
346
+ // dead job.
210
347
  try {
211
- if (this.onJobDue) {
212
- const result = await this.onJobDue(job);
213
- if (result) {
214
- status = result.status || 'ok';
215
- error = result.error;
216
- summary = result.summary;
348
+ // -- Phase 2: invoke (NOT locked) --
349
+ try {
350
+ if (this.onJobDue) {
351
+ const result = await this.onJobDue(job);
352
+ if (result) {
353
+ status = result.status || 'ok';
354
+ error = result.error;
355
+ summary = result.summary;
356
+ }
217
357
  }
218
358
  }
359
+ catch (err) {
360
+ status = 'error';
361
+ error = describeError(err);
362
+ this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
363
+ }
219
364
  }
220
- catch (err) {
221
- status = 'error';
222
- error = err instanceof Error ? err.message : String(err);
223
- this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
365
+ finally {
366
+ // -- Phase 3: settle (locked) --
367
+ settled = await locked(() => this.#settleJob(job, status, error, summary, startMs, Date.now() - startMs));
224
368
  }
225
- const durationMs = Date.now() - startMs;
226
- const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
227
- applyResult(job, validStatus, error, durationMs);
228
- // Log the run
229
- this.runLog.record({
230
- jobId: job.id,
231
- status,
232
- error,
233
- summary,
234
- runAtMs: startMs,
235
- durationMs,
236
- nextRunAtMs: job.state.nextRunAtMs,
237
- });
238
- // Handle one-shot auto-delete
239
- if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
240
- this.jobs.delete(job.id);
241
- this.runLog.removeJob(job.id);
242
- return { status, summary, deleted: true };
369
+ return settled;
370
+ }
371
+ /**
372
+ * Phase 1 — claim. Must be called while holding the lock (`locked()`, whose
373
+ * chain is module-global and therefore shared across CronService instances).
374
+ *
375
+ * Returns `null` on a successful claim, or the reason the claim was refused.
376
+ * `'already running'` is what makes a second `run()` report a skip instead of
377
+ * launching a concurrent invocation. `'removed'` covers the job being deleted
378
+ * between `run()`'s unlocked lookup and this lock turn — claiming then would
379
+ * `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
380
+ * belong to a replacement.
381
+ *
382
+ * Detaching from the heap here — rather than relying on phase 3 to push a
383
+ * fresh entry — is what stops manual runs permanently duplicating entries.
384
+ *
385
+ * `#private`: published, this would be a supported call performing
386
+ * `markRunning` + `removeFromHeap` with no guaranteed settle and no lease on
387
+ * `runningAtMs`, so a single such call would strand the job forever. The
388
+ * lock-held precondition cannot be expressed in the type system, so the
389
+ * method must not be reachable from outside the class body.
390
+ */
391
+ #claimJob(job) {
392
+ if (this.jobs.get(job.id) !== job)
393
+ return 'removed';
394
+ if (job.state.runningAtMs)
395
+ return 'already running';
396
+ markRunning(job);
397
+ this.removeFromHeap(job.id);
398
+ return null;
399
+ }
400
+ /**
401
+ * Phase 3 — settle. Must be called while holding the lock.
402
+ *
403
+ * `#private` for the same reason as `#claimJob`: unlocked it would run
404
+ * `applyResult`, a `runLog.record`, a full `removeFromHeap` rebuild, a
405
+ * `heap.push` and an `armTimer` with no mutual exclusion — exactly the
406
+ * corruption `locked()` exists to prevent.
407
+ */
408
+ #settleJob(job, status, error, summary, startMs, durationMs) {
409
+ try {
410
+ const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
411
+ applyResult(job, validStatus, error, durationMs);
412
+ // The callback ran unlocked, so this job may have been removed — or
413
+ // removed and re-registered under the same id, the shape
414
+ // `start(initialJobs)` uses — while it was in flight. Identity, not id.
415
+ //
416
+ // Deliberately touch NOTHING here. The claim already detached this job's
417
+ // heap entry and nothing re-added it, so there is nothing to clean up;
418
+ // any entry now filed under this id belongs to the replacement, and
419
+ // removing it by id would silently unschedule a live job. Do not
420
+ // resurrect a removed job's heap entry or run log either.
421
+ if (this.jobs.get(job.id) !== job) {
422
+ return { status, error, summary, durationMs };
423
+ }
424
+ // Log the run
425
+ this.runLog.record({
426
+ jobId: job.id,
427
+ status,
428
+ error,
429
+ summary,
430
+ runAtMs: startMs,
431
+ durationMs,
432
+ nextRunAtMs: job.state.nextRunAtMs,
433
+ });
434
+ // Handle one-shot auto-delete. The callback ran unlocked and may have
435
+ // pushed a heap entry for this job via add()/update(), so drop it — the
436
+ // job is about to stop existing.
437
+ if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
438
+ this.jobs.delete(job.id);
439
+ this.removeFromHeap(job.id);
440
+ this.runLog.removeJob(job.id);
441
+ return { status, summary, deleted: true };
442
+ }
443
+ // Re-insert into the heap if still active. Same reason as above: drop any
444
+ // entry the unlocked callback added for this job first, to preserve
445
+ // one-entry-per-key.
446
+ this.removeFromHeap(job.id);
447
+ if (job.enabled && job.state.nextRunAtMs) {
448
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
449
+ }
450
+ return { status, error, summary, durationMs };
243
451
  }
244
- // Re-insert into heap if still active
245
- if (job.enabled && job.state.nextRunAtMs) {
246
- this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
452
+ finally {
453
+ // One re-arm covering every exit, rather than one per branch. The claim
454
+ // detached this job from the heap, so a timer that fired during the
455
+ // unlocked invoke would have found nothing to arm — and `run()` has no
456
+ // `finally { armTimer() }` of its own the way `onTimer` does. Without
457
+ // this, a manual run() can leave the scheduler with no pending wake.
458
+ this.armTimer();
247
459
  }
248
- return { status, error, summary, durationMs };
249
460
  }
250
461
  // -- Helpers ---------------------------------------------------------
251
462
  removeFromHeap(id) {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.42",
6
+ "version": "0.2.1-alpha.43",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "dist/main.js",
9
9
  "types": "dist/main.d.ts",