@ultimat3/jobs 30.9.0 → 31.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 +72 -107
- package/README.md +202 -55
- package/package.json +8 -7
- package/src/adapter.ts +37 -0
- package/src/backfill-ledger.ts +2 -2
- package/src/backfill-rate.ts +1 -0
- package/src/clock.ts +2 -2
- package/src/drain-wait.ts +1 -0
- package/src/driver-memory.ts +2 -2
- package/src/driver-pg-ddl.ts +19 -0
- package/src/driver-pg-event-wake-sql.ts +43 -22
- package/src/driver-pg-sql.ts +68 -27
- package/src/driver-pg.ts +11 -10
- package/src/driver-redis.ts +2 -2
- package/src/driver.ts +14 -8
- package/src/error-codes.ts +27 -0
- package/src/errors-concurrency.ts +1 -1
- package/src/errors.ts +1 -1
- package/src/events-cursor.ts +31 -0
- package/src/events-pg.ts +52 -6
- package/src/events.ts +72 -36
- package/src/export.ts +2 -2
- package/src/gdpr-audit.ts +75 -0
- package/src/gdpr-coverage.ts +94 -0
- package/src/gdpr-errors.ts +35 -0
- package/src/import.ts +2 -2
- package/src/index.ts +45 -206
- package/src/inspect-operator.ts +2 -2
- package/src/inspect.ts +1 -1
- package/src/job-instrument.ts +115 -0
- package/src/job.ts +41 -4
- package/src/jobs-retention.ts +23 -6
- package/src/leases.ts +13 -3
- package/src/ledger-book.ts +168 -0
- package/src/ledger-errors.ts +48 -0
- package/src/ledger.ts +145 -0
- package/src/limits.ts +57 -19
- package/src/lock-errors.ts +54 -0
- package/src/lock.ts +176 -0
- package/src/outbox-relay.ts +4 -0
- package/src/outbox.ts +3 -3
- package/src/partitions.ts +119 -0
- package/src/reindex-sweep.ts +94 -0
- package/src/reindex.ts +147 -0
- package/src/retry-classification.ts +3 -2
- package/src/retry-jitter.ts +32 -0
- package/src/retry.ts +13 -16
- package/src/scheduler.ts +10 -6
- package/src/steps-api.ts +44 -5
- package/src/steps-suspension.ts +34 -3
- package/src/steps-wait.ts +52 -4
- package/src/steps.ts +1 -0
- package/src/subject-jobs.ts +12 -4
- package/src/webhook.ts +2 -2
- package/src/worker-admit.ts +8 -5
- package/src/worker-drain-cutoff.ts +1 -0
- package/src/worker-loop.ts +28 -5
- package/src/worker-registry.ts +7 -5
- package/src/worker-run.ts +53 -9
- package/src/worker.ts +8 -8
package/src/worker-run.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// one is everything that has to be true for the duration of a single run.
|
|
5
5
|
|
|
6
6
|
import type { Clock, Ctx } from '@ultimat3/core';
|
|
7
|
-
import { parseTraceparent, withSpan } from '@ultimat3/core';
|
|
7
|
+
import { instrument, parseTraceparent, SEMCONV, subscribed, withSpan } from '@ultimat3/core';
|
|
8
8
|
import type { ClaimedJob, JobDriver } from './driver';
|
|
9
9
|
import { claimOf } from './driver';
|
|
10
10
|
import { JobSlotLostError } from './errors';
|
|
@@ -12,6 +12,7 @@ import { executeJob } from './execute';
|
|
|
12
12
|
import type { JobExecution } from './execute-result';
|
|
13
13
|
import { startLeaseHeartbeat } from './heartbeat';
|
|
14
14
|
import { getJob } from './job';
|
|
15
|
+
import { messagingAttributes } from './job-instrument';
|
|
15
16
|
import type { IntervalScheduler } from './renewal-timer';
|
|
16
17
|
import type { RunSignal } from './run-signal';
|
|
17
18
|
import { createRunSignal } from './run-signal';
|
|
@@ -82,6 +83,36 @@ function settlingStops(driver: JobDriver, stop: () => void): JobDriver {
|
|
|
82
83
|
};
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/** The outcomes that failed the attempt — `suspended` and `interrupted` are not the body's fault. */
|
|
87
|
+
const FAILED_OUTCOMES: ReadonlySet<JobExecution['outcome']> = new Set([
|
|
88
|
+
'retried',
|
|
89
|
+
'dead-lettered',
|
|
90
|
+
'dropped',
|
|
91
|
+
'refused',
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const failureOf = (execution: JobExecution): { readonly error: unknown } | undefined =>
|
|
95
|
+
FAILED_OUTCOMES.has(execution.outcome)
|
|
96
|
+
? { error: execution.error ?? execution.outcome }
|
|
97
|
+
: undefined;
|
|
98
|
+
|
|
99
|
+
/** `job.perform` on the bus: every attempt that ran, sampled or not, failed or not. */
|
|
100
|
+
function performed(
|
|
101
|
+
claimed: ClaimedJob,
|
|
102
|
+
startedAt: number,
|
|
103
|
+
failure: { readonly error: unknown } | undefined,
|
|
104
|
+
): void {
|
|
105
|
+
if (!subscribed('job.perform')) return;
|
|
106
|
+
instrument('job.perform', {
|
|
107
|
+
job: claimed.name,
|
|
108
|
+
queue: claimed.queue,
|
|
109
|
+
id: claimed.id,
|
|
110
|
+
attempt: claimed.attempt,
|
|
111
|
+
durationMs: performance.now() - startedAt,
|
|
112
|
+
...failure,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
85
116
|
/** Run one claimed job under its lease, its slot and its span, and settle it with the driver. */
|
|
86
117
|
export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExecution> {
|
|
87
118
|
const { claimed, driver, fleetSlots, workerId, visibilityTimeoutMs } = options;
|
|
@@ -146,6 +177,8 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
|
|
|
146
177
|
// each one depends on the last. `undefined` is "never taken", and both handbacks are idempotent.
|
|
147
178
|
let runSignal: RunSignal | undefined;
|
|
148
179
|
let stopSlotRenewal: (() => void) | undefined;
|
|
180
|
+
let startedAt = 0;
|
|
181
|
+
let reported = false;
|
|
149
182
|
|
|
150
183
|
try {
|
|
151
184
|
// The caller's context plus this lease's cancellation, so a job cancelled from outside — or
|
|
@@ -170,15 +203,16 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
|
|
|
170
203
|
signal.abort(new JobSlotLostError({ job: claimed.name, jobId: claimed.id, slot: slot.slot }));
|
|
171
204
|
});
|
|
172
205
|
|
|
173
|
-
// The job's span is a
|
|
174
|
-
//
|
|
175
|
-
// `chargeCard` opens a fresh root two seconds later
|
|
206
|
+
// The job's CONSUMER span is a child of the enqueue's producer span (itself a child of the
|
|
207
|
+
// request that queued it) when the row carries a trace. That link is what `04-jobs.md`
|
|
208
|
+
// promised: without it a checkout's `chargeCard` opens a fresh root two seconds later.
|
|
176
209
|
const parent = parseTraceparent(claimed.traceparent);
|
|
177
210
|
|
|
178
|
-
|
|
211
|
+
const execution = await withSpan(
|
|
179
212
|
`job.${handle.name}`,
|
|
180
213
|
() => {
|
|
181
214
|
started = true;
|
|
215
|
+
startedAt = performance.now();
|
|
182
216
|
return executeJob({
|
|
183
217
|
driver: settlingStops(driver, heartbeat.stop),
|
|
184
218
|
claimed,
|
|
@@ -190,16 +224,26 @@ export async function runClaimedJob(options: RunClaimedOptions): Promise<JobExec
|
|
|
190
224
|
});
|
|
191
225
|
},
|
|
192
226
|
{
|
|
227
|
+
kind: 'consumer',
|
|
193
228
|
...(parent === undefined ? {} : { parent }),
|
|
194
229
|
attributes: {
|
|
195
|
-
'
|
|
196
|
-
|
|
197
|
-
'job.attempt': claimed.attempt,
|
|
198
|
-
...(claimed.enqueuedBy === undefined
|
|
230
|
+
...messagingAttributes('process', claimed.queue, handle.name),
|
|
231
|
+
[SEMCONV.messagingMessageId]: claimed.id,
|
|
232
|
+
'ultimate.job.attempt': claimed.attempt,
|
|
233
|
+
...(claimed.enqueuedBy === undefined
|
|
234
|
+
? {}
|
|
235
|
+
: { 'ultimate.job.enqueued_by': claimed.enqueuedBy }),
|
|
199
236
|
},
|
|
200
237
|
},
|
|
201
238
|
);
|
|
239
|
+
// Marked BEFORE the emit: a strict subscriber's throw lands in the `catch`, which must not
|
|
240
|
+
// report the same attempt twice.
|
|
241
|
+
reported = true;
|
|
242
|
+
performed(claimed, startedAt, failureOf(execution));
|
|
243
|
+
return execution;
|
|
202
244
|
} catch (error) {
|
|
245
|
+
// An attempt whose body started and then threw past `executeJob` is still an attempt.
|
|
246
|
+
if (started && !reported) performed(claimed, startedAt, { error });
|
|
203
247
|
// Stopped BEFORE the row goes back, never after: a renewal landing on a handed-back row
|
|
204
248
|
// answers "not yours", which `heartbeat.ts` reports as a lease lost under a running job.
|
|
205
249
|
// Both are idempotent, so the `finally` repeating them costs nothing.
|
package/src/worker.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { armWorkerCutoff, type DrainCutoff } from './worker-drain-cutoff';
|
|
|
28
28
|
import { assertConcurrencyEnforceable, createFleetSlots } from './worker-fleet-slots';
|
|
29
29
|
import { createHeldRuns } from './worker-held';
|
|
30
30
|
import type { ClaimLoop } from './worker-loop';
|
|
31
|
-
import { createClaimLoop } from './worker-loop';
|
|
31
|
+
import { createClaimLoop, fillsAsk } from './worker-loop';
|
|
32
32
|
import { resolveWorkerTimings } from './worker-options';
|
|
33
33
|
import { createProcessCap } from './worker-process-cap';
|
|
34
34
|
import { createQueueDepthPublisher } from './worker-queue-depth';
|
|
@@ -143,6 +143,7 @@ export function jobWorker(options: WorkerOptions): Worker {
|
|
|
143
143
|
await publishQueueDepth();
|
|
144
144
|
const started: Promise<JobExecution>[] = [];
|
|
145
145
|
let found = false;
|
|
146
|
+
let full = false; // an ask whose every row STARTED: a backlog (`worker-loop.ts`)
|
|
146
147
|
|
|
147
148
|
// One ask; WHICH queue, how many and in what order is `worker-claim-sweep.ts`'s (`maxInFlight`
|
|
148
149
|
// and the turn). It re-reads `claiming()` per ask: a `stop()` mid-pass stops claiming now.
|
|
@@ -174,7 +175,7 @@ export function jobWorker(options: WorkerOptions): Worker {
|
|
|
174
175
|
for (const [index, job] of claimed.entries()) {
|
|
175
176
|
const queue = job.queue;
|
|
176
177
|
// Both caps, and what becomes of a job that passes neither — `worker-admit.ts`.
|
|
177
|
-
const admission = await admit(job, queue, claimed.slice(index + 1));
|
|
178
|
+
const admission = await admit(job, queue, () => claimed.slice(index + 1));
|
|
178
179
|
if (admission.kind === 'waiting') continue;
|
|
179
180
|
if (admission.kind === 'refused') {
|
|
180
181
|
// Settled without a body: counted and returned with the pass, the one job this loop
|
|
@@ -218,9 +219,8 @@ export function jobWorker(options: WorkerOptions): Worker {
|
|
|
218
219
|
.finally(async () => {
|
|
219
220
|
lease.release();
|
|
220
221
|
returnCap();
|
|
221
|
-
// The slot is free NOW
|
|
222
|
-
//
|
|
223
|
-
// just finished is throughput lost to a timer.
|
|
222
|
+
// The slot is free NOW: behind a FULL pass, or a backed-off loop, the next pass runs at
|
|
223
|
+
// once (`worker-loop.ts`) — a backlog never waits out a timer for its next row.
|
|
224
224
|
loop.kick();
|
|
225
225
|
// AWAITED, never `void`: the slot is a row in `x_job_leases`, and a DELETE still on the
|
|
226
226
|
// wire at `driver.close()` left a `concurrency: 1` job unclaimable by the replacement
|
|
@@ -257,6 +257,7 @@ export function jobWorker(options: WorkerOptions): Worker {
|
|
|
257
257
|
},
|
|
258
258
|
);
|
|
259
259
|
}
|
|
260
|
+
if (fillsAsk(admitted, ask.limit)) full = true;
|
|
260
261
|
return admitted;
|
|
261
262
|
};
|
|
262
263
|
|
|
@@ -269,9 +270,8 @@ export function jobWorker(options: WorkerOptions): Worker {
|
|
|
269
270
|
claim: claimOne,
|
|
270
271
|
});
|
|
271
272
|
|
|
272
|
-
// The
|
|
273
|
-
|
|
274
|
-
loop.passed(found);
|
|
273
|
+
// The next wait (`idle-backoff.ts`), reported by the pass itself: `tick()` and the timer agree.
|
|
274
|
+
loop.passed(found, full);
|
|
275
275
|
return started;
|
|
276
276
|
};
|
|
277
277
|
|