@stonyx/cron 0.2.1-alpha.12 → 0.2.1-alpha.14

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.
Files changed (2) hide show
  1. package/dist/service.js +77 -16
  2. package/package.json +1 -1
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;
@@ -191,7 +209,26 @@ export default class CronService {
191
209
  // awaited here holding no lock at all, so a callback that never settles
192
210
  // cannot poison the lock chain.
193
211
  for (const job of dueJobs) {
194
- await this.executeJob(job, true);
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
+ }
195
232
  }
196
233
  }
197
234
  finally {
@@ -235,29 +272,40 @@ export default class CronService {
235
272
  if (!claimed)
236
273
  return { status: 'skipped', reason: 'already running' };
237
274
  }
238
- // -- Phase 2: invoke (NOT locked) --
239
275
  const startMs = Date.now();
240
276
  let status = 'ok';
241
277
  let error;
242
278
  let summary;
279
+ let settled;
280
+ // The claim above marked the job running and detached it from the heap.
281
+ // Phase 3 is the ONLY thing that undoes either, so it must survive every
282
+ // non-local exit from phase 2 - including a throw from the catch handler
283
+ // itself. A claim with no matching settle is not a degraded state, it is a
284
+ // permanently dead job: `runningAtMs` set, no heap entry, `isDue` false
285
+ // forever and `run()` refused forever.
243
286
  try {
244
- if (this.onJobDue) {
245
- const result = await this.onJobDue(job);
246
- if (result) {
247
- status = result.status || 'ok';
248
- error = result.error;
249
- summary = result.summary;
287
+ // -- Phase 2: invoke (NOT locked) --
288
+ try {
289
+ if (this.onJobDue) {
290
+ const result = await this.onJobDue(job);
291
+ if (result) {
292
+ status = result.status || 'ok';
293
+ error = result.error;
294
+ summary = result.summary;
295
+ }
250
296
  }
251
297
  }
298
+ catch (err) {
299
+ status = 'error';
300
+ error = describeError(err);
301
+ this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
302
+ }
252
303
  }
253
- catch (err) {
254
- status = 'error';
255
- error = err instanceof Error ? err.message : String(err);
256
- this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
304
+ finally {
305
+ // -- Phase 3: settle (locked) --
306
+ settled = await locked(() => this.settleJob(job, status, error, summary, startMs, Date.now() - startMs));
257
307
  }
258
- const durationMs = Date.now() - startMs;
259
- // -- Phase 3: settle (locked) --
260
- return locked(() => this.settleJob(job, status, error, summary, startMs, durationMs));
308
+ return settled;
261
309
  }
262
310
  /**
263
311
  * Phase 1 - claim. Must be called while holding the lock.
@@ -337,6 +385,19 @@ export default class CronService {
337
385
  log(message) {
338
386
  if (!config.cron?.log)
339
387
  return;
340
- log.cron(`Cron ${message}`);
388
+ // `log.cron` is created by `log.defineType`, which runs in `Cron.init()`
389
+ // (src/main.ts) - a DIFFERENT class. A consumer wiring CronService directly
390
+ // never runs it, while `config/environment.js` defaults `cron.log` to true,
391
+ // so an unguarded call throws `log.cron is not a function`. That throw
392
+ // escapes executeJob's catch, and the error-reporting path must never be
393
+ // the thing that kills the scheduler. `src/types/stonyx.d.ts:19` declares
394
+ // `cron()` unconditionally, so the type system will not catch this.
395
+ const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
396
+ if (typeof log[logMethod] !== 'function')
397
+ log.defineType(logMethod, logColor);
398
+ const method = log[logMethod];
399
+ if (typeof method !== 'function')
400
+ return;
401
+ method.call(log, `Cron — ${message}`);
341
402
  }
342
403
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.12",
6
+ "version": "0.2.1-alpha.14",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "dist/main.js",
9
9
  "types": "dist/main.d.ts",