@ultimat3/jobs 21.0.0 → 22.1.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/src/worker.ts CHANGED
@@ -4,16 +4,7 @@
4
4
  // deploy turns "at least once" into "always twice", so draining is on by default.
5
5
 
6
6
  import type { ShutdownReason } from '@ultimat3/core';
7
- import {
8
- beginWork,
9
- logger,
10
- onShutdown,
11
- recordJob,
12
- recordQueueDepth,
13
- renderThrowable,
14
- uuid,
15
- } from '@ultimat3/core';
16
- import { nowMs } from './clock';
7
+ import { beginWork, logger, onShutdown, recordJob, renderThrowable, uuid } from '@ultimat3/core';
17
8
  import { createDrainBudget, settleAllBy } from './drain-wait';
18
9
  import type { ClaimedJob } from './driver';
19
10
  import { DEFAULT_QUEUE } from './driver';
@@ -21,20 +12,14 @@ import { ConcurrencyUnenforceableError, JobDrainedError } from './errors';
21
12
  import type { JobExecution } from './execute';
22
13
  import { getJob, registeredJobs } from './job';
23
14
  import { createLimiter } from './limits';
24
- import { JOB_OUTCOME_LABELS, recordQueueDeadJobs, recordQueueOldestReady } from './metrics';
15
+ import { JOB_OUTCOME_LABELS } from './metrics';
25
16
  import { createFleetSlots } from './worker-fleet-slots';
17
+ import { handBack } from './worker-hand-back';
26
18
  import { resolveWorkerTimings } from './worker-options';
19
+ import { createQueueDepthPublisher } from './worker-queue-depth';
27
20
  import { runClaimedJob } from './worker-run';
28
21
  import type { Worker, WorkerOptions, WorkerStats } from './worker-types';
29
22
 
30
- /**
31
- * How often the claim loop republishes `queue_depth`. Its own interval, not `pollIntervalMs`:
32
- * `driver.stats()` is an aggregate over the whole jobs table and a scrape reads the gauge every
33
- * ~15s, so publishing at the poll rate would multiply the queue's read load by sixty to write the
34
- * same number sixty times.
35
- */
36
- const QUEUE_DEPTH_INTERVAL_MS = 15_000;
37
-
38
23
  export type { Worker, WorkerOptions, WorkerStats } from './worker-types';
39
24
 
40
25
  export function createWorker(options: WorkerOptions): Worker {
@@ -93,37 +78,13 @@ export function createWorker(options: WorkerOptions): Worker {
93
78
  let suspended = 0;
94
79
  let deadLettered = 0;
95
80
  let interrupted = 0;
96
- let depthPublishedAt = Number.NEGATIVE_INFINITY;
97
81
 
98
- /**
99
- * This package's ONE metrics call site: the `queue_depth` series `docker/helm`'s worker HPA
100
- * scales on. `ready` and not `ready + delayed` — the gauge means "waiting to be picked up", and
101
- * a job parked until Tuesday is not backlog no matter how many workers are added. Every queue
102
- * the driver reports, not only the ones this process serves, because depth is the queue's fact
103
- * and a queue no pod published is a queue no autoscaler can see.
104
- */
105
- const publishQueueDepth = async (): Promise<void> => {
106
- const now = nowMs(options.clock);
107
- if (now - depthPublishedAt < QUEUE_DEPTH_INTERVAL_MS) return;
108
- depthPublishedAt = now;
109
- try {
110
- for (const stat of await options.driver.stats()) {
111
- recordQueueDepth(stat.queue, stat.ready);
112
- // Depth alone is not alertable: it cannot tell "10 jobs stuck for an hour" from "10 jobs
113
- // enqueued a second ago", and `jobs_total{outcome="dead"}` is a rate, so a dead-letter
114
- // queue that filled overnight and stopped growing pages nobody. Both numbers are already
115
- // in `stats()` — this queries nothing new.
116
- recordQueueOldestReady(stat.queue, stat.oldestReadyMs);
117
- recordQueueDeadJobs(stat.queue, stat.dead);
118
- }
119
- } catch (error) {
120
- // Instrumentation never costs a tick: a queue that cannot be measured must still be worked.
121
- logger.warn('jobs.worker.depth-failed', {
122
- workerId,
123
- error: renderThrowable(error),
124
- });
125
- }
126
- };
82
+ /** `queue_depth` and its two siblings, republished on their own interval (`worker-queue-depth.ts`). */
83
+ const publishQueueDepth = createQueueDepthPublisher({
84
+ driver: options.driver,
85
+ clock: options.clock,
86
+ workerId,
87
+ });
127
88
 
128
89
  /** One claimed job, run under its lease, its slot and its span. `worker-run.ts` owns the wiring. */
129
90
  const runClaimed = (claimed: ClaimedJob): Promise<JobExecution> =>
@@ -189,7 +150,7 @@ export function createWorker(options: WorkerOptions): Worker {
189
150
  workerId,
190
151
  });
191
152
 
192
- for (const job of claimed) {
153
+ for (const [index, job] of claimed.entries()) {
193
154
  const lease = limiter.tryAcquire({
194
155
  queue,
195
156
  ...(job.tenantId === undefined ? {} : { tenantId: job.tenantId }),
@@ -199,14 +160,23 @@ export function createWorker(options: WorkerOptions): Worker {
199
160
  // and no `error` — the row stays in the ready bucket the depth gauge reads, and nothing
200
161
  // about this job failed, so `x jobs show` must not report a `lastError` for it. The
201
162
  // reason is a log FIELD instead, where it costs nothing when nobody is asking.
202
- await shed(job, {
203
- queue,
204
- reason:
205
- limiter.blockedBy({
206
- queue,
207
- ...(job.tenantId === undefined ? {} : { tenantId: job.tenantId }),
208
- }) ?? 'unknown',
209
- });
163
+ try {
164
+ await shed(job, {
165
+ queue,
166
+ reason:
167
+ limiter.blockedBy({
168
+ queue,
169
+ ...(job.tenantId === undefined ? {} : { tenantId: job.tenantId }),
170
+ }) ?? 'unknown',
171
+ });
172
+ } catch (error) {
173
+ // The nack itself failed: the jobs BEHIND it go back before the round reports.
174
+ await handBack(options.driver, claimed.slice(index + 1), {
175
+ delayMs: pollIntervalMs,
176
+ workerId,
177
+ });
178
+ throw error;
179
+ }
210
180
  continue;
211
181
  }
212
182
 
@@ -224,14 +194,29 @@ export function createWorker(options: WorkerOptions): Worker {
224
194
  granted = await fleetSlots.acquire(job);
225
195
  } catch (error) {
226
196
  lease.release();
197
+ // This job and every one behind it in the batch go BACK, unburned — rethrowing alone
198
+ // stranded them in `running` with an attempt spent on work that never started. Then the
199
+ // round's own catch reports it, once.
200
+ await handBack(options.driver, claimed.slice(index), {
201
+ delayMs: pollIntervalMs,
202
+ workerId,
203
+ });
227
204
  throw error;
228
205
  }
229
206
  if (!granted) {
230
207
  lease.release();
231
- await shed(job, {
232
- queue,
233
- reason: `job concurrency (${getJob(job.name)?.concurrency ?? 0})`,
234
- });
208
+ try {
209
+ await shed(job, {
210
+ queue,
211
+ reason: `job concurrency (${getJob(job.name)?.concurrency ?? 0})`,
212
+ });
213
+ } catch (error) {
214
+ await handBack(options.driver, claimed.slice(index + 1), {
215
+ delayMs: pollIntervalMs,
216
+ workerId,
217
+ });
218
+ throw error;
219
+ }
235
220
  continue;
236
221
  }
237
222