@stonyx/cron 0.2.1-alpha.10 → 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/dist/service.d.ts +29 -1
- package/dist/service.js +79 -8
- package/package.json +3 -3
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
|
-
|
|
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
|
-
|
|
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
|
|
178
|
-
for (const job of
|
|
184
|
+
const due = this.findDueJobs(nowMs);
|
|
185
|
+
for (const job of due) {
|
|
179
186
|
markRunning(job);
|
|
180
187
|
}
|
|
181
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
},
|
|
70
70
|
"homepage": "https://github.com/abofs/stonyx-cron#readme",
|
|
71
71
|
"devDependencies": {
|
|
72
|
-
"@stonyx/utils": "0.2.3-beta.
|
|
72
|
+
"@stonyx/utils": "0.2.3-beta.26",
|
|
73
73
|
"@types/node": "^25.5.2",
|
|
74
74
|
"@types/qunit": "^2.19.13",
|
|
75
75
|
"@types/sinon": "^21.0.1",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"typescript": "^5.8.3"
|
|
80
80
|
},
|
|
81
81
|
"dependencies": {
|
|
82
|
-
"stonyx": "0.2.3-beta.
|
|
82
|
+
"stonyx": "0.2.3-beta.76"
|
|
83
83
|
},
|
|
84
84
|
"scripts": {
|
|
85
85
|
"build": "tsc",
|