@helipod/scheduler 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/dist/index.js ADDED
@@ -0,0 +1,775 @@
1
+ // src/index.ts
2
+ import { defineComponent } from "@helipod/component";
3
+
4
+ // src/schema.ts
5
+ import { defineSchema, defineTable, v } from "@helipod/values";
6
+ var schedulerSchema = defineSchema({
7
+ jobs: defineTable({
8
+ fnPath: v.string(),
9
+ kind: v.union(v.literal("mutation"), v.literal("action")),
10
+ state: v.union(
11
+ v.literal("pending"),
12
+ v.literal("inProgress"),
13
+ v.literal("success"),
14
+ v.literal("failed"),
15
+ v.literal("canceled")
16
+ ),
17
+ nextTs: v.number(),
18
+ attempts: v.number(),
19
+ maxFailures: v.number(),
20
+ leaseHolder: v.optional(v.string()),
21
+ leaseExpiresAt: v.optional(v.number()),
22
+ idempotencyKey: v.optional(v.string()),
23
+ appVersion: v.optional(v.string()),
24
+ name: v.optional(v.string()),
25
+ hasArgs: v.boolean(),
26
+ onComplete: v.optional(v.string()),
27
+ parentId: v.optional(v.string()),
28
+ completedTs: v.optional(v.number()),
29
+ /** The most recent failure's `String(error)` — set on retry AND on dead-letter (Task 4). */
30
+ lastError: v.optional(v.string())
31
+ }).index("by_next_ts", ["state", "nextTs"]).index("by_completed_ts", ["completedTs"]).index("by_parent", ["parentId"]).index("by_idempotency", ["idempotencyKey"]),
32
+ job_args: defineTable({
33
+ jobId: v.string(),
34
+ args: v.any(),
35
+ context: v.optional(v.any())
36
+ }).index("by_job", ["jobId"]),
37
+ // Task 5: recurring/cron schedules. `cadenceJobId` points at the currently-pending `jobs` row
38
+ // for this cron's CADENCE job (the dual-job design's self-rescheduling half — see
39
+ // `_cronTick` in `./modules.ts`); the work job(s) it fires are ordinary `jobs` rows, tracked
40
+ // only via their deterministic `idempotencyKey` (`${name}:${fireTs}`), not by this pointer.
41
+ // `spec` is a JSON-serialized `CronSpec` (`./crons.ts`) — either `{kind:"interval",ms}` or
42
+ // `{kind:"cron",expr}`. `catchUp` was originally typed `boolean`; Task 5 widened it to the
43
+ // three-way policy `_cronTick` actually implements (`skip` | `fireOnce` | `fireAll`).
44
+ crons: defineTable({
45
+ name: v.string(),
46
+ spec: v.string(),
47
+ tz: v.string(),
48
+ catchUp: v.union(v.literal("skip"), v.literal("fireOnce"), v.literal("fireAll")),
49
+ lastScheduledTs: v.optional(v.number()),
50
+ workFnPath: v.string(),
51
+ workArgs: v.any(),
52
+ cadenceJobId: v.optional(v.string())
53
+ }).index("by_name", ["name"])
54
+ });
55
+
56
+ // src/facade.ts
57
+ import "@helipod/executor";
58
+ function getFunctionPath(ref) {
59
+ return typeof ref === "string" ? ref : ref.__path;
60
+ }
61
+ function currentAppVersion() {
62
+ return void 0;
63
+ }
64
+ function currentJobId() {
65
+ return void 0;
66
+ }
67
+ function compact(obj) {
68
+ const out = {};
69
+ for (const [k, v2] of Object.entries(obj)) if (v2 !== void 0) out[k] = v2;
70
+ return out;
71
+ }
72
+ async function enqueueInternal(db, now, tables, fnRef, args, opts, kindOf = () => "mutation") {
73
+ const fnPath = getFunctionPath(fnRef);
74
+ if (opts.idempotencyKey !== void 0) {
75
+ const existing = await db.query(tables.jobs, "by_idempotency").eq("idempotencyKey", opts.idempotencyKey).take(1).collect();
76
+ const found = existing[0];
77
+ if (found) return found._id;
78
+ }
79
+ const parentId = currentJobId();
80
+ let parentCanceled = false;
81
+ if (parentId !== void 0) {
82
+ const parent = await db.get(parentId);
83
+ parentCanceled = parent !== null && parent.state === "canceled";
84
+ }
85
+ const bornState = parentCanceled ? "canceled" : "pending";
86
+ const kind = kindOf(fnPath);
87
+ const nextTs = opts.runAt ?? (opts.runAfter !== void 0 ? now() + Math.max(0, opts.runAfter) : now());
88
+ const jobId = await db.insert(
89
+ tables.jobs,
90
+ compact({
91
+ fnPath,
92
+ kind,
93
+ state: bornState,
94
+ nextTs,
95
+ attempts: 0,
96
+ // Retry budget. Mutations are transactional (a failed one committed nothing), so retrying is
97
+ // always safe: default 4. Actions have arbitrary external side effects, so a CLEANLY-failed
98
+ // action (its own code threw after possibly running partway) must NOT be blindly re-run:
99
+ // default 1, i.e. dead-letter on the first failure (`_complete`'s `attempts >= maxFailures`
100
+ // check). An explicit `opts.retry` is the author's opt-in that the target is safe to re-run
101
+ // (e.g. `@helipod/workflow` threading a step's declared `maxAttempts` through here) and is
102
+ // honored as-is for both kinds.
103
+ maxFailures: opts.retry?.maxFailures ?? (kind === "action" ? 1 : 4),
104
+ leaseHolder: void 0,
105
+ leaseExpiresAt: void 0,
106
+ idempotencyKey: opts.idempotencyKey,
107
+ appVersion: currentAppVersion(),
108
+ name: opts.name,
109
+ hasArgs: true,
110
+ onComplete: opts.onComplete,
111
+ parentId,
112
+ completedTs: parentCanceled ? now() : void 0
113
+ })
114
+ );
115
+ await db.insert(tables.jobArgs, compact({ jobId, args, context: opts.context }));
116
+ return jobId;
117
+ }
118
+ var FACADE_TABLES = { jobs: "jobs", jobArgs: "job_args" };
119
+ async function fireOnComplete(db, now, tables, jobId, onComplete, result) {
120
+ if (onComplete === void 0) return;
121
+ const argRows = await db.query(tables.jobArgs, "by_job").eq("jobId", jobId).take(1).collect();
122
+ const context = argRows[0]?.context;
123
+ await enqueueInternal(db, now, tables, onComplete, compact({ jobId, context, result }), { runAt: now() });
124
+ }
125
+ function schedulerContext(cctx) {
126
+ const db = cctx.db;
127
+ const now = () => cctx.now;
128
+ const kindOf = (fnPath) => cctx.functionKind?.(fnPath) === "action" ? "action" : "mutation";
129
+ return {
130
+ async runAfter(delayMs, fnRef, args) {
131
+ return enqueueInternal(db, now, FACADE_TABLES, fnRef, args, { runAt: now() + Math.max(0, delayMs) }, kindOf);
132
+ },
133
+ async runAt(ts, fnRef, args) {
134
+ return enqueueInternal(db, now, FACADE_TABLES, fnRef, args, { runAt: ts instanceof Date ? ts.getTime() : ts }, kindOf);
135
+ },
136
+ async cancel(id) {
137
+ const job = await db.get(id);
138
+ if (job !== null && job.state === "pending") {
139
+ await db.replace(id, { ...job, state: "canceled", completedTs: now() });
140
+ await fireOnComplete(db, now, FACADE_TABLES, id, job.onComplete, { kind: "canceled" });
141
+ }
142
+ const queue = [id];
143
+ const seen = /* @__PURE__ */ new Set([id]);
144
+ while (queue.length > 0) {
145
+ const parentId = queue.shift();
146
+ const children = await db.query("jobs", "by_parent").eq("parentId", parentId).collect();
147
+ for (const child of children) {
148
+ const childId = child._id;
149
+ if (seen.has(childId)) continue;
150
+ seen.add(childId);
151
+ if (child.state === "pending") {
152
+ await db.replace(childId, { ...child, state: "canceled", completedTs: now() });
153
+ await fireOnComplete(db, now, FACADE_TABLES, childId, child.onComplete, { kind: "canceled" });
154
+ }
155
+ queue.push(childId);
156
+ }
157
+ }
158
+ },
159
+ async enqueue(fnRef, args, opts) {
160
+ return enqueueInternal(db, now, FACADE_TABLES, fnRef, args, opts ?? {}, kindOf);
161
+ }
162
+ };
163
+ }
164
+ function schedulerActionContext(api) {
165
+ return {
166
+ async runAfter(delayMs, fnRef, args) {
167
+ return api.runMutation("scheduler:_enqueue", { fnPath: getFunctionPath(fnRef), args, runAtMs: Date.now() + delayMs });
168
+ },
169
+ async runAt(ts, fnRef, args) {
170
+ return api.runMutation("scheduler:_enqueue", {
171
+ fnPath: getFunctionPath(fnRef),
172
+ args,
173
+ runAtMs: ts instanceof Date ? ts.getTime() : ts
174
+ });
175
+ },
176
+ async cancel(id) {
177
+ await api.runMutation("scheduler:_cancel", { id });
178
+ }
179
+ };
180
+ }
181
+
182
+ // src/modules.ts
183
+ import { query, mutation } from "@helipod/executor";
184
+
185
+ // src/backoff.ts
186
+ var DEFAULT_BACKOFF_OPTIONS = { initialBackoffMs: 250, base: 2 };
187
+ function computeBackoff(attempts, rng = Math.random, o = DEFAULT_BACKOFF_OPTIONS) {
188
+ const raw = o.initialBackoffMs * o.base ** (attempts + 1);
189
+ return Math.round(raw * (0.5 + 0.5 * rng()));
190
+ }
191
+
192
+ // src/crons.ts
193
+ import cronParser from "cron-parser";
194
+ import "@helipod/executor";
195
+ var { parseExpression } = cronParser;
196
+ var DEFAULT_CATCH_UP = "skip";
197
+ var DEFAULT_TZ = "UTC";
198
+ var DAY_NUMBERS = {
199
+ sunday: 0,
200
+ monday: 1,
201
+ tuesday: 2,
202
+ wednesday: 3,
203
+ thursday: 4,
204
+ friday: 5,
205
+ saturday: 6
206
+ };
207
+ function cronJobs() {
208
+ const entries = /* @__PURE__ */ new Map();
209
+ function register(name, spec, tz, catchUp, fnRef, args) {
210
+ if (entries.has(name)) throw new Error(`cron "${name}" is already registered \u2014 cron identifiers must be unique`);
211
+ entries.set(name, {
212
+ name,
213
+ spec,
214
+ tz: tz ?? DEFAULT_TZ,
215
+ catchUp: catchUp ?? DEFAULT_CATCH_UP,
216
+ workFnPath: getFunctionPath(fnRef),
217
+ workArgs: args
218
+ });
219
+ }
220
+ return {
221
+ interval(name, period, fnRef, args, opts) {
222
+ const ms = (period.seconds ?? 0) * 1e3 + (period.minutes ?? 0) * 6e4 + (period.hours ?? 0) * 36e5;
223
+ if (ms <= 0) throw new Error(`cron "${name}": interval must resolve to a positive duration`);
224
+ register(name, { kind: "interval", ms }, opts?.tz, opts?.catchUp, fnRef, args);
225
+ },
226
+ cron(name, expr, fnRef, args, opts) {
227
+ register(name, { kind: "cron", expr }, opts?.tz, opts?.catchUp, fnRef, args);
228
+ },
229
+ daily(name, at, fnRef, args, opts) {
230
+ register(name, { kind: "cron", expr: `${at.minuteUTC} ${at.hourUTC} * * *` }, DEFAULT_TZ, opts?.catchUp, fnRef, args);
231
+ },
232
+ hourly(name, at, fnRef, args, opts) {
233
+ register(name, { kind: "cron", expr: `${at.minuteUTC} * * * *` }, DEFAULT_TZ, opts?.catchUp, fnRef, args);
234
+ },
235
+ weekly(name, at, fnRef, args, opts) {
236
+ register(name, { kind: "cron", expr: `${at.minuteUTC} ${at.hourUTC} * * ${DAY_NUMBERS[at.dayOfWeek]}` }, DEFAULT_TZ, opts?.catchUp, fnRef, args);
237
+ },
238
+ monthly(name, at, fnRef, args, opts) {
239
+ register(name, { kind: "cron", expr: `${at.minuteUTC} ${at.hourUTC} ${at.day} * *` }, DEFAULT_TZ, opts?.catchUp, fnRef, args);
240
+ },
241
+ __entries: () => [...entries.values()]
242
+ };
243
+ }
244
+ function computeNextRun(spec, tz, afterTs) {
245
+ if (spec.kind === "interval") return afterTs + spec.ms;
246
+ const interval = parseExpression(spec.expr, { currentDate: new Date(afterTs), tz });
247
+ return interval.next().toDate().getTime();
248
+ }
249
+ function computePrevRun(spec, tz, atOrBeforeTs) {
250
+ if (spec.kind === "interval") {
251
+ throw new Error(
252
+ "computePrevRun: interval specs are anchor-relative \u2014 compute their previous occurrence via arithmetic from the cron's own anchor instead of calling this"
253
+ );
254
+ }
255
+ const interval = parseExpression(spec.expr, { currentDate: new Date(atOrBeforeTs + 1), tz });
256
+ return interval.prev().toDate().getTime();
257
+ }
258
+ async function enqueueCadenceJob(db, now, tables, cronName, runAt) {
259
+ const jobId = await enqueueInternal(db, now, tables, "scheduler:_cronTick", { cronName }, { runAt, name: cronName });
260
+ const rows = await db.query(tables.jobArgs, "by_job").eq("jobId", jobId).take(1).collect();
261
+ const row = rows[0];
262
+ if (row !== void 0) {
263
+ await db.replace(row._id, { ...row, args: { cronName, jobId } });
264
+ }
265
+ return jobId;
266
+ }
267
+ async function reconcileCrons(ctx, registry) {
268
+ const db = ctx.db;
269
+ const now = ctx.now;
270
+ const nowFn = () => now;
271
+ const tables = { jobs: "jobs", jobArgs: "job_args" };
272
+ const desired = registry ? registry.__entries() : [];
273
+ const desiredByName = new Map(desired.map((e) => [e.name, e]));
274
+ const existingRows = await db.query("crons", "by_creation").collect();
275
+ const existingByName = new Map(existingRows.map((r) => [r.name, r]));
276
+ for (const row of existingRows) {
277
+ if (desiredByName.has(row.name)) continue;
278
+ const cadenceJobId = row.cadenceJobId;
279
+ if (cadenceJobId !== void 0) {
280
+ const job = await db.get(cadenceJobId);
281
+ if (job !== null && job.state === "pending") {
282
+ await db.replace(cadenceJobId, { ...job, state: "canceled", completedTs: now });
283
+ }
284
+ }
285
+ await db.delete(row._id);
286
+ }
287
+ for (const entry of desired) {
288
+ const specJson = JSON.stringify(entry.spec);
289
+ const existingRow = existingByName.get(entry.name);
290
+ if (existingRow === void 0) {
291
+ const cronId = await db.insert("crons", {
292
+ name: entry.name,
293
+ spec: specJson,
294
+ tz: entry.tz,
295
+ catchUp: entry.catchUp,
296
+ lastScheduledTs: now,
297
+ workFnPath: entry.workFnPath,
298
+ workArgs: entry.workArgs
299
+ });
300
+ const firstRun = computeNextRun(entry.spec, entry.tz, now);
301
+ const cadenceJobId2 = await enqueueCadenceJob(db, nowFn, tables, entry.name, firstRun);
302
+ await db.replace(cronId, {
303
+ name: entry.name,
304
+ spec: specJson,
305
+ tz: entry.tz,
306
+ catchUp: entry.catchUp,
307
+ lastScheduledTs: now,
308
+ workFnPath: entry.workFnPath,
309
+ workArgs: entry.workArgs,
310
+ cadenceJobId: cadenceJobId2
311
+ });
312
+ continue;
313
+ }
314
+ const specChanged = existingRow.spec !== specJson || existingRow.tz !== entry.tz || existingRow.workFnPath !== entry.workFnPath || JSON.stringify(existingRow.workArgs) !== JSON.stringify(entry.workArgs);
315
+ if (specChanged) {
316
+ const oldCadenceJobId = existingRow.cadenceJobId;
317
+ if (oldCadenceJobId !== void 0) {
318
+ const job = await db.get(oldCadenceJobId);
319
+ if (job !== null && job.state === "pending") {
320
+ await db.replace(oldCadenceJobId, { ...job, state: "canceled", completedTs: now });
321
+ }
322
+ }
323
+ const firstRun = computeNextRun(entry.spec, entry.tz, now);
324
+ const cadenceJobId2 = await enqueueCadenceJob(db, nowFn, tables, entry.name, firstRun);
325
+ await db.replace(existingRow._id, {
326
+ ...existingRow,
327
+ spec: specJson,
328
+ tz: entry.tz,
329
+ catchUp: entry.catchUp,
330
+ workFnPath: entry.workFnPath,
331
+ workArgs: entry.workArgs,
332
+ lastScheduledTs: now,
333
+ cadenceJobId: cadenceJobId2
334
+ });
335
+ continue;
336
+ }
337
+ if (existingRow.catchUp !== entry.catchUp) {
338
+ await db.replace(existingRow._id, { ...existingRow, catchUp: entry.catchUp });
339
+ }
340
+ const cadenceJobId = existingRow.cadenceJobId;
341
+ let hasLiveCadence = false;
342
+ if (cadenceJobId !== void 0) {
343
+ const job = await db.get(cadenceJobId);
344
+ hasLiveCadence = job !== null && (job.state === "pending" || job.state === "inProgress");
345
+ }
346
+ if (!hasLiveCadence) {
347
+ const anchor = existingRow.lastScheduledTs ?? now;
348
+ const nextRun = computeNextRun(entry.spec, entry.tz, anchor);
349
+ const newCadenceJobId = await enqueueCadenceJob(db, nowFn, tables, entry.name, nextRun);
350
+ await db.replace(existingRow._id, { ...existingRow, cadenceJobId: newCadenceJobId });
351
+ }
352
+ }
353
+ }
354
+
355
+ // src/modules.ts
356
+ var BATCH_CAP = 64;
357
+ var CATCHUP_CAP = 1e3;
358
+ var LEASE_MS = 3e4;
359
+ var SWEEP_MS = 3e4;
360
+ function compact2(obj) {
361
+ const out = {};
362
+ for (const [k, v2] of Object.entries(obj)) if (v2 !== void 0) out[k] = v2;
363
+ return out;
364
+ }
365
+ var _peekDue = query(async (ctx) => {
366
+ const now = ctx.now();
367
+ const due = await ctx.db.query("scheduler/jobs", "by_next_ts").eq("state", "pending").lte("nextTs", now).order("asc").take(BATCH_CAP).collect();
368
+ const future = await ctx.db.query("scheduler/jobs", "by_next_ts").eq("state", "pending").gt("nextTs", now).order("asc").take(1).collect();
369
+ const next = future[0];
370
+ return {
371
+ due,
372
+ earliestFutureTs: next ? next.nextTs : null
373
+ };
374
+ });
375
+ var _claim = mutation(async (ctx, args) => {
376
+ const job = await ctx.db.get(args.jobId);
377
+ if (job === null || job.state !== "pending") return null;
378
+ const now = ctx.now();
379
+ await ctx.db.replace(args.jobId, {
380
+ ...job,
381
+ state: "inProgress",
382
+ leaseHolder: "driver",
383
+ leaseExpiresAt: now + LEASE_MS
384
+ });
385
+ const argRows = await ctx.db.query("scheduler/job_args", "by_job").eq("jobId", args.jobId).take(1).collect();
386
+ const argRow = argRows[0];
387
+ return {
388
+ jobId: args.jobId,
389
+ fnPath: job.fnPath,
390
+ kind: job.kind,
391
+ args: argRow?.args ?? null,
392
+ context: argRow?.context,
393
+ onComplete: job.onComplete
394
+ };
395
+ });
396
+ var _complete = mutation(async (ctx, args) => {
397
+ const job = await ctx.db.get(args.jobId);
398
+ if (job === null || job.state !== "inProgress") return null;
399
+ const now = ctx.now();
400
+ const nowFn = () => now;
401
+ if (args.result.kind === "success") {
402
+ await ctx.db.replace(
403
+ args.jobId,
404
+ compact2({
405
+ ...job,
406
+ state: "success",
407
+ completedTs: now,
408
+ leaseHolder: void 0,
409
+ leaseExpiresAt: void 0
410
+ })
411
+ );
412
+ await fireOnComplete(ctx.db, nowFn, CRON_TABLES, args.jobId, job.onComplete, {
413
+ kind: "success",
414
+ value: args.result.value
415
+ });
416
+ return null;
417
+ }
418
+ const attempts = job.attempts + 1;
419
+ const maxFailures = job.maxFailures;
420
+ const lastError = args.result.error;
421
+ const retryable = args.result.retryable ?? true;
422
+ if (attempts >= maxFailures || !retryable) {
423
+ await ctx.db.replace(
424
+ args.jobId,
425
+ compact2({
426
+ ...job,
427
+ state: "failed",
428
+ attempts,
429
+ completedTs: now,
430
+ lastError,
431
+ leaseHolder: void 0,
432
+ leaseExpiresAt: void 0
433
+ })
434
+ );
435
+ await fireOnComplete(ctx.db, nowFn, CRON_TABLES, args.jobId, job.onComplete, {
436
+ kind: "failed",
437
+ error: lastError
438
+ });
439
+ return null;
440
+ }
441
+ const nextTs = now + computeBackoff(attempts, ctx.random);
442
+ await ctx.db.replace(
443
+ args.jobId,
444
+ compact2({
445
+ ...job,
446
+ state: "pending",
447
+ attempts,
448
+ nextTs,
449
+ lastError,
450
+ leaseHolder: void 0,
451
+ leaseExpiresAt: void 0
452
+ })
453
+ );
454
+ return null;
455
+ });
456
+ var CRON_TABLES = { jobs: "scheduler/jobs", jobArgs: "scheduler/job_args" };
457
+ var _enqueue = mutation(
458
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
459
+ async (ctx, a) => ctx.scheduler.runAt(a.runAtMs, a.fnPath, a.args)
460
+ );
461
+ var _cancel = mutation(
462
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
463
+ async (ctx, a) => {
464
+ await ctx.scheduler.cancel(a.id);
465
+ return null;
466
+ }
467
+ );
468
+ var _cronTick = mutation(async (ctx, args) => {
469
+ const rows = await ctx.db.query("scheduler/crons", "by_name").eq("name", args.cronName).take(1).collect();
470
+ const cron = rows[0];
471
+ if (cron === void 0) return null;
472
+ if (args.jobId !== void 0 && cron.cadenceJobId !== void 0 && cron.cadenceJobId !== args.jobId) {
473
+ return null;
474
+ }
475
+ const now = ctx.now();
476
+ const spec = JSON.parse(cron.spec);
477
+ const tz = cron.tz;
478
+ const catchUp = cron.catchUp;
479
+ const anchor = cron.lastScheduledTs ?? now;
480
+ const nowFn = () => ctx.now();
481
+ let toFire;
482
+ let next;
483
+ let newLastScheduledTs;
484
+ if (spec.kind === "interval") {
485
+ const period = spec.ms;
486
+ const elapsed = now - anchor;
487
+ const n = elapsed >= 0 ? Math.floor(elapsed / period) : 0;
488
+ if (n === 0) {
489
+ toFire = [];
490
+ next = anchor + period;
491
+ newLastScheduledTs = anchor;
492
+ } else if (n === 1) {
493
+ const only = anchor + period;
494
+ toFire = [only];
495
+ next = only + period;
496
+ newLastScheduledTs = only;
497
+ } else {
498
+ const lastOccurrence = anchor + n * period;
499
+ next = lastOccurrence + period;
500
+ newLastScheduledTs = lastOccurrence;
501
+ if (catchUp === "fireAll") {
502
+ const fireCount = Math.min(n, CATCHUP_CAP);
503
+ toFire = [];
504
+ for (let i = 0; i < fireCount; i++) toFire.push(anchor + (i + 1) * period);
505
+ if (n > CATCHUP_CAP) {
506
+ console.warn(
507
+ `[scheduler] cron "${cron.name}": fireAll backlog of ${n} occurrences exceeded CATCHUP_CAP (${CATCHUP_CAP}) \u2014 fired the oldest ${CATCHUP_CAP}, discarded the rest.`
508
+ );
509
+ }
510
+ } else if (catchUp === "fireOnce") {
511
+ toFire = [lastOccurrence];
512
+ } else {
513
+ toFire = [];
514
+ }
515
+ }
516
+ } else {
517
+ const firstAfterAnchor = computeNextRun(spec, tz, anchor);
518
+ if (firstAfterAnchor > now) {
519
+ toFire = [];
520
+ next = firstAfterAnchor;
521
+ newLastScheduledTs = anchor;
522
+ } else {
523
+ const secondAfterAnchor = computeNextRun(spec, tz, firstAfterAnchor);
524
+ if (secondAfterAnchor > now) {
525
+ toFire = [firstAfterAnchor];
526
+ next = secondAfterAnchor;
527
+ newLastScheduledTs = firstAfterAnchor;
528
+ } else {
529
+ next = computeNextRun(spec, tz, now);
530
+ const lastOccurrence = computePrevRun(spec, tz, now);
531
+ newLastScheduledTs = lastOccurrence;
532
+ if (catchUp === "fireAll") {
533
+ toFire = [];
534
+ let cursor = firstAfterAnchor;
535
+ let truncated = false;
536
+ while (cursor <= now) {
537
+ if (toFire.length >= CATCHUP_CAP) {
538
+ truncated = true;
539
+ break;
540
+ }
541
+ toFire.push(cursor);
542
+ cursor = computeNextRun(spec, tz, cursor);
543
+ }
544
+ if (truncated) {
545
+ console.warn(
546
+ `[scheduler] cron "${cron.name}": fireAll backlog exceeded CATCHUP_CAP (${CATCHUP_CAP}) \u2014 fired the oldest ${CATCHUP_CAP}, discarded the rest.`
547
+ );
548
+ }
549
+ } else if (catchUp === "fireOnce") {
550
+ toFire = [lastOccurrence];
551
+ } else {
552
+ toFire = [];
553
+ console.warn(
554
+ `[scheduler] cron "${cron.name}": skipped a downtime backlog (catchUp:"skip") \u2014 exact occurrence count not tracked for cron-expression specs.`
555
+ );
556
+ }
557
+ }
558
+ }
559
+ }
560
+ const schedulerFacade = ctx.scheduler;
561
+ for (const fireTs of toFire) {
562
+ await schedulerFacade.enqueue(cron.workFnPath, cron.workArgs, {
563
+ runAt: fireTs,
564
+ idempotencyKey: `${cron.name}:${fireTs}`,
565
+ name: cron.name
566
+ });
567
+ }
568
+ const cadenceJobId = await enqueueCadenceJob(ctx.db, nowFn, CRON_TABLES, cron.name, next);
569
+ await ctx.db.replace(
570
+ cron._id,
571
+ compact2({
572
+ ...cron,
573
+ lastScheduledTs: newLastScheduledTs,
574
+ cadenceJobId
575
+ })
576
+ );
577
+ return null;
578
+ });
579
+ var _reclaim = mutation(async (ctx) => {
580
+ const now = ctx.now();
581
+ const nowFn = () => now;
582
+ const expired = await ctx.db.query("scheduler/jobs", "by_next_ts").eq("state", "inProgress").where("lt", "leaseExpiresAt", now).take(BATCH_CAP).collect();
583
+ let reclaimed = 0;
584
+ for (const job of expired) {
585
+ const jobId = job._id;
586
+ const attempts = job.attempts + 1;
587
+ const lastError = "lease expired: driver did not complete the job before its lease deadline (infra kill)";
588
+ if (job.kind === "mutation" && attempts < job.maxFailures) {
589
+ await ctx.db.replace(
590
+ jobId,
591
+ compact2({
592
+ ...job,
593
+ state: "pending",
594
+ attempts,
595
+ nextTs: now,
596
+ // immediate — the delay was the crash, not the job's own backoff
597
+ lastError,
598
+ leaseHolder: void 0,
599
+ leaseExpiresAt: void 0
600
+ })
601
+ );
602
+ } else {
603
+ await ctx.db.replace(
604
+ jobId,
605
+ compact2({
606
+ ...job,
607
+ state: "failed",
608
+ attempts,
609
+ completedTs: now,
610
+ lastError,
611
+ leaseHolder: void 0,
612
+ leaseExpiresAt: void 0
613
+ })
614
+ );
615
+ await fireOnComplete(ctx.db, nowFn, CRON_TABLES, jobId, job.onComplete, {
616
+ kind: "failed",
617
+ error: lastError
618
+ });
619
+ }
620
+ reclaimed++;
621
+ }
622
+ return { reclaimed };
623
+ });
624
+
625
+ // src/driver.ts
626
+ import { isHelipodError } from "@helipod/errors";
627
+ function schedulerDriver() {
628
+ let ctx;
629
+ let running = false;
630
+ let pendingWake = false;
631
+ let timer = null;
632
+ let inFlight = null;
633
+ let sweepTimer = null;
634
+ let stopped = false;
635
+ function wake() {
636
+ if (stopped) return;
637
+ iterate().catch((e) => {
638
+ console.error("[scheduler] driver iteration failed:", e);
639
+ });
640
+ }
641
+ function iterate() {
642
+ if (running) {
643
+ pendingWake = true;
644
+ return inFlight ?? Promise.resolve();
645
+ }
646
+ running = true;
647
+ const pass = runPass().finally(() => {
648
+ running = false;
649
+ inFlight = null;
650
+ if (pendingWake) void wake();
651
+ });
652
+ inFlight = pass;
653
+ return pass;
654
+ }
655
+ async function runPass() {
656
+ let earliestFutureTs = null;
657
+ do {
658
+ pendingWake = false;
659
+ const peeked = await ctx.runFunction("scheduler:_peekDue", {});
660
+ earliestFutureTs = peeked.earliestFutureTs;
661
+ for (const job of peeked.due) {
662
+ const claimed = await ctx.runFunction("scheduler:_claim", { jobId: job._id });
663
+ if (claimed === null) continue;
664
+ let result;
665
+ try {
666
+ const value = await ctx.runFunction(claimed.fnPath, claimed.args);
667
+ result = { kind: "success", value: value === void 0 ? null : value };
668
+ } catch (e) {
669
+ result = { kind: "failed", error: String(e), retryable: isHelipodError(e) ? e.retryable : true };
670
+ }
671
+ await ctx.runFunction("scheduler:_complete", { jobId: job._id, result });
672
+ }
673
+ } while (pendingWake);
674
+ if (timer !== null) {
675
+ ctx.clearTimer(timer);
676
+ timer = null;
677
+ }
678
+ if (!stopped && earliestFutureTs != null) timer = ctx.setTimer(earliestFutureTs, wake);
679
+ }
680
+ async function sweepOnce() {
681
+ try {
682
+ await ctx.runFunction("scheduler:_reclaim", {});
683
+ } catch (e) {
684
+ console.error("[scheduler] lease-reclaim sweep failed:", e);
685
+ } finally {
686
+ armSweep();
687
+ }
688
+ }
689
+ function armSweep() {
690
+ if (stopped) return;
691
+ if (sweepTimer !== null) {
692
+ ctx.clearTimer(sweepTimer);
693
+ sweepTimer = null;
694
+ }
695
+ sweepTimer = ctx.setTimer(ctx.now() + ctx.backstopMs(SWEEP_MS), () => {
696
+ void sweepOnce();
697
+ });
698
+ }
699
+ let unsubscribeCommit = null;
700
+ return {
701
+ name: "scheduler",
702
+ start(c) {
703
+ ctx = c;
704
+ unsubscribeCommit = c.onCommit((inv) => {
705
+ if (inv.tables.some((t) => t.startsWith("scheduler/"))) wake();
706
+ });
707
+ wake();
708
+ armSweep();
709
+ },
710
+ stop() {
711
+ stopped = true;
712
+ unsubscribeCommit?.();
713
+ unsubscribeCommit = null;
714
+ if (timer !== null) {
715
+ ctx.clearTimer(timer);
716
+ timer = null;
717
+ }
718
+ if (sweepTimer !== null) {
719
+ ctx.clearTimer(sweepTimer);
720
+ sweepTimer = null;
721
+ }
722
+ },
723
+ // Test seam: drives a loop pass and awaits its actual completion (coalescing into an
724
+ // already-in-flight pass — e.g. one a same-turn reactive `onCommit` wake already started —
725
+ // rather than resolving early), and lets errors propagate to the caller (unlike `wake()`,
726
+ // used by the reactive/timer paths, which swallows+logs), so tests see real failures instead
727
+ // of them being silently logged.
728
+ __tick: () => iterate(),
729
+ // Test seam: the same fire-and-forget signal `onCommit`/timers send internally — see the
730
+ // interface doc above.
731
+ __wake: () => wake(),
732
+ // Test seam: runs the lease-reclaim sweep exactly once, without the real `SWEEP_MS` wait and
733
+ // without re-arming a live timer (unlike `armSweep`/`sweepOnce` above) — errors propagate
734
+ // (unlike the internal `sweepOnce`) so a test sees real `_reclaim` failures.
735
+ __sweep: () => ctx.runFunction("scheduler:_reclaim", {}).then(() => void 0)
736
+ };
737
+ }
738
+
739
+ // src/index.ts
740
+ function defineScheduler(opts) {
741
+ return defineComponent({
742
+ name: "scheduler",
743
+ schema: schedulerSchema,
744
+ modules: { _peekDue, _claim, _complete, _reclaim, _cronTick, _enqueue, _cancel },
745
+ context: (cctx) => schedulerContext(cctx),
746
+ contextType: { import: "@helipod/scheduler", type: "SchedulerContext" },
747
+ serverExports: ["cronJobs"],
748
+ contextWrite: true,
749
+ driver: schedulerDriver(),
750
+ boot: (ctx) => reconcileCrons(ctx, opts?.crons),
751
+ // Action-mode `ctx.scheduler` (Convex parity: portable between a mutation and an action) —
752
+ // see `schedulerActionContext`'s doc comment in `./facade.ts`.
753
+ buildAction: (api) => schedulerActionContext(api)
754
+ });
755
+ }
756
+ export {
757
+ BATCH_CAP,
758
+ CATCHUP_CAP,
759
+ DEFAULT_BACKOFF_OPTIONS,
760
+ LEASE_MS,
761
+ SWEEP_MS,
762
+ computeBackoff,
763
+ computeNextRun,
764
+ computePrevRun,
765
+ cronJobs,
766
+ defineScheduler,
767
+ enqueueCadenceJob,
768
+ enqueueInternal,
769
+ fireOnComplete,
770
+ getFunctionPath,
771
+ schedulerActionContext,
772
+ schedulerDriver,
773
+ schedulerSchema
774
+ };
775
+ //# sourceMappingURL=index.js.map