@astrale-os/sdk 0.6.0-beta.40 → 0.6.0-beta.41
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/application/workflow/durable.d.ts +25 -13
- package/dist/execution/workflows/attempts.d.ts +87 -0
- package/dist/execution/workflows/attempts.js +205 -0
- package/dist/execution/workflows/engine.d.ts +6 -12
- package/dist/execution/workflows/failure.d.ts +6 -13
- package/dist/execution/workflows/failure.js +9 -26
- package/dist/execution/workflows/index.d.ts +1 -1
- package/dist/execution/workflows/options.d.ts +21 -6
- package/dist/execution/workflows/options.js +28 -18
- package/dist/execution/workflows/run.d.ts +2 -0
- package/dist/execution/workflows/run.js +120 -76
- package/dist/execution/workflows/steps.d.ts +7 -1
- package/dist/execution/workflows/steps.js +32 -6
- package/dist/testing/durable/engine.d.ts +18 -11
- package/dist/testing/durable/engine.js +43 -38
- package/dist/testing/durable/index.d.ts +2 -2
- package/dist/testing/durable/index.js +1 -1
- package/dist/testing/durable/steps.js +9 -4
- package/dist/testing/index.d.ts +2 -2
- package/dist/testing/index.js +1 -1
- package/dist/tooling/linter/implementations/source/workflows.js +0 -1
- package/docs/durable-workflows.md +69 -42
- package/package.json +1 -1
|
@@ -11,6 +11,8 @@ export interface DurableRun {
|
|
|
11
11
|
readonly serving: AdmittedDurableServing;
|
|
12
12
|
/** What each Step attempt holds. */
|
|
13
13
|
readonly steps: StepAuthorities;
|
|
14
|
+
/** The clock that times each Step attempt against its Step's `maxDurationMs`; `Date.now` by default. */
|
|
15
|
+
readonly now?: () => number;
|
|
14
16
|
}
|
|
15
17
|
/** Engine handlers of one durable Workflow: the `run` and the shared `signal`. */
|
|
16
18
|
export declare function durableHandlers(input: DurableRun): Readonly<{
|
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import { declaredErrorFactory } from '../../application/error/index.js';
|
|
2
2
|
import { acceptStepId } from '../../application/workflow/step/index.js';
|
|
3
3
|
import { acceptStepOutput } from '../../application/workflow/step/output.js';
|
|
4
|
-
import { ResponseError } from '../../platform/client/index.js';
|
|
5
4
|
import { validateOutput } from '../invocation/result/index.js';
|
|
5
|
+
import { BACKOFF, UNAVAILABLE, failedAttemptError, failedAttemptOf, nextAttempt, } from './attempts.js';
|
|
6
6
|
import { decodeEnvelope } from './envelope.js';
|
|
7
|
-
import {
|
|
7
|
+
import { replayedFailure, StepExhausted, StepRetryAfter, terminalFailure } from './failure.js';
|
|
8
8
|
import { stepServing } from './options.js';
|
|
9
9
|
import { keySequence } from './steps.js';
|
|
10
10
|
const MAXIMUM_SLEEP_MS = 365 * 24 * 60 * 60_000;
|
|
11
11
|
const MAXIMUM_SIGNAL_NAME = 128;
|
|
12
|
-
/** Bounds of one `result` wait and of the durable pause between two of them. */
|
|
13
|
-
const AWAIT_WAIT_MS = 10_000;
|
|
14
|
-
const AWAIT_MAXIMUM_PAUSE_MS = 5 * 60_000;
|
|
15
12
|
/** Engine handlers of one durable Workflow: the `run` and the shared `signal`. */
|
|
16
13
|
export function durableHandlers(input) {
|
|
17
14
|
const { engine } = input.serving;
|
|
@@ -95,52 +92,84 @@ function durableContext(input, context, envelope) {
|
|
|
95
92
|
running = undefined;
|
|
96
93
|
});
|
|
97
94
|
};
|
|
95
|
+
const now = input.now ?? Date.now;
|
|
98
96
|
/**
|
|
99
|
-
* One
|
|
100
|
-
* Mutations, durable calls and Integration calls share one key sequence derived from
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
97
|
+
* One attempt of Step `id`, holding the capabilities of an Action as the Domain alone. Its
|
|
98
|
+
* Mutations, durable calls and Integration calls share one key sequence derived from the Step's
|
|
99
|
+
* name, which every attempt derives again. The engine journals its outcome: the Step's output, its
|
|
100
|
+
* declared error, or the failed attempt, as `failedAttemptError` records it. The Step decides from
|
|
101
|
+
* that journal alone whether to go on, so a replay decides alike.
|
|
104
102
|
*
|
|
105
103
|
* The engine promise is returned unawaited: awaiting it is what tells the engine the run waits on
|
|
106
104
|
* it, so parallel Steps hand theirs to the engine's own combinator instead.
|
|
107
105
|
*/
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
};
|
|
117
|
-
const next = await keySequence(execution.id, name);
|
|
118
|
-
const { authority } = (step = input.steps({ name, execution, next, lifetime }));
|
|
119
|
-
const output = await Promise.race([
|
|
120
|
-
Promise.resolve().then(() => execute(authority)),
|
|
121
|
-
expired(expiry.signal),
|
|
122
|
-
]);
|
|
106
|
+
const attempt = (id, held, execute) => {
|
|
107
|
+
const name = `step:${id}`;
|
|
108
|
+
const { timeoutMs } = held;
|
|
109
|
+
return context.run(name, async () => {
|
|
110
|
+
const startedAt = now();
|
|
111
|
+
const expiry = new AbortController();
|
|
112
|
+
const timer = setTimeout(() => expiry.abort(new Error(`Workflow Step ${id} exceeded its ${timeoutMs} ms timeout.`)), timeoutMs);
|
|
113
|
+
let step;
|
|
123
114
|
try {
|
|
124
|
-
|
|
115
|
+
const lifetime = {
|
|
116
|
+
deadline: Date.now() + timeoutMs,
|
|
117
|
+
signal: AbortSignal.any([signal, expiry.signal]),
|
|
118
|
+
};
|
|
119
|
+
const next = await keySequence(execution.id, name);
|
|
120
|
+
const { authority } = (step = input.steps({ name, execution, next, lifetime }));
|
|
121
|
+
const output = await Promise.race([
|
|
122
|
+
Promise.resolve().then(() => execute(authority)),
|
|
123
|
+
expired(expiry.signal),
|
|
124
|
+
]);
|
|
125
|
+
try {
|
|
126
|
+
return acceptStepOutput(output);
|
|
127
|
+
}
|
|
128
|
+
catch (cause) {
|
|
129
|
+
throw new TypeError(`Workflow ${name} output must be portable.`, { cause });
|
|
130
|
+
}
|
|
125
131
|
}
|
|
126
132
|
catch (cause) {
|
|
127
|
-
throw
|
|
133
|
+
throw (terminalFailure(engine, cause, definition) ??
|
|
134
|
+
failedAttemptError(engine, cause, startedAt, now(), step?.repeatsEffects() ?? false));
|
|
128
135
|
}
|
|
136
|
+
finally {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
step?.close();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* How Step `id` goes on after an attempt that failed with the journaled `outcome`, given what its
|
|
144
|
+
* earlier attempts spent. A declared error, an exhaustion the Step threw again, and the engine's
|
|
145
|
+
* cancellation are final: they throw, as the Workflow sees them.
|
|
146
|
+
*/
|
|
147
|
+
const afterFailure = (id, held, spent, outcome) => {
|
|
148
|
+
const failed = failedAttemptOf(engine, outcome);
|
|
149
|
+
if (failed === undefined)
|
|
150
|
+
throw replayedFailure(engine, outcome, definition, id) ?? outcome;
|
|
151
|
+
return nextAttempt(spent, failed, held.retry);
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* One Step, awaited alone: attempted until it succeeds, fails for good or spends its budget, each
|
|
155
|
+
* attempt after a durable wait the failure of the previous one prescribes.
|
|
156
|
+
*/
|
|
157
|
+
const attempts = async (id, held, execute) => {
|
|
158
|
+
let spent;
|
|
159
|
+
for (;;) {
|
|
160
|
+
let next;
|
|
161
|
+
try {
|
|
162
|
+
return await attempt(id, held, execute);
|
|
163
|
+
}
|
|
164
|
+
catch (outcome) {
|
|
165
|
+
next = afterFailure(id, held, spent, outcome);
|
|
166
|
+
}
|
|
167
|
+
if (next.kind === 'exhausted')
|
|
168
|
+
throw new StepExhausted(id, next.refusal);
|
|
169
|
+
spent = next.spending;
|
|
170
|
+
await context.sleep(next.delayMs, `${next.wait}:step:${id}`);
|
|
129
171
|
}
|
|
130
|
-
|
|
131
|
-
throw (terminalFailure(engine, cause, definition) ??
|
|
132
|
-
delayedRetry(engine, cause) ??
|
|
133
|
-
attemptFailure(cause));
|
|
134
|
-
}
|
|
135
|
-
finally {
|
|
136
|
-
clearTimeout(timer);
|
|
137
|
-
step?.close();
|
|
138
|
-
}
|
|
139
|
-
}, retry);
|
|
140
|
-
/** One Step, awaited alone; its failure reaches the Workflow as `replayedFailure` reads it. */
|
|
141
|
-
const journal = (id, name, timeoutMs, retry, execute) => Promise.resolve(journaled(id, name, timeoutMs, retry, execute)).catch((cause) => {
|
|
142
|
-
throw replayedFailure(engine, cause, definition, id) ?? cause;
|
|
143
|
-
});
|
|
172
|
+
};
|
|
144
173
|
const step = (declared, execute) => {
|
|
145
174
|
let accepted;
|
|
146
175
|
try {
|
|
@@ -152,13 +181,15 @@ function durableContext(input, context, envelope) {
|
|
|
152
181
|
return Promise.reject(cause);
|
|
153
182
|
}
|
|
154
183
|
steps.add(accepted.id);
|
|
155
|
-
|
|
156
|
-
return sequenced(`Step ${accepted.id}`, () => journal(accepted.id, `step:${accepted.id}`, held.timeoutMs, held.retry, execute));
|
|
184
|
+
return sequenced(`Step ${accepted.id}`, () => attempts(accepted.id, accepted.held, execute));
|
|
157
185
|
};
|
|
158
186
|
/**
|
|
159
|
-
* The Steps `declare` declares,
|
|
160
|
-
* one operation.
|
|
161
|
-
*
|
|
187
|
+
* The Steps `declare` declares, attempted together in rounds, each round awaited through the
|
|
188
|
+
* engine's combinator as one operation. A Step whose attempt failed and that goes on is attempted
|
|
189
|
+
* again in the next round, after one durable wait, the longest any of them prescribes; each Step
|
|
190
|
+
* spends its own budget. Once all have settled, their outputs in declaration order, or the failure
|
|
191
|
+
* of the first failed one in that order, as that Step alone fails. A cancellation ends the round
|
|
192
|
+
* it reaches.
|
|
162
193
|
*/
|
|
163
194
|
const parallel = (declare) => {
|
|
164
195
|
const declared = new Map();
|
|
@@ -201,14 +232,50 @@ function durableContext(input, context, envelope) {
|
|
|
201
232
|
for (const { id } of group)
|
|
202
233
|
steps.add(id);
|
|
203
234
|
return sequenced(`parallel Steps ${group.map(({ id }) => id).join(', ')}`, async () => {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
235
|
+
const states = group.map((declared) => ({
|
|
236
|
+
...declared,
|
|
237
|
+
spent: undefined,
|
|
238
|
+
result: undefined,
|
|
239
|
+
}));
|
|
240
|
+
for (;;) {
|
|
241
|
+
const pending = states.filter(({ result }) => result === undefined);
|
|
242
|
+
const settled = await engine.RestatePromise.allSettled(pending.map(({ id, held, execute }) => attempt(id, held, execute)));
|
|
243
|
+
const cancelled = settled.find((outcome) => outcome.status === 'rejected' && outcome.reason instanceof engine.CancelledError);
|
|
244
|
+
if (cancelled !== undefined)
|
|
245
|
+
throw cancelled.reason;
|
|
246
|
+
let delayMs = 0;
|
|
247
|
+
let wait = BACKOFF;
|
|
248
|
+
settled.forEach((outcome, index) => {
|
|
249
|
+
const state = pending[index];
|
|
250
|
+
if (outcome.status === 'fulfilled') {
|
|
251
|
+
state.result = outcome;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
let next;
|
|
255
|
+
try {
|
|
256
|
+
next = afterFailure(state.id, state.held, state.spent, outcome.reason);
|
|
257
|
+
}
|
|
258
|
+
catch (failure) {
|
|
259
|
+
state.result = { status: 'rejected', reason: failure };
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (next.kind === 'exhausted') {
|
|
263
|
+
state.result = { status: 'rejected', reason: new StepExhausted(state.id, next.refusal) };
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
state.spent = next.spending;
|
|
267
|
+
delayMs = Math.max(delayMs, next.delayMs);
|
|
268
|
+
if (next.wait === UNAVAILABLE)
|
|
269
|
+
wait = UNAVAILABLE;
|
|
270
|
+
});
|
|
271
|
+
if (states.every(({ result }) => result !== undefined))
|
|
272
|
+
break;
|
|
273
|
+
await context.sleep(delayMs, `${wait}:parallel`);
|
|
209
274
|
}
|
|
210
|
-
const
|
|
211
|
-
|
|
275
|
+
const failed = states.find(({ result }) => result.status === 'rejected');
|
|
276
|
+
if (failed !== undefined)
|
|
277
|
+
throw failed.result.reason;
|
|
278
|
+
return states.map(({ result }) => result.value);
|
|
212
279
|
});
|
|
213
280
|
};
|
|
214
281
|
return Object.freeze({
|
|
@@ -253,29 +320,6 @@ function durableContext(input, context, envelope) {
|
|
|
253
320
|
cancelled: (error) => error instanceof engine.CancelledError,
|
|
254
321
|
exhausted: (error) => error instanceof StepExhausted,
|
|
255
322
|
retryAfter: (delayMs, cause) => new StepRetryAfter(delayMs, cause),
|
|
256
|
-
awaitExecution: (target) => sequenced(`wait for Execution ${target.id}`, async () => {
|
|
257
|
-
for (let poll = 1;; poll++) {
|
|
258
|
-
// A poll is no Step of the author's: it has no budget, so an outage pauses the run instead.
|
|
259
|
-
const name = `await:${target.id}:${poll}`;
|
|
260
|
-
const polled = await journal(name, name, serving.step.timeoutMs, undefined, async ({ kernel }) => {
|
|
261
|
-
try {
|
|
262
|
-
const outcome = await kernel.execution.result(target, {
|
|
263
|
-
waitMs: Math.min(AWAIT_WAIT_MS, Math.floor(serving.step.timeoutMs / 2)),
|
|
264
|
-
});
|
|
265
|
-
return { ended: true, outcome };
|
|
266
|
-
}
|
|
267
|
-
catch (cause) {
|
|
268
|
-
if (cause instanceof ResponseError && cause.reason?.code === 'EXECUTION_PENDING') {
|
|
269
|
-
return { ended: false };
|
|
270
|
-
}
|
|
271
|
-
throw cause;
|
|
272
|
-
}
|
|
273
|
-
});
|
|
274
|
-
if (polled.ended)
|
|
275
|
-
return polled.outcome;
|
|
276
|
-
await context.sleep(Math.min(AWAIT_MAXIMUM_PAUSE_MS, 1_000 * 2 ** Math.min(poll, 16)));
|
|
277
|
-
}
|
|
278
|
-
}),
|
|
279
323
|
...(execution.receiver === undefined ? {} : { self: execution.receiver }),
|
|
280
324
|
});
|
|
281
325
|
}
|
|
@@ -16,6 +16,8 @@ export interface StepAttempt {
|
|
|
16
16
|
/** What one Step attempt holds, released once the attempt ends. */
|
|
17
17
|
export interface StepAuthorityBinding {
|
|
18
18
|
readonly authority: DurableStepAuthority;
|
|
19
|
+
/** Whether the attempt ran an `invoke` without a key, which the next attempt runs again. */
|
|
20
|
+
repeatsEffects(): boolean;
|
|
19
21
|
close(): void;
|
|
20
22
|
}
|
|
21
23
|
/** Bind the capabilities one Step attempt holds. */
|
|
@@ -35,8 +37,12 @@ export declare function sessionSteps(input: {
|
|
|
35
37
|
* replays the same keys: the Kernel answers a Mutation with its receipt and a durable call with the
|
|
36
38
|
* Execution it started, or refuses a request that changed under its key. Any other `invoke` carries
|
|
37
39
|
* no key, because the Kernel accepts one only for a durable Function, and runs again with the Step.
|
|
40
|
+
*
|
|
41
|
+
* A keyed call and a Query repeat no effect when sent again, so their failures are marked
|
|
42
|
+
* resendable: an unreachable Kernel then costs the Step no attempt. `unkeyed` hears each `invoke`
|
|
43
|
+
* without a key that may have run.
|
|
38
44
|
*/
|
|
39
|
-
export declare function keyed<Session extends Pick<BoundClientSession, 'invoke' | 'mutate'>>(session: Session, domain: Domain, next: () => string): Session;
|
|
45
|
+
export declare function keyed<Session extends Pick<BoundClientSession, 'invoke' | 'mutate'>>(session: Session, domain: Domain, next: () => string, unkeyed: () => void): Session;
|
|
40
46
|
/** The key sequence of one Step of one Execution: `<execution>.<digest of the name>.<position>`. */
|
|
41
47
|
export declare function keySequence(execution: DurableExecution['id'], name: string): Promise<() => string>;
|
|
42
48
|
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { bindSession } from '../../platform/client/session/index.js';
|
|
2
2
|
import { bindCapabilities } from '../invocation/capabilities/index.js';
|
|
3
3
|
import { bindIntegrationAuthentication } from '../invocation/integration/index.js';
|
|
4
|
+
import { resendableCall, unkeyedCall } from './attempts.js';
|
|
4
5
|
import { stepCalls } from './integrations.js';
|
|
5
6
|
/**
|
|
6
7
|
* Steps acting as the Domain through a Session of the Execution they belong to: the capabilities of
|
|
@@ -12,13 +13,20 @@ export function sessionSteps(input) {
|
|
|
12
13
|
const session = executor(execution.kernel, execution.id, lifetime);
|
|
13
14
|
try {
|
|
14
15
|
const bound = bindSession(session, lifetime);
|
|
15
|
-
|
|
16
|
+
let repeats = false;
|
|
17
|
+
const self = keyed(bound, domain, next, () => {
|
|
18
|
+
repeats = true;
|
|
19
|
+
});
|
|
16
20
|
const auth = bindIntegrationAuthentication(lifetime, bound);
|
|
17
21
|
const authority = Object.freeze({
|
|
18
22
|
...bindCapabilities(domain, { self }),
|
|
19
23
|
integrations: integrations(stepCalls({ next, lifetime, auth })),
|
|
20
24
|
});
|
|
21
|
-
return Object.freeze({
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
authority,
|
|
27
|
+
repeatsEffects: () => repeats,
|
|
28
|
+
close: () => session.close(),
|
|
29
|
+
});
|
|
22
30
|
}
|
|
23
31
|
catch (cause) {
|
|
24
32
|
session.close();
|
|
@@ -32,13 +40,31 @@ export function sessionSteps(input) {
|
|
|
32
40
|
* replays the same keys: the Kernel answers a Mutation with its receipt and a durable call with the
|
|
33
41
|
* Execution it started, or refuses a request that changed under its key. Any other `invoke` carries
|
|
34
42
|
* no key, because the Kernel accepts one only for a durable Function, and runs again with the Step.
|
|
43
|
+
*
|
|
44
|
+
* A keyed call and a Query repeat no effect when sent again, so their failures are marked
|
|
45
|
+
* resendable: an unreachable Kernel then costs the Step no attempt. `unkeyed` hears each `invoke`
|
|
46
|
+
* without a key that may have run.
|
|
35
47
|
*/
|
|
36
|
-
export function keyed(session, domain, next) {
|
|
48
|
+
export function keyed(session, domain, next, unkeyed) {
|
|
37
49
|
const invokeSource = session.invoke;
|
|
38
50
|
const mutateSource = session.mutate;
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
51
|
+
const querySource = session.query;
|
|
52
|
+
const invoke = ((reference, value, options) => {
|
|
53
|
+
const sent = declaredDurable(domain, reference) ? withKey(options, next) : options;
|
|
54
|
+
const send = () => invokeSource(reference, value, sent);
|
|
55
|
+
return sent?.idempotencyKey === undefined ? unkeyedCall(send, unkeyed) : resendableCall(send);
|
|
56
|
+
});
|
|
57
|
+
const mutate = ((author, options) => {
|
|
58
|
+
const sent = withKey(options, next);
|
|
59
|
+
return sentKeyed(sent, () => mutateSource(author, sent));
|
|
60
|
+
});
|
|
61
|
+
const query = querySource === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: { query: (...input) => resendableCall(() => querySource(...input)) };
|
|
64
|
+
return Object.freeze({ ...session, invoke, mutate, ...query });
|
|
65
|
+
}
|
|
66
|
+
function sentKeyed(options, send) {
|
|
67
|
+
return options?.idempotencyKey === undefined ? send() : resendableCall(send);
|
|
42
68
|
}
|
|
43
69
|
/**
|
|
44
70
|
* Whether the Domain's Schema closure declares the referenced callable durable. An untyped Path
|
|
@@ -7,23 +7,25 @@ import type { NodeId } from '../../platform/graph/node/index.js';
|
|
|
7
7
|
import type { CallableInputOf, ResolvedMethod, schema } from '../../platform/schema/index.js';
|
|
8
8
|
import type { Value } from '../../platform/value/index.js';
|
|
9
9
|
import type { TestStepCapabilities } from './steps.js';
|
|
10
|
-
export interface
|
|
10
|
+
export interface TestWorkflowEngineOptions {
|
|
11
11
|
/** Start of the virtual clock, in Unix milliseconds; the current time by default. */
|
|
12
12
|
readonly now?: number;
|
|
13
13
|
/**
|
|
14
|
-
* The deployment's `durable.retry`: the budget of every Step that declares none,
|
|
15
|
-
*
|
|
14
|
+
* The deployment's `durable.retry`: the budget of every Step that declares none, and with
|
|
15
|
+
* `maxAttempts` the attempts of a run failing outside any Step before it pauses.
|
|
16
16
|
*/
|
|
17
17
|
readonly retry?: DurableStepRetry;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* An in-memory workflow engine that runs durable Workflows with the receiver a Host serves: it
|
|
21
|
-
* journals each Step, replays the journal on every attempt,
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* journals each Step attempt, replays the journal on every attempt, keeps timers on a virtual clock,
|
|
22
|
+
* buffers signals, pauses a run failing outside any Step after its last attempt and cancels
|
|
23
|
+
* cooperatively. A Step retries as on a Host: a failure of its own code spends its budget, and a
|
|
24
|
+
* Kernel or a transport that is unavailable makes it wait without spending it. The wait after the
|
|
25
|
+
* Step's own failure is not waited out, but counts toward its `maxDurationMs`; a wait for the
|
|
26
|
+
* platform is a timer, which `advance` moves past.
|
|
25
27
|
*/
|
|
26
|
-
export interface
|
|
28
|
+
export interface TestWorkflowEngine {
|
|
27
29
|
/** The virtual clock, in Unix milliseconds. */
|
|
28
30
|
readonly now: number;
|
|
29
31
|
/** Start one durable Execution and settle it: it ends, waits, or pauses. */
|
|
@@ -56,7 +58,7 @@ type ProvidersOf<Definitions extends Integrations> = keyof Definitions extends n
|
|
|
56
58
|
} : {
|
|
57
59
|
readonly providers: Providers<Definitions>;
|
|
58
60
|
};
|
|
59
|
-
/** One durable Execution on a test engine. */
|
|
61
|
+
/** One durable Execution on a test workflow engine. */
|
|
60
62
|
export interface TestExecution<Output> {
|
|
61
63
|
readonly id: NodeId;
|
|
62
64
|
readonly state: Extract<ExecutionState, 'running' | 'waiting' | 'paused' | 'completed' | 'failed' | 'cancelled'>;
|
|
@@ -96,12 +98,17 @@ export type TestJournalEntry = {
|
|
|
96
98
|
readonly kind: 'run';
|
|
97
99
|
readonly name: string;
|
|
98
100
|
readonly settled: boolean;
|
|
99
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* Times this entry's work ran, interruptions included; a replay runs it no more. Each retry of
|
|
103
|
+
* a Step is an entry of its own, after the wait its failure prescribes.
|
|
104
|
+
*/
|
|
100
105
|
readonly attempts: number;
|
|
101
106
|
readonly value?: unknown;
|
|
102
107
|
readonly error?: unknown;
|
|
103
108
|
} | {
|
|
104
109
|
readonly kind: 'sleep';
|
|
110
|
+
/** The wait's name, if it has one: `retry:` or `unavailable:` and what it retries. */
|
|
111
|
+
readonly name?: string;
|
|
105
112
|
readonly until: number;
|
|
106
113
|
readonly settled: boolean;
|
|
107
114
|
} | {
|
|
@@ -117,5 +124,5 @@ export type TestJournalEntry = {
|
|
|
117
124
|
readonly outcome?: 'signal' | 'timeout' | 'cancelled';
|
|
118
125
|
};
|
|
119
126
|
/** Create an in-memory workflow engine for tests. */
|
|
120
|
-
export declare function
|
|
127
|
+
export declare function createTestWorkflowEngine(options?: TestWorkflowEngineOptions): TestWorkflowEngine;
|
|
121
128
|
export {};
|
|
@@ -12,6 +12,10 @@ const TEST_KERNEL = issuer.accept('https://kernel.test.astrale.invalid');
|
|
|
12
12
|
const QUIET_TURNS = 64;
|
|
13
13
|
/** Metadata the Kernel reads a declared failure from. */
|
|
14
14
|
const FAILURE = 'astrale.failure';
|
|
15
|
+
/** Metadata a failed Step attempt's journal entry records it under. */
|
|
16
|
+
const ATTEMPT = 'astrale.attempt';
|
|
17
|
+
/** Name prefix of the wait a Step's code failure prescribes before its next attempt. */
|
|
18
|
+
const BACKOFF = 'retry:';
|
|
15
19
|
class TerminalError extends Error {
|
|
16
20
|
code;
|
|
17
21
|
metadata;
|
|
@@ -59,10 +63,10 @@ class JournalMismatch extends Error {
|
|
|
59
63
|
this.name = 'JournalMismatch';
|
|
60
64
|
}
|
|
61
65
|
}
|
|
62
|
-
/** The promises the test engine's `run` returned: the only ones its combinator accepts. */
|
|
66
|
+
/** The promises the test workflow engine's `run` returned: the only ones its combinator accepts. */
|
|
63
67
|
const RUNS = new WeakSet();
|
|
64
68
|
const ENGINE = Object.freeze({
|
|
65
|
-
createEndpointHandler: () => () => Promise.reject(new Error('A test engine serves no HTTP.')),
|
|
69
|
+
createEndpointHandler: () => () => Promise.reject(new Error('A test workflow engine serves no HTTP.')),
|
|
66
70
|
workflow: (definition) => definition,
|
|
67
71
|
TerminalError,
|
|
68
72
|
CancelledError,
|
|
@@ -81,7 +85,7 @@ const ENGINE = Object.freeze({
|
|
|
81
85
|
}),
|
|
82
86
|
});
|
|
83
87
|
/** Create an in-memory workflow engine for tests. */
|
|
84
|
-
export function
|
|
88
|
+
export function createTestWorkflowEngine(options = {}) {
|
|
85
89
|
const serving = durableServing({
|
|
86
90
|
engine: ENGINE,
|
|
87
91
|
...(options.retry === undefined ? {} : { retry: options.retry }),
|
|
@@ -89,7 +93,7 @@ export function createTestEngine(options = {}) {
|
|
|
89
93
|
const maxAttempts = serving.retryPolicy.maxAttempts;
|
|
90
94
|
let now = options.now ?? Date.now();
|
|
91
95
|
if (!Number.isFinite(now))
|
|
92
|
-
throw new TypeError('Test engine now must be finite.');
|
|
96
|
+
throw new TypeError('Test workflow engine now must be finite.');
|
|
93
97
|
let sequence = 0;
|
|
94
98
|
const runs = new Set();
|
|
95
99
|
const clock = { now: () => now };
|
|
@@ -106,7 +110,7 @@ export function createTestEngine(options = {}) {
|
|
|
106
110
|
},
|
|
107
111
|
async advance(durationMs) {
|
|
108
112
|
if (!Number.isSafeInteger(durationMs) || durationMs < 0) {
|
|
109
|
-
throw new TypeError('Test engine advance takes a non-negative integer of milliseconds.');
|
|
113
|
+
throw new TypeError('Test workflow engine advance takes a non-negative integer of milliseconds.');
|
|
110
114
|
}
|
|
111
115
|
now += durationMs;
|
|
112
116
|
for (const run of runs)
|
|
@@ -126,6 +130,8 @@ class TestRun {
|
|
|
126
130
|
signals = new Map();
|
|
127
131
|
interruptions = new Set();
|
|
128
132
|
cancellation = 'none';
|
|
133
|
+
/** The waits after a Step's own failures the run did not wait out, which its Steps still spent. */
|
|
134
|
+
skipped = 0;
|
|
129
135
|
failures = 0;
|
|
130
136
|
handlers;
|
|
131
137
|
envelope;
|
|
@@ -145,6 +151,7 @@ class TestRun {
|
|
|
145
151
|
workflow: admitted,
|
|
146
152
|
domain,
|
|
147
153
|
serving,
|
|
154
|
+
now: () => this.clock.now() + this.skipped,
|
|
148
155
|
steps: testSteps({
|
|
149
156
|
domain,
|
|
150
157
|
capabilities: (input.capabilities ?? {}),
|
|
@@ -312,12 +319,12 @@ class Attempt {
|
|
|
312
319
|
this.run = run;
|
|
313
320
|
this.mode = mode;
|
|
314
321
|
this.context = Object.freeze({
|
|
315
|
-
run: (name, action
|
|
316
|
-
const run = this.journalRun(name, action
|
|
322
|
+
run: (name, action) => {
|
|
323
|
+
const run = this.journalRun(name, action);
|
|
317
324
|
RUNS.add(run);
|
|
318
325
|
return run;
|
|
319
326
|
},
|
|
320
|
-
sleep: (durationMs) => this.sleep(durationMs),
|
|
327
|
+
sleep: (durationMs, name) => this.sleep(durationMs, name ?? ''),
|
|
321
328
|
promise: (name) => ({ get: () => this.promise(name) }),
|
|
322
329
|
request: () => ({ attemptCompletedSignal: this.controller.signal }),
|
|
323
330
|
});
|
|
@@ -389,11 +396,10 @@ class Attempt {
|
|
|
389
396
|
return new Promise(() => { });
|
|
390
397
|
}
|
|
391
398
|
/**
|
|
392
|
-
* One journaled `run`. A
|
|
393
|
-
* Workflow
|
|
394
|
-
* the entry with a `TerminalError`. Without options, the failure reaches the Workflow.
|
|
399
|
+
* One journaled `run`. A `TerminalError` settles the entry; any other failure reaches the
|
|
400
|
+
* Workflow unjournaled, so the `run` runs again on the next attempt.
|
|
395
401
|
*/
|
|
396
|
-
journalRun(name, action
|
|
402
|
+
journalRun(name, action) {
|
|
397
403
|
let entry;
|
|
398
404
|
try {
|
|
399
405
|
entry = this.take('run', name);
|
|
@@ -431,20 +437,14 @@ class Attempt {
|
|
|
431
437
|
if (cause instanceof TerminalError) {
|
|
432
438
|
entry.settled = true;
|
|
433
439
|
entry.error = cause;
|
|
440
|
+
// A failed Step attempt keeps what it threw on the live error, which tests inspect.
|
|
441
|
+
if (cause.metadata?.[ATTEMPT] !== undefined) {
|
|
442
|
+
this.run.failure = cause.cause ?? cause;
|
|
443
|
+
}
|
|
434
444
|
throw cause;
|
|
435
445
|
}
|
|
436
446
|
this.run.failure = cause;
|
|
437
|
-
|
|
438
|
-
throw cause;
|
|
439
|
-
if (spent(entry, options)) {
|
|
440
|
-
entry.settled = true;
|
|
441
|
-
entry.error = new TerminalError(cause?.message ?? String(cause));
|
|
442
|
-
throw entry.error;
|
|
443
|
-
}
|
|
444
|
-
// An attempt takes time too: a zero delay still spends a duration budget, by a millisecond.
|
|
445
|
-
entry.waitedMs = (entry.waitedMs ?? 0) + Math.max(1, delayOf(entry, options, cause));
|
|
446
|
-
this.end({ kind: 'retried', error: cause });
|
|
447
|
-
return this.block();
|
|
447
|
+
throw cause;
|
|
448
448
|
}
|
|
449
449
|
finally {
|
|
450
450
|
this.active -= 1;
|
|
@@ -452,10 +452,15 @@ class Attempt {
|
|
|
452
452
|
}
|
|
453
453
|
})();
|
|
454
454
|
}
|
|
455
|
-
|
|
455
|
+
/**
|
|
456
|
+
* A durable timer on the virtual clock. The wait after a Step's own failure is not waited out: it
|
|
457
|
+
* settles at once and ends the attempt, as a timer that suspends the run does, and the time it
|
|
458
|
+
* skipped counts toward the Step's `maxDurationMs`.
|
|
459
|
+
*/
|
|
460
|
+
sleep(durationMs, name) {
|
|
456
461
|
let entry;
|
|
457
462
|
try {
|
|
458
|
-
entry = this.take('sleep',
|
|
463
|
+
entry = this.take('sleep', name);
|
|
459
464
|
}
|
|
460
465
|
catch (cause) {
|
|
461
466
|
return Promise.reject(cause);
|
|
@@ -463,6 +468,13 @@ class Attempt {
|
|
|
463
468
|
entry.until ??= this.run.clock.now() + durationMs;
|
|
464
469
|
if (entry.settled)
|
|
465
470
|
return this.answer(entry);
|
|
471
|
+
if (this.mode === 'run' && name.startsWith(BACKOFF)) {
|
|
472
|
+
entry.settled = true;
|
|
473
|
+
// An attempt takes time too: a zero delay still spends a duration budget, by a millisecond.
|
|
474
|
+
this.run.skipped += Math.max(1, durationMs);
|
|
475
|
+
this.end({ kind: 'retried', error: this.run.failure });
|
|
476
|
+
return this.block();
|
|
477
|
+
}
|
|
466
478
|
if (this.run.clock.now() >= entry.until) {
|
|
467
479
|
if (this.mode === 'run')
|
|
468
480
|
entry.settled = true;
|
|
@@ -538,18 +550,6 @@ class Attempt {
|
|
|
538
550
|
return Promise.resolve(value);
|
|
539
551
|
}
|
|
540
552
|
}
|
|
541
|
-
/** Whether a Step that just failed spent its budget: its attempts, or the backoff it waited. */
|
|
542
|
-
function spent(entry, options) {
|
|
543
|
-
return ((options.maxRetryAttempts !== undefined && (entry.attempts ?? 0) >= options.maxRetryAttempts) ||
|
|
544
|
-
(options.maxRetryDuration !== undefined && (entry.waitedMs ?? 0) >= options.maxRetryDuration));
|
|
545
|
-
}
|
|
546
|
-
/** The delay before a failed Step's next attempt: the one its failure prescribes, or its backoff. */
|
|
547
|
-
function delayOf(entry, options, cause) {
|
|
548
|
-
if (cause instanceof RetryableError && cause.retryAfter !== undefined)
|
|
549
|
-
return cause.retryAfter;
|
|
550
|
-
const retries = (entry.attempts ?? 1) - 1;
|
|
551
|
-
return Math.min(options.maxRetryInterval, options.initialRetryInterval * options.retryIntervalFactor ** retries);
|
|
552
|
-
}
|
|
553
553
|
function outcomeOf(error) {
|
|
554
554
|
const declared = error.metadata?.[FAILURE];
|
|
555
555
|
if (declared !== undefined) {
|
|
@@ -584,7 +584,12 @@ function snapshot(entry) {
|
|
|
584
584
|
...(entry.error === undefined ? {} : { error: entry.error }),
|
|
585
585
|
});
|
|
586
586
|
case 'sleep':
|
|
587
|
-
return Object.freeze({
|
|
587
|
+
return Object.freeze({
|
|
588
|
+
kind: 'sleep',
|
|
589
|
+
...(entry.name === '' ? {} : { name: entry.name }),
|
|
590
|
+
until: entry.until,
|
|
591
|
+
settled: entry.settled,
|
|
592
|
+
});
|
|
588
593
|
case 'signal':
|
|
589
594
|
return Object.freeze({
|
|
590
595
|
kind: 'signal',
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export type {
|
|
1
|
+
export { createTestWorkflowEngine } from './engine.js';
|
|
2
|
+
export type { TestExecution, TestJournalEntry, TestStartOptions, TestWorkflowEngine, TestWorkflowEngineOptions, } from './engine.js';
|
|
3
3
|
export type { TestStepCapabilities } from './steps.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { createTestWorkflowEngine } from './engine.js';
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import { bindDependencies } from '../../execution/invocation/dependency/index.js';
|
|
2
|
+
import { resendableCall } from '../../execution/workflows/attempts.js';
|
|
2
3
|
import { stepCalls } from '../../execution/workflows/integrations.js';
|
|
3
4
|
import { keyed } from '../../execution/workflows/steps.js';
|
|
4
5
|
/** Bind each Step attempt of a test Execution to the test's capabilities, keyed as on a Host. */
|
|
5
6
|
export function testSteps(input) {
|
|
6
7
|
const { domain, capabilities, integrations } = input;
|
|
7
8
|
return ({ next, lifetime }) => {
|
|
9
|
+
let repeats = false;
|
|
8
10
|
const session = keyed({
|
|
9
11
|
...capabilities.kernel,
|
|
10
12
|
invoke: capabilities.kernel?.invoke ?? absent('kernel.invoke'),
|
|
11
13
|
mutate: capabilities.kernel?.mutate ?? absent('kernel.mutate'),
|
|
12
|
-
}, domain, next)
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
}, domain, next, () => {
|
|
15
|
+
repeats = true;
|
|
16
|
+
});
|
|
17
|
+
// A Query, and a Mutation under its key, repeat no effect when sent again, as on a Host.
|
|
18
|
+
const query = (definition, value) => resendableCall(() => call(capabilities.query, 'query', (answer) => answer(definition, value)));
|
|
19
|
+
const mutate = (definition, value) => resendableCall(() => call(capabilities.mutate, 'mutate', (answer) => answer(definition, value, Object.freeze({ idempotencyKey: next() }))));
|
|
15
20
|
const graph = Object.freeze({ query, mutate });
|
|
16
21
|
const auth = Object.freeze({
|
|
17
22
|
mint: (request) => call(capabilities.kernel?.auth?.mint, 'kernel.auth.mint', (mint) => mint(request)),
|
|
@@ -24,7 +29,7 @@ export function testSteps(input) {
|
|
|
24
29
|
mutate,
|
|
25
30
|
integrations: integrations(stepCalls({ next, lifetime, auth })),
|
|
26
31
|
});
|
|
27
|
-
return Object.freeze({ authority, close() { } });
|
|
32
|
+
return Object.freeze({ authority, repeatsEffects: () => repeats, close() { } });
|
|
28
33
|
};
|
|
29
34
|
}
|
|
30
35
|
function call(answer, name, apply) {
|