@ultimat3/jobs 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/errors.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // The X_* codes owned by @ultimat3/jobs. Every one names the command or code change that
2
2
  // fixes it — a job failure an agent cannot act on is a job failure that gets retried forever.
3
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
3
+ import { registerErrorCodes, registerErrorRetry, UltimateError } from '@ultimat3/core';
4
4
 
5
5
  /** Codes this package declares and owns. */
6
6
  export const JOB_OWNED_ERROR_CODES = [
@@ -23,6 +23,8 @@ export const JOB_OWNED_ERROR_CODES = [
23
23
  'X_BACKFILL_RUNNING',
24
24
  'X_BACKFILL_STALLED',
25
25
  'X_BACKFILL_UNKNOWN',
26
+ 'X_JOB_ROW_STATUS_UNKNOWN',
27
+ 'X_ACTION_JOB_UNBRIDGED',
26
28
  ] as const;
27
29
 
28
30
  /**
@@ -58,6 +60,8 @@ export const JOB_ERROR_TITLES: Readonly<Record<JobOwnedErrorCode, string>> = {
58
60
  X_BACKFILL_RUNNING: 'a pass under this name is already live',
59
61
  X_BACKFILL_STALLED: 'the sweep ended with rows its own count still matches',
60
62
  X_BACKFILL_UNKNOWN: 'no declaration carries this backfill name',
63
+ X_JOB_ROW_STATUS_UNKNOWN: 'a queue row carries a status this build does not know',
64
+ X_ACTION_JOB_UNBRIDGED: 'an action projection was registered as a job',
61
65
  };
62
66
 
63
67
  // One unconditional call, so a second package claiming one of jobs' codes throws
@@ -66,7 +70,31 @@ registerErrorCodes(
66
70
  Object.fromEntries(Object.entries(JOB_ERROR_TITLES).map(([code, title]) => [code, { title }])),
67
71
  );
68
72
 
69
- const docsFor = (code: JobErrorCode): string => `https://ultimate.dev/errors/${code}`;
73
+ /**
74
+ * The codes of this package's that can be thrown INSIDE a job body, classified — `executeJob`
75
+ * reads this, so a `terminal` one dead-letters on the attempt it happened instead of spending the
76
+ * whole policy on an answer that cannot change. Same rule every package uses: retryable means the
77
+ * same code, run again, has a real chance of a different answer.
78
+ *
79
+ * Two are deliberately absent. `X_JOB_LEASE_LOST` and `X_JOB_SLOT_LOST` mean the row is somebody
80
+ * else's now, so this attempt's verdict is not this attempt's to give: dead-lettering would settle
81
+ * a job another worker is running. They keep the attempt-count path, which ends in the queue
82
+ * re-delivering — the honest outcome for "we stopped owning it".
83
+ */
84
+ registerErrorRetry({
85
+ X_JOB_TIMEOUT: 'retryable',
86
+ X_DRIVER_UNAVAILABLE: 'retryable',
87
+ // A second `step.run` under one name is a defect in the handler, replayed identically forever.
88
+ X_STEP_DUPLICATE: 'terminal',
89
+ // A sweep whose source ran dry while its own count still matches rows: the next attempt resumes
90
+ // at the cursor that just ran dry and diverges again.
91
+ X_BACKFILL_STALLED: 'terminal',
92
+ X_BACKFILL_ENVIRONMENT: 'terminal',
93
+ X_BACKFILL_APPLIED: 'terminal',
94
+ });
95
+
96
+ /** Shared with `backfill-errors.ts`, which holds the seven `X_BACKFILL_*` classes. */
97
+ export const docsFor = (code: JobErrorCode): string => `https://ultimate.dev/errors/${code}`;
70
98
 
71
99
  /** An enqueue collided with a live job holding the same idempotency key under `onConflict: 'error'`. */
72
100
  export class JobDuplicateError extends UltimateError {
@@ -101,6 +129,57 @@ export class JobNameTakenError extends UltimateError {
101
129
  }
102
130
  }
103
131
 
132
+ /**
133
+ * A `text` status column holds a value outside the vocabulary this build compiled.
134
+ *
135
+ * Refused rather than passed through, because the alternative is what used to happen: the three
136
+ * decoders in `driver-pg-rows.ts` cast the column, and `stepRun`'s `existing?.status ===
137
+ * 'completed'` then read false for the laundered value and RE-EXECUTED the step. A second charge
138
+ * is a worse answer than a failed attempt, and "an unrecognised fact is never a satisfied one" is
139
+ * the rule the rest of the framework already follows.
140
+ *
141
+ * Almost always a NEWER deploy's row, not corruption: a status string only reaches the table
142
+ * because some version of this framework wrote it. A rolling deploy that only ADDS a status is
143
+ * safe in the normal direction — the new build knows every old value — and it is the old build
144
+ * reading the new build's row that lands here, on that one job, loudly.
145
+ */
146
+ export class JobRowStatusUnknownError extends UltimateError {
147
+ constructor(input: { table: string; column: string; value: string; known: readonly string[] }) {
148
+ super({
149
+ code: 'X_JOB_ROW_STATUS_UNKNOWN',
150
+ cause:
151
+ `${input.table}.${input.column} holds "${input.value}", which this build does not know — ` +
152
+ `it reads ${input.known.join(', ')}`,
153
+ fix: `x jobs show --json # then drain the older workers: a status this build cannot read was almost certainly written by a newer deploy`,
154
+ docs: docsFor('X_JOB_ROW_STATUS_UNKNOWN'),
155
+ });
156
+ }
157
+ }
158
+
159
+ /**
160
+ * `registerJobs()` was handed `someAction.job()`.
161
+ *
162
+ * That call answers an `ActionJobHandle` — `kind: 'action-job'`, deliberately a different literal
163
+ * from `'job'` — which is the four fields `job()` takes, not a job. It cannot be one: `action` and
164
+ * `jobs` are both tier 3, so neither may import the other, and only `job()` seats a handle the
165
+ * queue, the worker and the manifest accept.
166
+ *
167
+ * Refused BY NAME rather than skipped, which is what used to happen. `registerJobs(module)` is
168
+ * handed a whole module namespace, so silently ignoring a constant or a helper exported beside a
169
+ * job is right — but ignoring this one meant `registerJobs({ publishPost: publishPost.job() })`
170
+ * registered nothing, returned `[]`, and the job never ran, with nothing failing anywhere.
171
+ */
172
+ export class ActionJobUnbridgedError extends UltimateError {
173
+ constructor(input: { export: string; job: string }) {
174
+ super({
175
+ code: 'X_ACTION_JOB_UNBRIDGED',
176
+ cause: `export "${input.export}" is the action projection "${input.job}", which is not a job handle and cannot be registered as one`,
177
+ fix: `wrap it: agentJob(${input.export}, { name: '${input.export}', tenant, retry }) from @ultimat3/ai — that composes job() and returns a handle the queue accepts`,
178
+ docs: docsFor('X_ACTION_JOB_UNBRIDGED'),
179
+ });
180
+ }
181
+ }
182
+
104
183
  /** Two steps in one run share a name, so replay cannot tell their persisted results apart. */
105
184
  export class StepDuplicateError extends UltimateError {
106
185
  constructor(input: { job: string; step: string }) {
@@ -311,134 +390,6 @@ export class OutboxNoTxError extends UltimateError {
311
390
  }
312
391
  }
313
392
 
314
- /**
315
- * The seven backfill codes below all answer one question — "why is this sweep not running?" — and
316
- * each is here because it sends the reader somewhere different: run it, force it, change
317
- * environment, migrate first, wait, fix the predicates, or fix the name. A code that shared a fix
318
- * line with another one would be code inflation, which is why `X_BACKFILL_WRITE_UNCONFIRMED` was
319
- * considered and rejected: a dry run that wrote nothing did exactly what it was asked to.
320
- *
321
- * Every `fix` below is ONE line something can execute — a shell command, or the edit to make.
322
- * Never a command with prose appended: a `fix:` is copied and run verbatim, so a trailing clause
323
- * turns a working command into a syntax error at the one moment the reader is following it
324
- * literally. Explanations belong in `cause`, which is read and never run.
325
- */
326
-
327
- /**
328
- * Declared and never completed. The alarm the framework did not have: an author could
329
- * `x g backfill`, merge and deploy, and nothing anywhere said the pass had not run.
330
- */
331
- export class BackfillPendingError extends UltimateError {
332
- constructor(input: { backfill: string; environment: string }) {
333
- super({
334
- code: 'X_BACKFILL_PENDING',
335
- cause: `backfill "${input.backfill}" is declared and x_backfills holds no completed pass for it in ${input.environment}`,
336
- fix: `x db backfill ${input.backfill} --write --json`,
337
- docs: docsFor('X_BACKFILL_PENDING'),
338
- });
339
- }
340
- }
341
-
342
- /** Already swept. A rerun is legitimate, so this names the flag rather than refusing outright. */
343
- export class BackfillAppliedError extends UltimateError {
344
- constructor(input: { backfill: string; runId: string; completedAt: string }) {
345
- super({
346
- code: 'X_BACKFILL_APPLIED',
347
- cause: `backfill "${input.backfill}" completed as run ${input.runId} at ${input.completedAt}; a forced rerun writes a NEW ledger row and never edits that one`,
348
- fix: `x db backfill ${input.backfill} --write --force --json`,
349
- docs: docsFor('X_BACKFILL_APPLIED'),
350
- });
351
- }
352
- }
353
-
354
- /**
355
- * The declaration names the environments it belongs to and this is not one. Declared DATA, never a
356
- * hardcoded "cleanups are production": a staging rehearsal is correct practice, so which
357
- * environments a sweep belongs to is the app's convention and this is only the mechanism carrying
358
- * it (axiom 8).
359
- */
360
- export class BackfillEnvironmentError extends UltimateError {
361
- constructor(input: { backfill: string; environment: string; declared: readonly string[] }) {
362
- // The first declared environment, because the fix has to be ONE runnable line and the list is
363
- // ordered by the author. The empty case cannot arise from `checkBackfillEnvironment`, which
364
- // treats an empty list as "every environment" — but this constructor is public, so it answers
365
- // with the command that lists what IS declared rather than an `ULTIMATE_ENV=undefined`.
366
- const target = input.declared[0];
367
- super({
368
- code: 'X_BACKFILL_ENVIRONMENT',
369
- cause: `backfill "${input.backfill}" declares environments: ${input.declared.join(', ')} and this process resolved ${input.environment} — add "${input.environment}" to that list if this deploy should sweep too`,
370
- fix:
371
- target === undefined
372
- ? 'x db backfill --pending --json'
373
- : `ULTIMATE_ENV=${target} x db backfill ${input.backfill} --write --json`,
374
- docs: docsFor('X_BACKFILL_ENVIRONMENT'),
375
- });
376
- }
377
- }
378
-
379
- /**
380
- * `requires` names a migration the ledger has not applied. Checked where `x_migrations` is
381
- * readable — this package holds no `@ultimat3/db` dependency and growing one to read a ledger
382
- * would put the migration engine on the tier-3 queue's import graph.
383
- */
384
- export class BackfillMigrationPendingError extends UltimateError {
385
- constructor(input: { backfill: string; migration: string }) {
386
- super({
387
- code: 'X_BACKFILL_MIGRATION_PENDING',
388
- cause: `backfill "${input.backfill}" requires migration ${input.migration}, which x_migrations does not record as applied`,
389
- fix: 'x db migrate --json',
390
- docs: docsFor('X_BACKFILL_MIGRATION_PENDING'),
391
- });
392
- }
393
- }
394
-
395
- /**
396
- * The enqueue deduped: one live pass per name, so this run is the one already going. Distinct from
397
- * `X_BACKFILL_APPLIED`, which is a pass that finished — the response there is `--force`, and the
398
- * response here is to look at the run that is holding the key.
399
- */
400
- export class BackfillRunningError extends UltimateError {
401
- constructor(input: { backfill: string; jobId: string }) {
402
- super({
403
- code: 'X_BACKFILL_RUNNING',
404
- cause: `backfill "${input.backfill}" already has a live pass queued as ${input.jobId}, and one name holds one live pass; its step trace names the batch it is on, and a pass that is not advancing is a worker that lost its lease`,
405
- fix: `x jobs show ${input.jobId} --json`,
406
- docs: docsFor('X_BACKFILL_RUNNING'),
407
- });
408
- }
409
- }
410
-
411
- /**
412
- * The source ran out of rows and the declaration's own `count()` still matches some. Two
413
- * predicates that disagree is an authoring bug in any business — the sweep reported success over
414
- * rows it never visited — so the pass fails rather than writing a completed row nobody can trust.
415
- */
416
- export class BackfillStalledError extends UltimateError {
417
- constructor(input: { backfill: string; remaining: number; swept: number }) {
418
- super({
419
- code: 'X_BACKFILL_STALLED',
420
- cause: `backfill "${input.backfill}" swept ${input.swept} rows, exhausted its source, and count() still matches ${input.remaining} — a WHERE the sweep narrows and the count does not is what leaves rows behind`,
421
- fix: `make count() select on exactly what source() selects on in backfill("${input.backfill}")`,
422
- docs: docsFor('X_BACKFILL_STALLED'),
423
- });
424
- }
425
- }
426
-
427
- /** A name no declaration in this app carries — a typo, or a backfill whose module was deleted. */
428
- export class BackfillUnknownError extends UltimateError {
429
- constructor(input: { backfill: string; known: readonly string[] }) {
430
- super({
431
- code: 'X_BACKFILL_UNKNOWN',
432
- cause:
433
- input.known.length === 0
434
- ? `no backfill named "${input.backfill}" is declared, and this app declares none at all`
435
- : `no backfill named "${input.backfill}" is declared (declared: ${input.known.join(', ')})`,
436
- fix: 'x db backfill --pending --json',
437
- docs: docsFor('X_BACKFILL_UNKNOWN'),
438
- });
439
- }
440
- }
441
-
442
393
  export class JobsNotImplementedError extends UltimateError {
443
394
  constructor(input: { feature: string; fix: string }) {
444
395
  super({
package/src/execute.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // One claimed job run to completion, suspension or failure and settled with the driver — the
2
- // single execution path the worker loop and `x jobs run` share. It owns the run's deadline, and
2
+ // single execution path, shared by the worker loop (`worker-run.ts`) and by the one caller outside
3
+ // this package, `@ultimat3/testing`'s job fixture. There is no `x jobs run` to share it with: the
4
+ // subcommands are `ls`, `show`, `retry`, `cancel`, `drain`. It owns the run's deadline, and
3
5
  // a deadline here means CANCEL: the nack that follows makes the job claimable again, so a body
4
6
  // still running past it would be a second copy of one job, racing the attempt that replaced it.
5
7
 
@@ -18,7 +20,8 @@ import type { ClaimedJob, JobDriver } from './driver';
18
20
  import { JobAbortedError, JobTimeoutError } from './errors';
19
21
  import { eventBus } from './events';
20
22
  import type { AnyJobHandle } from './job';
21
- import { nextRetry } from './retry';
23
+ import type { JobStopReason } from './retry-classification';
24
+ import { nextRetryForError, recordedFailure } from './retry-classification';
22
25
  import type { EventLookup, StepRecord } from './steps';
23
26
  import { createStepRunner, isStepSuspension } from './steps';
24
27
  import { jobRunActor } from './tenant';
@@ -69,6 +72,8 @@ export interface JobExecution {
69
72
  readonly durationMs: number;
70
73
  readonly resumeAt?: number;
71
74
  readonly error?: string;
75
+ /** Why this attempt was the last. Absent while the job is still being retried. */
76
+ readonly stopReason?: JobStopReason;
72
77
  readonly steps: readonly StepRecord[];
73
78
  readonly replayed: readonly string[];
74
79
  }
@@ -84,7 +89,8 @@ export interface ExecuteJobOptions {
84
89
 
85
90
  /**
86
91
  * Run one claimed job to completion, suspension or failure, and settle it with the driver.
87
- * Shared by the worker loop and `x jobs run` so both take exactly the same code path.
92
+ * Shared by the worker loop and by `@ultimat3/testing`'s job fixture, so a job under test takes
93
+ * exactly the code path the worker takes.
88
94
  */
89
95
  export async function executeJob(options: ExecuteJobOptions): Promise<JobExecution> {
90
96
  const { driver, claimed, handle } = options;
@@ -105,6 +111,11 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
105
111
  jobName: handle.name,
106
112
  store: driver.steps,
107
113
  signal,
114
+ // The DECLARED per-step ceiling and event poll. Passed here or nowhere: this is the only
115
+ // production construction of a runner, so a `StepRunnerOptions` field it omits is a feature
116
+ // no `job()` can reach — which both of these were until 2026-08.
117
+ ...(handle.stepTimeoutMs === undefined ? {} : { stepTimeoutMs: handle.stepTimeoutMs }),
118
+ ...(handle.eventPollMs === undefined ? {} : { eventPollMs: handle.eventPollMs }),
108
119
  ...(options.clock === undefined ? {} : { clock: options.clock }),
109
120
  events: options.events ?? eventBus(),
110
121
  });
@@ -151,8 +162,10 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
151
162
  } catch (error) {
152
163
  if (isStepSuspension(error)) {
153
164
  const delayMs = Math.max(0, error.resumeAt - nowMs(options.clock));
154
- // countsAsAttempt: falseparking a run is not a failure.
155
- await driver.nack(claimed.id, { delayMs, countsAsAttempt: false });
165
+ // `park: true` is the suspension itself the row leaves the ready bucket — and
166
+ // `countsAsAttempt: false` only says not to burn an attempt on it. A limiter shed passes the
167
+ // second and not the first: it is a job still waiting, and it belongs in `queue_depth`.
168
+ await driver.nack(claimed.id, { delayMs, countsAsAttempt: false, park: true });
156
169
  return settle({
157
170
  outcome: 'suspended',
158
171
  jobId: claimed.id,
@@ -166,10 +179,16 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
166
179
  }
167
180
 
168
181
  const message = error instanceof Error ? error.message : String(error);
169
- const decision = nextRetry(handle.retry, claimed.attempt);
182
+ // The ERROR decides too, not only the attempt count. A `terminal` code — a rotated password,
183
+ // a schema mismatch, a permission denial — fails identically on every remaining attempt, so
184
+ // spending them is a queue slot, a provider bill and, at a site that locks an account after
185
+ // three wrong passwords, the framework destroying what it was asked to read. A code nobody
186
+ // classified keeps the attempt-count path exactly as it was.
187
+ const decision = nextRetryForError(handle.retry, claimed.attempt, error);
188
+ const stop = decision.stoppedBy;
170
189
  await driver.nack(claimed.id, {
171
190
  delayMs: decision.delayMs,
172
- error: message,
191
+ error: recordedFailure(message, decision),
173
192
  countsAsAttempt: true,
174
193
  deadLetter: !decision.retry && decision.deadLetter,
175
194
  });
@@ -178,13 +197,16 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
178
197
  jobId: claimed.id,
179
198
  attempt: claimed.attempt,
180
199
  retry: decision.retry,
200
+ // "stopped because terminal" and "stopped because the attempts ran out" are different
201
+ // incidents with the same `retry: false`, and only one of them is fixed by raising attempts.
202
+ ...(stop === undefined ? {} : { stop }),
181
203
  error: message,
182
204
  });
183
205
  // This package's ONE error-reporting call site, and it is here rather than in the loop because
184
206
  // this is the only frame that still holds the thrown value — the loop sees a message string.
185
207
  // A retry is a failure the framework recovered from, so it is a `warning`; a dead letter is
186
- // one nobody recovered from. `x jobs run` takes this path too, which is the point: one
187
- // execution path means one place a failed job can become visible.
208
+ // one nobody recovered from. A job driven by `@ultimat3/testing`'s fixture takes this path
209
+ // too, which is the point: one execution path means one place a failed job becomes visible.
188
210
  reportError(error, {
189
211
  source: 'job',
190
212
  severity: decision.retry ? 'warning' : 'error',
@@ -195,6 +217,7 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
195
217
  runId: claimed.runId,
196
218
  attempt: claimed.attempt,
197
219
  retry: decision.retry,
220
+ ...(stop === undefined ? {} : { stop }),
198
221
  },
199
222
  },
200
223
  });
@@ -205,6 +228,7 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
205
228
  attempt: claimed.attempt,
206
229
  durationMs: nowMs(options.clock) - startedAt,
207
230
  error: message,
231
+ ...(stop === undefined ? {} : { stopReason: stop }),
208
232
  steps: [],
209
233
  replayed: [],
210
234
  });
package/src/heartbeat.ts CHANGED
@@ -8,6 +8,7 @@ import { logger, recordLeaseLost } from '@ultimat3/core';
8
8
  import { nowMs } from './clock';
9
9
  import type { ClaimedJob, JobDriver } from './driver';
10
10
  import { LeaseLostError } from './errors';
11
+ import { startRenewalTimer } from './renewal-timer';
11
12
 
12
13
  export interface LeaseHeartbeatOptions {
13
14
  /** Only `heartbeat` is used — a lease renews itself and settles nothing. */
@@ -49,14 +50,8 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
49
50
  let renewedAt = now();
50
51
  let renewing = false;
51
52
  let lost = false;
52
- let timer: ReturnType<typeof setInterval> | undefined;
53
53
  const gone = new AbortController();
54
54
 
55
- const stop = (): void => {
56
- if (timer !== undefined) clearInterval(timer);
57
- timer = undefined;
58
- };
59
-
60
55
  const lapsed = (): boolean => now() - renewedAt >= visibilityTimeoutMs;
61
56
 
62
57
  /**
@@ -65,9 +60,14 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
65
60
  * ticked once per interval would count intervals, not jobs.
66
61
  */
67
62
  const reportLost = (error?: unknown, reason?: 'expired' | 'not-ours'): void => {
68
- if (lost) return;
63
+ // `stopped()` as well as `lost`, and it is the difference between a page and a fact: a clean
64
+ // completion acks the row out of `running` and `worker-run.ts` stops the heartbeat, so a
65
+ // renewal already in flight comes back `false` for a job that FINISHED. Reported, that is
66
+ // `jobs.lease.lost` at error plus `recordLeaseLost(queue)` — the one signal meaning the queue
67
+ // re-delivered a job this process was still running — raised for a run nobody re-delivered.
68
+ if (lost || timer.stopped()) return;
69
69
  lost = true;
70
- stop();
70
+ timer.stop();
71
71
  logger.error('jobs.lease.lost', {
72
72
  workerId,
73
73
  job: claimed.name,
@@ -86,7 +86,7 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
86
86
  };
87
87
 
88
88
  const renew = async (): Promise<void> => {
89
- if (lost) return;
89
+ if (lost || timer.stopped()) return;
90
90
  // Expiry is decided BEFORE the driver is asked, because the failure that loses a lease most
91
91
  // quietly is the one that never answers: a heartbeat hung on a dead connection neither
92
92
  // resolves nor rejects, so a check that ran only on rejection would never run at all.
@@ -100,6 +100,10 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
100
100
  renewing = true;
101
101
  try {
102
102
  const held = await options.driver.heartbeat(claimed.id, { visibilityTimeoutMs, workerId });
103
+ // Re-read AFTER the await, never only before it: the whole point of the flag is the answer
104
+ // that lands past `stop()`. Every branch below decides something about a lease this process
105
+ // may no longer be running under.
106
+ if (timer.stopped()) return;
103
107
  // The driver answered, and it said the row is not ours. That is a DIFFERENT fact from an
104
108
  // expired window and the only one an operator can cause on purpose: `x jobs cancel` writes
105
109
  // a terminal state, and the renewal that misses it is what tells this attempt to stop. It
@@ -138,9 +142,7 @@ export function startLeaseHeartbeat(options: LeaseHeartbeatOptions): LeaseHeartb
138
142
  }
139
143
  };
140
144
 
141
- timer = setInterval(() => {
142
- void renew();
143
- }, options.intervalMs);
145
+ const timer = startRenewalTimer(options.intervalMs, renew);
144
146
 
145
- return { renew, lost: () => lost, signal: gone.signal, stop };
147
+ return { renew, lost: () => lost, signal: gone.signal, stop: () => timer.stop() };
146
148
  }
package/src/index.ts CHANGED
@@ -17,6 +17,15 @@ export type {
17
17
  BackfillReport,
18
18
  } from './backfill';
19
19
  export { backfill, DEFAULT_BACKFILL_BATCH } from './backfill';
20
+ export {
21
+ BackfillAppliedError,
22
+ BackfillEnvironmentError,
23
+ BackfillMigrationPendingError,
24
+ BackfillPendingError,
25
+ BackfillRunningError,
26
+ BackfillStalledError,
27
+ BackfillUnknownError,
28
+ } from './backfill-errors';
20
29
  export type { BackfillGate, BackfillGateInput } from './backfill-gate';
21
30
  export { checkBackfillEnvironment, gateBackfill } from './backfill-gate';
22
31
  export type { BackfillProgress } from './backfill-inspect';
@@ -77,11 +86,13 @@ export type {
77
86
  export {
78
87
  DEFAULT_QUEUE,
79
88
  DEFAULT_VISIBILITY_TIMEOUT_MS,
89
+ isJobState,
90
+ JOB_STATES,
80
91
  jobDriver,
81
92
  resetJobDriver,
82
93
  setJobDriver,
83
94
  } from './driver';
84
- export type { MemoryDriverOptions } from './driver-memory';
95
+ export type { MemoryDriverOptions, MemoryJobDriver } from './driver-memory';
85
96
  export { createMemoryDriver } from './driver-memory';
86
97
  export type { NatsDriverOptions } from './driver-nats';
87
98
  export { createNatsDriver } from './driver-nats';
@@ -107,6 +118,7 @@ export {
107
118
  SQL_NACK,
108
119
  SQL_OUTBOX_CLAIM,
109
120
  SQL_OUTBOX_MARK_PUBLISHED,
121
+ SQL_OUTBOX_RELEASE,
110
122
  SQL_OUTBOX_STAGE,
111
123
  SQL_OUTBOX_TABLE,
112
124
  SQL_SCHEDULER_STATE_GET,
@@ -120,13 +132,7 @@ export type { RedisDriverOptions } from './driver-redis';
120
132
  export { createRedisDriver } from './driver-redis';
121
133
  export type { JobErrorCode } from './errors';
122
134
  export {
123
- BackfillAppliedError,
124
- BackfillEnvironmentError,
125
- BackfillMigrationPendingError,
126
- BackfillPendingError,
127
- BackfillRunningError,
128
- BackfillStalledError,
129
- BackfillUnknownError,
135
+ ActionJobUnbridgedError,
130
136
  CancelUnsupportedError,
131
137
  ConcurrencyUnenforceableError,
132
138
  DriverUnavailableError,
@@ -138,6 +144,7 @@ export {
138
144
  JobMaxAttemptsError,
139
145
  JobNameTakenError,
140
146
  JobNotCancellableError,
147
+ JobRowStatusUnknownError,
141
148
  JobSlotLostError,
142
149
  JobsNotImplementedError,
143
150
  JobTenantRequiredError,
@@ -191,6 +198,7 @@ export {
191
198
  export type {
192
199
  EnqueueOptions,
193
200
  JobsFacade,
201
+ MemoryOutboxOptions,
194
202
  MemoryOutboxStore,
195
203
  OutboxDeps,
196
204
  OutboxRecord,
@@ -207,11 +215,16 @@ export {
207
215
  resetJobsFacade,
208
216
  setJobsFacade,
209
217
  } from './outbox';
218
+ // One definition of the lease, consumed by both stores — a memory default and a pg default that
219
+ // could drift are two answers to "how long is a claim mine for", and the shorter one duplicates.
220
+ export { DEFAULT_OUTBOX_CLAIM_LEASE_MS } from './outbox-lease';
210
221
  export type { PgOutboxOptions } from './outbox-pg';
211
222
  export { createPgOutboxStore } from './outbox-pg';
212
223
 
213
224
  export type { BackoffStrategy, Random, RetryDecision, RetryPolicy } from './retry';
214
225
  export { backoffDelayMs, DEFAULT_RETRY, nextRetry, retrySchedule } from './retry';
226
+ export type { JobRetryDecision, JobStopReason } from './retry-classification';
227
+ export { classifyThrown, nextRetryForError } from './retry-classification';
215
228
  export type {
216
229
  CronResolver,
217
230
  DispatchedOccurrence,
@@ -241,8 +254,10 @@ export type {
241
254
  export {
242
255
  createMemoryStepStore,
243
256
  createStepRunner,
257
+ isStepStatus,
244
258
  isStepSuspension,
245
259
  MAX_TRACE_NAMES,
260
+ STEP_STATUSES,
246
261
  StepSuspension,
247
262
  } from './steps';
248
263
  export type {
package/src/job.ts CHANGED
@@ -80,6 +80,22 @@ export interface JobDefinition<I> {
80
80
  */
81
81
  readonly concurrency?: number;
82
82
  readonly timeout?: DurationInput;
83
+ /**
84
+ * Ceiling for ONE `step.run`, where `timeout` is the ceiling for the whole attempt. Folded into
85
+ * the signal the step body is handed, so a body reads one signal and sees whichever deadline
86
+ * lands first — and it ABORTS before it rejects, because the attempt that replaces this one is
87
+ * claimable the moment the nack lands.
88
+ *
89
+ * Declared here and nowhere else: the runner has implemented this ceiling since 1.0 and no
90
+ * declaration could ask for it, which is a documented guarantee that does nothing.
91
+ */
92
+ readonly stepTimeout?: DurationInput;
93
+ /**
94
+ * How long a `step.waitForEvent` parks between polls. Default 30s. Lower it for a wait a user
95
+ * is watching; the step suspends for exactly this long each time, so it is also the resolution
96
+ * of the resume, never a busy loop.
97
+ */
98
+ readonly eventPoll?: DurationInput;
83
99
  run(args: JobRunArgs<I>): Promise<unknown>;
84
100
  }
85
101
 
@@ -114,6 +130,10 @@ export interface JobHandle<I = unknown> {
114
130
  readonly retry: RetryPolicy;
115
131
  readonly concurrency: number | undefined;
116
132
  readonly timeoutMs: number | undefined;
133
+ /** The declared per-step ceiling in ms; `executeJob` hands it to the step runner. */
134
+ readonly stepTimeoutMs: number | undefined;
135
+ /** The declared event-poll interval in ms; `undefined` leaves the runner's 30s default. */
136
+ readonly eventPollMs: number | undefined;
117
137
  readonly input: StandardSchemaV1<unknown, I>;
118
138
  parse(raw: unknown): I;
119
139
  idempotencyKeyFor(input: I): string;
@@ -183,6 +203,28 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
183
203
  `set a whole concurrency of 1 or more on job("${name}"), or omit the field for no cap at all`,
184
204
  );
185
205
 
206
+ const stepTimeoutMs =
207
+ definition.stepTimeout === undefined ? undefined : toMs(definition.stepTimeout);
208
+ const eventPollMs = definition.eventPoll === undefined ? undefined : toMs(definition.eventPoll);
209
+ // `withStepTimeout` reads `<= 0` as "no ceiling at all" and a poll of zero is a suspension that
210
+ // resumes immediately, forever. Both are an author who asked for a limit and got the opposite,
211
+ // so they are refused where they are written — the same answer `concurrency: 0` gets.
212
+ //
213
+ // FINITE, not merely positive: `> 0` admits `Infinity`, which is the same defect spelled the
214
+ // other way. `eventPoll: Infinity` parks a waiting step and schedules the poll that would wake
215
+ // it for never; `stepTimeout: Infinity` is a ceiling no step can reach. `NaN` fails `> 0` on its
216
+ // own, and is covered here so the predicate says what it means rather than passing by accident.
217
+ assert(
218
+ stepTimeoutMs === undefined || (Number.isFinite(stepTimeoutMs) && stepTimeoutMs > 0),
219
+ `job "${name}" declares stepTimeout ${String(definition.stepTimeout)}, which is no ceiling at all`,
220
+ `set a finite positive stepTimeout on job("${name}") — "30s" or 30_000 — or omit the field for no per-step ceiling`,
221
+ );
222
+ assert(
223
+ eventPollMs === undefined || (Number.isFinite(eventPollMs) && eventPollMs > 0),
224
+ `job "${name}" declares eventPoll ${String(definition.eventPoll)}, which parks a waiting step for no time at all`,
225
+ `set a finite positive eventPoll on job("${name}") — "5s" or 5_000 — or omit the field for the 30s default`,
226
+ );
227
+
186
228
  const handle: JobHandle<I> = {
187
229
  kind: 'job',
188
230
  name,
@@ -190,6 +232,8 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
190
232
  retry: { ...DEFAULT_RETRY, ...definition.retry },
191
233
  concurrency: definition.concurrency,
192
234
  timeoutMs: definition.timeout === undefined ? undefined : toMs(definition.timeout),
235
+ stepTimeoutMs,
236
+ eventPollMs,
193
237
  input: definition.input,
194
238
  parse(raw: unknown): I {
195
239
  return parse(definition.input, raw) as I;
@@ -311,8 +355,18 @@ export function getJob(name: string): AnyJobHandle | undefined {
311
355
  return registry.get(name);
312
356
  }
313
357
 
358
+ /**
359
+ * Code-unit compare, never `localeCompare`. This list is projected into `x.manifest.json`, which
360
+ * both tracked apps COMMIT and `x verify`'s drift step diffs byte for byte — and `localeCompare`
361
+ * with no locale argument answers from the runtime's ICU default and collation version, so the
362
+ * same source could sort two ways on two machines. `@ultimat3/http`'s `describeRoutes` states the
363
+ * same rule; the comparator is restated rather than imported because `http` is not below this
364
+ * package on the tier table.
365
+ */
366
+ const byName = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
367
+
314
368
  export function registeredJobs(): readonly AnyJobHandle[] {
315
- return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name));
369
+ return [...registry.values()].sort((a, b) => byName(a.name, b.name));
316
370
  }
317
371
 
318
372
  export function resetJobs(): void {
package/src/metrics.ts CHANGED
@@ -18,7 +18,7 @@ import { gauge } from '@ultimat3/core';
18
18
  /** Seconds and not milliseconds: every Prometheus duration is seconds, and the alert is `> 300`. */
19
19
  export const queueOldestReady: Gauge = gauge('queue_oldest_ready_seconds', {
20
20
  unit: 's',
21
- description: 'Age of the oldest claimable job, by queue — 0 when the queue is empty',
21
+ description: 'Age of the oldest job that is ready and due, by queue — 0 when none is',
22
22
  });
23
23
 
24
24
  export const queueDeadJobs: Gauge = gauge('queue_dead_jobs', {
@@ -0,0 +1,29 @@
1
+ // The claim lease's one definition and its one normalisation. Both outbox stores read it, because
2
+ // a lease the memory store defaults and the pg store validates is two answers to "how long is a
3
+ // claim mine for" — and the shorter of the two is a row published twice.
4
+
5
+ import { assert } from '@ultimat3/core';
6
+
7
+ /**
8
+ * How long a claimed row stays its claimant's. Long enough that no healthy pass loses a batch it
9
+ * is still publishing, short enough that a relay killed mid-batch does not strand one for minutes.
10
+ */
11
+ export const DEFAULT_OUTBOX_CLAIM_LEASE_MS = 30_000;
12
+
13
+ /**
14
+ * Refused where it is written, the way `concurrency: 0` and `stepTimeout: 0` are. A lease of `0`
15
+ * expires before `claim()` resolves, so every relay reclaims every row on every tick and the lease
16
+ * buys nothing; a fractional one is compared against `now()` in Postgres and against whole ms
17
+ * here; `Infinity` never expires, so the rows of a relay that died are stranded forever — the one
18
+ * failure the lease exists to bound. `X_INVARIANT` because this is a caller-argument check with no
19
+ * dedicated code, the generic `@ultimat3/db` already borrows for the same shape.
20
+ */
21
+ export function resolveClaimLeaseMs(value: number | undefined): number {
22
+ if (value === undefined) return DEFAULT_OUTBOX_CLAIM_LEASE_MS;
23
+ assert(
24
+ Number.isInteger(value) && value > 0,
25
+ `outbox claimLeaseMs is ${String(value)}, which is not a positive whole number of milliseconds`,
26
+ 'pass a positive whole claimLeaseMs: 30_000 — createPgOutboxStore({ executor, txExecutor, claimLeaseMs: 30_000 }) — or omit the field for the 30s default',
27
+ );
28
+ return value;
29
+ }