@stonyx/cron 0.2.1-alpha.2 → 0.2.1-alpha.21

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.
@@ -0,0 +1,446 @@
1
+ /**
2
+ * CronService - the main API for advanced job scheduling.
3
+ *
4
+ * Manages jobs in memory with a min-heap for efficient next-job lookup.
5
+ * All state mutations are serialized via async locking.
6
+ */
7
+ import config from 'stonyx/config';
8
+ import log from 'stonyx/log';
9
+ import MinHeap from './min-heap.js';
10
+ import { createJob, updateJob, markRunning, applyResult, isDue } from './job.js';
11
+ import { locked } from './locked.js';
12
+ import { normalizeJobInput, recoverFlatParams } from './normalize.js';
13
+ import RunLog from './run-log.js';
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
+ export default class CronService {
34
+ jobs;
35
+ heap;
36
+ timer;
37
+ running;
38
+ runLog;
39
+ started;
40
+ // Pluggable callbacks for consumers
41
+ onJobDue;
42
+ constructor() {
43
+ this.jobs = new Map();
44
+ this.heap = new MinHeap();
45
+ this.timer = null;
46
+ this.running = false;
47
+ this.runLog = new RunLog();
48
+ this.started = false;
49
+ this.onJobDue = null;
50
+ }
51
+ // -- Lifecycle -------------------------------------------------------
52
+ /**
53
+ * Start the service. Loads jobs from store (if any), arms timer.
54
+ */
55
+ async start(initialJobs) {
56
+ if (this.started)
57
+ return;
58
+ this.started = true;
59
+ if (initialJobs) {
60
+ for (const job of initialJobs) {
61
+ this.jobs.set(job.id, job);
62
+ if (job.enabled && job.state.nextRunAtMs) {
63
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
64
+ }
65
+ }
66
+ }
67
+ this.armTimer();
68
+ }
69
+ /**
70
+ * Stop the service. Clears timer.
71
+ */
72
+ stop() {
73
+ this.started = false;
74
+ if (this.timer)
75
+ clearTimeout(this.timer);
76
+ this.timer = null;
77
+ }
78
+ // -- CRUD ------------------------------------------------------------
79
+ /**
80
+ * Get service status.
81
+ */
82
+ status() {
83
+ const peek = this.heap.peek();
84
+ return {
85
+ started: this.started,
86
+ jobCount: this.jobs.size,
87
+ nextWakeAtMs: peek ? peek.nextTrigger : undefined,
88
+ };
89
+ }
90
+ /**
91
+ * List jobs, optionally including disabled ones.
92
+ */
93
+ list(opts) {
94
+ const includeDisabled = opts?.includeDisabled ?? false;
95
+ const jobs = [...this.jobs.values()];
96
+ const filtered = includeDisabled ? jobs : jobs.filter(j => j.enabled);
97
+ return filtered.sort((a, b) => (a.state.nextRunAtMs ?? Infinity) - (b.state.nextRunAtMs ?? Infinity));
98
+ }
99
+ /**
100
+ * Get a single job by ID.
101
+ */
102
+ get(id) {
103
+ return this.jobs.get(id) || null;
104
+ }
105
+ /**
106
+ * Add a new job. Input is normalized for AI compatibility.
107
+ */
108
+ async add(rawInput) {
109
+ return locked(() => {
110
+ const input = normalizeJobInput(recoverFlatParams(rawInput));
111
+ const job = createJob(input);
112
+ this.jobs.set(job.id, job);
113
+ if (job.enabled && job.state.nextRunAtMs) {
114
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
115
+ this.armTimer();
116
+ }
117
+ return job;
118
+ });
119
+ }
120
+ /**
121
+ * Update an existing job.
122
+ */
123
+ async update(id, patch) {
124
+ return locked(() => {
125
+ const job = this.jobs.get(id);
126
+ if (!job)
127
+ throw new Error(`Job not found: ${id}`);
128
+ const oldNextRun = job.state.nextRunAtMs;
129
+ updateJob(job, patch);
130
+ // Update heap entry
131
+ this.removeFromHeap(id);
132
+ if (job.enabled && job.state.nextRunAtMs) {
133
+ this.heap.push({ key: id, nextTrigger: job.state.nextRunAtMs });
134
+ }
135
+ if (job.state.nextRunAtMs !== oldNextRun) {
136
+ this.armTimer();
137
+ }
138
+ return job;
139
+ });
140
+ }
141
+ /**
142
+ * Remove a job.
143
+ */
144
+ async remove(id) {
145
+ return locked(() => {
146
+ const job = this.jobs.get(id);
147
+ if (!job)
148
+ throw new Error(`Job not found: ${id}`);
149
+ this.jobs.delete(id);
150
+ this.removeFromHeap(id);
151
+ this.runLog.removeJob(id);
152
+ this.armTimer();
153
+ });
154
+ }
155
+ /**
156
+ * Manually trigger a job.
157
+ */
158
+ async run(id, mode = 'force') {
159
+ const job = this.jobs.get(id);
160
+ if (!job)
161
+ throw new Error(`Job not found: ${id}`);
162
+ if (mode === 'due' && !isDue(job, Date.now())) {
163
+ return { status: 'skipped', reason: 'not due' };
164
+ }
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
+ return this.executeJob(job);
170
+ }
171
+ /**
172
+ * Get run history for a job.
173
+ */
174
+ runs(id, limit) {
175
+ return this.runLog.get(id, limit);
176
+ }
177
+ // -- Timer Engine ----------------------------------------------------
178
+ armTimer() {
179
+ if (this.timer)
180
+ clearTimeout(this.timer);
181
+ if (!this.started)
182
+ return;
183
+ const peek = this.heap.peek();
184
+ if (!peek)
185
+ return;
186
+ const delay = Math.min(Math.max(peek.nextTrigger - Date.now(), 0), MAX_TIMER_DELAY_MS);
187
+ this.timer = setTimeout(() => this.onTimer(), delay);
188
+ }
189
+ async onTimer() {
190
+ if (this.running) {
191
+ // Already processing - re-arm at max delay to prevent scheduler death
192
+ this.timer = setTimeout(() => this.onTimer(), MAX_TIMER_DELAY_MS);
193
+ return;
194
+ }
195
+ this.running = true;
196
+ try {
197
+ // Phase 1 - claim (locked). Collecting due jobs pops them off the heap
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(() => {
201
+ const nowMs = Date.now();
202
+ const due = this.findDueJobs(nowMs);
203
+ for (const job of due) {
204
+ markRunning(job);
205
+ }
206
+ return due;
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.#executeClaimed(job);
214
+ }
215
+ catch (err) {
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
+ }
233
+ }
234
+ finally {
235
+ this.running = false;
236
+ this.armTimer();
237
+ }
238
+ }
239
+ findDueJobs(nowMs) {
240
+ const due = [];
241
+ while (!this.heap.isEmpty()) {
242
+ const peek = this.heap.peek();
243
+ if (!peek || peek.nextTrigger > nowMs)
244
+ break;
245
+ this.heap.pop();
246
+ const job = this.jobs.get(peek.key);
247
+ if (job && isDue(job, nowMs)) {
248
+ due.push(job);
249
+ }
250
+ }
251
+ return due;
252
+ }
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
+ * `onTimer` performs the batch claim (findDueJobs + markRunning) for all due
266
+ * jobs under a single lock, then enters at phase 2 via `#executeClaimed`.
267
+ * That entry point is a `#private` method rather than a parameter on this
268
+ * one: as a published `alreadyClaimed` boolean it was a supported way for a
269
+ * consumer to skip phase 1 entirely, which defeats the claim guard AC4 asks
270
+ * for and allows concurrent `onJobDue` invocations for the same job.
271
+ */
272
+ async executeJob(job) {
273
+ // -- Phase 1: claim (locked) --
274
+ const refusal = await locked(() => this.claimJob(job));
275
+ if (refusal)
276
+ return { status: 'skipped', reason: refusal };
277
+ return this.#executeClaimed(job);
278
+ }
279
+ /**
280
+ * Phases 2 and 3 for a job that has already been claimed - either by
281
+ * `executeJob` above or by `onTimer`'s batch claim.
282
+ *
283
+ * Private: reaching this without a claim would run the consumer callback for
284
+ * a job nobody owns.
285
+ */
286
+ async #executeClaimed(job) {
287
+ // Membership re-check. The claim and the invoke are no longer in the same
288
+ // critical section, and sibling callbacks run unlocked, so a `remove()` can
289
+ // land in between AND RESOLVE - it used to deadlock. A resolved `remove()`
290
+ // must keep meaning "this callback will not fire": settleJob's identity
291
+ // guard only cleans up afterwards, by which point the side effect has
292
+ // already happened. Identity, not id, so a removed-then-replaced key is
293
+ // caught too. Deliberately synchronous with the `onJobDue` call below -
294
+ // nothing can interleave between this check and the invocation.
295
+ if (this.jobs.get(job.id) !== job)
296
+ return { status: 'skipped', reason: 'removed' };
297
+ const startMs = Date.now();
298
+ let status = 'ok';
299
+ let error;
300
+ let summary;
301
+ let settled;
302
+ // The claim above marked the job running and detached it from the heap.
303
+ // Phase 3 is the ONLY thing that undoes either, so it must survive every
304
+ // non-local exit from phase 2 - including a throw from the catch handler
305
+ // itself. A claim with no matching settle is not a degraded state, it is a
306
+ // permanently dead job: `runningAtMs` set, no heap entry, `isDue` false
307
+ // forever and `run()` refused forever.
308
+ try {
309
+ // -- Phase 2: invoke (NOT locked) --
310
+ try {
311
+ if (this.onJobDue) {
312
+ const result = await this.onJobDue(job);
313
+ if (result) {
314
+ status = result.status || 'ok';
315
+ error = result.error;
316
+ summary = result.summary;
317
+ }
318
+ }
319
+ }
320
+ catch (err) {
321
+ status = 'error';
322
+ error = describeError(err);
323
+ this.log(`Job "${job.name}" (${job.id}) failed: ${error}`);
324
+ }
325
+ }
326
+ finally {
327
+ // -- Phase 3: settle (locked) --
328
+ settled = await locked(() => this.settleJob(job, status, error, summary, startMs, Date.now() - startMs));
329
+ }
330
+ return settled;
331
+ }
332
+ /**
333
+ * Phase 1 - claim. Must be called while holding the lock.
334
+ *
335
+ * Returns `null` on a successful claim, or the reason the claim was refused.
336
+ * "already running" is what makes a second `run()` report a skip instead of
337
+ * launching a concurrent invocation. "removed" covers the job being deleted
338
+ * between `run()`'s unlocked lookup and this lock turn - claiming then would
339
+ * `markRunning` an orphan and, worse, `removeFromHeap` an id that may now
340
+ * belong to a replacement.
341
+ *
342
+ * Detaching from the heap here (rather than relying on phase 3 to push a
343
+ * fresh entry) is what keeps manual runs from permanently duplicating heap
344
+ * entries.
345
+ */
346
+ claimJob(job) {
347
+ if (this.jobs.get(job.id) !== job)
348
+ return 'removed';
349
+ if (job.state.runningAtMs)
350
+ return 'already running';
351
+ markRunning(job);
352
+ this.removeFromHeap(job.id);
353
+ return null;
354
+ }
355
+ /**
356
+ * Phase 3 - settle. Must be called while holding the lock.
357
+ */
358
+ settleJob(job, status, error, summary, startMs, durationMs) {
359
+ try {
360
+ const validStatus = (status === 'ok' || status === 'error' || status === 'skipped') ? status : 'error';
361
+ applyResult(job, validStatus, error, durationMs);
362
+ // The callback ran unlocked, so this job may have been removed - or
363
+ // removed and re-registered under the same id (the shape
364
+ // `start(initialJobs)` uses) - while it was in flight. Identity, not id.
365
+ //
366
+ // Deliberately touch NOTHING here. The claim phase already detached this
367
+ // job's own heap entry and nothing re-added it, so there is nothing to
368
+ // clean up; any entry now filed under this id belongs to the
369
+ // replacement, and removing it by id would silently unschedule a live
370
+ // job. Do not resurrect a removed job's heap entry or run log either.
371
+ if (this.jobs.get(job.id) !== job) {
372
+ return { status, error, summary, durationMs };
373
+ }
374
+ // Log the run
375
+ this.runLog.record({
376
+ jobId: job.id,
377
+ status,
378
+ error,
379
+ summary,
380
+ runAtMs: startMs,
381
+ durationMs,
382
+ nextRunAtMs: job.state.nextRunAtMs,
383
+ });
384
+ // Handle one-shot auto-delete. The callback ran unlocked and may have
385
+ // pushed a heap entry for this job via update(), so drop it - the job is
386
+ // about to stop existing.
387
+ if (job.deleteAfterRun && status === 'ok' && !job.enabled) {
388
+ this.jobs.delete(job.id);
389
+ this.removeFromHeap(job.id);
390
+ this.runLog.removeJob(job.id);
391
+ return { status, summary, deleted: true };
392
+ }
393
+ // Re-insert into heap if still active. The callback ran unlocked, so it
394
+ // may itself have added a heap entry for this job (via add/update); drop
395
+ // any such entry first to preserve one-entry-per-key.
396
+ this.removeFromHeap(job.id);
397
+ if (job.enabled && job.state.nextRunAtMs) {
398
+ this.heap.push({ key: job.id, nextTrigger: job.state.nextRunAtMs });
399
+ }
400
+ return { status, error, summary, durationMs };
401
+ }
402
+ finally {
403
+ // One re-arm covering every exit, rather than one per branch. The claim
404
+ // phase detached this job from the heap, so a timer that fired during the
405
+ // unlocked invoke would have found an empty heap and armed nothing -
406
+ // and `run()` has no `finally { armTimer() }` of its own the way
407
+ // `onTimer` does. Without this a manual run() can leave the scheduler
408
+ // with no pending wake at all.
409
+ this.armTimer();
410
+ }
411
+ }
412
+ // -- Helpers ---------------------------------------------------------
413
+ removeFromHeap(id) {
414
+ // MinHeap doesn't support remove-by-key efficiently,
415
+ // so we rebuild. Fine for typical job counts (< 1000).
416
+ const remaining = [];
417
+ while (!this.heap.isEmpty()) {
418
+ const item = this.heap.pop();
419
+ if (!item)
420
+ break;
421
+ if (item.key !== id)
422
+ remaining.push(item);
423
+ }
424
+ for (const item of remaining) {
425
+ this.heap.push(item);
426
+ }
427
+ }
428
+ log(message) {
429
+ if (!config.cron?.log)
430
+ return;
431
+ // `log.cron` is created by `log.defineType`, which runs in `Cron.init()`
432
+ // (src/main.ts) - a DIFFERENT class. A consumer wiring CronService directly
433
+ // never runs it, while `config/environment.js` defaults `cron.log` to true,
434
+ // so an unguarded call throws `log.cron is not a function`. That throw
435
+ // escapes executeJob's catch, and the error-reporting path must never be
436
+ // the thing that kills the scheduler. `src/types/stonyx.d.ts:19` declares
437
+ // `cron()` unconditionally, so the type system will not catch this.
438
+ const { logColor = '#888', logMethod = 'cron' } = config.cron ?? {};
439
+ if (typeof log[logMethod] !== 'function')
440
+ log.defineType(logMethod, logColor);
441
+ const method = log[logMethod];
442
+ if (typeof method !== 'function')
443
+ return;
444
+ method.call(log, `Cron — ${message}`);
445
+ }
446
+ }
package/package.json CHANGED
@@ -3,23 +3,53 @@
3
3
  "keywords": [
4
4
  "stonyx-module"
5
5
  ],
6
- "version": "0.2.1-alpha.2",
6
+ "version": "0.2.1-alpha.21",
7
7
  "description": "Cron/job scheduler for Stonyx framework",
8
- "main": "src/main.js",
8
+ "main": "dist/main.js",
9
+ "types": "dist/main.d.ts",
9
10
  "type": "module",
10
11
  "files": [
11
- "*"
12
+ "dist",
13
+ "config",
14
+ "README.md"
12
15
  ],
13
16
  "exports": {
14
- ".": "./src/main.js",
15
- "./service": "./src/service.js",
16
- "./cron-parser": "./src/cron-parser.js",
17
- "./schedule": "./src/schedule.js",
18
- "./job": "./src/job.js",
19
- "./normalize": "./src/normalize.js",
20
- "./locked": "./src/locked.js",
21
- "./run-log": "./src/run-log.js",
22
- "./min-heap": "./src/min-heap.js"
17
+ ".": {
18
+ "types": "./dist/main.d.ts",
19
+ "default": "./dist/main.js"
20
+ },
21
+ "./service": {
22
+ "types": "./dist/service.d.ts",
23
+ "default": "./dist/service.js"
24
+ },
25
+ "./cron-parser": {
26
+ "types": "./dist/cron-parser.d.ts",
27
+ "default": "./dist/cron-parser.js"
28
+ },
29
+ "./schedule": {
30
+ "types": "./dist/schedule.d.ts",
31
+ "default": "./dist/schedule.js"
32
+ },
33
+ "./job": {
34
+ "types": "./dist/job.d.ts",
35
+ "default": "./dist/job.js"
36
+ },
37
+ "./normalize": {
38
+ "types": "./dist/normalize.d.ts",
39
+ "default": "./dist/normalize.js"
40
+ },
41
+ "./locked": {
42
+ "types": "./dist/locked.d.ts",
43
+ "default": "./dist/locked.js"
44
+ },
45
+ "./run-log": {
46
+ "types": "./dist/run-log.d.ts",
47
+ "default": "./dist/run-log.js"
48
+ },
49
+ "./min-heap": {
50
+ "types": "./dist/min-heap.d.ts",
51
+ "default": "./dist/min-heap.js"
52
+ }
23
53
  },
24
54
  "publishConfig": {
25
55
  "access": "public",
@@ -39,14 +69,21 @@
39
69
  },
40
70
  "homepage": "https://github.com/abofs/stonyx-cron#readme",
41
71
  "devDependencies": {
42
- "@stonyx/utils": "0.2.3-beta.7",
72
+ "@stonyx/utils": "0.2.3-beta.26",
73
+ "@types/node": "^25.5.2",
74
+ "@types/qunit": "^2.19.13",
75
+ "@types/sinon": "^21.0.1",
43
76
  "qunit": "^2.24.1",
44
- "sinon": "^21.0.0"
77
+ "sinon": "^21.0.0",
78
+ "tsx": "^4.21.0",
79
+ "typescript": "^5.8.3"
45
80
  },
46
81
  "dependencies": {
47
- "stonyx": "0.2.3-beta.11"
82
+ "stonyx": "0.2.3-beta.76"
48
83
  },
49
84
  "scripts": {
50
- "test": "stonyx test"
85
+ "build": "tsc",
86
+ "build:test": "tsc -p tsconfig.test.json",
87
+ "test": "pnpm build && NODE_ENV=test node --import tsx/esm --import ./test/setup.ts node_modules/qunit/bin/qunit.js 'test/**/*-test.ts'"
51
88
  }
52
89
  }