@ultimat3/jobs 17.0.0 → 19.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 -3
- package/package.json +6 -6
- package/src/clock.ts +35 -7
- package/src/events-pg.ts +4 -4
- package/src/events.ts +4 -4
- package/src/job.ts +12 -4
- package/src/outbox.ts +1 -1
- package/src/retry-classification.ts +2 -2
- package/src/retry.ts +3 -3
- package/src/steps.ts +6 -5
package/CLAUDE.md
CHANGED
|
@@ -370,7 +370,7 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
370
370
|
renders a dead letter at attempt 1 of 5 as a silent early stop.
|
|
371
371
|
- **The backoff arithmetic is `@ultimat3/core`'s, and `retry.ts` is the option names over it**
|
|
372
372
|
(`As of 2026-08-23`). `backoffDelayMs` is `backoffDelay({ curve, jitter, base, max, attempt })`
|
|
373
|
-
with this package's spellings applied on the way in — `DurationInput` through `
|
|
373
|
+
with this package's spellings applied on the way in — `DurationInput` through `finiteDurationMs`, the
|
|
374
374
|
`DEFAULT_RETRY` fallbacks, and `jitter: boolean` mapped to `'equal' | 'none'`. **EQUAL, never
|
|
375
375
|
`full`**: `jitter: true` here has meant half-fixed-half-random since it shipped, and `full`
|
|
376
376
|
would hand a job that already failed twice a near-zero wait. The public `RetryPolicy`,
|
|
@@ -842,8 +842,30 @@ Tier 3. The `job` + `task` primitives, durable steps, transactional outbox, queu
|
|
|
842
842
|
|
|
843
843
|
`clock.ts` calls `parseDuration(str)` and `scheduler.ts` calls
|
|
844
844
|
`nextCronOccurrence(cron, { tz, from })`. Both are normalised in one place each
|
|
845
|
-
(`
|
|
846
|
-
change in `@ultimat3/time` is a one-line fix, not a sweep.
|
|
845
|
+
(`finiteDurationMs`, `defaultCronResolver`) and the scheduler's resolver is injectable, so a
|
|
846
|
+
signature change in `@ultimat3/time` is a one-line fix, not a sweep.
|
|
847
|
+
|
|
848
|
+
**`clock.ts`'s conversion is `finiteDurationMs(duration, subject, option)`, never a bare `toMs`**
|
|
849
|
+
(`As of 2026-08-26`). It was `toMs` — a THIRD implementation of duration→ms with no finiteness
|
|
850
|
+
screen at all, and the one every scheduling decision in this package goes through:
|
|
851
|
+
`step.sleep(Number(process.env.DELAY))` on an unset variable made `wakeAt = at + NaN`, and every
|
|
852
|
+
`wakeAt <= now` against a `NaN` is false forever — a sleep that never ends, on a row `x jobs show`
|
|
853
|
+
prints as `sleeping`. Partial screening is what hid it: `events.ts`, `events-pg.ts` and `steps.ts`
|
|
854
|
+
wrapped the RESULT in `finiteOption` while `retry.ts` and `step.sleep` did not, so a reader saw
|
|
855
|
+
`finiteOption` in the file and concluded the package was screened.
|
|
856
|
+
|
|
857
|
+
Three things about the shape are load-bearing. The floor is `finiteOption` and NOT `finiteCount`,
|
|
858
|
+
measured rather than assumed: `retry-core-parity.test.ts` pins `maxDelay: -5` at `0` and four
|
|
859
|
+
`.job.test.ts` suites configure `retry: { delay: 0 }`, so a negative and a zero duration are
|
|
860
|
+
shipped behaviour here — only a non-finite one is refused, and a caller wanting a positive whole
|
|
861
|
+
number narrows on top, which is what `job()` does for `stepTimeout` and `eventPoll`. `subject` and
|
|
862
|
+
`option` are REQUIRED, so a call site that does not name the app author's own key
|
|
863
|
+
(`retry.delay`, `job("x") timeout`, `step.sleep`'s argument) is `TS2554` at the call rather than a
|
|
864
|
+
review note — `@ultimat3/time`'s `toMs` screens under the subject `toMs`, which names a framework
|
|
865
|
+
internal. And the callee CARRIES `Finite`, because `bun run finite-bounds` reads a repair off the
|
|
866
|
+
callee's name: that is what lets `finiteDurationMs(options.defaultTtl ?? 604_800_000, …)` be
|
|
867
|
+
recognised as screened with no second wrapper around it. `duration-bounds.test.ts` is the
|
|
868
|
+
enforcement `finite-bounds` cannot be — a `typeof duration === 'number'` arm has no `??` in it.
|
|
847
869
|
|
|
848
870
|
`driver-pg.ts`'s `PgExecutor` (`:62-64`) is a one-method duck-typed interface — this package still
|
|
849
871
|
has no `@ultimat3/db` dependency, and nothing here knows what an observer, a span or
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/jobs",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "19.0.0",
|
|
4
4
|
"description": "Durable background work: steps, transactional outbox, cron tasks, one driver interface",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,16 +25,16 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"engines": {
|
|
28
|
-
"bun": ">=1.
|
|
28
|
+
"bun": ">=1.4.0"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/schema": "
|
|
38
|
-
"@ultimat3/time": "
|
|
35
|
+
"@ultimat3/core": "19.0.0",
|
|
36
|
+
"@ultimat3/entity": "19.0.0",
|
|
37
|
+
"@ultimat3/schema": "19.0.0",
|
|
38
|
+
"@ultimat3/time": "19.0.0"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/src/clock.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// injected Clock, so tests never sleep and a frozen clock cannot be bypassed.
|
|
3
3
|
|
|
4
4
|
import type { Clock } from '@ultimat3/core';
|
|
5
|
-
import { systemClock } from '@ultimat3/core';
|
|
5
|
+
import { finiteOption, systemClock } from '@ultimat3/core';
|
|
6
6
|
import { parseDuration } from '@ultimat3/time';
|
|
7
7
|
|
|
8
8
|
export type DurationInput = string | number;
|
|
@@ -14,10 +14,38 @@ export function nowMs(clock: Clock = systemClock): number {
|
|
|
14
14
|
return Number(reading);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
/**
|
|
18
|
+
* `'3d'` | `'30s'` | `1500` -> ms, refused under the name the CALLER wrote.
|
|
19
|
+
*
|
|
20
|
+
* NOT a copy of `@ultimat3/time`'s `toMs`, and the name says which one this is — three functions
|
|
21
|
+
* spelled `toMs`, `toMs` and `toDurationMs` are exactly the trap a rule spelled by NAME falls into.
|
|
22
|
+
* The STRING arm delegates to `parseDuration`, the one duration vocabulary, which refuses
|
|
23
|
+
* everything it cannot read (an overflowing amount included). What this adds is the NUMBER arm and
|
|
24
|
+
* the two names on it.
|
|
25
|
+
*
|
|
26
|
+
* The number arm passed straight through, and this is the conversion every scheduling decision in
|
|
27
|
+
* this package goes through: `step.sleep(Number(process.env.DELAY))` on an unset variable made
|
|
28
|
+
* `wakeAt = at + NaN`, and every `wakeAt <= now` against a `NaN` is false forever — a sleep that
|
|
29
|
+
* never ends, a retry ceiling that is not one, an event that never expires, with no error
|
|
30
|
+
* anywhere. `??` does not guard it, because `NaN` is not nullish.
|
|
31
|
+
*
|
|
32
|
+
* `finiteOption`, not `finiteCount`, and the floor is measured rather than assumed:
|
|
33
|
+
* `backoffDelayMs({ ...policy, maxDelay: -5 }, 1) === 0` is pinned by `retry-core-parity.test.ts`
|
|
34
|
+
* and `retry: { delay: 0 }` is what four of this package's own `.job.test.ts` suites configure, so
|
|
35
|
+
* a negative and a zero duration are shipped behaviour here. Only a non-finite one is refused. A
|
|
36
|
+
* caller needing a positive whole number narrows ON TOP — `job()` does exactly that for
|
|
37
|
+
* `stepTimeout` and `eventPoll`.
|
|
38
|
+
*
|
|
39
|
+
* `subject` and `option` are REQUIRED, and that is the enforcement rather than a convention: a new
|
|
40
|
+
* call site that does not name the key the app author actually wrote is `TS2554: Expected 3
|
|
41
|
+
* arguments` at the call. `@ultimat3/time`'s screen names the subject `toMs`, a framework internal
|
|
42
|
+
* that tells an app author nothing about which knob of theirs is wrong — the shape
|
|
43
|
+
* `@ultimat3/notify`'s `toDurationMs` already has. The name no longer has to carry `Finite`:
|
|
44
|
+
* `bun run finite-bounds` read a repair off the callee's NAME until 2026-08-26 — which is what
|
|
45
|
+
* forced this rename — and now reads `SCREENING_CALLEES` in `scripts/lib/finite-screens.ts`.
|
|
46
|
+
* Rename it freely; move the row with it.
|
|
47
|
+
*/
|
|
48
|
+
export function finiteDurationMs(duration: DurationInput, subject: string, option: string): number {
|
|
49
|
+
if (typeof duration === 'number') return finiteOption(subject, option, duration);
|
|
50
|
+
return parseDuration(duration);
|
|
23
51
|
}
|
package/src/events-pg.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { Clock } from '@ultimat3/core';
|
|
10
10
|
import { finiteOption, logger, renderThrowable, systemClock, uuid } from '@ultimat3/core';
|
|
11
11
|
import type { DurationInput } from './clock';
|
|
12
|
-
import {
|
|
12
|
+
import { finiteDurationMs, nowMs } from './clock';
|
|
13
13
|
import type { PgExecutor } from './driver-pg';
|
|
14
14
|
import {
|
|
15
15
|
SQL_EVENT_FIND,
|
|
@@ -49,10 +49,10 @@ export function createPgEventBus(options: PgEventBusOptions): EventBus {
|
|
|
49
49
|
// TWO screens, for the reason `events.ts` states: `defaultTtl` is the constructor's knob and
|
|
50
50
|
// `ttl` is the publish call's, so one screen over `ttl ?? defaultTtl` names the wrong one for
|
|
51
51
|
// whichever value actually arrived.
|
|
52
|
-
const defaultTtlMs =
|
|
52
|
+
const defaultTtlMs = finiteDurationMs(
|
|
53
|
+
options.defaultTtl ?? 604_800_000,
|
|
53
54
|
'the pg event bus',
|
|
54
55
|
'defaultTtl',
|
|
55
|
-
toMs(options.defaultTtl ?? 604_800_000),
|
|
56
56
|
);
|
|
57
57
|
const listLimit = finiteOption('the pg event bus', 'listLimit', options.listLimit ?? 1_000);
|
|
58
58
|
const exec = options.executor;
|
|
@@ -79,7 +79,7 @@ export function createPgEventBus(options: PgEventBusOptions): EventBus {
|
|
|
79
79
|
at +
|
|
80
80
|
(publishOptions.ttl === undefined
|
|
81
81
|
? defaultTtlMs
|
|
82
|
-
:
|
|
82
|
+
: finiteDurationMs(publishOptions.ttl, 'the pg event bus', 'ttl')),
|
|
83
83
|
...(publishOptions.correlationKey === undefined
|
|
84
84
|
? {}
|
|
85
85
|
: { correlationKey: publishOptions.correlationKey }),
|
package/src/events.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { Clock } from '@ultimat3/core';
|
|
6
6
|
import { finiteOption, logger, systemClock, uuid } from '@ultimat3/core';
|
|
7
7
|
import type { DurationInput } from './clock';
|
|
8
|
-
import {
|
|
8
|
+
import { finiteDurationMs, nowMs } from './clock';
|
|
9
9
|
import type { EventLookup } from './steps';
|
|
10
10
|
|
|
11
11
|
export interface JobEvent {
|
|
@@ -44,10 +44,10 @@ export function createMemoryEventBus(options: MemoryEventBusOptions = {}): Event
|
|
|
44
44
|
// told a caller who wrote `{ ttl: NaN }` to "pass a finite defaultTtl" — an instruction naming an
|
|
45
45
|
// option they never set, on a constructor usually in another file. `steps.ts` names `timeout` for
|
|
46
46
|
// the same value shape.
|
|
47
|
-
const defaultTtlMs =
|
|
47
|
+
const defaultTtlMs = finiteDurationMs(
|
|
48
|
+
options.defaultTtl ?? 604_800_000,
|
|
48
49
|
'the memory event bus',
|
|
49
50
|
'defaultTtl',
|
|
50
|
-
toMs(options.defaultTtl ?? 604_800_000),
|
|
51
51
|
);
|
|
52
52
|
const maxEvents = finiteOption('the memory event bus', 'maxEvents', options.maxEvents ?? 10_000);
|
|
53
53
|
const events = new Map<string, JobEvent>();
|
|
@@ -77,7 +77,7 @@ export function createMemoryEventBus(options: MemoryEventBusOptions = {}): Event
|
|
|
77
77
|
at +
|
|
78
78
|
(publishOptions.ttl === undefined
|
|
79
79
|
? defaultTtlMs
|
|
80
|
-
:
|
|
80
|
+
: finiteDurationMs(publishOptions.ttl, 'the memory event bus', 'ttl')),
|
|
81
81
|
...(publishOptions.correlationKey === undefined
|
|
82
82
|
? {}
|
|
83
83
|
: { correlationKey: publishOptions.correlationKey }),
|
package/src/job.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { assert } from '@ultimat3/core';
|
|
|
12
12
|
import type { StandardSchemaV1 } from '@ultimat3/schema';
|
|
13
13
|
import { parse } from '@ultimat3/schema';
|
|
14
14
|
import type { DurationInput } from './clock';
|
|
15
|
-
import {
|
|
15
|
+
import { finiteDurationMs } from './clock';
|
|
16
16
|
import type { JobDescriptor } from './describe';
|
|
17
17
|
import { describeJob } from './describe';
|
|
18
18
|
import type { EnqueueResult } from './driver';
|
|
@@ -204,8 +204,13 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
|
204
204
|
);
|
|
205
205
|
|
|
206
206
|
const stepTimeoutMs =
|
|
207
|
-
definition.stepTimeout === undefined
|
|
208
|
-
|
|
207
|
+
definition.stepTimeout === undefined
|
|
208
|
+
? undefined
|
|
209
|
+
: finiteDurationMs(definition.stepTimeout, `job "${name}"`, 'stepTimeout');
|
|
210
|
+
const eventPollMs =
|
|
211
|
+
definition.eventPoll === undefined
|
|
212
|
+
? undefined
|
|
213
|
+
: finiteDurationMs(definition.eventPoll, `job "${name}"`, 'eventPoll');
|
|
209
214
|
// `withStepTimeout` reads `<= 0` as "no ceiling at all" and a poll of zero is a suspension that
|
|
210
215
|
// resumes immediately, forever. Both are an author who asked for a limit and got the opposite,
|
|
211
216
|
// so they are refused where they are written — the same answer `concurrency: 0` gets.
|
|
@@ -231,7 +236,10 @@ export function job<I>(definition: JobDefinition<I>): JobHandle<I> {
|
|
|
231
236
|
queue: definition.queue ?? DEFAULT_QUEUE,
|
|
232
237
|
retry: { ...DEFAULT_RETRY, ...definition.retry },
|
|
233
238
|
concurrency: definition.concurrency,
|
|
234
|
-
timeoutMs:
|
|
239
|
+
timeoutMs:
|
|
240
|
+
definition.timeout === undefined
|
|
241
|
+
? undefined
|
|
242
|
+
: finiteDurationMs(definition.timeout, `job "${name}"`, 'timeout'),
|
|
235
243
|
stepTimeoutMs,
|
|
236
244
|
eventPollMs,
|
|
237
245
|
input: definition.input,
|
package/src/outbox.ts
CHANGED
|
@@ -205,7 +205,7 @@ export function createMemoryOutboxStore(options: MemoryOutboxOptions = {}): Memo
|
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
export interface EnqueueOptions {
|
|
208
|
-
/** Epoch ms, or a delay
|
|
208
|
+
/** Epoch ms, or a delay off the caller's own clock: `clock.now().getTime() + toMs('5m')`. */
|
|
209
209
|
readonly runAt?: number;
|
|
210
210
|
readonly tenantId?: string;
|
|
211
211
|
readonly queue?: string;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { ErrorRetry } from '@ultimat3/core';
|
|
6
6
|
import { classifyThrown, statedDelayMs } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
7
|
+
import { finiteDurationMs } from './clock';
|
|
8
8
|
import type { Random, RetryDecision, RetryPolicy } from './retry';
|
|
9
9
|
import { DEFAULT_RETRY, nextRetry } from './retry';
|
|
10
10
|
|
|
@@ -67,7 +67,7 @@ export function nextRetryForError(
|
|
|
67
67
|
if (stated === undefined) return { ...decision, stoppedBy: undefined, classification };
|
|
68
68
|
// Clamped by the policy's own ceiling, which is what `maxDelay` is for: a responder naming a
|
|
69
69
|
// day is still a responder this deployment has not agreed to wait a day for.
|
|
70
|
-
const cap =
|
|
70
|
+
const cap = finiteDurationMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay, 'retry', 'maxDelay');
|
|
71
71
|
return { ...decision, delayMs: Math.min(stated, cap), stoppedBy: undefined, classification };
|
|
72
72
|
}
|
|
73
73
|
|
package/src/retry.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { BackoffCurve, Random } from '@ultimat3/core';
|
|
7
7
|
import { backoffDelay } from '@ultimat3/core';
|
|
8
8
|
import type { DurationInput } from './clock';
|
|
9
|
-
import {
|
|
9
|
+
import { finiteDurationMs } from './clock';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* This package's name for core's curve, and an ALIAS rather than a second union: two spellings of
|
|
@@ -60,8 +60,8 @@ export type { Random };
|
|
|
60
60
|
export function backoffDelayMs(policy: RetryPolicy, attempt: number, random?: Random): number {
|
|
61
61
|
return backoffDelay({
|
|
62
62
|
attempt,
|
|
63
|
-
base:
|
|
64
|
-
max:
|
|
63
|
+
base: finiteDurationMs(policy.delay ?? DEFAULT_RETRY.delay, 'retry', 'delay'),
|
|
64
|
+
max: finiteDurationMs(policy.maxDelay ?? DEFAULT_RETRY.maxDelay, 'retry', 'maxDelay'),
|
|
65
65
|
curve: policy.backoff ?? DEFAULT_RETRY.backoff,
|
|
66
66
|
// EQUAL, not full: a job that has already failed twice must not be handed a near-zero wait,
|
|
67
67
|
// and this package's `jitter: true` has meant "half fixed, half random" since it shipped.
|
package/src/steps.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type { Clock } from '@ultimat3/core';
|
|
10
10
|
import { finiteOption, logger, renderThrowable } from '@ultimat3/core';
|
|
11
11
|
import type { DurationInput } from './clock';
|
|
12
|
-
import {
|
|
12
|
+
import { finiteDurationMs, nowMs } from './clock';
|
|
13
13
|
import { JobAbortedError, JobTimeoutError, StepDuplicateError } from './errors';
|
|
14
14
|
import { createRunSignal } from './run-signal';
|
|
15
15
|
|
|
@@ -361,7 +361,8 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
361
361
|
throw new StepSuspension({ step: name, resumeAt: existing.wakeAt, reason: 'sleep' });
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
const wakeAt =
|
|
364
|
+
const wakeAt =
|
|
365
|
+
at + finiteDurationMs(duration, `job "${jobName}" step.sleep("${name}")`, 'duration');
|
|
365
366
|
await put({
|
|
366
367
|
runId,
|
|
367
368
|
name,
|
|
@@ -389,7 +390,7 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
389
390
|
const startedAt = existing?.startedAt ?? at;
|
|
390
391
|
const deadline =
|
|
391
392
|
startedAt +
|
|
392
|
-
|
|
393
|
+
finiteDurationMs(waitOptions.timeout ?? 86_400_000, 'step.waitForEvent', 'timeout');
|
|
393
394
|
const correlationKey = waitOptions.match;
|
|
394
395
|
|
|
395
396
|
const hit = await options.events?.find(event, correlationKey, startedAt);
|
|
@@ -413,10 +414,10 @@ export function createStepRunner(options: StepRunnerOptions): StepRunner {
|
|
|
413
414
|
throw new JobTimeoutError({
|
|
414
415
|
job: jobName,
|
|
415
416
|
step: name,
|
|
416
|
-
timeoutMs:
|
|
417
|
+
timeoutMs: finiteDurationMs(
|
|
418
|
+
waitOptions.timeout ?? 86_400_000,
|
|
417
419
|
'step.waitForEvent',
|
|
418
420
|
'timeout',
|
|
419
|
-
toMs(waitOptions.timeout ?? 86_400_000),
|
|
420
421
|
),
|
|
421
422
|
});
|
|
422
423
|
}
|