@ultimat3/jobs 29.3.2 → 30.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 +68 -89
- package/README.md +304 -10
- package/package.json +18 -6
- package/src/backfill-definition.ts +184 -0
- package/src/backfill-errors.ts +18 -2
- package/src/backfill-pass.ts +43 -40
- package/src/backfill-pending.ts +40 -17
- package/src/backfill-reader.ts +94 -0
- package/src/backfill-registry.ts +5 -0
- package/src/backfill-scope.ts +1 -2
- package/src/backfill-source.ts +113 -0
- package/src/backfill.ts +54 -135
- package/src/consumer-errors.ts +42 -0
- package/src/consumer-guard.ts +19 -0
- package/src/consumer-loop.ts +171 -0
- package/src/consumer-registry.ts +103 -0
- package/src/consumer-types.ts +81 -0
- package/src/consumer.ts +147 -0
- package/src/driver-memory-operator.ts +25 -2
- package/src/driver-memory.ts +35 -1
- package/src/driver-pg-claim-sql.ts +128 -0
- package/src/driver-pg-ddl.ts +11 -2
- package/src/driver-pg-demand-sql.ts +27 -0
- package/src/driver-pg-enqueue-many.ts +153 -0
- package/src/driver-pg-event-wake-sql.ts +51 -0
- package/src/driver-pg-operator-sql.ts +34 -6
- package/src/driver-pg-operator.ts +36 -2
- package/src/driver-pg-outbox-sql.ts +34 -0
- package/src/driver-pg-sql.ts +24 -91
- package/src/driver-pg.ts +17 -1
- package/src/driver-redis-refusal.ts +25 -0
- package/src/driver-redis-scripts.ts +79 -30
- package/src/driver-redis.ts +70 -42
- package/src/driver.ts +38 -1
- package/src/enqueue-record.ts +137 -0
- package/src/error-codes.ts +233 -0
- package/src/errors-concurrency.ts +2 -2
- package/src/errors-declaration.ts +15 -2
- package/src/errors-operator.ts +1 -1
- package/src/errors-requeue.ts +1 -1
- package/src/errors-saga.ts +87 -0
- package/src/errors-steps.ts +35 -0
- package/src/errors-tenant.ts +1 -1
- package/src/errors.ts +25 -165
- package/src/events-pg.ts +20 -8
- package/src/events.ts +14 -3
- package/src/execute-ctx.ts +51 -0
- package/src/execute-unwind.ts +191 -0
- package/src/execute.ts +50 -51
- package/src/export-errors.ts +1 -1
- package/src/failure-row.ts +43 -0
- package/src/import-errors.ts +102 -0
- package/src/import-format.ts +232 -0
- package/src/import-pass.ts +255 -0
- package/src/import-source.ts +49 -0
- package/src/import.ts +175 -0
- package/src/index.ts +163 -7
- package/src/inspect-operator.ts +24 -3
- package/src/inspect.ts +17 -4
- package/src/introspection.ts +24 -1
- package/src/job-enqueue-many.ts +108 -0
- package/src/job-types.ts +229 -0
- package/src/job.ts +19 -208
- package/src/metrics.ts +10 -0
- package/src/outbox-pg.ts +35 -0
- package/src/outbox-relay.ts +58 -19
- package/src/outbox.ts +65 -73
- package/src/purge-entities.ts +92 -0
- package/src/purge.ts +10 -2
- package/src/queue-wake.ts +1 -1
- package/src/refresh-view.ts +76 -0
- package/src/retry-classification.ts +27 -8
- package/src/retry.ts +26 -0
- package/src/rollup-errors.ts +27 -0
- package/src/rollup.ts +190 -0
- package/src/run-deadline.ts +11 -7
- package/src/scheduler.ts +2 -1
- package/src/settled.ts +56 -15
- package/src/step-resolve.ts +37 -0
- package/src/steps-api.ts +111 -0
- package/src/steps-core.ts +26 -0
- package/src/steps-output.ts +142 -0
- package/src/steps-store.ts +68 -0
- package/src/steps-suspension.ts +51 -0
- package/src/steps-timeout.ts +10 -5
- package/src/steps-unwind.ts +94 -0
- package/src/steps-wait.ts +163 -0
- package/src/steps.ts +174 -210
- package/src/subject-erase-pass.ts +134 -0
- package/src/subject-errors.ts +48 -0
- package/src/subject-export-pass.ts +228 -0
- package/src/subject-jobs.ts +121 -0
- package/src/subject-plan.ts +287 -0
- package/src/subject-select.ts +106 -0
- package/src/subject.ts +119 -0
- package/src/tenant.ts +69 -1
- package/src/type-pins.ts +90 -0
- package/src/webhook-errors.ts +1 -1
- package/src/webhook-receiver-errors.ts +59 -0
- package/src/webhook-receiver-route.ts +127 -0
- package/src/webhook-receiver-types.ts +97 -0
- package/src/webhook-receiver.ts +132 -0
- package/src/webhook-target.ts +20 -43
- package/src/webhook.ts +7 -4
- package/src/worker-queue-demand.ts +57 -0
- package/src/worker-queue-depth.ts +15 -3
- package/src/worker-registry.ts +4 -1
- package/src/worker-types.ts +2 -0
- package/src/worker.ts +2 -2
package/src/events-pg.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { PgExecutor } from '@ultimat3/core';
|
|
|
10
10
|
import { finiteOption, logger, uuidV7 } from '@ultimat3/core';
|
|
11
11
|
import type { DurationInput } from './clock';
|
|
12
12
|
import { finiteDurationMs } from './clock';
|
|
13
|
+
import { SQL_EVENT_PUBLISH_WAKE } from './driver-pg-event-wake-sql';
|
|
13
14
|
import {
|
|
14
15
|
SQL_EVENT_FIND,
|
|
15
16
|
SQL_EVENT_LIST,
|
|
@@ -38,6 +39,13 @@ export interface PostgresEventBusOptions {
|
|
|
38
39
|
readonly defaultTtl?: DurationInput;
|
|
39
40
|
/** Rows returned by `list()`. Diagnostics only; `find()` is what a step uses. */
|
|
40
41
|
readonly listLimit?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Publish with `SQL_EVENT_PUBLISH_WAKE`: the runs parked on the event are made due in the
|
|
44
|
+
* publishing statement, and their queues told at commit. Only over the database the queue's
|
|
45
|
+
* `x_jobs` lives in, so boot sets it for `postgresJobDriver` alone: another driver keeps no
|
|
46
|
+
* rows here, a wake would find none, and its waiters keep the 30 s poll. Default `false`.
|
|
47
|
+
*/
|
|
48
|
+
readonly wakes?: boolean;
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
export function postgresEventBus(options: PostgresEventBusOptions): EventBus {
|
|
@@ -51,6 +59,8 @@ export function postgresEventBus(options: PostgresEventBusOptions): EventBus {
|
|
|
51
59
|
);
|
|
52
60
|
const listLimit = finiteOption('the pg event bus', 'listLimit', options.listLimit ?? 1_000);
|
|
53
61
|
const exec = options.executor;
|
|
62
|
+
const wakes = options.wakes === true;
|
|
63
|
+
const publishStatement = wakes ? SQL_EVENT_PUBLISH_WAKE : SQL_EVENT_PUBLISH;
|
|
54
64
|
|
|
55
65
|
// Awaited and never fired-and-forgotten: it was `void`ed with nothing calling it, so the table
|
|
56
66
|
// only grew. A failure REJECTS — the caller is the retention sweep's step, which retries.
|
|
@@ -61,6 +71,7 @@ export function postgresEventBus(options: PostgresEventBusOptions): EventBus {
|
|
|
61
71
|
|
|
62
72
|
return {
|
|
63
73
|
stored: true,
|
|
74
|
+
wakes,
|
|
64
75
|
async now() {
|
|
65
76
|
const rows = await exec.query<{ now: number | string }>(SQL_EVENT_NOW, []);
|
|
66
77
|
return Number(rows[0]?.now);
|
|
@@ -75,7 +86,7 @@ export function postgresEventBus(options: PostgresEventBusOptions): EventBus {
|
|
|
75
86
|
const rows = await exec.query<{
|
|
76
87
|
published_at: number | string;
|
|
77
88
|
expires_at: number | string;
|
|
78
|
-
}>(
|
|
89
|
+
}>(publishStatement, [
|
|
79
90
|
id,
|
|
80
91
|
name,
|
|
81
92
|
JSON.stringify(payload ?? null),
|
|
@@ -99,16 +110,17 @@ export function postgresEventBus(options: PostgresEventBusOptions): EventBus {
|
|
|
99
110
|
};
|
|
100
111
|
},
|
|
101
112
|
|
|
102
|
-
/** Earliest match at or after `afterMs`, so a
|
|
103
|
-
async find(name, correlationKey, afterMs) {
|
|
104
|
-
const rows = await exec.query<{
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
113
|
+
/** Earliest match at or after `afterMs` and past `afterId`, so a step consumes in order. */
|
|
114
|
+
async find(name, correlationKey, afterMs, afterId) {
|
|
115
|
+
const rows = await exec.query<{
|
|
116
|
+
id: string;
|
|
117
|
+
payload: unknown;
|
|
118
|
+
published_at: number | string;
|
|
119
|
+
}>(SQL_EVENT_FIND, [name, correlationKey ?? null, afterMs, afterId ?? null]);
|
|
108
120
|
const row = rows[0];
|
|
109
121
|
return row === undefined
|
|
110
122
|
? undefined
|
|
111
|
-
: { payload: row.payload, publishedAt: Number(row.published_at) };
|
|
123
|
+
: { id: row.id, payload: row.payload, publishedAt: Number(row.published_at) };
|
|
112
124
|
},
|
|
113
125
|
|
|
114
126
|
async list(name) {
|
package/src/events.ts
CHANGED
|
@@ -111,12 +111,21 @@ export function memoryEventBus(options: MemoryEventBusOptions = {}): EventBus {
|
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
113
|
* Earliest matching event at or after `afterMs`, so a resumed step consumes events in
|
|
114
|
-
* publication order rather than jumping to the newest one.
|
|
114
|
+
* publication order rather than jumping to the newest one. Ties in one ms go to the earlier
|
|
115
|
+
* publish (the Map's insertion order), and `afterId` seeks past that event in the same order.
|
|
115
116
|
*/
|
|
116
|
-
find(name, correlationKey, afterMs) {
|
|
117
|
+
find(name, correlationKey, afterMs, afterId) {
|
|
117
118
|
const at = nowMs(clock);
|
|
119
|
+
const cursor = afterId === undefined ? undefined : events.get(afterId);
|
|
120
|
+
// Until the cursor's own event is passed, an event in its millisecond came before it.
|
|
121
|
+
let passed = cursor === undefined;
|
|
118
122
|
let best: JobEvent | undefined;
|
|
119
123
|
for (const event of events.values()) {
|
|
124
|
+
if (event === cursor) {
|
|
125
|
+
passed = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!passed && event.publishedAt <= (cursor?.publishedAt ?? 0)) continue;
|
|
120
129
|
if (event.name !== name) continue;
|
|
121
130
|
if (event.expiresAt <= at) continue;
|
|
122
131
|
if (event.publishedAt < afterMs) continue;
|
|
@@ -124,7 +133,9 @@ export function memoryEventBus(options: MemoryEventBusOptions = {}): EventBus {
|
|
|
124
133
|
if (best === undefined || event.publishedAt < best.publishedAt) best = event;
|
|
125
134
|
}
|
|
126
135
|
return Promise.resolve(
|
|
127
|
-
best === undefined
|
|
136
|
+
best === undefined
|
|
137
|
+
? undefined
|
|
138
|
+
: { id: best.id, payload: best.payload, publishedAt: best.publishedAt },
|
|
128
139
|
);
|
|
129
140
|
},
|
|
130
141
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// What `executeJob` reads off the caller's context, defensively — a context can arrive across a cast
|
|
2
|
+
// (`asCtx`, a test's `{} as Ctx`, an app's `WorkerOptions.context()`) without the fields the type
|
|
3
|
+
// promises — and whether the worker's drain ended the run. Split from `execute.ts` at its ceiling.
|
|
4
|
+
|
|
5
|
+
import type { Actor, Ctx, ServiceBag } from '@ultimat3/core';
|
|
6
|
+
import { anonymousActor, isUltimateError } from '@ultimat3/core';
|
|
7
|
+
|
|
8
|
+
/** Stands in for a caller with nothing to cancel, so the composition below has one shape. */
|
|
9
|
+
const NEVER_ABORTED = new AbortController().signal;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `Ctx.signal` is non-optional in the type and `ctxOf` always sets it — but a context can
|
|
13
|
+
* still arrive across a cast (`@ultimat3/http`'s `asCtx`, a test's `{} as Ctx`) without one, and
|
|
14
|
+
* a job that crashed on a missing field would be a far worse answer than a job with no caller to
|
|
15
|
+
* follow. Read it, do not assume it.
|
|
16
|
+
*/
|
|
17
|
+
export function callerSignal(ctx: Ctx): AbortSignal {
|
|
18
|
+
return ctx.signal instanceof AbortSignal ? ctx.signal : NEVER_ABORTED;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The same defensive read, for the same reason: `Ctx.actor` is non-optional in the type and
|
|
23
|
+
* `ctxOf` always sets it, but `WorkerOptions.context()` is the app's own function and a
|
|
24
|
+
* cast context (`{} as Ctx`) reaches here without one. Anonymous is the honest stand-in — it
|
|
25
|
+
* carries no org, so the job's declared tenant is the only thing that can put one on the run.
|
|
26
|
+
*/
|
|
27
|
+
export function callerActor(ctx: Ctx): Actor {
|
|
28
|
+
const actor: Actor | undefined = ctx.actor;
|
|
29
|
+
return actor === undefined ? anonymousActor() : actor;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const NO_SERVICES: ServiceBag = Object.freeze({});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The same defensive read a third time, and this one is load-bearing rather than merely kind:
|
|
36
|
+
* `withChildContext` ITERATES the parent's bag to decide what carries forward, so a cast context
|
|
37
|
+
* with no `services` would fail the run with a `TypeError` before the body ever started.
|
|
38
|
+
*/
|
|
39
|
+
export function callerServices(ctx: Ctx): ServiceBag {
|
|
40
|
+
const services: ServiceBag | undefined = ctx.services;
|
|
41
|
+
return services === undefined ? NO_SERVICES : services;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The run was cancelled by the worker's drain: the code `JobDrainedError` carries, read off the
|
|
46
|
+
* signal. The FIRST reason wins on a controller, so a timeout that fired before the drain still
|
|
47
|
+
* reports as a timeout — the drain only claims an attempt it ended.
|
|
48
|
+
*/
|
|
49
|
+
export function drainedBy(signal: AbortSignal): boolean {
|
|
50
|
+
return signal.aborted && isUltimateError(signal.reason) && signal.reason.code === 'X_DRAINING';
|
|
51
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// A `compensates: true` run that failed for good, settled: its compensators run, last-in first-out,
|
|
2
|
+
// and only then is the row dead-lettered with the failure it unwound from. A compensator that fails
|
|
3
|
+
// hands the row back while the unwind has attempts left (`isFinalUnwindAttempt`); past them, or on
|
|
4
|
+
// an unknown `once` step, the row is dead with `X_JOB_COMPENSATION_FAILED`. Split from `execute.ts`.
|
|
5
|
+
|
|
6
|
+
import type { Clock, Ctx } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
failureStack,
|
|
9
|
+
isUltimateError,
|
|
10
|
+
logger,
|
|
11
|
+
renderThrowable,
|
|
12
|
+
reportError,
|
|
13
|
+
} from '@ultimat3/core';
|
|
14
|
+
import { nowMs } from './clock';
|
|
15
|
+
import type { ClaimedJob, JobDriver, SettleBy } from './driver';
|
|
16
|
+
import { LEASE_LAPSED_FINAL_ATTEMPT } from './driver';
|
|
17
|
+
import type { JobExecution, JobOutcome } from './execute';
|
|
18
|
+
import type { AnyJobHandle } from './job';
|
|
19
|
+
import { backoffDelayMs, isFinalUnwindAttempt } from './retry';
|
|
20
|
+
import type { JobStopReason } from './retry-classification';
|
|
21
|
+
import { failureForRow } from './retry-classification';
|
|
22
|
+
import type { AnnounceOptions } from './settled';
|
|
23
|
+
import { announceSettled, settledCode } from './settled';
|
|
24
|
+
import type { StepRunner } from './steps';
|
|
25
|
+
|
|
26
|
+
/** What the attempt that failed for good knows about its own failure. Absent: already unwinding. */
|
|
27
|
+
export interface UnwindFailure {
|
|
28
|
+
readonly thrown: unknown;
|
|
29
|
+
/** As the row records it (`recordedFailure`). */
|
|
30
|
+
readonly recorded: string;
|
|
31
|
+
readonly stop: JobStopReason | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UnwindSettleOptions {
|
|
35
|
+
readonly driver: JobDriver;
|
|
36
|
+
readonly claimed: ClaimedJob;
|
|
37
|
+
readonly handle: AnyJobHandle;
|
|
38
|
+
readonly runner: StepRunner;
|
|
39
|
+
readonly ctx: Ctx;
|
|
40
|
+
readonly clock?: Clock;
|
|
41
|
+
/** When the attempt began, on `clock`. */
|
|
42
|
+
readonly startedAt: number;
|
|
43
|
+
readonly by: () => SettleBy;
|
|
44
|
+
readonly landed: (did: boolean, settling: string) => boolean;
|
|
45
|
+
/** `executeJob`'s: the run's steps and replay trace onto the execution it answers. */
|
|
46
|
+
readonly settle: (execution: JobExecution) => Promise<JobExecution>;
|
|
47
|
+
/** The body's scope and parsed payload, for `onSettled`. */
|
|
48
|
+
readonly run: AnnounceOptions['run'];
|
|
49
|
+
/** The worker's drain ended this attempt — the compensator's failure is not its own. */
|
|
50
|
+
readonly drained: () => boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Ending = Pick<JobExecution, 'error' | 'resumeAt' | 'stopReason'>;
|
|
54
|
+
|
|
55
|
+
/** The execution this attempt answers, and `onSettled` after a settle that landed. */
|
|
56
|
+
function endings(options: UnwindSettleOptions) {
|
|
57
|
+
const { handle, claimed } = options;
|
|
58
|
+
return {
|
|
59
|
+
finish: (outcome: JobOutcome, extra: Ending): Promise<JobExecution> =>
|
|
60
|
+
options.settle({
|
|
61
|
+
outcome,
|
|
62
|
+
jobId: claimed.id,
|
|
63
|
+
job: handle.name,
|
|
64
|
+
attempt: claimed.attempt,
|
|
65
|
+
durationMs: nowMs(options.clock) - options.startedAt,
|
|
66
|
+
...extra,
|
|
67
|
+
steps: [],
|
|
68
|
+
replayed: [],
|
|
69
|
+
}),
|
|
70
|
+
announce: (
|
|
71
|
+
outcome: 'dead-lettered' | 'dropped',
|
|
72
|
+
error: string,
|
|
73
|
+
code: string | undefined,
|
|
74
|
+
): Promise<void> =>
|
|
75
|
+
announceSettled({
|
|
76
|
+
handle,
|
|
77
|
+
claimed,
|
|
78
|
+
ctx: options.ctx,
|
|
79
|
+
...(options.run === undefined ? {} : { run: options.run }),
|
|
80
|
+
settlement: { outcome, error, code },
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** An unknown `once` step stops the unwind on any attempt: only a person can answer it. */
|
|
86
|
+
const isUnknownOutcome = (thrown: unknown): boolean =>
|
|
87
|
+
isUltimateError(thrown) &&
|
|
88
|
+
thrown.code === 'X_JOB_COMPENSATION_FAILED' &&
|
|
89
|
+
thrown.meta?.['outcome'] === 'unknown';
|
|
90
|
+
|
|
91
|
+
export async function unwindAndSettle(
|
|
92
|
+
options: UnwindSettleOptions,
|
|
93
|
+
failure: UnwindFailure | undefined,
|
|
94
|
+
): Promise<JobExecution> {
|
|
95
|
+
const { driver, claimed, handle, runner, by, landed } = options;
|
|
96
|
+
const { finish, announce } = endings(options);
|
|
97
|
+
const policy = handle.retry;
|
|
98
|
+
const keep = policy.deadLetter ?? true;
|
|
99
|
+
const settledAs = keep ? 'dead-lettered' : 'dropped';
|
|
100
|
+
let original: string;
|
|
101
|
+
try {
|
|
102
|
+
// A crash on the final attempt left no failure to read: the claim re-offered the row.
|
|
103
|
+
original = await runner.unwind(failure?.recorded ?? LEASE_LAPSED_FINAL_ATTEMPT);
|
|
104
|
+
} catch (thrown) {
|
|
105
|
+
const message = renderThrowable(thrown);
|
|
106
|
+
if (options.drained()) {
|
|
107
|
+
landed(
|
|
108
|
+
await driver.nack(claimed.id, { ...by(), delayMs: 0, countsAsAttempt: false }),
|
|
109
|
+
'interrupted',
|
|
110
|
+
);
|
|
111
|
+
return finish('interrupted', { error: message });
|
|
112
|
+
}
|
|
113
|
+
if (!isUnknownOutcome(thrown) && !isFinalUnwindAttempt(policy, claimed.attempt)) {
|
|
114
|
+
const delayMs = backoffDelayMs(policy, claimed.attempt);
|
|
115
|
+
// The failure the run unwinds from stays `last_error`: no error here keeps the row's own.
|
|
116
|
+
landed(
|
|
117
|
+
await driver.nack(claimed.id, {
|
|
118
|
+
...by(),
|
|
119
|
+
delayMs,
|
|
120
|
+
...(failure === undefined ? {} : { error: failure.recorded }),
|
|
121
|
+
countsAsAttempt: true,
|
|
122
|
+
}),
|
|
123
|
+
'retried',
|
|
124
|
+
);
|
|
125
|
+
logger.warn('jobs.unwind.retried', {
|
|
126
|
+
job: handle.name,
|
|
127
|
+
jobId: claimed.id,
|
|
128
|
+
attempt: claimed.attempt,
|
|
129
|
+
error: message,
|
|
130
|
+
});
|
|
131
|
+
return finish('retried', {
|
|
132
|
+
error: message,
|
|
133
|
+
resumeAt: nowMs(options.clock) + delayMs,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const recorded = failureForRow(thrown);
|
|
137
|
+
const stack = failureStack(thrown);
|
|
138
|
+
const settled = landed(
|
|
139
|
+
await driver.nack(claimed.id, {
|
|
140
|
+
...by(),
|
|
141
|
+
delayMs: 0,
|
|
142
|
+
error: recorded,
|
|
143
|
+
...(stack === undefined ? {} : { stack }),
|
|
144
|
+
countsAsAttempt: true,
|
|
145
|
+
deadLetter: keep,
|
|
146
|
+
fail: !keep,
|
|
147
|
+
}),
|
|
148
|
+
settledAs,
|
|
149
|
+
);
|
|
150
|
+
logger.warn('jobs.unwind.failed', { job: handle.name, jobId: claimed.id, error: message });
|
|
151
|
+
reportError(thrown, {
|
|
152
|
+
source: 'job',
|
|
153
|
+
severity: 'error',
|
|
154
|
+
scope: { operation: handle.name, extra: { jobId: claimed.id, runId: claimed.runId } },
|
|
155
|
+
});
|
|
156
|
+
if (settled) await announce(settledAs, recorded, settledCode(thrown));
|
|
157
|
+
return finish(settledAs, { error: message, stopReason: 'terminal' });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const stack = failure === undefined ? undefined : failureStack(failure.thrown);
|
|
161
|
+
const settled = landed(
|
|
162
|
+
await driver.nack(claimed.id, {
|
|
163
|
+
...by(),
|
|
164
|
+
delayMs: 0,
|
|
165
|
+
error: original,
|
|
166
|
+
...(stack === undefined ? {} : { stack }),
|
|
167
|
+
countsAsAttempt: true,
|
|
168
|
+
deadLetter: keep,
|
|
169
|
+
fail: !keep,
|
|
170
|
+
}),
|
|
171
|
+
settledAs,
|
|
172
|
+
);
|
|
173
|
+
logger.warn('jobs.unwind.completed', {
|
|
174
|
+
job: handle.name,
|
|
175
|
+
jobId: claimed.id,
|
|
176
|
+
attempt: claimed.attempt,
|
|
177
|
+
error: original,
|
|
178
|
+
});
|
|
179
|
+
if (failure !== undefined) {
|
|
180
|
+
reportError(failure.thrown, {
|
|
181
|
+
source: 'job',
|
|
182
|
+
severity: 'error',
|
|
183
|
+
scope: { operation: handle.name, extra: { jobId: claimed.id, runId: claimed.runId } },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
if (settled) await announce(settledAs, original, settledCode(failure?.thrown));
|
|
187
|
+
return finish(settledAs, {
|
|
188
|
+
error: original,
|
|
189
|
+
stopReason: failure?.stop ?? 'attempts-exhausted',
|
|
190
|
+
});
|
|
191
|
+
}
|
package/src/execute.ts
CHANGED
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
// a deadline here means CANCEL: the nack that follows makes the job claimable again, so a body
|
|
6
6
|
// still running past it would be a second copy of one job, racing the attempt that replaced it.
|
|
7
7
|
|
|
8
|
-
import type {
|
|
8
|
+
import type { Clock, Ctx } from '@ultimat3/core';
|
|
9
9
|
import {
|
|
10
|
-
anonymousActor,
|
|
11
10
|
failureStack,
|
|
12
|
-
isUltimateError,
|
|
13
11
|
logger,
|
|
14
12
|
renderThrowable,
|
|
15
13
|
reportError,
|
|
@@ -23,9 +21,12 @@ import type { ClaimedJob, JobDriver, SettleBy } from './driver';
|
|
|
23
21
|
import { claimOf } from './driver';
|
|
24
22
|
import { JobAbortedError } from './errors';
|
|
25
23
|
import { eventBus } from './events';
|
|
24
|
+
import { callerActor, callerServices, callerSignal, drainedBy } from './execute-ctx';
|
|
25
|
+
import type { UnwindSettleOptions } from './execute-unwind';
|
|
26
|
+
import { unwindAndSettle } from './execute-unwind';
|
|
26
27
|
import type { AnyJobHandle } from './job';
|
|
27
28
|
import { createProgressReporter } from './progress';
|
|
28
|
-
import { isFinalAttempt } from './retry';
|
|
29
|
+
import { isFinalAttempt, isUnwindAttempt } from './retry';
|
|
29
30
|
import type { JobStopReason } from './retry-classification';
|
|
30
31
|
import {
|
|
31
32
|
failureForRow,
|
|
@@ -38,7 +39,8 @@ import { createRunSignal } from './run-signal';
|
|
|
38
39
|
import { announceSettled, settledCode } from './settled';
|
|
39
40
|
import type { EventLookup, StepRecord } from './steps';
|
|
40
41
|
import { isStepSuspension, stepRunner } from './steps';
|
|
41
|
-
import {
|
|
42
|
+
import { isUnwindReached } from './steps-suspension';
|
|
43
|
+
import { jobRunActor, refuseRunTenant } from './tenant';
|
|
42
44
|
|
|
43
45
|
/**
|
|
44
46
|
* How one attempt ended. `interrupted` is the worker's drain cutting the attempt short: the job is
|
|
@@ -59,42 +61,6 @@ export type JobOutcome =
|
|
|
59
61
|
| 'interrupted'
|
|
60
62
|
| 'refused';
|
|
61
63
|
|
|
62
|
-
/** Stands in for a caller with nothing to cancel, so the composition below has one shape. */
|
|
63
|
-
const NEVER_ABORTED = new AbortController().signal;
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* `Ctx.signal` is non-optional in the type and `ctxOf` always sets it — but a context can
|
|
67
|
-
* still arrive across a cast (`@ultimat3/http`'s `asCtx`, a test's `{} as Ctx`) without one, and
|
|
68
|
-
* a job that crashed on a missing field would be a far worse answer than a job with no caller to
|
|
69
|
-
* follow. Read it, do not assume it.
|
|
70
|
-
*/
|
|
71
|
-
function callerSignal(ctx: Ctx): AbortSignal {
|
|
72
|
-
return ctx.signal instanceof AbortSignal ? ctx.signal : NEVER_ABORTED;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* The same defensive read, for the same reason: `Ctx.actor` is non-optional in the type and
|
|
77
|
-
* `ctxOf` always sets it, but `WorkerOptions.context()` is the app's own function and a
|
|
78
|
-
* cast context (`{} as Ctx`) reaches here without one. Anonymous is the honest stand-in — it
|
|
79
|
-
* carries no org, so the job's declared tenant is the only thing that can put one on the run.
|
|
80
|
-
*/
|
|
81
|
-
function callerActor(ctx: Ctx): Actor {
|
|
82
|
-
const actor: Actor | undefined = ctx.actor;
|
|
83
|
-
return actor === undefined ? anonymousActor() : actor;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const NO_SERVICES: ServiceBag = Object.freeze({});
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* The same defensive read a third time, and this one is load-bearing rather than merely kind:
|
|
90
|
-
* `withChildContext` ITERATES the parent's bag to decide what carries forward, so a cast context
|
|
91
|
-
* with no `services` would fail the run with a `TypeError` before the body ever started.
|
|
92
|
-
*/
|
|
93
|
-
function callerServices(ctx: Ctx): ServiceBag {
|
|
94
|
-
const services: ServiceBag | undefined = ctx.services;
|
|
95
|
-
return services === undefined ? NO_SERVICES : services;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
64
|
export interface JobExecution {
|
|
99
65
|
readonly outcome: JobOutcome;
|
|
100
66
|
readonly jobId: string;
|
|
@@ -167,6 +133,10 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
167
133
|
// no `job()` can reach — which both of these were until 2026-08.
|
|
168
134
|
...(handle.stepTimeoutMs === undefined ? {} : { stepTimeoutMs: handle.stepTimeoutMs }),
|
|
169
135
|
...(handle.eventPollMs === undefined ? {} : { eventPollMs: handle.eventPollMs }),
|
|
136
|
+
// Compensation is the job's declaration; an attempt past the policy's last is the claim
|
|
137
|
+
// re-offering a final attempt that crashed, and unwinds whatever its steps say.
|
|
138
|
+
compensates: handle.compensates,
|
|
139
|
+
unwind: handle.compensates && isUnwindAttempt(handle.retry, claimed.attempt),
|
|
170
140
|
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
171
141
|
events: options.events ?? eventBus(),
|
|
172
142
|
});
|
|
@@ -223,6 +193,22 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
223
193
|
return { ...outcome, steps, replayed: runner.replayedNames() };
|
|
224
194
|
};
|
|
225
195
|
|
|
196
|
+
/** Everything `unwindAndSettle` needs of this attempt — built only when a run unwinds. */
|
|
197
|
+
const unwinder = (): UnwindSettleOptions => ({
|
|
198
|
+
driver,
|
|
199
|
+
claimed,
|
|
200
|
+
handle,
|
|
201
|
+
runner,
|
|
202
|
+
ctx,
|
|
203
|
+
startedAt,
|
|
204
|
+
by,
|
|
205
|
+
landed,
|
|
206
|
+
settle,
|
|
207
|
+
run: { scope: inRunScope, input: parsed },
|
|
208
|
+
drained: () => drainedBy(signal),
|
|
209
|
+
...(options.clock === undefined ? {} : { clock: options.clock }),
|
|
210
|
+
});
|
|
211
|
+
|
|
226
212
|
try {
|
|
227
213
|
// Raised INSIDE the try so it takes the one failure path below — classified, nacked, logged
|
|
228
214
|
// and reported exactly as if the body had thrown it — and the body never starts.
|
|
@@ -231,7 +217,10 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
231
217
|
parsed = { value: input };
|
|
232
218
|
// The job's DECLARED tenant, on the actor the body runs as. `tenant: 'none'` strips the org
|
|
233
219
|
// rather than inheriting the worker's, so a tenant-scoped read inside such a job fails closed.
|
|
234
|
-
const
|
|
220
|
+
const declared = handle.tenantFor(input);
|
|
221
|
+
// A row queued for one org whose payload names another: refused before the body, terminally.
|
|
222
|
+
refuseRunTenant(handle.name, declared, claimed.tenantId);
|
|
223
|
+
const runActor = jobRunActor(callerActor(ctx), declared);
|
|
235
224
|
// Installed as the AMBIENT context and not only handed over as a parameter. This is the whole
|
|
236
225
|
// of the fix: `@ultimat3/entity`'s tenant guard derives from `tryUseContext()`, so a ctx passed
|
|
237
226
|
// as an argument was read by nobody — `actorTenant` answered `undefined`, `scopedPlan` derived
|
|
@@ -266,6 +255,12 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
266
255
|
result = await (handle.timeoutMs === undefined
|
|
267
256
|
? work
|
|
268
257
|
: raceTimeout(work, handle.timeoutMs, handle.name, cancel));
|
|
258
|
+
// An unwinding run's replay that RETURNED is still unwinding: the decision is durable.
|
|
259
|
+
// `return await`, at every unwind: the `finally` below ends the attempt, and the compensators
|
|
260
|
+
// must run — and write — before it does.
|
|
261
|
+
if (handle.compensates && (await runner.unwinding())) {
|
|
262
|
+
return await unwindAndSettle(unwinder(), undefined);
|
|
263
|
+
}
|
|
269
264
|
} catch (error) {
|
|
270
265
|
// Before ANY settle: the last value a body reported is written while the row is still this
|
|
271
266
|
// worker's, which is the only time `recordProgress`'s fence lets it land.
|
|
@@ -292,6 +287,15 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
292
287
|
}
|
|
293
288
|
|
|
294
289
|
const message = renderThrowable(error);
|
|
290
|
+
// Unwinding already: the replay stopped at its first incomplete step (`UnwindReached`) or
|
|
291
|
+
// ended any other way. Never retried forward — a drain still hands the attempt back.
|
|
292
|
+
if (
|
|
293
|
+
handle.compensates &&
|
|
294
|
+
!drainedBy(signal) &&
|
|
295
|
+
(isUnwindReached(error) || (await runner.unwinding().catch(() => false)))
|
|
296
|
+
) {
|
|
297
|
+
return await unwindAndSettle(unwinder(), undefined);
|
|
298
|
+
}
|
|
295
299
|
if (drainedBy(signal)) {
|
|
296
300
|
// The worker's drain told this body to stop, and it did. Whatever it stopped WITH is the
|
|
297
301
|
// framework's doing — the reason itself back from `fetch`, `throwIfAborted`'s `X_ABORTED`,
|
|
@@ -372,6 +376,10 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
372
376
|
// "do not keep" was re-claimed and re-run forever with its attempt counter climbing.
|
|
373
377
|
const dropped = !decision.retry && !decision.deadLetter;
|
|
374
378
|
const recorded = recordedFailure(failureForRow(error), decision);
|
|
379
|
+
// Failed for good, and the job undoes its steps: compensators first, the dead letter after.
|
|
380
|
+
if (!decision.retry && handle.compensates) {
|
|
381
|
+
return await unwindAndSettle(unwinder(), { thrown: error, recorded, stop });
|
|
382
|
+
}
|
|
375
383
|
const stack = failureStack(error);
|
|
376
384
|
const settled = landed(
|
|
377
385
|
await driver.nack(claimed.id, {
|
|
@@ -484,12 +492,3 @@ export async function executeJob(options: ExecuteJobOptions): Promise<JobExecuti
|
|
|
484
492
|
replayed: [],
|
|
485
493
|
});
|
|
486
494
|
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* The run was cancelled by the worker's drain: the code `JobDrainedError` carries, read off the
|
|
490
|
-
* signal. The FIRST reason wins on a controller, so a timeout that fired before the drain still
|
|
491
|
-
* reports as a timeout — the drain only claims an attempt it ended.
|
|
492
|
-
*/
|
|
493
|
-
function drainedBy(signal: AbortSignal): boolean {
|
|
494
|
-
return signal.aborted && isUltimateError(signal.reason) && signal.reason.code === 'X_DRAINING';
|
|
495
|
-
}
|
package/src/export-errors.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The two `X_EXPORT_*` codes an export pass can end on, apart from `errors.ts` for the reason
|
|
2
|
-
// `backfill-errors.ts` and `webhook-errors.ts` are: one file, one job, and `
|
|
2
|
+
// `backfill-errors.ts` and `webhook-errors.ts` are: one file, one job, and `error-codes.ts` holds the
|
|
3
3
|
// registry — the codes, the titles and the single `registerErrorCodes()` call.
|
|
4
4
|
//
|
|
5
5
|
// Both are TERMINAL and both are refusals of the DECLARATION rather than of the data: the same
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// A job row's `last_error` read for a CALLER: the code it was thrown with and the `callerFix` it
|
|
2
|
+
// declared. One text column holds two audiences, split by the one tab no rendered field contains.
|
|
3
|
+
|
|
4
|
+
import { hasErrorCode } from '@ultimat3/core';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Between the operator's line and the caller's fix. `failureForRow` runs the operator's line
|
|
8
|
+
* through core's `singleLine`, which escapes every control character, so the first raw tab in a
|
|
9
|
+
* row is this one — a thrown message cannot write it, and so cannot forge a caller's fix.
|
|
10
|
+
*/
|
|
11
|
+
export const CALLER_FIX_SEPARATOR = '\tcaller fix: ';
|
|
12
|
+
|
|
13
|
+
/** What a remote caller may be told about a failed job. Never the worker's text. */
|
|
14
|
+
export interface CallerFailure {
|
|
15
|
+
/** A registered code the row's failure was THROWN with, else `X_INTERNAL`. */
|
|
16
|
+
readonly code: string;
|
|
17
|
+
/** The thrown error's declared `callerFix`; absent when it declared none. */
|
|
18
|
+
readonly callerFix?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `<name>: <CODE>: …` — the leading position `renderThrowable` gives a coded error. */
|
|
22
|
+
const LEADING_CODE = /^[A-Za-z]*: (X(?:_[A-Z0-9]+)+): /;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The failure as a caller reads it. Only a code in the leading position counts — a `TypeError`
|
|
26
|
+
* whose message merely mentions one is not that code — and only a code this process registered;
|
|
27
|
+
* anything else is `X_INTERNAL`, http's name for the same case, and carries no fix of its own.
|
|
28
|
+
* The developer's `fix:` is on the operator's line and never crosses (core's `fixFor`).
|
|
29
|
+
*/
|
|
30
|
+
export function callerFailure(lastError: string | undefined): CallerFailure {
|
|
31
|
+
const row = lastError ?? '';
|
|
32
|
+
const code = LEADING_CODE.exec(row)?.[1];
|
|
33
|
+
if (code === undefined || !hasErrorCode(code)) return { code: 'X_INTERNAL' };
|
|
34
|
+
const at = row.indexOf(CALLER_FIX_SEPARATOR);
|
|
35
|
+
if (at === -1) return { code };
|
|
36
|
+
return { code, callerFix: row.slice(at + CALLER_FIX_SEPARATOR.length) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `row` with `suffix` appended to the operator's half, the caller's fix (if any) kept last. */
|
|
40
|
+
export function appendToOperatorLine(row: string, suffix: string): string {
|
|
41
|
+
const at = row.indexOf(CALLER_FIX_SEPARATOR);
|
|
42
|
+
return at === -1 ? `${row}${suffix}` : `${row.slice(0, at)}${suffix}${row.slice(at)}`;
|
|
43
|
+
}
|