@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.
Files changed (60) hide show
  1. package/CLAUDE.md +72 -107
  2. package/README.md +202 -55
  3. package/package.json +8 -7
  4. package/src/adapter.ts +37 -0
  5. package/src/backfill-ledger.ts +2 -2
  6. package/src/backfill-rate.ts +1 -0
  7. package/src/clock.ts +2 -2
  8. package/src/drain-wait.ts +1 -0
  9. package/src/driver-memory.ts +2 -2
  10. package/src/driver-pg-ddl.ts +19 -0
  11. package/src/driver-pg-event-wake-sql.ts +43 -22
  12. package/src/driver-pg-sql.ts +68 -27
  13. package/src/driver-pg.ts +11 -10
  14. package/src/driver-redis.ts +2 -2
  15. package/src/driver.ts +14 -8
  16. package/src/error-codes.ts +27 -0
  17. package/src/errors-concurrency.ts +1 -1
  18. package/src/errors.ts +1 -1
  19. package/src/events-cursor.ts +31 -0
  20. package/src/events-pg.ts +52 -6
  21. package/src/events.ts +72 -36
  22. package/src/export.ts +2 -2
  23. package/src/gdpr-audit.ts +75 -0
  24. package/src/gdpr-coverage.ts +94 -0
  25. package/src/gdpr-errors.ts +35 -0
  26. package/src/import.ts +2 -2
  27. package/src/index.ts +45 -206
  28. package/src/inspect-operator.ts +2 -2
  29. package/src/inspect.ts +1 -1
  30. package/src/job-instrument.ts +115 -0
  31. package/src/job.ts +41 -4
  32. package/src/jobs-retention.ts +23 -6
  33. package/src/leases.ts +13 -3
  34. package/src/ledger-book.ts +168 -0
  35. package/src/ledger-errors.ts +48 -0
  36. package/src/ledger.ts +145 -0
  37. package/src/limits.ts +57 -19
  38. package/src/lock-errors.ts +54 -0
  39. package/src/lock.ts +176 -0
  40. package/src/outbox-relay.ts +4 -0
  41. package/src/outbox.ts +3 -3
  42. package/src/partitions.ts +119 -0
  43. package/src/reindex-sweep.ts +94 -0
  44. package/src/reindex.ts +147 -0
  45. package/src/retry-classification.ts +3 -2
  46. package/src/retry-jitter.ts +32 -0
  47. package/src/retry.ts +13 -16
  48. package/src/scheduler.ts +10 -6
  49. package/src/steps-api.ts +44 -5
  50. package/src/steps-suspension.ts +34 -3
  51. package/src/steps-wait.ts +52 -4
  52. package/src/steps.ts +1 -0
  53. package/src/subject-jobs.ts +12 -4
  54. package/src/webhook.ts +2 -2
  55. package/src/worker-admit.ts +8 -5
  56. package/src/worker-drain-cutoff.ts +1 -0
  57. package/src/worker-loop.ts +28 -5
  58. package/src/worker-registry.ts +7 -5
  59. package/src/worker-run.ts +53 -9
  60. 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 CHILD of the request that queued it when the row carries a trace. That
174
- // link is what `04-jobs.md` promised and no column existed to hold: without it a checkout's
175
- // `chargeCard` opens a fresh root two seconds later with nothing pointing back.
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
- return await withSpan(
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
- 'job.name': handle.name,
196
- 'job.id': claimed.id,
197
- 'job.attempt': claimed.attempt,
198
- ...(claimed.enqueuedBy === undefined ? {} : { 'job.enqueued_by': claimed.enqueuedBy }),
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. A full worker asks the queue for nothing, so its loop backs
222
- // off like an idle one — and waiting that out with a backlog behind the job that
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 loop's next wait: the floor while passes find work, doubling to the ceiling while they
273
- // do not (`idle-backoff.ts`). Reported by the pass itself, so `tick()` and the timer agree.
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