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

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,7 +3,24 @@ interface CronJob extends HeapItem {
3
3
  callback: () => void | Promise<void>;
4
4
  interval: string;
5
5
  key: string;
6
- running: boolean;
6
+ /**
7
+ * Timestamp (ms) at which the current invocation started; `undefined` when the
8
+ * job is idle. Optional so the emitted `CronJob` stays assignable from a job
9
+ * object built by a consumer — `jobs`, `heap` and `setNextTrigger` all expose
10
+ * this interface structurally, so a required field is a breaking type change.
11
+ *
12
+ * A timestamp rather than a boolean, mirroring `job.state.runningAtMs` in the
13
+ * service tier (`markRunning` / `applyResult` / `isDue` in `src/job.ts`), and
14
+ * carrying the one fact a stuck-job warning needs: how long it has been stuck.
15
+ * `CronService.running` is a class-level re-entrancy flag and a different
16
+ * concept; reusing that word here would collide.
17
+ */
18
+ runningAtMs?: number;
19
+ /**
20
+ * True once a skip has been reported for the *current* invocation. Bounds the
21
+ * still-running warning to one line per stuck run instead of one per tick.
22
+ */
23
+ skipReported?: boolean;
7
24
  }
8
25
  export default class Cron {
9
26
  static instance: Cron | null;
@@ -17,14 +34,32 @@ export default class Cron {
17
34
  register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
18
35
  unregister(key: string): void;
19
36
  /**
20
- * Invoke a consumer callback without ever blocking the caller.
37
+ * The one place this class invokes a consumer callback.
38
+ *
39
+ * Never blocks the caller, catches synchronous throws and asynchronous
40
+ * rejections identically, and skips the invocation entirely while the job's
41
+ * previous invocation has not settled (fire-and-forget would otherwise let a
42
+ * slow job stack invocations on itself).
43
+ *
44
+ * Everything that touches the callback — including the thenable probe and the
45
+ * handler attachment — is inside the `try`. A callback may return an object
46
+ * whose `then` is a throwing getter, and reading it outside the guard would
47
+ * abort the drain loop before `scheduleNextRun()`, which is defect #36 again.
48
+ */
49
+ invokeJob(job: CronJob, runOnInit?: boolean): void;
50
+ /**
51
+ * Report a scheduler-level message without ever letting the logger's own
52
+ * failure reach the caller.
21
53
  *
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.
54
+ * `@stonyx/logs` convenience methods return a promise and write to disk
55
+ * through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
56
+ * log volume that promise rejects; an unobserved rejection raised from inside
57
+ * the handler that exists to prevent unhandled rejections would re-create
58
+ * exactly the defect this class was fixed for.
26
59
  */
27
- invokeJob(job: CronJob, errorMessage: string): void;
60
+ report(level: 'error' | 'warn', message: string): void;
61
+ /** Release a job's in-flight guard. Only ever called for the job it belongs to. */
62
+ release(job: CronJob): void;
28
63
  setNextTrigger(job: CronJob): void;
29
64
  log(text: string, key?: string | null): void;
30
65
  }
package/dist/main.js CHANGED
@@ -17,6 +17,30 @@ 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
+ * Render an unknown thrown value as log text.
22
+ *
23
+ * `@stonyx/logs` reads a second argument as `logToFile`, not as a format
24
+ * argument, so `log.error(message, err)` discards the error entirely *and*
25
+ * forces a disk write on every failure. The error has to be interpolated into
26
+ * the message instead — the shape `CronService.executeJob` already uses.
27
+ */
28
+ function describeError(err) {
29
+ if (err instanceof Error)
30
+ return err.stack ?? `${err.name}: ${err.message}`;
31
+ return String(err);
32
+ }
33
+ /**
34
+ * Render a consumer-supplied job key safely for log output.
35
+ *
36
+ * Keys reach the log verbatim, so a key containing a newline can forge a
37
+ * complete, well-formed log line (`'a:\n[FORGED] Cron::admin - all jobs
38
+ * healthy'`). `JSON.stringify` quotes the value and escapes the control
39
+ * characters, which is also how the key is rendered one tier up.
40
+ */
41
+ function describeKey(key) {
42
+ return JSON.stringify(key);
43
+ }
20
44
  export default class Cron {
21
45
  static instance;
22
46
  jobs = {};
@@ -43,38 +67,52 @@ export default class Cron {
43
67
  if (!nextJob)
44
68
  return;
45
69
  const delay = Math.max(0, nextJob.nextTrigger - getTimestamp()) * 1000;
46
- this.timer = setTimeout(() => this.runDueJobs(), delay);
70
+ // Terminal catch: `runDueJobs` is async, so anything that escapes it would
71
+ // otherwise become an unhandled rejection raised from a bare timer callback
72
+ // — the very failure mode this class was fixed for.
73
+ this.timer = setTimeout(() => {
74
+ this.runDueJobs().catch((err) => {
75
+ this.report('error', `Cron scheduler tick failed: ${describeError(err)}`);
76
+ });
77
+ }, delay);
47
78
  }
48
79
  async runDueJobs() {
49
80
  const now = getTimestamp();
50
81
  const { heap } = this;
51
- while (!heap.isEmpty()) {
52
- const next = heap.peek();
53
- if (!next || next.nextTrigger > now)
54
- break;
55
- const job = heap.pop();
56
- if (config.debug)
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.
61
- this.setNextTrigger(job);
62
- heap.push(job);
63
- this.invokeJob(job, `Cron job "${job.key}" failed:`);
82
+ // `finally`, not a trailing statement: `scheduleNextRun()` running on every
83
+ // exit from the drain loop is the invariant this whole fix is about. If
84
+ // anything in the loop body ever throws, the scheduler must still re-arm
85
+ // rather than stopping silently while `timer` still holds a fired handle.
86
+ try {
87
+ while (!heap.isEmpty()) {
88
+ const next = heap.peek();
89
+ if (!next || next.nextTrigger > now)
90
+ break;
91
+ const job = heap.pop();
92
+ if (config.debug)
93
+ this.log('job has been triggered', job.key);
94
+ // Reschedule before invoking: a consumer callback is never awaited here,
95
+ // so a callback that hangs or rejects can no longer starve the drain loop
96
+ // or leave the job orphaned outside the heap.
97
+ this.setNextTrigger(job);
98
+ heap.push(job);
99
+ this.invokeJob(job);
100
+ }
101
+ }
102
+ finally {
103
+ this.scheduleNextRun();
64
104
  }
65
- this.scheduleNextRun();
66
105
  }
67
106
  register(key, callback, interval, runOnInit = false) {
68
- const job = { callback, interval, key, nextTrigger: 0, running: false };
107
+ const job = { callback, interval, key, nextTrigger: 0 };
69
108
  this.jobs[key] = job;
70
109
  this.setNextTrigger(job);
71
110
  this.heap.push(job);
72
111
  if (config.debug) {
73
112
  this.log(`job has been registered with interval: ${interval}`, key);
74
113
  }
75
- if (runOnInit) {
76
- this.invokeJob(job, `Cron job "${key}" failed on init:`);
77
- }
114
+ if (runOnInit)
115
+ this.invokeJob(job, true);
78
116
  this.scheduleNextRun();
79
117
  }
80
118
  unregister(key) {
@@ -89,37 +127,92 @@ export default class Cron {
89
127
  this.scheduleNextRun();
90
128
  }
91
129
  /**
92
- * Invoke a consumer callback without ever blocking the caller.
130
+ * The one place this class invokes a consumer callback.
93
131
  *
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.
132
+ * Never blocks the caller, catches synchronous throws and asynchronous
133
+ * rejections identically, and skips the invocation entirely while the job's
134
+ * previous invocation has not settled (fire-and-forget would otherwise let a
135
+ * slow job stack invocations on itself).
136
+ *
137
+ * Everything that touches the callback — including the thenable probe and the
138
+ * handler attachment — is inside the `try`. A callback may return an object
139
+ * whose `then` is a throwing getter, and reading it outside the guard would
140
+ * abort the drain loop before `scheduleNextRun()`, which is defect #36 again.
98
141
  */
99
- invokeJob(job, errorMessage) {
100
- if (job.running) {
101
- this.log('job is still running, skipping this run', job.key);
142
+ invokeJob(job, runOnInit = false) {
143
+ const { key } = job;
144
+ const context = runOnInit ? 'failed on init:' : 'failed:';
145
+ // The in-flight guard lives on the job object, not in a module-level set
146
+ // keyed by string. Object identity is invocation identity: the only thing
147
+ // that clears the guard is the settle handler of the invocation that set it,
148
+ // and that handler closes over this exact job object, so a stale handler can
149
+ // never release a later invocation's guard.
150
+ if (job.runningAtMs !== undefined) {
151
+ // Bounded to one line per stuck run, not one per tick. A permanently hung
152
+ // job is re-pushed and re-skipped every interval forever; at the 1s
153
+ // interval this class's own tests use that measures 43,200 lines/day per
154
+ // job — a disk-fill and ingest-cost vector whose natural operator response
155
+ // is to silence the only signal that the job is dead.
156
+ if (!job.skipReported) {
157
+ job.skipReported = true;
158
+ const runningForSeconds = Math.max(0, Math.round((Date.now() - job.runningAtMs) / 1000));
159
+ // Ungated, deliberately, matching the sibling `CronService` handler. A
160
+ // skipped run is a *lost* execution, and `runDueJobs`/`register` both
161
+ // return `void`, so this is the legacy class's only wedged-job channel.
162
+ // Routing it through `this.log` would put it behind `config.cron.log`,
163
+ // where a permanently dead job is indistinguishable from a healthy one.
164
+ this.report('warn', `Cron job ${describeKey(key)} is still running after ${runningForSeconds}s; skipping this `
165
+ + 'tick and any further ticks until it settles (this warning is not repeated for this run)');
166
+ }
102
167
  return;
103
168
  }
104
- job.running = true;
105
- const settle = () => { job.running = false; };
106
- let result;
169
+ job.runningAtMs = Date.now();
170
+ job.skipReported = false;
107
171
  try {
108
- result = job.callback();
172
+ const result = job.callback();
173
+ if (result && typeof result.then === 'function') {
174
+ Promise.resolve(result)
175
+ .catch((err) => {
176
+ // Braces matter: returning `report`'s value would put it back into
177
+ // the chain, and `.finally` passes a rejection straight through.
178
+ this.report('error', `Cron job ${describeKey(key)} ${context} ${describeError(err)}`);
179
+ })
180
+ .finally(() => { this.release(job); })
181
+ // Backstop: a throw inside the error handler or the release must not
182
+ // re-create the unhandled rejection this helper exists to prevent.
183
+ .catch(() => { });
184
+ return;
185
+ }
186
+ this.release(job);
109
187
  }
110
188
  catch (err) {
111
- log.error(errorMessage, err);
112
- settle();
113
- return;
189
+ this.release(job);
190
+ this.report('error', `Cron job ${describeKey(key)} ${context} ${describeError(err)}`);
114
191
  }
115
- if (!result || typeof result.then !== 'function') {
116
- settle();
117
- return;
192
+ }
193
+ /**
194
+ * Report a scheduler-level message without ever letting the logger's own
195
+ * failure reach the caller.
196
+ *
197
+ * `@stonyx/logs` convenience methods return a promise and write to disk
198
+ * through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
199
+ * log volume that promise rejects; an unobserved rejection raised from inside
200
+ * the handler that exists to prevent unhandled rejections would re-create
201
+ * exactly the defect this class was fixed for.
202
+ */
203
+ report(level, message) {
204
+ try {
205
+ const result = level === 'error' ? log.error(message) : log.warn(message);
206
+ void Promise.resolve(result).catch(() => { });
118
207
  }
119
- result.then(settle, (err) => {
120
- log.error(errorMessage, err);
121
- settle();
122
- });
208
+ catch {
209
+ // Nowhere left to report to; the logger must never stop the scheduler.
210
+ }
211
+ }
212
+ /** Release a job's in-flight guard. Only ever called for the job it belongs to. */
213
+ release(job) {
214
+ job.runningAtMs = undefined;
215
+ job.skipReported = false;
123
216
  }
124
217
  setNextTrigger(job) {
125
218
  job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
@@ -127,7 +220,10 @@ export default class Cron {
127
220
  log(text, key = null) {
128
221
  if (!config.cron?.log)
129
222
  return;
130
- const tag = key ? `Cron::${key}` : `Cron`;
223
+ // The key is consumer-controlled and reaches the log verbatim. Strip the
224
+ // line terminators so a key cannot forge a second, well-formed log line;
225
+ // the surrounding format is unchanged.
226
+ const tag = key ? `Cron::${key.replace(/[\r\n]+/g, ' ')}` : `Cron`;
131
227
  log.cron(`${tag} - ${text}:`);
132
228
  }
133
229
  }
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.44",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "dist/main.js",
9
9
  "types": "dist/main.d.ts",
@@ -79,7 +79,7 @@
79
79
  "typescript": "^5.8.3"
80
80
  },
81
81
  "dependencies": {
82
- "stonyx": "0.2.3-beta.81"
82
+ "stonyx": "0.2.3-beta.83"
83
83
  },
84
84
  "scripts": {
85
85
  "build": "tsc",