@stonyx/cron 0.2.1-alpha.60 → 0.2.1-alpha.61
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 +1 -1
- package/dist/service.d.ts +32 -1
- package/dist/service.js +141 -24
- 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
|
|
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 be made silent by `config.cron.log`.
|
|
55
55
|
|
|
56
56
|
```js
|
|
57
57
|
import CronService from '@stonyx/cron/service';
|
package/dist/service.d.ts
CHANGED
|
@@ -44,7 +44,38 @@ export default class CronService {
|
|
|
44
44
|
onJobDue: OnJobDueCallback | null;
|
|
45
45
|
constructor();
|
|
46
46
|
/**
|
|
47
|
-
* Start the service. Loads jobs from store (if any), arms timer.
|
|
47
|
+
* Start the service. Loads jobs from store (if any), arms timer. A no-op if
|
|
48
|
+
* already started.
|
|
49
|
+
*
|
|
50
|
+
* `initialJobs` crosses a serialization boundary — it is whatever the
|
|
51
|
+
* consumer's store handed back — so `Job[]` is a compile-time claim about
|
|
52
|
+
* runtime data. Three behaviours follow from that and are worth knowing
|
|
53
|
+
* before you call this, because all three are deliberate and two of them
|
|
54
|
+
* differ from a plain "load and arm":
|
|
55
|
+
*
|
|
56
|
+
* 1. WRITES TO `row.state`. A STALE claim (`state.runningAtMs` set by a
|
|
57
|
+
* process that is gone) is released, because nothing else ever will —
|
|
58
|
+
* there is no lease on the field (#35) — and left in place it is a job
|
|
59
|
+
* that is dead forever while `status()` reports it healthy. A LIVE claim,
|
|
60
|
+
* held by an invocation still running in this process, is left alone:
|
|
61
|
+
* releasing it would let the timer start a second concurrent invocation of
|
|
62
|
+
* a job that is already running.
|
|
63
|
+
*
|
|
64
|
+
* 2. THROWS on a row this class cannot use, rather than accepting it. A row
|
|
65
|
+
* whose `state` is missing, or frozen (`structuredClone` + `Object.freeze`
|
|
66
|
+
* is an ordinary defensive rehydration), throws out of `start()` where the
|
|
67
|
+
* caller's own `await` can catch it. The alternative is a `TypeError` from
|
|
68
|
+
* inside a bare timer callback later — an unhandled rejection, and
|
|
69
|
+
* process-fatal under Node's default.
|
|
70
|
+
*
|
|
71
|
+
* 3. ARMS THE TIMER EVEN IF IT THROWS. The rows loaded before the throw are
|
|
72
|
+
* registered and scheduled. Without this, a throw leaves `started: true`
|
|
73
|
+
* (so a retry is a no-op) with jobs in the heap and no timer: nothing ever
|
|
74
|
+
* fires and `status()` still reports healthy.
|
|
75
|
+
*
|
|
76
|
+
* Hand it deserialized rows and all three are invisible. Hand it live `Job`
|
|
77
|
+
* objects this service is currently executing and only 1 is observable, by
|
|
78
|
+
* design.
|
|
48
79
|
*/
|
|
49
80
|
start(initialJobs?: Job[]): Promise<void>;
|
|
50
81
|
/**
|
package/dist/service.js
CHANGED
|
@@ -43,9 +43,39 @@ const MAX_LOGGED_NAME_LENGTH = 120;
|
|
|
43
43
|
* text. Newlines become the literal two characters so the content survives for
|
|
44
44
|
* a reader, and the length cap keeps one pathological value from swamping the
|
|
45
45
|
* file.
|
|
46
|
+
*
|
|
47
|
+
* TOTAL, for the same reason `describeError` below is total, and the parameter
|
|
48
|
+
* is coerced even though it is typed `string`. `job.name` is typed `string` but
|
|
49
|
+
* that is a compile-time claim about runtime data: `normalize.ts` only
|
|
50
|
+
* GENERATES a name when the field is falsy, so `add({ name: 12345 })` stores a
|
|
51
|
+
* number through the public API, and `start(initialJobs)` takes names verbatim
|
|
52
|
+
* from the consumer's store \u2014 the same untrusted boundary `start()` already
|
|
53
|
+
* hardens `state` against.
|
|
54
|
+
*
|
|
55
|
+
* Fixed HERE rather than by coercing in `normalize`, deliberately. Coercing at
|
|
56
|
+
* `normalize` closes the `add()` path only; the rehydration path bypasses both
|
|
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 \u2014 a helper that
|
|
59
|
+
* throws while building an error report destroys the report it exists to
|
|
60
|
+
* produce. Measured pre-fix: `run()` rejected with `TypeError: value.replace is
|
|
61
|
+
* not a function` instead of returning an `ExecuteResult`, and on the timer
|
|
62
|
+
* path the failure record was swallowed entirely (0 records for a job that
|
|
63
|
+
* failed) because the throw happened inside the reporter's own `try`.
|
|
64
|
+
*
|
|
65
|
+
* `String(value)` alone is NOT enough: a value whose `toString` or
|
|
66
|
+
* `Symbol.toPrimitive` throws raises out of the coercion itself, so the `try`
|
|
67
|
+
* is load-bearing and not belt-and-braces. Kept typed `string` rather than
|
|
68
|
+
* widened to `unknown` so call sites still get compile-time pressure; the
|
|
69
|
+
* runtime coercion is the defence, not the signature.
|
|
46
70
|
*/
|
|
47
71
|
function forLog(value, maxLength) {
|
|
48
|
-
|
|
72
|
+
let flattened;
|
|
73
|
+
try {
|
|
74
|
+
flattened = String(value).replace(/\r\n|[\r\n\u2028\u2029]/g, '\\n');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return '<unrenderable value>';
|
|
78
|
+
}
|
|
49
79
|
return flattened.length > maxLength ? `${flattened.slice(0, maxLength)}...` : flattened;
|
|
50
80
|
}
|
|
51
81
|
/**
|
|
@@ -70,8 +100,11 @@ function forLog(value, maxLength) {
|
|
|
70
100
|
* differ: it renders for a log line only, so it prefers `err.stack`; this one
|
|
71
101
|
* is also returned to the caller as `ExecuteResult.error` and persisted in a
|
|
72
102
|
* per-job run log, where a stack would be an unbounded blob in every stored
|
|
73
|
-
* failure. Recorded in `docs/architecture.md` under
|
|
74
|
-
*
|
|
103
|
+
* failure. Recorded in `docs/architecture.md` under Code Patterns &
|
|
104
|
+
* Conventions -> Private Members -> "Two `describeError` helpers, deliberately
|
|
105
|
+
* not shared (#34 / #36)" — do not merge them into a shared helper without
|
|
106
|
+
* reading that first. NOT the `### Error Handling` section further down, which
|
|
107
|
+
* is about the legacy `Cron` and does not carry this decision.
|
|
75
108
|
*/
|
|
76
109
|
function describeError(err) {
|
|
77
110
|
try {
|
|
@@ -81,6 +114,33 @@ function describeError(err) {
|
|
|
81
114
|
return 'unknown error';
|
|
82
115
|
}
|
|
83
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Job objects whose claim is held by an invocation still running IN THIS
|
|
119
|
+
* PROCESS. Added by phase 1, removed by phase 3 (or by the one hand-release in
|
|
120
|
+
* `#executeClaimed`), so membership is exactly "a settle is still coming".
|
|
121
|
+
*
|
|
122
|
+
* This exists so `start()` can tell a STALE claim from a LIVE one. Both look
|
|
123
|
+
* identical in `job.state.runningAtMs` — a number — but they need opposite
|
|
124
|
+
* treatment: a stale claim must be released (it is #34's permanently-dead job,
|
|
125
|
+
* and nothing reaps it) while a live one must be left alone (releasing it lets
|
|
126
|
+
* the timer launch a second concurrent invocation of a job that is already
|
|
127
|
+
* running, breaking the one invariant this class advertises).
|
|
128
|
+
*
|
|
129
|
+
* Keyed on the Job OBJECT, not the id, and module-level rather than per
|
|
130
|
+
* instance, for the two reasons that make those the only workable choices:
|
|
131
|
+
*
|
|
132
|
+
* - Object identity is what makes it correct across `CronService` instances.
|
|
133
|
+
* A second service handed live rows sees the same objects, so it inherits
|
|
134
|
+
* the answer rather than guessing; a per-instance set would report "not in
|
|
135
|
+
* flight" for a claim a sibling instance is holding. `locked()`'s chain is
|
|
136
|
+
* already module-global for the same reason.
|
|
137
|
+
* - Identity is also what makes it correct after a real crash. A restart
|
|
138
|
+
* deserializes its rows, so those are new objects and are never members —
|
|
139
|
+
* the #34 release still fires, which is the whole point of it.
|
|
140
|
+
*
|
|
141
|
+
* `WeakSet`, so a job that is removed and dropped mid-flight is not retained.
|
|
142
|
+
*/
|
|
143
|
+
const inFlight = new WeakSet();
|
|
84
144
|
export default class CronService {
|
|
85
145
|
jobs;
|
|
86
146
|
heap;
|
|
@@ -101,7 +161,38 @@ export default class CronService {
|
|
|
101
161
|
}
|
|
102
162
|
// -- Lifecycle -------------------------------------------------------
|
|
103
163
|
/**
|
|
104
|
-
* Start the service. Loads jobs from store (if any), arms timer.
|
|
164
|
+
* Start the service. Loads jobs from store (if any), arms timer. A no-op if
|
|
165
|
+
* already started.
|
|
166
|
+
*
|
|
167
|
+
* `initialJobs` crosses a serialization boundary — it is whatever the
|
|
168
|
+
* consumer's store handed back — so `Job[]` is a compile-time claim about
|
|
169
|
+
* runtime data. Three behaviours follow from that and are worth knowing
|
|
170
|
+
* before you call this, because all three are deliberate and two of them
|
|
171
|
+
* differ from a plain "load and arm":
|
|
172
|
+
*
|
|
173
|
+
* 1. WRITES TO `row.state`. A STALE claim (`state.runningAtMs` set by a
|
|
174
|
+
* process that is gone) is released, because nothing else ever will —
|
|
175
|
+
* there is no lease on the field (#35) — and left in place it is a job
|
|
176
|
+
* that is dead forever while `status()` reports it healthy. A LIVE claim,
|
|
177
|
+
* held by an invocation still running in this process, is left alone:
|
|
178
|
+
* releasing it would let the timer start a second concurrent invocation of
|
|
179
|
+
* a job that is already running.
|
|
180
|
+
*
|
|
181
|
+
* 2. THROWS on a row this class cannot use, rather than accepting it. A row
|
|
182
|
+
* whose `state` is missing, or frozen (`structuredClone` + `Object.freeze`
|
|
183
|
+
* is an ordinary defensive rehydration), throws out of `start()` where the
|
|
184
|
+
* caller's own `await` can catch it. The alternative is a `TypeError` from
|
|
185
|
+
* inside a bare timer callback later — an unhandled rejection, and
|
|
186
|
+
* process-fatal under Node's default.
|
|
187
|
+
*
|
|
188
|
+
* 3. ARMS THE TIMER EVEN IF IT THROWS. The rows loaded before the throw are
|
|
189
|
+
* registered and scheduled. Without this, a throw leaves `started: true`
|
|
190
|
+
* (so a retry is a no-op) with jobs in the heap and no timer: nothing ever
|
|
191
|
+
* fires and `status()` still reports healthy.
|
|
192
|
+
*
|
|
193
|
+
* Hand it deserialized rows and all three are invisible. Hand it live `Job`
|
|
194
|
+
* objects this service is currently executing and only 1 is observable, by
|
|
195
|
+
* design.
|
|
105
196
|
*/
|
|
106
197
|
async start(initialJobs) {
|
|
107
198
|
if (this.started)
|
|
@@ -121,15 +212,15 @@ export default class CronService {
|
|
|
121
212
|
try {
|
|
122
213
|
if (initialJobs) {
|
|
123
214
|
for (const job of initialJobs) {
|
|
124
|
-
// A `runningAtMs`
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
215
|
+
// A STALE `runningAtMs` records a claim taken by a process that is
|
|
216
|
+
// gone. Nothing will ever settle it, and nothing reaps it — there is
|
|
217
|
+
// no lease on the field (tracked on #35). Left in place it is a
|
|
218
|
+
// permanently dead job that still reports healthy: `isDue` returns
|
|
219
|
+
// false forever because of the flag, `run()` answers
|
|
220
|
+
// `'already running'` forever, `update()` never touches
|
|
221
|
+
// `state.runningAtMs`, and `status()` counts it like any other. The
|
|
222
|
+
// consumer's only recovery would be remove() + add(), losing the job
|
|
223
|
+
// id and its run history.
|
|
133
224
|
//
|
|
134
225
|
// Same hazard, same treatment as the hand-release on the `'removed'`
|
|
135
226
|
// path in `#executeClaimed`: a claim with no reachable settle must be
|
|
@@ -138,19 +229,36 @@ export default class CronService {
|
|
|
138
229
|
// run, so it gets no run-log row, no `lastStatus`, and no recomputed
|
|
139
230
|
// `nextRunAtMs`; it is rescheduled from the store's own value below.
|
|
140
231
|
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
232
|
+
// But NOT every `runningAtMs` here is stale, and the field cannot
|
|
233
|
+
// tell you which — it is a number either way. `start()` early-returns
|
|
234
|
+
// when `started`, so reaching this with a LIVE claim needs `stop()`
|
|
235
|
+
// then `start(sameObjects)` (an in-process restart against a store
|
|
236
|
+
// that hands back references rather than fresh rows) or a second
|
|
237
|
+
// `CronService` handed live rows. Measured on the unconditional
|
|
238
|
+
// version: the release cleared a live claim, the timer then found the
|
|
239
|
+
// job due, and one job got TWO concurrent in-flight callbacks. That
|
|
240
|
+
// is the single invariant this class advertises and that #34/#35
|
|
241
|
+
// exist to protect, so the release is guarded on `inFlight` —
|
|
242
|
+
// authoritative object identity, not a heuristic on the timestamp.
|
|
243
|
+
// See `inFlight`'s docblock for why identity is also what keeps the
|
|
244
|
+
// stale case working after a real crash.
|
|
245
|
+
//
|
|
246
|
+
// The guard is deliberately NOT a `job.state.runningAtMs` read.
|
|
247
|
+
// Guarding on THAT makes `start()` accept a row whose `state` is
|
|
248
|
+
// frozen — `structuredClone` + `Object.freeze` is an ordinary
|
|
249
|
+
// defensive rehydration — and that row is not usable by this class at
|
|
250
|
+
// all: `markRunning` writes the same field on every execution.
|
|
251
|
+
// Measured, that guard moves the failure from a throw out of
|
|
252
|
+
// `start()`, which the consumer's own `await` can catch, to a
|
|
148
253
|
// TypeError raised inside `onTimer`'s batch claim — a bare timer
|
|
149
254
|
// callback, so it surfaces as an unhandled rejection and is
|
|
150
|
-
// process-fatal under Node's default.
|
|
151
|
-
//
|
|
152
|
-
// the
|
|
153
|
-
|
|
255
|
+
// process-fatal under Node's default. `inFlight` does not have that
|
|
256
|
+
// problem: a deserialized row is never a member, so the write still
|
|
257
|
+
// happens and the frozen row still fails loudly at the boundary. Both
|
|
258
|
+
// properties are tested; do not collapse the two guards into one.
|
|
259
|
+
if (!inFlight.has(job)) {
|
|
260
|
+
job.state.runningAtMs = undefined;
|
|
261
|
+
}
|
|
154
262
|
this.jobs.set(job.id, job);
|
|
155
263
|
if (job.enabled && job.state.nextRunAtMs) {
|
|
156
264
|
this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
|
|
@@ -343,6 +451,7 @@ export default class CronService {
|
|
|
343
451
|
const due = this.findDueJobs(nowMs);
|
|
344
452
|
for (const job of due) {
|
|
345
453
|
markRunning(job);
|
|
454
|
+
inFlight.add(job);
|
|
346
455
|
}
|
|
347
456
|
return due;
|
|
348
457
|
});
|
|
@@ -470,6 +579,7 @@ export default class CronService {
|
|
|
470
579
|
// recomputed `nextRunAtMs` — the job did not run.
|
|
471
580
|
if (this.jobs.get(job.id) !== job) {
|
|
472
581
|
job.state.runningAtMs = undefined;
|
|
582
|
+
inFlight.delete(job);
|
|
473
583
|
return { status: 'skipped', reason: 'removed' };
|
|
474
584
|
}
|
|
475
585
|
const startMs = Date.now();
|
|
@@ -533,6 +643,7 @@ export default class CronService {
|
|
|
533
643
|
if (job.state.runningAtMs)
|
|
534
644
|
return 'already running';
|
|
535
645
|
markRunning(job);
|
|
646
|
+
inFlight.add(job);
|
|
536
647
|
this.removeFromHeap(job.id);
|
|
537
648
|
return null;
|
|
538
649
|
}
|
|
@@ -589,6 +700,12 @@ export default class CronService {
|
|
|
589
700
|
return { status, error, summary, durationMs };
|
|
590
701
|
}
|
|
591
702
|
finally {
|
|
703
|
+
// The invocation is over, so this job is no longer in flight in this
|
|
704
|
+
// process — whatever `applyResult` did or did not manage to write. In the
|
|
705
|
+
// `finally` so it covers a throw out of `applyResult` or `runLog.record`
|
|
706
|
+
// too: past this point no settle is coming, which is precisely the state
|
|
707
|
+
// `start()`'s release is for.
|
|
708
|
+
inFlight.delete(job);
|
|
592
709
|
// One re-arm covering every exit, rather than one per branch. The claim
|
|
593
710
|
// detached this job from the heap, so a timer that fired during the
|
|
594
711
|
// unlocked invoke would have found nothing to arm — and `run()` has no
|