@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.
- package/CLAUDE.md +68 -89
- package/README.md +304 -10
- package/package.json +18 -6
- package/src/backfill-definition.ts +184 -0
- package/src/backfill-errors.ts +18 -2
- package/src/backfill-pass.ts +43 -40
- package/src/backfill-pending.ts +40 -17
- package/src/backfill-reader.ts +94 -0
- package/src/backfill-registry.ts +5 -0
- package/src/backfill-scope.ts +1 -2
- package/src/backfill-source.ts +113 -0
- package/src/backfill.ts +54 -135
- package/src/consumer-errors.ts +42 -0
- package/src/consumer-guard.ts +19 -0
- package/src/consumer-loop.ts +171 -0
- package/src/consumer-registry.ts +103 -0
- package/src/consumer-types.ts +81 -0
- package/src/consumer.ts +147 -0
- package/src/driver-memory-operator.ts +25 -2
- package/src/driver-memory.ts +35 -1
- package/src/driver-pg-claim-sql.ts +128 -0
- package/src/driver-pg-ddl.ts +11 -2
- package/src/driver-pg-demand-sql.ts +27 -0
- package/src/driver-pg-enqueue-many.ts +153 -0
- package/src/driver-pg-event-wake-sql.ts +51 -0
- package/src/driver-pg-operator-sql.ts +34 -6
- package/src/driver-pg-operator.ts +36 -2
- package/src/driver-pg-outbox-sql.ts +34 -0
- package/src/driver-pg-sql.ts +24 -91
- package/src/driver-pg.ts +17 -1
- package/src/driver-redis-refusal.ts +25 -0
- package/src/driver-redis-scripts.ts +79 -30
- package/src/driver-redis.ts +70 -42
- package/src/driver.ts +38 -1
- package/src/enqueue-record.ts +137 -0
- package/src/error-codes.ts +233 -0
- package/src/errors-concurrency.ts +2 -2
- package/src/errors-declaration.ts +15 -2
- package/src/errors-operator.ts +1 -1
- package/src/errors-requeue.ts +1 -1
- package/src/errors-saga.ts +87 -0
- package/src/errors-steps.ts +35 -0
- package/src/errors-tenant.ts +1 -1
- package/src/errors.ts +25 -165
- package/src/events-pg.ts +20 -8
- package/src/events.ts +14 -3
- package/src/execute-ctx.ts +51 -0
- package/src/execute-unwind.ts +191 -0
- package/src/execute.ts +50 -51
- package/src/export-errors.ts +1 -1
- package/src/failure-row.ts +43 -0
- package/src/import-errors.ts +102 -0
- package/src/import-format.ts +232 -0
- package/src/import-pass.ts +255 -0
- package/src/import-source.ts +49 -0
- package/src/import.ts +175 -0
- package/src/index.ts +163 -7
- package/src/inspect-operator.ts +24 -3
- package/src/inspect.ts +17 -4
- package/src/introspection.ts +24 -1
- package/src/job-enqueue-many.ts +108 -0
- package/src/job-types.ts +229 -0
- package/src/job.ts +19 -208
- package/src/metrics.ts +10 -0
- package/src/outbox-pg.ts +35 -0
- package/src/outbox-relay.ts +58 -19
- package/src/outbox.ts +65 -73
- package/src/purge-entities.ts +92 -0
- package/src/purge.ts +10 -2
- package/src/queue-wake.ts +1 -1
- package/src/refresh-view.ts +76 -0
- package/src/retry-classification.ts +27 -8
- package/src/retry.ts +26 -0
- package/src/rollup-errors.ts +27 -0
- package/src/rollup.ts +190 -0
- package/src/run-deadline.ts +11 -7
- package/src/scheduler.ts +2 -1
- package/src/settled.ts +56 -15
- package/src/step-resolve.ts +37 -0
- package/src/steps-api.ts +111 -0
- package/src/steps-core.ts +26 -0
- package/src/steps-output.ts +142 -0
- package/src/steps-store.ts +68 -0
- package/src/steps-suspension.ts +51 -0
- package/src/steps-timeout.ts +10 -5
- package/src/steps-unwind.ts +94 -0
- package/src/steps-wait.ts +163 -0
- package/src/steps.ts +174 -210
- package/src/subject-erase-pass.ts +134 -0
- package/src/subject-errors.ts +48 -0
- package/src/subject-export-pass.ts +228 -0
- package/src/subject-jobs.ts +121 -0
- package/src/subject-plan.ts +287 -0
- package/src/subject-select.ts +106 -0
- package/src/subject.ts +119 -0
- package/src/tenant.ts +69 -1
- package/src/type-pins.ts +90 -0
- package/src/webhook-errors.ts +1 -1
- package/src/webhook-receiver-errors.ts +59 -0
- package/src/webhook-receiver-route.ts +127 -0
- package/src/webhook-receiver-types.ts +97 -0
- package/src/webhook-receiver.ts +132 -0
- package/src/webhook-target.ts +20 -43
- package/src/webhook.ts +7 -4
- package/src/worker-queue-demand.ts +57 -0
- package/src/worker-queue-depth.ts +15 -3
- package/src/worker-registry.ts +4 -1
- package/src/worker-types.ts +2 -0
- 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
|
+
}
|
package/src/job-types.ts
ADDED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
}
|