@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.
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 +142 -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 +90 -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
@@ -0,0 +1,142 @@
1
+ // What `step.run` answers: the JSON form of what the body returned, on the first run as on a
2
+ // replay. The type states it and `jsonForm` produces it, so a body reading a `Date` off a step
3
+ // result fails at compile time, and if forced, on attempt 1 rather than on the first resume.
4
+
5
+ import { isJsonObject, isUltimateError, jsonbRefusal, renderThrowable } from '@ultimat3/core';
6
+ import { StepOutputUnserializableError } from './errors-steps';
7
+
8
+ type Callable = ((...args: never[]) => unknown) | (abstract new (...args: never[]) => unknown);
9
+
10
+ /** The part of a member's type JSON leaves out: `{ a: undefined }` writes `{}`. */
11
+ type NonJson<V> = V extends void ? V : V extends symbol | Callable ? V : never;
12
+
13
+ /** `unknown` and `any` promise nothing about absence, so they stay what they are. */
14
+ type IsOpen<V> = unknown extends V ? true : false;
15
+
16
+ type AlwaysThere<V> = IsOpen<V> extends true ? true : [NonJson<V>] extends [never] ? true : false;
17
+
18
+ type SometimesThere<V> =
19
+ AlwaysThere<V> extends true ? false : [Exclude<V, NonJson<V>>] extends [never] ? false : true;
20
+
21
+ type JsonMembers<T> = {
22
+ [K in keyof T as K extends symbol
23
+ ? never
24
+ : AlwaysThere<T[K]> extends true
25
+ ? K
26
+ : never]: StepOutput<T[K]>;
27
+ } & {
28
+ [K in keyof T as K extends symbol
29
+ ? never
30
+ : SometimesThere<T[K]> extends true
31
+ ? K
32
+ : never]?: StepOutput<Exclude<T[K], NonJson<T[K]>>>;
33
+ };
34
+
35
+ type Flat<T> = { [K in keyof T]: T[K] };
36
+
37
+ /** Instances whose state lives in slots or on the prototype: `JSON.stringify` writes `{}`. */
38
+ type WritesNoMembers =
39
+ | ReadonlyMap<unknown, unknown>
40
+ | ReadonlySet<unknown>
41
+ | RegExp
42
+ | ArrayBuffer
43
+ | DataView;
44
+
45
+ /** `new Uint8Array([1])` writes `{"0":1}`, an object keyed by index, never an array. */
46
+ type TypedArray = Exclude<ArrayBufferView, DataView>;
47
+
48
+ type JsonOf<T> = T extends string | number | boolean | null
49
+ ? T
50
+ : T extends bigint
51
+ ? never
52
+ : T extends undefined
53
+ ? null
54
+ : T extends void
55
+ ? T
56
+ : T extends symbol | Callable
57
+ ? null
58
+ : T extends { toJSON(...args: never[]): infer R }
59
+ ? StepOutput<R>
60
+ : T extends WritesNoMembers
61
+ ? Record<string, never>
62
+ : T extends Error
63
+ ? Flat<JsonMembers<Omit<T, keyof Error>>>
64
+ : T extends TypedArray
65
+ ? Record<string, number>
66
+ : T extends readonly unknown[]
67
+ ? { [K in keyof T]: StepOutput<T[K]> }
68
+ : T extends object
69
+ ? Flat<JsonMembers<T>>
70
+ : T;
71
+
72
+ /**
73
+ * The JSON form of `T`, exactly what `JSON.parse(JSON.stringify(value))` leaves. Primitives and
74
+ * brands kept; a `Date` or anything with a `toJSON()` becomes what that returns; function and
75
+ * symbol members dropped; `undefined` at the top or in an array is `null`, on a member the member
76
+ * is absent; `readonly` and optionality kept. `unknown` stays `unknown`, `void` stays `void`.
77
+ * `bigint` is `never`: the value raises `X_STEP_OUTPUT_UNSERIALIZABLE` instead of arriving, as do
78
+ * `NaN`, an infinity, an invalid `Date` and text `jsonb` refuses, which the type cannot see.
79
+ */
80
+ export type StepOutput<T> = unknown extends T ? T : JsonOf<T>;
81
+
82
+ /**
83
+ * The value both attempts see. One stringify and one parse per EXECUTED step, none on a replay; a
84
+ * step persists a cursor, not a page, which is what keeps that cheap (`backfill()`'s rule).
85
+ *
86
+ * `JSON.stringify` answers `undefined` for `undefined`, a function and a symbol, and every store
87
+ * writes those as `null` (`JSON.stringify(output ?? null)`), so that is the stored form.
88
+ *
89
+ * A number `JSON.stringify` would write as `null` is refused here rather than typed `number` and
90
+ * answered `null`. Text `jsonb` refuses is refused on every store: Postgres alone raised it, from
91
+ * `put`, with nothing recorded, so the body ran again on each attempt where memory ran it once.
92
+ */
93
+ export function jsonForm<T>(value: T, at: { job: string; step: string }): StepOutput<T> {
94
+ const refused = (reason: string) => new StepOutputUnserializableError({ ...at, reason });
95
+ let text: string | undefined;
96
+ try {
97
+ text = JSON.stringify(value, function screen(this: unknown, key: string, written: unknown) {
98
+ if (typeof written === 'number' && !Number.isFinite(written)) {
99
+ throw refused('a NaN or an infinity, which JSON writes as null');
100
+ }
101
+ // Only the holder still has the `Date`: its `toJSON` has already answered `null`.
102
+ if (written === null && (this as Record<string, unknown>)[key] instanceof Date) {
103
+ throw refused('an invalid Date, which JSON writes as null');
104
+ }
105
+ return written;
106
+ });
107
+ } catch (error) {
108
+ if (isUltimateError(error) && error.code === 'X_STEP_OUTPUT_UNSERIALIZABLE') throw error;
109
+ throw refused(renderThrowable(error));
110
+ }
111
+ if (text === undefined) return null as StepOutput<T>;
112
+ const refusal = jsonbRefusal(text);
113
+ if (refusal !== undefined) throw refused(refusal);
114
+ return JSON.parse(text, inStoredOrder) as StepOutput<T>;
115
+ }
116
+
117
+ const UTF8 = new TextEncoder();
118
+
119
+ /** `jsonb`'s own key order: shorter first by bytes, then bytewise. */
120
+ function storedKeyOrder(a: Uint8Array, b: Uint8Array): number {
121
+ if (a.length !== b.length) return a.length - b.length;
122
+ for (let at = 0; at < a.length; at += 1) {
123
+ const difference = (a[at] ?? 0) - (b[at] ?? 0);
124
+ if (difference !== 0) return difference;
125
+ }
126
+ return 0;
127
+ }
128
+
129
+ /**
130
+ * A reviver: Postgres hands keys back in `jsonb`'s order and memory in the body's, so a body reading
131
+ * `Object.keys` saw two runs. Every attempt on every store now reads this one. Integer-like keys
132
+ * still come first, on both paths, because a JS object puts them there. `fromEntries` defines, so a
133
+ * `__proto__` key stays data.
134
+ */
135
+ function inStoredOrder(_key: string, value: unknown): unknown {
136
+ if (!isJsonObject(value)) return value;
137
+ const entries = Object.entries(value);
138
+ if (entries.length < 2) return value;
139
+ const keyed = entries.map((entry) => ({ entry, bytes: UTF8.encode(entry[0]) }));
140
+ keyed.sort((a, b) => storedKeyOrder(a.bytes, b.bytes));
141
+ return Object.fromEntries(keyed.map(({ entry }) => entry));
142
+ }
@@ -0,0 +1,68 @@
1
+ // What a run's steps are stored as: the records, their status vocabulary, the fence a write is made
2
+ // under, and the store seam every driver implements. Split from `steps.ts` at the file-size ceiling:
3
+ // that file is the runner, this is what it writes through.
4
+
5
+ /**
6
+ * The runtime list is the declaration and `StepStatus` is derived from it, the shape
7
+ * `BACKFILL_STATUSES` and `PRIMITIVE_KINDS` already have. A bare union cannot narrow a `text`
8
+ * column, so `driver-pg-rows.ts` cast one instead — and a cast that lands an unknown status on a
9
+ * record makes `stepRun`'s `existing?.status === 'completed'` false, which RE-EXECUTES a step
10
+ * this file promises runs once.
11
+ */
12
+ export const STEP_STATUSES = [
13
+ 'completed',
14
+ 'sleeping',
15
+ 'waiting',
16
+ 'failed',
17
+ // A `once` step's record BEFORE its `fn` runs. Read back with no `completed`, it is an unknown
18
+ // outcome (`X_STEP_OUTCOME_UNKNOWN`); `cleared` is an operator's "it did not run": run it again.
19
+ 'started',
20
+ 'cleared',
21
+ ] as const;
22
+
23
+ export type StepStatus = (typeof STEP_STATUSES)[number];
24
+
25
+ /** Narrows a status read back out of a store. Never a cast — the list decides. */
26
+ export const isStepStatus = (value: string): value is StepStatus =>
27
+ (STEP_STATUSES as readonly string[]).includes(value);
28
+
29
+ export interface StepRecord {
30
+ readonly runId: string;
31
+ readonly name: string;
32
+ readonly status: StepStatus;
33
+ /**
34
+ * The memoized return value. Present when `status === 'completed'` — and on a `waiting` record
35
+ * holding its search floor (`waitFloor`).
36
+ */
37
+ readonly output?: unknown;
38
+ readonly startedAt: number;
39
+ readonly completedAt?: number;
40
+ /** Epoch ms at which a sleeping/waiting step becomes runnable. */
41
+ readonly wakeAt?: number;
42
+ readonly event?: string;
43
+ readonly correlationKey?: string;
44
+ readonly attempts: number;
45
+ readonly error?: string;
46
+ }
47
+
48
+ /** The claim a step write is made under: `claimOf(claimed)`, plus the row it names. */
49
+ export interface StepFence {
50
+ readonly job: string;
51
+ readonly jobId: string;
52
+ readonly workerId: string;
53
+ readonly claim: number;
54
+ }
55
+
56
+ export interface StepStore {
57
+ get(runId: string, name: string): Promise<StepRecord | undefined>;
58
+ /**
59
+ * With `by`, the write lands only while that claim still holds the row (`running`, same worker,
60
+ * same claim ordinal — `SQL_ACK`'s fence) and is `X_JOB_LEASE_LOST` otherwise: the in-process
61
+ * signal cannot stop a body in ANOTHER process whose lease lapsed. Without it the write is
62
+ * unfenced — a transfer (`x jobs drain`) or a runner driven by hand, with no row to hold.
63
+ */
64
+ put(record: StepRecord, by?: StepFence): Promise<void>;
65
+ list(runId: string): Promise<readonly StepRecord[]>;
66
+ del(runId: string, name: string): Promise<void>;
67
+ clear(runId: string): Promise<void>;
68
+ }
@@ -41,6 +41,57 @@ export function isWaitTimedOut(output: unknown): boolean {
41
41
  );
42
42
  }
43
43
 
44
+ const WAIT_FLOOR_KEY = '~ultimate.waitFloor';
45
+ const WAIT_FLOOR_ID_KEY = '~ultimate.waitFloorId';
46
+
47
+ /** Where a wait's next search starts: at `afterMs`, and past the event `afterId` names. */
48
+ export interface WaitFloor {
49
+ readonly afterMs: number;
50
+ readonly afterId?: string;
51
+ }
52
+
53
+ /**
54
+ * What a `waiting` record holds once its schema refused an event: where the next search starts.
55
+ * `startedAt` cannot carry it, because the timeout counts from there. `find` answers the EARLIEST
56
+ * event, so without this a repaired publisher's event sat behind the refused one until its TTL.
57
+ * The refused event's id rides with its instant: a floor of `publishedAt + 1` skipped every valid
58
+ * event stamped in the same millisecond. A record written before the id has `afterMs` alone.
59
+ */
60
+ export const waitFloor = (floor: WaitFloor): Record<string, number | string> => ({
61
+ [WAIT_FLOOR_KEY]: floor.afterMs,
62
+ ...(floor.afterId === undefined ? {} : { [WAIT_FLOOR_ID_KEY]: floor.afterId }),
63
+ });
64
+
65
+ export function waitFloorOf(output: unknown): WaitFloor | undefined {
66
+ if (typeof output !== 'object' || output === null) return undefined;
67
+ const record = output as Record<string, unknown>;
68
+ const afterMs = record[WAIT_FLOOR_KEY];
69
+ if (typeof afterMs !== 'number') return undefined;
70
+ const afterId = record[WAIT_FLOOR_ID_KEY];
71
+ return typeof afterId === 'string' ? { afterMs, afterId } : { afterMs };
72
+ }
73
+
74
+ /**
75
+ * What an incomplete step throws while the run is UNWINDING: the body replays only to re-register
76
+ * its compensators, and the first step that never completed is where the forward path stops. The
77
+ * `StepSuspension` shape, and private: `executeJob` reads it, then calls `runner.unwind()`.
78
+ */
79
+ export class UnwindReached extends Error {
80
+ static readonly brand = 'ultimate.jobs.unwind-reached';
81
+ readonly brand: string = UnwindReached.brand;
82
+ readonly step: string;
83
+
84
+ constructor(step: string) {
85
+ super(`step "${step}" never completed, and the run is unwinding`);
86
+ this.name = 'UnwindReached';
87
+ this.step = step;
88
+ }
89
+ }
90
+
91
+ export function isUnwindReached(error: unknown): error is UnwindReached {
92
+ return error instanceof Error && (error as { brand?: unknown }).brand === UnwindReached.brand;
93
+ }
94
+
44
95
  export function isStepSuspension(error: unknown): error is StepSuspension {
45
96
  return error instanceof Error && (error as { brand?: unknown }).brand === StepSuspension.brand;
46
97
  }
@@ -1,6 +1,8 @@
1
1
  // A step's own ceiling: the race between a step body and `stepTimeout`. Split from `steps.ts` at
2
2
  // the file-size ceiling — that file is the runner, and this is the one timer it arms.
3
3
 
4
+ import { MAX_TIMER_MS } from '@ultimat3/core';
5
+
4
6
  /**
5
7
  * A step's own ceiling. It ABORTS before it rejects, in that order: `run()` fails the step on this
6
8
  * rejection and the job retries, so a body still holding a socket open past the deadline would be
@@ -14,11 +16,14 @@ export function withStepTimeout<T>(
14
16
  ): Promise<T> {
15
17
  if (timeoutMs === undefined || timeoutMs <= 0) return Promise.resolve(work);
16
18
  return new Promise<T>((resolve, reject) => {
17
- const timer = setTimeout(() => {
18
- const failure = error();
19
- deadline.abort(failure);
20
- reject(failure);
21
- }, timeoutMs);
19
+ const timer = setTimeout(
20
+ () => {
21
+ const failure = error();
22
+ deadline.abort(failure);
23
+ reject(failure);
24
+ },
25
+ Math.min(timeoutMs, MAX_TIMER_MS),
26
+ );
22
27
  Promise.resolve(work).then(
23
28
  (value) => {
24
29
  clearTimeout(timer);
@@ -0,0 +1,94 @@
1
+ // Durable compensation: the stack a replay rebuilds, and the unwind that runs it. No closure is
2
+ // stored — the body replays from the top, so each `step.run` re-registers its own — and every
3
+ // compensator runs as an ordinary step `compensate:<name>`, so a replay skips what already ran.
4
+
5
+ import { renderThrowable } from '@ultimat3/core';
6
+ import { COMPENSATE_PREFIX, JobCompensationFailedError, UNWINDING_STEP } from './errors-saga';
7
+ import type { RunnerCore } from './steps-core';
8
+ import type { StepRecord } from './steps-store';
9
+
10
+ /** Runs one step body and records it — `steps.ts`'s, shared so a compensator is a step like any. */
11
+ export type StepExecutor = (
12
+ name: string,
13
+ existing: StepRecord | undefined,
14
+ fn: (signal: AbortSignal) => unknown,
15
+ ) => Promise<unknown>;
16
+
17
+ interface Compensation {
18
+ readonly step: string;
19
+ /** Absent for a `once` step whose outcome is unknown: there is no output to undo from. */
20
+ readonly undo?: (signal: AbortSignal) => unknown;
21
+ }
22
+
23
+ export interface CompensationStack {
24
+ /** A completed step with a compensator, in the order it completed. */
25
+ done(step: string, undo: (signal: AbortSignal) => unknown): void;
26
+ /** A `once` step with a compensator whose outcome nobody knows. */
27
+ unknown(step: string): void;
28
+ /**
29
+ * Write `unwinding` (once, before the first compensator), then run every registered compensator
30
+ * last-in first-out. Answers the failure the run is unwinding FROM: the one `unwinding` already
31
+ * holds, or `failure` when this call wrote it. A compensator that throws, or an unknown `once`
32
+ * step, is `X_JOB_COMPENSATION_FAILED`.
33
+ */
34
+ unwind(failure: string): Promise<string>;
35
+ }
36
+
37
+ /** The failure an `unwinding` record carries, when it carries one. */
38
+ export function unwoundFrom(record: StepRecord | undefined): string | undefined {
39
+ const output = record?.output;
40
+ if (typeof output !== 'object' || output === null) return undefined;
41
+ const error = (output as Record<string, unknown>)['error'];
42
+ return typeof error === 'string' ? error : undefined;
43
+ }
44
+
45
+ export function compensationStack(core: RunnerCore, execute: StepExecutor): CompensationStack {
46
+ const entries: Compensation[] = [];
47
+ return {
48
+ done: (step, undo) => entries.push({ step, undo }),
49
+ unknown: (step) => entries.push({ step }),
50
+ async unwind(failure) {
51
+ const mark = await core.load(UNWINDING_STEP);
52
+ const original = unwoundFrom(mark) ?? failure;
53
+ if (mark === undefined) {
54
+ const at = core.now();
55
+ await core.put({
56
+ runId: core.runId,
57
+ name: UNWINDING_STEP,
58
+ status: 'completed',
59
+ output: { error: original },
60
+ startedAt: at,
61
+ completedAt: at,
62
+ attempts: 1,
63
+ });
64
+ }
65
+ for (const entry of [...entries].reverse()) {
66
+ if (entry.undo === undefined) {
67
+ throw new JobCompensationFailedError({
68
+ job: core.jobName,
69
+ step: entry.step,
70
+ outcome: 'unknown',
71
+ reason: 'X_STEP_OUTCOME_UNKNOWN',
72
+ });
73
+ }
74
+ const name = `${COMPENSATE_PREFIX}${entry.step}`;
75
+ const existing = await core.load(name);
76
+ if (existing?.status === 'completed') {
77
+ core.replayed(name);
78
+ continue;
79
+ }
80
+ try {
81
+ await execute(name, existing, entry.undo);
82
+ } catch (error) {
83
+ throw new JobCompensationFailedError({
84
+ job: core.jobName,
85
+ step: entry.step,
86
+ outcome: 'failed',
87
+ reason: renderThrowable(error),
88
+ });
89
+ }
90
+ }
91
+ return original;
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,163 @@
1
+ // `step.waitForEvent`: the stored answer, a hit parsed by the wait's own schema, the timeout called
2
+ // on the bus's clock, and the park. Split from `steps.ts` at the file-size ceiling — that file is
3
+ // the runner; this is one of its three step kinds, over the `RunnerCore` it shares.
4
+
5
+ import { logger, renderThrowable } from '@ultimat3/core';
6
+ import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
7
+ import { formatIssues, validateAsync } from '@ultimat3/schema';
8
+ import { finiteDurationMs } from './clock';
9
+ import { JobTimeoutError } from './errors';
10
+ import { JobEventInvalidError } from './errors-steps';
11
+ import type { WaitForEventOptions } from './steps-api';
12
+ import type { RunnerCore } from './steps-core';
13
+ import { jsonForm } from './steps-output';
14
+ import type { StepRecord } from './steps-store';
15
+ import {
16
+ isWaitTimedOut,
17
+ StepSuspension,
18
+ WAIT_TIMED_OUT,
19
+ waitFloor,
20
+ waitFloorOf,
21
+ } from './steps-suspension';
22
+
23
+ const DEFAULT_WAIT_MS = 86_400_000;
24
+
25
+ type Hit = { readonly id: string; readonly payload: unknown; readonly publishedAt: number };
26
+
27
+ export function waitForEventStep(core: RunnerCore) {
28
+ const { runId, jobName } = core;
29
+ return async function waitForEvent<S extends StandardSchemaV1>(
30
+ name: string,
31
+ event: string,
32
+ waitOptions: WaitForEventOptions<S>,
33
+ ): Promise<InferOutput<S> | undefined> {
34
+ /** The store keeps the raw payload; a hit and a replay parse it alike, so both see one value. */
35
+ const answer = async (payload: unknown): Promise<InferOutput<S>> => {
36
+ const parsed = await validateAsync(waitOptions.schema, payload);
37
+ if (parsed.issues === undefined) return parsed.value;
38
+ const issues = formatIssues(parsed.issues);
39
+ throw new JobEventInvalidError({ job: jobName, step: name, event, issues });
40
+ };
41
+
42
+ core.claim(name);
43
+ const existing = await core.load(name);
44
+ if (existing?.status === 'completed') {
45
+ core.replayed(name);
46
+ return isWaitTimedOut(existing.output) ? undefined : answer(existing.output);
47
+ }
48
+ await core.forwardOnly(name);
49
+
50
+ const at = core.now();
51
+ // A NEW wait is stamped by the bus, whose clock stamps every `publishedAt` it is compared
52
+ // with; the runner's own only when there is no bus to ask. A re-poll keeps what it persisted.
53
+ const startedAt = existing?.startedAt ?? (await core.events?.now()) ?? at;
54
+ const timeoutMs = finiteDurationMs(
55
+ waitOptions.timeout ?? DEFAULT_WAIT_MS,
56
+ 'step.waitForEvent',
57
+ 'timeout',
58
+ );
59
+ const deadline = startedAt + timeoutMs;
60
+ const correlationKey = waitOptions.match;
61
+ const attempts = (existing?.attempts ?? 0) + 1;
62
+ const keyed = correlationKey === undefined ? {} : { correlationKey };
63
+
64
+ const floor = waitFloorOf(existing?.output);
65
+ const parked = {
66
+ runId,
67
+ name,
68
+ status: 'waiting',
69
+ startedAt,
70
+ event,
71
+ attempts,
72
+ ...keyed,
73
+ } as const;
74
+
75
+ /** A found event, parsed before it is recorded: a refused one is no step's answer. */
76
+ const consume = async (hit: Hit): Promise<InferOutput<S>> => {
77
+ // The form a replay will parse, so a hit cannot answer what its replay refuses.
78
+ const stored = jsonForm(hit.payload, { job: jobName, step: name });
79
+ const value = await answer(stored).catch(async (refusal: unknown) => {
80
+ // The original error is what the caller sees; without the floor a retry is refused again.
81
+ await core
82
+ .put({
83
+ ...parked,
84
+ output: waitFloor({ afterMs: hit.publishedAt, afterId: hit.id }),
85
+ wakeAt: at,
86
+ })
87
+ .catch((unwritten: unknown) =>
88
+ logger.warn('jobs.step.floor-unrecorded', {
89
+ job: jobName,
90
+ step: name,
91
+ error: renderThrowable(unwritten),
92
+ }),
93
+ );
94
+ throw refusal;
95
+ });
96
+ const done: StepRecord = {
97
+ ...parked,
98
+ status: 'completed',
99
+ output: stored,
100
+ completedAt: at,
101
+ };
102
+ await core.put(done);
103
+ return value;
104
+ };
105
+
106
+ const hit = await core.events?.find(
107
+ event,
108
+ correlationKey,
109
+ floor?.afterMs ?? startedAt,
110
+ floor?.afterId,
111
+ );
112
+ if (hit !== undefined) return consume(hit);
113
+
114
+ // `deadline` is on the BUS's clock, so the bus calls the timeout: asked only once this runner
115
+ // believes the time is up, which costs a wait one statement at its end. A runner AHEAD of the
116
+ // bus used to give up early by the skew — on an answer still inside its window. (A runner
117
+ // BEHIND waits the skew longer; knowing that sooner would cost a round trip on every poll.)
118
+ let remaining = deadline - at;
119
+ if (remaining <= 0) remaining = deadline - ((await core.events?.now()) ?? at);
120
+ if (remaining <= 0) {
121
+ if (waitOptions.required === true) {
122
+ throw new JobTimeoutError({
123
+ job: jobName,
124
+ step: name,
125
+ timeoutMs,
126
+ fix: `step.waitForEvent('${name}', '${event}', { timeout: '1h' }) # raises the wait's timeout; or make the publisher of "${event}" send it sooner`,
127
+ });
128
+ }
129
+ logger.warn('jobs.step.wait-timeout', { job: jobName, step: name, event });
130
+ await core.put({
131
+ runId,
132
+ name,
133
+ status: 'completed',
134
+ output: WAIT_TIMED_OUT,
135
+ startedAt,
136
+ completedAt: at,
137
+ event,
138
+ attempts,
139
+ });
140
+ return undefined;
141
+ }
142
+
143
+ const resumeAt = at + Math.min(remaining, core.pollMs);
144
+ await core.put({
145
+ ...parked,
146
+ wakeAt: resumeAt,
147
+ ...(floor === undefined ? {} : { output: waitFloor(floor) }),
148
+ });
149
+ // A waking bus wakes only the runs it finds `waiting`, so an event committed between the first
150
+ // `find` and the write above woke nobody. Asked once more: one that commits after THIS read
151
+ // meets a `running` row, which the wake leaves alone, and is the backstop poll's.
152
+ if (core.events?.wakes === true) {
153
+ const late = await core.events.find(
154
+ event,
155
+ correlationKey,
156
+ floor?.afterMs ?? startedAt,
157
+ floor?.afterId,
158
+ );
159
+ if (late !== undefined) return consume(late);
160
+ }
161
+ throw new StepSuspension({ step: name, resumeAt, reason: 'event' });
162
+ };
163
+ }