yieldstar 0.4.6 → 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/README.md +57 -64
- 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 +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +791 -137
- 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 +93 -9
- package/dist/internal/step-runner.d.ts.map +1 -1
- package/dist/internal/utils.d.ts +11 -0
- package/dist/internal/utils.d.ts.map +1 -1
- package/package.json +8 -4
- 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 +159 -99
- package/src/index.ts +51 -1
- 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 +748 -53
- package/src/internal/utils.ts +24 -10
- package/dist/exports/scheduler.d.ts +0 -49
- package/dist/exports/scheduler.d.ts.map +0 -1
- package/dist/exports/simplified-types.d.ts +0 -31
- package/dist/exports/simplified-types.d.ts.map +0 -1
- package/dist/exports/types.d.ts +0 -6
- package/dist/exports/types.d.ts.map +0 -1
- package/dist/internal/types.d.ts +0 -9
- package/dist/internal/types.d.ts.map +0 -1
- package/src/internal/serialise.ts +0 -44
- 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";
|
|
12
|
+
import { decodeStepRecord, encodeStepRecord } from "../internal/step-record";
|
|
13
|
+
import { createStepRunner } from "../internal/step-runner";
|
|
10
14
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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,8 +24,15 @@ import {
|
|
|
19
24
|
StepResult,
|
|
20
25
|
StepDelay,
|
|
21
26
|
StepCacheCheck,
|
|
27
|
+
StepEffectWait,
|
|
22
28
|
StepInvalid,
|
|
29
|
+
StepStoreWait,
|
|
23
30
|
WorkflowResult,
|
|
31
|
+
InfrastructureError,
|
|
32
|
+
LeaseLostError,
|
|
33
|
+
StepConflictError,
|
|
34
|
+
isLeaseLost,
|
|
35
|
+
WORKFLOW_RESULT_STEP_KEY,
|
|
24
36
|
} from "@yieldstar/core";
|
|
25
37
|
|
|
26
38
|
export type WorkflowFn<
|
|
@@ -33,50 +45,70 @@ export type WorkflowFn<
|
|
|
33
45
|
logger: Logger
|
|
34
46
|
) => AsyncGenerator<any, Result>;
|
|
35
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
|
+
*/
|
|
36
66
|
export function workflow<
|
|
37
67
|
Params extends EventParams,
|
|
38
68
|
Result,
|
|
39
69
|
Context extends EventContext
|
|
40
70
|
>(
|
|
41
|
-
workflowFn: WorkflowFn<Params, Result, Context>
|
|
71
|
+
workflowFn: WorkflowFn<Params, Result, Context> &
|
|
72
|
+
DurableWorkflowFn<Params, Result>
|
|
42
73
|
): WorkflowGenerator<Params, Result, Context> {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 });
|
|
49
86
|
const workflowIterator = workflowFn(stepRunner, event, logger);
|
|
50
87
|
const { executionId } = event;
|
|
51
88
|
|
|
52
89
|
let iteratorResult: IteratorResult<any> | null = null;
|
|
53
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;
|
|
54
94
|
const stepKeys = new Set<string>();
|
|
55
95
|
|
|
56
96
|
while (true) {
|
|
57
97
|
let stepAttempt = 0;
|
|
58
98
|
let stepKey: string;
|
|
59
99
|
|
|
60
|
-
|
|
61
|
-
* If we already have the next iterator result from a previous iteration
|
|
62
|
-
* (e.g. it threw an error and advanced the workflow), use that, otherwise,
|
|
63
|
-
* advance the workflow generator
|
|
64
|
-
*/
|
|
100
|
+
// Throwing a step error into the workflow already advanced it.
|
|
65
101
|
if (nextIteratorResult) {
|
|
66
102
|
iteratorResult = nextIteratorResult;
|
|
67
103
|
nextIteratorResult = null;
|
|
68
104
|
} else {
|
|
69
|
-
|
|
105
|
+
stopIfAborted(signal);
|
|
106
|
+
iteratorResult = await workflowIterator.next(resumeWith);
|
|
70
107
|
}
|
|
108
|
+
resumeWith = undefined;
|
|
71
109
|
|
|
72
|
-
/**
|
|
73
|
-
* Get the StepKey from the step runner.
|
|
74
|
-
* If the generator is done, the workflow has returned, so we set a
|
|
75
|
-
* special key for that.
|
|
76
|
-
* Finally, check that this call site wasn't reached before.
|
|
77
|
-
*/
|
|
78
110
|
if (iteratorResult.done) {
|
|
79
|
-
stepKey =
|
|
111
|
+
stepKey = WORKFLOW_RESULT_STEP_KEY;
|
|
80
112
|
} else if (!(iteratorResult.value instanceof StepKey)) {
|
|
81
113
|
throw new Error("Step runners must yield a StepKey object");
|
|
82
114
|
} else {
|
|
@@ -87,41 +119,26 @@ export function workflow<
|
|
|
87
119
|
stepKeys.add(stepKey)
|
|
88
120
|
}
|
|
89
121
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
stepKey,
|
|
97
|
-
});
|
|
122
|
+
let cached = await infrastructure(() =>
|
|
123
|
+
heapClient.readStep({
|
|
124
|
+
executionId,
|
|
125
|
+
stepKey,
|
|
126
|
+
})
|
|
127
|
+
);
|
|
98
128
|
|
|
99
|
-
/**
|
|
100
|
-
* Increment attempt counter. We'll save this after execution.
|
|
101
|
-
*/
|
|
102
129
|
if (cached?.meta) {
|
|
103
130
|
stepAttempt = cached.meta.attempt + 1;
|
|
104
131
|
}
|
|
105
132
|
|
|
106
|
-
|
|
107
|
-
* If the step needs to be retried, ignore the cached value
|
|
108
|
-
*/
|
|
133
|
+
// An unfinished record is a failed attempt: run the step again.
|
|
109
134
|
if (cached?.meta.done === false) {
|
|
110
135
|
cached = null;
|
|
111
136
|
}
|
|
112
137
|
|
|
113
|
-
/**
|
|
114
|
-
* If the workflow generator hasn't returned, advance the step runner
|
|
115
|
-
*/
|
|
116
138
|
if (!iteratorResult.done) {
|
|
117
139
|
iteratorResult = await workflowIterator.next();
|
|
118
140
|
}
|
|
119
141
|
|
|
120
|
-
/**
|
|
121
|
-
* We can now get a StepResponse object from the iterator result.
|
|
122
|
-
* The step runner generator always yields a StepResponse, whereas
|
|
123
|
-
* the workflow generator return value needs to be wrapped.
|
|
124
|
-
*/
|
|
125
142
|
let stepResponse: StepResponse;
|
|
126
143
|
|
|
127
144
|
if (iteratorResult.done) {
|
|
@@ -130,26 +147,45 @@ export function workflow<
|
|
|
130
147
|
stepResponse = iteratorResult.value;
|
|
131
148
|
}
|
|
132
149
|
|
|
133
|
-
/**
|
|
134
|
-
* If the first yielded value is a cache check, advance to actual step,
|
|
135
|
-
* passing it the cached value
|
|
136
|
-
*/
|
|
137
150
|
if (stepResponse instanceof StepCacheCheck) {
|
|
138
151
|
const cachedResponse = cached
|
|
139
|
-
?
|
|
152
|
+
? decodeStepRecord(cached.stepResponseJson)
|
|
140
153
|
: null;
|
|
154
|
+
// Resuming the step runs the step function unless it is cached.
|
|
155
|
+
stopIfAborted(signal);
|
|
141
156
|
iteratorResult = await workflowIterator.next(cachedResponse);
|
|
142
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
|
+
}
|
|
143
183
|
} else if (!(stepResponse instanceof WorkflowResult)) {
|
|
144
184
|
throw new Error("Step runners must yield a CacheCheck object");
|
|
145
185
|
}
|
|
146
186
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
* use a step runner
|
|
150
|
-
* @todo: iterable check should be before StepKey check
|
|
151
|
-
* @todo: not throwing here causes a critical error – should workflow break?
|
|
152
|
-
*/
|
|
187
|
+
// TODO: the iterable check should come before the StepKey check.
|
|
188
|
+
// TODO: not throwing here causes a critical error; should the workflow break?
|
|
153
189
|
if (isIterable(stepResponse)) {
|
|
154
190
|
console.log("[ERR] Steps must be yielded using yield*\n");
|
|
155
191
|
stepResponse = new StepInvalid();
|
|
@@ -162,82 +198,106 @@ export function workflow<
|
|
|
162
198
|
stepResponse = new StepInvalid();
|
|
163
199
|
}
|
|
164
200
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
+
|
|
168
210
|
const needsRetry =
|
|
169
211
|
!cached?.meta.done &&
|
|
170
212
|
stepResponse instanceof StepError &&
|
|
171
213
|
// 1-indexed vs 0-indexed
|
|
172
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.
|
|
217
|
+
const needsWake =
|
|
218
|
+
!cached?.meta.done &&
|
|
219
|
+
(stepResponse instanceof StepStoreWait ||
|
|
220
|
+
stepResponse instanceof StepEffectWait);
|
|
173
221
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
}
|
|
185
249
|
}
|
|
186
250
|
|
|
187
|
-
/**
|
|
188
|
-
* Once the WorkflowResult is cached, return the WorkflowResult to the
|
|
189
|
-
* upstream consumer e.g the worker
|
|
190
|
-
*/
|
|
191
251
|
if (stepResponse instanceof WorkflowResult) {
|
|
192
252
|
return stepResponse;
|
|
193
253
|
}
|
|
194
254
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
|
|
198
261
|
if (stepResponse instanceof StepInvalid) {
|
|
199
262
|
break;
|
|
200
263
|
}
|
|
201
264
|
|
|
202
|
-
/**
|
|
203
|
-
* If step runner yielded a step error, either throw the error back for
|
|
204
|
-
* user to handle, or yield to worker, if it should be retried.
|
|
205
|
-
* (Throwing also advances the workflow generator, so, in that case, we
|
|
206
|
-
* store the next iterator result, and continue to the next step).
|
|
207
|
-
*/
|
|
208
265
|
if (stepResponse instanceof StepError) {
|
|
209
266
|
if (needsRetry) {
|
|
210
|
-
yield new StepDelay(
|
|
267
|
+
yield new StepDelay(clock() + stepResponse.retryInterval);
|
|
211
268
|
} else {
|
|
269
|
+
stopIfAborted(signal);
|
|
212
270
|
nextIteratorResult = await workflowIterator.throw(stepResponse.err);
|
|
213
271
|
continue;
|
|
214
272
|
}
|
|
215
273
|
}
|
|
216
274
|
|
|
217
|
-
/**
|
|
218
|
-
* If the delay has already elapsed, continue straight to next step
|
|
219
|
-
*/
|
|
220
275
|
if (stepResponse instanceof StepDelay) {
|
|
221
|
-
if (stepResponse.
|
|
276
|
+
if (stepResponse.wakeAt <= clock()) {
|
|
222
277
|
continue;
|
|
223
278
|
}
|
|
224
279
|
}
|
|
225
280
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
* always possible to continue straight to the next step).
|
|
230
|
-
*/
|
|
231
|
-
if (!(stepResponse instanceof StepResult)) {
|
|
281
|
+
if (stepResponse instanceof StepResult) {
|
|
282
|
+
resumeWith = stepResponse;
|
|
283
|
+
} else {
|
|
232
284
|
yield stepResponse;
|
|
233
285
|
}
|
|
234
286
|
}
|
|
235
287
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
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
|
+
);
|
|
240
291
|
};
|
|
241
292
|
}
|
|
242
293
|
|
|
243
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,5 +2,55 @@ 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";
|
|
17
|
+
export { defineStore } from "./internal/step-runner";
|
|
18
|
+
export type {
|
|
19
|
+
DurableStepFn,
|
|
20
|
+
EffectDescriptor,
|
|
21
|
+
EffectLookup,
|
|
22
|
+
StepRunner,
|
|
23
|
+
StepRunOptions,
|
|
24
|
+
WorkflowStore,
|
|
25
|
+
} from "./internal/step-runner";
|
|
26
|
+
export type {
|
|
27
|
+
Draft,
|
|
28
|
+
RuntimeStore,
|
|
29
|
+
StandardSchemaV1,
|
|
30
|
+
StoreDefinition,
|
|
31
|
+
StoreDeleteFromResult,
|
|
32
|
+
StoreKey,
|
|
33
|
+
StorePath,
|
|
34
|
+
StoreSelector,
|
|
35
|
+
StoreSnapshot,
|
|
36
|
+
StoreState,
|
|
37
|
+
DurableStoreSchema,
|
|
38
|
+
StoreUpdateFromResult,
|
|
39
|
+
StoreUpdateResult,
|
|
40
|
+
StoreVersion,
|
|
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,
|
|
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
|
+
}
|