@ultimat3/jobs 24.0.0 → 25.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 +25 -27
- package/README.md +50 -34
- 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.ts +5 -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 +37 -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 +64 -118
- 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-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
|
}
|
package/src/errors.ts
CHANGED
|
@@ -48,7 +48,7 @@ export const JOB_OWNED_ERROR_CODES = [
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* `X_NOT_IMPLEMENTED`, `X_ABORTED` and `X_DRAINING` are `@ultimat3/core`'s. The first is thrown as
|
|
51
|
-
* core's own `NotImplementedError` (`inspect.ts
|
|
51
|
+
* core's own `NotImplementedError` (`inspect.ts`), the other two by
|
|
52
52
|
* `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
53
|
* on once core's changed. Listed here all the same, so `JobErrorCode` can name every code a job
|
|
54
54
|
* can see — `X_DRAINING` was thrown for a day before it was, and the type said it could not be.
|
|
@@ -434,13 +434,13 @@ export class JobNotCancellableError extends UltimateError {
|
|
|
434
434
|
}
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
-
/** The driver has no `introspect.cancel
|
|
437
|
+
/** The driver has no `introspect.cancel` — a hand-rolled driver that ships no introspection. */
|
|
438
438
|
export class CancelUnsupportedError extends UltimateError {
|
|
439
439
|
constructor(input: { driver: string }) {
|
|
440
440
|
super({
|
|
441
441
|
code: 'X_JOB_NOT_CANCELLABLE',
|
|
442
442
|
cause: `the "${input.driver}" jobs driver cannot cancel a single job`,
|
|
443
|
-
fix: 'call setJobDriver(
|
|
443
|
+
fix: 'call setJobDriver(postgresJobDriver()) at boot — only the pg driver implements introspect.cancel — then: x jobs cancel <id> --json',
|
|
444
444
|
});
|
|
445
445
|
}
|
|
446
446
|
}
|
|
@@ -461,7 +461,7 @@ export class OutboxNoTxError extends UltimateError {
|
|
|
461
461
|
*
|
|
462
462
|
* The two shipped drivers had two meanings for it — every queue on `driver-memory.ts`, the
|
|
463
463
|
* `default` queue on `driver-pg.ts` — and `ClaimOptions.queues` documented neither. Nothing reached
|
|
464
|
-
* it (`
|
|
464
|
+
* it (`jobWorker` passes exactly one queue per pass), so the divergence could only ever be found
|
|
465
465
|
* by an embedder in production, and each meaning is silently wrong in the other's deployment: one
|
|
466
466
|
* takes work this worker was never configured for, the other drains nothing and reads as an idle
|
|
467
467
|
* queue. There is no third meaning to pick — an empty list is a caller that has not said what to
|
|
@@ -472,7 +472,7 @@ export class ClaimQueuesEmptyError extends UltimateError {
|
|
|
472
472
|
super({
|
|
473
473
|
code: 'X_JOB_CLAIM_QUEUES_EMPTY',
|
|
474
474
|
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
|
|
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 jobWorker walks one queue at a time",
|
|
476
476
|
meta: { driver },
|
|
477
477
|
});
|
|
478
478
|
}
|
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
|