@ultimat3/jobs 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 +202 -0
- package/package.json +38 -0
- package/src/clock.d.ts +7 -0
- package/src/clock.d.ts.map +1 -0
- package/src/clock.js +21 -0
- package/src/clock.js.map +1 -0
- package/src/clock.ts +23 -0
- package/src/describe.ts +61 -0
- package/src/driver-memory.d.ts +9 -0
- package/src/driver-memory.d.ts.map +1 -0
- package/src/driver-memory.js +189 -0
- package/src/driver-memory.js.map +1 -0
- package/src/driver-memory.ts +216 -0
- package/src/driver-nats.d.ts +7 -0
- package/src/driver-nats.d.ts.map +1 -0
- package/src/driver-nats.js +51 -0
- package/src/driver-nats.js.map +1 -0
- package/src/driver-nats.ts +73 -0
- package/src/driver-pg-sql.d.ts +19 -0
- package/src/driver-pg-sql.d.ts.map +1 -0
- package/src/driver-pg-sql.js +160 -0
- package/src/driver-pg-sql.js.map +1 -0
- package/src/driver-pg-sql.ts +170 -0
- package/src/driver-pg.d.ts +17 -0
- package/src/driver-pg.d.ts.map +1 -0
- package/src/driver-pg.js +246 -0
- package/src/driver-pg.js.map +1 -0
- package/src/driver-pg.ts +356 -0
- package/src/driver-redis.d.ts +7 -0
- package/src/driver-redis.d.ts.map +1 -0
- package/src/driver-redis.js +54 -0
- package/src/driver-redis.js.map +1 -0
- package/src/driver-redis.ts +76 -0
- package/src/driver.d.ts +114 -0
- package/src/driver.d.ts.map +1 -0
- package/src/driver.js +14 -0
- package/src/driver.js.map +1 -0
- package/src/driver.ts +143 -0
- package/src/errors.d.ts +63 -0
- package/src/errors.d.ts.map +1 -0
- package/src/errors.js +105 -0
- package/src/errors.js.map +1 -0
- package/src/errors.ts +165 -0
- package/src/events.d.ts +35 -0
- package/src/events.d.ts.map +1 -0
- package/src/events.js +92 -0
- package/src/events.js.map +1 -0
- package/src/events.ts +134 -0
- package/src/index.d.ts +32 -0
- package/src/index.d.ts.map +1 -0
- package/src/index.js +18 -0
- package/src/index.js.map +1 -0
- package/src/index.ts +172 -0
- package/src/inspect.d.ts +82 -0
- package/src/inspect.d.ts.map +1 -0
- package/src/inspect.js +113 -0
- package/src/inspect.js.map +1 -0
- package/src/inspect.ts +213 -0
- package/src/job.d.ts +71 -0
- package/src/job.d.ts.map +1 -0
- package/src/job.js +99 -0
- package/src/job.js.map +1 -0
- package/src/job.ts +261 -0
- package/src/limits.d.ts +47 -0
- package/src/limits.d.ts.map +1 -0
- package/src/limits.js +0 -0
- package/src/limits.js.map +1 -0
- package/src/limits.ts +0 -0
- package/src/outbox.d.ts +81 -0
- package/src/outbox.d.ts.map +1 -0
- package/src/outbox.js +202 -0
- package/src/outbox.js.map +1 -0
- package/src/outbox.ts +336 -0
- package/src/register.ts +40 -0
- package/src/retry.d.ts +40 -0
- package/src/retry.d.ts.map +1 -0
- package/src/retry.js +59 -0
- package/src/retry.js.map +1 -0
- package/src/retry.ts +90 -0
- package/src/scheduler.d.ts +79 -0
- package/src/scheduler.d.ts.map +1 -0
- package/src/scheduler.js +183 -0
- package/src/scheduler.js.map +1 -0
- package/src/scheduler.ts +417 -0
- package/src/steps.d.ts +86 -0
- package/src/steps.d.ts.map +1 -0
- package/src/steps.js +227 -0
- package/src/steps.js.map +1 -0
- package/src/steps.ts +339 -0
- package/src/worker.d.ts +68 -0
- package/src/worker.d.ts.map +1 -0
- package/src/worker.js +273 -0
- package/src/worker.js.map +1 -0
- package/src/worker.ts +356 -0
package/src/scheduler.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// The `task` primitive + the `scheduler` role. A task NEVER does work: it enqueues jobs, so
|
|
2
|
+
// retries, idempotency and observability all come from the job machinery instead of being
|
|
3
|
+
// re-invented per cron.
|
|
4
|
+
//
|
|
5
|
+
// `tz` is required by the type. A cron without a timezone is a bug waiting for March: `0 3 *
|
|
6
|
+
// * *` in a DST-observing zone runs twice or zero times on the switch day, and "the nightly
|
|
7
|
+
// digest went out at 2am and again at 3am" is not a mystery anyone should have to debug.
|
|
8
|
+
//
|
|
9
|
+
// Exactly one node dispatches per tick, enforced by a Postgres advisory lock. Two schedulers
|
|
10
|
+
// double-enqueue every task; the idempotency key would absorb it, but leader election means
|
|
11
|
+
// the queue never sees the duplicate at all.
|
|
12
|
+
import { assert, logger } from '@ultimat3/core';
|
|
13
|
+
import { instant, nextCronOccurrence } from '@ultimat3/time';
|
|
14
|
+
import { nowMs } from './clock';
|
|
15
|
+
const defaultCronResolver = (cron, options) =>
|
|
16
|
+
// Instant is a branded Date, so it satisfies CronResolver's Date return directly.
|
|
17
|
+
nextCronOccurrence(cron, options.tz, instant(options.from));
|
|
18
|
+
const registry = new Map();
|
|
19
|
+
let anonymous = 0;
|
|
20
|
+
export function task(definition) {
|
|
21
|
+
anonymous += 1;
|
|
22
|
+
const name = definition.name ?? `anonymous-task-${anonymous}`;
|
|
23
|
+
// Runtime backstop; the type already makes an omitted tz a build error.
|
|
24
|
+
assert(typeof definition.tz === 'string' && definition.tz.length > 0, `task "${name}" needs an explicit IANA tz — a cron without a timezone is a bug`, `add tz to task("${name}"), e.g. tz: 'UTC' — an unzoned cron silently drifts by an hour at every DST transition`);
|
|
25
|
+
const handle = {
|
|
26
|
+
kind: 'task',
|
|
27
|
+
name,
|
|
28
|
+
cron: definition.cron,
|
|
29
|
+
tz: definition.tz,
|
|
30
|
+
catchUp: definition.catchUp ?? 'skip',
|
|
31
|
+
maxCatchUp: definition.maxCatchUp ?? 10,
|
|
32
|
+
entries: () => definition.enqueue(),
|
|
33
|
+
};
|
|
34
|
+
registry.set(name, handle);
|
|
35
|
+
return handle;
|
|
36
|
+
}
|
|
37
|
+
export function nameTasks(record) {
|
|
38
|
+
for (const [exportName, handle] of Object.entries(record)) {
|
|
39
|
+
if (handle.name === exportName)
|
|
40
|
+
continue;
|
|
41
|
+
registry.delete(handle.name);
|
|
42
|
+
Object.defineProperty(handle, 'name', { value: exportName, configurable: true });
|
|
43
|
+
registry.set(exportName, handle);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function registeredTasks() {
|
|
47
|
+
return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
48
|
+
}
|
|
49
|
+
export function getTask(name) {
|
|
50
|
+
return registry.get(name);
|
|
51
|
+
}
|
|
52
|
+
export function resetTasks() {
|
|
53
|
+
registry.clear();
|
|
54
|
+
anonymous = 0;
|
|
55
|
+
}
|
|
56
|
+
/** Single-node default: always the leader. Multi-node uses `createPgLeader()`. */
|
|
57
|
+
export function soleLeader() {
|
|
58
|
+
return {
|
|
59
|
+
acquire: () => Promise.resolve(true),
|
|
60
|
+
release: () => Promise.resolve(),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function createMemorySchedulerState() {
|
|
64
|
+
const fired = new Map();
|
|
65
|
+
return {
|
|
66
|
+
lastFiredAt: (taskName) => Promise.resolve(fired.get(taskName)),
|
|
67
|
+
markFired(taskName, occurrenceMs) {
|
|
68
|
+
fired.set(taskName, occurrenceMs);
|
|
69
|
+
return Promise.resolve();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function createScheduler(options) {
|
|
74
|
+
const state = options.state ?? createMemorySchedulerState();
|
|
75
|
+
const resolveCron = options.cron ?? defaultCronResolver;
|
|
76
|
+
const tickIntervalMs = options.tickIntervalMs ?? 1_000;
|
|
77
|
+
const leader = options.leader ?? soleLeader();
|
|
78
|
+
let timer;
|
|
79
|
+
let isLeader = false;
|
|
80
|
+
const nextRunFor = (handle, from) => resolveCron(handle.cron, { tz: handle.tz, from: from ?? new Date(nowMs(options.clock)) });
|
|
81
|
+
/**
|
|
82
|
+
* Occurrences in `(after, until]`. Walking forward from the last fire is what makes
|
|
83
|
+
* catch-up possible at all — a scheduler that only knows "now" cannot know what it missed.
|
|
84
|
+
*/
|
|
85
|
+
const occurrencesSince = (handle, after, until) => {
|
|
86
|
+
const out = [];
|
|
87
|
+
let cursor = after;
|
|
88
|
+
for (let i = 0; i < handle.maxCatchUp; i += 1) {
|
|
89
|
+
const next = nextRunFor(handle, new Date(cursor)).getTime();
|
|
90
|
+
if (!Number.isFinite(next) || next <= cursor || next > until)
|
|
91
|
+
break;
|
|
92
|
+
out.push(next);
|
|
93
|
+
cursor = next;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
};
|
|
97
|
+
const dispatch = async (handle, occurrenceMs, catchUp) => {
|
|
98
|
+
const jobs = [];
|
|
99
|
+
for (const [handleForJob, input] of handle.entries()) {
|
|
100
|
+
const result = await options.driver.enqueue({
|
|
101
|
+
name: handleForJob.name,
|
|
102
|
+
queue: handleForJob.queue,
|
|
103
|
+
input,
|
|
104
|
+
// Occurrence-scoped key: two schedulers, or a retried tick, cannot double-fire.
|
|
105
|
+
idempotencyKey: `${handle.name}:${occurrenceMs}:${handleForJob.idempotencyKeyFor(input)}`,
|
|
106
|
+
maxAttempts: handleForJob.retry.attempts,
|
|
107
|
+
runAt: occurrenceMs,
|
|
108
|
+
});
|
|
109
|
+
jobs.push({ job: handleForJob.name, result });
|
|
110
|
+
}
|
|
111
|
+
await state.markFired(handle.name, occurrenceMs);
|
|
112
|
+
logger.info('jobs.scheduler.dispatched', {
|
|
113
|
+
task: handle.name,
|
|
114
|
+
occurrence: new Date(occurrenceMs).toISOString(),
|
|
115
|
+
tz: handle.tz,
|
|
116
|
+
catchUp,
|
|
117
|
+
jobs: jobs.length,
|
|
118
|
+
});
|
|
119
|
+
return { task: handle.name, occurrenceMs, jobs, catchUp };
|
|
120
|
+
};
|
|
121
|
+
const tick = async () => {
|
|
122
|
+
if (!isLeader) {
|
|
123
|
+
isLeader = await leader.acquire();
|
|
124
|
+
if (!isLeader)
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const at = nowMs(options.clock);
|
|
128
|
+
const tasks = options.tasks ?? registeredTasks();
|
|
129
|
+
const dispatched = [];
|
|
130
|
+
for (const handle of tasks) {
|
|
131
|
+
const last = await state.lastFiredAt(handle.name);
|
|
132
|
+
if (last === undefined) {
|
|
133
|
+
// First sight of this task: arm it, never fire retroactively for all of history.
|
|
134
|
+
await state.markFired(handle.name, nextRunFor(handle, new Date(at)).getTime() - 1);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const due = occurrencesSince(handle, last, at);
|
|
138
|
+
if (due.length === 0)
|
|
139
|
+
continue;
|
|
140
|
+
if (handle.catchUp === 'skip') {
|
|
141
|
+
const latest = due[due.length - 1];
|
|
142
|
+
if (latest !== undefined)
|
|
143
|
+
dispatched.push(await dispatch(handle, latest, due.length > 1));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (handle.catchUp === 'run-once') {
|
|
147
|
+
const first = due[0];
|
|
148
|
+
if (first !== undefined)
|
|
149
|
+
dispatched.push(await dispatch(handle, first, due.length > 1));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (const occurrence of due) {
|
|
153
|
+
dispatched.push(await dispatch(handle, occurrence, occurrence !== due[due.length - 1]));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return dispatched;
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
start() {
|
|
160
|
+
if (timer !== undefined)
|
|
161
|
+
return;
|
|
162
|
+
timer = setInterval(() => {
|
|
163
|
+
void tick().catch((error) => {
|
|
164
|
+
logger.error('jobs.scheduler.tick-failed', {
|
|
165
|
+
error: error instanceof Error ? error.message : String(error),
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}, tickIntervalMs);
|
|
169
|
+
logger.info('jobs.scheduler.started', { tasks: (options.tasks ?? registeredTasks()).length });
|
|
170
|
+
},
|
|
171
|
+
async stop() {
|
|
172
|
+
if (timer !== undefined)
|
|
173
|
+
clearInterval(timer);
|
|
174
|
+
timer = undefined;
|
|
175
|
+
if (isLeader)
|
|
176
|
+
await leader.release();
|
|
177
|
+
isLeader = false;
|
|
178
|
+
},
|
|
179
|
+
tick,
|
|
180
|
+
nextRunFor,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
//# sourceMappingURL=scheduler.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scheduler.js","sourceRoot":"","sources":["scheduler.ts"],"names":[],"mappings":"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"}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
// The `task` primitive + the `scheduler` role. A task NEVER does work: it enqueues jobs, so
|
|
2
|
+
// retries, idempotency and observability all come from the job machinery instead of being
|
|
3
|
+
// re-invented per cron.
|
|
4
|
+
//
|
|
5
|
+
// `tz` is required by the type. A cron without a timezone is a bug waiting for March: `0 3 *
|
|
6
|
+
// * *` in a DST-observing zone runs twice or zero times on the switch day, and "the nightly
|
|
7
|
+
// digest went out at 2am and again at 3am" is not a mystery anyone should have to debug.
|
|
8
|
+
//
|
|
9
|
+
// Exactly one node dispatches per tick, enforced by a Postgres advisory lock. Two schedulers
|
|
10
|
+
// double-enqueue every task; the idempotency key would absorb it, but leader election means
|
|
11
|
+
// the queue never sees the duplicate at all.
|
|
12
|
+
|
|
13
|
+
import type { Clock } from '@ultimat3/core';
|
|
14
|
+
import { assert, logger } from '@ultimat3/core';
|
|
15
|
+
import { instant, nextCronOccurrence } from '@ultimat3/time';
|
|
16
|
+
import { nowMs } from './clock';
|
|
17
|
+
import type { EnqueueResult, JobDriver } from './driver';
|
|
18
|
+
import { JobNameTakenError } from './errors';
|
|
19
|
+
import type { AnyJobHandle } from './job';
|
|
20
|
+
import type { EnqueueOptions } from './outbox';
|
|
21
|
+
|
|
22
|
+
/** `[[sendDigest, {}]]` — a job handle plus its input. */
|
|
23
|
+
export type TaskEnqueueEntry = readonly [AnyJobHandle, unknown];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What to do when the scheduler was down across one or more occurrences.
|
|
27
|
+
* `skip` (default) waits for the next one; `run-once` fires a single catch-up; `run-all`
|
|
28
|
+
* fires one per missed occurrence, bounded by `maxCatchUp`.
|
|
29
|
+
*/
|
|
30
|
+
export type CatchUpPolicy = 'skip' | 'run-once' | 'run-all';
|
|
31
|
+
|
|
32
|
+
export interface TaskDefinition {
|
|
33
|
+
/** Omit it: `defineApi({ tasks })` assigns the export name. Set it only to pin the name the
|
|
34
|
+
* scheduler's `lastFiredAt` and occurrence lock are already keyed by. */
|
|
35
|
+
readonly name?: string;
|
|
36
|
+
readonly cron: string;
|
|
37
|
+
/** REQUIRED IANA zone, e.g. `'UTC'`, `'America/New_York'`. */
|
|
38
|
+
readonly tz: string;
|
|
39
|
+
/**
|
|
40
|
+
* Builds the entries for ONE occurrence, given that occurrence's instant in epoch ms.
|
|
41
|
+
*
|
|
42
|
+
* The argument exists because catch-up does: a tick dispatched late, or replayed for a
|
|
43
|
+
* missed occurrence, has a wall clock that no longer matches the occurrence being fired.
|
|
44
|
+
* A payload derived from `Date.now()` there is silently for the wrong day — and the
|
|
45
|
+
* scheduler's own key is occurrence-scoped, so nothing downstream catches it.
|
|
46
|
+
*/
|
|
47
|
+
enqueue: (occurrenceMs: number) => readonly TaskEnqueueEntry[];
|
|
48
|
+
readonly catchUp?: CatchUpPolicy;
|
|
49
|
+
readonly maxCatchUp?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One entry's outcome, from a scheduled dispatch or a manual `task.enqueue()` alike. */
|
|
53
|
+
export interface TaskJobResult {
|
|
54
|
+
readonly job: string;
|
|
55
|
+
readonly result: EnqueueResult;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** JSON-safe view of a task for the manifest, `/_x` and the MCP dev server. */
|
|
59
|
+
export interface TaskDescriptor {
|
|
60
|
+
readonly kind: 'task';
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly cron: string;
|
|
63
|
+
readonly tz: string;
|
|
64
|
+
readonly catchUp: CatchUpPolicy;
|
|
65
|
+
readonly maxCatchUp: number;
|
|
66
|
+
readonly jobs: readonly string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface TaskHandle {
|
|
70
|
+
readonly kind: 'task';
|
|
71
|
+
readonly name: string;
|
|
72
|
+
readonly cron: string;
|
|
73
|
+
readonly tz: string;
|
|
74
|
+
readonly catchUp: CatchUpPolicy;
|
|
75
|
+
readonly maxCatchUp: number;
|
|
76
|
+
/**
|
|
77
|
+
* Entries for `occurrenceMs`. Defaults to now, which is the honest answer for the two
|
|
78
|
+
* callers that have no occurrence: a manual `task.enqueue()` and `describe()`, which only
|
|
79
|
+
* wants the job names.
|
|
80
|
+
*/
|
|
81
|
+
entries(occurrenceMs?: number): readonly TaskEnqueueEntry[];
|
|
82
|
+
/**
|
|
83
|
+
* Fire this task's declared entries now, through the same facade `JobHandle.enqueue` uses —
|
|
84
|
+
* the backfill and "run it again" path, with no scheduler and no leader involved.
|
|
85
|
+
*/
|
|
86
|
+
enqueue(options?: EnqueueOptions): Promise<readonly TaskJobResult[]>;
|
|
87
|
+
describe(): TaskDescriptor;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolves the next fire time. Injected so scheduling logic is testable without a cron impl. */
|
|
91
|
+
export type CronResolver = (cron: string, options: { tz: string; from: Date }) => Date;
|
|
92
|
+
|
|
93
|
+
const defaultCronResolver: CronResolver = (cron, options) =>
|
|
94
|
+
// Instant is a branded Date, so it satisfies CronResolver's Date return directly.
|
|
95
|
+
nextCronOccurrence(cron, options.tz, instant(options.from));
|
|
96
|
+
|
|
97
|
+
const registry = new Map<string, TaskHandle>();
|
|
98
|
+
let anonymous = 0;
|
|
99
|
+
|
|
100
|
+
/** Job's store, for tasks: proof `task()` built the handle, plus whether it named itself. */
|
|
101
|
+
interface TaskOrigin {
|
|
102
|
+
readonly declaredName: boolean;
|
|
103
|
+
/** The export name already stamped, once one has been. `undefined` while still provisional. */
|
|
104
|
+
readonly exportName?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const origin = new WeakMap<object, TaskOrigin>();
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `Intl` carries the runtime's copy of the tz database and rejects anything not in it with a
|
|
111
|
+
* `RangeError`, so it is the only check that can tell `America/Bogota` from `Bogota`.
|
|
112
|
+
*/
|
|
113
|
+
function isIanaZone(tz: string): boolean {
|
|
114
|
+
try {
|
|
115
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: tz }).resolvedOptions().timeZone.length > 0;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function task(definition: TaskDefinition): TaskHandle {
|
|
122
|
+
anonymous += 1;
|
|
123
|
+
const name = definition.name ?? `anonymous-task-${anonymous}`;
|
|
124
|
+
// Runtime backstop; the type already makes an omitted tz a build error.
|
|
125
|
+
assert(
|
|
126
|
+
typeof definition.tz === 'string' && definition.tz.length > 0,
|
|
127
|
+
`task "${name}" needs an explicit IANA tz — a cron without a timezone is a bug`,
|
|
128
|
+
`add tz to task("${name}"), e.g. tz: 'UTC' — an unzoned cron silently drifts by an hour at every DST transition`,
|
|
129
|
+
);
|
|
130
|
+
// A non-empty string is not a timezone. `tz: 'Bogota'` would otherwise resolve every
|
|
131
|
+
// occurrence in UTC and the cron would run five hours off, silently, forever.
|
|
132
|
+
assert(
|
|
133
|
+
isIanaZone(definition.tz),
|
|
134
|
+
`task "${name}" has tz "${definition.tz}", which is not a zone in the IANA tz database`,
|
|
135
|
+
`use the full zone id on task("${name}"), e.g. tz: 'America/Bogota' — list the valid ones with: bun -e "console.log(Intl.supportedValuesOf('timeZone').join('\\n'))"`,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const handle: TaskHandle = {
|
|
139
|
+
kind: 'task',
|
|
140
|
+
name,
|
|
141
|
+
cron: definition.cron,
|
|
142
|
+
tz: definition.tz,
|
|
143
|
+
catchUp: definition.catchUp ?? 'skip',
|
|
144
|
+
maxCatchUp: definition.maxCatchUp ?? 10,
|
|
145
|
+
// `nowMs()` and not `Date.now()`: every reading of time in this package goes through a
|
|
146
|
+
// Clock so a frozen one cannot be bypassed.
|
|
147
|
+
entries: (occurrenceMs: number = nowMs()) => definition.enqueue(occurrenceMs),
|
|
148
|
+
async enqueue(options?: EnqueueOptions): Promise<readonly TaskJobResult[]> {
|
|
149
|
+
const fired: TaskJobResult[] = [];
|
|
150
|
+
for (const [handleForJob, input] of handle.entries()) {
|
|
151
|
+
// The job's PLAIN key, deliberately not `dispatch()`'s `task:occurrence:key`: that one
|
|
152
|
+
// is occurrence-scoped so two schedulers cannot double-fire the same tick, and reusing
|
|
153
|
+
// it here would make a manual run dedupe against whichever occurrence it landed in.
|
|
154
|
+
fired.push({ job: handleForJob.name, result: await handleForJob.enqueue(input, options) });
|
|
155
|
+
}
|
|
156
|
+
return fired;
|
|
157
|
+
},
|
|
158
|
+
// Reads `handle`, never the captured `name`: `nameTasks()` rebinds the property in place.
|
|
159
|
+
describe(): TaskDescriptor {
|
|
160
|
+
return {
|
|
161
|
+
kind: 'task',
|
|
162
|
+
name: handle.name,
|
|
163
|
+
cron: handle.cron,
|
|
164
|
+
tz: handle.tz,
|
|
165
|
+
catchUp: handle.catchUp,
|
|
166
|
+
maxCatchUp: handle.maxCatchUp,
|
|
167
|
+
// Declaration order: a task's entries are a sequence, not a set.
|
|
168
|
+
jobs: handle.entries().map(([entry]) => entry.name),
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
origin.set(handle, { declaredName: definition.name !== undefined });
|
|
173
|
+
// Refused here, not at `registerTask`: a second `task({ name: 'nightly' })` would otherwise
|
|
174
|
+
// replace the seated handle, and the scheduler's persisted `lastFiredAt` — keyed by that name —
|
|
175
|
+
// would silently start driving a different cron. The anonymous names cannot collide.
|
|
176
|
+
if (registry.has(name)) throw new JobNameTakenError({ kind: 'task', name });
|
|
177
|
+
registry.set(name, handle);
|
|
178
|
+
return handle;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Structural, exactly as `isJobHandle` is: only a handle `task()` built has a cron behind it. */
|
|
182
|
+
export function isTaskHandle(value: unknown): value is TaskHandle {
|
|
183
|
+
return (
|
|
184
|
+
typeof value === 'object' &&
|
|
185
|
+
value !== null &&
|
|
186
|
+
(value as { kind?: unknown }).kind === 'task' &&
|
|
187
|
+
origin.has(value)
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Register `target` under `name`, stamped onto the handle the module exported — the scheduler's
|
|
193
|
+
* occurrence key is `task:occurrenceMs:jobKey`, so the task's name is what stops two nodes
|
|
194
|
+
* double-firing a tick, and a copy under a second name would defeat it.
|
|
195
|
+
*
|
|
196
|
+
* A definition that supplied its own `name` keeps it, for the same reason a job's does: the
|
|
197
|
+
* scheduler's persisted `lastFiredAt` is keyed by that name.
|
|
198
|
+
*/
|
|
199
|
+
export function registerTask(name: string, target: TaskHandle): TaskHandle {
|
|
200
|
+
const source = origin.get(target);
|
|
201
|
+
const key = source?.declaredName === true ? target.name : name;
|
|
202
|
+
const seated = registry.get(key);
|
|
203
|
+
// The same handle under the same name is one registration seen twice — `defineApi` and the
|
|
204
|
+
// framework's module scan both reach the same declaration file. A DIFFERENT task under a taken
|
|
205
|
+
// name is the ambiguity to refuse.
|
|
206
|
+
if (seated !== undefined) {
|
|
207
|
+
if (seated !== target) throw new JobNameTakenError({ kind: 'task', name: key });
|
|
208
|
+
return target;
|
|
209
|
+
}
|
|
210
|
+
// One handle exported under two names: the rebind below is in place, so the second alias would
|
|
211
|
+
// move the occurrence key the scheduler dedupes ticks on.
|
|
212
|
+
if (source?.exportName !== undefined && source.exportName !== key)
|
|
213
|
+
throw new JobNameTakenError({ kind: 'task', name: key });
|
|
214
|
+
registry.delete(target.name);
|
|
215
|
+
Object.defineProperty(target, 'name', { value: key, configurable: true });
|
|
216
|
+
if (source !== undefined) origin.set(target, { ...source, exportName: key });
|
|
217
|
+
registry.set(key, target);
|
|
218
|
+
return target;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `registerTasks(module)` is the call app code makes; this is the same rules over a record. */
|
|
222
|
+
export function nameTasks(record: Readonly<Record<string, TaskHandle>>): void {
|
|
223
|
+
for (const [exportName, handle] of Object.entries(record)) registerTask(exportName, handle);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function registeredTasks(): readonly TaskHandle[] {
|
|
227
|
+
return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function getTask(name: string): TaskHandle | undefined {
|
|
231
|
+
return registry.get(name);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function resetTasks(): void {
|
|
235
|
+
registry.clear();
|
|
236
|
+
anonymous = 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export interface LeaderElection {
|
|
240
|
+
acquire(): Promise<boolean>;
|
|
241
|
+
release(): Promise<void>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Single-node default: always the leader. Multi-node uses `createPgLeader()`. */
|
|
245
|
+
export function soleLeader(): LeaderElection {
|
|
246
|
+
return {
|
|
247
|
+
acquire: () => Promise.resolve(true),
|
|
248
|
+
release: () => Promise.resolve(),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface SchedulerState {
|
|
253
|
+
/** Epoch ms of the last occurrence this task was dispatched for. */
|
|
254
|
+
lastFiredAt(taskName: string): Promise<number | undefined>;
|
|
255
|
+
markFired(taskName: string, occurrenceMs: number): Promise<void>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function createMemorySchedulerState(): SchedulerState {
|
|
259
|
+
const fired = new Map<string, number>();
|
|
260
|
+
return {
|
|
261
|
+
lastFiredAt: (taskName) => Promise.resolve(fired.get(taskName)),
|
|
262
|
+
markFired(taskName, occurrenceMs) {
|
|
263
|
+
fired.set(taskName, occurrenceMs);
|
|
264
|
+
return Promise.resolve();
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface SchedulerOptions {
|
|
270
|
+
readonly driver: JobDriver;
|
|
271
|
+
readonly clock?: Clock;
|
|
272
|
+
readonly leader?: LeaderElection;
|
|
273
|
+
readonly state?: SchedulerState;
|
|
274
|
+
readonly cron?: CronResolver;
|
|
275
|
+
readonly tickIntervalMs?: number;
|
|
276
|
+
/** Defaults to every registered task. */
|
|
277
|
+
readonly tasks?: readonly TaskHandle[];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface DispatchedOccurrence {
|
|
281
|
+
readonly task: string;
|
|
282
|
+
readonly occurrenceMs: number;
|
|
283
|
+
readonly jobs: readonly TaskJobResult[];
|
|
284
|
+
readonly catchUp: boolean;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export interface Scheduler {
|
|
288
|
+
start(): void;
|
|
289
|
+
stop(): Promise<void>;
|
|
290
|
+
/** One dispatch round. Returns what it enqueued — tests call this, not the timer. */
|
|
291
|
+
tick(): Promise<readonly DispatchedOccurrence[]>;
|
|
292
|
+
nextRunFor(handle: TaskHandle, from?: Date): Date;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function createScheduler(options: SchedulerOptions): Scheduler {
|
|
296
|
+
const state = options.state ?? createMemorySchedulerState();
|
|
297
|
+
const resolveCron = options.cron ?? defaultCronResolver;
|
|
298
|
+
const tickIntervalMs = options.tickIntervalMs ?? 1_000;
|
|
299
|
+
const leader = options.leader ?? soleLeader();
|
|
300
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
301
|
+
let isLeader = false;
|
|
302
|
+
|
|
303
|
+
const nextRunFor = (handle: TaskHandle, from?: Date): Date =>
|
|
304
|
+
resolveCron(handle.cron, { tz: handle.tz, from: from ?? new Date(nowMs(options.clock)) });
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Occurrences in `(after, until]`. Walking forward from the last fire is what makes
|
|
308
|
+
* catch-up possible at all — a scheduler that only knows "now" cannot know what it missed.
|
|
309
|
+
*/
|
|
310
|
+
const occurrencesSince = (
|
|
311
|
+
handle: TaskHandle,
|
|
312
|
+
after: number,
|
|
313
|
+
until: number,
|
|
314
|
+
): readonly number[] => {
|
|
315
|
+
const out: number[] = [];
|
|
316
|
+
let cursor = after;
|
|
317
|
+
for (let i = 0; i < handle.maxCatchUp; i += 1) {
|
|
318
|
+
const next = nextRunFor(handle, new Date(cursor)).getTime();
|
|
319
|
+
if (!Number.isFinite(next) || next <= cursor || next > until) break;
|
|
320
|
+
out.push(next);
|
|
321
|
+
cursor = next;
|
|
322
|
+
}
|
|
323
|
+
return out;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const dispatch = async (
|
|
327
|
+
handle: TaskHandle,
|
|
328
|
+
occurrenceMs: number,
|
|
329
|
+
catchUp: boolean,
|
|
330
|
+
): Promise<DispatchedOccurrence> => {
|
|
331
|
+
const jobs: TaskJobResult[] = [];
|
|
332
|
+
// The occurrence, not `at`: a catch-up dispatch runs long after the instant it fires for,
|
|
333
|
+
// and the payload has to describe the occurrence the email/report claims to be about.
|
|
334
|
+
for (const [handleForJob, input] of handle.entries(occurrenceMs)) {
|
|
335
|
+
const result = await options.driver.enqueue({
|
|
336
|
+
name: handleForJob.name,
|
|
337
|
+
queue: handleForJob.queue,
|
|
338
|
+
input,
|
|
339
|
+
// Occurrence-scoped key: two schedulers, or a retried tick, cannot double-fire.
|
|
340
|
+
idempotencyKey: `${handle.name}:${occurrenceMs}:${handleForJob.idempotencyKeyFor(input)}`,
|
|
341
|
+
maxAttempts: handleForJob.retry.attempts,
|
|
342
|
+
runAt: occurrenceMs,
|
|
343
|
+
});
|
|
344
|
+
jobs.push({ job: handleForJob.name, result });
|
|
345
|
+
}
|
|
346
|
+
await state.markFired(handle.name, occurrenceMs);
|
|
347
|
+
logger.info('jobs.scheduler.dispatched', {
|
|
348
|
+
task: handle.name,
|
|
349
|
+
occurrence: new Date(occurrenceMs).toISOString(),
|
|
350
|
+
tz: handle.tz,
|
|
351
|
+
catchUp,
|
|
352
|
+
jobs: jobs.length,
|
|
353
|
+
});
|
|
354
|
+
return { task: handle.name, occurrenceMs, jobs, catchUp };
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const tick = async (): Promise<readonly DispatchedOccurrence[]> => {
|
|
358
|
+
if (!isLeader) {
|
|
359
|
+
isLeader = await leader.acquire();
|
|
360
|
+
if (!isLeader) return [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const at = nowMs(options.clock);
|
|
364
|
+
const tasks = options.tasks ?? registeredTasks();
|
|
365
|
+
const dispatched: DispatchedOccurrence[] = [];
|
|
366
|
+
|
|
367
|
+
for (const handle of tasks) {
|
|
368
|
+
const last = await state.lastFiredAt(handle.name);
|
|
369
|
+
if (last === undefined) {
|
|
370
|
+
// First sight of this task: arm it, never fire retroactively for all of history.
|
|
371
|
+
await state.markFired(handle.name, nextRunFor(handle, new Date(at)).getTime() - 1);
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const due = occurrencesSince(handle, last, at);
|
|
376
|
+
if (due.length === 0) continue;
|
|
377
|
+
|
|
378
|
+
if (handle.catchUp === 'skip') {
|
|
379
|
+
const latest = due[due.length - 1];
|
|
380
|
+
if (latest !== undefined) dispatched.push(await dispatch(handle, latest, due.length > 1));
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
if (handle.catchUp === 'run-once') {
|
|
384
|
+
const first = due[0];
|
|
385
|
+
if (first !== undefined) dispatched.push(await dispatch(handle, first, due.length > 1));
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
for (const occurrence of due) {
|
|
389
|
+
dispatched.push(await dispatch(handle, occurrence, occurrence !== due[due.length - 1]));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return dispatched;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
start() {
|
|
398
|
+
if (timer !== undefined) return;
|
|
399
|
+
timer = setInterval(() => {
|
|
400
|
+
void tick().catch((error: unknown) => {
|
|
401
|
+
logger.error('jobs.scheduler.tick-failed', {
|
|
402
|
+
error: error instanceof Error ? error.message : String(error),
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
}, tickIntervalMs);
|
|
406
|
+
logger.info('jobs.scheduler.started', { tasks: (options.tasks ?? registeredTasks()).length });
|
|
407
|
+
},
|
|
408
|
+
async stop() {
|
|
409
|
+
if (timer !== undefined) clearInterval(timer);
|
|
410
|
+
timer = undefined;
|
|
411
|
+
if (isLeader) await leader.release();
|
|
412
|
+
isLeader = false;
|
|
413
|
+
},
|
|
414
|
+
tick,
|
|
415
|
+
nextRunFor,
|
|
416
|
+
};
|
|
417
|
+
}
|