@ultimat3/jobs 11.1.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 +19 -1
- package/package.json +5 -5
- package/src/retry-classification.ts +9 -37
- package/src/retry.ts +33 -22
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` |
|
|
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.
|
|
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.
|
|
36
|
-
"@ultimat3/entity": "11.
|
|
37
|
-
"@ultimat3/schema": "11.
|
|
38
|
-
"@ultimat3/time": "11.
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
44
|
+
export type { Random };
|
|
38
45
|
|
|
39
|
-
/**
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 {
|