@ultimat3/jobs 24.0.0 → 25.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 +27 -27
- package/README.md +72 -44
- package/package.json +7 -7
- package/src/backfill-errors.ts +3 -2
- package/src/backfill-ledger.ts +1 -1
- package/src/backfill-rate.ts +2 -2
- package/src/backfill.ts +2 -2
- package/src/counters.ts +1 -1
- package/src/driver-memory-operator.ts +3 -3
- package/src/driver-memory.ts +14 -17
- package/src/driver-pg-settle-sql.ts +2 -3
- package/src/driver-pg-sql.ts +1 -1
- package/src/driver-pg.ts +18 -18
- package/src/driver.ts +9 -15
- package/src/errors-concurrency.ts +1 -1
- package/src/errors-tenant.ts +22 -0
- package/src/errors.ts +10 -5
- package/src/events-pg.ts +4 -4
- package/src/events.ts +6 -6
- package/src/execute.ts +46 -5
- package/src/export.ts +2 -2
- package/src/heartbeat.ts +7 -2
- package/src/index.ts +39 -97
- package/src/inspect-operator.ts +1 -1
- package/src/inspect.ts +1 -1
- package/src/job.ts +1 -1
- package/src/leases.ts +1 -1
- package/src/limits.ts +13 -8
- package/src/outbox-lease.ts +1 -1
- package/src/outbox-pg.ts +5 -5
- package/src/outbox-relay.ts +6 -6
- package/src/outbox.ts +13 -13
- package/src/renewal-timer.ts +1 -1
- package/src/retry-classification.ts +52 -15
- package/src/scheduler-leader.ts +2 -2
- package/src/scheduler-pg.ts +16 -14
- package/src/scheduler-state.ts +2 -2
- package/src/scheduler.ts +5 -5
- package/src/steps-memory.ts +2 -2
- package/src/steps.ts +35 -7
- package/src/task.ts +1 -1
- package/src/webhook-attempt.ts +206 -0
- package/src/webhook-ledger.ts +15 -4
- package/src/webhook.ts +141 -127
- package/src/worker-admit.ts +7 -3
- package/src/worker-drain-cutoff.ts +110 -0
- package/src/worker-fleet-slots.ts +5 -2
- package/src/worker-held.ts +100 -0
- package/src/worker-options.ts +19 -15
- package/src/worker-run.ts +38 -8
- package/src/worker-tally.ts +53 -0
- package/src/worker-types.ts +7 -4
- package/src/worker.ts +85 -77
- package/src/driver-nats.ts +0 -89
- package/src/driver-redis.ts +0 -92
package/src/driver-memory.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// real claim/ack/nack paths rather than a mock that always succeeds.
|
|
4
4
|
|
|
5
5
|
import type { Clock } from '@ultimat3/core';
|
|
6
|
-
import { finiteCount, systemClock,
|
|
6
|
+
import { finiteCount, systemClock, uuidV7 } from '@ultimat3/core';
|
|
7
7
|
import type { BackfillLedger } from './backfill-ledger';
|
|
8
|
-
import {
|
|
8
|
+
import { memoryBackfillLedger } from './backfill-ledger';
|
|
9
9
|
import { nowMs } from './clock';
|
|
10
10
|
import { nackOutcome } from './counters';
|
|
11
11
|
import type {
|
|
@@ -30,19 +30,19 @@ import {
|
|
|
30
30
|
nackState,
|
|
31
31
|
REQUEUEABLE_STATES,
|
|
32
32
|
} from './driver';
|
|
33
|
-
import {
|
|
33
|
+
import { memoryOperator } from './driver-memory-operator';
|
|
34
34
|
import { signalEnqueued } from './enqueue-signal';
|
|
35
35
|
import { JobDuplicateError, LeaseLostError } from './errors';
|
|
36
36
|
import { JobNotFoundError, JobNotRequeueableError, requeueKeyTaken } from './errors-requeue';
|
|
37
37
|
import type { JobIntrospection } from './introspection';
|
|
38
38
|
import { MAX_ERROR_STACK_LENGTH } from './introspection';
|
|
39
39
|
import type { LeaseStore } from './leases';
|
|
40
|
-
import {
|
|
40
|
+
import { memoryLeaseStore } from './leases';
|
|
41
41
|
import { isFinalAttempt } from './retry';
|
|
42
42
|
import type { StepStore } from './steps';
|
|
43
|
-
import {
|
|
43
|
+
import { memoryStepStore } from './steps-memory';
|
|
44
44
|
|
|
45
|
-
export interface
|
|
45
|
+
export interface MemoryJobDriverOptions {
|
|
46
46
|
readonly clock?: Clock;
|
|
47
47
|
readonly steps?: StepStore;
|
|
48
48
|
/** Injectable for the same reason `steps` is: two drivers in one test sharing one ledger. */
|
|
@@ -62,9 +62,9 @@ export interface MemoryDriverOptions {
|
|
|
62
62
|
*/
|
|
63
63
|
export type MemoryJobDriver = JobDriver & { close(): Promise<void> };
|
|
64
64
|
|
|
65
|
-
export function
|
|
65
|
+
export function memoryJobDriver(options: MemoryJobDriverOptions = {}): MemoryJobDriver {
|
|
66
66
|
const clock = options.clock ?? systemClock;
|
|
67
|
-
const stepStore = options.steps ??
|
|
67
|
+
const stepStore = options.steps ?? memoryStepStore();
|
|
68
68
|
// `SQL_STEP_PUT`'s fence: a write made under a claim lands only while that claim holds the row.
|
|
69
69
|
const steps: StepStore = {
|
|
70
70
|
...stepStore,
|
|
@@ -79,9 +79,8 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
79
79
|
return stepStore.put(record);
|
|
80
80
|
},
|
|
81
81
|
};
|
|
82
|
-
const backfills = options.backfills ??
|
|
83
|
-
const leases =
|
|
84
|
-
options.leases ?? createMemoryLeaseStore(options.clock === undefined ? {} : { clock });
|
|
82
|
+
const backfills = options.backfills ?? memoryBackfillLedger(clock);
|
|
83
|
+
const leases = options.leases ?? memoryLeaseStore(options.clock === undefined ? {} : { clock });
|
|
85
84
|
const jobs = new Map<string, JobRecord>();
|
|
86
85
|
|
|
87
86
|
// Keyed by NAME, TENANT and key, exactly as `x_jobs_name_tenant_idempotency_live_idx` is. A
|
|
@@ -134,7 +133,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
134
133
|
});
|
|
135
134
|
};
|
|
136
135
|
|
|
137
|
-
const operator =
|
|
136
|
+
const operator = memoryOperator({
|
|
138
137
|
jobs,
|
|
139
138
|
steps,
|
|
140
139
|
clock,
|
|
@@ -252,7 +251,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
252
251
|
const at = nowMs(clock);
|
|
253
252
|
const runAt = request.runAt ?? at;
|
|
254
253
|
const record: JobRecord = {
|
|
255
|
-
id: request.id ??
|
|
254
|
+
id: request.id ?? uuidV7(),
|
|
256
255
|
name: request.name,
|
|
257
256
|
queue: request.queue || DEFAULT_QUEUE,
|
|
258
257
|
// Through JSON, exactly as the pg driver binds it (`JSON.stringify(request.input ?? null)`):
|
|
@@ -260,7 +259,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
260
259
|
// queue in production never stores, so a job that read one passed here and failed there.
|
|
261
260
|
input: JSON.parse(JSON.stringify(request.input ?? null)) as unknown,
|
|
262
261
|
idempotencyKey: request.idempotencyKey,
|
|
263
|
-
runId: request.runId ??
|
|
262
|
+
runId: request.runId ?? uuidV7(),
|
|
264
263
|
attempt: 0,
|
|
265
264
|
maxAttempts: request.maxAttempts,
|
|
266
265
|
state: runAt > at ? 'delayed' : 'ready',
|
|
@@ -341,9 +340,7 @@ export function createMemoryDriver(options: MemoryDriverOptions = {}): MemoryJob
|
|
|
341
340
|
const record = heldBy(jobId, by);
|
|
342
341
|
if (record === undefined) return Promise.resolve(false);
|
|
343
342
|
settle(jobId, { state: 'done' });
|
|
344
|
-
|
|
345
|
-
operator.counters.add(record.name, 'done', by.durationMs ?? 0, nowMs(clock));
|
|
346
|
-
}
|
|
343
|
+
operator.counters.add(record.name, 'done', by.durationMs ?? 0, nowMs(clock));
|
|
347
344
|
return Promise.resolve(true);
|
|
348
345
|
},
|
|
349
346
|
|
|
@@ -17,8 +17,7 @@ const BUCKET = `${COUNTER_BUCKET_MS}, date_trunc('minute', now())`;
|
|
|
17
17
|
* that claimed it again is the SAME one: its id did not change, only the claim's ordinal did.
|
|
18
18
|
* `returning` is how the caller learns whether its settle landed.
|
|
19
19
|
*
|
|
20
|
-
* $1 id, $2 worker, $3 the attempt's duration in ms, $4 the claim
|
|
21
|
-
* row `x jobs drain` moved is `done` here and is not a completed run.
|
|
20
|
+
* $1 id, $2 worker, $3 the attempt's duration in ms, $4 the claim.
|
|
22
21
|
*/
|
|
23
22
|
export const SQL_ACK = `
|
|
24
23
|
with settled as (
|
|
@@ -28,7 +27,7 @@ with settled as (
|
|
|
28
27
|
returning name
|
|
29
28
|
), counted as (
|
|
30
29
|
insert into x_job_counters (job, bucket_ms, bucket_start, done, duration_ms)
|
|
31
|
-
select name, ${BUCKET}, 1, $3::bigint from settled
|
|
30
|
+
select name, ${BUCKET}, 1, $3::bigint from settled
|
|
32
31
|
on conflict (job, bucket_ms, bucket_start) do update
|
|
33
32
|
set done = x_job_counters.done + 1,
|
|
34
33
|
duration_ms = x_job_counters.duration_ms + excluded.duration_ms
|
package/src/driver-pg-sql.ts
CHANGED
|
@@ -216,7 +216,7 @@ select queue,
|
|
|
216
216
|
|
|
217
217
|
/**
|
|
218
218
|
* Session-scoped, so a crashed node's lock releases itself — and released just as surely when a
|
|
219
|
-
* POOLED connection goes back to the pool, which is why `
|
|
219
|
+
* POOLED connection goes back to the pool, which is why `postgresLeader` is not what a scheduler
|
|
220
220
|
* running on a shared executor should use. See `SQL_LEADER_ACQUIRE` below.
|
|
221
221
|
*/
|
|
222
222
|
export const SQL_TRY_ADVISORY_LOCK = 'select pg_try_advisory_lock($1) as locked';
|
package/src/driver-pg.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// they run against in `driver-pg-ddl.ts`, and the row-to-record decoding in `driver-pg-rows.ts`.
|
|
6
6
|
|
|
7
7
|
import type { Clock, PgExecutor } from '@ultimat3/core';
|
|
8
|
-
import { finiteCount, renderCauseValue, systemClock,
|
|
8
|
+
import { finiteCount, renderCauseValue, systemClock, uuidV7 } from '@ultimat3/core';
|
|
9
9
|
import type { BackfillLedger } from './backfill-ledger';
|
|
10
10
|
import { listedRunId } from './backfill-ledger';
|
|
11
11
|
import { nowMs } from './clock';
|
|
@@ -70,7 +70,7 @@ import type { StepStore } from './steps';
|
|
|
70
70
|
/** How often `enqueue` sends its insert before it calls a refusal an index fault, not a race. */
|
|
71
71
|
const ENQUEUE_ATTEMPTS = 2;
|
|
72
72
|
|
|
73
|
-
export interface
|
|
73
|
+
export interface PostgresJobDriverOptions {
|
|
74
74
|
readonly executor?: PgExecutor;
|
|
75
75
|
readonly clock?: Clock;
|
|
76
76
|
}
|
|
@@ -81,12 +81,13 @@ function resolveExecutor(injected: PgExecutor | undefined): PgExecutor {
|
|
|
81
81
|
driver: 'pg',
|
|
82
82
|
// `Bun.sql` is named nowhere in this function and never was: there is no ambient fallback to
|
|
83
83
|
// be "not configured". An executor is injected by the boot or the driver has none.
|
|
84
|
-
cause:
|
|
85
|
-
|
|
84
|
+
cause:
|
|
85
|
+
'postgresJobDriver() was called with no executor, and this driver has no ambient fallback',
|
|
86
|
+
fix: 'set DATABASE_URL in .env so the boot builds one — x db migrate then x dev — or hand this process a queue with no database: setJobDriver(memoryJobDriver())',
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
function
|
|
90
|
+
function postgresStepStore(exec: () => PgExecutor): StepStore {
|
|
90
91
|
return {
|
|
91
92
|
async get(runId, name) {
|
|
92
93
|
const rows = await exec().query<StepRow>(SQL_STEP_GET, [runId, name]);
|
|
@@ -128,7 +129,7 @@ function pgStepStore(exec: () => PgExecutor): StepStore {
|
|
|
128
129
|
};
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
function
|
|
132
|
+
function postgresBackfillLedger(exec: () => PgExecutor): BackfillLedger {
|
|
132
133
|
return {
|
|
133
134
|
async start(run) {
|
|
134
135
|
await exec().query(SQL_BACKFILL_START, [run.runId, run.name, run.checksum, run.appVersion]);
|
|
@@ -157,7 +158,7 @@ function pgBackfillLedger(exec: () => PgExecutor): BackfillLedger {
|
|
|
157
158
|
* Fleet-wide slots over `x_job_leases`. Every decision is ONE statement — the `(lease_key, slot)`
|
|
158
159
|
* primary key is what serialises two workers, so nothing here reads a count and then acts on it.
|
|
159
160
|
*/
|
|
160
|
-
function
|
|
161
|
+
function postgresLeaseStore(exec: () => PgExecutor): LeaseStore {
|
|
161
162
|
return {
|
|
162
163
|
async acquire(key, limit, ttlMs, holder) {
|
|
163
164
|
if (limit <= 0) return undefined;
|
|
@@ -196,7 +197,7 @@ function pgLeaseStore(exec: () => PgExecutor): LeaseStore {
|
|
|
196
197
|
};
|
|
197
198
|
}
|
|
198
199
|
|
|
199
|
-
export function
|
|
200
|
+
export function postgresJobDriver(options: PostgresJobDriverOptions = {}): JobDriver {
|
|
200
201
|
const clock = options.clock ?? systemClock;
|
|
201
202
|
let executor: PgExecutor | undefined;
|
|
202
203
|
const exec = (): PgExecutor => {
|
|
@@ -256,20 +257,20 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
256
257
|
|
|
257
258
|
return {
|
|
258
259
|
name: 'pg',
|
|
259
|
-
steps:
|
|
260
|
-
backfills:
|
|
261
|
-
leases:
|
|
260
|
+
steps: postgresStepStore(exec),
|
|
261
|
+
backfills: postgresBackfillLedger(exec),
|
|
262
|
+
leases: postgresLeaseStore(exec),
|
|
262
263
|
introspect,
|
|
263
264
|
|
|
264
265
|
async enqueue(request: EnqueueRequest): Promise<EnqueueResult> {
|
|
265
266
|
const runAt = request.runAt ?? nowMs(clock);
|
|
266
267
|
const params = [
|
|
267
|
-
request.id ??
|
|
268
|
+
request.id ?? uuidV7(),
|
|
268
269
|
request.name,
|
|
269
270
|
request.queue || DEFAULT_QUEUE,
|
|
270
271
|
JSON.stringify(request.input ?? null),
|
|
271
272
|
request.idempotencyKey,
|
|
272
|
-
request.runId ??
|
|
273
|
+
request.runId ?? uuidV7(),
|
|
273
274
|
request.maxAttempts,
|
|
274
275
|
runAt,
|
|
275
276
|
request.tenantId ?? null,
|
|
@@ -362,7 +363,6 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
362
363
|
by.workerId,
|
|
363
364
|
Math.round(by.durationMs ?? 0),
|
|
364
365
|
by.claim,
|
|
365
|
-
by.counted !== false,
|
|
366
366
|
]);
|
|
367
367
|
return rows.length > 0;
|
|
368
368
|
},
|
|
@@ -437,14 +437,14 @@ export function createPgDriver(options: PgDriverOptions = {}): JobDriver {
|
|
|
437
437
|
* key would need a second `release()` — hence the `held` guard below, which makes repeated
|
|
438
438
|
* `acquire()` calls (the scheduler renews every round) a no-op rather than a leak.
|
|
439
439
|
*
|
|
440
|
-
* `@ultimat3/realtime`'s `
|
|
440
|
+
* `@ultimat3/realtime`'s `postgresAdvisoryLock()` is the shape that gets this right: it opens a connection
|
|
441
441
|
* of its own and *is* the lock. This package holds no wire protocol, so the executor it is handed
|
|
442
|
-
* is whatever boot built — which is a pool. **Use `
|
|
442
|
+
* is whatever boot built — which is a pool. **Use `postgresLeaseLeader` instead** unless you can
|
|
443
443
|
* prove the executor is a single dedicated session.
|
|
444
444
|
*/
|
|
445
|
-
export function
|
|
445
|
+
export function postgresLeader(
|
|
446
446
|
lockKey: number,
|
|
447
|
-
options:
|
|
447
|
+
options: PostgresJobDriverOptions = {},
|
|
448
448
|
): { acquire(): Promise<boolean>; release(): Promise<void>; readonly renewEveryMs: number } {
|
|
449
449
|
const exec = (): PgExecutor => resolveExecutor(options.executor);
|
|
450
450
|
let held = false;
|
package/src/driver.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// Six methods and no more: claim/ack/nack with a visibility timeout is the smallest set that
|
|
3
3
|
// survives a worker crash.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
5
|
+
// There is no config line that picks a backend: boot always builds `postgresJobDriver()`, and a
|
|
6
|
+
// stale `jobs.driver` in `app.config.ts` is refused by `defineConfig` (25.0.0; it was read by
|
|
7
|
+
// nothing since 5.0.0). `postgresJobDriver` and `memoryJobDriver` are the two that exist — 25.0.0
|
|
8
|
+
// deleted the all-throw `redis` and `nats` stubs. Swapping the driver is `setJobDriver(other)` and
|
|
9
|
+
// ZERO job-code change, and that is what the interface buys.
|
|
10
10
|
|
|
11
11
|
import { finiteCount, finiteOption } from '@ultimat3/core';
|
|
12
12
|
import type { BackfillLedger } from './backfill-ledger';
|
|
@@ -157,7 +157,7 @@ export interface EnqueueResult {
|
|
|
157
157
|
export interface ClaimOptions {
|
|
158
158
|
/**
|
|
159
159
|
* The queues this pass may take work from, by name. **At least one, and an empty list is
|
|
160
|
-
* refused** (`X_JOB_CLAIM_QUEUES_EMPTY`) — see `assertClaimQueues`. `
|
|
160
|
+
* refused** (`X_JOB_CLAIM_QUEUES_EMPTY`) — see `assertClaimQueues`. `jobWorker` passes exactly
|
|
161
161
|
* one per pass, which is what keeps a slow queue from starving the others.
|
|
162
162
|
*/
|
|
163
163
|
readonly queues: readonly string[];
|
|
@@ -221,14 +221,8 @@ export interface SettleBy extends ClaimIdentity {
|
|
|
221
221
|
readonly durationMs?: number;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
* False when the row is finished WITHOUT its body having run here — `x jobs drain` moving it to
|
|
227
|
-
* another driver. It is settled `done` and adds nothing to the job's counters: a job that was
|
|
228
|
-
* moved is not a job that completed.
|
|
229
|
-
*/
|
|
230
|
-
readonly counted?: boolean;
|
|
231
|
-
}
|
|
224
|
+
/** An ack is a settle by the claimer: every ack is a completed run and adds to the counters. */
|
|
225
|
+
export type AckOptions = SettleBy;
|
|
232
226
|
|
|
233
227
|
export interface NackOptions extends SettleBy {
|
|
234
228
|
/** Delay before the job becomes claimable again. */
|
|
@@ -337,7 +331,7 @@ export interface JobDriver {
|
|
|
337
331
|
* Optional, like `introspect` and `backfills`: fleet-wide slot counting, which is the only thing
|
|
338
332
|
* that can make `job.concurrency` mean what its docstring says. The in-process limiter is a fast
|
|
339
333
|
* path over ONE heap and is multiplied by the replica count; this is the gate that is not.
|
|
340
|
-
* A driver without one can only hold the cap per process, so `
|
|
334
|
+
* A driver without one can only hold the cap per process, so `jobWorker().start()` THROWS
|
|
341
335
|
* `X_JOB_CONCURRENCY_UNENFORCEABLE` (`worker.ts`) naming every registered job that declared
|
|
342
336
|
* `concurrency` — refused rather than logged, because a documented guarantee that silently does
|
|
343
337
|
* nothing is worse than either alternative.
|
|
@@ -85,7 +85,7 @@ export class ConcurrencyUnenforceableError extends UltimateError {
|
|
|
85
85
|
super({
|
|
86
86
|
code: 'X_JOB_CONCURRENCY_UNENFORCEABLE',
|
|
87
87
|
cause: `${input.jobs.join(', ')} declare concurrency and the "${input.driver}" jobs driver has no lease store, so the cap would hold per process and the fleet would run concurrency x replicas`,
|
|
88
|
-
fix: `remove concurrency from job("${input.jobs[0] ?? 'the job'}"), or call setJobDriver(
|
|
88
|
+
fix: `remove concurrency from job("${input.jobs[0] ?? 'the job'}"), or call setJobDriver(postgresJobDriver()) at boot — the pg driver is the one with a lease store`,
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The one refusal for a job whose PAYLOAD and declared tenant disagree — a webhook delivery's
|
|
2
|
+
// `orgId` that is not the org the run is under, a tenant that derives no org from the payload, an
|
|
3
|
+
// agent actor resolved in another org. Its code stays declared in `errors.ts`, classified terminal.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
|
|
7
|
+
export class JobTenantMismatchError extends UltimateError {
|
|
8
|
+
constructor(input: {
|
|
9
|
+
readonly job: string;
|
|
10
|
+
/** What disagrees, in words — org ids only; never a secret, never a row. */
|
|
11
|
+
readonly reason: string;
|
|
12
|
+
/** The enqueue, or the declaration, that repairs it — specific to the caller. */
|
|
13
|
+
readonly fix: string;
|
|
14
|
+
}) {
|
|
15
|
+
super({
|
|
16
|
+
code: 'X_JOB_TENANT_MISMATCH',
|
|
17
|
+
cause: `job "${input.job}" ${input.reason} — the same payload is refused on every attempt, so it is dead-lettered on this one`,
|
|
18
|
+
fix: input.fix,
|
|
19
|
+
meta: { job: input.job },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -44,11 +44,12 @@ export const JOB_OWNED_ERROR_CODES = [
|
|
|
44
44
|
'X_JOB_PAGE_INVALID',
|
|
45
45
|
'X_JOB_ON_SETTLED_FAILED',
|
|
46
46
|
'X_JOB_NOT_FOUND',
|
|
47
|
+
'X_JOB_TENANT_MISMATCH',
|
|
47
48
|
] as const;
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* `X_NOT_IMPLEMENTED`, `X_ABORTED` and `X_DRAINING` are `@ultimat3/core`'s. The first is thrown as
|
|
51
|
-
* core's own `NotImplementedError` (`inspect.ts
|
|
52
|
+
* core's own `NotImplementedError` (`inspect.ts`), the other two by
|
|
52
53
|
* `JobAbortedError` and `JobDrainedError` below; jobs keeps no title for any of the three, because the copy this file used to hold was a second title that nothing would have failed
|
|
53
54
|
* on once core's changed. Listed here all the same, so `JobErrorCode` can name every code a job
|
|
54
55
|
* can see — `X_DRAINING` was thrown for a day before it was, and the type said it could not be.
|
|
@@ -102,6 +103,7 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
|
|
|
102
103
|
X_JOB_PAGE_INVALID: 'a job list page was asked for outside its bounds',
|
|
103
104
|
X_JOB_ON_SETTLED_FAILED: 'the onSettled hook failed after the run was settled',
|
|
104
105
|
X_JOB_NOT_FOUND: 'the queue holds no job with this id',
|
|
106
|
+
X_JOB_TENANT_MISMATCH: "a job's payload names an org other than the tenant it runs under",
|
|
105
107
|
};
|
|
106
108
|
|
|
107
109
|
// One unconditional call, so a second package claiming one of jobs' codes throws
|
|
@@ -155,6 +157,9 @@ registerErrorRetry({
|
|
|
155
157
|
X_EXPORT_PART_TOO_LARGE: 'terminal',
|
|
156
158
|
// The worker settles a busy key itself; listed so a body that RETHROWS one it was handed stops.
|
|
157
159
|
X_JOB_KEY_BUSY: 'terminal',
|
|
160
|
+
// The payload names the wrong org, or none its tenant can run under — a fact about the ROW, so
|
|
161
|
+
// every attempt and every `x jobs retry` is refused identically: the repair is a new enqueue.
|
|
162
|
+
X_JOB_TENANT_MISMATCH: 'terminal',
|
|
158
163
|
});
|
|
159
164
|
|
|
160
165
|
// No `docs:` on any class below, here or in `backfill-errors.ts`. `UltimateError` fills it from
|
|
@@ -434,13 +439,13 @@ export class JobNotCancellableError extends UltimateError {
|
|
|
434
439
|
}
|
|
435
440
|
}
|
|
436
441
|
|
|
437
|
-
/** The driver has no `introspect.cancel
|
|
442
|
+
/** The driver has no `introspect.cancel` — a hand-rolled driver that ships no introspection. */
|
|
438
443
|
export class CancelUnsupportedError extends UltimateError {
|
|
439
444
|
constructor(input: { driver: string }) {
|
|
440
445
|
super({
|
|
441
446
|
code: 'X_JOB_NOT_CANCELLABLE',
|
|
442
447
|
cause: `the "${input.driver}" jobs driver cannot cancel a single job`,
|
|
443
|
-
fix: 'call setJobDriver(
|
|
448
|
+
fix: 'call setJobDriver(postgresJobDriver()) at boot — only the pg driver implements introspect.cancel — then: x jobs cancel <id> --json',
|
|
444
449
|
});
|
|
445
450
|
}
|
|
446
451
|
}
|
|
@@ -461,7 +466,7 @@ export class OutboxNoTxError extends UltimateError {
|
|
|
461
466
|
*
|
|
462
467
|
* The two shipped drivers had two meanings for it — every queue on `driver-memory.ts`, the
|
|
463
468
|
* `default` queue on `driver-pg.ts` — and `ClaimOptions.queues` documented neither. Nothing reached
|
|
464
|
-
* it (`
|
|
469
|
+
* it (`jobWorker` passes exactly one queue per pass), so the divergence could only ever be found
|
|
465
470
|
* by an embedder in production, and each meaning is silently wrong in the other's deployment: one
|
|
466
471
|
* takes work this worker was never configured for, the other drains nothing and reads as an idle
|
|
467
472
|
* queue. There is no third meaning to pick — an empty list is a caller that has not said what to
|
|
@@ -472,7 +477,7 @@ export class ClaimQueuesEmptyError extends UltimateError {
|
|
|
472
477
|
super({
|
|
473
478
|
code: 'X_JOB_CLAIM_QUEUES_EMPTY',
|
|
474
479
|
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`,
|
|
475
|
-
fix: "pass the queue by name — driver.claim({ queues: ['default'], limit, visibilityTimeoutMs, workerId }) — or read the list from config.jobs.queues, which is what
|
|
480
|
+
fix: "pass the queue by name — driver.claim({ queues: ['default'], limit, visibilityTimeoutMs, workerId }) — or read the list from config.jobs.queues, which is what jobWorker walks one queue at a time",
|
|
476
481
|
meta: { driver },
|
|
477
482
|
});
|
|
478
483
|
}
|
package/src/events-pg.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// resumes at 12:00:30 must still see an event published at 12:00:10.
|
|
8
8
|
|
|
9
9
|
import type { PgExecutor } from '@ultimat3/core';
|
|
10
|
-
import { finiteOption, logger,
|
|
10
|
+
import { finiteOption, logger, uuidV7 } from '@ultimat3/core';
|
|
11
11
|
import type { DurationInput } from './clock';
|
|
12
12
|
import { finiteDurationMs } from './clock';
|
|
13
13
|
import {
|
|
@@ -28,7 +28,7 @@ interface EventRow {
|
|
|
28
28
|
readonly expires_at: number | string;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export interface
|
|
31
|
+
export interface PostgresEventBusOptions {
|
|
32
32
|
/**
|
|
33
33
|
* The only thing this bus is built from. There is no `clock`: every instant it writes or
|
|
34
34
|
* compares is the database's (`SQL_EVENT_PUBLISH`), and a process clock here was the defect.
|
|
@@ -40,7 +40,7 @@ export interface PgEventBusOptions {
|
|
|
40
40
|
readonly listLimit?: number;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
export function
|
|
43
|
+
export function postgresEventBus(options: PostgresEventBusOptions): EventBus {
|
|
44
44
|
// TWO screens, for the reason `events.ts` states: `defaultTtl` is the constructor's knob and
|
|
45
45
|
// `ttl` is the publish call's, so one screen over `ttl ?? defaultTtl` names the wrong one for
|
|
46
46
|
// whichever value actually arrived.
|
|
@@ -67,7 +67,7 @@ export function createPgEventBus(options: PgEventBusOptions): EventBus {
|
|
|
67
67
|
},
|
|
68
68
|
|
|
69
69
|
async publish(name, payload, publishOptions = {}) {
|
|
70
|
-
const id =
|
|
70
|
+
const id = uuidV7();
|
|
71
71
|
const ttlMs =
|
|
72
72
|
publishOptions.ttl === undefined
|
|
73
73
|
? defaultTtlMs
|
package/src/events.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 12:00:10, so a fire-and-forget emitter would silently strand every waiting run.
|
|
4
4
|
|
|
5
5
|
import type { Clock } from '@ultimat3/core';
|
|
6
|
-
import { finiteOption, logger, systemClock,
|
|
6
|
+
import { finiteOption, logger, systemClock, uuidV7 } from '@ultimat3/core';
|
|
7
7
|
import type { DurationInput } from './clock';
|
|
8
8
|
import { finiteDurationMs, nowMs } from './clock';
|
|
9
9
|
import type { PurgeTarget } from './purge';
|
|
@@ -49,7 +49,7 @@ export interface MemoryEventBusOptions {
|
|
|
49
49
|
readonly maxEvents?: number;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export function
|
|
52
|
+
export function memoryEventBus(options: MemoryEventBusOptions = {}): EventBus {
|
|
53
53
|
const clock = options.clock ?? systemClock;
|
|
54
54
|
// TWO screens, because these are two knobs: the default is declared at construction and belongs
|
|
55
55
|
// to whoever built the bus, `ttl` rides the publish CALL. One screen over `ttl ?? defaultTtl`
|
|
@@ -83,7 +83,7 @@ export function createMemoryEventBus(options: MemoryEventBusOptions = {}): Event
|
|
|
83
83
|
purgeExpired();
|
|
84
84
|
const at = nowMs(clock);
|
|
85
85
|
const event: JobEvent = {
|
|
86
|
-
id:
|
|
86
|
+
id: uuidV7(),
|
|
87
87
|
name,
|
|
88
88
|
payload,
|
|
89
89
|
publishedAt: at,
|
|
@@ -152,10 +152,10 @@ export function eventsPurgeTarget(bus: EventBus): PurgeTarget {
|
|
|
152
152
|
return { name: EVENTS_PURGE_TARGET, purgeExpired: () => bus.purgeExpired() };
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
let ambientBus: EventBus =
|
|
155
|
+
let ambientBus: EventBus = memoryEventBus();
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
-
* **Install `
|
|
158
|
+
* **Install `postgresEventBus({ executor })` here in any deployment with more than one process.**
|
|
159
159
|
* The default above is one process's heap: the pod that publishes and the pod that resumes are
|
|
160
160
|
* never the same one, so a webhook landing on web-3 strands a run on worker-7 until its 24h
|
|
161
161
|
* timeout dead-letters it, with nothing logged before then. This line used to promise a
|
|
@@ -176,7 +176,7 @@ export function eventBus(): EventBus {
|
|
|
176
176
|
* wait before that test has asked anything — call this between tests.
|
|
177
177
|
*/
|
|
178
178
|
export function resetEventBus(): void {
|
|
179
|
-
ambientBus =
|
|
179
|
+
ambientBus = memoryEventBus();
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
/** The one function app code calls to unblock a waiting step. */
|
package/src/execute.ts
CHANGED
|
@@ -26,12 +26,17 @@ import type { AnyJobHandle } from './job';
|
|
|
26
26
|
import { createProgressReporter } from './progress';
|
|
27
27
|
import { isFinalAttempt } from './retry';
|
|
28
28
|
import type { JobStopReason } from './retry-classification';
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
failureForRow,
|
|
31
|
+
nextRetryForError,
|
|
32
|
+
rateLimitDeferralMs,
|
|
33
|
+
recordedFailure,
|
|
34
|
+
} from './retry-classification';
|
|
30
35
|
import { raceTimeout } from './run-deadline';
|
|
31
36
|
import { createRunSignal } from './run-signal';
|
|
32
37
|
import { announceSettled, settledCode } from './settled';
|
|
33
38
|
import type { EventLookup, StepRecord } from './steps';
|
|
34
|
-
import {
|
|
39
|
+
import { isStepSuspension, stepRunner } from './steps';
|
|
35
40
|
import { jobRunActor } from './tenant';
|
|
36
41
|
|
|
37
42
|
/**
|
|
@@ -57,7 +62,7 @@ export type JobOutcome =
|
|
|
57
62
|
const NEVER_ABORTED = new AbortController().signal;
|
|
58
63
|
|
|
59
64
|
/**
|
|
60
|
-
* `Ctx.signal` is non-optional in the type and `
|
|
65
|
+
* `Ctx.signal` is non-optional in the type and `ctxOf` always sets it — but a context can
|
|
61
66
|
* still arrive across a cast (`@ultimat3/http`'s `asCtx`, a test's `{} as Ctx`) without one, and
|
|
62
67
|
* a job that crashed on a missing field would be a far worse answer than a job with no caller to
|
|
63
68
|
* follow. Read it, do not assume it.
|
|
@@ -68,7 +73,7 @@ function callerSignal(ctx: Ctx): AbortSignal {
|
|
|
68
73
|
|
|
69
74
|
/**
|
|
70
75
|
* The same defensive read, for the same reason: `Ctx.actor` is non-optional in the type and
|
|
71
|
-
* `
|
|
76
|
+
* `ctxOf` always sets it, but `WorkerOptions.context()` is the app's own function and a
|
|
72
77
|
* cast context (`{} as Ctx`) reaches here without one. Anonymous is the honest stand-in — it
|
|
73
78
|
* carries no org, so the job's declared tenant is the only thing that can put one on the run.
|
|
74
79
|
*/
|
|
@@ -147,12 +152,15 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
147
152
|
signal,
|
|
148
153
|
services: callerServices(options.ctx),
|
|
149
154
|
});
|
|
150
|
-
const runner =
|
|
155
|
+
const runner = stepRunner({
|
|
151
156
|
runId: claimed.runId,
|
|
152
157
|
jobName: handle.name,
|
|
153
158
|
store: driver.steps,
|
|
154
159
|
fence: { job: handle.name, jobId: claimed.id, ...claimOf(claimed) },
|
|
155
160
|
signal,
|
|
161
|
+
// This attempt's OWN end — the deadline, the `finally` below — apart from the run's signal,
|
|
162
|
+
// whose first reason may be the drain's: a drained step still records, an ended attempt never.
|
|
163
|
+
ended: cancel.signal,
|
|
156
164
|
// The DECLARED per-step ceiling and event poll. Passed here or nowhere: this is the only
|
|
157
165
|
// production construction of a runner, so a `StepRunnerOptions` field it omits is a feature
|
|
158
166
|
// no `job()` can reach — which both of these were until 2026-08.
|
|
@@ -310,6 +318,39 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
310
318
|
replayed: [],
|
|
311
319
|
});
|
|
312
320
|
}
|
|
321
|
+
// A rate-limit refusal is "not yet": rescheduled for its Retry-After with the attempt
|
|
322
|
+
// UNCOUNTED, like a suspension — but left in the ready bucket (no park): it is a job still
|
|
323
|
+
// waiting, and it belongs in `queue_depth`. Counted, a backlog of rate-limited `llm()` jobs was
|
|
324
|
+
// dead-lettered for having waited on a bucket that was only ever going to refill.
|
|
325
|
+
const deferMs = rateLimitDeferralMs(handle.retry, claimed.attempt, error);
|
|
326
|
+
if (deferMs !== undefined) {
|
|
327
|
+
landed(
|
|
328
|
+
await driver.nack(claimed.id, {
|
|
329
|
+
...by(),
|
|
330
|
+
delayMs: deferMs,
|
|
331
|
+
error: failureForRow(error),
|
|
332
|
+
countsAsAttempt: false,
|
|
333
|
+
}),
|
|
334
|
+
'retried',
|
|
335
|
+
);
|
|
336
|
+
logger.info('jobs.attempt.rate_limited', {
|
|
337
|
+
job: handle.name,
|
|
338
|
+
jobId: claimed.id,
|
|
339
|
+
attempt: claimed.attempt,
|
|
340
|
+
delayMs: deferMs,
|
|
341
|
+
});
|
|
342
|
+
return settle({
|
|
343
|
+
outcome: 'retried',
|
|
344
|
+
jobId: claimed.id,
|
|
345
|
+
job: handle.name,
|
|
346
|
+
attempt: claimed.attempt,
|
|
347
|
+
durationMs: nowMs(options.clock) - startedAt,
|
|
348
|
+
error: message,
|
|
349
|
+
resumeAt: nowMs(options.clock) + deferMs,
|
|
350
|
+
steps: [],
|
|
351
|
+
replayed: [],
|
|
352
|
+
});
|
|
353
|
+
}
|
|
313
354
|
// The ERROR decides too, not only the attempt count. A `terminal` code — a rotated password,
|
|
314
355
|
// a schema mismatch, a permission denial — fails identically on every remaining attempt, so
|
|
315
356
|
// spending them is a queue slot, a provider bill and, at a site that locks an account after
|
package/src/export.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { assert, systemClock } from '@ultimat3/core';
|
|
|
20
20
|
import type { ReadBuilder } from '@ultimat3/entity';
|
|
21
21
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
22
22
|
import type { Pacer } from './backfill-rate';
|
|
23
|
-
import {
|
|
23
|
+
import { backfillPacer } from './backfill-rate';
|
|
24
24
|
import type { DurationInput } from './clock';
|
|
25
25
|
import { nowMs } from './clock';
|
|
26
26
|
import type { ExportFormat, ExportRecord } from './export-format';
|
|
@@ -177,7 +177,7 @@ export function exportRows<Row, I>(definition: ExportDefinition<Row, I>): JobHan
|
|
|
177
177
|
const pace =
|
|
178
178
|
definition.rate === undefined
|
|
179
179
|
? undefined
|
|
180
|
-
:
|
|
180
|
+
: backfillPacer({ rate: definition.rate, job: definition.name, clock });
|
|
181
181
|
|
|
182
182
|
// Resolved per write, never here: this line runs at the app's import, before its storage exists.
|
|
183
183
|
const planned: PlannedExport<Row, I> = {
|
package/src/heartbeat.ts
CHANGED
|
@@ -98,6 +98,11 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
98
98
|
// request per tick onto the connection that is already the thing failing.
|
|
99
99
|
if (renewing) return;
|
|
100
100
|
renewing = true;
|
|
101
|
+
// Stamped BEFORE the call: the driver writes the new lease at the statement, so the window it
|
|
102
|
+
// bought starts no later than this. Stamped at the reply instead, a slow answer would stretch
|
|
103
|
+
// this process's view of the lease past the server's by the reply's latency — and a second
|
|
104
|
+
// worker re-claims the row while this one still believes it holds it.
|
|
105
|
+
const sentAt = now();
|
|
101
106
|
try {
|
|
102
107
|
const held = await options.driver.heartbeat(claimed.id, {
|
|
103
108
|
visibilityTimeoutMs,
|
|
@@ -121,14 +126,14 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
|
|
|
121
126
|
}
|
|
122
127
|
// Asked again AFTER the call, because a renewal that SUCCEEDS late is still late: an
|
|
123
128
|
// event-loop stall or a driver that answered at the end of a connect timeout can land this
|
|
124
|
-
// past the window it was renewing, and
|
|
129
|
+
// past the window it was renewing, and renewing there would restart the clock on
|
|
125
130
|
// a lease the queue has already re-delivered — the loss hidden by the very call meant to
|
|
126
131
|
// prevent it.
|
|
127
132
|
if (lapsed()) {
|
|
128
133
|
reportLost();
|
|
129
134
|
return;
|
|
130
135
|
}
|
|
131
|
-
renewedAt =
|
|
136
|
+
renewedAt = sentAt;
|
|
132
137
|
} catch (error) {
|
|
133
138
|
// One failed renewal is not a lost lease: there is a whole visibility window left to land
|
|
134
139
|
// the next one, and the default interval gives three tries inside it. Say it at warn and
|