@ultimat3/jobs 29.4.0 → 30.1.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 +149 -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 +99 -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,153 @@
1
+ // The Postgres driver's `enqueueMany`: every request in ONE insert over `jsonb_array_elements`,
2
+ // with `SQL_ENQUEUE`'s conflict target and its `not exists` on the id, then one read for the rows
3
+ // that insert did not return. Split from `driver-pg.ts` at that file's size ceiling.
4
+
5
+ import type { Clock, PgExecutor } from '@ultimat3/core';
6
+ import { uuidV7 } from '@ultimat3/core';
7
+ import { nowMs } from './clock';
8
+ import type { EnqueueRequest, EnqueueResult } from './driver';
9
+ import { assertEnqueueMany, DEFAULT_QUEUE } from './driver';
10
+ import { notifyJobReady } from './driver-pg-wake-sql';
11
+
12
+ /**
13
+ * Each request is one element of `$1`, in request order. The insert is `SQL_ENQUEUE`'s, row for
14
+ * row: the same conflict target spelling the index expression, and the same `not exists` on the
15
+ * id — without it a relayed batch repeated after a crash runs every job that already FINISHED a
16
+ * second time, because the partial index collapses a repeat only while its job is live.
17
+ *
18
+ * Two requests sharing a key inside the batch: `order by ord` inserts the first, and `do nothing`
19
+ * skips the second against the row the same statement just wrote.
20
+ *
21
+ * `runAt` crosses as `numeric` and is rounded by its `::bigint`, the instant `SQL_ENQUEUE`'s
22
+ * `$8::numeric::bigint` stores: JSON text such as `1767225660000.5` is no `bigint` literal, so a fractional
23
+ * epoch ms — `now + Math.random() * 1000` — was a raw `22P02` here and fine one row at a time.
24
+ *
25
+ * ONE wake per queue: only the earliest-due row of each queue asks `notifyJobReady`. A batch of
26
+ * 1,000 rows is one queue becoming claimable, not 1,000 announcements of it.
27
+ */
28
+ export const SQL_ENQUEUE_MANY = `
29
+ with req as (
30
+ select (r->>'id')::uuid as id, r->>'name' as name, r->>'queue' as queue, r->'input' as input,
31
+ r->>'idempotencyKey' as idempotency_key, (r->>'runId')::uuid as run_id,
32
+ (r->>'maxAttempts')::int as max_attempts,
33
+ to_timestamp((r->>'runAt')::numeric::bigint / 1000.0) as run_at,
34
+ r->>'tenantId' as tenant_id, r->>'traceparent' as traceparent,
35
+ r->>'enqueuedBy' as enqueued_by, t.ord
36
+ from jsonb_array_elements($1::jsonb) with ordinality as t(r, ord)
37
+ ), inserted as (
38
+ insert into x_jobs
39
+ (id, name, queue, input, idempotency_key, run_id, max_attempts, state, run_at, tenant_id,
40
+ traceparent, enqueued_by)
41
+ select q.id, q.name, q.queue, q.input, q.idempotency_key, q.run_id, q.max_attempts,
42
+ case when q.run_at > now() then 'delayed' else 'ready' end,
43
+ q.run_at, q.tenant_id, q.traceparent, q.enqueued_by
44
+ from req q
45
+ where not exists (select 1 from x_jobs published where published.id = q.id)
46
+ order by q.ord
47
+ on conflict (name, (coalesce(tenant_id, '')), idempotency_key)
48
+ where state in ('ready', 'delayed', 'running', 'suspended')
49
+ do nothing
50
+ returning id, run_id, queue, run_at
51
+ )
52
+ select r.id, r.run_id,
53
+ case when r.first then${notifyJobReady('r')}::text end as woke
54
+ from (select i.id, i.run_id, i.queue, i.run_at,
55
+ row_number() over (partition by i.queue order by i.run_at, i.id) = 1 as first
56
+ from inserted i) r
57
+ `.trim();
58
+
59
+ /**
60
+ * Who answers a request the insert skipped, one row per request: the job under its own id — a
61
+ * publish repeated, `pick` 0 — or the LIVE holder of its key in its name and tenant, `pick` 1,
62
+ * which `SQL_FIND_LIVE_BY_KEY` reads one at a time. A request neither finds is a holder that
63
+ * settled between the two statements, and goes back through `enqueue`, which retries it.
64
+ */
65
+ export const SQL_ENQUEUE_MANY_RESOLVE = `
66
+ select (t.r->>'ord')::int as ord, x.id, x.run_id
67
+ from jsonb_array_elements($1::jsonb) as t(r)
68
+ cross join lateral (
69
+ select c.id, c.run_id, c.pick from (
70
+ select j.id, j.run_id, 0 as pick from x_jobs j where j.id = (t.r->>'id')::uuid
71
+ union all
72
+ select j.id, j.run_id, 1 as pick from x_jobs j
73
+ where j.name = t.r->>'name'
74
+ and j.idempotency_key = t.r->>'idempotencyKey'
75
+ and coalesce(j.tenant_id, '') = coalesce(t.r->>'tenantId', '')
76
+ and j.state in ('ready', 'delayed', 'running', 'suspended')
77
+ ) c
78
+ order by c.pick
79
+ limit 1
80
+ ) x
81
+ `.trim();
82
+
83
+ interface Inserted {
84
+ readonly id: string;
85
+ readonly run_id: string;
86
+ }
87
+
88
+ interface Resolved extends Inserted {
89
+ readonly ord: number | string;
90
+ }
91
+
92
+ /** A request with its ids settled: the batch is read back by id, so every row needs one. */
93
+ type Stated = EnqueueRequest & { readonly id: string; readonly runId: string };
94
+
95
+ const wire = (request: Stated, at: number) => ({
96
+ id: request.id,
97
+ runId: request.runId,
98
+ name: request.name,
99
+ queue: request.queue || DEFAULT_QUEUE,
100
+ input: request.input ?? null,
101
+ idempotencyKey: request.idempotencyKey,
102
+ maxAttempts: request.maxAttempts,
103
+ runAt: request.runAt ?? at,
104
+ tenantId: request.tenantId,
105
+ traceparent: request.traceparent,
106
+ enqueuedBy: request.enqueuedBy,
107
+ });
108
+
109
+ /**
110
+ * `enqueueMany` over `exec`. `enqueueOne` is the driver's own `enqueue`, for the one request
111
+ * whose answer moved between the two statements. Answers in request order.
112
+ */
113
+ export async function pgEnqueueMany(
114
+ exec: () => PgExecutor,
115
+ clock: Clock,
116
+ requests: readonly EnqueueRequest[],
117
+ enqueueOne: (request: EnqueueRequest) => Promise<EnqueueResult>,
118
+ ): Promise<readonly EnqueueResult[]> {
119
+ assertEnqueueMany('pg', requests);
120
+ if (requests.length === 0) return [];
121
+ const at = nowMs(clock);
122
+ const stated: Stated[] = requests.map((request) => ({
123
+ ...request,
124
+ id: request.id ?? uuidV7(),
125
+ runId: request.runId ?? uuidV7(),
126
+ }));
127
+ const rows = await exec().query<Inserted>(SQL_ENQUEUE_MANY, [
128
+ JSON.stringify(stated.map((request) => wire(request, at))),
129
+ ]);
130
+ const inserted = new Map(rows.map((row) => [row.id, row.run_id]));
131
+ const results: (EnqueueResult | undefined)[] = stated.map((request) => {
132
+ const runId = inserted.get(request.id);
133
+ return runId === undefined ? undefined : { id: request.id, runId, deduped: false };
134
+ });
135
+ const skipped = stated.flatMap((request, ord) =>
136
+ results[ord] === undefined ? [{ ...wire(request, at), ord }] : [],
137
+ );
138
+ if (skipped.length === 0) return results.filter((result) => result !== undefined);
139
+
140
+ const found = await exec().query<Resolved>(SQL_ENQUEUE_MANY_RESOLVE, [JSON.stringify(skipped)]);
141
+ for (const row of found) {
142
+ const ord = Number(row.ord);
143
+ if (stated[ord] === undefined) continue;
144
+ results[ord] = { id: row.id, runId: row.run_id, deduped: true };
145
+ }
146
+ const answered: EnqueueResult[] = [];
147
+ for (const [ord, result] of results.entries()) {
148
+ const request = stated[ord];
149
+ if (result !== undefined) answered.push(result);
150
+ else if (request !== undefined) answered.push(await enqueueOne(request));
151
+ }
152
+ return answered;
153
+ }
@@ -0,0 +1,51 @@
1
+ // The publish that wakes its waiters: one statement inserts the event, makes every run parked on
2
+ // it due, and tells each of their queues. One statement, so a rollback undoes all three, and the
3
+ // NOTIFY is delivered at commit only. What `postgresEventBus({ wakes: true })` runs.
4
+
5
+ import { JOBS_WAKE_CHANNEL } from './driver-pg-wake-sql';
6
+
7
+ /**
8
+ * `SQL_EVENT_PUBLISH`, plus the wake. A waiter is a `waiting` step on this event whose key is this
9
+ * event's or absent — `SQL_EVENT_FIND`'s match, read from the step's side — over the partial
10
+ * `x_job_steps_waiting_idx`, so a publish with no waiter reads no step at all.
11
+ *
12
+ * Woken means `run_at` moved to now on a `suspended` row that was not already due: a running row,
13
+ * a finished one, or one a concurrent publish already woke (the row lock re-checks `run_at` after
14
+ * the first commit) is left alone. The poll stays the guarantee; this only brings it forward.
15
+ *
16
+ * One `pg_notify` per DISTINCT queue of the woken rows, and none when nothing woke: never
17
+ * unconditional, for the commit lock `driver-pg-wake-sql.ts` describes. The payload is the queue.
18
+ *
19
+ * $1 id, $2 name, $3 payload, $4 correlation key, $5 ttl (ms).
20
+ */
21
+ export const SQL_EVENT_PUBLISH_WAKE = `
22
+ with ev as (
23
+ insert into x_job_events (id, name, payload, correlation_key, published_at, expires_at)
24
+ values ($1, $2, $3::jsonb, $4, statement_timestamp(),
25
+ statement_timestamp() + ($5::bigint * interval '1 millisecond'))
26
+ returning published_at, expires_at
27
+ ), waiters as (
28
+ select distinct s.run_id
29
+ from x_job_steps s
30
+ where s.status = 'waiting'
31
+ and s.event = $2
32
+ and (s.correlation_key is null or s.correlation_key = $4::text)
33
+ ), woken as (
34
+ update x_jobs j
35
+ set run_at = statement_timestamp(),
36
+ updated_at = statement_timestamp()
37
+ from waiters w
38
+ where j.run_id = w.run_id
39
+ and j.state = 'suspended'
40
+ and j.run_at > statement_timestamp()
41
+ returning j.queue
42
+ ), told as (
43
+ select count(pg_notify('${JOBS_WAKE_CHANNEL}', q.queue))::int as told
44
+ from (select distinct queue from woken) q
45
+ )
46
+ select floor(extract(epoch from ev.published_at) * 1000)::bigint as published_at,
47
+ floor(extract(epoch from ev.expires_at) * 1000)::bigint as expires_at,
48
+ (select count(*) from woken)::int as woken,
49
+ (select told from told) as told
50
+ from ev
51
+ `.trim();
@@ -148,19 +148,22 @@ select name, (extract(epoch from paused_at) * 1000)::bigint as paused_at
148
148
 
149
149
  /**
150
150
  * A worker's heartbeat: the whole row rewritten, expiry pushed out. $1 id, $2 host, $3 queues,
151
- * $4 slots, $5 in-flight ids, $6 started (ms), $7 ttl (ms), $8 draining. `draining_at` is stamped by
152
- * the first draining announce and KEPT by the later ones — when the drain began, not the last beat.
151
+ * $4 slots, $5 in-flight ids, $6 started (ms), $7 ttl (ms), $8 draining, $9 build. `draining_at` is
152
+ * stamped by the first draining announce and KEPT by the later ones — when the drain began, not the
153
+ * last beat.
153
154
  */
154
155
  export const SQL_WORKER_ANNOUNCE = `
155
156
  insert into x_job_workers
156
- (id, host, queues, concurrency, in_flight, started_at, heartbeat_at, expires_at, draining_at)
157
+ (id, host, queues, concurrency, in_flight, started_at, heartbeat_at, expires_at, draining_at,
158
+ build)
157
159
  values
158
160
  ($1, $2, $3::jsonb, $4::int, $5::jsonb, to_timestamp($6::bigint / 1000.0), now(),
159
- now() + ($7::bigint * interval '1 millisecond'), case when $8::boolean then now() end)
161
+ now() + ($7::bigint * interval '1 millisecond'), case when $8::boolean then now() end,
162
+ $9::text)
160
163
  on conflict (id) do update
161
164
  set host = excluded.host, queues = excluded.queues, concurrency = excluded.concurrency,
162
165
  in_flight = excluded.in_flight, heartbeat_at = excluded.heartbeat_at,
163
- expires_at = excluded.expires_at,
166
+ expires_at = excluded.expires_at, build = excluded.build,
164
167
  draining_at = case when $8::boolean
165
168
  then coalesce(x_job_workers.draining_at, excluded.draining_at) end
166
169
  `.trim();
@@ -177,7 +180,7 @@ export const SQL_WORKERS = `
177
180
  with lapsed as (
178
181
  delete from x_job_workers where expires_at <= now()
179
182
  )
180
- select id, host, queues, concurrency, in_flight,
183
+ select id, host, queues, concurrency, in_flight, build,
181
184
  (extract(epoch from started_at) * 1000)::bigint as started_at,
182
185
  (extract(epoch from heartbeat_at) * 1000)::bigint as heartbeat_at,
183
186
  (extract(epoch from draining_at) * 1000)::bigint as draining_at
@@ -325,3 +328,28 @@ select (select count(*) from moved)::int as fired, q.id, q.run_id, q.idempotency
325
328
  from (select 1) as one
326
329
  left join queued q on true
327
330
  `.trim();
331
+
332
+ /**
333
+ * `resolveStep`, ONE write: the step becomes `completed` with `$4` (ran) or `cleared` (did not),
334
+ * only while its job is held by no worker and the step started or failed without completing. The
335
+ * job is read in the same statement, so a claim that lands first wins and nothing is written.
336
+ *
337
+ * $1 job id, $2 step name, $3 'completed' or 'cleared', $4 the output (jsonb text).
338
+ */
339
+ export const SQL_STEP_RESOLVE = `
340
+ with job as (
341
+ select run_id from x_jobs where id = $1::uuid and state <> 'running'
342
+ )
343
+ update x_job_steps s
344
+ set status = $3,
345
+ output = $4::jsonb,
346
+ completed_at = case when $3 = 'completed' then statement_timestamp() else null end,
347
+ error = null
348
+ from job
349
+ where s.run_id = job.run_id and s.name = $2 and s.status in ('started', 'failed')
350
+ returning s.run_id, s.name, s.status, s.output, s.attempts, s.error,
351
+ (extract(epoch from s.started_at) * 1000)::bigint as started_at,
352
+ (extract(epoch from s.completed_at) * 1000)::bigint as completed_at,
353
+ (extract(epoch from s.wake_at) * 1000)::bigint as wake_at,
354
+ s.event, s.correlation_key
355
+ `.trim();
@@ -21,16 +21,20 @@ import {
21
21
  SQL_PAUSE,
22
22
  SQL_PAUSED,
23
23
  SQL_RESUME,
24
+ SQL_STEP_RESOLVE,
24
25
  SQL_TASK_FIRE_RECORD,
25
26
  SQL_TASK_FIRES,
26
27
  SQL_WORKER_ANNOUNCE,
27
28
  SQL_WORKER_FORGET,
28
29
  SQL_WORKERS,
29
30
  } from './driver-pg-operator-sql';
30
- import type { JobRow } from './driver-pg-rows';
31
- import { num, toJobRecord } from './driver-pg-rows';
31
+ import type { JobRow, StepRow } from './driver-pg-rows';
32
+ import { num, toJobRecord, toStepRecord } from './driver-pg-rows';
33
+ import { SQL_STEP_GET } from './driver-pg-sql';
32
34
  import { JOBS_WAKE_CHANNEL, SQL_WAKE } from './driver-pg-wake-sql';
33
35
  import { JobNotRemovableError } from './errors-operator';
36
+ import { JobNotFoundError } from './errors-requeue';
37
+ import { StepNotResolvableError } from './errors-saga';
34
38
  import type {
35
39
  BulkFilter,
36
40
  BulkResult,
@@ -47,6 +51,7 @@ import {
47
51
  pageLimit,
48
52
  parseJobCursor,
49
53
  } from './introspection';
54
+ import { assertResolvable } from './step-resolve';
50
55
 
51
56
  export type PgOperatorMembers = Omit<
52
57
  JobIntrospection,
@@ -152,6 +157,32 @@ export function pgOperator(
152
157
  return rows.map(toJobRecord);
153
158
  },
154
159
 
160
+ async resolveStep(jobId, step, resolution) {
161
+ // Read first, so an id nobody queued — or no uuid at all — is `X_JOB_NOT_FOUND` as on memory.
162
+ const before = await job(jobId);
163
+ if (before === undefined) throw new JobNotFoundError({ jobId, driver: 'pg' });
164
+ const rows = await exec().query<StepRow>(SQL_STEP_RESOLVE, [
165
+ jobId,
166
+ step,
167
+ resolution.ran ? 'completed' : 'cleared',
168
+ resolution.ran ? JSON.stringify(resolution.output ?? null) : null,
169
+ ]);
170
+ const written = rows[0];
171
+ if (written !== undefined) return toStepRecord(written);
172
+ // Nothing written: say why, from what the rows hold now — the memory driver's refusals.
173
+ const current = await job(jobId);
174
+ const found = await exec().query<StepRow>(SQL_STEP_GET, [before.runId, step]);
175
+ const record = found[0] === undefined ? undefined : toStepRecord(found[0]);
176
+ assertResolvable('pg', jobId, step, current, record);
177
+ // Resolvable now and not a moment ago: a worker held the job, and has let go since.
178
+ throw new StepNotResolvableError({
179
+ jobId,
180
+ step,
181
+ state: 'running',
182
+ status: record?.status ?? 'missing',
183
+ });
184
+ },
185
+
155
186
  async remove(jobId) {
156
187
  const current = await job(jobId);
157
188
  if (current === undefined) return undefined;
@@ -252,6 +283,7 @@ export function pgOperator(
252
283
  worker.startedAt,
253
284
  finiteOption('the pg worker registry', 'ttlMs', ttlMs),
254
285
  worker.draining === true,
286
+ worker.build ?? null,
255
287
  ]);
256
288
  },
257
289
  async forgetWorker(workerId) {
@@ -267,6 +299,7 @@ export function pgOperator(
267
299
  started_at: Count;
268
300
  heartbeat_at: Count;
269
301
  draining_at: Count | null;
302
+ build: string | null;
270
303
  }>(SQL_WORKERS, []);
271
304
  return rows.map((row) => ({
272
305
  id: row.id,
@@ -277,6 +310,7 @@ export function pgOperator(
277
310
  inFlight: stringList(row.in_flight),
278
311
  heartbeatAt: num(row.heartbeat_at),
279
312
  ...(row.draining_at === null ? {} : { drainingAt: num(row.draining_at) }),
313
+ ...(row.build === null ? {} : { build: row.build }),
280
314
  }));
281
315
  },
282
316
 
@@ -20,6 +20,31 @@ select id,${NOTIFY_STAGED}::text as woke
20
20
  from staged
21
21
  `.trim();
22
22
 
23
+ /**
24
+ * `SQL_OUTBOX_STAGE` for a whole chunk: each element of `$1` one row, in order, and the wake ONCE
25
+ * for the statement rather than once per row — the same `NOTIFY_STAGED` clause, on the caller's
26
+ * connection, so it still commits or vanishes with the business rows. `staged_at` is shared by
27
+ * the chunk and `id` (a UUIDv7) orders it, which is the claim's total key. Both instants cross as
28
+ * `numeric`, never `bigint`: a fractional epoch ms is no `bigint` literal, and `SQL_OUTBOX_STAGE`
29
+ * takes one — a `22P02` here failed the caller's whole transaction.
30
+ */
31
+ export const SQL_OUTBOX_STAGE_MANY = `
32
+ with staged as (
33
+ insert into x_outbox
34
+ (id, job, queue, input, idempotency_key, max_attempts, run_at, staged_at, tenant_id,
35
+ traceparent, enqueued_by, run_id)
36
+ select (r->>'id')::uuid, r->>'job', r->>'queue', r->'input', r->>'idempotencyKey',
37
+ (r->>'maxAttempts')::int, to_timestamp((r->>'runAt')::numeric / 1000.0),
38
+ to_timestamp((r->>'stagedAt')::numeric / 1000.0), r->>'tenantId', r->>'traceparent',
39
+ r->>'enqueuedBy', (r->>'runId')::uuid
40
+ from jsonb_array_elements($1::jsonb) with ordinality as t(r, ord)
41
+ order by t.ord
42
+ returning id
43
+ )
44
+ select count(*)::bigint as staged,${NOTIFY_STAGED}::text as woke
45
+ from staged
46
+ `.trim();
47
+
23
48
  /**
24
49
  * The claim, and it has to be ONE statement. `for update skip locked` in a bare select holds its
25
50
  * row locks only until that statement ends — under autocommit, before `claim()` even resolves — so
@@ -97,6 +122,15 @@ update x_outbox set published_at = to_timestamp($2 / 1000.0)
97
122
  where id = $1 and published_at is null and claimed_by = $3
98
123
  `.trim();
99
124
 
125
+ /**
126
+ * `SQL_OUTBOX_MARK_PUBLISHED` for a published batch: the same two fences, row for row, so a
127
+ * lapsed claimant's batch mark retires none of the rows a newer claimant now holds.
128
+ */
129
+ export const SQL_OUTBOX_MARK_PUBLISHED_MANY = `
130
+ update x_outbox set published_at = to_timestamp($2 / 1000.0)
131
+ where id = any($1::uuid[]) and published_at is null and claimed_by = $3
132
+ `.trim();
133
+
100
134
  /**
101
135
  * The job a staged row's publish made, by the id the two share. Read when `SQL_ENQUEUE` inserted
102
136
  * nothing for a request that named its id: the answer is that publish, repeated.
@@ -7,7 +7,9 @@
7
7
  // it is applied once at boot and never by a driver method. Re-exported from here so the install
8
8
  // point keeps the ONE import path every caller already uses.
9
9
 
10
+ export { SQL_CLAIM } from './driver-pg-claim-sql';
10
11
  export { SQL_JOBS_TABLE } from './driver-pg-ddl';
12
+ export { SQL_ENQUEUE_MANY, SQL_ENQUEUE_MANY_RESOLVE } from './driver-pg-enqueue-many';
11
13
  // The whole-`x_jobs`-row reads, split off at this file's size ceiling and re-exported for the
12
14
  // same reason the DDL is. `JOB_ROW_COLUMNS` is imported as a value because `SQL_CANCEL` also
13
15
  // returns a whole row and must project it identically — two spellings of one row shape is how
@@ -20,34 +22,20 @@ export {
20
22
  SQL_JOB_LIVE_HOLDER,
21
23
  SQL_JOB_REQUEUE,
22
24
  } from './driver-pg-jobs-sql';
23
-
24
25
  export {
25
26
  SQL_OUTBOX_CLAIM,
26
27
  SQL_OUTBOX_MARK_PUBLISHED,
28
+ SQL_OUTBOX_MARK_PUBLISHED_MANY,
27
29
  SQL_OUTBOX_PENDING_COUNT,
28
30
  SQL_OUTBOX_PUBLISHED_JOB,
29
31
  SQL_OUTBOX_RELEASE,
30
32
  SQL_OUTBOX_STAGE,
33
+ SQL_OUTBOX_STAGE_MANY,
31
34
  } from './driver-pg-outbox-sql';
32
35
  export { SQL_ACK, SQL_NACK } from './driver-pg-settle-sql';
33
36
 
34
- import { LEASE_LAPSED_FINAL_ATTEMPT } from './driver';
35
37
  import { JOB_ROW_COLUMNS } from './driver-pg-jobs-sql';
36
38
  import { notifyJobReady } from './driver-pg-wake-sql';
37
- import { COUNTER_BUCKET_MS } from './introspection';
38
-
39
- /** What both arms of the claim return: a `JobRow`, read off the updated row `j`. */
40
- const CLAIM_ROW_COLUMNS = `j.id, j.name, j.queue, j.input, j.idempotency_key, j.run_id, j.attempt,
41
- j.max_attempts, j.state, j.tenant_id, j.last_error, j.claimed_by, j.claims,
42
- j.traceparent, j.enqueued_by,
43
- (extract(epoch from j.run_at) * 1000)::bigint as run_at,
44
- (extract(epoch from j.visible_at) * 1000)::bigint as visible_at,
45
- (extract(epoch from j.created_at) * 1000)::bigint as created_at,
46
- (extract(epoch from j.updated_at) * 1000)::bigint as updated_at`;
47
-
48
- const CLAIM_ROW_NAMES = `id, name, queue, input, idempotency_key, run_id, attempt, max_attempts,
49
- state, tenant_id, last_error, claimed_by, claims, traceparent, enqueued_by, run_at,
50
- visible_at, created_at, updated_at`;
51
39
 
52
40
  /**
53
41
  * The insert, and its wake: `woke` is the notification's own column, evaluated once per inserted
@@ -57,6 +45,10 @@ const CLAIM_ROW_NAMES = `id, name, queue, input, idempotency_key, run_id, attemp
57
45
  * (`EnqueueRequest.id`), so a publish repeated after a crash meets the job the first one made —
58
46
  * in any state — and inserts nothing; the partial index below collapses a repeat only while that
59
47
  * job is still live. A select over `values`, so each parameter carries its cast.
48
+ *
49
+ * `$8::numeric::bigint`, never `$8::bigint`: a fractional epoch ms — `now + Math.random() * 1000`
50
+ * — binds as text `1767225660000.5` on some clients, which no `bigint` reads (`22P02`). Through
51
+ * `numeric` it is rounded to the ms, the instant `SQL_ENQUEUE_MANY` stores for the same request.
60
52
  */
61
53
  export const SQL_ENQUEUE = `
62
54
  with inserted as (
@@ -64,8 +56,8 @@ with inserted as (
64
56
  (id, name, queue, input, idempotency_key, run_id, max_attempts, state, run_at, tenant_id,
65
57
  traceparent, enqueued_by)
66
58
  select $1::uuid, $2::text, $3::text, $4::jsonb, $5::text, $6::uuid, $7::int,
67
- case when to_timestamp($8::bigint / 1000.0) > now() then 'delayed' else 'ready' end,
68
- to_timestamp($8::bigint / 1000.0), $9::text, $10::text, $11::text
59
+ case when to_timestamp($8::numeric::bigint / 1000.0) > now() then 'delayed' else 'ready' end,
60
+ to_timestamp($8::numeric::bigint / 1000.0), $9::text, $10::text, $11::text
69
61
  where not exists (select 1 from x_jobs published where published.id = $1::uuid)
70
62
  on conflict (name, (coalesce(tenant_id, '')), idempotency_key)
71
63
  where state in ('ready', 'delayed', 'running', 'suspended')
@@ -94,75 +86,6 @@ select id, run_id from x_jobs
94
86
  limit 1
95
87
  `.trim();
96
88
 
97
- /**
98
- * The claim. SKIP LOCKED is the whole design: without it, worker 2 blocks on worker 1's row
99
- * lock and throughput collapses to one worker. `visible_at` in the predicate reclaims leases
100
- * abandoned by a crashed worker. `cancelled` is absent from every branch by construction — a
101
- * cancelled row is terminal, so it is never handed to a worker again.
102
- *
103
- * TWO arms over one pick. A `running` row is picked only with a lapsed lease, and one that lapsed
104
- * on its FINAL attempt (`attempt >= max_attempts` — `isFinalAttempt`, `retry.ts`) is `buried`:
105
- * settled `dead` here, counted, and answered with its new state so the driver reports it instead
106
- * of handing it out. Re-claimed, it was an attempt past `max_attempts` every lease, forever — a
107
- * job that kills its worker took one per visibility timeout and never reached the dead letters.
108
- * `claimed_by` is cleared with the lease, so the body that stalled rather than died cannot settle
109
- * the row it lost. A buried row spends a slot of `$2`: the pass after it claims what it displaced.
110
- * `$5` names the jobs declaring `retry.deadLetter: false`: their rows are buried `failed`.
111
- */
112
- export const SQL_CLAIM = `
113
- with picked as (
114
- select id, (state = 'running' and attempt >= max_attempts) as exhausted
115
- from x_jobs
116
- where queue = any($1::text[])
117
- and run_at <= now()
118
- and not exists (
119
- select 1 from x_job_pauses p where p.kind = 'queue' and p.name = x_jobs.queue
120
- )
121
- and (
122
- state in ('ready', 'delayed', 'suspended')
123
- or (state = 'running' and visible_at <= now())
124
- )
125
- order by run_at
126
- limit $2
127
- for update skip locked
128
- ), buried as (
129
- update x_jobs j
130
- set state = case when j.name = any($5::text[]) then 'failed' else 'dead' end,
131
- visible_at = null,
132
- claimed_by = null,
133
- last_error = '${LEASE_LAPSED_FINAL_ATTEMPT}',
134
- last_error_stack = null,
135
- updated_at = now()
136
- from picked p
137
- where j.id = p.id and p.exhausted
138
- returning ${CLAIM_ROW_COLUMNS}
139
- ), counted as (
140
- insert into x_job_counters (job, bucket_ms, bucket_start, dead, failed, duration_ms)
141
- select name, ${COUNTER_BUCKET_MS}, date_trunc('minute', now()),
142
- (count(*) filter (where state = 'dead'))::int,
143
- (count(*) filter (where state = 'failed'))::int, 0
144
- from buried
145
- group by name
146
- on conflict (job, bucket_ms, bucket_start) do update
147
- set dead = x_job_counters.dead + excluded.dead,
148
- failed = x_job_counters.failed + excluded.failed
149
- ), claimed as (
150
- update x_jobs j
151
- set state = 'running',
152
- attempt = j.attempt + 1,
153
- claims = j.claims + 1,
154
- claimed_by = $3,
155
- visible_at = now() + ($4::bigint * interval '1 millisecond'),
156
- updated_at = now()
157
- from picked p
158
- where j.id = p.id and not p.exhausted
159
- returning ${CLAIM_ROW_COLUMNS}
160
- )
161
- select ${CLAIM_ROW_NAMES} from claimed
162
- union all
163
- select ${CLAIM_ROW_NAMES} from buried
164
- `.trim();
165
-
166
89
  /**
167
90
  * Stop a job from outside — a LIVE one. The runaway backfill this exists for is `running`; the
168
91
  * worker holding it learns on its next heartbeat, which no longer matches `state = 'running'`.
@@ -352,16 +275,26 @@ select floor(extract(epoch from statement_timestamp()) * 1000)::bigint as now
352
275
 
353
276
  /**
354
277
  * Earliest matching event at or after `afterMs`, so a resumed step consumes events in publication
355
- * order rather than jumping to the newest one — the memory bus's rule, in SQL.
278
+ * order rather than jumping to the newest one — the memory bus's rule, in SQL. `$4` is the cursor a
279
+ * refused event leaves: rows at or before it in `(published_at, id)` order are passed over, and a
280
+ * cursor whose row is gone excludes nothing. `floor`, as `SQL_EVENT_PUBLISH` stamps: a rounding
281
+ * cast answered a stamp up to half a ms LATER than the row, which a floor then stepped past. The
282
+ * order is QUALIFIED: a bare `published_at` there names the floored output column, whose ties in
283
+ * one millisecond then fell to the random id.
284
+ *
285
+ * $1 name, $2 correlation key, $3 floor (ms), $4 cursor event id.
356
286
  */
357
287
  export const SQL_EVENT_FIND = `
358
- select payload, (extract(epoch from published_at) * 1000)::bigint as published_at
359
- from x_job_events
288
+ select id, payload, floor(extract(epoch from published_at) * 1000)::bigint as published_at
289
+ from x_job_events e
360
290
  where name = $1
361
291
  and expires_at > statement_timestamp()
362
292
  and published_at >= to_timestamp($3 / 1000.0)
363
293
  and ($2::text is null or correlation_key = $2)
364
- order by published_at
294
+ and not exists (
295
+ select 1 from x_job_events c
296
+ where c.id = $4::uuid and (e.published_at, e.id) <= (c.published_at, c.id))
297
+ order by e.published_at, e.id
365
298
  limit 1
366
299
  `.trim();
367
300
 
package/src/driver-pg.ts CHANGED
@@ -20,6 +20,7 @@ import type {
20
20
  JobDriver,
21
21
  JobRecord,
22
22
  NackOptions,
23
+ QueueDemand,
23
24
  QueueStats,
24
25
  } from './driver';
25
26
  import {
@@ -29,6 +30,8 @@ import {
29
30
  nackState,
30
31
  REQUEUEABLE_STATES,
31
32
  } from './driver';
33
+ import { SQL_QUEUE_DEMAND } from './driver-pg-demand-sql';
34
+ import { pgEnqueueMany } from './driver-pg-enqueue-many';
32
35
  import { pgOperator } from './driver-pg-operator';
33
36
  import type { BackfillRow, JobRow, StepRow } from './driver-pg-rows';
34
37
  import { num, toBackfillRun, toJobRecord, toStepRecord } from './driver-pg-rows';
@@ -255,7 +258,7 @@ export function postgresJobDriver(options: PostgresJobDriverOptions = {}): JobDr
255
258
  },
256
259
  };
257
260
 
258
- return {
261
+ const driver: JobDriver = {
259
262
  name: 'pg',
260
263
  steps: postgresStepStore(exec),
261
264
  backfills: postgresBackfillLedger(exec),
@@ -328,6 +331,10 @@ export function postgresJobDriver(options: PostgresJobDriverOptions = {}): JobDr
328
331
  });
329
332
  },
330
333
 
334
+ // One statement for the batch (`driver-pg-enqueue-many.ts`); `enqueue` for a row whose holder
335
+ // settled between that statement and the read behind it.
336
+ enqueueMany: (requests) => pgEnqueueMany(exec, clock, requests, (one) => driver.enqueue(one)),
337
+
331
338
  async claim(claimOptions: ClaimOptions): Promise<readonly ClaimedJob[]> {
332
339
  assertClaimQueues('pg', claimOptions);
333
340
  assertClaimBounds('pg', claimOptions);
@@ -402,6 +409,14 @@ export function postgresJobDriver(options: PostgresJobDriverOptions = {}): JobDr
402
409
  return rows.length > 0;
403
410
  },
404
411
 
412
+ async demand(): Promise<readonly QueueDemand[]> {
413
+ const rows = await exec().query<{ queue: string; demand: number | string }>(
414
+ SQL_QUEUE_DEMAND,
415
+ [],
416
+ );
417
+ return rows.map((row) => ({ queue: row.queue, demand: num(row.demand) }));
418
+ },
419
+
405
420
  async stats(): Promise<readonly QueueStats[]> {
406
421
  const rows = await exec().query<{
407
422
  queue: string;
@@ -425,6 +440,7 @@ export function postgresJobDriver(options: PostgresJobDriverOptions = {}): JobDr
425
440
  }));
426
441
  },
427
442
  };
443
+ return driver;
428
444
  }
429
445
 
430
446
  /**
@@ -0,0 +1,25 @@
1
+ // An unreachable Redis, as the jobs contract says it: `X_DRIVER_UNAVAILABLE`, not Bun's client
2
+ // error. Core's `redisClient()` refuses every command at once while down, so an enqueue fails typed
3
+ // and immediately where `Bun.redis` blocked it, then closed the queue for good.
4
+
5
+ import { renderThrowable, stringField } from '@ultimat3/core';
6
+ import type { RedisSender } from './driver-redis';
7
+ import { DriverUnavailableError } from './errors';
8
+
9
+ /** Bun's code for a command refused for want of a connection: nothing reached the server. */
10
+ const CONNECTION_CLOSED = 'ERR_REDIS_CONNECTION_CLOSED';
11
+
12
+ /** `client` with its connection refusals typed. A server's own error reply passes through. */
13
+ export function typedRefusals(client: RedisSender): RedisSender {
14
+ return {
15
+ send: (command, args) =>
16
+ client.send(command, args).catch((error: unknown) => {
17
+ if (stringField(error, 'code') !== CONNECTION_CLOSED) throw error;
18
+ throw new DriverUnavailableError({
19
+ driver: 'redis',
20
+ cause: `${command} was not sent, the connection is down (${renderThrowable(error)})`,
21
+ fix: 'redis-cli -u "$REDIS_URL" PING — nothing was queued, so the same call is safe to repeat once it answers PONG; the client reconnects on its own',
22
+ });
23
+ }),
24
+ };
25
+ }