@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.
- package/CLAUDE.md +51 -52
- package/README.md +34 -5
- package/package.json +6 -6
- package/src/backfill-ledger.ts +24 -2
- package/src/backfill-pass.ts +22 -5
- package/src/claim-exhausted.ts +76 -0
- package/src/driver-memory-operator.ts +31 -9
- package/src/driver-memory.ts +68 -23
- package/src/driver-nats.ts +5 -2
- 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-sql.ts +98 -29
- package/src/driver-pg.ts +86 -81
- package/src/driver-redis.ts +5 -2
- package/src/driver.ts +37 -1
- package/src/errors-operator.ts +12 -4
- package/src/errors-requeue.ts +21 -3
- package/src/errors.ts +5 -13
- package/src/events-pg.ts +8 -18
- package/src/events.ts +20 -9
- package/src/execute.ts +1 -0
- package/src/index.ts +10 -4
- package/src/inspect-operator.ts +3 -3
- package/src/inspect.ts +4 -3
- package/src/introspection.ts +23 -11
- package/src/job.ts +5 -6
- package/src/limits.ts +33 -7
- package/src/outbox-pg.ts +1 -2
- package/src/outbox.ts +37 -9
- package/src/purge.ts +1 -1
- package/src/queue-wake.ts +1 -2
- package/src/scheduler-pg.ts +3 -2
- package/src/scheduler-state.ts +8 -1
- package/src/scheduler.ts +9 -10
- package/src/steps-suspension.ts +46 -0
- package/src/steps-timeout.ts +33 -0
- package/src/steps.ts +55 -81
- package/src/worker-admit.ts +7 -3
- package/src/worker-fleet-slots.ts +78 -44
- package/src/worker-key-busy.ts +38 -10
- package/src/worker-loop.ts +35 -11
- package/src/worker-registry.ts +16 -1
- package/src/worker.ts +52 -9
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, uuid } from '@ultimat3/core';
|
|
7
|
+
import type { Clock, PgExecutor } from '@ultimat3/core';
|
|
8
|
+
import { finiteCount, renderCauseValue, systemClock, uuid } 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,32 +58,17 @@ 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
73
|
export interface PgDriverOptions {
|
|
88
74
|
readonly executor?: PgExecutor;
|
|
@@ -107,8 +93,8 @@ function pgStepStore(exec: () => PgExecutor): StepStore {
|
|
|
107
93
|
const row = rows[0];
|
|
108
94
|
return row === undefined ? undefined : toStepRecord(row);
|
|
109
95
|
},
|
|
110
|
-
async put(record) {
|
|
111
|
-
await exec().query(SQL_STEP_PUT, [
|
|
96
|
+
async put(record, by) {
|
|
97
|
+
const written = await exec().query(SQL_STEP_PUT, [
|
|
112
98
|
record.runId,
|
|
113
99
|
record.name,
|
|
114
100
|
record.status,
|
|
@@ -120,7 +106,14 @@ function pgStepStore(exec: () => PgExecutor): StepStore {
|
|
|
120
106
|
record.correlationKey ?? null,
|
|
121
107
|
record.attempts,
|
|
122
108
|
record.error ?? null,
|
|
109
|
+
by?.jobId ?? null,
|
|
110
|
+
by?.workerId ?? null,
|
|
111
|
+
by?.claim ?? null,
|
|
123
112
|
]);
|
|
113
|
+
// No row back is the fence refusing: the claim this write was made under is gone.
|
|
114
|
+
if (by !== undefined && written.length === 0) {
|
|
115
|
+
throw new LeaseLostError({ job: by.job, jobId: by.jobId });
|
|
116
|
+
}
|
|
124
117
|
},
|
|
125
118
|
async list(runId) {
|
|
126
119
|
const rows = await exec().query<StepRow>(SQL_STEP_LIST, [runId]);
|
|
@@ -147,10 +140,12 @@ function pgBackfillLedger(exec: () => PgExecutor): BackfillLedger {
|
|
|
147
140
|
await exec().query(SQL_BACKFILL_FINISH, [runId, at.status, at.rows]);
|
|
148
141
|
},
|
|
149
142
|
async list(filter = {}) {
|
|
143
|
+
// Before the statement: `$3::uuid` raises a raw 22P02 for anything the screen refuses.
|
|
144
|
+
const runId = listedRunId('the pg ledger', filter.runId);
|
|
150
145
|
const rows = await exec().query<BackfillRow>(SQL_BACKFILL_LIST, [
|
|
151
146
|
filter.name ?? null,
|
|
152
147
|
filter.status ?? null,
|
|
153
|
-
|
|
148
|
+
runId ?? null,
|
|
154
149
|
finiteCount('the pg driver list', 'limit', filter.limit ?? 100),
|
|
155
150
|
]);
|
|
156
151
|
return rows.map(toBackfillRun);
|
|
@@ -226,13 +221,9 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
226
221
|
},
|
|
227
222
|
async requeue(jobId, requeueOptions) {
|
|
228
223
|
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
|
-
}
|
|
224
|
+
// The one answer both drivers give an id nobody queued — it was `X_DRIVER_UNAVAILABLE`
|
|
225
|
+
// here, from a driver that was answering perfectly well.
|
|
226
|
+
if (current === undefined) throw new JobNotFoundError({ jobId, driver: 'pg' });
|
|
236
227
|
// Both refusals BEFORE anything is deleted or updated — a refused requeue changes nothing.
|
|
237
228
|
if (!REQUEUEABLE_STATES.has(current.state)) {
|
|
238
229
|
throw new JobNotRequeueableError({ jobId, state: current.state });
|
|
@@ -245,10 +236,12 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
245
236
|
]);
|
|
246
237
|
const holder = holders[0];
|
|
247
238
|
if (holder !== undefined) throw requeueKeyTaken({ ...current, holderId: holder.id });
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
239
|
+
// ONE statement moves the row and drops its steps: nothing is deleted for a requeue that
|
|
240
|
+
// did not land.
|
|
241
|
+
const rows = await exec().query<JobRow>(SQL_JOB_REQUEUE, [
|
|
242
|
+
jobId,
|
|
243
|
+
requeueOptions?.fromStep ?? null,
|
|
244
|
+
]);
|
|
252
245
|
const row = rows[0];
|
|
253
246
|
// Read as finished a moment ago and live now: another requeue won the race.
|
|
254
247
|
if (row === undefined) throw new JobNotRequeueableError({ jobId, state: 'live' });
|
|
@@ -270,7 +263,7 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
270
263
|
|
|
271
264
|
async enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
|
|
272
265
|
const runAt = request.runAt ?? nowMs(clock);
|
|
273
|
-
const
|
|
266
|
+
const params = [
|
|
274
267
|
request.id ?? uuid(),
|
|
275
268
|
request.name,
|
|
276
269
|
request.queue || DEFAULT_QUEUE,
|
|
@@ -282,49 +275,56 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
282
275
|
request.tenantId ?? null,
|
|
283
276
|
request.traceparent ?? null,
|
|
284
277
|
request.enqueuedBy ?? null,
|
|
285
|
-
]
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
278
|
+
];
|
|
279
|
+
// TWICE at most. The insert and the lookup below are two statements, and the live holder
|
|
280
|
+
// of the key can settle between them: the insert met it, the lookup did not. That is a key
|
|
281
|
+
// that just came free, so the insert is sent once more and lands. A second miss is not a
|
|
282
|
+
// race any more — see the refusal.
|
|
283
|
+
for (let attempt = 1; attempt <= ENQUEUE_ATTEMPTS; attempt += 1) {
|
|
284
|
+
const rows = await exec().query<{ id: string; run_id: string }>(SQL_ENQUEUE, params);
|
|
285
|
+
const inserted = rows[0];
|
|
286
|
+
if (inserted !== undefined) {
|
|
287
|
+
return { id: inserted.id, runId: inserted.run_id, deduped: false };
|
|
288
|
+
}
|
|
290
289
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
290
|
+
// Nothing inserted under an id the CALLER allocated: the outbox publishing one staged row
|
|
291
|
+
// a second time. The job its first publish made is the answer, whatever state it reached.
|
|
292
|
+
if (request.id !== undefined) {
|
|
293
|
+
const published = await exec().query<{ id: string; run_id: string }>(
|
|
294
|
+
SQL_OUTBOX_PUBLISHED_JOB,
|
|
295
|
+
[request.id],
|
|
296
|
+
);
|
|
297
|
+
const made = published[0];
|
|
298
|
+
if (made !== undefined) return { id: made.id, runId: made.run_id, deduped: true };
|
|
299
|
+
}
|
|
301
300
|
|
|
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
|
-
});
|
|
301
|
+
// `do nothing` fired: a live job OF THIS NAME, IN THIS TENANT, already owns this
|
|
302
|
+
// idempotency key. Both are in the lookup because both are in the index — without the
|
|
303
|
+
// name this returned whichever other job derived the same natural key; without the tenant
|
|
304
|
+
// it returned another TENANT's row, so the caller's work silently never ran AND the caller
|
|
305
|
+
// was handed an id it has no right to, on a surface (`cancel`) that takes an id with no
|
|
306
|
+
// tenant predicate.
|
|
307
|
+
const existing = await exec().query<{ id: string; run_id: string }>(SQL_FIND_LIVE_BY_KEY, [
|
|
308
|
+
request.name,
|
|
309
|
+
request.idempotencyKey,
|
|
310
|
+
request.tenantId ?? null,
|
|
311
|
+
]);
|
|
312
|
+
const found = existing[0];
|
|
313
|
+
if (found === undefined) continue;
|
|
314
|
+
if (request.onConflict === 'error') {
|
|
315
|
+
throw new JobDuplicateError({
|
|
316
|
+
job: request.name,
|
|
317
|
+
idempotencyKey: request.idempotencyKey,
|
|
318
|
+
existingId: found.id,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return { id: found.id, runId: found.run_id, deduped: true };
|
|
326
322
|
}
|
|
327
|
-
|
|
323
|
+
throw new DriverUnavailableError({
|
|
324
|
+
driver: 'pg',
|
|
325
|
+
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`,
|
|
326
|
+
fix: 'x db migrate',
|
|
327
|
+
});
|
|
328
328
|
},
|
|
329
329
|
|
|
330
330
|
async claim(claimOptions: ClaimOptions): Promise<readonly ClaimedJob[]> {
|
|
@@ -335,18 +335,23 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
335
335
|
claimOptions.limit,
|
|
336
336
|
claimOptions.workerId,
|
|
337
337
|
claimOptions.visibilityTimeoutMs,
|
|
338
|
+
claimOptions.dropExhausted ?? [],
|
|
338
339
|
]);
|
|
339
340
|
const at = nowMs(clock);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
341
|
+
// Both arms of the statement answer, told apart by the state each wrote: `running` is a
|
|
342
|
+
// claim, `dead` a row the claim buried — reported, never handed out as work.
|
|
343
|
+
const records = rows.map(toJobRecord);
|
|
344
|
+
const buried = records.filter((record) => record.state !== 'running');
|
|
345
|
+
if (buried.length > 0) claimOptions.onExhausted?.(buried);
|
|
346
|
+
return records
|
|
347
|
+
.filter((record) => record.state === 'running')
|
|
348
|
+
.map((record) => ({
|
|
343
349
|
...record,
|
|
344
350
|
claimedBy: claimOptions.workerId,
|
|
345
351
|
claim: record.claim ?? 0,
|
|
346
352
|
claimedAt: at,
|
|
347
353
|
visibleAt: record.visibleAt ?? at + claimOptions.visibilityTimeoutMs,
|
|
348
|
-
};
|
|
349
|
-
});
|
|
354
|
+
}));
|
|
350
355
|
},
|
|
351
356
|
|
|
352
357
|
// Both settles answer whether they LANDED: no row back is a settle from a worker that no
|
package/src/driver-redis.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// (XADD / XREADGROUP / XACK), `XAUTOCLAIM` for the visibility timeout, a ZSET for delayed
|
|
7
7
|
// and suspended runs, and step records in a hash keyed by run id.
|
|
8
8
|
|
|
9
|
+
import { NotImplementedError } from '@ultimat3/core';
|
|
9
10
|
import type {
|
|
10
11
|
ClaimedJob,
|
|
11
12
|
ClaimOptions,
|
|
@@ -16,7 +17,6 @@ import type {
|
|
|
16
17
|
NackOptions,
|
|
17
18
|
QueueStats,
|
|
18
19
|
} from './driver';
|
|
19
|
-
import { JobsNotImplementedError } from './errors';
|
|
20
20
|
import type { StepRecord, StepStore } from './steps';
|
|
21
21
|
|
|
22
22
|
// Names the seam that actually replaces the stub, and NOTHING ELSE — the two other repairs this
|
|
@@ -37,7 +37,10 @@ const FIX =
|
|
|
37
37
|
'call setJobDriver(createPgDriver()) at boot instead of this driver; nothing needs moving first, because enqueue here refuses too, so no job was ever written to it';
|
|
38
38
|
|
|
39
39
|
const unavailable = (method: string): never => {
|
|
40
|
-
throw new
|
|
40
|
+
throw new NotImplementedError({
|
|
41
|
+
cause: `redis jobs driver (${method}) is not implemented`,
|
|
42
|
+
fix: FIX,
|
|
43
|
+
});
|
|
41
44
|
};
|
|
42
45
|
|
|
43
46
|
const redisStepStore = (): StepStore => ({
|
package/src/driver.ts
CHANGED
|
@@ -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);
|
|
@@ -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 {
|
|
@@ -316,7 +352,7 @@ export const DEFAULT_VISIBILITY_TIMEOUT_MS = 30_000;
|
|
|
316
352
|
|
|
317
353
|
let ambient: JobDriver | undefined;
|
|
318
354
|
|
|
319
|
-
/** Set once at boot
|
|
355
|
+
/** Set once at boot over the database binding, never a config key. One instance per process. */
|
|
320
356
|
export function setJobDriver(driver: JobDriver): void {
|
|
321
357
|
ambient = driver;
|
|
322
358
|
}
|
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 redis/nats stubs), 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
|
|
@@ -454,16 +456,6 @@ 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
|
*
|
package/src/events-pg.ts
CHANGED
|
@@ -6,16 +6,16 @@
|
|
|
6
6
|
// Stored and not broadcast, exactly as the memory bus is: a step that suspends at 12:00 and
|
|
7
7
|
// resumes at 12:00:30 must still see an event published at 12:00:10.
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
10
|
+
import { finiteOption, logger, uuid } from '@ultimat3/core';
|
|
10
11
|
import type { DurationInput } from './clock';
|
|
11
12
|
import { finiteDurationMs } from './clock';
|
|
12
|
-
import type { PgExecutor } from './driver-pg';
|
|
13
13
|
import {
|
|
14
14
|
SQL_EVENT_FIND,
|
|
15
15
|
SQL_EVENT_LIST,
|
|
16
16
|
SQL_EVENT_NOW,
|
|
17
17
|
SQL_EVENT_PUBLISH,
|
|
18
|
-
|
|
18
|
+
SQL_EVENT_PURGE_COUNTED,
|
|
19
19
|
} from './driver-pg-sql';
|
|
20
20
|
import type { EventBus } from './events';
|
|
21
21
|
|
|
@@ -40,13 +40,6 @@ export interface PgEventBusOptions {
|
|
|
40
40
|
readonly listLimit?: number;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/**
|
|
44
|
-
* `purgeExpired()` is SYNCHRONOUS in `EventBus` because the memory bus can be — it walks a Map.
|
|
45
|
-
* Over SQL the delete is a round trip, so this fires it and answers 0: the count is a diagnostic
|
|
46
|
-
* the memory bus offers and this one cannot, and blocking a step's resume on a housekeeping
|
|
47
|
-
* DELETE would be a far worse trade than an unanswered number. The index on `(name, published_at)`
|
|
48
|
-
* plus `expires_at > now()` in `find` means an unpurged row costs a filter, never a wrong answer.
|
|
49
|
-
*/
|
|
50
43
|
export function createPgEventBus(options: PgEventBusOptions): EventBus {
|
|
51
44
|
// TWO screens, for the reason `events.ts` states: `defaultTtl` is the constructor's knob and
|
|
52
45
|
// `ttl` is the publish call's, so one screen over `ttl ?? defaultTtl` names the wrong one for
|
|
@@ -59,14 +52,11 @@ export function createPgEventBus(options: PgEventBusOptions): EventBus {
|
|
|
59
52
|
const listLimit = finiteOption('the pg event bus', 'listLimit', options.listLimit ?? 1_000);
|
|
60
53
|
const exec = options.executor;
|
|
61
54
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
return 0;
|
|
55
|
+
// Awaited and never fired-and-forgotten: it was `void`ed with nothing calling it, so the table
|
|
56
|
+
// only grew. A failure REJECTS — the caller is the retention sweep's step, which retries.
|
|
57
|
+
const purgeExpired = async (): Promise<number> => {
|
|
58
|
+
const rows = await exec.query<{ removed: number | string }>(SQL_EVENT_PURGE_COUNTED, []);
|
|
59
|
+
return Number(rows[0]?.removed ?? 0);
|
|
70
60
|
};
|
|
71
61
|
|
|
72
62
|
return {
|
package/src/events.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Clock } from '@ultimat3/core';
|
|
|
6
6
|
import { finiteOption, logger, systemClock, uuid } from '@ultimat3/core';
|
|
7
7
|
import type { DurationInput } from './clock';
|
|
8
8
|
import { finiteDurationMs, nowMs } from './clock';
|
|
9
|
+
import type { PurgeTarget } from './purge';
|
|
9
10
|
import type { EventLookup } from './steps';
|
|
10
11
|
|
|
11
12
|
export interface JobEvent {
|
|
@@ -31,16 +32,14 @@ export interface EventBus extends EventLookup {
|
|
|
31
32
|
* split asks, and the reason `eventPrompt()` refuses the in-memory bus outside development.
|
|
32
33
|
*/
|
|
33
34
|
readonly stored: boolean;
|
|
34
|
-
/**
|
|
35
|
-
* The bus's OWN clock, in epoch ms: what a publish issued now would be stamped with. A consumer
|
|
36
|
-
* that wants "published after I asked" takes its "asked at" from here and never from its own
|
|
37
|
-
* process — `publishedAt` is this clock's, and two clocks compared is a skew between pods
|
|
38
|
-
* deciding which answers count.
|
|
39
|
-
*/
|
|
40
|
-
now(): Promise<number>;
|
|
41
35
|
publish(name: string, payload: unknown, options?: PublishOptions): Promise<JobEvent>;
|
|
42
36
|
list(name?: string): Promise<readonly JobEvent[]>;
|
|
43
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Delete every event past its expiry and answer how many went. Measured on the bus's own clock,
|
|
39
|
+
* like every other instant it holds. The memory bus also sweeps on each publish; the stored one
|
|
40
|
+
* has no caller but the retention sweep (`eventsPurgeTarget`).
|
|
41
|
+
*/
|
|
42
|
+
purgeExpired(): Promise<number>;
|
|
44
43
|
size(): number;
|
|
45
44
|
}
|
|
46
45
|
|
|
@@ -136,11 +135,23 @@ export function createMemoryEventBus(options: MemoryEventBusOptions = {}): Event
|
|
|
136
135
|
return Promise.resolve(all);
|
|
137
136
|
},
|
|
138
137
|
|
|
139
|
-
purgeExpired,
|
|
138
|
+
purgeExpired: () => Promise.resolve(purgeExpired()),
|
|
140
139
|
size: () => events.size,
|
|
141
140
|
};
|
|
142
141
|
}
|
|
143
142
|
|
|
143
|
+
/** The table the stored bus keeps — the sweep's durable step key, log field and report name. */
|
|
144
|
+
export const EVENTS_PURGE_TARGET = 'x_job_events';
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The bus as one table of the retention sweep (`purge()`). `x_job_events` takes a row per publish
|
|
148
|
+
* and an expired one is only FILTERED by `find`, so with no sweep the table grows for the life of
|
|
149
|
+
* the deployment. The sweep's `nowMs` is not passed on: a bus holds its own clock.
|
|
150
|
+
*/
|
|
151
|
+
export function eventsPurgeTarget(bus: EventBus): PurgeTarget {
|
|
152
|
+
return { name: EVENTS_PURGE_TARGET, purgeExpired: () => bus.purgeExpired() };
|
|
153
|
+
}
|
|
154
|
+
|
|
144
155
|
let ambientBus: EventBus = createMemoryEventBus();
|
|
145
156
|
|
|
146
157
|
/**
|
package/src/execute.ts
CHANGED
|
@@ -151,6 +151,7 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
151
151
|
runId: claimed.runId,
|
|
152
152
|
jobName: handle.name,
|
|
153
153
|
store: driver.steps,
|
|
154
|
+
fence: { job: handle.name, jobId: claimed.id, ...claimOf(claimed) },
|
|
154
155
|
signal,
|
|
155
156
|
// The DECLARED per-step ceiling and event poll. Passed here or nowhere: this is the only
|
|
156
157
|
// production construction of a runner, so a `StepRunnerOptions` field it omits is a feature
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import './register';
|
|
|
11
11
|
// the tier both halves can reach, because this package signs a delivery, `@ultimat3/http` verifies
|
|
12
12
|
// one, and neither may import the other. Re-exported here so a `job` file needs one import rather
|
|
13
13
|
// than two — the same reason `t` is re-exported above.
|
|
14
|
-
export type { WebhookMacInput, WebhookSigningInput } from '@ultimat3/core';
|
|
14
|
+
export type { PgExecutor, WebhookMacInput, WebhookSigningInput } from '@ultimat3/core';
|
|
15
15
|
export {
|
|
16
16
|
isCanonicalWebhookField,
|
|
17
17
|
WEBHOOK_FIELD_MAX,
|
|
@@ -84,6 +84,8 @@ export {
|
|
|
84
84
|
isBackfill,
|
|
85
85
|
registeredBackfills,
|
|
86
86
|
} from './backfill-registry';
|
|
87
|
+
export type { AnnounceExhaustedOptions, ExhaustedCounts } from './claim-exhausted';
|
|
88
|
+
export { announceExhausted } from './claim-exhausted';
|
|
87
89
|
export type { JobConcurrency, KeyedConcurrency, WhenBusy } from './concurrency';
|
|
88
90
|
export { MAX_CONCURRENCY_KEY_LENGTH, WHEN_BUSY } from './concurrency';
|
|
89
91
|
export type { JobConcurrencyDescriptor, JobDescriptor } from './describe';
|
|
@@ -112,6 +114,8 @@ export {
|
|
|
112
114
|
isJobState,
|
|
113
115
|
JOB_STATES,
|
|
114
116
|
jobDriver,
|
|
117
|
+
LEASE_LAPSED_FINAL_ATTEMPT,
|
|
118
|
+
LIVE_STATES,
|
|
115
119
|
nackState,
|
|
116
120
|
resetJobDriver,
|
|
117
121
|
setJobDriver,
|
|
@@ -120,7 +124,7 @@ export type { MemoryDriverOptions, MemoryJobDriver } from './driver-memory';
|
|
|
120
124
|
export { createMemoryDriver } from './driver-memory';
|
|
121
125
|
export type { NatsDriverOptions } from './driver-nats';
|
|
122
126
|
export { createNatsDriver } from './driver-nats';
|
|
123
|
-
export type { PgDriverOptions
|
|
127
|
+
export type { PgDriverOptions } from './driver-pg';
|
|
124
128
|
export { createPgDriver, createPgLeader } from './driver-pg';
|
|
125
129
|
export {
|
|
126
130
|
SQL_COUNTER_DROP,
|
|
@@ -190,7 +194,6 @@ export {
|
|
|
190
194
|
JobNotCancellableError,
|
|
191
195
|
JobRowStatusUnknownError,
|
|
192
196
|
JobSlotLostError,
|
|
193
|
-
JobsNotImplementedError,
|
|
194
197
|
JobTenantRequiredError,
|
|
195
198
|
JobTimeoutError,
|
|
196
199
|
LeaseLostError,
|
|
@@ -210,11 +213,13 @@ export {
|
|
|
210
213
|
JobOnSettledFailedError,
|
|
211
214
|
JobPageInvalidError,
|
|
212
215
|
} from './errors-operator';
|
|
213
|
-
export { JobNotRequeueableError } from './errors-requeue';
|
|
216
|
+
export { JobNotFoundError, JobNotRequeueableError } from './errors-requeue';
|
|
214
217
|
export type { EventBus, JobEvent, MemoryEventBusOptions, PublishOptions } from './events';
|
|
215
218
|
export {
|
|
216
219
|
createMemoryEventBus,
|
|
220
|
+
EVENTS_PURGE_TARGET,
|
|
217
221
|
eventBus,
|
|
222
|
+
eventsPurgeTarget,
|
|
218
223
|
publishEvent,
|
|
219
224
|
resetEventBus,
|
|
220
225
|
setEventBus,
|
|
@@ -377,6 +382,7 @@ export { ON_SETTLED_ATTEMPTS } from './settled';
|
|
|
377
382
|
export type {
|
|
378
383
|
EventLookup,
|
|
379
384
|
StepApi,
|
|
385
|
+
StepFence,
|
|
380
386
|
StepRecord,
|
|
381
387
|
StepRunner,
|
|
382
388
|
StepRunnerOptions,
|