@ultimat3/jobs 11.2.0 → 12.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 +13 -0
- package/package.json +5 -5
- package/src/driver-memory.ts +7 -3
- package/src/driver-pg.ts +4 -4
- package/src/driver.ts +21 -0
- package/src/errors.ts +24 -0
- package/src/index.ts +2 -0
package/CLAUDE.md
CHANGED
|
@@ -75,6 +75,19 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
75
75
|
has the table, so a column added only there reaches new installs and nothing else. Comments in
|
|
76
76
|
that constant carry NO apostrophes and NO semicolons — `dev-queue.ts` splits it on `;` and
|
|
77
77
|
`driver-pg-sql.test.ts` checks quote parity, neither of which can tell prose from a literal.
|
|
78
|
+
- **`claim({ queues: [] })` is REFUSED by every driver, `As of 2026-08-24`.** It used to mean two
|
|
79
|
+
different things: EVERY queue on `driver-memory.ts` (`wanted.size === 0 ||`) and the `default`
|
|
80
|
+
queue on `driver-pg.ts` (`queues.length > 0 ? queues : [DEFAULT_QUEUE]`), with `ClaimOptions.queues`
|
|
81
|
+
documenting neither — so the memory driver every test in this repo runs against and the pg driver
|
|
82
|
+
production runs against answered one question two ways. Nothing reached it (`createWorker` passes
|
|
83
|
+
exactly ONE queue per pass, which is what keeps a slow queue from starving the others), so it could
|
|
84
|
+
only ever be found by an embedder, in production. Both meanings are silently wrong in the other's
|
|
85
|
+
deployment: claiming every queue is a worker taking work it was never configured for, claiming
|
|
86
|
+
`default` is a worker that drains nothing and reads as an idle queue. `assertClaimQueues` in
|
|
87
|
+
`driver.ts` is the one refusal (`X_JOB_CLAIM_QUEUES_EMPTY`), called by both drivers, and
|
|
88
|
+
`driver-memory.ts`'s `claim` is `async` so an empty list REJECTS on both rather than throwing
|
|
89
|
+
synchronously on one — a sync throw out of a method typed `Promise<…>` is itself a divergence.
|
|
90
|
+
**Breaking**: `driver-pg.test.ts` was leaning on the pg default and now names its queue.
|
|
78
91
|
- **`ack` and `nack` are FENCED on `state = 'running'`, and that fence is what makes cancellation
|
|
79
92
|
possible** (`As of 2026-08`). Without it the only way to stop a runaway pass was
|
|
80
93
|
`UPDATE x_jobs SET state='dead'`, which the worker's next settle wrote straight over. Same fence
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.0.0",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "12.0.0",
|
|
36
|
+
"@ultimat3/entity": "12.0.0",
|
|
37
|
+
"@ultimat3/schema": "12.0.0",
|
|
38
|
+
"@ultimat3/time": "12.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/driver-memory.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
NackOptions,
|
|
21
21
|
QueueStats,
|
|
22
22
|
} from './driver';
|
|
23
|
-
import { DEFAULT_QUEUE } from './driver';
|
|
23
|
+
import { assertClaimQueues, DEFAULT_QUEUE } from './driver';
|
|
24
24
|
import { JobDuplicateError } from './errors';
|
|
25
25
|
import type { LeaseStore } from './leases';
|
|
26
26
|
import { createMemoryLeaseStore } from './leases';
|
|
@@ -194,11 +194,15 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
194
194
|
return Promise.resolve({ id: record.id, runId: record.runId, deduped: false });
|
|
195
195
|
},
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
// `async`, so an empty queue list REJECTS here exactly as it does on the pg driver: a
|
|
198
|
+
// synchronous throw out of a method typed `Promise<…>` is a different answer to the same
|
|
199
|
+
// question, which is the class of divergence this pair is checked for.
|
|
200
|
+
async claim(claimOptions: ClaimOptions): Promise<readonly ClaimedJob[]> {
|
|
201
|
+
assertClaimQueues('memory', claimOptions);
|
|
198
202
|
const at = nowMs(clock);
|
|
199
203
|
const wanted = new Set(claimOptions.queues);
|
|
200
204
|
const claimable = [...jobs.values()]
|
|
201
|
-
.filter((record) => wanted.
|
|
205
|
+
.filter((record) => wanted.has(record.queue))
|
|
202
206
|
.filter((record) => {
|
|
203
207
|
if (record.runAt > at) return false;
|
|
204
208
|
if (record.state === 'ready' || record.state === 'delayed') return true;
|
package/src/driver-pg.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
NackOptions,
|
|
21
21
|
QueueStats,
|
|
22
22
|
} from './driver';
|
|
23
|
-
import { DEFAULT_QUEUE } from './driver';
|
|
23
|
+
import { assertClaimQueues, DEFAULT_QUEUE } from './driver';
|
|
24
24
|
import type { BackfillRow, JobRow, StepRow } from './driver-pg-rows';
|
|
25
25
|
import { num, toBackfillRun, toJobRecord, toStepRecord } from './driver-pg-rows';
|
|
26
26
|
import {
|
|
@@ -57,7 +57,7 @@ import type { StepStore } from './steps';
|
|
|
57
57
|
* The one thing this driver needs from the DB layer, declared structurally so this package can
|
|
58
58
|
* depend on no database package at all.
|
|
59
59
|
*
|
|
60
|
-
* **Not satisfied by `Bun.sql`** — verified against Bun 1.
|
|
60
|
+
* **Not satisfied by `Bun.sql`** — verified against Bun 1.4.0: `Bun.sql.query` is `undefined`.
|
|
61
61
|
* `Bun.sql` is a tagged template whose positional form is `unsafe`, so a `{ executor: Bun.sql }`
|
|
62
62
|
* would `TypeError` on the first claim. What satisfies it is a one-line adapter over a client that
|
|
63
63
|
* already speaks `(text, values)` — `@ultimat3/cli`'s `pgExecutorFor(client)` is the framework's
|
|
@@ -292,9 +292,9 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
292
292
|
},
|
|
293
293
|
|
|
294
294
|
async claim(claimOptions: ClaimOptions): Promise<readonly ClaimedJob[]> {
|
|
295
|
-
|
|
295
|
+
assertClaimQueues('pg', claimOptions);
|
|
296
296
|
const rows = await exec().query<JobRow>(SQL_CLAIM, [
|
|
297
|
-
queues,
|
|
297
|
+
claimOptions.queues,
|
|
298
298
|
claimOptions.limit,
|
|
299
299
|
claimOptions.workerId,
|
|
300
300
|
claimOptions.visibilityTimeoutMs,
|
package/src/driver.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// `setJobDriver(other)` and ZERO job-code change — and that is what the interface buys.
|
|
10
10
|
|
|
11
11
|
import type { BackfillLedger } from './backfill-ledger';
|
|
12
|
+
import { ClaimQueuesEmptyError } from './errors';
|
|
12
13
|
import type { LeaseStore } from './leases';
|
|
13
14
|
import type { StepStore } from './steps';
|
|
14
15
|
|
|
@@ -104,6 +105,11 @@ export interface EnqueueResult {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
export interface ClaimOptions {
|
|
108
|
+
/**
|
|
109
|
+
* The queues this pass may take work from, by name. **At least one, and an empty list is
|
|
110
|
+
* refused** (`X_JOB_CLAIM_QUEUES_EMPTY`) — see `assertClaimQueues`. `createWorker` passes exactly
|
|
111
|
+
* one per pass, which is what keeps a slow queue from starving the others.
|
|
112
|
+
*/
|
|
107
113
|
readonly queues: readonly string[];
|
|
108
114
|
readonly limit: number;
|
|
109
115
|
/** Lease length. A worker that dies without ack makes the job claimable again after this. */
|
|
@@ -249,3 +255,18 @@ export function jobDriver(): JobDriver | undefined {
|
|
|
249
255
|
export function resetJobDriver(): void {
|
|
250
256
|
ambient = undefined;
|
|
251
257
|
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Every driver's `claim` opens with this, so the two cannot answer an empty list differently — and
|
|
261
|
+
* they DID: `driver-memory.ts` read it as every queue and `driver-pg.ts` as the `default` one, with
|
|
262
|
+
* this interface documenting neither. A semantic only one driver holds is a guarantee that passes
|
|
263
|
+
* CI on memory and behaves differently on the deploy that runs Postgres, which is the whole reason
|
|
264
|
+
* `driver-parity.test.ts` exists.
|
|
265
|
+
*
|
|
266
|
+
* Refused, never defaulted, for `X_RATE_LIMIT_BUCKET_CONFLICT`'s reason one package over: whichever
|
|
267
|
+
* meaning a merge picked would leave the other a deployment somebody wrote against and nothing
|
|
268
|
+
* enforces.
|
|
269
|
+
*/
|
|
270
|
+
export const assertClaimQueues = (driver: string, options: ClaimOptions): void => {
|
|
271
|
+
if (options.queues.length === 0) throw new ClaimQueuesEmptyError(driver);
|
|
272
|
+
};
|
package/src/errors.ts
CHANGED
|
@@ -25,6 +25,7 @@ export const JOB_OWNED_ERROR_CODES = [
|
|
|
25
25
|
'X_BACKFILL_UNKNOWN',
|
|
26
26
|
'X_JOB_ROW_STATUS_UNKNOWN',
|
|
27
27
|
'X_ACTION_JOB_UNBRIDGED',
|
|
28
|
+
'X_JOB_CLAIM_QUEUES_EMPTY',
|
|
28
29
|
] as const;
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -62,6 +63,7 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
|
|
|
62
63
|
X_BACKFILL_UNKNOWN: 'no declaration carries this backfill name',
|
|
63
64
|
X_JOB_ROW_STATUS_UNKNOWN: 'a queue row carries a status this build does not know',
|
|
64
65
|
X_ACTION_JOB_UNBRIDGED: 'an action projection was registered as a job',
|
|
66
|
+
X_JOB_CLAIM_QUEUES_EMPTY: 'a claim named no queue, and the drivers do not agree what that means',
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
// One unconditional call, so a second package claiming one of jobs' codes throws
|
|
@@ -386,3 +388,25 @@ export class JobsNotImplementedError extends UltimateError {
|
|
|
386
388
|
});
|
|
387
389
|
}
|
|
388
390
|
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* `claim({ queues: [] })`, refused by every driver rather than answered by each.
|
|
394
|
+
*
|
|
395
|
+
* The two shipped drivers had two meanings for it — every queue on `driver-memory.ts`, the
|
|
396
|
+
* `default` queue on `driver-pg.ts` — and `ClaimOptions.queues` documented neither. Nothing reached
|
|
397
|
+
* it (`createWorker` passes exactly one queue per pass), so the divergence could only ever be found
|
|
398
|
+
* by an embedder in production, and each meaning is silently wrong in the other's deployment: one
|
|
399
|
+
* takes work this worker was never configured for, the other drains nothing and reads as an idle
|
|
400
|
+
* queue. There is no third meaning to pick — an empty list is a caller that has not said what to
|
|
401
|
+
* claim.
|
|
402
|
+
*/
|
|
403
|
+
export class ClaimQueuesEmptyError extends UltimateError {
|
|
404
|
+
constructor(driver: string) {
|
|
405
|
+
super({
|
|
406
|
+
code: 'X_JOB_CLAIM_QUEUES_EMPTY',
|
|
407
|
+
cause: `the ${driver} driver was asked to claim with queues: [] — an empty list names no queue, and "every queue" and "the default queue" are different deployments`,
|
|
408
|
+
fix: "pass the queue by name — driver.claim({ queues: ['default'], limit, visibilityTimeoutMs, workerId }) — or read the list from config.jobs.queues, which is what createWorker walks one queue at a time",
|
|
409
|
+
meta: { driver },
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -84,6 +84,7 @@ export type {
|
|
|
84
84
|
QueueStats,
|
|
85
85
|
} from './driver';
|
|
86
86
|
export {
|
|
87
|
+
assertClaimQueues,
|
|
87
88
|
DEFAULT_QUEUE,
|
|
88
89
|
DEFAULT_VISIBILITY_TIMEOUT_MS,
|
|
89
90
|
isJobState,
|
|
@@ -134,6 +135,7 @@ export type { JobErrorCode } from './errors';
|
|
|
134
135
|
export {
|
|
135
136
|
ActionJobUnbridgedError,
|
|
136
137
|
CancelUnsupportedError,
|
|
138
|
+
ClaimQueuesEmptyError,
|
|
137
139
|
ConcurrencyUnenforceableError,
|
|
138
140
|
DriverUnavailableError,
|
|
139
141
|
IdempotencyRequiredError,
|