yieldstar 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/README.md +43 -52
- package/dist/exports/errors.d.ts.map +1 -1
- package/dist/exports/sdk-http.d.ts +8 -0
- package/dist/exports/sdk-http.d.ts.map +1 -1
- package/dist/exports/sdk-local.d.ts +19 -2
- package/dist/exports/sdk-local.d.ts.map +1 -1
- package/dist/exports/workflow.d.ts +14 -2
- package/dist/exports/workflow.d.ts.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +664 -214
- package/dist/internal/effect-call.d.ts +60 -0
- package/dist/internal/effect-call.d.ts.map +1 -0
- package/dist/internal/runtime-call.d.ts +39 -0
- package/dist/internal/runtime-call.d.ts.map +1 -0
- package/dist/internal/serialise.d.ts.map +1 -1
- package/dist/internal/step-record.d.ts +13 -0
- package/dist/internal/step-record.d.ts.map +1 -0
- package/dist/internal/step-runner.d.ts +77 -14
- package/dist/internal/step-runner.d.ts.map +1 -1
- package/package.json +2 -3
- package/src/exports/errors.ts +4 -0
- package/src/exports/sdk-http.ts +60 -27
- package/src/exports/sdk-local.ts +41 -15
- package/src/exports/workflow.ts +157 -101
- package/src/index.ts +34 -2
- package/src/internal/effect-call.ts +217 -0
- package/src/internal/runtime-call.ts +95 -0
- package/src/internal/step-record.ts +110 -0
- package/src/internal/step-runner.ts +515 -191
- package/src/internal/serialise.ts +0 -47
- package/tsconfig.tsbuildinfo +0 -1
package/src/exports/workflow.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import type { Logger } from "pino";
|
|
2
2
|
import type {
|
|
3
|
+
Durable,
|
|
3
4
|
WorkflowEvent,
|
|
4
5
|
WorkflowGenerator,
|
|
5
6
|
EventParams,
|
|
6
7
|
EventContext,
|
|
8
|
+
IfDurable,
|
|
7
9
|
} from "@yieldstar/core";
|
|
8
10
|
import type { StepRunner } from "../internal/step-runner";
|
|
9
11
|
import { isIterable } from "../internal/utils";
|
|
10
|
-
import {
|
|
11
|
-
deserializeStepResponse,
|
|
12
|
-
serializeStepResponse,
|
|
13
|
-
} from "../internal/serialise";
|
|
12
|
+
import { decodeStepRecord, encodeStepRecord } from "../internal/step-record";
|
|
14
13
|
import { createStepRunner } from "../internal/step-runner";
|
|
14
|
+
import {
|
|
15
|
+
StepRuntimeCall,
|
|
16
|
+
executeRuntimeCall,
|
|
17
|
+
infrastructure,
|
|
18
|
+
} from "../internal/runtime-call";
|
|
19
|
+
import { StepEffectCall, executeEffectCall } from "../internal/effect-call";
|
|
15
20
|
import {
|
|
16
21
|
StepResponse,
|
|
17
22
|
StepKey,
|
|
@@ -19,9 +24,15 @@ import {
|
|
|
19
24
|
StepResult,
|
|
20
25
|
StepDelay,
|
|
21
26
|
StepCacheCheck,
|
|
27
|
+
StepEffectWait,
|
|
22
28
|
StepInvalid,
|
|
23
29
|
StepStoreWait,
|
|
24
30
|
WorkflowResult,
|
|
31
|
+
InfrastructureError,
|
|
32
|
+
LeaseLostError,
|
|
33
|
+
StepConflictError,
|
|
34
|
+
isLeaseLost,
|
|
35
|
+
WORKFLOW_RESULT_STEP_KEY,
|
|
25
36
|
} from "@yieldstar/core";
|
|
26
37
|
|
|
27
38
|
export type WorkflowFn<
|
|
@@ -34,51 +45,70 @@ export type WorkflowFn<
|
|
|
34
45
|
logger: Logger
|
|
35
46
|
) => AsyncGenerator<any, Result>;
|
|
36
47
|
|
|
48
|
+
/**
|
|
49
|
+
* A workflow function whose params and result are durable values. Params are
|
|
50
|
+
* an input, so a function type cannot check them; a missing property named
|
|
51
|
+
* for the problem does instead.
|
|
52
|
+
*/
|
|
53
|
+
export type DurableWorkflowFn<Params, Result> = IfDurable<
|
|
54
|
+
Params,
|
|
55
|
+
{ "Workflow params must be durable values": Durable<Params> }
|
|
56
|
+
> &
|
|
57
|
+
IfDurable<
|
|
58
|
+
Result,
|
|
59
|
+
(step: any, event: any, logger: any) => AsyncGenerator<any, Durable<Result>>
|
|
60
|
+
>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Wraps a workflow function in a generator that advances its steps and yields
|
|
64
|
+
* control to the executor whenever a step needs async work.
|
|
65
|
+
*/
|
|
37
66
|
export function workflow<
|
|
38
67
|
Params extends EventParams,
|
|
39
68
|
Result,
|
|
40
69
|
Context extends EventContext
|
|
41
70
|
>(
|
|
42
|
-
workflowFn: WorkflowFn<Params, Result, Context>
|
|
71
|
+
workflowFn: WorkflowFn<Params, Result, Context> &
|
|
72
|
+
DurableWorkflowFn<Params, Result>
|
|
43
73
|
): WorkflowGenerator<Params, Result, Context> {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
74
|
+
return async function* workflowGenerator({
|
|
75
|
+
event,
|
|
76
|
+
lease,
|
|
77
|
+
signal,
|
|
78
|
+
heapClient,
|
|
79
|
+
storeClient,
|
|
80
|
+
executionClient,
|
|
81
|
+
effectClient,
|
|
82
|
+
clock,
|
|
83
|
+
logger,
|
|
84
|
+
}) {
|
|
85
|
+
const stepRunner = createStepRunner({ event, clock });
|
|
51
86
|
const workflowIterator = workflowFn(stepRunner, event, logger);
|
|
52
87
|
const { executionId } = event;
|
|
53
88
|
|
|
54
89
|
let iteratorResult: IteratorResult<any> | null = null;
|
|
55
90
|
let nextIteratorResult: IteratorResult<any> | null = null;
|
|
91
|
+
// A step that produced a result resumes with its decoded record, which
|
|
92
|
+
// is what it returns to workflow code.
|
|
93
|
+
let resumeWith: StepResult | undefined;
|
|
56
94
|
const stepKeys = new Set<string>();
|
|
57
95
|
|
|
58
96
|
while (true) {
|
|
59
97
|
let stepAttempt = 0;
|
|
60
98
|
let stepKey: string;
|
|
61
99
|
|
|
62
|
-
|
|
63
|
-
* If we already have the next iterator result from a previous iteration
|
|
64
|
-
* (e.g. it threw an error and advanced the workflow), use that, otherwise,
|
|
65
|
-
* advance the workflow generator
|
|
66
|
-
*/
|
|
100
|
+
// Throwing a step error into the workflow already advanced it.
|
|
67
101
|
if (nextIteratorResult) {
|
|
68
102
|
iteratorResult = nextIteratorResult;
|
|
69
103
|
nextIteratorResult = null;
|
|
70
104
|
} else {
|
|
71
|
-
|
|
105
|
+
stopIfAborted(signal);
|
|
106
|
+
iteratorResult = await workflowIterator.next(resumeWith);
|
|
72
107
|
}
|
|
108
|
+
resumeWith = undefined;
|
|
73
109
|
|
|
74
|
-
/**
|
|
75
|
-
* Get the StepKey from the step runner.
|
|
76
|
-
* If the generator is done, the workflow has returned, so we set a
|
|
77
|
-
* special key for that.
|
|
78
|
-
* Finally, check that this call site wasn't reached before.
|
|
79
|
-
*/
|
|
80
110
|
if (iteratorResult.done) {
|
|
81
|
-
stepKey =
|
|
111
|
+
stepKey = WORKFLOW_RESULT_STEP_KEY;
|
|
82
112
|
} else if (!(iteratorResult.value instanceof StepKey)) {
|
|
83
113
|
throw new Error("Step runners must yield a StepKey object");
|
|
84
114
|
} else {
|
|
@@ -89,41 +119,26 @@ export function workflow<
|
|
|
89
119
|
stepKeys.add(stepKey)
|
|
90
120
|
}
|
|
91
121
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
stepKey,
|
|
99
|
-
});
|
|
122
|
+
let cached = await infrastructure(() =>
|
|
123
|
+
heapClient.readStep({
|
|
124
|
+
executionId,
|
|
125
|
+
stepKey,
|
|
126
|
+
})
|
|
127
|
+
);
|
|
100
128
|
|
|
101
|
-
/**
|
|
102
|
-
* Increment attempt counter. We'll save this after execution.
|
|
103
|
-
*/
|
|
104
129
|
if (cached?.meta) {
|
|
105
130
|
stepAttempt = cached.meta.attempt + 1;
|
|
106
131
|
}
|
|
107
132
|
|
|
108
|
-
|
|
109
|
-
* If the step needs to be retried, ignore the cached value
|
|
110
|
-
*/
|
|
133
|
+
// An unfinished record is a failed attempt: run the step again.
|
|
111
134
|
if (cached?.meta.done === false) {
|
|
112
135
|
cached = null;
|
|
113
136
|
}
|
|
114
137
|
|
|
115
|
-
/**
|
|
116
|
-
* If the workflow generator hasn't returned, advance the step runner
|
|
117
|
-
*/
|
|
118
138
|
if (!iteratorResult.done) {
|
|
119
139
|
iteratorResult = await workflowIterator.next();
|
|
120
140
|
}
|
|
121
141
|
|
|
122
|
-
/**
|
|
123
|
-
* We can now get a StepResponse object from the iterator result.
|
|
124
|
-
* The step runner generator always yields a StepResponse, whereas
|
|
125
|
-
* the workflow generator return value needs to be wrapped.
|
|
126
|
-
*/
|
|
127
142
|
let stepResponse: StepResponse;
|
|
128
143
|
|
|
129
144
|
if (iteratorResult.done) {
|
|
@@ -132,26 +147,45 @@ export function workflow<
|
|
|
132
147
|
stepResponse = iteratorResult.value;
|
|
133
148
|
}
|
|
134
149
|
|
|
135
|
-
/**
|
|
136
|
-
* If the first yielded value is a cache check, advance to actual step,
|
|
137
|
-
* passing it the cached value
|
|
138
|
-
*/
|
|
139
150
|
if (stepResponse instanceof StepCacheCheck) {
|
|
140
151
|
const cachedResponse = cached
|
|
141
|
-
?
|
|
152
|
+
? decodeStepRecord(cached.stepResponseJson)
|
|
142
153
|
: null;
|
|
154
|
+
// Resuming the step runs the step function unless it is cached.
|
|
155
|
+
stopIfAborted(signal);
|
|
143
156
|
iteratorResult = await workflowIterator.next(cachedResponse);
|
|
144
157
|
stepResponse = iteratorResult.value;
|
|
158
|
+
|
|
159
|
+
// A step that needs a runtime client yields a request. Make the call
|
|
160
|
+
// here, outside workflow code, and resume the step with its result.
|
|
161
|
+
// An infrastructure error rejects the delivery without resuming it.
|
|
162
|
+
while (
|
|
163
|
+
stepResponse instanceof StepRuntimeCall ||
|
|
164
|
+
stepResponse instanceof StepEffectCall
|
|
165
|
+
) {
|
|
166
|
+
stopIfAborted(signal);
|
|
167
|
+
const callResult =
|
|
168
|
+
stepResponse instanceof StepEffectCall
|
|
169
|
+
? await executeEffectCall(stepResponse.request, {
|
|
170
|
+
effects: effectClient,
|
|
171
|
+
executionId,
|
|
172
|
+
clock,
|
|
173
|
+
assertLease: () => stopIfAborted(signal),
|
|
174
|
+
logger,
|
|
175
|
+
})
|
|
176
|
+
: await executeRuntimeCall(stepResponse, {
|
|
177
|
+
stores: storeClient,
|
|
178
|
+
executions: executionClient,
|
|
179
|
+
});
|
|
180
|
+
iteratorResult = await workflowIterator.next(callResult);
|
|
181
|
+
stepResponse = iteratorResult.value;
|
|
182
|
+
}
|
|
145
183
|
} else if (!(stepResponse instanceof WorkflowResult)) {
|
|
146
184
|
throw new Error("Step runners must yield a CacheCheck object");
|
|
147
185
|
}
|
|
148
186
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
* use a step runner
|
|
152
|
-
* @todo: iterable check should be before StepKey check
|
|
153
|
-
* @todo: not throwing here causes a critical error – should workflow break?
|
|
154
|
-
*/
|
|
187
|
+
// TODO: the iterable check should come before the StepKey check.
|
|
188
|
+
// TODO: not throwing here causes a critical error; should the workflow break?
|
|
155
189
|
if (isIterable(stepResponse)) {
|
|
156
190
|
console.log("[ERR] Steps must be yielded using yield*\n");
|
|
157
191
|
stepResponse = new StepInvalid();
|
|
@@ -164,84 +198,106 @@ export function workflow<
|
|
|
164
198
|
stepResponse = new StepInvalid();
|
|
165
199
|
}
|
|
166
200
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
201
|
+
let stepResponseJson: string | undefined;
|
|
202
|
+
// The workflow result is encoded on every delivery, so that an invalid
|
|
203
|
+
// result fails the same way whether or not it is already recorded.
|
|
204
|
+
if (!cached || stepResponse instanceof WorkflowResult) {
|
|
205
|
+
const record = encodeStepRecord(stepResponse, stepKey);
|
|
206
|
+
stepResponseJson = record.json;
|
|
207
|
+
stepResponse = record.response;
|
|
208
|
+
}
|
|
209
|
+
|
|
170
210
|
const needsRetry =
|
|
171
211
|
!cached?.meta.done &&
|
|
172
212
|
stepResponse instanceof StepError &&
|
|
173
213
|
// 1-indexed vs 0-indexed
|
|
174
214
|
stepResponse.maxAttempts > stepAttempt + 1;
|
|
215
|
+
// A watched effect stays unfinished, so the next delivery begins it
|
|
216
|
+
// again; its waiter is deleted when the step's result is recorded.
|
|
175
217
|
const needsWake =
|
|
176
|
-
!cached?.meta.done &&
|
|
218
|
+
!cached?.meta.done &&
|
|
219
|
+
(stepResponse instanceof StepStoreWait ||
|
|
220
|
+
stepResponse instanceof StepEffectWait);
|
|
177
221
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
222
|
+
if (!cached && stepResponseJson !== undefined) {
|
|
223
|
+
const done = !needsRetry && !needsWake;
|
|
224
|
+
stopIfAborted(signal);
|
|
225
|
+
const written = await infrastructure(() =>
|
|
226
|
+
heapClient.writeStep({
|
|
227
|
+
lease,
|
|
228
|
+
stepKey,
|
|
229
|
+
stepAttempt,
|
|
230
|
+
done,
|
|
231
|
+
stepResponseJson: stepResponseJson!,
|
|
232
|
+
})
|
|
233
|
+
);
|
|
234
|
+
// Another claim has taken over: stop without an outcome.
|
|
235
|
+
if (isLeaseLost(written)) {
|
|
236
|
+
throw new InfrastructureError(new LeaseLostError(executionId));
|
|
237
|
+
}
|
|
238
|
+
// The step was already recorded with a different value. The record
|
|
239
|
+
// stands, so this delivery must not continue with its own value: end
|
|
240
|
+
// it as a retry, and the next delivery replays the recorded one.
|
|
241
|
+
if (
|
|
242
|
+
written.stepResponseJson !== stepResponseJson ||
|
|
243
|
+
written.meta.done !== done
|
|
244
|
+
) {
|
|
245
|
+
throw new InfrastructureError(
|
|
246
|
+
new StepConflictError(executionId, stepKey)
|
|
247
|
+
);
|
|
248
|
+
}
|
|
189
249
|
}
|
|
190
250
|
|
|
191
|
-
/**
|
|
192
|
-
* Once the WorkflowResult is cached, return the WorkflowResult to the
|
|
193
|
-
* upstream consumer e.g the worker
|
|
194
|
-
*/
|
|
195
251
|
if (stepResponse instanceof WorkflowResult) {
|
|
196
252
|
return stepResponse;
|
|
197
253
|
}
|
|
198
254
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
255
|
+
// Only an invalid workflow result is recorded as a step error under
|
|
256
|
+
// the workflow result's key. The workflow has finished, so it fails.
|
|
257
|
+
if (iteratorResult.done && stepResponse instanceof StepError) {
|
|
258
|
+
throw stepResponse.err;
|
|
259
|
+
}
|
|
260
|
+
|
|
202
261
|
if (stepResponse instanceof StepInvalid) {
|
|
203
262
|
break;
|
|
204
263
|
}
|
|
205
264
|
|
|
206
|
-
/**
|
|
207
|
-
* If step runner yielded a step error, either throw the error back for
|
|
208
|
-
* user to handle, or yield to worker, if it should be retried.
|
|
209
|
-
* (Throwing also advances the workflow generator, so, in that case, we
|
|
210
|
-
* store the next iterator result, and continue to the next step).
|
|
211
|
-
*/
|
|
212
265
|
if (stepResponse instanceof StepError) {
|
|
213
266
|
if (needsRetry) {
|
|
214
|
-
yield new StepDelay(
|
|
267
|
+
yield new StepDelay(clock() + stepResponse.retryInterval);
|
|
215
268
|
} else {
|
|
269
|
+
stopIfAborted(signal);
|
|
216
270
|
nextIteratorResult = await workflowIterator.throw(stepResponse.err);
|
|
217
271
|
continue;
|
|
218
272
|
}
|
|
219
273
|
}
|
|
220
274
|
|
|
221
|
-
/**
|
|
222
|
-
* If the delay has already elapsed, continue straight to next step
|
|
223
|
-
*/
|
|
224
275
|
if (stepResponse instanceof StepDelay) {
|
|
225
|
-
if (stepResponse.
|
|
276
|
+
if (stepResponse.wakeAt <= clock()) {
|
|
226
277
|
continue;
|
|
227
278
|
}
|
|
228
279
|
}
|
|
229
280
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
* always possible to continue straight to the next step).
|
|
234
|
-
*/
|
|
235
|
-
if (!(stepResponse instanceof StepResult)) {
|
|
281
|
+
if (stepResponse instanceof StepResult) {
|
|
282
|
+
resumeWith = stepResponse;
|
|
283
|
+
} else {
|
|
236
284
|
yield stepResponse;
|
|
237
285
|
}
|
|
238
286
|
}
|
|
239
287
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
throw new Error("Critical error");
|
|
288
|
+
throw new Error(
|
|
289
|
+
"Workflow yielded an invalid step. Steps must be yielded with yield* from a step runner."
|
|
290
|
+
);
|
|
244
291
|
};
|
|
245
292
|
}
|
|
246
293
|
|
|
247
294
|
export const createWorkflow = workflow;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Ends the delivery once its signal has aborted, before it runs more workflow
|
|
298
|
+
* code or makes another fenced write. Like a lost lease found by a fenced
|
|
299
|
+
* write, it rejects with an infrastructure error, which is not acknowledged.
|
|
300
|
+
*/
|
|
301
|
+
function stopIfAborted(signal: AbortSignal) {
|
|
302
|
+
if (signal.aborted) throw new InfrastructureError(signal.reason);
|
|
303
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,10 +2,27 @@ export { createLocalSdk } from "./exports/sdk-local";
|
|
|
2
2
|
export { createHttpSdkFactory } from "./exports/sdk-http";
|
|
3
3
|
export { createWorkflowRouter } from "./exports/router";
|
|
4
4
|
export { RetryableError } from "./exports/errors";
|
|
5
|
+
export {
|
|
6
|
+
EffectClientInvariantError,
|
|
7
|
+
EffectDeliveryError,
|
|
8
|
+
ExecutionAdmissionError,
|
|
9
|
+
WORKFLOW_RESULT_STEP_KEY,
|
|
10
|
+
WorkflowEffectError,
|
|
11
|
+
WorkflowStartError,
|
|
12
|
+
WorkflowValueError,
|
|
13
|
+
registerErrorClass,
|
|
14
|
+
} from "@yieldstar/core";
|
|
5
15
|
export { createWorkflow, workflow } from "./exports/workflow";
|
|
6
|
-
export type { WorkflowFn } from "./exports/workflow";
|
|
16
|
+
export type { DurableWorkflowFn, WorkflowFn } from "./exports/workflow";
|
|
7
17
|
export { defineStore } from "./internal/step-runner";
|
|
8
|
-
export type {
|
|
18
|
+
export type {
|
|
19
|
+
DurableStepFn,
|
|
20
|
+
EffectDescriptor,
|
|
21
|
+
EffectLookup,
|
|
22
|
+
StepRunner,
|
|
23
|
+
StepRunOptions,
|
|
24
|
+
WorkflowStore,
|
|
25
|
+
} from "./internal/step-runner";
|
|
9
26
|
export type {
|
|
10
27
|
Draft,
|
|
11
28
|
RuntimeStore,
|
|
@@ -17,8 +34,23 @@ export type {
|
|
|
17
34
|
StoreSelector,
|
|
18
35
|
StoreSnapshot,
|
|
19
36
|
StoreState,
|
|
37
|
+
DurableStoreSchema,
|
|
20
38
|
StoreUpdateFromResult,
|
|
21
39
|
StoreUpdateResult,
|
|
22
40
|
StoreVersion,
|
|
23
41
|
WorkflowEvent,
|
|
42
|
+
ExecutionAdmissionErrorCode,
|
|
43
|
+
ExecutionOutcome,
|
|
44
|
+
ExecutionRecord,
|
|
45
|
+
ExecutionStatus,
|
|
46
|
+
ExecutionParent,
|
|
47
|
+
Durable,
|
|
48
|
+
DurableObject,
|
|
49
|
+
DurableValue,
|
|
50
|
+
ErrorClass,
|
|
51
|
+
WorkflowValueErrorCode,
|
|
52
|
+
WorkflowStartErrorCode,
|
|
53
|
+
EffectDeliveryErrorCode,
|
|
54
|
+
EffectRecord,
|
|
55
|
+
WorkflowEffectErrorCode,
|
|
24
56
|
} from "@yieldstar/core";
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { Logger } from "pino";
|
|
2
|
+
import {
|
|
3
|
+
EffectClientInvariantError,
|
|
4
|
+
EffectDeliveryError,
|
|
5
|
+
InfrastructureError,
|
|
6
|
+
StepResponse,
|
|
7
|
+
durableValueCodec,
|
|
8
|
+
type DurableObject,
|
|
9
|
+
type DurableValue,
|
|
10
|
+
type EffectClient,
|
|
11
|
+
type EffectDeliveryErrorCode,
|
|
12
|
+
} from "@yieldstar/core";
|
|
13
|
+
|
|
14
|
+
/** What `lookup` reports about an effect ID (RFC 005). */
|
|
15
|
+
export type EffectLookup<T> =
|
|
16
|
+
| { status: "settled"; result: T }
|
|
17
|
+
| { status: "not-found" }
|
|
18
|
+
| { status: "unknown" };
|
|
19
|
+
|
|
20
|
+
/** The effect request a step yields once its descriptor is valid. */
|
|
21
|
+
export type EffectCallRequest = {
|
|
22
|
+
stepKey: string;
|
|
23
|
+
effectId: string;
|
|
24
|
+
operation: string;
|
|
25
|
+
input: DurableObject;
|
|
26
|
+
retryIn: number;
|
|
27
|
+
lookup(effectId: string): Promise<unknown>;
|
|
28
|
+
perform(effectId: string): Promise<unknown>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* - `settled`: the effect's result, which the step records.
|
|
33
|
+
* - `rejected`: the effect exists with a different operation or input.
|
|
34
|
+
* - `suspended`: the step watches the effect and is due again at `wakeAt`.
|
|
35
|
+
*/
|
|
36
|
+
export type EffectCallOutcome =
|
|
37
|
+
| { status: "settled"; result: DurableValue }
|
|
38
|
+
| { status: "rejected"; code: "EFFECT_REQUEST_MISMATCH" }
|
|
39
|
+
| { status: "suspended"; wakeAt: number };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Yielded by `step.effect`. The driver runs the effect with
|
|
43
|
+
* `executeEffectCall` and resumes the step with its outcome.
|
|
44
|
+
*/
|
|
45
|
+
export class StepEffectCall extends StepResponse {
|
|
46
|
+
static type = "effect-call";
|
|
47
|
+
readonly type = "effect-call";
|
|
48
|
+
readonly request: EffectCallRequest;
|
|
49
|
+
|
|
50
|
+
constructor(request: EffectCallRequest) {
|
|
51
|
+
super();
|
|
52
|
+
this.request = request;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Runs an effect step's protocol against the runtime's `EffectClient`. Every
|
|
58
|
+
* failure that must not be recorded rejects with an `InfrastructureError`
|
|
59
|
+
* whose cause is an `EffectDeliveryError`, so the delivery is retried.
|
|
60
|
+
*/
|
|
61
|
+
export async function executeEffectCall(
|
|
62
|
+
request: EffectCallRequest,
|
|
63
|
+
context: {
|
|
64
|
+
effects: EffectClient;
|
|
65
|
+
executionId: string;
|
|
66
|
+
clock: () => number;
|
|
67
|
+
/** Checked before every callback, so a lost lease makes no external call. */
|
|
68
|
+
assertLease: () => void;
|
|
69
|
+
logger: Logger;
|
|
70
|
+
}
|
|
71
|
+
): Promise<EffectCallOutcome> {
|
|
72
|
+
const { effects, executionId, clock, assertLease, logger } = context;
|
|
73
|
+
const { stepKey, effectId, operation, input, retryIn } = request;
|
|
74
|
+
|
|
75
|
+
const fail = (code: EffectDeliveryErrorCode, message: string, cause?: unknown) =>
|
|
76
|
+
new InfrastructureError(
|
|
77
|
+
new EffectDeliveryError(
|
|
78
|
+
code,
|
|
79
|
+
{ effectId, stepKey, message },
|
|
80
|
+
cause === undefined ? undefined : { cause }
|
|
81
|
+
)
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
async function client<T>(method: string, call: () => Promise<T>): Promise<T> {
|
|
85
|
+
try {
|
|
86
|
+
return await call();
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const message = `EffectClient.${method} failed for effect "${effectId}": ${errorMessage(err)}`;
|
|
89
|
+
if (isInvariantError(err)) throw fail("EFFECT_RUNTIME_INVARIANT", message, err);
|
|
90
|
+
throw fail("EFFECT_CLIENT_UNAVAILABLE", message, err);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const malformedClient = (method: string) =>
|
|
95
|
+
fail(
|
|
96
|
+
"EFFECT_RUNTIME_INVARIANT",
|
|
97
|
+
`EffectClient.${method} returned a malformed response for effect "${effectId}"`
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
async function suspend(): Promise<EffectCallOutcome> {
|
|
101
|
+
await client("watch", () => effects.watch({ effectId, executionId, stepKey }));
|
|
102
|
+
return { status: "suspended", wakeAt: clock() + retryIn };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function settle(result: unknown): Promise<EffectCallOutcome> {
|
|
106
|
+
const encoding = durableValueCodec.encode(result);
|
|
107
|
+
if (encoding.status === "invalid") {
|
|
108
|
+
throw fail(
|
|
109
|
+
"EFFECT_HANDLER_INVALID",
|
|
110
|
+
`Effect "${effectId}" result is not a durable value: ${encoding.message}`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const settled = await client("settle", () =>
|
|
114
|
+
effects.settle({ operation, effectId, result: result as DurableValue })
|
|
115
|
+
);
|
|
116
|
+
if (!isObject(settled) || !("result" in settled)) throw malformedClient("settle");
|
|
117
|
+
if (settled.status === "conflict") {
|
|
118
|
+
logger.warn(
|
|
119
|
+
{
|
|
120
|
+
executionId,
|
|
121
|
+
stepKey,
|
|
122
|
+
effectId,
|
|
123
|
+
result: settled.result,
|
|
124
|
+
conflictingResult: settled.conflictingResult,
|
|
125
|
+
},
|
|
126
|
+
`Effect "${effectId}" settled with a different result; the first result stands`
|
|
127
|
+
);
|
|
128
|
+
} else if (settled.status !== "settled") {
|
|
129
|
+
throw malformedClient("settle");
|
|
130
|
+
}
|
|
131
|
+
return { status: "settled", result: settled.result };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const begun = await client("begin", () =>
|
|
135
|
+
effects.begin({ operation, effectId, input, executionId, stepKey })
|
|
136
|
+
);
|
|
137
|
+
if (!isObject(begun)) throw malformedClient("begin");
|
|
138
|
+
|
|
139
|
+
let next: "lookup" | "perform";
|
|
140
|
+
switch (begun.status) {
|
|
141
|
+
case "settled":
|
|
142
|
+
if (!("result" in begun)) throw malformedClient("begin");
|
|
143
|
+
return { status: "settled", result: begun.result };
|
|
144
|
+
case "rejected":
|
|
145
|
+
if (begun.code !== "EFFECT_REQUEST_MISMATCH") throw malformedClient("begin");
|
|
146
|
+
return { status: "rejected", code: begun.code };
|
|
147
|
+
case "new":
|
|
148
|
+
next = "perform";
|
|
149
|
+
break;
|
|
150
|
+
case "pending":
|
|
151
|
+
// An earlier attempt may have started the operation, or finished it.
|
|
152
|
+
next = "lookup";
|
|
153
|
+
break;
|
|
154
|
+
default:
|
|
155
|
+
throw malformedClient("begin");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (next === "lookup") {
|
|
159
|
+
assertLease();
|
|
160
|
+
let found: unknown;
|
|
161
|
+
try {
|
|
162
|
+
found = await request.lookup(effectId);
|
|
163
|
+
} catch {
|
|
164
|
+
return suspend();
|
|
165
|
+
}
|
|
166
|
+
if (!isObject(found)) throw malformedLookup(fail, effectId);
|
|
167
|
+
switch (found.status) {
|
|
168
|
+
case "settled":
|
|
169
|
+
if (!("result" in found)) throw malformedLookup(fail, effectId);
|
|
170
|
+
return settle(found.result);
|
|
171
|
+
case "unknown":
|
|
172
|
+
return suspend();
|
|
173
|
+
case "not-found":
|
|
174
|
+
break;
|
|
175
|
+
default:
|
|
176
|
+
throw malformedLookup(fail, effectId);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
assertLease();
|
|
181
|
+
let performed: unknown;
|
|
182
|
+
try {
|
|
183
|
+
performed = await request.perform(effectId);
|
|
184
|
+
} catch {
|
|
185
|
+
return suspend();
|
|
186
|
+
}
|
|
187
|
+
return settle(performed);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function malformedLookup(
|
|
191
|
+
fail: (code: EffectDeliveryErrorCode, message: string) => InfrastructureError,
|
|
192
|
+
effectId: string
|
|
193
|
+
) {
|
|
194
|
+
return fail(
|
|
195
|
+
"EFFECT_HANDLER_INVALID",
|
|
196
|
+
`lookup for effect "${effectId}" returned a malformed response: expected { status: "settled", result }, { status: "not-found" } or { status: "unknown" }`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Matches by code too, so an invariant error from another copy of
|
|
201
|
+
// @yieldstar/core, or one revived without its class, is still recognised.
|
|
202
|
+
function isInvariantError(err: unknown) {
|
|
203
|
+
return (
|
|
204
|
+
err instanceof EffectClientInvariantError ||
|
|
205
|
+
(typeof err === "object" &&
|
|
206
|
+
err !== null &&
|
|
207
|
+
(err as { code?: unknown }).code === "EFFECT_RUNTIME_INVARIANT")
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
212
|
+
return typeof value === "object" && value !== null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function errorMessage(err: unknown) {
|
|
216
|
+
return err instanceof Error ? err.message : String(err);
|
|
217
|
+
}
|