@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.
- package/CLAUDE.md +68 -89
- package/README.md +304 -10
- package/package.json +18 -6
- package/src/backfill-definition.ts +184 -0
- package/src/backfill-errors.ts +18 -2
- package/src/backfill-pass.ts +43 -40
- package/src/backfill-pending.ts +40 -17
- package/src/backfill-reader.ts +94 -0
- package/src/backfill-registry.ts +5 -0
- package/src/backfill-scope.ts +1 -2
- package/src/backfill-source.ts +113 -0
- package/src/backfill.ts +54 -135
- package/src/consumer-errors.ts +42 -0
- package/src/consumer-guard.ts +19 -0
- package/src/consumer-loop.ts +171 -0
- package/src/consumer-registry.ts +103 -0
- package/src/consumer-types.ts +81 -0
- package/src/consumer.ts +147 -0
- package/src/driver-memory-operator.ts +25 -2
- package/src/driver-memory.ts +35 -1
- package/src/driver-pg-claim-sql.ts +128 -0
- package/src/driver-pg-ddl.ts +11 -2
- package/src/driver-pg-demand-sql.ts +27 -0
- package/src/driver-pg-enqueue-many.ts +153 -0
- package/src/driver-pg-event-wake-sql.ts +51 -0
- package/src/driver-pg-operator-sql.ts +34 -6
- package/src/driver-pg-operator.ts +36 -2
- package/src/driver-pg-outbox-sql.ts +34 -0
- package/src/driver-pg-sql.ts +24 -91
- package/src/driver-pg.ts +17 -1
- package/src/driver-redis-refusal.ts +25 -0
- package/src/driver-redis-scripts.ts +79 -30
- package/src/driver-redis.ts +70 -42
- package/src/driver.ts +38 -1
- package/src/enqueue-record.ts +137 -0
- package/src/error-codes.ts +233 -0
- package/src/errors-concurrency.ts +2 -2
- package/src/errors-declaration.ts +15 -2
- package/src/errors-operator.ts +1 -1
- package/src/errors-requeue.ts +1 -1
- package/src/errors-saga.ts +87 -0
- package/src/errors-steps.ts +35 -0
- package/src/errors-tenant.ts +1 -1
- package/src/errors.ts +25 -165
- package/src/events-pg.ts +20 -8
- package/src/events.ts +14 -3
- package/src/execute-ctx.ts +51 -0
- package/src/execute-unwind.ts +191 -0
- package/src/execute.ts +50 -51
- package/src/export-errors.ts +1 -1
- package/src/failure-row.ts +43 -0
- package/src/import-errors.ts +102 -0
- package/src/import-format.ts +232 -0
- package/src/import-pass.ts +255 -0
- package/src/import-source.ts +49 -0
- package/src/import.ts +175 -0
- package/src/index.ts +163 -7
- package/src/inspect-operator.ts +24 -3
- package/src/inspect.ts +17 -4
- package/src/introspection.ts +24 -1
- package/src/job-enqueue-many.ts +108 -0
- package/src/job-types.ts +229 -0
- package/src/job.ts +19 -208
- package/src/metrics.ts +10 -0
- package/src/outbox-pg.ts +35 -0
- package/src/outbox-relay.ts +58 -19
- package/src/outbox.ts +65 -73
- package/src/purge-entities.ts +92 -0
- package/src/purge.ts +10 -2
- package/src/queue-wake.ts +1 -1
- package/src/refresh-view.ts +76 -0
- package/src/retry-classification.ts +27 -8
- package/src/retry.ts +26 -0
- package/src/rollup-errors.ts +27 -0
- package/src/rollup.ts +190 -0
- package/src/run-deadline.ts +11 -7
- package/src/scheduler.ts +2 -1
- package/src/settled.ts +56 -15
- package/src/step-resolve.ts +37 -0
- package/src/steps-api.ts +111 -0
- package/src/steps-core.ts +26 -0
- package/src/steps-output.ts +149 -0
- package/src/steps-store.ts +68 -0
- package/src/steps-suspension.ts +51 -0
- package/src/steps-timeout.ts +10 -5
- package/src/steps-unwind.ts +94 -0
- package/src/steps-wait.ts +163 -0
- package/src/steps.ts +174 -210
- package/src/subject-erase-pass.ts +134 -0
- package/src/subject-errors.ts +48 -0
- package/src/subject-export-pass.ts +228 -0
- package/src/subject-jobs.ts +121 -0
- package/src/subject-plan.ts +287 -0
- package/src/subject-select.ts +106 -0
- package/src/subject.ts +119 -0
- package/src/tenant.ts +69 -1
- package/src/type-pins.ts +99 -0
- package/src/webhook-errors.ts +1 -1
- package/src/webhook-receiver-errors.ts +59 -0
- package/src/webhook-receiver-route.ts +127 -0
- package/src/webhook-receiver-types.ts +97 -0
- package/src/webhook-receiver.ts +132 -0
- package/src/webhook-target.ts +20 -43
- package/src/webhook.ts +7 -4
- package/src/worker-queue-demand.ts +57 -0
- package/src/worker-queue-depth.ts +15 -3
- package/src/worker-registry.ts +4 -1
- package/src/worker-types.ts +2 -0
- 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
|
|
152
|
-
* the first draining announce and KEPT by the later ones — when the drain began, not the
|
|
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.
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|