@ultimat3/jobs 11.0.0 → 11.2.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 CHANGED
@@ -313,6 +313,24 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
313
313
  `stop`, `JobExecution` carries `stopReason`, and `recordedFailure` appends the terminal verdict to
314
314
  the nack's `error` — `lastError` is the ONE failure field a row has, so without it `x jobs show`
315
315
  renders a dead letter at attempt 1 of 5 as a silent early stop.
316
+ - **The backoff arithmetic is `@ultimat3/core`'s, and `retry.ts` is the option names over it**
317
+ (`As of 2026-08-23`). `backoffDelayMs` is `backoffDelay({ curve, jitter, base, max, attempt })`
318
+ with this package's spellings applied on the way in — `DurationInput` through `toMs`, the
319
+ `DEFAULT_RETRY` fallbacks, and `jitter: boolean` mapped to `'equal' | 'none'`. **EQUAL, never
320
+ `full`**: `jitter: true` here has meant half-fixed-half-random since it shipped, and `full`
321
+ would hand a job that already failed twice a near-zero wait. The public `RetryPolicy`,
322
+ `DEFAULT_RETRY` and `retrySchedule()` are unchanged — a declared `retry: { attempts: 5 }` is
323
+ durable API — and `BackoffStrategy` is now an ALIAS of core's `BackoffCurve` rather than a second
324
+ spelling of the same three names. `retry-core-parity.test.ts` is the pin: 13,824 comparisons
325
+ across every curve, base, cap, attempt and roll, plus the 1-based attempt and the clamp-before-
326
+ jitter. Never re-derive a delay here — four packages shipped four curves, which is why core has
327
+ one — `bun run flight-copies` is the guard, and it refuses a second curve-and-jitter function
328
+ anywhere in `packages/*/src`, matched on the literal shape rather than the name.
329
+ - **`classifyThrown` / `statedDelayMs` are core's, RE-EXPORTED, not copied** (`As of 2026-08-23`).
330
+ They moved down to `packages/core/src/error-retry.ts` beside the table they read.
331
+ `retry-classification.test.ts` pins them by IDENTITY (`toBe`), not by agreement: two functions
332
+ that answer alike today are two that can drift, and the rule that must never drift is the one
333
+ above — an unregistered code carrying `terminal` reads as UNCLASSIFIED.
316
334
  - **The claim loop re-arms on the PASS, never on the jobs.** A slot belongs to its own job and is
317
335
  free the moment it settles, so `claimRound` starts what it claimed and returns the promises —
318
336
  ending the pass on `Promise.allSettled([...inFlight])` made the pool as slow as its slowest
@@ -757,7 +775,7 @@ picture from the other side.
757
775
  | `driver-pg-rows.ts` | a Postgres row → a wire record: `JobRow`/`StepRow`/`BackfillRow` and their mappings |
758
776
  | `driver-memory.ts` | `x dev` / tests |
759
777
  | `driver-redis.ts`, `driver-nats.ts` | honest `X_NOT_IMPLEMENTED` stubs |
760
- | `retry.ts` | backoff arithmetic, dead-letter decision |
778
+ | `retry.ts` | the dead-letter decision, and this package's option names over core's `backoffDelay` — no curve of its own |
761
779
  | `retry-classification.ts` | the OTHER half of that decision: what the thrown error says, and the stop reason the row and the log carry |
762
780
  | `execute.ts` | `executeJob` — one claimed job run and settled, and the run's deadline/cancel |
763
781
  | `heartbeat.ts` | one claimed job's lease: the renewal interval and the loss it reports |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/jobs",
3
- "version": "11.0.0",
3
+ "version": "11.2.0",
4
4
  "description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,9 +32,9 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "11.0.0",
36
- "@ultimat3/entity": "11.0.0",
37
- "@ultimat3/schema": "11.0.0",
38
- "@ultimat3/time": "11.0.0"
35
+ "@ultimat3/core": "11.2.0",
36
+ "@ultimat3/entity": "11.2.0",
37
+ "@ultimat3/schema": "11.2.0",
38
+ "@ultimat3/time": "11.2.0"
39
39
  }
40
40
  }
@@ -3,12 +3,7 @@
3
3
  // The backoff arithmetic stays in ./retry — nothing here recomputes a delay `nextRetry` owns.
4
4
 
5
5
  import type { ErrorRetry } from '@ultimat3/core';
6
- import {
7
- DEFAULT_ERROR_RETRY,
8
- declaredErrorRetry,
9
- isErrorRetry,
10
- isUltimateError,
11
- } from '@ultimat3/core';
6
+ import { classifyThrown, statedDelayMs } from '@ultimat3/core';
12
7
  import { toMs } from './clock';
13
8
  import type { Random, RetryDecision, RetryPolicy } from './retry';
14
9
  import { DEFAULT_RETRY, nextRetry } from './retry';
@@ -23,38 +18,15 @@ export interface JobRetryDecision extends RetryDecision {
23
18
  }
24
19
 
25
20
  /**
26
- * The classification that was DECLARED for this throw, or `undefined` when there is none.
27
- *
28
- * Deliberately not `error.retry` alone. That field is `init.retry ?? retryFor(code)` and
29
- * `retryFor` fails closed, so every unclassified `UltimateError` already carries `terminal`
30
- * reading it would dead-letter the first attempt of every job in every app whose codes nobody has
31
- * classified yet. So `terminal` counts only when it can have come from somewhere: an explicit
32
- * per-instance override is indistinguishable from the default here, which is why an UNCLASSIFIED
33
- * code carrying an instance `retry: 'terminal'` is read as unclassified. Register the code
34
- * (`registerErrorRetry({ X_YOUR_CODE: 'terminal' })`) to have it honoured — one way, and the same
35
- * way every other package declares it.
36
- */
37
- export function classifyThrown(error: unknown): ErrorRetry | undefined {
38
- if (!isUltimateError(error)) return undefined;
39
- const retry: unknown = error.retry;
40
- if (!isErrorRetry(retry)) return undefined;
41
- // Anything other than the fail-closed default can only have come from the code table or from an
42
- // explicit override, so it is somebody's answer either way.
43
- if (retry !== DEFAULT_ERROR_RETRY) return retry;
44
- return declaredErrorRetry(error.code) === undefined ? undefined : retry;
45
- }
46
-
47
- /**
48
- * The delay a `retry-after` error NAMED, in ms. `retryAfterSeconds` on the error's `meta` is the
49
- * framework's one spelling for it — `@ultimat3/http`'s `rateLimited` writes it and the 429's
50
- * `Retry-After` header renders it — so a job and an HTTP client read the same number.
21
+ * Core's, re-exported rather than copied they are the readers of core's classification table and
22
+ * every executor in the framework has to answer them the same way. Both were declared here first
23
+ * and moved down a tier VERBATIM, the subtle rule included: an UNCLASSIFIED code carrying an
24
+ * instance `retry: 'terminal'` reads as unclassified, because a per-instance `terminal` is
25
+ * indistinguishable from the fail-closed default and honouring it would dead-letter the first
26
+ * attempt of every job in every app whose codes nobody has classified. `retry-classification.test.ts`
27
+ * pins that they are the same FUNCTION, not merely two functions that agree today.
51
28
  */
52
- export function statedDelayMs(error: unknown): number | undefined {
53
- if (!isUltimateError(error)) return undefined;
54
- const seconds: unknown = error.meta?.['retryAfterSeconds'];
55
- if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds < 0) return undefined;
56
- return Math.round(seconds * 1_000);
57
- }
29
+ export { classifyThrown, statedDelayMs };
58
30
 
59
31
  /**
60
32
  * Retry, dead-letter, and when. `terminal` stops here on the attempt that failed — the same code
package/src/retry.ts CHANGED
@@ -3,10 +3,17 @@
3
3
  // when attempt 5 lands without running the queue. There is no `x jobs schedule`: the subcommands
4
4
  // are `ls`, `show`, `retry`, `cancel`, `drain`.
5
5
 
6
+ import type { BackoffCurve, Random } from '@ultimat3/core';
7
+ import { backoffDelay } from '@ultimat3/core';
6
8
  import type { DurationInput } from './clock';
7
9
  import { toMs } from './clock';
8
10
 
9
- export type BackoffStrategy = 'exponential' | 'linear' | 'fixed';
11
+ /**
12
+ * This package's name for core's curve, and an ALIAS rather than a second union: two spellings of
13
+ * `'exponential' | 'linear' | 'fixed'` can drift, and a `retry: { backoff: … }` an app declares has
14
+ * to mean the same thing the arithmetic reads.
15
+ */
16
+ export type BackoffStrategy = BackoffCurve;
10
17
 
11
18
  export interface RetryPolicy {
12
19
  /** Total attempts including the first. `attempts: 1` means no retry. */
@@ -34,29 +41,33 @@ export const DEFAULT_RETRY = {
34
41
  deadLetter: true,
35
42
  } satisfies RetryPolicy;
36
43
 
37
- export type Random = () => number;
44
+ export type { Random };
38
45
 
39
- /** Delay before `attempt` (1-based: the delay after attempt 1 failed is `attempt: 1`). */
46
+ /**
47
+ * Delay before `attempt` (1-based: the delay after attempt 1 failed is `attempt: 1`).
48
+ *
49
+ * The arithmetic is core's — one curve-and-jitter function for the framework, because four
50
+ * packages shipped four of them. What stays here is the part that is this package's and not the
51
+ * framework's: `DurationInput` (`'30s'` as well as a number), the `DEFAULT_RETRY` fallbacks, and
52
+ * the `jitter: boolean` this package's public `RetryPolicy` has always spelled as a flag.
53
+ *
54
+ * `?? DEFAULT_RETRY.jitter`, like every other option. It read `!== true` before, so an omitted
55
+ * `jitter` meant OFF while the field's own doc says "Equal jitter … by default" — a burst of
56
+ * failures then retried in lockstep, which is the thundering herd the default exists to break.
57
+ * Masked for jobs declared through `job()` (it merges the defaults) and live for every direct
58
+ * caller of this exported function, `retrySchedule` included.
59
+ */
40
60
  export function backoffDelayMs(policy: RetryPolicy, attempt: number, random?: Random): number {
41
- const base = toMs(policy.delay ?? DEFAULT_RETRY.delay ?? 1_000);
42
- const cap = toMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay ?? 3_600_000);
43
- const strategy = policy.backoff ?? 'exponential';
44
- const step = Math.max(1, attempt);
45
-
46
- let raw: number;
47
- if (strategy === 'fixed') raw = base;
48
- else if (strategy === 'linear') raw = base * step;
49
- else raw = base * 2 ** (step - 1);
50
-
51
- const capped = Math.min(raw, cap);
52
- // `?? DEFAULT_RETRY.jitter`, like every other option above. It read `!== true`, so an omitted
53
- // `jitter` meant OFF while the field's own doc says "Equal jitter … by default" — a burst of
54
- // failures then retried in lockstep, which is the thundering herd the default exists to break.
55
- // Masked for jobs declared through `job()` (it merges the defaults) and live for every direct
56
- // caller of this exported function, `retrySchedule` included.
57
- if ((policy.jitter ?? DEFAULT_RETRY.jitter) !== true) return Math.round(capped);
58
- const roll = (random ?? Math.random)();
59
- return Math.round(capped / 2 + (capped / 2) * roll);
61
+ return backoffDelay({
62
+ attempt,
63
+ base: toMs(policy.delay ?? DEFAULT_RETRY.delay),
64
+ max: toMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay),
65
+ curve: policy.backoff ?? DEFAULT_RETRY.backoff,
66
+ // EQUAL, not full: a job that has already failed twice must not be handed a near-zero wait,
67
+ // and this package's `jitter: true` has meant "half fixed, half random" since it shipped.
68
+ jitter: (policy.jitter ?? DEFAULT_RETRY.jitter) === true ? 'equal' : 'none',
69
+ random,
70
+ });
60
71
  }
61
72
 
62
73
  export interface RetryDecision {