@stonyx/cron 0.2.1-alpha.15 → 0.2.1-alpha.16

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
- > 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
@@ -3,12 +3,6 @@ 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
6
  }
13
7
  export default class Cron {
14
8
  static instance: Cron | null;
@@ -19,26 +13,8 @@ export default class Cron {
19
13
  init(): Promise<void>;
20
14
  scheduleNextRun(): void;
21
15
  runDueJobs(): Promise<void>;
22
- /**
23
- * The one safe way this class invokes a consumer callback.
24
- *
25
- * Never blocks the caller, catches synchronous throws and asynchronous
26
- * rejections alike, and skips the invocation entirely when the job's previous
27
- * invocation has not settled yet (fire-and-forget would otherwise let a slow
28
- * job stack invocations on itself).
29
- */
30
- safeInvoke(job: CronJob, runOnInit?: boolean): void;
31
- /** Release a job's in-flight guard. Only ever called for the job it belongs to. */
32
- release(job: CronJob): void;
33
16
  register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
34
17
  unregister(key: string): void;
35
- /**
36
- * Parse a job interval (whole seconds, as a string) into a positive integer.
37
- *
38
- * Returns `null` when the value cannot be parsed at all, so callers can choose
39
- * between failing fast (`register`) and falling back (`setNextTrigger`).
40
- */
41
- parseInterval(interval: string): number | null;
42
18
  setNextTrigger(job: CronJob): void;
43
19
  log(text: string, key?: string | null): void;
44
20
  }
package/dist/main.js CHANGED
@@ -17,15 +17,6 @@ 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
20
  export default class Cron {
30
21
  static instance;
31
22
  jobs = {};
@@ -64,80 +55,18 @@ export default class Cron {
64
55
  const job = heap.pop();
65
56
  if (config.debug)
66
57
  this.log('job has been triggered', job.key);
67
- // Reschedule *before* invoking. The callback's result is not used by this
68
- // class (`runDueJobs` returns void), so awaiting it bought nothing and
69
- // cost the scheduler: a callback that never settled left the job absent
70
- // from the heap and stopped the timer from ever re-arming.
58
+ try {
59
+ await job.callback();
60
+ }
61
+ catch (err) {
62
+ log.error(`Cron job "${job.key}" failed:`, err);
63
+ }
71
64
  this.setNextTrigger(job);
72
65
  heap.push(job);
73
- this.safeInvoke(job);
74
66
  }
75
67
  this.scheduleNextRun();
76
68
  }
77
- /**
78
- * The one safe way this class invokes a consumer callback.
79
- *
80
- * Never blocks the caller, catches synchronous throws and asynchronous
81
- * rejections alike, and skips the invocation entirely when the job's previous
82
- * invocation has not settled yet (fire-and-forget would otherwise let a slow
83
- * job stack invocations on itself).
84
- */
85
- safeInvoke(job, runOnInit = false) {
86
- const { key } = job;
87
- const context = runOnInit ? 'failed on init:' : 'failed:';
88
- // The in-flight guard lives on the job object, not in a module-level set
89
- // keyed by string. That is what gives each invocation an identity: the only
90
- // thing that ever clears the flag is the settle handler of the invocation
91
- // that set it, and that handler closes over this exact job object. A stale
92
- // handler therefore cannot release a *later* invocation's guard. It also
93
- // matches the in-repo idiom one tier up (`job.state.runningAtMs`).
94
- //
95
- // `unregister` needs no explicit clear as a result: the flag is dropped with
96
- // the job object, so a re-registered key gets a fresh object and runs
97
- // immediately, while the abandoned invocation can only ever release itself.
98
- if (job.runningAtMs !== undefined) {
99
- log.warn(`Cron job "${key}" is still running; skipping this tick`);
100
- return;
101
- }
102
- job.runningAtMs = Date.now();
103
- try {
104
- const result = job.callback();
105
- if (result && typeof result.then === 'function') {
106
- Promise.resolve(result)
107
- .catch(err => log.error(`Cron job "${key}" ${context}`, err))
108
- .finally(() => this.release(job));
109
- return;
110
- }
111
- this.release(job);
112
- }
113
- catch (err) {
114
- this.release(job);
115
- log.error(`Cron job "${key}" ${context}`, err);
116
- }
117
- }
118
- /** Release a job's in-flight guard. Only ever called for the job it belongs to. */
119
- release(job) {
120
- job.runningAtMs = undefined;
121
- }
122
69
  register(key, callback, interval, runOnInit = false) {
123
- const seconds = this.parseInterval(interval);
124
- // Fail fast rather than clamp. An unparseable interval is a programming
125
- // error with exactly one likely cause — a cron expression handed to the
126
- // legacy class, which takes whole seconds — and clamping it would silently
127
- // run a job intended for every 5 minutes once per second, hammering whatever
128
- // the callback talks to. Throwing surfaces it at the call site, at boot,
129
- // before anything is scheduled. A degenerate-but-parseable interval (`'0'`,
130
- // `'-5'`) is a different case: it is interpretable as "as often as possible"
131
- // and is clamped to the floor with one warning.
132
- if (seconds === null) {
133
- throw new TypeError(`Cron job ${JSON.stringify(key)} has an invalid interval ${JSON.stringify(interval)}: `
134
- + 'expected whole seconds (e.g. \'30\'). The legacy Cron class does not accept cron '
135
- + 'expressions — use CronService for those.');
136
- }
137
- if (parseInt(interval, 10) < MIN_INTERVAL_SECONDS) {
138
- log.warn(`Cron job ${JSON.stringify(key)} interval ${JSON.stringify(interval)} is below the `
139
- + `${MIN_INTERVAL_SECONDS}s floor; clamping to ${MIN_INTERVAL_SECONDS}s`);
140
- }
141
70
  const job = { callback, interval, key, nextTrigger: 0 };
142
71
  this.jobs[key] = job;
143
72
  this.setNextTrigger(job);
@@ -145,8 +74,14 @@ export default class Cron {
145
74
  if (config.debug) {
146
75
  this.log(`job has been registered with interval: ${interval}`, key);
147
76
  }
148
- if (runOnInit)
149
- this.safeInvoke(job, true);
77
+ if (runOnInit) {
78
+ try {
79
+ callback();
80
+ }
81
+ catch (err) {
82
+ log.error(`Cron job "${key}" failed on init:`, err);
83
+ }
84
+ }
150
85
  this.scheduleNextRun();
151
86
  }
152
87
  unregister(key) {
@@ -160,24 +95,8 @@ export default class Cron {
160
95
  this.log('job has been unregistered', key);
161
96
  this.scheduleNextRun();
162
97
  }
163
- /**
164
- * Parse a job interval (whole seconds, as a string) into a positive integer.
165
- *
166
- * Returns `null` when the value cannot be parsed at all, so callers can choose
167
- * between failing fast (`register`) and falling back (`setNextTrigger`).
168
- */
169
- parseInterval(interval) {
170
- const seconds = parseInt(interval, 10);
171
- if (!Number.isFinite(seconds))
172
- return null;
173
- return Math.max(MIN_INTERVAL_SECONDS, seconds);
174
- }
175
98
  setNextTrigger(job) {
176
- // `register` rejects an unparseable interval up front; this floor is the
177
- // backstop for a job object mutated after registration (`cron.jobs` is
178
- // public, mutable state) and is what actually guarantees the drain loop
179
- // terminates. Never let `nextTrigger` land on `NaN` or on `now`.
180
- job.nextTrigger = getTimestamp() + (this.parseInterval(job.interval) ?? MIN_INTERVAL_SECONDS);
99
+ job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
181
100
  }
182
101
  log(text, key = null) {
183
102
  if (!config.cron?.log)
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;
@@ -78,7 +79,41 @@ export default class CronService {
78
79
  armTimer(): void;
79
80
  onTimer(): Promise<void>;
80
81
  findDueJobs(nowMs: number): Job[];
81
- executeJob(job: Job): Promise<ExecuteResult>;
82
+ /**
83
+ * Execute a job in three phases:
84
+ *
85
+ * 1. claim (locked) - take ownership of the job, detach it from the heap
86
+ * 2. invoke (UNLOCKED) - await the consumer callback
87
+ * 3. settle (locked) - apply the result, log it, re-insert into the heap
88
+ *
89
+ * The critical section deliberately excludes phase 2. `onJobDue` is
90
+ * arbitrary, unbounded consumer code; awaiting it under the module-global
91
+ * lock is what wedged every subsequent `locked()` call (add/update/remove)
92
+ * when a callback never settled.
93
+ *
94
+ * `alreadyClaimed` is passed by `onTimer`, which performs the batch claim
95
+ * (findDueJobs + markRunning) for all due jobs under a single lock.
96
+ */
97
+ executeJob(job: Job, alreadyClaimed?: boolean): Promise<ExecuteResult>;
98
+ /**
99
+ * Phase 1 - claim. Must be called while holding the lock.
100
+ *
101
+ * Returns `null` on a successful claim, or the reason the claim was refused.
102
+ * "already running" is what makes a second `run()` report a skip instead of
103
+ * launching a concurrent invocation. "removed" covers the job being deleted
104
+ * between `run()`'s unlocked lookup and this lock turn - claiming then would
105
+ * `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
106
+ * belong to a replacement.
107
+ *
108
+ * Detaching from the heap here (rather than relying on phase 3 to push a
109
+ * fresh entry) is what keeps manual runs from permanently duplicating heap
110
+ * entries.
111
+ */
112
+ claimJob(job: Job): 'already running' | 'removed' | null;
113
+ /**
114
+ * Phase 3 - settle. Must be called while holding the lock.
115
+ */
116
+ settleJob(job: Job, status: string, error: string | undefined, summary: string | undefined, startMs: number, durationMs: number): ExecuteResult;
82
117
  removeFromHeap(id: string): void;
83
118
  log(message: string): void;
84
119
  }
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
- await locked(async () => {
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 dueJobs = this.findDueJobs(nowMs);
178
- for (const job of dueJobs) {
202
+ const due = this.findDueJobs(nowMs);
203
+ for (const job of due) {
179
204
  markRunning(job);
180
205
  }
181
- for (const job of dueJobs) {
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,109 @@ export default class CronService {
202
250
  }
203
251
  return due;
204
252
  }
205
- async executeJob(job) {
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 refusal = await locked(() => this.claimJob(job));
272
+ if (refusal)
273
+ return { status: 'skipped', reason: refusal };
274
+ }
275
+ // Membership re-check. The claim and the invoke are no longer in the same
276
+ // critical section, and sibling callbacks run unlocked, so a `remove()` can
277
+ // land in between AND RESOLVE - it used to deadlock. A resolved `remove()`
278
+ // must keep meaning "this callback will not fire": settleJob's identity
279
+ // guard only cleans up afterwards, by which point the side effect has
280
+ // already happened. Identity, not id, so a removed-then-replaced key is
281
+ // caught too. Deliberately synchronous with the `onJobDue` call below -
282
+ // nothing can interleave between this check and the invocation.
283
+ if (this.jobs.get(job.id) !== job)
284
+ return { status: 'skipped', reason: 'removed' };
206
285
  const startMs = Date.now();
207
286
  let status = 'ok';
208
287
  let error;
209
288
  let summary;
289
+ let settled;
290
+ // The claim above marked the job running and detached it from the heap.
291
+ // Phase 3 is the ONLY thing that undoes either, so it must survive every
292
+ // non-local exit from phase 2 - including a throw from the catch handler
293
+ // itself. A claim with no matching settle is not a degraded state, it is a
294
+ // permanently dead job: `runningAtMs` set, no heap entry, `isDue` false
295
+ // forever and `run()` refused forever.
210
296
  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;
297
+ // -- Phase 2: invoke (NOT locked) --
298
+ try {
299
+ if (this.onJobDue) {
300
+ const result = await this.onJobDue(job);
301
+ if (result) {
302
+ status = result.status || 'ok';
303
+ error = result.error;
304
+ summary = result.summary;
305
+ }
217
306
  }
218
307
  }
308
+ catch (err) {
309
+ status = 'error';
310
+ error = describeError(err);
311
+ this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
312
+ }
219
313
  }
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}`);
314
+ finally {
315
+ // -- Phase 3: settle (locked) --
316
+ settled = await locked(() => this.settleJob(job, status, error, summary, startMs, Date.now() - startMs));
224
317
  }
225
- const durationMs = Date.now() - startMs;
318
+ return settled;
319
+ }
320
+ /**
321
+ * Phase 1 - claim. Must be called while holding the lock.
322
+ *
323
+ * Returns `null` on a successful claim, or the reason the claim was refused.
324
+ * "already running" is what makes a second `run()` report a skip instead of
325
+ * launching a concurrent invocation. "removed" covers the job being deleted
326
+ * between `run()`'s unlocked lookup and this lock turn - claiming then would
327
+ * `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
328
+ * belong to a replacement.
329
+ *
330
+ * Detaching from the heap here (rather than relying on phase 3 to push a
331
+ * fresh entry) is what keeps manual runs from permanently duplicating heap
332
+ * entries.
333
+ */
334
+ claimJob(job) {
335
+ if (this.jobs.get(job.id) !== job)
336
+ return 'removed';
337
+ if (job.state.runningAtMs)
338
+ return 'already running';
339
+ markRunning(job);
340
+ this.removeFromHeap(job.id);
341
+ return null;
342
+ }
343
+ /**
344
+ * Phase 3 - settle. Must be called while holding the lock.
345
+ */
346
+ settleJob(job, status, error, summary, startMs, durationMs) {
226
347
  const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
227
348
  applyResult(job, validStatus, error, durationMs);
349
+ // The callback ran unlocked, so it may have removed this job while it was
350
+ // in flight. Do not resurrect a removed job's heap entry or run log.
351
+ if (this.jobs.get(job.id) !== job) {
352
+ this.removeFromHeap(job.id);
353
+ this.armTimer();
354
+ return { status, error, summary, durationMs };
355
+ }
228
356
  // Log the run
229
357
  this.runLog.record({
230
358
  jobId: job.id,
@@ -238,13 +366,22 @@ export default class CronService {
238
366
  // Handle one-shot auto-delete
239
367
  if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
240
368
  this.jobs.delete(job.id);
369
+ this.removeFromHeap(job.id);
241
370
  this.runLog.removeJob(job.id);
371
+ this.armTimer();
242
372
  return { status, summary, deleted: true };
243
373
  }
244
- // Re-insert into heap if still active
374
+ // Re-insert into heap if still active. The callback ran unlocked, so it
375
+ // may itself have added a heap entry for this job (via add/update); drop
376
+ // any such entry first to preserve one-entry-per-key.
377
+ this.removeFromHeap(job.id);
245
378
  if (job.enabled && job.state.nextRunAtMs) {
246
379
  this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
247
380
  }
381
+ // The claim phase detached this job from the heap, so a timer that fired
382
+ // during the unlocked invoke would have seen it missing. Re-arm here so a
383
+ // manual run() can never leave the scheduler without a pending wake.
384
+ this.armTimer();
248
385
  return { status, error, summary, durationMs };
249
386
  }
250
387
  // -- Helpers ---------------------------------------------------------
@@ -266,6 +403,19 @@ export default class CronService {
266
403
  log(message) {
267
404
  if (!config.cron?.log)
268
405
  return;
269
- log.cron(`Cron ${message}`);
406
+ // `log.cron` is created by `log.defineType`, which runs in `Cron.init()`
407
+ // (src/main.ts) - a DIFFERENT class. A consumer wiring CronService directly
408
+ // never runs it, while `config/environment.js` defaults `cron.log` to true,
409
+ // so an unguarded call throws `log.cron is not a function`. That throw
410
+ // escapes executeJob's catch, and the error-reporting path must never be
411
+ // the thing that kills the scheduler. `src/types/stonyx.d.ts:19` declares
412
+ // `cron()` unconditionally, so the type system will not catch this.
413
+ const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
414
+ if (typeof log[logMethod] !== 'function')
415
+ log.defineType(logMethod, logColor);
416
+ const method = log[logMethod];
417
+ if (typeof method !== 'function')
418
+ return;
419
+ method.call(log, `Cron — ${message}`);
270
420
  }
271
421
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.15",
6
+ "version": "0.2.1-alpha.16",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "dist/main.js",
9
9
  "types": "dist/main.d.ts",