@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "23.0.0",
3
+ "version": "25.0.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,17 +25,17 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.4.0"
28
+ "bun": ">=1.4.2"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "23.0.0",
36
- "@ultimat3/db": "23.0.0",
37
- "@ultimat3/entity": "23.0.0",
38
- "@ultimat3/schema": "23.0.0",
39
- "@ultimat3/time": "23.0.0"
35
+ "@ultimat3/core": "25.0.0",
36
+ "@ultimat3/db": "25.0.0",
37
+ "@ultimat3/entity": "25.0.0",
38
+ "@ultimat3/schema": "25.0.0",
39
+ "@ultimat3/time": "25.0.0"
40
40
  }
41
41
  }
@@ -5,7 +5,7 @@
5
5
  // declared in ONE place; only the classes that throw them live here, beside `backfill-ledger.ts`,
6
6
  // `backfill-pending.ts` and `backfill-registry.ts`.
7
7
 
8
- import { UltimateError } from '@ultimat3/core';
8
+ import { renderFixShellArg, UltimateError } from '@ultimat3/core';
9
9
 
10
10
  /**
11
11
  * The seven backfill codes below all answer one question — "why is this sweep not running?" — and
@@ -64,7 +64,8 @@ export class BackfillEnvironmentError extends UltimateError {
64
64
  fix:
65
65
  target === undefined
66
66
  ? 'x db backfill --pending --json'
67
- : `ULTIMATE_ENV=${target} x db backfill ${input.backfill} --write --json`,
67
+ : // Both are the declaration's own strings and the class is public: screened (sweep 1c).
68
+ `ULTIMATE_ENV=${renderFixShellArg(target, "'<environment>'")} x db backfill ${renderFixShellArg(input.backfill, "'<backfill>'")} --write --json`,
68
69
  });
69
70
  }
70
71
  }
@@ -10,7 +10,7 @@
10
10
  // become — the checkpoints are transactional with the work, and this row is not.
11
11
 
12
12
  import type { Clock } from '@ultimat3/core';
13
- import { finiteCount, systemClock } from '@ultimat3/core';
13
+ import { describeValue, finiteCount, systemClock, UltimateError } from '@ultimat3/core';
14
14
  import { nowMs } from './clock';
15
15
 
16
16
  /**
@@ -56,6 +56,27 @@ export interface BackfillFilter {
56
56
  readonly limit?: number | undefined;
57
57
  }
58
58
 
59
+ /** What Postgres prints for a `uuid` — the one spelling both ledgers store identically. */
60
+ const RUN_ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
61
+
62
+ /**
63
+ * `BackfillFilter.runId`, refused by BOTH ledgers' `list` when it is not a uuid. `run_id` is a
64
+ * `uuid` column: Postgres raised a raw `22P02` where the memory ledger answered an empty page, so
65
+ * a mistyped id read as "no such pass" under `x dev` and as a database fault in production. The
66
+ * shape `outbox.ts` holds an enqueue's `runId` to — lowercase, so the two never disagree on case.
67
+ */
68
+ export function listedRunId(subject: string, runId: unknown): string | undefined {
69
+ if (runId === undefined) return undefined;
70
+ if (typeof runId === 'string' && RUN_ID_SHAPE.test(runId)) return runId;
71
+ throw new UltimateError({
72
+ code: 'X_ID_INVALID',
73
+ // `describeValue`, never the value: what arrives here wrong is as often a key as a typo.
74
+ cause: `${subject} backfills.list was filtered by runId ${describeValue(runId)}, and a run id is a lowercase uuid (8-4-4-4-12 hex) — x_backfills.run_id is a uuid column`,
75
+ fix: 'x db backfill --list --json',
76
+ meta: { option: 'runId' },
77
+ });
78
+ }
79
+
59
80
  /**
60
81
  * Durable, and therefore behind the queue driver: `x_backfills` ships in the same DDL as `x_jobs`
61
82
  * and `x_job_steps` (`driver-pg-sql.ts`), so a ledger a pass cannot write is a queue it could not
@@ -132,7 +153,7 @@ export function decideBackfill(
132
153
  return { run: force, previous: completed, changed: completed.checksum !== checksum };
133
154
  }
134
155
 
135
- export function createMemoryBackfillLedger(clock: Clock = systemClock): BackfillLedger {
156
+ export function memoryBackfillLedger(clock: Clock = systemClock): BackfillLedger {
136
157
  const runs = new Map<string, BackfillRun>();
137
158
  const patch = (runId: string, fields: Partial<BackfillRun>): void => {
138
159
  const existing = runs.get(runId);
@@ -169,6 +190,7 @@ export function createMemoryBackfillLedger(clock: Clock = systemClock): Backfill
169
190
  },
170
191
  // `async`, so a refused limit REJECTS here as it does on the pg ledger — see `driver-memory.ts`.
171
192
  async list(filter = {}) {
193
+ const runId = listedRunId('the memory ledger', filter.runId);
172
194
  // Reversed BEFORE the sort: the test clock is frozen, so two rows share a `startedAt` and a
173
195
  // stable sort would hand back the oldest of them first under a "newest first" contract.
174
196
  const rows = [...runs.values()]
@@ -176,7 +198,7 @@ export function createMemoryBackfillLedger(clock: Clock = systemClock): Backfill
176
198
  .sort((a, b) => b.startedAt - a.startedAt)
177
199
  .filter((run) => filter.name === undefined || run.name === filter.name)
178
200
  .filter((run) => filter.status === undefined || run.status === filter.status)
179
- .filter((run) => filter.runId === undefined || run.runId === filter.runId)
201
+ .filter((run) => runId === undefined || run.runId === runId)
180
202
  .slice(0, finiteCount('the backfill ledger list', 'limit', filter.limit ?? 100));
181
203
  return rows;
182
204
  },
@@ -164,7 +164,13 @@ export async function backfillPass<Row>(
164
164
  // refusing it — the same degradation `introspect` already has.
165
165
  const ledger = jobDriver()?.backfills;
166
166
 
167
- const previous = await completedRun(ledger, name);
167
+ const completed = await completedRun(ledger, name);
168
+ // Its OWN row is not a previous pass: a worker that died between `ledger.finish` and the ack is
169
+ // handed the same run again, and skipping there reported `{ skipped: true, previousRunId:
170
+ // <itself> }` for a pass that swept. It falls through and replays — every batch is served from
171
+ // its checkpoint, nothing is read or handled twice, and the report is the one it first made.
172
+ const redelivered = completed?.runId === runId;
173
+ const previous = redelivered ? undefined : completed;
168
174
  const verdict = decideBackfill(previous, checksum, args.input.force === true);
169
175
  if (!verdict.run) {
170
176
  if (verdict.changed) {
@@ -196,7 +202,14 @@ export async function backfillPass<Row>(
196
202
  definition.tenant,
197
203
  args.ctx,
198
204
  async (ctx): Promise<BackfillReport> => {
199
- await ledger?.start({ runId, name, checksum, appVersion: appVersion() });
205
+ // A redelivered pass leaves its completed row ALONE until a batch body actually runs (an
206
+ // operator's `retryFromStep` re-opens one): replaying checkpoints sweeps nothing, so there is
207
+ // nothing to record and no convergence to re-ask — rows that started matching since would
208
+ // otherwise turn a finished pass into a failed one.
209
+ let recording = !redelivered;
210
+ const open = (): Promise<void> | undefined =>
211
+ ledger?.start({ runId, name, checksum, appVersion: appVersion() });
212
+ if (recording) await open();
200
213
 
201
214
  let cursor: string | null = null;
202
215
  let rows = 0;
@@ -242,6 +255,10 @@ export async function backfillPass<Row>(
242
255
  const iteration = await iterate();
243
256
  const next = await iteration.pull.next();
244
257
  if (next.done === true) return { cursor: null, rows: 0 };
258
+ if (!recording) {
259
+ recording = true;
260
+ await open();
261
+ }
245
262
  await definition.handle({ rows: next.value, ctx, signal, index });
246
263
  swept = true;
247
264
  return { cursor: iteration.batches.cursor, rows: next.value.length };
@@ -271,10 +288,10 @@ export async function backfillPass<Row>(
271
288
  // this converge", asked once, where the answer is finally decidable. Inside the `try` so the
272
289
  // ledger records the attempt as `failed`: a pass that left rows behind must not write the
273
290
  // completed row that stops the next deploy re-running it.
274
- await assertConverged(definition, ctx, rows);
291
+ if (recording) await assertConverged(definition, ctx, rows);
275
292
  } catch (error) {
276
293
  // Control flow, not a failure: a suspended run is parked and will be back on this step.
277
- if (!isStepSuspension(error)) await markFailed(ledger, runId, rows);
294
+ if (!isStepSuspension(error) && recording) await markFailed(ledger, runId, rows);
278
295
  throw error;
279
296
  } finally {
280
297
  // Whatever the iteration holds belongs to this attempt, and an attempt that failed, was
@@ -282,7 +299,7 @@ export async function backfillPass<Row>(
282
299
  await live?.batches.close();
283
300
  }
284
301
 
285
- await ledger?.finish(runId, { status: 'completed', rows });
302
+ if (recording) await ledger?.finish(runId, { status: 'completed', rows });
286
303
  return { name, batches, rows, skipped: false };
287
304
  },
288
305
  );
@@ -69,14 +69,14 @@ function timerSleep(ms: number, signal: AbortSignal): Promise<void> {
69
69
  * rate is a property of the table being swept and the pool it is swept through, not of whichever
70
70
  * attempt happens to hold the run.
71
71
  */
72
- export function createPacer(options: PacerOptions): Pacer {
72
+ export function backfillPacer(options: PacerOptions): Pacer {
73
73
  // Refused HERE and not only at `backfill()`: `rate: 0` makes `intervalMs` Infinity, which the
74
74
  // timer clamps to about a millisecond — so an unvalidated zero reads as "no throttle at all",
75
75
  // which is the one setting this module exists to make unreachable. A negative rate is the same
76
76
  // bug with a negative wait.
77
77
  assert(
78
78
  Number.isFinite(options.rate) && options.rate > 0,
79
- `createPacer({ rate: ${String(options.rate)} }) for "${options.job}" — a rate is batches per second, greater than zero`,
79
+ `backfillPacer({ rate: ${String(options.rate)} }) for "${options.job}" — a rate is batches per second, greater than zero`,
80
80
  `pass rate: ${DEFAULT_BACKFILL_RATE} — to sweep faster raise the number, there is no unthrottled mode`,
81
81
  );
82
82
  const clock = options.clock ?? systemClock;
package/src/backfill.ts CHANGED
@@ -15,7 +15,7 @@ import type { ReadBuilder } from '@ultimat3/entity';
15
15
  import { t } from '@ultimat3/schema';
16
16
  import { backfillChecksum } from './backfill-ledger';
17
17
  import { backfillPass } from './backfill-pass';
18
- import { createPacer, DEFAULT_BACKFILL_RATE } from './backfill-rate';
18
+ import { backfillPacer, DEFAULT_BACKFILL_RATE } from './backfill-rate';
19
19
  import type { BackfillCount } from './backfill-registry';
20
20
  import { stampBackfill } from './backfill-registry';
21
21
  import type { DurationInput } from './clock';
@@ -176,7 +176,7 @@ export function backfill<Row>(definition: BackfillDefinition<Row>): JobHandle<Ba
176
176
  const checksum = backfillChecksum(definition.source, definition.handle);
177
177
  // Built here rather than per attempt: the interval belongs to the table and the pool, not to
178
178
  // whichever attempt holds the run, so a retrying pass keeps the pace it was declared with.
179
- const pace = createPacer({ rate, job: definition.name });
179
+ const pace = backfillPacer({ rate, job: definition.name });
180
180
 
181
181
  // Bound to the definition rather than passed bare: `count` is declared as a method, so a
182
182
  // reference torn off the object literal would run with `this` undefined the first time an
@@ -0,0 +1,76 @@
1
+ // The claim round's half of a burial: a row the claim settled `dead` (`ClaimOptions.onExhausted`)
2
+ // has no body to end it and no settle to announce it, so the pass that buried it says so — one log
3
+ // line an operator can alert on, and the job's `onSettled`, exactly as a dead letter a worker
4
+ // settled itself would get.
5
+
6
+ import type { Ctx } from '@ultimat3/core';
7
+ import { logger, renderThrowable } from '@ultimat3/core';
8
+ import type { JobRecord } from './driver';
9
+ import { getJob } from './job';
10
+ import { announceSettled } from './settled';
11
+
12
+ export interface AnnounceExhaustedOptions {
13
+ /** What `onExhausted` handed this pass — every row the claim buried, as it now stands. */
14
+ readonly exhausted: readonly JobRecord[];
15
+ readonly workerId: string;
16
+ /** The worker's own context: what the job's `onSettled` is scoped from, as a body would be. */
17
+ readonly context: () => Ctx;
18
+ }
19
+
20
+ /** What one pass buried, by how each row ended — what the worker's own counters take. */
21
+ export interface ExhaustedCounts {
22
+ readonly deadLettered: number;
23
+ readonly dropped: number;
24
+ }
25
+
26
+ /**
27
+ * Announce every row one claim pass buried, and answer how each ended. Once per row, because the
28
+ * claim buries a row once: the statement that settled it is the only one that hands it to
29
+ * `onExhausted`. A row buried `failed` is a job declaring `retry.deadLetter: false`
30
+ * (`ClaimOptions.dropExhausted`) — `dropped`, as its own nack would have ended it. Never rejects:
31
+ * `announceSettled` owns a hook's failure, and a `context()` that throws is logged.
32
+ */
33
+ export async function announceExhausted(
34
+ options: AnnounceExhaustedOptions,
35
+ ): Promise<ExhaustedCounts> {
36
+ let dropped = 0;
37
+ for (const record of options.exhausted) {
38
+ const error = record.lastError ?? '';
39
+ const outcome = record.state === 'failed' ? 'dropped' : 'dead-lettered';
40
+ if (outcome === 'dropped') dropped += 1;
41
+ // `error`, not `warn`: nobody recovered from this one, and a job that outlives every worker
42
+ // that takes it is a crash loop somebody has to look at.
43
+ logger.error('jobs.claim.exhausted', {
44
+ workerId: options.workerId,
45
+ job: record.name,
46
+ jobId: record.id,
47
+ attempt: record.attempt,
48
+ maxAttempts: record.maxAttempts,
49
+ outcome,
50
+ error,
51
+ });
52
+ const handle = getJob(record.name);
53
+ if (handle === undefined) continue;
54
+ let ctx: Ctx;
55
+ try {
56
+ ctx = options.context();
57
+ } catch (raised) {
58
+ // The app's own function, asked AFTER the row was settled: thrown, it failed the round.
59
+ logger.error('jobs.claim.exhausted-unannounced', {
60
+ workerId: options.workerId,
61
+ job: record.name,
62
+ jobId: record.id,
63
+ error: renderThrowable(raised),
64
+ });
65
+ continue;
66
+ }
67
+ await announceSettled({
68
+ handle,
69
+ claimed: record,
70
+ ctx,
71
+ // No code: nothing was thrown, and `JobFailed.code` is never invented.
72
+ settlement: { outcome, error, code: undefined },
73
+ });
74
+ }
75
+ return { deadLettered: options.exhausted.length - dropped, dropped };
76
+ }
package/src/counters.ts CHANGED
@@ -45,7 +45,7 @@ export interface MemoryCounters {
45
45
  }
46
46
 
47
47
  /** The memory driver's counters. Same buckets, same tiers, same fold as `x_job_counters`. */
48
- export function createMemoryCounters(): MemoryCounters {
48
+ export function memoryCounters(): MemoryCounters {
49
49
  const buckets = new Map<string, JobCounter>();
50
50
  const keyOf = (job: string, bucketMs: number, bucketStart: number): string =>
51
51
  `${bucketMs}\u0000${bucketStart}\u0000${job}`;
@@ -7,7 +7,7 @@ import type { Clock } from '@ultimat3/core';
7
7
  import { assert, finiteOption } from '@ultimat3/core';
8
8
  import { nowMs } from './clock';
9
9
  import type { MemoryCounters } from './counters';
10
- import { createMemoryCounters } from './counters';
10
+ import { memoryCounters } from './counters';
11
11
  import type { JobRecord } from './driver';
12
12
  import { REQUEUEABLE_STATES } from './driver';
13
13
  import { signalEnqueued } from './enqueue-signal';
@@ -92,9 +92,9 @@ function pauseSet(clock: Clock): {
92
92
  };
93
93
  }
94
94
 
95
- export function createMemoryOperator(state: MemoryQueueState): MemoryOperator {
95
+ export function memoryOperator(state: MemoryQueueState): MemoryOperator {
96
96
  const { jobs, clock } = state;
97
- const counters = createMemoryCounters();
97
+ const counters = memoryCounters();
98
98
  const queues = pauseSet(clock);
99
99
  const tasks = pauseSet(clock);
100
100
  const workers = new Map<string, WorkerRecord & { readonly expiresAt: number }>();
@@ -134,7 +134,7 @@ export function createMemoryOperator(state: MemoryQueueState): MemoryOperator {
134
134
  const before =
135
135
  filter.before === undefined
136
136
  ? undefined
137
- : parseJobCursor(filter.before, 'the memory driver list');
137
+ : parseJobCursor(filter.before, 'the memory driver list', 'before');
138
138
  const { idPrefix, createdFrom, createdTo, tenantId } = filter;
139
139
  const matching = [...jobs.values()]
140
140
  .filter((record) => filter.queue === undefined || record.queue === filter.queue)
@@ -168,16 +168,38 @@ export function createMemoryOperator(state: MemoryQueueState): MemoryOperator {
168
168
  `requeueMany was asked for ${filter.state} jobs, and only a finished job can be requeued`,
169
169
  "pass state: 'dead', 'failed', 'cancelled' or 'done' to requeueMany",
170
170
  );
171
- const result = await bulk(filter, (record) => {
172
- // A key a live job holds stays where it is — the single requeue's `X_JOB_DUPLICATE`,
173
- // answered as "not this one" so one conflict does not fail a thousand rows.
174
- if (state.liveHolder(record) !== undefined) return false;
175
- state.settle(record.id, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
176
- return true;
177
- });
171
+ // A key a live job holds stays where it is — the single requeue's `X_JOB_DUPLICATE`,
172
+ // answered as "not this one" so one conflict does not fail a thousand rows.
173
+ const free = (record: JobRecord): boolean => state.liveHolder(record) === undefined;
174
+ // `SQL_JOB_REQUEUE_MANY`'s candidates: no live holder, and ONE row per idempotency namespace
175
+ // — the oldest — both decided BEFORE the bound. Decided inside `apply`, a held row spent a
176
+ // slot of `MAX_BULK_ROWS` and was then skipped: a thousand held rows ahead of one free row
177
+ // answered `affected: 0` here and `affected: 1` on Postgres.
178
+ const oldest = new Map<string, JobRecord>();
179
+ for (const record of jobs.values()) {
180
+ if (!inBulk(record, filter) || !free(record)) continue;
181
+ const key = JSON.stringify([record.name, record.tenantId ?? '', record.idempotencyKey]);
182
+ const seen = oldest.get(key);
183
+ if (seen === undefined || newerThan(seen, record)) oldest.set(key, record);
184
+ }
185
+ const candidates = new Set([...oldest.values()].map((record) => record.id));
186
+ const result = await bulk(
187
+ filter,
188
+ (record) => {
189
+ if (!free(record)) return false;
190
+ state.settle(record.id, { state: 'ready', attempt: 0, runAt: nowMs(clock) });
191
+ return true;
192
+ },
193
+ (record) => candidates.has(record.id),
194
+ );
178
195
  // The pg driver's `SQL_WAKE`, in-process: an operator's repair starts now.
179
196
  if (result.affected > 0) signalEnqueued(filter.queue);
180
- return result;
197
+ // What a second call could still move: rows of the filter whose key no live job holds. A
198
+ // row requeued just now holds its key, so its dead siblings left with it.
199
+ const remaining = [...jobs.values()].filter(
200
+ (record) => inBulk(record, filter) && free(record),
201
+ ).length;
202
+ return { affected: result.affected, remaining };
181
203
  },
182
204
 
183
205
  async promoteMany(filter) {
@@ -3,9 +3,9 @@
3
3
  // real claim/ack/nack paths rather than a mock that always succeeds.
4
4
 
5
5
  import type { Clock } from '@ultimat3/core';
6
- import { assert, finiteCount, systemClock, uuid } from '@ultimat3/core';
6
+ import { finiteCount, systemClock, uuidV7 } from '@ultimat3/core';
7
7
  import type { BackfillLedger } from './backfill-ledger';
8
- import { createMemoryBackfillLedger } from './backfill-ledger';
8
+ import { memoryBackfillLedger } from './backfill-ledger';
9
9
  import { nowMs } from './clock';
10
10
  import { nackOutcome } from './counters';
11
11
  import type {
@@ -25,21 +25,24 @@ import {
25
25
  assertClaimBounds,
26
26
  assertClaimQueues,
27
27
  DEFAULT_QUEUE,
28
+ LEASE_LAPSED_FINAL_ATTEMPT,
29
+ LIVE_STATES,
28
30
  nackState,
29
31
  REQUEUEABLE_STATES,
30
32
  } from './driver';
31
- import { createMemoryOperator } from './driver-memory-operator';
33
+ import { memoryOperator } from './driver-memory-operator';
32
34
  import { signalEnqueued } from './enqueue-signal';
33
- import { JobDuplicateError } from './errors';
34
- import { JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
35
+ import { JobDuplicateError, LeaseLostError } from './errors';
36
+ import { JobNotFoundError, JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
35
37
  import type { JobIntrospection } from './introspection';
36
38
  import { MAX_ERROR_STACK_LENGTH } from './introspection';
37
39
  import type { LeaseStore } from './leases';
38
- import { createMemoryLeaseStore } from './leases';
40
+ import { memoryLeaseStore } from './leases';
41
+ import { isFinalAttempt } from './retry';
39
42
  import type { StepStore } from './steps';
40
- import { createMemoryStepStore } from './steps-memory';
43
+ import { memoryStepStore } from './steps-memory';
41
44
 
42
- export interface MemoryDriverOptions {
45
+ export interface MemoryJobDriverOptions {
43
46
  readonly clock?: Clock;
44
47
  readonly steps?: StepStore;
45
48
  /** Injectable for the same reason `steps` is: two drivers in one test sharing one ledger. */
@@ -48,8 +51,6 @@ export interface MemoryDriverOptions {
48
51
  readonly leases?: LeaseStore;
49
52
  }
50
53
 
51
- const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
52
-
53
54
  /**
54
55
  * The in-memory driver's own type: `JobDriver` with `close` REQUIRED.
55
56
  *
@@ -61,12 +62,25 @@ const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
61
62
  */
62
63
  export type MemoryJobDriver = JobDriver & { close(): Promise<void> };
63
64
 
64
- export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJobDriver {
65
+ export function memoryJobDriver(options: MemoryJobDriverOptions = {}): MemoryJobDriver {
65
66
  const clock = options.clock ?? systemClock;
66
- const steps = options.steps ?? createMemoryStepStore();
67
- const backfills = options.backfills ?? createMemoryBackfillLedger(clock);
68
- const leases =
69
- options.leases ?? createMemoryLeaseStore(options.clock === undefined ? {} : { clock });
67
+ const stepStore = options.steps ?? memoryStepStore();
68
+ // `SQL_STEP_PUT`'s fence: a write made under a claim lands only while that claim holds the row.
69
+ const steps: StepStore = {
70
+ ...stepStore,
71
+ put(record, by) {
72
+ const row = by === undefined ? undefined : jobs.get(by.jobId);
73
+ if (
74
+ by !== undefined &&
75
+ !(row?.state === 'running' && row.claimedBy === by.workerId && row.claim === by.claim)
76
+ ) {
77
+ return Promise.reject(new LeaseLostError({ job: by.job, jobId: by.jobId }));
78
+ }
79
+ return stepStore.put(record);
80
+ },
81
+ };
82
+ const backfills = options.backfills ?? memoryBackfillLedger(clock);
83
+ const leases = options.leases ?? memoryLeaseStore(options.clock === undefined ? {} : { clock });
70
84
  const jobs = new Map<string, JobRecord>();
71
85
 
72
86
  // Keyed by NAME, TENANT and key, exactly as `x_jobs_name_tenant_idempotency_live_idx` is. A
@@ -104,14 +118,22 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
104
118
  * lease deadline — which `x jobs show` prints, and which is the very pair the claim scan's
105
119
  * lease-expiry branch reads to decide a row was abandoned.
106
120
  */
107
- const settle = (id: string, patch: Partial<JobRecord>): void => {
121
+ const settle = (id: string, patch: Partial<JobRecord>, drop?: 'stack'): void => {
108
122
  const existing = jobs.get(id);
109
123
  if (existing === undefined) return;
110
124
  const { visibleAt: _visibleAt, claimedBy: _claimedBy, ...released } = existing;
111
- jobs.set(id, { ...released, ...patch, updatedAt: nowMs(clock) });
125
+ // `drop: 'stack'` is `SQL_NACK`'s `case when $5 is null then last_error_stack else $7 end`
126
+ // with no stack bound: a NEW failure replaces the old one's stack even with nothing — the key
127
+ // is removed, never set to `undefined`, as a null column is absent from a pg record.
128
+ const { lastErrorStack: _stack, ...unstacked } = released;
129
+ jobs.set(id, {
130
+ ...(drop === 'stack' ? unstacked : released),
131
+ ...patch,
132
+ updatedAt: nowMs(clock),
133
+ });
112
134
  };
113
135
 
114
- const operator = createMemoryOperator({
136
+ const operator = memoryOperator({
115
137
  jobs,
116
138
  steps,
117
139
  clock,
@@ -145,13 +167,9 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
145
167
  return rows;
146
168
  },
147
169
  async requeue(jobId, requeueOptions) {
148
- const existing = jobs.get(jobId);
149
- assert(
150
- existing !== undefined,
151
- `no job ${jobId} in the memory driver`,
152
- 'requeue a job id returned by enqueue() or stats() — the memory driver holds no state across processes, so an id from another run will not resolve',
153
- );
154
- const record = existing;
170
+ const record = jobs.get(jobId);
171
+ // The one answer both drivers give an id nobody queued — it was `X_INVARIANT` here.
172
+ if (record === undefined) throw new JobNotFoundError({ jobId, driver: 'memory' });
155
173
  // The two refusals `SQL_JOB_REQUEUE` and `SQL_JOB_LIVE_HOLDER` give, before anything moves.
156
174
  if (!REQUEUEABLE_STATES.has(record.state)) {
157
175
  throw new JobNotRequeueableError({ jobId, state: record.state });
@@ -161,7 +179,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
161
179
  throw requeueKeyTaken({ ...record, holderId: holder.id });
162
180
  }
163
181
  if (requeueOptions?.fromStep !== undefined) {
164
- // The target step and every step that started AFTER it — `SQL_STEPS_FROM`, whose header
182
+ // The target step and every step that started AFTER it — `SQL_JOB_REQUEUE`, whose header
165
183
  // says why a tie is kept. Earlier steps stay memoized; a later one replaying the old run's
166
184
  // result is what this prevents.
167
185
  const all = await steps.list(record.runId);
@@ -182,9 +200,12 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
182
200
  },
183
201
  cancel(jobId, reason) {
184
202
  const existing = jobs.get(jobId);
185
- // `state !== 'done'`, mirroring `SQL_CANCEL`: a job that already finished has nothing to
186
- // stop, and cancelling it would rewrite a terminal row an operator is reading as success.
187
- if (existing === undefined || existing.state === 'done') return Promise.resolve(undefined);
203
+ // LIVE rows only, mirroring `SQL_CANCEL`: a job that already finished has nothing to stop.
204
+ // The fence was `state !== 'done'`, which let a cancel REWRITE a dead letter — the record of
205
+ // a failure, and its `lastError`, became the record of an operator's typo.
206
+ if (existing === undefined || !LIVE_STATES.has(existing.state)) {
207
+ return Promise.resolve(undefined);
208
+ }
188
209
  // `settle`, not `update`: a cancellation RELEASES the claim, and `SQL_CANCEL` writes
189
210
  // `visible_at = null, claimed_by = null` with the state. Left stamped, a cancelled row named
190
211
  // the worker still holding it and carried that attempt's lease deadline — the pair
@@ -230,7 +251,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
230
251
  const at = nowMs(clock);
231
252
  const runAt = request.runAt ?? at;
232
253
  const record: JobRecord = {
233
- id: request.id ?? uuid(),
254
+ id: request.id ?? uuidV7(),
234
255
  name: request.name,
235
256
  queue: request.queue || DEFAULT_QUEUE,
236
257
  // Through JSON, exactly as the pg driver binds it (`JSON.stringify(request.input ?? null)`):
@@ -238,7 +259,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
238
259
  // queue in production never stores, so a job that read one passed here and failed there.
239
260
  input: JSON.parse(JSON.stringify(request.input ?? null)) as unknown,
240
261
  idempotencyKey: request.idempotencyKey,
241
- runId: request.runId ?? uuid(),
262
+ runId: request.runId ?? uuidV7(),
242
263
  attempt: 0,
243
264
  maxAttempts: request.maxAttempts,
244
265
  state: runAt > at ? 'delayed' : 'ready',
@@ -276,7 +297,24 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
276
297
  .slice(0, claimOptions.limit);
277
298
 
278
299
  const out: ClaimedJob[] = [];
300
+ const buried: JobRecord[] = [];
279
301
  for (const record of claimable) {
302
+ // `SQL_CLAIM`'s second arm. A `running` row reaches this loop only with a lapsed lease, and
303
+ // one that lapsed on its FINAL attempt has nothing left to be re-delivered for: handing
304
+ // it out was an attempt past `maxAttempts`, every lease, forever — a job that kills its
305
+ // worker took one per visibility timeout and never reached the dead-letter queue.
306
+ if (
307
+ record.state === 'running' &&
308
+ isFinalAttempt({ attempts: record.maxAttempts }, record.attempt)
309
+ ) {
310
+ // `$5` of the statement: a job declaring `retry.deadLetter: false` is dropped.
311
+ const state = claimOptions.dropExhausted?.includes(record.name) ? 'failed' : 'dead';
312
+ settle(record.id, { state, lastError: LEASE_LAPSED_FINAL_ATTEMPT }, 'stack');
313
+ operator.counters.add(record.name, state, 0, at);
314
+ const dead = jobs.get(record.id);
315
+ if (dead !== undefined) buried.push(dead);
316
+ continue;
317
+ }
280
318
  const claimed: ClaimedJob = {
281
319
  ...record,
282
320
  state: 'running',
@@ -290,7 +328,8 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
290
328
  jobs.set(record.id, claimed);
291
329
  out.push(claimed);
292
330
  }
293
- return Promise.resolve(out);
331
+ if (buried.length > 0) claimOptions.onExhausted?.(buried);
332
+ return out;
294
333
  },
295
334
 
296
335
  // Both settlements are FENCED on `running` AND on the claimer, as `SQL_ACK`/`SQL_NACK` are: an
@@ -301,9 +340,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
301
340
  const record = heldBy(jobId, by);
302
341
  if (record === undefined) return Promise.resolve(false);
303
342
  settle(jobId, { state: 'done' });
304
- if (by.counted !== false) {
305
- operator.counters.add(record.name, 'done', by.durationMs ?? 0, nowMs(clock));
306
- }
343
+ operator.counters.add(record.name, 'done', by.durationMs ?? 0, nowMs(clock));
307
344
  return Promise.resolve(true);
308
345
  },
309
346
 
@@ -323,11 +360,12 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
323
360
  // being read as the only one.
324
361
  attempt: counts ? record.attempt : Math.max(0, record.attempt - 1),
325
362
  ...(nackOptions.error === undefined ? {} : { lastError: nackOptions.error }),
326
- ...(nackOptions.stack === undefined
363
+ // The stack belongs to the ERROR beside it, as `SQL_NACK` binds it: no error, no stack.
364
+ ...(nackOptions.error === undefined || nackOptions.stack === undefined
327
365
  ? {}
328
366
  : { lastErrorStack: nackOptions.stack.slice(0, MAX_ERROR_STACK_LENGTH) }),
329
367
  };
330
- settle(jobId, patch);
368
+ settle(jobId, patch, nackOptions.error === undefined ? undefined : 'stack');
331
369
  const outcome = nackOutcome(nackOptions);
332
370
  if (outcome !== undefined) {
333
371
  operator.counters.add(record.name, outcome, nackOptions.durationMs ?? 0, at);
@@ -382,7 +420,11 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
382
420
  else if (record.state === 'dead') next.dead += 1;
383
421
  byQueue.set(record.queue, next);
384
422
  }
385
- return Promise.resolve([...byQueue.values()].sort((a, b) => a.queue.localeCompare(b.queue)));
423
+ // Code units, as `collate "C"` orders them in `SQL_STATS` — never `localeCompare`, which
424
+ // answers by the runtime's locale and so differently from the database on the same names.
425
+ return Promise.resolve(
426
+ [...byQueue.values()].sort((a, b) => (a.queue < b.queue ? -1 : a.queue > b.queue ? 1 : 0)),
427
+ );
386
428
  },
387
429
 
388
430
  close(): Promise<void> {