@ultimat3/jobs 23.0.0 → 24.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 (45) hide show
  1. package/CLAUDE.md +51 -52
  2. package/README.md +34 -5
  3. package/package.json +6 -6
  4. package/src/backfill-ledger.ts +24 -2
  5. package/src/backfill-pass.ts +22 -5
  6. package/src/claim-exhausted.ts +76 -0
  7. package/src/driver-memory-operator.ts +31 -9
  8. package/src/driver-memory.ts +68 -23
  9. package/src/driver-nats.ts +5 -2
  10. package/src/driver-pg-jobs-sql.ts +27 -19
  11. package/src/driver-pg-operator-sql.ts +32 -12
  12. package/src/driver-pg-operator.ts +19 -3
  13. package/src/driver-pg-rows.ts +3 -3
  14. package/src/driver-pg-sql.ts +98 -29
  15. package/src/driver-pg.ts +86 -81
  16. package/src/driver-redis.ts +5 -2
  17. package/src/driver.ts +37 -1
  18. package/src/errors-operator.ts +12 -4
  19. package/src/errors-requeue.ts +21 -3
  20. package/src/errors.ts +5 -13
  21. package/src/events-pg.ts +8 -18
  22. package/src/events.ts +20 -9
  23. package/src/execute.ts +1 -0
  24. package/src/index.ts +10 -4
  25. package/src/inspect-operator.ts +3 -3
  26. package/src/inspect.ts +4 -3
  27. package/src/introspection.ts +23 -11
  28. package/src/job.ts +5 -6
  29. package/src/limits.ts +33 -7
  30. package/src/outbox-pg.ts +1 -2
  31. package/src/outbox.ts +37 -9
  32. package/src/purge.ts +1 -1
  33. package/src/queue-wake.ts +1 -2
  34. package/src/scheduler-pg.ts +3 -2
  35. package/src/scheduler-state.ts +8 -1
  36. package/src/scheduler.ts +9 -10
  37. package/src/steps-suspension.ts +46 -0
  38. package/src/steps-timeout.ts +33 -0
  39. package/src/steps.ts +55 -81
  40. package/src/worker-admit.ts +7 -3
  41. package/src/worker-fleet-slots.ts +78 -44
  42. package/src/worker-key-busy.ts +38 -10
  43. package/src/worker-loop.ts +35 -11
  44. package/src/worker-registry.ts +16 -1
  45. package/src/worker.ts +52 -9
package/src/worker.ts CHANGED
@@ -5,12 +5,14 @@
5
5
 
6
6
  import type { ShutdownReason } from '@ultimat3/core';
7
7
  import { beginWork, logger, onShutdown, recordJob, renderThrowable, uuid } from '@ultimat3/core';
8
+ import { announceExhausted } from './claim-exhausted';
8
9
  import { nowMs } from './clock';
9
10
  import { createDrainBudget, settleAllBy } from './drain-wait';
10
- import type { ClaimedJob } from './driver';
11
+ import type { ClaimedJob, JobRecord } from './driver';
11
12
  import { DEFAULT_QUEUE } from './driver';
12
13
  import { JobDrainedError } from './errors';
13
14
  import type { JobExecution } from './execute';
15
+ import { registeredJobs } from './job';
14
16
  import { createLimiter } from './limits';
15
17
  import { JOB_OUTCOME_LABELS } from './metrics';
16
18
  import { claimAsks, createAdmission } from './worker-admit';
@@ -43,6 +45,7 @@ export function createWorker(options: WorkerOptions): Worker {
43
45
  ttlMs: visibilityTimeoutMs,
44
46
  renewIntervalMs: heartbeatIntervalMs,
45
47
  ...(options.schedule === undefined ? {} : { schedule: options.schedule }),
48
+ ...(options.clock === undefined ? {} : { clock: options.clock }),
46
49
  });
47
50
 
48
51
  const admit = createAdmission({
@@ -94,8 +97,12 @@ export function createWorker(options: WorkerOptions): Worker {
94
97
  let interrupted = 0;
95
98
  let refused = 0;
96
99
  let dropped = 0;
97
- /** The ids this worker holds right now — what its registry row reports. */
98
- const holding = new Set<string>();
100
+ /**
101
+ * The ids this worker holds right now, with how many RUNS hold each — what its registry row
102
+ * reports. A count, because one worker can hold a job twice (a claim that lapsed under a body
103
+ * still running, then its own re-claim): a set dropped the id when the first run ended.
104
+ */
105
+ const holding = new Map<string, number>();
99
106
  let registration: WorkerRegistration | undefined;
100
107
  /** `queue_depth` and its two siblings, republished on their own interval (`worker-queue-depth.ts`). */
101
108
  const publishQueueDepth = createQueueDepthPublisher({
@@ -147,8 +154,36 @@ export function createWorker(options: WorkerOptions): Worker {
147
154
  // claiming" now, not at the next tick. What this round already holds still runs to the end
148
155
  // — that is the drain, and `stop()` waits for it.
149
156
  if (!claiming()) break;
150
- const claimed = await options.driver.claim({ ...ask, visibilityTimeoutMs, workerId });
157
+ // A lease that lapsed on a row's final attempt is settled by the claim itself and handed to
158
+ // `onExhausted`, never out as work: this round is the only thing that can count it, log it
159
+ // and tell the job. `dropExhausted` is the policy the row does not carry.
160
+ const buried: JobRecord[] = [];
161
+ const claimed = await options.driver.claim({
162
+ ...ask,
163
+ visibilityTimeoutMs,
164
+ workerId,
165
+ dropExhausted: registeredJobs()
166
+ .filter((handle) => handle.retry.deadLetter === false)
167
+ .map((handle) => handle.name),
168
+ onExhausted: (dead) => {
169
+ buried.push(...dead);
170
+ },
171
+ });
151
172
  if (claimed.length > 0) found = true;
173
+ if (buried.length > 0) {
174
+ found = true;
175
+ const ended = await announceExhausted({
176
+ exhausted: buried,
177
+ workerId,
178
+ context: options.context,
179
+ });
180
+ deadLettered += ended.deadLettered;
181
+ dropped += ended.dropped;
182
+ for (const row of buried) {
183
+ const label = JOB_OUTCOME_LABELS[row.state === 'failed' ? 'dropped' : 'dead-lettered'];
184
+ if (label !== null) recordJob(row.queue, label);
185
+ }
186
+ }
152
187
 
153
188
  for (const [index, job] of claimed.entries()) {
154
189
  const queue = job.queue;
@@ -170,7 +205,7 @@ export function createWorker(options: WorkerOptions): Worker {
170
205
  // core's own in-flight wait sits between `accept` and `inflight` and exists for exactly
171
206
  // this. Counted nowhere, the worker had to wait for its own jobs inside a hook.
172
207
  const finishWork = beginWork();
173
- holding.add(job.id);
208
+ holding.set(job.id, (holding.get(job.id) ?? 0) + 1);
174
209
  const running = runClaimed(job, admission.refusal)
175
210
  .then((execution) => {
176
211
  if (execution.outcome === 'completed') processed += 1;
@@ -193,7 +228,9 @@ export function createWorker(options: WorkerOptions): Worker {
193
228
  return execution;
194
229
  })
195
230
  .finally(async () => {
196
- holding.delete(job.id);
231
+ const held = (holding.get(job.id) ?? 1) - 1;
232
+ if (held === 0) holding.delete(job.id);
233
+ else holding.set(job.id, held);
197
234
  lease.release();
198
235
  // The slot is free NOW. A full worker asks the queue for nothing, so its loop backs
199
236
  // off like an idle one — and waiting that out with a backlog behind the job that
@@ -335,9 +372,15 @@ export function createWorker(options: WorkerOptions): Worker {
335
372
  fix: 'raise the drain budget past the slowest job — configureLifecycle({ deadlineMs: 600_000 }) — and set terminationGracePeriodSeconds to at least as many seconds',
336
373
  });
337
374
  }
338
- // Before the close, and awaited: the row is deleted through the driver being closed.
339
- await registration?.stop();
375
+ // Before the close, and awaited: the row is deleted through the driver being closed. Under
376
+ // the SAME budget as the jobs: `forgetWorker` is a statement on the pool that may be the
377
+ // thing failing, and unbounded it held a SIGTERM teardown open with the driver never closed.
378
+ // Abandoned, the row expires on its own TTL — what a killed worker's does.
379
+ const registered = registration;
340
380
  registration = undefined;
381
+ if (registered !== undefined && !(await settleAllBy([registered.stop()], budget))) {
382
+ logger.warn('jobs.worker.registry-abandoned', { workerId, reason });
383
+ }
341
384
  await options.driver.close?.();
342
385
  } finally {
343
386
  // Whatever the close did, this worker is done: a state left at 'draining' is a drain that
@@ -395,7 +438,7 @@ export function createWorker(options: WorkerOptions): Worker {
395
438
  host: options.host,
396
439
  queues,
397
440
  concurrency: queues.reduce((slots, queue) => slots + slotsFor(queue), 0),
398
- inFlight: () => [...holding],
441
+ inFlight: () => [...holding.keys()],
399
442
  // The visibility timeout, renewed on the heartbeat interval: a killed worker leaves the
400
443
  // registry on exactly the schedule the queue takes its jobs back.
401
444
  ttlMs: visibilityTimeoutMs,