@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,210 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { durableValueCodec, encodeThrownValue } from "./durable-value";
|
|
3
|
+
import { WorkflowStartError } from "./execution";
|
|
4
|
+
import { WorkflowValueError, registerErrorClass } from "./errors";
|
|
5
|
+
|
|
6
|
+
function encode(value: unknown) {
|
|
7
|
+
const encoding = durableValueCodec.encode(value);
|
|
8
|
+
if (encoding.status !== "encoded") {
|
|
9
|
+
throw new Error(`expected encoded, got: ${encoding.message}`);
|
|
10
|
+
}
|
|
11
|
+
return encoding.canonical;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function decode(canonical: string) {
|
|
15
|
+
const decoding = durableValueCodec.decode(canonical);
|
|
16
|
+
if (decoding.status !== "decoded") {
|
|
17
|
+
throw new Error(`expected decoded, got: ${decoding.message}`);
|
|
18
|
+
}
|
|
19
|
+
return decoding.value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function roundTrip<T>(value: T) {
|
|
23
|
+
return decode(encode(value)) as T;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class CourierError extends Error {
|
|
27
|
+
code = "COURIER_DOWN";
|
|
28
|
+
courier: string;
|
|
29
|
+
constructor(courier: string, options?: ErrorOptions) {
|
|
30
|
+
if (typeof courier !== "string") throw new Error("courier is required");
|
|
31
|
+
super(`${courier} is down`, options);
|
|
32
|
+
this.name = "CourierError";
|
|
33
|
+
this.courier = courier;
|
|
34
|
+
}
|
|
35
|
+
describe() {
|
|
36
|
+
return `${this.courier}: ${this.message}`;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
registerErrorClass("CourierError", CourierError);
|
|
40
|
+
|
|
41
|
+
class UnregisteredError extends Error {
|
|
42
|
+
code = "NOPE";
|
|
43
|
+
attempts = 3;
|
|
44
|
+
constructor(message: string) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "UnregisteredError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("error encoding", () => {
|
|
51
|
+
test("is tagged and canonical", () => {
|
|
52
|
+
const error = new Error("boom");
|
|
53
|
+
error.stack = "stack";
|
|
54
|
+
expect(encode(error)).toBe(
|
|
55
|
+
'{"$type":"error","message":"boom","name":"Error","stack":"stack"}'
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("keeps name, message, stack, a string code, cause and durable fields", () => {
|
|
60
|
+
const error = new CourierError("dhl", { cause: new TypeError("socket") });
|
|
61
|
+
const revived = roundTrip(error);
|
|
62
|
+
expect(revived).toBeInstanceOf(CourierError);
|
|
63
|
+
expect(revived.name).toBe("CourierError");
|
|
64
|
+
expect(revived.message).toBe("dhl is down");
|
|
65
|
+
expect(revived.stack).toBe(error.stack);
|
|
66
|
+
expect(revived.code).toBe("COURIER_DOWN");
|
|
67
|
+
expect(revived.courier).toBe("dhl");
|
|
68
|
+
expect(revived.describe()).toBe("dhl: dhl is down");
|
|
69
|
+
expect(revived.cause).toBeInstanceOf(TypeError);
|
|
70
|
+
expect((revived.cause as Error).message).toBe("socket");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("revives without calling the constructor", () => {
|
|
74
|
+
const revived = roundTrip(new CourierError("ups"));
|
|
75
|
+
expect(revived).toBeInstanceOf(CourierError);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("matches the original's own property layout", () => {
|
|
79
|
+
const error = new CourierError("dhl", { cause: "flaky" });
|
|
80
|
+
const revived = roundTrip(error);
|
|
81
|
+
expect(Object.keys(revived).sort()).toEqual(Object.keys(error).sort());
|
|
82
|
+
expect(Object.getOwnPropertyDescriptor(revived, "message")?.enumerable).toBe(false);
|
|
83
|
+
expect(Object.getOwnPropertyDescriptor(revived, "cause")?.enumerable).toBe(false);
|
|
84
|
+
expect(revived).toEqual(error);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("revives native errors", () => {
|
|
88
|
+
const revived = roundTrip(new CourierError("dhl"));
|
|
89
|
+
expect(Object.prototype.toString.call(revived)).toBe("[object Error]");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("revives an error without a stack without one", () => {
|
|
93
|
+
const error = new Error("boom");
|
|
94
|
+
delete error.stack;
|
|
95
|
+
expect(roundTrip(error).stack).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("omits non-durable properties", () => {
|
|
99
|
+
const error = Object.assign(new Error("boom"), {
|
|
100
|
+
at: new Date(0),
|
|
101
|
+
handler: () => {},
|
|
102
|
+
ok: 1,
|
|
103
|
+
});
|
|
104
|
+
const revived = roundTrip(error);
|
|
105
|
+
expect(Object.keys(revived)).toEqual(["ok"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("ignores a non-string code", () => {
|
|
109
|
+
const revived = roundTrip(Object.assign(new Error("boom"), { code: 42 }));
|
|
110
|
+
expect(revived).not.toHaveProperty("code");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("keeps a cause of undefined distinct from no cause", () => {
|
|
114
|
+
expect(Object.hasOwn(roundTrip(new Error("a", { cause: undefined })), "cause")).toBe(
|
|
115
|
+
true
|
|
116
|
+
);
|
|
117
|
+
expect(Object.hasOwn(roundTrip(new Error("a")), "cause")).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("omits a cause that cycles back to the error", () => {
|
|
121
|
+
const error = new Error("loop");
|
|
122
|
+
error.cause = error;
|
|
123
|
+
expect(Object.hasOwn(roundTrip(error), "cause")).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("wraps fields with a $type key", () => {
|
|
127
|
+
const revived = roundTrip(Object.assign(new Error("boom"), { $type: "undefined" }));
|
|
128
|
+
expect((revived as unknown as { $type: string }).$type).toBe("undefined");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("keeps AggregateError's errors", () => {
|
|
132
|
+
const revived = roundTrip(new AggregateError([new RangeError("r"), "two"], "all"));
|
|
133
|
+
expect(revived).toBeInstanceOf(AggregateError);
|
|
134
|
+
expect(revived.errors).toHaveLength(2);
|
|
135
|
+
expect(revived.errors[0]).toBeInstanceOf(RangeError);
|
|
136
|
+
expect(revived.errors[1]).toBe("two");
|
|
137
|
+
expect(Object.getOwnPropertyDescriptor(revived, "errors")?.enumerable).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("works anywhere a value can appear", () => {
|
|
141
|
+
const revived = roundTrip({ ok: false, errors: [new TypeError("t")] });
|
|
142
|
+
expect(revived.errors[0]).toBeInstanceOf(TypeError);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("error revival", () => {
|
|
147
|
+
test("revives an unregistered class as an Error with the same name, message, code and fields", () => {
|
|
148
|
+
const revived = roundTrip(new UnregisteredError("nope"));
|
|
149
|
+
expect(Object.getPrototypeOf(revived)).toBe(Error.prototype);
|
|
150
|
+
expect(revived.name).toBe("UnregisteredError");
|
|
151
|
+
expect(revived.message).toBe("nope");
|
|
152
|
+
expect(revived.code).toBe("NOPE");
|
|
153
|
+
expect(revived.attempts).toBe(3);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test.each([
|
|
157
|
+
TypeError,
|
|
158
|
+
RangeError,
|
|
159
|
+
SyntaxError,
|
|
160
|
+
ReferenceError,
|
|
161
|
+
EvalError,
|
|
162
|
+
URIError,
|
|
163
|
+
])("revives %o by default", (errorClass) => {
|
|
164
|
+
const revived = roundTrip(new errorClass("m"));
|
|
165
|
+
expect(Object.getPrototypeOf(revived)).toBe(errorClass.prototype);
|
|
166
|
+
expect(revived.name).toBe(errorClass.name);
|
|
167
|
+
expect(Object.hasOwn(revived, "name")).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("revives the Yieldstar error classes by default", () => {
|
|
171
|
+
const start = roundTrip(new WorkflowStartError("START_REQUEST_MISMATCH", "m"));
|
|
172
|
+
expect(start).toBeInstanceOf(WorkflowStartError);
|
|
173
|
+
expect(start.code).toBe("START_REQUEST_MISMATCH");
|
|
174
|
+
|
|
175
|
+
const value = roundTrip(new WorkflowValueError("INVALID_STEP_VALUE", "now", "m"));
|
|
176
|
+
expect(value).toBeInstanceOf(WorkflowValueError);
|
|
177
|
+
expect(value.code).toBe("INVALID_STEP_VALUE");
|
|
178
|
+
expect(value.stepKey).toBe("now");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("registration replaces the class for a name", () => {
|
|
182
|
+
class First extends Error {}
|
|
183
|
+
class Second extends Error {}
|
|
184
|
+
registerErrorClass("ReplacedError", First);
|
|
185
|
+
registerErrorClass("ReplacedError", Second);
|
|
186
|
+
const error = new First("m");
|
|
187
|
+
error.name = "ReplacedError";
|
|
188
|
+
expect(roundTrip(error)).toBeInstanceOf(Second);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("rejects invalid registrations", () => {
|
|
192
|
+
expect(() => registerErrorClass("", Error)).toThrow(TypeError);
|
|
193
|
+
expect(() => registerErrorClass("Point", class Point {} as never)).toThrow(TypeError);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("encodeThrownValue", () => {
|
|
198
|
+
test("keeps a durable thrown value as thrown", () => {
|
|
199
|
+
expect(decode(encodeThrownValue("boom"))).toBe("boom");
|
|
200
|
+
expect(decode(encodeThrownValue({ code: 1 }))).toEqual({ code: 1 });
|
|
201
|
+
expect(decode(encodeThrownValue(new RangeError("r")))).toBeInstanceOf(RangeError);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("records a non-durable thrown value as Error(String(value))", () => {
|
|
205
|
+
const revived = decode(encodeThrownValue(new Date(0))) as Error;
|
|
206
|
+
expect(Object.getPrototypeOf(revived)).toBe(Error.prototype);
|
|
207
|
+
expect(revived.message).toBe(String(new Date(0)));
|
|
208
|
+
expect((decode(encodeThrownValue(10n)) as Error).message).toBe("10");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EffectClientInvariantError,
|
|
3
|
+
EffectDeliveryError,
|
|
4
|
+
WorkflowEffectError,
|
|
5
|
+
} from "./effect";
|
|
6
|
+
import { WorkflowStartError } from "./execution";
|
|
7
|
+
|
|
8
|
+
export type WorkflowValueErrorCode = "INVALID_STEP_VALUE" | "STEP_SCHEMA_INVALID";
|
|
9
|
+
|
|
10
|
+
/** The `stepKey` of a `WorkflowValueError` raised by an invalid workflow result. */
|
|
11
|
+
export const WORKFLOW_RESULT_STEP_KEY = "$$workflow-result$$";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Thrown to workflow code when a step produces a value the durable value codec
|
|
15
|
+
* rejects, or a step's schema rejects its decoded value. It is recorded as the
|
|
16
|
+
* step's error, so replay throws it again.
|
|
17
|
+
*/
|
|
18
|
+
export class WorkflowValueError extends Error {
|
|
19
|
+
readonly code: WorkflowValueErrorCode;
|
|
20
|
+
readonly stepKey: string;
|
|
21
|
+
|
|
22
|
+
constructor(code: WorkflowValueErrorCode, stepKey: string, message: string) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "WorkflowValueError";
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.stepKey = stepKey;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ErrorClass = abstract new (...args: any[]) => Error;
|
|
31
|
+
|
|
32
|
+
const errorClasses = new Map<string, ErrorClass>();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Registers the class that decoding revives errors named `name` as, replacing
|
|
36
|
+
* any class registered under that name. Registration is per process: a worker
|
|
37
|
+
* that has not registered a class revives its errors as plain `Error`s with
|
|
38
|
+
* the same name, message, code, cause and fields.
|
|
39
|
+
*/
|
|
40
|
+
export function registerErrorClass(name: string, errorClass: ErrorClass): void {
|
|
41
|
+
if (typeof name !== "string" || name === "") {
|
|
42
|
+
throw new TypeError("Error class name must be a non-empty string");
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
typeof errorClass !== "function" ||
|
|
46
|
+
!(errorClass.prototype instanceof Error || errorClass === Error)
|
|
47
|
+
) {
|
|
48
|
+
throw new TypeError(`Error class "${name}" must extend Error`);
|
|
49
|
+
}
|
|
50
|
+
errorClasses.set(name, errorClass);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function registeredErrorClass(name: string): ErrorClass | undefined {
|
|
54
|
+
return errorClasses.get(name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Names are spelled out so a minifying bundler cannot change them.
|
|
58
|
+
const defaults: [string, ErrorClass][] = [
|
|
59
|
+
["TypeError", TypeError],
|
|
60
|
+
["RangeError", RangeError],
|
|
61
|
+
["SyntaxError", SyntaxError],
|
|
62
|
+
["ReferenceError", ReferenceError],
|
|
63
|
+
["EvalError", EvalError],
|
|
64
|
+
["URIError", URIError],
|
|
65
|
+
["AggregateError", AggregateError],
|
|
66
|
+
["WorkflowStartError", WorkflowStartError],
|
|
67
|
+
["WorkflowValueError", WorkflowValueError],
|
|
68
|
+
["WorkflowEffectError", WorkflowEffectError],
|
|
69
|
+
["EffectClientInvariantError", EffectClientInvariantError],
|
|
70
|
+
["EffectDeliveryError", EffectDeliveryError],
|
|
71
|
+
];
|
|
72
|
+
for (const [name, errorClass] of defaults) registerErrorClass(name, errorClass);
|
package/src/base/event.ts
CHANGED
|
@@ -38,9 +38,14 @@ export type WorkflowEvent<
|
|
|
38
38
|
context: Context;
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The event a trigger route passes through middleware before admission.
|
|
43
|
+
* `executionId` is absent when the caller lets the runtime choose one.
|
|
44
|
+
*/
|
|
41
45
|
export type MiddlewareEvent<
|
|
42
46
|
Params extends EventParams = EventParams,
|
|
43
47
|
Context extends MiddlewareEventContext = MiddlewareEventContext
|
|
44
|
-
> = ExecutionEvent<Params> & {
|
|
48
|
+
> = Omit<ExecutionEvent<Params>, "executionId" | "context"> & {
|
|
49
|
+
executionId?: string;
|
|
45
50
|
context: Context;
|
|
46
51
|
};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import type { DurableObject } from "./durable-value";
|
|
2
|
+
import type { EventContext, EventParams } from "./event";
|
|
3
|
+
|
|
4
|
+
export type ExecutionStatus =
|
|
5
|
+
| "admitted"
|
|
6
|
+
| "running"
|
|
7
|
+
| "suspended"
|
|
8
|
+
| "completed"
|
|
9
|
+
| "failed";
|
|
10
|
+
|
|
11
|
+
export type ExecutionOutcome =
|
|
12
|
+
| { status: "completed"; result: unknown }
|
|
13
|
+
| { status: "failed"; error: Error };
|
|
14
|
+
|
|
15
|
+
/** The execution and step that started an execution with `step.start`. */
|
|
16
|
+
export type ExecutionParent = { executionId: string; stepKey: string };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The durable record of one workflow execution. Admission creates it and
|
|
20
|
+
* stores the event once; every delivery reads the event from here.
|
|
21
|
+
*
|
|
22
|
+
* - `admitted`: no delivery has been acknowledged yet.
|
|
23
|
+
* - `running`: the execution has a live lease. When the lease ends without a
|
|
24
|
+
* terminal acknowledgement, the status returns to `suspended` if any
|
|
25
|
+
* earlier delivery was acknowledged as suspended, and to `admitted`
|
|
26
|
+
* otherwise.
|
|
27
|
+
* - `suspended`: the latest acknowledged delivery suspended the workflow.
|
|
28
|
+
* - `completed` and `failed` are terminal. `outcome` is present only in these
|
|
29
|
+
* states.
|
|
30
|
+
*
|
|
31
|
+
* `parent` is present, and immutable, only on executions admitted by `start`.
|
|
32
|
+
*/
|
|
33
|
+
export interface ExecutionRecord {
|
|
34
|
+
executionId: string;
|
|
35
|
+
workflowId: string;
|
|
36
|
+
params: EventParams;
|
|
37
|
+
context: EventContext;
|
|
38
|
+
status: ExecutionStatus;
|
|
39
|
+
outcome?: ExecutionOutcome;
|
|
40
|
+
admittedAt: number;
|
|
41
|
+
updatedAt: number;
|
|
42
|
+
/** Consecutive deliveries that ended without an acknowledgement. */
|
|
43
|
+
unacknowledgedDeliveryCount: number;
|
|
44
|
+
parent?: ExecutionParent;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ExecutionAdmissionErrorCode =
|
|
48
|
+
| "EXECUTION_ID_CONFLICT"
|
|
49
|
+
| "INVALID_EXECUTION_EVENT";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A lease on one execution, granted by `claimDue`. `token` is a fencing token:
|
|
53
|
+
* every claim of the execution issues a token greater than every earlier one,
|
|
54
|
+
* and only the current token's fenced writes are accepted. It also identifies
|
|
55
|
+
* the wakes the delivery covers. `expiresAt` is read from the runtime clock;
|
|
56
|
+
* clocks decide only when the execution becomes claimable again, never whose
|
|
57
|
+
* writes count.
|
|
58
|
+
*/
|
|
59
|
+
export type ExecutionLease = {
|
|
60
|
+
executionId: string;
|
|
61
|
+
token: number;
|
|
62
|
+
expiresAt: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Returned by a fenced write whose lease is no longer current: a higher token
|
|
67
|
+
* has been issued, or the lease has ended. The write changed nothing, and its
|
|
68
|
+
* holder must stop.
|
|
69
|
+
*/
|
|
70
|
+
export type LeaseLost = { status: "lost" };
|
|
71
|
+
|
|
72
|
+
export function isLeaseLost(value: unknown): value is LeaseLost {
|
|
73
|
+
return (
|
|
74
|
+
typeof value === "object" &&
|
|
75
|
+
value !== null &&
|
|
76
|
+
(value as { status?: unknown }).status === "lost"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What a delivery reports once its durable work is finished:
|
|
82
|
+
* - `completed`: the workflow returned a result;
|
|
83
|
+
* - `suspended`: the unfinished step is recorded, and `wakeAt`, when present,
|
|
84
|
+
* is the earliest time a paused step needs the execution to run again;
|
|
85
|
+
* - `failed`: workflow code threw an uncaught error.
|
|
86
|
+
*
|
|
87
|
+
* Infrastructure errors are never outcomes: the delivery rejects instead.
|
|
88
|
+
*/
|
|
89
|
+
export type WorkflowRunOutcome =
|
|
90
|
+
| { status: "completed"; result: unknown }
|
|
91
|
+
| { status: "suspended"; wakeAt?: number }
|
|
92
|
+
| { status: "failed"; error: Error };
|
|
93
|
+
|
|
94
|
+
export type ExecutionAdmissionRequest = {
|
|
95
|
+
executionId?: string;
|
|
96
|
+
workflowId: string;
|
|
97
|
+
params: EventParams;
|
|
98
|
+
context: EventContext;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type ExecutionAdmissionResult =
|
|
102
|
+
| { status: "accepted"; executionId: string }
|
|
103
|
+
| {
|
|
104
|
+
status: "rejected";
|
|
105
|
+
code: ExecutionAdmissionErrorCode;
|
|
106
|
+
message: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type WorkflowStartErrorCode =
|
|
110
|
+
| "INVALID_START_PARAMETERS"
|
|
111
|
+
| "START_REQUEST_MISMATCH";
|
|
112
|
+
|
|
113
|
+
export type ExecutionStartRequest = {
|
|
114
|
+
/** Identifies the request: the calling execution and its step key. */
|
|
115
|
+
requestId: { executionId: string; stepKey: string };
|
|
116
|
+
workflowId: string;
|
|
117
|
+
params?: DurableObject;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type ExecutionStartResult =
|
|
121
|
+
| { status: "accepted"; executionId: string }
|
|
122
|
+
| {
|
|
123
|
+
status: "rejected";
|
|
124
|
+
code: WorkflowStartErrorCode;
|
|
125
|
+
message: string;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export type ExecutionWakeResult =
|
|
129
|
+
| { status: "requested" }
|
|
130
|
+
| { status: "ignored" }
|
|
131
|
+
| {
|
|
132
|
+
status: "rejected";
|
|
133
|
+
code: "EXECUTION_NOT_FOUND";
|
|
134
|
+
message: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export type ExecutionAcknowledgeResult =
|
|
138
|
+
| { status: "suspended" }
|
|
139
|
+
| ExecutionOutcome
|
|
140
|
+
| LeaseLost;
|
|
141
|
+
|
|
142
|
+
export type ExecutionRenewResult =
|
|
143
|
+
| { status: "renewed"; lease: ExecutionLease }
|
|
144
|
+
| LeaseLost;
|
|
145
|
+
|
|
146
|
+
export type ExecutionReleaseResult = { status: "released" } | LeaseLost;
|
|
147
|
+
|
|
148
|
+
export interface ExecutionClient {
|
|
149
|
+
/**
|
|
150
|
+
* Creates the execution record with status `admitted` and makes it due
|
|
151
|
+
* immediately, all-or-nothing. Execution IDs are never reused.
|
|
152
|
+
*/
|
|
153
|
+
admit(request: ExecutionAdmissionRequest): Promise<ExecutionAdmissionResult>;
|
|
154
|
+
|
|
155
|
+
get(executionId: string): Promise<ExecutionRecord | undefined>;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Starts a workflow on behalf of the execution and step named by
|
|
159
|
+
* `requestId`, idempotently by that ID. A new start admits the target with
|
|
160
|
+
* the caller's context and a parent link to the caller, and records the
|
|
161
|
+
* start request, all-or-nothing. A repeated request returns the first
|
|
162
|
+
* target's ID if its workflow ID and canonical params match, and
|
|
163
|
+
* `START_REQUEST_MISMATCH` otherwise, whatever the target's status. Invalid
|
|
164
|
+
* params return `INVALID_START_PARAMETERS` and admit nothing. Not fenced.
|
|
165
|
+
*/
|
|
166
|
+
start(request: ExecutionStartRequest): Promise<ExecutionStartResult>;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Requests a wake from outside a delivery, such as a store commit. Wakes not
|
|
170
|
+
* covered by a claimed delivery merge into one due time: the earliest.
|
|
171
|
+
*/
|
|
172
|
+
requestWake(request: {
|
|
173
|
+
executionId: string;
|
|
174
|
+
wakeAt: number;
|
|
175
|
+
}): Promise<ExecutionWakeResult>;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Leases up to `limit` executions whose due time is at or before the runtime
|
|
179
|
+
* clock and that have no live lease, earliest due first. Each lease lasts
|
|
180
|
+
* `duration` milliseconds and carries a token greater than every earlier
|
|
181
|
+
* token for its execution. An execution has at most one live lease at a
|
|
182
|
+
* time, across every client sharing the backend.
|
|
183
|
+
*/
|
|
184
|
+
claimDue(request: {
|
|
185
|
+
limit: number;
|
|
186
|
+
duration: number;
|
|
187
|
+
}): Promise<ExecutionLease[]>;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Extends a lease to `duration` milliseconds from now. Returns `lost` if a
|
|
191
|
+
* higher token has been issued or the lease has ended. An expired lease
|
|
192
|
+
* that has not been claimed again is still current.
|
|
193
|
+
*/
|
|
194
|
+
renew(request: {
|
|
195
|
+
lease: ExecutionLease;
|
|
196
|
+
duration: number;
|
|
197
|
+
}): Promise<ExecutionRenewResult>;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Ends a lease without acknowledging an outcome. The delivery counts as
|
|
201
|
+
* unacknowledged, so the execution becomes claimable again only after the
|
|
202
|
+
* redelivery delay. Returns `lost` for a stale lease.
|
|
203
|
+
*/
|
|
204
|
+
release(request: { lease: ExecutionLease }): Promise<ExecutionReleaseResult>;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Records a delivery's outcome and ends its lease, all-or-nothing. A
|
|
208
|
+
* `suspended` outcome clears the wakes the lease covered, leaving the
|
|
209
|
+
* execution due at the earliest of `wakeAt` and any wake requested since the
|
|
210
|
+
* claim. A stale lease returns `lost` and changes nothing.
|
|
211
|
+
*/
|
|
212
|
+
acknowledge(request: {
|
|
213
|
+
lease: ExecutionLease;
|
|
214
|
+
outcome: WorkflowRunOutcome;
|
|
215
|
+
}): Promise<ExecutionAcknowledgeResult>;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Thrown by the driver when a fenced write returns `lost`: another claim has
|
|
220
|
+
* taken over the execution, so this delivery must stop without acknowledging.
|
|
221
|
+
*/
|
|
222
|
+
export class LeaseLostError extends Error {
|
|
223
|
+
readonly executionId: string;
|
|
224
|
+
|
|
225
|
+
constructor(executionId: string) {
|
|
226
|
+
super(`Lease on execution "${executionId}" was lost`);
|
|
227
|
+
this.name = "LeaseLostError";
|
|
228
|
+
this.executionId = executionId;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Thrown by the driver when the current holder writes a step record that
|
|
234
|
+
* already exists with a different value. The recorded value stands: the
|
|
235
|
+
* delivery ends as a retry, and the next delivery replays the recorded value.
|
|
236
|
+
*/
|
|
237
|
+
export class StepConflictError extends Error {
|
|
238
|
+
readonly executionId: string;
|
|
239
|
+
readonly stepKey: string;
|
|
240
|
+
|
|
241
|
+
constructor(executionId: string, stepKey: string) {
|
|
242
|
+
super(
|
|
243
|
+
`Step "${stepKey}" of execution "${executionId}" is already recorded with a different value`
|
|
244
|
+
);
|
|
245
|
+
this.name = "StepConflictError";
|
|
246
|
+
this.executionId = executionId;
|
|
247
|
+
this.stepKey = stepKey;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export class ExecutionAdmissionError extends Error {
|
|
252
|
+
readonly code: ExecutionAdmissionErrorCode;
|
|
253
|
+
|
|
254
|
+
constructor(code: ExecutionAdmissionErrorCode, message: string) {
|
|
255
|
+
super(message);
|
|
256
|
+
this.name = "ExecutionAdmissionError";
|
|
257
|
+
this.code = code;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Thrown to workflow code by `step.start` when the runtime rejects the start.
|
|
263
|
+
* The rejection is recorded as the step's result, so replay throws it again.
|
|
264
|
+
*/
|
|
265
|
+
export class WorkflowStartError extends Error {
|
|
266
|
+
readonly code: WorkflowStartErrorCode;
|
|
267
|
+
|
|
268
|
+
constructor(code: WorkflowStartErrorCode, message: string) {
|
|
269
|
+
super(message);
|
|
270
|
+
this.name = "WorkflowStartError";
|
|
271
|
+
this.code = code;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const INFRASTRUCTURE_ERROR = Symbol.for("yieldstar.infrastructureError");
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Wraps a failure of the runtime rather than of workflow code, such as a heap
|
|
279
|
+
* or store client rejecting. A delivery that hits one rejects without
|
|
280
|
+
* resuming workflow code, and is not acknowledged.
|
|
281
|
+
*/
|
|
282
|
+
export class InfrastructureError extends Error {
|
|
283
|
+
readonly [INFRASTRUCTURE_ERROR] = true;
|
|
284
|
+
|
|
285
|
+
constructor(cause: unknown) {
|
|
286
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
|
287
|
+
this.name = "InfrastructureError";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
static is(value: unknown): value is InfrastructureError {
|
|
291
|
+
return (
|
|
292
|
+
typeof value === "object" &&
|
|
293
|
+
value !== null &&
|
|
294
|
+
(value as Record<symbol, unknown>)[INFRASTRUCTURE_ERROR] === true
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function isTerminalExecutionStatus(
|
|
300
|
+
status: ExecutionStatus
|
|
301
|
+
): status is "completed" | "failed" {
|
|
302
|
+
return status === "completed" || status === "failed";
|
|
303
|
+
}
|
package/src/base/heap.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ExecutionLease, LeaseLost } from "./execution";
|
|
2
|
+
|
|
1
3
|
export type HeapRecord = {
|
|
2
4
|
stepResponseJson: string;
|
|
3
5
|
meta: { attempt: number; done: boolean };
|
|
@@ -8,11 +10,23 @@ export abstract class HeapClient {
|
|
|
8
10
|
executionId: string;
|
|
9
11
|
stepKey: string;
|
|
10
12
|
}): Promise<HeapRecord | null>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Records one attempt of a step, fenced by `lease`. The token check and the
|
|
16
|
+
* write are atomic: if the lease is not current, nothing changes and the
|
|
17
|
+
* write returns `lost`. A `done` record also deletes the step's store
|
|
18
|
+
* and effect waiters in the same write.
|
|
19
|
+
*
|
|
20
|
+
* Records are keyed by execution, step key and attempt, and never change
|
|
21
|
+
* once written. Rewriting an existing record succeeds without changing it,
|
|
22
|
+
* and resolves with the record as stored, which may hold a different value
|
|
23
|
+
* from the one written: callers compare it to detect a conflicting write.
|
|
24
|
+
*/
|
|
11
25
|
abstract writeStep(params: {
|
|
12
|
-
|
|
26
|
+
lease: ExecutionLease;
|
|
13
27
|
stepKey: string;
|
|
14
28
|
stepAttempt: number;
|
|
15
|
-
|
|
29
|
+
done: boolean;
|
|
16
30
|
stepResponseJson: string;
|
|
17
|
-
}): Promise<
|
|
31
|
+
}): Promise<HeapRecord | LeaseLost>;
|
|
18
32
|
}
|