@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.
Files changed (54) hide show
  1. package/CLAUDE.md +25 -27
  2. package/README.md +50 -34
  3. package/package.json +7 -7
  4. package/src/backfill-errors.ts +3 -2
  5. package/src/backfill-ledger.ts +1 -1
  6. package/src/backfill-rate.ts +2 -2
  7. package/src/backfill.ts +2 -2
  8. package/src/counters.ts +1 -1
  9. package/src/driver-memory-operator.ts +3 -3
  10. package/src/driver-memory.ts +14 -17
  11. package/src/driver-pg-settle-sql.ts +2 -3
  12. package/src/driver-pg-sql.ts +1 -1
  13. package/src/driver-pg.ts +18 -18
  14. package/src/driver.ts +9 -15
  15. package/src/errors-concurrency.ts +1 -1
  16. package/src/errors.ts +5 -5
  17. package/src/events-pg.ts +4 -4
  18. package/src/events.ts +6 -6
  19. package/src/execute.ts +46 -5
  20. package/src/export.ts +2 -2
  21. package/src/heartbeat.ts +7 -2
  22. package/src/index.ts +37 -97
  23. package/src/inspect-operator.ts +1 -1
  24. package/src/inspect.ts +1 -1
  25. package/src/job.ts +1 -1
  26. package/src/leases.ts +1 -1
  27. package/src/limits.ts +13 -8
  28. package/src/outbox-lease.ts +1 -1
  29. package/src/outbox-pg.ts +5 -5
  30. package/src/outbox-relay.ts +6 -6
  31. package/src/outbox.ts +13 -13
  32. package/src/renewal-timer.ts +1 -1
  33. package/src/retry-classification.ts +52 -15
  34. package/src/scheduler-leader.ts +2 -2
  35. package/src/scheduler-pg.ts +16 -14
  36. package/src/scheduler-state.ts +2 -2
  37. package/src/scheduler.ts +5 -5
  38. package/src/steps-memory.ts +2 -2
  39. package/src/steps.ts +35 -7
  40. package/src/task.ts +1 -1
  41. package/src/webhook-attempt.ts +206 -0
  42. package/src/webhook-ledger.ts +15 -4
  43. package/src/webhook.ts +64 -118
  44. package/src/worker-admit.ts +7 -3
  45. package/src/worker-drain-cutoff.ts +110 -0
  46. package/src/worker-fleet-slots.ts +5 -2
  47. package/src/worker-held.ts +100 -0
  48. package/src/worker-options.ts +19 -15
  49. package/src/worker-run.ts +38 -8
  50. package/src/worker-tally.ts +53 -0
  51. package/src/worker-types.ts +7 -4
  52. package/src/worker.ts +85 -77
  53. package/src/driver-nats.ts +0 -89
  54. 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, uuid } from '@ultimat3/core';
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 PgDriverOptions {
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: 'createPgDriver() was called with no executor, and this driver has no ambient fallback',
85
- 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(createMemoryDriver())',
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 pgStepStore(exec: () => PgExecutor): StepStore {
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 pgBackfillLedger(exec: () => PgExecutor): BackfillLedger {
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 pgLeaseStore(exec: () => PgExecutor): LeaseStore {
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 createPgDriver(options: PgDriverOptions = {}): JobDriver {
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: pgStepStore(exec),
260
- backfills: pgBackfillLedger(exec),
261
- leases: pgLeaseStore(exec),
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 ?? uuid(),
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 ?? uuid(),
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 `PgAdvisoryLock` is the shape that gets this right: it opens a connection
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 `createPgLeaseLeader` instead** unless you can
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 createPgLeader(
445
+ export function postgresLeader(
446
446
  lockKey: number,
447
- options: PgDriverOptions = {},
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
- // This header used to say "switching backends is a config line" (`As of 2026-08`). There is no
6
- // such config line: `JobsConfig.driver` has no reader anywhere and boot always builds
7
- // `createPgDriver`. `pg` and `memory` are the two that exist; `redis` and `nats` are honest
8
- // `X_NOT_IMPLEMENTED` stubs. What IS true is the second half — swapping the driver is
9
- // `setJobDriver(other)` and ZERO job-code change — and that is what the interface buys.
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`. `createWorker` passes exactly
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
- export interface AckOptions extends SettleBy {
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 `createWorker().start()` THROWS
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(createPgDriver()) at boot — the pg driver is the one with a lease store`,
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`, the redis/nats stubs), the other two by
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`. The redis/nats stubs, and any hand-rolled driver. */
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(createPgDriver()) at boot — only the pg driver implements introspect.cancel — then: x jobs cancel <id> --json',
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 (`createWorker` passes exactly one queue per pass), so the divergence could only ever be found
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 createWorker walks one queue at a time",
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, uuid } from '@ultimat3/core';
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 PgEventBusOptions {
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 createPgEventBus(options: PgEventBusOptions): EventBus {
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 = uuid();
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, uuid } from '@ultimat3/core';
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 createMemoryEventBus(options: MemoryEventBusOptions = {}): EventBus {
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: uuid(),
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 = createMemoryEventBus();
155
+ let ambientBus: EventBus = memoryEventBus();
156
156
 
157
157
  /**
158
- * **Install `createPgEventBus({ executor })` here in any deployment with more than one process.**
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 = createMemoryEventBus();
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 { failureForRow, nextRetryForError, recordedFailure } from './retry-classification';
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 { createStepRunner, isStepSuspension } from './steps';
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 `createContext` always sets it — but a context can
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
- * `createContext` always sets it, but `WorkerOptions.context()` is the app's own function and a
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 = createStepRunner({
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 { createPacer } from './backfill-rate';
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
- : createPacer({ rate: definition.rate, job: definition.name, clock });
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 `renewedAt = now()` there would restart the clock on
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 = now();
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