@stonyx/cron 0.2.1-alpha.60 → 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 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 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.
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
- Four consumer-visible changes landed with the phase split (#34). All are measured against the emitted `dist/service.d.ts`:
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.** They were never a supported API; a claim taken without its matching settle strands the job permanently.
91
- 4. **`run()` no longer serializes across jobs** — see the concurrency note above.
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 **informational** messages only. Error reports and the
108
- stuck-job warning described above are never gated by it, so setting it to `false`
109
- cannot make a dropped execution silent.
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
- * Kept byte-identical to the `forLog` landing in `src/service.ts` on #34: the
33
- * two tiers render the same untrusted values into the same log file, and a
34
- * reader diagnosing a forged record should not have to know which tier wrote
35
- * it. Duplicated rather than shared because the two land on separate branches;
36
- * folding them into one helper is a follow-up once both are on `dev`, not a
37
- * cross-PR dependency that would make either unmergeable alone.
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
@@ -44,7 +44,46 @@ 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
+ * 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.
48
87
  */
49
88
  start(initialJobs?: Job[]): Promise<void>;
50
89
  /**
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 — 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 — 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
- const flattened = value.replace(/\r\n|[\r\n\u2028\u2029]/g, '\\n');
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 Error Handling — do not
74
- * merge them into a shared helper without reading that first.
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,46 @@ 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
+ * 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.
105
204
  */
106
205
  async start(initialJobs) {
107
206
  if (this.started)
@@ -121,15 +220,15 @@ export default class CronService {
121
220
  try {
122
221
  if (initialJobs) {
123
222
  for (const job of initialJobs) {
124
- // A `runningAtMs` on a rehydrated job is always stale. The claim it
125
- // records was taken by a process that is gone, so nothing will ever
126
- // settle it, and nothing reaps it — there is no lease on the field
127
- // (tracked on #35). Left in place it is a permanently dead job that
128
- // still reports healthy: `isDue` returns false forever because of the
129
- // flag, `run()` answers `'already running'` forever, `update()` never
130
- // touches `state.runningAtMs`, and `status()` counts it like any other.
131
- // The consumer's only recovery would be remove() + add(), losing the
132
- // job id and its run history.
223
+ // A STALE `runningAtMs` records a claim taken by a process that is
224
+ // gone. Nothing will ever settle it, and nothing reaps it — there is
225
+ // no lease on the field (tracked on #35). Left in place it is a
226
+ // permanently dead job that still reports healthy: `isDue` returns
227
+ // false forever because of the flag, `run()` answers
228
+ // `'already running'` forever, `update()` never touches
229
+ // `state.runningAtMs`, and `status()` counts it like any other. The
230
+ // consumer's only recovery would be remove() + add(), losing the job
231
+ // id and its run history.
133
232
  //
134
233
  // Same hazard, same treatment as the hand-release on the `'removed'`
135
234
  // path in `#executeClaimed`: a claim with no reachable settle must be
@@ -138,19 +237,36 @@ export default class CronService {
138
237
  // run, so it gets no run-log row, no `lastStatus`, and no recomputed
139
238
  // `nextRunAtMs`; it is rescheduled from the store's own value below.
140
239
  //
141
- // Written unconditionally, and deliberately NOT guarded on a
142
- // `job.state.runningAtMs` read. Guarding it makes `start()` accept a
143
- // row whose `state` is frozen — `structuredClone` + `Object.freeze`
144
- // is an ordinary defensive rehydration — and that row is not usable
145
- // by this class at all: `markRunning` writes the same field on every
146
- // execution. Measured, the guard moves the failure from a throw out
147
- // of `start()`, which the consumer's own `await` can catch, to a
240
+ // But NOT every `runningAtMs` here is stale, and the field cannot
241
+ // tell you which — it is a number either way. `start()` early-returns
242
+ // when `started`, so reaching this with a LIVE claim needs `stop()`
243
+ // then `start(sameObjects)` (an in-process restart against a store
244
+ // that hands back references rather than fresh rows) or a second
245
+ // `CronService` handed live rows. Measured on the unconditional
246
+ // version: the release cleared a live claim, the timer then found the
247
+ // job due, and one job got TWO concurrent in-flight callbacks. That
248
+ // is the single invariant this class advertises and that #34/#35
249
+ // exist to protect, so the release is guarded on `inFlight` —
250
+ // authoritative object identity, not a heuristic on the timestamp.
251
+ // See `inFlight`'s docblock for why identity is also what keeps the
252
+ // stale case working after a real crash.
253
+ //
254
+ // The guard is deliberately NOT a `job.state.runningAtMs` read.
255
+ // Guarding on THAT makes `start()` accept a row whose `state` is
256
+ // frozen — `structuredClone` + `Object.freeze` is an ordinary
257
+ // defensive rehydration — and that row is not usable by this class at
258
+ // all: `markRunning` writes the same field on every execution.
259
+ // Measured, that guard moves the failure from a throw out of
260
+ // `start()`, which the consumer's own `await` can catch, to a
148
261
  // TypeError raised inside `onTimer`'s batch claim — a bare timer
149
262
  // callback, so it surfaces as an unhandled rejection and is
150
- // process-fatal under Node's default. Failing loudly at the store
151
- // boundary is the better of the two, and the `finally` above keeps
152
- // the rows that loaded before it scheduled.
153
- job.state.runningAtMs = undefined;
263
+ // process-fatal under Node's default. `inFlight` does not have that
264
+ // problem: a deserialized row is never a member, so the write still
265
+ // happens and the frozen row still fails loudly at the boundary. Both
266
+ // properties are tested; do not collapse the two guards into one.
267
+ if (!inFlight.has(job)) {
268
+ job.state.runningAtMs = undefined;
269
+ }
154
270
  this.jobs.set(job.id, job);
155
271
  if (job.enabled && job.state.nextRunAtMs) {
156
272
  this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
@@ -343,6 +459,7 @@ export default class CronService {
343
459
  const due = this.findDueJobs(nowMs);
344
460
  for (const job of due) {
345
461
  markRunning(job);
462
+ inFlight.add(job);
346
463
  }
347
464
  return due;
348
465
  });
@@ -470,6 +587,7 @@ export default class CronService {
470
587
  // recomputed `nextRunAtMs` — the job did not run.
471
588
  if (this.jobs.get(job.id) !== job) {
472
589
  job.state.runningAtMs = undefined;
590
+ inFlight.delete(job);
473
591
  return { status: 'skipped', reason: 'removed' };
474
592
  }
475
593
  const startMs = Date.now();
@@ -533,6 +651,7 @@ export default class CronService {
533
651
  if (job.state.runningAtMs)
534
652
  return 'already running';
535
653
  markRunning(job);
654
+ inFlight.add(job);
536
655
  this.removeFromHeap(job.id);
537
656
  return null;
538
657
  }
@@ -589,6 +708,12 @@ export default class CronService {
589
708
  return { status, error, summary, durationMs };
590
709
  }
591
710
  finally {
711
+ // The invocation is over, so this job is no longer in flight in this
712
+ // process — whatever `applyResult` did or did not manage to write. In the
713
+ // `finally` so it covers a throw out of `applyResult` or `runLog.record`
714
+ // too: past this point no settle is coming, which is precisely the state
715
+ // `start()`'s release is for.
716
+ inFlight.delete(job);
592
717
  // One re-arm covering every exit, rather than one per branch. The claim
593
718
  // detached this job from the heap, so a timer that fired during the
594
719
  // unlocked invoke would have found nothing to arm — and `run()` has no
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.60",
6
+ "version": "0.2.1-alpha.62",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "dist/main.js",
9
9
  "types": "dist/main.d.ts",