@yieldstar/core 0.5.0 → 0.6.1
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/dist/base/durable-value.d.ts +71 -0
- package/dist/base/durable-value.d.ts.map +1 -0
- package/dist/base/durable-value.test.d.ts +2 -0
- package/dist/base/durable-value.test.d.ts.map +1 -0
- package/dist/base/effect.d.ts +136 -0
- package/dist/base/effect.d.ts.map +1 -0
- package/dist/base/errors.d.ts +23 -0
- package/dist/base/errors.d.ts.map +1 -0
- package/dist/base/errors.test.d.ts +2 -0
- package/dist/base/errors.test.d.ts.map +1 -0
- package/dist/base/event.d.ts +6 -1
- package/dist/base/event.d.ts.map +1 -1
- package/dist/base/execution.d.ts +255 -0
- package/dist/base/execution.d.ts.map +1 -0
- package/dist/base/heap.d.ts +15 -3
- package/dist/base/heap.d.ts.map +1 -1
- package/dist/base/invoker.d.ts +47 -2
- package/dist/base/invoker.d.ts.map +1 -1
- package/dist/base/runtime.d.ts +27 -4
- package/dist/base/runtime.d.ts.map +1 -1
- package/dist/base/step.d.ts +21 -5
- package/dist/base/step.d.ts.map +1 -1
- package/dist/base/store.d.ts +135 -21
- package/dist/base/store.d.ts.map +1 -1
- package/dist/base/workflow.d.ts +15 -0
- package/dist/base/workflow.d.ts.map +1 -1
- package/dist/index.d.ts +23 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1653 -172
- package/dist/lib/effect-state.d.ts +78 -0
- package/dist/lib/effect-state.d.ts.map +1 -0
- package/dist/lib/execution-dispatcher.d.ts +112 -0
- package/dist/lib/execution-dispatcher.d.ts.map +1 -0
- package/dist/lib/execution-state.d.ts +248 -0
- package/dist/lib/execution-state.d.ts.map +1 -0
- package/dist/lib/execution-state.test.d.ts +2 -0
- package/dist/lib/execution-state.test.d.ts.map +1 -0
- package/dist/lib/wait-for-outcome.d.ts +15 -0
- package/dist/lib/wait-for-outcome.d.ts.map +1 -0
- package/dist/lib/workflow-runner.d.ts +14 -9
- package/dist/lib/workflow-runner.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/base/durable-value.test.ts +285 -0
- package/src/base/durable-value.ts +442 -0
- package/src/base/effect.ts +165 -0
- package/src/base/errors.test.ts +210 -0
- package/src/base/errors.ts +72 -0
- package/src/base/event.ts +6 -1
- package/src/base/execution.ts +315 -0
- package/src/base/heap.ts +17 -3
- package/src/base/invoker.ts +60 -2
- package/src/base/runtime.ts +28 -7
- package/src/base/step.ts +29 -7
- package/src/base/store.ts +339 -49
- package/src/base/workflow.ts +15 -0
- package/src/index.ts +138 -3
- package/src/lib/effect-state.ts +254 -0
- package/src/lib/execution-dispatcher.ts +408 -0
- package/src/lib/execution-state.test.ts +249 -0
- package/src/lib/execution-state.ts +650 -0
- package/src/lib/wait-for-outcome.ts +40 -0
- package/src/lib/workflow-runner.ts +75 -43
- package/src/base/scheduler.ts +0 -8
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import type { Logger } from "pino";
|
|
2
|
+
import type { WorkflowInvoker } from "../base/invoker";
|
|
3
|
+
import { WorkerExitedError } from "../base/invoker";
|
|
4
|
+
import type { Runtime } from "../base/runtime";
|
|
5
|
+
import type {
|
|
6
|
+
ExecutionAcknowledgeResult,
|
|
7
|
+
ExecutionClaimFilter,
|
|
8
|
+
ExecutionLease,
|
|
9
|
+
WorkflowRunOutcome,
|
|
10
|
+
} from "../base/execution";
|
|
11
|
+
import { LeaseLostError, isTerminalExecutionStatus } from "../base/execution";
|
|
12
|
+
import { DEFAULT_LEASE_DURATION } from "./execution-state";
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_DISPATCHER_CONCURRENCY = 10;
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_ABORT_GRACE_PERIOD = 1_000;
|
|
17
|
+
|
|
18
|
+
const ABANDONED = Symbol("abandoned");
|
|
19
|
+
|
|
20
|
+
export interface DispatcherOptions {
|
|
21
|
+
invoker: WorkflowInvoker;
|
|
22
|
+
logger: Logger;
|
|
23
|
+
/** The maximum number of deliveries this dispatcher runs at once. @default 10 */
|
|
24
|
+
concurrency?: number;
|
|
25
|
+
/** Milliseconds between claims when nothing is due. @default 10 */
|
|
26
|
+
pollInterval?: number;
|
|
27
|
+
/** Whether the dispatcher's timers keep the process alive. @default true */
|
|
28
|
+
keepAlive?: boolean;
|
|
29
|
+
/** How long each lease lasts unless renewed, in milliseconds. @default 30_000 */
|
|
30
|
+
leaseDuration?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Milliseconds between renewals of a delivery's lease, measured on the
|
|
33
|
+
* process's timers. Must be shorter than `leaseDuration`.
|
|
34
|
+
* @default leaseDuration / 3
|
|
35
|
+
*/
|
|
36
|
+
renewInterval?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Milliseconds to keep waiting for a delivery after its lease is lost and
|
|
39
|
+
* its signal aborts. After that the dispatcher abandons the delivery and
|
|
40
|
+
* frees its slot, even if the invoker cannot stop a step function that is
|
|
41
|
+
* still running.
|
|
42
|
+
* @default 1_000
|
|
43
|
+
*/
|
|
44
|
+
abortGracePeriod?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Delivers only the executions that match. It is passed to every `claimDue`,
|
|
47
|
+
* so other executions are never claimed and gain no redelivery delay. An
|
|
48
|
+
* empty `executionIds` list is rejected, since the dispatcher would never
|
|
49
|
+
* deliver anything.
|
|
50
|
+
* @default every execution whose due time has passed
|
|
51
|
+
*/
|
|
52
|
+
filter?: ExecutionClaimFilter;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The only component that delivers executions. It leases due executions, reads
|
|
57
|
+
* each event from its record, hands it to the invoker, and acknowledges the
|
|
58
|
+
* outcome under the delivery's lease.
|
|
59
|
+
*
|
|
60
|
+
* - Up to `concurrency` deliveries run at once. The dispatcher claims new
|
|
61
|
+
* executions as slots free up, without waiting for earlier deliveries.
|
|
62
|
+
* - It renews each lease every `renewInterval` while the delivery runs. If a
|
|
63
|
+
* renewal returns `lost`, another claim has taken over: the dispatcher stops
|
|
64
|
+
* renewing, aborts the delivery's signal so it runs no more workflow code,
|
|
65
|
+
* and neither acknowledges nor releases. If the delivery has not settled
|
|
66
|
+
* `abortGracePeriod` after the abort, the dispatcher abandons it and frees
|
|
67
|
+
* its slot. An abandoned delivery may keep running a step function, but its
|
|
68
|
+
* signal and lease token stop every write it attempts.
|
|
69
|
+
* - A delivery that rejects is not acknowledged. If the worker exited before
|
|
70
|
+
* replying, the lease is left to expire; any other rejection is a retry
|
|
71
|
+
* reply, and the lease is released. Either way the wakes stay pending and the
|
|
72
|
+
* execution is delivered again after the redelivery delay.
|
|
73
|
+
* - If reading the record, acknowledging or releasing throws, the lease is
|
|
74
|
+
* left to expire rather than released.
|
|
75
|
+
*/
|
|
76
|
+
export class ExecutionDispatcher {
|
|
77
|
+
private runtime: Runtime;
|
|
78
|
+
private invoker: WorkflowInvoker;
|
|
79
|
+
private logger: Logger;
|
|
80
|
+
private pollInterval: number;
|
|
81
|
+
private keepAlive: boolean;
|
|
82
|
+
private concurrency: number;
|
|
83
|
+
private leaseDuration: number;
|
|
84
|
+
private renewInterval: number;
|
|
85
|
+
private abortGracePeriod: number;
|
|
86
|
+
private filter: ExecutionClaimFilter | undefined;
|
|
87
|
+
private running = false;
|
|
88
|
+
private looping = false;
|
|
89
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
90
|
+
private inFlight = new Set<Promise<void>>();
|
|
91
|
+
/** Slots reserved by claims that have not returned yet. */
|
|
92
|
+
private claiming = 0;
|
|
93
|
+
|
|
94
|
+
constructor(runtime: Runtime, options: DispatcherOptions) {
|
|
95
|
+
this.runtime = runtime;
|
|
96
|
+
this.invoker = options.invoker;
|
|
97
|
+
this.logger = options.logger;
|
|
98
|
+
this.pollInterval = options.pollInterval ?? 10;
|
|
99
|
+
this.keepAlive = options.keepAlive ?? true;
|
|
100
|
+
this.concurrency = options.concurrency ?? DEFAULT_DISPATCHER_CONCURRENCY;
|
|
101
|
+
this.leaseDuration = options.leaseDuration ?? DEFAULT_LEASE_DURATION;
|
|
102
|
+
this.renewInterval = options.renewInterval ?? this.leaseDuration / 3;
|
|
103
|
+
this.abortGracePeriod = options.abortGracePeriod ?? DEFAULT_ABORT_GRACE_PERIOD;
|
|
104
|
+
this.filter = options.filter;
|
|
105
|
+
if (!Number.isInteger(this.concurrency) || this.concurrency < 1) {
|
|
106
|
+
throw new RangeError("concurrency must be a positive integer");
|
|
107
|
+
}
|
|
108
|
+
if (!(this.leaseDuration > 0)) {
|
|
109
|
+
throw new RangeError("leaseDuration must be positive");
|
|
110
|
+
}
|
|
111
|
+
if (!(this.renewInterval > 0 && this.renewInterval < this.leaseDuration)) {
|
|
112
|
+
throw new RangeError("renewInterval must be positive and shorter than leaseDuration");
|
|
113
|
+
}
|
|
114
|
+
if (!(this.abortGracePeriod >= 0)) {
|
|
115
|
+
throw new RangeError("abortGracePeriod must not be negative");
|
|
116
|
+
}
|
|
117
|
+
if (this.filter?.executionIds?.length === 0) {
|
|
118
|
+
throw new RangeError("filter.executionIds must not be empty");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
start() {
|
|
123
|
+
if (this.running) return;
|
|
124
|
+
this.running = true;
|
|
125
|
+
void this.loop();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Stops claiming and waits for in-flight deliveries to settle. */
|
|
129
|
+
async stop() {
|
|
130
|
+
this.running = false;
|
|
131
|
+
if (this.timer) clearTimeout(this.timer);
|
|
132
|
+
this.timer = undefined;
|
|
133
|
+
await this.drain();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get activeDeliveries() {
|
|
137
|
+
return this.inFlight.size;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Leases due executions, up to the free delivery slots, and starts
|
|
142
|
+
* delivering them. Resolves with the number of deliveries claimed; it does
|
|
143
|
+
* not wait for them to finish.
|
|
144
|
+
*/
|
|
145
|
+
async tick(): Promise<number> {
|
|
146
|
+
const slots = this.concurrency - this.inFlight.size - this.claiming;
|
|
147
|
+
if (slots <= 0) return 0;
|
|
148
|
+
this.claiming += slots;
|
|
149
|
+
let leases: ExecutionLease[];
|
|
150
|
+
try {
|
|
151
|
+
leases = await this.runtime.executions.claimDue({
|
|
152
|
+
limit: slots,
|
|
153
|
+
duration: this.leaseDuration,
|
|
154
|
+
filter: this.filter,
|
|
155
|
+
});
|
|
156
|
+
} finally {
|
|
157
|
+
this.claiming -= slots;
|
|
158
|
+
}
|
|
159
|
+
for (const lease of leases) {
|
|
160
|
+
const task: Promise<void> = this.dispatch(lease).finally(() => {
|
|
161
|
+
this.inFlight.delete(task);
|
|
162
|
+
// A slot is free: claim again now rather than at the next poll.
|
|
163
|
+
if (this.running && !this.looping) this.schedule(0);
|
|
164
|
+
});
|
|
165
|
+
this.inFlight.add(task);
|
|
166
|
+
}
|
|
167
|
+
return leases.length;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Waits for every delivery started so far to settle. */
|
|
171
|
+
async drain() {
|
|
172
|
+
while (this.inFlight.size > 0) {
|
|
173
|
+
await Promise.allSettled([...this.inFlight]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private schedule(delay: number) {
|
|
178
|
+
if (this.timer) clearTimeout(this.timer);
|
|
179
|
+
this.timer = setTimeout(() => {
|
|
180
|
+
this.timer = undefined;
|
|
181
|
+
void this.loop();
|
|
182
|
+
}, delay);
|
|
183
|
+
if (!this.keepAlive) this.timer.unref?.();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async loop() {
|
|
187
|
+
if (!this.running || this.looping) return;
|
|
188
|
+
this.looping = true;
|
|
189
|
+
let claimed = 0;
|
|
190
|
+
try {
|
|
191
|
+
claimed = await this.tick();
|
|
192
|
+
} catch (err) {
|
|
193
|
+
this.logger.error({ err }, "Failed to claim due executions");
|
|
194
|
+
} finally {
|
|
195
|
+
this.looping = false;
|
|
196
|
+
}
|
|
197
|
+
if (!this.running) return;
|
|
198
|
+
// When every slot is busy, the next delivery to finish schedules a claim.
|
|
199
|
+
if (this.inFlight.size + this.claiming >= this.concurrency) return;
|
|
200
|
+
this.schedule(claimed > 0 ? 0 : this.pollInterval);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private async dispatch(lease: ExecutionLease) {
|
|
204
|
+
const { executionId } = lease;
|
|
205
|
+
const { executions } = this.runtime;
|
|
206
|
+
const renewal = this.renew(lease);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const record = await executions.get(executionId);
|
|
210
|
+
if (!record) {
|
|
211
|
+
this.logger.error({ executionId }, "Claimed execution has no record");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// A finished execution never runs again: return its recorded outcome.
|
|
216
|
+
// Reaching a terminal status ends the lease, so no lease is current
|
|
217
|
+
// here and there is nothing to acknowledge.
|
|
218
|
+
if (isTerminalExecutionStatus(record.status)) {
|
|
219
|
+
this.emit(executionId, record.outcome!);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let outcome: WorkflowRunOutcome | typeof ABANDONED;
|
|
224
|
+
try {
|
|
225
|
+
outcome = await this.abandonAfterAbort(
|
|
226
|
+
this.invoker.deliver(
|
|
227
|
+
{
|
|
228
|
+
workflowId: record.workflowId,
|
|
229
|
+
executionId,
|
|
230
|
+
params: record.params,
|
|
231
|
+
context: record.context,
|
|
232
|
+
},
|
|
233
|
+
{ lease, signal: renewal.signal }
|
|
234
|
+
),
|
|
235
|
+
lease,
|
|
236
|
+
renewal.signal
|
|
237
|
+
);
|
|
238
|
+
} catch (err) {
|
|
239
|
+
const current = await renewal.stop();
|
|
240
|
+
if (!current) return;
|
|
241
|
+
if (WorkerExitedError.is(err)) {
|
|
242
|
+
// The worker may have died mid-write; let the lease expire rather
|
|
243
|
+
// than invite a new holder while its last writes may still land.
|
|
244
|
+
this.logger.error(
|
|
245
|
+
{ executionId, err },
|
|
246
|
+
"Worker exited without replying; the lease will expire and the execution will be redelivered"
|
|
247
|
+
);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
this.logger.error(
|
|
251
|
+
{ executionId, err },
|
|
252
|
+
"Delivery failed without an outcome; releasing its lease for redelivery"
|
|
253
|
+
);
|
|
254
|
+
const released = await executions.release({ lease: current });
|
|
255
|
+
if (released.status === "lost") this.warnLost(lease);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// An abandoned delivery's lease was lost: there is nothing to end.
|
|
260
|
+
if (outcome === ABANDONED) return;
|
|
261
|
+
const current = await renewal.stop();
|
|
262
|
+
if (!current) return;
|
|
263
|
+
const result = await executions.acknowledge({ lease: current, outcome });
|
|
264
|
+
if (result.status === "lost") {
|
|
265
|
+
this.warnLost(lease);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
this.emit(executionId, result);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
// Reading the record, acknowledging or releasing threw. Leave the lease
|
|
271
|
+
// to expire rather than trying to release it: expiry is safe, it counts
|
|
272
|
+
// as an unacknowledged delivery so the execution is delivered again
|
|
273
|
+
// after the redelivery delay, and the backend that just threw may be
|
|
274
|
+
// unreachable anyway.
|
|
275
|
+
this.logger.error(
|
|
276
|
+
{ executionId, err },
|
|
277
|
+
"Failed to dispatch execution; the lease will expire and the execution will be redelivered"
|
|
278
|
+
);
|
|
279
|
+
} finally {
|
|
280
|
+
await renewal.stop();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Settles with `delivery`, or with `ABANDONED` once `abortGracePeriod` has
|
|
286
|
+
* passed since `signal` aborted. An abandoned delivery's late outcome or
|
|
287
|
+
* rejection is logged and otherwise ignored, so it is never acknowledged or
|
|
288
|
+
* released and never surfaces as an unhandled rejection.
|
|
289
|
+
*/
|
|
290
|
+
private abandonAfterAbort<T>(
|
|
291
|
+
delivery: Promise<T>,
|
|
292
|
+
lease: ExecutionLease,
|
|
293
|
+
signal: AbortSignal
|
|
294
|
+
): Promise<T | typeof ABANDONED> {
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
let abandoned = false;
|
|
297
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
298
|
+
const onAbort = () => {
|
|
299
|
+
timer = setTimeout(() => {
|
|
300
|
+
abandoned = true;
|
|
301
|
+
this.logger.warn(
|
|
302
|
+
{ executionId: lease.executionId, token: lease.token },
|
|
303
|
+
"Delivery did not stop within the abort grace period after its lease was lost; abandoning it"
|
|
304
|
+
);
|
|
305
|
+
resolve(ABANDONED);
|
|
306
|
+
}, this.abortGracePeriod);
|
|
307
|
+
if (!this.keepAlive) timer.unref?.();
|
|
308
|
+
};
|
|
309
|
+
const settle = () => {
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
signal.removeEventListener("abort", onAbort);
|
|
312
|
+
};
|
|
313
|
+
if (signal.aborted) onAbort();
|
|
314
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
315
|
+
|
|
316
|
+
delivery.then(
|
|
317
|
+
(value) => {
|
|
318
|
+
settle();
|
|
319
|
+
if (abandoned) {
|
|
320
|
+
this.logger.warn(
|
|
321
|
+
{ executionId: lease.executionId, token: lease.token },
|
|
322
|
+
"Abandoned delivery settled late; its outcome is ignored"
|
|
323
|
+
);
|
|
324
|
+
} else resolve(value);
|
|
325
|
+
},
|
|
326
|
+
(err) => {
|
|
327
|
+
settle();
|
|
328
|
+
if (abandoned) {
|
|
329
|
+
this.logger.warn(
|
|
330
|
+
{ executionId: lease.executionId, token: lease.token, err },
|
|
331
|
+
"Abandoned delivery rejected late; ignored"
|
|
332
|
+
);
|
|
333
|
+
} else reject(err);
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Renews `lease` every `renewInterval` until stopped. `signal` aborts, with
|
|
341
|
+
* a `LeaseLostError`, once a renewal returns `lost`. `stop` waits for any
|
|
342
|
+
* renewal in flight and resolves with the latest lease, or `undefined` once
|
|
343
|
+
* a renewal has returned `lost`.
|
|
344
|
+
*/
|
|
345
|
+
private renew(lease: ExecutionLease) {
|
|
346
|
+
const controller = new AbortController();
|
|
347
|
+
let current: ExecutionLease | undefined = lease;
|
|
348
|
+
let pending: Promise<void> | undefined;
|
|
349
|
+
let stopped = false;
|
|
350
|
+
const { executions } = this.runtime;
|
|
351
|
+
|
|
352
|
+
const timer = setInterval(() => {
|
|
353
|
+
if (stopped || pending || !current) return;
|
|
354
|
+
const renewing = current;
|
|
355
|
+
pending = executions
|
|
356
|
+
.renew({ lease: renewing, duration: this.leaseDuration })
|
|
357
|
+
.then(
|
|
358
|
+
(result) => {
|
|
359
|
+
if (result.status === "lost") {
|
|
360
|
+
current = undefined;
|
|
361
|
+
clearInterval(timer);
|
|
362
|
+
this.warnLost(lease);
|
|
363
|
+
controller.abort(new LeaseLostError(lease.executionId));
|
|
364
|
+
} else if (current) {
|
|
365
|
+
current = result.lease;
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
(err) => {
|
|
369
|
+
// Keep trying: the lease stays current until another claim
|
|
370
|
+
// issues a higher token, even after it expires.
|
|
371
|
+
this.logger.error(
|
|
372
|
+
{ executionId: lease.executionId, err },
|
|
373
|
+
"Failed to renew lease"
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
.finally(() => {
|
|
378
|
+
pending = undefined;
|
|
379
|
+
});
|
|
380
|
+
}, this.renewInterval);
|
|
381
|
+
if (!this.keepAlive) timer.unref?.();
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
signal: controller.signal,
|
|
385
|
+
async stop() {
|
|
386
|
+
stopped = true;
|
|
387
|
+
clearInterval(timer);
|
|
388
|
+
await pending;
|
|
389
|
+
return current;
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private warnLost(lease: ExecutionLease) {
|
|
395
|
+
this.logger.warn(
|
|
396
|
+
{ executionId: lease.executionId, token: lease.token },
|
|
397
|
+
"Lease was lost to another claim; this delivery stops without an outcome"
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private emit(executionId: string, result: ExecutionAcknowledgeResult) {
|
|
402
|
+
if (result.status === "completed") {
|
|
403
|
+
this.invoker.workflowEndEmitter.emit(executionId, result.result);
|
|
404
|
+
} else if (result.status === "failed") {
|
|
405
|
+
this.invoker.workflowEndEmitter.emit(executionId, result.error);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { WorkflowValueError } from "../base/errors";
|
|
3
|
+
import { WorkflowStartError } from "../base/execution";
|
|
4
|
+
import { ReadOnlyMap } from "../utils/map";
|
|
5
|
+
import {
|
|
6
|
+
acknowledgeExecution,
|
|
7
|
+
claimExecution,
|
|
8
|
+
createExecutionState,
|
|
9
|
+
createStartedExecution,
|
|
10
|
+
decodeExecutionEvent,
|
|
11
|
+
decodeExecutionOutcome,
|
|
12
|
+
encodeExecutionEvent,
|
|
13
|
+
encodeExecutionOutcome,
|
|
14
|
+
matchStartRequest,
|
|
15
|
+
prepareExecutionAdmission,
|
|
16
|
+
prepareExecutionStart,
|
|
17
|
+
toExecutionRecord,
|
|
18
|
+
} from "./execution-state";
|
|
19
|
+
|
|
20
|
+
const requestId = { executionId: "caller", stepKey: "start:job" };
|
|
21
|
+
|
|
22
|
+
function prepare(request: { workflowId: string; params?: unknown }) {
|
|
23
|
+
const prepared = prepareExecutionStart({ requestId, ...request } as never);
|
|
24
|
+
if (!prepared.ok) throw new Error(prepared.result.message);
|
|
25
|
+
return prepared.start;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test.each([
|
|
29
|
+
["an empty workflow ID", { workflowId: "" }],
|
|
30
|
+
["array params", { workflowId: "w", params: [] }],
|
|
31
|
+
["null params", { workflowId: "w", params: null }],
|
|
32
|
+
["string params", { workflowId: "w", params: "x" }],
|
|
33
|
+
["class instance params", { workflowId: "w", params: new Date(0) }],
|
|
34
|
+
])("prepareExecutionStart rejects %s", (_label, request) => {
|
|
35
|
+
const prepared = prepareExecutionStart({ requestId, ...request } as never);
|
|
36
|
+
expect(prepared.ok).toBe(false);
|
|
37
|
+
if (!prepared.ok) {
|
|
38
|
+
expect(prepared.result.code).toBe("INVALID_START_PARAMETERS");
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("prepareExecutionStart distinguishes missing params from an empty object", () => {
|
|
43
|
+
expect(prepare({ workflowId: "w" }).params).toBeNull();
|
|
44
|
+
expect(prepare({ workflowId: "w", params: {} }).params).toBe("{}");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("matchStartRequest compares workflow ID and canonical params", () => {
|
|
48
|
+
const caller = createExecutionState({
|
|
49
|
+
executionId: "caller",
|
|
50
|
+
workflowId: "parent",
|
|
51
|
+
encoded: encodeExecutionEvent({ params: undefined, context: undefined }),
|
|
52
|
+
now: 0,
|
|
53
|
+
});
|
|
54
|
+
const { startRequest } = createStartedExecution({
|
|
55
|
+
start: prepare({ workflowId: "w", params: { a: 1, b: 2 } }),
|
|
56
|
+
caller,
|
|
57
|
+
now: 0,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(
|
|
61
|
+
matchStartRequest(startRequest, prepare({ workflowId: "w", params: { b: 2, a: 1 } }))
|
|
62
|
+
).toEqual({ status: "accepted", executionId: startRequest.executionId });
|
|
63
|
+
|
|
64
|
+
for (const request of [
|
|
65
|
+
{ workflowId: "other", params: { a: 1, b: 2 } },
|
|
66
|
+
{ workflowId: "w", params: { a: 1 } },
|
|
67
|
+
{ workflowId: "w", params: { a: 1, b: 2, c: undefined } },
|
|
68
|
+
{ workflowId: "w" },
|
|
69
|
+
]) {
|
|
70
|
+
expect(matchStartRequest(startRequest, prepare(request))).toMatchObject({
|
|
71
|
+
status: "rejected",
|
|
72
|
+
code: "START_REQUEST_MISMATCH",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("createStartedExecution admits the target with the caller's context and a parent link", () => {
|
|
78
|
+
const caller = createExecutionState({
|
|
79
|
+
executionId: "caller",
|
|
80
|
+
workflowId: "parent",
|
|
81
|
+
encoded: encodeExecutionEvent({
|
|
82
|
+
params: undefined,
|
|
83
|
+
context: new ReadOnlyMap(new Map([["tenant", "t-1"]])),
|
|
84
|
+
}),
|
|
85
|
+
now: 0,
|
|
86
|
+
});
|
|
87
|
+
const { target, startRequest } = createStartedExecution({
|
|
88
|
+
start: prepare({ workflowId: "w", params: { n: -0, list: [1] } }),
|
|
89
|
+
caller,
|
|
90
|
+
now: 5,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(startRequest.executionId).toBe(target.executionId);
|
|
94
|
+
expect(target.executionId).not.toBe("caller");
|
|
95
|
+
expect(target.dueAt).toBe(5);
|
|
96
|
+
|
|
97
|
+
const record = toExecutionRecord(target, 5);
|
|
98
|
+
expect(record).toMatchObject({
|
|
99
|
+
workflowId: "w",
|
|
100
|
+
params: { list: [1], n: 0 },
|
|
101
|
+
status: "admitted",
|
|
102
|
+
parent: { executionId: "caller", stepKey: "start:job" },
|
|
103
|
+
});
|
|
104
|
+
expect(record.context.get("tenant")).toBe("t-1");
|
|
105
|
+
|
|
106
|
+
const other = createStartedExecution({
|
|
107
|
+
start: prepare({ workflowId: "w" }),
|
|
108
|
+
caller,
|
|
109
|
+
now: 5,
|
|
110
|
+
});
|
|
111
|
+
expect(other.target.executionId).not.toBe(target.executionId);
|
|
112
|
+
expect(toExecutionRecord(other.target, 5).params).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("records admitted without start have no parent", () => {
|
|
116
|
+
const state = createExecutionState({
|
|
117
|
+
executionId: "e",
|
|
118
|
+
workflowId: "w",
|
|
119
|
+
encoded: encodeExecutionEvent({ params: undefined, context: undefined }),
|
|
120
|
+
now: 0,
|
|
121
|
+
});
|
|
122
|
+
expect(toExecutionRecord(state, 0)).not.toHaveProperty("parent");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("start params keep undefined properties", () => {
|
|
126
|
+
const caller = createExecutionState({
|
|
127
|
+
executionId: "caller",
|
|
128
|
+
workflowId: "parent",
|
|
129
|
+
encoded: encodeExecutionEvent({ params: undefined, context: undefined }),
|
|
130
|
+
now: 0,
|
|
131
|
+
});
|
|
132
|
+
const { target } = createStartedExecution({
|
|
133
|
+
start: prepare({ workflowId: "w", params: { a: undefined } }),
|
|
134
|
+
caller,
|
|
135
|
+
now: 0,
|
|
136
|
+
});
|
|
137
|
+
const params = toExecutionRecord(target, 0).params;
|
|
138
|
+
expect(params).toEqual({ a: undefined });
|
|
139
|
+
expect(Object.keys(params!)).toEqual(["a"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("execution events round trip through the codec", () => {
|
|
143
|
+
const params = {
|
|
144
|
+
missing: undefined,
|
|
145
|
+
list: [1, undefined],
|
|
146
|
+
tagged: { $type: "undefined" },
|
|
147
|
+
report: { name: "launch", message: "Ship it", stack: "web" },
|
|
148
|
+
};
|
|
149
|
+
const context = new ReadOnlyMap(
|
|
150
|
+
new Map<string, unknown>([
|
|
151
|
+
["tenant", "acme"],
|
|
152
|
+
["nothing", undefined],
|
|
153
|
+
])
|
|
154
|
+
);
|
|
155
|
+
const decoded = decodeExecutionEvent(encodeExecutionEvent({ params, context }));
|
|
156
|
+
expect(decoded.params).toEqual(params);
|
|
157
|
+
expect(Object.keys(decoded.params!)).toContain("missing");
|
|
158
|
+
expect(decoded.params!.report).not.toBeInstanceOf(Error);
|
|
159
|
+
expect([...decoded.context.entries()]).toEqual([
|
|
160
|
+
["tenant", "acme"],
|
|
161
|
+
["nothing", undefined],
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
const bare = decodeExecutionEvent(
|
|
165
|
+
encodeExecutionEvent({ params: undefined, context: undefined })
|
|
166
|
+
);
|
|
167
|
+
expect(bare.params).toBeUndefined();
|
|
168
|
+
expect(bare.context.size).toBe(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test.each([
|
|
172
|
+
["a Date in params", { params: { at: new Date(0) }, context: new Map() }],
|
|
173
|
+
["a Date in context", { params: {}, context: new Map([["at", new Date(0)]]) }],
|
|
174
|
+
["a number context key", { params: {}, context: new Map([[7, 1]]) }],
|
|
175
|
+
])("prepareExecutionAdmission rejects %s with INVALID_EXECUTION_EVENT", (_label, event) => {
|
|
176
|
+
expect(prepareExecutionAdmission({ workflowId: "w", ...event })).toMatchObject({
|
|
177
|
+
ok: false,
|
|
178
|
+
code: "INVALID_EXECUTION_EVENT",
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
class PaymentError extends Error {
|
|
183
|
+
code = "DECLINED";
|
|
184
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
185
|
+
super(message, options);
|
|
186
|
+
this.name = "PaymentError";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
test("failed outcomes keep the error's registered class, code and cause", () => {
|
|
191
|
+
const error = new WorkflowStartError(
|
|
192
|
+
"START_REQUEST_MISMATCH",
|
|
193
|
+
"mismatch"
|
|
194
|
+
);
|
|
195
|
+
error.cause = new PaymentError("declined");
|
|
196
|
+
const decoded = decodeExecutionOutcome(
|
|
197
|
+
encodeExecutionOutcome({ status: "failed", error })
|
|
198
|
+
);
|
|
199
|
+
if (decoded.status !== "failed") throw new Error("expected failed");
|
|
200
|
+
expect(decoded.error).toBeInstanceOf(WorkflowStartError);
|
|
201
|
+
expect(decoded.error).toMatchObject({ code: "START_REQUEST_MISMATCH", message: "mismatch" });
|
|
202
|
+
expect(decoded.error.stack).toBe(error.stack);
|
|
203
|
+
const cause = decoded.error.cause as Error & { code?: string };
|
|
204
|
+
expect([cause.name, cause.message, cause.code]).toEqual(["PaymentError", "declined", "DECLINED"]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("failed outcomes record a thrown non-error as Error(String(value))", () => {
|
|
208
|
+
const decoded = decodeExecutionOutcome(
|
|
209
|
+
encodeExecutionOutcome({ status: "failed", error: "boom" as unknown as Error })
|
|
210
|
+
);
|
|
211
|
+
if (decoded.status !== "failed") throw new Error("expected failed");
|
|
212
|
+
expect(Object.getPrototypeOf(decoded.error)).toBe(Error.prototype);
|
|
213
|
+
expect(decoded.error.message).toBe("boom");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("completed outcomes keep errors and undefined in the result", () => {
|
|
217
|
+
const result = { ok: false, error: new RangeError("r"), extra: undefined };
|
|
218
|
+
const decoded = decodeExecutionOutcome(
|
|
219
|
+
encodeExecutionOutcome({ status: "completed", result })
|
|
220
|
+
);
|
|
221
|
+
if (decoded.status !== "completed") throw new Error("expected completed");
|
|
222
|
+
expect(decoded.result).toEqual(result);
|
|
223
|
+
expect((decoded.result as typeof result).error).toBeInstanceOf(RangeError);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("acknowledging a non-durable result records a failed outcome with INVALID_STEP_VALUE", () => {
|
|
227
|
+
const admitted = createExecutionState({
|
|
228
|
+
executionId: "e",
|
|
229
|
+
workflowId: "w",
|
|
230
|
+
encoded: encodeExecutionEvent({ params: undefined, context: undefined }),
|
|
231
|
+
now: 0,
|
|
232
|
+
});
|
|
233
|
+
const { state, lease } = claimExecution(admitted, { now: 0, duration: 1_000 });
|
|
234
|
+
const acknowledged = acknowledgeExecution(state, {
|
|
235
|
+
lease,
|
|
236
|
+
outcome: { status: "completed", result: { at: new Date(0) } },
|
|
237
|
+
now: 1,
|
|
238
|
+
});
|
|
239
|
+
expect(acknowledged.state.status).toBe("failed");
|
|
240
|
+
const outcome = toExecutionRecord(acknowledged.state, 1).outcome;
|
|
241
|
+
for (const recorded of [acknowledged.result, outcome]) {
|
|
242
|
+
if (recorded?.status !== "failed") throw new Error("expected failed");
|
|
243
|
+
expect(recorded.error).toBeInstanceOf(WorkflowValueError);
|
|
244
|
+
expect(recorded.error).toMatchObject({
|
|
245
|
+
code: "INVALID_STEP_VALUE",
|
|
246
|
+
stepKey: "$$workflow-result$$",
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
});
|