@ultimat3/jobs 29.4.0 → 30.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.
Files changed (109) hide show
  1. package/CLAUDE.md +68 -89
  2. package/README.md +304 -10
  3. package/package.json +18 -6
  4. package/src/backfill-definition.ts +184 -0
  5. package/src/backfill-errors.ts +18 -2
  6. package/src/backfill-pass.ts +43 -40
  7. package/src/backfill-pending.ts +40 -17
  8. package/src/backfill-reader.ts +94 -0
  9. package/src/backfill-registry.ts +5 -0
  10. package/src/backfill-scope.ts +1 -2
  11. package/src/backfill-source.ts +113 -0
  12. package/src/backfill.ts +54 -135
  13. package/src/consumer-errors.ts +42 -0
  14. package/src/consumer-guard.ts +19 -0
  15. package/src/consumer-loop.ts +171 -0
  16. package/src/consumer-registry.ts +103 -0
  17. package/src/consumer-types.ts +81 -0
  18. package/src/consumer.ts +147 -0
  19. package/src/driver-memory-operator.ts +25 -2
  20. package/src/driver-memory.ts +35 -1
  21. package/src/driver-pg-claim-sql.ts +128 -0
  22. package/src/driver-pg-ddl.ts +11 -2
  23. package/src/driver-pg-demand-sql.ts +27 -0
  24. package/src/driver-pg-enqueue-many.ts +153 -0
  25. package/src/driver-pg-event-wake-sql.ts +51 -0
  26. package/src/driver-pg-operator-sql.ts +34 -6
  27. package/src/driver-pg-operator.ts +36 -2
  28. package/src/driver-pg-outbox-sql.ts +34 -0
  29. package/src/driver-pg-sql.ts +24 -91
  30. package/src/driver-pg.ts +17 -1
  31. package/src/driver-redis-refusal.ts +25 -0
  32. package/src/driver-redis-scripts.ts +79 -30
  33. package/src/driver-redis.ts +70 -42
  34. package/src/driver.ts +38 -1
  35. package/src/enqueue-record.ts +137 -0
  36. package/src/error-codes.ts +233 -0
  37. package/src/errors-concurrency.ts +2 -2
  38. package/src/errors-declaration.ts +15 -2
  39. package/src/errors-operator.ts +1 -1
  40. package/src/errors-requeue.ts +1 -1
  41. package/src/errors-saga.ts +87 -0
  42. package/src/errors-steps.ts +35 -0
  43. package/src/errors-tenant.ts +1 -1
  44. package/src/errors.ts +25 -165
  45. package/src/events-pg.ts +20 -8
  46. package/src/events.ts +14 -3
  47. package/src/execute-ctx.ts +51 -0
  48. package/src/execute-unwind.ts +191 -0
  49. package/src/execute.ts +50 -51
  50. package/src/export-errors.ts +1 -1
  51. package/src/failure-row.ts +43 -0
  52. package/src/import-errors.ts +102 -0
  53. package/src/import-format.ts +232 -0
  54. package/src/import-pass.ts +255 -0
  55. package/src/import-source.ts +49 -0
  56. package/src/import.ts +175 -0
  57. package/src/index.ts +163 -7
  58. package/src/inspect-operator.ts +24 -3
  59. package/src/inspect.ts +17 -4
  60. package/src/introspection.ts +24 -1
  61. package/src/job-enqueue-many.ts +108 -0
  62. package/src/job-types.ts +229 -0
  63. package/src/job.ts +19 -208
  64. package/src/metrics.ts +10 -0
  65. package/src/outbox-pg.ts +35 -0
  66. package/src/outbox-relay.ts +58 -19
  67. package/src/outbox.ts +65 -73
  68. package/src/purge-entities.ts +92 -0
  69. package/src/purge.ts +10 -2
  70. package/src/queue-wake.ts +1 -1
  71. package/src/refresh-view.ts +76 -0
  72. package/src/retry-classification.ts +27 -8
  73. package/src/retry.ts +26 -0
  74. package/src/rollup-errors.ts +27 -0
  75. package/src/rollup.ts +190 -0
  76. package/src/run-deadline.ts +11 -7
  77. package/src/scheduler.ts +2 -1
  78. package/src/settled.ts +56 -15
  79. package/src/step-resolve.ts +37 -0
  80. package/src/steps-api.ts +111 -0
  81. package/src/steps-core.ts +26 -0
  82. package/src/steps-output.ts +142 -0
  83. package/src/steps-store.ts +68 -0
  84. package/src/steps-suspension.ts +51 -0
  85. package/src/steps-timeout.ts +10 -5
  86. package/src/steps-unwind.ts +94 -0
  87. package/src/steps-wait.ts +163 -0
  88. package/src/steps.ts +174 -210
  89. package/src/subject-erase-pass.ts +134 -0
  90. package/src/subject-errors.ts +48 -0
  91. package/src/subject-export-pass.ts +228 -0
  92. package/src/subject-jobs.ts +121 -0
  93. package/src/subject-plan.ts +287 -0
  94. package/src/subject-select.ts +106 -0
  95. package/src/subject.ts +119 -0
  96. package/src/tenant.ts +69 -1
  97. package/src/type-pins.ts +90 -0
  98. package/src/webhook-errors.ts +1 -1
  99. package/src/webhook-receiver-errors.ts +59 -0
  100. package/src/webhook-receiver-route.ts +127 -0
  101. package/src/webhook-receiver-types.ts +97 -0
  102. package/src/webhook-receiver.ts +132 -0
  103. package/src/webhook-target.ts +20 -43
  104. package/src/webhook.ts +7 -4
  105. package/src/worker-queue-demand.ts +57 -0
  106. package/src/worker-queue-depth.ts +15 -3
  107. package/src/worker-registry.ts +4 -1
  108. package/src/worker-types.ts +2 -0
  109. package/src/worker.ts +2 -2
@@ -0,0 +1,108 @@
1
+ // `handle.enqueueMany(inputs)`: many jobs of one kind in one statement per chunk, on the same two
2
+ // paths `enqueue` takes — staged in the caller's transaction, or straight to the driver. A loop of
3
+ // `enqueue` is the N+1 write the detector refuses (`X_N_PLUS_ONE_WRITE`): 1,000 rows were 1,000
4
+ // statements and a second on Postgres, against one statement and 18 ms (measured 2026-11-02).
5
+
6
+ import { uuidV7 } from '@ultimat3/core';
7
+ import { refuseAbandoned } from '@ultimat3/db';
8
+ import type { Tx } from '@ultimat3/entity';
9
+ import { nowMs } from './clock';
10
+ import type { EnqueueResult } from './driver';
11
+ import { ambientTraceparent, directRequest, stagedRecord } from './enqueue-record';
12
+ import { signalEnqueued, signalStaged } from './enqueue-signal';
13
+ import { JobEnqueueBatchTooLargeError, OutboxNoTxError } from './errors';
14
+ import type { JobHandle } from './job';
15
+ import type { EnqueueOptions, OutboxDeps, OutboxRecord, OutboxStore } from './outbox';
16
+ import { refuseEnqueueTenant } from './tenant';
17
+
18
+ /**
19
+ * The most inputs one call takes. A list longer than this is a table, and a table is paged by a
20
+ * `backfill()` — never held whole in memory and staged inside one request's transaction.
21
+ */
22
+ export const MAX_ENQUEUE_BATCH = 10_000;
23
+
24
+ /** Rows per statement: bounds one bind's size, and so the time one statement holds its locks. */
25
+ export const ENQUEUE_CHUNK = 1_000;
26
+
27
+ /**
28
+ * One set of options for the whole batch, and no `runId`: one id names one run. A batch whose
29
+ * inputs need options of their own is a loop of `enqueue`.
30
+ */
31
+ export type EnqueueManyOptions = Omit<EnqueueOptions, 'runId'>;
32
+
33
+ function chunked<T>(rows: readonly T[]): readonly (readonly T[])[] {
34
+ const out: T[][] = [];
35
+ for (let at = 0; at < rows.length; at += ENQUEUE_CHUNK) {
36
+ out.push(rows.slice(at, at + ENQUEUE_CHUNK));
37
+ }
38
+ return out;
39
+ }
40
+
41
+ /** Stage `records` in `tx` in one statement when the store can, else one by one, in order. */
42
+ export async function stageAll(
43
+ store: OutboxStore,
44
+ tx: Tx,
45
+ records: readonly OutboxRecord[],
46
+ ): Promise<void> {
47
+ if (store.stageMany !== undefined) return store.stageMany(tx, records);
48
+ for (const record of records) await store.stage(tx, record);
49
+ }
50
+
51
+ /**
52
+ * The facade's `enqueueMany`. Every refusal is decided before the first write: the abandoned
53
+ * scope, the size, and each input's idempotency and concurrency keys — an empty key in input 900
54
+ * refuses the call, not the rows after 899. Answers in input order; two inputs sharing a key are
55
+ * one job, the second answered `deduped: true` on the direct path.
56
+ *
57
+ * ATOMIC PER CHUNK on the direct path: a failure past a chunk boundary leaves the earlier chunks
58
+ * enqueued. Inside `withTransaction` the batch is staged, and commits or vanishes whole.
59
+ */
60
+ export async function enqueueMany<I>(
61
+ deps: OutboxDeps,
62
+ tx: Tx | undefined,
63
+ handle: JobHandle<I>,
64
+ inputs: readonly I[],
65
+ options: EnqueueManyOptions = {},
66
+ ): Promise<readonly EnqueueResult[]> {
67
+ refuseAbandoned();
68
+ if (inputs.length > MAX_ENQUEUE_BATCH) {
69
+ throw new JobEnqueueBatchTooLargeError({
70
+ job: handle.name,
71
+ count: inputs.length,
72
+ max: MAX_ENQUEUE_BATCH,
73
+ });
74
+ }
75
+ if (inputs.length === 0) return [];
76
+ const at = nowMs(deps.clock);
77
+ const trace = options.traceparent ?? ambientTraceparent();
78
+ // Allocated here on both paths, so the answer needs no read per row.
79
+ const rows = inputs.map((input) => {
80
+ handle.concurrencyKeyFor(input);
81
+ refuseEnqueueTenant(handle.name, handle.tenantFor(input), options.tenantId);
82
+ const idempotencyKey = handle.idempotencyKeyFor(input);
83
+ return { input, facts: { id: uuidV7(), runId: uuidV7(), idempotencyKey, at, trace } };
84
+ });
85
+
86
+ if (tx === undefined) {
87
+ if (deps.mode === 'required' && options.outbox !== false) {
88
+ throw new OutboxNoTxError({ job: handle.name });
89
+ }
90
+ const requests = rows.map((row) => directRequest(handle, row.input, options, row.facts));
91
+ const results: EnqueueResult[] = [];
92
+ for (const chunk of chunked(requests)) {
93
+ // Before EVERY chunk: a direct write is one no transaction guard sees, and a scope abandoned
94
+ // while chunk 3 was in flight must not start chunks 4 to 10.
95
+ refuseAbandoned();
96
+ results.push(...(await deps.driver.enqueueMany(chunk)));
97
+ }
98
+ signalEnqueued(options.queue ?? handle.queue);
99
+ return results;
100
+ }
101
+
102
+ const records = rows.map((row) => stagedRecord(handle, row.input, options, row.facts));
103
+ for (const chunk of chunked(records)) await stageAll(deps.store, tx, chunk);
104
+ signalStaged();
105
+ // As `enqueue` answers a staged row: the ids are the jobs' to be, and only the publish knows
106
+ // whether a LIVE job already held the key.
107
+ return records.map((record) => ({ id: record.id, runId: record.runId, deduped: false }));
108
+ }
@@ -0,0 +1,229 @@
1
+ // The `job` primitive's declared shapes: what a definition holds, what a run receives, and the
2
+ // handle `job()` returns. Types only, so the factory in `job.ts` stays one responsibility.
3
+
4
+ import type { Ctx } from '@ultimat3/core';
5
+ import type { StandardSchemaV1 } from '@ultimat3/schema';
6
+ import type { DurationInput } from './clock';
7
+ import type { JobConcurrency, WhenBusy } from './concurrency';
8
+ import type { JobDescriptor } from './describe';
9
+ import type { EnqueueResult } from './driver';
10
+ import type { EnqueueManyOptions } from './job-enqueue-many';
11
+ import type { EnqueueOptions } from './outbox';
12
+ import type { ProgressFn } from './progress';
13
+ import type { RetryPolicy } from './retry';
14
+ import type { JobSettled } from './settled';
15
+ import type { StepApi } from './steps';
16
+ import type { JobTenant } from './tenant';
17
+
18
+ export interface JobRunArgs<I> {
19
+ readonly input: I;
20
+ readonly step: StepApi;
21
+ /**
22
+ * `ctx.signal` aborts when this attempt's `timeout` passes — the same seam an action reads, so
23
+ * `throwIfAborted(ctx)` and `fetch(url, { signal: ctx.signal })` work here unchanged. Past it
24
+ * the run belongs to whoever claims it next and `step.run` refuses to write, so a loop that
25
+ * never checks it is a body running beside its own retry.
26
+ */
27
+ readonly ctx: Ctx;
28
+ /** 1-based. Assume at-least-once: never branch on `attempt === 1` for correctness. */
29
+ readonly attempt: number;
30
+ /**
31
+ * True when a failure of THIS attempt is not retried for want of attempts — the same boolean
32
+ * the runner dead-letters on (`isFinalAttempt` in `retry.ts`), so a body never re-derives it
33
+ * from `retry.attempts`. The place to release what a retry would have reused. REQUIRED: a test
34
+ * that drives a body by hand states it, `isFinalAttempt(handle.retry, attempt)` being the
35
+ * runner's own answer.
36
+ *
37
+ * Not "this body runs once more at most": a `terminal` error stops an earlier attempt, and an
38
+ * attempt handed back UNCOUNTED — a `step.sleep`, a drain — is presented again under the same
39
+ * number, so the last attempt of a job that sleeps sees `true` on every resume.
40
+ */
41
+ readonly finalAttempt: boolean;
42
+ /**
43
+ * `progress(done, total, note?)` — how far this run has got, shown by `x jobs show` and the
44
+ * dashboard. Call it as often as the loop turns: it is written to the row at most once a
45
+ * second, and the last call before the run settles is always written.
46
+ */
47
+ readonly progress: ProgressFn;
48
+ readonly jobId: string;
49
+ readonly runId: string;
50
+ }
51
+
52
+ export interface JobDefinition<I, R = unknown> {
53
+ /**
54
+ * Omit it: `defineApi({ jobs })` assigns the export name. Set it only to pin a queue key the
55
+ * export name must not decide — a framework job like `mail.send`, or a name rows already carry.
56
+ */
57
+ readonly name?: string;
58
+ readonly input: StandardSchemaV1<unknown, I>;
59
+ /** REQUIRED. See the file header — this is the whole point. */
60
+ readonly idempotencyKey: (input: I) => string;
61
+ /**
62
+ * REQUIRED, and the org this job's body runs under. `tenant: (input) => input.orgId` derives it
63
+ * from the payload; `tenant: 'none'` says this job belongs to no tenant, and then every
64
+ * tenant-scoped read inside it fails closed with `X_TENANCY_ACTOR_ORG_REQUIRED`.
65
+ *
66
+ * There is no default, because both candidates are wrong. Until this field existed the worker ran
67
+ * a body with no ambient context at all, so `@ultimat3/entity`'s tenant guard — which derives
68
+ * from `tryUseContext()` and not from the ctx it is handed — read no actor, added no predicate
69
+ * and accepted a caller-named `orgId` unchecked: the same write refused over HTTP as
70
+ * `X_TENANCY_ACTOR_MISMATCH` was ACCEPTED through the job surface. A boot-supplied service actor
71
+ * would close that with ONE identity for every job, which is a cross-tenant read waiting for the
72
+ * first job that takes an org id in its input. So the job declares it, per job, from its own
73
+ * payload — the value an author already had to pass anyway.
74
+ */
75
+ readonly tenant: JobTenant<I>;
76
+ readonly retry: RetryPolicy;
77
+ readonly queue?: string;
78
+ /**
79
+ * Max in-flight runs of THIS job across the fleet. Omit for the queue-wide cap.
80
+ *
81
+ * A number is one cap for the job. `{ key, limit, whenBusy }` is that cap PER KEY — "one run
82
+ * per account" — and says what a claim over it does: `'wait'` (default) leaves the run queued,
83
+ * `'fail'` settles it `failed` with `X_JOB_KEY_BUSY` without running its body (`concurrency.ts`).
84
+ *
85
+ * Enforced by `JobDriver.leases` — a row every replica can see — and NOT by `limits.ts`, which
86
+ * counts one process's heap. A driver with no lease store cannot hold this cap, so
87
+ * `jobWorker().start()` refuses to boot rather than let it pass silently
88
+ * (`X_JOB_CONCURRENCY_UNENFORCEABLE`): this field was declared, documented and in the manifest
89
+ * while nothing read it, which is exactly what axiom 3 exists to prevent.
90
+ */
91
+ readonly concurrency?: JobConcurrency<I>;
92
+ readonly timeout?: DurationInput;
93
+ /**
94
+ * Ceiling for ONE `step.run`, where `timeout` is the ceiling for the whole attempt. Folded into
95
+ * the signal the step body is handed, so a body reads one signal and sees whichever deadline
96
+ * lands first — and it ABORTS before it rejects, because the attempt that replaces this one is
97
+ * claimable the moment the nack lands.
98
+ *
99
+ * Declared here and nowhere else: the runner has implemented this ceiling since 1.0 and no
100
+ * declaration could ask for it, which is a documented guarantee that does nothing.
101
+ */
102
+ readonly stepTimeout?: DurationInput;
103
+ /**
104
+ * Ceiling for `onSettled`, every try included. Default `ON_SETTLED_TIMEOUT_MS`. It aborts the
105
+ * hook's `ctx.signal` with `X_JOB_TIMEOUT`, then the worker moves on, so a hook on a dead host
106
+ * cannot hold a slot for good.
107
+ */
108
+ readonly onSettledTimeout?: DurationInput;
109
+ /**
110
+ * How long a `step.waitForEvent` parks between polls. Default 30s — 15 min when the event bus
111
+ * wakes its waiters (`postgresEventBus` over the queue's database, as boot installs it), where
112
+ * the poll is only the backstop. Lower it for a wait a user is watching on a bus that does not
113
+ * wake; the step suspends for exactly this long each time, never a busy loop.
114
+ */
115
+ readonly eventPoll?: DurationInput;
116
+ /**
117
+ * This job undoes its steps when it fails FOR GOOD: `step.run(name, fn, { compensate })` is
118
+ * legal, and the row is enqueued with `UNWIND_MARGIN` attempts past `retry.attempts`, so a
119
+ * crash on the final attempt still unwinds. Default false: no path of the job changes.
120
+ */
121
+ readonly compensates?: boolean;
122
+ run(args: JobRunArgs<I>): Promise<R>;
123
+ /**
124
+ * How the run ENDED, once: `completed` with what `run` returned, `dead-lettered`, `dropped`
125
+ * (`retry.deadLetter: false`) or `refused` (`whenBusy: 'fail'` over a busy key — the body never
126
+ * ran). Called after the row is settled, by the worker whose settle landed, under the job's
127
+ * tenant. Not called for a retry, a suspension, a drain, or a row an operator cancelled.
128
+ *
129
+ * AT MOST ONCE across a crash: a worker killed between the settle and this call never makes it.
130
+ * In one process it gets `ON_SETTLED_ATTEMPTS` tries; if all throw, the failure is logged and
131
+ * reported (`X_JOB_ON_SETTLED_FAILED`) and the row is untouched. What must be recorded for
132
+ * certain belongs in the body, inside a `step.run`.
133
+ */
134
+ onSettled?(settled: JobSettled<I, R>): Promise<void>;
135
+ }
136
+
137
+ /**
138
+ * Whoever the enqueue is for. Structural, exactly like `tenantKeyFrom` in `limits.ts`: the
139
+ * queue needs the actor's org and nothing else, so this package never imports the auth types.
140
+ */
141
+ export interface JobActor {
142
+ readonly orgId?: string | undefined;
143
+ /**
144
+ * The actor's id, recorded as `enqueuedBy` — ATTRIBUTION, never authority.
145
+ *
146
+ * The framework picks one answer to "whose permissions does a job run with" (axiom 1) and it is
147
+ * this: a job body runs with SYSTEM authority and this is an audit column. Impersonating the
148
+ * enqueuer at claim time is the defensible alternative and is rejected for one reason — a job
149
+ * that sleeps three days, or dead-letters and is retried next quarter, would then act as
150
+ * somebody whose role, org membership or employment has changed since. `02-primitives.md`
151
+ * already frames a job as server-authoritative work. A job that must act FOR a user takes that
152
+ * user's id in its input and re-authorises it in the body, where the check is visible in review.
153
+ */
154
+ readonly id?: string | undefined;
155
+ }
156
+
157
+ /**
158
+ * Methods (not function-typed properties) throughout, so `JobHandle<Specific>` is assignable
159
+ * to `AnyJobHandle` and heterogeneous handles can share a registry and a task's enqueue list.
160
+ */
161
+ export interface JobHandle<I = unknown> {
162
+ readonly kind: 'job';
163
+ readonly name: string;
164
+ readonly queue: string;
165
+ readonly retry: RetryPolicy;
166
+ /** The declared cap, resolved: the plain number, or a keyed declaration's `limit`. */
167
+ readonly concurrency: number | undefined;
168
+ /**
169
+ * What a claim over the cap does — set exactly when the cap is KEYED, `undefined` for a plain
170
+ * number and for no cap. A resolved field and never the declaration itself: `key` is a function
171
+ * of `I`, and a function-typed property would break `AnyJobHandle` — see `tenantFor`.
172
+ */
173
+ readonly whenBusy: WhenBusy | undefined;
174
+ readonly timeoutMs: number | undefined;
175
+ /** The declared per-step ceiling in ms; `executeJob` hands it to the step runner. */
176
+ readonly stepTimeoutMs: number | undefined;
177
+ readonly onSettledTimeoutMs: number;
178
+ /** The declared event-poll interval in ms; `undefined` leaves the runner's default. */
179
+ readonly eventPollMs: number | undefined;
180
+ /** `compensates: true` was declared. `executeJob` unwinds a run that failed for good. */
181
+ readonly compensates: boolean;
182
+ readonly input: StandardSchemaV1<unknown, I>;
183
+ parse(raw: unknown): I;
184
+ idempotencyKeyFor(input: I): string;
185
+ /**
186
+ * The org THIS payload's run acts under — `undefined` for `tenant: 'none'`. A method and never a
187
+ * `readonly tenant: JobTenant<I>` field: a function-typed property is contravariant in its
188
+ * parameter, so `JobHandle<OrgInput>` would stop being assignable to `AnyJobHandle` and the
189
+ * registry, the worker and a task's enqueue list could no longer hold heterogeneous handles.
190
+ */
191
+ tenantFor(input: I): string | undefined;
192
+ /**
193
+ * The concurrency key THIS payload's run counts under — `undefined` when the cap is not keyed.
194
+ * Refuses an empty key (`X_JOB_DECLARATION_INVALID`): the facade asks it at enqueue so the
195
+ * refusal reaches the caller, and the worker asks it again at claim.
196
+ */
197
+ concurrencyKeyFor(input: I): string | undefined;
198
+ run(args: JobRunArgs<I>): Promise<unknown>;
199
+ /** Whether the definition declared `onSettled` — published by `describe()`. */
200
+ readonly declaresOnSettled: boolean;
201
+ /**
202
+ * The declared hook; a no-op when there is none. A method, for `tenantFor`'s variance reason,
203
+ * and `unknown`-typed on both sides for the same one: the handle carries no result generic, so
204
+ * the definition's own `onSettled` is where `result` and `input` are typed.
205
+ */
206
+ onSettled(settled: JobSettled<unknown>): Promise<void>;
207
+ /**
208
+ * Put this job on the queue. Joins the caller's transaction when the app installed the
209
+ * outbox — same call site in a request handler, a job, a script or a test.
210
+ */
211
+ enqueue(input: I, options?: EnqueueOptions): Promise<EnqueueResult>;
212
+ /**
213
+ * Many inputs of this job, one round trip per 1,000, answered in input order — never a loop of
214
+ * `enqueue` (`X_N_PLUS_ONE_WRITE`). At most `MAX_ENQUEUE_BATCH`; see `job-enqueue-many.ts`.
215
+ */
216
+ enqueueMany(
217
+ inputs: readonly I[],
218
+ options?: EnqueueManyOptions,
219
+ ): Promise<readonly EnqueueResult[]>;
220
+ /**
221
+ * Enqueue on behalf of `actor`: fills `tenantId` from the actor's org so per-tenant limits
222
+ * apply. It queues rather than running inline because a job's execution surface IS the
223
+ * queue — an inline run would be a second execution path alongside `executeJob`.
224
+ */
225
+ as(actor: JobActor | null, input: I, options?: EnqueueOptions): Promise<EnqueueResult>;
226
+ describe(): JobDescriptor;
227
+ }
228
+
229
+ export type AnyJobHandle = JobHandle<unknown>;
package/src/job.ts CHANGED
@@ -7,219 +7,28 @@
7
7
  // orgs) surfaces in production under load, never in a test. Requiring it by construction
8
8
  // deletes that class of bug: there is no way to define a job that cannot be deduped.
9
9
 
10
- import type { Ctx } from '@ultimat3/core';
11
10
  import { assert, finiteCount } from '@ultimat3/core';
12
- import type { StandardSchemaV1 } from '@ultimat3/schema';
13
11
  import { parse } from '@ultimat3/schema';
14
- import type { DurationInput } from './clock';
15
12
  import { finiteDurationMs } from './clock';
16
- import type { JobConcurrency, WhenBusy } from './concurrency';
17
13
  import { resolveConcurrency } from './concurrency';
18
14
  import type { JobDescriptor } from './describe';
19
15
  import { describeJob } from './describe';
20
16
  import type { EnqueueResult } from './driver';
21
17
  import { DEFAULT_QUEUE } from './driver';
22
18
  import { IdempotencyRequiredError, JobNameTakenError } from './errors';
23
- import { JobDeclarationInvalidError } from './errors-declaration';
19
+ import { JobDeclarationInvalidError, missingJobFields } from './errors-declaration';
20
+ import type { EnqueueManyOptions } from './job-enqueue-many';
24
21
  import { NO_TENANT, tenantKeyFrom } from './limits';
25
22
  import type { EnqueueOptions } from './outbox';
26
23
  import { jobsFacade } from './outbox';
27
- import type { ProgressFn } from './progress';
28
- import type { RetryPolicy } from './retry';
29
24
  import { DEFAULT_RETRY } from './retry';
30
25
  import type { JobSettled } from './settled';
31
- import type { StepApi } from './steps';
32
- import type { JobTenant } from './tenant';
26
+ import { ON_SETTLED_TIMEOUT_MS } from './settled';
33
27
  import { assertJobTenant, jobTenantFor } from './tenant';
34
28
 
35
- export interface JobRunArgs<I> {
36
- readonly input: I;
37
- readonly step: StepApi;
38
- /**
39
- * `ctx.signal` aborts when this attempt's `timeout` passes — the same seam an action reads, so
40
- * `throwIfAborted(ctx)` and `fetch(url, { signal: ctx.signal })` work here unchanged. Past it
41
- * the run belongs to whoever claims it next and `step.run` refuses to write, so a loop that
42
- * never checks it is a body running beside its own retry.
43
- */
44
- readonly ctx: Ctx;
45
- /** 1-based. Assume at-least-once: never branch on `attempt === 1` for correctness. */
46
- readonly attempt: number;
47
- /**
48
- * True when a failure of THIS attempt is not retried for want of attempts — the same boolean
49
- * the runner dead-letters on (`isFinalAttempt` in `retry.ts`), so a body never re-derives it
50
- * from `retry.attempts`. The place to release what a retry would have reused. REQUIRED: a test
51
- * that drives a body by hand states it, `isFinalAttempt(handle.retry, attempt)` being the
52
- * runner's own answer.
53
- *
54
- * Not "this body runs once more at most": a `terminal` error stops an earlier attempt, and an
55
- * attempt handed back UNCOUNTED — a `step.sleep`, a drain — is presented again under the same
56
- * number, so the last attempt of a job that sleeps sees `true` on every resume.
57
- */
58
- readonly finalAttempt: boolean;
59
- /**
60
- * `progress(done, total, note?)` — how far this run has got, shown by `x jobs show` and the
61
- * dashboard. Call it as often as the loop turns: it is written to the row at most once a
62
- * second, and the last call before the run settles is always written.
63
- */
64
- readonly progress: ProgressFn;
65
- readonly jobId: string;
66
- readonly runId: string;
67
- }
68
-
69
- export interface JobDefinition<I, R = unknown> {
70
- /**
71
- * Omit it: `defineApi({ jobs })` assigns the export name. Set it only to pin a queue key the
72
- * export name must not decide — a framework job like `mail.send`, or a name rows already carry.
73
- */
74
- readonly name?: string;
75
- readonly input: StandardSchemaV1<unknown, I>;
76
- /** REQUIRED. See the file header — this is the whole point. */
77
- readonly idempotencyKey: (input: I) => string;
78
- /**
79
- * REQUIRED, and the org this job's body runs under. `tenant: (input) => input.orgId` derives it
80
- * from the payload; `tenant: 'none'` says this job belongs to no tenant, and then every
81
- * tenant-scoped read inside it fails closed with `X_TENANCY_ACTOR_ORG_REQUIRED`.
82
- *
83
- * There is no default, because both candidates are wrong. Until this field existed the worker ran
84
- * a body with no ambient context at all, so `@ultimat3/entity`'s tenant guard — which derives
85
- * from `tryUseContext()` and not from the ctx it is handed — read no actor, added no predicate
86
- * and accepted a caller-named `orgId` unchecked: the same write refused over HTTP as
87
- * `X_TENANCY_ACTOR_MISMATCH` was ACCEPTED through the job surface. A boot-supplied service actor
88
- * would close that with ONE identity for every job, which is a cross-tenant read waiting for the
89
- * first job that takes an org id in its input. So the job declares it, per job, from its own
90
- * payload — the value an author already had to pass anyway.
91
- */
92
- readonly tenant: JobTenant<I>;
93
- readonly retry: RetryPolicy;
94
- readonly queue?: string;
95
- /**
96
- * Max in-flight runs of THIS job across the fleet. Omit for the queue-wide cap.
97
- *
98
- * A number is one cap for the job. `{ key, limit, whenBusy }` is that cap PER KEY — "one run
99
- * per account" — and says what a claim over it does: `'wait'` (default) leaves the run queued,
100
- * `'fail'` settles it `failed` with `X_JOB_KEY_BUSY` without running its body (`concurrency.ts`).
101
- *
102
- * Enforced by `JobDriver.leases` — a row every replica can see — and NOT by `limits.ts`, which
103
- * counts one process's heap. A driver with no lease store cannot hold this cap, so
104
- * `jobWorker().start()` refuses to boot rather than let it pass silently
105
- * (`X_JOB_CONCURRENCY_UNENFORCEABLE`): this field was declared, documented and in the manifest
106
- * while nothing read it, which is exactly what axiom 3 exists to prevent.
107
- */
108
- readonly concurrency?: JobConcurrency<I>;
109
- readonly timeout?: DurationInput;
110
- /**
111
- * Ceiling for ONE `step.run`, where `timeout` is the ceiling for the whole attempt. Folded into
112
- * the signal the step body is handed, so a body reads one signal and sees whichever deadline
113
- * lands first — and it ABORTS before it rejects, because the attempt that replaces this one is
114
- * claimable the moment the nack lands.
115
- *
116
- * Declared here and nowhere else: the runner has implemented this ceiling since 1.0 and no
117
- * declaration could ask for it, which is a documented guarantee that does nothing.
118
- */
119
- readonly stepTimeout?: DurationInput;
120
- /**
121
- * How long a `step.waitForEvent` parks between polls. Default 30s. Lower it for a wait a user
122
- * is watching; the step suspends for exactly this long each time, so it is also the resolution
123
- * of the resume, never a busy loop.
124
- */
125
- readonly eventPoll?: DurationInput;
126
- run(args: JobRunArgs<I>): Promise<R>;
127
- /**
128
- * How the run ENDED, once: `completed` with what `run` returned, `dead-lettered`, `dropped`
129
- * (`retry.deadLetter: false`) or `refused` (`whenBusy: 'fail'` over a busy key — the body never
130
- * ran). Called after the row is settled, by the worker whose settle landed, under the job's
131
- * tenant. Not called for a retry, a suspension, a drain, or a row an operator cancelled.
132
- *
133
- * AT MOST ONCE across a crash: a worker killed between the settle and this call never makes it.
134
- * In one process it gets `ON_SETTLED_ATTEMPTS` tries; if all throw, the failure is logged and
135
- * reported (`X_JOB_ON_SETTLED_FAILED`) and the row is untouched. What must be recorded for
136
- * certain belongs in the body, inside a `step.run`.
137
- */
138
- onSettled?(settled: JobSettled<I, R>): Promise<void>;
139
- }
140
-
141
- /**
142
- * Whoever the enqueue is for. Structural, exactly like `tenantKeyFrom` in `limits.ts`: the
143
- * queue needs the actor's org and nothing else, so this package never imports the auth types.
144
- */
145
- export interface JobActor {
146
- readonly orgId?: string | undefined;
147
- /**
148
- * The actor's id, recorded as `enqueuedBy` — ATTRIBUTION, never authority.
149
- *
150
- * The framework picks one answer to "whose permissions does a job run with" (axiom 1) and it is
151
- * this: a job body runs with SYSTEM authority and this is an audit column. Impersonating the
152
- * enqueuer at claim time is the defensible alternative and is rejected for one reason — a job
153
- * that sleeps three days, or dead-letters and is retried next quarter, would then act as
154
- * somebody whose role, org membership or employment has changed since. `02-primitives.md`
155
- * already frames a job as server-authoritative work. A job that must act FOR a user takes that
156
- * user's id in its input and re-authorises it in the body, where the check is visible in review.
157
- */
158
- readonly id?: string | undefined;
159
- }
160
-
161
- /**
162
- * Methods (not function-typed properties) throughout, so `JobHandle<Specific>` is assignable
163
- * to `AnyJobHandle` and heterogeneous handles can share a registry and a task's enqueue list.
164
- */
165
- export interface JobHandle<I = unknown> {
166
- readonly kind: 'job';
167
- readonly name: string;
168
- readonly queue: string;
169
- readonly retry: RetryPolicy;
170
- /** The declared cap, resolved: the plain number, or a keyed declaration's `limit`. */
171
- readonly concurrency: number | undefined;
172
- /**
173
- * What a claim over the cap does — set exactly when the cap is KEYED, `undefined` for a plain
174
- * number and for no cap. A resolved field and never the declaration itself: `key` is a function
175
- * of `I`, and a function-typed property would break `AnyJobHandle` — see `tenantFor`.
176
- */
177
- readonly whenBusy: WhenBusy | undefined;
178
- readonly timeoutMs: number | undefined;
179
- /** The declared per-step ceiling in ms; `executeJob` hands it to the step runner. */
180
- readonly stepTimeoutMs: number | undefined;
181
- /** The declared event-poll interval in ms; `undefined` leaves the runner's 30s default. */
182
- readonly eventPollMs: number | undefined;
183
- readonly input: StandardSchemaV1<unknown, I>;
184
- parse(raw: unknown): I;
185
- idempotencyKeyFor(input: I): string;
186
- /**
187
- * The org THIS payload's run acts under — `undefined` for `tenant: 'none'`. A method and never a
188
- * `readonly tenant: JobTenant<I>` field: a function-typed property is contravariant in its
189
- * parameter, so `JobHandle<OrgInput>` would stop being assignable to `AnyJobHandle` and the
190
- * registry, the worker and a task's enqueue list could no longer hold heterogeneous handles.
191
- */
192
- tenantFor(input: I): string | undefined;
193
- /**
194
- * The concurrency key THIS payload's run counts under — `undefined` when the cap is not keyed.
195
- * Refuses an empty key (`X_JOB_DECLARATION_INVALID`): the facade asks it at enqueue so the
196
- * refusal reaches the caller, and the worker asks it again at claim.
197
- */
198
- concurrencyKeyFor(input: I): string | undefined;
199
- run(args: JobRunArgs<I>): Promise<unknown>;
200
- /** Whether the definition declared `onSettled` — published by `describe()`. */
201
- readonly declaresOnSettled: boolean;
202
- /**
203
- * The declared hook; a no-op when there is none. A method, for `tenantFor`'s variance reason,
204
- * and `unknown`-typed on both sides for the same one: the handle carries no result generic, so
205
- * the definition's own `onSettled` is where `result` and `input` are typed.
206
- */
207
- onSettled(settled: JobSettled<unknown>): Promise<void>;
208
- /**
209
- * Put this job on the queue. Joins the caller's transaction when the app installed the
210
- * outbox — same call site in a request handler, a job, a script or a test.
211
- */
212
- enqueue(input: I, options?: EnqueueOptions): Promise<EnqueueResult>;
213
- /**
214
- * Enqueue on behalf of `actor`: fills `tenantId` from the actor's org so per-tenant limits
215
- * apply. It queues rather than running inline because a job's execution surface IS the
216
- * queue — an inline run would be a second execution path alongside `executeJob`.
217
- */
218
- as(actor: JobActor | null, input: I, options?: EnqueueOptions): Promise<EnqueueResult>;
219
- describe(): JobDescriptor;
220
- }
29
+ export type { AnyJobHandle, JobActor, JobDefinition, JobHandle, JobRunArgs } from './job-types';
221
30
 
222
- export type AnyJobHandle = JobHandle<unknown>;
31
+ import type { AnyJobHandle, JobActor, JobDefinition, JobHandle, JobRunArgs } from './job-types';
223
32
 
224
33
  const registry = new Map<string, AnyJobHandle>();
225
34
  /** Process-monotonic — `resetJobs()` deliberately leaves it alone. */
@@ -239,18 +48,6 @@ interface JobOrigin {
239
48
 
240
49
  const origin = new WeakMap<object, JobOrigin>();
241
50
 
242
- /** The required fields a definition lacks, in declaration order. Read off the value, not the type. */
243
- function missingJobFields(definition: object): string[] {
244
- const loose = definition as unknown as Readonly<Record<string, unknown>>;
245
- const missing: string[] = [];
246
- if (typeof loose['input'] !== 'object' || loose['input'] === null) missing.push('input');
247
- if (typeof loose['idempotencyKey'] !== 'function') missing.push('idempotencyKey');
248
- if (loose['tenant'] === undefined) missing.push('tenant');
249
- if (typeof loose['retry'] !== 'object' || loose['retry'] === null) missing.push('retry');
250
- if (typeof loose['run'] !== 'function') missing.push('run');
251
- return missing;
252
- }
253
-
254
51
  export function job<I, R = unknown>(definition: JobDefinition<I, R>): JobHandle<I> {
255
52
  anonymous += 1;
256
53
  const name = definition.name ?? `anonymous-job-${anonymous}`;
@@ -278,6 +75,15 @@ export function job<I, R = unknown>(definition: JobDefinition<I, R>): JobHandle<
278
75
  definition.stepTimeout === undefined
279
76
  ? undefined
280
77
  : finiteDurationMs(definition.stepTimeout, `job "${name}"`, 'stepTimeout');
78
+ const onSettledTimeoutMs =
79
+ definition.onSettledTimeout === undefined
80
+ ? ON_SETTLED_TIMEOUT_MS
81
+ : finiteDurationMs(definition.onSettledTimeout, `job "${name}"`, 'onSettledTimeout');
82
+ assert(
83
+ onSettledTimeoutMs > 0,
84
+ `job "${name}" declares onSettledTimeout ${String(definition.onSettledTimeout)}, which is no ceiling at all`,
85
+ `set a finite positive onSettledTimeout on job("${name}") — "30s" or 30_000 — or omit the field for the default`,
86
+ );
281
87
  const eventPollMs =
282
88
  definition.eventPoll === undefined
283
89
  ? undefined
@@ -313,7 +119,9 @@ export function job<I, R = unknown>(definition: JobDefinition<I, R>): JobHandle<
313
119
  ? undefined
314
120
  : finiteDurationMs(definition.timeout, `job "${name}"`, 'timeout'),
315
121
  stepTimeoutMs,
122
+ onSettledTimeoutMs,
316
123
  eventPollMs,
124
+ compensates: definition.compensates === true,
317
125
  input: definition.input,
318
126
  parse(raw: unknown): I {
319
127
  return parse(definition.input, raw) as I;
@@ -346,6 +154,9 @@ export function job<I, R = unknown>(definition: JobDefinition<I, R>): JobHandle<
346
154
  enqueue(input: I, options?: EnqueueOptions): Promise<EnqueueResult> {
347
155
  return jobsFacade().enqueue(handle, input, options);
348
156
  },
157
+ enqueueMany(inputs: readonly I[], options?: EnqueueManyOptions) {
158
+ return jobsFacade().enqueueMany(handle, inputs, options);
159
+ },
349
160
  as(actor: JobActor | null, input: I, options: EnqueueOptions = {}): Promise<EnqueueResult> {
350
161
  const tenantId = options.tenantId ?? tenantFor(actor);
351
162
  // `NO_TENANT` is the limiter's own bucket for an absent tenant, so leaving the column
package/src/metrics.ts CHANGED
@@ -49,6 +49,16 @@ export const queueDeadJobs: Gauge = gauge('queue_dead_jobs', {
49
49
  description: 'Jobs sitting in the dead-letter state, by queue',
50
50
  });
51
51
 
52
+ /** `SCALING_METRICS['queue-demand']`: what the chart's worker HPA divides by a pod's capacity. */
53
+ export const queueDemand: Gauge = gauge('queue_demand', {
54
+ unit: '{job}',
55
+ description: 'Jobs a serving worker must hold, by queue: running on one, or due and claimable',
56
+ });
57
+
58
+ export function recordQueueDemand(queue: string, demand: number): void {
59
+ queueDemand.record(demand, { queue });
60
+ }
61
+
52
62
  export function recordQueueOldestReady(queue: string, oldestReadyMs: number): void {
53
63
  queueOldestReady.record(oldestReadyMs / 1000, { queue });
54
64
  }