@ultimat3/jobs 2.0.0 → 4.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.
- package/CLAUDE.md +146 -15
- package/README.md +80 -4
- package/package.json +5 -5
- package/src/backfill-errors.ts +137 -0
- package/src/backfill-gate.ts +5 -5
- package/src/backfill-pass.ts +1 -1
- package/src/describe.ts +17 -1
- package/src/driver-memory.ts +34 -8
- package/src/driver-pg-ddl.ts +31 -6
- package/src/driver-pg-rows.ts +34 -6
- package/src/driver-pg-sql.ts +79 -9
- package/src/driver-pg.ts +15 -5
- package/src/driver.ts +36 -12
- package/src/errors.ts +81 -130
- package/src/execute.ts +33 -9
- package/src/heartbeat.ts +15 -13
- package/src/index.ts +23 -8
- package/src/job.ts +55 -1
- package/src/metrics.ts +1 -1
- package/src/outbox-lease.ts +29 -0
- package/src/outbox-pg.ts +58 -7
- package/src/outbox.ts +91 -8
- package/src/register.ts +25 -1
- package/src/renewal-timer.ts +35 -0
- package/src/retry-classification.ts +112 -0
- package/src/retry.ts +4 -3
- package/src/steps.ts +14 -1
- package/src/task.ts +29 -2
- package/src/worker-fleet-slots.ts +16 -11
- package/src/worker-run.ts +3 -0
- package/src/worker.ts +34 -9
package/src/driver-memory.ts
CHANGED
|
@@ -38,7 +38,18 @@ export interface MemoryDriverOptions {
|
|
|
38
38
|
|
|
39
39
|
const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The in-memory driver's own type: `JobDriver` with `close` REQUIRED.
|
|
43
|
+
*
|
|
44
|
+
* `JobDriver.close` is optional because a driver may hold nothing to release. This one always
|
|
45
|
+
* does — it clears the job map — and every wrapper in the test suite delegates through
|
|
46
|
+
* `base.close()`. Declaring it here is what makes that delegation a CHECKED call: against a plain
|
|
47
|
+
* `JobDriver` the only way to write it is `base.close?.()`, which a driver that quietly stopped
|
|
48
|
+
* shipping a `close` would satisfy in silence.
|
|
49
|
+
*/
|
|
50
|
+
export type MemoryJobDriver = JobDriver & { close(): Promise<void> };
|
|
51
|
+
|
|
52
|
+
export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJobDriver {
|
|
42
53
|
const clock = options.clock ?? systemClock;
|
|
43
54
|
const steps = options.steps ?? createMemoryStepStore();
|
|
44
55
|
const backfills = options.backfills ?? createMemoryBackfillLedger(clock);
|
|
@@ -46,12 +57,20 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver
|
|
|
46
57
|
options.leases ?? createMemoryLeaseStore(options.clock === undefined ? {} : { clock });
|
|
47
58
|
const jobs = new Map<string, JobRecord>();
|
|
48
59
|
|
|
49
|
-
// Keyed by NAME and key, exactly as `
|
|
50
|
-
// namespace let two unrelated jobs that derived the same natural key dedupe against
|
|
51
|
-
// other: the second enqueue returned the first's id and its work never ran.
|
|
52
|
-
|
|
60
|
+
// Keyed by NAME, TENANT and key, exactly as `x_jobs_name_tenant_idempotency_live_idx` is. A
|
|
61
|
+
// global key namespace let two unrelated jobs that derived the same natural key dedupe against
|
|
62
|
+
// each other: the second enqueue returned the first's id and its work never ran. A tenant-blind
|
|
63
|
+
// one did the same ACROSS tenants, where the id handed back belongs to somebody else and is
|
|
64
|
+
// valid on every id-addressed surface. `?? ''` mirrors the index's `coalesce`, so all tenantless
|
|
65
|
+
// rows share one namespace rather than each becoming its own.
|
|
66
|
+
const liveByKey = (name: string, key: string, tenantId?: string): JobRecord | undefined => {
|
|
53
67
|
for (const record of jobs.values()) {
|
|
54
|
-
if (
|
|
68
|
+
if (
|
|
69
|
+
record.name === name &&
|
|
70
|
+
record.idempotencyKey === key &&
|
|
71
|
+
(record.tenantId ?? '') === (tenantId ?? '') &&
|
|
72
|
+
LIVE_STATES.has(record.state)
|
|
73
|
+
) {
|
|
55
74
|
return record;
|
|
56
75
|
}
|
|
57
76
|
}
|
|
@@ -124,7 +143,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver
|
|
|
124
143
|
introspect,
|
|
125
144
|
|
|
126
145
|
enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
|
|
127
|
-
const existing = liveByKey(request.name, request.idempotencyKey);
|
|
146
|
+
const existing = liveByKey(request.name, request.idempotencyKey, request.tenantId);
|
|
128
147
|
if (existing !== undefined) {
|
|
129
148
|
if (request.onConflict === 'error') {
|
|
130
149
|
throw new JobDuplicateError({
|
|
@@ -206,7 +225,14 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver
|
|
|
206
225
|
const at = nowMs(clock);
|
|
207
226
|
const counts = nackOptions.countsAsAttempt !== false;
|
|
208
227
|
const patch: Partial<JobRecord> = {
|
|
209
|
-
|
|
228
|
+
// `park`, never `counts`: parking is what leaves the ready bucket, and burning an attempt
|
|
229
|
+
// is a separate fact. A shed sets neither and stays `ready`, which is what it is.
|
|
230
|
+
state:
|
|
231
|
+
nackOptions.deadLetter === true
|
|
232
|
+
? 'dead'
|
|
233
|
+
: nackOptions.park === true
|
|
234
|
+
? 'suspended'
|
|
235
|
+
: 'ready',
|
|
210
236
|
runAt: at + nackOptions.delayMs,
|
|
211
237
|
// A suspension must not burn an attempt, or a 3-day sleep dead-letters the run. Floored
|
|
212
238
|
// where `SQL_NACK` floors it (`greatest(attempt - 1, 0)`): the fence above is what keeps
|
package/src/driver-pg-ddl.ts
CHANGED
|
@@ -39,19 +39,33 @@ alter table x_jobs add column if not exists traceparent text;
|
|
|
39
39
|
|
|
40
40
|
alter table x_jobs add column if not exists enqueued_by text;
|
|
41
41
|
|
|
42
|
-
-- Partial unique index: one LIVE job per (name, idempotency key). Completed rows stay for
|
|
42
|
+
-- Partial unique index: one LIVE job per (name, tenant, idempotency key). Completed rows stay for
|
|
43
43
|
-- history, so re-running the same work tomorrow is allowed and re-delivering it today is not.
|
|
44
44
|
--
|
|
45
45
|
-- The NAME is in the key, and its absence was silent data loss: two jobs that happened to derive
|
|
46
46
|
-- the same natural key from the same input ("user:42") shared one namespace, so the second
|
|
47
47
|
-- enqueue deduped against the FIRST jobs row and returned its id. The work never ran, no error
|
|
48
|
-
-- was raised, and the queue showed one healthy job.
|
|
49
|
-
--
|
|
50
|
-
--
|
|
48
|
+
-- was raised, and the queue showed one healthy job.
|
|
49
|
+
--
|
|
50
|
+
-- The TENANT is in the key for that same argument with one word substituted, and it is the worse
|
|
51
|
+
-- half. Every natural key an app writes is unique only WITHIN a tenant, invoice:1001 and
|
|
52
|
+
-- order:5540 being the shapes the docs suggest, so tenant B enqueuing while tenant A held that
|
|
53
|
+
-- key deduped into tenant A row: tenant B work never ran, and tenant B caller received tenant A
|
|
54
|
+
-- job id, which is valid on every id-addressed surface. Cancel takes an id with no tenant
|
|
55
|
+
-- predicate.
|
|
56
|
+
--
|
|
57
|
+
-- coalesce rather than the bare column, because a null tenant_id compares unequal to every other
|
|
58
|
+
-- null under a unique index and a tenantless queue would lose its dedupe entirely. All tenantless
|
|
59
|
+
-- rows share one namespace instead, which is exactly what they had before tenancy existed.
|
|
60
|
+
--
|
|
61
|
+
-- Each superseded index is dropped rather than left beside the new one. Each is strictly narrower,
|
|
62
|
+
-- so keeping it would keep enforcing exactly the collision this fixes.
|
|
51
63
|
drop index if exists x_jobs_idempotency_live_idx;
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
drop index if exists x_jobs_name_idempotency_live_idx;
|
|
66
|
+
|
|
67
|
+
create unique index if not exists x_jobs_name_tenant_idempotency_live_idx
|
|
68
|
+
on x_jobs (name, (coalesce(tenant_id, '')), idempotency_key)
|
|
55
69
|
where state in ('ready', 'delayed', 'running', 'suspended');
|
|
56
70
|
|
|
57
71
|
create index if not exists x_jobs_claim_idx
|
|
@@ -109,6 +123,15 @@ create table if not exists x_outbox (
|
|
|
109
123
|
create index if not exists x_outbox_unpublished_idx
|
|
110
124
|
on x_outbox (staged_at) where published_at is null;
|
|
111
125
|
|
|
126
|
+
-- The relays claim lease. A claim stamped in the same statement that locks the row is what stops
|
|
127
|
+
-- two relays publishing one row twice: for update skip locked holds its locks only until that
|
|
128
|
+
-- statement ends, which under autocommit is before claim returns. claimed_at is also what gives
|
|
129
|
+
-- back the rows of a relay that died mid-batch, since a claim nothing can expire strands them.
|
|
130
|
+
-- Added by alter because x_outbox shipped without them.
|
|
131
|
+
alter table x_outbox add column if not exists claimed_at timestamptz;
|
|
132
|
+
|
|
133
|
+
alter table x_outbox add column if not exists claimed_by text;
|
|
134
|
+
|
|
112
135
|
-- The scheduler watermark. Without a durable one a redeployed scheduler has no idea what the
|
|
113
136
|
-- pod it replaced already fired, so runRound takes the arming branch and every occurrence
|
|
114
137
|
-- between the two processes is dropped with nothing logged.
|
|
@@ -177,4 +200,6 @@ create table if not exists x_outbox (
|
|
|
177
200
|
);
|
|
178
201
|
create index if not exists x_outbox_unpublished_idx
|
|
179
202
|
on x_outbox (staged_at) where published_at is null;
|
|
203
|
+
alter table x_outbox add column if not exists claimed_at timestamptz;
|
|
204
|
+
alter table x_outbox add column if not exists claimed_by text;
|
|
180
205
|
`.trim();
|
package/src/driver-pg-rows.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// decoding a row is not control flow — every number arrives as `number | string` (a bigint is a
|
|
4
4
|
// string in every client) and every absent column as `null`, and that translation is its own job.
|
|
5
5
|
|
|
6
|
-
import type
|
|
7
|
-
import type
|
|
8
|
-
import
|
|
6
|
+
import { BACKFILL_STATUSES, type BackfillRun, isBackfillStatus } from './backfill-ledger';
|
|
7
|
+
import { isJobState, JOB_STATES, type JobRecord, type JobState } from './driver';
|
|
8
|
+
import { JobRowStatusUnknownError } from './errors';
|
|
9
|
+
import { isStepStatus, STEP_STATUSES, type StepRecord, type StepStatus } from './steps';
|
|
9
10
|
|
|
10
11
|
export interface StepRow {
|
|
11
12
|
readonly run_id: string;
|
|
@@ -54,6 +55,21 @@ export interface JobRow {
|
|
|
54
55
|
readonly enqueued_by?: string | null;
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The one narrowing for all three status columns. `as` is not a check, and these rows cross a
|
|
60
|
+
* process boundary — a queue row was written by whatever build was deployed when the job was
|
|
61
|
+
* enqueued, which on a rolling deploy is not this one. `isBackfillStatus`'s own doc already stated
|
|
62
|
+
* the rule ("Never a cast — the list decides") and this file was the caller ignoring it.
|
|
63
|
+
*/
|
|
64
|
+
const statusIn = <T extends string>(
|
|
65
|
+
known: readonly T[],
|
|
66
|
+
is: (value: string) => value is T,
|
|
67
|
+
input: { table: string; column: string; value: string },
|
|
68
|
+
): T => {
|
|
69
|
+
if (is(input.value)) return input.value;
|
|
70
|
+
throw new JobRowStatusUnknownError({ ...input, known });
|
|
71
|
+
};
|
|
72
|
+
|
|
57
73
|
export const num = (value: number | string | null | undefined): number =>
|
|
58
74
|
value === null || value === undefined ? 0 : Number(value);
|
|
59
75
|
|
|
@@ -71,7 +87,11 @@ export function toJobRecord(row: JobRow): JobRecord {
|
|
|
71
87
|
runId: row.run_id,
|
|
72
88
|
attempt: row.attempt,
|
|
73
89
|
maxAttempts: row.max_attempts,
|
|
74
|
-
state:
|
|
90
|
+
state: statusIn<JobState>(JOB_STATES, isJobState, {
|
|
91
|
+
table: 'ultimate_jobs',
|
|
92
|
+
column: 'state',
|
|
93
|
+
value: row.state,
|
|
94
|
+
}),
|
|
75
95
|
runAt: num(row.run_at),
|
|
76
96
|
createdAt: num(row.created_at),
|
|
77
97
|
updatedAt: num(row.updated_at),
|
|
@@ -94,7 +114,11 @@ export function toStepRecord(row: StepRow): StepRecord {
|
|
|
94
114
|
return {
|
|
95
115
|
runId: row.run_id,
|
|
96
116
|
name: row.name,
|
|
97
|
-
status:
|
|
117
|
+
status: statusIn<StepStatus>(STEP_STATUSES, isStepStatus, {
|
|
118
|
+
table: 'ultimate_job_steps',
|
|
119
|
+
column: 'status',
|
|
120
|
+
value: row.status,
|
|
121
|
+
}),
|
|
98
122
|
output: row.output,
|
|
99
123
|
startedAt: num(row.started_at),
|
|
100
124
|
attempts: row.attempts,
|
|
@@ -112,7 +136,11 @@ export function toBackfillRun(row: BackfillRow): BackfillRun {
|
|
|
112
136
|
runId: row.run_id,
|
|
113
137
|
name: row.name,
|
|
114
138
|
checksum: row.checksum,
|
|
115
|
-
status:
|
|
139
|
+
status: statusIn(BACKFILL_STATUSES, isBackfillStatus, {
|
|
140
|
+
table: 'ultimate_backfills',
|
|
141
|
+
column: 'status',
|
|
142
|
+
value: row.status,
|
|
143
|
+
}),
|
|
116
144
|
appVersion: row.app_version,
|
|
117
145
|
// `rows_processed` is a bigint, which every Postgres client hands back as a string.
|
|
118
146
|
rows: num(row.rows_processed),
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -17,17 +17,26 @@ values
|
|
|
17
17
|
($1, $2, $3, $4::jsonb, $5, $6, $7,
|
|
18
18
|
case when to_timestamp($8 / 1000.0) > now() then 'delayed' else 'ready' end,
|
|
19
19
|
to_timestamp($8 / 1000.0), $9, $10, $11)
|
|
20
|
-
on conflict (name, idempotency_key)
|
|
20
|
+
on conflict (name, (coalesce(tenant_id, '')), idempotency_key)
|
|
21
21
|
where state in ('ready', 'delayed', 'running', 'suspended')
|
|
22
22
|
do nothing
|
|
23
23
|
returning id, run_id
|
|
24
24
|
`.trim();
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Scoped by NAME and TENANT as well as key, because the index is — a lookup narrower than the
|
|
28
|
+
* index it reads answers with whichever stranger holds the key, and `{ deduped: true, id: <another
|
|
29
|
+
* tenant's> }` is a cross-tenant handle, not just a missed run.
|
|
30
|
+
*
|
|
31
|
+
* `coalesce` on both sides, matching the index expression exactly: a bare `tenant_id = $3` never
|
|
32
|
+
* matches a null row, so every tenantless enqueue would fall through to the "rejected but no live
|
|
33
|
+
* row holds its idempotency key" refusal instead of finding its own live job.
|
|
34
|
+
*/
|
|
27
35
|
export const SQL_FIND_LIVE_BY_KEY = `
|
|
28
36
|
select id, run_id from x_jobs
|
|
29
37
|
where name = $1
|
|
30
38
|
and idempotency_key = $2
|
|
39
|
+
and coalesce(tenant_id, '') = coalesce($3::text, '')
|
|
31
40
|
and state in ('ready', 'delayed', 'running', 'suspended')
|
|
32
41
|
limit 1
|
|
33
42
|
`.trim();
|
|
@@ -271,20 +280,81 @@ values ($1, $2, $3, $4::jsonb, $5, $6, to_timestamp($7 / 1000.0), to_timestamp($
|
|
|
271
280
|
$9, $10, $11)
|
|
272
281
|
`.trim();
|
|
273
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The claim, and it has to be ONE statement. `for update skip locked` in a bare select holds its
|
|
285
|
+
* row locks only until that statement ends — under autocommit, before `claim()` even resolves — so
|
|
286
|
+
* two relays polling 200ms apart read the same unpublished rows and both hand them to `enqueue`.
|
|
287
|
+
* `SQL_ENQUEUE` collapses the repeat only while the first job is still LIVE: its conflict target
|
|
288
|
+
* is a partial index over the live states, so a second publish landing after that job reached a
|
|
289
|
+
* terminal state inserts a second row and the handler runs again. (The mechanism is Postgres
|
|
290
|
+
* semantics; how often the two orderings line up in a deployment was never measured.)
|
|
291
|
+
*
|
|
292
|
+
* So the lock and the claim commit together, the CTE shape `SQL_CLAIM` already uses, and
|
|
293
|
+
* `claimed_at` is a LEASE: `$2` is the window after which a row a dead relay was holding is
|
|
294
|
+
* claimable again, because a claim nothing can expire strands its rows forever.
|
|
295
|
+
*
|
|
296
|
+
* The outer `select ... order by staged_at, id` is not cosmetic. `update ... returning` has no
|
|
297
|
+
* defined row order and the relay publishes in the order it is handed rows, so an app staging
|
|
298
|
+
* `createInvoice` then `chargeCard` in one transaction depends on this line.
|
|
299
|
+
*
|
|
300
|
+
* `, id` is what makes that key TOTAL, and the CTE needs it as much as the projection does: every
|
|
301
|
+
* row staged in one transaction shares a `staged_at`, so `staged_at` alone leaves the tie to the
|
|
302
|
+
* planner — which rows a `limit` takes, and in which order they publish, then differ between two
|
|
303
|
+
* relays and between two runs of one relay. No column was added for it: `id` is a UUIDv7 minted by
|
|
304
|
+
* `uuid()`, monotonic and already the primary key, so the tiebreak IS stage order.
|
|
305
|
+
*/
|
|
274
306
|
export const SQL_OUTBOX_CLAIM = `
|
|
307
|
+
with claimable as (
|
|
308
|
+
select id
|
|
309
|
+
from x_outbox
|
|
310
|
+
where published_at is null
|
|
311
|
+
and (claimed_at is null
|
|
312
|
+
or claimed_at <= now() - ($2::bigint * interval '1 millisecond'))
|
|
313
|
+
order by staged_at, id
|
|
314
|
+
limit $1
|
|
315
|
+
for update skip locked
|
|
316
|
+
), claimed as (
|
|
317
|
+
update x_outbox o
|
|
318
|
+
set claimed_at = now(), claimed_by = $3
|
|
319
|
+
from claimable c
|
|
320
|
+
where o.id = c.id
|
|
321
|
+
returning o.id, o.job, o.queue, o.input, o.idempotency_key, o.max_attempts, o.tenant_id,
|
|
322
|
+
o.traceparent, o.enqueued_by, o.claimed_by, o.run_at, o.staged_at
|
|
323
|
+
)
|
|
275
324
|
select id, job, queue, input, idempotency_key, max_attempts, tenant_id,
|
|
276
|
-
traceparent, enqueued_by,
|
|
325
|
+
traceparent, enqueued_by, claimed_by,
|
|
277
326
|
(extract(epoch from run_at) * 1000)::bigint as run_at,
|
|
278
327
|
(extract(epoch from staged_at) * 1000)::bigint as staged_at
|
|
279
|
-
from
|
|
280
|
-
|
|
281
|
-
order by staged_at
|
|
282
|
-
limit $1
|
|
283
|
-
for update skip locked
|
|
328
|
+
from claimed
|
|
329
|
+
order by staged_at, id
|
|
284
330
|
`.trim();
|
|
285
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Hand a claim back early. The relay stops its batch on the first publish that fails, and without
|
|
334
|
+
* this the rows behind it would wait out the whole lease before any relay could retry them — a
|
|
335
|
+
* pool blip during a failover becoming tens of seconds of unpublished, committed work.
|
|
336
|
+
*
|
|
337
|
+
* Fenced on `published_at is null` so it can never unclaim a row some other pass already
|
|
338
|
+
* published, AND on `claimed_by` so it can never unclaim one a NEWER claimant now holds: a relay
|
|
339
|
+
* whose lease lapsed while it stalled wakes into a world where its batch is another relay's, and
|
|
340
|
+
* an unfenced release frees rows that relay is mid-publish on — a third relay claims them and
|
|
341
|
+
* publishes them again, which is the duplicate the lease exists to prevent.
|
|
342
|
+
*/
|
|
343
|
+
export const SQL_OUTBOX_RELEASE = `
|
|
344
|
+
update x_outbox
|
|
345
|
+
set claimed_at = null, claimed_by = null
|
|
346
|
+
where id = any($1::uuid[]) and published_at is null and claimed_by = $2
|
|
347
|
+
`.trim();
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Same fence, and here it is the more expensive one to miss: marking a row published is LOSING it,
|
|
351
|
+
* so a lapsed claimant stamping a row the current one has not published yet drops that job with
|
|
352
|
+
* nothing to notice. `published_at is null` makes the stamp first-writer-wins rather than a
|
|
353
|
+
* rewrite of an audit timestamp.
|
|
354
|
+
*/
|
|
286
355
|
export const SQL_OUTBOX_MARK_PUBLISHED = `
|
|
287
|
-
update x_outbox set published_at = to_timestamp($2 / 1000.0)
|
|
356
|
+
update x_outbox set published_at = to_timestamp($2 / 1000.0)
|
|
357
|
+
where id = $1 and published_at is null and claimed_by = $3
|
|
288
358
|
`.trim();
|
|
289
359
|
|
|
290
360
|
export const SQL_OUTBOX_PENDING_COUNT = `
|
package/src/driver-pg.ts
CHANGED
|
@@ -271,19 +271,22 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
271
271
|
return { id: inserted.id, runId: inserted.run_id, deduped: false };
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
// `do nothing` fired: a live job OF THIS NAME already owns this idempotency
|
|
275
|
-
//
|
|
276
|
-
//
|
|
274
|
+
// `do nothing` fired: a live job OF THIS NAME, IN THIS TENANT, already owns this idempotency
|
|
275
|
+
// key. Both are in the lookup because both are in the index — without the name this returned
|
|
276
|
+
// whichever other job derived the same natural key; without the tenant it returned another
|
|
277
|
+
// TENANT's row, so the caller's work silently never ran AND the caller was handed an id it
|
|
278
|
+
// has no right to, on a surface (`cancel`) that takes an id with no tenant predicate.
|
|
277
279
|
const existing = await exec().query<{ id: string; run_id: string }>(SQL_FIND_LIVE_BY_KEY, [
|
|
278
280
|
request.name,
|
|
279
281
|
request.idempotencyKey,
|
|
282
|
+
request.tenantId ?? null,
|
|
280
283
|
]);
|
|
281
284
|
const found = existing[0];
|
|
282
285
|
if (found === undefined) {
|
|
283
286
|
throw new DriverUnavailableError({
|
|
284
287
|
driver: 'pg',
|
|
285
288
|
cause: `enqueue of "${request.name}" was rejected but no live row holds its idempotency key`,
|
|
286
|
-
fix: 'x db migrate # reapplies SQL_JOBS_TABLE, whose
|
|
289
|
+
fix: 'x db migrate # reapplies SQL_JOBS_TABLE, whose x_jobs_name_tenant_idempotency_live_idx is what this lookup reads',
|
|
287
290
|
});
|
|
288
291
|
}
|
|
289
292
|
if (request.onConflict === 'error') {
|
|
@@ -321,7 +324,14 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
321
324
|
|
|
322
325
|
async nack(jobId: string, nackOptions: NackOptions): Promise<void> {
|
|
323
326
|
const counts = nackOptions.countsAsAttempt !== false;
|
|
324
|
-
|
|
327
|
+
// The same three-way the memory driver takes, and it reads `park` rather than `counts`: the
|
|
328
|
+
// attempt counter and the ready bucket are two facts, and a shed only ever meant the first.
|
|
329
|
+
const state =
|
|
330
|
+
nackOptions.deadLetter === true
|
|
331
|
+
? 'dead'
|
|
332
|
+
: nackOptions.park === true
|
|
333
|
+
? 'suspended'
|
|
334
|
+
: 'ready';
|
|
325
335
|
await exec().query(SQL_NACK, [
|
|
326
336
|
jobId,
|
|
327
337
|
state,
|
package/src/driver.ts
CHANGED
|
@@ -17,15 +17,22 @@ import type { StepStore } from './steps';
|
|
|
17
17
|
* a cancellation is work an operator stopped on purpose and `x jobs retry` must not resurrect by
|
|
18
18
|
* accident. It appears in no claim predicate, so the queue never hands a cancelled row out again.
|
|
19
19
|
*/
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
export const JOB_STATES = [
|
|
21
|
+
'ready',
|
|
22
|
+
'delayed',
|
|
23
|
+
'running',
|
|
24
|
+
'suspended',
|
|
25
|
+
'done',
|
|
26
|
+
'failed',
|
|
27
|
+
'dead',
|
|
28
|
+
'cancelled',
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export type JobState = (typeof JOB_STATES)[number];
|
|
32
|
+
|
|
33
|
+
/** Narrows a state read back off a queue row. Never a cast — the list decides. */
|
|
34
|
+
export const isJobState = (value: string): value is JobState =>
|
|
35
|
+
(JOB_STATES as readonly string[]).includes(value);
|
|
29
36
|
|
|
30
37
|
export interface JobRecord {
|
|
31
38
|
readonly id: string;
|
|
@@ -114,10 +121,23 @@ export interface NackOptions {
|
|
|
114
121
|
readonly delayMs: number;
|
|
115
122
|
readonly error?: string;
|
|
116
123
|
/**
|
|
117
|
-
*
|
|
118
|
-
* a
|
|
124
|
+
* The ATTEMPT COUNTER, and nothing else. False for a suspension and for a shed alike: neither is
|
|
125
|
+
* a failure, and a 3-day sleep that burned an attempt would dead-letter the job.
|
|
119
126
|
*/
|
|
120
127
|
readonly countsAsAttempt?: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* True for a SUSPENSION — `step.sleep`, or a name this deploy does not know — which leaves the
|
|
130
|
+
* ready bucket and is counted `suspended`. Absent for a limiter or `job.concurrency` shed, which
|
|
131
|
+
* is a job still WAITING to run.
|
|
132
|
+
*
|
|
133
|
+
* The two were one flag until 2026-08: `countsAsAttempt: false` decided the state as well as the
|
|
134
|
+
* counter, so a shed was filed beside a 3-day sleep and `stats()` excluded it from `ready` and
|
|
135
|
+
* from `oldestReadyMs` — the two numbers the worker publishes as `queue_depth` and
|
|
136
|
+
* `queue_oldest_ready_seconds`. Under sustained overload the shed fraction approaches 100%, so
|
|
137
|
+
* the HPA signal and the "oldest job older than 5 minutes" page both went quiet exactly when the
|
|
138
|
+
* queue was saturated.
|
|
139
|
+
*/
|
|
140
|
+
readonly park?: boolean;
|
|
121
141
|
readonly deadLetter?: boolean;
|
|
122
142
|
}
|
|
123
143
|
|
|
@@ -128,7 +148,11 @@ export interface QueueStats {
|
|
|
128
148
|
readonly running: number;
|
|
129
149
|
readonly suspended: number;
|
|
130
150
|
readonly dead: number;
|
|
131
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* Age in ms of the oldest job that is READY and due — the number that decides autoscaling.
|
|
153
|
+
* Not "claimable": a `suspended` row is claimable once its `runAt` passes and is deliberately
|
|
154
|
+
* excluded here, which is exactly why a limiter shed may not be filed as a suspension.
|
|
155
|
+
*/
|
|
132
156
|
readonly oldestReadyMs: number;
|
|
133
157
|
}
|
|
134
158
|
|