@stonyx/cron 0.2.1-alpha.11 → 0.2.1-alpha.12

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
@@ -35,8 +35,6 @@ 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
-
40
38
  > `MinHeap` is also exported as a public subpath (`@stonyx/cron/min-heap`) and can be imported directly for advanced usage.
41
39
 
42
40
  ## Configuration
package/dist/main.d.ts CHANGED
@@ -9,21 +9,10 @@ export default class Cron {
9
9
  jobs: Record<string, CronJob>;
10
10
  heap: MinHeap<CronJob>;
11
11
  timer: ReturnType<typeof setTimeout> | null;
12
- /** Keys whose previous invocation has not settled yet. */
13
- inFlight: Set<string>;
14
12
  constructor();
15
13
  init(): Promise<void>;
16
14
  scheduleNextRun(): void;
17
15
  runDueJobs(): Promise<void>;
18
- /**
19
- * The one safe way this class invokes a consumer callback.
20
- *
21
- * Never blocks the caller, catches synchronous throws and asynchronous
22
- * rejections alike, and skips the invocation entirely when the job's previous
23
- * invocation has not settled yet (fire-and-forget would otherwise let a slow
24
- * job stack invocations on itself).
25
- */
26
- safeInvoke(job: CronJob, runOnInit?: boolean): void;
27
16
  register(key: string, callback: () => void | Promise<void>, interval: string, runOnInit?: boolean): void;
28
17
  unregister(key: string): void;
29
18
  setNextTrigger(job: CronJob): void;
package/dist/main.js CHANGED
@@ -22,8 +22,6 @@ export default class Cron {
22
22
  jobs = {};
23
23
  heap = new MinHeap();
24
24
  timer = null;
25
- /** Keys whose previous invocation has not settled yet. */
26
- inFlight = new Set();
27
25
  constructor() {
28
26
  if (Cron.instance)
29
27
  return Cron.instance;
@@ -57,47 +55,17 @@ export default class Cron {
57
55
  const job = heap.pop();
58
56
  if (config.debug)
59
57
  this.log('job has been triggered', job.key);
60
- // Reschedule *before* invoking. The callback's result is not used by this
61
- // class (`runDueJobs` returns void), so awaiting it bought nothing and
62
- // cost the scheduler: a callback that never settled left the job absent
63
- // from the heap and stopped the timer from ever re-arming.
58
+ try {
59
+ await job.callback();
60
+ }
61
+ catch (err) {
62
+ log.error(`Cron job "${job.key}" failed:`, err);
63
+ }
64
64
  this.setNextTrigger(job);
65
65
  heap.push(job);
66
- this.safeInvoke(job);
67
66
  }
68
67
  this.scheduleNextRun();
69
68
  }
70
- /**
71
- * The one safe way this class invokes a consumer callback.
72
- *
73
- * Never blocks the caller, catches synchronous throws and asynchronous
74
- * rejections alike, and skips the invocation entirely when the job's previous
75
- * invocation has not settled yet (fire-and-forget would otherwise let a slow
76
- * job stack invocations on itself).
77
- */
78
- safeInvoke(job, runOnInit = false) {
79
- const { key } = job;
80
- const context = runOnInit ? 'failed on init:' : 'failed:';
81
- if (this.inFlight.has(key)) {
82
- log.warn(`Cron job "${key}" is still running; skipping this tick`);
83
- return;
84
- }
85
- this.inFlight.add(key);
86
- try {
87
- const result = job.callback();
88
- if (result && typeof result.then === 'function') {
89
- Promise.resolve(result)
90
- .catch(err => log.error(`Cron job "${key}" ${context}`, err))
91
- .finally(() => this.inFlight.delete(key));
92
- return;
93
- }
94
- this.inFlight.delete(key);
95
- }
96
- catch (err) {
97
- this.inFlight.delete(key);
98
- log.error(`Cron job "${key}" ${context}`, err);
99
- }
100
- }
101
69
  register(key, callback, interval, runOnInit = false) {
102
70
  const job = { callback, interval, key, nextTrigger: 0 };
103
71
  this.jobs[key] = job;
@@ -106,8 +74,14 @@ export default class Cron {
106
74
  if (config.debug) {
107
75
  this.log(`job has been registered with interval: ${interval}`, key);
108
76
  }
109
- if (runOnInit)
110
- this.safeInvoke(job, true);
77
+ if (runOnInit) {
78
+ try {
79
+ callback();
80
+ }
81
+ catch (err) {
82
+ log.error(`Cron job "${key}" failed on init:`, err);
83
+ }
84
+ }
111
85
  this.scheduleNextRun();
112
86
  }
113
87
  unregister(key) {
@@ -117,7 +91,6 @@ export default class Cron {
117
91
  return;
118
92
  delete jobs[key];
119
93
  heap.remove(job);
120
- this.inFlight.delete(key);
121
94
  if (config.debug)
122
95
  this.log('job has been unregistered', key);
123
96
  this.scheduleNextRun();
package/dist/service.d.ts CHANGED
@@ -78,7 +78,35 @@ export default class CronService {
78
78
  armTimer(): void;
79
79
  onTimer(): Promise<void>;
80
80
  findDueJobs(nowMs: number): Job[];
81
- executeJob(job: Job): Promise<ExecuteResult>;
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;
82
110
  removeFromHeap(id: string): void;
83
111
  log(message: string): void;
84
112
  }
package/dist/service.js CHANGED
@@ -144,6 +144,10 @@ export default class CronService {
144
144
  if (mode === 'due' && !isDue(job, Date.now())) {
145
145
  return { status: 'skipped', reason: 'not due' };
146
146
  }
147
+ // Deliberately NOT wrapped in locked(): executeJob takes the lock itself
148
+ // for its claim and settle phases only. Wrapping here would re-create the
149
+ // wedge through a second door, since the callback would again be awaited
150
+ // while a lock is held.
147
151
  return this.executeJob(job);
148
152
  }
149
153
  /**
@@ -172,16 +176,23 @@ export default class CronService {
172
176
  }
173
177
  this.running = true;
174
178
  try {
175
- await locked(async () => {
179
+ // Phase 1 - claim (locked). Collecting due jobs pops them off the heap
180
+ // and marking them running makes them un-collectable by anyone else, so
181
+ // both must happen under the same lock.
182
+ const dueJobs = await locked(() => {
176
183
  const nowMs = Date.now();
177
- const dueJobs = this.findDueJobs(nowMs);
178
- for (const job of dueJobs) {
184
+ const due = this.findDueJobs(nowMs);
185
+ for (const job of due) {
179
186
  markRunning(job);
180
187
  }
181
- for (const job of dueJobs) {
182
- await this.executeJob(job);
183
- }
188
+ return due;
184
189
  });
190
+ // Phases 2 and 3 run outside the claim lock. The consumer callback is
191
+ // awaited here holding no lock at all, so a callback that never settles
192
+ // cannot poison the lock chain.
193
+ for (const job of dueJobs) {
194
+ await this.executeJob(job, true);
195
+ }
185
196
  }
186
197
  finally {
187
198
  this.running = false;
@@ -202,7 +213,29 @@ export default class CronService {
202
213
  }
203
214
  return due;
204
215
  }
205
- async executeJob(job) {
216
+ /**
217
+ * Execute a job in three phases:
218
+ *
219
+ * 1. claim (locked) - take ownership of the job, detach it from the heap
220
+ * 2. invoke (UNLOCKED) - await the consumer callback
221
+ * 3. settle (locked) - apply the result, log it, re-insert into the heap
222
+ *
223
+ * The critical section deliberately excludes phase 2. `onJobDue` is
224
+ * arbitrary, unbounded consumer code; awaiting it under the module-global
225
+ * lock is what wedged every subsequent `locked()` call (add/update/remove)
226
+ * when a callback never settled.
227
+ *
228
+ * `alreadyClaimed` is passed by `onTimer`, which performs the batch claim
229
+ * (findDueJobs + markRunning) for all due jobs under a single lock.
230
+ */
231
+ async executeJob(job, alreadyClaimed = false) {
232
+ // -- Phase 1: claim (locked) --
233
+ if (!alreadyClaimed) {
234
+ const claimed = await locked(() => this.claimJob(job));
235
+ if (!claimed)
236
+ return { status: 'skipped', reason: 'already running' };
237
+ }
238
+ // -- Phase 2: invoke (NOT locked) --
206
239
  const startMs = Date.now();
207
240
  let status = 'ok';
208
241
  let error;
@@ -223,8 +256,37 @@ export default class CronService {
223
256
  this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
224
257
  }
225
258
  const durationMs = Date.now() - startMs;
259
+ // -- Phase 3: settle (locked) --
260
+ return locked(() => this.settleJob(job, status, error, summary, startMs, durationMs));
261
+ }
262
+ /**
263
+ * Phase 1 - claim. Must be called while holding the lock.
264
+ *
265
+ * Returns false if the job is already running, so a second `run()` reports
266
+ * "already running" instead of launching a concurrent invocation. Detaching
267
+ * from the heap here (rather than relying on phase 3 to push a fresh entry)
268
+ * is what keeps manual runs from permanently duplicating heap entries.
269
+ */
270
+ claimJob(job) {
271
+ if (job.state.runningAtMs)
272
+ return false;
273
+ markRunning(job);
274
+ this.removeFromHeap(job.id);
275
+ return true;
276
+ }
277
+ /**
278
+ * Phase 3 - settle. Must be called while holding the lock.
279
+ */
280
+ settleJob(job, status, error, summary, startMs, durationMs) {
226
281
  const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
227
282
  applyResult(job, validStatus, error, durationMs);
283
+ // The callback ran unlocked, so it may have removed this job while it was
284
+ // in flight. Do not resurrect a removed job's heap entry or run log.
285
+ if (this.jobs.get(job.id) !== job) {
286
+ this.removeFromHeap(job.id);
287
+ this.armTimer();
288
+ return { status, error, summary, durationMs };
289
+ }
228
290
  // Log the run
229
291
  this.runLog.record({
230
292
  jobId: job.id,
@@ -238,13 +300,22 @@ export default class CronService {
238
300
  // Handle one-shot auto-delete
239
301
  if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
240
302
  this.jobs.delete(job.id);
303
+ this.removeFromHeap(job.id);
241
304
  this.runLog.removeJob(job.id);
305
+ this.armTimer();
242
306
  return { status, summary, deleted: true };
243
307
  }
244
- // Re-insert into heap if still active
308
+ // Re-insert into heap if still active. The callback ran unlocked, so it
309
+ // may itself have added a heap entry for this job (via add/update); drop
310
+ // any such entry first to preserve one-entry-per-key.
311
+ this.removeFromHeap(job.id);
245
312
  if (job.enabled && job.state.nextRunAtMs) {
246
313
  this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
247
314
  }
315
+ // The claim phase detached this job from the heap, so a timer that fired
316
+ // during the unlocked invoke would have seen it missing. Re-arm here so a
317
+ // manual run() can never leave the scheduler without a pending wake.
318
+ this.armTimer();
248
319
  return { status, error, summary, durationMs };
249
320
  }
250
321
  // -- Helpers ---------------------------------------------------------
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.11",
6
+ "version": "0.2.1-alpha.12",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
8
  "main": "dist/main.js",
9
9
  "types": "dist/main.d.ts",