@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/inspect.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Introspection for `/_x`, the CLI and MCP. Every function returns a plain JSON-serialisable
|
|
2
|
+
// object so `x jobs ... --json` and the MCP tool share one shape — an agent debugging a stuck
|
|
3
|
+
// queue reads exactly what the dashboard renders.
|
|
4
|
+
|
|
5
|
+
import type { JobDriver, JobFilter, JobRecord, QueueStats } from './driver';
|
|
6
|
+
import { JobsNotImplementedError } from './errors';
|
|
7
|
+
import { registeredJobs } from './job';
|
|
8
|
+
import { retrySchedule } from './retry';
|
|
9
|
+
import type { Scheduler } from './scheduler';
|
|
10
|
+
import { registeredTasks } from './scheduler';
|
|
11
|
+
import type { StepRecord } from './steps';
|
|
12
|
+
|
|
13
|
+
export interface QueueDepthReport {
|
|
14
|
+
readonly driver: string;
|
|
15
|
+
readonly queues: readonly QueueStats[];
|
|
16
|
+
readonly totals: {
|
|
17
|
+
readonly ready: number;
|
|
18
|
+
readonly delayed: number;
|
|
19
|
+
readonly running: number;
|
|
20
|
+
readonly suspended: number;
|
|
21
|
+
readonly dead: number;
|
|
22
|
+
};
|
|
23
|
+
/** Oldest claimable job across all queues, in ms. The autoscaling signal. */
|
|
24
|
+
readonly oldestReadyMs: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function inspectQueues(driver: JobDriver): Promise<QueueDepthReport> {
|
|
28
|
+
const queues = [...(await driver.stats())];
|
|
29
|
+
const totals = queues.reduce(
|
|
30
|
+
(acc, queue) => ({
|
|
31
|
+
ready: acc.ready + queue.ready,
|
|
32
|
+
delayed: acc.delayed + queue.delayed,
|
|
33
|
+
running: acc.running + queue.running,
|
|
34
|
+
suspended: acc.suspended + queue.suspended,
|
|
35
|
+
dead: acc.dead + queue.dead,
|
|
36
|
+
}),
|
|
37
|
+
{ ready: 0, delayed: 0, running: 0, suspended: 0, dead: 0 },
|
|
38
|
+
);
|
|
39
|
+
return {
|
|
40
|
+
driver: driver.name,
|
|
41
|
+
queues,
|
|
42
|
+
totals,
|
|
43
|
+
oldestReadyMs: queues.reduce((max, queue) => Math.max(max, queue.oldestReadyMs), 0),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface StepTrace {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly status: StepRecord['status'];
|
|
50
|
+
readonly startedAt: string;
|
|
51
|
+
readonly completedAt: string | null;
|
|
52
|
+
readonly wakeAt: string | null;
|
|
53
|
+
readonly durationMs: number | null;
|
|
54
|
+
readonly attempts: number;
|
|
55
|
+
readonly error: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface JobTrace {
|
|
59
|
+
readonly id: string;
|
|
60
|
+
readonly name: string;
|
|
61
|
+
readonly queue: string;
|
|
62
|
+
readonly state: JobRecord['state'];
|
|
63
|
+
readonly attempt: number;
|
|
64
|
+
readonly maxAttempts: number;
|
|
65
|
+
readonly idempotencyKey: string;
|
|
66
|
+
readonly runId: string;
|
|
67
|
+
readonly runAt: string;
|
|
68
|
+
readonly lastError: string | null;
|
|
69
|
+
readonly tenantId: string | null;
|
|
70
|
+
readonly steps: readonly StepTrace[];
|
|
71
|
+
/** Remaining retry delays in ms, jitter excluded. */
|
|
72
|
+
readonly retryDelaysMs: readonly number[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const iso = (ms: number | undefined): string | null =>
|
|
76
|
+
ms === undefined ? null : new Date(ms).toISOString();
|
|
77
|
+
|
|
78
|
+
function requireIntrospection(driver: JobDriver): NonNullable<JobDriver['introspect']> {
|
|
79
|
+
if (driver.introspect === undefined) {
|
|
80
|
+
throw new JobsNotImplementedError({
|
|
81
|
+
feature: `introspection for the "${driver.name}" jobs driver`,
|
|
82
|
+
fix: "set jobs: { driver: 'postgres' } in app.config.ts, then: x jobs ls --json",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return driver.introspect;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toStepTrace(record: StepRecord): StepTrace {
|
|
89
|
+
return {
|
|
90
|
+
name: record.name,
|
|
91
|
+
status: record.status,
|
|
92
|
+
startedAt: new Date(record.startedAt).toISOString(),
|
|
93
|
+
completedAt: iso(record.completedAt),
|
|
94
|
+
wakeAt: iso(record.wakeAt),
|
|
95
|
+
durationMs: record.completedAt === undefined ? null : record.completedAt - record.startedAt,
|
|
96
|
+
attempts: record.attempts,
|
|
97
|
+
error: record.error ?? null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function inspectJob(driver: JobDriver, jobId: string): Promise<JobTrace | undefined> {
|
|
102
|
+
const record = await requireIntrospection(driver).job(jobId);
|
|
103
|
+
if (record === undefined) return undefined;
|
|
104
|
+
const steps = await driver.steps.list(record.runId);
|
|
105
|
+
const handle = registeredJobs().find((candidate) => candidate.name === record.name);
|
|
106
|
+
return {
|
|
107
|
+
id: record.id,
|
|
108
|
+
name: record.name,
|
|
109
|
+
queue: record.queue,
|
|
110
|
+
state: record.state,
|
|
111
|
+
attempt: record.attempt,
|
|
112
|
+
maxAttempts: record.maxAttempts,
|
|
113
|
+
idempotencyKey: record.idempotencyKey,
|
|
114
|
+
runId: record.runId,
|
|
115
|
+
runAt: new Date(record.runAt).toISOString(),
|
|
116
|
+
lastError: record.lastError ?? null,
|
|
117
|
+
tenantId: record.tenantId ?? null,
|
|
118
|
+
steps: steps.map(toStepTrace),
|
|
119
|
+
retryDelaysMs: handle === undefined ? [] : [...retrySchedule(handle.retry)],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function inspectJobList(
|
|
124
|
+
driver: JobDriver,
|
|
125
|
+
filter?: JobFilter,
|
|
126
|
+
): Promise<readonly JobRecord[]> {
|
|
127
|
+
return requireIntrospection(driver).list(filter);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface DeadLetterEntry {
|
|
131
|
+
readonly id: string;
|
|
132
|
+
readonly name: string;
|
|
133
|
+
readonly queue: string;
|
|
134
|
+
readonly attempt: number;
|
|
135
|
+
readonly lastError: string | null;
|
|
136
|
+
readonly failedAt: string;
|
|
137
|
+
readonly retryCommand: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function inspectDeadLetters(
|
|
141
|
+
driver: JobDriver,
|
|
142
|
+
limit = 100,
|
|
143
|
+
): Promise<readonly DeadLetterEntry[]> {
|
|
144
|
+
const rows = await requireIntrospection(driver).deadLetters(limit);
|
|
145
|
+
return rows.map((record) => ({
|
|
146
|
+
id: record.id,
|
|
147
|
+
name: record.name,
|
|
148
|
+
queue: record.queue,
|
|
149
|
+
attempt: record.attempt,
|
|
150
|
+
lastError: record.lastError ?? null,
|
|
151
|
+
failedAt: new Date(record.updatedAt).toISOString(),
|
|
152
|
+
retryCommand: `x jobs retry ${record.id}`,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Re-queue a job, optionally dropping one step's persisted result so it re-executes while
|
|
158
|
+
* everything before it replays from storage. This is why steps are stored, not just logged.
|
|
159
|
+
*/
|
|
160
|
+
export async function retryFromStep(
|
|
161
|
+
driver: JobDriver,
|
|
162
|
+
jobId: string,
|
|
163
|
+
stepName?: string,
|
|
164
|
+
): Promise<JobTrace | undefined> {
|
|
165
|
+
await requireIntrospection(driver).requeue(
|
|
166
|
+
jobId,
|
|
167
|
+
stepName === undefined ? undefined : { fromStep: stepName },
|
|
168
|
+
);
|
|
169
|
+
return inspectJob(driver, jobId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface JobsManifest {
|
|
173
|
+
readonly jobs: readonly {
|
|
174
|
+
readonly name: string;
|
|
175
|
+
readonly queue: string;
|
|
176
|
+
readonly attempts: number;
|
|
177
|
+
readonly backoff: string;
|
|
178
|
+
readonly concurrency: number | null;
|
|
179
|
+
readonly timeoutMs: number | null;
|
|
180
|
+
readonly retryDelaysMs: readonly number[];
|
|
181
|
+
}[];
|
|
182
|
+
readonly tasks: readonly {
|
|
183
|
+
readonly name: string;
|
|
184
|
+
readonly cron: string;
|
|
185
|
+
readonly tz: string;
|
|
186
|
+
readonly catchUp: string;
|
|
187
|
+
readonly nextRun: string | null;
|
|
188
|
+
readonly enqueues: readonly string[];
|
|
189
|
+
}[];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Feeds `x.manifest.json` and the MCP `jobs.list` tool. Generated facts, never prose. */
|
|
193
|
+
export function inspectManifest(scheduler?: Scheduler): JobsManifest {
|
|
194
|
+
return {
|
|
195
|
+
jobs: registeredJobs().map((handle) => ({
|
|
196
|
+
name: handle.name,
|
|
197
|
+
queue: handle.queue,
|
|
198
|
+
attempts: handle.retry.attempts,
|
|
199
|
+
backoff: handle.retry.backoff ?? 'exponential',
|
|
200
|
+
concurrency: handle.concurrency ?? null,
|
|
201
|
+
timeoutMs: handle.timeoutMs ?? null,
|
|
202
|
+
retryDelaysMs: [...retrySchedule(handle.retry)],
|
|
203
|
+
})),
|
|
204
|
+
tasks: registeredTasks().map((handle) => ({
|
|
205
|
+
name: handle.name,
|
|
206
|
+
cron: handle.cron,
|
|
207
|
+
tz: handle.tz,
|
|
208
|
+
catchUp: handle.catchUp,
|
|
209
|
+
nextRun: scheduler === undefined ? null : scheduler.nextRunFor(handle).toISOString(),
|
|
210
|
+
enqueues: handle.entries().map(([job]) => job.name),
|
|
211
|
+
})),
|
|
212
|
+
};
|
|
213
|
+
}
|
package/src/job.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Ctx } from '@ultimat3/core';
|
|
2
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
3
|
+
import type { DurationInput } from './clock';
|
|
4
|
+
import type { RetryPolicy } from './retry';
|
|
5
|
+
import { type BackoffStrategy } from './retry';
|
|
6
|
+
import type { StepApi } from './steps';
|
|
7
|
+
export interface JobRunArgs<I> {
|
|
8
|
+
readonly input: I;
|
|
9
|
+
readonly step: StepApi;
|
|
10
|
+
readonly ctx: Ctx;
|
|
11
|
+
/** 1-based. Assume at-least-once: never branch on `attempt === 1` for correctness. */
|
|
12
|
+
readonly attempt: number;
|
|
13
|
+
readonly jobId: string;
|
|
14
|
+
readonly runId: string;
|
|
15
|
+
}
|
|
16
|
+
export interface JobDefinition<I> {
|
|
17
|
+
/** Assigned by `x manifest` from the export name; only set by hand in tests. */
|
|
18
|
+
readonly name?: string;
|
|
19
|
+
readonly input: StandardSchemaV1<unknown, I>;
|
|
20
|
+
/** REQUIRED. See the file header — this is the whole point. */
|
|
21
|
+
readonly idempotencyKey: (input: I) => string;
|
|
22
|
+
readonly retry: RetryPolicy;
|
|
23
|
+
readonly queue?: string;
|
|
24
|
+
/** Max in-flight runs of THIS job across the fleet. Omit for the queue-wide cap. */
|
|
25
|
+
readonly concurrency?: number;
|
|
26
|
+
readonly timeout?: DurationInput;
|
|
27
|
+
run(args: JobRunArgs<I>): Promise<unknown>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Methods (not function-typed properties) throughout, so `JobHandle<Specific>` is assignable
|
|
31
|
+
* to `AnyJobHandle` and heterogeneous handles can share a registry and a task's enqueue list.
|
|
32
|
+
*/
|
|
33
|
+
export interface JobHandle<I = unknown> {
|
|
34
|
+
readonly kind: 'job';
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly queue: string;
|
|
37
|
+
readonly retry: RetryPolicy;
|
|
38
|
+
readonly concurrency: number | undefined;
|
|
39
|
+
readonly timeoutMs: number | undefined;
|
|
40
|
+
readonly input: StandardSchemaV1<unknown, I>;
|
|
41
|
+
parse(raw: unknown): I;
|
|
42
|
+
idempotencyKeyFor(input: I): string;
|
|
43
|
+
run(args: JobRunArgs<I>): Promise<unknown>;
|
|
44
|
+
}
|
|
45
|
+
export type AnyJobHandle = JobHandle<unknown>;
|
|
46
|
+
export declare function job<I>(definition: JobDefinition<I>): JobHandle<I>;
|
|
47
|
+
/**
|
|
48
|
+
* Called by generated code with `{ onboardOrg, sendDigest }` so queue rows carry the export
|
|
49
|
+
* name rather than a positional id. Enqueue works either way; the name is for humans.
|
|
50
|
+
*/
|
|
51
|
+
export declare function nameJobs(record: Readonly<Record<string, AnyJobHandle>>): void;
|
|
52
|
+
export declare function getJob(name: string): AnyJobHandle | undefined;
|
|
53
|
+
export declare function registeredJobs(): readonly AnyJobHandle[];
|
|
54
|
+
export declare function resetJobs(): void;
|
|
55
|
+
/**
|
|
56
|
+
* Registered jobs as the manifest, the `/_x` jobs panel and the MCP dev server need them.
|
|
57
|
+
* Name-sorted and JSON-safe because `x.manifest.json` is committed and diffed — an
|
|
58
|
+
* iteration-order-dependent field would show up as a spurious change on every build.
|
|
59
|
+
* `steps` stays in declaration order: it is a sequence, not a set.
|
|
60
|
+
*/
|
|
61
|
+
export declare function describeJobs(): readonly {
|
|
62
|
+
readonly name: string;
|
|
63
|
+
readonly input: unknown;
|
|
64
|
+
readonly queue: string;
|
|
65
|
+
readonly retry: {
|
|
66
|
+
readonly attempts: number;
|
|
67
|
+
readonly backoff: BackoffStrategy;
|
|
68
|
+
};
|
|
69
|
+
readonly steps: readonly string[];
|
|
70
|
+
}[];
|
|
71
|
+
//# sourceMappingURL=job.d.ts.map
|
package/src/job.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"job.d.ts","sourceRoot":"","sources":["job.ts"],"names":[],"mappings":"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"}
|
package/src/job.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// The `job` primitive: durable background work. The shape is the contract's shape exactly.
|
|
2
|
+
//
|
|
3
|
+
// `idempotencyKey` is NON-OPTIONAL in the type. Queues deliver at least once — network
|
|
4
|
+
// partitions, visibility-timeout expiry and outbox relays all replay — so "did this already
|
|
5
|
+
// run?" is a question every job must answer. Making it optional means the answer is usually
|
|
6
|
+
// "nobody thought about it", and the bug (two charges, two welcome emails, two provisioned
|
|
7
|
+
// orgs) surfaces in production under load, never in a test. Requiring it by construction
|
|
8
|
+
// deletes that class of bug: there is no way to define a job that cannot be deduped.
|
|
9
|
+
import { assert } from '@ultimat3/core';
|
|
10
|
+
import { parse } from '@ultimat3/schema';
|
|
11
|
+
import { toMs } from './clock';
|
|
12
|
+
import { DEFAULT_QUEUE } from './driver';
|
|
13
|
+
import { IdempotencyRequiredError } from './errors';
|
|
14
|
+
import { DEFAULT_RETRY } from './retry';
|
|
15
|
+
const registry = new Map();
|
|
16
|
+
let anonymous = 0;
|
|
17
|
+
export function job(definition) {
|
|
18
|
+
anonymous += 1;
|
|
19
|
+
const name = definition.name ?? `anonymous-job-${anonymous}`;
|
|
20
|
+
// Runtime backstop for generated code and JS callers; TS already forbids omitting it.
|
|
21
|
+
if (typeof definition.idempotencyKey !== 'function') {
|
|
22
|
+
throw new IdempotencyRequiredError({ job: name });
|
|
23
|
+
}
|
|
24
|
+
assert(definition.retry.attempts >= 1, `job "${name}" needs retry.attempts >= 1, got ${String(definition.retry.attempts)}`, `set retry: { attempts: 1 } or higher on job("${name}") — 0 attempts means the job is never executed at all, not that it never retries`);
|
|
25
|
+
const handle = {
|
|
26
|
+
kind: 'job',
|
|
27
|
+
name,
|
|
28
|
+
queue: definition.queue ?? DEFAULT_QUEUE,
|
|
29
|
+
retry: { ...DEFAULT_RETRY, ...definition.retry },
|
|
30
|
+
concurrency: definition.concurrency,
|
|
31
|
+
timeoutMs: definition.timeout === undefined ? undefined : toMs(definition.timeout),
|
|
32
|
+
input: definition.input,
|
|
33
|
+
parse(raw) {
|
|
34
|
+
return parse(definition.input, raw);
|
|
35
|
+
},
|
|
36
|
+
idempotencyKeyFor(input) {
|
|
37
|
+
const key = definition.idempotencyKey(input);
|
|
38
|
+
assert(typeof key === 'string' && key.length > 0, `job "${name}" idempotencyKey returned an empty string`, `return a non-empty stable key from job("${name}").idempotencyKey — an empty key makes every enqueue look like a duplicate of every other`);
|
|
39
|
+
return key;
|
|
40
|
+
},
|
|
41
|
+
run(args) {
|
|
42
|
+
return definition.run(args);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
registry.set(name, handle);
|
|
46
|
+
return handle;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Called by generated code with `{ onboardOrg, sendDigest }` so queue rows carry the export
|
|
50
|
+
* name rather than a positional id. Enqueue works either way; the name is for humans.
|
|
51
|
+
*/
|
|
52
|
+
export function nameJobs(record) {
|
|
53
|
+
for (const [exportName, handle] of Object.entries(record)) {
|
|
54
|
+
if (handle.name === exportName)
|
|
55
|
+
continue;
|
|
56
|
+
registry.delete(handle.name);
|
|
57
|
+
// The caller holds a reference to this exact object, so rebind its name in place.
|
|
58
|
+
Object.defineProperty(handle, 'name', { value: exportName, configurable: true });
|
|
59
|
+
registry.set(exportName, handle);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function getJob(name) {
|
|
63
|
+
return registry.get(name);
|
|
64
|
+
}
|
|
65
|
+
export function registeredJobs() {
|
|
66
|
+
return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
67
|
+
}
|
|
68
|
+
export function resetJobs() {
|
|
69
|
+
registry.clear();
|
|
70
|
+
anonymous = 0;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Registered jobs as the manifest, the `/_x` jobs panel and the MCP dev server need them.
|
|
74
|
+
* Name-sorted and JSON-safe because `x.manifest.json` is committed and diffed — an
|
|
75
|
+
* iteration-order-dependent field would show up as a spurious change on every build.
|
|
76
|
+
* `steps` stays in declaration order: it is a sequence, not a set.
|
|
77
|
+
*/
|
|
78
|
+
export function describeJobs() {
|
|
79
|
+
return registeredJobs().map((handle) => ({
|
|
80
|
+
name: handle.name,
|
|
81
|
+
input: describeSchema(handle.input),
|
|
82
|
+
queue: handle.queue,
|
|
83
|
+
retry: {
|
|
84
|
+
attempts: handle.retry.attempts,
|
|
85
|
+
backoff: handle.retry.backoff ?? DEFAULT_RETRY.backoff,
|
|
86
|
+
},
|
|
87
|
+
// Empty by design: step names are chosen inside `run()` at execution time, so they are
|
|
88
|
+
// not statically knowable. `inspect(name)` reports the steps an actual run recorded.
|
|
89
|
+
steps: [],
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
/** Best-effort JSON view of a Standard Schema; the vendor decides how much it exposes. */
|
|
93
|
+
function describeSchema(schema) {
|
|
94
|
+
if (schema === null || typeof schema !== 'object')
|
|
95
|
+
return null;
|
|
96
|
+
const vendor = schema['~standard'];
|
|
97
|
+
return { vendor: vendor?.vendor ?? 'unknown' };
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=job.js.map
|
package/src/job.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"job.js","sourceRoot":"","sources":["job.ts"],"names":[],"mappings":"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"}
|
package/src/job.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// The `job` primitive: durable background work. The shape is the contract's shape exactly.
|
|
2
|
+
//
|
|
3
|
+
// `idempotencyKey` is NON-OPTIONAL in the type. Queues deliver at least once — network
|
|
4
|
+
// partitions, visibility-timeout expiry and outbox relays all replay — so "did this already
|
|
5
|
+
// run?" is a question every job must answer. Making it optional means the answer is usually
|
|
6
|
+
// "nobody thought about it", and the bug (two charges, two welcome emails, two provisioned
|
|
7
|
+
// orgs) surfaces in production under load, never in a test. Requiring it by construction
|
|
8
|
+
// deletes that class of bug: there is no way to define a job that cannot be deduped.
|
|
9
|
+
|
|
10
|
+
import type { Ctx } from '@ultimat3/core';
|
|
11
|
+
import { assert } from '@ultimat3/core';
|
|
12
|
+
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
13
|
+
import { parse } from '@ultimat3/schema';
|
|
14
|
+
import type { DurationInput } from './clock';
|
|
15
|
+
import { toMs } from './clock';
|
|
16
|
+
import type { JobDescriptor } from './describe';
|
|
17
|
+
import { describeJob } from './describe';
|
|
18
|
+
import type { EnqueueResult } from './driver';
|
|
19
|
+
import { DEFAULT_QUEUE } from './driver';
|
|
20
|
+
import { IdempotencyRequiredError, JobNameTakenError } from './errors';
|
|
21
|
+
import { NO_TENANT, tenantKeyFrom } from './limits';
|
|
22
|
+
import type { EnqueueOptions } from './outbox';
|
|
23
|
+
import { jobsFacade } from './outbox';
|
|
24
|
+
import type { RetryPolicy } from './retry';
|
|
25
|
+
import { DEFAULT_RETRY } from './retry';
|
|
26
|
+
import type { StepApi } from './steps';
|
|
27
|
+
|
|
28
|
+
export interface JobRunArgs<I> {
|
|
29
|
+
readonly input: I;
|
|
30
|
+
readonly step: StepApi;
|
|
31
|
+
readonly ctx: Ctx;
|
|
32
|
+
/** 1-based. Assume at-least-once: never branch on `attempt === 1` for correctness. */
|
|
33
|
+
readonly attempt: number;
|
|
34
|
+
readonly jobId: string;
|
|
35
|
+
readonly runId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface JobDefinition<I> {
|
|
39
|
+
/**
|
|
40
|
+
* Omit it: `defineApi({ jobs })` assigns the export name. Set it only to pin a queue key the
|
|
41
|
+
* export name must not decide — a framework job like `mail.send`, or a name rows already carry.
|
|
42
|
+
*/
|
|
43
|
+
readonly name?: string;
|
|
44
|
+
readonly input: StandardSchemaV1<unknown, I>;
|
|
45
|
+
/** REQUIRED. See the file header — this is the whole point. */
|
|
46
|
+
readonly idempotencyKey: (input: I) => string;
|
|
47
|
+
readonly retry: RetryPolicy;
|
|
48
|
+
readonly queue?: string;
|
|
49
|
+
/** Max in-flight runs of THIS job across the fleet. Omit for the queue-wide cap. */
|
|
50
|
+
readonly concurrency?: number;
|
|
51
|
+
readonly timeout?: DurationInput;
|
|
52
|
+
run(args: JobRunArgs<I>): Promise<unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whoever the enqueue is for. Structural, exactly like `tenantKeyFrom` in `limits.ts`: the
|
|
57
|
+
* queue needs the actor's org and nothing else, so this package never imports the auth types.
|
|
58
|
+
*/
|
|
59
|
+
export interface JobActor {
|
|
60
|
+
readonly orgId?: string | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Methods (not function-typed properties) throughout, so `JobHandle<Specific>` is assignable
|
|
65
|
+
* to `AnyJobHandle` and heterogeneous handles can share a registry and a task's enqueue list.
|
|
66
|
+
*/
|
|
67
|
+
export interface JobHandle<I = unknown> {
|
|
68
|
+
readonly kind: 'job';
|
|
69
|
+
readonly name: string;
|
|
70
|
+
readonly queue: string;
|
|
71
|
+
readonly retry: RetryPolicy;
|
|
72
|
+
readonly concurrency: number | undefined;
|
|
73
|
+
readonly timeoutMs: number | undefined;
|
|
74
|
+
readonly input: StandardSchemaV1<unknown, I>;
|
|
75
|
+
parse(raw: unknown): I;
|
|
76
|
+
idempotencyKeyFor(input: I): string;
|
|
77
|
+
run(args: JobRunArgs<I>): Promise<unknown>;
|
|
78
|
+
/**
|
|
79
|
+
* Put this job on the queue. Joins the caller's transaction when the app installed the
|
|
80
|
+
* outbox — same call site in a request handler, a job, a script or a test.
|
|
81
|
+
*/
|
|
82
|
+
enqueue(input: I, options?: EnqueueOptions): Promise<EnqueueResult>;
|
|
83
|
+
/**
|
|
84
|
+
* Enqueue on behalf of `actor`: fills `tenantId` from the actor's org so per-tenant limits
|
|
85
|
+
* apply. It queues rather than running inline because a job's execution surface IS the
|
|
86
|
+
* queue — an inline run would be a second execution path alongside `executeJob`.
|
|
87
|
+
*/
|
|
88
|
+
as(actor: JobActor | null, input: I, options?: EnqueueOptions): Promise<EnqueueResult>;
|
|
89
|
+
describe(): JobDescriptor;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type AnyJobHandle = JobHandle<unknown>;
|
|
93
|
+
|
|
94
|
+
const registry = new Map<string, AnyJobHandle>();
|
|
95
|
+
let anonymous = 0;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Proof `job()` built this handle, plus whether its definition named itself and which export
|
|
99
|
+
* name registration stamped on it. Private, and deliberately not a registry lookup: the registry
|
|
100
|
+
* is what `registerJob` rewrites, so a guard that read it would reject exactly the handles
|
|
101
|
+
* registration exists to rename.
|
|
102
|
+
*/
|
|
103
|
+
interface JobOrigin {
|
|
104
|
+
readonly declaredName: boolean;
|
|
105
|
+
/** The export name already stamped, once one has been. `undefined` while still provisional. */
|
|
106
|
+
readonly exportName?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const origin = new WeakMap<object, JobOrigin>();
|
|
110
|
+
|
|
111
|
+
export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
112
|
+
anonymous += 1;
|
|
113
|
+
const name = definition.name ?? `anonymous-job-${anonymous}`;
|
|
114
|
+
|
|
115
|
+
// Runtime backstop for generated code and JS callers; TS already forbids omitting it.
|
|
116
|
+
if (typeof definition.idempotencyKey !== 'function') {
|
|
117
|
+
throw new IdempotencyRequiredError({ job: name });
|
|
118
|
+
}
|
|
119
|
+
assert(
|
|
120
|
+
definition.retry.attempts >= 1,
|
|
121
|
+
`job "${name}" needs retry.attempts >= 1, got ${String(definition.retry.attempts)}`,
|
|
122
|
+
`set retry: { attempts: 1 } or higher on job("${name}") — 0 attempts means the job is never executed at all, not that it never retries`,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const handle: JobHandle<I> = {
|
|
126
|
+
kind: 'job',
|
|
127
|
+
name,
|
|
128
|
+
queue: definition.queue ?? DEFAULT_QUEUE,
|
|
129
|
+
retry: { ...DEFAULT_RETRY, ...definition.retry },
|
|
130
|
+
concurrency: definition.concurrency,
|
|
131
|
+
timeoutMs: definition.timeout === undefined ? undefined : toMs(definition.timeout),
|
|
132
|
+
input: definition.input,
|
|
133
|
+
parse(raw: unknown): I {
|
|
134
|
+
return parse(definition.input, raw) as I;
|
|
135
|
+
},
|
|
136
|
+
idempotencyKeyFor(input: I): string {
|
|
137
|
+
const key = definition.idempotencyKey(input);
|
|
138
|
+
assert(
|
|
139
|
+
typeof key === 'string' && key.length > 0,
|
|
140
|
+
`job "${name}" idempotencyKey returned an empty string`,
|
|
141
|
+
`return a non-empty stable key from job("${name}").idempotencyKey — an empty key makes every enqueue look like a duplicate of every other`,
|
|
142
|
+
);
|
|
143
|
+
return key;
|
|
144
|
+
},
|
|
145
|
+
run(args: JobRunArgs<I>): Promise<unknown> {
|
|
146
|
+
return definition.run(args);
|
|
147
|
+
},
|
|
148
|
+
enqueue(input: I, options?: EnqueueOptions): Promise<EnqueueResult> {
|
|
149
|
+
return jobsFacade().enqueue(handle, input, options);
|
|
150
|
+
},
|
|
151
|
+
as(actor: JobActor | null, input: I, options: EnqueueOptions = {}): Promise<EnqueueResult> {
|
|
152
|
+
const tenantId = options.tenantId ?? tenantFor(actor);
|
|
153
|
+
// `NO_TENANT` is the limiter's own bucket for an absent tenant, so leaving the column
|
|
154
|
+
// empty is the same limit and one less fake org id on the row.
|
|
155
|
+
return handle.enqueue(input, {
|
|
156
|
+
...options,
|
|
157
|
+
...(tenantId === NO_TENANT ? {} : { tenantId }),
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
// Reads `handle`, never the captured `name`: `nameJobs()` rebinds the property in place.
|
|
161
|
+
describe(): JobDescriptor {
|
|
162
|
+
return describeJob(handle);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
origin.set(handle, { declaredName: definition.name !== undefined });
|
|
167
|
+
// Refused here, not at `registerJob`: a second `job({ name: 'send-digest' })` would otherwise
|
|
168
|
+
// overwrite the seated handle and silently take over delivery of every row already queued
|
|
169
|
+
// under that key. The anonymous names cannot collide — the counter above only ever grows.
|
|
170
|
+
if (registry.has(name)) throw new JobNameTakenError({ kind: 'job', name });
|
|
171
|
+
registry.set(name, handle as AnyJobHandle);
|
|
172
|
+
return handle;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `orgId` is optional-with-undefined on an actor and optional-only on `tenantKeyFrom`. */
|
|
176
|
+
function tenantFor(actor: JobActor | null): string {
|
|
177
|
+
const orgId = actor?.orgId;
|
|
178
|
+
return tenantKeyFrom(orgId === undefined ? undefined : { orgId });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Structural, not nominal: an object counts as a job handle only if `job()` built it, because
|
|
183
|
+
* only then does a retry policy, an idempotency key and a queue exist behind it. A look-alike
|
|
184
|
+
* carrying `kind: 'job'` never reaches the registry, the queue or the manifest.
|
|
185
|
+
*/
|
|
186
|
+
export function isJobHandle(value: unknown): value is AnyJobHandle {
|
|
187
|
+
return (
|
|
188
|
+
typeof value === 'object' &&
|
|
189
|
+
value !== null &&
|
|
190
|
+
(value as { kind?: unknown }).kind === 'job' &&
|
|
191
|
+
origin.has(value)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Register `target` under `name`, stamping the name onto the handle the module exported rather
|
|
197
|
+
* than seating a differently-named copy: `import { notifySubscribers }` is the handle
|
|
198
|
+
* `enqueue()` routes through after boot, with nothing to remember.
|
|
199
|
+
*
|
|
200
|
+
* A definition that supplied its own `name` keeps it. A job name is the durable queue key that
|
|
201
|
+
* queued, retrying and dead-lettered rows already carry, so renaming an export must never move
|
|
202
|
+
* where they are delivered.
|
|
203
|
+
*/
|
|
204
|
+
export function registerJob<H extends AnyJobHandle>(name: string, target: H): H {
|
|
205
|
+
const source = origin.get(target);
|
|
206
|
+
const key = source?.declaredName === true ? target.name : name;
|
|
207
|
+
const seated = registry.get(key);
|
|
208
|
+
// Re-registering the SAME handle under the SAME name is one registration seen twice, not a
|
|
209
|
+
// collision: `defineApi` hands over a feature module at boot and the framework's module scan
|
|
210
|
+
// reaches the same declaration file directly. Only a DIFFERENT job under a taken name is the
|
|
211
|
+
// ambiguity `X_JOB_DUPLICATE` exists to refuse.
|
|
212
|
+
if (seated !== undefined) {
|
|
213
|
+
if (seated !== (target as AnyJobHandle))
|
|
214
|
+
throw new JobNameTakenError({ kind: 'job', name: key });
|
|
215
|
+
return target;
|
|
216
|
+
}
|
|
217
|
+
// One handle, two export names — `export { notify as first, notify as second }`. The rebind
|
|
218
|
+
// below is in place, so the second alias would move the durable queue key to whichever name
|
|
219
|
+
// the module happened to export last, and queued rows would stop being delivered.
|
|
220
|
+
if (source?.exportName !== undefined && source.exportName !== key)
|
|
221
|
+
throw new JobNameTakenError({ kind: 'job', name: key });
|
|
222
|
+
registry.delete(target.name);
|
|
223
|
+
// The caller holds a reference to this exact object, so rebind its name in place.
|
|
224
|
+
Object.defineProperty(target, 'name', { value: key, configurable: true });
|
|
225
|
+
if (source !== undefined) origin.set(target, { ...source, exportName: key });
|
|
226
|
+
registry.set(key, target as AnyJobHandle);
|
|
227
|
+
return target;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Called by generated code with `{ onboardOrg, sendDigest }` so queue rows carry the export
|
|
232
|
+
* name rather than a positional id. `registerJobs(module)` is the call app code makes; this is
|
|
233
|
+
* the same rules over an explicit record — a declared `name` wins, and a second handle under a
|
|
234
|
+
* taken name is `X_JOB_DUPLICATE`.
|
|
235
|
+
*/
|
|
236
|
+
export function nameJobs(record: Readonly<Record<string, AnyJobHandle>>): void {
|
|
237
|
+
for (const [exportName, handle] of Object.entries(record)) registerJob(exportName, handle);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function getJob(name: string): AnyJobHandle | undefined {
|
|
241
|
+
return registry.get(name);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function registeredJobs(): readonly AnyJobHandle[] {
|
|
245
|
+
return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function resetJobs(): void {
|
|
249
|
+
registry.clear();
|
|
250
|
+
anonymous = 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Registered jobs as the manifest, the `/_x` jobs panel and the MCP dev server need them.
|
|
255
|
+
* Name-sorted because `x.manifest.json` is committed and diffed — an iteration-order-dependent
|
|
256
|
+
* list would show up as a spurious change on every build. Each row is the handle's own
|
|
257
|
+
* `describe()`, so the list and the single job can never disagree.
|
|
258
|
+
*/
|
|
259
|
+
export function describeJobs(): readonly JobDescriptor[] {
|
|
260
|
+
return registeredJobs().map((handle) => handle.describe());
|
|
261
|
+
}
|