@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.
Files changed (70) hide show
  1. package/CLAUDE.md +72 -75
  2. package/README.md +83 -38
  3. package/package.json +7 -7
  4. package/src/backfill-errors.ts +3 -2
  5. package/src/backfill-ledger.ts +25 -3
  6. package/src/backfill-pass.ts +22 -5
  7. package/src/backfill-rate.ts +2 -2
  8. package/src/backfill.ts +2 -2
  9. package/src/claim-exhausted.ts +76 -0
  10. package/src/counters.ts +1 -1
  11. package/src/driver-memory-operator.ts +34 -12
  12. package/src/driver-memory.ts +80 -38
  13. package/src/driver-pg-jobs-sql.ts +27 -19
  14. package/src/driver-pg-operator-sql.ts +32 -12
  15. package/src/driver-pg-operator.ts +19 -3
  16. package/src/driver-pg-rows.ts +3 -3
  17. package/src/driver-pg-settle-sql.ts +2 -3
  18. package/src/driver-pg-sql.ts +99 -30
  19. package/src/driver-pg.ts +103 -98
  20. package/src/driver.ts +46 -16
  21. package/src/errors-concurrency.ts +1 -1
  22. package/src/errors-operator.ts +12 -4
  23. package/src/errors-requeue.ts +21 -3
  24. package/src/errors.ts +9 -17
  25. package/src/events-pg.ts +11 -21
  26. package/src/events.ts +26 -15
  27. package/src/execute.ts +47 -5
  28. package/src/export.ts +2 -2
  29. package/src/heartbeat.ts +7 -2
  30. package/src/index.ts +45 -99
  31. package/src/inspect-operator.ts +4 -4
  32. package/src/inspect.ts +5 -4
  33. package/src/introspection.ts +23 -11
  34. package/src/job.ts +6 -7
  35. package/src/leases.ts +1 -1
  36. package/src/limits.ts +46 -15
  37. package/src/outbox-lease.ts +1 -1
  38. package/src/outbox-pg.ts +6 -7
  39. package/src/outbox-relay.ts +6 -6
  40. package/src/outbox.ts +47 -19
  41. package/src/purge.ts +1 -1
  42. package/src/queue-wake.ts +1 -2
  43. package/src/renewal-timer.ts +1 -1
  44. package/src/retry-classification.ts +52 -15
  45. package/src/scheduler-leader.ts +2 -2
  46. package/src/scheduler-pg.ts +19 -16
  47. package/src/scheduler-state.ts +10 -3
  48. package/src/scheduler.ts +14 -15
  49. package/src/steps-memory.ts +2 -2
  50. package/src/steps-suspension.ts +46 -0
  51. package/src/steps-timeout.ts +33 -0
  52. package/src/steps.ts +90 -88
  53. package/src/task.ts +1 -1
  54. package/src/webhook-attempt.ts +206 -0
  55. package/src/webhook-ledger.ts +15 -4
  56. package/src/webhook.ts +64 -118
  57. package/src/worker-admit.ts +14 -6
  58. package/src/worker-drain-cutoff.ts +110 -0
  59. package/src/worker-fleet-slots.ts +82 -45
  60. package/src/worker-held.ts +100 -0
  61. package/src/worker-key-busy.ts +38 -10
  62. package/src/worker-loop.ts +35 -11
  63. package/src/worker-options.ts +19 -15
  64. package/src/worker-registry.ts +16 -1
  65. package/src/worker-run.ts +38 -8
  66. package/src/worker-tally.ts +53 -0
  67. package/src/worker-types.ts +7 -4
  68. package/src/worker.ts +124 -73
  69. package/src/driver-nats.ts +0 -86
  70. 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, uuid } from '@ultimat3/core';
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
- * The one thing this driver needs from the DB layer, declared structurally so this package needs
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 PgDriverOptions {
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: 'createPgDriver() was called with no executor, and this driver has no ambient fallback',
99
- 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(createMemoryDriver())',
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 pgStepStore(exec: () => PgExecutor): StepStore {
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 pgBackfillLedger(exec: () => PgExecutor): BackfillLedger {
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
- filter.runId ?? null,
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 pgLeaseStore(exec: () => PgExecutor): LeaseStore {
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 createPgDriver(options: PgDriverOptions = {}): JobDriver {
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
- if (current === undefined) {
230
- throw new DriverUnavailableError({
231
- driver: 'pg',
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
- if (requeueOptions?.fromStep !== undefined) {
249
- await exec().query(SQL_STEPS_FROM, [current.runId, requeueOptions.fromStep]);
250
- }
251
- const rows = await exec().query<JobRow>(SQL_JOB_REQUEUE, [jobId]);
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: pgStepStore(exec),
267
- backfills: pgBackfillLedger(exec),
268
- leases: pgLeaseStore(exec),
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 rows = await exec().query<{ id: string; run_id: string }>(SQL_ENQUEUE, [
274
- request.id ?? uuid(),
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 ?? uuid(),
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
- const inserted = rows[0];
287
- if (inserted !== undefined) {
288
- return { id: inserted.id, runId: inserted.run_id, deduped: false };
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
- // Nothing inserted under an id the CALLER allocated: the outbox publishing one staged row a
292
- // second time. The job its first publish made is the answer, whatever state it has 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
- }
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
- // `do nothing` fired: a live job OF THIS NAME, IN THIS TENANT, already owns this idempotency
303
- // key. Both are in the lookup because both are in the index — without the name this returned
304
- // whichever other job derived the same natural key; without the tenant it returned another
305
- // TENANT's row, so the caller's work silently never ran AND the caller was handed an id it
306
- // has no right to, on a surface (`cancel`) that takes an id with no 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) {
314
- throw new DriverUnavailableError({
315
- driver: 'pg',
316
- cause: `enqueue of "${request.name}" was rejected but no live row holds its idempotency key`,
317
- fix: 'x db migrate # reapplies SQL_JOBS_TABLE, whose x_jobs_name_tenant_idempotency_live_idx is what this lookup reads',
318
- });
319
- }
320
- if (request.onConflict === 'error') {
321
- throw new JobDuplicateError({
322
- job: request.name,
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
- return { id: found.id, runId: found.run_id, deduped: true };
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
- return rows.map((row) => {
341
- const record = toJobRecord(row);
342
- return {
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 `PgAdvisoryLock` is the shape that gets this right: it opens a connection
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 `createPgLeaseLeader` instead** unless you can
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 createPgLeader(
445
+ export function postgresLeader(
441
446
  lockKey: number,
442
- options: PgDriverOptions = {},
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
- // This header used to say "switching backends is a config line" (`As of 2026-08`). There is no
6
- // such config line: `JobsConfig.driver` has no reader anywhere and boot always builds
7
- // `createPgDriver`. `pg` and `memory` are the two that exist; `redis` and `nats` are honest
8
- // `X_NOT_IMPLEMENTED` stubs. What IS true is the second half — swapping the driver is
9
- // `setJobDriver(other)` and ZERO job-code change — and that is what the interface buys.
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`. `createWorker` passes exactly
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
- export interface AckOptions extends SettleBy {
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 `createWorker().start()` THROWS
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 from `app.config.ts`. Roles share one driver instance per process. */
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(createPgDriver()) at boot — the pg driver is the one with a lease store`,
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
  }
@@ -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: { subject: string; maxPage: number; limit?: number }) {
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 previous page produced`
86
- : `${input.subject} limit is ${input.limit}, over MAX_JOB_PAGE (${input.maxPage}) — the rows one page may answer`,
87
- fix: `list({ limit: ${input.maxPage}, after: jobCursor(lastRow) }) # lastRow is the last row of the page before; omit after for the first page, and stop at a page shorter than its limit`,
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 }),
@@ -1,7 +1,8 @@
1
- // Single responsibility: the two refusals `requeue` — `x jobs retry` — gives before it moves a
2
- // row. Its own module because `errors.ts` sits at the size ceiling; the codes are registered there.
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. `JobsNotImplementedError`,
50
- * `JobAbortedError` and `JobDrainedError` below throw them; jobs keeps no title for any of the
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`. The redis/nats stubs, and any hand-rolled driver. */
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(createPgDriver()) at boot — only the pg driver implements introspect.cancel — then: x jobs cancel <id> --json',
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 (`createWorker` passes exactly one queue per pass), so the divergence could only ever be found
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 createWorker walks one queue at a time",
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
  }