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

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
@@ -37,6 +37,51 @@ When a job is executed, its next trigger time is updated, and it is re-inserted
37
37
 
38
38
  > `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
39
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
+ ```
57
+
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.
84
+
40
85
  ## Configuration
41
86
 
42
87
  Optionally, logging and debugging can be enabled through `config.cron`:
package/dist/service.d.ts CHANGED
@@ -84,6 +84,12 @@ export default class CronService {
84
84
  * (`'removed'`). Before the phase split, a forced run against an in-flight
85
85
  * job launched a second concurrent invocation.
86
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
+ *
87
93
  * CONCURRENCY: the same job is bounded to one in-flight invocation on every
88
94
  * path, and the timer path invokes due jobs one at a time. `run()` fan-out
89
95
  * across DIFFERENT jobs is deliberately unbounded — N concurrent `run()`
@@ -91,7 +97,17 @@ export default class CronService {
91
97
  * these serialized behind the module-global lock; that serialization was the
92
98
  * bug rather than the feature (one hung callback wedged every other caller),
93
99
  * so it is not restored here. The fan-out is caller-driven and the scheduler
94
- * never produces it on its own.
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".
95
111
  */
96
112
  run(id: string, mode?: 'due' | 'force'): Promise<ExecuteResult>;
97
113
  /**
package/dist/service.js CHANGED
@@ -222,6 +222,12 @@ export default class CronService {
222
222
  * (`'removed'`). Before the phase split, a forced run against an in-flight
223
223
  * job launched a second concurrent invocation.
224
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
+ *
225
231
  * CONCURRENCY: the same job is bounded to one in-flight invocation on every
226
232
  * path, and the timer path invokes due jobs one at a time. `run()` fan-out
227
233
  * across DIFFERENT jobs is deliberately unbounded — N concurrent `run()`
@@ -229,7 +235,17 @@ export default class CronService {
229
235
  * these serialized behind the module-global lock; that serialization was the
230
236
  * bug rather than the feature (one hung callback wedged every other caller),
231
237
  * so it is not restored here. The fan-out is caller-driven and the scheduler
232
- * never produces it on its own.
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".
233
249
  */
234
250
  async run(id, mode = 'force') {
235
251
  const job = this.jobs.get(id);
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.50",
6
+ "version": "0.2.1-alpha.52",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "dist/main.js",
9
9
  "types": "dist/main.d.ts",