@stonyx/cron 0.2.1-alpha.52 → 0.2.1-alpha.54

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,65 +35,34 @@ 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
- > `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
39
-
40
- ## CronService
41
-
42
- The default export above is `Cron`: a fire-and-forget interval registry. `@stonyx/cron/service` is a separate, heavier class for jobs that need CRUD, persistence, a run log and error backoff. It is not a drop-in replacement and the two do not share a scheduler.
43
-
44
- The two classes agree on the guarantee — the same job is never run concurrently with itself, and different jobs may overlap — but not on the mechanism or on what you can observe. `Cron` invokes callbacks fire-and-forget and reports a skipped run only through the `config.cron`-gated log. `CronService` **awaits** `onJobDue`, its return value shapes `status`/`error`/`summary`, and a refused run comes back to the caller as a value that no log setting can suppress — it is not logged.
45
-
46
- ```js
47
- import CronService from '@stonyx/cron/service';
48
-
49
- const service = new CronService();
50
- service.onJobDue = async (job) => ({ status: 'ok', summary: 'done' });
51
-
52
- await service.start();
53
- const job = await service.add({ name: 'Nightly', schedule: { kind: 'every', everyMs: 86_400_000 }, payload: { kind: 'agentTurn', message: 'go' } });
54
-
55
- const result = await service.run(job.id, 'force');
56
- ```
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. `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, even though the scheduler stays healthy and the job stays visible in `jobs` and in the heap. Other jobs are unaffected.
41
+ >
42
+ > One warning is emitted per stuck run (not per tick), including how long the invocation has been running. That warning goes to `log.warn` and is **not** gated by `config.cron.log` — a dropped execution reported on a channel a config flag can silence would be indistinguishable from a healthy scheduler.
43
+ >
44
+ > The same-job guarantee holds for the lifetime of a **registration**, not of a key: `unregister` followed by `register` on a key whose invocation is still in flight builds a fresh job object with a fresh guard, so the replacement can run alongside the abandoned invocation. That is also the only way to recover a permanently stuck job.
45
+ >
46
+ > 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. Note that a rejection which previously escaped `register()` as an unhandled rejection — process-fatal under Node's default — is now swallowed into `log.error`.
57
47
 
58
- ### The `run()` contract
59
-
60
- `run(id, mode)` resolves with an `ExecuteResult`. It **never** invokes the callback twice for one job, and it will refuse rather than queue:
61
-
62
- | `status` | `reason` | Meaning |
63
- | :---------- | :------------------ | :----------------------------------------------------------------------------- |
64
- | `'ok'` | — | The callback resolved. `summary` and `durationMs` are set. |
65
- | `'error'` | — | The callback threw or rejected. `error` carries the message; backoff is applied. |
66
- | `'skipped'` | `'not due'` | `mode` was `'due'` and the job's next run time has not arrived. Use `'force'` to run anyway. |
67
- | `'skipped'` | `'already running'` | A previous invocation of **this** job has not settled. The call is refused, not queued, and nothing is logged. |
68
- | `'skipped'` | `'removed'` | The job was removed between the lookup and the claim. The callback did not fire. |
69
-
70
- `run()` throws (rather than returning a result) when `id` is not a registered job: `Error: Job not found: <id>`.
71
-
72
- **Concurrency.** A job is bounded to one in-flight invocation on every path — manual `run()` and the timer both claim it first. **Different** jobs are not bounded: the callback is deliberately invoked outside the internal lock, so N concurrent `run()` calls on N distinct jobs produce N concurrent callbacks. The scheduler itself never generates that fan-out (its timer path invokes a due batch sequentially); only a caller can. If you drive `run()` from a request handler, bound it on your side. Taking the callback out of the lock is what stops a callback that never settles from blocking `add`/`update`/`remove`; restoring the bound by putting it back would restore that deadlock.
73
-
74
- ### Breaking changes in this line
75
-
76
- Four consumer-visible changes landed with the phase split (#34). All are measured against the emitted `dist/service.d.ts`:
77
-
78
- 1. **`ExecuteResult.reason` narrowed** from `string` to `'not due' | 'already running' | 'removed'`, and gained the `'removed'` member. Comparing it against a literal outside the union, or `switch`ing on one, is now a compile error (`TS2367` / `TS2678`). Assigning it into `string | undefined` and spreading it are unaffected. The type is exported as `SkipReason`.
79
- 2. **`CronService` is nominally typed.** It carries ECMAScript hard-private members, so the declarations emit `#private;` and a structurally hand-built test double no longer assigns to `CronService` (`TS2741: Property '#private' is missing`). The break is one-directional: `class X extends CronService` still compiles, and assigning a real `CronService` to your own hand-written interface still compiles. **Migration:** declare your own interface and depend on that instead of a `CronService`-typed mock.
80
- 3. **`claimJob`, `settleJob` and `executeClaimed` are not published.** They were never a supported API; a claim taken without its matching settle strands the job permanently.
81
- 4. **`run()` no longer serializes across jobs** — see the concurrency note above.
82
-
83
- `SkipReason`, `ExecuteResult`, `JobDueResult`, `ServiceStatus`, `ListOptions` and `OnJobDueCallback` are all exported from `@stonyx/cron/service`, so an exhaustive handler over `reason` is expressible.
48
+ > `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
84
49
 
85
50
  ## Configuration
86
51
 
87
- Optionally, logging and debugging can be enabled through `config.cron`:
52
+ Optionally, informational logging and debugging can be controlled through `config.cron`:
88
53
 
89
54
  ```js
90
55
  config.cron = {
91
- log: true // enable cron job logs
56
+ log: true // informational cron job logs; defaults to true
92
57
  };
93
58
 
94
59
  config.debug = true; // optional: debug logs for job registration and execution
95
60
  ```
96
61
 
62
+ `config.cron.log` gates **informational** messages only. Error reports and the
63
+ stuck-job warning described above are never gated by it, so setting it to `false`
64
+ cannot make a dropped execution silent.
65
+
97
66
  ## License
98
67
 
99
68
  Apache — do what you want, just keep attribution.
package/dist/main.d.ts CHANGED
@@ -3,6 +3,24 @@ 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. 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;
6
24
  }
7
25
  export default class Cron {
8
26
  static instance: Cron | null;
@@ -15,6 +33,33 @@ export default class Cron {
15
33
  runDueJobs(): Promise<void>;
16
34
  register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
17
35
  unregister(key: string): void;
36
+ /**
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.
53
+ *
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.
59
+ */
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;
18
63
  setNextTrigger(job: CronJob): void;
19
64
  log(text: string, key?: string | null): void;
20
65
  }
package/dist/main.js CHANGED
@@ -17,6 +17,44 @@ 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. Total by construction.
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
+ * Every read below touches a consumer-controlled value and can therefore throw:
29
+ * `instanceof` runs a proxy's `getPrototypeOf` trap, `stack`/`name`/`message`
30
+ * can be accessor properties, and `String(Object.create(null))` throws outright.
31
+ * This function runs *inside* `invokeJob`'s catch — the one place whose job is
32
+ * to stop a callback failure from reaching the scheduler — so a throw here
33
+ * escapes that catch and skips `scheduleNextRun()`, which is defect #36.
34
+ */
35
+ function describeError(err) {
36
+ try {
37
+ if (err instanceof Error)
38
+ return err.stack ?? `${err.name}: ${err.message}`;
39
+ return String(err);
40
+ }
41
+ catch {
42
+ // Deliberately not re-entrant: describing the failure to describe the error
43
+ // would be the same read that just threw.
44
+ return '<thrown value could not be rendered>';
45
+ }
46
+ }
47
+ /**
48
+ * Render a consumer-supplied job key safely for log output.
49
+ *
50
+ * Keys reach the log verbatim, so a key containing a newline can forge a
51
+ * complete, well-formed log line (`'a:\n[FORGED] Cron::admin - all jobs
52
+ * healthy'`). `JSON.stringify` quotes the value and escapes the control
53
+ * characters, which is also how the key is rendered one tier up.
54
+ */
55
+ function describeKey(key) {
56
+ return JSON.stringify(key);
57
+ }
20
58
  export default class Cron {
21
59
  static instance;
22
60
  jobs = {};
@@ -43,28 +81,41 @@ export default class Cron {
43
81
  if (!nextJob)
44
82
  return;
45
83
  const delay = Math.max(0, nextJob.nextTrigger - getTimestamp()) * 1000;
46
- this.timer = setTimeout(() => this.runDueJobs(), delay);
84
+ // Terminal catch: `runDueJobs` is async, so anything that escapes it would
85
+ // otherwise become an unhandled rejection raised from a bare timer callback
86
+ // — the very failure mode this class was fixed for.
87
+ this.timer = setTimeout(() => {
88
+ this.runDueJobs().catch((err) => {
89
+ this.report('error', `Cron scheduler tick failed: ${describeError(err)}`);
90
+ });
91
+ }, delay);
47
92
  }
48
93
  async runDueJobs() {
49
94
  const now = getTimestamp();
50
95
  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
- try {
59
- await job.callback();
96
+ // `finally`, not a trailing statement: `scheduleNextRun()` running on every
97
+ // exit from the drain loop is the invariant this whole fix is about. If
98
+ // anything in the loop body ever throws, the scheduler must still re-arm
99
+ // rather than stopping silently while `timer` still holds a fired handle.
100
+ try {
101
+ while (!heap.isEmpty()) {
102
+ const next = heap.peek();
103
+ if (!next || next.nextTrigger > now)
104
+ break;
105
+ const job = heap.pop();
106
+ if (config.debug)
107
+ this.log('job has been triggered', job.key);
108
+ // Reschedule before invoking: a consumer callback is never awaited here,
109
+ // so a callback that hangs or rejects can no longer starve the drain loop
110
+ // or leave the job orphaned outside the heap.
111
+ this.setNextTrigger(job);
112
+ heap.push(job);
113
+ this.invokeJob(job);
60
114
  }
61
- catch (err) {
62
- log.error(`Cron job "${job.key}" failed:`, err);
63
- }
64
- this.setNextTrigger(job);
65
- heap.push(job);
66
115
  }
67
- this.scheduleNextRun();
116
+ finally {
117
+ this.scheduleNextRun();
118
+ }
68
119
  }
69
120
  register(key, callback, interval, runOnInit = false) {
70
121
  const job = { callback, interval, key, nextTrigger: 0 };
@@ -74,15 +125,18 @@ export default class Cron {
74
125
  if (config.debug) {
75
126
  this.log(`job has been registered with interval: ${interval}`, key);
76
127
  }
77
- if (runOnInit) {
78
- try {
79
- callback();
80
- }
81
- catch (err) {
82
- log.error(`Cron job "${key}" failed on init:`, err);
83
- }
128
+ // `finally`, not a trailing statement, for the same reason as `runDueJobs`:
129
+ // a job that is registered but never scheduled is defect #36's terminal
130
+ // state reached through the other entry point. `invokeJob` is total, so
131
+ // this guard should be unreachable — which is exactly why it is a guard and
132
+ // not an assumption.
133
+ try {
134
+ if (runOnInit)
135
+ this.invokeJob(job, true);
136
+ }
137
+ finally {
138
+ this.scheduleNextRun();
84
139
  }
85
- this.scheduleNextRun();
86
140
  }
87
141
  unregister(key) {
88
142
  const { heap, jobs } = this;
@@ -95,13 +149,104 @@ export default class Cron {
95
149
  this.log('job has been unregistered', key);
96
150
  this.scheduleNextRun();
97
151
  }
152
+ /**
153
+ * The one place this class invokes a consumer callback.
154
+ *
155
+ * Never blocks the caller, catches synchronous throws and asynchronous
156
+ * rejections identically, and skips the invocation entirely while the job's
157
+ * previous invocation has not settled (fire-and-forget would otherwise let a
158
+ * slow job stack invocations on itself).
159
+ *
160
+ * Everything that touches the callback — including the thenable probe and the
161
+ * handler attachment — is inside the `try`. A callback may return an object
162
+ * whose `then` is a throwing getter, and reading it outside the guard would
163
+ * abort the drain loop before `scheduleNextRun()`, which is defect #36 again.
164
+ */
165
+ invokeJob(job, runOnInit = false) {
166
+ const { key } = job;
167
+ const context = runOnInit ? 'failed on init:' : 'failed:';
168
+ // The in-flight guard lives on the job object, not in a module-level set
169
+ // keyed by string. Object identity is invocation identity: the only thing
170
+ // that clears the guard is the settle handler of the invocation that set it,
171
+ // and that handler closes over this exact job object, so a stale handler can
172
+ // never release a later invocation's guard.
173
+ if (job.runningAtMs !== undefined) {
174
+ // Bounded to one line per stuck run, not one per tick. A permanently hung
175
+ // job is re-pushed and re-skipped every interval forever; at the 1s
176
+ // interval this class's own tests use that measures 43,200 lines/day per
177
+ // job — a disk-fill and ingest-cost vector whose natural operator response
178
+ // is to silence the only signal that the job is dead.
179
+ if (!job.skipReported) {
180
+ job.skipReported = true;
181
+ const runningForSeconds = Math.max(0, Math.round((Date.now() - job.runningAtMs) / 1000));
182
+ // Ungated, deliberately, matching the sibling `CronService` handler. A
183
+ // skipped run is a *lost* execution, and `runDueJobs`/`register` both
184
+ // return `void`, so this is the legacy class's only wedged-job channel.
185
+ // Routing it through `this.log` would put it behind `config.cron.log`,
186
+ // where a permanently dead job is indistinguishable from a healthy one.
187
+ this.report('warn', `Cron job ${describeKey(key)} is still running after ${runningForSeconds}s; skipping this `
188
+ + 'tick and any further ticks until it settles (this warning is not repeated for this run)');
189
+ }
190
+ return;
191
+ }
192
+ job.runningAtMs = Date.now();
193
+ job.skipReported = false;
194
+ try {
195
+ const result = job.callback();
196
+ if (result && typeof result.then === 'function') {
197
+ Promise.resolve(result)
198
+ .catch((err) => {
199
+ // Braces matter: returning `report`'s value would put it back into
200
+ // the chain, and `.finally` passes a rejection straight through.
201
+ this.report('error', `Cron job ${describeKey(key)} ${context} ${describeError(err)}`);
202
+ })
203
+ .finally(() => { this.release(job); })
204
+ // Backstop: a throw inside the error handler or the release must not
205
+ // re-create the unhandled rejection this helper exists to prevent.
206
+ .catch(() => { });
207
+ return;
208
+ }
209
+ this.release(job);
210
+ }
211
+ catch (err) {
212
+ this.release(job);
213
+ this.report('error', `Cron job ${describeKey(key)} ${context} ${describeError(err)}`);
214
+ }
215
+ }
216
+ /**
217
+ * Report a scheduler-level message without ever letting the logger's own
218
+ * failure reach the caller.
219
+ *
220
+ * `@stonyx/logs` convenience methods return a promise and write to disk
221
+ * through an unguarded `mkdirSync` + `fsp.appendFile`. On a read-only or full
222
+ * log volume that promise rejects; an unobserved rejection raised from inside
223
+ * the handler that exists to prevent unhandled rejections would re-create
224
+ * exactly the defect this class was fixed for.
225
+ */
226
+ report(level, message) {
227
+ try {
228
+ const result = level === 'error' ? log.error(message) : log.warn(message);
229
+ void Promise.resolve(result).catch(() => { });
230
+ }
231
+ catch {
232
+ // Nowhere left to report to; the logger must never stop the scheduler.
233
+ }
234
+ }
235
+ /** Release a job's in-flight guard. Only ever called for the job it belongs to. */
236
+ release(job) {
237
+ job.runningAtMs = undefined;
238
+ job.skipReported = false;
239
+ }
98
240
  setNextTrigger(job) {
99
241
  job.nextTrigger = getTimestamp() + parseInt(job.interval, 10);
100
242
  }
101
243
  log(text, key = null) {
102
244
  if (!config.cron?.log)
103
245
  return;
104
- const tag = key ? `Cron::${key}` : `Cron`;
246
+ // The key is consumer-controlled and reaches the log verbatim. Strip the
247
+ // line terminators so a key cannot forge a second, well-formed log line;
248
+ // the surrounding format is unchanged.
249
+ const tag = key ? `Cron::${key.replace(/[\r\n]+/g, ' ')}` : `Cron`;
105
250
  log.cron(`${tag} - ${text}:`);
106
251
  }
107
252
  }
package/dist/service.d.ts CHANGED
@@ -4,37 +4,29 @@ import RunLog from './run-log.js';
4
4
  interface HeapEntry extends HeapItem {
5
5
  key: string;
6
6
  }
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 {
7
+ interface JobDueResult {
14
8
  status?: string;
15
9
  error?: string;
16
10
  summary?: string;
17
11
  }
18
- export interface ExecuteResult {
12
+ interface ExecuteResult {
19
13
  status: string;
20
14
  error?: string;
21
15
  summary?: string;
22
16
  durationMs?: number;
23
17
  deleted?: boolean;
24
- /** Only set when `status` is `'skipped'`. */
25
- reason?: SkipReason;
18
+ reason?: string;
26
19
  }
27
- export interface ServiceStatus {
20
+ interface ServiceStatus {
28
21
  started: boolean;
29
22
  jobCount: number;
30
23
  nextWakeAtMs: number | undefined;
31
24
  }
32
- export interface ListOptions {
25
+ interface ListOptions {
33
26
  includeDisabled?: boolean;
34
27
  }
35
- export type OnJobDueCallback = (job: Job) => Promise<JobDueResult | void> | JobDueResult | void;
28
+ type OnJobDueCallback = (job: Job) => Promise<JobDueResult | void> | JobDueResult | void;
36
29
  export default class CronService {
37
- #private;
38
30
  jobs: Map<string, Job>;
39
31
  heap: MinHeap<HeapEntry>;
40
32
  timer: ReturnType<typeof setTimeout> | null;
@@ -77,37 +69,6 @@ export default class CronService {
77
69
  remove(id: string): Promise<void>;
78
70
  /**
79
71
  * 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
- * THROWS (rather than returning a skip) when `id` is not a registered job:
88
- * `Error("Job not found: <id>")`. A job that disappears between this lookup
89
- * and the claim is the `'removed'` skip above, not a throw — the two differ
90
- * only by the timing of a race, and the second is a legitimate outcome
91
- * whereas the first is a caller error.
92
- *
93
- * CONCURRENCY: the same job is bounded to one in-flight invocation on every
94
- * path, and the timer path invokes due jobs one at a time. `run()` fan-out
95
- * across DIFFERENT jobs is deliberately unbounded — N concurrent `run()`
96
- * calls produce N concurrent consumer callbacks. Before the phase split
97
- * these serialized behind the module-global lock; that serialization was the
98
- * bug rather than the feature (one hung callback wedged every other caller),
99
- * so it is not restored here. The fan-out is caller-driven and the scheduler
100
- * never produces it on its own. A per-invoke bound belongs above this layer;
101
- * it is tracked on stonyx-cron#35 alongside the execution timeout.
102
- *
103
- * NOT FIXED HERE: the phase split fixes the LOCK wedge, not the TIMER. A
104
- * callback that never settles still stops `onTimer`'s sequential loop
105
- * forever — `running` stays true, every later tick early-returns and re-arms,
106
- * and the hung job's batch siblings stay claimed and off-heap having never
107
- * been invoked. CRUD still resolves and `status()` still reports
108
- * `started: true`, so that failure is now silent where it used to be loud.
109
- * Bounding the callback and releasing batch siblings is stonyx-cron#35. Do
110
- * not read this method's doc as "the hang is fixed".
111
72
  */
112
73
  run(id: string, mode?: 'due' | 'force'): Promise<ExecuteResult>;
113
74
  /**
@@ -117,25 +78,6 @@ export default class CronService {
117
78
  armTimer(): void;
118
79
  onTimer(): Promise<void>;
119
80
  findDueJobs(nowMs: number): Job[];
120
- /**
121
- * Execute a job in three phases:
122
- *
123
- * 1. claim (locked) — take ownership of the job, detach it from the heap
124
- * 2. invoke (UNLOCKED) — await the consumer callback
125
- * 3. settle (locked) — apply the result, log it, re-insert into the heap
126
- *
127
- * The critical section deliberately excludes phase 2. `onJobDue` is
128
- * arbitrary, unbounded consumer code; awaiting it under the module-global
129
- * lock is what wedged every subsequent `locked()` call (add/update/remove)
130
- * when a callback never settled.
131
- *
132
- * `onTimer` performs the batch claim (`findDueJobs` + `markRunning`) for all
133
- * due jobs under a single lock and then enters at phase 2 via
134
- * `#executeClaimed`. That entry point is `#private` rather than a parameter
135
- * on this method: as a published `alreadyClaimed` flag it would be a
136
- * supported way to skip phase 1 entirely, defeating the claim guard and
137
- * allowing concurrent `onJobDue` invocations for the same job.
138
- */
139
81
  executeJob(job: Job): Promise<ExecuteResult>;
140
82
  removeFromHeap(id: string): void;
141
83
  log(message: string): void;
package/dist/service.js CHANGED
@@ -2,21 +2,7 @@
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
- *
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.
5
+ * All state mutations are serialized via async locking.
20
6
  */
21
7
  import config from 'stonyx/config';
22
8
  import log from 'stonyx/log';
@@ -26,54 +12,6 @@ import { locked } from './locked.js';
26
12
  import { normalizeJobInput, recoverFlatParams } from './normalize.js';
27
13
  import RunLog from './run-log.js';
28
14
  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
- }
77
15
  export default class CronService {
78
16
  jobs;
79
17
  heap;
@@ -102,23 +40,6 @@ export default class CronService {
102
40
  this.started = true;
103
41
  if (initialJobs) {
104
42
  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;
122
43
  this.jobs.set(job.id, job);
123
44
  if (job.enabled && job.state.nextRunAtMs) {
124
45
  this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
@@ -215,37 +136,6 @@ export default class CronService {
215
136
  }
216
137
  /**
217
138
  * 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
- * THROWS (rather than returning a skip) when `id` is not a registered job:
226
- * `Error("Job not found: <id>")`. A job that disappears between this lookup
227
- * and the claim is the `'removed'` skip above, not a throw — the two differ
228
- * only by the timing of a race, and the second is a legitimate outcome
229
- * whereas the first is a caller error.
230
- *
231
- * CONCURRENCY: the same job is bounded to one in-flight invocation on every
232
- * path, and the timer path invokes due jobs one at a time. `run()` fan-out
233
- * across DIFFERENT jobs is deliberately unbounded — N concurrent `run()`
234
- * calls produce N concurrent consumer callbacks. Before the phase split
235
- * these serialized behind the module-global lock; that serialization was the
236
- * bug rather than the feature (one hung callback wedged every other caller),
237
- * so it is not restored here. The fan-out is caller-driven and the scheduler
238
- * never produces it on its own. A per-invoke bound belongs above this layer;
239
- * it is tracked on stonyx-cron#35 alongside the execution timeout.
240
- *
241
- * NOT FIXED HERE: the phase split fixes the LOCK wedge, not the TIMER. A
242
- * callback that never settles still stops `onTimer`'s sequential loop
243
- * forever — `running` stays true, every later tick early-returns and re-arms,
244
- * and the hung job's batch siblings stay claimed and off-heap having never
245
- * been invoked. CRUD still resolves and `status()` still reports
246
- * `started: true`, so that failure is now silent where it used to be loud.
247
- * Bounding the callback and releasing batch siblings is stonyx-cron#35. Do
248
- * not read this method's doc as "the hang is fixed".
249
139
  */
250
140
  async run(id, mode = 'force') {
251
141
  const job = this.jobs.get(id);
@@ -254,10 +144,6 @@ export default class CronService {
254
144
  if (mode === 'due' && !isDue(job, Date.now())) {
255
145
  return { status: 'skipped', reason: 'not due' };
256
146
  }
257
- // Deliberately NOT wrapped in locked(): executeJob takes the lock itself,
258
- // for its claim and settle phases only. Wrapping here would re-create the
259
- // wedge through a second door, because the consumer callback would once
260
- // again be awaited while a lock is held.
261
147
  return this.executeJob(job);
262
148
  }
263
149
  /**
@@ -286,77 +172,16 @@ export default class CronService {
286
172
  }
287
173
  this.running = true;
288
174
  try {
289
- // -- Phase 1: claim (locked), batched --
290
- // Collecting due jobs pops them off the heap, and marking them running
291
- // makes them un-claimable by anyone else. Both must happen under the
292
- // same lock turn, or a concurrent run() could claim a job this batch has
293
- // already detached.
294
- //
295
- // This is the SECOND claim implementation — `#claimJob` is the other, and
296
- // the two reach the same state by different routes. `#claimJob` guards
297
- // with an explicit `job.state.runningAtMs` check; this path has no such
298
- // check and relies entirely on `isDue`'s `!job.state.runningAtMs` clause
299
- // (`job.ts`) to keep `findDueJobs` from re-claiming a job that `run()`
300
- // already holds. THAT CLAUSE IS LOAD-BEARING HERE, not an optimisation:
301
- // drop it and the timer path silently double-invokes a job that `run()`
302
- // is mid-flight on, while `run()` keeps refusing correctly and looks
303
- // healthy. The one-in-flight-invocation-per-job invariant this class
304
- // advertises holds by two independent guards in two files; a change to
305
- // either has to be checked against the other.
306
- const dueJobs = await locked(() => {
175
+ await locked(async () => {
307
176
  const nowMs = Date.now();
308
- const due = this.findDueJobs(nowMs);
309
- for (const job of due) {
177
+ const dueJobs = this.findDueJobs(nowMs);
178
+ for (const job of dueJobs) {
310
179
  markRunning(job);
311
180
  }
312
- return due;
313
- });
314
- // Phases 2 and 3 run OUTSIDE the claim lock. The consumer callback is
315
- // awaited here holding no lock at all, so a callback that never settles
316
- // cannot poison the lock chain and wedge add/update/remove.
317
- for (const job of dueJobs) {
318
- try {
319
- await this.#executeClaimed(job);
181
+ for (const job of dueJobs) {
182
+ await this.executeJob(job);
320
183
  }
321
- catch (err) {
322
- // One job's unexpected throw must not abort the batch. Every job in
323
- // `dueJobs` is already claimed — marked running and detached from
324
- // the heap — and only its own settle releases it, so aborting here
325
- // would strand every sibling permanently un-due.
326
- //
327
- // Reported on an UNGATED channel. `this.log()` returns early when
328
- // `config.cron.log` is false, which is a supported production
329
- // setting, and a failure here permanently unschedules a job while
330
- // `status()` keeps reporting the service healthy. Silent-and-healthy
331
- // is exactly the failure class this split exists to remove.
332
- //
333
- // This is the outermost handler on the timer path, so it is the one
334
- // that must not be able to throw: `log` is a shared singleton whose
335
- // transports can reach the filesystem, so its own failure is
336
- // swallowed rather than allowed to take the batch down.
337
- //
338
- // BOTH halves of that failure have to be caught, and they are caught
339
- // by different constructs. `log.error` is a chronicle convenience
340
- // method that returns `logAction(...)` -> `async log(...)`, so its
341
- // console write, colour lookup, `mkdirSync` and `appendFile` all
342
- // surface as REJECTIONS, never as synchronous throws. A bare call
343
- // here escapes this `catch` entirely and terminates the process under
344
- // Node's default `--unhandled-rejections=throw` — the handler written
345
- // so it "must not be able to throw" would be the one taking the
346
- // daemon down. The `try` covers the synchronous half (evaluating the
347
- // template literal); `Promise.resolve(...).catch()` covers the async
348
- // half. Deliberately not awaited: the batch must not block on a log
349
- // transport, and `void` marks the floated promise as intentional.
350
- try {
351
- 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(() => {
352
- // Nothing left to report to.
353
- });
354
- }
355
- catch {
356
- // Nothing left to report to.
357
- }
358
- }
359
- }
184
+ });
360
185
  }
361
186
  finally {
362
187
  this.running = false;
@@ -377,190 +202,50 @@ export default class CronService {
377
202
  }
378
203
  return due;
379
204
  }
380
- /**
381
- * Execute a job in three phases:
382
- *
383
- * 1. claim (locked) — take ownership of the job, detach it from the heap
384
- * 2. invoke (UNLOCKED) — await the consumer callback
385
- * 3. settle (locked) — apply the result, log it, re-insert into the heap
386
- *
387
- * The critical section deliberately excludes phase 2. `onJobDue` is
388
- * arbitrary, unbounded consumer code; awaiting it under the module-global
389
- * lock is what wedged every subsequent `locked()` call (add/update/remove)
390
- * when a callback never settled.
391
- *
392
- * `onTimer` performs the batch claim (`findDueJobs` + `markRunning`) for all
393
- * due jobs under a single lock and then enters at phase 2 via
394
- * `#executeClaimed`. That entry point is `#private` rather than a parameter
395
- * on this method: as a published `alreadyClaimed` flag it would be a
396
- * supported way to skip phase 1 entirely, defeating the claim guard and
397
- * allowing concurrent `onJobDue` invocations for the same job.
398
- */
399
205
  async executeJob(job) {
400
- // -- Phase 1: claim (locked) --
401
- const refusal = await locked(() => this.#claimJob(job));
402
- if (refusal)
403
- return { status: 'skipped', reason: refusal };
404
- return this.#executeClaimed(job);
405
- }
406
- /**
407
- * Phases 2 and 3 for a job that has already been claimed — either by
408
- * `executeJob` above or by `onTimer`'s batch claim.
409
- *
410
- * Private: reaching this without a claim would run the consumer callback for
411
- * a job nobody owns, and would leave nothing to release the claim.
412
- */
413
- async #executeClaimed(job) {
414
- // Membership re-check. The claim and the invoke are no longer in the same
415
- // critical section, and sibling callbacks run unlocked, so a `remove()` can
416
- // now land in between AND RESOLVE — it used to deadlock. A resolved
417
- // `remove()` must keep meaning "this callback will not fire"; the identity
418
- // guard in `#settleJob` only cleans up afterwards, by which point the side
419
- // effect has already happened. Identity, not id, so a removed-then-replaced
420
- // key is caught too. Deliberately synchronous with the `onJobDue` call
421
- // below — nothing can interleave between this check and the invocation.
422
- //
423
- // This is the one early return after a claim, so it is the one that has to
424
- // release the claim by hand. Skipping settle is right — re-inserting or
425
- // run-logging a removed job is the resurrection `#settleJob` refuses, and
426
- // the heap entry is already gone. But the claim must still come off,
427
- // because the detached object is NOT unreachable: it is the object `add()`
428
- // returned and `get()`/`list()` hand out, and `start(initialJobs)`
429
- // re-registers those objects verbatim, `state` included. A leftover
430
- // `runningAtMs` rehydrates a permanently dead job — `isDue` false forever,
431
- // `run()` refused forever, `status()` reporting it healthy.
432
- //
433
- // Assigned directly rather than via `applyResult`: this releases the claim
434
- // and nothing else. No run-log row, no heap entry, no `lastStatus`, no
435
- // recomputed `nextRunAtMs` — the job did not run.
436
- if (this.jobs.get(job.id) !== job) {
437
- job.state.runningAtMs = undefined;
438
- return { status: 'skipped', reason: 'removed' };
439
- }
440
206
  const startMs = Date.now();
441
207
  let status = 'ok';
442
208
  let error;
443
209
  let summary;
444
- let settled;
445
- // The claim marked the job running and detached it from the heap. Phase 3
446
- // is the ONLY thing that undoes either, so it must survive every non-local
447
- // exit from phase 2 — including a throw from the catch handler itself
448
- // (`this.log` is public and overridable and reaches a transport). A claim
449
- // with no matching settle is not a degraded state, it is a permanently
450
- // dead job.
451
210
  try {
452
- // -- Phase 2: invoke (NOT locked) --
453
- try {
454
- if (this.onJobDue) {
455
- const result = await this.onJobDue(job);
456
- if (result) {
457
- status = result.status || 'ok';
458
- error = result.error;
459
- summary = result.summary;
460
- }
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;
461
217
  }
462
218
  }
463
- catch (err) {
464
- status = 'error';
465
- error = describeError(err);
466
- this.log(`Job "${forLog(job.name, MAX_LOGGED_NAME_LENGTH)}" (${job.id}) failed: ${forLog(error, MAX_LOGGED_ERROR_LENGTH)}`);
467
- }
468
219
  }
469
- finally {
470
- // -- Phase 3: settle (locked) --
471
- 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}`);
472
224
  }
473
- return settled;
474
- }
475
- /**
476
- * Phase 1 — claim. Must be called while holding the lock (`locked()`, whose
477
- * chain is module-global and therefore shared across CronService instances).
478
- *
479
- * Returns `null` on a successful claim, or the reason the claim was refused.
480
- * `'already running'` is what makes a second `run()` report a skip instead of
481
- * launching a concurrent invocation. `'removed'` covers the job being deleted
482
- * between `run()`'s unlocked lookup and this lock turn — claiming then would
483
- * `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
484
- * belong to a replacement.
485
- *
486
- * Detaching from the heap here — rather than relying on phase 3 to push a
487
- * fresh entry — is what stops manual runs permanently duplicating entries.
488
- *
489
- * `#private`: published, this would be a supported call performing
490
- * `markRunning` + `removeFromHeap` with no guaranteed settle and no lease on
491
- * `runningAtMs`, so a single such call would strand the job forever. The
492
- * lock-held precondition cannot be expressed in the type system, so the
493
- * method must not be reachable from outside the class body.
494
- */
495
- #claimJob(job) {
496
- if (this.jobs.get(job.id) !== job)
497
- return 'removed';
498
- if (job.state.runningAtMs)
499
- return 'already running';
500
- markRunning(job);
501
- this.removeFromHeap(job.id);
502
- return null;
503
- }
504
- /**
505
- * Phase 3 — settle. Must be called while holding the lock.
506
- *
507
- * `#private` for the same reason as `#claimJob`: unlocked it would run
508
- * `applyResult`, a `runLog.record`, a full `removeFromHeap` rebuild, a
509
- * `heap.push` and an `armTimer` with no mutual exclusion — exactly the
510
- * corruption `locked()` exists to prevent.
511
- */
512
- #settleJob(job, status, error, summary, startMs, durationMs) {
513
- try {
514
- const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
515
- applyResult(job, validStatus, error, durationMs);
516
- // The callback ran unlocked, so this job may have been removed — or
517
- // removed and re-registered under the same id, the shape
518
- // `start(initialJobs)` uses — while it was in flight. Identity, not id.
519
- //
520
- // Deliberately touch NOTHING here. The claim already detached this job's
521
- // heap entry and nothing re-added it, so there is nothing to clean up;
522
- // any entry now filed under this id belongs to the replacement, and
523
- // removing it by id would silently unschedule a live job. Do not
524
- // resurrect a removed job's heap entry or run log either.
525
- if (this.jobs.get(job.id) !== job) {
526
- return { status, error, summary, durationMs };
527
- }
528
- // Log the run
529
- this.runLog.record({
530
- jobId: job.id,
531
- status,
532
- error,
533
- summary,
534
- runAtMs: startMs,
535
- durationMs,
536
- nextRunAtMs: job.state.nextRunAtMs,
537
- });
538
- // Handle one-shot auto-delete. The callback ran unlocked and may have
539
- // pushed a heap entry for this job via add()/update(), so drop it — the
540
- // job is about to stop existing.
541
- if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
542
- this.jobs.delete(job.id);
543
- this.removeFromHeap(job.id);
544
- this.runLog.removeJob(job.id);
545
- return { status, summary, deleted: true };
546
- }
547
- // Re-insert into the heap if still active. Same reason as above: drop any
548
- // entry the unlocked callback added for this job first, to preserve
549
- // one-entry-per-key.
550
- this.removeFromHeap(job.id);
551
- if (job.enabled && job.state.nextRunAtMs) {
552
- this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
553
- }
554
- 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 };
555
243
  }
556
- finally {
557
- // One re-arm covering every exit, rather than one per branch. The claim
558
- // detached this job from the heap, so a timer that fired during the
559
- // unlocked invoke would have found nothing to arm — and `run()` has no
560
- // `finally { armTimer() }` of its own the way `onTimer` does. Without
561
- // this, a manual run() can leave the scheduler with no pending wake.
562
- 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 });
563
247
  }
248
+ return { status, error, summary, durationMs };
564
249
  }
565
250
  // -- Helpers ---------------------------------------------------------
566
251
  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.52",
6
+ "version": "0.2.1-alpha.54",
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",