@ultimat3/jobs 29.4.0 → 30.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 (109) hide show
  1. package/CLAUDE.md +68 -89
  2. package/README.md +304 -10
  3. package/package.json +18 -6
  4. package/src/backfill-definition.ts +184 -0
  5. package/src/backfill-errors.ts +18 -2
  6. package/src/backfill-pass.ts +43 -40
  7. package/src/backfill-pending.ts +40 -17
  8. package/src/backfill-reader.ts +94 -0
  9. package/src/backfill-registry.ts +5 -0
  10. package/src/backfill-scope.ts +1 -2
  11. package/src/backfill-source.ts +113 -0
  12. package/src/backfill.ts +54 -135
  13. package/src/consumer-errors.ts +42 -0
  14. package/src/consumer-guard.ts +19 -0
  15. package/src/consumer-loop.ts +171 -0
  16. package/src/consumer-registry.ts +103 -0
  17. package/src/consumer-types.ts +81 -0
  18. package/src/consumer.ts +147 -0
  19. package/src/driver-memory-operator.ts +25 -2
  20. package/src/driver-memory.ts +35 -1
  21. package/src/driver-pg-claim-sql.ts +128 -0
  22. package/src/driver-pg-ddl.ts +11 -2
  23. package/src/driver-pg-demand-sql.ts +27 -0
  24. package/src/driver-pg-enqueue-many.ts +153 -0
  25. package/src/driver-pg-event-wake-sql.ts +51 -0
  26. package/src/driver-pg-operator-sql.ts +34 -6
  27. package/src/driver-pg-operator.ts +36 -2
  28. package/src/driver-pg-outbox-sql.ts +34 -0
  29. package/src/driver-pg-sql.ts +24 -91
  30. package/src/driver-pg.ts +17 -1
  31. package/src/driver-redis-refusal.ts +25 -0
  32. package/src/driver-redis-scripts.ts +79 -30
  33. package/src/driver-redis.ts +70 -42
  34. package/src/driver.ts +38 -1
  35. package/src/enqueue-record.ts +137 -0
  36. package/src/error-codes.ts +233 -0
  37. package/src/errors-concurrency.ts +2 -2
  38. package/src/errors-declaration.ts +15 -2
  39. package/src/errors-operator.ts +1 -1
  40. package/src/errors-requeue.ts +1 -1
  41. package/src/errors-saga.ts +87 -0
  42. package/src/errors-steps.ts +35 -0
  43. package/src/errors-tenant.ts +1 -1
  44. package/src/errors.ts +25 -165
  45. package/src/events-pg.ts +20 -8
  46. package/src/events.ts +14 -3
  47. package/src/execute-ctx.ts +51 -0
  48. package/src/execute-unwind.ts +191 -0
  49. package/src/execute.ts +50 -51
  50. package/src/export-errors.ts +1 -1
  51. package/src/failure-row.ts +43 -0
  52. package/src/import-errors.ts +102 -0
  53. package/src/import-format.ts +232 -0
  54. package/src/import-pass.ts +255 -0
  55. package/src/import-source.ts +49 -0
  56. package/src/import.ts +175 -0
  57. package/src/index.ts +163 -7
  58. package/src/inspect-operator.ts +24 -3
  59. package/src/inspect.ts +17 -4
  60. package/src/introspection.ts +24 -1
  61. package/src/job-enqueue-many.ts +108 -0
  62. package/src/job-types.ts +229 -0
  63. package/src/job.ts +19 -208
  64. package/src/metrics.ts +10 -0
  65. package/src/outbox-pg.ts +35 -0
  66. package/src/outbox-relay.ts +58 -19
  67. package/src/outbox.ts +65 -73
  68. package/src/purge-entities.ts +92 -0
  69. package/src/purge.ts +10 -2
  70. package/src/queue-wake.ts +1 -1
  71. package/src/refresh-view.ts +76 -0
  72. package/src/retry-classification.ts +27 -8
  73. package/src/retry.ts +26 -0
  74. package/src/rollup-errors.ts +27 -0
  75. package/src/rollup.ts +190 -0
  76. package/src/run-deadline.ts +11 -7
  77. package/src/scheduler.ts +2 -1
  78. package/src/settled.ts +56 -15
  79. package/src/step-resolve.ts +37 -0
  80. package/src/steps-api.ts +111 -0
  81. package/src/steps-core.ts +26 -0
  82. package/src/steps-output.ts +142 -0
  83. package/src/steps-store.ts +68 -0
  84. package/src/steps-suspension.ts +51 -0
  85. package/src/steps-timeout.ts +10 -5
  86. package/src/steps-unwind.ts +94 -0
  87. package/src/steps-wait.ts +163 -0
  88. package/src/steps.ts +174 -210
  89. package/src/subject-erase-pass.ts +134 -0
  90. package/src/subject-errors.ts +48 -0
  91. package/src/subject-export-pass.ts +228 -0
  92. package/src/subject-jobs.ts +121 -0
  93. package/src/subject-plan.ts +287 -0
  94. package/src/subject-select.ts +106 -0
  95. package/src/subject.ts +119 -0
  96. package/src/tenant.ts +69 -1
  97. package/src/type-pins.ts +90 -0
  98. package/src/webhook-errors.ts +1 -1
  99. package/src/webhook-receiver-errors.ts +59 -0
  100. package/src/webhook-receiver-route.ts +127 -0
  101. package/src/webhook-receiver-types.ts +97 -0
  102. package/src/webhook-receiver.ts +132 -0
  103. package/src/webhook-target.ts +20 -43
  104. package/src/webhook.ts +7 -4
  105. package/src/worker-queue-demand.ts +57 -0
  106. package/src/worker-queue-depth.ts +15 -3
  107. package/src/worker-registry.ts +4 -1
  108. package/src/worker-types.ts +2 -0
  109. package/src/worker.ts +2 -2
@@ -0,0 +1,103 @@
1
+ // What `consumer()` declared, read back off a plain `JobHandle` — the `origin` WeakMap shape
2
+ // `backfill-registry.ts` uses, and derived from `registeredJobs()` rather than a second registry.
3
+ // Also the one projection of a consumer's run for `x jobs show`: key, last batch at, lag.
4
+
5
+ import type { JobProgress } from './introspection';
6
+ import type { AnyJobHandle } from './job';
7
+ import { isJobHandle, registeredJobs } from './job';
8
+
9
+ /** Everything `consumer()` knows that a `JobHandle` has no field for. */
10
+ export interface ConsumerOrigin {
11
+ readonly supervisor: string;
12
+ readonly arm: string;
13
+ readonly cron: string;
14
+ readonly batch: number;
15
+ readonly lags: boolean;
16
+ }
17
+
18
+ /** One declaration as `--json` and the manifest report it. Plain JSON. */
19
+ export interface ConsumerDeclaration {
20
+ readonly kind: 'consumer';
21
+ readonly name: string;
22
+ readonly queue: string;
23
+ /** The task that re-arms every key, and the job its tick enqueues. */
24
+ readonly supervisor: string;
25
+ readonly arm: string;
26
+ /** The supervisor's cron, in UTC: a re-arm interval has no wall-clock meaning. */
27
+ readonly rearm: string;
28
+ readonly batch: number;
29
+ /** Whether `lag` was declared, so a reader knows a `lag: null` is "not declared". */
30
+ readonly lags: boolean;
31
+ }
32
+
33
+ /** One consumer run, as `x jobs show <id> --json` reports it under `consumer`. */
34
+ export interface ConsumerRow {
35
+ readonly key: string | null;
36
+ /** When the run last reported a committed batch; `null` before its first. */
37
+ readonly lastBatchAt: string | null;
38
+ /** `null` when no `lag` is declared or no batch has committed yet. */
39
+ readonly lag: number | null;
40
+ }
41
+
42
+ const origin = new WeakMap<object, ConsumerOrigin>();
43
+
44
+ /** Called by `consumer()` and nothing else — not exported from `src/index.ts`. */
45
+ export function stampConsumer(handle: AnyJobHandle, source: ConsumerOrigin): void {
46
+ origin.set(handle, source);
47
+ }
48
+
49
+ export function isConsumer(value: unknown): value is AnyJobHandle {
50
+ return isJobHandle(value) && origin.has(value);
51
+ }
52
+
53
+ /** Reads `handle.name` live: registration rebinds that property in place. */
54
+ export function consumerDeclarationOf(handle: AnyJobHandle): ConsumerDeclaration | undefined {
55
+ const source = origin.get(handle);
56
+ if (source === undefined) return undefined;
57
+ return {
58
+ kind: 'consumer',
59
+ name: handle.name,
60
+ queue: handle.queue,
61
+ supervisor: source.supervisor,
62
+ arm: source.arm,
63
+ rearm: source.cron,
64
+ batch: source.batch,
65
+ lags: source.lags,
66
+ };
67
+ }
68
+
69
+ /** Every consumer this process declared, in `registeredJobs()`' code-unit order. */
70
+ export function registeredConsumers(): readonly ConsumerDeclaration[] {
71
+ const declarations: ConsumerDeclaration[] = [];
72
+ for (const handle of registeredJobs()) {
73
+ const declaration = consumerDeclarationOf(handle);
74
+ if (declaration !== undefined) declarations.push(declaration);
75
+ }
76
+ return declarations;
77
+ }
78
+
79
+ const keyOf = (input: unknown): string | null => {
80
+ if (typeof input !== 'object' || input === null) return null;
81
+ const key = (input as { readonly key?: unknown }).key;
82
+ return typeof key === 'string' ? key : null;
83
+ };
84
+
85
+ /**
86
+ * The consumer row for one run, or `null` when `name` is no declared consumer. The loop reports
87
+ * `progress(items, items + lag)`, so the lag is `total - done` — and only when one is declared.
88
+ */
89
+ export function consumerRow(run: {
90
+ readonly name: string;
91
+ readonly input: unknown;
92
+ readonly progress: JobProgress | null;
93
+ }): ConsumerRow | null {
94
+ const handle = registeredJobs().find((candidate) => candidate.name === run.name);
95
+ const source = handle === undefined ? undefined : origin.get(handle);
96
+ if (source === undefined) return null;
97
+ const progress = run.progress;
98
+ return {
99
+ key: keyOf(run.input),
100
+ lastBatchAt: progress === null ? null : new Date(progress.at).toISOString(),
101
+ lag: !source.lags || progress === null ? null : progress.total - progress.done,
102
+ };
103
+ }
@@ -0,0 +1,81 @@
1
+ // The `consumer()` declaration's shape and what it hands back. Apart from `consumer.ts` so the
2
+ // factory, the turn loop (`consumer-loop.ts`) and the registry read one set of types.
3
+
4
+ import type { Ctx } from '@ultimat3/core';
5
+ import type { DurationInput } from './clock';
6
+ import type { JobHandle } from './job';
7
+ import type { RetryPolicy } from './retry';
8
+ import type { TaskHandle } from './task';
9
+ import type { JobTenant } from './tenant';
10
+
11
+ /** One run per key: the payload is the key, and nothing else a queue row could drift on. */
12
+ export interface ConsumerInput {
13
+ readonly key: string;
14
+ }
15
+
16
+ /**
17
+ * The app's own row, read and written through this seam — the reasoning `webhook-ledger.ts`
18
+ * states: the app owns the table. `save` runs INSIDE the batch's transaction, so a batch and the
19
+ * cursor past it commit together or not at all.
20
+ */
21
+ export interface ConsumerCursor<C> {
22
+ /** Where `key` stopped. `null` before its first committed batch. */
23
+ load(key: string): Promise<C | null> | C | null;
24
+ save(key: string, next: C): Promise<void> | void;
25
+ }
26
+
27
+ export interface ConsumerBatch<Item> {
28
+ readonly items: readonly Item[];
29
+ readonly key: string;
30
+ readonly ctx: Ctx;
31
+ /** The run's cancellation — a drain aborts it. Hand it to whatever the body calls. */
32
+ readonly signal: AbortSignal;
33
+ }
34
+
35
+ export interface ConsumerDefinition<Item, C> {
36
+ /** REQUIRED: a durable queue key, the idempotency key's prefix and the supervisor's name. */
37
+ readonly name: string;
38
+ /** REQUIRED, as on `job()`: the org a key's run acts under, from the key — or `'none'`. */
39
+ readonly tenant: JobTenant<ConsumerInput>;
40
+ /**
41
+ * What should be running: one key, or one per account. Asked by the supervisor's arm run under
42
+ * `tenant: 'none'`, so a tenant-scoped read here needs the app's own `crossTenant(reason, fn)`.
43
+ */
44
+ keys(args: { readonly ctx: Ctx }): Promise<readonly string[]> | readonly string[];
45
+ /** The stream, from `cursor`. Any broker is the app's dependency, behind this one seam. */
46
+ open(args: {
47
+ readonly key: string;
48
+ readonly cursor: C | null;
49
+ readonly ctx: Ctx;
50
+ readonly signal: AbortSignal;
51
+ }): AsyncIterable<Item>;
52
+ /**
53
+ * One batch, inside a transaction, answering the cursor past it — which the framework hands to
54
+ * `cursor.save` in the SAME transaction. At least once: a drained or failed batch is delivered
55
+ * again, so the effect must be idempotent per item (item ids are the app's).
56
+ */
57
+ handle(batch: ConsumerBatch<Item>): Promise<C> | C;
58
+ readonly cursor: ConsumerCursor<C>;
59
+ /** Items per transaction, at most. Defaults to `DEFAULT_CONSUMER_BATCH`. */
60
+ readonly batch?: number;
61
+ readonly queue?: string;
62
+ readonly retry?: RetryPolicy;
63
+ /** How often the supervisor re-arms every key: whole minutes dividing an hour. Default `'1m'`. */
64
+ readonly rearm?: DurationInput;
65
+ /** How far behind `key` is, in the app's own unit. Reported on the run's progress. */
66
+ lag?(args: { readonly key: string; readonly cursor: C | null }): Promise<number> | number;
67
+ /** Refused (`X_CONSUMER_INVALID`): a consumer runs until its stream ends or a drain stops it. */
68
+ readonly timeout?: never;
69
+ }
70
+
71
+ /** What one run reports when its stream ends. Bounded, so `x jobs show` can print it. */
72
+ export interface ConsumerReport {
73
+ readonly key: string;
74
+ readonly batches: number;
75
+ readonly items: number;
76
+ }
77
+
78
+ export interface ConsumerHandle extends JobHandle<ConsumerInput> {
79
+ /** The task re-arming every key at `rearm`. Registered at declaration under `<name>.supervisor`. */
80
+ readonly supervisor: TaskHandle;
81
+ }
@@ -0,0 +1,147 @@
1
+ // `consumer()` — reading a queue, a stream or a vendor's cursor as a `job` plus a `.supervisor`
2
+ // `task`: a factory over `job()`, never a ninth primitive. It assembles what a hand-rolled one must
3
+ // remember and has no build error for — one body per key, a key per key, no timeout, a supervisor,
4
+ // the drain, a cursor committed with its batch. The turn itself is `consumer-loop.ts`.
5
+
6
+ import { t } from '@ultimat3/schema';
7
+ import { finiteDurationMs } from './clock';
8
+ import { ConsumerInvalidError } from './consumer-errors';
9
+ import { asConsumer, refuseSelfEnqueue } from './consumer-guard';
10
+ import { consumerRun, DEFAULT_CONSUMER_BATCH } from './consumer-loop';
11
+ import { stampConsumer } from './consumer-registry';
12
+ import type { ConsumerDefinition, ConsumerHandle, ConsumerInput } from './consumer-types';
13
+ import type { EnqueueResult } from './driver';
14
+ import type { JobHandle } from './job';
15
+ import { job } from './job';
16
+ import type { EnqueueManyOptions } from './job-enqueue-many';
17
+ import type { EnqueueOptions } from './outbox';
18
+ import { DEFAULT_RETRY } from './retry';
19
+ import { task } from './task';
20
+
21
+ const MINUTE_MS = 60_000;
22
+
23
+ /** `'1m'` → `* * * * *`; whole minutes dividing an hour, so every tick is evenly spaced. */
24
+ function rearmCron(name: string, rearm: string | number): string {
25
+ const ms = finiteDurationMs(rearm, `consumer "${name}"`, 'rearm');
26
+ const minutes = ms / MINUTE_MS;
27
+ if (!Number.isInteger(minutes) || minutes < 1 || 60 % minutes !== 0) {
28
+ throw new ConsumerInvalidError({
29
+ consumer: name,
30
+ problem: `declares rearm ${String(rearm)}, and the supervisor's cron ticks in whole minutes that divide an hour (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30, 60)`,
31
+ fix: `rearm: '1m' on consumer({ name: "${name}" })`,
32
+ });
33
+ }
34
+ if (minutes === 60) return '0 * * * *';
35
+ return minutes === 1 ? '* * * * *' : `*/${minutes} * * * *`;
36
+ }
37
+
38
+ /** Every refusal a declaration can earn, before anything is registered under its name. */
39
+ function assertDeclaration<Item, C>(definition: ConsumerDefinition<Item, C>): number {
40
+ const name = definition.name;
41
+ const cursor: unknown = definition.cursor;
42
+ const seam = (cursor ?? {}) as { readonly load?: unknown; readonly save?: unknown };
43
+ if (typeof seam.load !== 'function' || typeof seam.save !== 'function') {
44
+ throw new ConsumerInvalidError({
45
+ consumer: name,
46
+ problem:
47
+ 'declares no cursor.load and cursor.save, so a restart would re-read its stream from the top and no batch could commit its position',
48
+ fix: `cursor: { load: (key) => repo.cursorOf(key), save: (key, next) => repo.saveCursor(key, next) } on consumer({ name: "${name}" })`,
49
+ });
50
+ }
51
+ if ((definition as { readonly timeout?: unknown }).timeout !== undefined) {
52
+ throw new ConsumerInvalidError({
53
+ consumer: name,
54
+ problem:
55
+ 'declares a timeout, which kills a long-lived stream mid-batch on a schedule nobody chose; a run ends when its stream does, or when a drain stops it',
56
+ fix: `consumer({ name: "${name}", ...definition }) with the timeout field deleted`,
57
+ });
58
+ }
59
+ for (const field of ['keys', 'open', 'handle'] as const) {
60
+ if (typeof definition[field] !== 'function') {
61
+ throw new ConsumerInvalidError({
62
+ consumer: name,
63
+ problem: `declares no ${field}()`,
64
+ fix: `${field}: (args) => … on consumer({ name: "${name}" })`,
65
+ });
66
+ }
67
+ }
68
+ const size = definition.batch ?? DEFAULT_CONSUMER_BATCH;
69
+ if (!Number.isSafeInteger(size) || size < 1) {
70
+ throw new ConsumerInvalidError({
71
+ consumer: name,
72
+ problem: `declares batch ${String(size)}, and a batch is a whole number of items, at least one`,
73
+ fix: `consumer({ name: "${name}", batch: ${DEFAULT_CONSUMER_BATCH} })`,
74
+ });
75
+ }
76
+ return size;
77
+ }
78
+
79
+ export function consumer<Item, C>(definition: ConsumerDefinition<Item, C>): ConsumerHandle {
80
+ const size = assertDeclaration(definition);
81
+ const name = definition.name;
82
+ const cron = rearmCron(name, definition.rearm ?? '1m');
83
+ const queue = definition.queue === undefined ? {} : { queue: definition.queue };
84
+
85
+ const handle: JobHandle<ConsumerInput> = job<ConsumerInput>({
86
+ name,
87
+ input: t.object({ key: t.string.min(1) }),
88
+ tenant: definition.tenant,
89
+ // A key is live while its row is queued or running, so a re-arm of a running key is deduped:
90
+ // the supervisor is a no-op while a run is live and a restart once it is not.
91
+ idempotencyKey: ({ key }) => `consumer:${name}:${key}`,
92
+ // The backstop the key alone cannot be: two rows of one key never run at once.
93
+ concurrency: { key: ({ key }) => key, limit: 1, whenBusy: 'wait' },
94
+ retry: definition.retry ?? DEFAULT_RETRY,
95
+ ...queue,
96
+ run: (args) => asConsumer(name, () => consumerRun({ definition, size }, args)),
97
+ });
98
+
99
+ // The un-guarded enqueues, kept for the arm run below: it is a different job, and the only
100
+ // thing in the framework that enqueues a consumer.
101
+ const enqueue = handle.enqueue.bind(handle);
102
+ const enqueueMany = handle.enqueueMany.bind(handle);
103
+ // Guarded IN PLACE, never a wrapper object: only the object `job()` built passes `isJobHandle`.
104
+ Object.assign(handle, {
105
+ async enqueue(input: ConsumerInput, options?: EnqueueOptions): Promise<EnqueueResult> {
106
+ refuseSelfEnqueue(handle.name);
107
+ return enqueue(input, options);
108
+ },
109
+ async enqueueMany(inputs: readonly ConsumerInput[], options?: EnqueueManyOptions) {
110
+ refuseSelfEnqueue(handle.name);
111
+ return enqueueMany(inputs, options);
112
+ },
113
+ });
114
+
115
+ // `keys` may read the database, and a task's entries are synchronous — so the tick enqueues
116
+ // this one arm run, which asks `keys` and enqueues every key it answers.
117
+ const arm = job<Record<string, never>>({
118
+ name: `${name}.arm`,
119
+ input: t.object({}),
120
+ tenant: 'none',
121
+ idempotencyKey: () => `consumer:${name}:arm`,
122
+ retry: DEFAULT_RETRY,
123
+ ...queue,
124
+ async run({ ctx, step }) {
125
+ const keys = await definition.keys({ ctx });
126
+ // A step: an arm killed after its enqueue and before its ack replays the result rather than
127
+ // enqueuing the keys again behind a consumer that already finished.
128
+ const results = await step.run('start-keys', () => enqueueMany(keys.map((key) => ({ key }))));
129
+ return { keys: keys.length, started: results.filter((result) => !result.deduped).length };
130
+ },
131
+ });
132
+ const supervisor = task({
133
+ name: `${name}.supervisor`,
134
+ cron,
135
+ tz: 'UTC',
136
+ enqueue: () => [[arm, {}]],
137
+ });
138
+
139
+ stampConsumer(handle, {
140
+ supervisor: supervisor.name,
141
+ arm: arm.name,
142
+ cron,
143
+ batch: size,
144
+ lags: definition.lag !== undefined,
145
+ });
146
+ return Object.assign(handle, { supervisor });
147
+ }
@@ -29,7 +29,8 @@ import {
29
29
  pageLimit,
30
30
  parseJobCursor,
31
31
  } from './introspection';
32
- import type { StepStore } from './steps';
32
+ import { assertResolvable, resolvedRecord } from './step-resolve';
33
+ import type { StepRecord, StepStore } from './steps';
33
34
 
34
35
  export interface MemoryQueueState {
35
36
  readonly jobs: Map<string, JobRecord>;
@@ -51,6 +52,8 @@ export interface MemoryOperator {
51
52
  readonly counters: MemoryCounters;
52
53
  /** The claim scan's question: is this queue held back? */
53
54
  isQueuePaused(queue: string): boolean;
55
+ /** Does a live registry row say this worker drains? `SQL_QUEUE_DEMAND`'s `not exists`. */
56
+ isWorkerDraining(workerId: string): boolean;
54
57
  }
55
58
 
56
59
  /** Newest first by `(createdAt, id)` — the keyset `SQL_JOB_LIST` orders and seeks by. */
@@ -153,6 +156,18 @@ export function memoryOperator(state: MemoryQueueState): MemoryOperator {
153
156
  : matching.slice(Math.max(0, matching.length - limit));
154
157
  },
155
158
 
159
+ async resolveStep(jobId, step, resolution) {
160
+ const before = jobs.get(jobId);
161
+ const record = before === undefined ? undefined : await state.steps.get(before.runId, step);
162
+ // Read again after the await, as `SQL_STEP_RESOLVE` reads the row it writes under: a claim
163
+ // that landed in between makes the job a worker's.
164
+ const job = jobs.get(jobId);
165
+ assertResolvable('memory', jobId, step, job, record);
166
+ const resolved = resolvedRecord(record as StepRecord, resolution, nowMs(clock));
167
+ await state.steps.put(resolved);
168
+ return resolved;
169
+ },
170
+
156
171
  async remove(jobId) {
157
172
  const record = jobs.get(jobId);
158
173
  if (record === undefined) return undefined;
@@ -320,5 +335,13 @@ export function memoryOperator(state: MemoryQueueState): MemoryOperator {
320
335
  rollupCounters: () => Promise.resolve(counters.rollup(nowMs(clock))),
321
336
  };
322
337
 
323
- return { members, counters, isQueuePaused: (queue) => queues.has(queue) };
338
+ return {
339
+ members,
340
+ counters,
341
+ isQueuePaused: (queue) => queues.has(queue),
342
+ isWorkerDraining: (workerId) => {
343
+ const worker = workers.get(workerId);
344
+ return worker?.drainingAt !== undefined && worker.expiresAt > nowMs(clock);
345
+ },
346
+ };
324
347
  }
@@ -19,11 +19,13 @@ import type {
19
19
  JobDriver,
20
20
  JobRecord,
21
21
  NackOptions,
22
+ QueueDemand,
22
23
  QueueStats,
23
24
  } from './driver';
24
25
  import {
25
26
  assertClaimBounds,
26
27
  assertClaimQueues,
28
+ assertEnqueueMany,
27
29
  DEFAULT_QUEUE,
28
30
  LEASE_LAPSED_FINAL_ATTEMPT,
29
31
  LIVE_STATES,
@@ -219,7 +221,7 @@ export function memoryJobDriver(options: MemoryJobDriverOptions = {}): MemoryJob
219
221
  },
220
222
  };
221
223
 
222
- return {
224
+ const driver: MemoryJobDriver = {
223
225
  name: 'memory',
224
226
  steps,
225
227
  backfills,
@@ -275,6 +277,15 @@ export function memoryJobDriver(options: MemoryJobDriverOptions = {}): MemoryJob
275
277
  return { id: record.id, runId: record.runId, deduped: false };
276
278
  },
277
279
 
280
+ // A loop of this driver's own `enqueue`, which IS its semantics: in order, so a second request
281
+ // under a key the first just took is `deduped`, as one `SQL_ENQUEUE_MANY` statement answers.
282
+ async enqueueMany(requests: readonly EnqueueRequest[]): Promise<readonly EnqueueResult[]> {
283
+ assertEnqueueMany('memory', requests);
284
+ const results: EnqueueResult[] = [];
285
+ for (const request of requests) results.push(await driver.enqueue(request));
286
+ return results;
287
+ },
288
+
278
289
  // `async`, so an empty queue list REJECTS here exactly as it does on the pg driver: a
279
290
  // synchronous throw out of a method typed `Promise<…>` is a different answer to the same
280
291
  // question, which is the class of divergence this pair is checked for.
@@ -431,9 +442,32 @@ export function memoryJobDriver(options: MemoryJobDriverOptions = {}): MemoryJob
431
442
  );
432
443
  },
433
444
 
445
+ // `SQL_QUEUE_DEMAND` over these rows. A `running` row is never "due": it counts on its claimer.
446
+ demand(): Promise<readonly QueueDemand[]> {
447
+ const at = nowMs(clock);
448
+ const byQueue = new Map<string, number>();
449
+ for (const record of jobs.values()) {
450
+ const held =
451
+ record.state === 'running' && !operator.isWorkerDraining(record.claimedBy ?? '');
452
+ const due =
453
+ (record.state === 'ready' ||
454
+ record.state === 'delayed' ||
455
+ record.state === 'suspended') &&
456
+ record.runAt <= at &&
457
+ !operator.isQueuePaused(record.queue);
458
+ if (held || due) byQueue.set(record.queue, (byQueue.get(record.queue) ?? 0) + 1);
459
+ }
460
+ return Promise.resolve(
461
+ [...byQueue]
462
+ .map(([queue, demand]) => ({ queue, demand }))
463
+ .sort((a, b) => (a.queue < b.queue ? -1 : a.queue > b.queue ? 1 : 0)),
464
+ );
465
+ },
466
+
434
467
  close(): Promise<void> {
435
468
  jobs.clear();
436
469
  return Promise.resolve();
437
470
  },
438
471
  };
472
+ return driver;
439
473
  }
@@ -0,0 +1,128 @@
1
+ // The claim: the one statement every worker loop runs, and the only one whose cost is a question of
2
+ // how far behind the queue is. Apart from `driver-pg-sql.ts`, which re-exports it, because the plan
3
+ // it depends on takes a page to explain. `driver-pg-claim.live.test.ts` holds that plan to account.
4
+
5
+ import { LEASE_LAPSED_FINAL_ATTEMPT } from './driver';
6
+ import { COUNTER_BUCKET_MS } from './introspection';
7
+
8
+ /** What both arms of the claim return: a `JobRow`, read off the updated row `j`. */
9
+ const CLAIM_ROW_COLUMNS = `j.id, j.name, j.queue, j.input, j.idempotency_key, j.run_id, j.attempt,
10
+ j.max_attempts, j.state, j.tenant_id, j.last_error, j.claimed_by, j.claims,
11
+ j.traceparent, j.enqueued_by,
12
+ (extract(epoch from j.run_at) * 1000)::bigint as run_at,
13
+ (extract(epoch from j.visible_at) * 1000)::bigint as visible_at,
14
+ (extract(epoch from j.created_at) * 1000)::bigint as created_at,
15
+ (extract(epoch from j.updated_at) * 1000)::bigint as updated_at`;
16
+
17
+ const CLAIM_ROW_NAMES = `id, name, queue, input, idempotency_key, run_id, attempt, max_attempts,
18
+ state, tenant_id, last_error, claimed_by, claims, traceparent, enqueued_by, run_at,
19
+ visible_at, created_at, updated_at`;
20
+
21
+ /**
22
+ * The claim. SKIP LOCKED is the whole design: without it, worker 2 blocks on worker 1's row
23
+ * lock and throughput collapses to one worker. `visible_at` in the predicate reclaims leases
24
+ * abandoned by a crashed worker. `cancelled` is absent from every branch by construction — a
25
+ * cancelled row is terminal, so it is never handed to a worker again.
26
+ *
27
+ * TWO arms over one pick. A `running` row is picked only with a lapsed lease, and one that lapsed
28
+ * on its FINAL attempt (`attempt >= max_attempts` — `isFinalAttempt`, `retry.ts`) is `buried`:
29
+ * settled `dead` here, counted, and answered with its new state so the driver reports it instead
30
+ * of handing it out. Re-claimed, it was an attempt past `max_attempts` every lease, forever — a
31
+ * job that kills its worker took one per visibility timeout and never reached the dead letters.
32
+ * `claimed_by` is cleared with the lease, so the body that stalled rather than died cannot settle
33
+ * the row it lost. A buried row spends a slot of `$2`: the pass after it claims what it displaced.
34
+ * `$5` names the jobs declaring `retry.deadLetter: false`: their rows are buried `failed`.
35
+ *
36
+ * `picked` SEEKS, so a claim costs the same at any backlog. One `state in (...) or running` test
37
+ * over `queue = any(...)` cannot be served in `run_at` order by any index, so it read and sorted
38
+ * every due row, and a queue slowed down exactly when it was behind. Hence two ordered index scans
39
+ * per open queue, due rows and lapsed leases, then an outer sort of at most `2 x limit x queues`
40
+ * rows. The due scan's `state in` is `x_jobs_claim_idx`'s predicate word for word, or the planner
41
+ * cannot prove the index applies; the lapsed scan reads `x_jobs_depth_idx`. Measured 2026-11-02,
42
+ * Postgres 17, 1M `done` rows, a backlog of 100k, shared box (read the ratios): one queue
43
+ * 88-122 ms against 0.8 ms; three queues 130-189 ms against 0.7-0.9 ms; a noop `jobWorker` on 8
44
+ * slots 42-55 jobs/s against 443-493. At a backlog of 1,000 the two are level.
45
+ *
46
+ * Rows a scan locked and the outer `limit` dropped stay locked until the TRANSACTION ends, not the
47
+ * statement. That is correct only because a claim autocommits (`exec()` in `driver-pg.ts` is the
48
+ * pool), so the two coincide; a concurrent claimer may skip them for one poll, which `skip locked`
49
+ * already permits. A claim inside a long transaction would hold them for its whole life.
50
+ *
51
+ * `distinct` because a queue named twice is two laterals over one queue, and a row this statement
52
+ * already locked is not skipped: the repeat would spend `limit` on rows it had already picked.
53
+ */
54
+ export const SQL_CLAIM = `
55
+ with open as (
56
+ select distinct q.queue
57
+ from unnest($1::text[]) as q(queue)
58
+ where not exists (
59
+ select 1 from x_job_pauses p where p.kind = 'queue' and p.name = q.queue
60
+ )
61
+ ), picked as (
62
+ select id, exhausted
63
+ from (
64
+ select due.id, due.run_at, due.exhausted
65
+ from open
66
+ cross join lateral (
67
+ select id, run_at, false as exhausted
68
+ from x_jobs
69
+ where queue = open.queue
70
+ and state in ('ready', 'delayed', 'suspended')
71
+ and run_at <= now()
72
+ order by run_at
73
+ limit $2
74
+ for update skip locked
75
+ ) due
76
+ union all
77
+ select lapsed.id, lapsed.run_at, lapsed.exhausted
78
+ from open
79
+ cross join lateral (
80
+ select id, run_at, attempt >= max_attempts as exhausted
81
+ from x_jobs
82
+ where queue = open.queue
83
+ and state = 'running' and visible_at <= now()
84
+ and run_at <= now()
85
+ order by run_at
86
+ limit $2
87
+ for update skip locked
88
+ ) lapsed
89
+ ) candidates
90
+ order by run_at
91
+ limit $2
92
+ ), buried as (
93
+ update x_jobs j
94
+ set state = case when j.name = any($5::text[]) then 'failed' else 'dead' end,
95
+ visible_at = null,
96
+ claimed_by = null,
97
+ last_error = '${LEASE_LAPSED_FINAL_ATTEMPT}',
98
+ last_error_stack = null,
99
+ updated_at = now()
100
+ from picked p
101
+ where j.id = p.id and p.exhausted
102
+ returning ${CLAIM_ROW_COLUMNS}
103
+ ), counted as (
104
+ insert into x_job_counters (job, bucket_ms, bucket_start, dead, failed, duration_ms)
105
+ select name, ${COUNTER_BUCKET_MS}, date_trunc('minute', now()),
106
+ (count(*) filter (where state = 'dead'))::int,
107
+ (count(*) filter (where state = 'failed'))::int, 0
108
+ from buried
109
+ group by name
110
+ on conflict (job, bucket_ms, bucket_start) do update
111
+ set dead = x_job_counters.dead + excluded.dead,
112
+ failed = x_job_counters.failed + excluded.failed
113
+ ), claimed as (
114
+ update x_jobs j
115
+ set state = 'running',
116
+ attempt = j.attempt + 1,
117
+ claims = j.claims + 1,
118
+ claimed_by = $3,
119
+ visible_at = now() + ($4::bigint * interval '1 millisecond'),
120
+ updated_at = now()
121
+ from picked p
122
+ where j.id = p.id and not p.exhausted
123
+ returning ${CLAIM_ROW_COLUMNS}
124
+ )
125
+ select ${CLAIM_ROW_NAMES} from claimed
126
+ union all
127
+ select ${CLAIM_ROW_NAMES} from buried
128
+ `.trim();
@@ -194,6 +194,11 @@ create table if not exists x_job_events (
194
194
  create index if not exists x_job_events_lookup_idx
195
195
  on x_job_events (name, published_at);
196
196
 
197
+ -- The steps parked on an event, which is what a waking publish reads. Partial, so it holds only
198
+ -- the open waits: without it each publish is a scan of every step ever written.
199
+ create index if not exists x_job_steps_waiting_idx
200
+ on x_job_steps (event) where status = 'waiting';
201
+
197
202
  -- What the body last reported through progress, as one value: done, total, note and the instant.
198
203
  -- A column and not a table, because it is read with the row and dies with it. Added by alter
199
204
  -- because x_jobs shipped without it.
@@ -257,11 +262,15 @@ create table if not exists x_job_workers (
257
262
  );
258
263
 
259
264
  -- When the worker began draining (a retire, a SIGTERM, a stop) - set by its first draining
260
- -- announce and kept by every later one, null while it serves. A queue-depth autoscaler leaves the
261
- -- jobs such a worker still holds out of its count (SQL_SCALER_DEPTH): its replacement cannot take
265
+ -- announce and kept by every later one, null while it serves. A queue-demand autoscaler leaves the
266
+ -- jobs such a worker still holds out of its count (SQL_SCALER_DEMAND): its replacement cannot take
262
267
  -- them. Added by alter because x_job_workers shipped without it.
263
268
  alter table x_job_workers add column if not exists draining_at timestamptz;
264
269
 
270
+ -- The build the worker runs, the id x-ultimate-build answers with. Null on a row a worker from
271
+ -- before this column wrote. x deploy verify counts a queue as served only by a row of this build.
272
+ alter table x_job_workers add column if not exists build text;
273
+
265
274
  -- Per-job history, in buckets. A settle adds to the one-minute bucket in the SAME statement that
266
275
  -- moves the row, and the scheduler leader folds old buckets into wider ones, so the key count per
267
276
  -- job name is fixed whatever the throughput.
@@ -0,0 +1,27 @@
1
+ // `queue_demand` on Postgres: which `x_jobs` rows a serving worker of the fleet must hold, and that
2
+ // count per queue. One predicate, because `SQL_SCALER_DEMAND` (a scaler reading Postgres itself) and
3
+ // the gauge answer one question. Only Postgres knows who drains, so no sum of gauges can replace it.
4
+
5
+ /**
6
+ * A `running` row whose claimer's live registry row says `draining_at` is out: that worker claims
7
+ * nothing more and finishes the job itself, so no pod a scaler adds could take it. One adopter
8
+ * counted them and averaged 1.84 pods where one carried the load. A claimer with no live row
9
+ * (killed, lapsed) stays in: the queue hands that job back and somebody must pick it up.
10
+ */
11
+ export const QUEUE_DEMAND_ROWS = `((j.state = 'running'
12
+ and not exists (select 1 from x_job_workers w
13
+ where w.id = j.claimed_by
14
+ and w.draining_at is not null
15
+ and w.expires_at > now()))
16
+ or (j.state in ('ready', 'delayed', 'suspended') and j.run_at <= now()
17
+ and not exists (select 1 from x_job_pauses p
18
+ where p.kind = 'queue' and p.name = j.queue)))`;
19
+
20
+ /** Grouped, every 15 s from a worker with work about: an idle one never asks (`worker-queue-demand.ts`). */
21
+ export const SQL_QUEUE_DEMAND = `
22
+ select j.queue, count(*)::int as demand
23
+ from x_jobs j
24
+ where ${QUEUE_DEMAND_ROWS}
25
+ group by j.queue
26
+ order by j.queue collate "C"
27
+ `.trim();