@yieldstar/core 0.5.0 → 0.6.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/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 +244 -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 +1647 -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 +102 -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 +303 -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 +137 -3
- package/src/lib/effect-state.ts +254 -0
- package/src/lib/execution-dispatcher.ts +393 -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,650 @@
|
|
|
1
|
+
import type { EventContext, EventParams } from "../base/event";
|
|
2
|
+
import type {
|
|
3
|
+
ExecutionAcknowledgeResult,
|
|
4
|
+
ExecutionLease,
|
|
5
|
+
ExecutionOutcome,
|
|
6
|
+
ExecutionParent,
|
|
7
|
+
ExecutionRecord,
|
|
8
|
+
ExecutionReleaseResult,
|
|
9
|
+
ExecutionRenewResult,
|
|
10
|
+
ExecutionStartRequest,
|
|
11
|
+
ExecutionStartResult,
|
|
12
|
+
ExecutionStatus,
|
|
13
|
+
LeaseLost,
|
|
14
|
+
WorkflowRunOutcome,
|
|
15
|
+
} from "../base/execution";
|
|
16
|
+
import { isTerminalExecutionStatus } from "../base/execution";
|
|
17
|
+
import type { DurableValue } from "../base/durable-value";
|
|
18
|
+
import {
|
|
19
|
+
decodeParsedValue,
|
|
20
|
+
durableValueCodec,
|
|
21
|
+
encodeThrownValue,
|
|
22
|
+
isPlainObject,
|
|
23
|
+
} from "../base/durable-value";
|
|
24
|
+
import { WORKFLOW_RESULT_STEP_KEY, WorkflowValueError } from "../base/errors";
|
|
25
|
+
import { ReadOnlyMap } from "../utils/map";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Storage-neutral state of one execution record, with the event and outcome
|
|
29
|
+
* already encoded. Runtime adapters persist these fields however they like and
|
|
30
|
+
* apply the transitions below inside one atomic write, so every adapter keeps
|
|
31
|
+
* the same lease, wake and acknowledgement semantics.
|
|
32
|
+
*
|
|
33
|
+
* Lease transitions (claim, renew, release) do not change `updatedAt`.
|
|
34
|
+
*/
|
|
35
|
+
export type ExecutionState = {
|
|
36
|
+
executionId: string;
|
|
37
|
+
workflowId: string;
|
|
38
|
+
params: string;
|
|
39
|
+
context: string;
|
|
40
|
+
/**
|
|
41
|
+
* The status the execution rests in when it has no live lease. The record's
|
|
42
|
+
* `running` status is derived from the lease and the clock by
|
|
43
|
+
* `executionStatusAt`, so an expired lease reads as ended without a write.
|
|
44
|
+
*/
|
|
45
|
+
status: Exclude<ExecutionStatus, "running">;
|
|
46
|
+
outcome: string | null;
|
|
47
|
+
admittedAt: number;
|
|
48
|
+
updatedAt: number;
|
|
49
|
+
/** The earliest of the wakes not covered by a lease. */
|
|
50
|
+
dueAt: number | null;
|
|
51
|
+
/**
|
|
52
|
+
* The wakes the current lease covers. They stay pending until that lease is
|
|
53
|
+
* acknowledged, so a lease that is released or expires leaves them due.
|
|
54
|
+
*/
|
|
55
|
+
claimedDueAt: number | null;
|
|
56
|
+
/** The highest token issued for this execution (0 before the first claim). */
|
|
57
|
+
leaseToken: number;
|
|
58
|
+
/**
|
|
59
|
+
* Non-null while the lease with `leaseToken` is held, that is, claimed and
|
|
60
|
+
* not yet released or acknowledged. A held lease stays current after it
|
|
61
|
+
* expires, until a later claim issues a higher token.
|
|
62
|
+
*/
|
|
63
|
+
leaseExpiresAt: number | null;
|
|
64
|
+
/**
|
|
65
|
+
* Consecutive leases that ended by release or by expiry and a later claim.
|
|
66
|
+
* It does not include a held lease that has expired;
|
|
67
|
+
* `unacknowledgedDeliveryCountAt` adds that one.
|
|
68
|
+
*/
|
|
69
|
+
unacknowledgedDeliveryCount: number;
|
|
70
|
+
/**
|
|
71
|
+
* When a released execution may be claimed again. A held lease's delay is
|
|
72
|
+
* measured from `leaseExpiresAt` instead.
|
|
73
|
+
*/
|
|
74
|
+
redeliverAt: number | null;
|
|
75
|
+
/** Set at admission by `start`, and never changed. */
|
|
76
|
+
parent: ExecutionParent | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const DEFAULT_LEASE_DURATION = 30_000;
|
|
80
|
+
|
|
81
|
+
/** The redelivery delay after one unacknowledged delivery. */
|
|
82
|
+
export const REDELIVERY_DELAY_BASE = 1_000;
|
|
83
|
+
|
|
84
|
+
export const REDELIVERY_DELAY_MAX = 300_000;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* How long an execution waits after a delivery ends unacknowledged before it
|
|
88
|
+
* can be claimed again. Starts at `REDELIVERY_DELAY_BASE` and doubles with
|
|
89
|
+
* each consecutive unacknowledged delivery, up to `REDELIVERY_DELAY_MAX`.
|
|
90
|
+
*/
|
|
91
|
+
export function redeliveryDelay(unacknowledgedDeliveryCount: number): number {
|
|
92
|
+
if (unacknowledgedDeliveryCount <= 0) return 0;
|
|
93
|
+
const exponent = Math.min(unacknowledgedDeliveryCount - 1, 30);
|
|
94
|
+
return Math.min(REDELIVERY_DELAY_BASE * 2 ** exponent, REDELIVERY_DELAY_MAX);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class ExecutionEventEncodingError extends Error {
|
|
98
|
+
constructor(message: string) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.name = "ExecutionEventEncodingError";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Encodes `params` with the durable value codec and `context` as its entries,
|
|
106
|
+
* so that decoding returns exactly what was admitted. Context keys must be
|
|
107
|
+
* strings. Throws `ExecutionEventEncodingError` for any value the codec
|
|
108
|
+
* rejects.
|
|
109
|
+
*/
|
|
110
|
+
export function encodeExecutionEvent(event: {
|
|
111
|
+
params: EventParams;
|
|
112
|
+
context: EventContext | undefined;
|
|
113
|
+
}): { params: string; context: string } {
|
|
114
|
+
const params = durableValueCodec.encode(event.params);
|
|
115
|
+
if (params.status === "invalid") {
|
|
116
|
+
throw new ExecutionEventEncodingError(`params${params.message.slice(1)}`);
|
|
117
|
+
}
|
|
118
|
+
const entries: unknown[] = [];
|
|
119
|
+
for (const [key, value] of event.context?.entries() ?? []) {
|
|
120
|
+
if (typeof key !== "string") {
|
|
121
|
+
throw new ExecutionEventEncodingError(
|
|
122
|
+
`context: keys must be strings, got ${typeof key}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
entries.push([key, value]);
|
|
126
|
+
}
|
|
127
|
+
const context = durableValueCodec.encode(entries);
|
|
128
|
+
if (context.status === "invalid") {
|
|
129
|
+
throw new ExecutionEventEncodingError(`context${context.message.slice(1)}`);
|
|
130
|
+
}
|
|
131
|
+
return { params: params.canonical, context: context.canonical };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function decodeExecutionEvent(encoded: {
|
|
135
|
+
params: string;
|
|
136
|
+
context: string;
|
|
137
|
+
}): { params: EventParams; context: EventContext } {
|
|
138
|
+
const entries = decodeOrThrow(encoded.context, "context");
|
|
139
|
+
if (
|
|
140
|
+
!Array.isArray(entries) ||
|
|
141
|
+
!entries.every(
|
|
142
|
+
(entry) => Array.isArray(entry) && entry.length === 2 && typeof entry[0] === "string"
|
|
143
|
+
)
|
|
144
|
+
) {
|
|
145
|
+
throw new Error("Invalid execution context encoding: expected [key, value] entries");
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
params: decodeOrThrow(encoded.params, "params") as EventParams,
|
|
149
|
+
context: new ReadOnlyMap(new Map(entries as [string, DurableValue][])),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Encodes a terminal outcome. A completed result the codec rejects is recorded
|
|
155
|
+
* as a failed outcome with `WorkflowValueError` (`INVALID_STEP_VALUE`), and a
|
|
156
|
+
* failure that is not an error is recorded as `Error(String(value))`.
|
|
157
|
+
*/
|
|
158
|
+
export function encodeExecutionOutcome(outcome: ExecutionOutcome): string {
|
|
159
|
+
if (outcome.status === "completed") {
|
|
160
|
+
const result = durableValueCodec.encode(outcome.result);
|
|
161
|
+
if (result.status === "encoded") {
|
|
162
|
+
return `{"result":${result.canonical},"status":"completed"}`;
|
|
163
|
+
}
|
|
164
|
+
return encodeFailedOutcome(
|
|
165
|
+
new WorkflowValueError(
|
|
166
|
+
"INVALID_STEP_VALUE",
|
|
167
|
+
WORKFLOW_RESULT_STEP_KEY,
|
|
168
|
+
`Workflow result is not a durable value: ${result.message}`
|
|
169
|
+
)
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return encodeFailedOutcome(outcome.error);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function encodeFailedOutcome(error: unknown) {
|
|
176
|
+
// An outcome's error is always an Error, so a thrown value that is not one
|
|
177
|
+
// is recorded as Error(String(value)) even when it is durable.
|
|
178
|
+
const encoded = encodeThrownValue(
|
|
179
|
+
error instanceof Error ? error : new Error(safeString(error))
|
|
180
|
+
);
|
|
181
|
+
return `{"error":${encoded},"status":"failed"}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function decodeExecutionOutcome(encoded: string): ExecutionOutcome {
|
|
185
|
+
const parsed = JSON.parse(encoded);
|
|
186
|
+
if (parsed.status === "completed") {
|
|
187
|
+
return { status: "completed", result: decodeParsedValue(parsed.result, "result") };
|
|
188
|
+
}
|
|
189
|
+
const error = decodeParsedValue(parsed.error, "error");
|
|
190
|
+
if (!(error instanceof Error)) {
|
|
191
|
+
throw new Error("Invalid failed outcome encoding: expected an error");
|
|
192
|
+
}
|
|
193
|
+
return { status: "failed", error };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function decodeOrThrow(canonical: string, label: string) {
|
|
197
|
+
const decoding = durableValueCodec.decode(canonical);
|
|
198
|
+
if (decoding.status === "invalid") {
|
|
199
|
+
throw new Error(`Invalid ${label} encoding: ${decoding.message}`);
|
|
200
|
+
}
|
|
201
|
+
return decoding.value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function safeString(value: unknown) {
|
|
205
|
+
try {
|
|
206
|
+
return String(value);
|
|
207
|
+
} catch {
|
|
208
|
+
return "Thrown value is not a durable value";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function createExecutionState(params: {
|
|
213
|
+
executionId: string;
|
|
214
|
+
workflowId: string;
|
|
215
|
+
encoded: { params: string; context: string };
|
|
216
|
+
now: number;
|
|
217
|
+
parent?: ExecutionParent;
|
|
218
|
+
}): ExecutionState {
|
|
219
|
+
return {
|
|
220
|
+
executionId: params.executionId,
|
|
221
|
+
workflowId: params.workflowId,
|
|
222
|
+
params: params.encoded.params,
|
|
223
|
+
context: params.encoded.context,
|
|
224
|
+
status: "admitted",
|
|
225
|
+
outcome: null,
|
|
226
|
+
admittedAt: params.now,
|
|
227
|
+
updatedAt: params.now,
|
|
228
|
+
dueAt: params.now,
|
|
229
|
+
claimedDueAt: null,
|
|
230
|
+
leaseToken: 0,
|
|
231
|
+
leaseExpiresAt: null,
|
|
232
|
+
unacknowledgedDeliveryCount: 0,
|
|
233
|
+
redeliverAt: null,
|
|
234
|
+
parent: params.parent ?? null,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Whether a lease is held and has expired. Such a lease is still current. */
|
|
239
|
+
function hasExpiredLease(state: ExecutionState, now: number) {
|
|
240
|
+
return state.leaseExpiresAt !== null && state.leaseExpiresAt <= now;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The record's status at `now`: `running` while a lease is live. */
|
|
244
|
+
export function executionStatusAt(
|
|
245
|
+
state: ExecutionState,
|
|
246
|
+
now: number
|
|
247
|
+
): ExecutionStatus {
|
|
248
|
+
if (state.leaseExpiresAt !== null && state.leaseExpiresAt > now) {
|
|
249
|
+
return "running";
|
|
250
|
+
}
|
|
251
|
+
return state.status;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Consecutive unacknowledged deliveries at `now`, counting a held lease that
|
|
256
|
+
* has expired as ended.
|
|
257
|
+
*/
|
|
258
|
+
export function unacknowledgedDeliveryCountAt(state: ExecutionState, now: number) {
|
|
259
|
+
return state.unacknowledgedDeliveryCount + (hasExpiredLease(state, now) ? 1 : 0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function toExecutionRecord(
|
|
263
|
+
state: ExecutionState,
|
|
264
|
+
now: number
|
|
265
|
+
): ExecutionRecord {
|
|
266
|
+
const event = decodeExecutionEvent(state);
|
|
267
|
+
return {
|
|
268
|
+
executionId: state.executionId,
|
|
269
|
+
workflowId: state.workflowId,
|
|
270
|
+
params: event.params,
|
|
271
|
+
context: event.context,
|
|
272
|
+
status: executionStatusAt(state, now),
|
|
273
|
+
...(state.outcome !== null
|
|
274
|
+
? { outcome: decodeExecutionOutcome(state.outcome) }
|
|
275
|
+
: {}),
|
|
276
|
+
admittedAt: state.admittedAt,
|
|
277
|
+
updatedAt: state.updatedAt,
|
|
278
|
+
unacknowledgedDeliveryCount: unacknowledgedDeliveryCountAt(state, now),
|
|
279
|
+
...(state.parent !== null ? { parent: { ...state.parent } } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The earliest time `claimDue` may return this execution, or null when it has
|
|
285
|
+
* nothing pending. It depends only on the state, not on the clock, so
|
|
286
|
+
* adapters can persist and index it to find due executions.
|
|
287
|
+
*/
|
|
288
|
+
export function nextClaimAt(state: ExecutionState): number | null {
|
|
289
|
+
if (isTerminalExecutionStatus(state.status)) return null;
|
|
290
|
+
if (state.leaseExpiresAt !== null) {
|
|
291
|
+
// A held lease covers the wakes that were due when it was claimed. Once it
|
|
292
|
+
// expires, it counts as unacknowledged, and the redelivery delay runs
|
|
293
|
+
// from its expiry.
|
|
294
|
+
const due = earliest(state.dueAt, state.claimedDueAt);
|
|
295
|
+
if (due === null) return null;
|
|
296
|
+
return Math.max(
|
|
297
|
+
due,
|
|
298
|
+
state.leaseExpiresAt + redeliveryDelay(state.unacknowledgedDeliveryCount + 1)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (state.dueAt === null) return null;
|
|
302
|
+
return state.redeliverAt === null
|
|
303
|
+
? state.dueAt
|
|
304
|
+
: Math.max(state.dueAt, state.redeliverAt);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function isExecutionClaimable(state: ExecutionState, now: number) {
|
|
308
|
+
const at = nextClaimAt(state);
|
|
309
|
+
return at !== null && at <= now;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Whether `lease` is the execution's current lease: its token is the highest
|
|
314
|
+
* issued and the lease has not been released or acknowledged. Expiry alone
|
|
315
|
+
* does not end it. Adapters check this in the same atomic write as every
|
|
316
|
+
* fenced write.
|
|
317
|
+
*/
|
|
318
|
+
export function isLeaseCurrent(state: ExecutionState, lease: ExecutionLease) {
|
|
319
|
+
return (
|
|
320
|
+
state.executionId === lease.executionId &&
|
|
321
|
+
state.leaseExpiresAt !== null &&
|
|
322
|
+
state.leaseToken === lease.token
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const LOST: LeaseLost = { status: "lost" };
|
|
327
|
+
|
|
328
|
+
/** Returns null when the execution is terminal and the wake is ignored. */
|
|
329
|
+
export function requestExecutionWake(
|
|
330
|
+
state: ExecutionState,
|
|
331
|
+
wakeAt: number,
|
|
332
|
+
now: number
|
|
333
|
+
): ExecutionState | null {
|
|
334
|
+
if (isTerminalExecutionStatus(state.status)) return null;
|
|
335
|
+
return { ...state, dueAt: earliest(state.dueAt, wakeAt), updatedAt: now };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Leases a claimable execution with the next token. Adapters check
|
|
340
|
+
* `isExecutionClaimable` first, in the same atomic write.
|
|
341
|
+
*/
|
|
342
|
+
export function claimExecution(
|
|
343
|
+
state: ExecutionState,
|
|
344
|
+
params: { now: number; duration: number }
|
|
345
|
+
): { state: ExecutionState; lease: ExecutionLease } {
|
|
346
|
+
const lease: ExecutionLease = {
|
|
347
|
+
executionId: state.executionId,
|
|
348
|
+
token: state.leaseToken + 1,
|
|
349
|
+
expiresAt: params.now + params.duration,
|
|
350
|
+
};
|
|
351
|
+
return {
|
|
352
|
+
state: {
|
|
353
|
+
...state,
|
|
354
|
+
// Claiming over an expired lease ends it unacknowledged.
|
|
355
|
+
unacknowledgedDeliveryCount:
|
|
356
|
+
state.unacknowledgedDeliveryCount + (state.leaseExpiresAt !== null ? 1 : 0),
|
|
357
|
+
// The lease covers the wakes due now, plus any an expired lease covered.
|
|
358
|
+
claimedDueAt: earliest(state.dueAt, state.claimedDueAt),
|
|
359
|
+
dueAt: null,
|
|
360
|
+
leaseToken: lease.token,
|
|
361
|
+
leaseExpiresAt: lease.expiresAt,
|
|
362
|
+
redeliverAt: null,
|
|
363
|
+
},
|
|
364
|
+
lease,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function renewExecutionLease(
|
|
369
|
+
state: ExecutionState,
|
|
370
|
+
params: { lease: ExecutionLease; duration: number; now: number }
|
|
371
|
+
): { state: ExecutionState; result: ExecutionRenewResult } {
|
|
372
|
+
if (!isLeaseCurrent(state, params.lease)) return { state, result: LOST };
|
|
373
|
+
const lease: ExecutionLease = {
|
|
374
|
+
...params.lease,
|
|
375
|
+
expiresAt: params.now + params.duration,
|
|
376
|
+
};
|
|
377
|
+
return {
|
|
378
|
+
state: { ...state, leaseExpiresAt: lease.expiresAt },
|
|
379
|
+
result: { status: "renewed", lease },
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function releaseExecutionLease(
|
|
384
|
+
state: ExecutionState,
|
|
385
|
+
params: { lease: ExecutionLease; now: number }
|
|
386
|
+
): { state: ExecutionState; result: ExecutionReleaseResult } {
|
|
387
|
+
if (!isLeaseCurrent(state, params.lease)) return { state, result: LOST };
|
|
388
|
+
const unacknowledgedDeliveryCount = state.unacknowledgedDeliveryCount + 1;
|
|
389
|
+
return {
|
|
390
|
+
state: {
|
|
391
|
+
...state,
|
|
392
|
+
// The wakes the lease covered stay pending.
|
|
393
|
+
dueAt: earliest(state.dueAt, state.claimedDueAt),
|
|
394
|
+
claimedDueAt: null,
|
|
395
|
+
leaseExpiresAt: null,
|
|
396
|
+
unacknowledgedDeliveryCount,
|
|
397
|
+
redeliverAt: params.now + redeliveryDelay(unacknowledgedDeliveryCount),
|
|
398
|
+
},
|
|
399
|
+
result: { status: "released" },
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function acknowledgeExecution(
|
|
404
|
+
state: ExecutionState,
|
|
405
|
+
params: {
|
|
406
|
+
lease: ExecutionLease;
|
|
407
|
+
outcome: WorkflowRunOutcome;
|
|
408
|
+
now: number;
|
|
409
|
+
}
|
|
410
|
+
): { state: ExecutionState; result: ExecutionAcknowledgeResult } {
|
|
411
|
+
if (!isLeaseCurrent(state, params.lease)) return { state, result: LOST };
|
|
412
|
+
|
|
413
|
+
// A held lease is never on a terminal record, but keep the first outcome
|
|
414
|
+
// regardless: terminal outcomes are immutable.
|
|
415
|
+
if (isTerminalExecutionStatus(state.status)) {
|
|
416
|
+
return { state, result: decodeExecutionOutcome(state.outcome!) };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const { outcome, now } = params;
|
|
420
|
+
const ended = {
|
|
421
|
+
claimedDueAt: null,
|
|
422
|
+
leaseExpiresAt: null,
|
|
423
|
+
unacknowledgedDeliveryCount: 0,
|
|
424
|
+
redeliverAt: null,
|
|
425
|
+
updatedAt: now,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
if (outcome.status !== "suspended") {
|
|
429
|
+
const encoded = encodeExecutionOutcome(
|
|
430
|
+
outcome.status === "completed"
|
|
431
|
+
? { status: "completed", result: outcome.result }
|
|
432
|
+
: { status: "failed", error: outcome.error }
|
|
433
|
+
);
|
|
434
|
+
// An invalid result is recorded as failed, so the status comes from the
|
|
435
|
+
// encoding rather than the reported outcome.
|
|
436
|
+
const terminal = decodeExecutionOutcome(encoded);
|
|
437
|
+
return {
|
|
438
|
+
// Reaching a terminal status clears every pending wake.
|
|
439
|
+
state: {
|
|
440
|
+
...state,
|
|
441
|
+
...ended,
|
|
442
|
+
status: terminal.status,
|
|
443
|
+
outcome: encoded,
|
|
444
|
+
dueAt: null,
|
|
445
|
+
},
|
|
446
|
+
result: terminal,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
// The wakes the lease covered are cleared; wakes requested since the
|
|
452
|
+
// claim stay pending, merged with the outcome's `wakeAt`.
|
|
453
|
+
state: {
|
|
454
|
+
...state,
|
|
455
|
+
...ended,
|
|
456
|
+
status: "suspended",
|
|
457
|
+
dueAt: earliest(state.dueAt, outcome.wakeAt ?? null),
|
|
458
|
+
},
|
|
459
|
+
result: { status: "suspended" },
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function earliest(a: number | null, b: number | null): number | null {
|
|
464
|
+
if (a === null) return b;
|
|
465
|
+
if (b === null) return a;
|
|
466
|
+
return Math.min(a, b);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Validates an admission request and encodes its event. Adapters call this
|
|
471
|
+
* before their atomic insert, then reject with `EXECUTION_ID_CONFLICT` if the
|
|
472
|
+
* ID is already in use.
|
|
473
|
+
*/
|
|
474
|
+
export function prepareExecutionAdmission(request: {
|
|
475
|
+
executionId?: string;
|
|
476
|
+
workflowId: string;
|
|
477
|
+
params: EventParams;
|
|
478
|
+
context: EventContext;
|
|
479
|
+
}):
|
|
480
|
+
| { ok: true; executionId: string; encoded: { params: string; context: string } }
|
|
481
|
+
| { ok: false; code: "INVALID_EXECUTION_EVENT"; message: string } {
|
|
482
|
+
if (typeof request.workflowId !== "string" || request.workflowId === "") {
|
|
483
|
+
return {
|
|
484
|
+
ok: false,
|
|
485
|
+
code: "INVALID_EXECUTION_EVENT",
|
|
486
|
+
message: "workflowId must be a non-empty string",
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
if (
|
|
490
|
+
request.executionId !== undefined &&
|
|
491
|
+
(typeof request.executionId !== "string" || request.executionId === "")
|
|
492
|
+
) {
|
|
493
|
+
return {
|
|
494
|
+
ok: false,
|
|
495
|
+
code: "INVALID_EXECUTION_EVENT",
|
|
496
|
+
message: "executionId must be a non-empty string",
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
try {
|
|
500
|
+
return {
|
|
501
|
+
ok: true,
|
|
502
|
+
executionId: request.executionId ?? crypto.randomUUID(),
|
|
503
|
+
encoded: encodeExecutionEvent(request),
|
|
504
|
+
};
|
|
505
|
+
} catch (err) {
|
|
506
|
+
if (!(err instanceof ExecutionEventEncodingError)) throw err;
|
|
507
|
+
return { ok: false, code: "INVALID_EXECUTION_EVENT", message: err.message };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function executionIdConflict(executionId: string) {
|
|
512
|
+
return {
|
|
513
|
+
status: "rejected" as const,
|
|
514
|
+
code: "EXECUTION_ID_CONFLICT" as const,
|
|
515
|
+
message: `Execution "${executionId}" already exists`,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export function executionNotFound(executionId: string) {
|
|
520
|
+
return {
|
|
521
|
+
status: "rejected" as const,
|
|
522
|
+
code: "EXECUTION_NOT_FOUND" as const,
|
|
523
|
+
message: `Execution "${executionId}" does not exist`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* A recorded start request, keyed by `callerExecutionId` and `stepKey`.
|
|
529
|
+
* `executionId` is the target it admitted. Adapters keep it until the caller
|
|
530
|
+
* is pruned, including after the target is terminal.
|
|
531
|
+
*/
|
|
532
|
+
export type StartRequestState = {
|
|
533
|
+
callerExecutionId: string;
|
|
534
|
+
stepKey: string;
|
|
535
|
+
workflowId: string;
|
|
536
|
+
/** The canonical params, or null when the request had none. */
|
|
537
|
+
params: string | null;
|
|
538
|
+
executionId: string;
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
export type PreparedExecutionStart = {
|
|
542
|
+
callerExecutionId: string;
|
|
543
|
+
stepKey: string;
|
|
544
|
+
workflowId: string;
|
|
545
|
+
params: string | null;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Validates a start request and encodes its params canonically. Adapters call
|
|
550
|
+
* this before touching storage and return the rejection as is.
|
|
551
|
+
*/
|
|
552
|
+
export function prepareExecutionStart(
|
|
553
|
+
request: ExecutionStartRequest
|
|
554
|
+
):
|
|
555
|
+
| { ok: true; start: PreparedExecutionStart }
|
|
556
|
+
| {
|
|
557
|
+
ok: false;
|
|
558
|
+
result: Extract<ExecutionStartResult, { status: "rejected" }>;
|
|
559
|
+
} {
|
|
560
|
+
const invalid = (message: string) => ({
|
|
561
|
+
ok: false as const,
|
|
562
|
+
result: {
|
|
563
|
+
status: "rejected" as const,
|
|
564
|
+
code: "INVALID_START_PARAMETERS" as const,
|
|
565
|
+
message,
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
if (typeof request.workflowId !== "string" || request.workflowId === "") {
|
|
569
|
+
return invalid("workflowId must be a non-empty string");
|
|
570
|
+
}
|
|
571
|
+
let params: string | null = null;
|
|
572
|
+
if (request.params !== undefined) {
|
|
573
|
+
if (!isPlainObject(request.params)) {
|
|
574
|
+
return invalid("params must be a plain object");
|
|
575
|
+
}
|
|
576
|
+
const encoding = durableValueCodec.encode(request.params);
|
|
577
|
+
if (encoding.status === "invalid") {
|
|
578
|
+
return invalid(`params: ${encoding.message}`);
|
|
579
|
+
}
|
|
580
|
+
params = encoding.canonical;
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
ok: true,
|
|
584
|
+
start: {
|
|
585
|
+
callerExecutionId: request.requestId.executionId,
|
|
586
|
+
stepKey: request.requestId.stepKey,
|
|
587
|
+
workflowId: request.workflowId,
|
|
588
|
+
params,
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Answers a repeated start request from its record: the first target's ID when
|
|
595
|
+
* the workflow ID and canonical params match, and `START_REQUEST_MISMATCH`
|
|
596
|
+
* otherwise. The target's status does not matter.
|
|
597
|
+
*/
|
|
598
|
+
export function matchStartRequest(
|
|
599
|
+
recorded: StartRequestState,
|
|
600
|
+
start: PreparedExecutionStart
|
|
601
|
+
): ExecutionStartResult {
|
|
602
|
+
if (
|
|
603
|
+
recorded.workflowId === start.workflowId &&
|
|
604
|
+
recorded.params === start.params
|
|
605
|
+
) {
|
|
606
|
+
return { status: "accepted", executionId: recorded.executionId };
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
status: "rejected",
|
|
610
|
+
code: "START_REQUEST_MISMATCH",
|
|
611
|
+
message: `Step "${start.stepKey}" of execution "${start.callerExecutionId}" already started workflow "${recorded.workflowId}" with different parameters or workflow ID`,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Builds a new start's target state and start-request record. The target
|
|
617
|
+
* inherits the caller's encoded context and links to the caller as its parent.
|
|
618
|
+
* Adapters insert both in one atomic write, after finding no recorded request
|
|
619
|
+
* for the same ID. `executionId` defaults to a random UUID; if it is already in
|
|
620
|
+
* use, adapters must not reuse it.
|
|
621
|
+
*/
|
|
622
|
+
export function createStartedExecution(params: {
|
|
623
|
+
start: PreparedExecutionStart;
|
|
624
|
+
caller: ExecutionState;
|
|
625
|
+
now: number;
|
|
626
|
+
executionId?: string;
|
|
627
|
+
}): { target: ExecutionState; startRequest: StartRequestState } {
|
|
628
|
+
const { start, caller, now } = params;
|
|
629
|
+
const executionId = params.executionId ?? crypto.randomUUID();
|
|
630
|
+
const target = createExecutionState({
|
|
631
|
+
executionId,
|
|
632
|
+
workflowId: start.workflowId,
|
|
633
|
+
encoded: {
|
|
634
|
+
params: start.params ?? encodeExecutionEvent({ params: undefined, context: undefined }).params,
|
|
635
|
+
context: caller.context,
|
|
636
|
+
},
|
|
637
|
+
now,
|
|
638
|
+
parent: { executionId: start.callerExecutionId, stepKey: start.stepKey },
|
|
639
|
+
});
|
|
640
|
+
return {
|
|
641
|
+
target,
|
|
642
|
+
startRequest: {
|
|
643
|
+
callerExecutionId: start.callerExecutionId,
|
|
644
|
+
stepKey: start.stepKey,
|
|
645
|
+
workflowId: start.workflowId,
|
|
646
|
+
params: start.params,
|
|
647
|
+
executionId,
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { EventEmitter } from "node:events";
|
|
2
|
+
import type { ExecutionClient, ExecutionOutcome } from "../base/execution";
|
|
3
|
+
import { isTerminalExecutionStatus } from "../base/execution";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves with an execution's outcome once its record is terminal, or with
|
|
7
|
+
* undefined if no record exists. The record is the source of truth, so this
|
|
8
|
+
* settles even if the process that ran the execution restarted. An optional
|
|
9
|
+
* `emitter` (the invoker's deprecated `workflowEndEmitter`) only nudges an
|
|
10
|
+
* early re-read.
|
|
11
|
+
*/
|
|
12
|
+
export async function waitForExecutionOutcome(
|
|
13
|
+
executions: Pick<ExecutionClient, "get">,
|
|
14
|
+
executionId: string,
|
|
15
|
+
options?: { emitter?: EventEmitter; pollInterval?: number; signal?: AbortSignal }
|
|
16
|
+
): Promise<ExecutionOutcome | undefined> {
|
|
17
|
+
const pollInterval = options?.pollInterval ?? 25;
|
|
18
|
+
const emitter = options?.emitter;
|
|
19
|
+
let wake: (() => void) | undefined;
|
|
20
|
+
const nudge = () => wake?.();
|
|
21
|
+
emitter?.on(executionId, nudge);
|
|
22
|
+
try {
|
|
23
|
+
while (true) {
|
|
24
|
+
options?.signal?.throwIfAborted();
|
|
25
|
+
const record = await executions.get(executionId);
|
|
26
|
+
if (!record) return undefined;
|
|
27
|
+
if (isTerminalExecutionStatus(record.status)) return record.outcome;
|
|
28
|
+
await new Promise<void>((resolve) => {
|
|
29
|
+
const timer = setTimeout(resolve, pollInterval);
|
|
30
|
+
wake = () => {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
resolve();
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
wake = undefined;
|
|
36
|
+
}
|
|
37
|
+
} finally {
|
|
38
|
+
emitter?.off(executionId, nudge);
|
|
39
|
+
}
|
|
40
|
+
}
|