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

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/dist/main.d.ts CHANGED
@@ -3,14 +3,18 @@ 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;
6
12
  }
7
13
  export default class Cron {
8
14
  static instance: Cron | null;
9
15
  jobs: Record<string, CronJob>;
10
16
  heap: MinHeap<CronJob>;
11
17
  timer: ReturnType<typeof setTimeout> | null;
12
- /** Keys whose previous invocation has not settled yet. */
13
- inFlight: Set<string>;
14
18
  constructor();
15
19
  init(): Promise<void>;
16
20
  scheduleNextRun(): void;
@@ -24,8 +28,17 @@ export default class Cron {
24
28
  * job stack invocations on itself).
25
29
  */
26
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;
27
33
  register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
28
34
  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;
29
42
  setNextTrigger(job: CronJob): void;
30
43
  log(text: string, key?: string | null): void;
31
44
  }
package/dist/main.js CHANGED
@@ -17,13 +17,20 @@ 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;
20
29
  export default class Cron {
21
30
  static instance;
22
31
  jobs = {};
23
32
  heap = new MinHeap();
24
33
  timer = null;
25
- /** Keys whose previous invocation has not settled yet. */
26
- inFlight = new Set();
27
34
  constructor() {
28
35
  if (Cron.instance)
29
36
  return Cron.instance;
@@ -78,27 +85,59 @@ export default class Cron {
78
85
  safeInvoke(job, runOnInit = false) {
79
86
  const { key } = job;
80
87
  const context = runOnInit ? 'failed on init:' : 'failed:';
81
- if (this.inFlight.has(key)) {
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) {
82
99
  log.warn(`Cron job "${key}" is still running; skipping this tick`);
83
100
  return;
84
101
  }
85
- this.inFlight.add(key);
102
+ job.runningAtMs = Date.now();
86
103
  try {
87
104
  const result = job.callback();
88
105
  if (result && typeof result.then === 'function') {
89
106
  Promise.resolve(result)
90
107
  .catch(err => log.error(`Cron job "${key}" ${context}`, err))
91
- .finally(() => this.inFlight.delete(key));
108
+ .finally(() => this.release(job));
92
109
  return;
93
110
  }
94
- this.inFlight.delete(key);
111
+ this.release(job);
95
112
  }
96
113
  catch (err) {
97
- this.inFlight.delete(key);
114
+ this.release(job);
98
115
  log.error(`Cron job "${key}" ${context}`, err);
99
116
  }
100
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
+ }
101
122
  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
+ }
102
141
  const job = { callback, interval, key, nextTrigger: 0 };
103
142
  this.jobs[key] = job;
104
143
  this.setNextTrigger(job);
@@ -117,13 +156,28 @@ export default class Cron {
117
156
  return;
118
157
  delete jobs[key];
119
158
  heap.remove(job);
120
- this.inFlight.delete(key);
121
159
  if (config.debug)
122
160
  this.log('job has been unregistered', key);
123
161
  this.scheduleNextRun();
124
162
  }
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
+ }
125
175
  setNextTrigger(job) {
126
- job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
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);
127
181
  }
128
182
  log(text, key = null) {
129
183
  if (!config.cron?.log)
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.13",
6
+ "version": "0.2.1-alpha.15",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "dist/main.js",
9
9
  "types": "dist/main.d.ts",