@stonyx/cron 0.2.1-alpha.61 → 0.2.1-alpha.62
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 +43 -7
- package/dist/main.js +22 -6
- package/dist/service.d.ts +11 -3
- package/dist/service.js +13 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ When a job is executed, its next trigger time is updated, and it is re-inserted
|
|
|
51
51
|
|
|
52
52
|
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.
|
|
53
53
|
|
|
54
|
-
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 as an ungated `log.warn` line, one per stuck run, never as a value. `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. The two therefore agree on one more thing than the contrast suggests: neither can
|
|
54
|
+
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 as an ungated `log.warn` line, one per stuck run, never as a value. `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. The two therefore agree on one more thing than the contrast suggests: neither can have a *refused* run made silent by `config.cron.log` — `Cron`'s skip warning is ungated, and `CronService`'s refusal is a return value rather than a log line. A job *failure* is the opposite case, and there the two classes genuinely diverge; see [Configuration](#configuration).
|
|
55
55
|
|
|
56
56
|
```js
|
|
57
57
|
import CronService from '@stonyx/cron/service';
|
|
@@ -81,14 +81,31 @@ const result = await service.run(job.id, 'force');
|
|
|
81
81
|
|
|
82
82
|
**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.
|
|
83
83
|
|
|
84
|
+
**The residual, stated plainly.** Taking the callback out of the lock fixes
|
|
85
|
+
`add`/`update`/`remove`; it does **not** bound the callback. A callback that
|
|
86
|
+
never settles still holds its job's claim forever, and because the timer's
|
|
87
|
+
re-entrancy latch is only released when the batch settles, it also **stops the
|
|
88
|
+
timer loop for every other job** — silently, with `status()` still reporting
|
|
89
|
+
`started: true`. There is no execution timeout by design, so bounding your own
|
|
90
|
+
callback is your responsibility, exactly as it is for `Cron`. Only a real
|
|
91
|
+
process restart recovers it: `start()` releases a claim whose owner is provably
|
|
92
|
+
gone, but an object still held in this process is deliberately left alone.
|
|
93
|
+
Tracked as #35.
|
|
94
|
+
|
|
84
95
|
### Breaking changes in this line
|
|
85
96
|
|
|
86
|
-
|
|
97
|
+
Five consumer-visible changes landed with the phase split (#34). Only the first
|
|
98
|
+
two are visible in the emitted `dist/service.d.ts`; the rest are runtime
|
|
99
|
+
behaviour and a type-checker will not find them for you. The measured
|
|
100
|
+
`dist/service.d.ts` delta against the previous release is exactly three things:
|
|
101
|
+
the class gained `#private;`, `reason` narrowed, and six type declarations
|
|
102
|
+
gained `export`. `dist/main.d.ts` is unchanged.
|
|
87
103
|
|
|
88
104
|
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`.
|
|
89
105
|
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.
|
|
90
|
-
3. **`claimJob`, `settleJob` and `executeClaimed` are not published.**
|
|
91
|
-
4. **`run()` no longer serializes across jobs** —
|
|
106
|
+
3. **`claimJob`, `settleJob` and `executeClaimed` are not published.** Not a change against the previous release — these members did not exist there at all. Listed because they are new internals that look like API and are deliberately unreachable: a claim taken without its matching settle strands the job permanently in-process. Guarded by `test/unit/publish-surface-test.ts`.
|
|
107
|
+
4. **`run()` no longer serializes across jobs** — a runtime change, not a d.ts one. See the concurrency note above.
|
|
108
|
+
5. **`start()` now throws on a row it cannot use.** A `Job` whose `state` is missing or frozen — `structuredClone` + `Object.freeze` is an ordinary defensive rehydration — makes `start()` reject where the previous release resolved and carried on. Measured: previous release resolves and leaves the stale claim in place; this line throws `TypeError: Cannot assign to read only property 'runningAtMs'`. The timer is still armed for the rows loaded before the throw, so this surfaces at your `await` instead of as an unhandled rejection from inside a timer callback later. **Migration:** if your store hands back frozen rows, thaw `state` before passing them, or catch at the `start()` call site.
|
|
92
109
|
|
|
93
110
|
`SkipReason`, `ExecuteResult`, `JobDueResult`, `ServiceStatus`, `ListOptions` and `OnJobDueCallback` are all exported from `@stonyx/cron/service`, so an exhaustive handler over `reason` is expressible.
|
|
94
111
|
|
|
@@ -104,9 +121,28 @@ config.cron = {
|
|
|
104
121
|
config.debug = true; // optional: debug logs for job registration and execution
|
|
105
122
|
```
|
|
106
123
|
|
|
107
|
-
`config.cron.log` gates
|
|
108
|
-
|
|
109
|
-
|
|
124
|
+
`config.cron.log` gates the `log.cron` channel only. **The two classes route job
|
|
125
|
+
failures differently, so what `log: false` costs you depends on which one you are
|
|
126
|
+
running.** Measured on this build, with a callback that throws:
|
|
127
|
+
|
|
128
|
+
| | Failure channel | Records with `log: false` |
|
|
129
|
+
| :--- | :--- | :--- |
|
|
130
|
+
| `Cron` (default export) | ungated `log.error` | emitted — unchanged |
|
|
131
|
+
| `CronService` | **gated** `log.cron` | **none** |
|
|
132
|
+
|
|
133
|
+
- **`Cron`** reports a callback's throw or rejection through `log.error`, and the
|
|
134
|
+
stuck-job warning described above through `log.warn`. Neither is gated, so
|
|
135
|
+
setting `log` to `false` cannot make a dropped or failed execution silent.
|
|
136
|
+
- **`CronService`** reports a job whose `onJobDue` callback throws through the
|
|
137
|
+
**gated** channel, so `log: false` yields **zero** log records for that
|
|
138
|
+
failure. It stays observable as `ExecuteResult.error` and as a run-log row —
|
|
139
|
+
but a job driven off the timer rather than `run()` has no caller to read that
|
|
140
|
+
return value, so the failure is visible only in the run log. Only the timer
|
|
141
|
+
path's *unexpected* internal throw (a fault in the scheduler itself, not in
|
|
142
|
+
your callback) is reported on the ungated `log.error` channel.
|
|
143
|
+
|
|
144
|
+
If you set `log: false` in production and rely on `CronService`, read failures
|
|
145
|
+
from the run log or from `ExecuteResult`, not from the log file.
|
|
110
146
|
|
|
111
147
|
## License
|
|
112
148
|
|
package/dist/main.js
CHANGED
|
@@ -29,12 +29,28 @@ const MAX_LOGGED_ERROR_LENGTH = 512;
|
|
|
29
29
|
* become the literal two characters so the content survives for a reader, and
|
|
30
30
|
* the length cap keeps one pathological value from swamping the file.
|
|
31
31
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
32
|
+
* DIVERGED from the `forLog` in `src/service.ts`, deliberately — this copy is
|
|
33
|
+
* NOT byte-identical to it and must not be folded into it by assuming it is.
|
|
34
|
+
* (An earlier version of this docblock claimed byte-identity; #34's `fac09cb`
|
|
35
|
+
* falsified that, and the two bodies now measure unequal.) `service.ts`'s is
|
|
36
|
+
* TOTAL: it wraps the coercion in `String(value)` and a `try`, returning
|
|
37
|
+
* `'<unrenderable value>'` rather than throwing. This one is not.
|
|
38
|
+
*
|
|
39
|
+
* The divergence is correct on the merits, and the reason is the call site, not
|
|
40
|
+
* the helper. Both of this copy's callers are the two `forLog(...)` calls in
|
|
41
|
+
* `describeError` directly below (`:82`, `:85`), and both are INSIDE that
|
|
42
|
+
* function's own `try`, so a `TypeError` from `value.replace` on a
|
|
43
|
+
* non-string degrades to `'<thrown value could not be rendered>'` and the log
|
|
44
|
+
* record is still produced. `service.ts`'s callers are not so contained: `:692`
|
|
45
|
+
* sits in a bare `catch` with nothing above it, and `:571`'s enclosing `catch`
|
|
46
|
+
* has nothing left to report to — so a throw there destroyed the failure
|
|
47
|
+
* record outright (measured: 0 records for a job that failed). That is why
|
|
48
|
+
* totality was a live defect there and is not one here.
|
|
49
|
+
*
|
|
50
|
+
* Duplicated rather than shared because the two landed on separate branches.
|
|
51
|
+
* The fold is still wanted, but whoever performs it MUST adopt `service.ts`'s
|
|
52
|
+
* total version as the survivor: folding to this one would silently revert
|
|
53
|
+
* `fac09cb`. See #66, which carries the consolidation.
|
|
38
54
|
*/
|
|
39
55
|
function forLog(value, maxLength) {
|
|
40
56
|
const flattened = value.replace(/\r\n|[\r\n\u2028\u2029]/g, '\\n');
|
package/dist/service.d.ts
CHANGED
|
@@ -73,9 +73,17 @@ export default class CronService {
|
|
|
73
73
|
* (so a retry is a no-op) with jobs in the heap and no timer: nothing ever
|
|
74
74
|
* fires and `status()` still reports healthy.
|
|
75
75
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* Which of the three you can observe depends on the CONTENT of the rows, not
|
|
77
|
+
* on whether they were deserialized — 1 fires only on a row that already
|
|
78
|
+
* carries a claim, and 2/3 only on a row this class cannot use. Hand it
|
|
79
|
+
* well-formed deserialized rows with no claim set and none of the three is
|
|
80
|
+
* observable. Hand it a deserialized row that DOES carry one and 1 and 2 are
|
|
81
|
+
* exactly what you get: measured, a stale `state.runningAtMs` of 1 comes back
|
|
82
|
+
* `undefined`, and a `structuredClone` + `Object.freeze` row makes `start()`
|
|
83
|
+
* throw `TypeError: Cannot assign to read only property 'runningAtMs'` with
|
|
84
|
+
* the timer still armed behind it. Hand it live `Job` objects this service is
|
|
85
|
+
* currently executing and only 1 is in play, by design — and on those it
|
|
86
|
+
* deliberately does nothing.
|
|
79
87
|
*/
|
|
80
88
|
start(initialJobs?: Job[]): Promise<void>;
|
|
81
89
|
/**
|
package/dist/service.js
CHANGED
|
@@ -49,13 +49,13 @@ const MAX_LOGGED_NAME_LENGTH = 120;
|
|
|
49
49
|
* that is a compile-time claim about runtime data: `normalize.ts` only
|
|
50
50
|
* GENERATES a name when the field is falsy, so `add({ name: 12345 })` stores a
|
|
51
51
|
* number through the public API, and `start(initialJobs)` takes names verbatim
|
|
52
|
-
* from the consumer's store
|
|
52
|
+
* from the consumer's store — the same untrusted boundary `start()` already
|
|
53
53
|
* hardens `state` against.
|
|
54
54
|
*
|
|
55
55
|
* Fixed HERE rather than by coercing in `normalize`, deliberately. Coercing at
|
|
56
56
|
* `normalize` closes the `add()` path only; the rehydration path bypasses both
|
|
57
57
|
* `normalize` and `createJob` entirely, and that is the path this class already
|
|
58
|
-
* treats as hostile. And this helper is on the ERROR path
|
|
58
|
+
* treats as hostile. And this helper is on the ERROR path — a helper that
|
|
59
59
|
* throws while building an error report destroys the report it exists to
|
|
60
60
|
* produce. Measured pre-fix: `run()` rejected with `TypeError: value.replace is
|
|
61
61
|
* not a function` instead of returning an `ExecuteResult`, and on the timer
|
|
@@ -190,9 +190,17 @@ export default class CronService {
|
|
|
190
190
|
* (so a retry is a no-op) with jobs in the heap and no timer: nothing ever
|
|
191
191
|
* fires and `status()` still reports healthy.
|
|
192
192
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
193
|
+
* Which of the three you can observe depends on the CONTENT of the rows, not
|
|
194
|
+
* on whether they were deserialized — 1 fires only on a row that already
|
|
195
|
+
* carries a claim, and 2/3 only on a row this class cannot use. Hand it
|
|
196
|
+
* well-formed deserialized rows with no claim set and none of the three is
|
|
197
|
+
* observable. Hand it a deserialized row that DOES carry one and 1 and 2 are
|
|
198
|
+
* exactly what you get: measured, a stale `state.runningAtMs` of 1 comes back
|
|
199
|
+
* `undefined`, and a `structuredClone` + `Object.freeze` row makes `start()`
|
|
200
|
+
* throw `TypeError: Cannot assign to read only property 'runningAtMs'` with
|
|
201
|
+
* the timer still armed behind it. Hand it live `Job` objects this service is
|
|
202
|
+
* currently executing and only 1 is in play, by design — and on those it
|
|
203
|
+
* deliberately does nothing.
|
|
196
204
|
*/
|
|
197
205
|
async start(initialJobs) {
|
|
198
206
|
if (this.started)
|