@stonyx/cron 0.2.1-alpha.21 → 0.2.1-alpha.23

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**. `register` throws a `TypeError` on a value it cannot parse — in particular, this class does not accept cron expressions; use `CronService` (`@stonyx/cron/service`) for those. Values below `1` are clamped to `1` second with a warning.
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,37 @@ 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
+ * Parse a job interval (whole seconds, as a string) into a positive integer.
53
+ *
54
+ * Returns `null` when the value cannot be parsed at all, so callers can choose
55
+ * between failing fast (`register`) and falling back (`setNextTrigger`).
56
+ */
57
+ parseInterval(interval: string): number | null;
18
58
  setNextTrigger(job: CronJob): void;
19
59
  log(text: string, key?: string | null): void;
20
60
  }
package/dist/main.js CHANGED
@@ -17,6 +17,28 @@ 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
+ * Render an unknown thrown value as log text.
31
+ *
32
+ * `@stonyx/logs` reads a second argument as `logToFile`, not as a format
33
+ * argument, so `log.error(message, err)` discards the error entirely *and*
34
+ * forces a disk write on every failure. The error has to be interpolated into
35
+ * the message instead — the shape `CronService.executeJob` already uses.
36
+ */
37
+ function describeError(err) {
38
+ if (err instanceof Error)
39
+ return err.stack ?? `${err.name}: ${err.message}`;
40
+ return String(err);
41
+ }
20
42
  export default class Cron {
21
43
  static instance;
22
44
  jobs = {};
@@ -55,18 +77,117 @@ export default class Cron {
55
77
  const job = heap.pop();
56
78
  if (config.debug)
57
79
  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
- }
80
+ // Reschedule *before* invoking. The callback's result is not used by this
81
+ // class (`runDueJobs` returns void), so awaiting it bought nothing and
82
+ // cost the scheduler: a callback that never settled left the job absent
83
+ // from the heap and stopped the timer from ever re-arming.
64
84
  this.setNextTrigger(job);
65
85
  heap.push(job);
86
+ this.safeInvoke(job);
66
87
  }
67
88
  this.scheduleNextRun();
68
89
  }
90
+ /**
91
+ * The one safe way this class invokes a consumer callback.
92
+ *
93
+ * Never blocks the caller, catches synchronous throws and asynchronous
94
+ * rejections alike, and skips the invocation entirely when the job's previous
95
+ * invocation has not settled yet (fire-and-forget would otherwise let a slow
96
+ * job stack invocations on itself).
97
+ */
98
+ safeInvoke(job, runOnInit = false) {
99
+ const { key } = job;
100
+ const context = runOnInit ? 'failed on init:' : 'failed:';
101
+ // The in-flight guard lives on the job object, not in a module-level set
102
+ // keyed by string. That is what gives each invocation an identity: the only
103
+ // thing that ever clears the flag is the settle handler of the invocation
104
+ // that set it, and that handler closes over this exact job object. A stale
105
+ // handler therefore cannot release a *later* invocation's guard. It also
106
+ // matches the in-repo idiom one tier up (`job.state.runningAtMs`).
107
+ //
108
+ // `unregister` needs no explicit clear as a result: the flag is dropped with
109
+ // the job object, so a re-registered key gets a fresh object and runs
110
+ // immediately, while the abandoned invocation can only ever release itself.
111
+ if (job.runningAtMs !== undefined) {
112
+ // Bounded: one line per stuck run, not one per tick. A permanently hung
113
+ // job is re-pushed and re-skipped every interval forever, which at the
114
+ // 1s interval this class's own tests use is ~86k log lines a day, per job
115
+ // — a disk-fill and ingest-cost vector on any deployment capturing stdout.
116
+ if (!job.skipReported) {
117
+ job.skipReported = true;
118
+ const runningForSeconds = Math.max(0, Math.round((Date.now() - job.runningAtMs) / 1000));
119
+ this.report('warn', `Cron job ${JSON.stringify(key)} is still running after ${runningForSeconds}s; skipping this `
120
+ + 'tick and any further ticks until it settles (this warning is not repeated for this run)');
121
+ }
122
+ return;
123
+ }
124
+ job.runningAtMs = Date.now();
125
+ job.skipReported = false;
126
+ try {
127
+ const result = job.callback();
128
+ if (result && typeof result.then === 'function') {
129
+ Promise.resolve(result)
130
+ .catch((err) => {
131
+ // Braces matter: returning `report`'s value would put it back into
132
+ // the chain, and `.finally` passes a rejection straight through.
133
+ this.report('error', `Cron job ${JSON.stringify(key)} ${context} ${describeError(err)}`);
134
+ })
135
+ .finally(() => { this.release(job); })
136
+ // Backstop: a throw inside the error handler or the release must not
137
+ // re-create the unhandled rejection this helper exists to prevent.
138
+ .catch(() => { });
139
+ return;
140
+ }
141
+ this.release(job);
142
+ }
143
+ catch (err) {
144
+ this.release(job);
145
+ this.report('error', `Cron job ${JSON.stringify(key)} ${context} ${describeError(err)}`);
146
+ }
147
+ }
148
+ /**
149
+ * Report a scheduler-level message without ever letting the logger's own
150
+ * failure reach the caller.
151
+ *
152
+ * `@stonyx/logs` convenience methods return a promise and write to disk
153
+ * through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
154
+ * log volume that promise rejects; an unobserved rejection raised from inside
155
+ * the handler that exists to prevent unhandled rejections would re-create
156
+ * exactly the defect this class was fixed for (measured: exit code 1).
157
+ */
158
+ report(level, message) {
159
+ try {
160
+ const result = level === 'error' ? log.error(message) : log.warn(message);
161
+ void Promise.resolve(result).catch(() => { });
162
+ }
163
+ catch {
164
+ // Nowhere left to report to; the logger must never stop the scheduler.
165
+ }
166
+ }
167
+ /** Release a job's in-flight guard. Only ever called for the job it belongs to. */
168
+ release(job) {
169
+ job.runningAtMs = undefined;
170
+ job.skipReported = false;
171
+ }
69
172
  register(key, callback, interval, runOnInit = false) {
173
+ const seconds = this.parseInterval(interval);
174
+ // Fail fast rather than clamp. An unparseable interval is a programming
175
+ // error with exactly one likely cause — a cron expression handed to the
176
+ // legacy class, which takes whole seconds — and clamping it would silently
177
+ // run a job intended for every 5 minutes once per second, hammering whatever
178
+ // the callback talks to. Throwing surfaces it at the call site, at boot,
179
+ // before anything is scheduled. A degenerate-but-parseable interval (`'0'`,
180
+ // `'-5'`) is a different case: it is interpretable as "as often as possible"
181
+ // and is clamped to the floor with one warning.
182
+ if (seconds === null) {
183
+ throw new TypeError(`Cron job ${JSON.stringify(key)} has an invalid interval ${JSON.stringify(interval)}: `
184
+ + 'expected whole seconds (e.g. \'30\'). The legacy Cron class does not accept cron '
185
+ + 'expressions — use CronService for those.');
186
+ }
187
+ if (parseInt(interval, 10) < MIN_INTERVAL_SECONDS) {
188
+ this.report('warn', `Cron job ${JSON.stringify(key)} interval ${JSON.stringify(interval)} is below the `
189
+ + `${MIN_INTERVAL_SECONDS}s floor; clamping to ${MIN_INTERVAL_SECONDS}s`);
190
+ }
70
191
  const job = { callback, interval, key, nextTrigger: 0 };
71
192
  this.jobs[key] = job;
72
193
  this.setNextTrigger(job);
@@ -74,14 +195,8 @@ export default class Cron {
74
195
  if (config.debug) {
75
196
  this.log(`job has been registered with interval: ${interval}`, key);
76
197
  }
77
- if (runOnInit) {
78
- try {
79
- callback();
80
- }
81
- catch (err) {
82
- log.error(`Cron job "${key}" failed on init:`, err);
83
- }
84
- }
198
+ if (runOnInit)
199
+ this.safeInvoke(job, true);
85
200
  this.scheduleNextRun();
86
201
  }
87
202
  unregister(key) {
@@ -95,8 +210,24 @@ export default class Cron {
95
210
  this.log('job has been unregistered', key);
96
211
  this.scheduleNextRun();
97
212
  }
213
+ /**
214
+ * Parse a job interval (whole seconds, as a string) into a positive integer.
215
+ *
216
+ * Returns `null` when the value cannot be parsed at all, so callers can choose
217
+ * between failing fast (`register`) and falling back (`setNextTrigger`).
218
+ */
219
+ parseInterval(interval) {
220
+ const seconds = parseInt(interval, 10);
221
+ if (!Number.isFinite(seconds))
222
+ return null;
223
+ return Math.max(MIN_INTERVAL_SECONDS, seconds);
224
+ }
98
225
  setNextTrigger(job) {
99
- job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
226
+ // `register` rejects an unparseable interval up front; this floor is the
227
+ // backstop for a job object mutated after registration (`cron.jobs` is
228
+ // public, mutable state) and is what actually guarantees the drain loop
229
+ // terminates. Never let `nextTrigger` land on `NaN` or on `now`.
230
+ job.nextTrigger = getTimestamp() + (this.parseInterval(job.interval) ?? MIN_INTERVAL_SECONDS);
100
231
  }
101
232
  log(text, key = null) {
102
233
  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;
@@ -80,45 +78,7 @@ export default class CronService {
80
78
  armTimer(): void;
81
79
  onTimer(): Promise<void>;
82
80
  findDueJobs(nowMs: number): Job[];
83
- /**
84
- * Execute a job in three phases:
85
- *
86
- * 1. claim (locked) - take ownership of the job, detach it from the heap
87
- * 2. invoke (UNLOCKED) - await the consumer callback
88
- * 3. settle (locked) - apply the result, log it, re-insert into the heap
89
- *
90
- * The critical section deliberately excludes phase 2. `onJobDue` is
91
- * arbitrary, unbounded consumer code; awaiting it under the module-global
92
- * lock is what wedged every subsequent `locked()` call (add/update/remove)
93
- * when a callback never settled.
94
- *
95
- * `onTimer` performs the batch claim (findDueJobs + markRunning) for all due
96
- * jobs under a single lock, then enters at phase 2 via `#executeClaimed`.
97
- * That entry point is a `#private` method rather than a parameter on this
98
- * one: as a published `alreadyClaimed` boolean it was a supported way for a
99
- * consumer to skip phase 1 entirely, which defeats the claim guard AC4 asks
100
- * for and allows concurrent `onJobDue` invocations for the same job.
101
- */
102
81
  executeJob(job: Job): Promise<ExecuteResult>;
103
- /**
104
- * Phase 1 - claim. Must be called while holding the lock.
105
- *
106
- * Returns `null` on a successful claim, or the reason the claim was refused.
107
- * "already running" is what makes a second `run()` report a skip instead of
108
- * launching a concurrent invocation. "removed" covers the job being deleted
109
- * between `run()`'s unlocked lookup and this lock turn - claiming then would
110
- * `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
111
- * belong to a replacement.
112
- *
113
- * Detaching from the heap here (rather than relying on phase 3 to push a
114
- * fresh entry) is what keeps manual runs from permanently duplicating heap
115
- * entries.
116
- */
117
- claimJob(job: Job): 'already running' | 'removed' | null;
118
- /**
119
- * Phase 3 - settle. Must be called while holding the lock.
120
- */
121
- settleJob(job: Job, status: string, error: string | undefined, summary: string | undefined, startMs: number, durationMs: number): ExecuteResult;
122
82
  removeFromHeap(id: string): void;
123
83
  log(message: string): void;
124
84
  }
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;
@@ -162,10 +144,6 @@ export default class CronService {
162
144
  if (mode === 'due' && !isDue(job, Date.now())) {
163
145
  return { status: 'skipped', reason: 'not due' };
164
146
  }
165
- // Deliberately NOT wrapped in locked(): executeJob takes the lock itself
166
- // for its claim and settle phases only. Wrapping here would re-create the
167
- // wedge through a second door, since the callback would again be awaited
168
- // while a lock is held.
169
147
  return this.executeJob(job);
170
148
  }
171
149
  /**
@@ -194,42 +172,16 @@ export default class CronService {
194
172
  }
195
173
  this.running = true;
196
174
  try {
197
- // Phase 1 - claim (locked). Collecting due jobs pops them off the heap
198
- // and marking them running makes them un-collectable by anyone else, so
199
- // both must happen under the same lock.
200
- const dueJobs = await locked(() => {
175
+ await locked(async () => {
201
176
  const nowMs = Date.now();
202
- const due = this.findDueJobs(nowMs);
203
- for (const job of due) {
177
+ const dueJobs = this.findDueJobs(nowMs);
178
+ for (const job of dueJobs) {
204
179
  markRunning(job);
205
180
  }
206
- return due;
207
- });
208
- // Phases 2 and 3 run outside the claim lock. The consumer callback is
209
- // awaited here holding no lock at all, so a callback that never settles
210
- // cannot poison the lock chain.
211
- for (const job of dueJobs) {
212
- try {
213
- await this.#executeClaimed(job);
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
- }
181
+ for (const job of dueJobs) {
182
+ await this.executeJob(job);
231
183
  }
232
- }
184
+ });
233
185
  }
234
186
  finally {
235
187
  this.running = false;
@@ -250,164 +202,50 @@ export default class CronService {
250
202
  }
251
203
  return due;
252
204
  }
253
- /**
254
- * Execute a job in three phases:
255
- *
256
- * 1. claim (locked) - take ownership of the job, detach it from the heap
257
- * 2. invoke (UNLOCKED) - await the consumer callback
258
- * 3. settle (locked) - apply the result, log it, re-insert into the heap
259
- *
260
- * The critical section deliberately excludes phase 2. `onJobDue` is
261
- * arbitrary, unbounded consumer code; awaiting it under the module-global
262
- * lock is what wedged every subsequent `locked()` call (add/update/remove)
263
- * when a callback never settled.
264
- *
265
- * `onTimer` performs the batch claim (findDueJobs + markRunning) for all due
266
- * jobs under a single lock, then enters at phase 2 via `#executeClaimed`.
267
- * That entry point is a `#private` method rather than a parameter on this
268
- * one: as a published `alreadyClaimed` boolean it was a supported way for a
269
- * consumer to skip phase 1 entirely, which defeats the claim guard AC4 asks
270
- * for and allows concurrent `onJobDue` invocations for the same job.
271
- */
272
205
  async executeJob(job) {
273
- // -- Phase 1: claim (locked) --
274
- const refusal = await locked(() => this.claimJob(job));
275
- if (refusal)
276
- return { status: 'skipped', reason: refusal };
277
- return this.#executeClaimed(job);
278
- }
279
- /**
280
- * Phases 2 and 3 for a job that has already been claimed - either by
281
- * `executeJob` above or by `onTimer`'s batch claim.
282
- *
283
- * Private: reaching this without a claim would run the consumer callback for
284
- * a job nobody owns.
285
- */
286
- async #executeClaimed(job) {
287
- // Membership re-check. The claim and the invoke are no longer in the same
288
- // critical section, and sibling callbacks run unlocked, so a `remove()` can
289
- // land in between AND RESOLVE - it used to deadlock. A resolved `remove()`
290
- // must keep meaning "this callback will not fire": settleJob's identity
291
- // guard only cleans up afterwards, by which point the side effect has
292
- // already happened. Identity, not id, so a removed-then-replaced key is
293
- // caught too. Deliberately synchronous with the `onJobDue` call below -
294
- // nothing can interleave between this check and the invocation.
295
- if (this.jobs.get(job.id) !== job)
296
- return { status: 'skipped', reason: 'removed' };
297
206
  const startMs = Date.now();
298
207
  let status = 'ok';
299
208
  let error;
300
209
  let summary;
301
- let settled;
302
- // The claim above marked the job running and detached it from the heap.
303
- // Phase 3 is the ONLY thing that undoes either, so it must survive every
304
- // non-local exit from phase 2 - including a throw from the catch handler
305
- // itself. A claim with no matching settle is not a degraded state, it is a
306
- // permanently dead job: `runningAtMs` set, no heap entry, `isDue` false
307
- // forever and `run()` refused forever.
308
210
  try {
309
- // -- Phase 2: invoke (NOT locked) --
310
- try {
311
- if (this.onJobDue) {
312
- const result = await this.onJobDue(job);
313
- if (result) {
314
- status = result.status || 'ok';
315
- error = result.error;
316
- summary = result.summary;
317
- }
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;
318
217
  }
319
218
  }
320
- catch (err) {
321
- status = 'error';
322
- error = describeError(err);
323
- this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
324
- }
325
219
  }
326
- finally {
327
- // -- Phase 3: settle (locked) --
328
- 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}`);
329
224
  }
330
- return settled;
331
- }
332
- /**
333
- * Phase 1 - claim. Must be called while holding the lock.
334
- *
335
- * Returns `null` on a successful claim, or the reason the claim was refused.
336
- * "already running" is what makes a second `run()` report a skip instead of
337
- * launching a concurrent invocation. "removed" covers the job being deleted
338
- * between `run()`'s unlocked lookup and this lock turn - claiming then would
339
- * `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
340
- * belong to a replacement.
341
- *
342
- * Detaching from the heap here (rather than relying on phase 3 to push a
343
- * fresh entry) is what keeps manual runs from permanently duplicating heap
344
- * entries.
345
- */
346
- claimJob(job) {
347
- if (this.jobs.get(job.id) !== job)
348
- return 'removed';
349
- if (job.state.runningAtMs)
350
- return 'already running';
351
- markRunning(job);
352
- this.removeFromHeap(job.id);
353
- return null;
354
- }
355
- /**
356
- * Phase 3 - settle. Must be called while holding the lock.
357
- */
358
- settleJob(job, status, error, summary, startMs, durationMs) {
359
- try {
360
- const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
361
- applyResult(job, validStatus, error, durationMs);
362
- // The callback ran unlocked, so this job may have been removed - or
363
- // removed and re-registered under the same id (the shape
364
- // `start(initialJobs)` uses) - while it was in flight. Identity, not id.
365
- //
366
- // Deliberately touch NOTHING here. The claim phase already detached this
367
- // job's own heap entry and nothing re-added it, so there is nothing to
368
- // clean up; any entry now filed under this id belongs to the
369
- // replacement, and removing it by id would silently unschedule a live
370
- // job. Do not resurrect a removed job's heap entry or run log either.
371
- if (this.jobs.get(job.id) !== job) {
372
- return { status, error, summary, durationMs };
373
- }
374
- // Log the run
375
- this.runLog.record({
376
- jobId: job.id,
377
- status,
378
- error,
379
- summary,
380
- runAtMs: startMs,
381
- durationMs,
382
- nextRunAtMs: job.state.nextRunAtMs,
383
- });
384
- // Handle one-shot auto-delete. The callback ran unlocked and may have
385
- // pushed a heap entry for this job via update(), so drop it - the job is
386
- // about to stop existing.
387
- if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
388
- this.jobs.delete(job.id);
389
- this.removeFromHeap(job.id);
390
- this.runLog.removeJob(job.id);
391
- return { status, summary, deleted: true };
392
- }
393
- // Re-insert into heap if still active. The callback ran unlocked, so it
394
- // may itself have added a heap entry for this job (via add/update); drop
395
- // any such entry first to preserve one-entry-per-key.
396
- this.removeFromHeap(job.id);
397
- if (job.enabled && job.state.nextRunAtMs) {
398
- this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
399
- }
400
- 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 };
401
243
  }
402
- finally {
403
- // One re-arm covering every exit, rather than one per branch. The claim
404
- // phase detached this job from the heap, so a timer that fired during the
405
- // unlocked invoke would have found an empty heap and armed nothing -
406
- // and `run()` has no `finally { armTimer() }` of its own the way
407
- // `onTimer` does. Without this a manual run() can leave the scheduler
408
- // with no pending wake at all.
409
- 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 });
410
247
  }
248
+ return { status, error, summary, durationMs };
411
249
  }
412
250
  // -- Helpers ---------------------------------------------------------
413
251
  removeFromHeap(id) {
@@ -428,19 +266,6 @@ export default class CronService {
428
266
  log(message) {
429
267
  if (!config.cron?.log)
430
268
  return;
431
- // `log.cron` is created by `log.defineType`, which runs in `Cron.init()`
432
- // (src/main.ts) - a DIFFERENT class. A consumer wiring CronService directly
433
- // never runs it, while `config/environment.js` defaults `cron.log` to true,
434
- // so an unguarded call throws `log.cron is not a function`. That throw
435
- // escapes executeJob's catch, and the error-reporting path must never be
436
- // the thing that kills the scheduler. `src/types/stonyx.d.ts:19` declares
437
- // `cron()` unconditionally, so the type system will not catch this.
438
- const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
439
- if (typeof log[logMethod] !== 'function')
440
- log.defineType(logMethod, logColor);
441
- const method = log[logMethod];
442
- if (typeof method !== 'function')
443
- return;
444
- method.call(log, `Cron — ${message}`);
269
+ log.cron(`Cron ${message}`);
445
270
  }
446
271
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.21",
6
+ "version": "0.2.1-alpha.23",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "dist/main.js",
9
9
  "types": "dist/main.d.ts",