@ap3x/cadence 0.1.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 +28 -0
- package/dist/core/backoff.d.ts +21 -0
- package/dist/core/backoff.d.ts.map +1 -0
- package/dist/core/clock.d.ts +48 -0
- package/dist/core/clock.d.ts.map +1 -0
- package/dist/core/engine.d.ts +46 -0
- package/dist/core/engine.d.ts.map +1 -0
- package/dist/core/executor.d.ts +28 -0
- package/dist/core/executor.d.ts.map +1 -0
- package/dist/core/memory-store.d.ts +36 -0
- package/dist/core/memory-store.d.ts.map +1 -0
- package/dist/core/next-run.d.ts +21 -0
- package/dist/core/next-run.d.ts.map +1 -0
- package/dist/core/store.d.ts +52 -0
- package/dist/core/store.d.ts.map +1 -0
- package/dist/core/types.d.ts +116 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/file-store.d.ts +21 -0
- package/dist/file-store.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +867 -0
- package/package.json +33 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
// src/core/backoff.ts
|
|
2
|
+
function backoffMs(attempt, baseMs, capMs) {
|
|
3
|
+
const a = Number.isFinite(attempt) ? Math.max(1, Math.floor(attempt)) : 1;
|
|
4
|
+
const base = Number.isFinite(baseMs) ? Math.max(0, baseMs) : 0;
|
|
5
|
+
const cap = Number.isFinite(capMs) ? Math.max(0, capMs) : Number.POSITIVE_INFINITY;
|
|
6
|
+
const grown = base * 2 ** (a - 1);
|
|
7
|
+
return Math.min(grown, cap);
|
|
8
|
+
}
|
|
9
|
+
function applyJitter(ms, jitterMs, seed) {
|
|
10
|
+
if (!Number.isFinite(jitterMs) || jitterMs <= 0) return ms;
|
|
11
|
+
let h = (Number.isFinite(seed) ? Math.floor(seed) : 0) | 0;
|
|
12
|
+
h = Math.imul(h ^ h >>> 16, 73244475);
|
|
13
|
+
h = Math.imul(h ^ h >>> 16, 73244475);
|
|
14
|
+
h = (h ^ h >>> 16) >>> 0;
|
|
15
|
+
const offset = Math.floor(h / 4294967296 * (jitterMs + 1));
|
|
16
|
+
return ms + offset;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/core/clock.ts
|
|
20
|
+
var SystemClock = class {
|
|
21
|
+
#nowFn;
|
|
22
|
+
constructor(nowFn) {
|
|
23
|
+
this.#nowFn = nowFn;
|
|
24
|
+
}
|
|
25
|
+
now() {
|
|
26
|
+
return this.#nowFn();
|
|
27
|
+
}
|
|
28
|
+
sleep(ms, signal) {
|
|
29
|
+
if (signal?.aborted) return Promise.resolve();
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
const onAbort = () => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
resolve();
|
|
34
|
+
};
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
signal?.removeEventListener("abort", onAbort);
|
|
37
|
+
resolve();
|
|
38
|
+
}, ms);
|
|
39
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var TestClock = class {
|
|
44
|
+
#nowMs;
|
|
45
|
+
#pending = [];
|
|
46
|
+
constructor(startIso) {
|
|
47
|
+
this.#nowMs = new Date(startIso).getTime();
|
|
48
|
+
}
|
|
49
|
+
now() {
|
|
50
|
+
return new Date(this.#nowMs);
|
|
51
|
+
}
|
|
52
|
+
/** Move time forward by `ms`, resolving any sleeps whose deadline passed. */
|
|
53
|
+
advance(ms) {
|
|
54
|
+
this.#nowMs += ms;
|
|
55
|
+
this.#flush();
|
|
56
|
+
}
|
|
57
|
+
/** Jump to an absolute instant, resolving any sleeps whose deadline passed. */
|
|
58
|
+
setNow(iso) {
|
|
59
|
+
this.#nowMs = new Date(iso).getTime();
|
|
60
|
+
this.#flush();
|
|
61
|
+
}
|
|
62
|
+
sleep(ms, signal) {
|
|
63
|
+
if (signal?.aborted) return Promise.resolve();
|
|
64
|
+
return new Promise((resolve) => {
|
|
65
|
+
const entry = { deadline: this.#nowMs + ms, resolve };
|
|
66
|
+
this.#pending.push(entry);
|
|
67
|
+
signal?.addEventListener(
|
|
68
|
+
"abort",
|
|
69
|
+
() => {
|
|
70
|
+
const i = this.#pending.indexOf(entry);
|
|
71
|
+
if (i !== -1) this.#pending.splice(i, 1);
|
|
72
|
+
resolve();
|
|
73
|
+
},
|
|
74
|
+
{ once: true }
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
#flush() {
|
|
79
|
+
const due = this.#pending.filter((p) => p.deadline <= this.#nowMs).sort((a, b) => a.deadline - b.deadline);
|
|
80
|
+
this.#pending = this.#pending.filter((p) => p.deadline > this.#nowMs);
|
|
81
|
+
for (const p of due) p.resolve();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// src/core/engine.ts
|
|
86
|
+
import { Value } from "@sinclair/typebox/value";
|
|
87
|
+
|
|
88
|
+
// src/core/next-run.ts
|
|
89
|
+
import parser from "cron-parser";
|
|
90
|
+
var REFERENCE = /* @__PURE__ */ new Date(0);
|
|
91
|
+
function computeNextRun(s, after) {
|
|
92
|
+
switch (s.kind) {
|
|
93
|
+
case "cron": {
|
|
94
|
+
try {
|
|
95
|
+
const it = parser.parseExpression(s.expr, { currentDate: after, tz: s.timezone });
|
|
96
|
+
if (!it.hasNext()) return null;
|
|
97
|
+
return it.next().toDate();
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
case "interval": {
|
|
103
|
+
const ms = parseIsoDuration(s.expr);
|
|
104
|
+
if (ms === null || ms <= 0) return null;
|
|
105
|
+
return new Date(after.getTime() + ms);
|
|
106
|
+
}
|
|
107
|
+
case "one_shot": {
|
|
108
|
+
const at = new Date(s.expr);
|
|
109
|
+
if (Number.isNaN(at.getTime())) return null;
|
|
110
|
+
return at.getTime() > after.getTime() ? at : null;
|
|
111
|
+
}
|
|
112
|
+
default:
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function validateSchedule(input, opts = {}) {
|
|
117
|
+
const { minCadenceMs } = opts;
|
|
118
|
+
switch (input.kind) {
|
|
119
|
+
case "cron": {
|
|
120
|
+
let fires;
|
|
121
|
+
try {
|
|
122
|
+
const it = parser.parseExpression(input.expr, {
|
|
123
|
+
currentDate: REFERENCE,
|
|
124
|
+
tz: input.timezone ?? "UTC"
|
|
125
|
+
});
|
|
126
|
+
fires = [];
|
|
127
|
+
for (let i = 0; i < 5 && it.hasNext(); i++) fires.push(it.next().toDate());
|
|
128
|
+
} catch (err) {
|
|
129
|
+
return { ok: false, error: `invalid cron expression: ${errMsg(err)}` };
|
|
130
|
+
}
|
|
131
|
+
if (fires.length === 0) {
|
|
132
|
+
return { ok: false, error: "cron expression never fires" };
|
|
133
|
+
}
|
|
134
|
+
if (minCadenceMs != null) {
|
|
135
|
+
const gap = tightestGapMs(fires);
|
|
136
|
+
if (gap != null && gap < minCadenceMs) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
error: `cron cadence ${gap}ms is below the floor of ${minCadenceMs}ms`
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
145
|
+
case "interval": {
|
|
146
|
+
const ms = parseIsoDuration(input.expr);
|
|
147
|
+
if (ms === null || ms <= 0) {
|
|
148
|
+
return { ok: false, error: `invalid interval duration: ${input.expr}` };
|
|
149
|
+
}
|
|
150
|
+
if (minCadenceMs != null && ms < minCadenceMs) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
error: `interval cadence ${ms}ms is below the floor of ${minCadenceMs}ms`
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { ok: true };
|
|
157
|
+
}
|
|
158
|
+
case "one_shot": {
|
|
159
|
+
const at = new Date(input.expr);
|
|
160
|
+
if (Number.isNaN(at.getTime())) {
|
|
161
|
+
return { ok: false, error: `invalid one_shot instant: ${input.expr}` };
|
|
162
|
+
}
|
|
163
|
+
return { ok: true };
|
|
164
|
+
}
|
|
165
|
+
default:
|
|
166
|
+
return { ok: false, error: `unknown schedule kind: ${String(input.kind)}` };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function tightestGapMs(fires) {
|
|
170
|
+
if (fires.length < 2) return null;
|
|
171
|
+
let min = Number.POSITIVE_INFINITY;
|
|
172
|
+
for (let i = 1; i < fires.length; i++) {
|
|
173
|
+
const gap = fires[i].getTime() - fires[i - 1].getTime();
|
|
174
|
+
if (gap < min) min = gap;
|
|
175
|
+
}
|
|
176
|
+
return min;
|
|
177
|
+
}
|
|
178
|
+
var ISO_DURATION = /^P(?:(\d+(?:\.\d+)?)W)?(?:(\d+(?:\.\d+)?)D)?(?:T(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
|
|
179
|
+
function parseIsoDuration(expr) {
|
|
180
|
+
const m = ISO_DURATION.exec(expr);
|
|
181
|
+
if (!m) return null;
|
|
182
|
+
const [, w, d, h, min, s] = m;
|
|
183
|
+
if (!w && !d && !h && !min && !s) return null;
|
|
184
|
+
const weeks = w ? Number(w) : 0;
|
|
185
|
+
const days = d ? Number(d) : 0;
|
|
186
|
+
const hours = h ? Number(h) : 0;
|
|
187
|
+
const minutes = min ? Number(min) : 0;
|
|
188
|
+
const seconds = s ? Number(s) : 0;
|
|
189
|
+
return weeks * 7 * 864e5 + days * 864e5 + hours * 36e5 + minutes * 6e4 + seconds * 1e3;
|
|
190
|
+
}
|
|
191
|
+
function errMsg(err) {
|
|
192
|
+
return err instanceof Error ? err.message : String(err);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/core/types.ts
|
|
196
|
+
import { Type } from "@sinclair/typebox";
|
|
197
|
+
var ScheduleKind = {
|
|
198
|
+
Cron: "cron",
|
|
199
|
+
Interval: "interval",
|
|
200
|
+
OneShot: "one_shot"
|
|
201
|
+
};
|
|
202
|
+
var ScheduleKindSchema = Type.Union([
|
|
203
|
+
Type.Literal(ScheduleKind.Cron),
|
|
204
|
+
Type.Literal(ScheduleKind.Interval),
|
|
205
|
+
Type.Literal(ScheduleKind.OneShot)
|
|
206
|
+
]);
|
|
207
|
+
var OverlapPolicySchema = Type.Union([
|
|
208
|
+
Type.Literal("allow"),
|
|
209
|
+
Type.Literal("skip_if_running"),
|
|
210
|
+
Type.Literal("queue")
|
|
211
|
+
]);
|
|
212
|
+
var MisfirePolicySchema = Type.Union([
|
|
213
|
+
Type.Literal("fire"),
|
|
214
|
+
Type.Literal("skip"),
|
|
215
|
+
Type.Literal("backfill")
|
|
216
|
+
]);
|
|
217
|
+
var AttemptStatusSchema = Type.Union([
|
|
218
|
+
Type.Literal("running"),
|
|
219
|
+
Type.Literal("succeeded"),
|
|
220
|
+
Type.Literal("failed"),
|
|
221
|
+
Type.Literal("pending"),
|
|
222
|
+
Type.Literal("dead_letter")
|
|
223
|
+
]);
|
|
224
|
+
var MetadataSchema = Type.Record(Type.String(), Type.Unknown());
|
|
225
|
+
var ScheduleSchema = Type.Object({
|
|
226
|
+
id: Type.String(),
|
|
227
|
+
name: Type.Optional(Type.String()),
|
|
228
|
+
kind: ScheduleKindSchema,
|
|
229
|
+
expr: Type.String(),
|
|
230
|
+
timezone: Type.String(),
|
|
231
|
+
payload: Type.Unknown(),
|
|
232
|
+
enabled: Type.Boolean(),
|
|
233
|
+
overlap: OverlapPolicySchema,
|
|
234
|
+
misfire: MisfirePolicySchema,
|
|
235
|
+
maxAttempts: Type.Number(),
|
|
236
|
+
maxRuntimeMs: Type.Optional(Type.Number()),
|
|
237
|
+
jitterMs: Type.Optional(Type.Number()),
|
|
238
|
+
validFrom: Type.Optional(Type.String()),
|
|
239
|
+
validUntil: Type.Optional(Type.String()),
|
|
240
|
+
maxOccurrences: Type.Optional(Type.Number()),
|
|
241
|
+
occurrenceCount: Type.Number(),
|
|
242
|
+
nextRunAt: Type.Union([Type.String(), Type.Null()]),
|
|
243
|
+
lastTriggeredAt: Type.Optional(Type.String()),
|
|
244
|
+
createdAt: Type.String(),
|
|
245
|
+
metadata: Type.Optional(MetadataSchema)
|
|
246
|
+
});
|
|
247
|
+
var AttemptSchema = Type.Object({
|
|
248
|
+
id: Type.String(),
|
|
249
|
+
scheduleId: Type.String(),
|
|
250
|
+
occurrenceAt: Type.String(),
|
|
251
|
+
attempt: Type.Number(),
|
|
252
|
+
status: AttemptStatusSchema,
|
|
253
|
+
startedAt: Type.Optional(Type.String()),
|
|
254
|
+
finishedAt: Type.Optional(Type.String()),
|
|
255
|
+
retryAt: Type.Optional(Type.String()),
|
|
256
|
+
error: Type.Optional(Type.String()),
|
|
257
|
+
outcome: Type.Optional(Type.Unknown())
|
|
258
|
+
});
|
|
259
|
+
var NewScheduleSchema = Type.Object({
|
|
260
|
+
name: Type.Optional(Type.String()),
|
|
261
|
+
kind: ScheduleKindSchema,
|
|
262
|
+
expr: Type.String(),
|
|
263
|
+
timezone: Type.Optional(Type.String()),
|
|
264
|
+
payload: Type.Unknown(),
|
|
265
|
+
overlap: Type.Optional(OverlapPolicySchema),
|
|
266
|
+
misfire: Type.Optional(MisfirePolicySchema),
|
|
267
|
+
maxAttempts: Type.Optional(Type.Number()),
|
|
268
|
+
maxRuntimeMs: Type.Optional(Type.Number()),
|
|
269
|
+
jitterMs: Type.Optional(Type.Number()),
|
|
270
|
+
validFrom: Type.Optional(Type.String()),
|
|
271
|
+
validUntil: Type.Optional(Type.String()),
|
|
272
|
+
maxOccurrences: Type.Optional(Type.Number()),
|
|
273
|
+
enabled: Type.Optional(Type.Boolean()),
|
|
274
|
+
metadata: Type.Optional(MetadataSchema)
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// src/core/engine.ts
|
|
278
|
+
var Engine = class {
|
|
279
|
+
#store;
|
|
280
|
+
#executor;
|
|
281
|
+
#clock;
|
|
282
|
+
#pollIntervalMs;
|
|
283
|
+
#batchLimit;
|
|
284
|
+
#orphanGraceMs;
|
|
285
|
+
#retryBaseMs;
|
|
286
|
+
#retryCapMs;
|
|
287
|
+
#idFactory;
|
|
288
|
+
#running = false;
|
|
289
|
+
#loopCtl = null;
|
|
290
|
+
#loopDone = Promise.resolve();
|
|
291
|
+
/** In-flight fire/retry tasks; drained by stop(). */
|
|
292
|
+
#inflight = /* @__PURE__ */ new Set();
|
|
293
|
+
/** Attempt ids this instance is executing right now (orphan-reclaim exemption). */
|
|
294
|
+
#inflightAttemptIds = /* @__PURE__ */ new Set();
|
|
295
|
+
constructor(opts) {
|
|
296
|
+
this.#store = opts.store;
|
|
297
|
+
this.#executor = opts.executor;
|
|
298
|
+
this.#clock = opts.clock;
|
|
299
|
+
this.#pollIntervalMs = opts.pollIntervalMs ?? 1e3;
|
|
300
|
+
this.#batchLimit = opts.batchLimit ?? 25;
|
|
301
|
+
this.#orphanGraceMs = opts.orphanGraceMs ?? 3e5;
|
|
302
|
+
this.#retryBaseMs = opts.retryBaseMs ?? 1e3;
|
|
303
|
+
this.#retryCapMs = opts.retryCapMs ?? 6e4;
|
|
304
|
+
this.#idFactory = opts.idFactory ?? defaultIdFactory();
|
|
305
|
+
}
|
|
306
|
+
// -- registration & passthroughs ----------------------------------------
|
|
307
|
+
/**
|
|
308
|
+
* Validate (TypeBox shape + kind-specific expression check), fill server-side
|
|
309
|
+
* fields, compute the initial slot, and persist. Throws on invalid input.
|
|
310
|
+
*/
|
|
311
|
+
async register(input) {
|
|
312
|
+
if (!Value.Check(NewScheduleSchema, input)) {
|
|
313
|
+
throw new Error("invalid schedule input: does not match NewScheduleSchema");
|
|
314
|
+
}
|
|
315
|
+
const valid = validateSchedule(input);
|
|
316
|
+
if (!valid.ok) throw new Error(valid.error);
|
|
317
|
+
const now = this.#clock.now();
|
|
318
|
+
const s = {
|
|
319
|
+
...input,
|
|
320
|
+
id: this.#idFactory(),
|
|
321
|
+
timezone: input.timezone ?? "UTC",
|
|
322
|
+
enabled: input.enabled ?? true,
|
|
323
|
+
overlap: input.overlap ?? "allow",
|
|
324
|
+
misfire: input.misfire ?? "fire",
|
|
325
|
+
maxAttempts: input.maxAttempts ?? 3,
|
|
326
|
+
occurrenceCount: 0,
|
|
327
|
+
createdAt: now.toISOString(),
|
|
328
|
+
nextRunAt: null
|
|
329
|
+
};
|
|
330
|
+
const from = s.validFrom !== void 0 && new Date(s.validFrom).getTime() > now.getTime() ? new Date(s.validFrom) : now;
|
|
331
|
+
s.nextRunAt = this.#boundedNext(s, from, 0);
|
|
332
|
+
await this.#store.createSchedule(s);
|
|
333
|
+
return s;
|
|
334
|
+
}
|
|
335
|
+
remove(id) {
|
|
336
|
+
return this.#store.removeSchedule(id);
|
|
337
|
+
}
|
|
338
|
+
setEnabled(id, enabled) {
|
|
339
|
+
return this.#store.setEnabled(id, enabled);
|
|
340
|
+
}
|
|
341
|
+
get(id) {
|
|
342
|
+
return this.#store.getSchedule(id);
|
|
343
|
+
}
|
|
344
|
+
list() {
|
|
345
|
+
return this.#store.listSchedules();
|
|
346
|
+
}
|
|
347
|
+
/** Attempt history for one schedule, newest first — "did it actually run?". */
|
|
348
|
+
listAttempts(scheduleId, limit = 50) {
|
|
349
|
+
return this.#store.listAttempts(scheduleId, limit);
|
|
350
|
+
}
|
|
351
|
+
/** Occurrences that exhausted the retry ladder: the permanent-failure record. */
|
|
352
|
+
deadLetteredFor(scheduleId) {
|
|
353
|
+
return this.#store.deadLetteredFor(scheduleId);
|
|
354
|
+
}
|
|
355
|
+
// -- lifecycle -----------------------------------------------------------
|
|
356
|
+
/** Begin the poll loop. Idempotent: a second start while running is a no-op. */
|
|
357
|
+
start() {
|
|
358
|
+
if (this.#running) return;
|
|
359
|
+
this.#running = true;
|
|
360
|
+
this.#loopCtl = new AbortController();
|
|
361
|
+
this.#loopDone = this.#loop(this.#loopCtl.signal);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Drain: stop claiming, let in-flight attempts run to completion (bounded by
|
|
365
|
+
* their own maxRuntimeMs timeouts), then resolve. Idempotent.
|
|
366
|
+
*/
|
|
367
|
+
async stop() {
|
|
368
|
+
if (this.#running) {
|
|
369
|
+
this.#running = false;
|
|
370
|
+
this.#loopCtl?.abort();
|
|
371
|
+
}
|
|
372
|
+
await this.#loopDone;
|
|
373
|
+
while (this.#inflight.size > 0) {
|
|
374
|
+
await Promise.allSettled([...this.#inflight]);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async #loop(signal) {
|
|
378
|
+
while (!signal.aborted) {
|
|
379
|
+
await this.#tick();
|
|
380
|
+
if (signal.aborted) break;
|
|
381
|
+
await this.#clock.sleep(this.#pollIntervalMs, signal);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// -- tick ----------------------------------------------------------------
|
|
385
|
+
async #tick() {
|
|
386
|
+
const now = this.#clock.now();
|
|
387
|
+
await this.#recoverOrphans(now);
|
|
388
|
+
await this.#runDueRetries(now);
|
|
389
|
+
const claimed = await this.#store.claimDue(now, this.#batchLimit);
|
|
390
|
+
for (const c of claimed) await this.#dispatch(c, now);
|
|
391
|
+
}
|
|
392
|
+
/** Crashed-worker recovery: route stale "running" attempts to the retry ladder. */
|
|
393
|
+
async #recoverOrphans(now) {
|
|
394
|
+
const orphans = await this.#store.reclaimOrphans(now, this.#orphanGraceMs);
|
|
395
|
+
for (const a of orphans) {
|
|
396
|
+
if (this.#inflightAttemptIds.has(a.id)) continue;
|
|
397
|
+
const s = await this.#store.getSchedule(a.scheduleId);
|
|
398
|
+
if (s !== void 0 && a.attempt < s.maxAttempts) {
|
|
399
|
+
await this.#store.updateAttempt(a.id, {
|
|
400
|
+
status: "pending",
|
|
401
|
+
retryAt: this.#retryAt(s, a.occurrenceAt, now, a.attempt),
|
|
402
|
+
error: "orphaned: worker crashed or stalled"
|
|
403
|
+
});
|
|
404
|
+
} else {
|
|
405
|
+
await this.#store.updateAttempt(a.id, {
|
|
406
|
+
status: "dead_letter",
|
|
407
|
+
finishedAt: now.toISOString(),
|
|
408
|
+
error: "orphaned: worker crashed or stalled"
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** Re-execute pending attempts whose retryAt has come due. */
|
|
414
|
+
async #runDueRetries(now) {
|
|
415
|
+
const due = await this.#store.listDueRetries(now, this.#batchLimit);
|
|
416
|
+
for (const a of due) {
|
|
417
|
+
const s = await this.#store.getSchedule(a.scheduleId);
|
|
418
|
+
if (s === void 0) {
|
|
419
|
+
await this.#store.updateAttempt(a.id, {
|
|
420
|
+
status: "dead_letter",
|
|
421
|
+
finishedAt: now.toISOString(),
|
|
422
|
+
error: "schedule removed"
|
|
423
|
+
});
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const attemptNo = a.attempt + 1;
|
|
427
|
+
await this.#store.updateAttempt(a.id, {
|
|
428
|
+
status: "running",
|
|
429
|
+
attempt: attemptNo,
|
|
430
|
+
startedAt: now.toISOString()
|
|
431
|
+
});
|
|
432
|
+
this.#track(this.#execute(s, a.id, a.occurrenceAt, attemptNo));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/** Apply misfire + overlap policies to one claimed occurrence, advance its slot. */
|
|
436
|
+
async #dispatch(c, now) {
|
|
437
|
+
const s = c.schedule;
|
|
438
|
+
const occ = new Date(c.occurrenceAt);
|
|
439
|
+
const nextAfterOcc = computeNextRun(s, occ);
|
|
440
|
+
const misfired = nextAfterOcc !== null && nextAfterOcc.getTime() <= now.getTime();
|
|
441
|
+
let toFire;
|
|
442
|
+
if (!misfired || s.misfire === "fire") {
|
|
443
|
+
toFire = [c.occurrenceAt];
|
|
444
|
+
} else if (s.misfire === "skip") {
|
|
445
|
+
toFire = [];
|
|
446
|
+
} else {
|
|
447
|
+
toFire = [c.occurrenceAt];
|
|
448
|
+
let cur = occ;
|
|
449
|
+
while (true) {
|
|
450
|
+
cur = computeNextRun(s, cur);
|
|
451
|
+
if (cur === null || cur.getTime() > now.getTime()) break;
|
|
452
|
+
toFire.push(cur.toISOString());
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (s.maxOccurrences !== void 0) {
|
|
456
|
+
toFire = toFire.slice(0, Math.max(0, s.maxOccurrences - s.occurrenceCount));
|
|
457
|
+
}
|
|
458
|
+
if (toFire.length > 0 && s.overlap === "queue" && await this.#store.hasActiveAttempt(s.id)) {
|
|
459
|
+
await this.#store.advanceSlot(
|
|
460
|
+
s.id,
|
|
461
|
+
c.occurrenceAt,
|
|
462
|
+
s.lastTriggeredAt ?? s.createdAt,
|
|
463
|
+
s.occurrenceCount
|
|
464
|
+
);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const skipped = toFire.length > 0 && s.overlap === "skip_if_running" && await this.#store.hasActiveAttempt(s.id);
|
|
468
|
+
const firedCount = skipped ? 0 : toFire.length;
|
|
469
|
+
const countAfter = s.occurrenceCount + firedCount;
|
|
470
|
+
let anchor;
|
|
471
|
+
if (!misfired) {
|
|
472
|
+
anchor = occ;
|
|
473
|
+
} else if (s.misfire === "backfill" && firedCount > 0) {
|
|
474
|
+
anchor = new Date(toFire[toFire.length - 1]);
|
|
475
|
+
} else {
|
|
476
|
+
anchor = now;
|
|
477
|
+
}
|
|
478
|
+
const nextRunAt = this.#boundedNext(s, anchor, countAfter);
|
|
479
|
+
const lastTriggeredAt = firedCount > 0 ? now.toISOString() : s.lastTriggeredAt ?? s.createdAt;
|
|
480
|
+
await this.#store.advanceSlot(s.id, nextRunAt, lastTriggeredAt, countAfter);
|
|
481
|
+
if (skipped) {
|
|
482
|
+
await this.#store.openAttempt({
|
|
483
|
+
id: this.#idFactory(),
|
|
484
|
+
scheduleId: s.id,
|
|
485
|
+
occurrenceAt: c.occurrenceAt,
|
|
486
|
+
attempt: 0,
|
|
487
|
+
status: "failed",
|
|
488
|
+
finishedAt: now.toISOString(),
|
|
489
|
+
error: "skipped: overlap policy skip_if_running"
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (toFire.length === 0) return;
|
|
494
|
+
this.#track(this.#fire(s, toFire));
|
|
495
|
+
}
|
|
496
|
+
// -- execution -----------------------------------------------------------
|
|
497
|
+
/** Fire the given occurrences of one schedule, in order, honoring jitter. */
|
|
498
|
+
async #fire(s, occurrences) {
|
|
499
|
+
for (const occurrenceAt of occurrences) {
|
|
500
|
+
if (s.jitterMs !== void 0 && s.jitterMs > 0) {
|
|
501
|
+
const delay = applyJitter(0, s.jitterMs, hashSeed(`${s.id}@${occurrenceAt}`));
|
|
502
|
+
if (delay > 0) await this.#clock.sleep(delay, this.#loopCtl?.signal);
|
|
503
|
+
}
|
|
504
|
+
const now = this.#clock.now();
|
|
505
|
+
const attemptId = this.#idFactory();
|
|
506
|
+
await this.#store.openAttempt({
|
|
507
|
+
id: attemptId,
|
|
508
|
+
scheduleId: s.id,
|
|
509
|
+
occurrenceAt,
|
|
510
|
+
attempt: 1,
|
|
511
|
+
status: "running",
|
|
512
|
+
startedAt: now.toISOString()
|
|
513
|
+
});
|
|
514
|
+
await this.#execute(s, attemptId, occurrenceAt, 1);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/** One attempt: executor call under a timeout signal, then the retry ladder. */
|
|
518
|
+
async #execute(s, attemptId, occurrenceAt, attemptNo) {
|
|
519
|
+
this.#inflightAttemptIds.add(attemptId);
|
|
520
|
+
const ac = new AbortController();
|
|
521
|
+
const timeoutCtl = new AbortController();
|
|
522
|
+
if (s.maxRuntimeMs !== void 0 && s.maxRuntimeMs > 0) {
|
|
523
|
+
void this.#clock.sleep(s.maxRuntimeMs, timeoutCtl.signal).then(() => {
|
|
524
|
+
if (!timeoutCtl.signal.aborted) ac.abort();
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
let outcome;
|
|
528
|
+
try {
|
|
529
|
+
outcome = await this.#executor.execute({
|
|
530
|
+
schedule: s,
|
|
531
|
+
occurrenceAt,
|
|
532
|
+
attempt: attemptNo,
|
|
533
|
+
signal: ac.signal
|
|
534
|
+
});
|
|
535
|
+
} catch (err) {
|
|
536
|
+
outcome = { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
537
|
+
} finally {
|
|
538
|
+
timeoutCtl.abort();
|
|
539
|
+
this.#inflightAttemptIds.delete(attemptId);
|
|
540
|
+
}
|
|
541
|
+
const now = this.#clock.now();
|
|
542
|
+
if (outcome.ok) {
|
|
543
|
+
await this.#store.updateAttempt(attemptId, {
|
|
544
|
+
status: "succeeded",
|
|
545
|
+
finishedAt: now.toISOString(),
|
|
546
|
+
...outcome.result !== void 0 ? { outcome: outcome.result } : {}
|
|
547
|
+
});
|
|
548
|
+
} else if (attemptNo < s.maxAttempts) {
|
|
549
|
+
await this.#store.updateAttempt(attemptId, {
|
|
550
|
+
status: "pending",
|
|
551
|
+
retryAt: this.#retryAt(s, occurrenceAt, now, attemptNo),
|
|
552
|
+
...outcome.error !== void 0 ? { error: outcome.error } : {}
|
|
553
|
+
});
|
|
554
|
+
} else {
|
|
555
|
+
await this.#store.updateAttempt(attemptId, {
|
|
556
|
+
status: "dead_letter",
|
|
557
|
+
finishedAt: now.toISOString(),
|
|
558
|
+
...outcome.error !== void 0 ? { error: outcome.error } : {}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// -- helpers ---------------------------------------------------------------
|
|
563
|
+
/**
|
|
564
|
+
* Next slot strictly after `anchor`, or null when the schedule terminates:
|
|
565
|
+
* maxOccurrences reached, validUntil passed, or no further occurrence exists.
|
|
566
|
+
*/
|
|
567
|
+
#boundedNext(s, anchor, countAfter) {
|
|
568
|
+
if (s.maxOccurrences !== void 0 && countAfter >= s.maxOccurrences) return null;
|
|
569
|
+
const next = computeNextRun(s, anchor);
|
|
570
|
+
if (next === null) return null;
|
|
571
|
+
if (s.validUntil !== void 0 && next.getTime() > new Date(s.validUntil).getTime()) {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
return next.toISOString();
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Backoff, jittered per (schedule, occurrence, attempt). Without the jitter,
|
|
578
|
+
* N schedules that fail together retry at the identical instant, take the
|
|
579
|
+
* identical rate-limit rejection, and march the whole ladder in lockstep to a
|
|
580
|
+
* mass dead-letter. Deterministic seed — no RNG, no wall clock.
|
|
581
|
+
*/
|
|
582
|
+
#retryAt(s, occurrenceAt, now, attemptNo) {
|
|
583
|
+
const base = backoffMs(attemptNo, this.#retryBaseMs, this.#retryCapMs);
|
|
584
|
+
const spread = s.jitterMs ?? 0;
|
|
585
|
+
const delay = spread > 0 ? applyJitter(base, spread, hashSeed(`${s.id}@${occurrenceAt}@${attemptNo}`)) : base;
|
|
586
|
+
return new Date(now.getTime() + delay).toISOString();
|
|
587
|
+
}
|
|
588
|
+
#track(task) {
|
|
589
|
+
const wrapped = task.catch(() => {
|
|
590
|
+
});
|
|
591
|
+
this.#inflight.add(wrapped);
|
|
592
|
+
void wrapped.finally(() => this.#inflight.delete(wrapped));
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
function defaultIdFactory() {
|
|
596
|
+
let n = 0;
|
|
597
|
+
return () => `cad_${++n}`;
|
|
598
|
+
}
|
|
599
|
+
function hashSeed(input) {
|
|
600
|
+
let h = 5381;
|
|
601
|
+
for (let i = 0; i < input.length; i++) {
|
|
602
|
+
h = Math.imul(h, 33) + input.charCodeAt(i) | 0;
|
|
603
|
+
}
|
|
604
|
+
return h;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/core/memory-store.ts
|
|
608
|
+
var InMemoryStore = class {
|
|
609
|
+
#clock;
|
|
610
|
+
#schedules = /* @__PURE__ */ new Map();
|
|
611
|
+
/** Attempts in insertion order; `listAttempts` returns them newest-first. */
|
|
612
|
+
#attempts = [];
|
|
613
|
+
/** Schedule ids whose due slot is claimed pending `advanceSlot`. */
|
|
614
|
+
#claimed = /* @__PURE__ */ new Set();
|
|
615
|
+
constructor(clock) {
|
|
616
|
+
this.#clock = clock;
|
|
617
|
+
}
|
|
618
|
+
async createSchedule(s) {
|
|
619
|
+
this.#schedules.set(s.id, { ...s });
|
|
620
|
+
}
|
|
621
|
+
async getSchedule(id) {
|
|
622
|
+
const s = this.#schedules.get(id);
|
|
623
|
+
return s ? { ...s } : void 0;
|
|
624
|
+
}
|
|
625
|
+
async listSchedules() {
|
|
626
|
+
return [...this.#schedules.values()].map((s) => ({ ...s }));
|
|
627
|
+
}
|
|
628
|
+
async removeSchedule(id) {
|
|
629
|
+
this.#claimed.delete(id);
|
|
630
|
+
return this.#schedules.delete(id);
|
|
631
|
+
}
|
|
632
|
+
async setEnabled(id, enabled) {
|
|
633
|
+
const s = this.#schedules.get(id);
|
|
634
|
+
if (s) s.enabled = enabled;
|
|
635
|
+
}
|
|
636
|
+
async claimDue(now, limit) {
|
|
637
|
+
const nowMs = now.getTime();
|
|
638
|
+
const out = [];
|
|
639
|
+
for (const s of this.#schedules.values()) {
|
|
640
|
+
if (out.length >= limit) break;
|
|
641
|
+
if (!s.enabled || s.nextRunAt === null) continue;
|
|
642
|
+
if (this.#claimed.has(s.id)) continue;
|
|
643
|
+
if (new Date(s.nextRunAt).getTime() > nowMs) continue;
|
|
644
|
+
this.#claimed.add(s.id);
|
|
645
|
+
out.push({ schedule: { ...s }, occurrenceAt: s.nextRunAt });
|
|
646
|
+
}
|
|
647
|
+
return out;
|
|
648
|
+
}
|
|
649
|
+
async advanceSlot(id, nextRunAt, lastTriggeredAt, occurrenceCount) {
|
|
650
|
+
const s = this.#schedules.get(id);
|
|
651
|
+
if (s) {
|
|
652
|
+
s.nextRunAt = nextRunAt;
|
|
653
|
+
s.lastTriggeredAt = lastTriggeredAt;
|
|
654
|
+
s.occurrenceCount = occurrenceCount;
|
|
655
|
+
}
|
|
656
|
+
this.#claimed.delete(id);
|
|
657
|
+
}
|
|
658
|
+
async openAttempt(a) {
|
|
659
|
+
this.#attempts.push({ ...a });
|
|
660
|
+
}
|
|
661
|
+
async updateAttempt(id, patch) {
|
|
662
|
+
const a = this.#attempts.find((x) => x.id === id);
|
|
663
|
+
if (a) Object.assign(a, patch);
|
|
664
|
+
}
|
|
665
|
+
async listDueRetries(now, limit) {
|
|
666
|
+
const nowMs = now.getTime();
|
|
667
|
+
return this.#attempts.filter(
|
|
668
|
+
(a) => a.status === "pending" && a.retryAt !== void 0 && new Date(a.retryAt).getTime() <= nowMs
|
|
669
|
+
).slice(0, limit).map((a) => ({ ...a }));
|
|
670
|
+
}
|
|
671
|
+
async hasActiveAttempt(scheduleId) {
|
|
672
|
+
return this.#attempts.some((a) => a.scheduleId === scheduleId && a.status === "running");
|
|
673
|
+
}
|
|
674
|
+
async reclaimOrphans(now, graceMs) {
|
|
675
|
+
const cutoff = now.getTime() - graceMs;
|
|
676
|
+
return this.#attempts.filter(
|
|
677
|
+
(a) => a.status === "running" && a.startedAt !== void 0 && new Date(a.startedAt).getTime() < cutoff
|
|
678
|
+
).map((a) => ({ ...a }));
|
|
679
|
+
}
|
|
680
|
+
async listAttempts(scheduleId, limit) {
|
|
681
|
+
const matches = this.#attempts.filter((a) => a.scheduleId === scheduleId);
|
|
682
|
+
return matches.slice().reverse().slice(0, limit).map((a) => ({ ...a }));
|
|
683
|
+
}
|
|
684
|
+
async deadLetteredFor(scheduleId) {
|
|
685
|
+
return this.#attempts.filter((a) => a.scheduleId === scheduleId && a.status === "dead_letter").map((a) => ({ ...a }));
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
// src/core/store.ts
|
|
690
|
+
var StoreError = class extends Error {
|
|
691
|
+
code;
|
|
692
|
+
constructor(code, message) {
|
|
693
|
+
super(message);
|
|
694
|
+
this.name = "StoreError";
|
|
695
|
+
this.code = code;
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
// src/file-store.ts
|
|
700
|
+
import {
|
|
701
|
+
closeSync,
|
|
702
|
+
existsSync,
|
|
703
|
+
fsyncSync,
|
|
704
|
+
mkdirSync,
|
|
705
|
+
openSync,
|
|
706
|
+
readFileSync,
|
|
707
|
+
renameSync,
|
|
708
|
+
rmSync,
|
|
709
|
+
writeSync
|
|
710
|
+
} from "fs";
|
|
711
|
+
import { join } from "path";
|
|
712
|
+
import { Value as Value2 } from "@sinclair/typebox/value";
|
|
713
|
+
var STATE_FILE = "state.json";
|
|
714
|
+
var TEMP_FILE = "state.json.tmp";
|
|
715
|
+
var FileStore = class {
|
|
716
|
+
#statePath;
|
|
717
|
+
#tempPath;
|
|
718
|
+
#schedules = /* @__PURE__ */ new Map();
|
|
719
|
+
/** Attempts in insertion order; `listAttempts` returns them newest-first. */
|
|
720
|
+
#attempts = [];
|
|
721
|
+
/** Schedule ids whose due slot is claimed pending `advanceSlot`. Transient by design. */
|
|
722
|
+
#claimed = /* @__PURE__ */ new Set();
|
|
723
|
+
constructor(dir) {
|
|
724
|
+
mkdirSync(dir, { recursive: true });
|
|
725
|
+
this.#statePath = join(dir, STATE_FILE);
|
|
726
|
+
this.#tempPath = join(dir, TEMP_FILE);
|
|
727
|
+
rmSync(this.#tempPath, { force: true });
|
|
728
|
+
this.#load();
|
|
729
|
+
}
|
|
730
|
+
#load() {
|
|
731
|
+
if (!existsSync(this.#statePath)) return;
|
|
732
|
+
const raw = readFileSync(this.#statePath, "utf8");
|
|
733
|
+
let parsed;
|
|
734
|
+
try {
|
|
735
|
+
parsed = JSON.parse(raw);
|
|
736
|
+
} catch {
|
|
737
|
+
throw new StoreError("corrupt", `${this.#statePath} is not valid JSON`);
|
|
738
|
+
}
|
|
739
|
+
const state = parsed;
|
|
740
|
+
if (typeof parsed !== "object" || parsed === null || !Array.isArray(state.schedules) || !Array.isArray(state.attempts)) {
|
|
741
|
+
throw new StoreError("corrupt", `${this.#statePath} has an unexpected shape`);
|
|
742
|
+
}
|
|
743
|
+
for (const s of state.schedules) {
|
|
744
|
+
if (!Value2.Check(ScheduleSchema, s)) {
|
|
745
|
+
throw new StoreError("corrupt", `${this.#statePath}: invalid schedule record`);
|
|
746
|
+
}
|
|
747
|
+
this.#schedules.set(s.id, s);
|
|
748
|
+
}
|
|
749
|
+
for (const a of state.attempts) {
|
|
750
|
+
if (!Value2.Check(AttemptSchema, a)) {
|
|
751
|
+
throw new StoreError("corrupt", `${this.#statePath}: invalid attempt record`);
|
|
752
|
+
}
|
|
753
|
+
this.#attempts.push(a);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/** Atomically rewrite the full state: temp file + fsync + rename. */
|
|
757
|
+
#persist() {
|
|
758
|
+
const json = JSON.stringify({
|
|
759
|
+
schedules: [...this.#schedules.values()],
|
|
760
|
+
attempts: this.#attempts
|
|
761
|
+
});
|
|
762
|
+
const fd = openSync(this.#tempPath, "w");
|
|
763
|
+
try {
|
|
764
|
+
writeSync(fd, json);
|
|
765
|
+
fsyncSync(fd);
|
|
766
|
+
} finally {
|
|
767
|
+
closeSync(fd);
|
|
768
|
+
}
|
|
769
|
+
renameSync(this.#tempPath, this.#statePath);
|
|
770
|
+
}
|
|
771
|
+
async createSchedule(s) {
|
|
772
|
+
this.#schedules.set(s.id, { ...s });
|
|
773
|
+
this.#persist();
|
|
774
|
+
}
|
|
775
|
+
async getSchedule(id) {
|
|
776
|
+
const s = this.#schedules.get(id);
|
|
777
|
+
return s ? { ...s } : void 0;
|
|
778
|
+
}
|
|
779
|
+
async listSchedules() {
|
|
780
|
+
return [...this.#schedules.values()].map((s) => ({ ...s }));
|
|
781
|
+
}
|
|
782
|
+
async removeSchedule(id) {
|
|
783
|
+
this.#claimed.delete(id);
|
|
784
|
+
const removed = this.#schedules.delete(id);
|
|
785
|
+
if (removed) this.#persist();
|
|
786
|
+
return removed;
|
|
787
|
+
}
|
|
788
|
+
async setEnabled(id, enabled) {
|
|
789
|
+
const s = this.#schedules.get(id);
|
|
790
|
+
if (s) {
|
|
791
|
+
s.enabled = enabled;
|
|
792
|
+
this.#persist();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async claimDue(now, limit) {
|
|
796
|
+
const nowMs = now.getTime();
|
|
797
|
+
const out = [];
|
|
798
|
+
for (const s of this.#schedules.values()) {
|
|
799
|
+
if (out.length >= limit) break;
|
|
800
|
+
if (!s.enabled || s.nextRunAt === null) continue;
|
|
801
|
+
if (this.#claimed.has(s.id)) continue;
|
|
802
|
+
if (new Date(s.nextRunAt).getTime() > nowMs) continue;
|
|
803
|
+
this.#claimed.add(s.id);
|
|
804
|
+
out.push({ schedule: { ...s }, occurrenceAt: s.nextRunAt });
|
|
805
|
+
}
|
|
806
|
+
return out;
|
|
807
|
+
}
|
|
808
|
+
async advanceSlot(id, nextRunAt, lastTriggeredAt, occurrenceCount) {
|
|
809
|
+
const s = this.#schedules.get(id);
|
|
810
|
+
if (s) {
|
|
811
|
+
s.nextRunAt = nextRunAt;
|
|
812
|
+
s.lastTriggeredAt = lastTriggeredAt;
|
|
813
|
+
s.occurrenceCount = occurrenceCount;
|
|
814
|
+
this.#persist();
|
|
815
|
+
}
|
|
816
|
+
this.#claimed.delete(id);
|
|
817
|
+
}
|
|
818
|
+
async openAttempt(a) {
|
|
819
|
+
this.#attempts.push({ ...a });
|
|
820
|
+
this.#persist();
|
|
821
|
+
}
|
|
822
|
+
async updateAttempt(id, patch) {
|
|
823
|
+
const a = this.#attempts.find((x) => x.id === id);
|
|
824
|
+
if (a) {
|
|
825
|
+
Object.assign(a, patch);
|
|
826
|
+
this.#persist();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
async listDueRetries(now, limit) {
|
|
830
|
+
const nowMs = now.getTime();
|
|
831
|
+
return this.#attempts.filter(
|
|
832
|
+
(a) => a.status === "pending" && a.retryAt !== void 0 && new Date(a.retryAt).getTime() <= nowMs
|
|
833
|
+
).slice(0, limit).map((a) => ({ ...a }));
|
|
834
|
+
}
|
|
835
|
+
async hasActiveAttempt(scheduleId) {
|
|
836
|
+
return this.#attempts.some((a) => a.scheduleId === scheduleId && a.status === "running");
|
|
837
|
+
}
|
|
838
|
+
async reclaimOrphans(now, graceMs) {
|
|
839
|
+
const cutoff = now.getTime() - graceMs;
|
|
840
|
+
return this.#attempts.filter(
|
|
841
|
+
(a) => a.status === "running" && a.startedAt !== void 0 && new Date(a.startedAt).getTime() < cutoff
|
|
842
|
+
).map((a) => ({ ...a }));
|
|
843
|
+
}
|
|
844
|
+
async listAttempts(scheduleId, limit) {
|
|
845
|
+
const matches = this.#attempts.filter((a) => a.scheduleId === scheduleId);
|
|
846
|
+
return matches.slice().reverse().slice(0, limit).map((a) => ({ ...a }));
|
|
847
|
+
}
|
|
848
|
+
async deadLetteredFor(scheduleId) {
|
|
849
|
+
return this.#attempts.filter((a) => a.scheduleId === scheduleId && a.status === "dead_letter").map((a) => ({ ...a }));
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
export {
|
|
853
|
+
AttemptSchema,
|
|
854
|
+
Engine,
|
|
855
|
+
FileStore,
|
|
856
|
+
InMemoryStore,
|
|
857
|
+
NewScheduleSchema,
|
|
858
|
+
ScheduleKind,
|
|
859
|
+
ScheduleSchema,
|
|
860
|
+
StoreError,
|
|
861
|
+
SystemClock,
|
|
862
|
+
TestClock,
|
|
863
|
+
applyJitter,
|
|
864
|
+
backoffMs,
|
|
865
|
+
computeNextRun,
|
|
866
|
+
validateSchedule
|
|
867
|
+
};
|