@stonyx/cron 0.2.1-alpha.5 → 0.2.1-alpha.50

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
@@ -10,6 +10,7 @@ export default class Cron {
10
10
  heap: MinHeap<CronJob>;
11
11
  timer: ReturnType<typeof setTimeout> | null;
12
12
  constructor();
13
+ init(): Promise<void>;
13
14
  scheduleNextRun(): void;
14
15
  runDueJobs(): Promise<void>;
15
16
  register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
package/dist/main.js CHANGED
@@ -27,6 +27,12 @@ export default class Cron {
27
27
  return Cron.instance;
28
28
  Cron.instance = this;
29
29
  }
30
+ async init() {
31
+ // Self-register so log.cron works even when @stonyx/cron is in the
32
+ // consumer's `dependencies` (stonyx loader only merges devDependencies).
33
+ const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
34
+ log.defineType(logMethod, logColor);
35
+ }
30
36
  scheduleNextRun() {
31
37
  if (this.timer)
32
38
  clearTimeout(this.timer);
package/dist/service.d.ts CHANGED
@@ -4,29 +4,37 @@ import RunLog from './run-log.js';
4
4
  interface HeapEntry extends HeapItem {
5
5
  key: string;
6
6
  }
7
- interface JobDueResult {
7
+ /**
8
+ * Why a `run()` did not invoke the callback. Exported so a consumer can write a
9
+ * total handler over it: the union is closed and narrowed (it was `string`
10
+ * before #34), so an exhaustive `switch` is now both possible and expected.
11
+ */
12
+ export type SkipReason = 'not due' | 'already running' | 'removed';
13
+ export interface JobDueResult {
8
14
  status?: string;
9
15
  error?: string;
10
16
  summary?: string;
11
17
  }
12
- interface ExecuteResult {
18
+ export interface ExecuteResult {
13
19
  status: string;
14
20
  error?: string;
15
21
  summary?: string;
16
22
  durationMs?: number;
17
23
  deleted?: boolean;
18
- reason?: string;
24
+ /** Only set when `status` is `'skipped'`. */
25
+ reason?: SkipReason;
19
26
  }
20
- interface ServiceStatus {
27
+ export interface ServiceStatus {
21
28
  started: boolean;
22
29
  jobCount: number;
23
30
  nextWakeAtMs: number | undefined;
24
31
  }
25
- interface ListOptions {
32
+ export interface ListOptions {
26
33
  includeDisabled?: boolean;
27
34
  }
28
- type OnJobDueCallback = (job: Job) => Promise<JobDueResult | void> | JobDueResult | void;
35
+ export type OnJobDueCallback = (job: Job) => Promise<JobDueResult | void> | JobDueResult | void;
29
36
  export default class CronService {
37
+ #private;
30
38
  jobs: Map<string, Job>;
31
39
  heap: MinHeap<HeapEntry>;
32
40
  timer: ReturnType<typeof setTimeout> | null;
@@ -69,6 +77,21 @@ export default class CronService {
69
77
  remove(id: string): Promise<void>;
70
78
  /**
71
79
  * Manually trigger a job.
80
+ *
81
+ * Returns `{ status: 'skipped', reason }` without invoking the callback when
82
+ * the job is not due (`mode: 'due'`), is already in flight
83
+ * (`'already running'`), or was removed before the claim landed
84
+ * (`'removed'`). Before the phase split, a forced run against an in-flight
85
+ * job launched a second concurrent invocation.
86
+ *
87
+ * CONCURRENCY: the same job is bounded to one in-flight invocation on every
88
+ * path, and the timer path invokes due jobs one at a time. `run()` fan-out
89
+ * across DIFFERENT jobs is deliberately unbounded — N concurrent `run()`
90
+ * calls produce N concurrent consumer callbacks. Before the phase split
91
+ * these serialized behind the module-global lock; that serialization was the
92
+ * bug rather than the feature (one hung callback wedged every other caller),
93
+ * so it is not restored here. The fan-out is caller-driven and the scheduler
94
+ * never produces it on its own.
72
95
  */
73
96
  run(id: string, mode?: 'due' | 'force'): Promise<ExecuteResult>;
74
97
  /**
@@ -78,6 +101,25 @@ export default class CronService {
78
101
  armTimer(): void;
79
102
  onTimer(): Promise<void>;
80
103
  findDueJobs(nowMs: number): Job[];
104
+ /**
105
+ * Execute a job in three phases:
106
+ *
107
+ * 1. claim (locked) — take ownership of the job, detach it from the heap
108
+ * 2. invoke (UNLOCKED) — await the consumer callback
109
+ * 3. settle (locked) — apply the result, log it, re-insert into the heap
110
+ *
111
+ * The critical section deliberately excludes phase 2. `onJobDue` is
112
+ * arbitrary, unbounded consumer code; awaiting it under the module-global
113
+ * lock is what wedged every subsequent `locked()` call (add/update/remove)
114
+ * when a callback never settled.
115
+ *
116
+ * `onTimer` performs the batch claim (`findDueJobs` + `markRunning`) for all
117
+ * due jobs under a single lock and then enters at phase 2 via
118
+ * `#executeClaimed`. That entry point is `#private` rather than a parameter
119
+ * on this method: as a published `alreadyClaimed` flag it would be a
120
+ * supported way to skip phase 1 entirely, defeating the claim guard and
121
+ * allowing concurrent `onJobDue` invocations for the same job.
122
+ */
81
123
  executeJob(job: Job): Promise<ExecuteResult>;
82
124
  removeFromHeap(id: string): void;
83
125
  log(message: string): void;
package/dist/service.js CHANGED
@@ -2,7 +2,21 @@
2
2
  * CronService - the main API for advanced job scheduling.
3
3
  *
4
4
  * Manages jobs in memory with a min-heap for efficient next-job lookup.
5
- * All state mutations are serialized via async locking.
5
+ *
6
+ * Async locking, but never around the consumer callback. Execution is split
7
+ * into three phases: claim (locked), invoke (UNLOCKED), settle (locked). Only
8
+ * phases 1 and 3 are serialized; the critical section deliberately excludes
9
+ * phase 2, so a consumer callback that never settles cannot wedge the lock
10
+ * chain and block add/update/remove. See `run()` and `#executeClaimed`.
11
+ *
12
+ * CONSUMER NOTE — this class is NOMINALLY typed. It carries ECMAScript hard-
13
+ * private members, so `dist/service.d.ts` emits `#private;` on the class and a
14
+ * structurally-built test double will not assign to `CronService`
15
+ * (`TS2741: Property '#private' is missing`). The break is one-directional and
16
+ * has a zero-cost workaround: `extends CronService` still compiles, and
17
+ * assigning a real `CronService` to your own hand-written interface still
18
+ * compiles. Declare your own interface and depend on that rather than
19
+ * hand-building a `CronService`-typed mock. See README's `CronService` section.
6
20
  */
7
21
  import config from 'stonyx/config';
8
22
  import log from 'stonyx/log';
@@ -12,6 +26,54 @@ import { locked } from './locked.js';
12
26
  import { normalizeJobInput, recoverFlatParams } from './normalize.js';
13
27
  import RunLog from './run-log.js';
14
28
  const MAX_TIMER_DELAY_MS = 60_000;
29
+ /** Longest error text that may reach a log line. Anything past this is truncated. */
30
+ const MAX_LOGGED_ERROR_LENGTH = 512;
31
+ /** Longest job name that may reach a log line. Anything past this is truncated. */
32
+ const MAX_LOGGED_NAME_LENGTH = 120;
33
+ /**
34
+ * Flatten a value for interpolation into a single log line.
35
+ *
36
+ * Chronicle writes `${timestamp} ${content}\n` to a newline-delimited file, so
37
+ * any `\r` or `\n` inside `content` ends the record early and everything after
38
+ * it is read back as a separate, attacker-shaped entry — including a forged
39
+ * `[timestamp] Cron — ...` prefix that is indistinguishable from a real one.
40
+ * Both values that reach these lines are untrusted: `job.name` is passed
41
+ * through `createJob` unvalidated and `normalize.ts` exists specifically to
42
+ * accept AI-shaped input, and an error message is arbitrary consumer-callback
43
+ * text. Newlines become the literal two characters so the content survives for
44
+ * a reader, and the length cap keeps one pathological value from swamping the
45
+ * file.
46
+ */
47
+ function forLog(value, maxLength) {
48
+ const flattened = value.replace(/\r\n|[\r\n\u2028\u2029]/g, '\\n');
49
+ return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}...` : flattened;
50
+ }
51
+ /**
52
+ * Describe a thrown value without ever throwing.
53
+ *
54
+ * `String(err)` is not total: a null-prototype object, or any object whose
55
+ * `toString`/`Symbol.toPrimitive` throws, raises "Cannot convert object to
56
+ * primitive value". Consumer callbacks throw arbitrary values, so the error
57
+ * handler must not become a second failure source of its own.
58
+ *
59
+ * The `instanceof Error` branch needs the same guard as the other one. `Error`
60
+ * is subclassable and `message` is a plain writable property, so a consumer can
61
+ * hand back an `Error` whose `message` is a getter that throws, or one that is
62
+ * an object whose `toString` throws — `Error.prototype.message` is typed
63
+ * `string`, so TypeScript sees nothing wrong and the coercion is deferred to
64
+ * the caller's template literal, outside every guard here. That made `run()`
65
+ * reject instead of returning an `ExecuteResult`: a contract violation in the
66
+ * function written to prevent exactly that. Reading and coercing `message`
67
+ * inside the `try` is what closes it.
68
+ */
69
+ function describeError(err) {
70
+ try {
71
+ return err instanceof Error ? String(err.message) : String(err);
72
+ }
73
+ catch {
74
+ return 'unknown error';
75
+ }
76
+ }
15
77
  export default class CronService {
16
78
  jobs;
17
79
  heap;
@@ -40,6 +102,23 @@ export default class CronService {
40
102
  this.started = true;
41
103
  if (initialJobs) {
42
104
  for (const job of initialJobs) {
105
+ // A `runningAtMs` on a rehydrated job is always stale. The claim it
106
+ // records was taken by a process that is gone, so nothing will ever
107
+ // settle it, and nothing reaps it — there is no lease on the field
108
+ // (tracked on #35). Left in place it is a permanently dead job that
109
+ // still reports healthy: `isDue` returns false forever because of the
110
+ // flag, `run()` answers `'already running'` forever, `update()` never
111
+ // touches `state.runningAtMs`, and `status()` counts it like any other.
112
+ // The consumer's only recovery would be remove() + add(), losing the
113
+ // job id and its run history.
114
+ //
115
+ // Same hazard, same treatment as the hand-release on the `'removed'`
116
+ // path in `#executeClaimed`: a claim with no reachable settle must be
117
+ // released. Assigned directly rather than via `applyResult` for the same
118
+ // reason — this releases the claim and nothing else. The job did not
119
+ // run, so it gets no run-log row, no `lastStatus`, and no recomputed
120
+ // `nextRunAtMs`; it is rescheduled from the store's own value below.
121
+ job.state.runningAtMs = undefined;
43
122
  this.jobs.set(job.id, job);
44
123
  if (job.enabled && job.state.nextRunAtMs) {
45
124
  this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
@@ -136,6 +215,21 @@ export default class CronService {
136
215
  }
137
216
  /**
138
217
  * Manually trigger a job.
218
+ *
219
+ * Returns `{ status: 'skipped', reason }` without invoking the callback when
220
+ * the job is not due (`mode: 'due'`), is already in flight
221
+ * (`'already running'`), or was removed before the claim landed
222
+ * (`'removed'`). Before the phase split, a forced run against an in-flight
223
+ * job launched a second concurrent invocation.
224
+ *
225
+ * CONCURRENCY: the same job is bounded to one in-flight invocation on every
226
+ * path, and the timer path invokes due jobs one at a time. `run()` fan-out
227
+ * across DIFFERENT jobs is deliberately unbounded — N concurrent `run()`
228
+ * calls produce N concurrent consumer callbacks. Before the phase split
229
+ * these serialized behind the module-global lock; that serialization was the
230
+ * bug rather than the feature (one hung callback wedged every other caller),
231
+ * so it is not restored here. The fan-out is caller-driven and the scheduler
232
+ * never produces it on its own.
139
233
  */
140
234
  async run(id, mode = 'force') {
141
235
  const job = this.jobs.get(id);
@@ -144,6 +238,10 @@ export default class CronService {
144
238
  if (mode === 'due' && !isDue(job, Date.now())) {
145
239
  return { status: 'skipped', reason: 'not due' };
146
240
  }
241
+ // Deliberately NOT wrapped in locked(): executeJob takes the lock itself,
242
+ // for its claim and settle phases only. Wrapping here would re-create the
243
+ // wedge through a second door, because the consumer callback would once
244
+ // again be awaited while a lock is held.
147
245
  return this.executeJob(job);
148
246
  }
149
247
  /**
@@ -172,16 +270,77 @@ export default class CronService {
172
270
  }
173
271
  this.running = true;
174
272
  try {
175
- await locked(async () => {
273
+ // -- Phase 1: claim (locked), batched --
274
+ // Collecting due jobs pops them off the heap, and marking them running
275
+ // makes them un-claimable by anyone else. Both must happen under the
276
+ // same lock turn, or a concurrent run() could claim a job this batch has
277
+ // already detached.
278
+ //
279
+ // This is the SECOND claim implementation — `#claimJob` is the other, and
280
+ // the two reach the same state by different routes. `#claimJob` guards
281
+ // with an explicit `job.state.runningAtMs` check; this path has no such
282
+ // check and relies entirely on `isDue`'s `!job.state.runningAtMs` clause
283
+ // (`job.ts`) to keep `findDueJobs` from re-claiming a job that `run()`
284
+ // already holds. THAT CLAUSE IS LOAD-BEARING HERE, not an optimisation:
285
+ // drop it and the timer path silently double-invokes a job that `run()`
286
+ // is mid-flight on, while `run()` keeps refusing correctly and looks
287
+ // healthy. The one-in-flight-invocation-per-job invariant this class
288
+ // advertises holds by two independent guards in two files; a change to
289
+ // either has to be checked against the other.
290
+ const dueJobs = await locked(() => {
176
291
  const nowMs = Date.now();
177
- const dueJobs = this.findDueJobs(nowMs);
178
- for (const job of dueJobs) {
292
+ const due = this.findDueJobs(nowMs);
293
+ for (const job of due) {
179
294
  markRunning(job);
180
295
  }
181
- for (const job of dueJobs) {
182
- await this.executeJob(job);
183
- }
296
+ return due;
184
297
  });
298
+ // Phases 2 and 3 run OUTSIDE the claim lock. The consumer callback is
299
+ // awaited here holding no lock at all, so a callback that never settles
300
+ // cannot poison the lock chain and wedge add/update/remove.
301
+ for (const job of dueJobs) {
302
+ try {
303
+ await this.#executeClaimed(job);
304
+ }
305
+ catch (err) {
306
+ // One job's unexpected throw must not abort the batch. Every job in
307
+ // `dueJobs` is already claimed — marked running and detached from
308
+ // the heap — and only its own settle releases it, so aborting here
309
+ // would strand every sibling permanently un-due.
310
+ //
311
+ // Reported on an UNGATED channel. `this.log()` returns early when
312
+ // `config.cron.log` is false, which is a supported production
313
+ // setting, and a failure here permanently unschedules a job while
314
+ // `status()` keeps reporting the service healthy. Silent-and-healthy
315
+ // is exactly the failure class this split exists to remove.
316
+ //
317
+ // This is the outermost handler on the timer path, so it is the one
318
+ // that must not be able to throw: `log` is a shared singleton whose
319
+ // transports can reach the filesystem, so its own failure is
320
+ // swallowed rather than allowed to take the batch down.
321
+ //
322
+ // BOTH halves of that failure have to be caught, and they are caught
323
+ // by different constructs. `log.error` is a chronicle convenience
324
+ // method that returns `logAction(...)` -> `async log(...)`, so its
325
+ // console write, colour lookup, `mkdirSync` and `appendFile` all
326
+ // surface as REJECTIONS, never as synchronous throws. A bare call
327
+ // here escapes this `catch` entirely and terminates the process under
328
+ // Node's default `--unhandled-rejections=throw` — the handler written
329
+ // so it "must not be able to throw" would be the one taking the
330
+ // daemon down. The `try` covers the synchronous half (evaluating the
331
+ // template literal); `Promise.resolve(...).catch()` covers the async
332
+ // half. Deliberately not awaited: the batch must not block on a log
333
+ // transport, and `void` marks the floated promise as intentional.
334
+ try {
335
+ void Promise.resolve(log.error(`Cron — Job "${forLog(job.name, MAX_LOGGED_NAME_LENGTH)}" (${job.id}) execution failed unexpectedly: ${forLog(describeError(err), MAX_LOGGED_ERROR_LENGTH)}`)).catch(() => {
336
+ // Nothing left to report to.
337
+ });
338
+ }
339
+ catch {
340
+ // Nothing left to report to.
341
+ }
342
+ }
343
+ }
185
344
  }
186
345
  finally {
187
346
  this.running = false;
@@ -202,50 +361,190 @@ export default class CronService {
202
361
  }
203
362
  return due;
204
363
  }
364
+ /**
365
+ * Execute a job in three phases:
366
+ *
367
+ * 1. claim (locked) — take ownership of the job, detach it from the heap
368
+ * 2. invoke (UNLOCKED) — await the consumer callback
369
+ * 3. settle (locked) — apply the result, log it, re-insert into the heap
370
+ *
371
+ * The critical section deliberately excludes phase 2. `onJobDue` is
372
+ * arbitrary, unbounded consumer code; awaiting it under the module-global
373
+ * lock is what wedged every subsequent `locked()` call (add/update/remove)
374
+ * when a callback never settled.
375
+ *
376
+ * `onTimer` performs the batch claim (`findDueJobs` + `markRunning`) for all
377
+ * due jobs under a single lock and then enters at phase 2 via
378
+ * `#executeClaimed`. That entry point is `#private` rather than a parameter
379
+ * on this method: as a published `alreadyClaimed` flag it would be a
380
+ * supported way to skip phase 1 entirely, defeating the claim guard and
381
+ * allowing concurrent `onJobDue` invocations for the same job.
382
+ */
205
383
  async executeJob(job) {
384
+ // -- Phase 1: claim (locked) --
385
+ const refusal = await locked(() => this.#claimJob(job));
386
+ if (refusal)
387
+ return { status: 'skipped', reason: refusal };
388
+ return this.#executeClaimed(job);
389
+ }
390
+ /**
391
+ * Phases 2 and 3 for a job that has already been claimed — either by
392
+ * `executeJob` above or by `onTimer`'s batch claim.
393
+ *
394
+ * Private: reaching this without a claim would run the consumer callback for
395
+ * a job nobody owns, and would leave nothing to release the claim.
396
+ */
397
+ async #executeClaimed(job) {
398
+ // Membership re-check. The claim and the invoke are no longer in the same
399
+ // critical section, and sibling callbacks run unlocked, so a `remove()` can
400
+ // now land in between AND RESOLVE — it used to deadlock. A resolved
401
+ // `remove()` must keep meaning "this callback will not fire"; the identity
402
+ // guard in `#settleJob` only cleans up afterwards, by which point the side
403
+ // effect has already happened. Identity, not id, so a removed-then-replaced
404
+ // key is caught too. Deliberately synchronous with the `onJobDue` call
405
+ // below — nothing can interleave between this check and the invocation.
406
+ //
407
+ // This is the one early return after a claim, so it is the one that has to
408
+ // release the claim by hand. Skipping settle is right — re-inserting or
409
+ // run-logging a removed job is the resurrection `#settleJob` refuses, and
410
+ // the heap entry is already gone. But the claim must still come off,
411
+ // because the detached object is NOT unreachable: it is the object `add()`
412
+ // returned and `get()`/`list()` hand out, and `start(initialJobs)`
413
+ // re-registers those objects verbatim, `state` included. A leftover
414
+ // `runningAtMs` rehydrates a permanently dead job — `isDue` false forever,
415
+ // `run()` refused forever, `status()` reporting it healthy.
416
+ //
417
+ // Assigned directly rather than via `applyResult`: this releases the claim
418
+ // and nothing else. No run-log row, no heap entry, no `lastStatus`, no
419
+ // recomputed `nextRunAtMs` — the job did not run.
420
+ if (this.jobs.get(job.id) !== job) {
421
+ job.state.runningAtMs = undefined;
422
+ return { status: 'skipped', reason: 'removed' };
423
+ }
206
424
  const startMs = Date.now();
207
425
  let status = 'ok';
208
426
  let error;
209
427
  let summary;
428
+ let settled;
429
+ // The claim marked the job running and detached it from the heap. Phase 3
430
+ // is the ONLY thing that undoes either, so it must survive every non-local
431
+ // exit from phase 2 — including a throw from the catch handler itself
432
+ // (`this.log` is public and overridable and reaches a transport). A claim
433
+ // with no matching settle is not a degraded state, it is a permanently
434
+ // dead job.
210
435
  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;
436
+ // -- Phase 2: invoke (NOT locked) --
437
+ try {
438
+ if (this.onJobDue) {
439
+ const result = await this.onJobDue(job);
440
+ if (result) {
441
+ status = result.status || 'ok';
442
+ error = result.error;
443
+ summary = result.summary;
444
+ }
217
445
  }
218
446
  }
447
+ catch (err) {
448
+ status = 'error';
449
+ error = describeError(err);
450
+ this.log(`Job "${forLog(job.name, MAX_LOGGED_NAME_LENGTH)}" (${job.id}) failed: ${forLog(error, MAX_LOGGED_ERROR_LENGTH)}`);
451
+ }
219
452
  }
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}`);
453
+ finally {
454
+ // -- Phase 3: settle (locked) --
455
+ settled = await locked(() => this.#settleJob(job, status, error, summary, startMs, Date.now() - startMs));
224
456
  }
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 };
457
+ return settled;
458
+ }
459
+ /**
460
+ * Phase 1 — claim. Must be called while holding the lock (`locked()`, whose
461
+ * chain is module-global and therefore shared across CronService instances).
462
+ *
463
+ * Returns `null` on a successful claim, or the reason the claim was refused.
464
+ * `'already running'` is what makes a second `run()` report a skip instead of
465
+ * launching a concurrent invocation. `'removed'` covers the job being deleted
466
+ * between `run()`'s unlocked lookup and this lock turn — claiming then would
467
+ * `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
468
+ * belong to a replacement.
469
+ *
470
+ * Detaching from the heap here — rather than relying on phase 3 to push a
471
+ * fresh entry — is what stops manual runs permanently duplicating entries.
472
+ *
473
+ * `#private`: published, this would be a supported call performing
474
+ * `markRunning` + `removeFromHeap` with no guaranteed settle and no lease on
475
+ * `runningAtMs`, so a single such call would strand the job forever. The
476
+ * lock-held precondition cannot be expressed in the type system, so the
477
+ * method must not be reachable from outside the class body.
478
+ */
479
+ #claimJob(job) {
480
+ if (this.jobs.get(job.id) !== job)
481
+ return 'removed';
482
+ if (job.state.runningAtMs)
483
+ return 'already running';
484
+ markRunning(job);
485
+ this.removeFromHeap(job.id);
486
+ return null;
487
+ }
488
+ /**
489
+ * Phase 3 — settle. Must be called while holding the lock.
490
+ *
491
+ * `#private` for the same reason as `#claimJob`: unlocked it would run
492
+ * `applyResult`, a `runLog.record`, a full `removeFromHeap` rebuild, a
493
+ * `heap.push` and an `armTimer` with no mutual exclusion — exactly the
494
+ * corruption `locked()` exists to prevent.
495
+ */
496
+ #settleJob(job, status, error, summary, startMs, durationMs) {
497
+ try {
498
+ const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
499
+ applyResult(job, validStatus, error, durationMs);
500
+ // The callback ran unlocked, so this job may have been removed — or
501
+ // removed and re-registered under the same id, the shape
502
+ // `start(initialJobs)` uses — while it was in flight. Identity, not id.
503
+ //
504
+ // Deliberately touch NOTHING here. The claim already detached this job's
505
+ // heap entry and nothing re-added it, so there is nothing to clean up;
506
+ // any entry now filed under this id belongs to the replacement, and
507
+ // removing it by id would silently unschedule a live job. Do not
508
+ // resurrect a removed job's heap entry or run log either.
509
+ if (this.jobs.get(job.id) !== job) {
510
+ return { status, error, summary, durationMs };
511
+ }
512
+ // Log the run
513
+ this.runLog.record({
514
+ jobId: job.id,
515
+ status,
516
+ error,
517
+ summary,
518
+ runAtMs: startMs,
519
+ durationMs,
520
+ nextRunAtMs: job.state.nextRunAtMs,
521
+ });
522
+ // Handle one-shot auto-delete. The callback ran unlocked and may have
523
+ // pushed a heap entry for this job via add()/update(), so drop it — the
524
+ // job is about to stop existing.
525
+ if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
526
+ this.jobs.delete(job.id);
527
+ this.removeFromHeap(job.id);
528
+ this.runLog.removeJob(job.id);
529
+ return { status, summary, deleted: true };
530
+ }
531
+ // Re-insert into the heap if still active. Same reason as above: drop any
532
+ // entry the unlocked callback added for this job first, to preserve
533
+ // one-entry-per-key.
534
+ this.removeFromHeap(job.id);
535
+ if (job.enabled && job.state.nextRunAtMs) {
536
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
537
+ }
538
+ return { status, error, summary, durationMs };
243
539
  }
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 });
540
+ finally {
541
+ // One re-arm covering every exit, rather than one per branch. The claim
542
+ // detached this job from the heap, so a timer that fired during the
543
+ // unlocked invoke would have found nothing to arm — and `run()` has no
544
+ // `finally { armTimer() }` of its own the way `onTimer` does. Without
545
+ // this, a manual run() can leave the scheduler with no pending wake.
546
+ this.armTimer();
247
547
  }
248
- return { status, error, summary, durationMs };
249
548
  }
250
549
  // -- Helpers ---------------------------------------------------------
251
550
  removeFromHeap(id) {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.5",
6
+ "version": "0.2.1-alpha.50",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "dist/main.js",
9
9
  "types": "dist/main.d.ts",
@@ -69,18 +69,21 @@
69
69
  },
70
70
  "homepage": "https://github.com/abofs/stonyx-cron#readme",
71
71
  "devDependencies": {
72
- "@stonyx/utils": "0.2.3-beta.7",
72
+ "@stonyx/utils": "0.2.3-beta.26",
73
73
  "@types/node": "^25.5.2",
74
+ "@types/qunit": "^2.19.13",
75
+ "@types/sinon": "^21.0.1",
74
76
  "qunit": "^2.24.1",
75
77
  "sinon": "^21.0.0",
78
+ "tsx": "^4.21.0",
76
79
  "typescript": "^5.8.3"
77
80
  },
78
81
  "dependencies": {
79
- "stonyx": "0.2.3-beta.12"
82
+ "stonyx": "0.2.3-beta.81"
80
83
  },
81
84
  "scripts": {
82
85
  "build": "tsc",
83
86
  "build:test": "tsc -p tsconfig.test.json",
84
- "test": "pnpm build && pnpm build:test && stonyx test 'dist-test/test/**/*-test.js'"
87
+ "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
85
88
  }
86
89
  }