@ultimat3/jobs 20.2.1 → 22.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 +198 -927
- package/README.md +56 -6
- package/package.json +6 -6
- package/src/driver-memory.ts +31 -6
- package/src/driver-pg-jobs-sql.ts +34 -3
- package/src/driver-pg-sql.ts +5 -4
- package/src/driver-pg.ts +25 -13
- package/src/driver.ts +16 -1
- package/src/errors-declaration.ts +34 -0
- package/src/errors-requeue.ts +34 -0
- package/src/errors.ts +18 -4
- package/src/index.ts +2 -0
- package/src/job.ts +22 -3
- package/src/scheduler.ts +59 -38
- package/src/steps-memory.ts +5 -1
- package/src/steps.ts +21 -2
- package/src/task.ts +6 -1
- package/src/webhook-errors.ts +5 -2
- package/src/webhook-target.ts +98 -0
- package/src/webhook.ts +57 -17
- package/src/worker-hand-back.ts +30 -0
- package/src/worker-queue-depth.ts +61 -0
- package/src/worker.ts +47 -62
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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
|