@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.
Files changed (45) hide show
  1. package/CLAUDE.md +51 -52
  2. package/README.md +34 -5
  3. package/package.json +6 -6
  4. package/src/backfill-ledger.ts +24 -2
  5. package/src/backfill-pass.ts +22 -5
  6. package/src/claim-exhausted.ts +76 -0
  7. package/src/driver-memory-operator.ts +31 -9
  8. package/src/driver-memory.ts +68 -23
  9. package/src/driver-nats.ts +5 -2
  10. package/src/driver-pg-jobs-sql.ts +27 -19
  11. package/src/driver-pg-operator-sql.ts +32 -12
  12. package/src/driver-pg-operator.ts +19 -3
  13. package/src/driver-pg-rows.ts +3 -3
  14. package/src/driver-pg-sql.ts +98 -29
  15. package/src/driver-pg.ts +86 -81
  16. package/src/driver-redis.ts +5 -2
  17. package/src/driver.ts +37 -1
  18. package/src/errors-operator.ts +12 -4
  19. package/src/errors-requeue.ts +21 -3
  20. package/src/errors.ts +5 -13
  21. package/src/events-pg.ts +8 -18
  22. package/src/events.ts +20 -9
  23. package/src/execute.ts +1 -0
  24. package/src/index.ts +10 -4
  25. package/src/inspect-operator.ts +3 -3
  26. package/src/inspect.ts +4 -3
  27. package/src/introspection.ts +23 -11
  28. package/src/job.ts +5 -6
  29. package/src/limits.ts +33 -7
  30. package/src/outbox-pg.ts +1 -2
  31. package/src/outbox.ts +37 -9
  32. package/src/purge.ts +1 -1
  33. package/src/queue-wake.ts +1 -2
  34. package/src/scheduler-pg.ts +3 -2
  35. package/src/scheduler-state.ts +8 -1
  36. package/src/scheduler.ts +9 -10
  37. package/src/steps-suspension.ts +46 -0
  38. package/src/steps-timeout.ts +33 -0
  39. package/src/steps.ts +55 -81
  40. package/src/worker-admit.ts +7 -3
  41. package/src/worker-fleet-slots.ts +78 -44
  42. package/src/worker-key-busy.ts +38 -10
  43. package/src/worker-loop.ts +35 -11
  44. package/src/worker-registry.ts +16 -1
  45. 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
- * 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
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
- filter.runId ?? null,
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
- 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
- }
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
- 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]);
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 rows = await exec().query<{ id: string; run_id: string }>(SQL_ENQUEUE, [
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
- const inserted = rows[0];
287
- if (inserted !== undefined) {
288
- return { id: inserted.id, runId: inserted.run_id, deduped: false };
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
- // 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
- }
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
- // `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
- });
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
- return { id: found.id, runId: found.run_id, deduped: true };
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
- return rows.map((row) => {
341
- const record = toJobRecord(row);
342
- return {
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
@@ -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 JobsNotImplementedError({ feature: `redis jobs driver (${method})`, fix: FIX });
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 from `app.config.ts`. Roles share one driver instance per process. */
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
  }
@@ -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 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 { finiteOption, logger, renderThrowable, uuid } from '@ultimat3/core';
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
- SQL_EVENT_PURGE,
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
- const purgeExpired = (): number => {
63
- void exec.query(SQL_EVENT_PURGE, []).catch((error: unknown) => {
64
- // Housekeeping never costs a publish: an unpurged row is filtered out of every read.
65
- logger.warn('jobs.event.purge-failed', {
66
- error: renderThrowable(error),
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
- purgeExpired(): number;
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, PgExecutor } from './driver-pg';
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,