@stonyx/cron 0.2.1-alpha.14 → 0.2.1-alpha.15
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 +2 -0
- package/dist/main.d.ts +24 -0
- package/dist/main.js +96 -15
- package/dist/service.d.ts +1 -29
- package/dist/service.js +20 -152
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,6 +35,8 @@ 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
|
+
> Callbacks are invoked fire-and-forget: `Cron` never waits for one to settle. Two *different* jobs that fall due on the same tick may therefore overlap, and a job that is still running when it next falls due is skipped for that tick (a warning is logged). Both synchronous throws and asynchronous rejections are caught and logged; neither can stop the scheduler.
|
|
39
|
+
|
|
38
40
|
> `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
|
|
39
41
|
|
|
40
42
|
## Configuration
|
package/dist/main.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ 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. Mirrors `job.state.runningAtMs` in the service tier
|
|
9
|
+
* (`src/job.ts` `markRunning`/`applyResult`/`isDue`).
|
|
10
|
+
*/
|
|
11
|
+
runningAtMs?: number;
|
|
6
12
|
}
|
|
7
13
|
export default class Cron {
|
|
8
14
|
static instance: Cron | null;
|
|
@@ -13,8 +19,26 @@ export default class Cron {
|
|
|
13
19
|
init(): Promise<void>;
|
|
14
20
|
scheduleNextRun(): void;
|
|
15
21
|
runDueJobs(): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* The one safe way this class invokes a consumer callback.
|
|
24
|
+
*
|
|
25
|
+
* Never blocks the caller, catches synchronous throws and asynchronous
|
|
26
|
+
* rejections alike, and skips the invocation entirely when the job's previous
|
|
27
|
+
* invocation has not settled yet (fire-and-forget would otherwise let a slow
|
|
28
|
+
* job stack invocations on itself).
|
|
29
|
+
*/
|
|
30
|
+
safeInvoke(job: CronJob, runOnInit?: boolean): void;
|
|
31
|
+
/** Release a job's in-flight guard. Only ever called for the job it belongs to. */
|
|
32
|
+
release(job: CronJob): void;
|
|
16
33
|
register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
|
|
17
34
|
unregister(key: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* Parse a job interval (whole seconds, as a string) into a positive integer.
|
|
37
|
+
*
|
|
38
|
+
* Returns `null` when the value cannot be parsed at all, so callers can choose
|
|
39
|
+
* between failing fast (`register`) and falling back (`setNextTrigger`).
|
|
40
|
+
*/
|
|
41
|
+
parseInterval(interval: string): number | null;
|
|
18
42
|
setNextTrigger(job: CronJob): void;
|
|
19
43
|
log(text: string, key?: string | null): void;
|
|
20
44
|
}
|
package/dist/main.js
CHANGED
|
@@ -17,6 +17,15 @@ 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
|
+
* Floor for a job interval, in whole seconds.
|
|
22
|
+
*
|
|
23
|
+
* `runDueJobs` no longer awaits the callback, so `next.nextTrigger > now` is the
|
|
24
|
+
* drain loop's only exit condition *and* the loop has no suspension point left.
|
|
25
|
+
* An interval that fails to advance `nextTrigger` therefore spins the loop
|
|
26
|
+
* forever and blocks the event loop, rather than merely scheduling too often.
|
|
27
|
+
*/
|
|
28
|
+
const MIN_INTERVAL_SECONDS = 1;
|
|
20
29
|
export default class Cron {
|
|
21
30
|
static instance;
|
|
22
31
|
jobs = {};
|
|
@@ -55,18 +64,80 @@ export default class Cron {
|
|
|
55
64
|
const job = heap.pop();
|
|
56
65
|
if (config.debug)
|
|
57
66
|
this.log('job has been triggered', job.key);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
log.error(`Cron job "${job.key}" failed:`, err);
|
|
63
|
-
}
|
|
67
|
+
// Reschedule *before* invoking. The callback's result is not used by this
|
|
68
|
+
// class (`runDueJobs` returns void), so awaiting it bought nothing and
|
|
69
|
+
// cost the scheduler: a callback that never settled left the job absent
|
|
70
|
+
// from the heap and stopped the timer from ever re-arming.
|
|
64
71
|
this.setNextTrigger(job);
|
|
65
72
|
heap.push(job);
|
|
73
|
+
this.safeInvoke(job);
|
|
66
74
|
}
|
|
67
75
|
this.scheduleNextRun();
|
|
68
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* The one safe way this class invokes a consumer callback.
|
|
79
|
+
*
|
|
80
|
+
* Never blocks the caller, catches synchronous throws and asynchronous
|
|
81
|
+
* rejections alike, and skips the invocation entirely when the job's previous
|
|
82
|
+
* invocation has not settled yet (fire-and-forget would otherwise let a slow
|
|
83
|
+
* job stack invocations on itself).
|
|
84
|
+
*/
|
|
85
|
+
safeInvoke(job, runOnInit = false) {
|
|
86
|
+
const { key } = job;
|
|
87
|
+
const context = runOnInit ? 'failed on init:' : 'failed:';
|
|
88
|
+
// The in-flight guard lives on the job object, not in a module-level set
|
|
89
|
+
// keyed by string. That is what gives each invocation an identity: the only
|
|
90
|
+
// thing that ever clears the flag is the settle handler of the invocation
|
|
91
|
+
// that set it, and that handler closes over this exact job object. A stale
|
|
92
|
+
// handler therefore cannot release a *later* invocation's guard. It also
|
|
93
|
+
// matches the in-repo idiom one tier up (`job.state.runningAtMs`).
|
|
94
|
+
//
|
|
95
|
+
// `unregister` needs no explicit clear as a result: the flag is dropped with
|
|
96
|
+
// the job object, so a re-registered key gets a fresh object and runs
|
|
97
|
+
// immediately, while the abandoned invocation can only ever release itself.
|
|
98
|
+
if (job.runningAtMs !== undefined) {
|
|
99
|
+
log.warn(`Cron job "${key}" is still running; skipping this tick`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
job.runningAtMs = Date.now();
|
|
103
|
+
try {
|
|
104
|
+
const result = job.callback();
|
|
105
|
+
if (result && typeof result.then === 'function') {
|
|
106
|
+
Promise.resolve(result)
|
|
107
|
+
.catch(err => log.error(`Cron job "${key}" ${context}`, err))
|
|
108
|
+
.finally(() => this.release(job));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.release(job);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
this.release(job);
|
|
115
|
+
log.error(`Cron job "${key}" ${context}`, err);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Release a job's in-flight guard. Only ever called for the job it belongs to. */
|
|
119
|
+
release(job) {
|
|
120
|
+
job.runningAtMs = undefined;
|
|
121
|
+
}
|
|
69
122
|
register(key, callback, interval, runOnInit = false) {
|
|
123
|
+
const seconds = this.parseInterval(interval);
|
|
124
|
+
// Fail fast rather than clamp. An unparseable interval is a programming
|
|
125
|
+
// error with exactly one likely cause — a cron expression handed to the
|
|
126
|
+
// legacy class, which takes whole seconds — and clamping it would silently
|
|
127
|
+
// run a job intended for every 5 minutes once per second, hammering whatever
|
|
128
|
+
// the callback talks to. Throwing surfaces it at the call site, at boot,
|
|
129
|
+
// before anything is scheduled. A degenerate-but-parseable interval (`'0'`,
|
|
130
|
+
// `'-5'`) is a different case: it is interpretable as "as often as possible"
|
|
131
|
+
// and is clamped to the floor with one warning.
|
|
132
|
+
if (seconds === null) {
|
|
133
|
+
throw new TypeError(`Cron job ${JSON.stringify(key)} has an invalid interval ${JSON.stringify(interval)}: `
|
|
134
|
+
+ 'expected whole seconds (e.g. \'30\'). The legacy Cron class does not accept cron '
|
|
135
|
+
+ 'expressions — use CronService for those.');
|
|
136
|
+
}
|
|
137
|
+
if (parseInt(interval, 10) < MIN_INTERVAL_SECONDS) {
|
|
138
|
+
log.warn(`Cron job ${JSON.stringify(key)} interval ${JSON.stringify(interval)} is below the `
|
|
139
|
+
+ `${MIN_INTERVAL_SECONDS}s floor; clamping to ${MIN_INTERVAL_SECONDS}s`);
|
|
140
|
+
}
|
|
70
141
|
const job = { callback, interval, key, nextTrigger: 0 };
|
|
71
142
|
this.jobs[key] = job;
|
|
72
143
|
this.setNextTrigger(job);
|
|
@@ -74,14 +145,8 @@ export default class Cron {
|
|
|
74
145
|
if (config.debug) {
|
|
75
146
|
this.log(`job has been registered with interval: ${interval}`, key);
|
|
76
147
|
}
|
|
77
|
-
if (runOnInit)
|
|
78
|
-
|
|
79
|
-
callback();
|
|
80
|
-
}
|
|
81
|
-
catch (err) {
|
|
82
|
-
log.error(`Cron job "${key}" failed on init:`, err);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
148
|
+
if (runOnInit)
|
|
149
|
+
this.safeInvoke(job, true);
|
|
85
150
|
this.scheduleNextRun();
|
|
86
151
|
}
|
|
87
152
|
unregister(key) {
|
|
@@ -95,8 +160,24 @@ export default class Cron {
|
|
|
95
160
|
this.log('job has been unregistered', key);
|
|
96
161
|
this.scheduleNextRun();
|
|
97
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Parse a job interval (whole seconds, as a string) into a positive integer.
|
|
165
|
+
*
|
|
166
|
+
* Returns `null` when the value cannot be parsed at all, so callers can choose
|
|
167
|
+
* between failing fast (`register`) and falling back (`setNextTrigger`).
|
|
168
|
+
*/
|
|
169
|
+
parseInterval(interval) {
|
|
170
|
+
const seconds = parseInt(interval, 10);
|
|
171
|
+
if (!Number.isFinite(seconds))
|
|
172
|
+
return null;
|
|
173
|
+
return Math.max(MIN_INTERVAL_SECONDS, seconds);
|
|
174
|
+
}
|
|
98
175
|
setNextTrigger(job) {
|
|
99
|
-
|
|
176
|
+
// `register` rejects an unparseable interval up front; this floor is the
|
|
177
|
+
// backstop for a job object mutated after registration (`cron.jobs` is
|
|
178
|
+
// public, mutable state) and is what actually guarantees the drain loop
|
|
179
|
+
// terminates. Never let `nextTrigger` land on `NaN` or on `now`.
|
|
180
|
+
job.nextTrigger = getTimestamp() + (this.parseInterval(job.interval) ?? MIN_INTERVAL_SECONDS);
|
|
100
181
|
}
|
|
101
182
|
log(text, key = null) {
|
|
102
183
|
if (!config.cron?.log)
|
package/dist/service.d.ts
CHANGED
|
@@ -78,35 +78,7 @@ export default class CronService {
|
|
|
78
78
|
armTimer(): void;
|
|
79
79
|
onTimer(): Promise<void>;
|
|
80
80
|
findDueJobs(nowMs: number): Job[];
|
|
81
|
-
|
|
82
|
-
* Execute a job in three phases:
|
|
83
|
-
*
|
|
84
|
-
* 1. claim (locked) - take ownership of the job, detach it from the heap
|
|
85
|
-
* 2. invoke (UNLOCKED) - await the consumer callback
|
|
86
|
-
* 3. settle (locked) - apply the result, log it, re-insert into the heap
|
|
87
|
-
*
|
|
88
|
-
* The critical section deliberately excludes phase 2. `onJobDue` is
|
|
89
|
-
* arbitrary, unbounded consumer code; awaiting it under the module-global
|
|
90
|
-
* lock is what wedged every subsequent `locked()` call (add/update/remove)
|
|
91
|
-
* when a callback never settled.
|
|
92
|
-
*
|
|
93
|
-
* `alreadyClaimed` is passed by `onTimer`, which performs the batch claim
|
|
94
|
-
* (findDueJobs + markRunning) for all due jobs under a single lock.
|
|
95
|
-
*/
|
|
96
|
-
executeJob(job: Job, alreadyClaimed?: boolean): Promise<ExecuteResult>;
|
|
97
|
-
/**
|
|
98
|
-
* Phase 1 - claim. Must be called while holding the lock.
|
|
99
|
-
*
|
|
100
|
-
* Returns false if the job is already running, so a second `run()` reports
|
|
101
|
-
* "already running" instead of launching a concurrent invocation. Detaching
|
|
102
|
-
* from the heap here (rather than relying on phase 3 to push a fresh entry)
|
|
103
|
-
* is what keeps manual runs from permanently duplicating heap entries.
|
|
104
|
-
*/
|
|
105
|
-
claimJob(job: Job): boolean;
|
|
106
|
-
/**
|
|
107
|
-
* Phase 3 - settle. Must be called while holding the lock.
|
|
108
|
-
*/
|
|
109
|
-
settleJob(job: Job, status: string, error: string | undefined, summary: string | undefined, startMs: number, durationMs: number): ExecuteResult;
|
|
81
|
+
executeJob(job: Job): Promise<ExecuteResult>;
|
|
110
82
|
removeFromHeap(id: string): void;
|
|
111
83
|
log(message: string): void;
|
|
112
84
|
}
|
package/dist/service.js
CHANGED
|
@@ -12,24 +12,6 @@ import { locked } from './locked.js';
|
|
|
12
12
|
import { normalizeJobInput, recoverFlatParams } from './normalize.js';
|
|
13
13
|
import RunLog from './run-log.js';
|
|
14
14
|
const MAX_TIMER_DELAY_MS = 60_000;
|
|
15
|
-
/**
|
|
16
|
-
* Describe a thrown value without ever throwing.
|
|
17
|
-
*
|
|
18
|
-
* `String(err)` is not total: a null-prototype object, or any object whose
|
|
19
|
-
* `toString`/`Symbol.toPrimitive` throws, raises "Cannot convert object to
|
|
20
|
-
* primitive value". Consumer callbacks throw arbitrary values, so the error
|
|
21
|
-
* handler itself must not be a second failure source.
|
|
22
|
-
*/
|
|
23
|
-
function describeError(err) {
|
|
24
|
-
if (err instanceof Error)
|
|
25
|
-
return err.message;
|
|
26
|
-
try {
|
|
27
|
-
return String(err);
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
return 'unknown error';
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
15
|
export default class CronService {
|
|
34
16
|
jobs;
|
|
35
17
|
heap;
|
|
@@ -162,10 +144,6 @@ export default class CronService {
|
|
|
162
144
|
if (mode === 'due' && !isDue(job, Date.now())) {
|
|
163
145
|
return { status: 'skipped', reason: 'not due' };
|
|
164
146
|
}
|
|
165
|
-
// Deliberately NOT wrapped in locked(): executeJob takes the lock itself
|
|
166
|
-
// for its claim and settle phases only. Wrapping here would re-create the
|
|
167
|
-
// wedge through a second door, since the callback would again be awaited
|
|
168
|
-
// while a lock is held.
|
|
169
147
|
return this.executeJob(job);
|
|
170
148
|
}
|
|
171
149
|
/**
|
|
@@ -194,42 +172,16 @@ export default class CronService {
|
|
|
194
172
|
}
|
|
195
173
|
this.running = true;
|
|
196
174
|
try {
|
|
197
|
-
|
|
198
|
-
// and marking them running makes them un-collectable by anyone else, so
|
|
199
|
-
// both must happen under the same lock.
|
|
200
|
-
const dueJobs = await locked(() => {
|
|
175
|
+
await locked(async () => {
|
|
201
176
|
const nowMs = Date.now();
|
|
202
|
-
const
|
|
203
|
-
for (const job of
|
|
177
|
+
const dueJobs = this.findDueJobs(nowMs);
|
|
178
|
+
for (const job of dueJobs) {
|
|
204
179
|
markRunning(job);
|
|
205
180
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
// Phases 2 and 3 run outside the claim lock. The consumer callback is
|
|
209
|
-
// awaited here holding no lock at all, so a callback that never settles
|
|
210
|
-
// cannot poison the lock chain.
|
|
211
|
-
for (const job of dueJobs) {
|
|
212
|
-
try {
|
|
213
|
-
await this.executeJob(job, true);
|
|
181
|
+
for (const job of dueJobs) {
|
|
182
|
+
await this.executeJob(job);
|
|
214
183
|
}
|
|
215
|
-
|
|
216
|
-
// One job's unexpected throw must not abort the batch. Every job in
|
|
217
|
-
// `dueJobs` is already claimed - marked running and detached from the
|
|
218
|
-
// heap - and only its own settle releases it, so aborting here would
|
|
219
|
-
// strand every sibling permanently un-due.
|
|
220
|
-
//
|
|
221
|
-
// This is the outermost handler on the timer path, so it is the one
|
|
222
|
-
// that must not be able to throw. `log()` is public, overridable and
|
|
223
|
-
// can reach a file transport, so its own failure is swallowed here
|
|
224
|
-
// rather than being allowed to take the batch down.
|
|
225
|
-
try {
|
|
226
|
-
this.log(`Job "${job.name}" (${job.id}) execution failed unexpectedly: ${describeError(err)}`);
|
|
227
|
-
}
|
|
228
|
-
catch {
|
|
229
|
-
// Nothing left to report to.
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
184
|
+
});
|
|
233
185
|
}
|
|
234
186
|
finally {
|
|
235
187
|
this.running = false;
|
|
@@ -250,91 +202,29 @@ export default class CronService {
|
|
|
250
202
|
}
|
|
251
203
|
return due;
|
|
252
204
|
}
|
|
253
|
-
|
|
254
|
-
* Execute a job in three phases:
|
|
255
|
-
*
|
|
256
|
-
* 1. claim (locked) - take ownership of the job, detach it from the heap
|
|
257
|
-
* 2. invoke (UNLOCKED) - await the consumer callback
|
|
258
|
-
* 3. settle (locked) - apply the result, log it, re-insert into the heap
|
|
259
|
-
*
|
|
260
|
-
* The critical section deliberately excludes phase 2. `onJobDue` is
|
|
261
|
-
* arbitrary, unbounded consumer code; awaiting it under the module-global
|
|
262
|
-
* lock is what wedged every subsequent `locked()` call (add/update/remove)
|
|
263
|
-
* when a callback never settled.
|
|
264
|
-
*
|
|
265
|
-
* `alreadyClaimed` is passed by `onTimer`, which performs the batch claim
|
|
266
|
-
* (findDueJobs + markRunning) for all due jobs under a single lock.
|
|
267
|
-
*/
|
|
268
|
-
async executeJob(job, alreadyClaimed = false) {
|
|
269
|
-
// -- Phase 1: claim (locked) --
|
|
270
|
-
if (!alreadyClaimed) {
|
|
271
|
-
const claimed = await locked(() => this.claimJob(job));
|
|
272
|
-
if (!claimed)
|
|
273
|
-
return { status: 'skipped', reason: 'already running' };
|
|
274
|
-
}
|
|
205
|
+
async executeJob(job) {
|
|
275
206
|
const startMs = Date.now();
|
|
276
207
|
let status = 'ok';
|
|
277
208
|
let error;
|
|
278
209
|
let summary;
|
|
279
|
-
let settled;
|
|
280
|
-
// The claim above marked the job running and detached it from the heap.
|
|
281
|
-
// Phase 3 is the ONLY thing that undoes either, so it must survive every
|
|
282
|
-
// non-local exit from phase 2 - including a throw from the catch handler
|
|
283
|
-
// itself. A claim with no matching settle is not a degraded state, it is a
|
|
284
|
-
// permanently dead job: `runningAtMs` set, no heap entry, `isDue` false
|
|
285
|
-
// forever and `run()` refused forever.
|
|
286
210
|
try {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
error = result.error;
|
|
294
|
-
summary = result.summary;
|
|
295
|
-
}
|
|
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;
|
|
296
217
|
}
|
|
297
218
|
}
|
|
298
|
-
catch (err) {
|
|
299
|
-
status = 'error';
|
|
300
|
-
error = describeError(err);
|
|
301
|
-
this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
|
|
302
|
-
}
|
|
303
219
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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}`);
|
|
307
224
|
}
|
|
308
|
-
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Phase 1 - claim. Must be called while holding the lock.
|
|
312
|
-
*
|
|
313
|
-
* Returns false if the job is already running, so a second `run()` reports
|
|
314
|
-
* "already running" instead of launching a concurrent invocation. Detaching
|
|
315
|
-
* from the heap here (rather than relying on phase 3 to push a fresh entry)
|
|
316
|
-
* is what keeps manual runs from permanently duplicating heap entries.
|
|
317
|
-
*/
|
|
318
|
-
claimJob(job) {
|
|
319
|
-
if (job.state.runningAtMs)
|
|
320
|
-
return false;
|
|
321
|
-
markRunning(job);
|
|
322
|
-
this.removeFromHeap(job.id);
|
|
323
|
-
return true;
|
|
324
|
-
}
|
|
325
|
-
/**
|
|
326
|
-
* Phase 3 - settle. Must be called while holding the lock.
|
|
327
|
-
*/
|
|
328
|
-
settleJob(job, status, error, summary, startMs, durationMs) {
|
|
225
|
+
const durationMs = Date.now() - startMs;
|
|
329
226
|
const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
|
|
330
227
|
applyResult(job, validStatus, error, durationMs);
|
|
331
|
-
// The callback ran unlocked, so it may have removed this job while it was
|
|
332
|
-
// in flight. Do not resurrect a removed job's heap entry or run log.
|
|
333
|
-
if (this.jobs.get(job.id) !== job) {
|
|
334
|
-
this.removeFromHeap(job.id);
|
|
335
|
-
this.armTimer();
|
|
336
|
-
return { status, error, summary, durationMs };
|
|
337
|
-
}
|
|
338
228
|
// Log the run
|
|
339
229
|
this.runLog.record({
|
|
340
230
|
jobId: job.id,
|
|
@@ -348,22 +238,13 @@ export default class CronService {
|
|
|
348
238
|
// Handle one-shot auto-delete
|
|
349
239
|
if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
|
|
350
240
|
this.jobs.delete(job.id);
|
|
351
|
-
this.removeFromHeap(job.id);
|
|
352
241
|
this.runLog.removeJob(job.id);
|
|
353
|
-
this.armTimer();
|
|
354
242
|
return { status, summary, deleted: true };
|
|
355
243
|
}
|
|
356
|
-
// Re-insert into heap if still active
|
|
357
|
-
// may itself have added a heap entry for this job (via add/update); drop
|
|
358
|
-
// any such entry first to preserve one-entry-per-key.
|
|
359
|
-
this.removeFromHeap(job.id);
|
|
244
|
+
// Re-insert into heap if still active
|
|
360
245
|
if (job.enabled && job.state.nextRunAtMs) {
|
|
361
246
|
this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
|
|
362
247
|
}
|
|
363
|
-
// The claim phase detached this job from the heap, so a timer that fired
|
|
364
|
-
// during the unlocked invoke would have seen it missing. Re-arm here so a
|
|
365
|
-
// manual run() can never leave the scheduler without a pending wake.
|
|
366
|
-
this.armTimer();
|
|
367
248
|
return { status, error, summary, durationMs };
|
|
368
249
|
}
|
|
369
250
|
// -- Helpers ---------------------------------------------------------
|
|
@@ -385,19 +266,6 @@ export default class CronService {
|
|
|
385
266
|
log(message) {
|
|
386
267
|
if (!config.cron?.log)
|
|
387
268
|
return;
|
|
388
|
-
|
|
389
|
-
// (src/main.ts) - a DIFFERENT class. A consumer wiring CronService directly
|
|
390
|
-
// never runs it, while `config/environment.js` defaults `cron.log` to true,
|
|
391
|
-
// so an unguarded call throws `log.cron is not a function`. That throw
|
|
392
|
-
// escapes executeJob's catch, and the error-reporting path must never be
|
|
393
|
-
// the thing that kills the scheduler. `src/types/stonyx.d.ts:19` declares
|
|
394
|
-
// `cron()` unconditionally, so the type system will not catch this.
|
|
395
|
-
const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
|
|
396
|
-
if (typeof log[logMethod] !== 'function')
|
|
397
|
-
log.defineType(logMethod, logColor);
|
|
398
|
-
const method = log[logMethod];
|
|
399
|
-
if (typeof method !== 'function')
|
|
400
|
-
return;
|
|
401
|
-
method.call(log, `Cron — ${message}`);
|
|
269
|
+
log.cron(`Cron — ${message}`);
|
|
402
270
|
}
|
|
403
271
|
}
|