@ultimat3/jobs 23.0.0 → 25.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 -75
- package/README.md +83 -38
- package/package.json +7 -7
- package/src/backfill-errors.ts +3 -2
- package/src/backfill-ledger.ts +25 -3
- package/src/backfill-pass.ts +22 -5
- package/src/backfill-rate.ts +2 -2
- package/src/backfill.ts +2 -2
- package/src/claim-exhausted.ts +76 -0
- package/src/counters.ts +1 -1
- package/src/driver-memory-operator.ts +34 -12
- package/src/driver-memory.ts +80 -38
- package/src/driver-pg-jobs-sql.ts +27 -19
- package/src/driver-pg-operator-sql.ts +32 -12
- package/src/driver-pg-operator.ts +19 -3
- package/src/driver-pg-rows.ts +3 -3
- package/src/driver-pg-settle-sql.ts +2 -3
- package/src/driver-pg-sql.ts +99 -30
- package/src/driver-pg.ts +103 -98
- package/src/driver.ts +46 -16
- package/src/errors-concurrency.ts +1 -1
- package/src/errors-operator.ts +12 -4
- package/src/errors-requeue.ts +21 -3
- package/src/errors.ts +9 -17
- package/src/events-pg.ts +11 -21
- package/src/events.ts +26 -15
- package/src/execute.ts +47 -5
- package/src/export.ts +2 -2
- package/src/heartbeat.ts +7 -2
- package/src/index.ts +45 -99
- package/src/inspect-operator.ts +4 -4
- package/src/inspect.ts +5 -4
- package/src/introspection.ts +23 -11
- package/src/job.ts +6 -7
- package/src/leases.ts +1 -1
- package/src/limits.ts +46 -15
- package/src/outbox-lease.ts +1 -1
- package/src/outbox-pg.ts +6 -7
- package/src/outbox-relay.ts +6 -6
- package/src/outbox.ts +47 -19
- package/src/purge.ts +1 -1
- package/src/queue-wake.ts +1 -2
- package/src/renewal-timer.ts +1 -1
- package/src/retry-classification.ts +52 -15
- package/src/scheduler-leader.ts +2 -2
- package/src/scheduler-pg.ts +19 -16
- package/src/scheduler-state.ts +10 -3
- package/src/scheduler.ts +14 -15
- package/src/steps-memory.ts +2 -2
- package/src/steps-suspension.ts +46 -0
- package/src/steps-timeout.ts +33 -0
- package/src/steps.ts +90 -88
- package/src/task.ts +1 -1
- package/src/webhook-attempt.ts +206 -0
- package/src/webhook-ledger.ts +15 -4
- package/src/webhook.ts +64 -118
- package/src/worker-admit.ts +14 -6
- package/src/worker-drain-cutoff.ts +110 -0
- package/src/worker-fleet-slots.ts +82 -45
- package/src/worker-held.ts +100 -0
- package/src/worker-key-busy.ts +38 -10
- package/src/worker-loop.ts +35 -11
- package/src/worker-options.ts +19 -15
- package/src/worker-registry.ts +16 -1
- package/src/worker-run.ts +38 -8
- package/src/worker-tally.ts +53 -0
- package/src/worker-types.ts +7 -4
- package/src/worker.ts +124 -73
- package/src/driver-nats.ts +0 -86
- package/src/driver-redis.ts +0 -89
package/src/driver-pg.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// one lease instead of one job. The statements themselves live in `driver-pg-sql.ts`, the schema
|
|
5
5
|
// they run against in `driver-pg-ddl.ts`, and the row-to-record decoding in `driver-pg-rows.ts`.
|
|
6
6
|
|
|
7
|
-
import type { Clock } from '@ultimat3/core';
|
|
8
|
-
import { finiteCount, systemClock,
|
|
7
|
+
import type { Clock, PgExecutor } from '@ultimat3/core';
|
|
8
|
+
import { finiteCount, renderCauseValue, systemClock, uuidV7 } from '@ultimat3/core';
|
|
9
9
|
import type { BackfillLedger } from './backfill-ledger';
|
|
10
|
+
import { listedRunId } from './backfill-ledger';
|
|
10
11
|
import { nowMs } from './clock';
|
|
11
12
|
import { nackOutcome } from './counters';
|
|
12
13
|
import type {
|
|
@@ -57,34 +58,19 @@ import {
|
|
|
57
58
|
SQL_STEP_GET,
|
|
58
59
|
SQL_STEP_LIST,
|
|
59
60
|
SQL_STEP_PUT,
|
|
60
|
-
SQL_STEPS_FROM,
|
|
61
61
|
SQL_TRY_ADVISORY_LOCK,
|
|
62
62
|
} from './driver-pg-sql';
|
|
63
|
-
import { DriverUnavailableError, JobDuplicateError } from './errors';
|
|
64
|
-
import { JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
|
|
63
|
+
import { DriverUnavailableError, JobDuplicateError, LeaseLostError } from './errors';
|
|
64
|
+
import { JobNotFoundError, JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
|
|
65
65
|
import type { JobIntrospection } from './introspection';
|
|
66
66
|
import { MAX_ERROR_STACK_LENGTH } from './introspection';
|
|
67
67
|
import type { HeldLease, LeaseStore } from './leases';
|
|
68
68
|
import type { StepStore } from './steps';
|
|
69
69
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
* no database CLIENT: `@ultimat3/db` is imported for `expectedQueryLoop` — the marker that tells
|
|
73
|
-
* the N+1 detector a step write is one-per-step by design (`steps.ts`) — and never for a
|
|
74
|
-
* connection.
|
|
75
|
-
*
|
|
76
|
-
* **Not satisfied by `Bun.sql`** — verified against Bun 1.4.0: `Bun.sql.query` is `undefined`.
|
|
77
|
-
* `Bun.sql` is a tagged template whose positional form is `unsafe`, so a `{ executor: Bun.sql }`
|
|
78
|
-
* would `TypeError` on the first claim. What satisfies it is a one-line adapter over a client that
|
|
79
|
-
* already speaks `(text, values)` — `@ultimat3/cli`'s `pgExecutorFor(client)` is the framework's
|
|
80
|
-
* own, wrapping `@ultimat3/db`'s `DbClient.query({ text, values })` — and a `DbTx`, which is a
|
|
81
|
-
* client on the transaction's own connection.
|
|
82
|
-
*/
|
|
83
|
-
export interface PgExecutor {
|
|
84
|
-
query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
|
|
85
|
-
}
|
|
70
|
+
/** How often `enqueue` sends its insert before it calls a refusal an index fault, not a race. */
|
|
71
|
+
const ENQUEUE_ATTEMPTS = 2;
|
|
86
72
|
|
|
87
|
-
export interface
|
|
73
|
+
export interface PostgresJobDriverOptions {
|
|
88
74
|
readonly executor?: PgExecutor;
|
|
89
75
|
readonly clock?: Clock;
|
|
90
76
|
}
|
|
@@ -95,20 +81,21 @@ function resolveExecutor(injected: PgExecutor | undefined): PgExecutor {
|
|
|
95
81
|
driver: 'pg',
|
|
96
82
|
// `Bun.sql` is named nowhere in this function and never was: there is no ambient fallback to
|
|
97
83
|
// be "not configured". An executor is injected by the boot or the driver has none.
|
|
98
|
-
cause:
|
|
99
|
-
|
|
84
|
+
cause:
|
|
85
|
+
'postgresJobDriver() was called with no executor, and this driver has no ambient fallback',
|
|
86
|
+
fix: 'set DATABASE_URL in .env so the boot builds one — x db migrate then x dev — or hand this process a queue with no database: setJobDriver(memoryJobDriver())',
|
|
100
87
|
});
|
|
101
88
|
}
|
|
102
89
|
|
|
103
|
-
function
|
|
90
|
+
function postgresStepStore(exec: () => PgExecutor): StepStore {
|
|
104
91
|
return {
|
|
105
92
|
async get(runId, name) {
|
|
106
93
|
const rows = await exec().query<StepRow>(SQL_STEP_GET, [runId, name]);
|
|
107
94
|
const row = rows[0];
|
|
108
95
|
return row === undefined ? undefined : toStepRecord(row);
|
|
109
96
|
},
|
|
110
|
-
async put(record) {
|
|
111
|
-
await exec().query(SQL_STEP_PUT, [
|
|
97
|
+
async put(record, by) {
|
|
98
|
+
const written = await exec().query(SQL_STEP_PUT, [
|
|
112
99
|
record.runId,
|
|
113
100
|
record.name,
|
|
114
101
|
record.status,
|
|
@@ -120,7 +107,14 @@ function pgStepStore(exec: () => PgExecutor): StepStore {
|
|
|
120
107
|
record.correlationKey ?? null,
|
|
121
108
|
record.attempts,
|
|
122
109
|
record.error ?? null,
|
|
110
|
+
by?.jobId ?? null,
|
|
111
|
+
by?.workerId ?? null,
|
|
112
|
+
by?.claim ?? null,
|
|
123
113
|
]);
|
|
114
|
+
// No row back is the fence refusing: the claim this write was made under is gone.
|
|
115
|
+
if (by !== undefined && written.length === 0) {
|
|
116
|
+
throw new LeaseLostError({ job: by.job, jobId: by.jobId });
|
|
117
|
+
}
|
|
124
118
|
},
|
|
125
119
|
async list(runId) {
|
|
126
120
|
const rows = await exec().query<StepRow>(SQL_STEP_LIST, [runId]);
|
|
@@ -135,7 +129,7 @@ function pgStepStore(exec: () => PgExecutor): StepStore {
|
|
|
135
129
|
};
|
|
136
130
|
}
|
|
137
131
|
|
|
138
|
-
function
|
|
132
|
+
function postgresBackfillLedger(exec: () => PgExecutor): BackfillLedger {
|
|
139
133
|
return {
|
|
140
134
|
async start(run) {
|
|
141
135
|
await exec().query(SQL_BACKFILL_START, [run.runId, run.name, run.checksum, run.appVersion]);
|
|
@@ -147,10 +141,12 @@ function pgBackfillLedger(exec: () => PgExecutor): BackfillLedger {
|
|
|
147
141
|
await exec().query(SQL_BACKFILL_FINISH, [runId, at.status, at.rows]);
|
|
148
142
|
},
|
|
149
143
|
async list(filter = {}) {
|
|
144
|
+
// Before the statement: `$3::uuid` raises a raw 22P02 for anything the screen refuses.
|
|
145
|
+
const runId = listedRunId('the pg ledger', filter.runId);
|
|
150
146
|
const rows = await exec().query<BackfillRow>(SQL_BACKFILL_LIST, [
|
|
151
147
|
filter.name ?? null,
|
|
152
148
|
filter.status ?? null,
|
|
153
|
-
|
|
149
|
+
runId ?? null,
|
|
154
150
|
finiteCount('the pg driver list', 'limit', filter.limit ?? 100),
|
|
155
151
|
]);
|
|
156
152
|
return rows.map(toBackfillRun);
|
|
@@ -162,7 +158,7 @@ function pgBackfillLedger(exec: () => PgExecutor): BackfillLedger {
|
|
|
162
158
|
* Fleet-wide slots over `x_job_leases`. Every decision is ONE statement — the `(lease_key, slot)`
|
|
163
159
|
* primary key is what serialises two workers, so nothing here reads a count and then acts on it.
|
|
164
160
|
*/
|
|
165
|
-
function
|
|
161
|
+
function postgresLeaseStore(exec: () => PgExecutor): LeaseStore {
|
|
166
162
|
return {
|
|
167
163
|
async acquire(key, limit, ttlMs, holder) {
|
|
168
164
|
if (limit <= 0) return undefined;
|
|
@@ -201,7 +197,7 @@ function pgLeaseStore(exec: () => PgExecutor): LeaseStore {
|
|
|
201
197
|
};
|
|
202
198
|
}
|
|
203
199
|
|
|
204
|
-
export function
|
|
200
|
+
export function postgresJobDriver(options: PostgresJobDriverOptions = {}): JobDriver {
|
|
205
201
|
const clock = options.clock ?? systemClock;
|
|
206
202
|
let executor: PgExecutor | undefined;
|
|
207
203
|
const exec = (): PgExecutor => {
|
|
@@ -226,13 +222,9 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
226
222
|
},
|
|
227
223
|
async requeue(jobId, requeueOptions) {
|
|
228
224
|
const current = await job(jobId);
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
cause: `job ${jobId} does not exist`,
|
|
233
|
-
fix: 'x jobs ls --state dead --json',
|
|
234
|
-
});
|
|
235
|
-
}
|
|
225
|
+
// The one answer both drivers give an id nobody queued — it was `X_DRIVER_UNAVAILABLE`
|
|
226
|
+
// here, from a driver that was answering perfectly well.
|
|
227
|
+
if (current === undefined) throw new JobNotFoundError({ jobId, driver: 'pg' });
|
|
236
228
|
// Both refusals BEFORE anything is deleted or updated — a refused requeue changes nothing.
|
|
237
229
|
if (!REQUEUEABLE_STATES.has(current.state)) {
|
|
238
230
|
throw new JobNotRequeueableError({ jobId, state: current.state });
|
|
@@ -245,10 +237,12 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
245
237
|
]);
|
|
246
238
|
const holder = holders[0];
|
|
247
239
|
if (holder !== undefined) throw requeueKeyTaken({ ...current, holderId: holder.id });
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
240
|
+
// ONE statement moves the row and drops its steps: nothing is deleted for a requeue that
|
|
241
|
+
// did not land.
|
|
242
|
+
const rows = await exec().query<JobRow>(SQL_JOB_REQUEUE, [
|
|
243
|
+
jobId,
|
|
244
|
+
requeueOptions?.fromStep ?? null,
|
|
245
|
+
]);
|
|
252
246
|
const row = rows[0];
|
|
253
247
|
// Read as finished a moment ago and live now: another requeue won the race.
|
|
254
248
|
if (row === undefined) throw new JobNotRequeueableError({ jobId, state: 'live' });
|
|
@@ -263,68 +257,75 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
263
257
|
|
|
264
258
|
return {
|
|
265
259
|
name: 'pg',
|
|
266
|
-
steps:
|
|
267
|
-
backfills:
|
|
268
|
-
leases:
|
|
260
|
+
steps: postgresStepStore(exec),
|
|
261
|
+
backfills: postgresBackfillLedger(exec),
|
|
262
|
+
leases: postgresLeaseStore(exec),
|
|
269
263
|
introspect,
|
|
270
264
|
|
|
271
265
|
async enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
|
|
272
266
|
const runAt = request.runAt ?? nowMs(clock);
|
|
273
|
-
const
|
|
274
|
-
request.id ??
|
|
267
|
+
const params = [
|
|
268
|
+
request.id ?? uuidV7(),
|
|
275
269
|
request.name,
|
|
276
270
|
request.queue || DEFAULT_QUEUE,
|
|
277
271
|
JSON.stringify(request.input ?? null),
|
|
278
272
|
request.idempotencyKey,
|
|
279
|
-
request.runId ??
|
|
273
|
+
request.runId ?? uuidV7(),
|
|
280
274
|
request.maxAttempts,
|
|
281
275
|
runAt,
|
|
282
276
|
request.tenantId ?? null,
|
|
283
277
|
request.traceparent ?? null,
|
|
284
278
|
request.enqueuedBy ?? null,
|
|
285
|
-
]
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
279
|
+
];
|
|
280
|
+
// TWICE at most. The insert and the lookup below are two statements, and the live holder
|
|
281
|
+
// of the key can settle between them: the insert met it, the lookup did not. That is a key
|
|
282
|
+
// that just came free, so the insert is sent once more and lands. A second miss is not a
|
|
283
|
+
// race any more — see the refusal.
|
|
284
|
+
for (let attempt = 1; attempt <= ENQUEUE_ATTEMPTS; attempt += 1) {
|
|
285
|
+
const rows = await exec().query<{ id: string; run_id: string }>(SQL_ENQUEUE, params);
|
|
286
|
+
const inserted = rows[0];
|
|
287
|
+
if (inserted !== undefined) {
|
|
288
|
+
return { id: inserted.id, runId: inserted.run_id, deduped: false };
|
|
289
|
+
}
|
|
290
290
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
291
|
+
// Nothing inserted under an id the CALLER allocated: the outbox publishing one staged row
|
|
292
|
+
// a second time. The job its first publish made is the answer, whatever state it reached.
|
|
293
|
+
if (request.id !== undefined) {
|
|
294
|
+
const published = await exec().query<{ id: string; run_id: string }>(
|
|
295
|
+
SQL_OUTBOX_PUBLISHED_JOB,
|
|
296
|
+
[request.id],
|
|
297
|
+
);
|
|
298
|
+
const made = published[0];
|
|
299
|
+
if (made !== undefined) return { id: made.id, runId: made.run_id, deduped: true };
|
|
300
|
+
}
|
|
301
301
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
idempotencyKey: request.idempotencyKey,
|
|
324
|
-
existingId: found.id,
|
|
325
|
-
});
|
|
302
|
+
// `do nothing` fired: a live job OF THIS NAME, IN THIS TENANT, already owns this
|
|
303
|
+
// idempotency key. Both are in the lookup because both are in the index — without the
|
|
304
|
+
// name this returned whichever other job derived the same natural key; without the tenant
|
|
305
|
+
// it returned another TENANT's row, so the caller's work silently never ran AND the caller
|
|
306
|
+
// was handed an id it has no right to, on a surface (`cancel`) that takes an id with no
|
|
307
|
+
// tenant predicate.
|
|
308
|
+
const existing = await exec().query<{ id: string; run_id: string }>(SQL_FIND_LIVE_BY_KEY, [
|
|
309
|
+
request.name,
|
|
310
|
+
request.idempotencyKey,
|
|
311
|
+
request.tenantId ?? null,
|
|
312
|
+
]);
|
|
313
|
+
const found = existing[0];
|
|
314
|
+
if (found === undefined) continue;
|
|
315
|
+
if (request.onConflict === 'error') {
|
|
316
|
+
throw new JobDuplicateError({
|
|
317
|
+
job: request.name,
|
|
318
|
+
idempotencyKey: request.idempotencyKey,
|
|
319
|
+
existingId: found.id,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return { id: found.id, runId: found.run_id, deduped: true };
|
|
326
323
|
}
|
|
327
|
-
|
|
324
|
+
throw new DriverUnavailableError({
|
|
325
|
+
driver: 'pg',
|
|
326
|
+
cause: `enqueue of ${renderCauseValue(request.name)} was rejected ${ENQUEUE_ATTEMPTS} times running and no live row holds its idempotency key either time — one miss is a holder that settled between the insert and the lookup, and is retried; two is an index the lookup does not agree with, and x db migrate reapplies SQL_JOBS_TABLE, whose x_jobs_name_tenant_idempotency_live_idx is what the lookup reads`,
|
|
327
|
+
fix: 'x db migrate',
|
|
328
|
+
});
|
|
328
329
|
},
|
|
329
330
|
|
|
330
331
|
async claim(claimOptions: ClaimOptions): Promise<readonly ClaimedJob[]> {
|
|
@@ -335,18 +336,23 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
335
336
|
claimOptions.limit,
|
|
336
337
|
claimOptions.workerId,
|
|
337
338
|
claimOptions.visibilityTimeoutMs,
|
|
339
|
+
claimOptions.dropExhausted ?? [],
|
|
338
340
|
]);
|
|
339
341
|
const at = nowMs(clock);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
342
|
+
// Both arms of the statement answer, told apart by the state each wrote: `running` is a
|
|
343
|
+
// claim, `dead` a row the claim buried — reported, never handed out as work.
|
|
344
|
+
const records = rows.map(toJobRecord);
|
|
345
|
+
const buried = records.filter((record) => record.state !== 'running');
|
|
346
|
+
if (buried.length > 0) claimOptions.onExhausted?.(buried);
|
|
347
|
+
return records
|
|
348
|
+
.filter((record) => record.state === 'running')
|
|
349
|
+
.map((record) => ({
|
|
343
350
|
...record,
|
|
344
351
|
claimedBy: claimOptions.workerId,
|
|
345
352
|
claim: record.claim ?? 0,
|
|
346
353
|
claimedAt: at,
|
|
347
354
|
visibleAt: record.visibleAt ?? at + claimOptions.visibilityTimeoutMs,
|
|
348
|
-
};
|
|
349
|
-
});
|
|
355
|
+
}));
|
|
350
356
|
},
|
|
351
357
|
|
|
352
358
|
// Both settles answer whether they LANDED: no row back is a settle from a worker that no
|
|
@@ -357,7 +363,6 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
357
363
|
by.workerId,
|
|
358
364
|
Math.round(by.durationMs ?? 0),
|
|
359
365
|
by.claim,
|
|
360
|
-
by.counted !== false,
|
|
361
366
|
]);
|
|
362
367
|
return rows.length > 0;
|
|
363
368
|
},
|
|
@@ -432,14 +437,14 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
432
437
|
* key would need a second `release()` — hence the `held` guard below, which makes repeated
|
|
433
438
|
* `acquire()` calls (the scheduler renews every round) a no-op rather than a leak.
|
|
434
439
|
*
|
|
435
|
-
* `@ultimat3/realtime`'s `
|
|
440
|
+
* `@ultimat3/realtime`'s `postgresAdvisoryLock()` is the shape that gets this right: it opens a connection
|
|
436
441
|
* of its own and *is* the lock. This package holds no wire protocol, so the executor it is handed
|
|
437
|
-
* is whatever boot built — which is a pool. **Use `
|
|
442
|
+
* is whatever boot built — which is a pool. **Use `postgresLeaseLeader` instead** unless you can
|
|
438
443
|
* prove the executor is a single dedicated session.
|
|
439
444
|
*/
|
|
440
|
-
export function
|
|
445
|
+
export function postgresLeader(
|
|
441
446
|
lockKey: number,
|
|
442
|
-
options:
|
|
447
|
+
options: PostgresJobDriverOptions = {},
|
|
443
448
|
): { acquire(): Promise<boolean>; release(): Promise<void>; readonly renewEveryMs: number } {
|
|
444
449
|
const exec = (): PgExecutor => resolveExecutor(options.executor);
|
|
445
450
|
let held = false;
|
package/src/driver.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// Six methods and no more: claim/ack/nack with a visibility timeout is the smallest set that
|
|
3
3
|
// survives a worker crash.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// There is no config line that picks a backend: boot always builds `postgresJobDriver()`, and a
|
|
6
|
+
// stale `jobs.driver` in `app.config.ts` is refused by `defineConfig` (25.0.0; it was read by
|
|
7
|
+
// nothing since 5.0.0). `postgresJobDriver` and `memoryJobDriver` are the two that exist — 25.0.0
|
|
8
|
+
// deleted the all-throw `redis` and `nats` stubs. Swapping the driver is `setJobDriver(other)` and
|
|
9
|
+
// ZERO job-code change, and that is what the interface buys.
|
|
10
10
|
|
|
11
11
|
import { finiteCount, finiteOption } from '@ultimat3/core';
|
|
12
12
|
import type { BackfillLedger } from './backfill-ledger';
|
|
@@ -44,6 +44,27 @@ export const REQUEUEABLE_STATES: ReadonlySet<JobState> = new Set<JobState>([
|
|
|
44
44
|
'failed',
|
|
45
45
|
]);
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The states a row is still WORK in — queued, held or parked. One set, read by the idempotency
|
|
49
|
+
* namespace (a key is taken only while a live row holds it) and by `cancel`, which stops live work
|
|
50
|
+
* and refuses everything else: cancelling a dead letter rewrote the record of a failure into the
|
|
51
|
+
* record of an operator's decision. The pg statements spell the same four.
|
|
52
|
+
*/
|
|
53
|
+
export const LIVE_STATES: ReadonlySet<JobState> = new Set<JobState>([
|
|
54
|
+
'ready',
|
|
55
|
+
'delayed',
|
|
56
|
+
'running',
|
|
57
|
+
'suspended',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* What a row's `lastError` says when the CLAIM buried it: its lease lapsed on the attempt that was
|
|
62
|
+
* its last, so nothing ever settled it and nothing may run it again. Fixed text, written by the
|
|
63
|
+
* claim statement itself — no apostrophe, because it is spliced into SQL as a literal.
|
|
64
|
+
*/
|
|
65
|
+
export const LEASE_LAPSED_FINAL_ATTEMPT =
|
|
66
|
+
'the lease lapsed on the final attempt: the worker holding this job stopped renewing it and never settled it, and no attempts remain';
|
|
67
|
+
|
|
47
68
|
/** Narrows a state read back off a queue row. Never a cast — the list decides. */
|
|
48
69
|
export const isJobState = (value: string): value is JobState =>
|
|
49
70
|
(JOB_STATES as readonly string[]).includes(value);
|
|
@@ -136,7 +157,7 @@ export interface EnqueueResult {
|
|
|
136
157
|
export interface ClaimOptions {
|
|
137
158
|
/**
|
|
138
159
|
* The queues this pass may take work from, by name. **At least one, and an empty list is
|
|
139
|
-
* refused** (`X_JOB_CLAIM_QUEUES_EMPTY`) — see `assertClaimQueues`. `
|
|
160
|
+
* refused** (`X_JOB_CLAIM_QUEUES_EMPTY`) — see `assertClaimQueues`. `jobWorker` passes exactly
|
|
140
161
|
* one per pass, which is what keeps a slow queue from starving the others.
|
|
141
162
|
*/
|
|
142
163
|
readonly queues: readonly string[];
|
|
@@ -144,6 +165,21 @@ export interface ClaimOptions {
|
|
|
144
165
|
/** Lease length. A worker that dies without ack makes the job claimable again after this. */
|
|
145
166
|
readonly visibilityTimeoutMs: number;
|
|
146
167
|
readonly workerId: string;
|
|
168
|
+
/**
|
|
169
|
+
* Told the rows this pass BURIED instead of claiming: a lease that lapsed on the row's final
|
|
170
|
+
* attempt is settled `dead` by the claim itself (`LEASE_LAPSED_FINAL_ATTEMPT`) and is never
|
|
171
|
+
* handed out, so a job whose worker dies on every attempt reaches the dead-letter queue rather
|
|
172
|
+
* than killing a worker per lease, forever. Called at most once per pass, after the statement
|
|
173
|
+
* landed, with the rows as they now stand — what the claim round announces `onSettled` from
|
|
174
|
+
* (`announceExhausted`). Omit it and the rows are buried all the same.
|
|
175
|
+
*/
|
|
176
|
+
readonly onExhausted?: (dead: readonly JobRecord[]) => void;
|
|
177
|
+
/**
|
|
178
|
+
* Job NAMES whose final failure is `failed`, never `dead` — the ones declaring
|
|
179
|
+
* `retry.deadLetter: false`. The row carries `max_attempts` and not the policy, so the caller
|
|
180
|
+
* that holds the registry says which rows a burial DROPS; a name not listed is dead-lettered.
|
|
181
|
+
*/
|
|
182
|
+
readonly dropExhausted?: readonly string[];
|
|
147
183
|
}
|
|
148
184
|
|
|
149
185
|
export interface ClaimedJob extends JobRecord {
|
|
@@ -185,14 +221,8 @@ export interface SettleBy extends ClaimIdentity {
|
|
|
185
221
|
readonly durationMs?: number;
|
|
186
222
|
}
|
|
187
223
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
* False when the row is finished WITHOUT its body having run here — `x jobs drain` moving it to
|
|
191
|
-
* another driver. It is settled `done` and adds nothing to the job's counters: a job that was
|
|
192
|
-
* moved is not a job that completed.
|
|
193
|
-
*/
|
|
194
|
-
readonly counted?: boolean;
|
|
195
|
-
}
|
|
224
|
+
/** An ack is a settle by the claimer: every ack is a completed run and adds to the counters. */
|
|
225
|
+
export type AckOptions = SettleBy;
|
|
196
226
|
|
|
197
227
|
export interface NackOptions extends SettleBy {
|
|
198
228
|
/** Delay before the job becomes claimable again. */
|
|
@@ -301,7 +331,7 @@ export interface JobDriver {
|
|
|
301
331
|
* Optional, like `introspect` and `backfills`: fleet-wide slot counting, which is the only thing
|
|
302
332
|
* that can make `job.concurrency` mean what its docstring says. The in-process limiter is a fast
|
|
303
333
|
* path over ONE heap and is multiplied by the replica count; this is the gate that is not.
|
|
304
|
-
* A driver without one can only hold the cap per process, so `
|
|
334
|
+
* A driver without one can only hold the cap per process, so `jobWorker().start()` THROWS
|
|
305
335
|
* `X_JOB_CONCURRENCY_UNENFORCEABLE` (`worker.ts`) naming every registered job that declared
|
|
306
336
|
* `concurrency` — refused rather than logged, because a documented guarantee that silently does
|
|
307
337
|
* nothing is worse than either alternative.
|
|
@@ -316,7 +346,7 @@ export const DEFAULT_VISIBILITY_TIMEOUT_MS = 30_000;
|
|
|
316
346
|
|
|
317
347
|
let ambient: JobDriver | undefined;
|
|
318
348
|
|
|
319
|
-
/** Set once at boot
|
|
349
|
+
/** Set once at boot over the database binding, never a config key. One instance per process. */
|
|
320
350
|
export function setJobDriver(driver: JobDriver): void {
|
|
321
351
|
ambient = driver;
|
|
322
352
|
}
|
|
@@ -85,7 +85,7 @@ export class ConcurrencyUnenforceableError extends UltimateError {
|
|
|
85
85
|
super({
|
|
86
86
|
code: 'X_JOB_CONCURRENCY_UNENFORCEABLE',
|
|
87
87
|
cause: `${input.jobs.join(', ')} declare concurrency and the "${input.driver}" jobs driver has no lease store, so the cap would hold per process and the fleet would run concurrency x replicas`,
|
|
88
|
-
fix: `remove concurrency from job("${input.jobs[0] ?? 'the job'}"), or call setJobDriver(
|
|
88
|
+
fix: `remove concurrency from job("${input.jobs[0] ?? 'the job'}"), or call setJobDriver(postgresJobDriver()) at boot — the pg driver is the one with a lease store`,
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
}
|
package/src/errors-operator.ts
CHANGED
|
@@ -77,14 +77,22 @@ export class JobOnSettledFailedError extends UltimateError {
|
|
|
77
77
|
* walks past it. `MAX_JOB_PAGE` is passed in — `introspection.ts` owns it and imports this file.
|
|
78
78
|
*/
|
|
79
79
|
export class JobPageInvalidError extends UltimateError {
|
|
80
|
-
constructor(input: {
|
|
80
|
+
constructor(input: {
|
|
81
|
+
subject: string;
|
|
82
|
+
maxPage: number;
|
|
83
|
+
limit?: number;
|
|
84
|
+
/** Which cursor was refused. Default `after`. */
|
|
85
|
+
cursor?: 'after' | 'before';
|
|
86
|
+
}) {
|
|
87
|
+
const cursor = input.cursor ?? 'after';
|
|
88
|
+
const source = cursor === 'after' ? 'jobCursor(lastRow)' : 'jobCursor(firstRow)';
|
|
81
89
|
super({
|
|
82
90
|
code: 'X_JOB_PAGE_INVALID',
|
|
83
91
|
cause:
|
|
84
92
|
input.limit === undefined
|
|
85
|
-
? `${input.subject} was handed an after cursor that no
|
|
86
|
-
: `${input.subject} limit is ${input.limit}, over MAX_JOB_PAGE (${input.maxPage}) — the rows one page may answer`,
|
|
87
|
-
fix: `
|
|
93
|
+
? `${input.subject} was handed ${cursor === 'after' ? 'an after' : 'a before'} cursor that no page produced — ${cursor}: ${source} of a page this driver answered is the only one it reads; omit it for the first page`
|
|
94
|
+
: `${input.subject} limit is ${input.limit}, over MAX_JOB_PAGE (${input.maxPage}) — the rows one page may answer; walk further with after: jobCursor(lastRow), and stop at a page shorter than its limit`,
|
|
95
|
+
fix: `x jobs ls --limit ${renderFixShellArg(String(input.maxPage), '200')} --json`,
|
|
88
96
|
meta: {
|
|
89
97
|
maxPage: input.maxPage,
|
|
90
98
|
...(input.limit === undefined ? {} : { limit: input.limit }),
|
package/src/errors-requeue.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// Single responsibility: the
|
|
2
|
-
// row
|
|
1
|
+
// Single responsibility: the three refusals `requeue` — `x jobs retry` — gives before it moves a
|
|
2
|
+
// row, raised identically by `driver-memory.ts` and `driver-pg.ts`: one answer for both drivers is
|
|
3
|
+
// the point, and neither driver imports the other. The codes are registered in `errors.ts`.
|
|
3
4
|
|
|
4
|
-
import { renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
5
|
+
import { renderCauseValue, renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
5
6
|
import { JobDuplicateError } from './errors';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -19,6 +20,23 @@ export class JobNotRequeueableError extends UltimateError {
|
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* `requeue` of an id the queue does not hold. ONE answer for both drivers: memory raised
|
|
25
|
+
* `X_INVARIANT` and pg `X_DRIVER_UNAVAILABLE` — a driver that is perfectly available — so a caller
|
|
26
|
+
* could not tell a mistyped id from an outage, and tested against one answer on `x dev` and met
|
|
27
|
+
* the other in production.
|
|
28
|
+
*/
|
|
29
|
+
export class JobNotFoundError extends UltimateError {
|
|
30
|
+
constructor(input: { jobId: string; driver: string }) {
|
|
31
|
+
super({
|
|
32
|
+
code: 'X_JOB_NOT_FOUND',
|
|
33
|
+
cause: `the "${input.driver}" queue holds no job with id ${renderCauseValue(input.jobId)} — a mistyped id, a job removed since, or an id from another environment`,
|
|
34
|
+
fix: 'x jobs ls --json',
|
|
35
|
+
meta: { jobId: input.jobId },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
/** The error a requeue gives when a LIVE job already holds the requeued row's key. */
|
|
23
41
|
export const requeueKeyTaken = (record: {
|
|
24
42
|
readonly id: string;
|
package/src/errors.ts
CHANGED
|
@@ -43,12 +43,13 @@ export const JOB_OWNED_ERROR_CODES = [
|
|
|
43
43
|
'X_JOB_NOT_PROMOTABLE',
|
|
44
44
|
'X_JOB_PAGE_INVALID',
|
|
45
45
|
'X_JOB_ON_SETTLED_FAILED',
|
|
46
|
+
'X_JOB_NOT_FOUND',
|
|
46
47
|
] as const;
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
|
-
* `X_NOT_IMPLEMENTED`, `X_ABORTED` and `X_DRAINING` are `@ultimat3/core`'s.
|
|
50
|
-
* `
|
|
51
|
-
* three, because the copy this file used to hold was a second title that nothing would have failed
|
|
50
|
+
* `X_NOT_IMPLEMENTED`, `X_ABORTED` and `X_DRAINING` are `@ultimat3/core`'s. The first is thrown as
|
|
51
|
+
* core's own `NotImplementedError` (`inspect.ts`), the other two by
|
|
52
|
+
* `JobAbortedError` and `JobDrainedError` below; jobs keeps no title for any of the three, because the copy this file used to hold was a second title that nothing would have failed
|
|
52
53
|
* on once core's changed. Listed here all the same, so `JobErrorCode` can name every code a job
|
|
53
54
|
* can see — `X_DRAINING` was thrown for a day before it was, and the type said it could not be.
|
|
54
55
|
*/
|
|
@@ -100,6 +101,7 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
|
|
|
100
101
|
X_JOB_NOT_PROMOTABLE: 'the job is not waiting on its run time',
|
|
101
102
|
X_JOB_PAGE_INVALID: 'a job list page was asked for outside its bounds',
|
|
102
103
|
X_JOB_ON_SETTLED_FAILED: 'the onSettled hook failed after the run was settled',
|
|
104
|
+
X_JOB_NOT_FOUND: 'the queue holds no job with this id',
|
|
103
105
|
};
|
|
104
106
|
|
|
105
107
|
// One unconditional call, so a second package claiming one of jobs' codes throws
|
|
@@ -432,13 +434,13 @@ export class JobNotCancellableError extends UltimateError {
|
|
|
432
434
|
}
|
|
433
435
|
}
|
|
434
436
|
|
|
435
|
-
/** The driver has no `introspect.cancel
|
|
437
|
+
/** The driver has no `introspect.cancel` — a hand-rolled driver that ships no introspection. */
|
|
436
438
|
export class CancelUnsupportedError extends UltimateError {
|
|
437
439
|
constructor(input: { driver: string }) {
|
|
438
440
|
super({
|
|
439
441
|
code: 'X_JOB_NOT_CANCELLABLE',
|
|
440
442
|
cause: `the "${input.driver}" jobs driver cannot cancel a single job`,
|
|
441
|
-
fix: 'call setJobDriver(
|
|
443
|
+
fix: 'call setJobDriver(postgresJobDriver()) at boot — only the pg driver implements introspect.cancel — then: x jobs cancel <id> --json',
|
|
442
444
|
});
|
|
443
445
|
}
|
|
444
446
|
}
|
|
@@ -454,22 +456,12 @@ export class OutboxNoTxError extends UltimateError {
|
|
|
454
456
|
}
|
|
455
457
|
}
|
|
456
458
|
|
|
457
|
-
export class JobsNotImplementedError extends UltimateError {
|
|
458
|
-
constructor(input: { feature: string; fix: string }) {
|
|
459
|
-
super({
|
|
460
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
461
|
-
cause: `${input.feature} is declared but not implemented in @ultimat3/jobs`,
|
|
462
|
-
fix: input.fix,
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
459
|
/**
|
|
468
460
|
* `claim({ queues: [] })`, refused by every driver rather than answered by each.
|
|
469
461
|
*
|
|
470
462
|
* The two shipped drivers had two meanings for it — every queue on `driver-memory.ts`, the
|
|
471
463
|
* `default` queue on `driver-pg.ts` — and `ClaimOptions.queues` documented neither. Nothing reached
|
|
472
|
-
* it (`
|
|
464
|
+
* it (`jobWorker` passes exactly one queue per pass), so the divergence could only ever be found
|
|
473
465
|
* by an embedder in production, and each meaning is silently wrong in the other's deployment: one
|
|
474
466
|
* takes work this worker was never configured for, the other drains nothing and reads as an idle
|
|
475
467
|
* queue. There is no third meaning to pick — an empty list is a caller that has not said what to
|
|
@@ -480,7 +472,7 @@ export class ClaimQueuesEmptyError extends UltimateError {
|
|
|
480
472
|
super({
|
|
481
473
|
code: 'X_JOB_CLAIM_QUEUES_EMPTY',
|
|
482
474
|
cause: `the ${driver} driver was asked to claim with queues: [] — an empty list names no queue, and "every queue" and "the default queue" are different deployments`,
|
|
483
|
-
fix: "pass the queue by name — driver.claim({ queues: ['default'], limit, visibilityTimeoutMs, workerId }) — or read the list from config.jobs.queues, which is what
|
|
475
|
+
fix: "pass the queue by name — driver.claim({ queues: ['default'], limit, visibilityTimeoutMs, workerId }) — or read the list from config.jobs.queues, which is what jobWorker walks one queue at a time",
|
|
484
476
|
meta: { driver },
|
|
485
477
|
});
|
|
486
478
|
}
|