@astrale-os/sdk 0.6.0-beta.38 → 0.6.0-beta.39
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/runtime/workflows.js +0 -3
- package/dist/application/workflow/authoring.d.ts +1 -1
- package/dist/application/workflow/define.js +4 -15
- package/dist/application/workflow/durable.d.ts +43 -4
- package/dist/application/workflow/index.d.ts +1 -1
- package/dist/application/workflow/workflow.d.ts +0 -8
- package/dist/deployment/address/configuration.d.ts +1 -0
- package/dist/deployment/address/configuration.js +2 -1
- package/dist/execution/index.d.ts +1 -0
- package/dist/execution/index.js +1 -0
- package/dist/execution/workflows/engine.d.ts +19 -1
- package/dist/execution/workflows/failure.d.ts +40 -5
- package/dist/execution/workflows/failure.js +123 -6
- package/dist/execution/workflows/index.d.ts +2 -2
- package/dist/execution/workflows/index.js +1 -1
- package/dist/execution/workflows/options.d.ts +36 -9
- package/dist/execution/workflows/options.js +82 -19
- package/dist/execution/workflows/receiver.js +2 -0
- package/dist/execution/workflows/run.js +69 -29
- package/dist/testing/durable/engine.d.ts +16 -6
- package/dist/testing/durable/engine.js +67 -15
- package/dist/tooling/linter/implementations/source/workflows.js +28 -5
- package/package.json +1 -1
|
@@ -20,9 +20,6 @@ export function admitWorkflows(callables, input) {
|
|
|
20
20
|
throw new TypeError(`Runtime Workflow ${definition.address} must have value output.`);
|
|
21
21
|
}
|
|
22
22
|
const durable = callable.execution?.durable === true;
|
|
23
|
-
if (!durable && definition.compat !== undefined) {
|
|
24
|
-
throw new TypeError(`Runtime Workflow ${definition.address} declares compat but its callable is not durable.`);
|
|
25
|
-
}
|
|
26
23
|
return Object.freeze({
|
|
27
24
|
kind: 'workflow',
|
|
28
25
|
address: definition.address,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { defineWorkflow } from './define.js';
|
|
2
2
|
export type { InlineWorkflowContext, WorkflowContext } from './context.js';
|
|
3
|
-
export type { DurableCallable, DurableExecution, DurableStep, DurableStepAuthority, DurableWorkflowContext, SignalOutcome, } from './durable.js';
|
|
3
|
+
export type { DurableCallable, DurableExecution, DurableStep, DurableStepAuthority, DurableStepDeclaration, DurableStepId, DurableStepRetry, DurableWorkflowContext, SignalOutcome, } from './durable.js';
|
|
4
4
|
export type { Workflow, WorkflowAddress, WorkflowDefinition, WorkflowOptions } from './workflow.js';
|
|
5
5
|
export type { DeclaredErrorDefinition, DeclaredErrorFactory, DeclaredErrorFamily, DeclaredErrorOptions, DeclaredErrors, } from '../error/index.js';
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { patterns } from '@astrale-os/kernel-dsl/v1/addressing';
|
|
2
2
|
import { rememberDeclaredErrors } from '../error/index.js';
|
|
3
3
|
const admittedWorkflows = new WeakSet();
|
|
4
|
-
const COMPAT_TOKEN = /^[A-Za-z0-9._~-]{1,64}$/u;
|
|
5
4
|
/** Define one multi-step value-output implementation by its SDK Action address. */
|
|
6
5
|
export function defineWorkflow() {
|
|
7
6
|
return function define(address, optionsOrRun, possibleRun) {
|
|
@@ -10,12 +9,7 @@ export function defineWorkflow() {
|
|
|
10
9
|
const run = possibleRun ?? optionsOrRun;
|
|
11
10
|
if (typeof run !== 'function')
|
|
12
11
|
throw new TypeError('Workflow run must be a function.');
|
|
13
|
-
const workflow = Object.freeze({
|
|
14
|
-
kind: 'workflow',
|
|
15
|
-
address,
|
|
16
|
-
run,
|
|
17
|
-
...(options?.compat === undefined ? {} : { compat: options.compat }),
|
|
18
|
-
});
|
|
12
|
+
const workflow = Object.freeze({ kind: 'workflow', address, run });
|
|
19
13
|
if (options?.errors !== undefined)
|
|
20
14
|
rememberDeclaredErrors(workflow, { errors: options.errors });
|
|
21
15
|
admittedWorkflows.add(workflow);
|
|
@@ -26,15 +20,10 @@ function acceptOptions(input) {
|
|
|
26
20
|
if (input === null ||
|
|
27
21
|
typeof input !== 'object' ||
|
|
28
22
|
Array.isArray(input) ||
|
|
29
|
-
Reflect.ownKeys(input).some((key) => key !== 'errors'
|
|
30
|
-
throw new TypeError('Workflow options accept only errors
|
|
23
|
+
Reflect.ownKeys(input).some((key) => key !== 'errors')) {
|
|
24
|
+
throw new TypeError('Workflow options accept only errors.');
|
|
31
25
|
}
|
|
32
|
-
|
|
33
|
-
if (options.compat !== undefined &&
|
|
34
|
-
(typeof options.compat !== 'string' || !COMPAT_TOKEN.test(options.compat))) {
|
|
35
|
-
throw new TypeError(`Invalid Workflow compat token: ${JSON.stringify(options.compat)}.`);
|
|
36
|
-
}
|
|
37
|
-
return options;
|
|
26
|
+
return input;
|
|
38
27
|
}
|
|
39
28
|
export function isWorkflow(input) {
|
|
40
29
|
return input !== null && typeof input === 'object' && admittedWorkflows.has(input);
|
|
@@ -61,24 +61,55 @@ export interface DurableStepAuthority<DomainValue extends Domain = Domain, Defin
|
|
|
61
61
|
*/
|
|
62
62
|
readonly integrations: ReplayableIntegrationClients<Definitions>;
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* How long a failing Step is retried. Its limits, `maxAttempts` and `maxDurationMs`, go together: a
|
|
66
|
+
* Step that declares either replaces both limits of the deployment, and the one it omits does not
|
|
67
|
+
* bound it. The budget is spent at the first limit reached.
|
|
68
|
+
*/
|
|
69
|
+
export interface DurableStepRetry {
|
|
70
|
+
/** Attempts, the first one included: 1 to 1,000. */
|
|
71
|
+
readonly maxAttempts?: number;
|
|
72
|
+
/**
|
|
73
|
+
* Time from the first attempt after which a failed attempt is not retried, 1 ms to 365 days; an
|
|
74
|
+
* attempt already scheduled, such as after a long prescribed delay, still runs.
|
|
75
|
+
*/
|
|
76
|
+
readonly maxDurationMs?: number;
|
|
77
|
+
/** Delay before the first retry, doubled on each failure: 1 ms to one hour. */
|
|
78
|
+
readonly initialIntervalMs?: number;
|
|
79
|
+
/** Longest delay between two attempts: `initialIntervalMs` to one day. */
|
|
80
|
+
readonly maxIntervalMs?: number;
|
|
81
|
+
}
|
|
82
|
+
/** A Step: its id, and the retry budget and timeout it declares instead of the deployment's. */
|
|
83
|
+
export interface DurableStepDeclaration {
|
|
84
|
+
readonly id: string;
|
|
85
|
+
readonly retry?: DurableStepRetry;
|
|
86
|
+
/** Deadline of one attempt, including its Kernel calls: one second to ten minutes. */
|
|
87
|
+
readonly timeoutMs?: number;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A Step's id alone, under the deployment's retry budget and Step timeout, or a declaration of its
|
|
91
|
+
* own. A Step that spends its budget throws an error `exhausted` recognizes; uncaught, the Execution
|
|
92
|
+
* ends `failed` with code `STEP_EXHAUSTED`.
|
|
93
|
+
*/
|
|
94
|
+
export type DurableStepId = string | DurableStepDeclaration;
|
|
64
95
|
export interface DurableStep<DomainValue extends Domain = Domain, Definitions extends Integrations = Readonly<Record<never, never>>> {
|
|
65
96
|
/**
|
|
66
97
|
* Run `execute` once and journal its portable output; a replay returns the journaled output. A
|
|
67
98
|
* Step may run again when its outcome was not journaled: its Mutations, durable calls and keyed
|
|
68
99
|
* Integration operations are answered from their keys, and any other effect must be idempotent.
|
|
69
100
|
*/
|
|
70
|
-
run<Output>(
|
|
101
|
+
run<Output>(step: DurableStepId, execute: (authority: DurableStepAuthority<DomainValue, Definitions>) => Output | Promise<Output>): Promise<Output>;
|
|
71
102
|
/**
|
|
72
103
|
* One journaled Query. A replay returns the result of its first run, so a later Step that depends
|
|
73
104
|
* on it resends the same request whenever it is retried.
|
|
74
105
|
*/
|
|
75
|
-
query<Output>(
|
|
76
|
-
query<Input, Output>(
|
|
106
|
+
query<Output>(step: DurableStepId, query: QueryDefinition<DomainValue, void, Output>): Promise<Output>;
|
|
107
|
+
query<Input, Output>(step: DurableStepId, query: QueryDefinition<DomainValue, Input, Output>, input: Input): Promise<Output>;
|
|
77
108
|
/**
|
|
78
109
|
* One journaled Mutation. A Step replayed after its commit receives the result of that commit
|
|
79
110
|
* from the Kernel's receipt instead of writing again.
|
|
80
111
|
*/
|
|
81
|
-
mutate<Input, Output>(
|
|
112
|
+
mutate<Input, Output>(step: DurableStepId, mutation: Mutation<DomainValue, Input, Output>, input: Input): Promise<Output>;
|
|
82
113
|
}
|
|
83
114
|
type DurableSelfOf<Callable> = Callable extends ResolvedMethod<unknown, unknown, false> ? {
|
|
84
115
|
readonly self: NodeId;
|
|
@@ -116,6 +147,14 @@ export type DurableWorkflowContext<Callable, DomainValue extends Domain = Domain
|
|
|
116
147
|
}): Promise<SignalOutcome<Signal>>;
|
|
117
148
|
/** Whether `error` is the engine's cooperative cancellation, which the Workflow may compensate. */
|
|
118
149
|
cancelled(error: unknown): boolean;
|
|
150
|
+
/** Whether `error` is a Step that spent its retry budget, which the Workflow may handle. */
|
|
151
|
+
exhausted(error: unknown): boolean;
|
|
152
|
+
/**
|
|
153
|
+
* The failure of a Step attempt, to throw inside the Step, that the engine attempts again no
|
|
154
|
+
* earlier than `delayMs` (at most one hour), as an HTTP `Retry-After` asks. The attempt counts
|
|
155
|
+
* toward the Step's budget. A Kernel refusal that prescribes a delay is retried this way already.
|
|
156
|
+
*/
|
|
157
|
+
retryAfter(delayMs: number, cause?: unknown): Error;
|
|
119
158
|
/** Wait durably for another Execution to end, by journaled polling of its `result`. */
|
|
120
159
|
awaitExecution<Output>(execution: ExecutionRef<Output>): Promise<ExecutionOutcome<Output>>;
|
|
121
160
|
} & DurableSelfOf<Callable>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export { defineWorkflow, isWorkflow } from './define.js';
|
|
2
2
|
export type { InlineWorkflowContext, WorkflowContext } from './context.js';
|
|
3
|
-
export type { DurableCallable, DurableExecution, DurableStep, DurableStepAuthority, DurableWorkflowContext, SignalOutcome, } from './durable.js';
|
|
3
|
+
export type { DurableCallable, DurableExecution, DurableStep, DurableStepAuthority, DurableStepDeclaration, DurableStepId, DurableStepRetry, DurableWorkflowContext, SignalOutcome, } from './durable.js';
|
|
4
4
|
export type { CallableAtWorkflowAddress, Workflow, WorkflowAddress, WorkflowCallable, WorkflowDefinition, WorkflowOptions, } from './workflow.js';
|
|
5
5
|
export * as step from './step/index.js';
|
|
6
6
|
export * as runner from './runner/index.js';
|
|
@@ -13,20 +13,12 @@ declare const WORKFLOW_SCHEMA: unique symbol;
|
|
|
13
13
|
/** Options of one Workflow implementation. */
|
|
14
14
|
export interface WorkflowOptions<out Errors extends DeclaredErrors = DeclaredErrors> {
|
|
15
15
|
readonly errors?: Errors;
|
|
16
|
-
/**
|
|
17
|
-
* Durable only. Compatibility token journaled first by every run: keep it while a redeployment
|
|
18
|
-
* can replay the journals of running Executions, change it when it cannot, and those runs pause
|
|
19
|
-
* until they are resumed on compatible code or cancelled.
|
|
20
|
-
*/
|
|
21
|
-
readonly compat?: string;
|
|
22
16
|
}
|
|
23
17
|
/** Sealed multi-step Workflow awaiting canonical callable association by Runtime admission. */
|
|
24
18
|
export interface WorkflowDefinition<Schema extends schema.DomainSchema = schema.DomainSchema, Address extends WorkflowAddress<Schema> = WorkflowAddress<Schema>, Definitions extends Integrations = Readonly<Record<never, never>>, Errors extends DeclaredErrors = NoDeclaredErrors> {
|
|
25
19
|
readonly kind: 'workflow';
|
|
26
20
|
readonly address: Address;
|
|
27
21
|
readonly run: Workflow<CallableAtWorkflowAddress<Schema, Address>, Definitions, ResolvedDomainOf<Schema>, Errors>;
|
|
28
|
-
/** Durable compatibility token; absent means the default token. */
|
|
29
|
-
readonly compat?: string;
|
|
30
22
|
/** Invariant type-only Schema witness; absent from emitted JavaScript. */
|
|
31
23
|
readonly [WORKFLOW_SCHEMA]: (schema: Schema) => Schema;
|
|
32
24
|
}
|
|
@@ -195,11 +195,12 @@ function durableOptionsOf(input) {
|
|
|
195
195
|
const options = input;
|
|
196
196
|
let retry;
|
|
197
197
|
if (Object.hasOwn(options, 'retry')) {
|
|
198
|
-
exact(options.retry, [], ['initialIntervalMs', 'maxAttempts', 'maxIntervalMs']);
|
|
198
|
+
exact(options.retry, [], ['initialIntervalMs', 'maxAttempts', 'maxDurationMs', 'maxIntervalMs']);
|
|
199
199
|
const value = options.retry;
|
|
200
200
|
retry = Object.freeze({
|
|
201
201
|
...optional(value, 'initialIntervalMs'),
|
|
202
202
|
...optional(value, 'maxAttempts'),
|
|
203
|
+
...optional(value, 'maxDurationMs'),
|
|
203
204
|
...optional(value, 'maxIntervalMs'),
|
|
204
205
|
});
|
|
205
206
|
}
|
|
@@ -3,4 +3,5 @@ export type { ExecutionContext, ServedDomain, ServeInput } from './serve.js';
|
|
|
3
3
|
export { DEFAULT_MEMORY_CACHE_ENTRIES, memoryCache, type DeploymentCache, type MemoryCacheOptions, } from './cache/index.js';
|
|
4
4
|
export { createIdentity, maximumExchangeTtl, type ExchangeOptions, type Identity, type IdentityInput, } from './identity/index.js';
|
|
5
5
|
export type { DurableEngine, DurableServing, DurableServingProvider } from './workflows/index.js';
|
|
6
|
+
export { DURABLE_ENGINE_EXPORTS } from './workflows/options.js';
|
|
6
7
|
export { isCanonicalDurableRequest } from './workflows/route.js';
|
package/dist/execution/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { serve } from './serve.js';
|
|
2
2
|
export { DEFAULT_MEMORY_CACHE_ENTRIES, memoryCache, } from './cache/index.js';
|
|
3
3
|
export { createIdentity, maximumExchangeTtl, } from './identity/index.js';
|
|
4
|
+
export { DURABLE_ENGINE_EXPORTS } from './workflows/options.js';
|
|
4
5
|
export { isCanonicalDurableRequest } from './workflows/route.js';
|
|
@@ -15,6 +15,11 @@ export interface DurableEngine {
|
|
|
15
15
|
readonly metadata?: Record<string, string>;
|
|
16
16
|
}) => Error;
|
|
17
17
|
readonly CancelledError: abstract new (...input: never[]) => Error;
|
|
18
|
+
/** A failure inside `run` that the engine attempts again no earlier than `retryAfter` ms. */
|
|
19
|
+
readonly RetryableError: new (message: string, options?: {
|
|
20
|
+
readonly retryAfter?: number;
|
|
21
|
+
readonly cause?: unknown;
|
|
22
|
+
}) => Error;
|
|
18
23
|
/** The rejection of `orTimeout` once its duration elapses first. */
|
|
19
24
|
readonly TimeoutError: abstract new (...input: never[]) => Error;
|
|
20
25
|
}
|
|
@@ -27,6 +32,8 @@ export interface EngineWorkflowDefinition {
|
|
|
27
32
|
readonly options: {
|
|
28
33
|
readonly workflowRetention: number;
|
|
29
34
|
readonly retryPolicy: EngineRetryPolicy;
|
|
35
|
+
/** How long an attempt may run without progress before the engine aborts it. */
|
|
36
|
+
readonly abortTimeout: number;
|
|
30
37
|
};
|
|
31
38
|
}
|
|
32
39
|
export interface EngineRetryPolicy {
|
|
@@ -36,6 +43,17 @@ export interface EngineRetryPolicy {
|
|
|
36
43
|
readonly maxInterval: number;
|
|
37
44
|
readonly exponentiationFactor: number;
|
|
38
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The retry budget of one journaled `run`. Spent, the `run` rejects with a `TerminalError` and the
|
|
48
|
+
* engine attempts it no more; an absent limit does not bound it.
|
|
49
|
+
*/
|
|
50
|
+
export interface EngineRunOptions {
|
|
51
|
+
readonly maxRetryAttempts?: number;
|
|
52
|
+
readonly maxRetryDuration?: number;
|
|
53
|
+
readonly initialRetryInterval: number;
|
|
54
|
+
readonly maxRetryInterval: number;
|
|
55
|
+
readonly retryIntervalFactor: number;
|
|
56
|
+
}
|
|
39
57
|
/**
|
|
40
58
|
* A promise the engine journals. Its combinators are journaled too, so a replay settles them as the
|
|
41
59
|
* first run did; racing it against another promise with `Promise.race` would not be.
|
|
@@ -46,7 +64,7 @@ export interface EnginePromise<Output> extends PromiseLike<Output> {
|
|
|
46
64
|
}
|
|
47
65
|
/** Journaled operations of one workflow run. */
|
|
48
66
|
export interface EngineWorkflowContext {
|
|
49
|
-
run<Output>(name: string, action: () => Promise<Output
|
|
67
|
+
run<Output>(name: string, action: () => Promise<Output>, options?: EngineRunOptions): PromiseLike<Output>;
|
|
50
68
|
sleep(durationMs: number, name?: string): PromiseLike<void>;
|
|
51
69
|
promise(name: string): {
|
|
52
70
|
get(): EnginePromise<unknown>;
|
|
@@ -1,11 +1,46 @@
|
|
|
1
1
|
import type { DurableEngine } from './engine.js';
|
|
2
|
+
/** Failure code of an Execution whose Workflow did not catch a Step that spent its retry budget. */
|
|
3
|
+
export declare const STEP_EXHAUSTED = "STEP_EXHAUSTED";
|
|
4
|
+
/** A Kernel refusal as the wire names it: its protocol code and, when given, its reason code. */
|
|
5
|
+
export interface KernelRefusal {
|
|
6
|
+
readonly code: number;
|
|
7
|
+
readonly reason?: string;
|
|
8
|
+
}
|
|
2
9
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
10
|
+
* A Step that spent its retry budget. Its message never carries what the last attempt threw; when
|
|
11
|
+
* the Kernel refused that attempt, `refusal` names the refusal by its codes alone.
|
|
12
|
+
*/
|
|
13
|
+
export declare class StepExhausted extends Error {
|
|
14
|
+
readonly step: string;
|
|
15
|
+
readonly refusal?: KernelRefusal;
|
|
16
|
+
constructor(step: string, refusal?: KernelRefusal);
|
|
17
|
+
}
|
|
18
|
+
/** The Kernel refusal behind `cause`, itself or one of its causes. */
|
|
19
|
+
export declare function kernelRefusalOf(cause: unknown): KernelRefusal | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* The failure a Step attempt hands the engine: `cause` itself, or, when the Kernel refused the
|
|
22
|
+
* attempt, an error whose message starts with the refusal's codes, so the exhaustion can name it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function attemptFailure(cause: unknown): unknown;
|
|
25
|
+
/** A Step attempt's failure that asks the engine to wait `delayMs` before the next attempt. */
|
|
26
|
+
export declare class StepRetryAfter extends Error {
|
|
27
|
+
readonly delayMs: number;
|
|
28
|
+
constructor(delayMs: number, cause?: unknown);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The failure of a Step attempt that waits out a prescribed delay before the next attempt, instead
|
|
32
|
+
* of the Step's backoff: a `retryAfter` failure, or a Kernel refusal that prescribes `retryAfterMs`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function delayedRetry(engine: DurableEngine, cause: unknown): Error | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Encode a declared business error of `definition`, or a Step that spent its retry budget, as a
|
|
37
|
+
* terminal engine failure, or return undefined for any other error, which the engine retries.
|
|
5
38
|
*/
|
|
6
39
|
export declare function terminalFailure(engine: DurableEngine, cause: unknown, definition: object): Error | undefined;
|
|
7
40
|
/**
|
|
8
|
-
*
|
|
9
|
-
* factory,
|
|
41
|
+
* What a journaled Step failed with, as the Workflow sees it: its declared business error, rebuilt
|
|
42
|
+
* through the definition's own factory, or `StepExhausted` once the engine spent its retry budget
|
|
43
|
+
* or the Step threw an exhaustion caught from an earlier Step. A replay throws what the live run
|
|
44
|
+
* threw.
|
|
10
45
|
*/
|
|
11
|
-
export declare function replayedFailure(engine: DurableEngine, cause: unknown, definition: object): Error | undefined;
|
|
46
|
+
export declare function replayedFailure(engine: DurableEngine, cause: unknown, definition: object, step: string): Error | undefined;
|
|
@@ -1,6 +1,101 @@
|
|
|
1
1
|
import { declaredErrorFactory, declaredErrorOf } from '../../application/error/index.js';
|
|
2
|
+
import { ResponseError, retryAfterOf } from '../../platform/client/index.js';
|
|
2
3
|
/** Metadata key the Kernel reads a declared failure from (`backend/restate` outcome decoding). */
|
|
3
4
|
const FAILURE = 'astrale.failure';
|
|
5
|
+
/** Failure code of an Execution whose Workflow did not catch a Step that spent its retry budget. */
|
|
6
|
+
export const STEP_EXHAUSTED = 'STEP_EXHAUSTED';
|
|
7
|
+
/** The longest delay a Step attempt may ask for, as the Kernel bounds a `retryAfterMs` hint. */
|
|
8
|
+
const MAXIMUM_RETRY_AFTER_MS = 3_600_000;
|
|
9
|
+
/** How deep a failure's causes are searched for the Kernel refusal behind it. */
|
|
10
|
+
const MAXIMUM_CAUSE_DEPTH = 8;
|
|
11
|
+
const REASON_CODE = /^[A-Z][A-Z0-9_]{0,63}$/u;
|
|
12
|
+
/**
|
|
13
|
+
* The mark a failed attempt's message carries when the Kernel refused it. The engine journals only
|
|
14
|
+
* that message, and gives back the last one when the budget is spent.
|
|
15
|
+
*/
|
|
16
|
+
const REFUSAL_MARK = /\[kernel refusal ([1-9][0-9]{3})(?: ([A-Z][A-Z0-9_]{0,63}))?\] /gu;
|
|
17
|
+
/**
|
|
18
|
+
* A Step that spent its retry budget. Its message never carries what the last attempt threw; when
|
|
19
|
+
* the Kernel refused that attempt, `refusal` names the refusal by its codes alone.
|
|
20
|
+
*/
|
|
21
|
+
export class StepExhausted extends Error {
|
|
22
|
+
step;
|
|
23
|
+
refusal;
|
|
24
|
+
constructor(step, refusal) {
|
|
25
|
+
super(`Workflow Step ${step} exhausted its retries.`);
|
|
26
|
+
this.name = 'StepExhausted';
|
|
27
|
+
this.step = step;
|
|
28
|
+
if (refusal !== undefined)
|
|
29
|
+
this.refusal = refusal;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The Kernel refusal behind `cause`, itself or one of its causes. */
|
|
33
|
+
export function kernelRefusalOf(cause) {
|
|
34
|
+
let current = cause;
|
|
35
|
+
for (let depth = 0; depth < MAXIMUM_CAUSE_DEPTH; depth += 1) {
|
|
36
|
+
if (current instanceof ResponseError) {
|
|
37
|
+
const reason = current.reason?.code;
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
code: current.code,
|
|
40
|
+
...(reason !== undefined && REASON_CODE.test(reason) ? { reason } : {}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (current === null || typeof current !== 'object')
|
|
44
|
+
return undefined;
|
|
45
|
+
current = current.cause;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The failure a Step attempt hands the engine: `cause` itself, or, when the Kernel refused the
|
|
51
|
+
* attempt, an error whose message starts with the refusal's codes, so the exhaustion can name it.
|
|
52
|
+
*/
|
|
53
|
+
export function attemptFailure(cause) {
|
|
54
|
+
const refusal = kernelRefusalOf(cause);
|
|
55
|
+
if (refusal === undefined)
|
|
56
|
+
return cause;
|
|
57
|
+
return new Error(attemptMessage(cause, refusal), { cause });
|
|
58
|
+
}
|
|
59
|
+
function attemptMessage(cause, refusal = kernelRefusalOf(cause)) {
|
|
60
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
61
|
+
if (refusal === undefined)
|
|
62
|
+
return message;
|
|
63
|
+
const reason = refusal.reason === undefined ? '' : ` ${refusal.reason}`;
|
|
64
|
+
return `[kernel refusal ${refusal.code}${reason}] ${message}`;
|
|
65
|
+
}
|
|
66
|
+
/** The refusal the last attempt's message names, if the Kernel refused it. */
|
|
67
|
+
function refusalIn(message) {
|
|
68
|
+
const marks = [...message.matchAll(REFUSAL_MARK)];
|
|
69
|
+
const last = marks.at(-1);
|
|
70
|
+
if (last === undefined)
|
|
71
|
+
return undefined;
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
code: Number(last[1]),
|
|
74
|
+
...(last[2] === undefined ? {} : { reason: last[2] }),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** A Step attempt's failure that asks the engine to wait `delayMs` before the next attempt. */
|
|
78
|
+
export class StepRetryAfter extends Error {
|
|
79
|
+
delayMs;
|
|
80
|
+
constructor(delayMs, cause) {
|
|
81
|
+
if (!Number.isSafeInteger(delayMs) || delayMs < 0 || delayMs > MAXIMUM_RETRY_AFTER_MS) {
|
|
82
|
+
throw new TypeError(`A Step retry delay must be an integer between 0 and ${MAXIMUM_RETRY_AFTER_MS} ms.`);
|
|
83
|
+
}
|
|
84
|
+
super(cause instanceof Error ? cause.message : `Workflow Step asked to retry after ${delayMs} ms.`, cause === undefined ? undefined : { cause });
|
|
85
|
+
this.name = 'StepRetryAfter';
|
|
86
|
+
this.delayMs = delayMs;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The failure of a Step attempt that waits out a prescribed delay before the next attempt, instead
|
|
91
|
+
* of the Step's backoff: a `retryAfter` failure, or a Kernel refusal that prescribes `retryAfterMs`.
|
|
92
|
+
*/
|
|
93
|
+
export function delayedRetry(engine, cause) {
|
|
94
|
+
const delayMs = cause instanceof StepRetryAfter ? cause.delayMs : retryAfterOf(cause);
|
|
95
|
+
if (delayMs === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
return new engine.RetryableError(attemptMessage(cause), { retryAfter: delayMs, cause });
|
|
98
|
+
}
|
|
4
99
|
const STATUS = {
|
|
5
100
|
VALIDATION_ERROR: 422,
|
|
6
101
|
NOT_FOUND: 404,
|
|
@@ -8,10 +103,24 @@ const STATUS = {
|
|
|
8
103
|
UNSUPPORTED: 422,
|
|
9
104
|
};
|
|
10
105
|
/**
|
|
11
|
-
* Encode a declared business error of `definition
|
|
12
|
-
* undefined for any other error, which the engine retries.
|
|
106
|
+
* Encode a declared business error of `definition`, or a Step that spent its retry budget, as a
|
|
107
|
+
* terminal engine failure, or return undefined for any other error, which the engine retries.
|
|
13
108
|
*/
|
|
14
109
|
export function terminalFailure(engine, cause, definition) {
|
|
110
|
+
if (cause instanceof StepExhausted) {
|
|
111
|
+
return new engine.TerminalError(cause.message, {
|
|
112
|
+
metadata: {
|
|
113
|
+
[FAILURE]: JSON.stringify({
|
|
114
|
+
code: STEP_EXHAUSTED,
|
|
115
|
+
message: cause.message,
|
|
116
|
+
details: {
|
|
117
|
+
step: cause.step,
|
|
118
|
+
...(cause.refusal === undefined ? {} : { refusal: cause.refusal }),
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
15
124
|
const declared = declaredErrorOf(cause, definition);
|
|
16
125
|
if (declared === undefined)
|
|
17
126
|
return undefined;
|
|
@@ -28,16 +137,24 @@ export function terminalFailure(engine, cause, definition) {
|
|
|
28
137
|
});
|
|
29
138
|
}
|
|
30
139
|
/**
|
|
31
|
-
*
|
|
32
|
-
* factory,
|
|
140
|
+
* What a journaled Step failed with, as the Workflow sees it: its declared business error, rebuilt
|
|
141
|
+
* through the definition's own factory, or `StepExhausted` once the engine spent its retry budget
|
|
142
|
+
* or the Step threw an exhaustion caught from an earlier Step. A replay throws what the live run
|
|
143
|
+
* threw.
|
|
33
144
|
*/
|
|
34
|
-
export function replayedFailure(engine, cause, definition) {
|
|
145
|
+
export function replayedFailure(engine, cause, definition, step) {
|
|
35
146
|
if (!(cause instanceof engine.TerminalError) || cause instanceof engine.CancelledError) {
|
|
36
147
|
return undefined;
|
|
37
148
|
}
|
|
38
149
|
const encoded = cause.metadata?.[FAILURE];
|
|
150
|
+
// The Step's own code turns every terminal failure into a declared one; the engine's is its budget.
|
|
39
151
|
if (encoded === undefined)
|
|
40
|
-
return
|
|
152
|
+
return new StepExhausted(step, refusalIn(cause.message));
|
|
41
153
|
const failure = JSON.parse(encoded);
|
|
154
|
+
// Only a declared business error has a family, so a declared code may be STEP_EXHAUSTED too.
|
|
155
|
+
if (failure.family === undefined && failure.code === STEP_EXHAUSTED) {
|
|
156
|
+
const details = failure.details;
|
|
157
|
+
return new StepExhausted(details.step, details.refusal);
|
|
158
|
+
}
|
|
42
159
|
return declaredErrorFactory(definition)(failure.code, failure.details);
|
|
43
160
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { DurableEngine, EnginePromise, EngineRetryPolicy, EngineSharedContext, EngineWorkflowContext, EngineWorkflowDefinition, } from './engine.js';
|
|
2
|
-
export { durableServing, type AdmittedDurableServing, type DurableServing, type DurableServingProvider, } from './options.js';
|
|
1
|
+
export type { DurableEngine, EnginePromise, EngineRetryPolicy, EngineRunOptions, EngineSharedContext, EngineWorkflowContext, EngineWorkflowDefinition, } from './engine.js';
|
|
2
|
+
export { DURABLE_ENGINE_EXPORTS, durableServing, type AdmittedDurableServing, type DurableServing, type DurableServingProvider, } from './options.js';
|
|
3
3
|
export { createDurableReceiver, type DurableReceiver } from './receiver.js';
|
|
4
4
|
export { isDurableRequest, isCanonicalDurableRequest } from './route.js';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Fetch } from '@astrale-os/kernel-client';
|
|
2
|
-
import type {
|
|
2
|
+
import type { DurableStepRetry } from '../../application/workflow/index.js';
|
|
3
|
+
import type { DurableEngine, EngineRetryPolicy, EngineRunOptions } from './engine.js';
|
|
3
4
|
/** Serving configuration of the durable Workflows of one deployment. */
|
|
4
5
|
export interface DurableServing {
|
|
5
6
|
/** The Restate SDK Fetch build of this platform. */
|
|
@@ -12,13 +13,13 @@ export interface DurableServing {
|
|
|
12
13
|
* default.
|
|
13
14
|
*/
|
|
14
15
|
readonly retentionMs?: number;
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/** Deadline of one Step, including its Kernel calls. One minute by default. */
|
|
16
|
+
/**
|
|
17
|
+
* Retry budget of every Step that declares none: 20 attempts from 1 s to 60 s by default. A Step
|
|
18
|
+
* that spends it fails into its Workflow. `maxAttempts` also bounds the attempts of a run that
|
|
19
|
+
* fails outside any Step, which then pauses for `resume` or `cancel`. The Host caps them.
|
|
20
|
+
*/
|
|
21
|
+
readonly retry?: DurableStepRetry;
|
|
22
|
+
/** Deadline of one Step attempt that declares none, including its Kernel calls. One minute by default. */
|
|
22
23
|
readonly stepTimeoutMs?: number;
|
|
23
24
|
}
|
|
24
25
|
/** Resolve a deployment's engine only when its durable receiver is first requested. */
|
|
@@ -27,8 +28,34 @@ export interface AdmittedDurableServing {
|
|
|
27
28
|
readonly engine: DurableEngine;
|
|
28
29
|
readonly fetch: Fetch;
|
|
29
30
|
readonly retentionMs: number;
|
|
31
|
+
/** Attempts of a run that fails outside any Step, before the engine pauses it. */
|
|
30
32
|
readonly retryPolicy: EngineRetryPolicy;
|
|
31
|
-
|
|
33
|
+
/** Retry budget and attempt deadline of a Step that declares none. */
|
|
34
|
+
readonly step: StepServing;
|
|
35
|
+
}
|
|
36
|
+
/** What a Step is held to: its retry budget, as the engine applies it, and its attempt deadline. */
|
|
37
|
+
export interface StepServing {
|
|
38
|
+
readonly retry: EngineRunOptions;
|
|
39
|
+
readonly timeoutMs: number;
|
|
32
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* The members of the Restate SDK Fetch entrypoint the durable receiver uses, which a platform's
|
|
43
|
+
* engine module must export.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DURABLE_ENGINE_EXPORTS: readonly ["createEndpointHandler", "workflow", "TerminalError", "CancelledError", "RetryableError", "TimeoutError"];
|
|
46
|
+
/**
|
|
47
|
+
* How long the engine lets an attempt run without progress before it aborts it, declared on every
|
|
48
|
+
* Workflow service. Restate's own default is ten minutes, which a ten-minute Step would race: the
|
|
49
|
+
* margin lets a Step's own timeout fail the attempt, and report it, before the engine cuts it.
|
|
50
|
+
*/
|
|
51
|
+
export declare const ENGINE_ABORT_TIMEOUT_MS: number;
|
|
33
52
|
/** Capture one durable serving configuration, failing closed on any gap. */
|
|
34
53
|
export declare function durableServing(input: DurableServing): AdmittedDurableServing;
|
|
54
|
+
/**
|
|
55
|
+
* What one Step is held to: the retry budget and timeout it declares, in place of `fallback`'s. A
|
|
56
|
+
* declared limit replaces both limits of the fallback; an undeclared interval keeps the fallback's.
|
|
57
|
+
*/
|
|
58
|
+
export declare function stepServing(declared: {
|
|
59
|
+
readonly retry?: DurableStepRetry | undefined;
|
|
60
|
+
readonly timeoutMs?: number | undefined;
|
|
61
|
+
}, fallback: StepServing): StepServing;
|
|
@@ -1,5 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The members of the Restate SDK Fetch entrypoint the durable receiver uses, which a platform's
|
|
3
|
+
* engine module must export.
|
|
4
|
+
*/
|
|
5
|
+
export const DURABLE_ENGINE_EXPORTS = Object.freeze([
|
|
6
|
+
'createEndpointHandler',
|
|
7
|
+
'workflow',
|
|
8
|
+
'TerminalError',
|
|
9
|
+
'CancelledError',
|
|
10
|
+
'RetryableError',
|
|
11
|
+
'TimeoutError',
|
|
12
|
+
]);
|
|
1
13
|
const HOUR_MS = 60 * 60_000;
|
|
2
14
|
const DAY_MS = 24 * HOUR_MS;
|
|
15
|
+
const DEFAULT_ATTEMPTS = 20;
|
|
16
|
+
/** The longest attempt a Step may declare, which the engine's abort timeout leaves to end first. */
|
|
17
|
+
const MAXIMUM_STEP_TIMEOUT_MS = 10 * 60_000;
|
|
18
|
+
/**
|
|
19
|
+
* How long the engine lets an attempt run without progress before it aborts it, declared on every
|
|
20
|
+
* Workflow service. Restate's own default is ten minutes, which a ten-minute Step would race: the
|
|
21
|
+
* margin lets a Step's own timeout fail the attempt, and report it, before the engine cuts it.
|
|
22
|
+
*/
|
|
23
|
+
export const ENGINE_ABORT_TIMEOUT_MS = MAXIMUM_STEP_TIMEOUT_MS + 5 * 60_000;
|
|
24
|
+
const DEFAULT_STEP_RETRY = Object.freeze({
|
|
25
|
+
maxRetryAttempts: DEFAULT_ATTEMPTS,
|
|
26
|
+
initialRetryInterval: 1_000,
|
|
27
|
+
maxRetryInterval: 60_000,
|
|
28
|
+
retryIntervalFactor: 2,
|
|
29
|
+
});
|
|
3
30
|
/** Capture one durable serving configuration, failing closed on any gap. */
|
|
4
31
|
export function durableServing(input) {
|
|
5
32
|
if (input === null ||
|
|
@@ -15,45 +42,81 @@ export function durableServing(input) {
|
|
|
15
42
|
const engine = input.engine;
|
|
16
43
|
if (engine === null ||
|
|
17
44
|
typeof engine !== 'object' ||
|
|
18
|
-
typeof engine
|
|
19
|
-
typeof engine.workflow !== 'function' ||
|
|
20
|
-
typeof engine.TerminalError !== 'function' ||
|
|
21
|
-
typeof engine.CancelledError !== 'function' ||
|
|
22
|
-
typeof engine.TimeoutError !== 'function') {
|
|
45
|
+
DURABLE_ENGINE_EXPORTS.some((member) => typeof engine[member] !== 'function')) {
|
|
23
46
|
invalid('Durable serving requires the Restate SDK Fetch entrypoint of this platform.');
|
|
24
47
|
}
|
|
25
48
|
const fetch = input.fetch ?? globalThis.fetch.bind(globalThis);
|
|
26
49
|
if (typeof fetch !== 'function')
|
|
27
50
|
invalid('Durable serving requires Fetch for engine discovery.');
|
|
28
|
-
const
|
|
29
|
-
if (retry === null ||
|
|
30
|
-
typeof retry !== 'object' ||
|
|
31
|
-
Array.isArray(retry) ||
|
|
32
|
-
Reflect.ownKeys(retry).some((key) => key !== 'maxAttempts' && key !== 'initialIntervalMs' && key !== 'maxIntervalMs')) {
|
|
33
|
-
invalid('Durable retry options are invalid.');
|
|
34
|
-
}
|
|
35
|
-
const initialInterval = bounded(retry.initialIntervalMs, 1_000, 1, HOUR_MS, 'initialIntervalMs');
|
|
36
|
-
const maxInterval = bounded(retry.maxIntervalMs, 60_000, initialInterval, DAY_MS, 'maxIntervalMs');
|
|
51
|
+
const step = stepServing({ retry: input.retry, timeoutMs: input.stepTimeoutMs }, { retry: DEFAULT_STEP_RETRY, timeoutMs: 60_000 });
|
|
37
52
|
return Object.freeze({
|
|
38
53
|
engine: Object.freeze({
|
|
39
54
|
createEndpointHandler: engine.createEndpointHandler.bind(engine),
|
|
40
55
|
workflow: engine.workflow.bind(engine),
|
|
41
56
|
TerminalError: engine.TerminalError,
|
|
42
57
|
CancelledError: engine.CancelledError,
|
|
58
|
+
RetryableError: engine.RetryableError,
|
|
43
59
|
TimeoutError: engine.TimeoutError,
|
|
44
60
|
}),
|
|
45
61
|
fetch,
|
|
46
62
|
retentionMs: bounded(input.retentionMs, 7 * DAY_MS, HOUR_MS, 365 * DAY_MS, 'retentionMs'),
|
|
47
63
|
retryPolicy: Object.freeze({
|
|
48
|
-
maxAttempts:
|
|
64
|
+
maxAttempts: input.retry?.maxAttempts ?? DEFAULT_ATTEMPTS,
|
|
49
65
|
onMaxAttempts: 'pause',
|
|
50
|
-
initialInterval,
|
|
51
|
-
maxInterval,
|
|
52
|
-
exponentiationFactor:
|
|
66
|
+
initialInterval: step.retry.initialRetryInterval,
|
|
67
|
+
maxInterval: step.retry.maxRetryInterval,
|
|
68
|
+
exponentiationFactor: step.retry.retryIntervalFactor,
|
|
53
69
|
}),
|
|
54
|
-
|
|
70
|
+
step,
|
|
55
71
|
});
|
|
56
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* What one Step is held to: the retry budget and timeout it declares, in place of `fallback`'s. A
|
|
75
|
+
* declared limit replaces both limits of the fallback; an undeclared interval keeps the fallback's.
|
|
76
|
+
*/
|
|
77
|
+
export function stepServing(declared, fallback) {
|
|
78
|
+
const retry = declared.retry;
|
|
79
|
+
if (retry === undefined) {
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
retry: fallback.retry,
|
|
82
|
+
timeoutMs: stepTimeout(declared.timeoutMs, fallback.timeoutMs),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (retry === null ||
|
|
86
|
+
typeof retry !== 'object' ||
|
|
87
|
+
Array.isArray(retry) ||
|
|
88
|
+
Reflect.ownKeys(retry).some((key) => key !== 'maxAttempts' &&
|
|
89
|
+
key !== 'maxDurationMs' &&
|
|
90
|
+
key !== 'initialIntervalMs' &&
|
|
91
|
+
key !== 'maxIntervalMs')) {
|
|
92
|
+
invalid('Durable retry options are invalid.');
|
|
93
|
+
}
|
|
94
|
+
const limited = retry.maxAttempts !== undefined || retry.maxDurationMs !== undefined;
|
|
95
|
+
const maxAttempts = limited
|
|
96
|
+
? optionalBounded(retry.maxAttempts, 1, 1_000, 'maxAttempts')
|
|
97
|
+
: fallback.retry.maxRetryAttempts;
|
|
98
|
+
const maxDuration = limited
|
|
99
|
+
? optionalBounded(retry.maxDurationMs, 1, 365 * DAY_MS, 'maxDurationMs')
|
|
100
|
+
: fallback.retry.maxRetryDuration;
|
|
101
|
+
const initialInterval = bounded(retry.initialIntervalMs, fallback.retry.initialRetryInterval, 1, HOUR_MS, 'initialIntervalMs');
|
|
102
|
+
const maxInterval = bounded(retry.maxIntervalMs, Math.max(fallback.retry.maxRetryInterval, initialInterval), initialInterval, DAY_MS, 'maxIntervalMs');
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
retry: Object.freeze({
|
|
105
|
+
...(maxAttempts === undefined ? {} : { maxRetryAttempts: maxAttempts }),
|
|
106
|
+
...(maxDuration === undefined ? {} : { maxRetryDuration: maxDuration }),
|
|
107
|
+
initialRetryInterval: initialInterval,
|
|
108
|
+
maxRetryInterval: maxInterval,
|
|
109
|
+
retryIntervalFactor: fallback.retry.retryIntervalFactor,
|
|
110
|
+
}),
|
|
111
|
+
timeoutMs: stepTimeout(declared.timeoutMs, fallback.timeoutMs),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function stepTimeout(input, fallback) {
|
|
115
|
+
return bounded(input, fallback, 1_000, MAXIMUM_STEP_TIMEOUT_MS, 'Step timeout');
|
|
116
|
+
}
|
|
117
|
+
function optionalBounded(input, minimum, maximum, label) {
|
|
118
|
+
return input === undefined ? undefined : bounded(input, minimum, minimum, maximum, label);
|
|
119
|
+
}
|
|
57
120
|
function bounded(input, fallback, minimum, maximum, label) {
|
|
58
121
|
if (input === undefined)
|
|
59
122
|
return fallback;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { workflowEngineDiscovery, WorkflowEngineUnavailable } from './discovery.js';
|
|
2
|
+
import { ENGINE_ABORT_TIMEOUT_MS } from './options.js';
|
|
2
3
|
import { durableRoute } from './route.js';
|
|
3
4
|
import { durableHandlers } from './run.js';
|
|
4
5
|
import { sessionSteps } from './steps.js';
|
|
@@ -60,6 +61,7 @@ export function createDurableReceiver(input) {
|
|
|
60
61
|
options: {
|
|
61
62
|
workflowRetention: input.serving.retentionMs,
|
|
62
63
|
retryPolicy: input.serving.retryPolicy,
|
|
64
|
+
abortTimeout: ENGINE_ABORT_TIMEOUT_MS,
|
|
63
65
|
},
|
|
64
66
|
});
|
|
65
67
|
try {
|
|
@@ -4,10 +4,9 @@ import { acceptStepOutput } from '../../application/workflow/step/output.js';
|
|
|
4
4
|
import { ResponseError } from '../../platform/client/index.js';
|
|
5
5
|
import { validateOutput } from '../invocation/result/index.js';
|
|
6
6
|
import { decodeEnvelope } from './envelope.js';
|
|
7
|
-
import { replayedFailure, terminalFailure } from './failure.js';
|
|
7
|
+
import { delayedRetry, replayedFailure, StepExhausted, StepRetryAfter, attemptFailure, terminalFailure, } from './failure.js';
|
|
8
|
+
import { stepServing } from './options.js';
|
|
8
9
|
import { keySequence } from './steps.js';
|
|
9
|
-
/** Token of a Workflow that declares none. */
|
|
10
|
-
const DEFAULT_COMPAT = '0';
|
|
11
10
|
const MAXIMUM_SLEEP_MS = 365 * 24 * 60 * 60_000;
|
|
12
11
|
const MAXIMUM_SIGNAL_NAME = 128;
|
|
13
12
|
/** Bounds of one `result` wait and of the durable pause between two of them. */
|
|
@@ -37,15 +36,9 @@ async function run(input, context, raw) {
|
|
|
37
36
|
const { workflow, serving } = input;
|
|
38
37
|
const { engine } = serving;
|
|
39
38
|
const definition = workflow.definition;
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
const recorded = await context.run('compat', async () => token);
|
|
44
|
-
if (recorded !== token) {
|
|
45
|
-
throw new Error(`Workflow ${workflow.address} started under compat token ${JSON.stringify(recorded)}; ` +
|
|
46
|
-
`this deployment declares ${JSON.stringify(token)}.`);
|
|
47
|
-
}
|
|
48
|
-
// Code that cannot read what the Kernel recorded pauses the run the same way.
|
|
39
|
+
// A run replays the code of the release that started it, each release at its own URL. Code
|
|
40
|
+
// served in place of it replays the journal: the engine refuses a journal whose Steps no longer
|
|
41
|
+
// match, and code that cannot read what the Kernel recorded fails every attempt; both pause.
|
|
49
42
|
const envelope = admitted(workflow, raw, input.kernel);
|
|
50
43
|
const durable = durableContext(input, context, envelope);
|
|
51
44
|
let output;
|
|
@@ -82,13 +75,25 @@ function durableContext(input, context, envelope) {
|
|
|
82
75
|
/**
|
|
83
76
|
* One journaled unit of work holding the capabilities of an Action as the Domain alone. Its
|
|
84
77
|
* Mutations, durable calls and Integration calls share one key sequence derived from its name.
|
|
78
|
+
* With a `retry` budget, the engine retries a failed attempt within it, after its backoff or the
|
|
79
|
+
* delay the failure prescribes, and the Step throws `StepExhausted` once it is spent; without
|
|
80
|
+
* one, a failed attempt fails the run's attempt, which the engine retries until it pauses.
|
|
85
81
|
*/
|
|
86
|
-
const journal = (name, execute) => Promise.resolve(context.run(name, async () => {
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
82
|
+
const journal = (id, name, timeoutMs, retry, execute) => Promise.resolve(context.run(name, async () => {
|
|
83
|
+
const expiry = new AbortController();
|
|
84
|
+
const timer = setTimeout(() => expiry.abort(new Error(`Workflow Step ${id} exceeded its ${timeoutMs} ms timeout.`)), timeoutMs);
|
|
85
|
+
let step;
|
|
90
86
|
try {
|
|
91
|
-
const
|
|
87
|
+
const lifetime = {
|
|
88
|
+
deadline: Date.now() + timeoutMs,
|
|
89
|
+
signal: AbortSignal.any([signal, expiry.signal]),
|
|
90
|
+
};
|
|
91
|
+
const next = await keySequence(execution.id, name);
|
|
92
|
+
const { authority } = (step = input.steps({ name, execution, next, lifetime }));
|
|
93
|
+
const output = await Promise.race([
|
|
94
|
+
Promise.resolve().then(() => execute(authority)),
|
|
95
|
+
expired(expiry.signal),
|
|
96
|
+
]);
|
|
92
97
|
try {
|
|
93
98
|
return acceptStepOutput(output);
|
|
94
99
|
}
|
|
@@ -97,26 +102,30 @@ function durableContext(input, context, envelope) {
|
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
catch (cause) {
|
|
100
|
-
throw terminalFailure(engine, cause, definition) ??
|
|
105
|
+
throw (terminalFailure(engine, cause, definition) ??
|
|
106
|
+
delayedRetry(engine, cause) ??
|
|
107
|
+
attemptFailure(cause));
|
|
101
108
|
}
|
|
102
109
|
finally {
|
|
103
|
-
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
step?.close();
|
|
104
112
|
}
|
|
105
|
-
})).catch((cause) => {
|
|
106
|
-
throw replayedFailure(engine, cause, definition) ?? cause;
|
|
113
|
+
}, retry)).catch((cause) => {
|
|
114
|
+
throw replayedFailure(engine, cause, definition, id) ?? cause;
|
|
107
115
|
});
|
|
108
|
-
const step = (
|
|
116
|
+
const step = (declared, execute) => {
|
|
109
117
|
let accepted;
|
|
110
118
|
try {
|
|
111
|
-
accepted =
|
|
112
|
-
if (steps.has(accepted))
|
|
113
|
-
throw new TypeError(`Workflow Step ${accepted} occurs more than once.`);
|
|
119
|
+
accepted = stepDeclaration(declared, serving.step);
|
|
120
|
+
if (steps.has(accepted.id))
|
|
121
|
+
throw new TypeError(`Workflow Step ${accepted.id} occurs more than once.`);
|
|
114
122
|
}
|
|
115
123
|
catch (cause) {
|
|
116
124
|
return Promise.reject(cause);
|
|
117
125
|
}
|
|
118
|
-
steps.add(accepted);
|
|
119
|
-
|
|
126
|
+
steps.add(accepted.id);
|
|
127
|
+
const { held } = accepted;
|
|
128
|
+
return journal(accepted.id, `step:${accepted.id}`, held.timeoutMs, held.retry, execute);
|
|
120
129
|
};
|
|
121
130
|
return Object.freeze({
|
|
122
131
|
domain,
|
|
@@ -156,12 +165,16 @@ function durableContext(input, context, envelope) {
|
|
|
156
165
|
}
|
|
157
166
|
},
|
|
158
167
|
cancelled: (error) => error instanceof engine.CancelledError,
|
|
168
|
+
exhausted: (error) => error instanceof StepExhausted,
|
|
169
|
+
retryAfter: (delayMs, cause) => new StepRetryAfter(delayMs, cause),
|
|
159
170
|
async awaitExecution(target) {
|
|
160
171
|
for (let poll = 1;; poll++) {
|
|
161
|
-
|
|
172
|
+
// A poll is no Step of the author's: it has no budget, so an outage pauses the run instead.
|
|
173
|
+
const name = `await:${target.id}:${poll}`;
|
|
174
|
+
const polled = await journal(name, name, serving.step.timeoutMs, undefined, async ({ kernel }) => {
|
|
162
175
|
try {
|
|
163
176
|
const outcome = await kernel.execution.result(target, {
|
|
164
|
-
waitMs: Math.min(AWAIT_WAIT_MS, Math.floor(serving.
|
|
177
|
+
waitMs: Math.min(AWAIT_WAIT_MS, Math.floor(serving.step.timeoutMs / 2)),
|
|
165
178
|
});
|
|
166
179
|
return { ended: true, outcome };
|
|
167
180
|
}
|
|
@@ -180,6 +193,33 @@ function durableContext(input, context, envelope) {
|
|
|
180
193
|
...(execution.receiver === undefined ? {} : { self: execution.receiver }),
|
|
181
194
|
});
|
|
182
195
|
}
|
|
196
|
+
/** Admit a Step's id, and its declared retry budget and timeout over the deployment's. */
|
|
197
|
+
function stepDeclaration(declared, fallback) {
|
|
198
|
+
if (typeof declared === 'string')
|
|
199
|
+
return { id: acceptStepId(declared), held: fallback };
|
|
200
|
+
if (declared === null ||
|
|
201
|
+
typeof declared !== 'object' ||
|
|
202
|
+
Array.isArray(declared) ||
|
|
203
|
+
Reflect.ownKeys(declared).some((key) => key !== 'id' && key !== 'retry' && key !== 'timeoutMs')) {
|
|
204
|
+
throw new TypeError('A Workflow Step declaration takes only id, retry and timeoutMs.');
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
id: acceptStepId(declared.id),
|
|
208
|
+
held: stepServing({ retry: declared.retry, timeoutMs: declared.timeoutMs }, fallback),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Rejects with the abort reason once `signal` aborts, at once if it already has: a timeout may fire
|
|
213
|
+
* while the attempt derives its keys. Never settles otherwise.
|
|
214
|
+
*/
|
|
215
|
+
function expired(signal) {
|
|
216
|
+
return new Promise((_, reject) => {
|
|
217
|
+
if (signal.aborted)
|
|
218
|
+
reject(signal.reason);
|
|
219
|
+
else
|
|
220
|
+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
221
|
+
});
|
|
222
|
+
}
|
|
183
223
|
function signalRequest(input) {
|
|
184
224
|
if (input === null ||
|
|
185
225
|
typeof input !== 'object' ||
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ActionOutputOf, ResolvedDomainOf } from '../../application/action/index.js';
|
|
2
2
|
import type { DeclaredErrors } from '../../application/error/index.js';
|
|
3
3
|
import type { Integrations, Providers } from '../../application/integration/index.js';
|
|
4
|
-
import type { CallableAtWorkflowAddress, WorkflowAddress, WorkflowDefinition } from '../../application/workflow/index.js';
|
|
4
|
+
import type { CallableAtWorkflowAddress, DurableStepRetry, WorkflowAddress, WorkflowDefinition } from '../../application/workflow/index.js';
|
|
5
5
|
import type { ExecutionOutcome, ExecutionState } from '../../platform/client/session/index.js';
|
|
6
6
|
import type { NodeId } from '../../platform/graph/node/index.js';
|
|
7
7
|
import type { CallableInputOf, ResolvedMethod, schema } from '../../platform/schema/index.js';
|
|
@@ -10,13 +10,18 @@ import type { TestStepCapabilities } from './steps.js';
|
|
|
10
10
|
export interface TestEngineOptions {
|
|
11
11
|
/** Start of the virtual clock, in Unix milliseconds; the current time by default. */
|
|
12
12
|
readonly now?: number;
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The deployment's `durable.retry`: the budget of every Step that declares none, 20 attempts by
|
|
15
|
+
* default, and with `maxAttempts` the attempts of a run failing outside any Step before it pauses.
|
|
16
|
+
*/
|
|
17
|
+
readonly retry?: DurableStepRetry;
|
|
15
18
|
}
|
|
16
19
|
/**
|
|
17
20
|
* An in-memory workflow engine that runs durable Workflows with the receiver a Host serves: it
|
|
18
|
-
* journals each Step, replays the journal on every attempt, retries failed
|
|
19
|
-
* on a virtual clock, buffers signals, pauses
|
|
21
|
+
* journals each Step, replays the journal on every attempt, retries a failed Step within its budget
|
|
22
|
+
* and fails it once the budget is spent, keeps timers on a virtual clock, buffers signals, pauses a
|
|
23
|
+
* run failing outside any Step after its last attempt and cancels cooperatively. A retry does not
|
|
24
|
+
* wait out its backoff, but the backoff it skips counts toward the Step's `maxDurationMs`.
|
|
20
25
|
*/
|
|
21
26
|
export interface TestEngine {
|
|
22
27
|
/** The virtual clock, in Unix milliseconds. */
|
|
@@ -57,7 +62,10 @@ export interface TestExecution<Output> {
|
|
|
57
62
|
readonly state: Extract<ExecutionState, 'running' | 'waiting' | 'paused' | 'completed' | 'failed' | 'cancelled'>;
|
|
58
63
|
/** Present once the Execution ended, as `Execution.result` answers it. */
|
|
59
64
|
readonly outcome: ExecutionOutcome<Output> | undefined;
|
|
60
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* What the last failed attempt threw: why a paused Execution paused, or what the last attempt of
|
|
67
|
+
* an exhausted Step threw, which its `STEP_EXHAUSTED` outcome does not carry.
|
|
68
|
+
*/
|
|
61
69
|
readonly failure: unknown;
|
|
62
70
|
/** Attempts made so far, replays included. */
|
|
63
71
|
readonly attempts: number;
|
|
@@ -88,6 +96,8 @@ export type TestJournalEntry = {
|
|
|
88
96
|
readonly kind: 'run';
|
|
89
97
|
readonly name: string;
|
|
90
98
|
readonly settled: boolean;
|
|
99
|
+
/** Times the Step ran, retries and interruptions included; a replay runs it no more. */
|
|
100
|
+
readonly attempts: number;
|
|
91
101
|
readonly value?: unknown;
|
|
92
102
|
readonly error?: unknown;
|
|
93
103
|
} | {
|
|
@@ -8,8 +8,6 @@ import { NodeId as nodeId } from '../../platform/graph/node/index.js';
|
|
|
8
8
|
import { testSteps } from './steps.js';
|
|
9
9
|
/** Kernel every test Execution names; Steps never reach it. */
|
|
10
10
|
const TEST_KERNEL = issuer.accept('https://kernel.test.astrale.invalid');
|
|
11
|
-
/** Attempts before a run pauses, as on a Host. */
|
|
12
|
-
const DEFAULT_MAXIMUM_ATTEMPTS = 20;
|
|
13
11
|
/** Microtask turns without progress after which a blocked attempt counts as suspended. */
|
|
14
12
|
const QUIET_TURNS = 64;
|
|
15
13
|
/** Metadata the Kernel reads a declared failure from. */
|
|
@@ -37,6 +35,15 @@ class TimeoutError extends TerminalError {
|
|
|
37
35
|
this.name = 'TimeoutError';
|
|
38
36
|
}
|
|
39
37
|
}
|
|
38
|
+
class RetryableError extends Error {
|
|
39
|
+
retryAfter;
|
|
40
|
+
constructor(message, options) {
|
|
41
|
+
super(message, options?.cause === undefined ? undefined : { cause: options.cause });
|
|
42
|
+
this.name = 'RetryableError';
|
|
43
|
+
if (options?.retryAfter !== undefined)
|
|
44
|
+
this.retryAfter = options.retryAfter;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
40
47
|
/** The attempt lost its Step result on purpose; the engine retries it. */
|
|
41
48
|
class Interruption extends Error {
|
|
42
49
|
constructor(step) {
|
|
@@ -57,14 +64,16 @@ const ENGINE = Object.freeze({
|
|
|
57
64
|
workflow: (definition) => definition,
|
|
58
65
|
TerminalError,
|
|
59
66
|
CancelledError,
|
|
67
|
+
RetryableError,
|
|
60
68
|
TimeoutError,
|
|
61
69
|
});
|
|
62
70
|
/** Create an in-memory workflow engine for tests. */
|
|
63
71
|
export function createTestEngine(options = {}) {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
72
|
+
const serving = durableServing({
|
|
73
|
+
engine: ENGINE,
|
|
74
|
+
...(options.retry === undefined ? {} : { retry: options.retry }),
|
|
75
|
+
});
|
|
76
|
+
const maxAttempts = serving.retryPolicy.maxAttempts;
|
|
68
77
|
let now = options.now ?? Date.now();
|
|
69
78
|
if (!Number.isFinite(now))
|
|
70
79
|
throw new TypeError('Test engine now must be finite.');
|
|
@@ -77,7 +86,7 @@ export function createTestEngine(options = {}) {
|
|
|
77
86
|
},
|
|
78
87
|
async start(workflow, input) {
|
|
79
88
|
sequence += 1;
|
|
80
|
-
const run = new TestRun(workflow, input, clock, maxAttempts, sequence);
|
|
89
|
+
const run = new TestRun(workflow, input, clock, serving, maxAttempts, sequence);
|
|
81
90
|
runs.add(run);
|
|
82
91
|
await run.settle();
|
|
83
92
|
return run.execution;
|
|
@@ -108,7 +117,7 @@ class TestRun {
|
|
|
108
117
|
handlers;
|
|
109
118
|
envelope;
|
|
110
119
|
execution;
|
|
111
|
-
constructor(workflow, input, clock, maxAttempts, sequence) {
|
|
120
|
+
constructor(workflow, input, clock, serving, maxAttempts, sequence) {
|
|
112
121
|
this.clock = clock;
|
|
113
122
|
this.maxAttempts = maxAttempts;
|
|
114
123
|
const domain = input.schema;
|
|
@@ -122,7 +131,7 @@ class TestRun {
|
|
|
122
131
|
kernel: TEST_KERNEL,
|
|
123
132
|
workflow: admitted,
|
|
124
133
|
domain,
|
|
125
|
-
serving
|
|
134
|
+
serving,
|
|
126
135
|
steps: testSteps({
|
|
127
136
|
domain,
|
|
128
137
|
capabilities: (input.capabilities ?? {}),
|
|
@@ -158,6 +167,10 @@ class TestRun {
|
|
|
158
167
|
this.end({ kind: 'completed', value: attempted.value });
|
|
159
168
|
return;
|
|
160
169
|
}
|
|
170
|
+
if (attempted.kind === 'retried') {
|
|
171
|
+
this.failure = attempted.error;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
161
174
|
const { error } = attempted;
|
|
162
175
|
if (error instanceof TerminalError) {
|
|
163
176
|
this.end(outcomeOf(error));
|
|
@@ -186,7 +199,7 @@ class TestRun {
|
|
|
186
199
|
.then(() => this.handlers.run(attempt.context, this.envelope))
|
|
187
200
|
.then((value) => ({ kind: 'completed', value }), (error) => ({ kind: 'failed', error }))
|
|
188
201
|
.finally(() => attempt.finish());
|
|
189
|
-
return Promise.race([running, attempt.suspended()]).finally(() => attempt.finish());
|
|
202
|
+
return Promise.race([running, attempt.suspended(), attempt.ended]).finally(() => attempt.finish());
|
|
190
203
|
}
|
|
191
204
|
async signal(name, value) {
|
|
192
205
|
if (this.ended)
|
|
@@ -276,12 +289,17 @@ class Attempt {
|
|
|
276
289
|
progress = 0;
|
|
277
290
|
done = false;
|
|
278
291
|
controller = new AbortController();
|
|
292
|
+
end;
|
|
293
|
+
/** Settles when a Step ends the attempt without reaching the Workflow, as a retry or a crash. */
|
|
294
|
+
ended = new Promise((resolve) => {
|
|
295
|
+
this.end = resolve;
|
|
296
|
+
});
|
|
279
297
|
context;
|
|
280
298
|
constructor(run, mode) {
|
|
281
299
|
this.run = run;
|
|
282
300
|
this.mode = mode;
|
|
283
301
|
this.context = Object.freeze({
|
|
284
|
-
run: (name, action) => this.journalRun(name, action),
|
|
302
|
+
run: (name, action, options) => this.journalRun(name, action, options),
|
|
285
303
|
sleep: (durationMs) => this.sleep(durationMs),
|
|
286
304
|
promise: (name) => ({ get: () => this.promise(name) }),
|
|
287
305
|
request: () => ({ attemptCompletedSignal: this.controller.signal }),
|
|
@@ -353,7 +371,12 @@ class Attempt {
|
|
|
353
371
|
this.progress += 1;
|
|
354
372
|
return new Promise(() => { });
|
|
355
373
|
}
|
|
356
|
-
|
|
374
|
+
/**
|
|
375
|
+
* One journaled `run`. A failure within the budget of `options` ends the attempt before the
|
|
376
|
+
* Workflow sees it, as the engine retries the `run` with a fresh attempt; a spent budget settles
|
|
377
|
+
* the entry with a `TerminalError`. Without options, the failure reaches the Workflow.
|
|
378
|
+
*/
|
|
379
|
+
journalRun(name, action, options) {
|
|
357
380
|
let entry;
|
|
358
381
|
try {
|
|
359
382
|
entry = this.take('run', name);
|
|
@@ -369,11 +392,15 @@ class Attempt {
|
|
|
369
392
|
return Promise.reject(new JournalMismatch(this.cursor - 1, 'a pending entry', `${name} to run again`));
|
|
370
393
|
}
|
|
371
394
|
this.active += 1;
|
|
395
|
+
entry.attempts = (entry.attempts ?? 0) + 1;
|
|
372
396
|
return (async () => {
|
|
373
397
|
try {
|
|
374
398
|
const value = await action();
|
|
375
|
-
if (this.run.interruptions.delete(name))
|
|
376
|
-
|
|
399
|
+
if (this.run.interruptions.delete(name)) {
|
|
400
|
+
// A crash after the effect: the run is attempted again, and the Step with it.
|
|
401
|
+
this.end({ kind: 'failed', error: new Interruption(name.replace(/^step:/u, '')) });
|
|
402
|
+
return this.block();
|
|
403
|
+
}
|
|
377
404
|
entry.settled = true;
|
|
378
405
|
entry.value = value;
|
|
379
406
|
return value;
|
|
@@ -382,8 +409,20 @@ class Attempt {
|
|
|
382
409
|
if (cause instanceof TerminalError) {
|
|
383
410
|
entry.settled = true;
|
|
384
411
|
entry.error = cause;
|
|
412
|
+
throw cause;
|
|
385
413
|
}
|
|
386
|
-
|
|
414
|
+
this.run.failure = cause;
|
|
415
|
+
if (options === undefined)
|
|
416
|
+
throw cause;
|
|
417
|
+
if (spent(entry, options)) {
|
|
418
|
+
entry.settled = true;
|
|
419
|
+
entry.error = new TerminalError(cause?.message ?? String(cause));
|
|
420
|
+
throw entry.error;
|
|
421
|
+
}
|
|
422
|
+
// An attempt takes time too: a zero delay still spends a duration budget, by a millisecond.
|
|
423
|
+
entry.waitedMs = (entry.waitedMs ?? 0) + Math.max(1, delayOf(entry, options, cause));
|
|
424
|
+
this.end({ kind: 'retried', error: cause });
|
|
425
|
+
return this.block();
|
|
387
426
|
}
|
|
388
427
|
finally {
|
|
389
428
|
this.active -= 1;
|
|
@@ -477,6 +516,18 @@ class Attempt {
|
|
|
477
516
|
return Promise.resolve(value);
|
|
478
517
|
}
|
|
479
518
|
}
|
|
519
|
+
/** Whether a Step that just failed spent its budget: its attempts, or the backoff it waited. */
|
|
520
|
+
function spent(entry, options) {
|
|
521
|
+
return ((options.maxRetryAttempts !== undefined && (entry.attempts ?? 0) >= options.maxRetryAttempts) ||
|
|
522
|
+
(options.maxRetryDuration !== undefined && (entry.waitedMs ?? 0) >= options.maxRetryDuration));
|
|
523
|
+
}
|
|
524
|
+
/** The delay before a failed Step's next attempt: the one its failure prescribes, or its backoff. */
|
|
525
|
+
function delayOf(entry, options, cause) {
|
|
526
|
+
if (cause instanceof RetryableError && cause.retryAfter !== undefined)
|
|
527
|
+
return cause.retryAfter;
|
|
528
|
+
const retries = (entry.attempts ?? 1) - 1;
|
|
529
|
+
return Math.min(options.maxRetryInterval, options.initialRetryInterval * options.retryIntervalFactor ** retries);
|
|
530
|
+
}
|
|
480
531
|
function outcomeOf(error) {
|
|
481
532
|
const declared = error.metadata?.[FAILURE];
|
|
482
533
|
if (declared !== undefined) {
|
|
@@ -506,6 +557,7 @@ function snapshot(entry) {
|
|
|
506
557
|
kind: 'run',
|
|
507
558
|
name: entry.name,
|
|
508
559
|
settled: entry.settled,
|
|
560
|
+
attempts: entry.attempts ?? 0,
|
|
509
561
|
...(entry.settled && entry.error === undefined ? { value: entry.value } : {}),
|
|
510
562
|
...(entry.error === undefined ? {} : { error: entry.error }),
|
|
511
563
|
});
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import ts from 'typescript';
|
|
2
2
|
import { expressionType, propertyChain, unwrap, visit, visitOwnBody, } from '../../adapters/typescript/index.js';
|
|
3
|
-
import { ambiguity, calls, contextPath, globalCallName, production, staticId, violation, } from './shared.js';
|
|
3
|
+
import { ambiguity, calls, contextPath, globalCallName, production, propertyExpression, staticId, violation, } from './shared.js';
|
|
4
4
|
// `query` and `mutate` are the durable Step sugar over `run`; each is one Step too.
|
|
5
5
|
const STEP_METHODS = new Set(['run', 'query', 'mutate']);
|
|
6
6
|
// Members only a durable Workflow context has: using one proves the Workflow durable.
|
|
7
|
-
const DURABLE_CONTEXT_MEMBERS = new Set([
|
|
7
|
+
const DURABLE_CONTEXT_MEMBERS = new Set([
|
|
8
|
+
'awaitSignal',
|
|
9
|
+
'awaitExecution',
|
|
10
|
+
'cancelled',
|
|
11
|
+
'exhausted',
|
|
12
|
+
'retryAfter',
|
|
13
|
+
'sleep',
|
|
14
|
+
]);
|
|
8
15
|
const AUTHORITIES = new Set(['caller', 'self']);
|
|
9
16
|
// Raw Kernel graph reads a Step's `kernel` offers beside `mutate`.
|
|
10
17
|
const KERNEL_READS = new Set(['query', 'get', 'getOrThrow', 'neighbors']);
|
|
@@ -71,7 +78,7 @@ export const workflowRules = [
|
|
|
71
78
|
const method = stepMethod(node, roots);
|
|
72
79
|
if (!method)
|
|
73
80
|
return;
|
|
74
|
-
const id =
|
|
81
|
+
const id = stepId(node.arguments[0]);
|
|
75
82
|
if (!id || !/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(id)) {
|
|
76
83
|
evidence.push(violation(file, node.arguments[0] ?? node, `Workflow ${method} step ID is not a non-empty kebab-case literal.`));
|
|
77
84
|
}
|
|
@@ -209,8 +216,11 @@ function durableSteps(project, evidence) {
|
|
|
209
216
|
if (!ts.isCallExpression(node))
|
|
210
217
|
return;
|
|
211
218
|
const method = stepMethod(node, roots);
|
|
212
|
-
if (method
|
|
219
|
+
if (!method)
|
|
220
|
+
return;
|
|
221
|
+
if (method === 'query' || method === 'mutate' || declaredStep(node.arguments[0])) {
|
|
213
222
|
durable = true;
|
|
223
|
+
}
|
|
214
224
|
if (method !== 'run')
|
|
215
225
|
return;
|
|
216
226
|
const callback = node.arguments[1] && stepRunCallback(node.arguments[1], helpers);
|
|
@@ -218,7 +228,7 @@ function durableSteps(project, evidence) {
|
|
|
218
228
|
return;
|
|
219
229
|
if (callback.parameters.length > 0)
|
|
220
230
|
durable = true;
|
|
221
|
-
const id =
|
|
231
|
+
const id = stepId(node.arguments[0]);
|
|
222
232
|
runs.push({ file, call: node, label: id ?? 'with a dynamic id', callback });
|
|
223
233
|
});
|
|
224
234
|
if (durable)
|
|
@@ -227,6 +237,19 @@ function durableSteps(project, evidence) {
|
|
|
227
237
|
}
|
|
228
238
|
return steps;
|
|
229
239
|
}
|
|
240
|
+
/** Only a durable Step takes a declaration, `{ id, retry, timeoutMs }`, in place of its id. */
|
|
241
|
+
function declaredStep(argument) {
|
|
242
|
+
return argument !== undefined && ts.isObjectLiteralExpression(unwrap(argument));
|
|
243
|
+
}
|
|
244
|
+
/** A Step's literal id: its first argument, or the `id` of the declaration written there. */
|
|
245
|
+
function stepId(argument) {
|
|
246
|
+
if (argument === undefined)
|
|
247
|
+
return undefined;
|
|
248
|
+
const value = unwrap(argument);
|
|
249
|
+
return ts.isObjectLiteralExpression(value)
|
|
250
|
+
? staticId(propertyExpression(value, 'id'))
|
|
251
|
+
: staticId(value);
|
|
252
|
+
}
|
|
230
253
|
/**
|
|
231
254
|
* A `step.run` callback written inline or named by one local function. Any other is not inspected;
|
|
232
255
|
* FNC-NO-NEST reports it as ambiguous.
|