@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.
@@ -38,7 +38,18 @@ export interface MemoryDriverOptions {
38
38
 
39
39
  const LIVE_STATES = new Set(['ready', 'delayed', 'running', 'suspended']);
40
40
 
41
- export function createMemoryDriver(options: MemoryDriverOptions = {}): JobDriver {
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 `x_jobs_name_idempotency_live_idx` is. A global key
50
- // namespace let two unrelated jobs that derived the same natural key dedupe against each
51
- // other: the second enqueue returned the first's id and its work never ran.
52
- const liveByKey = (name: string, key: string): JobRecord | undefined => {
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 (record.name === name && record.idempotencyKey === key && LIVE_STATES.has(record.state)) {
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
- state: nackOptions.deadLetter === true ? 'dead' : counts ? 'ready' : 'suspended',
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
@@ -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. The old index is dropped rather than left
49
- -- beside the new one — it is strictly narrower, so keeping it would keep enforcing exactly the
50
- -- collision this fixes.
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
- create unique index if not exists x_jobs_name_idempotency_live_idx
54
- on x_jobs (name, idempotency_key)
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();
@@ -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 { BackfillRun, BackfillStatus } from './backfill-ledger';
7
- import type { JobRecord } from './driver';
8
- import type { StepRecord } from './steps';
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: row.state as JobRecord['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: row.status as StepRecord['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: row.status as BackfillStatus,
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),
@@ -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
- /** Scoped by NAME as well as key — the index is, so a lookup that was not would find a stranger. */
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 x_outbox
280
- where published_at is null
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) where id = $1
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 key. The name
275
- // is in the lookup because it is in the index — without it this returned whichever other
276
- // job happened to derive the same natural key, and the caller's work silently never ran.
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 x_jobs_name_idempotency_live_idx is what this lookup reads',
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
- const state = nackOptions.deadLetter === true ? 'dead' : counts ? 'ready' : 'suspended';
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 type JobState =
21
- | 'ready'
22
- | 'delayed'
23
- | 'running'
24
- | 'suspended'
25
- | 'done'
26
- | 'failed'
27
- | 'dead'
28
- | 'cancelled';
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
- * False for a suspension (`step.sleep`): parking a run is not a failure and must not burn
118
- * a retry attempt, or a 3-day sleep would dead-letter the job.
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
- /** Age in ms of the oldest claimable job — the number that decides autoscaling. */
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