@stonyx/cron 0.2.1-alpha.35 → 0.2.1-alpha.36

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,6 +35,14 @@ 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. One warning is logged per stuck run (not per tick), including how long the invocation has been running. `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. Other jobs are unaffected.
41
+ >
42
+ > 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.
43
+ >
44
+ > `interval` is **whole seconds, as a string**, and the value must be *wholly* numeric. `register` throws a `TypeError` on anything else — including **partially** numeric values: `'1h'`, `'30s'` and `'5m'` are rejected outright, **not** read as 1, 30 and 5 seconds. Cron expressions are rejected for the same reason; use `CronService` (`@stonyx/cron/service`) for those, and an empty string is rejected too. The interval is read with `Number()`, so exponent notation and surrounding whitespace resolve at full value (`'1e3'` is 1000 seconds, `' 60 '` is 60). A value that *is* wholly numeric but below `1` (`'0'`, `'-5'`) is interpretable as “as often as possible” and is clamped to `1` second with a warning rather than rejected. Intervals longer than `setTimeout`'s range (~24.9 days) are scheduled in hops rather than overflowing the timer, so they run on schedule.
45
+
38
46
  > `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
39
47
 
40
48
  ## Configuration
package/dist/main.d.ts CHANGED
@@ -3,6 +3,17 @@ 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
+ /**
13
+ * True once a skip has been reported for the *current* invocation. Bounds the
14
+ * still-running warning to one line per stuck run instead of one per tick.
15
+ */
16
+ skipReported?: boolean;
6
17
  }
7
18
  export default class Cron {
8
19
  static instance: Cron | null;
@@ -13,8 +24,55 @@ export default class Cron {
13
24
  init(): Promise<void>;
14
25
  scheduleNextRun(): void;
15
26
  runDueJobs(): Promise<void>;
27
+ /**
28
+ * The one safe way this class invokes a consumer callback.
29
+ *
30
+ * Never blocks the caller, catches synchronous throws and asynchronous
31
+ * rejections alike, and skips the invocation entirely when the job's previous
32
+ * invocation has not settled yet (fire-and-forget would otherwise let a slow
33
+ * job stack invocations on itself).
34
+ */
35
+ safeInvoke(job: CronJob, runOnInit?: boolean): void;
36
+ /**
37
+ * Report a scheduler-level message without ever letting the logger's own
38
+ * failure reach the caller.
39
+ *
40
+ * `@stonyx/logs` convenience methods return a promise and write to disk
41
+ * through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
42
+ * log volume that promise rejects; an unobserved rejection raised from inside
43
+ * the handler that exists to prevent unhandled rejections would re-create
44
+ * exactly the defect this class was fixed for (measured: exit code 1).
45
+ */
46
+ report(level: 'error' | 'warn', message: string): void;
47
+ /** Release a job's in-flight guard. Only ever called for the job it belongs to. */
48
+ release(job: CronJob): void;
16
49
  register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
17
50
  unregister(key: string): void;
51
+ /**
52
+ * Read a job interval (whole seconds, as a string) as a number, WITHOUT
53
+ * applying the floor. Returns `null` when the value is not wholly numeric.
54
+ *
55
+ * `Number()` rather than `parseInt`, deliberately. `parseInt` stops at the
56
+ * first non-numeric character and so fails in the dangerous direction: it
57
+ * reads `'1h'` as 1, `'30s'` as 30 and `'5m'` as 5 — intervals 3600x, 120x and
58
+ * 60x faster than written, scheduled with no error attached to them. A `NaN`
59
+ * check catches a cron expression but not those, and those are the likelier
60
+ * typo: `stonyx-orm` hands `DB_SAVE_INTERVAL` straight through from the
61
+ * environment as a string. `Number()` reads the whole value or none of it, and
62
+ * also gets `'1e3'` (1000, not 1) and `' 60 '` (60) right.
63
+ *
64
+ * An empty or whitespace-only string is rejected rather than read as
65
+ * `Number('')` === 0, so a missing value is a loud error and not a job silently
66
+ * clamped to the floor.
67
+ */
68
+ toSeconds(interval: string): number | null;
69
+ /**
70
+ * Parse a job interval into a positive integer at or above the floor.
71
+ *
72
+ * Returns `null` when the value cannot be parsed at all, so callers can choose
73
+ * between failing fast (`register`) and falling back (`setNextTrigger`).
74
+ */
75
+ parseInterval(interval: string): number | null;
18
76
  setNextTrigger(job: CronJob): void;
19
77
  log(text: string, key?: string | null): void;
20
78
  }
package/dist/main.js CHANGED
@@ -17,6 +17,42 @@ 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
+ /**
30
+ * Ceiling for a single `setTimeout` delay, in milliseconds (2^31 - 1, ~24.9
31
+ * days).
32
+ *
33
+ * Node stores a timer's delay in a 32-bit signed int. A larger value overflows,
34
+ * is truncated to 1 ms and emits a `TimeoutOverflowWarning`, so `scheduleNextRun`
35
+ * would re-arm every millisecond while the job never came due — measured at
36
+ * `'86400000'` (a day expressed in *milliseconds*, the plausible typo): ~790
37
+ * wakeups a second and ~8 GB of stderr a day from a job that never runs.
38
+ *
39
+ * Long intervals are clamped and re-armed rather than rejected, so they work
40
+ * instead of merely failing loudly.
41
+ */
42
+ const TIMEOUT_MAX_MS = 2_147_483_647;
43
+ /**
44
+ * Render an unknown thrown value as log text.
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
+ function describeError(err) {
52
+ if (err instanceof Error)
53
+ return err.stack ?? `${err.name}: ${err.message}`;
54
+ return String(err);
55
+ }
20
56
  export default class Cron {
21
57
  static instance;
22
58
  jobs = {};
@@ -42,7 +78,11 @@ export default class Cron {
42
78
  const nextJob = heap.peek();
43
79
  if (!nextJob)
44
80
  return;
45
- const delay = Math.max(0, nextJob.nextTrigger - getTimestamp()) * 1000;
81
+ // Clamped to `setTimeout`'s range. `runDueJobs` finds nothing due when the
82
+ // clamp fires, breaks out of the drain loop, and re-arms the remainder — so
83
+ // an interval past the ceiling costs one extra wakeup every ~24.9 days
84
+ // instead of one every millisecond, forever.
85
+ const delay = Math.min(Math.max(0, nextJob.nextTrigger - getTimestamp()) * 1000, TIMEOUT_MAX_MS);
46
86
  this.timer = setTimeout(() => this.runDueJobs(), delay);
47
87
  }
48
88
  async runDueJobs() {
@@ -55,18 +95,118 @@ export default class Cron {
55
95
  const job = heap.pop();
56
96
  if (config.debug)
57
97
  this.log('job has been triggered', job.key);
58
- try {
59
- await job.callback();
60
- }
61
- catch (err) {
62
- log.error(`Cron job "${job.key}" failed:`, err);
63
- }
98
+ // Reschedule *before* invoking. The callback's result is not used by this
99
+ // class (`runDueJobs` returns void), so awaiting it bought nothing and
100
+ // cost the scheduler: a callback that never settled left the job absent
101
+ // from the heap and stopped the timer from ever re-arming.
64
102
  this.setNextTrigger(job);
65
103
  heap.push(job);
104
+ this.safeInvoke(job);
66
105
  }
67
106
  this.scheduleNextRun();
68
107
  }
108
+ /**
109
+ * The one safe way this class invokes a consumer callback.
110
+ *
111
+ * Never blocks the caller, catches synchronous throws and asynchronous
112
+ * rejections alike, and skips the invocation entirely when the job's previous
113
+ * invocation has not settled yet (fire-and-forget would otherwise let a slow
114
+ * job stack invocations on itself).
115
+ */
116
+ safeInvoke(job, runOnInit = false) {
117
+ const { key } = job;
118
+ const context = runOnInit ? 'failed on init:' : 'failed:';
119
+ // The in-flight guard lives on the job object, not in a module-level set
120
+ // keyed by string. That is what gives each invocation an identity: the only
121
+ // thing that ever clears the flag is the settle handler of the invocation
122
+ // that set it, and that handler closes over this exact job object. A stale
123
+ // handler therefore cannot release a *later* invocation's guard. It also
124
+ // matches the in-repo idiom one tier up (`job.state.runningAtMs`).
125
+ //
126
+ // `unregister` needs no explicit clear as a result: the flag is dropped with
127
+ // the job object, so a re-registered key gets a fresh object and runs
128
+ // immediately, while the abandoned invocation can only ever release itself.
129
+ if (job.runningAtMs !== undefined) {
130
+ // Bounded: one line per stuck run, not one per tick. A permanently hung
131
+ // job is re-pushed and re-skipped every interval forever, which at the
132
+ // 1s interval this class's own tests use is ~86k log lines a day, per job
133
+ // — a disk-fill and ingest-cost vector on any deployment capturing stdout.
134
+ if (!job.skipReported) {
135
+ job.skipReported = true;
136
+ const runningForSeconds = Math.max(0, Math.round((Date.now() - job.runningAtMs) / 1000));
137
+ this.report('warn', `Cron job ${JSON.stringify(key)} is still running after ${runningForSeconds}s; skipping this `
138
+ + 'tick and any further ticks until it settles (this warning is not repeated for this run)');
139
+ }
140
+ return;
141
+ }
142
+ job.runningAtMs = Date.now();
143
+ job.skipReported = false;
144
+ try {
145
+ const result = job.callback();
146
+ if (result && typeof result.then === 'function') {
147
+ Promise.resolve(result)
148
+ .catch((err) => {
149
+ // Braces matter: returning `report`'s value would put it back into
150
+ // the chain, and `.finally` passes a rejection straight through.
151
+ this.report('error', `Cron job ${JSON.stringify(key)} ${context} ${describeError(err)}`);
152
+ })
153
+ .finally(() => { this.release(job); })
154
+ // Backstop: a throw inside the error handler or the release must not
155
+ // re-create the unhandled rejection this helper exists to prevent.
156
+ .catch(() => { });
157
+ return;
158
+ }
159
+ this.release(job);
160
+ }
161
+ catch (err) {
162
+ this.release(job);
163
+ this.report('error', `Cron job ${JSON.stringify(key)} ${context} ${describeError(err)}`);
164
+ }
165
+ }
166
+ /**
167
+ * Report a scheduler-level message without ever letting the logger's own
168
+ * failure reach the caller.
169
+ *
170
+ * `@stonyx/logs` convenience methods return a promise and write to disk
171
+ * through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
172
+ * log volume that promise rejects; an unobserved rejection raised from inside
173
+ * the handler that exists to prevent unhandled rejections would re-create
174
+ * exactly the defect this class was fixed for (measured: exit code 1).
175
+ */
176
+ report(level, message) {
177
+ try {
178
+ const result = level === 'error' ? log.error(message) : log.warn(message);
179
+ void Promise.resolve(result).catch(() => { });
180
+ }
181
+ catch {
182
+ // Nowhere left to report to; the logger must never stop the scheduler.
183
+ }
184
+ }
185
+ /** Release a job's in-flight guard. Only ever called for the job it belongs to. */
186
+ release(job) {
187
+ job.runningAtMs = undefined;
188
+ job.skipReported = false;
189
+ }
69
190
  register(key, callback, interval, runOnInit = false) {
191
+ const seconds = this.toSeconds(interval);
192
+ // Fail fast rather than clamp. An interval that is not wholly a number is a
193
+ // programming error — a cron expression handed to the legacy class, or a
194
+ // duration with a unit on it (`'1h'`, `'30s'`) — and clamping or truncating
195
+ // it would silently run a job intended for every hour once per second,
196
+ // hammering whatever the callback talks to. Throwing surfaces it at the call
197
+ // site, at boot, before anything is scheduled. A degenerate-but-numeric
198
+ // interval (`'0'`, `'-5'`) is a different case: it is interpretable as "as
199
+ // often as possible" and is clamped to the floor with one warning.
200
+ if (seconds === null) {
201
+ throw new TypeError(`Cron job ${JSON.stringify(key)} has an invalid interval ${JSON.stringify(interval)}: `
202
+ + 'expected a value that is wholly a whole-second count (e.g. \'30\'). Units are not '
203
+ + 'accepted — \'1h\' is rejected, not read as 1. The legacy Cron class does not accept '
204
+ + 'cron expressions — use CronService for those.');
205
+ }
206
+ if (seconds < MIN_INTERVAL_SECONDS) {
207
+ this.report('warn', `Cron job ${JSON.stringify(key)} interval ${JSON.stringify(interval)} is below the `
208
+ + `${MIN_INTERVAL_SECONDS}s floor; clamping to ${MIN_INTERVAL_SECONDS}s`);
209
+ }
70
210
  const job = { callback, interval, key, nextTrigger: 0 };
71
211
  this.jobs[key] = job;
72
212
  this.setNextTrigger(job);
@@ -74,14 +214,8 @@ export default class Cron {
74
214
  if (config.debug) {
75
215
  this.log(`job has been registered with interval: ${interval}`, key);
76
216
  }
77
- if (runOnInit) {
78
- try {
79
- callback();
80
- }
81
- catch (err) {
82
- log.error(`Cron job "${key}" failed on init:`, err);
83
- }
84
- }
217
+ if (runOnInit)
218
+ this.safeInvoke(job, true);
85
219
  this.scheduleNextRun();
86
220
  }
87
221
  unregister(key) {
@@ -95,8 +229,51 @@ export default class Cron {
95
229
  this.log('job has been unregistered', key);
96
230
  this.scheduleNextRun();
97
231
  }
232
+ /**
233
+ * Read a job interval (whole seconds, as a string) as a number, WITHOUT
234
+ * applying the floor. Returns `null` when the value is not wholly numeric.
235
+ *
236
+ * `Number()` rather than `parseInt`, deliberately. `parseInt` stops at the
237
+ * first non-numeric character and so fails in the dangerous direction: it
238
+ * reads `'1h'` as 1, `'30s'` as 30 and `'5m'` as 5 — intervals 3600x, 120x and
239
+ * 60x faster than written, scheduled with no error attached to them. A `NaN`
240
+ * check catches a cron expression but not those, and those are the likelier
241
+ * typo: `stonyx-orm` hands `DB_SAVE_INTERVAL` straight through from the
242
+ * environment as a string. `Number()` reads the whole value or none of it, and
243
+ * also gets `'1e3'` (1000, not 1) and `' 60 '` (60) right.
244
+ *
245
+ * An empty or whitespace-only string is rejected rather than read as
246
+ * `Number('')` === 0, so a missing value is a loud error and not a job silently
247
+ * clamped to the floor.
248
+ */
249
+ toSeconds(interval) {
250
+ const trimmed = String(interval ?? '').trim();
251
+ if (!trimmed)
252
+ return null;
253
+ const seconds = Number(trimmed);
254
+ if (!Number.isFinite(seconds))
255
+ return null;
256
+ // Whole seconds; a fractional value truncates as it always has.
257
+ return Math.trunc(seconds);
258
+ }
259
+ /**
260
+ * Parse a job interval into a positive integer at or above the floor.
261
+ *
262
+ * Returns `null` when the value cannot be parsed at all, so callers can choose
263
+ * between failing fast (`register`) and falling back (`setNextTrigger`).
264
+ */
265
+ parseInterval(interval) {
266
+ const seconds = this.toSeconds(interval);
267
+ if (seconds === null)
268
+ return null;
269
+ return Math.max(MIN_INTERVAL_SECONDS, seconds);
270
+ }
98
271
  setNextTrigger(job) {
99
- job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
272
+ // `register` rejects an unparseable interval up front; this floor is the
273
+ // backstop for a job object mutated after registration (`cron.jobs` is
274
+ // public, mutable state) and is what actually guarantees the drain loop
275
+ // terminates. Never let `nextTrigger` land on `NaN` or on `now`.
276
+ job.nextTrigger = getTimestamp() + (this.parseInterval(job.interval) ?? MIN_INTERVAL_SECONDS);
100
277
  }
101
278
  log(text, key = null) {
102
279
  if (!config.cron?.log)
package/dist/service.d.ts CHANGED
@@ -15,8 +15,7 @@ interface ExecuteResult {
15
15
  summary?: string;
16
16
  durationMs?: number;
17
17
  deleted?: boolean;
18
- /** Only set when `status` is `'skipped'`. */
19
- reason?: 'not due' | 'already running' | 'removed';
18
+ reason?: string;
20
19
  }
21
20
  interface ServiceStatus {
22
21
  started: boolean;
@@ -28,7 +27,6 @@ interface ListOptions {
28
27
  }
29
28
  type OnJobDueCallback = (job: Job) => Promise<JobDueResult | void> | JobDueResult | void;
30
29
  export default class CronService {
31
- #private;
32
30
  jobs: Map<string, Job>;
33
31
  heap: MinHeap<HeapEntry>;
34
32
  timer: ReturnType<typeof setTimeout> | null;
@@ -71,25 +69,6 @@ export default class CronService {
71
69
  remove(id: string): Promise<void>;
72
70
  /**
73
71
  * 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 job
79
- * launched a second concurrent invocation; refusing it is AC4 of #34.
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, not the feature (one hung callback wedged every other caller), so it
87
- * is not being restored here. The fan-out is caller-driven: it is bounded by
88
- * how many times the consumer chooses to call `run()`, exactly like any other
89
- * async API, and the scheduler never produces it on its own. A consumer that
90
- * exposes `run()` over HTTP or a CLI owns that bound the same way it owns
91
- * request concurrency for every other handler. A per-invoke bound inside the
92
- * service is tracked separately (#35).
93
72
  */
94
73
  run(id: string, mode?: 'due' | 'force'): Promise<ExecuteResult>;
95
74
  /**
@@ -99,25 +78,6 @@ export default class CronService {
99
78
  armTimer(): void;
100
79
  onTimer(): Promise<void>;
101
80
  findDueJobs(nowMs: number): Job[];
102
- /**
103
- * Execute a job in three phases:
104
- *
105
- * 1. claim (locked) - take ownership of the job, detach it from the heap
106
- * 2. invoke (UNLOCKED) - await the consumer callback
107
- * 3. settle (locked) - apply the result, log it, re-insert into the heap
108
- *
109
- * The critical section deliberately excludes phase 2. `onJobDue` is
110
- * arbitrary, unbounded consumer code; awaiting it under the module-global
111
- * lock is what wedged every subsequent `locked()` call (add/update/remove)
112
- * when a callback never settled.
113
- *
114
- * `onTimer` performs the batch claim (findDueJobs + markRunning) for all due
115
- * jobs under a single lock, then enters at phase 2 via `#executeClaimed`.
116
- * That entry point is a `#private` method rather than a parameter on this
117
- * one: as a published `alreadyClaimed` boolean it was a supported way for a
118
- * consumer to skip phase 1 entirely, which defeats the claim guard AC4 asks
119
- * for and allows concurrent `onJobDue` invocations for the same job.
120
- */
121
81
  executeJob(job: Job): Promise<ExecuteResult>;
122
82
  removeFromHeap(id: string): void;
123
83
  log(message: string): void;
package/dist/service.js CHANGED
@@ -12,24 +12,6 @@ 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
- }
33
15
  export default class CronService {
34
16
  jobs;
35
17
  heap;
@@ -154,25 +136,6 @@ export default class CronService {
154
136
  }
155
137
  /**
156
138
  * 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 job
162
- * launched a second concurrent invocation; refusing it is AC4 of #34.
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, not the feature (one hung callback wedged every other caller), so it
170
- * is not being restored here. The fan-out is caller-driven: it is bounded by
171
- * how many times the consumer chooses to call `run()`, exactly like any other
172
- * async API, and the scheduler never produces it on its own. A consumer that
173
- * exposes `run()` over HTTP or a CLI owns that bound the same way it owns
174
- * request concurrency for every other handler. A per-invoke bound inside the
175
- * service is tracked separately (#35).
176
139
  */
177
140
  async run(id, mode = 'force') {
178
141
  const job = this.jobs.get(id);
@@ -181,10 +144,6 @@ export default class CronService {
181
144
  if (mode === 'due' && !isDue(job, Date.now())) {
182
145
  return { status: 'skipped', reason: 'not due' };
183
146
  }
184
- // Deliberately NOT wrapped in locked(): executeJob takes the lock itself
185
- // for its claim and settle phases only. Wrapping here would re-create the
186
- // wedge through a second door, since the callback would again be awaited
187
- // while a lock is held.
188
147
  return this.executeJob(job);
189
148
  }
190
149
  /**
@@ -213,51 +172,16 @@ export default class CronService {
213
172
  }
214
173
  this.running = true;
215
174
  try {
216
- // Phase 1 - claim (locked). Collecting due jobs pops them off the heap
217
- // and marking them running makes them un-collectable by anyone else, so
218
- // both must happen under the same lock.
219
- const dueJobs = await locked(() => {
175
+ await locked(async () => {
220
176
  const nowMs = Date.now();
221
- const due = this.findDueJobs(nowMs);
222
- for (const job of due) {
177
+ const dueJobs = this.findDueJobs(nowMs);
178
+ for (const job of dueJobs) {
223
179
  markRunning(job);
224
180
  }
225
- return due;
226
- });
227
- // Phases 2 and 3 run outside the claim lock. The consumer callback is
228
- // awaited here holding no lock at all, so a callback that never settles
229
- // cannot poison the lock chain.
230
- for (const job of dueJobs) {
231
- try {
232
- await this.#executeClaimed(job);
233
- }
234
- catch (err) {
235
- // One job's unexpected throw must not abort the batch. Every job in
236
- // `dueJobs` is already claimed - marked running and detached from the
237
- // heap - and only its own settle releases it, so aborting here would
238
- // strand every sibling permanently un-due.
239
- //
240
- // Reported on an UNGATED channel. `this.log()` returns early when
241
- // `config.cron.log` is false - a supported production setting - and a
242
- // failure here permanently unschedules the job while `status()` keeps
243
- // reporting the service healthy. Silent-and-healthy is the failure
244
- // class the phase split exists to remove, so the one handler that
245
- // survives it must not depend on a log flag. `log.error` is a Stonyx
246
- // system log type, created in the Log constructor rather than by
247
- // `defineType`, so it is always callable and never gated.
248
- //
249
- // This is the outermost handler on the timer path, so it is the one
250
- // that must not be able to throw. `log` is a shared singleton whose
251
- // transports can reach the filesystem, so its own failure is
252
- // swallowed here rather than being allowed to take the batch down.
253
- try {
254
- log.error(`Cron — Job "${job.name}" (${job.id}) execution failed unexpectedly: ${describeError(err)}`);
255
- }
256
- catch {
257
- // Nothing left to report to.
258
- }
181
+ for (const job of dueJobs) {
182
+ await this.executeJob(job);
259
183
  }
260
- }
184
+ });
261
185
  }
262
186
  finally {
263
187
  this.running = false;
@@ -278,203 +202,50 @@ export default class CronService {
278
202
  }
279
203
  return due;
280
204
  }
281
- /**
282
- * Execute a job in three phases:
283
- *
284
- * 1. claim (locked) - take ownership of the job, detach it from the heap
285
- * 2. invoke (UNLOCKED) - await the consumer callback
286
- * 3. settle (locked) - apply the result, log it, re-insert into the heap
287
- *
288
- * The critical section deliberately excludes phase 2. `onJobDue` is
289
- * arbitrary, unbounded consumer code; awaiting it under the module-global
290
- * lock is what wedged every subsequent `locked()` call (add/update/remove)
291
- * when a callback never settled.
292
- *
293
- * `onTimer` performs the batch claim (findDueJobs + markRunning) for all due
294
- * jobs under a single lock, then enters at phase 2 via `#executeClaimed`.
295
- * That entry point is a `#private` method rather than a parameter on this
296
- * one: as a published `alreadyClaimed` boolean it was a supported way for a
297
- * consumer to skip phase 1 entirely, which defeats the claim guard AC4 asks
298
- * for and allows concurrent `onJobDue` invocations for the same job.
299
- */
300
205
  async executeJob(job) {
301
- // -- Phase 1: claim (locked) --
302
- const refusal = await locked(() => this.#claimJob(job));
303
- if (refusal)
304
- return { status: 'skipped', reason: refusal };
305
- return this.#executeClaimed(job);
306
- }
307
- /**
308
- * Phases 2 and 3 for a job that has already been claimed - either by
309
- * `executeJob` above or by `onTimer`'s batch claim.
310
- *
311
- * Private: reaching this without a claim would run the consumer callback for
312
- * a job nobody owns.
313
- */
314
- async #executeClaimed(job) {
315
- // Membership re-check. The claim and the invoke are no longer in the same
316
- // critical section, and sibling callbacks run unlocked, so a `remove()` can
317
- // land in between AND RESOLVE - it used to deadlock. A resolved `remove()`
318
- // must keep meaning "this callback will not fire": #settleJob's identity
319
- // guard only cleans up afterwards, by which point the side effect has
320
- // already happened. Identity, not id, so a removed-then-replaced key is
321
- // caught too. Deliberately synchronous with the `onJobDue` call below -
322
- // nothing can interleave between this check and the invocation.
323
- //
324
- // This is the ONE early return in the phase-2/3 control flow that happens
325
- // after a claim, so it is the one that has to release the claim by hand.
326
- // (`executeJob`'s refusal return at the call site is pre-claim; `onTimer`'s
327
- // re-entrancy return never claims; every return inside `#settleJob` is
328
- // already past phase 3.)
329
- //
330
- // Skipping settle entirely here is right - re-inserting or run-logging a
331
- // removed job is exactly the resurrection `#settleJob`'s identity guard
332
- // refuses, and its heap entry is already gone. But the claim itself must
333
- // still come off, because the detached object is NOT unreachable: it is the
334
- // object `add()` returned and `get()`/`list()` hand out, and
335
- // `start(initialJobs)` re-registers those objects verbatim, `state`
336
- // included. Leaving `runningAtMs` set means a consumer that persists jobs
337
- // rehydrates a permanently dead one - `isDue` false forever, `run()`
338
- // refused forever, `status()` reporting it healthy. That is the
339
- // claim-without-settle hazard the try/finally below exists for, reached
340
- // through a different door.
341
- //
342
- // Assigned directly rather than via `applyResult`: this must release the
343
- // claim and nothing else. No run-log row, no heap entry, no lastStatus, no
344
- // recomputed nextRunAtMs - the job did not run.
345
- if (this.jobs.get(job.id) !== job) {
346
- job.state.runningAtMs = undefined;
347
- return { status: 'skipped', reason: 'removed' };
348
- }
349
206
  const startMs = Date.now();
350
207
  let status = 'ok';
351
208
  let error;
352
209
  let summary;
353
- let settled;
354
- // The claim above marked the job running and detached it from the heap.
355
- // Phase 3 is the ONLY thing that undoes either, so it must survive every
356
- // non-local exit from phase 2 - including a throw from the catch handler
357
- // itself. A claim with no matching settle is not a degraded state, it is a
358
- // permanently dead job: `runningAtMs` set, no heap entry, `isDue` false
359
- // forever and `run()` refused forever.
360
210
  try {
361
- // -- Phase 2: invoke (NOT locked) --
362
- try {
363
- if (this.onJobDue) {
364
- const result = await this.onJobDue(job);
365
- if (result) {
366
- status = result.status || 'ok';
367
- error = result.error;
368
- summary = result.summary;
369
- }
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;
370
217
  }
371
218
  }
372
- catch (err) {
373
- status = 'error';
374
- error = describeError(err);
375
- this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
376
- }
377
219
  }
378
- finally {
379
- // -- Phase 3: settle (locked) --
380
- settled = await locked(() => this.#settleJob(job, status, error, summary, startMs, Date.now() - startMs));
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}`);
381
224
  }
382
- return settled;
383
- }
384
- /**
385
- * Phase 1 - claim. Must be called while holding the lock (`locked()`, whose
386
- * chain is module-global and therefore shared across CronService instances).
387
- *
388
- * `#private`, like `#executeClaimed` and for the same reason. Published, this
389
- * was a supported call performing `markRunning` + `removeFromHeap` with no
390
- * guaranteed settle - the claim-without-settle shape the try/finally in
391
- * `#executeClaimed` exists to prevent, and with no lease on `runningAtMs` a
392
- * single such call permanently strands the job: off the heap, marked running,
393
- * with nothing that will ever release it. The precondition below cannot be
394
- * expressed in the type system, so the method must not be reachable from
395
- * outside the class body.
396
- *
397
- * Returns `null` on a successful claim, or the reason the claim was refused.
398
- * "already running" is what makes a second `run()` report a skip instead of
399
- * launching a concurrent invocation. "removed" covers the job being deleted
400
- * between `run()`'s unlocked lookup and this lock turn - claiming then would
401
- * `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
402
- * belong to a replacement.
403
- *
404
- * Detaching from the heap here (rather than relying on phase 3 to push a
405
- * fresh entry) is what keeps manual runs from permanently duplicating heap
406
- * entries.
407
- */
408
- #claimJob(job) {
409
- if (this.jobs.get(job.id) !== job)
410
- return 'removed';
411
- if (job.state.runningAtMs)
412
- return 'already running';
413
- markRunning(job);
414
- this.removeFromHeap(job.id);
415
- return null;
416
- }
417
- /**
418
- * Phase 3 - settle. Must be called while holding the lock.
419
- *
420
- * `#private` for the same reason as `#claimJob`: unlocked it would run
421
- * `applyResult`, a `runLog.record`, a full `removeFromHeap` rebuild, a
422
- * `heap.push` and an `armTimer` with no mutual exclusion - exactly the
423
- * corruption `locked()` exists to prevent.
424
- */
425
- #settleJob(job, status, error, summary, startMs, durationMs) {
426
- try {
427
- const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
428
- applyResult(job, validStatus, error, durationMs);
429
- // The callback ran unlocked, so this job may have been removed - or
430
- // removed and re-registered under the same id (the shape
431
- // `start(initialJobs)` uses) - while it was in flight. Identity, not id.
432
- //
433
- // Deliberately touch NOTHING here. The claim phase already detached this
434
- // job's own heap entry and nothing re-added it, so there is nothing to
435
- // clean up; any entry now filed under this id belongs to the
436
- // replacement, and removing it by id would silently unschedule a live
437
- // job. Do not resurrect a removed job's heap entry or run log either.
438
- if (this.jobs.get(job.id) !== job) {
439
- return { status, error, summary, durationMs };
440
- }
441
- // Log the run
442
- this.runLog.record({
443
- jobId: job.id,
444
- status,
445
- error,
446
- summary,
447
- runAtMs: startMs,
448
- durationMs,
449
- nextRunAtMs: job.state.nextRunAtMs,
450
- });
451
- // Handle one-shot auto-delete. The callback ran unlocked and may have
452
- // pushed a heap entry for this job via update(), so drop it - the job is
453
- // about to stop existing.
454
- if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
455
- this.jobs.delete(job.id);
456
- this.removeFromHeap(job.id);
457
- this.runLog.removeJob(job.id);
458
- return { status, summary, deleted: true };
459
- }
460
- // Re-insert into heap if still active. The callback ran unlocked, so it
461
- // may itself have added a heap entry for this job (via add/update); drop
462
- // any such entry first to preserve one-entry-per-key.
463
- this.removeFromHeap(job.id);
464
- if (job.enabled && job.state.nextRunAtMs) {
465
- this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
466
- }
467
- return { status, error, summary, durationMs };
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 };
468
243
  }
469
- finally {
470
- // One re-arm covering every exit, rather than one per branch. The claim
471
- // phase detached this job from the heap, so a timer that fired during the
472
- // unlocked invoke would have found an empty heap and armed nothing -
473
- // and `run()` has no `finally { armTimer() }` of its own the way
474
- // `onTimer` does. Without this a manual run() can leave the scheduler
475
- // with no pending wake at all.
476
- this.armTimer();
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 });
477
247
  }
248
+ return { status, error, summary, durationMs };
478
249
  }
479
250
  // -- Helpers ---------------------------------------------------------
480
251
  removeFromHeap(id) {
@@ -495,19 +266,6 @@ export default class CronService {
495
266
  log(message) {
496
267
  if (!config.cron?.log)
497
268
  return;
498
- // `log.cron` is created by `log.defineType`, which runs in `Cron.init()`
499
- // (src/main.ts) - a DIFFERENT class. A consumer wiring CronService directly
500
- // never runs it, while `config/environment.js` defaults `cron.log` to true,
501
- // so an unguarded call throws `log.cron is not a function`. That throw
502
- // escapes executeJob's catch, and the error-reporting path must never be
503
- // the thing that kills the scheduler. `src/types/stonyx.d.ts:19` declares
504
- // `cron()` unconditionally, so the type system will not catch this.
505
- const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
506
- if (typeof log[logMethod] !== 'function')
507
- log.defineType(logMethod, logColor);
508
- const method = log[logMethod];
509
- if (typeof method !== 'function')
510
- return;
511
- method.call(log, `Cron — ${message}`);
269
+ log.cron(`Cron ${message}`);
512
270
  }
513
271
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.35",
6
+ "version": "0.2.1-alpha.36",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "dist/main.js",
9
9
  "types": "dist/main.d.ts",