@ultimat3/jobs 29.4.0 → 30.1.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.
Files changed (109) hide show
  1. package/CLAUDE.md +68 -89
  2. package/README.md +304 -10
  3. package/package.json +18 -6
  4. package/src/backfill-definition.ts +184 -0
  5. package/src/backfill-errors.ts +18 -2
  6. package/src/backfill-pass.ts +43 -40
  7. package/src/backfill-pending.ts +40 -17
  8. package/src/backfill-reader.ts +94 -0
  9. package/src/backfill-registry.ts +5 -0
  10. package/src/backfill-scope.ts +1 -2
  11. package/src/backfill-source.ts +113 -0
  12. package/src/backfill.ts +54 -135
  13. package/src/consumer-errors.ts +42 -0
  14. package/src/consumer-guard.ts +19 -0
  15. package/src/consumer-loop.ts +171 -0
  16. package/src/consumer-registry.ts +103 -0
  17. package/src/consumer-types.ts +81 -0
  18. package/src/consumer.ts +147 -0
  19. package/src/driver-memory-operator.ts +25 -2
  20. package/src/driver-memory.ts +35 -1
  21. package/src/driver-pg-claim-sql.ts +128 -0
  22. package/src/driver-pg-ddl.ts +11 -2
  23. package/src/driver-pg-demand-sql.ts +27 -0
  24. package/src/driver-pg-enqueue-many.ts +153 -0
  25. package/src/driver-pg-event-wake-sql.ts +51 -0
  26. package/src/driver-pg-operator-sql.ts +34 -6
  27. package/src/driver-pg-operator.ts +36 -2
  28. package/src/driver-pg-outbox-sql.ts +34 -0
  29. package/src/driver-pg-sql.ts +24 -91
  30. package/src/driver-pg.ts +17 -1
  31. package/src/driver-redis-refusal.ts +25 -0
  32. package/src/driver-redis-scripts.ts +79 -30
  33. package/src/driver-redis.ts +70 -42
  34. package/src/driver.ts +38 -1
  35. package/src/enqueue-record.ts +137 -0
  36. package/src/error-codes.ts +233 -0
  37. package/src/errors-concurrency.ts +2 -2
  38. package/src/errors-declaration.ts +15 -2
  39. package/src/errors-operator.ts +1 -1
  40. package/src/errors-requeue.ts +1 -1
  41. package/src/errors-saga.ts +87 -0
  42. package/src/errors-steps.ts +35 -0
  43. package/src/errors-tenant.ts +1 -1
  44. package/src/errors.ts +25 -165
  45. package/src/events-pg.ts +20 -8
  46. package/src/events.ts +14 -3
  47. package/src/execute-ctx.ts +51 -0
  48. package/src/execute-unwind.ts +191 -0
  49. package/src/execute.ts +50 -51
  50. package/src/export-errors.ts +1 -1
  51. package/src/failure-row.ts +43 -0
  52. package/src/import-errors.ts +102 -0
  53. package/src/import-format.ts +232 -0
  54. package/src/import-pass.ts +255 -0
  55. package/src/import-source.ts +49 -0
  56. package/src/import.ts +175 -0
  57. package/src/index.ts +163 -7
  58. package/src/inspect-operator.ts +24 -3
  59. package/src/inspect.ts +17 -4
  60. package/src/introspection.ts +24 -1
  61. package/src/job-enqueue-many.ts +108 -0
  62. package/src/job-types.ts +229 -0
  63. package/src/job.ts +19 -208
  64. package/src/metrics.ts +10 -0
  65. package/src/outbox-pg.ts +35 -0
  66. package/src/outbox-relay.ts +58 -19
  67. package/src/outbox.ts +65 -73
  68. package/src/purge-entities.ts +92 -0
  69. package/src/purge.ts +10 -2
  70. package/src/queue-wake.ts +1 -1
  71. package/src/refresh-view.ts +76 -0
  72. package/src/retry-classification.ts +27 -8
  73. package/src/retry.ts +26 -0
  74. package/src/rollup-errors.ts +27 -0
  75. package/src/rollup.ts +190 -0
  76. package/src/run-deadline.ts +11 -7
  77. package/src/scheduler.ts +2 -1
  78. package/src/settled.ts +56 -15
  79. package/src/step-resolve.ts +37 -0
  80. package/src/steps-api.ts +111 -0
  81. package/src/steps-core.ts +26 -0
  82. package/src/steps-output.ts +149 -0
  83. package/src/steps-store.ts +68 -0
  84. package/src/steps-suspension.ts +51 -0
  85. package/src/steps-timeout.ts +10 -5
  86. package/src/steps-unwind.ts +94 -0
  87. package/src/steps-wait.ts +163 -0
  88. package/src/steps.ts +174 -210
  89. package/src/subject-erase-pass.ts +134 -0
  90. package/src/subject-errors.ts +48 -0
  91. package/src/subject-export-pass.ts +228 -0
  92. package/src/subject-jobs.ts +121 -0
  93. package/src/subject-plan.ts +287 -0
  94. package/src/subject-select.ts +106 -0
  95. package/src/subject.ts +119 -0
  96. package/src/tenant.ts +69 -1
  97. package/src/type-pins.ts +99 -0
  98. package/src/webhook-errors.ts +1 -1
  99. package/src/webhook-receiver-errors.ts +59 -0
  100. package/src/webhook-receiver-route.ts +127 -0
  101. package/src/webhook-receiver-types.ts +97 -0
  102. package/src/webhook-receiver.ts +132 -0
  103. package/src/webhook-target.ts +20 -43
  104. package/src/webhook.ts +7 -4
  105. package/src/worker-queue-demand.ts +57 -0
  106. package/src/worker-queue-depth.ts +15 -3
  107. package/src/worker-registry.ts +4 -1
  108. package/src/worker-types.ts +2 -0
  109. 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
- }>(SQL_EVENT_PUBLISH, [
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 resumed step consumes events in order. */
103
- async find(name, correlationKey, afterMs) {
104
- const rows = await exec.query<{ payload: unknown; published_at: number | string }>(
105
- SQL_EVENT_FIND,
106
- [name, correlationKey ?? null, afterMs],
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 ? undefined : { payload: best.payload, publishedAt: best.publishedAt },
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 { Actor, Clock, Ctx, ServiceBag } from '@ultimat3/core';
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 { jobRunActor } from './tenant';
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 runActor = jobRunActor(callerActor(ctx), handle.tenantFor(input));
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
- }
@@ -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 `errors.ts` holds the
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
+ }