silent-cronx 1.0.0
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/LICENSE +21 -0
- package/README.md +314 -0
- package/dist/index.cjs +1169 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +321 -0
- package/dist/index.d.ts +321 -0
- package/dist/index.js +1138 -0
- package/dist/index.js.map +1 -0
- package/dist/workers/workerRunner.cjs +29 -0
- package/dist/workers/workerRunner.cjs.map +1 -0
- package/dist/workers/workerRunner.d.cts +2 -0
- package/dist/workers/workerRunner.d.ts +2 -0
- package/dist/workers/workerRunner.js +27 -0
- package/dist/workers/workerRunner.js.map +1 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
// src/core/eventBus.ts
|
|
2
|
+
import { EventEmitter } from "events";
|
|
3
|
+
var EventBus = class {
|
|
4
|
+
emitter = new EventEmitter();
|
|
5
|
+
on(eventName, handler) {
|
|
6
|
+
this.emitter.on(eventName, handler);
|
|
7
|
+
}
|
|
8
|
+
off(eventName, handler) {
|
|
9
|
+
this.emitter.off(eventName, handler);
|
|
10
|
+
}
|
|
11
|
+
emit(eventName, event) {
|
|
12
|
+
this.emitter.emit(eventName, event);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// src/core/retryManager.ts
|
|
17
|
+
function getMaxAttempts(retry) {
|
|
18
|
+
return Math.max(1, retry?.attempts ?? 1);
|
|
19
|
+
}
|
|
20
|
+
function shouldRetry(error, attempt, retry) {
|
|
21
|
+
if (!retry || attempt >= getMaxAttempts(retry)) return false;
|
|
22
|
+
return retry.shouldRetry ? retry.shouldRetry(error) : true;
|
|
23
|
+
}
|
|
24
|
+
function getRetryDelayMs(attempt, retry) {
|
|
25
|
+
if (!retry) return 0;
|
|
26
|
+
const base = retry.delayMs ?? 1e3;
|
|
27
|
+
switch (retry.backoff ?? "fixed") {
|
|
28
|
+
case "linear":
|
|
29
|
+
return base * attempt;
|
|
30
|
+
case "exponential":
|
|
31
|
+
return base * 2 ** Math.max(0, attempt - 1);
|
|
32
|
+
case "fixed":
|
|
33
|
+
default:
|
|
34
|
+
return base;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/utils/errors.ts
|
|
39
|
+
var SilentCronXError = class extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "SilentCronXError";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var JobTimeoutError = class extends SilentCronXError {
|
|
46
|
+
constructor(message = "Job timed out") {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "JobTimeoutError";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
var JobCancelledError = class extends SilentCronXError {
|
|
52
|
+
constructor(message = "Job cancelled") {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "JobCancelledError";
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
function serializeError(error) {
|
|
58
|
+
if (error instanceof Error) {
|
|
59
|
+
return {
|
|
60
|
+
name: error.name,
|
|
61
|
+
message: error.message,
|
|
62
|
+
stack: error.stack
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
name: "Error",
|
|
67
|
+
message: typeof error === "string" ? error : JSON.stringify(error)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/core/timeoutManager.ts
|
|
72
|
+
async function withTimeout(work, timeoutMs, externalSignal) {
|
|
73
|
+
const controller = new AbortController();
|
|
74
|
+
const abort = (reason) => controller.abort(reason);
|
|
75
|
+
if (externalSignal?.aborted) abort(externalSignal.reason);
|
|
76
|
+
externalSignal?.addEventListener("abort", () => abort(externalSignal.reason), { once: true });
|
|
77
|
+
let timer;
|
|
78
|
+
const timeout = new Promise((_, reject) => {
|
|
79
|
+
timer = setTimeout(() => {
|
|
80
|
+
const error = new JobTimeoutError(`Job timed out after ${timeoutMs}ms`);
|
|
81
|
+
abort(error);
|
|
82
|
+
reject(error);
|
|
83
|
+
}, timeoutMs);
|
|
84
|
+
timer.unref?.();
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
return await Promise.race([work(controller.signal), timeout]);
|
|
88
|
+
} finally {
|
|
89
|
+
if (timer) clearTimeout(timer);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// src/utils/time.ts
|
|
94
|
+
function nowIso() {
|
|
95
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
96
|
+
}
|
|
97
|
+
function delay(ms, signal) {
|
|
98
|
+
if (ms <= 0) return Promise.resolve();
|
|
99
|
+
return new Promise((resolve, reject) => {
|
|
100
|
+
if (signal?.aborted) {
|
|
101
|
+
reject(signal.reason ?? new Error("Operation aborted"));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const timer = setTimeout(resolve, ms);
|
|
105
|
+
timer.unref?.();
|
|
106
|
+
signal?.addEventListener(
|
|
107
|
+
"abort",
|
|
108
|
+
() => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
reject(signal.reason ?? new Error("Operation aborted"));
|
|
111
|
+
},
|
|
112
|
+
{ once: true }
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/core/jobRunner.ts
|
|
118
|
+
var JobRunner = class {
|
|
119
|
+
activeCount = 0;
|
|
120
|
+
getActiveCount() {
|
|
121
|
+
return this.activeCount;
|
|
122
|
+
}
|
|
123
|
+
async run(options) {
|
|
124
|
+
const { record, storage, eventBus } = options;
|
|
125
|
+
const lockKey = options.lockKey;
|
|
126
|
+
let locked = false;
|
|
127
|
+
if (lockKey && options.acquireLock) {
|
|
128
|
+
locked = await options.acquireLock(lockKey, options.lockTtlMs ?? options.timeoutMs);
|
|
129
|
+
if (!locked) {
|
|
130
|
+
const skipped = {
|
|
131
|
+
jobId: record.id,
|
|
132
|
+
name: record.name,
|
|
133
|
+
status: "scheduled",
|
|
134
|
+
attempt: record.attempts,
|
|
135
|
+
durationMs: 0
|
|
136
|
+
};
|
|
137
|
+
return skipped;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
this.activeCount += 1;
|
|
141
|
+
try {
|
|
142
|
+
let attempt = record.attempts;
|
|
143
|
+
while (true) {
|
|
144
|
+
attempt += 1;
|
|
145
|
+
const startedAt = /* @__PURE__ */ new Date();
|
|
146
|
+
await storage.updateJob(record.id, {
|
|
147
|
+
status: "running",
|
|
148
|
+
attempts: attempt,
|
|
149
|
+
startedAt: startedAt.toISOString(),
|
|
150
|
+
lastRunAt: startedAt.toISOString()
|
|
151
|
+
});
|
|
152
|
+
eventBus.emit("job:started", { jobId: record.id, name: record.name, attempt });
|
|
153
|
+
try {
|
|
154
|
+
const result = await withTimeout(
|
|
155
|
+
async (timeoutSignal) => {
|
|
156
|
+
const signal = mergeSignals(options.signal, timeoutSignal);
|
|
157
|
+
if (signal.aborted) throw signal.reason ?? new JobCancelledError();
|
|
158
|
+
return options.task({
|
|
159
|
+
jobId: record.id,
|
|
160
|
+
name: record.name,
|
|
161
|
+
payload: record.payload,
|
|
162
|
+
attempt,
|
|
163
|
+
signal,
|
|
164
|
+
createdAt: new Date(record.createdAt),
|
|
165
|
+
startedAt
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
options.timeoutMs,
|
|
169
|
+
options.signal
|
|
170
|
+
);
|
|
171
|
+
const finishedAt = nowIso();
|
|
172
|
+
const durationMs = Date.now() - startedAt.getTime();
|
|
173
|
+
const finalResult = {
|
|
174
|
+
jobId: record.id,
|
|
175
|
+
name: record.name,
|
|
176
|
+
status: "success",
|
|
177
|
+
result,
|
|
178
|
+
attempt,
|
|
179
|
+
durationMs,
|
|
180
|
+
startedAt: startedAt.toISOString(),
|
|
181
|
+
finishedAt
|
|
182
|
+
};
|
|
183
|
+
const latest = await storage.getJob(record.id);
|
|
184
|
+
await storage.updateJob(record.id, {
|
|
185
|
+
status: "success",
|
|
186
|
+
result,
|
|
187
|
+
finishedAt,
|
|
188
|
+
successCount: (latest?.successCount ?? record.successCount) + 1
|
|
189
|
+
});
|
|
190
|
+
eventBus.emit("job:success", finalResult);
|
|
191
|
+
return finalResult;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
const finishedAt = nowIso();
|
|
194
|
+
const durationMs = Date.now() - startedAt.getTime();
|
|
195
|
+
const status = error instanceof JobTimeoutError ? "timeout" : isAbortError(error) ? "cancelled" : "failed";
|
|
196
|
+
const finalResult = {
|
|
197
|
+
jobId: record.id,
|
|
198
|
+
name: record.name,
|
|
199
|
+
status,
|
|
200
|
+
error: serializeError(error),
|
|
201
|
+
attempt,
|
|
202
|
+
durationMs,
|
|
203
|
+
startedAt: startedAt.toISOString(),
|
|
204
|
+
finishedAt
|
|
205
|
+
};
|
|
206
|
+
if (status === "cancelled" || status === "timeout" || !shouldRetry(error, attempt, options.retry)) {
|
|
207
|
+
const latest = await storage.getJob(record.id);
|
|
208
|
+
await storage.updateJob(record.id, {
|
|
209
|
+
status,
|
|
210
|
+
error: finalResult.error,
|
|
211
|
+
finishedAt,
|
|
212
|
+
failureCount: (latest?.failureCount ?? record.failureCount) + 1
|
|
213
|
+
});
|
|
214
|
+
if (status === "timeout") {
|
|
215
|
+
eventBus.emit("job:timeout", finalResult);
|
|
216
|
+
} else if (status === "cancelled") {
|
|
217
|
+
eventBus.emit("job:cancelled", { jobId: record.id, name: record.name });
|
|
218
|
+
} else {
|
|
219
|
+
eventBus.emit("job:failed", finalResult);
|
|
220
|
+
}
|
|
221
|
+
return finalResult;
|
|
222
|
+
}
|
|
223
|
+
const retryDelayMs = getRetryDelayMs(attempt, options.retry);
|
|
224
|
+
eventBus.emit("job:retry", { jobId: record.id, name: record.name, attempt, delayMs: retryDelayMs });
|
|
225
|
+
await delay(retryDelayMs, options.signal);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} finally {
|
|
229
|
+
this.activeCount -= 1;
|
|
230
|
+
if (locked && lockKey && options.releaseLock) await options.releaseLock(lockKey);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
function mergeSignals(a, b) {
|
|
235
|
+
const controller = new AbortController();
|
|
236
|
+
const abort = (signal) => controller.abort(signal.reason);
|
|
237
|
+
if (a?.aborted) abort(a);
|
|
238
|
+
if (b?.aborted) abort(b);
|
|
239
|
+
a?.addEventListener("abort", () => abort(a), { once: true });
|
|
240
|
+
b?.addEventListener("abort", () => abort(b), { once: true });
|
|
241
|
+
return controller.signal;
|
|
242
|
+
}
|
|
243
|
+
function isAbortError(error) {
|
|
244
|
+
return error instanceof JobCancelledError || error instanceof Error && error.name === "AbortError";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// src/core/lockManager.ts
|
|
248
|
+
var LockManager = class {
|
|
249
|
+
constructor(storage, defaultTtlMs) {
|
|
250
|
+
this.storage = storage;
|
|
251
|
+
this.defaultTtlMs = defaultTtlMs;
|
|
252
|
+
}
|
|
253
|
+
storage;
|
|
254
|
+
defaultTtlMs;
|
|
255
|
+
acquire(lockKey, ttlMs = this.defaultTtlMs) {
|
|
256
|
+
return this.storage.acquireLock(lockKey, ttlMs);
|
|
257
|
+
}
|
|
258
|
+
release(lockKey) {
|
|
259
|
+
return this.storage.releaseLock(lockKey);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
// src/utils/id.ts
|
|
264
|
+
import { randomUUID } from "crypto";
|
|
265
|
+
function createJobId(prefix = "job") {
|
|
266
|
+
return `${prefix}_${randomUUID()}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/utils/safeSerialize.ts
|
|
270
|
+
function assertSerializablePayload(payload, maxBytes) {
|
|
271
|
+
if (payload === void 0) return;
|
|
272
|
+
try {
|
|
273
|
+
const json = JSON.stringify(payload);
|
|
274
|
+
if (maxBytes && Buffer.byteLength(json, "utf8") > maxBytes) {
|
|
275
|
+
throw new SilentCronXError(`Payload exceeds configured maxPayloadBytes (${maxBytes})`);
|
|
276
|
+
}
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (error instanceof SilentCronXError) throw error;
|
|
279
|
+
throw new SilentCronXError("Payload must be JSON-serializable");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/utils/validation.ts
|
|
284
|
+
var JOB_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/;
|
|
285
|
+
function validateJobName(name) {
|
|
286
|
+
if (!JOB_NAME_RE.test(name)) {
|
|
287
|
+
throw new SilentCronXError(
|
|
288
|
+
"Job name must start with a letter or number and contain only letters, numbers, underscore, dash, dot, or colon"
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function assertPositiveNumber(value, label) {
|
|
293
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
294
|
+
throw new SilentCronXError(`${label} must be a positive number`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function assertTask(task, label = "task") {
|
|
298
|
+
if (typeof task !== "function") {
|
|
299
|
+
throw new SilentCronXError(`${label} must be a function`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/core/queueManager.ts
|
|
304
|
+
var QueueManager = class {
|
|
305
|
+
constructor(storage, eventBus, maxPayloadBytes, runJob) {
|
|
306
|
+
this.storage = storage;
|
|
307
|
+
this.eventBus = eventBus;
|
|
308
|
+
this.maxPayloadBytes = maxPayloadBytes;
|
|
309
|
+
this.runJob = runJob;
|
|
310
|
+
}
|
|
311
|
+
storage;
|
|
312
|
+
eventBus;
|
|
313
|
+
maxPayloadBytes;
|
|
314
|
+
runJob;
|
|
315
|
+
queues = /* @__PURE__ */ new Map();
|
|
316
|
+
createQueue(name, options = {}) {
|
|
317
|
+
validateJobName(name);
|
|
318
|
+
const concurrency = options.concurrency ?? 1;
|
|
319
|
+
assertPositiveNumber(concurrency, "queue concurrency");
|
|
320
|
+
const runtime = {
|
|
321
|
+
name,
|
|
322
|
+
options: {
|
|
323
|
+
...options,
|
|
324
|
+
concurrency,
|
|
325
|
+
maxSize: options.maxSize ?? 1e3
|
|
326
|
+
},
|
|
327
|
+
pending: [],
|
|
328
|
+
running: 0,
|
|
329
|
+
rateWindowStartedAt: Date.now(),
|
|
330
|
+
rateCount: 0
|
|
331
|
+
};
|
|
332
|
+
this.queues.set(name, runtime);
|
|
333
|
+
this.eventBus.emit("queue:created", { queueName: name, concurrency });
|
|
334
|
+
}
|
|
335
|
+
async addJob(queueName, job) {
|
|
336
|
+
const queue = this.getQueue(queueName);
|
|
337
|
+
validateJobName(job.name);
|
|
338
|
+
assertSerializablePayload(job.payload, this.maxPayloadBytes);
|
|
339
|
+
if (!job.task && !queue.options.processor) {
|
|
340
|
+
throw new Error(`Queue "${queueName}" requires either a queue processor or a task on addJob`);
|
|
341
|
+
}
|
|
342
|
+
if (queue.pending.length >= queue.options.maxSize) {
|
|
343
|
+
throw new Error(`Queue "${queueName}" is full`);
|
|
344
|
+
}
|
|
345
|
+
const id = job.id ?? createJobId("queue");
|
|
346
|
+
const createdAt = nowIso();
|
|
347
|
+
const record = {
|
|
348
|
+
id,
|
|
349
|
+
name: job.name,
|
|
350
|
+
kind: "queue",
|
|
351
|
+
status: "pending",
|
|
352
|
+
payload: job.payload,
|
|
353
|
+
priority: job.priority ?? 0,
|
|
354
|
+
attempts: 0,
|
|
355
|
+
maxAttempts: getMaxAttempts(job.retry ?? queue.options.retry),
|
|
356
|
+
createdAt,
|
|
357
|
+
updatedAt: createdAt,
|
|
358
|
+
successCount: 0,
|
|
359
|
+
failureCount: 0,
|
|
360
|
+
queueName
|
|
361
|
+
};
|
|
362
|
+
await this.storage.saveJob(record);
|
|
363
|
+
await this.insertByPriority(queue, id, record.priority);
|
|
364
|
+
void this.drain(queueName, job.task ?? queue.options.processor);
|
|
365
|
+
return id;
|
|
366
|
+
}
|
|
367
|
+
getQueuedCount() {
|
|
368
|
+
return [...this.queues.values()].reduce((total, queue) => total + queue.pending.length, 0);
|
|
369
|
+
}
|
|
370
|
+
async drain(queueName, task) {
|
|
371
|
+
const queue = this.getQueue(queueName);
|
|
372
|
+
while (queue.running < queue.options.concurrency && queue.pending.length > 0 && this.canRunByRateLimit(queue)) {
|
|
373
|
+
const jobId = queue.pending.shift();
|
|
374
|
+
if (!jobId) break;
|
|
375
|
+
queue.running += 1;
|
|
376
|
+
void this.runJob(jobId, task, queueName).finally(() => {
|
|
377
|
+
queue.running = Math.max(0, queue.running - 1);
|
|
378
|
+
if (queue.pending.length === 0 && queue.running === 0) this.eventBus.emit("queue:drain", { queueName });
|
|
379
|
+
void this.drain(queueName, task);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
getQueue(name) {
|
|
384
|
+
const queue = this.queues.get(name);
|
|
385
|
+
if (!queue) throw new Error(`Queue not found: ${name}`);
|
|
386
|
+
return queue;
|
|
387
|
+
}
|
|
388
|
+
async insertByPriority(queue, jobId, priority) {
|
|
389
|
+
let index = queue.pending.length;
|
|
390
|
+
for (let i = 0; i < queue.pending.length; i += 1) {
|
|
391
|
+
const existing = await this.storage.getJob(queue.pending[i]);
|
|
392
|
+
if ((existing?.priority ?? 0) < priority) {
|
|
393
|
+
index = i;
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
queue.pending.splice(index, 0, jobId);
|
|
398
|
+
}
|
|
399
|
+
canRunByRateLimit(queue) {
|
|
400
|
+
const rateLimit = queue.options.rateLimit;
|
|
401
|
+
if (!rateLimit) return true;
|
|
402
|
+
const now = Date.now();
|
|
403
|
+
if (now - queue.rateWindowStartedAt >= rateLimit.intervalMs) {
|
|
404
|
+
queue.rateWindowStartedAt = now;
|
|
405
|
+
queue.rateCount = 0;
|
|
406
|
+
}
|
|
407
|
+
if (queue.rateCount >= rateLimit.limit) {
|
|
408
|
+
const waitMs = rateLimit.intervalMs - (now - queue.rateWindowStartedAt);
|
|
409
|
+
setTimeout(() => void this.drain(queue.name), Math.max(1, waitMs)).unref?.();
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
queue.rateCount += 1;
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// src/core/cronParser.ts
|
|
418
|
+
var MONTHS = {
|
|
419
|
+
jan: 1,
|
|
420
|
+
feb: 2,
|
|
421
|
+
mar: 3,
|
|
422
|
+
apr: 4,
|
|
423
|
+
may: 5,
|
|
424
|
+
jun: 6,
|
|
425
|
+
jul: 7,
|
|
426
|
+
aug: 8,
|
|
427
|
+
sep: 9,
|
|
428
|
+
oct: 10,
|
|
429
|
+
nov: 11,
|
|
430
|
+
dec: 12
|
|
431
|
+
};
|
|
432
|
+
var DOW = {
|
|
433
|
+
sun: 0,
|
|
434
|
+
mon: 1,
|
|
435
|
+
tue: 2,
|
|
436
|
+
wed: 3,
|
|
437
|
+
thu: 4,
|
|
438
|
+
fri: 5,
|
|
439
|
+
sat: 6
|
|
440
|
+
};
|
|
441
|
+
function parseCronExpression(expression) {
|
|
442
|
+
const parts = expression.trim().replace(/\s+/g, " ").split(" ");
|
|
443
|
+
if (parts.length !== 5 && parts.length !== 6) {
|
|
444
|
+
throw new SilentCronXError("Invalid cron expression. Expected 5 fields, or 6 fields with seconds.");
|
|
445
|
+
}
|
|
446
|
+
const hasSeconds = parts.length === 6;
|
|
447
|
+
const [secondsRaw, minutesRaw, hoursRaw, domRaw, monthsRaw, dowRaw] = hasSeconds ? parts : ["0", ...parts];
|
|
448
|
+
return {
|
|
449
|
+
hasSeconds,
|
|
450
|
+
seconds: parseField(secondsRaw, 0, 59),
|
|
451
|
+
minutes: parseField(minutesRaw, 0, 59),
|
|
452
|
+
hours: parseField(hoursRaw, 0, 23),
|
|
453
|
+
daysOfMonth: parseField(domRaw, 1, 31),
|
|
454
|
+
months: parseField(monthsRaw, 1, 12, MONTHS),
|
|
455
|
+
daysOfWeek: normalizeDow(parseField(dowRaw, 0, 7, DOW)),
|
|
456
|
+
expression
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
function getNextCronDate(expression, from = /* @__PURE__ */ new Date(), timezone = "UTC") {
|
|
460
|
+
const parsed = typeof expression === "string" ? parseCronExpression(expression) : expression;
|
|
461
|
+
const stepMs = parsed.hasSeconds ? 1e3 : 6e4;
|
|
462
|
+
let candidate = new Date(Math.ceil((from.getTime() + stepMs) / stepMs) * stepMs);
|
|
463
|
+
const limit = candidate.getTime() + 366 * 24 * 60 * 60 * 1e3;
|
|
464
|
+
while (candidate.getTime() <= limit) {
|
|
465
|
+
const parts = getZonedParts(candidate, timezone);
|
|
466
|
+
if (parsed.seconds.has(parts.second) && parsed.minutes.has(parts.minute) && parsed.hours.has(parts.hour) && parsed.daysOfMonth.has(parts.day) && parsed.months.has(parts.month) && parsed.daysOfWeek.has(parts.weekday)) {
|
|
467
|
+
return candidate;
|
|
468
|
+
}
|
|
469
|
+
candidate = new Date(candidate.getTime() + stepMs);
|
|
470
|
+
}
|
|
471
|
+
throw new SilentCronXError(`Unable to find next run for cron expression: ${parsed.expression}`);
|
|
472
|
+
}
|
|
473
|
+
function parseField(raw, min, max, aliases = {}) {
|
|
474
|
+
if (!raw) throw new SilentCronXError("Cron field is missing");
|
|
475
|
+
const values = /* @__PURE__ */ new Set();
|
|
476
|
+
for (const token of raw.toLowerCase().split(",")) {
|
|
477
|
+
const [rangeRaw, stepRaw] = token.split("/");
|
|
478
|
+
const step = stepRaw ? Number(stepRaw) : 1;
|
|
479
|
+
if (!Number.isInteger(step) || step <= 0) {
|
|
480
|
+
throw new SilentCronXError(`Invalid cron step: ${token}`);
|
|
481
|
+
}
|
|
482
|
+
let start;
|
|
483
|
+
let end;
|
|
484
|
+
if (rangeRaw === "*") {
|
|
485
|
+
start = min;
|
|
486
|
+
end = max;
|
|
487
|
+
} else if (rangeRaw?.includes("-")) {
|
|
488
|
+
const [a, b] = rangeRaw.split("-");
|
|
489
|
+
start = parseValue(a, aliases);
|
|
490
|
+
end = parseValue(b, aliases);
|
|
491
|
+
} else {
|
|
492
|
+
start = parseValue(rangeRaw, aliases);
|
|
493
|
+
end = start;
|
|
494
|
+
}
|
|
495
|
+
if (start < min || end > max || start > end) {
|
|
496
|
+
throw new SilentCronXError(`Cron value out of range: ${token}`);
|
|
497
|
+
}
|
|
498
|
+
for (let value = start; value <= end; value += step) values.add(value);
|
|
499
|
+
}
|
|
500
|
+
return values;
|
|
501
|
+
}
|
|
502
|
+
function parseValue(raw, aliases) {
|
|
503
|
+
if (!raw) throw new SilentCronXError("Invalid empty cron value");
|
|
504
|
+
const aliased = aliases[raw] ?? Number(raw);
|
|
505
|
+
if (!Number.isInteger(aliased)) throw new SilentCronXError(`Invalid cron value: ${raw}`);
|
|
506
|
+
return aliased;
|
|
507
|
+
}
|
|
508
|
+
function normalizeDow(values) {
|
|
509
|
+
const normalized = /* @__PURE__ */ new Set();
|
|
510
|
+
for (const value of values) normalized.add(value === 7 ? 0 : value);
|
|
511
|
+
return normalized;
|
|
512
|
+
}
|
|
513
|
+
function getZonedParts(date, timezone) {
|
|
514
|
+
const formatter = new Intl.DateTimeFormat("en-US", {
|
|
515
|
+
timeZone: timezone,
|
|
516
|
+
second: "numeric",
|
|
517
|
+
minute: "numeric",
|
|
518
|
+
hour: "numeric",
|
|
519
|
+
hourCycle: "h23",
|
|
520
|
+
day: "numeric",
|
|
521
|
+
month: "numeric",
|
|
522
|
+
weekday: "short"
|
|
523
|
+
});
|
|
524
|
+
const map = new Map(formatter.formatToParts(date).map((part) => [part.type, part.value]));
|
|
525
|
+
return {
|
|
526
|
+
second: Number(map.get("second")),
|
|
527
|
+
minute: Number(map.get("minute")),
|
|
528
|
+
hour: Number(map.get("hour")),
|
|
529
|
+
day: Number(map.get("day")),
|
|
530
|
+
month: Number(map.get("month")),
|
|
531
|
+
weekday: DOW[(map.get("weekday") ?? "sun").toLowerCase()] ?? 0
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// src/storage/MemoryStorageAdapter.ts
|
|
536
|
+
var MemoryStorageAdapter = class {
|
|
537
|
+
jobs = /* @__PURE__ */ new Map();
|
|
538
|
+
locks = /* @__PURE__ */ new Map();
|
|
539
|
+
async saveJob(job) {
|
|
540
|
+
if (this.jobs.has(job.id)) {
|
|
541
|
+
throw new SilentCronXError(`Duplicate job id rejected: ${job.id}`);
|
|
542
|
+
}
|
|
543
|
+
this.jobs.set(job.id, { ...job });
|
|
544
|
+
}
|
|
545
|
+
async updateJob(jobId, patch) {
|
|
546
|
+
const existing = this.jobs.get(jobId);
|
|
547
|
+
if (!existing) throw new SilentCronXError(`Job not found: ${jobId}`);
|
|
548
|
+
this.jobs.set(jobId, { ...existing, ...patch, updatedAt: nowIso() });
|
|
549
|
+
}
|
|
550
|
+
async getJob(jobId) {
|
|
551
|
+
const job = this.jobs.get(jobId);
|
|
552
|
+
return job ? { ...job } : null;
|
|
553
|
+
}
|
|
554
|
+
async listJobs(filter = {}) {
|
|
555
|
+
return [...this.jobs.values()].filter((job) => !filter.status || job.status === filter.status).filter((job) => !filter.name || job.name === filter.name).filter((job) => !filter.kind || job.kind === filter.kind).filter((job) => !filter.queueName || job.queueName === filter.queueName).map((job) => ({ ...job }));
|
|
556
|
+
}
|
|
557
|
+
async deleteJob(jobId) {
|
|
558
|
+
this.jobs.delete(jobId);
|
|
559
|
+
}
|
|
560
|
+
async acquireLock(lockKey, ttlMs) {
|
|
561
|
+
this.cleanupExpiredLocks();
|
|
562
|
+
const existing = this.locks.get(lockKey);
|
|
563
|
+
if (existing && existing.expiresAt > Date.now()) return false;
|
|
564
|
+
this.locks.set(lockKey, { expiresAt: Date.now() + ttlMs });
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
async releaseLock(lockKey) {
|
|
568
|
+
this.locks.delete(lockKey);
|
|
569
|
+
}
|
|
570
|
+
cleanupExpiredLocks() {
|
|
571
|
+
const now = Date.now();
|
|
572
|
+
for (const [key, lock] of this.locks) {
|
|
573
|
+
if (lock.expiresAt <= now) this.locks.delete(key);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
// src/workers/workerPool.ts
|
|
579
|
+
import { Worker } from "worker_threads";
|
|
580
|
+
|
|
581
|
+
// src/workers/workerBridge.ts
|
|
582
|
+
function normalizeWorkerReference(worker) {
|
|
583
|
+
return {
|
|
584
|
+
modulePath: worker.path instanceof URL ? worker.path.href : worker.path,
|
|
585
|
+
exportName: worker.exportName ?? "default"
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/workers/workerPool.ts
|
|
590
|
+
var WorkerPool = class {
|
|
591
|
+
constructor(maxWorkers) {
|
|
592
|
+
this.maxWorkers = maxWorkers;
|
|
593
|
+
}
|
|
594
|
+
maxWorkers;
|
|
595
|
+
busy = 0;
|
|
596
|
+
pending = [];
|
|
597
|
+
liveWorkers = /* @__PURE__ */ new Set();
|
|
598
|
+
getStats() {
|
|
599
|
+
return {
|
|
600
|
+
max: this.maxWorkers,
|
|
601
|
+
busy: this.busy,
|
|
602
|
+
idle: Math.max(0, this.maxWorkers - this.busy)
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
run(options) {
|
|
606
|
+
return new Promise((resolve, reject) => {
|
|
607
|
+
const execute = () => {
|
|
608
|
+
this.busy += 1;
|
|
609
|
+
const normalized = normalizeWorkerReference(options.worker);
|
|
610
|
+
const worker = new Worker(getWorkerBridgeUrl(), {
|
|
611
|
+
workerData: {
|
|
612
|
+
modulePath: normalized.modulePath,
|
|
613
|
+
exportName: normalized.exportName,
|
|
614
|
+
payload: options.payload,
|
|
615
|
+
jobId: options.jobId,
|
|
616
|
+
name: options.name,
|
|
617
|
+
attempt: options.attempt,
|
|
618
|
+
createdAt: options.createdAt,
|
|
619
|
+
startedAt: options.startedAt
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
this.liveWorkers.add(worker);
|
|
623
|
+
let settled = false;
|
|
624
|
+
const timer = setTimeout(() => {
|
|
625
|
+
if (settled) return;
|
|
626
|
+
settled = true;
|
|
627
|
+
void worker.terminate();
|
|
628
|
+
reject(new Error(`Worker timed out after ${options.timeoutMs}ms`));
|
|
629
|
+
this.finish(worker);
|
|
630
|
+
}, options.timeoutMs);
|
|
631
|
+
timer.unref?.();
|
|
632
|
+
worker.once("message", (message) => {
|
|
633
|
+
if (settled) return;
|
|
634
|
+
settled = true;
|
|
635
|
+
clearTimeout(timer);
|
|
636
|
+
if (message.ok) resolve(message.result);
|
|
637
|
+
else reject(serializeError(message.error));
|
|
638
|
+
void worker.terminate();
|
|
639
|
+
this.finish(worker);
|
|
640
|
+
});
|
|
641
|
+
worker.once("error", (error) => {
|
|
642
|
+
if (settled) return;
|
|
643
|
+
settled = true;
|
|
644
|
+
clearTimeout(timer);
|
|
645
|
+
reject(error);
|
|
646
|
+
this.finish(worker);
|
|
647
|
+
});
|
|
648
|
+
worker.once("exit", (code) => {
|
|
649
|
+
if (settled) return;
|
|
650
|
+
settled = true;
|
|
651
|
+
clearTimeout(timer);
|
|
652
|
+
if (code === 0) resolve(void 0);
|
|
653
|
+
else reject(new Error(`Worker exited with code ${code}`));
|
|
654
|
+
this.finish(worker);
|
|
655
|
+
});
|
|
656
|
+
};
|
|
657
|
+
if (this.busy < this.maxWorkers) execute();
|
|
658
|
+
else this.pending.push({ run: execute });
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
async shutdown() {
|
|
662
|
+
this.pending.length = 0;
|
|
663
|
+
await Promise.all([...this.liveWorkers].map((worker) => worker.terminate()));
|
|
664
|
+
this.liveWorkers.clear();
|
|
665
|
+
this.busy = 0;
|
|
666
|
+
}
|
|
667
|
+
finish(worker) {
|
|
668
|
+
this.liveWorkers.delete(worker);
|
|
669
|
+
this.busy = Math.max(0, this.busy - 1);
|
|
670
|
+
const next = this.pending.shift();
|
|
671
|
+
if (next) queueMicrotask(next.run);
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
var workerBridgeUrl;
|
|
675
|
+
function getWorkerBridgeUrl() {
|
|
676
|
+
workerBridgeUrl ??= new URL(
|
|
677
|
+
`data:text/javascript;charset=utf-8,${encodeURIComponent(`
|
|
678
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
679
|
+
|
|
680
|
+
async function run() {
|
|
681
|
+
const mod = await import(workerData.modulePath);
|
|
682
|
+
const worker = mod[workerData.exportName];
|
|
683
|
+
if (typeof worker !== "function") {
|
|
684
|
+
throw new Error(\`Worker export "\${workerData.exportName}" was not found or is not a function\`);
|
|
685
|
+
}
|
|
686
|
+
const result = await worker({
|
|
687
|
+
jobId: workerData.jobId,
|
|
688
|
+
name: workerData.name,
|
|
689
|
+
payload: workerData.payload,
|
|
690
|
+
attempt: workerData.attempt,
|
|
691
|
+
signal: new AbortController().signal,
|
|
692
|
+
createdAt: new Date(workerData.createdAt),
|
|
693
|
+
startedAt: new Date(workerData.startedAt)
|
|
694
|
+
});
|
|
695
|
+
parentPort?.postMessage({ ok: true, result });
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
run().catch((error) => {
|
|
699
|
+
parentPort?.postMessage({
|
|
700
|
+
ok: false,
|
|
701
|
+
error: error instanceof Error
|
|
702
|
+
? { name: error.name, message: error.message, stack: error.stack }
|
|
703
|
+
: error
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
`)}`
|
|
707
|
+
);
|
|
708
|
+
return workerBridgeUrl;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/utils/logger.ts
|
|
712
|
+
var noop = () => void 0;
|
|
713
|
+
function createLogger(config) {
|
|
714
|
+
const base = config.logger ?? {};
|
|
715
|
+
return {
|
|
716
|
+
info: base.info ?? noop,
|
|
717
|
+
warn: base.warn ?? noop,
|
|
718
|
+
error: base.error ?? noop,
|
|
719
|
+
debug: config.debug ? base.debug ?? noop : noop
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// src/core/SilentCronX.ts
|
|
724
|
+
var SilentCronX = class {
|
|
725
|
+
config;
|
|
726
|
+
storage;
|
|
727
|
+
eventBus = new EventBus();
|
|
728
|
+
runner = new JobRunner();
|
|
729
|
+
workerPool;
|
|
730
|
+
lockManager;
|
|
731
|
+
queueManager;
|
|
732
|
+
logger;
|
|
733
|
+
entries = /* @__PURE__ */ new Map();
|
|
734
|
+
controllers = /* @__PURE__ */ new Map();
|
|
735
|
+
running = false;
|
|
736
|
+
constructor(config = {}) {
|
|
737
|
+
this.config = {
|
|
738
|
+
timezone: config.timezone ?? "UTC",
|
|
739
|
+
maxWorkers: config.maxWorkers ?? 2,
|
|
740
|
+
maxConcurrency: config.maxConcurrency ?? 50,
|
|
741
|
+
defaultTimeout: config.defaultTimeout ?? 3e4,
|
|
742
|
+
preventOverlapping: config.preventOverlapping ?? true,
|
|
743
|
+
lockTimeout: config.lockTimeout ?? config.defaultTimeout ?? 3e4,
|
|
744
|
+
...config
|
|
745
|
+
};
|
|
746
|
+
this.storage = config.storage && config.storage !== "memory" ? config.storage : new MemoryStorageAdapter();
|
|
747
|
+
this.logger = createLogger(config);
|
|
748
|
+
this.workerPool = new WorkerPool(this.config.maxWorkers);
|
|
749
|
+
this.lockManager = new LockManager(this.storage, this.config.lockTimeout);
|
|
750
|
+
this.queueManager = new QueueManager(
|
|
751
|
+
this.storage,
|
|
752
|
+
this.eventBus,
|
|
753
|
+
this.config.maxPayloadBytes,
|
|
754
|
+
(jobId, task) => this.executeJob(jobId, task)
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
async schedule(name, options) {
|
|
758
|
+
validateJobName(name);
|
|
759
|
+
assertTask(options.task);
|
|
760
|
+
assertSerializablePayload(options.payload, this.config.maxPayloadBytes);
|
|
761
|
+
parseCronExpression(options.cron);
|
|
762
|
+
const id = createJobId("cron");
|
|
763
|
+
const nextRunAt = getNextCronDate(options.cron, /* @__PURE__ */ new Date(), options.timezone ?? this.config.timezone).toISOString();
|
|
764
|
+
await this.createRecord(id, name, "cron", options.payload, options.priority, getMaxAttempts(options.retry), {
|
|
765
|
+
nextRunAt,
|
|
766
|
+
metadata: { cron: options.cron, timezone: options.timezone ?? this.config.timezone },
|
|
767
|
+
status: options.enabled === false ? "paused" : "scheduled"
|
|
768
|
+
});
|
|
769
|
+
this.entries.set(id, {
|
|
770
|
+
id,
|
|
771
|
+
name,
|
|
772
|
+
kind: "cron",
|
|
773
|
+
task: options.task,
|
|
774
|
+
cron: options.cron,
|
|
775
|
+
retry: options.retry,
|
|
776
|
+
timeoutMs: options.timeout ?? this.config.defaultTimeout,
|
|
777
|
+
preventOverlap: options.preventOverlap ?? this.config.preventOverlapping
|
|
778
|
+
});
|
|
779
|
+
this.eventBus.emit("job:scheduled", { jobId: id, name, nextRunAt });
|
|
780
|
+
if (this.running && options.enabled !== false) this.armCron(id);
|
|
781
|
+
return id;
|
|
782
|
+
}
|
|
783
|
+
async delay(name, options) {
|
|
784
|
+
validateJobName(name);
|
|
785
|
+
assertPositiveNumber(options.delayMs, "delayMs");
|
|
786
|
+
assertTask(options.task);
|
|
787
|
+
assertSerializablePayload(options.payload, this.config.maxPayloadBytes);
|
|
788
|
+
const id = createJobId("delay");
|
|
789
|
+
const nextRunAt = new Date(Date.now() + options.delayMs).toISOString();
|
|
790
|
+
await this.createRecord(id, name, "delay", options.payload, options.priority, getMaxAttempts(options.retry), {
|
|
791
|
+
nextRunAt,
|
|
792
|
+
status: "scheduled"
|
|
793
|
+
});
|
|
794
|
+
this.entries.set(id, {
|
|
795
|
+
id,
|
|
796
|
+
name,
|
|
797
|
+
kind: "delay",
|
|
798
|
+
task: options.task,
|
|
799
|
+
delayMs: options.delayMs,
|
|
800
|
+
retry: options.retry,
|
|
801
|
+
timeoutMs: options.timeout ?? this.config.defaultTimeout,
|
|
802
|
+
preventOverlap: false
|
|
803
|
+
});
|
|
804
|
+
if (this.running) this.armDelay(id, options.delayMs);
|
|
805
|
+
return id;
|
|
806
|
+
}
|
|
807
|
+
async every(name, options) {
|
|
808
|
+
validateJobName(name);
|
|
809
|
+
assertPositiveNumber(options.intervalMs, "intervalMs");
|
|
810
|
+
assertTask(options.task);
|
|
811
|
+
assertSerializablePayload(options.payload, this.config.maxPayloadBytes);
|
|
812
|
+
const id = createJobId("every");
|
|
813
|
+
const nextRunAt = new Date(Date.now() + options.intervalMs).toISOString();
|
|
814
|
+
await this.createRecord(id, name, "every", options.payload, options.priority, getMaxAttempts(options.retry), {
|
|
815
|
+
nextRunAt,
|
|
816
|
+
status: options.enabled === false ? "paused" : "scheduled"
|
|
817
|
+
});
|
|
818
|
+
this.entries.set(id, {
|
|
819
|
+
id,
|
|
820
|
+
name,
|
|
821
|
+
kind: "every",
|
|
822
|
+
task: options.task,
|
|
823
|
+
intervalMs: options.intervalMs,
|
|
824
|
+
retry: options.retry,
|
|
825
|
+
timeoutMs: options.timeout ?? this.config.defaultTimeout,
|
|
826
|
+
preventOverlap: options.preventOverlap ?? this.config.preventOverlapping
|
|
827
|
+
});
|
|
828
|
+
if (this.running && options.enabled !== false) this.armEvery(id);
|
|
829
|
+
return id;
|
|
830
|
+
}
|
|
831
|
+
async runNow(name, options) {
|
|
832
|
+
validateJobName(name);
|
|
833
|
+
assertTask(options.task);
|
|
834
|
+
assertSerializablePayload(options.payload, this.config.maxPayloadBytes);
|
|
835
|
+
const id = createJobId("now");
|
|
836
|
+
await this.createRecord(id, name, "runNow", options.payload, options.priority, getMaxAttempts(options.retry), {
|
|
837
|
+
status: "pending"
|
|
838
|
+
});
|
|
839
|
+
this.entries.set(id, {
|
|
840
|
+
id,
|
|
841
|
+
name,
|
|
842
|
+
kind: "runNow",
|
|
843
|
+
task: options.task,
|
|
844
|
+
retry: options.retry,
|
|
845
|
+
timeoutMs: options.timeout ?? this.config.defaultTimeout,
|
|
846
|
+
preventOverlap: false
|
|
847
|
+
});
|
|
848
|
+
return this.executeJob(id);
|
|
849
|
+
}
|
|
850
|
+
async runWorker(name, options) {
|
|
851
|
+
validateJobName(name);
|
|
852
|
+
assertSerializablePayload(options.payload, this.config.maxPayloadBytes);
|
|
853
|
+
const id = createJobId("worker");
|
|
854
|
+
await this.createRecord(id, name, "worker", options.payload, options.priority, getMaxAttempts(options.retry), {
|
|
855
|
+
status: "pending"
|
|
856
|
+
});
|
|
857
|
+
const timeoutMs = options.timeout ?? this.config.defaultTimeout;
|
|
858
|
+
const workerTask = typeof options.worker === "function" ? options.worker : async (context) => this.workerPool.run({
|
|
859
|
+
worker: options.worker,
|
|
860
|
+
payload: context.payload,
|
|
861
|
+
jobId: context.jobId,
|
|
862
|
+
name: context.name,
|
|
863
|
+
attempt: context.attempt,
|
|
864
|
+
timeoutMs,
|
|
865
|
+
createdAt: context.createdAt.toISOString(),
|
|
866
|
+
startedAt: context.startedAt.toISOString()
|
|
867
|
+
});
|
|
868
|
+
if (typeof options.worker === "function") {
|
|
869
|
+
this.logger.warn(
|
|
870
|
+
"runWorker received an inline function. For security, SilentCronX does not eval code strings; the function will run through the async runner. Pass { path, exportName } to use worker_threads."
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
this.entries.set(id, {
|
|
874
|
+
id,
|
|
875
|
+
name,
|
|
876
|
+
kind: "worker",
|
|
877
|
+
task: workerTask,
|
|
878
|
+
retry: options.retry,
|
|
879
|
+
timeoutMs,
|
|
880
|
+
preventOverlap: false
|
|
881
|
+
});
|
|
882
|
+
return this.executeJob(id);
|
|
883
|
+
}
|
|
884
|
+
queue(name, options = {}) {
|
|
885
|
+
this.queueManager.createQueue(name, options);
|
|
886
|
+
}
|
|
887
|
+
addJob(queueName, job) {
|
|
888
|
+
return this.queueManager.addJob(queueName, job);
|
|
889
|
+
}
|
|
890
|
+
async cancel(jobId) {
|
|
891
|
+
this.controllers.get(jobId)?.abort(new JobCancelledError());
|
|
892
|
+
const job = await this.storage.getJob(jobId);
|
|
893
|
+
if (!job) return;
|
|
894
|
+
this.clearTimer(jobId);
|
|
895
|
+
await this.storage.updateJob(jobId, { status: "cancelled", finishedAt: nowIso() });
|
|
896
|
+
this.eventBus.emit("job:cancelled", { jobId, name: job.name });
|
|
897
|
+
}
|
|
898
|
+
async pause(jobId) {
|
|
899
|
+
const job = await this.storage.getJob(jobId);
|
|
900
|
+
if (!job) return;
|
|
901
|
+
this.clearTimer(jobId);
|
|
902
|
+
await this.storage.updateJob(jobId, { status: "paused" });
|
|
903
|
+
this.eventBus.emit("job:paused", { jobId, name: job.name });
|
|
904
|
+
}
|
|
905
|
+
async resume(jobId) {
|
|
906
|
+
const job = await this.storage.getJob(jobId);
|
|
907
|
+
const entry = this.entries.get(jobId);
|
|
908
|
+
if (!job || !entry) return;
|
|
909
|
+
await this.storage.updateJob(jobId, { status: entry.kind === "runNow" ? "pending" : "scheduled" });
|
|
910
|
+
this.eventBus.emit("job:resumed", { jobId, name: job.name });
|
|
911
|
+
if (this.running) this.armEntry(jobId);
|
|
912
|
+
}
|
|
913
|
+
async getStatus(jobId) {
|
|
914
|
+
const job = await this.storage.getJob(jobId);
|
|
915
|
+
if (!job) return null;
|
|
916
|
+
return {
|
|
917
|
+
jobId,
|
|
918
|
+
name: job.name,
|
|
919
|
+
status: job.status,
|
|
920
|
+
attempt: job.attempts,
|
|
921
|
+
nextRunAt: job.nextRunAt,
|
|
922
|
+
lastRunAt: job.lastRunAt
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
getJobs() {
|
|
926
|
+
return this.storage.listJobs();
|
|
927
|
+
}
|
|
928
|
+
start() {
|
|
929
|
+
if (this.running) return;
|
|
930
|
+
this.running = true;
|
|
931
|
+
for (const jobId of this.entries.keys()) this.armEntry(jobId);
|
|
932
|
+
this.eventBus.emit("scheduler:started", { running: true, at: nowIso() });
|
|
933
|
+
}
|
|
934
|
+
stop() {
|
|
935
|
+
if (!this.running) return;
|
|
936
|
+
this.running = false;
|
|
937
|
+
for (const jobId of this.entries.keys()) this.clearTimer(jobId);
|
|
938
|
+
this.eventBus.emit("scheduler:stopped", { running: false, at: nowIso() });
|
|
939
|
+
}
|
|
940
|
+
async shutdown() {
|
|
941
|
+
this.stop();
|
|
942
|
+
for (const controller of this.controllers.values()) controller.abort(new JobCancelledError("Scheduler shutdown"));
|
|
943
|
+
this.controllers.clear();
|
|
944
|
+
await this.workerPool.shutdown();
|
|
945
|
+
this.eventBus.emit("scheduler:shutdown", { at: nowIso() });
|
|
946
|
+
}
|
|
947
|
+
on(eventName, handler) {
|
|
948
|
+
this.eventBus.on(eventName, handler);
|
|
949
|
+
}
|
|
950
|
+
off(eventName, handler) {
|
|
951
|
+
this.eventBus.off(eventName, handler);
|
|
952
|
+
}
|
|
953
|
+
async getHealth() {
|
|
954
|
+
const jobs = await this.storage.listJobs();
|
|
955
|
+
return {
|
|
956
|
+
running: this.running,
|
|
957
|
+
scheduledJobs: jobs.filter((job) => job.status === "scheduled").length,
|
|
958
|
+
runningJobs: jobs.filter((job) => job.status === "running").length,
|
|
959
|
+
queuedJobs: this.queueManager.getQueuedCount(),
|
|
960
|
+
workers: this.workerPool.getStats(),
|
|
961
|
+
memory: {
|
|
962
|
+
storage: this.storage instanceof MemoryStorageAdapter ? "memory" : "custom"
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
async executeJob(jobId, taskOverride) {
|
|
967
|
+
while (this.runner.getActiveCount() >= this.config.maxConcurrency) {
|
|
968
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
969
|
+
}
|
|
970
|
+
const record = await this.storage.getJob(jobId);
|
|
971
|
+
const entry = this.entries.get(jobId);
|
|
972
|
+
const task = taskOverride ?? entry?.task;
|
|
973
|
+
if (!record || !entry || !task) throw new SilentCronXError(`Job cannot be executed: ${jobId}`);
|
|
974
|
+
if (record.status === "cancelled" || record.status === "paused") {
|
|
975
|
+
return { jobId, name: record.name, status: record.status, attempt: record.attempts, durationMs: 0 };
|
|
976
|
+
}
|
|
977
|
+
const controller = new AbortController();
|
|
978
|
+
this.controllers.set(jobId, controller);
|
|
979
|
+
try {
|
|
980
|
+
return await this.runner.run({
|
|
981
|
+
record,
|
|
982
|
+
task,
|
|
983
|
+
retry: entry.retry,
|
|
984
|
+
timeoutMs: entry.timeoutMs,
|
|
985
|
+
signal: controller.signal,
|
|
986
|
+
lockKey: entry.preventOverlap ? `job:${record.name}` : void 0,
|
|
987
|
+
lockTtlMs: this.config.lockTimeout,
|
|
988
|
+
storage: this.storage,
|
|
989
|
+
eventBus: this.eventBus,
|
|
990
|
+
acquireLock: (key, ttl) => this.lockManager.acquire(key, ttl),
|
|
991
|
+
releaseLock: (key) => this.lockManager.release(key)
|
|
992
|
+
});
|
|
993
|
+
} finally {
|
|
994
|
+
this.controllers.delete(jobId);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
armEntry(jobId) {
|
|
998
|
+
const entry = this.entries.get(jobId);
|
|
999
|
+
if (!entry) return;
|
|
1000
|
+
if (entry.kind === "cron") this.armCron(jobId);
|
|
1001
|
+
else if (entry.kind === "delay") this.armDelay(jobId, entry.delayMs ?? 0);
|
|
1002
|
+
else if (entry.kind === "every") this.armEvery(jobId);
|
|
1003
|
+
}
|
|
1004
|
+
async armCron(jobId) {
|
|
1005
|
+
const entry = this.entries.get(jobId);
|
|
1006
|
+
const record = await this.storage.getJob(jobId);
|
|
1007
|
+
if (!entry?.cron || !record || record.status === "paused" || record.status === "cancelled") return;
|
|
1008
|
+
const next = getNextCronDate(entry.cron, /* @__PURE__ */ new Date(), this.config.timezone);
|
|
1009
|
+
await this.storage.updateJob(jobId, { status: "scheduled", nextRunAt: next.toISOString() });
|
|
1010
|
+
this.clearTimer(jobId);
|
|
1011
|
+
const timer = setTimeout(() => {
|
|
1012
|
+
void this.executeJob(jobId).finally(() => {
|
|
1013
|
+
if (this.running) void this.armCron(jobId);
|
|
1014
|
+
});
|
|
1015
|
+
}, Math.max(1, next.getTime() - Date.now()));
|
|
1016
|
+
timer.unref?.();
|
|
1017
|
+
entry.timer = timer;
|
|
1018
|
+
}
|
|
1019
|
+
armDelay(jobId, delayMs) {
|
|
1020
|
+
const entry = this.entries.get(jobId);
|
|
1021
|
+
if (!entry) return;
|
|
1022
|
+
this.clearTimer(jobId);
|
|
1023
|
+
const timer = setTimeout(() => void this.executeJob(jobId), delayMs);
|
|
1024
|
+
timer.unref?.();
|
|
1025
|
+
entry.timer = timer;
|
|
1026
|
+
}
|
|
1027
|
+
async armEvery(jobId) {
|
|
1028
|
+
const entry = this.entries.get(jobId);
|
|
1029
|
+
const record = await this.storage.getJob(jobId);
|
|
1030
|
+
if (!entry?.intervalMs || !record || record.status === "paused" || record.status === "cancelled") return;
|
|
1031
|
+
const next = new Date(Date.now() + entry.intervalMs);
|
|
1032
|
+
await this.storage.updateJob(jobId, { status: "scheduled", nextRunAt: next.toISOString() });
|
|
1033
|
+
this.clearTimer(jobId);
|
|
1034
|
+
const timer = setTimeout(() => {
|
|
1035
|
+
void this.executeJob(jobId).finally(() => {
|
|
1036
|
+
if (this.running) void this.armEvery(jobId);
|
|
1037
|
+
});
|
|
1038
|
+
}, entry.intervalMs);
|
|
1039
|
+
timer.unref?.();
|
|
1040
|
+
entry.timer = timer;
|
|
1041
|
+
}
|
|
1042
|
+
clearTimer(jobId) {
|
|
1043
|
+
const entry = this.entries.get(jobId);
|
|
1044
|
+
if (entry?.timer) clearTimeout(entry.timer);
|
|
1045
|
+
if (entry) entry.timer = void 0;
|
|
1046
|
+
}
|
|
1047
|
+
async createRecord(id, name, kind, payload, priority = 0, maxAttempts = 1, extra = {}) {
|
|
1048
|
+
const createdAt = nowIso();
|
|
1049
|
+
await this.storage.saveJob({
|
|
1050
|
+
id,
|
|
1051
|
+
name,
|
|
1052
|
+
kind,
|
|
1053
|
+
status: extra.status ?? "pending",
|
|
1054
|
+
payload,
|
|
1055
|
+
priority,
|
|
1056
|
+
attempts: 0,
|
|
1057
|
+
maxAttempts,
|
|
1058
|
+
createdAt,
|
|
1059
|
+
updatedAt: createdAt,
|
|
1060
|
+
successCount: 0,
|
|
1061
|
+
failureCount: 0,
|
|
1062
|
+
...extra
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
// src/core/createSilentCronX.ts
|
|
1068
|
+
function createSilentCronX(config = {}) {
|
|
1069
|
+
return new SilentCronX(config);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// src/storage/RedisStorageAdapter.placeholder.ts
|
|
1073
|
+
var RedisStorageAdapter = class {
|
|
1074
|
+
constructor() {
|
|
1075
|
+
throw new Error(
|
|
1076
|
+
"RedisStorageAdapter is a design placeholder. Implement StorageAdapter with SET NX PX locks, JSON job records, and atomic updates for multi-instance production use."
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
saveJob(_job) {
|
|
1080
|
+
throw new Error("Not implemented");
|
|
1081
|
+
}
|
|
1082
|
+
updateJob(_jobId, _patch) {
|
|
1083
|
+
throw new Error("Not implemented");
|
|
1084
|
+
}
|
|
1085
|
+
getJob(_jobId) {
|
|
1086
|
+
throw new Error("Not implemented");
|
|
1087
|
+
}
|
|
1088
|
+
listJobs(_filter) {
|
|
1089
|
+
throw new Error("Not implemented");
|
|
1090
|
+
}
|
|
1091
|
+
deleteJob(_jobId) {
|
|
1092
|
+
throw new Error("Not implemented");
|
|
1093
|
+
}
|
|
1094
|
+
acquireLock(_lockKey, _ttlMs) {
|
|
1095
|
+
throw new Error("Not implemented");
|
|
1096
|
+
}
|
|
1097
|
+
releaseLock(_lockKey) {
|
|
1098
|
+
throw new Error("Not implemented");
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
// src/storage/PostgresStorageAdapter.placeholder.ts
|
|
1103
|
+
var PostgresStorageAdapter = class {
|
|
1104
|
+
constructor() {
|
|
1105
|
+
throw new Error(
|
|
1106
|
+
"PostgresStorageAdapter is a design placeholder. Implement StorageAdapter using transactions, row-level locks, indexed status columns, and advisory locks for production use."
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
saveJob(_job) {
|
|
1110
|
+
throw new Error("Not implemented");
|
|
1111
|
+
}
|
|
1112
|
+
updateJob(_jobId, _patch) {
|
|
1113
|
+
throw new Error("Not implemented");
|
|
1114
|
+
}
|
|
1115
|
+
getJob(_jobId) {
|
|
1116
|
+
throw new Error("Not implemented");
|
|
1117
|
+
}
|
|
1118
|
+
listJobs(_filter) {
|
|
1119
|
+
throw new Error("Not implemented");
|
|
1120
|
+
}
|
|
1121
|
+
deleteJob(_jobId) {
|
|
1122
|
+
throw new Error("Not implemented");
|
|
1123
|
+
}
|
|
1124
|
+
acquireLock(_lockKey, _ttlMs) {
|
|
1125
|
+
throw new Error("Not implemented");
|
|
1126
|
+
}
|
|
1127
|
+
releaseLock(_lockKey) {
|
|
1128
|
+
throw new Error("Not implemented");
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
export {
|
|
1132
|
+
MemoryStorageAdapter,
|
|
1133
|
+
PostgresStorageAdapter,
|
|
1134
|
+
RedisStorageAdapter,
|
|
1135
|
+
SilentCronX,
|
|
1136
|
+
createSilentCronX
|
|
1137
|
+
};
|
|
1138
|
+
//# sourceMappingURL=index.js.map
|