From 2e66902152fd6d882f35b4ee81792ae4cce6539f Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Fri, 10 Feb 2023 15:18:21 -0600 Subject: [PATCH 01/28] promise hook and tests --- .../src/cancellable-promise-hook.ts | 114 ++++++ .../test/cancellable-promises-hook.test.ts | 324 ++++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 packages/@eventual/core-runtime/src/cancellable-promise-hook.ts create mode 100644 packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts diff --git a/packages/@eventual/core-runtime/src/cancellable-promise-hook.ts b/packages/@eventual/core-runtime/src/cancellable-promise-hook.ts new file mode 100644 index 000000000..431e68bc0 --- /dev/null +++ b/packages/@eventual/core-runtime/src/cancellable-promise-hook.ts @@ -0,0 +1,114 @@ +/** + * This file patches Node's Promise type, making it cancellable and without memory leaks. + * + * It makes use of AsyncLocalStorage to achieve this: + * https://nodejs.org/api/async_context.html#class-asynclocalstorage + */ + +import { AsyncLocalStorage } from "async_hooks"; +import { isPromise } from "util/types"; + +const storage = new AsyncLocalStorage(); + +const _then = Promise.prototype.then; +const _catch = Promise.prototype.catch; +const _finally = Promise.prototype.finally; + +const _Promise = Promise; + +// @ts-ignore - naughty naughty +globalThis.Promise = function (executor: any) { + if (isCancelled()) { + // if the local storage has a cancelled flag, break the Promise chain + return new _Promise(() => {}); + } + return new _Promise(executor); +}; + +globalThis.Promise.resolve = ( + ...args: [] | [value: T] +): Promise | void> => { + if (args.length === 0) { + return new Promise((resolve) => resolve(void 0)); + } + const [value] = args; + return new Promise(async (resolve) => + isPromise(value) + ? (value as Promise>).then(resolve) + : resolve(value as Awaited) + ); +}; + +globalThis.Promise.reject = (reason?: any): Promise => { + return new Promise(async (_, reject) => reject(reason)); +}; + +globalThis.Promise.all = _Promise.all; +globalThis.Promise.allSettled = _Promise.allSettled; +globalThis.Promise.any = _Promise.any; +globalThis.Promise.race = _Promise.race; + +Promise.prototype.then = function (outerResolve, outerReject) { + const p = (_then as typeof _then).call(this, (value) => { + if (!isCancelled()) { + outerResolve?.(value); + } + }); + return outerReject ? p.catch(outerReject) : p; +}; + +Promise.prototype.catch = function (outerReject) { + return (_catch as typeof _catch).call(this, (err) => { + if (!isCancelled()) { + outerReject?.(err); + } + }); +}; + +Promise.prototype.finally = function (outerFinally) { + console.log("finally 1"); + return _finally.call(this, () => { + console.log("finally 2"); + if (!isCancelled()) { + console.log("finally 3"); + return outerFinally?.(); + } + }); +}; + +function isCancelled() { + const state = storage.getStore(); + return state?.cancelled === true; +} + +export function cancelLocalPromises() { + (storage.getStore() as CancelState | undefined)?.cancel(); +} + +interface CancelState { + cancelled: boolean; + cancel(): void; +} + +interface CancellablePromise extends Promise { + cancel(): void; +} + +export function cancellable(fn: () => Promise): CancellablePromise { + const state: CancelState = { + cancelled: false, + cancel: () => {}, + }; + return storage.run(state, () => { + let _reject: (reason?: any) => void; + const promise = new Promise((resolve, reject) => { + _reject = reject; + fn().then(resolve).catch(reject); + }); + state.cancel = (promise as any).cancel = function () { + state.cancelled = true; + _reject(new Error("cancelled")); + }; + return promise as CancellablePromise; + }); +} diff --git a/packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts b/packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts new file mode 100644 index 000000000..1e49c8aa0 --- /dev/null +++ b/packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts @@ -0,0 +1,324 @@ +import { + cancellable, + cancelLocalPromises, +} from "../src/cancellable-promise-hook.js"; +import { jest } from "@jest/globals"; +import { isPromise } from "util/types"; + +function sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +function never() { + return new Promise(() => {}); +} + +// const myPromise = cancellable(async () => { +// console.log("function called"); + +// try { +// // trigger an await +// await sleep(0); + +// cancelLocalPromises(); + +// console.log("in between sleep"); + +// // uncomment for test +// // cancel(); + +// // trigger another wait +// await sleep(0); + +// console.log("I will never be called 1"); +// } catch (err) { +// console.log("I will never be called 2", err); +// } finally { +// console.log("I will never be called 3"); +// } +// console.log("I will never be called 4"); +// }); + +// try { +// await new Promise((resolve, reject) => { +// // wrapping this in a new Promise prevents the async local storage from +// // leaking into the parent + +// // `await myPromise` binds its local storage to the outer promise it seems +// return myPromise.then(resolve).catch(reject); +// }); +// console.log("Promise was not cancelled"); +// } catch (err) { +// console.log("Promise was cancelled"); +// } + +test("no cancel", async () => { + const fn = jest.fn(); + const myPromise = cancellable(async () => { + // trigger an await + await sleep(0); + fn(); + }); + + await myPromise; + expect(fn).toBeCalled(); +}); + +test("cancel", async () => { + const fn = jest.fn(); + await expect(() => + cancellable(async () => { + // trigger an await + await sleep(0); + fn(); + + cancelLocalPromises(); + + await sleep(0); + fn(); + }) + ).rejects.toThrow(); + + expect(fn).toBeCalledTimes(1); +}); + +test("cancel in catch", async () => { + const fn = jest.fn(); + await expect(() => + cancellable(async () => { + try { + // trigger an await + await sleep(0); + fn(); + cancelLocalPromises(); + } catch { + fn(); + } + await sleep(0); + fn(); + }) + ).rejects.toThrow(); + + expect(fn).toBeCalledTimes(1); +}); + +test("cancel with nested", async () => { + const fn = jest.fn(); + await expect(() => + cancellable(async () => { + try { + // trigger an await + await sleep(0); + await (async () => { + await sleep(0); + fn(); + await sleep(0); + fn(); + })(); + fn(); + cancelLocalPromises(); + } catch { + fn(); + } + await sleep(0); + fn(); + }) + ).rejects.toThrow(); + + expect(fn).toBeCalledTimes(3); +}); + +test("cancel with dangling", async () => { + const fn = jest.fn(); + await expect(() => + cancellable(async () => { + try { + // trigger an await + await sleep(0); + (async () => { + sleep(0); + fn(); + sleep(0); + fn(); + })(); + fn(); + cancelLocalPromises(); + } catch { + fn(); + } + await sleep(0); + fn(); + }) + ).rejects.toThrow(); + + expect(fn).toBeCalledTimes(3); +}); + +test("cancel with never resolving", async () => { + const fn = jest.fn(); + await expect(() => + cancellable(async () => { + try { + // trigger an await + await sleep(0); + (async () => { + await never(); + })(); + fn(); + cancelLocalPromises(); + } catch { + fn(); + } + await sleep(0); + fn(); + }) + ).rejects.toThrow(); + + expect(fn).toBeCalledTimes(1); +}); + +test("test all", async () => { + const fn = jest.fn(); + await cancellable(async () => { + fn(); + await Promise.all([sleep(0), sleep(0)]); + fn(); + }); + + expect(fn).toBeCalledTimes(2); +}); + +test("cancel with all", async () => { + const fn = jest.fn(); + await expect(() => + cancellable(async () => { + try { + // trigger an await + await Promise.all([ + sleep(0), + never(), + sleep(0).then(() => { + fn(); + cancelLocalPromises(); + }), + ]); + } catch (err) { + console.error(err); + fn(); + } + await sleep(0); + fn(); + }) + ).rejects.toThrow(); + + expect(fn).toBeCalledTimes(1); +}); + +test("cancel with all settled", async () => { + const fn = jest.fn(); + await expect(() => + cancellable(async () => { + try { + // trigger an await + await Promise.allSettled([ + sleep(0), + never(), + sleep(0).then(() => { + fn(); + cancelLocalPromises(); + }), + ]); + fn(); + } catch (err) { + console.error(err); + fn(); + } + await sleep(0); + fn(); + }) + ).rejects.toThrow(); + + expect(fn).toBeCalledTimes(1); +}); + +test("cancel with any", async () => { + const fn = jest.fn(); + await expect(() => + cancellable(async () => { + try { + // trigger an await + await Promise.any([ + never(), + sleep(0).then(() => { + fn(); + cancelLocalPromises(); + }), + ]); + fn(); + } catch (err) { + console.error(err); + fn(); + } + await sleep(0); + fn(); + }) + ).rejects.toThrow(); + + expect(fn).toBeCalledTimes(1); +}); + +test("cancel with race", async () => { + const fn = jest.fn(); + await expect(() => + cancellable(async () => { + try { + // trigger an await + await Promise.race([ + never(), + sleep(0).then(() => { + fn(); + cancelLocalPromises(); + }), + ]); + fn(); + } catch (err) { + console.error(err); + fn(); + } + await sleep(0); + fn(); + }) + ).rejects.toThrow(); + + expect(fn).toBeCalledTimes(1); +}); + +test("isPromise", async () => { + await cancellable(async () => { + expect(isPromise(new Promise(() => {}))).toBeTruthy(); + }); +}); + +// test("cancel with ordered", async () => { +// const fn = jest.fn(); +// const fn2 = jest.fn(); +// const fn3 = jest.fn(); +// await expect(() => +// cancellable(async () => { +// try { +// // trigger an await +// await sleep(0); +// fn(); +// cancelLocalPromises(); +// } catch { +// fn(); +// } +// await sleep(0); +// fn(); +// }) +// ).rejects.toThrow(); + +// expect(fn).toBeCalledTimes(1); +// }); From f22dd6c3152a67fce68772802ad1422eb2639aa8 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Fri, 3 Mar 2023 04:29:50 -0600 Subject: [PATCH 02/28] feat: async workflow executor --- .../@eventual/compiler/src/eventual-bundle.ts | 6 +- .../core-runtime/src/eventual-factory.ts | 206 ++ .../core-runtime/src/handlers/orchestrator.ts | 26 +- .../@eventual/core-runtime/src/interpret.ts | 598 ---- .../@eventual/core-runtime/src/log-agent.ts | 2 +- .../core-runtime/src/workflow-executor.ts | 520 ++++ .../@eventual/core-runtime/src/workflow.ts | 3 +- .../test/cancellable-promises-hook.test.ts | 16 + .../core-runtime/test/interpret.test.ts | 2365 --------------- .../test/workflow-executor.test.ts | 2624 +++++++++++++++++ packages/@eventual/core/src/activity.ts | 15 +- packages/@eventual/core/src/condition.ts | 8 +- packages/@eventual/core/src/event.ts | 2 +- .../core/src/internal/await-all-settled.ts | 28 - .../@eventual/core/src/internal/await-all.ts | 27 - .../@eventual/core/src/internal/await-any.ts | 27 - .../core/src/internal/calls/activity-call.ts | 33 +- .../src/internal/calls/await-time-call.ts | 35 +- .../core/src/internal/calls/calls.ts | 62 + .../core/src/internal/calls/condition-call.ts | 27 +- .../src/internal/calls/expect-signal-call.ts | 33 +- .../core/src/internal/calls/index.ts | 3 +- .../src/internal/calls/publish-events-call.ts | 36 + .../src/internal/calls/send-events-call.ts | 38 - .../src/internal/calls/send-signal-call.ts | 34 +- .../src/internal/calls/signal-handler-call.ts | 46 +- .../core/src/internal/calls/workflow-call.ts | 56 +- packages/@eventual/core/src/internal/chain.ts | 38 - .../core/src/internal/eventual-hook.ts | 48 + .../@eventual/core/src/internal/eventual.ts | 178 -- .../@eventual/core/src/internal/global.ts | 42 +- packages/@eventual/core/src/internal/index.ts | 10 +- packages/@eventual/core/src/internal/race.ts | 24 - .../@eventual/core/src/internal/result.ts | 18 +- .../core/src/internal/workflow-events.ts | 69 +- packages/@eventual/core/src/signals.ts | 8 +- packages/@eventual/core/src/workflow.ts | 24 +- 37 files changed, 3733 insertions(+), 3602 deletions(-) create mode 100644 packages/@eventual/core-runtime/src/eventual-factory.ts delete mode 100644 packages/@eventual/core-runtime/src/interpret.ts create mode 100644 packages/@eventual/core-runtime/src/workflow-executor.ts delete mode 100644 packages/@eventual/core-runtime/test/interpret.test.ts create mode 100644 packages/@eventual/core-runtime/test/workflow-executor.test.ts delete mode 100644 packages/@eventual/core/src/internal/await-all-settled.ts delete mode 100644 packages/@eventual/core/src/internal/await-all.ts delete mode 100644 packages/@eventual/core/src/internal/await-any.ts create mode 100644 packages/@eventual/core/src/internal/calls/calls.ts create mode 100644 packages/@eventual/core/src/internal/calls/publish-events-call.ts delete mode 100644 packages/@eventual/core/src/internal/calls/send-events-call.ts delete mode 100644 packages/@eventual/core/src/internal/chain.ts create mode 100644 packages/@eventual/core/src/internal/eventual-hook.ts delete mode 100644 packages/@eventual/core/src/internal/eventual.ts delete mode 100644 packages/@eventual/core/src/internal/race.ts diff --git a/packages/@eventual/compiler/src/eventual-bundle.ts b/packages/@eventual/compiler/src/eventual-bundle.ts index ab4baaefa..adfcf4cca 100755 --- a/packages/@eventual/compiler/src/eventual-bundle.ts +++ b/packages/@eventual/compiler/src/eventual-bundle.ts @@ -4,7 +4,7 @@ import { aliasPath } from "esbuild-plugin-alias-path"; import fs from "fs/promises"; import path from "path"; import { prepareOutDir } from "./build.js"; -import { eventualESPlugin } from "./esbuild-plugin.js"; +// import { eventualESPlugin } from "./esbuild-plugin.js"; export async function bundleSources( outDir: string, @@ -65,7 +65,7 @@ export async function build({ injectedServiceSpec, name, entry, - eventualTransform = false, + // eventualTransform = false, sourcemap, serviceType, external, @@ -102,7 +102,7 @@ export async function build({ }), ] : []), - ...(eventualTransform ? [eventualESPlugin] : []), + // ...(eventualTransform ? [eventualESPlugin] : []), ], conditions: ["module", "import", "require"], // external: ["@aws-sdk"], diff --git a/packages/@eventual/core-runtime/src/eventual-factory.ts b/packages/@eventual/core-runtime/src/eventual-factory.ts new file mode 100644 index 000000000..2e09ae8e6 --- /dev/null +++ b/packages/@eventual/core-runtime/src/eventual-factory.ts @@ -0,0 +1,206 @@ +import { + EventualError, + HeartbeatTimeout, + Schedule, + Timeout, +} from "@eventual/core"; +import { + assertNever, + CommandType, + EventualCall, + isActivityCall, + isActivityFailed, + isActivityHeartbeatTimedOut, + isActivityScheduled, + isActivitySucceeded, + isAwaitDurationCall, + isAwaitTimeCall, + isChildWorkflowFailed, + isChildWorkflowScheduled, + isChildWorkflowSucceeded, + isConditionCall, + isEventsPublished, + isExpectSignalCall, + isPublishEventsCall, + isRegisterSignalHandlerCall, + isSendSignalCall, + isSignalReceived, + isSignalSent, + isTimerCompleted, + isTimerScheduled, + isWorkflowCall, + Result, + ScheduledEvent, +} from "@eventual/core/internal"; +import { Eventual } from "./workflow-executor.js"; + +export function createEventualFromCall( + call: EventualCall +): Omit, "seq"> { + if (isActivityCall(call)) { + return { + applyEvent: (event) => { + if (isActivitySucceeded(event)) { + return Result.resolved(event.result); + } else if (isActivityFailed(event)) { + return Result.failed(new EventualError(event.error, event.message)); + } else if (isActivityHeartbeatTimedOut(event)) { + return Result.failed( + new HeartbeatTimeout("Activity Heartbeat TimedOut") + ); + } + return undefined; + }, + dependencies: call.timeout + ? { + promise: call.timeout, + handler: () => Result.failed(new Timeout("Activity Timed Out")), + } + : undefined, + generateCommands(seq) { + return { + kind: CommandType.StartActivity, + seq, + input: call.input, + name: call.name, + heartbeat: call.heartbeat, + }; + }, + }; + } else if (isWorkflowCall(call)) { + return { + applyEvent: (event) => { + if (isChildWorkflowSucceeded(event)) { + return Result.resolved(event.result); + } else if (isChildWorkflowFailed(event)) { + return Result.failed(new EventualError(event.error, event.message)); + } + return undefined; + }, + dependencies: call.timeout + ? { + promise: call.timeout, + handler: () => Result.failed("Activity Timed Out"), + } + : undefined, + generateCommands(seq) { + return { + kind: CommandType.StartWorkflow, + seq, + name: call.name, + input: call.input, + opts: call.opts, + }; + }, + }; + } else if (isAwaitTimeCall(call) || isAwaitDurationCall(call)) { + return { + applyEvent: (event) => { + if (isTimerCompleted(event)) { + return Result.resolved(undefined); + } + return undefined; + }, + generateCommands(seq) { + return { + kind: CommandType.StartTimer, + seq, + schedule: isAwaitTimeCall(call) + ? Schedule.time(call.isoDate) + : Schedule.duration(call.dur, call.unit), + }; + }, + }; + } else if (isSendSignalCall(call)) { + return { + generateCommands(seq) { + return { + kind: CommandType.SendSignal, + seq, + signalId: call.signalId, + target: call.target, + payload: call.payload, + }; + }, + result: Result.resolved(undefined), + }; + } else if (isExpectSignalCall(call)) { + return { + signals: call.signalId, + applyEvent: (event) => { + if (isSignalReceived(event)) { + return Result.resolved(event.payload); + } + return undefined; + }, + dependencies: call.timeout + ? { + promise: call.timeout, + handler: () => + Result.failed(new Timeout("Expect Signal Timed Out")), + } + : undefined, + }; + } else if (isPublishEventsCall(call)) { + return { + generateCommands(seq) { + return { kind: CommandType.PublishEvents, seq, events: call.events }; + }, + result: Result.resolved(undefined), + }; + } else if (isConditionCall(call)) { + // if the condition resolves immediately, just return a completed eventual + const result = call.predicate(); + if (result) { + return { + result: Result.resolved(result), + }; + } else { + // otherwise check the state after every event is applied. + return { + afterEveryEvent: () => { + const result = call.predicate(); + return result ? Result.resolved(result) : undefined; + }, + dependencies: call.timeout + ? { + promise: call.timeout, + handler: () => Result.resolved(false), + } + : undefined, + }; + } + } else if (isRegisterSignalHandlerCall(call)) { + return { + signals: call.signalId, + applyEvent: (event) => { + if (isSignalReceived(event)) { + call.handler(event.payload); + } + return undefined; + }, + }; + } + return assertNever(call); +} + +export function isCorresponding( + event: ScheduledEvent, + seq: number, + call: EventualCall +) { + if (event.seq !== seq) { + return false; + } else if (isActivityScheduled(event)) { + return isActivityCall(call) && call.name === event.name; + } else if (isChildWorkflowScheduled(event)) { + return isWorkflowCall(call) && call.name === event.name; + } else if (isTimerScheduled(event)) { + return isAwaitTimeCall(call) || isAwaitDurationCall(call); + } else if (isSignalSent(event)) { + return isSendSignalCall(call) && event.signalId === call.signalId; + } else if (isEventsPublished(event)) { + return isPublishEventsCall(call); + } + return assertNever(event); +} diff --git a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts index ae7a43834..115e1d24e 100644 --- a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts +++ b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts @@ -1,5 +1,4 @@ import { - WorkflowContext, DeterminismError, ExecutionID, ExecutionStatus, @@ -10,9 +9,9 @@ import { Schedule, SucceededExecution, Workflow, + WorkflowContext, } from "@eventual/core"; import { - clearEventualCollector, getEventId, HistoryEvent, HistoryStateEvent, @@ -48,18 +47,18 @@ import { WorkflowClient } from "../clients/workflow-client.js"; import { CommandExecutor } from "../command-executor.js"; import { hookDate, restoreDate } from "../date-hook.js"; import { isExecutionId, parseWorkflowName } from "../execution.js"; -import { interpret } from "../interpret.js"; import { ExecutionLogContext, LogAgent, LogContextType } from "../log-agent.js"; import { MetricsCommon, OrchestratorMetrics } from "../metrics/constants.js"; import { MetricsLogger } from "../metrics/metrics-logger.js"; import { Unit } from "../metrics/unit.js"; -import { timed, timedSync } from "../metrics/utils.js"; +import { timed } from "../metrics/utils.js"; import { WorkflowProvider } from "../providers/workflow-provider.js"; import { ExecutionHistoryStateStore } from "../stores/execution-history-state-store.js"; import { ExecutionHistoryStore } from "../stores/execution-history-store.js"; import { WorkflowTask } from "../tasks.js"; import { groupBy, promiseAllSettledPartitioned } from "../utils.js"; import { createEvent } from "../workflow-events.js"; +import { WorkflowExecutor } from "../workflow-executor.js"; /** * The Orchestrator's client dependencies. @@ -316,9 +315,8 @@ export function createOrchestrator({ } } - const { result, commands: newCommands } = logAgent.logContextScopeSync( - executionLogContext, - () => { + const { result, commands: newCommands } = + await logAgent.logContextScope(executionLogContext, () => { console.debug("history events", JSON.stringify(history)); console.debug("task events", JSON.stringify(events)); console.debug( @@ -330,7 +328,7 @@ export function createOrchestrator({ JSON.stringify(processedEvents.interpretEvents) ); - return timedSync( + return timed( metrics, OrchestratorMetrics.AdvanceExecutionDuration, () => @@ -341,8 +339,7 @@ export function createOrchestrator({ logAgent ) ); - } - ); + }); metrics.setProperty( OrchestratorMetrics.AdvanceExecutionEvents, @@ -676,7 +673,7 @@ function logEventMetrics( } } -export function progressWorkflow( +export async function progressWorkflow( executionId: string, workflow: Workflow, processedEvents: ProcessEventsResult, @@ -704,8 +701,8 @@ export function progressWorkflow( processedEvents.firstRunStarted.timestamp ).getTime(); hookDate(() => currentTime); - return interpret( - workflow.definition(processedEvents.startEvent.input, context), + const executor = new WorkflowExecutor( + workflow, processedEvents.interpretEvents, { hooks: { @@ -727,9 +724,10 @@ export function progressWorkflow( }, } ); + return await executor.start(processedEvents.startEvent.input, context); } catch (err) { // temporary fix when the interpreter fails, but the activities are not cleared. - clearEventualCollector(); + // clearEventualCollector(); console.debug("workflow error", inspect(err)); throw err; } finally { diff --git a/packages/@eventual/core-runtime/src/interpret.ts b/packages/@eventual/core-runtime/src/interpret.ts deleted file mode 100644 index d765f91a7..000000000 --- a/packages/@eventual/core-runtime/src/interpret.ts +++ /dev/null @@ -1,598 +0,0 @@ -import { - DeterminismError, - EventualError, - HeartbeatTimeout, - Schedule, - SynchronousOperationError, - Timeout, -} from "@eventual/core"; -import { - assertNever, - Chain, - clearEventualCollector, - CommandCall, - CommandType, - createChain, - Eventual, - EventualCallCollector, - ExpectSignalCall, - Failed, - FailedEvent, - HistoryEvent, - HistoryResultEvent, - isActivityCall, - isActivityHeartbeatTimedOut, - isActivityScheduled, - isAwaitAll, - isAwaitAllSettled, - isAwaitAny, - isAwaitDurationCall, - isAwaitTimeCall, - isChain, - isChildWorkflowScheduled, - isCommandCall, - isConditionCall, - isEventsPublished, - isEventual, - isExpectSignalCall, - isFailed, - isHistoryResultEvent, - isPending, - isPublishEventsCall, - isRace, - isRegisterSignalHandlerCall, - isResolved, - isResolvedOrFailed, - isScheduledEvent, - isSendSignalCall, - isSignalReceived, - isSignalSent, - isSucceededEvent, - isTimerCompleted, - isTimerScheduled, - isWorkflowCall, - isWorkflowRunStarted, - isWorkflowTimedOut, - iterator, - Program, - RegisterSignalHandlerCall, - Resolved, - Result, - ScheduledEvent, - setEventualCollector, - SignalReceived, - SucceededEvent, - WorkflowCommand, - WorkflowEvent, - _Iterator, -} from "@eventual/core/internal"; - -export interface WorkflowResult { - /** - * The Result if this chain has terminated. - */ - result?: Result; - /** - * Any Commands that need to be scheduled. - * - * This can still be non-empty even if the chain has terminated because of dangling promises. - */ - commands: WorkflowCommand[]; -} - -export interface InterpretProps { - hooks?: { - /** - * Callback called when a returned call matches an input event. - * - * This call will be ignored. - */ - historicalEventMatched?: (event: WorkflowEvent, call: CommandCall) => void; - - /** - * Callback immediately before applying a result event. - */ - beforeApplyingResultEvent?: (resultEvent: HistoryResultEvent) => void; - }; -} - -/** - * Interprets a workflow program - */ -export function interpret( - program: Program, - history: HistoryEvent[], - props?: InterpretProps -): WorkflowResult> { - const callTable: Record = {}; - /** - * A map of signalIds to calls and handlers that are listening for this signal. - */ - const signalSubscriptions: Record< - string, - (ExpectSignalCall | RegisterSignalHandlerCall)[] - > = {}; - const mainChain = createChain(program); - const activeChains = new Set([mainChain]); - - let seq = 0; - function nextSeq() { - return seq++; - } - - const emittedEvents = iterator(history, isScheduledEvent); - const resultEvents = iterator(history, isHistoryResultEvent); - - try { - /** - * Try to advance machine - * While we have calls and emitted events, drain the queues. - * When we run out of emitted events or calls, try to apply the next result event - * advance run again - * any calls at the event of all result commands and advances, return - */ - const calls: CommandCall[] = []; - let newCalls: _Iterator; - // iterate until we are no longer finding commands, no longer have completion events to apply - // or the workflow has a terminal status. - while ( - (!mainChain.result || isPending(mainChain.result)) && - ((newCalls = iterator(advance() ?? [])).hasNext() || - resultEvents.hasNext()) - ) { - // if there are result events (completed or failed), apply it before the next run - if (!newCalls.hasNext() && resultEvents.hasNext()) { - const resultEvent = resultEvents.next()!; - - props?.hooks?.beforeApplyingResultEvent?.(resultEvent); - - // it is possible that committing events - newCalls = iterator( - collectActivitiesScope(() => { - if (isSignalReceived(resultEvent)) { - commitSignal(resultEvent); - } else if (isWorkflowTimedOut(resultEvent)) { - // will stop the workflow execution as the workflow has failed. - mainChain.result = Result.failed( - new Timeout("Workflow timed out") - ); - } else if (!isWorkflowRunStarted(resultEvent)) { - commitCompletionEvent(resultEvent); - } - }) - ); - } - - // Match and filter found commands against the given scheduled events. - // scheduled events must be in order or not present. - while (newCalls.hasNext() && emittedEvents.hasNext()) { - const call = newCalls.next()!; - const event = emittedEvents.next()!; - - props?.hooks?.historicalEventMatched?.(event, call); - - if (!isCorresponding(event, call)) { - throw new DeterminismError( - `Workflow returned ${JSON.stringify(call)}, but ${JSON.stringify( - event - )} was expected at ${event?.seq}` - ); - } - } - - // any calls not matched against historical schedule events will be returned to the caller. - calls.push(...newCalls.drain()); - } - - // if the history shows events have been scheduled, but we did not find them when running the workflow, - // something is wrong, fail - if (emittedEvents.hasNext()) { - throw new DeterminismError( - "Workflow did not return expected commands: " + - JSON.stringify(emittedEvents.drain()) - ); - } - - const result = tryResolveResult(mainChain); - - return { - result, - commands: calls.flatMap(callToCommand), - }; - } catch (err) { - return { - commands: [], - // errors thrown by the workflow (and interpreter) are considered fatal workflow events unless caught by the workflow code. - result: Result.failed(err), - }; - } - - function callToCommand( - call: CommandCall - ): WorkflowCommand[] | WorkflowCommand { - if (isActivityCall(call)) { - return { - kind: CommandType.StartActivity, - input: call.input, - name: call.name, - heartbeat: call.heartbeat, - seq: call.seq!, - }; - } else if (isAwaitTimeCall(call) || isAwaitDurationCall(call)) { - return { - kind: CommandType.StartTimer, - seq: call.seq!, - schedule: isAwaitTimeCall(call) - ? Schedule.time(call.isoDate) - : Schedule.duration(call.dur, call.unit), - }; - } else if (isWorkflowCall(call)) { - return { - kind: CommandType.StartWorkflow, - seq: call.seq!, - input: call.input, - name: call.name, - opts: call.opts, - }; - } else if (isSendSignalCall(call)) { - return { - kind: CommandType.SendSignal, - signalId: call.signalId, - target: call.target, - seq: call.seq!, - payload: call.payload, - }; - } else if (isRegisterSignalHandlerCall(call)) { - return []; - } else if (isPublishEventsCall(call)) { - return { - kind: CommandType.PublishEvents, - seq: call.seq!, - events: call.events, - }; - } - - return assertNever(call); - } - - function advance(): CommandCall[] | undefined { - let madeProgress: boolean; - - return collectActivitiesScope(() => { - do { - madeProgress = false; - for (const chain of activeChains) { - madeProgress = madeProgress || tryAdvanceChain(chain); - } - } while (madeProgress); - }); - } - - function collectActivitiesScope(func: () => void): CommandCall[] { - const calls: CommandCall[] = []; - - const collector: EventualCallCollector = { - /** - * The returned activity is available to the workflow and may be yielded later if it was not already. - */ - pushEventual(activity) { - if (isCommandCall(activity)) { - activity.seq = nextSeq(); - callTable[activity.seq!] = activity; - calls.push(activity); - return activity; - } else if (isChain(activity)) { - activeChains.add(activity); - tryAdvanceChain(activity); - return activity; - } else if ( - isAwaitAll(activity) || - isAwaitAllSettled(activity) || - isAwaitAny(activity) || - isConditionCall(activity) || - isRace(activity) - ) { - return activity; - } else if (isRegisterSignalHandlerCall(activity)) { - subscribeToSignal(activity.signalId, activity); - // signal handler does not emit a call/command. It is only internal. - return activity; - } else if (isExpectSignalCall(activity)) { - subscribeToSignal(activity.signalId, activity); - return activity; - } - - return assertNever(activity); - }, - }; - - try { - setEventualCollector(collector); - - func(); - } finally { - clearEventualCollector(); - } - - return calls; - } - - function subscribeToSignal( - signalId: string, - sub: ExpectSignalCall | RegisterSignalHandlerCall - ) { - if (!(signalId in signalSubscriptions)) { - signalSubscriptions[signalId] = []; - } - signalSubscriptions[signalId]!.push(sub); - } - - /** - * Try and advance chain if it can be resumed up with a new value. - * - * Returns an array of Commands if the chain progressed, otherwise `undefined`: - * 1. If an empty array of Commands is returned, it indicates that the chain woke up - * but did not emit any new Commands. - * 2. An `undefined` return value indicates that the chain did not advance - */ - function tryAdvanceChain(chain: Chain): boolean { - if (chain.awaiting === undefined) { - // this is the first time the chain is running, so wake it with an undefined input - advanceChain(chain, undefined); - return true; - } - const result = tryResolveResult(chain.awaiting); - if (result === undefined) { - return false; - } else if (isResolved(result) || isFailed(result)) { - advanceChain(chain, result); - return true; - } else if (isAwaitAll(result.activity)) { - const results = []; - for (const activity of result.activity.activities) { - const result = activity.result; - if (result === undefined) { - // something went wrong, we should always have a Pending Result for an Activity - // TODO: this may be an internal IllegalStateException, not a DeterminismError - // -> this state should not be possible to get into, even when writing non-deterministic code - throw new DeterminismError(); - } else if (isPending(result)) { - // chain cannot wake up because we're waiting on all tasks - return false; - } else if (isFailed(result)) { - // one of the inner activities has failed, the chain should throw - advanceChain(chain, result); - return true; - } else { - results.push(result.value); - } - } - advanceChain(chain, Result.resolved(results)); - return true; - } else { - return false; - } - - /** - * Resumes a {@link Chain} with a new value and return any newly spawned {@link CommandCall}s. - */ - function advanceChain( - chain: Chain, - result: Resolved | Failed | undefined - ): void { - try { - const iterResult = - result === undefined || isResolved(result) - ? chain.next(result?.value) - : chain.throw(result.error); - if (iterResult.done) { - activeChains.delete(chain); - if (isEventual(iterResult.value)) { - chain.result = Result.pending(iterResult.value); - } else if (isGenerator(iterResult.value)) { - const childChain = createChain(iterResult.value); - activeChains.add(childChain); - chain.result = Result.pending(childChain); - } else { - chain.result = Result.resolved(iterResult.value); - } - } else if ( - !isChain(iterResult.value) && - isGenerator(iterResult.value) - ) { - const childChain = createChain(iterResult.value); - activeChains.add(childChain); - chain.awaiting = childChain; - } else { - chain.awaiting = iterResult.value; - } - } catch (err) { - console.error(chain, err); - activeChains.delete(chain); - chain.result = Result.failed(err); - } - } - } - - function tryResolveResult(activity: any): Result | undefined { - // it is possible that a non-eventual is yielded or passed to an all settled, send the value through. - if (!isEventual(activity)) { - return Result.resolved(activity); - } - // check if a result has been stored on the activity before computing - else if (isResolved(activity.result) || isFailed(activity.result)) { - return activity.result; - } else if (isPending(activity.result)) { - // if an activity is marked as pending another activity, defer to the pending activities's result - return tryResolveResult(activity.result.activity); - } - return (activity.result = resolveResult( - activity as Eventual & { result: undefined } - )); - - /** - * When the result has not been cached in the activity, try to compute it. - */ - function resolveResult(activity: Eventual & { result: undefined }) { - if (isConditionCall(activity)) { - // first check the state of the condition's timeout - if (activity.timeout) { - const timeoutResult = tryResolveResult(activity.timeout); - if (isResolved(timeoutResult) || isFailed(timeoutResult)) { - return Result.resolved(false); - } - } - // try to evaluate the condition's result. - const predicateResult = activity.predicate(); - if (isGenerator(predicateResult)) { - return Result.failed( - new SynchronousOperationError( - "Condition Predicates must be synchronous" - ) - ); - } else if (predicateResult) { - return Result.resolved(true); - } - } else if ( - isActivityCall(activity) || - isExpectSignalCall(activity) || - isWorkflowCall(activity) - ) { - if (activity.timeout) { - const timeoutResult = tryResolveResult(activity.timeout); - if (isResolved(timeoutResult) || isFailed(timeoutResult)) { - return Result.failed( - new Timeout( - isActivityCall(activity) - ? "Activity Timed Out" - : isExpectSignalCall(activity) - ? "Expect Signal Timed Out" - : isWorkflowCall(activity) - ? "Child Workflow Timed Out" - : assertNever(activity) - ) - ); - } - } - return undefined; - } else if ( - isChain(activity) || - isCommandCall(activity) || - isRegisterSignalHandlerCall(activity) - ) { - // chain and most commands will be resolved elsewhere (ex: commitCompletionEvent or commitSignal) - return undefined; - } else if (isAwaitAll(activity)) { - // try to resolve all of the nested activities - const results = activity.activities.map(tryResolveResult); - // if all results are resolved, return their values as a map - if (results.every(isResolved)) { - return Result.resolved(results.map((r) => r.value)); - } - // if any failed, return the first one, otherwise continue - return results.find(isFailed); - } else if (isAwaitAny(activity)) { - // try to resolve all of the nested activities - const results = activity.activities.map(tryResolveResult); - // if all are failed, return their errors as an AggregateError - if (results.every(isFailed)) { - return Result.failed(new AggregateError(results.map((e) => e.error))); - } - // if any are fulfilled, return it, otherwise continue - return results.find(isResolved); - } else if (isAwaitAllSettled(activity)) { - // try to resolve all of the nested activities - const results = activity.activities.map(tryResolveResult); - // if all are resolved or failed, return the Promise Result API - if (results.every(isResolvedOrFailed)) { - return Result.resolved( - results.map( - (r): PromiseFulfilledResult | PromiseRejectedResult => - isResolved(r) - ? { status: "fulfilled", value: r.value } - : { status: "rejected", reason: r.error } - ) - ); - } - } else if (isRace(activity)) { - // try to resolve all of the nested activities - const results = activity.activities.map(tryResolveResult); - // if any of the results are complete, return the first one, otherwise continue - return results.find(isResolvedOrFailed); - } else { - return assertNever(activity); - } - // no result was found, continue - return undefined; - } - } - - /** - * Add result to ExpectSignal and call any event handlers. - */ - function commitSignal(signal: SignalReceived) { - const subscriptions = signalSubscriptions[signal.signalId]; - - subscriptions?.forEach((sub) => { - if (isExpectSignalCall(sub)) { - if (!sub.result) { - sub.result = Result.resolved(signal.payload); - } - } else if (isRegisterSignalHandlerCall(sub)) { - if (!sub.result) { - // call the handler - // the transformer may wrap the handler function as a chain, it will be registered internally - const output = sub.handler(signal.payload); - // if the handler returns generator instead, start a new chain - if (isGenerator(output)) { - activeChains.add(createChain(output)); - } - } - } - }); - } - - function commitCompletionEvent(event: SucceededEvent | FailedEvent) { - const call = callTable[event.seq]; - if (call === undefined) { - throw new DeterminismError(`Call for seq ${event.seq} was not emitted.`); - } - if (call.result && !isPending(call.result)) { - return; - } - call.result = isSucceededEvent(event) - ? Result.resolved(event.result) - : isTimerCompleted(event) - ? Result.resolved(undefined) - : isActivityHeartbeatTimedOut(event) - ? Result.failed(new HeartbeatTimeout("Activity Heartbeat TimedOut")) - : Result.failed(new EventualError(event.error, event.message)); - } -} - -function isCorresponding(event: ScheduledEvent, call: CommandCall) { - if (event.seq !== call.seq) { - return false; - } else if (isActivityScheduled(event)) { - return isActivityCall(call) && call.name === event.name; - } else if (isChildWorkflowScheduled(event)) { - return isWorkflowCall(call) && call.name === event.name; - } else if (isTimerScheduled(event)) { - return isAwaitTimeCall(call) || isAwaitDurationCall(call); - } else if (isSignalSent(event)) { - return isSendSignalCall(call) && event.signalId === call.signalId; - } else if (isEventsPublished(event)) { - return isPublishEventsCall(call); - } - return assertNever(event); -} - -function isGenerator(a: any): a is Program { - return ( - a && - typeof a === "object" && - typeof a.next === "function" && - typeof a.return === "function" && - typeof a.throw === "function" - ); -} diff --git a/packages/@eventual/core-runtime/src/log-agent.ts b/packages/@eventual/core-runtime/src/log-agent.ts index 17bfeae01..f01023f97 100644 --- a/packages/@eventual/core-runtime/src/log-agent.ts +++ b/packages/@eventual/core-runtime/src/log-agent.ts @@ -228,7 +228,7 @@ export class LogAgent { public async logContextScope( context: LogContext, scopeHandler: () => T - ): Promise { + ): Promise> { try { this.pushContext(context); return await scopeHandler(); diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts new file mode 100644 index 000000000..557ffd3cc --- /dev/null +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -0,0 +1,520 @@ +import { + DeterminismError, + Timeout, + Workflow, + WorkflowContext, +} from "@eventual/core"; +import { + assertNever, + createEventualPromise, + EventualCall, + EventualPromise, + HistoryEvent, + HistoryResultEvent, + HistoryScheduledEvent, + isFailed, + isResolved, + isResultEvent, + isScheduledEvent, + isSignalReceived, + isWorkflowRunStarted, + isWorkflowTimedOut, + iterator, + registerWorkflowHook, + Result, + WorkflowCommand, + WorkflowEvent, + WorkflowRunStarted, + WorkflowTimedOut, + _Iterator, +} from "@eventual/core/internal"; +import { isPromise } from "util/types"; +import { createEventualFromCall, isCorresponding } from "./eventual-factory.js"; + +interface ActiveEventual { + resolve: (result: R) => void; + reject: (reason: any) => void; + promise: EventualPromise; + eventual: Eventual; +} + +interface RuntimeState { + /** + * All {@link Eventual} waiting on a sequence based result event. + */ + active: Record; + /** + * All {@link Eventual}s waiting on a signal. + */ + awaitingSignals: Record>; + /** + * All {@link Eventual}s which should be invoked on every new event. + * + * For example, Condition should be checked after applying any event. + */ + awaitingAny: Set; + /** + * Iterator containing the in order events we expected to see in a deterministic workflow. + */ + expected: _Iterator; + /** + * Iterator containing events to apply. + */ + events: _Iterator; +} + +interface DependencyHandler { + promise: Promise; + handler: (val: Result) => Result | undefined; +} + +export interface Eventual { + /** + * Invoke this handler every time a new event is applied, in seq order. + */ + afterEveryEvent?: () => Result | undefined; + signals?: string[] | string; + /** + * When an promise completes, call the handler on this eventual. + * + * Useful for things like activities, which use other {@link Eventual}s to cancel/timeout. + */ + dependencies?: DependencyHandler[] | DependencyHandler; + /** + * When an event comes in that matches this eventual's sequence, + * pass the event to the eventual if not already resolved. + */ + applyEvent?: (event: HistoryEvent) => Result | undefined; + /** + * Commands to emit. + * + * When undefined, the eventual will not be checked against an expected event as it does not emit commands. + */ + generateCommands?: (seq: number) => WorkflowCommand[] | WorkflowCommand; + seq: number; + result?: Result; +} + +function initializeRuntimeState(historyEvents: HistoryEvent[]): RuntimeState { + return { + active: {}, + awaitingSignals: {}, + awaitingAny: new Set(), + expected: iterator(historyEvents, isScheduledEvent), + events: iterator(historyEvents, isResultEvent), + }; +} + +/** + * 1. get hook - getEventualHook + * 2. register - register eventual call + * a. create eventual with handlers and callbacks + * b. check for completion - the eventual can choose to end early, for example, a condition + * c. create promise + * d. register eventual with the executor + * e. return promise + * 3. return promise to caller/workflow + */ + +interface ExecutorOptions { + /** + * When false, the workflow will auto-cancel when it has exhausted all history events provided + * during {@link start}. + * + * When true, use {@link continue} to provide more history events. + */ + resumable?: boolean; + hooks?: { + /** + * Callback called when a returned call matches an input event. + * + * This call will be ignored. + */ + historicalEventMatched?: (event: WorkflowEvent, call: EventualCall) => void; + + /** + * Callback immediately before applying a result event. + */ + beforeApplyingResultEvent?: (resultEvent: HistoryResultEvent) => void; + }; +} + +export class WorkflowExecutor { + private seq: number; + private runtimeState: RuntimeState; + private started?: { + resolve: (result: Result) => void; + }; + private commandsToEmit: WorkflowCommand[]; + public result?: Result; + + constructor( + private workflow: Workflow, + private history: HistoryEvent[], + private options?: ExecutorOptions + ) { + this.seq = 0; + this.runtimeState = initializeRuntimeState(history); + this.commandsToEmit = []; + } + + public start( + input: Input, + context: WorkflowContext + ): Promise> { + if (this.started) { + throw new Error( + "Execution has already been started. If resumable is on, use continue to apply new events or create a new Interpreter" + ); + } + + return new Promise(async (resolve) => { + // start context with execution hook + this.registerExecutionHook(); + this.started = { + // TODO, also cancel? + resolve: (result) => { + const newCommands = this.commandsToEmit; + this.commandsToEmit = []; + + resolve({ commands: newCommands, result }); + }, + }; + try { + const workflowPromise = this.workflow.definition(input, context); + workflowPromise.then( + (result) => this.forceComplete(Result.resolved(result)), + (err) => this.forceComplete(Result.failed(err)) + ); + } catch (err) { + // handle any synchronous errors. + this.forceComplete(Result.failed(err)); + } + + // APPLY EVENTS + await this.drainHistoryEvents(); + + // let everything that has started or will be started complete + setTimeout(() => { + const newCommands = this.commandsToEmit; + this.commandsToEmit = []; + + if (!this.result && !this.options?.resumable) { + // cancel promises? + if (this.runtimeState.expected.hasNext()) { + this.forceComplete( + Result.failed( + new DeterminismError( + "Workflow did not return expected commands" + ) + ) + ); + } + } + + resolve({ + commands: newCommands, + result: this.result, + }); + }); + }); + } + + public async continue( + ...history: HistoryEvent[] + ): Promise> { + if (!this.options?.resumable) { + throw new Error( + "Cannot continue an execution unless resumable is set to true." + ); + } else if (!this.started) { + throw new Error("Execution has not been started, call start first."); + } + + this.history.push(...history); + + return new Promise(async (resolve) => { + this.registerExecutionHook(); + await this.drainHistoryEvents(); + + const newCommands = this.commandsToEmit; + this.commandsToEmit = []; + + resolve({ + commands: newCommands, + result: this.result, + }); + }); + } + + private forceComplete(result: Result) { + if (!this.started) { + throw new Error("Execution is not started."); + } + this.started.resolve(result); + } + + private registerExecutionHook() { + const self = this; + registerWorkflowHook({ + registerEventualCall: (call) => { + try { + const eventual = createEventualFromCall(call); + const seq = this.seq++; + + // if the call is new, generate and emit it's commands + // if the eventual does not generate commands, do not check it against the expected events. + if (eventual.generateCommands && !isExpectedCall(seq, call)) { + this.commandsToEmit.push( + ...normalizeToArray(eventual.generateCommands(seq)) + ); + } + + /** + * If the eventual comes with a result, do not active it, it is already resolved! + */ + if (eventual.result) { + return createEventualPromise( + isResolved(eventual.result) + ? Promise.resolve(eventual.result.value) + : Promise.reject(eventual.result.error), + seq + ); + } + + const activeEventual = this.activateEventual({ ...eventual, seq }); + + /** + * For each dependency, wire the dependency promise to the handler provided by the eventual. + * + * If the dependency resolves or rejects, pass the result along. + */ + const deps = normalizeToArray(eventual.dependencies); + deps.forEach((dep) => { + (!isPromise(dep.promise) + ? Promise.resolve(dep.promise) + : dep.promise + ).then( + (res) => { + this.tryResolveEventual(seq, dep.handler(Result.resolved(res))); + }, + (err) => { + this.tryResolveEventual(seq, dep.handler(Result.failed(err))); + } + ); + }); + + return activeEventual.promise; + } catch (err) { + this.forceComplete(Result.failed(err)); + throw err; + } + }, + resolveEventual: (seq, result) => { + this.tryResolveEventual(seq, result); + }, + }); + + /** + * Checks the call against the expected events. + * @returns false if the call is new and true if the call matches the expected events. + * @throws {@link DeterminismError} when the call is not expected and there are expected events remaining. + */ + function isExpectedCall(seq: number, call: EventualCall) { + if (self.runtimeState.expected.hasNext()) { + const expected = self.runtimeState.expected.next()!; + + self.options?.hooks?.historicalEventMatched?.(expected, call); + + if (!isCorresponding(expected, seq, call)) { + throw new DeterminismError( + `Workflow returned ${JSON.stringify(call)}, but ${JSON.stringify( + expected + )} was expected at ${expected?.seq}` + ); + } + return true; + } + return false; + } + } + + private async drainHistoryEvents() { + while (this.runtimeState.events.hasNext() && !this.result) { + const event = this.runtimeState.events.next()!; + this.options?.hooks?.beforeApplyingResultEvent?.(event); + await new Promise((resolve) => { + setTimeout(() => { + this.tryCommitResultEvent(event); + resolve(undefined); + }); + }); + // TODO: do we need to use setTimeout here to go to the end of the event loop? + } + } + + /** + * Applies a new event to the existing execution. + * + * 1. if the event is a timeout, timeout the workflow + * 2. find all of the active eventuals that are waiting for the given event + * 3. try to resolve each of the provided eventuals in order + * 4. if the resolved eventuals have any dependents, try resolve them too until the queue is drained. + * note: dependents are those eventuals while have declared other eventuals they care about. + */ + private tryCommitResultEvent(event: HistoryResultEvent) { + if (isWorkflowTimedOut(event)) { + this.forceComplete(Result.failed(new Timeout("Workflow timed out"))); + // TODO cancel workflow? + } else if (!isWorkflowRunStarted(event)) { + const eventuals = this.getEventualsForEvent(event); + for (const eventual of eventuals ?? []) { + // pass event to the eventual + this.tryResolveEventual( + eventual.eventual.seq, + eventual.eventual.applyEvent?.(event) + ); + } + [...this.runtimeState.awaitingAny].map((s) => { + const eventual = this.runtimeState.active[s]; + if (eventual && eventual.eventual.afterEveryEvent) { + this.tryResolveEventual(s, eventual.eventual.afterEveryEvent()); + } + }); + } + } + + private tryResolveEventual( + seq: number, + result: Result | undefined + ): void { + if (result) { + const eventual = this.runtimeState.active[seq]; + if (eventual) { + // deactivate the eventual to avoid a circular resolution + this.deactivateEventual(eventual.eventual); + // TODO: remove from signal listens + if (isResolved(result)) { + eventual.resolve(result.value); + } else if (isFailed(result)) { + eventual.reject(result.error); + } else { + return assertNever(result); + } + } + } + } + + private getEventualsForEvent( + event: Exclude + ): ActiveEventual[] | undefined { + if (isSignalReceived(event)) { + return [...(this.runtimeState.awaitingSignals[event.signalId] ?? [])] + ?.map((seq) => this.getActiveEventual(seq)) + .filter((envt): envt is ActiveEventual => !!envt); + } else { + const eventual = this.runtimeState.active[event.seq]; + // no more active Eventuals for this seq, ignore it + if (!eventual) { + return []; + } else { + return [eventual]; + } + } + } + + private getActiveEventual(seq: number): ActiveEventual | undefined { + return this.runtimeState.active[seq]; + } + + /** + * Apply the eventual to the runtime state. + * An active eventual can be resolved and waited on, it is yet to have a resolve. + * + * Roughly the opposite of {@link deactivateEventual}. + */ + private activateEventual(eventual: Eventual): ActiveEventual { + /** + * The promise that represents + */ + let reject: any, resolve: any; + const promise = createEventualPromise( + new Promise((r, rr) => { + resolve = r; + reject = rr; + }), + eventual.seq + ); + + const activeEventual: ActiveEventual = { + resolve, + reject, + promise, + eventual, + }; + + /** + * Add the eventual to the active eventual collection. + * + * This is how we determine which eventuals are active. + */ + this.runtimeState.active[eventual.seq] = activeEventual; + + /** + * If the eventual subscribes to a signal, add it to the map. + */ + if (eventual.signals) { + const signals = new Set(normalizeToArray(eventual.signals)); + [...signals].map((signal) => { + if (!(signal in this.runtimeState.awaitingSignals)) { + this.runtimeState.awaitingSignals[signal] = new Set(); + } + this.runtimeState.awaitingSignals[signal]!.add(eventual.seq); + }); + } + + /** + * If the eventual should be invoked after each event is applied, add it to the set. + */ + if (eventual.afterEveryEvent) { + this.runtimeState.awaitingAny.add(eventual.seq); + } + + return activeEventual; + } + + /** + * Remove an eventual from the runtime state. + * An inactive eventual has already been resolved and has a result. + * + * Roughly the opposite of {@link activateEventual}. + */ + private deactivateEventual(eventual: Eventual) { + // if the eventual is has a result, immediately remove it + delete this.runtimeState.active[eventual.seq]; + this.runtimeState.awaitingAny.delete(eventual.seq); + if (eventual.signals) { + const signals = normalizeToArray(eventual.signals); + signals.forEach((signal) => + this.runtimeState.awaitingSignals[signal]?.delete(eventual.seq) + ); + } + } +} + +function normalizeToArray(items?: T | T[]): T[] { + return items ? (Array.isArray(items) ? items : [items]) : []; +} + +export interface WorkflowResult { + /** + * The Result if this chain has terminated. + */ + result?: Result; + /** + * Any Commands that need to be scheduled. + * + * This can still be non-empty even if the chain has terminated because of dangling promises. + */ + commands: WorkflowCommand[]; +} diff --git a/packages/@eventual/core-runtime/src/workflow.ts b/packages/@eventual/core-runtime/src/workflow.ts index 58cf67149..1af4a0b23 100644 --- a/packages/@eventual/core-runtime/src/workflow.ts +++ b/packages/@eventual/core-runtime/src/workflow.ts @@ -1,11 +1,10 @@ import { WorkflowContext } from "@eventual/core"; -import { Program, AwaitedEventual } from "@eventual/core/internal"; declare module "@eventual/core" { export interface Workflow { definition: ( input: Input, context: WorkflowContext - ) => Program>; + ) => Promise>; } } diff --git a/packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts b/packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts index 1e49c8aa0..931edb984 100644 --- a/packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts +++ b/packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts @@ -301,6 +301,22 @@ test("isPromise", async () => { }); }); +/** + * This currently fails, cancellation only rejects the top promise, not all of the children, what value does it do? + */ +test.skip("cancelled child", async () => { + let p1: Promise; + const p2 = cancellable(async () => { + p1 = new Promise(() => {}); + await sleep(0); + cancelLocalPromises(); + await p1; + }); + + await expect(() => p2!).rejects.toThrow("cancelled"); + await expect(p1!).rejects.toThrow("cancelled"); +}); + // test("cancel with ordered", async () => { // const fn = jest.fn(); // const fn2 = jest.fn(); diff --git a/packages/@eventual/core-runtime/test/interpret.test.ts b/packages/@eventual/core-runtime/test/interpret.test.ts deleted file mode 100644 index feeb62653..000000000 --- a/packages/@eventual/core-runtime/test/interpret.test.ts +++ /dev/null @@ -1,2365 +0,0 @@ -/* eslint-disable require-yield, no-throw-literal */ -import { - WorkflowContext, - duration, - EventualError, - HeartbeatTimeout, - Schedule, - signal, - time, - Timeout, - workflow as _workflow, -} from "@eventual/core"; -import { - chain, - createActivityCall, - createAwaitAll, - createAwaitAllSettled, - createAwaitDurationCall, - createAwaitTimeCall, - createConditionCall, - createExpectSignalCall, - createPublishEventsCall, - createRegisterSignalHandlerCall, - createSendSignalCall, - createWorkflowCall, - Eventual, - Program, - Result, - ServiceType, - serviceTypeScopeSync, - SignalTargetType, -} from "@eventual/core/internal"; -import { interpret as _interpret, WorkflowResult } from "../src/interpret.js"; -import { - activityFailed, - activityHeartbeatTimedOut, - activityScheduled, - activitySucceeded, - createPublishEventCommand, - createScheduledActivityCommand, - createScheduledWorkflowCommand, - createSendSignalCommand, - createStartTimerCommand, - eventsPublished, - signalReceived, - signalSent, - timerCompleted, - timerScheduled, - workflowFailed, - workflowScheduled, - workflowSucceeded, - workflowTimedOut, -} from "./command-util.js"; - -import "../src/workflow"; - -function* myWorkflow(event: any): Program { - try { - const a: any = yield createActivityCall("my-activity", [event]); - - // dangling - it should still be scheduled - createActivityCall("my-activity-0", [event]); - - const all = yield Eventual.all([ - createAwaitTimeCall("then"), - createActivityCall("my-activity-2", [event]), - ]) as any; - return [a, all]; - } catch (err) { - yield createActivityCall("handle-error", [err]); - return []; - } -} - -const event = "hello world"; - -const context: WorkflowContext = { - workflow: { - name: "wf1", - }, - execution: { - id: "123/", - name: "wf1#123", - startTime: "", - }, -}; - -const workflow = (() => { - let n = 0; - return (handler: Parameters[2]) => { - return _workflow(`wf${n++}`, handler); - }; -})(); - -/** - * We expect to call interpret from within an orchestrator. - * Only change the service scope when interpret is called, allowing us to use - * contextual methods like {@link duration} outside of the workflows. - */ -const interpret = (...args: Parameters) => { - return serviceTypeScopeSync(ServiceType.OrchestratorWorker, () => { - return _interpret(...args); - }); -}; - -test("no history", () => { - expect(interpret(myWorkflow(event), [])).toMatchObject({ - commands: [createScheduledActivityCommand("my-activity", [event], 0)], - }); -}); - -test("should continue with result of completed Activity", () => { - expect( - interpret(myWorkflow(event), [ - activityScheduled("my-activity", 0), - activitySucceeded("result", 0), - ]) - ).toMatchObject({ - commands: [ - createScheduledActivityCommand("my-activity-0", [event], 1), - createStartTimerCommand(2), - createScheduledActivityCommand("my-activity-2", [event], 3), - ], - }); -}); - -test("should fail on workflow timeout event", () => { - expect( - interpret(myWorkflow(event), [ - activityScheduled("my-activity", 0), - workflowTimedOut(), - ]) - ).toMatchObject({ - result: Result.failed(new Timeout("Workflow timed out")), - commands: [], - }); -}); - -test("should not continue on workflow timeout event", () => { - expect( - interpret(myWorkflow(event), [ - activityScheduled("my-activity", 0), - workflowTimedOut(), - activitySucceeded("result", 0), - ]) - ).toMatchObject({ - result: Result.failed(new Timeout("Workflow timed out")), - commands: [], - }); -}); - -test("should catch error of failed Activity", () => { - expect( - interpret(myWorkflow(event), [ - activityScheduled("my-activity", 0), - activityFailed("error", 0), - ]) - ).toMatchObject({ - commands: [ - createScheduledActivityCommand( - "handle-error", - [new EventualError("error").toJSON()], - 1 - ), - ], - }); -}); - -test("should catch error of timing out Activity", () => { - function* myWorkflow(event: any): Program { - try { - const a: any = yield createActivityCall( - "my-activity", - [event], - createAwaitTimeCall("") - ); - - return a; - } catch (err) { - yield createActivityCall("handle-error", [err]); - return []; - } - } - - expect( - interpret(myWorkflow(event), [ - timerScheduled(0), - timerCompleted(0), - activityScheduled("my-activity", 1), - ]) - ).toMatchObject({ - commands: [ - createScheduledActivityCommand( - "handle-error", - [new Timeout("Activity Timed Out")], - 2 - ), - ], - }); -}); - -test("immediately abort activity on invalid timeout", () => { - function* myWorkflow(event: any): Program { - return createActivityCall( - "my-activity", - [event], - "not an awaitable" as any - ); - } - - expect( - interpret(myWorkflow(event), [activityScheduled("my-activity", 0)]) - ).toMatchObject({ - result: Result.failed(new Timeout("Activity Timed Out")), - }); -}); - -test("timeout multiple activities at once", () => { - function* myWorkflow(event: any): Program { - const time = createAwaitTimeCall(""); - const a = createActivityCall("my-activity", [event], time); - const b = createActivityCall("my-activity", [event], time); - - return yield createAwaitAllSettled([a, b]); - } - - expect( - interpret(myWorkflow(event), [ - timerScheduled(0), - activityScheduled("my-activity", 1), - activityScheduled("my-activity", 2), - timerCompleted(0), - ]) - ).toMatchObject({ - result: Result.resolved([ - { - status: "rejected", - reason: new Timeout("Activity Timed Out").toJSON(), - }, - { - status: "rejected", - reason: new Timeout("Activity Timed Out").toJSON(), - }, - ]), - commands: [], - }); -}); - -test("activity times out activity", () => { - function* myWorkflow(event: any): Program { - const z = createActivityCall("my-activity", [event]); - const a = createActivityCall("my-activity", [event], z); - const b = createActivityCall("my-activity", [event], a); - - return yield createAwaitAllSettled([z, a, b]); - } - - expect( - interpret(myWorkflow(event), [ - activityScheduled("my-activity", 0), - activityScheduled("my-activity", 1), - activityScheduled("my-activity", 2), - activitySucceeded("woo", 0), - ]) - ).toMatchObject({ - result: Result.resolved([ - { - status: "fulfilled", - value: "woo", - }, - { - status: "rejected", - reason: new Timeout("Activity Timed Out").toJSON(), - }, - { - status: "rejected", - reason: new Timeout("Activity Timed Out").toJSON(), - }, - ]), - commands: [], - }); -}); - -test("should return final result", () => { - expect( - interpret(myWorkflow(event), [ - activityScheduled("my-activity", 0), - activitySucceeded("result", 0), - activityScheduled("my-activity-0", 1), - timerScheduled(2), - activityScheduled("my-activity-2", 3), - activitySucceeded("result-0", 1), - timerCompleted(2), - activitySucceeded("result-2", 3), - ]) - ).toMatchObject({ - result: Result.resolved(["result", [undefined, "result-2"]]), - commands: [], - }); -}); - -test("should handle missing blocks", () => { - expect( - interpret(myWorkflow(event), [activitySucceeded("result", 0)]) - ).toMatchObject({ - commands: [ - createScheduledActivityCommand("my-activity", [event], 0), - createScheduledActivityCommand("my-activity-0", [event], 1), - createStartTimerCommand(2), - createScheduledActivityCommand("my-activity-2", [event], 3), - ], - }); -}); - -test("should handle partial blocks", () => { - expect( - interpret(myWorkflow(event), [ - activityScheduled("my-activity", 0), - activitySucceeded("result", 0), - activityScheduled("my-activity-0", 1), - ]) - ).toMatchObject({ - commands: [ - createStartTimerCommand(2), - createScheduledActivityCommand("my-activity-2", [event], 3), - ], - }); -}); - -test("should handle partial blocks with partial completes", () => { - expect( - interpret(myWorkflow(event), [ - activityScheduled("my-activity", 0), - activitySucceeded("result", 0), - activityScheduled("my-activity-0", 1), - activitySucceeded("result", 1), - ]) - ).toMatchObject({ - commands: [ - createStartTimerCommand(2), - createScheduledActivityCommand("my-activity-2", [event], 3), - ], - }); -}); - -test("yield constant", () => { - function* workflow(): any { - return yield 1; - } - - expect(interpret(workflow() as any, [])).toMatchObject({ - result: Result.resolved(1), - }); -}); - -describe("activity", () => { - describe("heartbeat", () => { - const wf = workflow(function* () { - return createActivityCall( - "getPumpedUp", - [], - undefined, - Schedule.duration(100) - ); - }); - - test("timeout from heartbeat seconds", () => { - expect( - interpret(wf.definition(undefined, context), [ - activityScheduled("getPumpedUp", 0), - activityHeartbeatTimedOut(0, 101), - ]) - ).toMatchObject({ - result: Result.failed( - new HeartbeatTimeout("Activity Heartbeat TimedOut") - ), - commands: [], - }); - }); - - test("timeout after complete", () => { - expect( - interpret(wf.definition(undefined, context), [ - activityScheduled("getPumpedUp", 0), - activitySucceeded("done", 0), - activityHeartbeatTimedOut(0, 1000), - ]) - ).toMatchObject({ - result: Result.resolved("done"), - commands: [], - }); - }); - - test("catch heartbeat timeout", () => { - const wf = workflow(function* (): any { - try { - const result = yield createActivityCall( - "getPumpedUp", - [], - undefined, - Schedule.duration(1) - ); - return result; - } catch (err) { - if (err instanceof HeartbeatTimeout) { - return err.message; - } - return "no"; - } - }); - - expect( - interpret(wf.definition(undefined, context), [ - activityScheduled("getPumpedUp", 0), - activityHeartbeatTimedOut(0, 10), - ]) - ).toMatchObject({ - result: Result.resolved("Activity Heartbeat TimedOut"), - commands: [], - }); - }); - }); -}); - -test("should throw when scheduled does not correspond to call", () => { - expect( - interpret(myWorkflow(event), [timerScheduled(0)]) - ).toMatchObject({ - result: Result.failed({ name: "DeterminismError" }), - commands: [], - }); -}); - -test("should throw when there are more schedules than calls emitted", () => { - expect( - interpret(myWorkflow(event), [ - activityScheduled("my-activity", 0), - activityScheduled("result", 1), - ]) - ).toMatchObject({ - result: Result.failed({ name: "DeterminismError" }), - commands: [], - }); -}); - -test("should throw when a completed precedes workflow state", () => { - expect( - interpret(myWorkflow(event), [ - activityScheduled("my-activity", 0), - activityScheduled("result", 1), - // the workflow does not return a seq: 2, where does this go? - // note: a completed event can be accepted without a "scheduled" counterpart, - // but the workflow must resolve the schedule before the complete - // is applied. - activitySucceeded("", 2), - ]) - ).toMatchObject({ - result: Result.failed({ name: "DeterminismError" }), - commands: [], - }); -}); - -test("should fail the workflow on uncaught user error", () => { - const wf = workflow(function* () { - throw new Error("Hi"); - }); - expect( - interpret(wf.definition(undefined, context), []) - ).toMatchObject({ - result: Result.failed({ name: "Error", message: "Hi" }), - commands: [], - }); -}); - -test("should fail the workflow on uncaught user error of random type", () => { - const wf = workflow(function* () { - throw new TypeError("Hi"); - }); - expect( - interpret(wf.definition(undefined, context), []) - ).toMatchObject({ - result: Result.failed({ name: "TypeError", message: "Hi" }), - commands: [], - }); -}); - -test("should fail the workflow on uncaught thrown value", () => { - const wf = workflow(function* () { - throw "hi"; - }); - expect( - interpret(wf.definition(undefined, context), []) - ).toMatchObject({ - result: Result.failed("hi"), - commands: [], - }); -}); - -test("should wait if partial results", () => { - expect( - interpret(myWorkflow(event), [ - activityScheduled("my-activity", 0), - activitySucceeded("result", 0), - activityScheduled("my-activity-0", 1), - timerScheduled(2), - activityScheduled("my-activity-2", 3), - activitySucceeded("result-0", 1), - timerCompleted(2), - ]) - ).toMatchObject({ - commands: [], - }); -}); - -test("should return result of inner function", () => { - function* workflow(): any { - const inner = chain(function* () { - return "foo"; - }); - return yield inner() as any; - } - - expect(interpret(workflow() as any, [])).toMatchObject({ - result: Result.resolved("foo"), - commands: [], - }); -}); - -test("should schedule duration", () => { - function* workflow() { - yield duration(10); - } - - expect(interpret(workflow() as any, [])).toMatchObject({ - commands: [createStartTimerCommand(Schedule.duration(10), 0)], - }); -}); - -test("should not re-schedule duration", () => { - function* workflow() { - yield duration(10); - } - - expect(interpret(workflow() as any, [timerScheduled(0)])).toMatchObject(< - WorkflowResult - >{ - commands: [], - }); -}); - -test("should complete duration", () => { - function* workflow() { - yield duration(10); - return "done"; - } - - expect( - interpret(workflow() as any, [timerScheduled(0), timerCompleted(0)]) - ).toMatchObject({ - result: Result.resolved("done"), - commands: [], - }); -}); - -test("should schedule time", () => { - const now = new Date(); - - function* workflow() { - yield time(now); - } - - expect(interpret(workflow() as any, [])).toMatchObject({ - commands: [createStartTimerCommand(Schedule.time(now.toISOString()), 0)], - }); -}); - -test("should not re-schedule time", () => { - const now = new Date(); - - function* workflow() { - yield time(now); - } - - expect(interpret(workflow() as any, [timerScheduled(0)])).toMatchObject(< - WorkflowResult - >{ - commands: [], - }); -}); - -test("should complete time", () => { - const now = new Date(); - - function* workflow() { - yield time(now); - return "done"; - } - - expect( - interpret(workflow() as any, [timerScheduled(0), timerCompleted(0)]) - ).toMatchObject({ - result: Result.resolved("done"), - commands: [], - }); -}); - -describe("temple of doom", () => { - /** - * In our game, the player wants to get to the end of a hallway with traps. - * The trap starts above the player and moves to a space in front of them - * after a time("X"). - * - * If the trap has moved (X time), the player may jump to avoid it. - * If the player jumps when then trap has not moved, they will beheaded. - * If the player runs when the trap has been triggered without jumping, they will have their legs cut off. - * - * The trap is represented by a timer command for X time. - * The player starts running by returning the "run" activity. - * The player jumps be returning the "jump" activity. - * (this would be better modeled with signals and conditions, but the effect is the same, wait, complete) - * - * Jumping after avoid the trap has no effect. - */ - function* workflow() { - let trapDown = false; - let jump = false; - - const startTrap = chain(function* () { - yield createAwaitTimeCall("then"); - trapDown = true; - }); - const waitForJump = chain(function* () { - yield createActivityCall("jump", []); - jump = true; - }); - - startTrap(); - // the player can jump now - waitForJump(); - - yield createActivityCall("run", []); - - if (jump) { - if (!trapDown) { - return "dead: beheaded"; - } - return "alive: party"; - } else { - if (trapDown) { - return "dead: lost your feet"; - } - return "alive"; - } - } - - test("run until blocked", () => { - expect(interpret(workflow() as any, [])).toMatchObject({ - commands: [ - createStartTimerCommand(0), - createScheduledActivityCommand("jump", [], 1), - createScheduledActivityCommand("run", [], 2), - ], - }); - }); - - test("waiting", () => { - expect( - interpret(workflow() as any, [ - timerScheduled(0), - activityScheduled("jump", 1), - activityScheduled("run", 2), - ]) - ).toMatchObject({ - commands: [], - }); - }); - - test("trap triggers, player has not started, nothing happens", () => { - // complete timer, nothing happens - expect( - interpret(workflow() as any, [ - timerScheduled(0), - activityScheduled("jump", 1), - activityScheduled("run", 2), - timerCompleted(0), - ]) - ).toMatchObject({ - commands: [], - }); - }); - - test("trap triggers and then the player starts, player is dead", () => { - // complete timer, turn on, release the player, dead - expect( - interpret(workflow() as any, [ - timerScheduled(0), - activityScheduled("jump", 1), - activityScheduled("run", 2), - timerCompleted(0), - activitySucceeded("anything", 2), - ]) - ).toMatchObject({ - result: Result.resolved("dead: lost your feet"), - commands: [], - }); - }); - - test("trap triggers and then the player starts, player is dead, commands are out of order", () => { - // complete timer, turn on, release the player, dead - expect( - interpret(workflow() as any, [ - timerCompleted(0), - activitySucceeded("anything", 2), - timerScheduled(0), - activityScheduled("jump", 1), - activityScheduled("run", 2), - ]) - ).toMatchObject({ - result: Result.resolved("dead: lost your feet"), - commands: [], - }); - }); - - test("player starts and the trap has not triggered", () => { - // release the player, not on, alive - expect( - interpret(workflow() as any, [ - timerScheduled(0), - activityScheduled("jump", 1), - activityScheduled("run", 2), - activitySucceeded("anything", 2), - ]) - ).toMatchObject({ - result: Result.resolved("alive"), - commands: [], - }); - }); - - test("player starts and the trap has not triggered, completed before activity", () => { - // release the player, not on, alive - expect( - interpret(workflow() as any, [ - timerScheduled(0), - activitySucceeded("anything", 2), - activityScheduled("jump", 1), - activityScheduled("run", 2), - ]) - ).toMatchObject({ - result: Result.resolved("alive"), - commands: [], - }); - }); - - test("player starts and the trap has not triggered, completed before any command", () => { - // release the player, not on, alive - expect( - interpret(workflow() as any, [ - activitySucceeded("anything", 2), - timerScheduled(0), - activityScheduled("jump", 1), - activityScheduled("run", 2), - ]) - ).toMatchObject({ - result: Result.resolved("alive"), - commands: [], - }); - }); - - test("release the player before the trap triggers, player lives", () => { - expect( - interpret(workflow() as any, [ - timerScheduled(0), - activityScheduled("jump", 1), - activityScheduled("run", 2), - activitySucceeded("anything", 2), - timerCompleted(0), - ]) - ).toMatchObject({ - result: Result.resolved("alive"), - commands: [], - }); - }); -}); - -test("should await an un-awaited returned Activity", () => { - function* workflow() { - const inner = chain(function* () { - return "foo"; - }); - return inner(); - } - - expect(interpret(workflow(), [])).toMatchObject({ - result: Result.resolved("foo"), - commands: [], - }); -}); - -describe("AwaitAll", () => { - test("should await an un-awaited returned AwaitAll", () => { - function* workflow() { - let i = 0; - const inner = chain(function* () { - return `foo-${i++}`; - }); - return Eventual.all([inner(), inner()]); - } - - expect(interpret(workflow(), [])).toMatchObject({ - result: Result.resolved(["foo-0", "foo-1"]), - commands: [], - }); - }); - - test("should return constants", () => { - function* workflow() { - return Eventual.all([1 as any, 1 as any]); - } - - expect(interpret(workflow(), [])).toMatchObject({ - result: Result.resolved([1, 1]), - commands: [], - }); - }); - - test("should support already awaited or yielded eventuals ", () => { - function* workflow(): any { - return Eventual.all([ - yield createActivityCall("process-item", []), - yield createActivityCall("process-item", []), - ]); - } - - expect( - interpret(workflow(), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activitySucceeded(1, 0), - activitySucceeded(1, 1), - ]) - ).toMatchObject({ - result: Result.resolved([1, 1]), - commands: [], - }); - }); - - test("should support Eventual.all of function calls", () => { - function* workflow(items: string[]) { - return Eventual.all( - items.map( - chain(function* (item): Program { - return yield createActivityCall("process-item", [item]); - }) - ) - ); - } - - expect(interpret(workflow(["a", "b"]), [])).toMatchObject({ - commands: [ - createScheduledActivityCommand("process-item", ["a"], 0), - createScheduledActivityCommand("process-item", ["b"], 1), - ], - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activitySucceeded("A", 0), - activitySucceeded("B", 1), - ]) - ).toMatchObject({ - result: Result.resolved(["A", "B"]), - }); - }); - - test("should have left-to-right determinism semantics for Eventual.all", () => { - function* workflow(items: string[]) { - return Eventual.all([ - createActivityCall("before", ["before"]), - ...items.map( - chain(function* (item) { - yield createActivityCall("inside", [item]); - }) - ), - createActivityCall("after", ["after"]), - ]); - } - - const result = interpret(workflow(["a", "b"]), []); - expect(result).toMatchObject({ - commands: [ - createScheduledActivityCommand("before", ["before"], 0), - createScheduledActivityCommand("inside", ["a"], 1), - createScheduledActivityCommand("inside", ["b"], 2), - createScheduledActivityCommand("after", ["after"], 3), - ], - }); - }); -}); - -describe("AwaitAny", () => { - test("should await an un-awaited returned AwaitAny", () => { - function* workflow() { - let i = 0; - const inner = chain(function* () { - return `foo-${i++}`; - }); - return Eventual.any([inner(), inner()]); - } - - expect(interpret(workflow(), [])).toMatchObject({ - result: Result.resolved("foo-0"), - commands: [], - }); - }); - - test("should support Eventual.any of function calls", () => { - function* workflow(items: string[]) { - return Eventual.any( - items.map( - chain(function* (item): Program { - return yield createActivityCall("process-item", [item]); - }) - ) - ); - } - - expect(interpret(workflow(["a", "b"]), [])).toMatchObject({ - commands: [ - createScheduledActivityCommand("process-item", ["a"], 0), - createScheduledActivityCommand("process-item", ["b"], 1), - ], - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activitySucceeded("A", 0), - activitySucceeded("B", 1), - ]) - ).toMatchObject({ - result: Result.resolved("A"), - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activitySucceeded("A", 0), - ]) - ).toMatchObject({ - result: Result.resolved("A"), - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activitySucceeded("B", 1), - ]) - ).toMatchObject({ - result: Result.resolved("B"), - }); - }); - - test("should ignore failures when one failed", () => { - function* workflow(items: string[]) { - return Eventual.any( - items.map( - chain(function* (item): Program { - return yield createActivityCall("process-item", [item]); - }) - ) - ); - } - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activityFailed("A", 0), - activitySucceeded("B", 1), - ]) - ).toMatchObject({ - result: Result.resolved("B"), - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activitySucceeded("A", 0), - activityFailed("B", 1), - ]) - ).toMatchObject({ - result: Result.resolved("A"), - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activityFailed("B", 1), - activitySucceeded("A", 0), - ]) - ).toMatchObject({ - result: Result.resolved("A"), - }); - }); - - test("should fail when all fail", () => { - function* workflow(items: string[]) { - return Eventual.any( - items.map( - chain(function* (item): Program { - return yield createActivityCall("process-item", [item]); - }) - ) - ); - } - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activityFailed("A", 0), - ]) - ).toMatchObject({ - result: undefined, - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activityFailed("A", 0), - activityFailed("B", 1), - ]) - ).toMatchObject({ - // @ts-ignore - AggregateError not available? - result: Result.failed(new AggregateError(["A", "B"])), - }); - }); -}); - -describe("Race", () => { - test("should await an un-awaited returned Race", () => { - function* workflow() { - let i = 0; - const inner = chain(function* () { - return `foo-${i++}`; - }); - return Eventual.race([inner(), inner()]); - } - - expect(interpret(workflow(), [])).toMatchObject({ - result: Result.resolved("foo-0"), - commands: [], - }); - }); - - test("should support Eventual.race of function calls", () => { - function* workflow(items: string[]) { - return Eventual.race( - items.map( - chain(function* (item): Program { - return yield createActivityCall("process-item", [item]); - }) - ) - ); - } - - expect(interpret(workflow(["a", "b"]), [])).toMatchObject({ - commands: [ - createScheduledActivityCommand("process-item", ["a"], 0), - createScheduledActivityCommand("process-item", ["b"], 1), - ], - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activitySucceeded("A", 0), - activitySucceeded("B", 1), - ]) - ).toMatchObject({ - result: Result.resolved("A"), - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activitySucceeded("A", 0), - ]) - ).toMatchObject({ - result: Result.resolved("A"), - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activitySucceeded("B", 1), - ]) - ).toMatchObject({ - result: Result.resolved("B"), - }); - }); - - test("should return any settled call", () => { - function* workflow(items: string[]) { - return Eventual.race( - items.map( - chain(function* (item): Program { - return yield createActivityCall("process-item", [item]); - }) - ) - ); - } - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activityFailed("A", 0), - activitySucceeded("B", 1), - ]) - ).toMatchObject({ - result: Result.failed(new EventualError("A").toJSON()), - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activityFailed("B", 1), - ]) - ).toMatchObject({ - result: Result.failed(new EventualError("B").toJSON()), - }); - }); -}); - -describe("AwaitAllSettled", () => { - test("should await an un-awaited returned AwaitAllSettled", () => { - function* workflow() { - let i = 0; - const inner = chain(function* () { - return `foo-${i++}`; - }); - return Eventual.allSettled([inner(), inner()]); - } - - expect(interpret(workflow(), [])).toMatchObject< - WorkflowResult[]> - >({ - result: Result.resolved([ - { status: "fulfilled", value: "foo-0" }, - { status: "fulfilled", value: "foo-1" }, - ]), - commands: [], - }); - }); - - test("should support Eventual.allSettled of function calls", () => { - function* workflow(items: string[]) { - return Eventual.allSettled( - items.map( - chain(function* (item): Program { - return yield createActivityCall("process-item", [item]); - }) - ) - ); - } - - expect(interpret(workflow(["a", "b"]), [])).toMatchObject< - WorkflowResult[]> - >({ - commands: [ - createScheduledActivityCommand("process-item", ["a"], 0), - createScheduledActivityCommand("process-item", ["b"], 1), - ], - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activitySucceeded("A", 0), - activitySucceeded("B", 1), - ]) - ).toMatchObject[]>>({ - result: Result.resolved([ - { status: "fulfilled", value: "A" }, - { status: "fulfilled", value: "B" }, - ]), - commands: [], - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activityFailed("A", 0), - activityFailed("B", 1), - ]) - ).toMatchObject[]>>({ - result: Result.resolved([ - { status: "rejected", reason: new EventualError("A").toJSON() }, - { status: "rejected", reason: new EventualError("B").toJSON() }, - ]), - commands: [], - }); - - expect( - interpret(workflow(["a", "b"]), [ - activityScheduled("process-item", 0), - activityScheduled("process-item", 1), - activityFailed("A", 0), - activitySucceeded("B", 1), - ]) - ).toMatchObject[]>>({ - result: Result.resolved([ - { status: "rejected", reason: new EventualError("A").toJSON() }, - { status: "fulfilled", value: "B" }, - ]), - commands: [], - }); - }); -}); - -test("try-catch-finally with yield in catch", () => { - function* workflow() { - try { - throw new Error("error"); - } catch { - yield createActivityCall("catch", []); - } finally { - yield createActivityCall("finally", []); - } - } - expect(interpret(workflow(), [])).toMatchObject({ - commands: [createScheduledActivityCommand("catch", [], 0)], - }); - expect( - interpret(workflow(), [ - activityScheduled("catch", 0), - activitySucceeded(undefined, 0), - ]) - ).toMatchObject({ - commands: [createScheduledActivityCommand("finally", [], 1)], - }); -}); - -test("try-catch-finally with dangling promise in catch", () => { - expect( - interpret( - (function* () { - try { - throw new Error("error"); - } catch { - createActivityCall("catch", []); - } finally { - yield createActivityCall("finally", []); - } - })(), - [] - ) - ).toMatchObject({ - commands: [ - createScheduledActivityCommand("catch", [], 0), - createScheduledActivityCommand("finally", [], 1), - ], - }); -}); - -test("throw error within nested function", () => { - function* workflow(items: string[]) { - try { - yield Eventual.all( - items.map( - chain(function* (item) { - const result = yield createActivityCall("inside", [item]); - - if (result === "bad") { - throw new Error("bad"); - } - }) - ) - ); - } catch { - yield createActivityCall("catch", []); - return "returned in catch"; // this should be trumped by the finally - } finally { - yield createActivityCall("finally", []); - // eslint-disable-next-line no-unsafe-finally - return "returned in finally"; - } - } - expect(interpret(workflow(["good", "bad"]), [])).toMatchObject(< - WorkflowResult - >{ - commands: [ - createScheduledActivityCommand("inside", ["good"], 0), - createScheduledActivityCommand("inside", ["bad"], 1), - ], - }); - expect( - interpret(workflow(["good", "bad"]), [ - activityScheduled("inside", 0), - activityScheduled("inside", 1), - activitySucceeded("good", 0), - activitySucceeded("bad", 1), - ]) - ).toMatchObject({ - commands: [createScheduledActivityCommand("catch", [], 2)], - }); - expect( - interpret(workflow(["good", "bad"]), [ - activityScheduled("inside", 0), - activityScheduled("inside", 1), - activitySucceeded("good", 0), - activitySucceeded("bad", 1), - activityScheduled("catch", 2), - activitySucceeded("catch", 2), - ]) - ).toMatchObject({ - commands: [createScheduledActivityCommand("finally", [], 3)], - }); - expect( - interpret(workflow(["good", "bad"]), [ - activityScheduled("inside", 0), - activityScheduled("inside", 1), - activitySucceeded("good", 0), - activitySucceeded("bad", 1), - activityScheduled("catch", 2), - activitySucceeded("catch", 2), - activityScheduled("finally", 3), - activitySucceeded("finally", 3), - ]) - ).toMatchObject({ - result: Result.resolved("returned in finally"), - commands: [], - }); -}); - -test("properly evaluate yield* of sub-programs", () => { - function* sub(): any { - const item = yield Eventual.all([ - createActivityCall("a", []), - createActivityCall("b", []), - ]); - - return item; - } - - function* workflow() { - return yield* sub(); - } - - expect(interpret(workflow(), [])).toMatchObject({ - commands: [ - // - createScheduledActivityCommand("a", [], 0), - createScheduledActivityCommand("b", [], 1), - ], - }); - - expect( - interpret(workflow(), [ - activityScheduled("a", 0), - activityScheduled("b", 1), - activitySucceeded("a", 0), - activitySucceeded("b", 1), - ]) - ).toMatchObject({ - result: Result.resolved(["a", "b"]), - commands: [], - }); -}); - -test("properly evaluate yield of Eventual.all", () => { - function* workflow(): any { - const item = yield Eventual.all([ - createActivityCall("a", []), - createActivityCall("b", []), - ]); - - return item; - } - - expect(interpret(workflow(), [])).toMatchObject({ - commands: [ - // - createScheduledActivityCommand("a", [], 0), - createScheduledActivityCommand("b", [], 1), - ], - }); - - expect( - interpret(workflow(), [ - activityScheduled("a", 0), - activityScheduled("b", 1), - activitySucceeded("a", 0), - activitySucceeded("b", 1), - ]) - ).toMatchObject({ - result: Result.resolved(["a", "b"]), - commands: [], - }); -}); - -test("generator function returns an ActivityCall", () => { - function* workflow(): any { - return yield sub(); - } - - const sub = chain(function* () { - return createActivityCall("call-a", []); - }); - - expect(interpret(workflow(), [])).toMatchObject({ - commands: [createScheduledActivityCommand("call-a", [], 0)], - }); - expect( - interpret(workflow(), [ - activityScheduled("call-a", 0), - activitySucceeded("result", 0), - ]) - ).toMatchObject({ - result: Result.resolved("result"), - commands: [], - }); -}); - -test("workflow calling other workflow", () => { - const wf1 = workflow(function* () { - yield createActivityCall("call-a", []); - }); - const wf2 = workflow(function* (): any { - const result = yield createWorkflowCall(wf1.name) as any; - yield createActivityCall("call-b", []); - return result; - }); - - expect(interpret(wf2.definition(undefined, context), [])).toMatchObject({ - commands: [createScheduledWorkflowCommand(wf1.name, undefined, 0)], - }); - - expect( - interpret(wf2.definition(undefined, context), [ - workflowScheduled(wf1.name, 0), - ]) - ).toMatchObject({ - commands: [], - }); - - expect( - interpret(wf2.definition(undefined, context), [ - workflowScheduled(wf1.name, 0), - workflowSucceeded("result", 0), - ]) - ).toMatchObject({ - commands: [createScheduledActivityCommand("call-b", [], 1)], - }); - - expect( - interpret(wf2.definition(undefined, context), [ - workflowScheduled(wf1.name, 0), - workflowSucceeded("result", 0), - activityScheduled("call-b", 1), - ]) - ).toMatchObject({ - commands: [], - }); - - expect( - interpret(wf2.definition(undefined, context), [ - workflowScheduled(wf1.name, 0), - workflowSucceeded("result", 0), - activityScheduled("call-b", 1), - activitySucceeded(undefined, 1), - ]) - ).toMatchObject({ - result: Result.resolved("result"), - commands: [], - }); - - expect( - interpret(wf2.definition(undefined, context), [ - workflowScheduled(wf1.name, 0), - workflowFailed("error", 0), - ]) - ).toMatchObject({ - result: Result.failed(new EventualError("error").toJSON()), - commands: [], - }); -}); - -describe("signals", () => { - describe("expect signal", () => { - const wf = workflow(function* (): any { - const result = yield createExpectSignalCall( - "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") - ); - - return result ?? "done"; - }); - - test("start expect signal", () => { - expect(interpret(wf.definition(undefined, context), [])).toMatchObject(< - WorkflowResult - >{ - commands: [createStartTimerCommand(Schedule.duration(100 * 1000), 0)], - }); - }); - - test("no signal", () => { - expect( - interpret(wf.definition(undefined, context), [timerScheduled(0)]) - ).toMatchObject({ - commands: [], - }); - }); - - test("match signal", () => { - expect( - interpret(wf.definition(undefined, context), [ - timerScheduled(0), - signalReceived("MySignal"), - ]) - ).toMatchObject({ - result: Result.resolved("done"), - commands: [], - }); - }); - - test("match signal with payload", () => { - expect( - interpret(wf.definition(undefined, context), [ - timerScheduled(0), - signalReceived("MySignal", { done: true }), - ]) - ).toMatchObject({ - result: Result.resolved({ done: true }), - commands: [], - }); - }); - - test("timed out", () => { - expect( - interpret(wf.definition(undefined, context), [ - timerScheduled(0), - timerCompleted(0), - ]) - ).toMatchObject({ - result: Result.failed(new Timeout("Expect Signal Timed Out")), - commands: [], - }); - }); - - test("timed out then signal", () => { - expect( - interpret(wf.definition(undefined, context), [ - timerScheduled(0), - timerCompleted(0), - signalReceived("MySignal", { done: true }), - ]) - ).toMatchObject({ - result: Result.failed(new Timeout("Expect Signal Timed Out")), - commands: [], - }); - }); - - test("match signal then timeout", () => { - expect( - interpret(wf.definition(undefined, context), [ - timerScheduled(0), - signalReceived("MySignal"), - timerCompleted(0), - ]) - ).toMatchObject({ - result: Result.resolved("done"), - commands: [], - }); - }); - - test("match signal twice", () => { - expect( - interpret(wf.definition(undefined, context), [ - timerScheduled(0), - signalReceived("MySignal"), - signalReceived("MySignal"), - ]) - ).toMatchObject({ - result: Result.resolved("done"), - commands: [], - }); - }); - - test("multiple of the same signal", () => { - const wf = workflow(function* () { - const wait1 = createExpectSignalCall( - "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") - ); - const wait2 = createExpectSignalCall( - "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") - ); - - return Eventual.all([wait1, wait2]); - }); - - expect( - interpret(wf.definition(undefined, context), [ - timerScheduled(0), - timerScheduled(1), - signalReceived("MySignal", "done!!!"), - ]) - ).toMatchObject({ - result: Result.resolved(["done!!!", "done!!!"]), - commands: [], - }); - }); - - test("expect then timeout", () => { - const wf = workflow(function* (): any { - yield createExpectSignalCall( - "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") - ); - yield createExpectSignalCall( - "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") - ); - }); - - expect( - interpret(wf.definition(undefined, context), [ - timerScheduled(0), - timerCompleted(0), - ]) - ).toMatchObject({ - result: Result.failed({ name: "Timeout" }), - commands: [], - }); - }); - - test("expect random signal then timeout", () => { - const wf = workflow(function* (): any { - yield createExpectSignalCall( - "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") - ); - yield createExpectSignalCall( - "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") - ); - }); - - expect( - interpret(wf.definition(undefined, context), [ - timerScheduled(0), - signalReceived("SomethingElse"), - timerCompleted(0), - ]) - ).toMatchObject({ - result: Result.failed({ name: "Timeout" }), - commands: [], - }); - }); - }); - - describe("signal handler", () => { - const wf = workflow(function* () { - let mySignalHappened = 0; - let myOtherSignalHappened = 0; - let myOtherSignalCompleted = 0; - const mySignalHandler = createRegisterSignalHandlerCall( - "MySignal", - // the transformer will turn this closure into a generator wrapped in chain - chain(function* () { - mySignalHappened++; - }) - ); - const myOtherSignalHandler = createRegisterSignalHandlerCall( - "MyOtherSignal", - function* (payload) { - myOtherSignalHappened++; - yield createActivityCall("act1", [payload]); - myOtherSignalCompleted++; - } - ); - - yield createAwaitTimeCall("then"); - - mySignalHandler.dispose(); - myOtherSignalHandler.dispose(); - - yield createAwaitTimeCall("then"); - - return { - mySignalHappened, - myOtherSignalHappened, - myOtherSignalCompleted, - }; - }); - - test("start", () => { - expect(interpret(wf.definition(undefined, context), [])).toMatchObject(< - WorkflowResult - >{ - commands: [createStartTimerCommand(0)], - }); - }); - - test("send signal, do not wake up", () => { - expect( - interpret(wf.definition(undefined, context), [ - signalReceived("MySignal"), - ]) - ).toMatchObject({ - commands: [createStartTimerCommand(0)], - }); - }); - - test("send signal, wake up", () => { - expect( - interpret(wf.definition(undefined, context), [ - signalReceived("MySignal"), - timerScheduled(0), - timerCompleted(0), - timerScheduled(1), - timerCompleted(1), - ]) - ).toMatchObject({ - result: Result.resolved({ - mySignalHappened: 1, - myOtherSignalHappened: 0, - myOtherSignalCompleted: 0, - }), - commands: [], - }); - }); - - test("send multiple signal, wake up", () => { - expect( - interpret(wf.definition(undefined, context), [ - signalReceived("MySignal"), - signalReceived("MySignal"), - signalReceived("MySignal"), - timerScheduled(0), - timerCompleted(0), - timerScheduled(1), - timerCompleted(1), - ]) - ).toMatchObject({ - result: Result.resolved({ - mySignalHappened: 3, - myOtherSignalHappened: 0, - myOtherSignalCompleted: 0, - }), - commands: [], - }); - }); - - test("send signal after dispose", () => { - expect( - interpret(wf.definition(undefined, context), [ - timerScheduled(0), - timerCompleted(0), - signalReceived("MySignal"), - signalReceived("MySignal"), - signalReceived("MySignal"), - timerScheduled(1), - timerCompleted(1), - ]) - ).toMatchObject({ - result: Result.resolved({ - mySignalHappened: 0, - myOtherSignalHappened: 0, - myOtherSignalCompleted: 0, - }), - commands: [], - }); - }); - - test("send other signal, do not complete", () => { - expect( - interpret(wf.definition(undefined, context), [ - signalReceived("MyOtherSignal", "hi"), - ]) - ).toMatchObject({ - commands: [ - createStartTimerCommand(0), - createScheduledActivityCommand("act1", ["hi"], 1), - ], - }); - }); - - test("send multiple other signal, do not complete", () => { - expect( - interpret(wf.definition(undefined, context), [ - signalReceived("MyOtherSignal", "hi"), - signalReceived("MyOtherSignal", "hi2"), - ]) - ).toMatchObject({ - commands: [ - createStartTimerCommand(0), - createScheduledActivityCommand("act1", ["hi"], 1), - createScheduledActivityCommand("act1", ["hi2"], 2), - ], - }); - }); - - test("send other signal, wake timer, with act scheduled", () => { - expect( - interpret(wf.definition(undefined, context), [ - signalReceived("MyOtherSignal", "hi"), - timerScheduled(0), - timerCompleted(0), - activityScheduled("act1", 1), - timerScheduled(2), - timerCompleted(2), - ]) - ).toMatchObject({ - result: Result.resolved({ - mySignalHappened: 0, - myOtherSignalHappened: 1, - myOtherSignalCompleted: 0, - }), - commands: [], - }); - }); - - test("send other signal, wake timer, complete activity", () => { - expect( - interpret(wf.definition(undefined, context), [ - signalReceived("MyOtherSignal", "hi"), - timerScheduled(0), - activityScheduled("act1", 1), - activitySucceeded("act1", 1), - timerCompleted(0), - timerScheduled(2), - timerCompleted(2), - ]) - ).toMatchObject({ - result: Result.resolved({ - mySignalHappened: 0, - myOtherSignalHappened: 1, - myOtherSignalCompleted: 1, - }), - commands: [], - }); - }); - - test("send other signal, wake timer, complete activity after dispose", () => { - expect( - interpret(wf.definition(undefined, context), [ - signalReceived("MyOtherSignal", "hi"), - timerScheduled(0), - timerCompleted(0), - activityScheduled("act1", 1), - activitySucceeded("act1", 1), - timerScheduled(2), - timerCompleted(2), - ]) - ).toMatchObject({ - result: Result.resolved({ - mySignalHappened: 0, - myOtherSignalHappened: 1, - myOtherSignalCompleted: 1, - }), - commands: [], - }); - }); - - test("send other signal after dispose", () => { - expect( - interpret(wf.definition(undefined, context), [ - timerScheduled(0), - timerCompleted(0), - signalReceived("MyOtherSignal", "hi"), - timerScheduled(1), - timerCompleted(1), - ]) - ).toMatchObject({ - result: Result.resolved({ - mySignalHappened: 0, - myOtherSignalHappened: 0, - myOtherSignalCompleted: 0, - }), - commands: [], - }); - }); - }); - - describe("send signal", () => { - const mySignal = signal("MySignal"); - const wf = workflow(function* (): any { - createSendSignalCall( - { type: SignalTargetType.Execution, executionId: "someExecution/" }, - mySignal.id - ); - - const childWorkflow = createWorkflowCall("childWorkflow"); - - childWorkflow.sendSignal(mySignal); - - return yield childWorkflow; - }); - - test("start", () => { - expect(interpret(wf.definition(undefined, context), [])).toMatchObject(< - WorkflowResult - >{ - commands: [ - createSendSignalCommand( - { - type: SignalTargetType.Execution, - executionId: "someExecution/", - }, - "MySignal", - 0 - ), - createScheduledWorkflowCommand("childWorkflow", undefined, 1), - createSendSignalCommand( - { - type: SignalTargetType.ChildExecution, - workflowName: "childWorkflow", - seq: 1, - }, - "MySignal", - 2 - ), - ], - }); - }); - - test("partial", () => { - expect( - interpret(wf.definition(undefined, context), [ - signalSent("someExec", "MySignal", 0), - ]) - ).toMatchObject({ - commands: [ - createScheduledWorkflowCommand("childWorkflow", undefined, 1), - createSendSignalCommand( - { - type: SignalTargetType.ChildExecution, - workflowName: "childWorkflow", - seq: 1, - }, - "MySignal", - 2 - ), - ], - }); - }); - - test("matching scheduled events", () => { - expect( - interpret(wf.definition(undefined, context), [ - signalSent("someExec", "MySignal", 0), - workflowScheduled("childWorkflow", 1), - signalSent("someExecution/", "MySignal", 2), - ]) - ).toMatchObject({ - commands: [], - }); - }); - - test("complete", () => { - expect( - interpret(wf.definition(undefined, context), [ - signalSent("someExec", "MySignal", 0), - workflowScheduled("childWorkflow", 1), - signalSent("someExecution/", "MySignal", 2), - workflowSucceeded("done", 1), - ]) - ).toMatchObject({ - result: Result.resolved("done"), - commands: [], - }); - }); - - test("yielded sendSignal does nothing", () => { - const wf = workflow(function* (): any { - yield createSendSignalCall( - { type: SignalTargetType.Execution, executionId: "someExecution/" }, - mySignal.id - ); - - const childWorkflow = createWorkflowCall("childWorkflow"); - - yield childWorkflow.sendSignal(mySignal); - - return yield childWorkflow; - }); - - expect( - interpret(wf.definition(undefined, context), [ - signalSent("someExec", "MySignal", 0), - workflowScheduled("childWorkflow", 1), - signalSent("someExecution/", "MySignal", 2), - workflowSucceeded("done", 1), - ]) - ).toMatchObject({ - result: Result.resolved("done"), - commands: [], - }); - }); - }); -}); - -describe("condition", () => { - test("already true condition does not emit events", () => { - const wf = workflow(function* (): any { - yield createConditionCall(() => true); - }); - - expect( - interpret(wf.definition(undefined, context), []) - ).toMatchObject({ - commands: [], - }); - }); - - test("false condition emits events", () => { - const wf = workflow(function* (): any { - yield createConditionCall(() => false); - }); - - expect( - interpret(wf.definition(undefined, context), []) - ).toMatchObject({ - commands: [], - }); - }); - - test("false condition emits events with timeout", () => { - const wf = workflow(function* (): any { - yield createConditionCall( - () => false, - createAwaitDurationCall(100, "seconds") - ); - }); - - expect( - interpret(wf.definition(undefined, context), []) - ).toMatchObject({ - commands: [createStartTimerCommand(Schedule.duration(100), 0)], - }); - }); - - test("false condition does not re-emit", () => { - const wf = workflow(function* (): any { - yield createConditionCall( - () => false, - createAwaitDurationCall(100, "seconds") - ); - }); - - expect( - interpret(wf.definition(undefined, context), [timerScheduled(0)]) - ).toMatchObject({ - commands: [], - }); - }); - - const signalConditionFlow = workflow(function* (): any { - let yes = false; - createRegisterSignalHandlerCall("Yes", () => { - yes = true; - }); - if ( - !(yield createConditionCall( - () => yes, - createAwaitDurationCall(100, "seconds") - ) as any) - ) { - return "timed out"; - } - return "done"; - }); - - test("trigger success", () => { - expect( - interpret(signalConditionFlow.definition(undefined, context), [ - timerScheduled(0), - signalReceived("Yes"), - ]) - ).toMatchObject({ - result: Result.resolved("done"), - commands: [], - }); - }); - - test("trigger success eventually", () => { - expect( - interpret(signalConditionFlow.definition(undefined, context), [ - timerScheduled(0), - signalReceived("No"), - signalReceived("No"), - signalReceived("No"), - signalReceived("No"), - signalReceived("Yes"), - ]) - ).toMatchObject({ - result: Result.resolved("done"), - commands: [], - }); - }); - - test("never trigger when state changes", () => { - const signalConditionOnAndOffFlow = workflow(function* (): any { - let yes = false; - createRegisterSignalHandlerCall("Yes", () => { - yes = true; - }); - createRegisterSignalHandlerCall("Yes", () => { - yes = false; - }); - yield createConditionCall(() => yes); - return "done"; - }); - - expect( - interpret(signalConditionOnAndOffFlow.definition(undefined, context), [ - signalReceived("Yes"), - ]) - ).toMatchObject({ - commands: [], - }); - }); - - test("trigger timeout", () => { - expect( - interpret(signalConditionFlow.definition(undefined, context), [ - timerScheduled(0), - timerCompleted(0), - ]) - ).toMatchObject({ - result: Result.resolved("timed out"), - commands: [], - }); - }); - - test("trigger success before timeout", () => { - expect( - interpret(signalConditionFlow.definition(undefined, context), [ - timerScheduled(0), - signalReceived("Yes"), - timerCompleted(0), - ]) - ).toMatchObject({ - result: Result.resolved("done"), - commands: [], - }); - }); - - test("trigger timeout before success", () => { - expect( - interpret(signalConditionFlow.definition(undefined, context), [ - timerScheduled(0), - timerCompleted(0), - signalReceived("Yes"), - ]) - ).toMatchObject({ - result: Result.resolved("timed out"), - commands: [], - }); - }); - - test("condition as simple generator", () => { - const wf = workflow(function* (): any { - yield createConditionCall(() => false); - return "done"; - }); - - expect( - interpret(wf.definition(undefined, context), []) - ).toMatchObject({ - commands: [], - }); - }); -}); - -test("nestedChains", () => { - const wf = workflow(function* () { - const funcs = { - a: chain(function* () { - yield createAwaitTimeCall("then"); - }), - }; - - Object.fromEntries( - yield createAwaitAll( - Object.entries(funcs).map( - chain(function* ([name, func]) { - return [name, yield func()]; - }) - ) - ) - ); - }); - - expect( - interpret(wf.definition(undefined, context), []) - ).toMatchObject({ - commands: [createStartTimerCommand(0)], - }); -}); - -test("mixing closure types", () => { - const workflow4 = workflow(function* () { - const greetings = Eventual.all( - ["sam", "chris", "sam"].map((name) => createActivityCall("hello", [name])) - ); - const greetings2 = Eventual.all( - ["sam", "chris", "sam"].map( - chain(function* (name) { - const greeting = yield createActivityCall("hello", [name]); - return greeting * 2; - }) - ) - ); - const greetings3 = Eventual.all([ - createActivityCall("hello", ["sam"]), - createActivityCall("hello", ["chris"]), - createActivityCall("hello", ["sam"]), - ]); - return Eventual.all([greetings as any, greetings2, greetings3]); - }); - - expect( - interpret(workflow4.definition(undefined, context), []) - ).toEqual({ - commands: [ - createScheduledActivityCommand("hello", ["sam"], 0), - createScheduledActivityCommand("hello", ["chris"], 1), - createScheduledActivityCommand("hello", ["sam"], 2), - createScheduledActivityCommand("hello", ["sam"], 3), - createScheduledActivityCommand("hello", ["chris"], 4), - createScheduledActivityCommand("hello", ["sam"], 5), - createScheduledActivityCommand("hello", ["sam"], 6), - createScheduledActivityCommand("hello", ["chris"], 7), - createScheduledActivityCommand("hello", ["sam"], 8), - ], - }); - - expect( - interpret(workflow4.definition(undefined, context), [ - activityScheduled("hello", 0), - activityScheduled("hello", 1), - activityScheduled("hello", 2), - activityScheduled("hello", 3), - activityScheduled("hello", 4), - activityScheduled("hello", 5), - activityScheduled("hello", 6), - activityScheduled("hello", 7), - activityScheduled("hello", 8), - ]) - ).toEqual({ - commands: [], - }); - - expect( - interpret(workflow4.definition(undefined, context), [ - activityScheduled("hello", 0), - activityScheduled("hello", 1), - activityScheduled("hello", 2), - activityScheduled("hello", 3), - activityScheduled("hello", 4), - activityScheduled("hello", 5), - activityScheduled("hello", 6), - activityScheduled("hello", 7), - activityScheduled("hello", 8), - activitySucceeded(1, 0), - activitySucceeded(2, 1), - activitySucceeded(3, 2), - activitySucceeded(4, 3), - activitySucceeded(5, 4), - activitySucceeded(6, 5), - activitySucceeded(7, 6), - activitySucceeded(8, 7), - activitySucceeded(9, 8), - ]) - ).toEqual({ - result: Result.resolved([ - [1, 2, 3], - [8, 10, 12], - [7, 8, 9], - ]), - commands: [], - }); -}); - -test("workflow with synchronous function", () => { - const workflow4 = workflow(function (): any { - return createActivityCall("hi", []); - }); - - expect( - interpret(workflow4.definition(undefined, context), []) - ).toEqual({ - commands: [createScheduledActivityCommand("hi", [], 0)], - }); - - expect( - interpret(workflow4.definition(undefined, context), [ - activityScheduled("hi", 0), - activitySucceeded("result", 0), - ]) - ).toEqual({ - result: Result.resolved("result"), - commands: [], - }); -}); - -test("publish event", () => { - const wf = workflow(function* () { - yield createPublishEventsCall([ - { - name: "event-type", - event: { - key: "value", - }, - }, - ]); - - return "done!"; - }); - - const events = [ - { - name: "event-type", - event: { - key: "value", - }, - }, - ]; - - expect( - interpret(wf.definition(undefined, context), []) - ).toEqual({ - // promise should be instantly resolved - result: Result.resolved("done!"), - commands: [createPublishEventCommand(events, 0)], - }); - - expect( - interpret(wf.definition(undefined, context), [eventsPublished(events, 0)]) - ).toEqual({ - // promise should be instantly resolved - result: Result.resolved("done!"), - commands: [], - }); -}); diff --git a/packages/@eventual/core-runtime/test/workflow-executor.test.ts b/packages/@eventual/core-runtime/test/workflow-executor.test.ts new file mode 100644 index 000000000..5d0b2ba4d --- /dev/null +++ b/packages/@eventual/core-runtime/test/workflow-executor.test.ts @@ -0,0 +1,2624 @@ +/* eslint-disable require-await, no-throw-literal */ +import { + duration, + EventualError, + HeartbeatTimeout, + Schedule, + signal, + time, + Timeout, + Workflow, + workflow as _workflow, + WorkflowContext, + WorkflowHandler, + WorkflowInput, +} from "@eventual/core"; +import { + createActivityCall, + createAwaitDurationCall, + createAwaitTimeCall, + createConditionCall, + createExpectSignalCall, + createPublishEventsCall, + createRegisterSignalHandlerCall, + createSendSignalCall, + createWorkflowCall, + HistoryEvent, + Result, + ServiceType, + SERVICE_TYPE_FLAG, + SignalTargetType, +} from "@eventual/core/internal"; +import { + activityFailed, + activityHeartbeatTimedOut, + activityScheduled, + activitySucceeded, + createPublishEventCommand, + createScheduledActivityCommand, + createScheduledWorkflowCommand, + createSendSignalCommand, + createStartTimerCommand, + eventsPublished, + signalReceived, + signalSent, + timerCompleted, + timerScheduled, + workflowFailed, + workflowScheduled, + workflowSucceeded, + workflowTimedOut, +} from "./command-util.js"; + +import { WorkflowExecutor, WorkflowResult } from "../src/workflow-executor.js"; +import "../src/workflow.js"; + +const event = "hello world"; + +const context: WorkflowContext = { + workflow: { + name: "wf1", + }, + execution: { + id: "123/", + name: "wf1#123", + startTime: "", + }, +}; + +const workflow = (() => { + let n = 0; + return ( + handler: WorkflowHandler + ) => { + return _workflow(`wf${n++}`, handler); + }; +})(); + +const myWorkflow = workflow(async (event) => { + try { + const a = await createActivityCall("my-activity", [event]); + + // dangling - it should still be scheduled + createActivityCall("my-activity-0", [event]); + + const all = (await Promise.all([ + createAwaitTimeCall("then"), + createActivityCall("my-activity-2", [event]), + ])) as any; + return [a, all]; + } catch (err) { + await createActivityCall("handle-error", [err]); + return []; + } +}); + +const serviceTypeBack = process.env[SERVICE_TYPE_FLAG]; +beforeAll(() => { + process.env[SERVICE_TYPE_FLAG] = ServiceType.OrchestratorWorker; +}); + +afterAll(() => { + process.env[SERVICE_TYPE_FLAG] = serviceTypeBack; +}); + +async function execute( + workflow: W, + history: HistoryEvent[], + input: WorkflowInput +) { + const executor = new WorkflowExecutor(workflow, history); + return executor.start(input, context); +} + +test("no history", async () => { + await expect(execute(myWorkflow, [], event)).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [createScheduledActivityCommand("my-activity", [event], 0)], + }); +}); + +test("should continue with result of completed Activity", async () => { + await expect( + execute( + myWorkflow, + [activityScheduled("my-activity", 0), activitySucceeded("result", 0)], + event + ) + ).resolves.toMatchObject({ + commands: [ + createScheduledActivityCommand("my-activity-0", [event], 1), + createStartTimerCommand(2), + createScheduledActivityCommand("my-activity-2", [event], 3), + ], + }); +}); + +test("should fail on workflow timeout event", async () => { + await expect( + execute( + myWorkflow, + [activityScheduled("my-activity", 0), workflowTimedOut()], + event + ) + ).resolves.toMatchObject({ + result: Result.failed(new Timeout("Workflow timed out")), + commands: [], + }); +}); + +test("should not continue on workflow timeout event", async () => { + await expect( + execute( + myWorkflow, + [ + activityScheduled("my-activity", 0), + workflowTimedOut(), + activitySucceeded("result", 0), + ], + event + ) + ).resolves.toMatchObject({ + result: Result.failed(new Timeout("Workflow timed out")), + commands: [], + }); +}); + +test("should catch error of failed Activity", async () => { + await expect( + execute( + myWorkflow, + [activityScheduled("my-activity", 0), activityFailed("error", 0)], + event + ) + ).resolves.toMatchObject({ + commands: [ + createScheduledActivityCommand( + "handle-error", + [new EventualError("error").toJSON()], + 1 + ), + ], + }); +}); + +test("should catch error of timing out Activity", async () => { + const myWorkflow = workflow(async (event) => { + try { + const a = await createActivityCall( + "my-activity", + [event], + createAwaitTimeCall("") + ); + + return a; + } catch (err) { + await createActivityCall("handle-error", [err]); + return []; + } + }); + + await expect( + execute( + myWorkflow, + [ + timerScheduled(0), + timerCompleted(0), + activityScheduled("my-activity", 1), + ], + event + ) + ).resolves.toMatchObject({ + commands: [ + createScheduledActivityCommand( + "handle-error", + [new Timeout("Activity Timed Out")], + 2 + ), + ], + }); +}); + +test("immediately abort activity on invalid timeout", async () => { + const myWorkflow = workflow((event) => { + return createActivityCall("my-activity", [event], "not a promise" as any); + }); + + await expect( + execute(myWorkflow, [activityScheduled("my-activity", 0)], event) + ).resolves.toMatchObject({ + result: Result.failed(new Timeout("Activity Timed Out")), + }); +}); + +test("timeout multiple activities at once", async () => { + const myWorkflow = workflow(async (event) => { + const time = createAwaitTimeCall(""); + const a = createActivityCall("my-activity", [event], time); + const b = createActivityCall("my-activity", [event], time); + + return Promise.allSettled([a, b]); + }); + + await expect( + execute( + myWorkflow, + [ + timerScheduled(0), + activityScheduled("my-activity", 1), + activityScheduled("my-activity", 2), + timerCompleted(0), + ], + event + ) + ).resolves.toMatchObject({ + result: Result.resolved([ + { + status: "rejected", + reason: new Timeout("Activity Timed Out").toJSON(), + }, + { + status: "rejected", + reason: new Timeout("Activity Timed Out").toJSON(), + }, + ]), + commands: [], + }); +}); + +test("activity times out activity", async () => { + const myWorkflow = workflow(async (event) => { + const z = createActivityCall("my-activity", [event]); + const a = createActivityCall("my-activity", [event], z); + const b = createActivityCall("my-activity", [event], a); + + return Promise.allSettled([z, a, b]); + }); + + await expect( + execute( + myWorkflow, + [ + activityScheduled("my-activity", 0), + activityScheduled("my-activity", 1), + activityScheduled("my-activity", 2), + activitySucceeded("woo", 0), + ], + event + ) + ).resolves.toMatchObject({ + result: Result.resolved([ + { + status: "fulfilled", + value: "woo", + }, + { + status: "rejected", + reason: new Timeout("Activity Timed Out").toJSON(), + }, + { + status: "rejected", + reason: new Timeout("Activity Timed Out").toJSON(), + }, + ]), + commands: [], + }); +}); + +test("should return final result", async () => { + await expect( + execute( + myWorkflow, + [ + activityScheduled("my-activity", 0), + activitySucceeded("result", 0), + activityScheduled("my-activity-0", 1), + timerScheduled(2), + activityScheduled("my-activity-2", 3), + activitySucceeded("result-0", 1), + timerCompleted(2), + activitySucceeded("result-2", 3), + ], + event + ) + ).resolves.toMatchObject({ + result: Result.resolved(["result", [undefined, "result-2"]]), + commands: [], + }); +}); + +test("should handle missing blocks", async () => { + await expect( + execute(myWorkflow, [activitySucceeded("result", 0)], event) + ).resolves.toMatchObject({ + commands: [ + createScheduledActivityCommand("my-activity", [event], 0), + createScheduledActivityCommand("my-activity-0", [event], 1), + createStartTimerCommand(2), + createScheduledActivityCommand("my-activity-2", [event], 3), + ], + }); +}); + +test("should handle partial blocks", async () => { + await expect( + execute( + myWorkflow, + [ + activityScheduled("my-activity", 0), + activitySucceeded("result", 0), + activityScheduled("my-activity-0", 1), + ], + event + ) + ).resolves.toMatchObject({ + commands: [ + createStartTimerCommand(2), + createScheduledActivityCommand("my-activity-2", [event], 3), + ], + }); +}); + +test("should handle partial blocks with partial completes", async () => { + await expect( + execute( + myWorkflow, + [ + activityScheduled("my-activity", 0), + activitySucceeded("result", 0), + activityScheduled("my-activity-0", 1), + activitySucceeded("result", 1), + ], + event + ) + ).resolves.toMatchObject({ + commands: [ + createStartTimerCommand(2), + createScheduledActivityCommand("my-activity-2", [event], 3), + ], + }); +}); + +test("await constant", async () => { + const testWorkflow = workflow(async () => { + return await 1; + }); + + await expect(execute(testWorkflow, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + result: Result.resolved(1), + }); +}); +7; +describe("activity", () => { + describe("heartbeat", () => { + const wf = workflow(() => { + return createActivityCall( + "getPumpedUp", + [], + undefined, + Schedule.duration(100) + ); + }); + + test("timeout from heartbeat seconds", async () => { + await expect( + execute( + wf, + [ + activityScheduled("getPumpedUp", 0), + activityHeartbeatTimedOut(0, 101), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.failed( + new HeartbeatTimeout("Activity Heartbeat TimedOut") + ), + commands: [], + }); + }); + + test("timeout after complete", async () => { + await expect( + execute( + wf, + [ + activityScheduled("getPumpedUp", 0), + activitySucceeded("done", 0), + activityHeartbeatTimedOut(0, 1000), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("done"), + commands: [], + }); + }); + + test("catch heartbeat timeout", async () => { + const wf = workflow(async () => { + try { + const result = await createActivityCall( + "getPumpedUp", + [], + undefined, + Schedule.duration(1) + ); + return result; + } catch (err) { + if (err instanceof HeartbeatTimeout) { + return err.message; + } + return "no"; + } + }); + + await expect( + execute( + wf, + [ + activityScheduled("getPumpedUp", 0), + activityHeartbeatTimedOut(0, 10), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("Activity Heartbeat TimedOut"), + commands: [], + }); + }); + }); +}); + +test("should throw when scheduled does not correspond to call", async () => { + await expect( + execute(myWorkflow, [timerScheduled(0)], event) + ).resolves.toMatchObject({ + result: Result.failed({ name: "DeterminismError" }), + commands: [], + }); +}); + +test("should throw when there are more schedules than calls emitted", async () => { + await expect( + execute( + myWorkflow, + [activityScheduled("my-activity", 0), activityScheduled("result", 1)], + event + ) + ).resolves.toMatchObject({ + result: Result.failed({ name: "DeterminismError" }), + commands: [], + }); +}); + +test("should throw when a completed precedes workflow state", async () => { + await expect( + execute( + myWorkflow, + [ + activityScheduled("my-activity", 0), + activityScheduled("result", 1), + // the workflow does not return a seq: 2, where does this go? + // note: a completed event can be accepted without a "scheduled" counterpart, + // but the workflow must resolve the schedule before the complete + // is applied. + activitySucceeded("", 2), + ], + event + ) + ).resolves.toMatchObject({ + result: Result.failed({ name: "DeterminismError" }), + commands: [], + }); +}); + +test("should fail the workflow on uncaught user error", async () => { + const wf = workflow(() => { + throw new Error("Hi"); + }); + + await expect( + execute(wf, [], undefined) + ).resolves.toMatchObject({ + result: Result.failed({ name: "Error", message: "Hi" }), + commands: [], + }); +}); + +test("should fail the workflow on uncaught user error of random type", async () => { + const wf = workflow(() => { + throw new TypeError("Hi"); + }); + + await expect( + execute(wf, [], undefined) + ).resolves.toMatchObject({ + result: Result.failed({ name: "TypeError", message: "Hi" }), + commands: [], + }); +}); + +test("should fail the workflow on uncaught thrown value", async () => { + const wf = workflow(() => { + throw "hi"; + }); + await expect( + execute(wf, [], undefined) + ).resolves.toMatchObject({ + result: Result.failed("hi"), + commands: [], + }); +}); + +test("should wait if partial results", async () => { + await expect( + execute( + myWorkflow, + [ + activityScheduled("my-activity", 0), + activitySucceeded("result", 0), + activityScheduled("my-activity-0", 1), + timerScheduled(2), + activityScheduled("my-activity-2", 3), + activitySucceeded("result-0", 1), + timerCompleted(2), + ], + event + ) + ).resolves.toMatchObject({ + commands: [], + }); +}); + +test("should return result of inner function", async () => { + const wf = workflow(async () => { + const inner = async () => { + return "foo"; + }; + + return await inner(); + }); + + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + result: Result.resolved("foo"), + commands: [], + }); +}); + +test("should schedule duration", async () => { + const wf = workflow(async () => { + await duration(10); + }); + + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [createStartTimerCommand(Schedule.duration(10), 0)], + }); +}); + +test("should not re-schedule duration", async () => { + const wf = workflow(async () => { + await duration(10); + }); + + await expect( + execute(wf, [timerScheduled(0)], undefined) + ).resolves.toMatchObject({ + commands: [], + }); +}); + +test("should complete duration", async () => { + const wf = workflow(async () => { + await duration(10); + return "done"; + }); + + await expect( + execute(wf, [timerScheduled(0), timerCompleted(0)], undefined) + ).resolves.toMatchObject({ + result: Result.resolved("done"), + commands: [], + }); +}); + +test("should schedule time", async () => { + const now = new Date(); + + const wf = workflow(async () => { + await time(now); + }); + + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [createStartTimerCommand(Schedule.time(now.toISOString()), 0)], + }); +}); + +test("should not re-schedule time", async () => { + const now = new Date(); + + const wf = workflow(async () => { + await time(now); + }); + + await expect( + execute(wf, [timerScheduled(0)], undefined) + ).resolves.toMatchObject({ + commands: [], + }); +}); + +test("should complete time", async () => { + const now = new Date(); + + const wf = workflow(async () => { + await time(now); + return "done"; + }); + + await expect( + execute(wf, [timerScheduled(0), timerCompleted(0)], undefined) + ).resolves.toMatchObject({ + result: Result.resolved("done"), + commands: [], + }); +}); + +describe("temple of doom", () => { + /** + * In our game, the player wants to get to the end of a hallway with traps. + * The trap starts above the player and moves to a space in front of them + * after a time("X"). + * + * If the trap has moved (X time), the player may jump to avoid it. + * If the player jumps when then trap has not moved, they will beheaded. + * If the player runs when the trap has been triggered without jumping, they will have their legs cut off. + * + * The trap is represented by a timer command for X time. + * The player starts running by returning the "run" activity. + * The player jumps be returning the "jump" activity. + * (this would be better modeled with signals and conditions, but the effect is the same, wait, complete) + * + * Jumping after avoid the trap has no effect. + */ + const doomWf = workflow(async () => { + let trapDown = false; + let jump = false; + + async function startTrap() { + await createAwaitTimeCall("then"); + trapDown = true; + } + + async function waitForJump() { + await createActivityCall("jump", []); + jump = true; + } + + startTrap(); + // the player can jump now + waitForJump(); + + await createActivityCall("run", []); + + if (jump) { + if (!trapDown) { + return "dead: beheaded"; + } + return "alive: party"; + } else { + if (trapDown) { + return "dead: lost your feet"; + } + return "alive"; + } + }); + + test("run until blocked", async () => { + await expect(execute(doomWf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [ + createStartTimerCommand(0), + createScheduledActivityCommand("jump", [], 1), + createScheduledActivityCommand("run", [], 2), + ], + }); + }); + + test("waiting", async () => { + await expect( + execute( + doomWf, + [ + timerScheduled(0), + activityScheduled("jump", 1), + activityScheduled("run", 2), + ], + undefined + ) + ).resolves.toMatchObject({ + commands: [], + }); + }); + + test("trap triggers, player has not started, nothing happens", async () => { + // complete timer, nothing happens + await expect( + execute( + doomWf, + [ + timerScheduled(0), + activityScheduled("jump", 1), + activityScheduled("run", 2), + timerCompleted(0), + ], + undefined + ) + ).resolves.toMatchObject({ + commands: [], + }); + }); + + test("trap triggers and then the player starts, player is dead", async () => { + // complete timer, turn on, release the player, dead + await expect( + execute( + doomWf, + [ + timerScheduled(0), + activityScheduled("jump", 1), + activityScheduled("run", 2), + timerCompleted(0), + activitySucceeded("anything", 2), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("dead: lost your feet"), + commands: [], + }); + }); + + test("trap triggers and then the player starts, player is dead, commands are out of order", async () => { + // complete timer, turn on, release the player, dead + await expect( + execute( + doomWf, + [ + timerCompleted(0), + activitySucceeded("anything", 2), + timerScheduled(0), + activityScheduled("jump", 1), + activityScheduled("run", 2), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("dead: lost your feet"), + commands: [], + }); + }); + + test("player starts and the trap has not triggered", async () => { + // release the player, not on, alive + await expect( + execute( + doomWf, + [ + timerScheduled(0), + activityScheduled("jump", 1), + activityScheduled("run", 2), + activitySucceeded("anything", 2), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("alive"), + commands: [], + }); + }); + + test("player starts and the trap has not triggered, completed before activity", async () => { + // release the player, not on, alive + await expect( + execute( + doomWf, + [ + timerScheduled(0), + activitySucceeded("anything", 2), + activityScheduled("jump", 1), + activityScheduled("run", 2), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("alive"), + commands: [], + }); + }); + + test("player starts and the trap has not triggered, completed before any command", async () => { + // release the player, not on, alive + await expect( + execute( + doomWf, + [ + activitySucceeded("anything", 2), + timerScheduled(0), + activityScheduled("jump", 1), + activityScheduled("run", 2), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("alive"), + commands: [], + }); + }); + + test("release the player before the trap triggers, player lives", async () => { + await expect( + execute( + doomWf, + [ + timerScheduled(0), + activityScheduled("jump", 1), + activityScheduled("run", 2), + activitySucceeded("anything", 2), + timerCompleted(0), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("alive"), + commands: [], + }); + }); +}); + +test("should await an un-awaited returned Activity", async () => { + const wf = workflow(async () => { + async function inner() { + return "foo"; + } + return inner(); + }); + + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + result: Result.resolved("foo"), + commands: [], + }); +}); + +describe("AwaitAll", () => { + test("should await an un-awaited returned AwaitAll", async () => { + const wf = workflow(() => { + let i = 0; + function inner() { + return `foo-${i++}`; + } + return Promise.all([inner(), inner()]); + }); + + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + result: Result.resolved(["foo-0", "foo-1"]), + commands: [], + }); + }); + + test("should return constants", async () => { + const wf = workflow(() => { + return Promise.all([1 as any, 1 as any]); + }); + + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + result: Result.resolved([1, 1]), + commands: [], + }); + }); + + test("should support already awaited or awaited eventuals", async () => { + const wf = workflow(async () => { + return Promise.all([ + await createActivityCall("process-item", []), + await createActivityCall("process-item", []), + ]); + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activitySucceeded(1, 0), + activitySucceeded(1, 1), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved([1, 1]), + commands: [], + }); + }); + + test("should support Promise.all of function calls", async () => { + const wf = workflow(async (items: string[]) => { + return Promise.all( + items.map(async (item) => { + return await createActivityCall("process-item", [item]); + }) + ); + }); + + await expect(execute(wf, [], ["a", "b"])).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [ + createScheduledActivityCommand("process-item", ["a"], 0), + createScheduledActivityCommand("process-item", ["b"], 1), + ], + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activitySucceeded("A", 0), + activitySucceeded("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.resolved(["A", "B"]), + }); + }); + + test("should have left-to-right determinism semantics for Promise.all", async () => { + const wf = workflow(async (items: string[]) => { + return Promise.all([ + createActivityCall("before", ["before"]), + ...items.map(async (item) => { + await createActivityCall("inside", [item]); + }), + createActivityCall("after", ["after"]), + ]); + }); + + await expect(execute(wf, [], ["a", "b"])).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [ + createScheduledActivityCommand("before", ["before"], 0), + createScheduledActivityCommand("inside", ["a"], 1), + createScheduledActivityCommand("inside", ["b"], 2), + createScheduledActivityCommand("after", ["after"], 3), + ], + }); + }); +}); + +describe("AwaitAny", () => { + test("should await an un-awaited returned AwaitAny", async () => { + const wf = workflow(async () => { + let i = 0; + function inner() { + return `foo-${i++}`; + } + return Promise.any([inner(), inner()]); + }); + + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + result: Result.resolved("foo-0"), + commands: [], + }); + }); + + test("should support Promise.any of function calls", async () => { + const wf = workflow(async (items: string[]) => { + return Promise.any( + items.map(async (item) => { + return await createActivityCall("process-item", [item]); + }) + ); + }); + + await expect(execute(wf, [], ["a", "b"])).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [ + createScheduledActivityCommand("process-item", ["a"], 0), + createScheduledActivityCommand("process-item", ["b"], 1), + ], + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activitySucceeded("A", 0), + activitySucceeded("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.resolved("A"), + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activitySucceeded("A", 0), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.resolved("A"), + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activitySucceeded("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.resolved("B"), + }); + }); + + test("should ignore failures when one failed", async () => { + const wf = workflow(async (items: string[]) => { + return Promise.any( + items.map(async (item) => { + return await createActivityCall("process-item", [item]); + }) + ); + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activityFailed("A", 0), + activitySucceeded("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.resolved("B"), + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activitySucceeded("A", 0), + activityFailed("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.resolved("A"), + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activityFailed("B", 1), + activitySucceeded("A", 0), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.resolved("A"), + }); + }); + + test("should fail when all fail", async () => { + const wf = workflow(async (items: string[]) => { + return Promise.any( + items.map(async (item) => { + return await createActivityCall("process-item", [item]); + }) + ); + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activityFailed("A", 0), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: undefined, + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activityFailed("A", 0), + activityFailed("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + // @ts-ignore - AggregateError not available? + result: Result.failed( + new AggregateError(["A", "B"], "All promises were rejected") + ), + }); + }); +}); + +describe("Race", () => { + test("should await an un-awaited returned Race", async () => { + const wf = workflow(async () => { + let i = 0; + async function inner() { + return `foo-${i++}`; + } + return Promise.race([inner(), inner()]); + }); + + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + result: Result.resolved("foo-0"), + commands: [], + }); + }); + + test("should support Promise.race of function calls", async () => { + const wf = workflow(async (items: string[]) => { + return Promise.race( + items.map(async (item) => { + return await createActivityCall("process-item", [item]); + }) + ); + }); + + await expect(execute(wf, [], ["a", "b"])).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [ + createScheduledActivityCommand("process-item", ["a"], 0), + createScheduledActivityCommand("process-item", ["b"], 1), + ], + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activitySucceeded("A", 0), + activitySucceeded("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.resolved("A"), + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activitySucceeded("A", 0), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.resolved("A"), + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activitySucceeded("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.resolved("B"), + }); + }); + + test("should return any settled call", async () => { + const wf = workflow(async (items: string[]) => { + return Promise.race( + items.map(async (item) => { + return await createActivityCall("process-item", [item]); + }) + ); + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activityFailed("A", 0), + activitySucceeded("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.failed(new EventualError("A").toJSON()), + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activityFailed("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject({ + result: Result.failed(new EventualError("B").toJSON()), + }); + }); +}); + +describe("AwaitAllSettled", () => { + test("should await an un-awaited returned AwaitAllSettled", async () => { + const wf = workflow(async () => { + let i = 0; + async function inner() { + return `foo-${i++}`; + } + return Promise.allSettled([inner(), inner()]); + }); + + await expect(execute(wf, [], undefined)).resolves.toMatchObject< + WorkflowResult[]> + >({ + result: Result.resolved([ + { status: "fulfilled", value: "foo-0" }, + { status: "fulfilled", value: "foo-1" }, + ]), + commands: [], + }); + }); + + test("should support Promise.allSettled of function calls", async () => { + const wf = workflow(async (items: string[]) => { + return Promise.allSettled( + items.map(async (item) => { + return await createActivityCall("process-item", [item]); + }) + ); + }); + + await expect(execute(wf, [], ["a", "b"])).resolves.toMatchObject< + WorkflowResult[]> + >({ + commands: [ + createScheduledActivityCommand("process-item", ["a"], 0), + createScheduledActivityCommand("process-item", ["b"], 1), + ], + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activitySucceeded("A", 0), + activitySucceeded("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject[]>>({ + result: Result.resolved([ + { status: "fulfilled", value: "A" }, + { status: "fulfilled", value: "B" }, + ]), + commands: [], + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activityFailed("A", 0), + activityFailed("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject[]>>({ + result: Result.resolved([ + { status: "rejected", reason: new EventualError("A").toJSON() }, + { status: "rejected", reason: new EventualError("B").toJSON() }, + ]), + commands: [], + }); + + await expect( + execute( + wf, + [ + activityScheduled("process-item", 0), + activityScheduled("process-item", 1), + activityFailed("A", 0), + activitySucceeded("B", 1), + ], + ["a", "b"] + ) + ).resolves.toMatchObject[]>>({ + result: Result.resolved([ + { status: "rejected", reason: new EventualError("A").toJSON() }, + { status: "fulfilled", value: "B" }, + ]), + commands: [], + }); + }); +}); + +test("try-catch-finally with await in catch", async () => { + const wf = workflow(async () => { + try { + throw new Error("error"); + } catch { + await createActivityCall("catch", []); + } finally { + await createActivityCall("finally", []); + } + }); + expect(execute(wf, [], undefined)).resolves.toMatchObject({ + commands: [createScheduledActivityCommand("catch", [], 0)], + }); + expect( + execute( + wf, + [activityScheduled("catch", 0), activitySucceeded(undefined, 0)], + undefined + ) + ).resolves.toMatchObject({ + commands: [createScheduledActivityCommand("finally", [], 1)], + }); +}); + +test("try-catch-finally with dangling promise in catch", async () => { + expect( + execute( + workflow(async () => { + try { + throw new Error("error"); + } catch { + createActivityCall("catch", []); + } finally { + await createActivityCall("finally", []); + } + }), + [], + undefined + ) + ).resolves.toMatchObject({ + commands: [ + createScheduledActivityCommand("catch", [], 0), + createScheduledActivityCommand("finally", [], 1), + ], + }); +}); + +test("throw error within nested function", async () => { + const wf = workflow(async (items: string[]) => { + try { + await Promise.all( + items.map(async (item) => { + const result = await createActivityCall("inside", [item]); + + if (result === "bad") { + throw new Error("bad"); + } + }) + ); + } catch { + await createActivityCall("catch", []); + return "returned in catch"; // this should be trumped by the finally + } finally { + await createActivityCall("finally", []); + // eslint-disable-next-line no-unsafe-finally + return "returned in finally"; + } + }); + expect(execute(wf, [], ["good", "bad"])).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [ + createScheduledActivityCommand("inside", ["good"], 0), + createScheduledActivityCommand("inside", ["bad"], 1), + ], + }); + expect( + execute( + wf, + [ + activityScheduled("inside", 0), + activityScheduled("inside", 1), + activitySucceeded("good", 0), + activitySucceeded("bad", 1), + ], + ["good", "bad"] + ) + ).resolves.toMatchObject({ + commands: [createScheduledActivityCommand("catch", [], 2)], + }); + expect( + execute( + wf, + [ + activityScheduled("inside", 0), + activityScheduled("inside", 1), + activitySucceeded("good", 0), + activitySucceeded("bad", 1), + activityScheduled("catch", 2), + activitySucceeded("catch", 2), + ], + ["good", "bad"] + ) + ).resolves.toMatchObject({ + commands: [createScheduledActivityCommand("finally", [], 3)], + }); + expect( + execute( + wf, + [ + activityScheduled("inside", 0), + activityScheduled("inside", 1), + activitySucceeded("good", 0), + activitySucceeded("bad", 1), + activityScheduled("catch", 2), + activitySucceeded("catch", 2), + activityScheduled("finally", 3), + activitySucceeded("finally", 3), + ], + ["good", "bad"] + ) + ).resolves.toMatchObject({ + result: Result.resolved("returned in finally"), + commands: [], + }); +}); + +test("properly evaluate await of sub-programs", async () => { + async function sub() { + const item = await Promise.all([ + createActivityCall("a", []), + createActivityCall("b", []), + ]); + + return item; + } + + const wf = workflow(async () => { + return await sub(); + }); + + expect(execute(wf, [], undefined)).resolves.toMatchObject({ + commands: [ + // + createScheduledActivityCommand("a", [], 0), + createScheduledActivityCommand("b", [], 1), + ], + }); + + expect( + execute( + wf, + [ + activityScheduled("a", 0), + activityScheduled("b", 1), + activitySucceeded("a", 0), + activitySucceeded("b", 1), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved(["a", "b"]), + commands: [], + }); +}); + +test("properly evaluate await of Promise.all", async () => { + const wf = workflow(async () => { + const item = await Promise.all([ + createActivityCall("a", []), + createActivityCall("b", []), + ]); + + return item; + }); + + expect(execute(wf, [], undefined)).resolves.toMatchObject({ + commands: [ + // + createScheduledActivityCommand("a", [], 0), + createScheduledActivityCommand("b", [], 1), + ], + }); + + expect( + execute( + wf, + [ + activityScheduled("a", 0), + activityScheduled("b", 1), + activitySucceeded("a", 0), + activitySucceeded("b", 1), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved(["a", "b"]), + commands: [], + }); +}); + +test("generator function returns an ActivityCall", async () => { + const wf = workflow(async () => { + return await sub(); + }); + + async function sub() { + return createActivityCall("call-a", []); + } + + expect(execute(wf, [], undefined)).resolves.toMatchObject({ + commands: [createScheduledActivityCommand("call-a", [], 0)], + }); + expect( + execute( + wf, + [activityScheduled("call-a", 0), activitySucceeded("result", 0)], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("result"), + commands: [], + }); +}); + +test("workflow calling other workflow", async () => { + const wf1 = workflow(async () => { + await createActivityCall("call-a", []); + }); + const wf2 = workflow(async () => { + const result = (await createWorkflowCall(wf1.name)) as any; + await createActivityCall("call-b", []); + return result; + }); + + expect(execute(wf2, [], undefined)).resolves.toMatchObject({ + commands: [createScheduledWorkflowCommand(wf1.name, undefined, 0)], + }); + + expect( + execute(wf2, [workflowScheduled(wf1.name, 0)], undefined) + ).resolves.toMatchObject({ + commands: [], + }); + + expect( + execute( + wf2, + [workflowScheduled(wf1.name, 0), workflowSucceeded("result", 0)], + undefined + ) + ).resolves.toMatchObject({ + commands: [createScheduledActivityCommand("call-b", [], 1)], + }); + + expect( + execute( + wf2, + [ + workflowScheduled(wf1.name, 0), + workflowSucceeded("result", 0), + activityScheduled("call-b", 1), + ], + undefined + ) + ).resolves.toMatchObject({ + commands: [], + }); + + expect( + execute( + wf2, + [ + workflowScheduled(wf1.name, 0), + workflowSucceeded("result", 0), + activityScheduled("call-b", 1), + activitySucceeded(undefined, 1), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("result"), + commands: [], + }); + + expect( + execute( + wf2, + [workflowScheduled(wf1.name, 0), workflowFailed("error", 0)], + undefined + ) + ).resolves.toMatchObject({ + result: Result.failed(new EventualError("error").toJSON()), + commands: [], + }); +}); + +describe("signals", () => { + describe("expect signal", () => { + const wf = workflow(async () => { + const result = await createExpectSignalCall( + "MySignal", + createAwaitDurationCall(100 * 1000, "seconds") + ); + + return result ?? "done"; + }); + + test("start expect signal", async () => { + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [createStartTimerCommand(Schedule.duration(100 * 1000), 0)], + }); + }); + + test("no signal", async () => { + await expect( + execute(wf, [timerScheduled(0)], undefined) + ).resolves.toMatchObject({ + commands: [], + }); + }); + + test("match signal", async () => { + await expect( + execute(wf, [timerScheduled(0), signalReceived("MySignal")], undefined) + ).resolves.toMatchObject({ + result: Result.resolved("done"), + commands: [], + }); + }); + + test("match signal with payload", async () => { + await expect( + execute( + wf, + [timerScheduled(0), signalReceived("MySignal", { done: true })], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved({ done: true }), + commands: [], + }); + }); + + test("timed out", async () => { + await expect( + execute(wf, [timerScheduled(0), timerCompleted(0)], undefined) + ).resolves.toMatchObject({ + result: Result.failed(new Timeout("Expect Signal Timed Out")), + commands: [], + }); + }); + + test("timed out then signal", async () => { + await expect( + execute( + wf, + [ + timerScheduled(0), + timerCompleted(0), + signalReceived("MySignal", { done: true }), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.failed(new Timeout("Expect Signal Timed Out")), + commands: [], + }); + }); + + test("match signal then timeout", async () => { + await expect( + execute( + wf, + [timerScheduled(0), signalReceived("MySignal"), timerCompleted(0)], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("done"), + commands: [], + }); + }); + + test("match signal twice", async () => { + await expect( + execute( + wf, + [ + timerScheduled(0), + signalReceived("MySignal"), + signalReceived("MySignal"), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("done"), + commands: [], + }); + }); + + test("multiple of the same signal", async () => { + const wf = workflow(async () => { + const wait1 = createExpectSignalCall( + "MySignal", + createAwaitDurationCall(100 * 1000, "seconds") + ); + const wait2 = createExpectSignalCall( + "MySignal", + createAwaitDurationCall(100 * 1000, "seconds") + ); + + return Promise.all([wait1, wait2]); + }); + + await expect( + execute( + wf, + [ + timerScheduled(0), + timerScheduled(2), + signalReceived("MySignal", "done!!!"), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved(["done!!!", "done!!!"]), + commands: [], + }); + }); + + test("expect then timeout", async () => { + const wf = workflow(async () => { + await createExpectSignalCall( + "MySignal", + createAwaitDurationCall(100 * 1000, "seconds") + ); + await createExpectSignalCall( + "MySignal", + createAwaitDurationCall(100 * 1000, "seconds") + ); + }); + + await expect( + execute(wf, [timerScheduled(0), timerCompleted(0)], undefined) + ).resolves.toMatchObject({ + result: Result.failed({ name: "Timeout" }), + commands: [], + }); + }); + + test("expect random signal then timeout", async () => { + const wf = workflow(async () => { + await createExpectSignalCall( + "MySignal", + createAwaitDurationCall(100 * 1000, "seconds") + ); + await createExpectSignalCall( + "MySignal", + createAwaitDurationCall(100 * 1000, "seconds") + ); + }); + + await expect( + execute( + wf, + [ + timerScheduled(0), + signalReceived("SomethingElse"), + timerCompleted(0), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.failed({ name: "Timeout" }), + commands: [], + }); + }); + }); + + describe("signal handler", () => { + const wf = workflow(async () => { + let mySignalHappened = 0; + let myOtherSignalHappened = 0; + let myOtherSignalCompleted = 0; + const mySignalHandler = createRegisterSignalHandlerCall( + "MySignal", + // the transformer will turn this closure into a generator wrapped in chain + function () { + mySignalHappened++; + } + ); + const myOtherSignalHandler = createRegisterSignalHandlerCall( + "MyOtherSignal", + async function (payload) { + myOtherSignalHappened++; + await createActivityCall("act1", [payload]); + myOtherSignalCompleted++; + } + ); + + await createAwaitTimeCall("then"); + + mySignalHandler.dispose(); + myOtherSignalHandler.dispose(); + + await createAwaitTimeCall("then"); + + return { + mySignalHappened, + myOtherSignalHappened, + myOtherSignalCompleted, + }; + }); + + test("start", async () => { + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [createStartTimerCommand(2)], + }); + }); + + test("send signal, do not wake up", async () => { + await expect( + execute(wf, [signalReceived("MySignal")], undefined) + ).resolves.toMatchObject({ + commands: [createStartTimerCommand(2)], + }); + }); + + test("send signal, wake up", async () => { + await expect( + execute( + wf, + [ + signalReceived("MySignal"), + timerScheduled(2), + timerCompleted(2), + timerScheduled(3), + timerCompleted(3), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved({ + mySignalHappened: 1, + myOtherSignalHappened: 0, + myOtherSignalCompleted: 0, + }), + commands: [], + }); + }); + + test("send multiple signal, wake up", async () => { + await expect( + execute( + wf, + [ + signalReceived("MySignal"), + signalReceived("MySignal"), + signalReceived("MySignal"), + timerScheduled(2), + timerCompleted(2), + timerScheduled(3), + timerCompleted(3), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved({ + mySignalHappened: 3, + myOtherSignalHappened: 0, + myOtherSignalCompleted: 0, + }), + commands: [], + }); + }); + + test("send signal after dispose", async () => { + await expect( + execute( + wf, + [ + timerScheduled(2), + timerCompleted(2), + signalReceived("MySignal"), + signalReceived("MySignal"), + signalReceived("MySignal"), + timerScheduled(3), + timerCompleted(3), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved({ + mySignalHappened: 0, + myOtherSignalHappened: 0, + myOtherSignalCompleted: 0, + }), + commands: [], + }); + }); + + test("send other signal, do not complete", async () => { + await expect( + execute(wf, [signalReceived("MyOtherSignal", "hi")], undefined) + ).resolves.toMatchObject({ + commands: [ + createStartTimerCommand(2), + createScheduledActivityCommand("act1", ["hi"], 3), + ], + }); + }); + + test("send multiple other signal, do not complete", async () => { + await expect( + execute( + wf, + [ + signalReceived("MyOtherSignal", "hi"), + signalReceived("MyOtherSignal", "hi2"), + ], + undefined + ) + ).resolves.toMatchObject({ + commands: [ + createStartTimerCommand(2), + createScheduledActivityCommand("act1", ["hi"], 3), + createScheduledActivityCommand("act1", ["hi2"], 4), + ], + }); + }); + + test("send other signal, wake timer, with act scheduled", async () => { + await expect( + execute( + wf, + [ + signalReceived("MyOtherSignal", "hi"), + timerScheduled(2), + timerCompleted(2), + activityScheduled("act1", 3), + timerScheduled(4), + timerCompleted(4), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved({ + mySignalHappened: 0, + myOtherSignalHappened: 1, + myOtherSignalCompleted: 0, + }), + commands: [], + }); + }); + + test("send other signal, wake timer, complete activity", async () => { + await expect( + execute( + wf, + [ + signalReceived("MyOtherSignal", "hi"), + timerScheduled(2), + activityScheduled("act1", 3), + activitySucceeded("act1", 3), + timerCompleted(2), + timerScheduled(4), + timerCompleted(4), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved({ + mySignalHappened: 0, + myOtherSignalHappened: 1, + myOtherSignalCompleted: 1, + }), + commands: [], + }); + }); + + test("send other signal, wake timer, complete activity after dispose", async () => { + await expect( + execute( + wf, + [ + signalReceived("MyOtherSignal", "hi"), + timerScheduled(2), + timerCompleted(2), + activityScheduled("act1", 3), + activitySucceeded("act1", 3), + timerScheduled(4), + timerCompleted(4), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved({ + mySignalHappened: 0, + myOtherSignalHappened: 1, + myOtherSignalCompleted: 1, + }), + commands: [], + }); + }); + + test("send other signal after dispose", async () => { + await expect( + execute( + wf, + [ + timerScheduled(2), + timerCompleted(2), + signalReceived("MyOtherSignal", "hi"), + timerScheduled(3), + timerCompleted(3), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved({ + mySignalHappened: 0, + myOtherSignalHappened: 0, + myOtherSignalCompleted: 0, + }), + commands: [], + }); + }); + }); + + describe("send signal", () => { + const mySignal = signal("MySignal"); + const wf = workflow(async () => { + createSendSignalCall( + { type: SignalTargetType.Execution, executionId: "someExecution/" }, + mySignal.id + ); + + const childWorkflow = createWorkflowCall("childWorkflow"); + + childWorkflow.sendSignal(mySignal); + + return await childWorkflow; + }); + + test("start", async () => { + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ + commands: [ + createSendSignalCommand( + { + type: SignalTargetType.Execution, + executionId: "someExecution/", + }, + "MySignal", + 0 + ), + createScheduledWorkflowCommand("childWorkflow", undefined, 1), + createSendSignalCommand( + { + type: SignalTargetType.ChildExecution, + workflowName: "childWorkflow", + seq: 1, + }, + "MySignal", + 2 + ), + ], + }); + }); + + test("partial", async () => { + await expect( + execute(wf, [signalSent("someExec", "MySignal", 0)], undefined) + ).resolves.toMatchObject({ + commands: [ + createScheduledWorkflowCommand("childWorkflow", undefined, 1), + createSendSignalCommand( + { + type: SignalTargetType.ChildExecution, + workflowName: "childWorkflow", + seq: 1, + }, + "MySignal", + 2 + ), + ], + }); + }); + + test("matching scheduled events", async () => { + await expect( + execute( + wf, + [ + signalSent("someExec", "MySignal", 0), + workflowScheduled("childWorkflow", 1), + signalSent("someExecution/", "MySignal", 2), + ], + undefined + ) + ).resolves.toMatchObject({ + commands: [], + }); + }); + + test("complete", async () => { + await expect( + execute( + wf, + [ + signalSent("someExec", "MySignal", 0), + workflowScheduled("childWorkflow", 1), + signalSent("someExecution/", "MySignal", 2), + workflowSucceeded("done", 1), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("done"), + commands: [], + }); + }); + + test("awaited sendSignal does nothing", async () => { + const wf = workflow(async () => { + await createSendSignalCall( + { type: SignalTargetType.Execution, executionId: "someExecution/" }, + mySignal.id + ); + + const childWorkflow = createWorkflowCall("childWorkflow"); + + await childWorkflow.sendSignal(mySignal); + + return await childWorkflow; + }); + + await expect( + execute( + wf, + [ + signalSent("someExec", "MySignal", 0), + workflowScheduled("childWorkflow", 1), + signalSent("someExecution/", "MySignal", 2), + workflowSucceeded("done", 1), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("done"), + commands: [], + }); + }); + }); +}); + +describe("condition", () => { + test("already true condition does not emit events", async () => { + const wf = workflow(async () => { + await createConditionCall(() => true); + }); + + await expect( + execute(wf, [], undefined) + ).resolves.toMatchObject({ + commands: [], + }); + }); + + test("false condition emits events", async () => { + const wf = workflow(async () => { + await createConditionCall(() => false); + }); + + await expect( + execute(wf, [], undefined) + ).resolves.toMatchObject({ + commands: [], + }); + }); + + test("false condition emits events with timeout", async () => { + const wf = workflow(async () => { + await createConditionCall( + () => false, + createAwaitDurationCall(100, "seconds") + ); + }); + + await expect( + execute(wf, [], undefined) + ).resolves.toMatchObject({ + commands: [createStartTimerCommand(Schedule.duration(100), 0)], + }); + }); + + test("false condition does not re-emit", async () => { + const wf = workflow(async () => { + await createConditionCall( + () => false, + createAwaitDurationCall(100, "seconds") + ); + }); + + await expect( + execute(wf, [timerScheduled(0)], undefined) + ).resolves.toMatchObject({ + commands: [], + }); + }); + + const signalConditionFlow = workflow(async () => { + let yes = false; + createRegisterSignalHandlerCall("Yes", () => { + yes = true; + }); + if ( + !(await createConditionCall( + () => yes, + createAwaitDurationCall(100, "seconds") + )) + ) { + return "timed out"; + } + return "done"; + }); + + test("trigger success", async () => { + await expect( + execute( + signalConditionFlow, + [timerScheduled(1), signalReceived("Yes")], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("done"), + commands: [], + }); + }); + + test("trigger success eventually", async () => { + await expect( + execute( + signalConditionFlow, + [ + timerScheduled(1), + signalReceived("No"), + signalReceived("No"), + signalReceived("No"), + signalReceived("No"), + signalReceived("Yes"), + ], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("done"), + commands: [], + }); + }); + + test("never trigger when state changes", async () => { + const signalConditionOnAndOffFlow = workflow(async () => { + let yes = false; + createRegisterSignalHandlerCall("Yes", () => { + yes = true; + }); + createRegisterSignalHandlerCall("Yes", () => { + yes = false; + }); + await createConditionCall(() => yes); + return "done"; + }); + + await expect( + execute(signalConditionOnAndOffFlow, [signalReceived("Yes")], undefined) + ).resolves.toMatchObject({ + commands: [], + }); + }); + + test("trigger timeout", async () => { + await expect( + execute( + signalConditionFlow, + [timerScheduled(1), timerCompleted(1)], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("timed out"), + commands: [], + }); + }); + + test("trigger success before timeout", async () => { + await expect( + execute( + signalConditionFlow, + [timerScheduled(1), signalReceived("Yes"), timerCompleted(1)], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("done"), + commands: [], + }); + }); + + test("trigger timeout before success", async () => { + await expect( + execute( + signalConditionFlow, + [timerScheduled(1), timerCompleted(1), signalReceived("Yes")], + undefined + ) + ).resolves.toMatchObject({ + result: Result.resolved("timed out"), + commands: [], + }); + }); + + test("condition as simple generator", async () => { + const wf = workflow(async () => { + await createConditionCall(() => false); + return "done"; + }); + + await expect( + execute(wf, [], undefined) + ).resolves.toMatchObject({ + commands: [], + }); + }); +}); + +test("nestedChains", async () => { + const wf = workflow(async () => { + const funcs = { + a: async () => { + await createAwaitTimeCall("then"); + }, + }; + + Object.fromEntries( + await Promise.all( + Object.entries(funcs).map(async ([name, func]) => { + return [name, await func()]; + }) + ) + ); + }); + + expect(execute(wf, [], undefined)).resolves.toMatchObject({ + commands: [createStartTimerCommand(0)], + }); +}); + +test("mixing closure types", async () => { + const workflow4 = workflow(async () => { + const greetings = Promise.all( + ["sam", "chris", "sam"].map((name) => createActivityCall("hello", [name])) + ); + const greetings2 = Promise.all( + ["sam", "chris", "sam"].map(async (name) => { + const greeting = await createActivityCall("hello", [name]); + return greeting * 2; + }) + ); + const greetings3 = Promise.all([ + createActivityCall("hello", ["sam"]), + createActivityCall("hello", ["chris"]), + createActivityCall("hello", ["sam"]), + ]); + return Promise.all([greetings, greetings2, greetings3]); + }); + + await expect( + execute(workflow4, [], undefined) + ).resolves.toEqual({ + commands: [ + createScheduledActivityCommand("hello", ["sam"], 0), + createScheduledActivityCommand("hello", ["chris"], 1), + createScheduledActivityCommand("hello", ["sam"], 2), + createScheduledActivityCommand("hello", ["sam"], 3), + createScheduledActivityCommand("hello", ["chris"], 4), + createScheduledActivityCommand("hello", ["sam"], 5), + createScheduledActivityCommand("hello", ["sam"], 6), + createScheduledActivityCommand("hello", ["chris"], 7), + createScheduledActivityCommand("hello", ["sam"], 8), + ], + }); + + await expect( + execute( + workflow4, + [ + activityScheduled("hello", 0), + activityScheduled("hello", 1), + activityScheduled("hello", 2), + activityScheduled("hello", 3), + activityScheduled("hello", 4), + activityScheduled("hello", 5), + activityScheduled("hello", 6), + activityScheduled("hello", 7), + activityScheduled("hello", 8), + ], + undefined + ) + ).resolves.toEqual({ + commands: [], + }); + + await expect( + execute( + workflow4, + [ + activityScheduled("hello", 0), + activityScheduled("hello", 1), + activityScheduled("hello", 2), + activityScheduled("hello", 3), + activityScheduled("hello", 4), + activityScheduled("hello", 5), + activityScheduled("hello", 6), + activityScheduled("hello", 7), + activityScheduled("hello", 8), + activitySucceeded(1, 0), + activitySucceeded(2, 1), + activitySucceeded(3, 2), + activitySucceeded(4, 3), + activitySucceeded(5, 4), + activitySucceeded(6, 5), + activitySucceeded(7, 6), + activitySucceeded(8, 7), + activitySucceeded(9, 8), + ], + undefined + ) + ).resolves.toEqual({ + result: Result.resolved([ + [1, 2, 3], + [8, 10, 12], + [7, 8, 9], + ]), + commands: [], + }); +}); + +test("workflow with synchronous function", async () => { + const workflow4 = workflow(function () { + return createActivityCall("hi", []); + }); + + await expect( + execute(workflow4, [], undefined) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("hi", [], 0)], + }); + + await expect( + execute( + workflow4, + [activityScheduled("hi", 0), activitySucceeded("result", 0)], + undefined + ) + ).resolves.toEqual({ + result: Result.resolved("result"), + commands: [], + }); +}); + +test("publish event", async () => { + const wf = workflow(async () => { + await createPublishEventsCall([ + { + name: "event-type", + event: { + key: "value", + }, + }, + ]); + + return "done!"; + }); + + const events = [ + { + name: "event-type", + event: { + key: "value", + }, + }, + ]; + + await expect(execute(wf, [], undefined)).resolves.toEqual({ + // promise should be instantly resolved + result: Result.resolved("done!"), + commands: [createPublishEventCommand(events, 0)], + }); + + await expect( + execute(wf, [eventsPublished(events, 0)], undefined) + ).resolves.toEqual({ + // promise should be instantly resolved + result: Result.resolved("done!"), + commands: [], + }); +}); diff --git a/packages/@eventual/core/src/activity.ts b/packages/@eventual/core/src/activity.ts index 3b73d8875..9901ee080 100644 --- a/packages/@eventual/core/src/activity.ts +++ b/packages/@eventual/core/src/activity.ts @@ -1,3 +1,4 @@ +import { isPromise } from "util/types"; import { ExecutionID } from "./execution.js"; import { FunctionRuntimeProps } from "./function-props.js"; import { AsyncTokenSymbol } from "./internal/activity.js"; @@ -11,7 +12,6 @@ import type { SendActivityHeartbeatRequest, SendActivitySuccessRequest, } from "./internal/eventual-service.js"; -import { isEventual } from "./internal/eventual.js"; import { isActivityWorker, isOrchestratorWorker } from "./internal/flags.js"; import { activities, @@ -305,23 +305,14 @@ export function activity( const func = ((input, options) => { if (isOrchestratorWorker()) { const timeout = options?.timeout ?? opts?.timeout; - if ( - timeout && - !( - isEventual(timeout) || - isDurationSchedule(timeout) || - isTimeSchedule(timeout) - ) - ) { - throw new Error("Timeout promise must be an Eventual or a Schedule."); - } + // if we're in the orchestrator, return a command to invoke the activity in the worker function return createActivityCall( name, input, timeout ? // if the timeout is an eventual already, just use that - isEventual(timeout) + isPromise(timeout) ? timeout : // otherwise make the right eventual type isDurationSchedule(timeout) diff --git a/packages/@eventual/core/src/condition.ts b/packages/@eventual/core/src/condition.ts index 47a1c7c56..d0d548e8a 100644 --- a/packages/@eventual/core/src/condition.ts +++ b/packages/@eventual/core/src/condition.ts @@ -1,5 +1,4 @@ import { createConditionCall } from "./internal/calls/condition-call.js"; -import { isEventual } from "./internal/eventual.js"; import { isOrchestratorWorker } from "./internal/flags.js"; export type ConditionPredicate = () => boolean; @@ -56,10 +55,5 @@ export function condition( } const [opts, predicate] = args.length === 1 ? [undefined, args[0]] : args; - const timeout = opts?.timeout; - if (timeout && !isEventual(timeout)) { - throw new Error("Timeout promise must be an Eventual."); - } - - return createConditionCall(predicate, timeout) as any; + return createConditionCall(predicate, opts?.timeout) as any; } diff --git a/packages/@eventual/core/src/event.ts b/packages/@eventual/core/src/event.ts index 3eeaa8bf0..b1336b097 100644 --- a/packages/@eventual/core/src/event.ts +++ b/packages/@eventual/core/src/event.ts @@ -1,5 +1,5 @@ import type { z } from "zod"; -import { createPublishEventsCall } from "./internal/calls/send-events-call.js"; +import { createPublishEventsCall } from "./internal/calls/publish-events-call.js"; import { isOrchestratorWorker } from "./internal/flags.js"; import { events, getServiceClient, subscriptions } from "./internal/global.js"; import { isSourceLocation } from "./internal/service-spec.js"; diff --git a/packages/@eventual/core/src/internal/await-all-settled.ts b/packages/@eventual/core/src/internal/await-all-settled.ts deleted file mode 100644 index deceea57c..000000000 --- a/packages/@eventual/core/src/internal/await-all-settled.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - createEventual, - Eventual, - EventualArrayPromiseResult, - EventualBase, - EventualKind, - isEventualOfKind, -} from "./eventual.js"; -import { Resolved } from "./result.js"; - -export function isAwaitAllSettled(a: any): a is AwaitAllSettled { - return isEventualOfKind(EventualKind.AwaitAllSettled, a); -} - -export interface AwaitAllSettled< - T extends (PromiseFulfilledResult | PromiseRejectedResult)[] -> extends EventualBase> { - activities: Eventual[]; -} - -export function createAwaitAllSettled(activities: A) { - return createEventual>>( - EventualKind.AwaitAllSettled, - { - activities, - } - ); -} diff --git a/packages/@eventual/core/src/internal/await-all.ts b/packages/@eventual/core/src/internal/await-all.ts deleted file mode 100644 index 5e7343aad..000000000 --- a/packages/@eventual/core/src/internal/await-all.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - createEventual, - Eventual, - EventualArrayPositional, - EventualBase, - EventualKind, - isEventualOfKind, -} from "./eventual.js"; -import { Failed, Resolved } from "./result.js"; - -export function isAwaitAll(a: any): a is AwaitAll { - return isEventualOfKind(EventualKind.AwaitAll, a); -} - -export interface AwaitAll - extends EventualBase | Failed> { - activities: Eventual[]; -} - -export function createAwaitAll(activities: A) { - return createEventual>>( - EventualKind.AwaitAll, - { - activities, - } - ); -} diff --git a/packages/@eventual/core/src/internal/await-any.ts b/packages/@eventual/core/src/internal/await-any.ts deleted file mode 100644 index ad54c4be1..000000000 --- a/packages/@eventual/core/src/internal/await-any.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - createEventual, - Eventual, - EventualArrayUnion, - EventualBase, - EventualKind, - isEventualOfKind, -} from "./eventual.js"; -import { Failed, Resolved } from "./result.js"; - -export function isAwaitAny(a: any): a is AwaitAny { - return isEventualOfKind(EventualKind.AwaitAny, a); -} - -export interface AwaitAny - extends EventualBase | Failed> { - activities: Eventual[]; -} - -export function createAwaitAny(activities: A) { - return createEventual>>( - EventualKind.AwaitAny, - { - activities, - } - ); -} diff --git a/packages/@eventual/core/src/internal/calls/activity-call.ts b/packages/@eventual/core/src/internal/calls/activity-call.ts index e92e5f6bf..2adbc52e0 100644 --- a/packages/@eventual/core/src/internal/calls/activity-call.ts +++ b/packages/@eventual/core/src/internal/calls/activity-call.ts @@ -1,38 +1,35 @@ import { DurationSchedule } from "../../schedule.js"; +import { EventualPromise, getWorkflowHook } from "../eventual-hook.js"; import { - createEventual, - Eventual, - EventualBase, - EventualKind, - isEventualOfKind, -} from "../eventual.js"; -import { registerEventual } from "../global.js"; -import { Failed, Resolved } from "../result.js"; + createEventualCall, + EventualCallBase, + EventualCallKind, + isEventualCallOfKind, +} from "./calls.js"; export function isActivityCall(a: any): a is ActivityCall { - return isEventualOfKind(EventualKind.ActivityCall, a); + return isEventualCallOfKind(EventualCallKind.ActivityCall, a); } -export interface ActivityCall - extends EventualBase | Failed> { - seq?: number; +export interface ActivityCall + extends EventualCallBase { name: string; input: any; heartbeat?: DurationSchedule; /** * Timeout can be any Eventual (promise). When the promise resolves, the activity is considered to be timed out. */ - timeout?: Eventual; + timeout?: Promise; } -export function createActivityCall( +export function createActivityCall( name: string, input: any, - timeout?: Eventual, + timeout?: Promise, heartbeat?: DurationSchedule -): ActivityCall { - return registerEventual( - createEventual(EventualKind.ActivityCall, { +): EventualPromise { + return getWorkflowHook().registerEventualCall( + createEventualCall(EventualCallKind.ActivityCall, { name, input, timeout, diff --git a/packages/@eventual/core/src/internal/calls/await-time-call.ts b/packages/@eventual/core/src/internal/calls/await-time-call.ts index d3cce57e7..7ae5c9fb3 100644 --- a/packages/@eventual/core/src/internal/calls/await-time-call.ts +++ b/packages/@eventual/core/src/internal/calls/await-time-call.ts @@ -1,49 +1,46 @@ import { DurationUnit } from "../../schedule.js"; +import { EventualPromise, getWorkflowHook } from "../eventual-hook.js"; import { - createEventual, - EventualBase, - EventualKind, - isEventualOfKind, -} from "../eventual.js"; -import { registerEventual } from "../global.js"; -import { Resolved } from "../result.js"; + createEventualCall, + EventualCallBase, + EventualCallKind, + isEventualCallOfKind, +} from "./calls.js"; export function isAwaitDurationCall(a: any): a is AwaitDurationCall { - return isEventualOfKind(EventualKind.AwaitDurationCall, a); + return isEventualCallOfKind(EventualCallKind.AwaitDurationCall, a); } export function isAwaitTimeCall(a: any): a is AwaitTimeCall { - return isEventualOfKind(EventualKind.AwaitTimeCall, a); + return isEventualCallOfKind(EventualCallKind.AwaitTimeCall, a); } export interface AwaitDurationCall - extends EventualBase> { - seq?: number; + extends EventualCallBase { dur: number; unit: DurationUnit; } export interface AwaitTimeCall - extends EventualBase> { - seq?: number; + extends EventualCallBase { isoDate: string; } export function createAwaitDurationCall( dur: number, unit: DurationUnit -): AwaitDurationCall { - return registerEventual( - createEventual(EventualKind.AwaitDurationCall, { +): EventualPromise { + return getWorkflowHook().registerEventualCall( + createEventualCall(EventualCallKind.AwaitDurationCall, { dur, unit, }) ); } -export function createAwaitTimeCall(isoDate: string): AwaitTimeCall { - return registerEventual( - createEventual(EventualKind.AwaitTimeCall, { +export function createAwaitTimeCall(isoDate: string): EventualPromise { + return getWorkflowHook().registerEventualCall( + createEventualCall(EventualCallKind.AwaitTimeCall, { isoDate, }) ); diff --git a/packages/@eventual/core/src/internal/calls/calls.ts b/packages/@eventual/core/src/internal/calls/calls.ts new file mode 100644 index 000000000..5e81d2d8f --- /dev/null +++ b/packages/@eventual/core/src/internal/calls/calls.ts @@ -0,0 +1,62 @@ +import { ActivityCall } from "./activity-call.js"; +import { AwaitDurationCall, AwaitTimeCall } from "./await-time-call.js"; +import { ConditionCall } from "./condition-call.js"; +import { ExpectSignalCall } from "./expect-signal-call.js"; +import { PublishEventsCall } from "./publish-events-call.js"; +import { SendSignalCall } from "./send-signal-call.js"; +import { RegisterSignalHandlerCall } from "./signal-handler-call.js"; +import { WorkflowCall } from "./workflow-call.js"; + +export type EventualCall = + | ActivityCall + | AwaitDurationCall + | AwaitTimeCall + | ConditionCall + | ExpectSignalCall + | PublishEventsCall + | SendSignalCall + | RegisterSignalHandlerCall + | WorkflowCall; + +export enum EventualCallKind { + ActivityCall = 1, + AwaitAll = 0, + AwaitAllSettled = 12, + AwaitAny = 10, + AwaitDurationCall = 3, + AwaitTimeCall = 4, + ConditionCall = 9, + ExpectSignalCall = 6, + PublishEventsCall = 13, + Race = 11, + RegisterSignalHandlerCall = 7, + SendSignalCall = 8, + WorkflowCall = 5, +} + +const EventualCallSymbol = Symbol.for("eventual:EventualCall"); + +export interface EventualCallBase< + Kind extends EventualCall[typeof EventualCallSymbol] +> { + [EventualCallSymbol]: Kind; +} + +export function createEventualCall( + kind: E[typeof EventualCallSymbol], + e: Omit +): E { + (e as E)[EventualCallSymbol] = kind; + return e as E; +} + +export function isEventualCall(a: any): a is EventualCall { + return a && typeof a === "object" && EventualCallSymbol in a; +} + +export function isEventualCallOfKind( + kind: E[typeof EventualCallSymbol], + a: any +): a is E { + return isEventualCall(a) && a[EventualCallSymbol] === kind; +} diff --git a/packages/@eventual/core/src/internal/calls/condition-call.ts b/packages/@eventual/core/src/internal/calls/condition-call.ts index f5218e6c8..1c901a0a6 100644 --- a/packages/@eventual/core/src/internal/calls/condition-call.ts +++ b/packages/@eventual/core/src/internal/calls/condition-call.ts @@ -1,31 +1,28 @@ import { ConditionPredicate } from "../../condition.js"; +import { getWorkflowHook } from "../eventual-hook.js"; import { - createEventual, - Eventual, - EventualBase, - EventualKind, - isEventualOfKind -} from "../eventual.js"; -import { registerEventual } from "../global.js"; -import { Failed, Resolved } from "../result.js"; + createEventualCall, + EventualCallBase, + EventualCallKind, + isEventualCallOfKind, +} from "./calls.js"; export function isConditionCall(a: any): a is ConditionCall { - return isEventualOfKind(EventualKind.ConditionCall, a); + return isEventualCallOfKind(EventualCallKind.ConditionCall, a); } export interface ConditionCall - extends EventualBase | Failed> { - seq?: number; + extends EventualCallBase { predicate: ConditionPredicate; - timeout?: Eventual; + timeout?: Promise; } export function createConditionCall( predicate: ConditionPredicate, - timeout?: Eventual + timeout?: Promise ) { - return registerEventual( - createEventual(EventualKind.ConditionCall, { + return getWorkflowHook().registerEventualCall( + createEventualCall(EventualCallKind.ConditionCall, { predicate, timeout, }) diff --git a/packages/@eventual/core/src/internal/calls/expect-signal-call.ts b/packages/@eventual/core/src/internal/calls/expect-signal-call.ts index e8ccfcaf1..88d3236a2 100644 --- a/packages/@eventual/core/src/internal/calls/expect-signal-call.ts +++ b/packages/@eventual/core/src/internal/calls/expect-signal-call.ts @@ -1,30 +1,27 @@ +import { EventualPromise, getWorkflowHook } from "../eventual-hook.js"; import { - createEventual, - Eventual, - EventualBase, - EventualKind, - isEventualOfKind, -} from "../eventual.js"; -import { registerEventual } from "../global.js"; -import { Failed, Resolved } from "../result.js"; + createEventualCall, + EventualCallBase, + EventualCallKind, + isEventualCallOfKind, +} from "./calls.js"; export function isExpectSignalCall(a: any): a is ExpectSignalCall { - return isEventualOfKind(EventualKind.ExpectSignalCall, a); + return isEventualCallOfKind(EventualCallKind.ExpectSignalCall, a); } -export interface ExpectSignalCall - extends EventualBase | Failed> { - seq?: number; +export interface ExpectSignalCall + extends EventualCallBase { signalId: string; - timeout?: Eventual; + timeout?: Promise; } -export function createExpectSignalCall( +export function createExpectSignalCall( signalId: string, - timeout?: Eventual -): ExpectSignalCall { - return registerEventual( - createEventual(EventualKind.ExpectSignalCall, { + timeout?: Promise +): EventualPromise { + return getWorkflowHook().registerEventualCall( + createEventualCall(EventualCallKind.ExpectSignalCall, { timeout, signalId, }) diff --git a/packages/@eventual/core/src/internal/calls/index.ts b/packages/@eventual/core/src/internal/calls/index.ts index 1c20b60b1..89f056a2a 100644 --- a/packages/@eventual/core/src/internal/calls/index.ts +++ b/packages/@eventual/core/src/internal/calls/index.ts @@ -2,7 +2,8 @@ export * from "./activity-call.js"; export * from "./await-time-call.js"; export * from "./condition-call.js"; export * from "./expect-signal-call.js"; -export * from "./send-events-call.js"; +export * from "./publish-events-call.js"; export * from "./send-signal-call.js"; export * from "./signal-handler-call.js"; export * from "./workflow-call.js"; +export * from "./calls.js"; diff --git a/packages/@eventual/core/src/internal/calls/publish-events-call.ts b/packages/@eventual/core/src/internal/calls/publish-events-call.ts new file mode 100644 index 000000000..2fa66bbdf --- /dev/null +++ b/packages/@eventual/core/src/internal/calls/publish-events-call.ts @@ -0,0 +1,36 @@ +import { EventEnvelope } from "../../event.js"; +import { EventualPromise, getWorkflowHook } from "../eventual-hook.js"; +import { + createEventualCall, + EventualCallBase, + EventualCallKind, + isEventualCallOfKind, +} from "./calls.js"; + +export function isPublishEventsCall(a: any): a is PublishEventsCall { + return isEventualCallOfKind(EventualCallKind.PublishEventsCall, a); +} + +export interface PublishEventsCall + extends EventualCallBase { + events: EventEnvelope[]; + id?: string; +} + +export function createPublishEventsCall( + events: EventEnvelope[], + id?: string +): EventualPromise { + return getWorkflowHook().registerEventualCall( + createEventualCall(EventualCallKind.PublishEventsCall, { + events, + id, + // /** + // * Publish Events is modeled synchronously, but the {@link sendEvents} method + // * returns a promise. Ensure the PublishEventsCall is always considered to be + // * immediately resolved. + // */ + // result: Result.resolved(undefined), + }) + ); +} diff --git a/packages/@eventual/core/src/internal/calls/send-events-call.ts b/packages/@eventual/core/src/internal/calls/send-events-call.ts deleted file mode 100644 index 3ddd39a95..000000000 --- a/packages/@eventual/core/src/internal/calls/send-events-call.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { EventEnvelope } from "../../event.js"; -import { - createEventual, - EventualBase, - EventualKind, - isEventualOfKind, -} from "../eventual.js"; -import { registerEventual } from "../global.js"; -import { Resolved, Result } from "../result.js"; - -export function isPublishEventsCall(a: any): a is PublishEventsCall { - return isEventualOfKind(EventualKind.PublishEventsCall, a); -} - -export interface PublishEventsCall - extends EventualBase> { - seq?: number; - events: EventEnvelope[]; - id?: string; -} - -export function createPublishEventsCall( - events: EventEnvelope[], - id?: string -): PublishEventsCall { - return registerEventual( - createEventual(EventualKind.PublishEventsCall, { - events, - id, - /** - * Publish Events is modeled synchronously, but the {@link sendEvents} method - * returns a promise. Ensure the PublishEventsCall is always considered to be - * immediately resolved. - */ - result: Result.resolved(undefined), - }) - ); -} diff --git a/packages/@eventual/core/src/internal/calls/send-signal-call.ts b/packages/@eventual/core/src/internal/calls/send-signal-call.ts index e53590aea..0e0c660d9 100644 --- a/packages/@eventual/core/src/internal/calls/send-signal-call.ts +++ b/packages/@eventual/core/src/internal/calls/send-signal-call.ts @@ -1,20 +1,18 @@ +import { EventualPromise, getWorkflowHook } from "../eventual-hook.js"; import { SignalTarget } from "../signal.js"; import { - createEventual, - EventualBase, - EventualKind, - isEventualOfKind, -} from "../eventual.js"; -import { registerEventual } from "../global.js"; -import { Resolved, Result } from "../result.js"; + createEventualCall, + EventualCallBase, + EventualCallKind, + isEventualCallOfKind, +} from "./calls.js"; export function isSendSignalCall(a: any): a is SendSignalCall { - return isEventualOfKind(EventualKind.SendSignalCall, a); + return isEventualCallOfKind(EventualCallKind.SendSignalCall, a); } export interface SendSignalCall - extends EventualBase> { - seq?: number; + extends EventualCallBase { signalId: string; payload?: any; target: SignalTarget; @@ -26,18 +24,18 @@ export function createSendSignalCall( signalId: string, payload?: any, id?: string -): SendSignalCall { - return registerEventual( - createEventual(EventualKind.SendSignalCall, { +): EventualPromise { + return getWorkflowHook().registerEventualCall( + createEventualCall(EventualCallKind.SendSignalCall, { payload, signalId, target, id, - /** - * Send signal is modeled synchronously, but the {@link sendSignal} method - * returns a promise. Ensure the SendSignalCall is always considered to be immediately resolved. - */ - result: Result.resolved(undefined), + // /** + // * Send signal is modeled synchronously, but the {@link sendSignal} method + // * returns a promise. Ensure the SendSignalCall is always considered to be immediately resolved. + // */ + // result: Result.resolved(undefined), }) ); } diff --git a/packages/@eventual/core/src/internal/calls/signal-handler-call.ts b/packages/@eventual/core/src/internal/calls/signal-handler-call.ts index 2dd80024f..fd9bc2a4e 100644 --- a/packages/@eventual/core/src/internal/calls/signal-handler-call.ts +++ b/packages/@eventual/core/src/internal/calls/signal-handler-call.ts @@ -1,23 +1,25 @@ import { SignalsHandler } from "../../signals.js"; import { - createEventual, - EventualBase, - EventualKind, - isEventualOfKind, -} from "../eventual.js"; -import { registerEventual } from "../global.js"; -import { Resolved, Result } from "../result.js"; + EventualPromise, + EventualPromiseSymbol, + getWorkflowHook, +} from "../eventual-hook.js"; +import { Result } from "../result.js"; +import { + createEventualCall, + EventualCallBase, + EventualCallKind, + isEventualCallOfKind, +} from "./calls.js"; export function isRegisterSignalHandlerCall( a: any ): a is RegisterSignalHandlerCall { - return isEventualOfKind(EventualKind.RegisterSignalHandlerCall, a); + return isEventualCallOfKind(EventualCallKind.RegisterSignalHandlerCall, a); } export interface RegisterSignalHandlerCall - extends EventualBase, - SignalsHandler { - seq?: number; + extends EventualCallBase { signalId: string; handler: (input: T) => void; } @@ -25,14 +27,22 @@ export interface RegisterSignalHandlerCall export function createRegisterSignalHandlerCall( signalId: string, handler: RegisterSignalHandlerCall["handler"] -): RegisterSignalHandlerCall { - return registerEventual( - createEventual(EventualKind.RegisterSignalHandlerCall, { +): SignalsHandler { + const hook = getWorkflowHook(); + const eventualPromise = hook.registerEventualCall( + createEventualCall(EventualCallKind.RegisterSignalHandlerCall, { signalId, handler, - dispose: function () { - this.result = Result.resolved(undefined); - }, }) - ); + ) as EventualPromise & SignalsHandler; + // the signal handler call should not block + return { + dispose: function () { + // resolving the signal handler eventual makes it unable to accept new events. + hook.resolveEventual( + eventualPromise[EventualPromiseSymbol], + Result.resolved(undefined) + ); + }, + }; } diff --git a/packages/@eventual/core/src/internal/calls/workflow-call.ts b/packages/@eventual/core/src/internal/calls/workflow-call.ts index 221c51cea..6dbdaf1a0 100644 --- a/packages/@eventual/core/src/internal/calls/workflow-call.ts +++ b/packages/@eventual/core/src/internal/calls/workflow-call.ts @@ -1,30 +1,31 @@ import { ChildExecution } from "../../execution.js"; -import { SignalTargetType } from "../signal.js"; import { Workflow, WorkflowExecutionOptions } from "../../workflow.js"; import { - createEventual, - Eventual, - EventualBase, - EventualKind, - isEventualOfKind, -} from "../eventual.js"; -import { registerEventual } from "../global.js"; -import { Result } from "../result.js"; + EventualPromise, + EventualPromiseSymbol, + getWorkflowHook, +} from "../eventual-hook.js"; +import { SignalTargetType } from "../signal.js"; +import { + createEventualCall, + EventualCall, + EventualCallBase, + EventualCallKind, + isEventualCallOfKind, +} from "./calls.js"; import { createSendSignalCall } from "./send-signal-call.js"; -export function isWorkflowCall(a: Eventual): a is WorkflowCall { - return isEventualOfKind(EventualKind.WorkflowCall, a); +export function isWorkflowCall(a: EventualCall): a is WorkflowCall { + return isEventualCallOfKind(EventualCallKind.WorkflowCall, a); } /** * An {@link Eventual} representing an awaited call to a {@link Workflow}. */ -export interface WorkflowCall - extends EventualBase>, - ChildExecution { +export interface WorkflowCall + extends EventualCallBase { name: string; input?: any; - seq?: number; opts?: WorkflowExecutionOptions; /** * An Eventual/Promise that determines when a child workflow should timeout. @@ -33,38 +34,39 @@ export interface WorkflowCall * * TODO: support cancellation of child workflow. */ - timeout: Eventual; + timeout: Promise; } -export function createWorkflowCall( +export function createWorkflowCall( name: string, input?: any, opts?: WorkflowExecutionOptions, - timeout?: Eventual -): WorkflowCall { - const call = registerEventual( - createEventual(EventualKind.WorkflowCall, { + timeout?: Promise +): EventualPromise & ChildExecution { + const hook = getWorkflowHook(); + const eventual = hook.registerEventualCall( + createEventualCall(EventualCallKind.WorkflowCall, { input, name, opts, timeout, - } as WorkflowCall) - ); + }) + ) as EventualPromise & ChildExecution; // create a reference to the child workflow started at a sequence in this execution. // this reference will be resolved by the runtime. - call.sendSignal = function (signal, payload?) { + eventual.sendSignal = function (signal, payload?) { const signalId = typeof signal === "string" ? signal : signal.id; return createSendSignalCall( { type: SignalTargetType.ChildExecution, - seq: call.seq!, - workflowName: call.name, + seq: eventual[EventualPromiseSymbol]!, + workflowName: name, }, signalId, payload ) as unknown as any; }; - return call; + return eventual; } diff --git a/packages/@eventual/core/src/internal/chain.ts b/packages/@eventual/core/src/internal/chain.ts deleted file mode 100644 index 28127ba7e..000000000 --- a/packages/@eventual/core/src/internal/chain.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - AwaitedEventual, - createEventual, - Eventual, - EventualBase, - EventualKind, - isEventualOfKind, - Program, -} from "./eventual.js"; -import { registerEventual } from "./global.js"; -import { Result } from "./result.js"; - -export function isChain(a: any): a is Chain { - return isEventualOfKind(EventualKind.Chain, a); -} - -export interface Chain - extends Program, - EventualBase> { - awaiting?: Eventual; -} - -export function chain Program>( - func: F -): (...args: Parameters) => Chain>> { - return ((...args: any[]) => { - const generator = func(...args); - return registerChain(generator); - }) as any; -} - -export function createChain(program: Program): Chain { - return createEventual(EventualKind.Chain, program); -} - -export function registerChain(program: Program): Chain { - return registerEventual(createChain(program)); -} diff --git a/packages/@eventual/core/src/internal/eventual-hook.ts b/packages/@eventual/core/src/internal/eventual-hook.ts new file mode 100644 index 000000000..ff5a6813c --- /dev/null +++ b/packages/@eventual/core/src/internal/eventual-hook.ts @@ -0,0 +1,48 @@ +import { AsyncLocalStorage } from "async_hooks"; +import { EventualCall } from "./calls/calls.js"; +import { Result } from "./result.js"; + +const storage = new AsyncLocalStorage(); + +export const EventualPromiseSymbol = Symbol.for("Eventual:Promise"); + +export interface EventualPromise extends Promise { + /** + * The sequence number associated with the Eventual for the execution. + */ + [EventualPromiseSymbol]: number; +} + +export function createEventualPromise( + promise: Promise, + seq: number +): EventualPromise { + const _promise = promise as EventualPromise; + _promise[EventualPromiseSymbol] = seq; + return _promise; +} + +export interface ExecutionWorkflowHook { + registerEventualCall(eventual: EventualCall): EventualPromise; + resolveEventual(seq: number, result: Result): void; +} + +export function tryGetWorkflowHook() { + return storage.getStore(); +} + +export function getWorkflowHook() { + const hook = tryGetWorkflowHook(); + + if (!hook) { + throw new Error( + "EventualHook cannot be retrieved outside of a Workflow Executor." + ); + } + + return hook; +} + +export function registerWorkflowHook(eventualHook: ExecutionWorkflowHook) { + storage.enterWith(eventualHook); +} diff --git a/packages/@eventual/core/src/internal/eventual.ts b/packages/@eventual/core/src/internal/eventual.ts deleted file mode 100644 index 621ae69e8..000000000 --- a/packages/@eventual/core/src/internal/eventual.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { AwaitAllSettled, createAwaitAllSettled } from "./await-all-settled.js"; -import { AwaitAll, createAwaitAll } from "./await-all.js"; -import { AwaitAny, createAwaitAny } from "./await-any.js"; -import { ActivityCall, isActivityCall } from "./calls/activity-call.js"; -import { - AwaitDurationCall, - AwaitTimeCall, - isAwaitDurationCall, - isAwaitTimeCall, -} from "./calls/await-time-call.js"; -import { ConditionCall } from "./calls/condition-call.js"; -import { ExpectSignalCall } from "./calls/expect-signal-call.js"; -import { - isPublishEventsCall, - PublishEventsCall, -} from "./calls/send-events-call.js"; -import { isSendSignalCall, SendSignalCall } from "./calls/send-signal-call.js"; -import { RegisterSignalHandlerCall } from "./calls/signal-handler-call.js"; -import { isWorkflowCall, WorkflowCall } from "./calls/workflow-call.js"; -import { chain, Chain } from "./chain.js"; -import { isOrchestratorWorker } from "./flags.js"; -import { createRace, Race } from "./race.js"; -import { Result } from "./result.js"; - -export type Program = Generator; - -export type AwaitedEventual = T extends Promise - ? Awaited - : T extends Program - ? AwaitedEventual - : T extends Eventual - ? AwaitedEventual - : T; - -const EventualSymbol = Symbol.for("eventual:Eventual"); - -export interface EventualBase { - [EventualSymbol]: Kind; - result?: R; -} - -export enum EventualKind { - ActivityCall = 1, - AwaitAll = 0, - AwaitAllSettled = 12, - AwaitAny = 10, - AwaitDurationCall = 3, - AwaitTimeCall = 4, - Chain = 2, - ConditionCall = 9, - ExpectSignalCall = 6, - PublishEventsCall = 13, - Race = 11, - RegisterSignalHandlerCall = 7, - SendSignalCall = 8, - WorkflowCall = 5, -} - -export function isEventual(a: any): a is Eventual { - return a && typeof a === "object" && EventualSymbol in a; -} - -export function isEventualOfKind( - kind: E[typeof EventualSymbol], - a: any -): a is E { - return isEventual(a) && a[EventualSymbol] === kind; -} - -export function createEventual( - kind: E[typeof EventualSymbol], - e: Omit -): E { - (e as E)[EventualSymbol] = kind; - return e as E; -} - -export type Eventual = - | AwaitAll - | AwaitAllSettled - | AwaitAny - | Chain - | CommandCall - | ConditionCall - | ExpectSignalCall - | Race - | RegisterSignalHandlerCall; - -/** - * Calls which emit commands. - */ -export type CommandCall = - | ActivityCall - | AwaitDurationCall - | AwaitTimeCall - | PublishEventsCall - | SendSignalCall - | WorkflowCall; - -export function isCommandCall(call: Eventual): call is CommandCall { - return ( - isActivityCall(call) || - isPublishEventsCall(call) || - isSendSignalCall(call) || - isAwaitDurationCall(call) || - isAwaitTimeCall(call) || - isWorkflowCall(call) - ); -} - -export const Eventual = { - /** - * Wait for all {@link activities} to succeed or until at least one throws. - * - * This is the equivalent behavior to Promise.all. - */ - all( - activities: A - ): AwaitAll> { - if (!isOrchestratorWorker()) { - throw new Error("Eventual.all is only valid in a workflow"); - } - - return createAwaitAll(activities) as any; - }, - any(activities: A): AwaitAny> { - if (!isOrchestratorWorker()) { - throw new Error("Eventual.any is only valid in a workflow"); - } - - return createAwaitAny(activities) as any; - }, - race(activities: A): Race> { - if (!isOrchestratorWorker()) { - throw new Error("Eventual.race is only valid in a workflow"); - } - - return createRace(activities) as any; - }, - allSettled( - activities: A - ): AwaitAllSettled> { - if (!isOrchestratorWorker()) { - throw new Error("Eventual.allSettled is only valid in a workflow"); - } - - return createAwaitAllSettled(activities) as any; - }, -}; - -export interface EventualCallCollector { - pushEventual(activity: E): E; -} - -export type EventualArrayPositional = { - [i in keyof A]: A[i] extends Eventual ? T : A[i]; -}; - -export type EventualArrayPromiseResult = { - [i in keyof A]: - | PromiseFulfilledResult ? T : A[i]> - | PromiseRejectedResult; -}; - -export type EventualArrayUnion[]> = - A[number] extends Eventual ? T : never; - -// the below globals are required by the transformer - -declare global { - // eslint-disable-next-line no-var - var $eventual: typeof chain; - // eslint-disable-next-line no-var - var $Eventual: typeof Eventual; -} - -globalThis.$eventual = chain; -globalThis.$Eventual = Eventual; diff --git a/packages/@eventual/core/src/internal/global.ts b/packages/@eventual/core/src/internal/global.ts index e8ab52217..cc789366a 100644 --- a/packages/@eventual/core/src/internal/global.ts +++ b/packages/@eventual/core/src/internal/global.ts @@ -5,7 +5,7 @@ import type { EventualServiceClient } from "../service-client.js"; import type { Subscription } from "../subscription.js"; import type { Workflow } from "../workflow.js"; import type { ActivityRuntimeContext } from "./activity.js"; -import type { Eventual, EventualCallCollector } from "./eventual.js"; +// import type { Eventual, EventualCallCollector } from "./eventual.js"; declare global { // eslint-disable-next-line no-var @@ -19,7 +19,7 @@ declare global { * * Set by the interpreter only when needed. */ - eventualCollector?: EventualCallCollector; + // eventualCollector?: EventualCallCollector; /** * Callable activities which register themselves in an activity worker. */ @@ -73,25 +73,25 @@ export function clearEventHandlers() { export const activities = (): Record> => (globalThis._eventual.activities ??= {}); -const eventualCollector = (): EventualCallCollector => { - const collector = globalThis._eventual.eventualCollector; - if (!collector) { - throw new Error("No Eventual Collector Provided"); - } - return collector; -}; - -export function registerEventual(eventual: A): A { - return eventualCollector().pushEventual(eventual); -} - -export function setEventualCollector(collector: EventualCallCollector) { - globalThis._eventual.eventualCollector = collector; -} - -export function clearEventualCollector() { - globalThis._eventual.eventualCollector = undefined; -} +// const eventualCollector = (): EventualCallCollector => { +// const collector = globalThis._eventual.eventualCollector; +// if (!collector) { +// throw new Error("No Eventual Collector Provided"); +// } +// return collector; +// }; + +// export function registerEventual(eventual: A): A { +// return eventualCollector().pushEventual(eventual); +// } + +// export function setEventualCollector(collector: EventualCallCollector) { +// globalThis._eventual.eventualCollector = collector; +// } + +// export function clearEventualCollector() { +// globalThis._eventual.eventualCollector = undefined; +// } /** * Register the global service client used by workflow functions diff --git a/packages/@eventual/core/src/internal/index.ts b/packages/@eventual/core/src/internal/index.ts index 405cab4e1..1dd82bd3d 100644 --- a/packages/@eventual/core/src/internal/index.ts +++ b/packages/@eventual/core/src/internal/index.ts @@ -1,20 +1,16 @@ export * from "./activity.js"; -export * from "./await-all-settled.js"; -export * from "./await-all.js"; -export * from "./await-any.js"; export * from "./calls/index.js"; -export * from "./chain.js"; -export * from "./workflow-command.js"; +export * from "./eventual-hook.js"; export * from "./eventual-service.js"; -export * from "./eventual.js"; export * from "./flags.js"; export * from "./global.js"; export * from "./guards.js"; -export * from "./race.js"; export * from "./result.js"; export * from "./schedule.js"; export * from "./service-spec.js"; export * from "./service-type.js"; export * from "./signal.js"; export * from "./util.js"; +export * from "./workflow-command.js"; export * from "./workflow-events.js"; + diff --git a/packages/@eventual/core/src/internal/race.ts b/packages/@eventual/core/src/internal/race.ts deleted file mode 100644 index 7c19fdf3d..000000000 --- a/packages/@eventual/core/src/internal/race.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - createEventual, - Eventual, - EventualArrayUnion, - EventualBase, - EventualKind, - isEventualOfKind, -} from "./eventual.js"; -import { Failed, Resolved } from "./result.js"; - -export function isRace(a: any): a is Race { - return isEventualOfKind(EventualKind.Race, a); -} - -export interface Race - extends EventualBase | Failed> { - activities: Eventual[]; -} - -export function createRace(activities: A) { - return createEventual>>(EventualKind.Race, { - activities, - }); -} diff --git a/packages/@eventual/core/src/internal/result.ts b/packages/@eventual/core/src/internal/result.ts index f5e391b9d..a33a97a11 100644 --- a/packages/@eventual/core/src/internal/result.ts +++ b/packages/@eventual/core/src/internal/result.ts @@ -1,9 +1,8 @@ -import { Eventual } from "./eventual.js"; import { extendsError, or } from "./util.js"; export const ResultSymbol = Symbol.for("eventual:Result"); -export type Result = Pending | Resolved | Failed; +export type Result = Resolved | Failed; export const Result = { resolved(value: T): Resolved { @@ -18,12 +17,6 @@ export const Result = { error, }; }, - pending(activity: A): Pending { - return { - [ResultSymbol]: ResultKind.Pending, - activity, - }; - }, }; export enum ResultKind { @@ -32,11 +25,6 @@ export enum ResultKind { Failed = 2, } -export interface Pending { - [ResultSymbol]: ResultKind.Pending; - activity: A; -} - export interface Resolved { [ResultSymbol]: ResultKind.Resolved; value: T; @@ -51,10 +39,6 @@ export function isResult(a: any): a is Result { return a && typeof a === "object" && ResultSymbol in a; } -export function isPending(result: Result | undefined): result is Pending { - return isResult(result) && result[ResultSymbol] === ResultKind.Pending; -} - export function isResolved( result: Result | undefined ): result is Resolved { diff --git a/packages/@eventual/core/src/internal/workflow-events.ts b/packages/@eventual/core/src/internal/workflow-events.ts index 81e2c7eaa..d9c675bf4 100644 --- a/packages/@eventual/core/src/internal/workflow-events.ts +++ b/packages/@eventual/core/src/internal/workflow-events.ts @@ -73,17 +73,41 @@ export type HistoryResultEvent = | WorkflowTimedOut | WorkflowRunStarted; -export function isHistoryResultEvent( - event: WorkflowEvent -): event is HistoryResultEvent { - return ( - isSucceededEvent(event) || - isFailedEvent(event) || - isSignalReceived(event) || - isWorkflowTimedOut(event) || - isWorkflowRunStarted(event) - ); -} +export type HistoryScheduledEvent = + | ActivityScheduled + | ChildWorkflowScheduled + | EventsPublished + | SignalSent + | TimerScheduled; + +export const isScheduledEvent = or( + isActivityScheduled, + isChildWorkflowScheduled, + isEventsPublished, + isSignalSent, + isTimerScheduled +); + +export const isSucceededEvent = or( + isActivitySucceeded, + isChildWorkflowSucceeded, + isTimerCompleted +); + +export const isFailedEvent = or( + isActivityFailed, + isActivityHeartbeatTimedOut, + isChildWorkflowFailed, + isWorkflowTimedOut +); + +export const isResultEvent = or( + isSucceededEvent, + isFailedEvent, + isSignalReceived, + isWorkflowTimedOut, + isWorkflowRunStarted +); /** * Events used by the workflow to replay an execution. @@ -91,7 +115,7 @@ export function isHistoryResultEvent( export type HistoryEvent = HistoryResultEvent | ScheduledEvent; export function isHistoryEvent(event: WorkflowEvent): event is HistoryEvent { - return isHistoryResultEvent(event) || isScheduledEvent(event); + return isResultEvent(event) || isScheduledEvent(event); } /** @@ -327,27 +351,6 @@ export function isWorkflowTimedOut( return event.type === WorkflowEventType.WorkflowTimedOut; } -export const isScheduledEvent = or( - isActivityScheduled, - isChildWorkflowScheduled, - isEventsPublished, - isSignalSent, - isTimerScheduled -); - -export const isSucceededEvent = or( - isActivitySucceeded, - isChildWorkflowSucceeded, - isTimerCompleted -); - -export const isFailedEvent = or( - isActivityFailed, - isActivityHeartbeatTimedOut, - isChildWorkflowFailed, - isWorkflowTimedOut -); - export function assertEventType( event: any, type: T["type"] diff --git a/packages/@eventual/core/src/signals.ts b/packages/@eventual/core/src/signals.ts index de0a25c85..5119b4914 100644 --- a/packages/@eventual/core/src/signals.ts +++ b/packages/@eventual/core/src/signals.ts @@ -2,7 +2,6 @@ import { ulid } from "ulidx"; import { createExpectSignalCall } from "./internal/calls/expect-signal-call.js"; import { createSendSignalCall } from "./internal/calls/send-signal-call.js"; import { createRegisterSignalHandlerCall } from "./internal/calls/signal-handler-call.js"; -import { isEventual } from "./internal/eventual.js"; import { isOrchestratorWorker } from "./internal/flags.js"; import { getServiceClient } from "./internal/global.js"; import { SignalTargetType } from "./internal/signal.js"; @@ -177,14 +176,9 @@ export function expectSignal( throw new Error("expectSignal is only valid in a workflow"); } - const timeout = opts?.timeout; - if (timeout && !isEventual(timeout)) { - throw new Error("Timeout promise must be an Eventual."); - } - return createExpectSignalCall( typeof signal === "string" ? signal : signal.id, - timeout + opts?.timeout ) as any; } diff --git a/packages/@eventual/core/src/workflow.ts b/packages/@eventual/core/src/workflow.ts index 4a7c3777f..16e92d4d2 100644 --- a/packages/@eventual/core/src/workflow.ts +++ b/packages/@eventual/core/src/workflow.ts @@ -1,12 +1,10 @@ +import { isPromise } from "util/types"; import type { ChildExecution, ExecutionHandle, ExecutionID, } from "./execution.js"; import { createWorkflowCall } from "./internal/calls/workflow-call.js"; -import { isChain } from "./internal/chain.js"; -import type { Program } from "./internal/eventual.js"; -import { isEventual } from "./internal/eventual.js"; import { isOrchestratorWorker } from "./internal/flags.js"; import { getServiceClient, workflows } from "./internal/global.js"; import { isDurationSchedule, isTimeSchedule } from "./internal/schedule.js"; @@ -23,7 +21,7 @@ import { Schedule } from "./schedule.js"; import type { StartExecutionRequest } from "./service-client.js"; export interface WorkflowHandler { - (input: Input, context: WorkflowContext): Promise | Program; + (input: Input, context: WorkflowContext): Promise; } /** @@ -172,16 +170,6 @@ export function workflow( // a timeout can either by from definition or a eventual/promise or both. // take the invocation time configuration first. const timeout = options?.timeout ?? opts?.timeout; - if ( - timeout && - !( - isEventual(timeout) || - isTimeSchedule(timeout) || - isDurationSchedule(timeout) - ) - ) { - throw new Error("Timeout promise must be an Eventual or a Schedule."); - } return createWorkflowCall( name, @@ -197,7 +185,7 @@ export function workflow( // if an eventual/promise is given, even if it is a duration or a time, timeout based on the // promise resolution. // TODO: support reporting cancellation to children when the parent times out? - isEventual(timeout) ? timeout : undefined + isPromise(timeout) ? timeout : undefined ); }) as any; @@ -215,11 +203,7 @@ export function workflow( }; // @ts-ignore - workflow.definition = isChain(definition) - ? definition - : function* (input: Input, context: WorkflowContext): any { - return yield definition(input, context); - }; // This type is added in the core-runtime package declaration. + workflow.definition = definition; workflows().set(name, workflow); return workflow; From 057b0748b1f8010d9a7bf6dcda9dc027a9a4ba79 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Fri, 3 Mar 2023 05:10:27 -0600 Subject: [PATCH 03/28] fix... --- packages/@eventual/cli/src/commands/replay.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/@eventual/cli/src/commands/replay.ts b/packages/@eventual/cli/src/commands/replay.ts index d38ee6812..683e2d1c7 100644 --- a/packages/@eventual/cli/src/commands/replay.ts +++ b/packages/@eventual/cli/src/commands/replay.ts @@ -19,7 +19,7 @@ import { Result, resultToString, ServiceType, - serviceTypeScopeSync, + serviceTypeScope, workflows, } from "@eventual/core/internal"; import path from "path"; @@ -62,7 +62,7 @@ export const replay = (yargs: Argv) => } spinner.start("Running program"); - serviceTypeScopeSync(ServiceType.OrchestratorWorker, () => { + serviceTypeScope(ServiceType.OrchestratorWorker, async () => { const processedEvents = processEvents( events, [], @@ -74,7 +74,11 @@ export const replay = (yargs: Argv) => ) ); - const res = progressWorkflow(execution, workflow, processedEvents); + const res = await progressWorkflow( + execution, + workflow, + processedEvents + ); assertExpectedResult(executionObj, res.result); From 8484f3833f0ec15a18da385a2c7c7fb216b41590 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Fri, 3 Mar 2023 05:39:54 -0600 Subject: [PATCH 04/28] tests pass --- .../__snapshots__/infer-plugin.test.ts.snap | 199 ++++++------------ .../core-runtime/src/workflow-executor.ts | 8 +- packages/@eventual/core/src/activity.ts | 10 +- .../core/src/internal/eventual-hook.ts | 11 +- .../@eventual/core/src/internal/global.ts | 27 --- packages/@eventual/core/src/internal/util.ts | 4 - packages/@eventual/core/src/workflow.ts | 3 +- packages/@eventual/timeline/src/App.tsx | 7 +- 8 files changed, 80 insertions(+), 189 deletions(-) diff --git a/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap b/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap index 1961705b5..7edad5e3a 100644 --- a/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap +++ b/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap @@ -445,77 +445,25 @@ module.exports = __toCommonJS(command_worker_exports); var AsyncTokenSymbol = Symbol.for("eventual:AsyncToken"); -function createAwaitAllSettled(activities2) { - return createEventual(EventualKind.AwaitAllSettled, { - activities: activities2 - }); -} - -function createAwaitAll(activities2) { - return createEventual(EventualKind.AwaitAll, { - activities: activities2 - }); -} - -function createAwaitAny(activities2) { - return createEventual(EventualKind.AwaitAny, { - activities: activities2 - }); -} - -globalThis._eventual ??= {}; -var commands = globalThis._eventual.commands ??= []; -var eventualCollector = () => { - const collector = globalThis._eventual.eventualCollector; - if (!collector) { - throw new Error("No Eventual Collector Provided"); - } - return collector; -}; -function registerEventual(eventual) { - return eventualCollector().pushEventual(eventual); -} - -function or(...conditions) { - return (a) => conditions.some((cond) => cond(a)); -} - -var ResultSymbol = Symbol.for("eventual:Result"); -var ResultKind; -(function(ResultKind2) { - ResultKind2[ResultKind2["Pending"] = 0] = "Pending"; - ResultKind2[ResultKind2["Resolved"] = 1] = "Resolved"; - ResultKind2[ResultKind2["Failed"] = 2] = "Failed"; -})(ResultKind || (ResultKind = {})); -function isResult(a) { - return a && typeof a === "object" && ResultSymbol in a; -} -function isResolved(result) { - return isResult(result) && result[ResultSymbol] === ResultKind.Resolved; -} -function isFailed(result) { - return isResult(result) && result[ResultSymbol] === ResultKind.Failed; -} -var isResolvedOrFailed = or(isResolved, isFailed); - -var SignalTargetType; -(function(SignalTargetType2) { - SignalTargetType2[SignalTargetType2["Execution"] = 0] = "Execution"; - SignalTargetType2[SignalTargetType2["ChildExecution"] = 1] = "ChildExecution"; -})(SignalTargetType || (SignalTargetType = {})); - -function chain(func) { - return (...args) => { - const generator = func(...args); - return registerChain(generator); - }; -} -function createChain(program) { - return createEventual(EventualKind.Chain, program); -} -function registerChain(program) { - return registerEventual(createChain(program)); -} +var EventualPromiseSymbol = Symbol.for("Eventual:Promise"); + +var EventualCallKind; +(function(EventualCallKind2) { + EventualCallKind2[EventualCallKind2["ActivityCall"] = 1] = "ActivityCall"; + EventualCallKind2[EventualCallKind2["AwaitAll"] = 0] = "AwaitAll"; + EventualCallKind2[EventualCallKind2["AwaitAllSettled"] = 12] = "AwaitAllSettled"; + EventualCallKind2[EventualCallKind2["AwaitAny"] = 10] = "AwaitAny"; + EventualCallKind2[EventualCallKind2["AwaitDurationCall"] = 3] = "AwaitDurationCall"; + EventualCallKind2[EventualCallKind2["AwaitTimeCall"] = 4] = "AwaitTimeCall"; + EventualCallKind2[EventualCallKind2["ConditionCall"] = 9] = "ConditionCall"; + EventualCallKind2[EventualCallKind2["ExpectSignalCall"] = 6] = "ExpectSignalCall"; + EventualCallKind2[EventualCallKind2["PublishEventsCall"] = 13] = "PublishEventsCall"; + EventualCallKind2[EventualCallKind2["Race"] = 11] = "Race"; + EventualCallKind2[EventualCallKind2["RegisterSignalHandlerCall"] = 7] = "RegisterSignalHandlerCall"; + EventualCallKind2[EventualCallKind2["SendSignalCall"] = 8] = "SendSignalCall"; + EventualCallKind2[EventualCallKind2["WorkflowCall"] = 5] = "WorkflowCall"; +})(EventualCallKind || (EventualCallKind = {})); +var EventualCallSymbol = Symbol.for("eventual:EventualCall"); var ServiceType; (function(ServiceType2) { @@ -525,72 +473,8 @@ var ServiceType; ServiceType2["OrchestratorWorker"] = "OrchestratorWorker"; })(ServiceType || (ServiceType = {})); -var SERVICE_TYPE_FLAG = "EVENTUAL_SERVICE_TYPE"; -function isOrchestratorWorker() { - return process.env[SERVICE_TYPE_FLAG] === ServiceType.OrchestratorWorker; -} - -function createRace(activities2) { - return createEventual(EventualKind.Race, { - activities: activities2 - }); -} - -var EventualSymbol = Symbol.for("eventual:Eventual"); -var EventualKind; -(function(EventualKind2) { - EventualKind2[EventualKind2["ActivityCall"] = 1] = "ActivityCall"; - EventualKind2[EventualKind2["AwaitAll"] = 0] = "AwaitAll"; - EventualKind2[EventualKind2["AwaitAllSettled"] = 12] = "AwaitAllSettled"; - EventualKind2[EventualKind2["AwaitAny"] = 10] = "AwaitAny"; - EventualKind2[EventualKind2["AwaitDurationCall"] = 3] = "AwaitDurationCall"; - EventualKind2[EventualKind2["AwaitTimeCall"] = 4] = "AwaitTimeCall"; - EventualKind2[EventualKind2["Chain"] = 2] = "Chain"; - EventualKind2[EventualKind2["ConditionCall"] = 9] = "ConditionCall"; - EventualKind2[EventualKind2["ExpectSignalCall"] = 6] = "ExpectSignalCall"; - EventualKind2[EventualKind2["PublishEventsCall"] = 13] = "PublishEventsCall"; - EventualKind2[EventualKind2["Race"] = 11] = "Race"; - EventualKind2[EventualKind2["RegisterSignalHandlerCall"] = 7] = "RegisterSignalHandlerCall"; - EventualKind2[EventualKind2["SendSignalCall"] = 8] = "SendSignalCall"; - EventualKind2[EventualKind2["WorkflowCall"] = 5] = "WorkflowCall"; -})(EventualKind || (EventualKind = {})); -function createEventual(kind, e2) { - e2[EventualSymbol] = kind; - return e2; -} -var Eventual = { - /** - * Wait for all {@link activities} to succeed or until at least one throws. - * - * This is the equivalent behavior to Promise.all. - */ - all(activities2) { - if (!isOrchestratorWorker()) { - throw new Error("Eventual.all is only valid in a workflow"); - } - return createAwaitAll(activities2); - }, - any(activities2) { - if (!isOrchestratorWorker()) { - throw new Error("Eventual.any is only valid in a workflow"); - } - return createAwaitAny(activities2); - }, - race(activities2) { - if (!isOrchestratorWorker()) { - throw new Error("Eventual.race is only valid in a workflow"); - } - return createRace(activities2); - }, - allSettled(activities2) { - if (!isOrchestratorWorker()) { - throw new Error("Eventual.allSettled is only valid in a workflow"); - } - return createAwaitAllSettled(activities2); - } -}; -globalThis.$eventual = chain; -globalThis.$Eventual = Eventual; +globalThis._eventual ??= {}; +var commands = globalThis._eventual.commands ??= []; function isSourceLocation(a) { return a && typeof a === "object" && typeof a.fileName === "string" && typeof a.exportName === "string"; @@ -776,6 +660,34 @@ _BaseCachingSecret_value = /* @__PURE__ */ new WeakMap(); var import_ulidx2 = __toESM(require_dist2(), 1); +function or(...conditions) { + return (a) => conditions.some((cond) => cond(a)); +} + +var ResultSymbol = Symbol.for("eventual:Result"); +var ResultKind; +(function(ResultKind2) { + ResultKind2[ResultKind2["Pending"] = 0] = "Pending"; + ResultKind2[ResultKind2["Resolved"] = 1] = "Resolved"; + ResultKind2[ResultKind2["Failed"] = 2] = "Failed"; +})(ResultKind || (ResultKind = {})); +function isResult(a) { + return a && typeof a === "object" && ResultSymbol in a; +} +function isResolved(result) { + return isResult(result) && result[ResultSymbol] === ResultKind.Resolved; +} +function isFailed(result) { + return isResult(result) && result[ResultSymbol] === ResultKind.Failed; +} +var isResolvedOrFailed = or(isResolved, isFailed); + +var SignalTargetType; +(function(SignalTargetType2) { + SignalTargetType2[SignalTargetType2["Execution"] = 0] = "Execution"; + SignalTargetType2[SignalTargetType2["ChildExecution"] = 1] = "ChildExecution"; +})(SignalTargetType || (SignalTargetType = {})); + var WorkflowEventType; (function(WorkflowEventType2) { WorkflowEventType2["ActivitySucceeded"] = "ActivitySucceeded"; @@ -797,6 +709,13 @@ var WorkflowEventType; WorkflowEventType2["WorkflowRunStarted"] = "WorkflowRunStarted"; WorkflowEventType2["WorkflowTimedOut"] = "WorkflowTimedOut"; })(WorkflowEventType || (WorkflowEventType = {})); +var isScheduledEvent = or(isActivityScheduled, isChildWorkflowScheduled, isEventsPublished, isSignalSent, isTimerScheduled); +var isSucceededEvent = or(isActivitySucceeded, isChildWorkflowSucceeded, isTimerCompleted); +var isFailedEvent = or(isActivityFailed, isActivityHeartbeatTimedOut, isChildWorkflowFailed, isWorkflowTimedOut); +var isResultEvent = or(isSucceededEvent, isFailedEvent, isSignalReceived, isWorkflowTimedOut, isWorkflowRunStarted); +function isWorkflowRunStarted(event) { + return event.type === WorkflowEventType.WorkflowRunStarted; +} function isActivityScheduled(event) { return event.type === WorkflowEventType.ActivityScheduled; } @@ -831,6 +750,9 @@ function isTimerCompleted(event) { return event.type === WorkflowEventType.TimerCompleted; } var isWorkflowCompletedEvent = or(isWorkflowFailed, isWorkflowSucceeded); +function isSignalReceived(event) { + return event.type === WorkflowEventType.SignalReceived; +} function isSignalSent(event) { return event.type === WorkflowEventType.SignalSent; } @@ -840,9 +762,6 @@ function isEventsPublished(event) { function isWorkflowTimedOut(event) { return event.type === WorkflowEventType.WorkflowTimedOut; } -var isScheduledEvent = or(isActivityScheduled, isChildWorkflowScheduled, isEventsPublished, isSignalSent, isTimerScheduled); -var isSucceededEvent = or(isActivitySucceeded, isChildWorkflowSucceeded, isTimerCompleted); -var isFailedEvent = or(isActivityFailed, isActivityHeartbeatTimedOut, isChildWorkflowFailed, isWorkflowTimedOut); var myHandler = api.get("/", async () => { return new HttpResponse(); diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index 557ffd3cc..a9b5e45d1 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -170,7 +170,7 @@ export class WorkflowExecutor { return new Promise(async (resolve) => { // start context with execution hook - this.registerExecutionHook(); + await this.registerExecutionHook(); this.started = { // TODO, also cancel? resolve: (result) => { @@ -234,7 +234,7 @@ export class WorkflowExecutor { this.history.push(...history); return new Promise(async (resolve) => { - this.registerExecutionHook(); + await this.registerExecutionHook(); await this.drainHistoryEvents(); const newCommands = this.commandsToEmit; @@ -254,9 +254,9 @@ export class WorkflowExecutor { this.started.resolve(result); } - private registerExecutionHook() { + private async registerExecutionHook() { const self = this; - registerWorkflowHook({ + await registerWorkflowHook({ registerEventualCall: (call) => { try { const eventual = createEventualFromCall(call); diff --git a/packages/@eventual/core/src/activity.ts b/packages/@eventual/core/src/activity.ts index 9901ee080..a7e78f406 100644 --- a/packages/@eventual/core/src/activity.ts +++ b/packages/@eventual/core/src/activity.ts @@ -1,4 +1,3 @@ -import { isPromise } from "util/types"; import { ExecutionID } from "./execution.js"; import { FunctionRuntimeProps } from "./function-props.js"; import { AsyncTokenSymbol } from "./internal/activity.js"; @@ -20,7 +19,6 @@ import { } from "./internal/global.js"; import { isDurationSchedule, isTimeSchedule } from "./internal/schedule.js"; import { isSourceLocation, SourceLocation } from "./internal/service-spec.js"; -import { assertNever } from "./internal/util.js"; import type { DurationSchedule, Schedule } from "./schedule.js"; import { EventualServiceClient, @@ -311,15 +309,11 @@ export function activity( name, input, timeout - ? // if the timeout is an eventual already, just use that - isPromise(timeout) - ? timeout - : // otherwise make the right eventual type - isDurationSchedule(timeout) + ? isDurationSchedule(timeout) ? createAwaitDurationCall(timeout.dur, timeout.unit) : isTimeSchedule(timeout) ? createAwaitTimeCall(timeout.isoDate) - : assertNever(timeout) + : timeout : undefined, options?.heartbeatTimeout ?? opts?.heartbeatTimeout ) as any; diff --git a/packages/@eventual/core/src/internal/eventual-hook.ts b/packages/@eventual/core/src/internal/eventual-hook.ts index ff5a6813c..6974f6c46 100644 --- a/packages/@eventual/core/src/internal/eventual-hook.ts +++ b/packages/@eventual/core/src/internal/eventual-hook.ts @@ -1,8 +1,8 @@ -import { AsyncLocalStorage } from "async_hooks"; +import type { AsyncLocalStorage } from "async_hooks"; import { EventualCall } from "./calls/calls.js"; import { Result } from "./result.js"; -const storage = new AsyncLocalStorage(); +let storage: AsyncLocalStorage; export const EventualPromiseSymbol = Symbol.for("Eventual:Promise"); @@ -43,6 +43,11 @@ export function getWorkflowHook() { return hook; } -export function registerWorkflowHook(eventualHook: ExecutionWorkflowHook) { +export async function registerWorkflowHook( + eventualHook: ExecutionWorkflowHook +) { + if (!storage) { + storage = new (await import("async_hooks")).AsyncLocalStorage(); + } storage.enterWith(eventualHook); } diff --git a/packages/@eventual/core/src/internal/global.ts b/packages/@eventual/core/src/internal/global.ts index cc789366a..29dd560f0 100644 --- a/packages/@eventual/core/src/internal/global.ts +++ b/packages/@eventual/core/src/internal/global.ts @@ -5,7 +5,6 @@ import type { EventualServiceClient } from "../service-client.js"; import type { Subscription } from "../subscription.js"; import type { Workflow } from "../workflow.js"; import type { ActivityRuntimeContext } from "./activity.js"; -// import type { Eventual, EventualCallCollector } from "./eventual.js"; declare global { // eslint-disable-next-line no-var @@ -14,12 +13,6 @@ declare global { * Data about the current activity assigned before running an activity on an the activity worker. */ activityContext?: ActivityRuntimeContext; - /** - * An object used by the interpreter to collect {@link Eventual}s while running a workflow code. - * - * Set by the interpreter only when needed. - */ - // eventualCollector?: EventualCallCollector; /** * Callable activities which register themselves in an activity worker. */ @@ -73,26 +66,6 @@ export function clearEventHandlers() { export const activities = (): Record> => (globalThis._eventual.activities ??= {}); -// const eventualCollector = (): EventualCallCollector => { -// const collector = globalThis._eventual.eventualCollector; -// if (!collector) { -// throw new Error("No Eventual Collector Provided"); -// } -// return collector; -// }; - -// export function registerEventual(eventual: A): A { -// return eventualCollector().pushEventual(eventual); -// } - -// export function setEventualCollector(collector: EventualCallCollector) { -// globalThis._eventual.eventualCollector = collector; -// } - -// export function clearEventualCollector() { -// globalThis._eventual.eventualCollector = undefined; -// } - /** * Register the global service client used by workflow functions * to start workflows within an eventual-controlled environment. diff --git a/packages/@eventual/core/src/internal/util.ts b/packages/@eventual/core/src/internal/util.ts index 19e69203b..7115c64c7 100644 --- a/packages/@eventual/core/src/internal/util.ts +++ b/packages/@eventual/core/src/internal/util.ts @@ -1,4 +1,3 @@ -import { ExecutionID } from "../execution.js"; export function assertNever(never: never, msg?: string): never { throw new Error(msg ?? `reached unreachable code with value ${never}`); @@ -103,6 +102,3 @@ export function encodeExecutionId(executionId: string) { return Buffer.from(executionId, "utf-8").toString("base64"); } -export function decodeExecutionId(executionId: string): ExecutionID { - return Buffer.from(executionId, "base64").toString("utf-8") as ExecutionID; -} diff --git a/packages/@eventual/core/src/workflow.ts b/packages/@eventual/core/src/workflow.ts index 16e92d4d2..c1a475aa4 100644 --- a/packages/@eventual/core/src/workflow.ts +++ b/packages/@eventual/core/src/workflow.ts @@ -1,4 +1,3 @@ -import { isPromise } from "util/types"; import type { ChildExecution, ExecutionHandle, @@ -185,7 +184,7 @@ export function workflow( // if an eventual/promise is given, even if it is a duration or a time, timeout based on the // promise resolution. // TODO: support reporting cancellation to children when the parent times out? - isPromise(timeout) ? timeout : undefined + timeout && "then" in timeout ? timeout : undefined ); }) as any; diff --git a/packages/@eventual/timeline/src/App.tsx b/packages/@eventual/timeline/src/App.tsx index 7f7210b8a..958bd9e16 100644 --- a/packages/@eventual/timeline/src/App.tsx +++ b/packages/@eventual/timeline/src/App.tsx @@ -1,5 +1,6 @@ import { HttpEventualClient } from "@eventual/client"; -import { decodeExecutionId, WorkflowStarted } from "@eventual/core/internal"; +import type { ExecutionID } from "@eventual/core"; +import type { WorkflowStarted } from "@eventual/core/internal"; import { useQuery } from "@tanstack/react-query"; import { ReactNode } from "react"; import { aggregateEvents } from "./activity.js"; @@ -111,4 +112,8 @@ function App() { } } +export function decodeExecutionId(executionId: string): ExecutionID { + return Buffer.from(executionId, "base64").toString("utf-8") as ExecutionID; +} + export default App; From 4f35d157c70db78ba402c281d72b9be8316dba27 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Sat, 4 Mar 2023 18:50:40 -0600 Subject: [PATCH 05/28] feedback --- .../core-runtime/src/workflow-executor.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index a9b5e45d1..398376de9 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -195,6 +195,9 @@ export class WorkflowExecutor { await this.drainHistoryEvents(); // let everything that has started or will be started complete + // set timeout adds the closure to the end of the event loop + // this assumption breaks down when the user tries to start a promise + // to accomplish non-deterministic actions like IO. setTimeout(() => { const newCommands = this.commandsToEmit; this.commandsToEmit = []; @@ -220,8 +223,17 @@ export class WorkflowExecutor { }); } + /** + * Continue a previously started workflow by feeding in new {@link HistoryResultEvent}, possibly advancing the execution. + * + * This allows the workflow to continue without re-running previous history. + * + * Events will be applied to the workflow in order. + * + * @returns {@link WorkflowResult} - containing new commands and a result of one was generated. + */ public async continue( - ...history: HistoryEvent[] + ...history: HistoryResultEvent[] ): Promise> { if (!this.options?.resumable) { throw new Error( @@ -375,7 +387,7 @@ export class WorkflowExecutor { eventual.eventual.applyEvent?.(event) ); } - [...this.runtimeState.awaitingAny].map((s) => { + [...this.runtimeState.awaitingAny].forEach((s) => { const eventual = this.runtimeState.active[s]; if (eventual && eventual.eventual.afterEveryEvent) { this.tryResolveEventual(s, eventual.eventual.afterEveryEvent()); From a00fd195f15f4dea1c988f6a512d0e6bb52ec322 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Sat, 4 Mar 2023 21:01:25 -0600 Subject: [PATCH 06/28] simplify test env, fix bugs, remove async transformer --- packages/@eventual/compiler/src/ast-util.ts | 73 +---- .../@eventual/compiler/src/esbuild-plugin.ts | 30 --- .../@eventual/compiler/src/eventual-bundle.ts | 1 - packages/@eventual/compiler/src/index.ts | 2 - .../compiler/src/workflow-visitor.ts | 252 ------------------ .../compiler/test-files/json-file.json | 1 - .../compiler/test-files/not-workflow.ts | 7 - .../compiler/test-files/open-account.ts | 29 -- .../compiler/test-files/workflow.mts | 37 --- .../@eventual/compiler/test-files/workflow.ts | 74 ----- .../__snapshots__/esbuild-plugin.test.ts.snap | 126 --------- .../compiler/test/esbuild-plugin.test.ts | 100 ------- .../core-runtime/src/workflow-executor.ts | 43 ++- .../core/src/internal/eventual-hook.ts | 11 +- packages/@eventual/core/src/signals.ts | 6 +- packages/@eventual/testing/src/environment.ts | 38 +-- packages/@eventual/testing/test/env.test.ts | 46 ++-- 17 files changed, 66 insertions(+), 810 deletions(-) delete mode 100644 packages/@eventual/compiler/src/esbuild-plugin.ts delete mode 100644 packages/@eventual/compiler/src/workflow-visitor.ts delete mode 100644 packages/@eventual/compiler/test-files/json-file.json delete mode 100644 packages/@eventual/compiler/test-files/not-workflow.ts delete mode 100644 packages/@eventual/compiler/test-files/open-account.ts delete mode 100644 packages/@eventual/compiler/test-files/workflow.mts delete mode 100644 packages/@eventual/compiler/test-files/workflow.ts delete mode 100644 packages/@eventual/compiler/test/__snapshots__/esbuild-plugin.test.ts.snap delete mode 100644 packages/@eventual/compiler/test/esbuild-plugin.test.ts diff --git a/packages/@eventual/compiler/src/ast-util.ts b/packages/@eventual/compiler/src/ast-util.ts index b35912759..7ce9d1086 100644 --- a/packages/@eventual/compiler/src/ast-util.ts +++ b/packages/@eventual/compiler/src/ast-util.ts @@ -1,17 +1,11 @@ import { - Argument, - ArrowFunctionExpression, CallExpression, + ComputedPropName, Expression, - FunctionExpression, - Span, - Node, - StringLiteral, - FunctionDeclaration, - Statement, HasSpan, Identifier, - ComputedPropName, + Node, + Span, } from "@swc/core"; /** @@ -101,67 +95,6 @@ function isId( ); } -/** - * A heuristic for identifying a {@link CallExpression} that is a call - * to the eventual.workflow utility: - * - * 1. must be a function call with exactly 2 arguments - * 2. first argument is a string literal - * 3. second argument is a FunctionExpression or ArrowFunctionExpression - * 4. callee is an identifier `"workflow"` or `.workflow` - */ -export function isWorkflowCall(call: CallExpression): call is CallExpression & { - arguments: [ - Argument & { expression: StringLiteral }, - Argument & { expression: FunctionExpression | ArrowFunctionExpression } - ]; -} { - return ( - isWorkflowCallee(call.callee) && - call.arguments[0]?.expression.type === "StringLiteral" && - ((call.arguments.length === 2 && - isNonGeneratorFunction(call.arguments[1]?.expression)) || - (call.arguments.length === 3 && - isNonGeneratorFunction(call.arguments[2]?.expression))) - ); -} - -export function isNonGeneratorFunction( - expr?: Expression -): expr is ArrowFunctionExpression | FunctionExpression { - return ( - (expr?.type === "ArrowFunctionExpression" || - expr?.type === "FunctionExpression") && - !expr.generator - ); -} - -export function isActivityCallee(callee: CallExpression["callee"]) { - return isCallee("activity", callee); -} - -export function isWorkflowCallee(callee: CallExpression["callee"]) { - return isCallee("workflow", callee); -} - -export function isCallee( - type: "activity" | "workflow", - callee: CallExpression["callee"] -) { - return ( - (callee.type === "Identifier" && callee.value === type) || - (callee.type === "MemberExpression" && - callee.property.type === "Identifier" && - callee.property.value === type) - ); -} - -export function isAsyncFunctionDecl( - stmt: Statement -): stmt is FunctionDeclaration & { async: true } { - return stmt.type === "FunctionDeclaration" && stmt.async; -} - export function hasSpan(expr: Node): expr is Node & HasSpan { return "span" in expr; } diff --git a/packages/@eventual/compiler/src/esbuild-plugin.ts b/packages/@eventual/compiler/src/esbuild-plugin.ts deleted file mode 100644 index 5f490431c..000000000 --- a/packages/@eventual/compiler/src/esbuild-plugin.ts +++ /dev/null @@ -1,30 +0,0 @@ -import esBuild from "esbuild"; -import { parseFile } from "@swc/core"; -import { OuterVisitor } from "./workflow-visitor.js"; -import { printModule } from "./print-module.js"; - -export const eventualESPlugin: esBuild.Plugin = { - name: "eventual", - setup(build) { - build.onLoad({ filter: /\.[mc]?[tj]s$/g }, async (args) => { - // FYI: SWC erases comments: https://github.com/swc-project/swc/issues/6403 - const sourceModule = await parseFile(args.path, { - syntax: "typescript", - }); - - const outerVisitor = new OuterVisitor(); - const transformedModule = outerVisitor.visitModule(sourceModule); - - // only format the module and return it if we found eventual functions to transform. - if (outerVisitor.foundEventual) { - const { code } = await printModule(transformedModule, args.path); - - return { - contents: code, - loader: "ts", - }; - } - return undefined; - }); - }, -}; diff --git a/packages/@eventual/compiler/src/eventual-bundle.ts b/packages/@eventual/compiler/src/eventual-bundle.ts index adfcf4cca..189b554f0 100755 --- a/packages/@eventual/compiler/src/eventual-bundle.ts +++ b/packages/@eventual/compiler/src/eventual-bundle.ts @@ -4,7 +4,6 @@ import { aliasPath } from "esbuild-plugin-alias-path"; import fs from "fs/promises"; import path from "path"; import { prepareOutDir } from "./build.js"; -// import { eventualESPlugin } from "./esbuild-plugin.js"; export async function bundleSources( outDir: string, diff --git a/packages/@eventual/compiler/src/index.ts b/packages/@eventual/compiler/src/index.ts index fe1908f9f..25edbb0ab 100644 --- a/packages/@eventual/compiler/src/index.ts +++ b/packages/@eventual/compiler/src/index.ts @@ -1,5 +1,3 @@ export * from "./eventual-bundle.js"; export * from "./eventual-infer.js"; -export * from "./esbuild-plugin.js"; export * from "./build.js"; -export * from "./workflow-visitor.js"; diff --git a/packages/@eventual/compiler/src/workflow-visitor.ts b/packages/@eventual/compiler/src/workflow-visitor.ts deleted file mode 100644 index 1e499115c..000000000 --- a/packages/@eventual/compiler/src/workflow-visitor.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { - ArrowFunctionExpression, - AwaitExpression, - CallExpression, - Expression, - FunctionExpression, - Param, - TsType, - FunctionDeclaration, - BlockStatement, - VariableDeclaration, -} from "@swc/core"; - -import { Visitor } from "@swc/core/Visitor.js"; -import { getSpan, isAsyncFunctionDecl, isWorkflowCall } from "./ast-util.js"; - -const supportedPromiseFunctions: string[] = [ - "all", - "allSettled", - "any", - "race", -]; - -export class OuterVisitor extends Visitor { - private readonly inner = new InnerVisitor(); - - public foundEventual = false; - - public visitCallExpression(call: CallExpression): Expression { - if (isWorkflowCall(call)) { - this.foundEventual = true; - - const [name, options, func] = - call.arguments.length === 2 - ? [call.arguments[0], undefined, call.arguments[1]] - : [call.arguments[0], call.arguments[1], call.arguments[2]]; - - // workflow("id", async () => { .. }) - return { - ...call, - arguments: [ - // workflow name, e.g. "id" - name, - ...(options ? [options] : []), - { - spread: func!.spread, - // transform the function into a generator - // e.g. async () => { .. } becomes function*() { .. } - expression: this.inner.visitWorkflow( - func!.expression as ArrowFunctionExpression | FunctionExpression - ), - }, - ], - }; - } - return super.visitCallExpression(call); - } - - public visitTsType(n: TsType): TsType { - return n; - } -} - -export class InnerVisitor extends Visitor { - public visitTsType(n: TsType): TsType { - return n; - } - - public visitWorkflow( - workflow: FunctionExpression | ArrowFunctionExpression - ): FunctionExpression { - return { - type: "FunctionExpression", - generator: true, - span: workflow.span, - async: false, - identifier: - workflow.type === "FunctionExpression" - ? workflow.identifier - : undefined, - decorators: - workflow.type === "FunctionExpression" - ? workflow.decorators - : undefined, - body: workflow.body - ? workflow.body.type === "BlockStatement" - ? this.visitBlockStatement(workflow.body) - : { - type: "BlockStatement", - span: getSpan(workflow.body), - stmts: [ - { - type: "ReturnStatement", - span: getSpan(workflow.body), - argument: this.visitExpression(workflow.body), - }, - ], - } - : undefined, - params: workflow.params.map((p) => - p.type === "Parameter" - ? this.visitParameter(p) - : { - pat: this.visitPattern(p), - span: getSpan(p), - type: "Parameter", - } - ), - }; - } - - public visitAwaitExpression(awaitExpr: AwaitExpression): Expression { - return { - type: "YieldExpression", - delegate: false, - span: awaitExpr.span, - argument: this.visitExpression(awaitExpr.argument), - }; - } - - public visitCallExpression(call: CallExpression): Expression { - if ( - call.callee.type === "MemberExpression" && - call.callee.object.type === "Identifier" && - call.callee.object.value === "Promise" && - call.callee.property.type === "Identifier" - ) { - if ( - supportedPromiseFunctions.includes(call.callee.property.value as any) - ) { - call.callee.object.value = "$Eventual"; - } - } - return super.visitCallExpression(call); - } - - public visitFunctionExpression( - funcExpr: FunctionExpression - ): FunctionExpression { - return funcExpr.async - ? this.createChain({ - ...super.visitFunctionExpression(funcExpr), - async: false, - generator: true, - }) - : (this.visitFunctionExpression(funcExpr) as any); // SWC's types are broken, we can return any Expression here - } - - public visitArrowFunctionExpression( - funcExpr: ArrowFunctionExpression - ): Expression { - return funcExpr.async - ? this.createChain(funcExpr) - : super.visitArrowFunctionExpression(funcExpr); - } - - /** - * Hoist async {@link FunctionDeclaration} as {@link VariableDeclaration} {@link chain}s. - */ - public visitBlockStatement(block: BlockStatement): BlockStatement { - const functionStmts = block.stmts.filter(isAsyncFunctionDecl); - - return { - ...block, - stmts: [ - // hoist function decls and turn them into chains - ...functionStmts.map((stmt) => this.createFunctionDeclChain(stmt)), - ...block.stmts - .filter((stmt) => !isAsyncFunctionDecl(stmt)) - .map((stmt) => this.visitStatement(stmt)), - ], - }; - } - - /** - * Turn a {@link FunctionDeclaration} into a {@link VariableDeclaration} wrapped in {@link chain}. - */ - private createFunctionDeclChain( - funcDecl: FunctionDeclaration & { async: true } - ): VariableDeclaration { - return { - type: "VariableDeclaration", - span: funcDecl.span, - kind: "const", - declarations: [ - { - type: "VariableDeclarator", - span: funcDecl.span, - definite: false, - id: funcDecl.identifier, - init: this.createChain(funcDecl), - }, - ], - declare: false, - }; - } - - private createChain( - funcExpr: FunctionExpression | ArrowFunctionExpression | FunctionDeclaration - ): CallExpression { - const call: CallExpression = { - type: "CallExpression", - span: funcExpr.span, - callee: { - type: "Identifier", - value: "$eventual", - optional: false, - span: funcExpr.span, - }, - arguments: [ - { - expression: { - type: "FunctionExpression", - span: funcExpr.span, - identifier: - funcExpr.type === "FunctionExpression" - ? funcExpr.identifier - : undefined, - async: false, - generator: true, - body: - funcExpr.body?.type === "BlockStatement" - ? this.visitBlockStatement(funcExpr.body) - : funcExpr.body - ? { - type: "BlockStatement", - span: getSpan(funcExpr.body), - stmts: [ - { - type: "ReturnStatement", - span: getSpan(funcExpr.body), - argument: this.visitExpression(funcExpr.body), - }, - ], - } - : undefined, - params: funcExpr.params.map((param) => - param.type === "Parameter" - ? this.visitParameter(param) - : { - type: "Parameter", - pat: this.visitPattern(param), - span: (param).span ?? funcExpr.span, - } - ), - }, - }, - ], - }; - return call; - } -} diff --git a/packages/@eventual/compiler/test-files/json-file.json b/packages/@eventual/compiler/test-files/json-file.json deleted file mode 100644 index 0967ef424..000000000 --- a/packages/@eventual/compiler/test-files/json-file.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/packages/@eventual/compiler/test-files/not-workflow.ts b/packages/@eventual/compiler/test-files/not-workflow.ts deleted file mode 100644 index adccf4c92..000000000 --- a/packages/@eventual/compiler/test-files/not-workflow.ts +++ /dev/null @@ -1,7 +0,0 @@ -// @ts-nocheck - -const doWork = async () => {}; - -export default function () { - console.log(doWork); -} diff --git a/packages/@eventual/compiler/test-files/open-account.ts b/packages/@eventual/compiler/test-files/open-account.ts deleted file mode 100644 index 7a57c0ad2..000000000 --- a/packages/@eventual/compiler/test-files/open-account.ts +++ /dev/null @@ -1,29 +0,0 @@ -// @ts-nocheck - -export default workflow( - "open-account", - async ({ accountId, address, email, bankDetails }: OpenAccountRequest) => { - const rollbacks: RollbackHandler[] = []; - - try { - await createAccount(accountId); - } catch (err) { - console.error(err); - throw err; - } - - try { - await addAddress(accountId, address); - rollbacks.push(async () => removeAddress(accountId)); - - await addEmail(accountId, email); - rollbacks.push(async () => removeEmail(accountId)); - - await addBankAccount(accountId, bankDetails); - rollbacks.push(async () => removeBankAccount(accountId)); - } catch (err) { - // roll back procedures are independent of each other, run them in parallel - await Promise.all(rollbacks.map((rollback) => rollback())); - } - } -); diff --git a/packages/@eventual/compiler/test-files/workflow.mts b/packages/@eventual/compiler/test-files/workflow.mts deleted file mode 100644 index f20e27871..000000000 --- a/packages/@eventual/compiler/test-files/workflow.mts +++ /dev/null @@ -1,37 +0,0 @@ -// @ts-nocheck - -const doWork = activity("doWork", async (input: any) => input); - -export default workflow("workflow", async (input) => { - const items = await doWork(input); - - await Promise.all( - items.map(async (item) => { - await doWork(item); - }) - ); - // function expression - await Promise.all( - items.map(async function (item) { - await doWork(item); - }) - ); - - await Promise.allSettled( - items.map(async (item) => { - await doWork(item); - }) - ); - - await Promise.any( - items.map(async (item) => { - await doWork(item); - }) - ); - - await Promise.race( - items.map(async (item) => { - await doWork(item); - }) - ); -}); diff --git a/packages/@eventual/compiler/test-files/workflow.ts b/packages/@eventual/compiler/test-files/workflow.ts deleted file mode 100644 index f882e963d..000000000 --- a/packages/@eventual/compiler/test-files/workflow.ts +++ /dev/null @@ -1,74 +0,0 @@ -// @ts-nocheck - -const doWork = activity("doWork", async (input: any) => input); - -export default workflow("workflow", async (input) => { - const items = await doWork(input); - - await Promise.all( - items.map(async (item) => { - await doWork(item); - }) - ); - // function expression - await Promise.all( - items.map(async function (item) { - await doWork(item); - }) - ); - - await Promise.allSettled( - items.map(async (item) => { - await doWork(item); - }) - ); - - await Promise.any( - items.map(async (item) => { - await doWork(item); - }) - ); - - await Promise.race( - items.map(async (item) => { - await doWork(item); - }) - ); - - condition(() => true); - - const func = () => - Promise.all( - items.map(async (item) => { - await doWork(item); - }) - ); - - await func(); - - const func2 = async () => { - await Promise.all( - items.map(async (item) => { - await doWork(item); - }) - ); - }; - - await func2(); -}); - -export const workflow2 = workflow( - "timeoutFlow", - { timeout: duration(100, "seconds") }, - async () => { - await doWork("something"); - } -); - -export const workflow3 = workflow("timeoutFlow", async () => { - await callMe(); - - async function callMe() { - await duration(20, "seconds"); - } -}); diff --git a/packages/@eventual/compiler/test/__snapshots__/esbuild-plugin.test.ts.snap b/packages/@eventual/compiler/test/__snapshots__/esbuild-plugin.test.ts.snap deleted file mode 100644 index 7188a34e5..000000000 --- a/packages/@eventual/compiler/test/__snapshots__/esbuild-plugin.test.ts.snap +++ /dev/null @@ -1,126 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`esbuild-plugin json file 1`] = ` -"(() => { - var json_file_default = {}; -})(); -" -`; - -exports[`esbuild-plugin mts workflow 1`] = ` -""use strict"; -(() => { - var doWork = activity("doWork", async (input) => input); - var workflow_default = workflow("workflow", function* (input) { - const items = yield doWork(input); - yield $Eventual.all(items.map($eventual(function* (item) { - yield doWork(item); - }))); - yield $Eventual.all(items.map($eventual(function* (item) { - yield doWork(item); - }))); - yield $Eventual.allSettled(items.map($eventual(function* (item) { - yield doWork(item); - }))); - yield $Eventual.any(items.map($eventual(function* (item) { - yield doWork(item); - }))); - yield $Eventual.race(items.map($eventual(function* (item) { - yield doWork(item); - }))); - }); -})(); -" -`; - -exports[`esbuild-plugin open-account 1`] = ` -""use strict"; -(() => { - var open_account_default = workflow("open-account", function* ({ accountId, address, email, bankDetails }) { - const rollbacks = []; - try { - yield createAccount(accountId); - } catch (err) { - console.error(err); - throw err; - } - try { - yield addAddress(accountId, address); - rollbacks.push($eventual(function* () { - return removeAddress(accountId); - })); - yield addEmail(accountId, email); - rollbacks.push($eventual(function* () { - return removeEmail(accountId); - })); - yield addBankAccount(accountId, bankDetails); - rollbacks.push($eventual(function* () { - return removeBankAccount(accountId); - })); - } catch (err) { - yield $Eventual.all(rollbacks.map((rollback) => rollback())); - } - }); -})(); -" -`; - -exports[`esbuild-plugin ts not workflow 1`] = ` -""use strict"; -(() => { - var doWork = async () => { - }; - function not_workflow_default() { - console.log(doWork); - } -})(); -" -`; - -exports[`esbuild-plugin ts workflow 1`] = ` -""use strict"; -(() => { - var doWork = activity("doWork", async (input) => input); - var workflow_default = workflow("workflow", function* (input) { - const items = yield doWork(input); - yield $Eventual.all(items.map($eventual(function* (item) { - yield doWork(item); - }))); - yield $Eventual.all(items.map($eventual(function* (item) { - yield doWork(item); - }))); - yield $Eventual.allSettled(items.map($eventual(function* (item) { - yield doWork(item); - }))); - yield $Eventual.any(items.map($eventual(function* (item) { - yield doWork(item); - }))); - yield $Eventual.race(items.map($eventual(function* (item) { - yield doWork(item); - }))); - condition(() => true); - const func = () => $Eventual.all(items.map($eventual(function* (item) { - yield doWork(item); - }))); - yield func(); - const func2 = $eventual(function* () { - yield $Eventual.all(items.map($eventual(function* (item) { - yield doWork(item); - }))); - }); - yield func2(); - }); - var workflow2 = workflow("timeoutFlow", { - timeout: duration(100, "seconds") - }, function* () { - yield doWork("something"); - }); - var workflow3 = workflow("timeoutFlow", function* () { - const callMe = $eventual(function* () { - yield duration(20, "seconds"); - }); - yield callMe(); - }); -})(); -" -`; diff --git a/packages/@eventual/compiler/test/esbuild-plugin.test.ts b/packages/@eventual/compiler/test/esbuild-plugin.test.ts deleted file mode 100644 index dd04edb7c..000000000 --- a/packages/@eventual/compiler/test/esbuild-plugin.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import "jest"; - -import path from "path"; -import esbuild from "esbuild"; -import { eventualESPlugin } from "../src/esbuild-plugin.js"; -import url from "url"; - -const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); - -describe("esbuild-plugin", () => { - test("ts workflow", async () => { - const bundle = await esbuild.build({ - mainFields: ["module", "main"], - entryPoints: [path.resolve(__dirname, "..", "test-files", "workflow.ts")], - sourcemap: false, - plugins: [eventualESPlugin], - bundle: true, - write: false, - }); - - expect(sanitizeBundle(bundle)).toMatchSnapshot(); - }); - - test("ts not workflow", async () => { - const bundle = await esbuild.build({ - mainFields: ["module", "main"], - entryPoints: [ - path.resolve(__dirname, "..", "test-files", "not-workflow.ts"), - ], - sourcemap: false, - plugins: [eventualESPlugin], - bundle: true, - write: false, - }); - - expect(sanitizeBundle(bundle)).toMatchSnapshot(); - }); - - test("mts workflow", async () => { - const bundle = await esbuild.build({ - mainFields: ["module", "main"], - entryPoints: [ - path.resolve(__dirname, "..", "test-files", "workflow.mts"), - ], - sourcemap: false, - plugins: [eventualESPlugin], - bundle: true, - write: false, - }); - - expect(sanitizeBundle(bundle)).toMatchSnapshot(); - }); - - test("json file", async () => { - const bundle = await esbuild.build({ - mainFields: ["module", "main"], - entryPoints: [ - path.resolve(__dirname, "..", "test-files", "json-file.json"), - ], - sourcemap: false, - plugins: [eventualESPlugin], - bundle: true, - write: false, - }); - - expect(sanitizeBundle(bundle)).toMatchSnapshot(); - }); - - test("open-account", async () => { - const bundle = await esbuild.build({ - mainFields: ["module", "main"], - entryPoints: [ - path.resolve(__dirname, "..", "test-files", "open-account.ts"), - ], - sourcemap: false, - plugins: [eventualESPlugin], - bundle: true, - write: false, - }); - - expect(sanitizeBundle(bundle)).toMatchSnapshot(); - }); -}); - -function sanitizeBundle( - bundle: esbuild.BuildResult & { - outputFiles: esbuild.OutputFile[]; - } -) { - return ( - bundle - .outputFiles![0]?.text.split("\n") - // HACK: filter out comment that is breaking the tests when run from VS Code - // TODO: figure out why running vs code test is having trouble identifying the right - // tsconfig.test.json without a configuration at the root. - // HINT: something to do with `.vscode/launch.json` - .filter((line) => !line.includes("test-files/")) - .join("\n") - ); -} diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index 398376de9..af661e6aa 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -20,13 +20,15 @@ import { isWorkflowRunStarted, isWorkflowTimedOut, iterator, - registerWorkflowHook, Result, + tryGetWorkflowHook, WorkflowCommand, WorkflowEvent, + enterWorkflowHookScope, WorkflowRunStarted, WorkflowTimedOut, _Iterator, + ExecutionWorkflowHook, } from "@eventual/core/internal"; import { isPromise } from "util/types"; import { createEventualFromCall, isCorresponding } from "./eventual-factory.js"; @@ -170,9 +172,8 @@ export class WorkflowExecutor { return new Promise(async (resolve) => { // start context with execution hook - await this.registerExecutionHook(); + console.log(tryGetWorkflowHook()); this.started = { - // TODO, also cancel? resolve: (result) => { const newCommands = this.commandsToEmit; this.commandsToEmit = []; @@ -181,7 +182,13 @@ export class WorkflowExecutor { }, }; try { - const workflowPromise = this.workflow.definition(input, context); + console.log(tryGetWorkflowHook()); + // ensure the workflow hook is available to the workflow + // and tied to the workflow promise context + const workflowPromise = this.enterWorkflowHookScope(() => { + return this.workflow.definition(input, context); + }); + console.log(tryGetWorkflowHook()); workflowPromise.then( (result) => this.forceComplete(Result.resolved(result)), (err) => this.forceComplete(Result.failed(err)) @@ -225,12 +232,12 @@ export class WorkflowExecutor { /** * Continue a previously started workflow by feeding in new {@link HistoryResultEvent}, possibly advancing the execution. - * + * * This allows the workflow to continue without re-running previous history. - * + * * Events will be applied to the workflow in order. - * - * @returns {@link WorkflowResult} - containing new commands and a result of one was generated. + * + * @returns {@link WorkflowResult} - containing new commands and a result of one was generated. */ public async continue( ...history: HistoryResultEvent[] @@ -246,7 +253,6 @@ export class WorkflowExecutor { this.history.push(...history); return new Promise(async (resolve) => { - await this.registerExecutionHook(); await this.drainHistoryEvents(); const newCommands = this.commandsToEmit; @@ -266,16 +272,24 @@ export class WorkflowExecutor { this.started.resolve(result); } - private async registerExecutionHook() { + private async enterWorkflowHookScope(callback: (...args: any) => R) { const self = this; - await registerWorkflowHook({ + const workflowHook: ExecutionWorkflowHook = { registerEventualCall: (call) => { try { const eventual = createEventualFromCall(call); const seq = this.seq++; - // if the call is new, generate and emit it's commands - // if the eventual does not generate commands, do not check it against the expected events. + /** + * if the call is new, generate and emit it's commands + * if the eventual does not generate commands, do not check it against the expected events. + * Note: this contract is too abstracted, call => command => schedule event + * where the scheduled event is generated by the orchestrator. + * I'd love to be able to match calls to call or something + * instead of matching events to calls in {@link }. + * One thought would be to get rid of the scheduled events + * and instead maintain the call history to match against. + */ if (eventual.generateCommands && !isExpectedCall(seq, call)) { this.commandsToEmit.push( ...normalizeToArray(eventual.generateCommands(seq)) @@ -325,7 +339,8 @@ export class WorkflowExecutor { resolveEventual: (seq, result) => { this.tryResolveEventual(seq, result); }, - }); + }; + return await enterWorkflowHookScope(workflowHook, callback); /** * Checks the call against the expected events. diff --git a/packages/@eventual/core/src/internal/eventual-hook.ts b/packages/@eventual/core/src/internal/eventual-hook.ts index 6974f6c46..9a5706fcf 100644 --- a/packages/@eventual/core/src/internal/eventual-hook.ts +++ b/packages/@eventual/core/src/internal/eventual-hook.ts @@ -2,7 +2,7 @@ import type { AsyncLocalStorage } from "async_hooks"; import { EventualCall } from "./calls/calls.js"; import { Result } from "./result.js"; -let storage: AsyncLocalStorage; +let storage: AsyncLocalStorage | undefined; export const EventualPromiseSymbol = Symbol.for("Eventual:Promise"); @@ -28,7 +28,7 @@ export interface ExecutionWorkflowHook { } export function tryGetWorkflowHook() { - return storage.getStore(); + return storage?.getStore(); } export function getWorkflowHook() { @@ -43,11 +43,12 @@ export function getWorkflowHook() { return hook; } -export async function registerWorkflowHook( - eventualHook: ExecutionWorkflowHook +export async function enterWorkflowHookScope( + eventualHook: ExecutionWorkflowHook, + callback: (...args: any[]) => R ) { if (!storage) { storage = new (await import("async_hooks")).AsyncLocalStorage(); } - storage.enterWith(eventualHook); + return storage.run(eventualHook, callback); } diff --git a/packages/@eventual/core/src/signals.ts b/packages/@eventual/core/src/signals.ts index 5119b4914..c613bfaa4 100644 --- a/packages/@eventual/core/src/signals.ts +++ b/packages/@eventual/core/src/signals.ts @@ -13,7 +13,7 @@ export interface SignalsHandler { /** * Remove the handler from the signal. * - * Any ongoing {@link Chain}s started by the handler will continue to run to completion. + * Any ongoing {@link Promise}s started by the handler will continue to run to completion. */ dispose: () => void; } @@ -32,7 +32,7 @@ export class Signal { * Listens for signals sent to the current workflow. * * When the signal is received, the handler is invoked. - * If the handler return a promise, the handler is added a {@link Chain} + * If the handler return a promise, the handler is added a {@link Promise} * and progressed until completion. * * ```ts @@ -186,7 +186,7 @@ export function expectSignal( * Listens for a signal matching the signalId provided. * * When the signal is received, the handler is invoked. - * If the handler return a promise, the handler is added as a {@link Chain} + * If the handler return a promise, the handler is added as a {@link Promise} * and progressed until completion. * * ```ts diff --git a/packages/@eventual/testing/src/environment.ts b/packages/@eventual/testing/src/environment.ts index eaa7c5436..023d8f35f 100644 --- a/packages/@eventual/testing/src/environment.ts +++ b/packages/@eventual/testing/src/environment.ts @@ -1,4 +1,3 @@ -import { bundleService } from "@eventual/compiler"; import { Activity, ActivityOutput, @@ -34,14 +33,7 @@ import { WorkflowClient, WorkflowTask, } from "@eventual/core-runtime"; -import { - clearEventHandlers, - events, - registerServiceClient, - ServiceType, - workflows, -} from "@eventual/core/internal"; -import path from "path"; +import { registerServiceClient } from "@eventual/core/internal"; import { TestActivityClient } from "./clients/activity-client.js"; import { TestEventClient } from "./clients/event-client.js"; import { TestExecutionQueueClient } from "./clients/execution-queue-client.js"; @@ -66,8 +58,6 @@ export interface TestEnvironmentProps { * @default testing */ serviceName?: string; - entry: string; - outDir?: string; /** * Start time, starting at the nearest second (rounded down). * @@ -93,8 +83,6 @@ export interface TestEnvironmentProps { * ``` */ export class TestEnvironment extends RuntimeServiceClient { - private serviceFile: Promise; - private executionHistoryStore: ExecutionHistoryStore; private executionStore: ExecutionStore; @@ -111,8 +99,8 @@ export class TestEnvironment extends RuntimeServiceClient { private orchestrator: Orchestrator; - constructor(props: TestEnvironmentProps) { - const start = props.start + constructor(props?: TestEnvironmentProps) { + const start = props?.start ? new Date(props.start.getTime() - props.start.getMilliseconds()) : new Date(0); @@ -171,7 +159,7 @@ export class TestEnvironment extends RuntimeServiceClient { executionQueueClient, activityProvider, logAgent: testLogAgent, - serviceName: props.serviceName ?? "testing", + serviceName: props?.serviceName ?? "testing", }); const activityClient = new TestActivityClient( @@ -195,15 +183,6 @@ export class TestEnvironment extends RuntimeServiceClient { workflowProvider, }); - this.serviceFile = bundleService( - props.outDir ?? path.resolve(".eventual"), - props.entry, - undefined, // testing does not currently use the app spec - ServiceType.OrchestratorWorker, - undefined, - true - ); - this.executionStore = executionStore; this.executionHistoryStore = executionHistoryStore; @@ -234,7 +213,7 @@ export class TestEnvironment extends RuntimeServiceClient { logAgent: testLogAgent, executionHistoryStateStore, workflowProvider, - serviceName: props.serviceName ?? "testing", + serviceName: props?.serviceName ?? "testing", }); } @@ -245,13 +224,6 @@ export class TestEnvironment extends RuntimeServiceClient { public async initialize() { if (!this.initialized) { registerServiceClient(this); - const _workflows = workflows(); - _workflows.clear(); - const _events = events(); - _events.clear(); - clearEventHandlers(); - // run the service to re-import the workflows, but transformed - await import(await this.serviceFile); this.initialized = true; } } diff --git a/packages/@eventual/testing/test/env.test.ts b/packages/@eventual/testing/test/env.test.ts index cba92f7eb..4690f69d1 100644 --- a/packages/@eventual/testing/test/env.test.ts +++ b/packages/@eventual/testing/test/env.test.ts @@ -1,4 +1,4 @@ -import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; +import type { SQSClient } from "@aws-sdk/client-sqs"; import { EventPayloadType, EventualError, @@ -8,11 +8,25 @@ import { Timeout, } from "@eventual/core"; import { jest } from "@jest/globals"; -import path from "path"; -import * as url from "url"; import { TestEnvironment } from "../src/environment.js"; import { MockActivity } from "../src/providers/activity-provider.js"; -import { + +const fakeSqsClientSend = jest.fn(); +jest.unstable_mockModule("@aws-sdk/client-sqs", () => { + return { + ...(jest.requireActual("@aws-sdk/client-sqs") as any), + SQSClient: jest + .fn() + .mockImplementation(() => ({ send: fakeSqsClientSend })), + }; +}); +const { SendMessageCommand } = await import("@aws-sdk/client-sqs"); + +// using a dynamic import for the service/workflows because +// 1. this is an esm module https://jestjs.io/docs/ecmascript-modules#module-mocking-in-esm +// 2. we want the test env to use the mocked SQS client instead of the real one +// if YOU are not using mocked modules, the service can be imported normally. +const { activity1, actWithTimeout, continueEvent, @@ -33,33 +47,13 @@ import { workflow1, workflow3, workflowWithTimeouts, -} from "./workflow.js"; - -const fakeSqsClientSend = jest.fn(); - -jest.mock("@aws-sdk/client-sqs", () => { - return { - ...(jest.requireActual("@aws-sdk/client-sqs") as any), - SQSClient: jest - .fn() - .mockImplementation(() => ({ send: fakeSqsClientSend })), - }; -}); +} = await import("./workflow.js"); let env: TestEnvironment; // if there is pollution between tests, call reset() beforeAll(async () => { - env = new TestEnvironment({ - entry: path.resolve( - url.fileURLToPath(new URL(".", import.meta.url)), - "./workflow.ts" - ), - outDir: path.resolve( - url.fileURLToPath(new URL(".", import.meta.url)), - ".eventual" - ), - }); + env = new TestEnvironment(); await env.initialize(); }); From ce4a7d11ccfcbcb2a041d3610cd781ca90dbadb0 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Sun, 5 Mar 2023 00:59:42 -0600 Subject: [PATCH 07/28] more test refactoring --- .../core-runtime/src/workflow-executor.ts | 8 ++------ packages/@eventual/testing/src/environment.ts | 19 ++++--------------- packages/@eventual/testing/test/env.test.ts | 2 -- .../src/create-new-aws-cdk-project.ts | 6 +----- 4 files changed, 7 insertions(+), 28 deletions(-) diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index af661e6aa..3d39e45ff 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -7,8 +7,10 @@ import { import { assertNever, createEventualPromise, + enterWorkflowHookScope, EventualCall, EventualPromise, + ExecutionWorkflowHook, HistoryEvent, HistoryResultEvent, HistoryScheduledEvent, @@ -21,14 +23,11 @@ import { isWorkflowTimedOut, iterator, Result, - tryGetWorkflowHook, WorkflowCommand, WorkflowEvent, - enterWorkflowHookScope, WorkflowRunStarted, WorkflowTimedOut, _Iterator, - ExecutionWorkflowHook, } from "@eventual/core/internal"; import { isPromise } from "util/types"; import { createEventualFromCall, isCorresponding } from "./eventual-factory.js"; @@ -172,7 +171,6 @@ export class WorkflowExecutor { return new Promise(async (resolve) => { // start context with execution hook - console.log(tryGetWorkflowHook()); this.started = { resolve: (result) => { const newCommands = this.commandsToEmit; @@ -182,13 +180,11 @@ export class WorkflowExecutor { }, }; try { - console.log(tryGetWorkflowHook()); // ensure the workflow hook is available to the workflow // and tied to the workflow promise context const workflowPromise = this.enterWorkflowHookScope(() => { return this.workflow.definition(input, context); }); - console.log(tryGetWorkflowHook()); workflowPromise.then( (result) => this.forceComplete(Result.resolved(result)), (err) => this.forceComplete(Result.failed(err)) diff --git a/packages/@eventual/testing/src/environment.ts b/packages/@eventual/testing/src/environment.ts index 023d8f35f..2f4db40cb 100644 --- a/packages/@eventual/testing/src/environment.ts +++ b/packages/@eventual/testing/src/environment.ts @@ -13,7 +13,7 @@ import { SendSignalRequest, StartExecutionRequest, SubscriptionHandler, - Workflow, + Workflow } from "@eventual/core"; import { ActivityClient, @@ -31,7 +31,7 @@ import { RuntimeServiceClient, TimerClient, WorkflowClient, - WorkflowTask, + WorkflowTask } from "@eventual/core-runtime"; import { registerServiceClient } from "@eventual/core/internal"; import { TestActivityClient } from "./clients/activity-client.js"; @@ -42,7 +42,7 @@ import { TestMetricsClient } from "./clients/metrics-client.js"; import { TestTimerClient } from "./clients/timer-client.js"; import { MockableActivityProvider, - MockActivity, + MockActivity } from "./providers/activity-provider.js"; import { TestSubscriptionProvider } from "./providers/subscription-provider.js"; import { TestActivityStore } from "./stores/activity-store.js"; @@ -73,7 +73,6 @@ export interface TestEnvironmentProps { * * ```ts * const env = new TestEnvironment(...); - * await env.initialize(); * * // start a workflow * await env.startExecution(workflow, input); @@ -94,7 +93,6 @@ export class TestEnvironment extends RuntimeServiceClient { private activityProvider: MockableActivityProvider; private eventHandlerProvider: TestSubscriptionProvider; - private initialized = false; private timeController: TimeController; private orchestrator: Orchestrator; @@ -215,17 +213,8 @@ export class TestEnvironment extends RuntimeServiceClient { workflowProvider, serviceName: props?.serviceName ?? "testing", }); - } - /** - * Initializes a {@link TestEnvironment}, bootstrapping the workflows and event handlers - * in the provided service entry point file. - */ - public async initialize() { - if (!this.initialized) { - registerServiceClient(this); - this.initialized = true; - } + registerServiceClient(this); } /** diff --git a/packages/@eventual/testing/test/env.test.ts b/packages/@eventual/testing/test/env.test.ts index 4690f69d1..801197fcf 100644 --- a/packages/@eventual/testing/test/env.test.ts +++ b/packages/@eventual/testing/test/env.test.ts @@ -54,8 +54,6 @@ let env: TestEnvironment; // if there is pollution between tests, call reset() beforeAll(async () => { env = new TestEnvironment(); - - await env.initialize(); }); afterEach(() => { diff --git a/packages/create-eventual/src/create-new-aws-cdk-project.ts b/packages/create-eventual/src/create-new-aws-cdk-project.ts index 2c0456cab..9fe10dc6d 100644 --- a/packages/create-eventual/src/create-new-aws-cdk-project.ts +++ b/packages/create-eventual/src/create-new-aws-cdk-project.ts @@ -403,11 +403,7 @@ let env: TestEnvironment; // if there is pollution between tests, call reset() beforeAll(async () => { - env = new TestEnvironment({ - entry: require.resolve("../src"), - }); - - await env.initialize(); + env = new TestEnvironment(); }); test("hello workflow should publish helloEvent and return message", async () => { From 8da98d8e9b040e0f8f147bd4046369f2515bb5f0 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Sun, 5 Mar 2023 01:31:59 -0600 Subject: [PATCH 08/28] fix replay bug --- apps/tests/aws-runtime/scripts/test-cli | 14 ++++++------- packages/@eventual/cli/src/commands/replay.ts | 2 +- .../core/src/internal/eventual-hook.ts | 21 ++++++++++++++----- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/tests/aws-runtime/scripts/test-cli b/apps/tests/aws-runtime/scripts/test-cli index f2982419d..5ffd6f37b 100755 --- a/apps/tests/aws-runtime/scripts/test-cli +++ b/apps/tests/aws-runtime/scripts/test-cli @@ -8,9 +8,13 @@ npx eventual list services npx eventual list workflows -npx eventual list executions +npx eventual start workflow sleepy + +npx eventual start workflow sleepy -f + +npx eventual list executions -npx eventual list executions --workflow parallel --json > .eventual/out.json +npx eventual list executions --workflow sleepy --json > .eventual/out.json execution_id=$(node -p 'JSON.parse(require("fs").readFileSync(".eventual/out.json").toString("utf8")).executions[0].id') @@ -18,12 +22,8 @@ npx eventual get history -e ${execution_id} npx eventual get logs --all -npx eventual get logs --workflow parallel +npx eventual get logs --workflow sleepy npx eventual get logs --execution ${execution_id} -npx eventual start workflow sleepy - -npx eventual start workflow sleepy -f - npx eventual replay execution ${execution_id} --entry 'test/test-service.ts' \ No newline at end of file diff --git a/packages/@eventual/cli/src/commands/replay.ts b/packages/@eventual/cli/src/commands/replay.ts index 683e2d1c7..dec50349b 100644 --- a/packages/@eventual/cli/src/commands/replay.ts +++ b/packages/@eventual/cli/src/commands/replay.ts @@ -62,7 +62,7 @@ export const replay = (yargs: Argv) => } spinner.start("Running program"); - serviceTypeScope(ServiceType.OrchestratorWorker, async () => { + await serviceTypeScope(ServiceType.OrchestratorWorker, async () => { const processedEvents = processEvents( events, [], diff --git a/packages/@eventual/core/src/internal/eventual-hook.ts b/packages/@eventual/core/src/internal/eventual-hook.ts index 9a5706fcf..440a1c17e 100644 --- a/packages/@eventual/core/src/internal/eventual-hook.ts +++ b/packages/@eventual/core/src/internal/eventual-hook.ts @@ -2,7 +2,16 @@ import type { AsyncLocalStorage } from "async_hooks"; import { EventualCall } from "./calls/calls.js"; import { Result } from "./result.js"; -let storage: AsyncLocalStorage | undefined; +/** + * In the case that the workflow is bundled with a different instance of eventual/core, + * put the store in globals. + */ +declare global { + // eslint-disable-next-line no-var + var eventualWorkflowHookStore: + | AsyncLocalStorage + | undefined; +} export const EventualPromiseSymbol = Symbol.for("Eventual:Promise"); @@ -28,7 +37,7 @@ export interface ExecutionWorkflowHook { } export function tryGetWorkflowHook() { - return storage?.getStore(); + return globalThis.eventualWorkflowHookStore?.getStore(); } export function getWorkflowHook() { @@ -47,8 +56,10 @@ export async function enterWorkflowHookScope( eventualHook: ExecutionWorkflowHook, callback: (...args: any[]) => R ) { - if (!storage) { - storage = new (await import("async_hooks")).AsyncLocalStorage(); + if (!globalThis.eventualWorkflowHookStore) { + globalThis.eventualWorkflowHookStore = new ( + await import("async_hooks") + ).AsyncLocalStorage(); } - return storage.run(eventualHook, callback); + return globalThis.eventualWorkflowHookStore.run(eventualHook, callback); } From c4ea0ea3b2abcf767e42730ac0033cb7c1a32a93 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Sun, 5 Mar 2023 02:14:43 -0600 Subject: [PATCH 09/28] signal handler access to workflow hook --- .../core-runtime/src/workflow-executor.ts | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index 3d39e45ff..918590fca 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -179,23 +179,25 @@ export class WorkflowExecutor { resolve({ commands: newCommands, result }); }, }; - try { - // ensure the workflow hook is available to the workflow - // and tied to the workflow promise context - const workflowPromise = this.enterWorkflowHookScope(() => { - return this.workflow.definition(input, context); - }); - workflowPromise.then( - (result) => this.forceComplete(Result.resolved(result)), - (err) => this.forceComplete(Result.failed(err)) - ); - } catch (err) { - // handle any synchronous errors. - this.forceComplete(Result.failed(err)); - } + // ensure the workflow hook is available to the workflow + // and tied to the workflow promise context + // Also ensure that any handlers like signal handlers returned by the workflow + // have access to the workflow hook + await this.enterWorkflowHookScope(async () => { + try { + const workflowPromise = this.workflow.definition(input, context); + workflowPromise.then( + (result) => this.forceComplete(Result.resolved(result)), + (err) => this.forceComplete(Result.failed(err)) + ); + } catch (err) { + // handle any synchronous errors. + this.forceComplete(Result.failed(err)); + } - // APPLY EVENTS - await this.drainHistoryEvents(); + // APPLY EVENTS + await this.drainHistoryEvents(); + }); // let everything that has started or will be started complete // set timeout adds the closure to the end of the event loop @@ -249,7 +251,9 @@ export class WorkflowExecutor { this.history.push(...history); return new Promise(async (resolve) => { - await this.drainHistoryEvents(); + // Also ensure that any handlers like signal handlers returned by the workflow + // have access to the workflow hook + await this.enterWorkflowHookScope(() => this.drainHistoryEvents()); const newCommands = this.commandsToEmit; this.commandsToEmit = []; From 95116f25127e48fe6ff7c121db78a6b8b93a886e Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Sun, 5 Mar 2023 02:38:32 -0600 Subject: [PATCH 10/28] move commands to core runtime --- .../src/clients/activity-client.ts | 7 +++++- .../core-runtime/src/command-executor.ts | 24 ++++++++++--------- .../core-runtime/src/eventual-factory.ts | 2 +- .../core-runtime/src/handlers/orchestrator.ts | 2 +- packages/@eventual/core-runtime/src/index.ts | 2 ++ .../src}/workflow-command.ts | 8 +++---- .../core-runtime/src/workflow-executor.ts | 18 +++++--------- .../core-runtime/test/command-util.ts | 14 ++++++----- .../test/commend-executor.test.ts | 2 +- .../core/src/internal/calls/calls.ts | 22 +++++++---------- packages/@eventual/core/src/internal/index.ts | 2 -- 11 files changed, 51 insertions(+), 52 deletions(-) rename packages/@eventual/{core/src/internal => core-runtime/src}/workflow-command.ts (88%) diff --git a/packages/@eventual/core-runtime/src/clients/activity-client.ts b/packages/@eventual/core-runtime/src/clients/activity-client.ts index f022ac31f..dd5ba93fc 100644 --- a/packages/@eventual/core-runtime/src/clients/activity-client.ts +++ b/packages/@eventual/core-runtime/src/clients/activity-client.ts @@ -4,10 +4,15 @@ import { SendActivityHeartbeatRequest, SendActivitySuccessRequest, } from "@eventual/core"; -import { ActivityFailed, ActivitySucceeded, ScheduleActivityCommand, WorkflowEventType } from "@eventual/core/internal"; +import { + ActivityFailed, + ActivitySucceeded, + WorkflowEventType, +} from "@eventual/core/internal"; import { decodeActivityToken } from "../activity-token.js"; import { ActivityStore } from "../stores/activity-store.js"; import { ExecutionStore } from "../stores/execution-store.js"; +import { ScheduleActivityCommand } from "../workflow-command.js"; import { createEvent } from "../workflow-events.js"; import { ExecutionQueueClient } from "./execution-queue-client.js"; diff --git a/packages/@eventual/core-runtime/src/command-executor.ts b/packages/@eventual/core-runtime/src/command-executor.ts index beb7bd370..e27231033 100644 --- a/packages/@eventual/core-runtime/src/command-executor.ts +++ b/packages/@eventual/core-runtime/src/command-executor.ts @@ -6,20 +6,9 @@ import { EventsPublished, HistoryStateEvent, isChildExecutionTarget, - isPublishEventsCommand, - isScheduleActivityCommand, - isScheduleWorkflowCommand, - isSendSignalCommand, - isStartTimerCommand, - PublishEventsCommand, - ScheduleActivityCommand, - ScheduleWorkflowCommand, - SendSignalCommand, SignalSent, - StartTimerCommand, TimerCompleted, TimerScheduled, - WorkflowCommand, WorkflowEventType, } from "@eventual/core/internal"; import { @@ -32,6 +21,19 @@ import { TimerClient } from "./clients/timer-client.js"; import { WorkflowClient } from "./clients/workflow-client.js"; import { formatChildExecutionName, formatExecutionId } from "./execution.js"; import { computeScheduleDate } from "./schedule.js"; +import { + isPublishEventsCommand, + isScheduleActivityCommand, + isScheduleWorkflowCommand, + isSendSignalCommand, + isStartTimerCommand, + PublishEventsCommand, + ScheduleActivityCommand, + ScheduleWorkflowCommand, + SendSignalCommand, + StartTimerCommand, + WorkflowCommand, +} from "./workflow-command.js"; import { createEvent } from "./workflow-events.js"; interface CommandExecutorProps { diff --git a/packages/@eventual/core-runtime/src/eventual-factory.ts b/packages/@eventual/core-runtime/src/eventual-factory.ts index 2e09ae8e6..a1fcd1e77 100644 --- a/packages/@eventual/core-runtime/src/eventual-factory.ts +++ b/packages/@eventual/core-runtime/src/eventual-factory.ts @@ -6,7 +6,6 @@ import { } from "@eventual/core"; import { assertNever, - CommandType, EventualCall, isActivityCall, isActivityFailed, @@ -32,6 +31,7 @@ import { Result, ScheduledEvent, } from "@eventual/core/internal"; +import { CommandType } from "./workflow-command.js"; import { Eventual } from "./workflow-executor.js"; export function createEventualFromCall( diff --git a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts index 115e1d24e..2e13be39e 100644 --- a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts +++ b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts @@ -30,7 +30,6 @@ import { Result, ServiceType, serviceTypeScope, - WorkflowCommand, WorkflowEvent, WorkflowEventType, WorkflowFailed, @@ -57,6 +56,7 @@ import { ExecutionHistoryStateStore } from "../stores/execution-history-state-st import { ExecutionHistoryStore } from "../stores/execution-history-store.js"; import { WorkflowTask } from "../tasks.js"; import { groupBy, promiseAllSettledPartitioned } from "../utils.js"; +import { WorkflowCommand } from "../workflow-command.js"; import { createEvent } from "../workflow-events.js"; import { WorkflowExecutor } from "../workflow-executor.js"; diff --git a/packages/@eventual/core-runtime/src/index.ts b/packages/@eventual/core-runtime/src/index.ts index 1a3855b98..cf09ac64a 100644 --- a/packages/@eventual/core-runtime/src/index.ts +++ b/packages/@eventual/core-runtime/src/index.ts @@ -12,3 +12,5 @@ export * from "./stores/index.js"; export * from "./system-commands.js"; export * from "./tasks.js"; export * from "./utils.js"; +export * from "./workflow-command.js"; + diff --git a/packages/@eventual/core/src/internal/workflow-command.ts b/packages/@eventual/core-runtime/src/workflow-command.ts similarity index 88% rename from packages/@eventual/core/src/internal/workflow-command.ts rename to packages/@eventual/core-runtime/src/workflow-command.ts index 1af6c6a56..ead9c708b 100644 --- a/packages/@eventual/core/src/internal/workflow-command.ts +++ b/packages/@eventual/core-runtime/src/workflow-command.ts @@ -1,7 +1,7 @@ -import { EventEnvelope } from "../event.js"; -import { DurationSchedule, Schedule } from "../schedule.js"; -import { WorkflowExecutionOptions } from "../workflow.js"; -import { SignalTarget } from "./signal.js"; +import { EventEnvelope } from "../../core/src/event.js"; +import { DurationSchedule, Schedule } from "../../core/src/schedule.js"; +import { WorkflowExecutionOptions } from "../../core/src/workflow.js"; +import { SignalTarget } from "../../core/src/internal/signal.js"; export type WorkflowCommand = | StartTimerCommand diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index 918590fca..06ad6c116 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -23,7 +23,6 @@ import { isWorkflowTimedOut, iterator, Result, - WorkflowCommand, WorkflowEvent, WorkflowRunStarted, WorkflowTimedOut, @@ -31,6 +30,7 @@ import { } from "@eventual/core/internal"; import { isPromise } from "util/types"; import { createEventualFromCall, isCorresponding } from "./eventual-factory.js"; +import { WorkflowCommand } from "./index.js"; interface ActiveEventual { resolve: (result: R) => void; @@ -106,17 +106,6 @@ function initializeRuntimeState(historyEvents: HistoryEvent[]): RuntimeState { }; } -/** - * 1. get hook - getEventualHook - * 2. register - register eventual call - * a. create eventual with handlers and callbacks - * b. check for completion - the eventual can choose to end early, for example, a condition - * c. create promise - * d. register eventual with the executor - * e. return promise - * 3. return promise to caller/workflow - */ - interface ExecutorOptions { /** * When false, the workflow will auto-cancel when it has exhausted all history events provided @@ -159,6 +148,11 @@ export class WorkflowExecutor { this.commandsToEmit = []; } + /** + * Starts an execution. + * + * The execution will run until completion or until the events are exhausted. + */ public start( input: Input, context: WorkflowContext diff --git a/packages/@eventual/core-runtime/test/command-util.ts b/packages/@eventual/core-runtime/test/command-util.ts index 5b3ec6846..7295a42d8 100644 --- a/packages/@eventual/core-runtime/test/command-util.ts +++ b/packages/@eventual/core-runtime/test/command-util.ts @@ -7,22 +7,24 @@ import { ChildWorkflowFailed, ChildWorkflowScheduled, ChildWorkflowSucceeded, - CommandType, EventsPublished, - PublishEventsCommand, - ScheduleActivityCommand, - ScheduleWorkflowCommand, - SendSignalCommand, SignalReceived, SignalSent, SignalTarget, - StartTimerCommand, TimerCompleted, TimerScheduled, WorkflowEventType, WorkflowTimedOut, } from "@eventual/core/internal"; import { ulid } from "ulidx"; +import { + CommandType, + PublishEventsCommand, + ScheduleActivityCommand, + ScheduleWorkflowCommand, + SendSignalCommand, + StartTimerCommand, +} from "../src/workflow-command.js"; export function createStartTimerCommand( schedule: Schedule, diff --git a/packages/@eventual/core-runtime/test/commend-executor.test.ts b/packages/@eventual/core-runtime/test/commend-executor.test.ts index 4d5d86ea7..7357b5877 100644 --- a/packages/@eventual/core-runtime/test/commend-executor.test.ts +++ b/packages/@eventual/core-runtime/test/commend-executor.test.ts @@ -7,7 +7,6 @@ import { import { ActivityScheduled, ChildWorkflowScheduled, - CommandType, EventsPublished, SignalSent, SignalTargetType, @@ -30,6 +29,7 @@ import { formatExecutionId, INTERNAL_EXECUTION_ID_PREFIX, } from "../src/execution.js"; +import { CommandType } from "../src/workflow-command.js"; const mockTimerClient = { scheduleEvent: jest.fn() as TimerClient["scheduleEvent"], diff --git a/packages/@eventual/core/src/internal/calls/calls.ts b/packages/@eventual/core/src/internal/calls/calls.ts index 5e81d2d8f..df3f31a40 100644 --- a/packages/@eventual/core/src/internal/calls/calls.ts +++ b/packages/@eventual/core/src/internal/calls/calls.ts @@ -19,19 +19,15 @@ export type EventualCall = | WorkflowCall; export enum EventualCallKind { - ActivityCall = 1, - AwaitAll = 0, - AwaitAllSettled = 12, - AwaitAny = 10, - AwaitDurationCall = 3, - AwaitTimeCall = 4, - ConditionCall = 9, - ExpectSignalCall = 6, - PublishEventsCall = 13, - Race = 11, - RegisterSignalHandlerCall = 7, - SendSignalCall = 8, - WorkflowCall = 5, + ActivityCall = 0, + AwaitDurationCall = 1, + AwaitTimeCall = 2, + ConditionCall = 3, + ExpectSignalCall = 4, + PublishEventsCall = 5, + RegisterSignalHandlerCall = 6, + SendSignalCall = 7, + WorkflowCall = 8, } const EventualCallSymbol = Symbol.for("eventual:EventualCall"); diff --git a/packages/@eventual/core/src/internal/index.ts b/packages/@eventual/core/src/internal/index.ts index 1dd82bd3d..191ad8f44 100644 --- a/packages/@eventual/core/src/internal/index.ts +++ b/packages/@eventual/core/src/internal/index.ts @@ -11,6 +11,4 @@ export * from "./service-spec.js"; export * from "./service-type.js"; export * from "./signal.js"; export * from "./util.js"; -export * from "./workflow-command.js"; export * from "./workflow-events.js"; - From aec39b7adcd5f83a4a43256c1106d5713a72d4f0 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Mon, 6 Mar 2023 09:45:40 -0600 Subject: [PATCH 11/28] simplify await timer calls --- .../core-runtime/src/eventual-factory.ts | 18 +++----- .../test/workflow-executor.test.ts | 37 ++++++++-------- packages/@eventual/core/src/activity.ts | 11 ++--- packages/@eventual/core/src/await-time.ts | 16 +++---- .../src/internal/calls/await-time-call.ts | 42 +++++-------------- .../core/src/internal/calls/calls.ts | 20 ++++----- 6 files changed, 55 insertions(+), 89 deletions(-) diff --git a/packages/@eventual/core-runtime/src/eventual-factory.ts b/packages/@eventual/core-runtime/src/eventual-factory.ts index a1fcd1e77..f4e9f4ad2 100644 --- a/packages/@eventual/core-runtime/src/eventual-factory.ts +++ b/packages/@eventual/core-runtime/src/eventual-factory.ts @@ -1,9 +1,4 @@ -import { - EventualError, - HeartbeatTimeout, - Schedule, - Timeout, -} from "@eventual/core"; +import { EventualError, HeartbeatTimeout, Timeout } from "@eventual/core"; import { assertNever, EventualCall, @@ -12,8 +7,7 @@ import { isActivityHeartbeatTimedOut, isActivityScheduled, isActivitySucceeded, - isAwaitDurationCall, - isAwaitTimeCall, + isAwaitTimerCall, isChildWorkflowFailed, isChildWorkflowScheduled, isChildWorkflowSucceeded, @@ -93,7 +87,7 @@ export function createEventualFromCall( }; }, }; - } else if (isAwaitTimeCall(call) || isAwaitDurationCall(call)) { + } else if (isAwaitTimerCall(call)) { return { applyEvent: (event) => { if (isTimerCompleted(event)) { @@ -105,9 +99,7 @@ export function createEventualFromCall( return { kind: CommandType.StartTimer, seq, - schedule: isAwaitTimeCall(call) - ? Schedule.time(call.isoDate) - : Schedule.duration(call.dur, call.unit), + schedule: call.schedule, }; }, }; @@ -196,7 +188,7 @@ export function isCorresponding( } else if (isChildWorkflowScheduled(event)) { return isWorkflowCall(call) && call.name === event.name; } else if (isTimerScheduled(event)) { - return isAwaitTimeCall(call) || isAwaitDurationCall(call); + return isAwaitTimerCall(call); } else if (isSignalSent(event)) { return isSendSignalCall(call) && event.signalId === call.signalId; } else if (isEventsPublished(event)) { diff --git a/packages/@eventual/core-runtime/test/workflow-executor.test.ts b/packages/@eventual/core-runtime/test/workflow-executor.test.ts index 5d0b2ba4d..320daa2bf 100644 --- a/packages/@eventual/core-runtime/test/workflow-executor.test.ts +++ b/packages/@eventual/core-runtime/test/workflow-executor.test.ts @@ -15,8 +15,7 @@ import { } from "@eventual/core"; import { createActivityCall, - createAwaitDurationCall, - createAwaitTimeCall, + createAwaitTimerCall, createConditionCall, createExpectSignalCall, createPublishEventsCall, @@ -83,7 +82,7 @@ const myWorkflow = workflow(async (event) => { createActivityCall("my-activity-0", [event]); const all = (await Promise.all([ - createAwaitTimeCall("then"), + createAwaitTimerCall(Schedule.time("then")), createActivityCall("my-activity-2", [event]), ])) as any; return [a, all]; @@ -189,7 +188,7 @@ test("should catch error of timing out Activity", async () => { const a = await createActivityCall( "my-activity", [event], - createAwaitTimeCall("") + createAwaitTimerCall(Schedule.time("")) ); return a; @@ -234,7 +233,7 @@ test("immediately abort activity on invalid timeout", async () => { test("timeout multiple activities at once", async () => { const myWorkflow = workflow(async (event) => { - const time = createAwaitTimeCall(""); + const time = createAwaitTimerCall(Schedule.time("")); const a = createActivityCall("my-activity", [event], time); const b = createActivityCall("my-activity", [event], time); @@ -695,7 +694,7 @@ describe("temple of doom", () => { let jump = false; async function startTrap() { - await createAwaitTimeCall("then"); + await createAwaitTimerCall(Schedule.time("then")); trapDown = true; } @@ -1714,7 +1713,7 @@ describe("signals", () => { const wf = workflow(async () => { const result = await createExpectSignalCall( "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") + createAwaitTimerCall(Schedule.duration(100 * 1000, "seconds")) ); return result ?? "done"; @@ -1818,11 +1817,11 @@ describe("signals", () => { const wf = workflow(async () => { const wait1 = createExpectSignalCall( "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") + createAwaitTimerCall(Schedule.duration(100 * 1000, "seconds")) ); const wait2 = createExpectSignalCall( "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") + createAwaitTimerCall(Schedule.duration(100 * 1000, "seconds")) ); return Promise.all([wait1, wait2]); @@ -1848,11 +1847,11 @@ describe("signals", () => { const wf = workflow(async () => { await createExpectSignalCall( "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") + createAwaitTimerCall(Schedule.duration(100 * 1000, "seconds")) ); await createExpectSignalCall( "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") + createAwaitTimerCall(Schedule.duration(100 * 1000, "seconds")) ); }); @@ -1868,11 +1867,11 @@ describe("signals", () => { const wf = workflow(async () => { await createExpectSignalCall( "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") + createAwaitTimerCall(Schedule.duration(100 * 1000, "seconds")) ); await createExpectSignalCall( "MySignal", - createAwaitDurationCall(100 * 1000, "seconds") + createAwaitTimerCall(Schedule.duration(100 * 1000, "seconds")) ); }); @@ -1914,12 +1913,12 @@ describe("signals", () => { } ); - await createAwaitTimeCall("then"); + await createAwaitTimerCall(Schedule.time("then")); mySignalHandler.dispose(); myOtherSignalHandler.dispose(); - await createAwaitTimeCall("then"); + await createAwaitTimerCall(Schedule.time("then")); return { mySignalHappened, @@ -2302,7 +2301,7 @@ describe("condition", () => { const wf = workflow(async () => { await createConditionCall( () => false, - createAwaitDurationCall(100, "seconds") + createAwaitTimerCall(Schedule.duration(100, "seconds")) ); }); @@ -2317,7 +2316,7 @@ describe("condition", () => { const wf = workflow(async () => { await createConditionCall( () => false, - createAwaitDurationCall(100, "seconds") + createAwaitTimerCall(Schedule.duration(100, "seconds")) ); }); @@ -2336,7 +2335,7 @@ describe("condition", () => { if ( !(await createConditionCall( () => yes, - createAwaitDurationCall(100, "seconds") + createAwaitTimerCall(Schedule.duration(100, "seconds")) )) ) { return "timed out"; @@ -2454,7 +2453,7 @@ test("nestedChains", async () => { const wf = workflow(async () => { const funcs = { a: async () => { - await createAwaitTimeCall("then"); + await createAwaitTimerCall(Schedule.time("then")); }, }; diff --git a/packages/@eventual/core/src/activity.ts b/packages/@eventual/core/src/activity.ts index a7e78f406..9a1261179 100644 --- a/packages/@eventual/core/src/activity.ts +++ b/packages/@eventual/core/src/activity.ts @@ -2,10 +2,7 @@ import { ExecutionID } from "./execution.js"; import { FunctionRuntimeProps } from "./function-props.js"; import { AsyncTokenSymbol } from "./internal/activity.js"; import { createActivityCall } from "./internal/calls/activity-call.js"; -import { - createAwaitDurationCall, - createAwaitTimeCall, -} from "./internal/calls/await-time-call.js"; +import { createAwaitTimerCall } from "./internal/calls/await-time-call.js"; import type { SendActivityFailureRequest, SendActivityHeartbeatRequest, @@ -309,10 +306,8 @@ export function activity( name, input, timeout - ? isDurationSchedule(timeout) - ? createAwaitDurationCall(timeout.dur, timeout.unit) - : isTimeSchedule(timeout) - ? createAwaitTimeCall(timeout.isoDate) + ? isDurationSchedule(timeout) || isTimeSchedule(timeout) + ? createAwaitTimerCall(timeout) : timeout : undefined, options?.heartbeatTimeout ?? opts?.heartbeatTimeout diff --git a/packages/@eventual/core/src/await-time.ts b/packages/@eventual/core/src/await-time.ts index fb90818e7..b0bd8c37d 100644 --- a/packages/@eventual/core/src/await-time.ts +++ b/packages/@eventual/core/src/await-time.ts @@ -1,9 +1,11 @@ -import { - createAwaitDurationCall, - createAwaitTimeCall -} from "./internal/calls/await-time-call.js"; +import { createAwaitTimerCall } from "./internal/calls/await-time-call.js"; import { isOrchestratorWorker } from "./internal/flags.js"; -import type { DurationSchedule, DurationUnit, TimeSchedule } from "./schedule.js"; +import { + DurationSchedule, + DurationUnit, + Schedule, + TimeSchedule, +} from "./schedule.js"; /** * Represents a time duration. @@ -58,7 +60,7 @@ export function duration( } // register an await duration command and return it (to be yielded) - return createAwaitDurationCall(dur, unit) as any; + return createAwaitTimerCall(Schedule.duration(dur, unit)) as any; } /** @@ -95,5 +97,5 @@ export function time(date: Date | string): Promise & TimeSchedule { } // register an await time command and return it (to be yielded) - return createAwaitTimeCall(iso) as any; + return createAwaitTimerCall(Schedule.time(iso)) as any; } diff --git a/packages/@eventual/core/src/internal/calls/await-time-call.ts b/packages/@eventual/core/src/internal/calls/await-time-call.ts index 7ae5c9fb3..c29c34f63 100644 --- a/packages/@eventual/core/src/internal/calls/await-time-call.ts +++ b/packages/@eventual/core/src/internal/calls/await-time-call.ts @@ -1,47 +1,27 @@ -import { DurationUnit } from "../../schedule.js"; +import { Schedule } from "../../schedule.js"; import { EventualPromise, getWorkflowHook } from "../eventual-hook.js"; import { createEventualCall, EventualCallBase, EventualCallKind, - isEventualCallOfKind, + isEventualCallOfKind } from "./calls.js"; -export function isAwaitDurationCall(a: any): a is AwaitDurationCall { - return isEventualCallOfKind(EventualCallKind.AwaitDurationCall, a); +export function isAwaitTimerCall(a: any): a is AwaitTimerCall { + return isEventualCallOfKind(EventualCallKind.AwaitTimerCall, a); } -export function isAwaitTimeCall(a: any): a is AwaitTimeCall { - return isEventualCallOfKind(EventualCallKind.AwaitTimeCall, a); +export interface AwaitTimerCall + extends EventualCallBase { + schedule: Schedule; } -export interface AwaitDurationCall - extends EventualCallBase { - dur: number; - unit: DurationUnit; -} - -export interface AwaitTimeCall - extends EventualCallBase { - isoDate: string; -} - -export function createAwaitDurationCall( - dur: number, - unit: DurationUnit +export function createAwaitTimerCall( + schedule: Schedule ): EventualPromise { return getWorkflowHook().registerEventualCall( - createEventualCall(EventualCallKind.AwaitDurationCall, { - dur, - unit, - }) - ); -} - -export function createAwaitTimeCall(isoDate: string): EventualPromise { - return getWorkflowHook().registerEventualCall( - createEventualCall(EventualCallKind.AwaitTimeCall, { - isoDate, + createEventualCall(EventualCallKind.AwaitTimerCall, { + schedule, }) ); } diff --git a/packages/@eventual/core/src/internal/calls/calls.ts b/packages/@eventual/core/src/internal/calls/calls.ts index df3f31a40..60142a93b 100644 --- a/packages/@eventual/core/src/internal/calls/calls.ts +++ b/packages/@eventual/core/src/internal/calls/calls.ts @@ -1,5 +1,5 @@ import { ActivityCall } from "./activity-call.js"; -import { AwaitDurationCall, AwaitTimeCall } from "./await-time-call.js"; +import { AwaitTimerCall } from "./await-time-call.js"; import { ConditionCall } from "./condition-call.js"; import { ExpectSignalCall } from "./expect-signal-call.js"; import { PublishEventsCall } from "./publish-events-call.js"; @@ -9,8 +9,7 @@ import { WorkflowCall } from "./workflow-call.js"; export type EventualCall = | ActivityCall - | AwaitDurationCall - | AwaitTimeCall + | AwaitTimerCall | ConditionCall | ExpectSignalCall | PublishEventsCall @@ -20,14 +19,13 @@ export type EventualCall = export enum EventualCallKind { ActivityCall = 0, - AwaitDurationCall = 1, - AwaitTimeCall = 2, - ConditionCall = 3, - ExpectSignalCall = 4, - PublishEventsCall = 5, - RegisterSignalHandlerCall = 6, - SendSignalCall = 7, - WorkflowCall = 8, + AwaitTimerCall = 1, + ConditionCall = 2, + ExpectSignalCall = 3, + PublishEventsCall = 4, + RegisterSignalHandlerCall = 5, + SendSignalCall = 6, + WorkflowCall = 7, } const EventualCallSymbol = Symbol.for("eventual:EventualCall"); From 44bcb659d3dffa2478455786ea0d6349e68e821d Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Mon, 6 Mar 2023 15:50:16 -0600 Subject: [PATCH 12/28] refactor executor --- .../src/cancellable-promise-hook.ts | 114 ---- .../core-runtime/src/eventual-factory.ts | 136 ++-- .../core-runtime/src/workflow-executor.ts | 581 +++++++++++------- .../test/cancellable-promises-hook.test.ts | 340 ---------- .../core/src/internal/eventual-hook.ts | 15 +- 5 files changed, 423 insertions(+), 763 deletions(-) delete mode 100644 packages/@eventual/core-runtime/src/cancellable-promise-hook.ts delete mode 100644 packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts diff --git a/packages/@eventual/core-runtime/src/cancellable-promise-hook.ts b/packages/@eventual/core-runtime/src/cancellable-promise-hook.ts deleted file mode 100644 index 431e68bc0..000000000 --- a/packages/@eventual/core-runtime/src/cancellable-promise-hook.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * This file patches Node's Promise type, making it cancellable and without memory leaks. - * - * It makes use of AsyncLocalStorage to achieve this: - * https://nodejs.org/api/async_context.html#class-asynclocalstorage - */ - -import { AsyncLocalStorage } from "async_hooks"; -import { isPromise } from "util/types"; - -const storage = new AsyncLocalStorage(); - -const _then = Promise.prototype.then; -const _catch = Promise.prototype.catch; -const _finally = Promise.prototype.finally; - -const _Promise = Promise; - -// @ts-ignore - naughty naughty -globalThis.Promise = function (executor: any) { - if (isCancelled()) { - // if the local storage has a cancelled flag, break the Promise chain - return new _Promise(() => {}); - } - return new _Promise(executor); -}; - -globalThis.Promise.resolve = ( - ...args: [] | [value: T] -): Promise | void> => { - if (args.length === 0) { - return new Promise((resolve) => resolve(void 0)); - } - const [value] = args; - return new Promise(async (resolve) => - isPromise(value) - ? (value as Promise>).then(resolve) - : resolve(value as Awaited) - ); -}; - -globalThis.Promise.reject = (reason?: any): Promise => { - return new Promise(async (_, reject) => reject(reason)); -}; - -globalThis.Promise.all = _Promise.all; -globalThis.Promise.allSettled = _Promise.allSettled; -globalThis.Promise.any = _Promise.any; -globalThis.Promise.race = _Promise.race; - -Promise.prototype.then = function (outerResolve, outerReject) { - const p = (_then as typeof _then).call(this, (value) => { - if (!isCancelled()) { - outerResolve?.(value); - } - }); - return outerReject ? p.catch(outerReject) : p; -}; - -Promise.prototype.catch = function (outerReject) { - return (_catch as typeof _catch).call(this, (err) => { - if (!isCancelled()) { - outerReject?.(err); - } - }); -}; - -Promise.prototype.finally = function (outerFinally) { - console.log("finally 1"); - return _finally.call(this, () => { - console.log("finally 2"); - if (!isCancelled()) { - console.log("finally 3"); - return outerFinally?.(); - } - }); -}; - -function isCancelled() { - const state = storage.getStore(); - return state?.cancelled === true; -} - -export function cancelLocalPromises() { - (storage.getStore() as CancelState | undefined)?.cancel(); -} - -interface CancelState { - cancelled: boolean; - cancel(): void; -} - -interface CancellablePromise extends Promise { - cancel(): void; -} - -export function cancellable(fn: () => Promise): CancellablePromise { - const state: CancelState = { - cancelled: false, - cancel: () => {}, - }; - return storage.run(state, () => { - let _reject: (reason?: any) => void; - const promise = new Promise((resolve, reject) => { - _reject = reject; - fn().then(resolve).catch(reject); - }); - state.cancel = (promise as any).cancel = function () { - state.cancelled = true; - _reject(new Error("cancelled")); - }; - return promise as CancellablePromise; - }); -} diff --git a/packages/@eventual/core-runtime/src/eventual-factory.ts b/packages/@eventual/core-runtime/src/eventual-factory.ts index f4e9f4ad2..7a252afe5 100644 --- a/packages/@eventual/core-runtime/src/eventual-factory.ts +++ b/packages/@eventual/core-runtime/src/eventual-factory.ts @@ -3,54 +3,46 @@ import { assertNever, EventualCall, isActivityCall, - isActivityFailed, - isActivityHeartbeatTimedOut, isActivityScheduled, - isActivitySucceeded, isAwaitTimerCall, - isChildWorkflowFailed, isChildWorkflowScheduled, - isChildWorkflowSucceeded, isConditionCall, isEventsPublished, isExpectSignalCall, isPublishEventsCall, isRegisterSignalHandlerCall, isSendSignalCall, - isSignalReceived, isSignalSent, - isTimerCompleted, isTimerScheduled, isWorkflowCall, Result, ScheduledEvent, + WorkflowEventType, } from "@eventual/core/internal"; import { CommandType } from "./workflow-command.js"; -import { Eventual } from "./workflow-executor.js"; +import { EventualDefinition, Trigger } from "./workflow-executor.js"; export function createEventualFromCall( call: EventualCall -): Omit, "seq"> { +): EventualDefinition { if (isActivityCall(call)) { return { - applyEvent: (event) => { - if (isActivitySucceeded(event)) { - return Result.resolved(event.result); - } else if (isActivityFailed(event)) { - return Result.failed(new EventualError(event.error, event.message)); - } else if (isActivityHeartbeatTimedOut(event)) { - return Result.failed( - new HeartbeatTimeout("Activity Heartbeat TimedOut") - ); - } - return undefined; - }, - dependencies: call.timeout - ? { - promise: call.timeout, - handler: () => Result.failed(new Timeout("Activity Timed Out")), - } - : undefined, + triggers: [ + Trigger.workflowEvent(WorkflowEventType.ActivitySucceeded, (event) => + Result.resolved(event.result) + ), + Trigger.workflowEvent(WorkflowEventType.ActivityFailed, (event) => + Result.failed(new EventualError(event.error, event.message)) + ), + Trigger.workflowEvent(WorkflowEventType.ActivityHeartbeatTimedOut, () => + Result.failed(new HeartbeatTimeout("Activity Heartbeat TimedOut")) + ), + call.timeout + ? Trigger.promise(call.timeout, () => + Result.failed(new Timeout("Activity Timed Out")) + ) + : undefined, + ], generateCommands(seq) { return { kind: CommandType.StartActivity, @@ -63,20 +55,20 @@ export function createEventualFromCall( }; } else if (isWorkflowCall(call)) { return { - applyEvent: (event) => { - if (isChildWorkflowSucceeded(event)) { - return Result.resolved(event.result); - } else if (isChildWorkflowFailed(event)) { - return Result.failed(new EventualError(event.error, event.message)); - } - return undefined; - }, - dependencies: call.timeout - ? { - promise: call.timeout, - handler: () => Result.failed("Activity Timed Out"), - } - : undefined, + triggers: [ + Trigger.workflowEvent( + WorkflowEventType.ChildWorkflowSucceeded, + (event) => Result.resolved(event.result) + ), + Trigger.workflowEvent(WorkflowEventType.ChildWorkflowFailed, (event) => + Result.failed(new EventualError(event.error, event.message)) + ), + call.timeout + ? Trigger.promise(call.timeout, () => + Result.failed("Child Workflow Timed Out") + ) + : undefined, + ], generateCommands(seq) { return { kind: CommandType.StartWorkflow, @@ -89,12 +81,9 @@ export function createEventualFromCall( }; } else if (isAwaitTimerCall(call)) { return { - applyEvent: (event) => { - if (isTimerCompleted(event)) { - return Result.resolved(undefined); - } - return undefined; - }, + triggers: Trigger.workflowEvent(WorkflowEventType.TimerCompleted, () => + Result.resolved(undefined) + ), generateCommands(seq) { return { kind: CommandType.StartTimer, @@ -118,20 +107,16 @@ export function createEventualFromCall( }; } else if (isExpectSignalCall(call)) { return { - signals: call.signalId, - applyEvent: (event) => { - if (isSignalReceived(event)) { - return Result.resolved(event.payload); - } - return undefined; - }, - dependencies: call.timeout - ? { - promise: call.timeout, - handler: () => - Result.failed(new Timeout("Expect Signal Timed Out")), - } - : undefined, + triggers: [ + Trigger.signal(call.signalId, (event) => + Result.resolved(event.payload) + ), + call.timeout + ? Trigger.promise(call.timeout, () => + Result.failed(new Timeout("Expect Signal Timed Out")) + ) + : undefined, + ], }; } else if (isPublishEventsCall(call)) { return { @@ -150,27 +135,22 @@ export function createEventualFromCall( } else { // otherwise check the state after every event is applied. return { - afterEveryEvent: () => { - const result = call.predicate(); - return result ? Result.resolved(result) : undefined; - }, - dependencies: call.timeout - ? { - promise: call.timeout, - handler: () => Result.resolved(false), - } - : undefined, + triggers: [ + Trigger.afterEveryEvent(() => { + const result = call.predicate(); + return result ? Result.resolved(result) : undefined; + }), + call.timeout + ? Trigger.promise(call.timeout, () => Result.resolved(false)) + : undefined, + ], }; } } else if (isRegisterSignalHandlerCall(call)) { return { - signals: call.signalId, - applyEvent: (event) => { - if (isSignalReceived(event)) { - call.handler(event.payload); - } - return undefined; - }, + triggers: Trigger.signal(call.signalId, (event) => { + call.handler(event.payload); + }), }; } return assertNever(call); diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index 06ad6c116..bdecf4887 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -1,20 +1,19 @@ import { DeterminismError, + Signal, Timeout, Workflow, WorkflowContext, } from "@eventual/core"; import { - assertNever, - createEventualPromise, enterWorkflowHookScope, EventualCall, EventualPromise, + EventualPromiseSymbol, ExecutionWorkflowHook, HistoryEvent, HistoryResultEvent, HistoryScheduledEvent, - isFailed, isResolved, isResultEvent, isScheduledEvent, @@ -23,87 +22,60 @@ import { isWorkflowTimedOut, iterator, Result, + SignalReceived, WorkflowEvent, - WorkflowRunStarted, - WorkflowTimedOut, _Iterator, } from "@eventual/core/internal"; import { isPromise } from "util/types"; import { createEventualFromCall, isCorresponding } from "./eventual-factory.js"; -import { WorkflowCommand } from "./index.js"; +import type { WorkflowCommand } from "./workflow-command.js"; -interface ActiveEventual { - resolve: (result: R) => void; - reject: (reason: any) => void; - promise: EventualPromise; - eventual: Eventual; +/** + * Put the resolve method on the promise, but don't expose it. + */ +export interface RuntimeEventualPromise extends EventualPromise { + resolve: (result: Result) => void; } -interface RuntimeState { - /** - * All {@link Eventual} waiting on a sequence based result event. - */ - active: Record; - /** - * All {@link Eventual}s waiting on a signal. - */ - awaitingSignals: Record>; - /** - * All {@link Eventual}s which should be invoked on every new event. - * - * For example, Condition should be checked after applying any event. - */ - awaitingAny: Set; - /** - * Iterator containing the in order events we expected to see in a deterministic workflow. - */ - expected: _Iterator; - /** - * Iterator containing events to apply. - */ - events: _Iterator; -} - -interface DependencyHandler { - promise: Promise; - handler: (val: Result) => Result | undefined; -} - -export interface Eventual { +/** + * Values used to interact with an eventual while it is active. + */ +interface ActiveEventual { /** - * Invoke this handler every time a new event is applied, in seq order. + * A reference to the promise passed back to the workflow. */ - afterEveryEvent?: () => Result | undefined; - signals?: string[] | string; + promise: RuntimeEventualPromise; /** - * When an promise completes, call the handler on this eventual. + * Back reference from eventual to the signals it consumes. * - * Useful for things like activities, which use other {@link Eventual}s to cancel/timeout. + * Intended to reduce searching when deactivating an eventual. */ - dependencies?: DependencyHandler[] | DependencyHandler; + signals?: string[]; /** - * When an event comes in that matches this eventual's sequence, - * pass the event to the eventual if not already resolved. + * The sequence number of the eventual. */ - applyEvent?: (event: HistoryEvent) => Result | undefined; - /** - * Commands to emit. - * - * When undefined, the eventual will not be checked against an expected event as it does not emit commands. - */ - generateCommands?: (seq: number) => WorkflowCommand[] | WorkflowCommand; seq: number; - result?: Result; } -function initializeRuntimeState(historyEvents: HistoryEvent[]): RuntimeState { - return { - active: {}, - awaitingSignals: {}, - awaitingAny: new Set(), - expected: iterator(historyEvents, isScheduledEvent), - events: iterator(historyEvents, isResultEvent), +export function createEventualPromise( + seq: number, + beforeResolve?: () => void +): RuntimeEventualPromise { + let resolve: (r: R) => void, reject: (reason: any) => void; + const promise = new Promise((r, rr) => { + resolve = r; + reject = rr; + }) as RuntimeEventualPromise; + promise[EventualPromiseSymbol] = seq; + promise.resolve = (result) => { + beforeResolve?.(); + if (isResolved(result)) { + resolve(result.value); + } else { + reject(result.error); + } }; + return promise; } interface ExecutorOptions { @@ -130,10 +102,45 @@ interface ExecutorOptions { } export class WorkflowExecutor { - private seq: number; - private runtimeState: RuntimeState; + /** + * The sequence number to assign to the next eventual registered. + */ + private nextSeq: number; + /** + * All {@link EventualDefinition} which are still active. + */ + private activeEventuals: Record = {}; + /** + * All {@link EventualDefinition}s waiting on a signal. + */ + private activeHandlers: { + events: Record["handler"]>>; + signals: Record["handler"]>>; + afterEveryEvent: Record["afterEvery"]>; + } = { + signals: {}, + events: {}, + afterEveryEvent: {}, + }; + /** + * Iterator containing the in order events we expected to see in a deterministic workflow. + */ + private expected: _Iterator; + /** + * Iterator containing events to apply. + */ + private events: _Iterator; + + /** + * Set when the workflow is started. + */ private started?: { - resolve: (result: Result) => void; + /** + * When called, resolves the workflow execution with a {@link result}. + * + * This will cause the current promise from start or continue to resolve with a the given value. + */ + resolve: (result?: Result) => void; }; private commandsToEmit: WorkflowCommand[]; public result?: Result; @@ -143,8 +150,9 @@ export class WorkflowExecutor { private history: HistoryEvent[], private options?: ExecutorOptions ) { - this.seq = 0; - this.runtimeState = initializeRuntimeState(history); + this.nextSeq = 0; + this.expected = iterator(history, isScheduledEvent); + this.events = iterator(history, isResultEvent); this.commandsToEmit = []; } @@ -167,10 +175,7 @@ export class WorkflowExecutor { // start context with execution hook this.started = { resolve: (result) => { - const newCommands = this.commandsToEmit; - this.commandsToEmit = []; - - resolve({ commands: newCommands, result }); + resolve(this.flushCurrentWorkflowResult(result)); }, }; // ensure the workflow hook is available to the workflow @@ -181,12 +186,12 @@ export class WorkflowExecutor { try { const workflowPromise = this.workflow.definition(input, context); workflowPromise.then( - (result) => this.forceComplete(Result.resolved(result)), - (err) => this.forceComplete(Result.failed(err)) + (result) => this.endWorkflowRun(Result.resolved(result)), + (err) => this.endWorkflowRun(Result.failed(err)) ); } catch (err) { // handle any synchronous errors. - this.forceComplete(Result.failed(err)); + this.endWorkflowRun(Result.failed(err)); } // APPLY EVENTS @@ -198,30 +203,32 @@ export class WorkflowExecutor { // this assumption breaks down when the user tries to start a promise // to accomplish non-deterministic actions like IO. setTimeout(() => { - const newCommands = this.commandsToEmit; - this.commandsToEmit = []; - - if (!this.result && !this.options?.resumable) { - // cancel promises? - if (this.runtimeState.expected.hasNext()) { - this.forceComplete( - Result.failed( - new DeterminismError( - "Workflow did not return expected commands" - ) - ) - ); - } - } - - resolve({ - commands: newCommands, - result: this.result, - }); + resolve(this.flushCurrentWorkflowResult()); }); }); } + /** + * Returns current result and commands, resetting the command array. + * + * If the workflow has additional expected events to apply, fails the workflow with a determinism error. + */ + private flushCurrentWorkflowResult( + overrideResult?: Result + ): WorkflowResult { + const newCommands = this.commandsToEmit; + this.commandsToEmit = []; + + this.result = + !this.result && !this.options?.resumable && this.expected.hasNext() + ? Result.failed( + new DeterminismError("Workflow did not return expected commands") + ) + : overrideResult ?? this.result; + + return { result: this.result, commands: newCommands }; + } + /** * Continue a previously started workflow by feeding in new {@link HistoryResultEvent}, possibly advancing the execution. * @@ -259,20 +266,20 @@ export class WorkflowExecutor { }); } - private forceComplete(result: Result) { + private endWorkflowRun(result?: Result) { if (!this.started) { throw new Error("Execution is not started."); } this.started.resolve(result); } - private async enterWorkflowHookScope(callback: (...args: any) => R) { + private async enterWorkflowHookScope(callback: (...args: any) => Res) { const self = this; const workflowHook: ExecutionWorkflowHook = { - registerEventualCall: (call) => { + registerEventualCall>(call: EventualCall) { try { const eventual = createEventualFromCall(call); - const seq = this.seq++; + const seq = self.nextSeq++; /** * if the call is new, generate and emit it's commands @@ -285,7 +292,7 @@ export class WorkflowExecutor { * and instead maintain the call history to match against. */ if (eventual.generateCommands && !isExpectedCall(seq, call)) { - this.commandsToEmit.push( + self.commandsToEmit.push( ...normalizeToArray(eventual.generateCommands(seq)) ); } @@ -293,45 +300,20 @@ export class WorkflowExecutor { /** * If the eventual comes with a result, do not active it, it is already resolved! */ - if (eventual.result) { - return createEventualPromise( - isResolved(eventual.result) - ? Promise.resolve(eventual.result.value) - : Promise.reject(eventual.result.error), - seq - ); + if (isResolvedEventualDefinition(eventual)) { + const promise = createEventualPromise(seq); + promise.resolve(eventual.result); + return promise as unknown as E; } - const activeEventual = this.activateEventual({ ...eventual, seq }); - - /** - * For each dependency, wire the dependency promise to the handler provided by the eventual. - * - * If the dependency resolves or rejects, pass the result along. - */ - const deps = normalizeToArray(eventual.dependencies); - deps.forEach((dep) => { - (!isPromise(dep.promise) - ? Promise.resolve(dep.promise) - : dep.promise - ).then( - (res) => { - this.tryResolveEventual(seq, dep.handler(Result.resolved(res))); - }, - (err) => { - this.tryResolveEventual(seq, dep.handler(Result.failed(err))); - } - ); - }); - - return activeEventual.promise; + return self.activateEventual(seq, eventual) as E; } catch (err) { - this.forceComplete(Result.failed(err)); + self.endWorkflowRun(Result.failed(err)); throw err; } }, - resolveEventual: (seq, result) => { - this.tryResolveEventual(seq, result); + resolveEventual(seq, result) { + self.tryResolveEventual(seq, result); }, }; return await enterWorkflowHookScope(workflowHook, callback); @@ -342,8 +324,8 @@ export class WorkflowExecutor { * @throws {@link DeterminismError} when the call is not expected and there are expected events remaining. */ function isExpectedCall(seq: number, call: EventualCall) { - if (self.runtimeState.expected.hasNext()) { - const expected = self.runtimeState.expected.next()!; + if (self.expected.hasNext()) { + const expected = self.expected.next()!; self.options?.hooks?.historicalEventMatched?.(expected, call); @@ -361,8 +343,8 @@ export class WorkflowExecutor { } private async drainHistoryEvents() { - while (this.runtimeState.events.hasNext() && !this.result) { - const event = this.runtimeState.events.next()!; + while (this.events.hasNext() && !this.result) { + const event = this.events.next()!; this.options?.hooks?.beforeApplyingResultEvent?.(event); await new Promise((resolve) => { setTimeout(() => { @@ -385,23 +367,34 @@ export class WorkflowExecutor { */ private tryCommitResultEvent(event: HistoryResultEvent) { if (isWorkflowTimedOut(event)) { - this.forceComplete(Result.failed(new Timeout("Workflow timed out"))); - // TODO cancel workflow? + return this.endWorkflowRun( + Result.failed(new Timeout("Workflow timed out")) + ); } else if (!isWorkflowRunStarted(event)) { - const eventuals = this.getEventualsForEvent(event); - for (const eventual of eventuals ?? []) { - // pass event to the eventual - this.tryResolveEventual( - eventual.eventual.seq, - eventual.eventual.applyEvent?.(event) - ); - } - [...this.runtimeState.awaitingAny].forEach((s) => { - const eventual = this.runtimeState.active[s]; - if (eventual && eventual.eventual.afterEveryEvent) { - this.tryResolveEventual(s, eventual.eventual.afterEveryEvent()); + if (isSignalReceived(event)) { + const signalHandlers = + this.activeHandlers.signals[event.signalId] ?? {}; + Object.entries(signalHandlers) + .filter(([seq]) => this.isEventualActive(seq)) + .map(([seq, handler]) => { + this.tryResolveEventual(Number(seq), handler(event) ?? undefined); + }); + } else { + if (this.isEventualActive(event.seq)) { + const eventHandler = + this.activeHandlers.events[event.seq]?.[event.type]; + this.tryResolveEventual( + event.seq, + eventHandler?.(event) ?? undefined + ); } - }); + } + // resolve any eventuals should be triggered on each new event + Object.entries(this.activeHandlers.afterEveryEvent) + .filter(([seq]) => this.isEventualActive(seq)) + .forEach(([seq, handler]) => { + this.tryResolveEventual(Number(seq), handler() ?? undefined); + }); } } @@ -410,42 +403,15 @@ export class WorkflowExecutor { result: Result | undefined ): void { if (result) { - const eventual = this.runtimeState.active[seq]; + const eventual = this.activeEventuals[seq]; if (eventual) { - // deactivate the eventual to avoid a circular resolution - this.deactivateEventual(eventual.eventual); - // TODO: remove from signal listens - if (isResolved(result)) { - eventual.resolve(result.value); - } else if (isFailed(result)) { - eventual.reject(result.error); - } else { - return assertNever(result); - } - } - } - } - - private getEventualsForEvent( - event: Exclude - ): ActiveEventual[] | undefined { - if (isSignalReceived(event)) { - return [...(this.runtimeState.awaitingSignals[event.signalId] ?? [])] - ?.map((seq) => this.getActiveEventual(seq)) - .filter((envt): envt is ActiveEventual => !!envt); - } else { - const eventual = this.runtimeState.active[event.seq]; - // no more active Eventuals for this seq, ignore it - if (!eventual) { - return []; - } else { - return [eventual]; + eventual.promise.resolve(result); } } } - private getActiveEventual(seq: number): ActiveEventual | undefined { - return this.runtimeState.active[seq]; + private isEventualActive(seq: number | string) { + return seq in this.activeEventuals; } /** @@ -454,24 +420,21 @@ export class WorkflowExecutor { * * Roughly the opposite of {@link deactivateEventual}. */ - private activateEventual(eventual: Eventual): ActiveEventual { + private activateEventual( + seq: number, + eventual: UnresolvedEventualDefinition + ): EventualPromise { /** * The promise that represents */ - let reject: any, resolve: any; - const promise = createEventualPromise( - new Promise((r, rr) => { - resolve = r; - reject = rr; - }), - eventual.seq + const promise = createEventualPromise(seq, () => + // ensure the eventual is deactivated when resolved + this.deactivateEventual(seq) ); - const activeEventual: ActiveEventual = { - resolve, - reject, + const activeEventual: ActiveEventual = { promise, - eventual, + seq, }; /** @@ -479,46 +442,97 @@ export class WorkflowExecutor { * * This is how we determine which eventuals are active. */ - this.runtimeState.active[eventual.seq] = activeEventual; + this.activeEventuals[seq] = activeEventual; + + const triggers = normalizeToArray(eventual.triggers).filter( + (t): t is Exclude => !!t + ); + + const workflowEventTriggers = triggers.filter(isEventTrigger); + if (workflowEventTriggers.length > 0) { + this.activeHandlers.events[seq] = Object.fromEntries( + workflowEventTriggers.map((eventTrigger) => [ + eventTrigger.eventType, + eventTrigger.handler, + ]) + ); + } /** - * If the eventual subscribes to a signal, add it to the map. + * For each dependency, wire the dependency promise to the handler provided by the eventual. + * + * If the dependency resolves or rejects, pass the result along. */ - if (eventual.signals) { - const signals = new Set(normalizeToArray(eventual.signals)); - [...signals].map((signal) => { - if (!(signal in this.runtimeState.awaitingSignals)) { - this.runtimeState.awaitingSignals[signal] = new Set(); + const promiseTriggers = triggers.filter(isPromiseTrigger); + promiseTriggers.forEach((promiseTrigger) => { + // in case someone sneaks a non-promise in here, just make it a promise + (!isPromise(promiseTrigger.promise) + ? Promise.resolve(promiseTrigger.promise) + : promiseTrigger.promise + ).then( + (res) => { + if (this.isEventualActive(seq)) { + this.tryResolveEventual( + seq, + promiseTrigger.handler(Result.resolved(res)) ?? undefined + ); + } + }, + (err) => { + if (this.isEventualActive(seq)) { + this.tryResolveEventual( + seq, + promiseTrigger.handler(Result.failed(err)) ?? undefined + ); + } } - this.runtimeState.awaitingSignals[signal]!.add(eventual.seq); - }); - } + ); + }); + + /** + * If the eventual subscribes to a signal, add it to the map. + */ + const signalTriggers = triggers.filter(isSignalTrigger); + signalTriggers.forEach( + (signalTrigger) => + (this.activeHandlers.signals[signalTrigger.signalId] = { + ...(this.activeHandlers.signals[signalTrigger.signalId] ?? {}), + [seq]: signalTrigger.handler, + }) + ); + // maintain a reference to the signals this eventual is listening for + // in order to effectively remove the handlers later. + activeEventual.signals = signalTriggers.map((s) => s.signalId); /** * If the eventual should be invoked after each event is applied, add it to the set. */ - if (eventual.afterEveryEvent) { - this.runtimeState.awaitingAny.add(eventual.seq); + const [afterEventHandler] = triggers.filter(isAfterEveryEventTrigger); + if (afterEventHandler) { + this.activeHandlers.afterEveryEvent[seq] = afterEventHandler.afterEvery; } - return activeEventual; + return activeEventual.promise; } /** - * Remove an eventual from the runtime state. + * Remove an eventual from the active handlers. * An inactive eventual has already been resolved and has a result. * * Roughly the opposite of {@link activateEventual}. */ - private deactivateEventual(eventual: Eventual) { - // if the eventual is has a result, immediately remove it - delete this.runtimeState.active[eventual.seq]; - this.runtimeState.awaitingAny.delete(eventual.seq); - if (eventual.signals) { - const signals = normalizeToArray(eventual.signals); - signals.forEach((signal) => - this.runtimeState.awaitingSignals[signal]?.delete(eventual.seq) - ); + private deactivateEventual(seq: number) { + const active = this.activeEventuals[seq]; + if (active) { + // if the eventual is has a result, immediately remove it + delete this.activeEventuals[seq]; + delete this.activeHandlers.events[seq]; + delete this.activeHandlers.afterEveryEvent[seq]; + if (active.signals) { + active.signals.forEach( + (signal) => delete this.activeHandlers.signals[signal]?.[seq] + ); + } } } } @@ -539,3 +553,130 @@ export interface WorkflowResult { */ commands: WorkflowCommand[]; } + +export type Trigger = + | PromiseTrigger + | EventTrigger + | AfterEveryEventTrigger + | SignalTrigger; + +export const Trigger = { + promise: ( + promise: Promise, + handler: PromiseTrigger["handler"] + ): PromiseTrigger => { + return { + promise, + handler, + }; + }, + afterEveryEvent: ( + handler: AfterEveryEventTrigger["afterEvery"] + ): AfterEveryEventTrigger => { + return { + afterEvery: handler, + }; + }, + workflowEvent: ( + eventType: T, + handler: EventTrigger["handler"] + ): EventTrigger => { + return { + eventType, + handler, + }; + }, + signal: ( + signalId: Signal["id"], + handler: SignalTrigger["handler"] + ): SignalTrigger => { + return { + signalId, + handler, + }; + }, +}; + +export interface PromiseTrigger { + promise: Promise; + handler: (val: Result) => Result | void; +} + +export interface AfterEveryEventTrigger { + afterEvery: () => Result | void; +} + +export interface EventTrigger< + out OwnRes = any, + E extends HistoryResultEvent = any +> { + eventType: E["type"]; + handler: (event: E) => Result | void; +} + +export interface SignalTrigger { + signalId: Signal["id"]; + handler: (event: SignalReceived) => Result | void; +} + +export function isPromiseTrigger( + t: Trigger +): t is PromiseTrigger { + return "promise" in t; +} + +export function isAfterEveryEventTrigger( + t: Trigger +): t is AfterEveryEventTrigger { + return "afterEvery" in t; +} + +export function isEventTrigger( + t: Trigger +): t is EventTrigger { + return "eventType" in t; +} + +export function isSignalTrigger( + t: Trigger +): t is SignalTrigger { + return "signalId" in t; +} + +interface EventualDefinitionBase { + /** + * Commands to emit. + * + * When undefined, the eventual will not be checked against an expected event as it does not emit commands. + */ + generateCommands?: (seq: number) => WorkflowCommand[] | WorkflowCommand; +} + +export interface ResolvedEventualDefinition extends EventualDefinitionBase { + /** + * When provided, immediately resolves an EventualPromise with a value or error back to the workflow. + * + * Commands can still be emitted, but the eventual cannot be triggered. + */ + result: Result; +} + +export interface UnresolvedEventualDefinition + extends EventualDefinitionBase { + /** + * Triggers give the Eventual an opportunity to resolve themselves. + * + * Triggers are only called when an eventual is considered to be active. + */ + triggers: Trigger | (Trigger | undefined)[]; +} + +export type EventualDefinition = + | ResolvedEventualDefinition + | UnresolvedEventualDefinition; + +export function isResolvedEventualDefinition( + eventualDefinition: EventualDefinition +): eventualDefinition is ResolvedEventualDefinition { + return "result" in eventualDefinition; +} diff --git a/packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts b/packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts deleted file mode 100644 index 931edb984..000000000 --- a/packages/@eventual/core-runtime/test/cancellable-promises-hook.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { - cancellable, - cancelLocalPromises, -} from "../src/cancellable-promise-hook.js"; -import { jest } from "@jest/globals"; -import { isPromise } from "util/types"; - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function never() { - return new Promise(() => {}); -} - -// const myPromise = cancellable(async () => { -// console.log("function called"); - -// try { -// // trigger an await -// await sleep(0); - -// cancelLocalPromises(); - -// console.log("in between sleep"); - -// // uncomment for test -// // cancel(); - -// // trigger another wait -// await sleep(0); - -// console.log("I will never be called 1"); -// } catch (err) { -// console.log("I will never be called 2", err); -// } finally { -// console.log("I will never be called 3"); -// } -// console.log("I will never be called 4"); -// }); - -// try { -// await new Promise((resolve, reject) => { -// // wrapping this in a new Promise prevents the async local storage from -// // leaking into the parent - -// // `await myPromise` binds its local storage to the outer promise it seems -// return myPromise.then(resolve).catch(reject); -// }); -// console.log("Promise was not cancelled"); -// } catch (err) { -// console.log("Promise was cancelled"); -// } - -test("no cancel", async () => { - const fn = jest.fn(); - const myPromise = cancellable(async () => { - // trigger an await - await sleep(0); - fn(); - }); - - await myPromise; - expect(fn).toBeCalled(); -}); - -test("cancel", async () => { - const fn = jest.fn(); - await expect(() => - cancellable(async () => { - // trigger an await - await sleep(0); - fn(); - - cancelLocalPromises(); - - await sleep(0); - fn(); - }) - ).rejects.toThrow(); - - expect(fn).toBeCalledTimes(1); -}); - -test("cancel in catch", async () => { - const fn = jest.fn(); - await expect(() => - cancellable(async () => { - try { - // trigger an await - await sleep(0); - fn(); - cancelLocalPromises(); - } catch { - fn(); - } - await sleep(0); - fn(); - }) - ).rejects.toThrow(); - - expect(fn).toBeCalledTimes(1); -}); - -test("cancel with nested", async () => { - const fn = jest.fn(); - await expect(() => - cancellable(async () => { - try { - // trigger an await - await sleep(0); - await (async () => { - await sleep(0); - fn(); - await sleep(0); - fn(); - })(); - fn(); - cancelLocalPromises(); - } catch { - fn(); - } - await sleep(0); - fn(); - }) - ).rejects.toThrow(); - - expect(fn).toBeCalledTimes(3); -}); - -test("cancel with dangling", async () => { - const fn = jest.fn(); - await expect(() => - cancellable(async () => { - try { - // trigger an await - await sleep(0); - (async () => { - sleep(0); - fn(); - sleep(0); - fn(); - })(); - fn(); - cancelLocalPromises(); - } catch { - fn(); - } - await sleep(0); - fn(); - }) - ).rejects.toThrow(); - - expect(fn).toBeCalledTimes(3); -}); - -test("cancel with never resolving", async () => { - const fn = jest.fn(); - await expect(() => - cancellable(async () => { - try { - // trigger an await - await sleep(0); - (async () => { - await never(); - })(); - fn(); - cancelLocalPromises(); - } catch { - fn(); - } - await sleep(0); - fn(); - }) - ).rejects.toThrow(); - - expect(fn).toBeCalledTimes(1); -}); - -test("test all", async () => { - const fn = jest.fn(); - await cancellable(async () => { - fn(); - await Promise.all([sleep(0), sleep(0)]); - fn(); - }); - - expect(fn).toBeCalledTimes(2); -}); - -test("cancel with all", async () => { - const fn = jest.fn(); - await expect(() => - cancellable(async () => { - try { - // trigger an await - await Promise.all([ - sleep(0), - never(), - sleep(0).then(() => { - fn(); - cancelLocalPromises(); - }), - ]); - } catch (err) { - console.error(err); - fn(); - } - await sleep(0); - fn(); - }) - ).rejects.toThrow(); - - expect(fn).toBeCalledTimes(1); -}); - -test("cancel with all settled", async () => { - const fn = jest.fn(); - await expect(() => - cancellable(async () => { - try { - // trigger an await - await Promise.allSettled([ - sleep(0), - never(), - sleep(0).then(() => { - fn(); - cancelLocalPromises(); - }), - ]); - fn(); - } catch (err) { - console.error(err); - fn(); - } - await sleep(0); - fn(); - }) - ).rejects.toThrow(); - - expect(fn).toBeCalledTimes(1); -}); - -test("cancel with any", async () => { - const fn = jest.fn(); - await expect(() => - cancellable(async () => { - try { - // trigger an await - await Promise.any([ - never(), - sleep(0).then(() => { - fn(); - cancelLocalPromises(); - }), - ]); - fn(); - } catch (err) { - console.error(err); - fn(); - } - await sleep(0); - fn(); - }) - ).rejects.toThrow(); - - expect(fn).toBeCalledTimes(1); -}); - -test("cancel with race", async () => { - const fn = jest.fn(); - await expect(() => - cancellable(async () => { - try { - // trigger an await - await Promise.race([ - never(), - sleep(0).then(() => { - fn(); - cancelLocalPromises(); - }), - ]); - fn(); - } catch (err) { - console.error(err); - fn(); - } - await sleep(0); - fn(); - }) - ).rejects.toThrow(); - - expect(fn).toBeCalledTimes(1); -}); - -test("isPromise", async () => { - await cancellable(async () => { - expect(isPromise(new Promise(() => {}))).toBeTruthy(); - }); -}); - -/** - * This currently fails, cancellation only rejects the top promise, not all of the children, what value does it do? - */ -test.skip("cancelled child", async () => { - let p1: Promise; - const p2 = cancellable(async () => { - p1 = new Promise(() => {}); - await sleep(0); - cancelLocalPromises(); - await p1; - }); - - await expect(() => p2!).rejects.toThrow("cancelled"); - await expect(p1!).rejects.toThrow("cancelled"); -}); - -// test("cancel with ordered", async () => { -// const fn = jest.fn(); -// const fn2 = jest.fn(); -// const fn3 = jest.fn(); -// await expect(() => -// cancellable(async () => { -// try { -// // trigger an await -// await sleep(0); -// fn(); -// cancelLocalPromises(); -// } catch { -// fn(); -// } -// await sleep(0); -// fn(); -// }) -// ).rejects.toThrow(); - -// expect(fn).toBeCalledTimes(1); -// }); diff --git a/packages/@eventual/core/src/internal/eventual-hook.ts b/packages/@eventual/core/src/internal/eventual-hook.ts index 440a1c17e..ebef84f91 100644 --- a/packages/@eventual/core/src/internal/eventual-hook.ts +++ b/packages/@eventual/core/src/internal/eventual-hook.ts @@ -22,18 +22,11 @@ export interface EventualPromise extends Promise { [EventualPromiseSymbol]: number; } -export function createEventualPromise( - promise: Promise, - seq: number -): EventualPromise { - const _promise = promise as EventualPromise; - _promise[EventualPromiseSymbol] = seq; - return _promise; -} - export interface ExecutionWorkflowHook { - registerEventualCall(eventual: EventualCall): EventualPromise; - resolveEventual(seq: number, result: Result): void; + registerEventualCall>( + eventual: EventualCall + ): E; + resolveEventual(seq: number, result: Result): void; } export function tryGetWorkflowHook() { From a0f7b9456dd0a6a68e47f30b77767bbe61a9b8b1 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Mon, 6 Mar 2023 15:51:23 -0600 Subject: [PATCH 13/28] old spelling error --- .../test/{commend-executor.test.ts => command-executor.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/@eventual/core-runtime/test/{commend-executor.test.ts => command-executor.test.ts} (100%) diff --git a/packages/@eventual/core-runtime/test/commend-executor.test.ts b/packages/@eventual/core-runtime/test/command-executor.test.ts similarity index 100% rename from packages/@eventual/core-runtime/test/commend-executor.test.ts rename to packages/@eventual/core-runtime/test/command-executor.test.ts From 1ac48bd84c408cce58844a4f15b4c9ecf3ed279e Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Mon, 6 Mar 2023 16:19:56 -0600 Subject: [PATCH 14/28] test continue --- .../test/workflow-executor.test.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/@eventual/core-runtime/test/workflow-executor.test.ts b/packages/@eventual/core-runtime/test/workflow-executor.test.ts index 320daa2bf..84cc27877 100644 --- a/packages/@eventual/core-runtime/test/workflow-executor.test.ts +++ b/packages/@eventual/core-runtime/test/workflow-executor.test.ts @@ -2621,3 +2621,99 @@ test("publish event", async () => { commands: [], }); }); + +describe("continue", () => { + test("start a workflow with no events and feed it one after", async () => { + const executor = new WorkflowExecutor(myWorkflow, [], { resumable: true }); + await expect( + executor.start(event, context) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("my-activity", [event], 0)], + result: undefined, + }); + + await expect( + executor.continue(activitySucceeded("result", 0)) + ).resolves.toEqual({ + commands: [ + createScheduledActivityCommand("my-activity-0", [event], 1), + createStartTimerCommand(2), + createScheduledActivityCommand("my-activity-2", [event], 3), + ], + result: undefined, + }); + }); + + test("start a workflow with events and feed it one after", async () => { + const executor = new WorkflowExecutor( + myWorkflow, + [activityScheduled("my-activity", 0), activitySucceeded("result", 0)], + { resumable: true } + ); + await expect( + executor.start(event, context) + ).resolves.toEqual({ + commands: [ + createScheduledActivityCommand("my-activity-0", [event], 1), + createStartTimerCommand(2), + createScheduledActivityCommand("my-activity-2", [event], 3), + ], + result: undefined, + }); + + await expect( + executor.continue(timerCompleted(2), activitySucceeded("result-2", 3)) + ).resolves.toEqual({ + commands: [], + result: Result.resolved(["result", [undefined, "result-2"]]), + }); + }); + + test("many iterations", async () => { + const wf = workflow(async () => { + for (const i of [...Array(100).keys()]) { + await createActivityCall("myAct", i); + } + + return "done"; + }); + + const executor = new WorkflowExecutor(wf, [], { resumable: true }); + + await executor.start(undefined, context); + + for (const i of [...Array(99).keys()]) { + await executor.continue(activitySucceeded(undefined, i)); + } + + await expect( + executor.continue(activitySucceeded(undefined, 99)) + ).resolves.toEqual({ + commands: [], + result: Result.resolved("done"), + }); + }); + + test("many iterations at once", async () => { + const wf = workflow(async () => { + for (const i of [...Array(100).keys()]) { + await createActivityCall("myAct", i); + } + + return "done"; + }); + + const executor = new WorkflowExecutor(wf, [], { resumable: true }); + + await executor.start(undefined, context); + + await expect( + executor.continue( + ...[...Array(100).keys()].map((i) => activitySucceeded(undefined, i)) + ) + ).resolves.toEqual({ + commands: [], + result: Result.resolved("done"), + }); + }); +}); From 94ef5f712b89d5aaf4b1efcf44be7bf9a8eb36aa Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Mon, 6 Mar 2023 17:19:15 -0600 Subject: [PATCH 15/28] fix some tests --- .../core-runtime/src/workflow-executor.ts | 35 ++++++++--- .../test/workflow-executor.test.ts | 60 +++++++++++++++++++ 2 files changed, 87 insertions(+), 8 deletions(-) diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index bdecf4887..7f98f5d01 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -203,7 +203,7 @@ export class WorkflowExecutor { // this assumption breaks down when the user tries to start a promise // to accomplish non-deterministic actions like IO. setTimeout(() => { - resolve(this.flushCurrentWorkflowResult()); + this.started?.resolve(); }); }); } @@ -252,16 +252,21 @@ export class WorkflowExecutor { this.history.push(...history); return new Promise(async (resolve) => { + // start context with execution hook + this.started = { + resolve: (result) => { + resolve(this.flushCurrentWorkflowResult(result)); + }, + }; + // Also ensure that any handlers like signal handlers returned by the workflow // have access to the workflow hook - await this.enterWorkflowHookScope(() => this.drainHistoryEvents()); - - const newCommands = this.commandsToEmit; - this.commandsToEmit = []; + await this.enterWorkflowHookScope(async () => { + await this.drainHistoryEvents(); + }); - resolve({ - commands: newCommands, - result: this.result, + setTimeout(() => { + resolve(this.flushCurrentWorkflowResult()); }); }); } @@ -273,6 +278,9 @@ export class WorkflowExecutor { this.started.resolve(result); } + /** + * Provides a scope where the workflowHook is available to the {@link Call}s. + */ private async enterWorkflowHookScope(callback: (...args: any) => Res) { const self = this; const workflowHook: ExecutionWorkflowHook = { @@ -342,6 +350,12 @@ export class WorkflowExecutor { } } + /** + * Applies each of the history events to the workflow in the order they were received. + * + * Each event will find all of the applicable handlers and let them resolve + * before applying the next set of events. + */ private async drainHistoryEvents() { while (this.events.hasNext() && !this.result) { const event = this.events.next()!; @@ -398,6 +412,11 @@ export class WorkflowExecutor { } } + /** + * Attempts to provide a result to an eventual. + * + * If the eventual is not active, the result will be ignored. + */ private tryResolveEventual( seq: number, result: Result | undefined diff --git a/packages/@eventual/core-runtime/test/workflow-executor.test.ts b/packages/@eventual/core-runtime/test/workflow-executor.test.ts index 84cc27877..5370f29c2 100644 --- a/packages/@eventual/core-runtime/test/workflow-executor.test.ts +++ b/packages/@eventual/core-runtime/test/workflow-executor.test.ts @@ -2622,6 +2622,31 @@ test("publish event", async () => { }); }); +test("many events at once", async () => { + const wf = workflow(async () => { + for (const i of [...Array(100).keys()]) { + await createActivityCall("myAct", i); + } + + return "done"; + }); + + const executor = new WorkflowExecutor( + wf, + [...Array(100).keys()].flatMap((i) => [ + activityScheduled("myAct", i), + activitySucceeded(undefined, i), + ]) + ); + + await expect( + executor.start(undefined, context) + ).resolves.toEqual({ + commands: [], + result: Result.resolved("done"), + }); +}); + describe("continue", () => { test("start a workflow with no events and feed it one after", async () => { const executor = new WorkflowExecutor(myWorkflow, [], { resumable: true }); @@ -2707,6 +2732,41 @@ describe("continue", () => { await executor.start(undefined, context); + await expect( + executor.continue( + ...[...Array(100).keys()].map((i) => activitySucceeded(undefined, i)) + ) + ).resolves.toEqual({ + commands: [...Array(99).keys()].map((i) => + // commands are still emitted because normally the command would precede the events. + // the first command is emitted during start + createScheduledActivityCommand("myAct", i + 1, i + 1) + ), + result: Result.resolved("done"), + }); + }); + + test("many iterations at once with expected", async () => { + const wf = workflow(async () => { + for (const i of [...Array(100).keys()]) { + await createActivityCall("myAct", i); + } + + return "done"; + }); + + const executor = new WorkflowExecutor( + wf, + /** + * We will provide expected events, but will not consume them all until all of the + * succeeded events are supplied. + */ + [...Array(100).keys()].map((i) => activityScheduled("myAct", i)), + { resumable: true } + ); + + await executor.start(undefined, context); + await expect( executor.continue( ...[...Array(100).keys()].map((i) => activitySucceeded(undefined, i)) From 724e7ac907a7af2f9f04dc6663f4318519409562 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Mon, 6 Mar 2023 17:22:19 -0600 Subject: [PATCH 16/28] fix tests --- .../__snapshots__/infer-plugin.test.ts.snap | 21 +++++++------------ .../core-runtime/src/workflow-command.ts | 11 ++++++---- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap b/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap index 7edad5e3a..9d40707c9 100644 --- a/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap +++ b/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap @@ -449,19 +449,14 @@ var EventualPromiseSymbol = Symbol.for("Eventual:Promise"); var EventualCallKind; (function(EventualCallKind2) { - EventualCallKind2[EventualCallKind2["ActivityCall"] = 1] = "ActivityCall"; - EventualCallKind2[EventualCallKind2["AwaitAll"] = 0] = "AwaitAll"; - EventualCallKind2[EventualCallKind2["AwaitAllSettled"] = 12] = "AwaitAllSettled"; - EventualCallKind2[EventualCallKind2["AwaitAny"] = 10] = "AwaitAny"; - EventualCallKind2[EventualCallKind2["AwaitDurationCall"] = 3] = "AwaitDurationCall"; - EventualCallKind2[EventualCallKind2["AwaitTimeCall"] = 4] = "AwaitTimeCall"; - EventualCallKind2[EventualCallKind2["ConditionCall"] = 9] = "ConditionCall"; - EventualCallKind2[EventualCallKind2["ExpectSignalCall"] = 6] = "ExpectSignalCall"; - EventualCallKind2[EventualCallKind2["PublishEventsCall"] = 13] = "PublishEventsCall"; - EventualCallKind2[EventualCallKind2["Race"] = 11] = "Race"; - EventualCallKind2[EventualCallKind2["RegisterSignalHandlerCall"] = 7] = "RegisterSignalHandlerCall"; - EventualCallKind2[EventualCallKind2["SendSignalCall"] = 8] = "SendSignalCall"; - EventualCallKind2[EventualCallKind2["WorkflowCall"] = 5] = "WorkflowCall"; + EventualCallKind2[EventualCallKind2["ActivityCall"] = 0] = "ActivityCall"; + EventualCallKind2[EventualCallKind2["AwaitTimerCall"] = 1] = "AwaitTimerCall"; + EventualCallKind2[EventualCallKind2["ConditionCall"] = 2] = "ConditionCall"; + EventualCallKind2[EventualCallKind2["ExpectSignalCall"] = 3] = "ExpectSignalCall"; + EventualCallKind2[EventualCallKind2["PublishEventsCall"] = 4] = "PublishEventsCall"; + EventualCallKind2[EventualCallKind2["RegisterSignalHandlerCall"] = 5] = "RegisterSignalHandlerCall"; + EventualCallKind2[EventualCallKind2["SendSignalCall"] = 6] = "SendSignalCall"; + EventualCallKind2[EventualCallKind2["WorkflowCall"] = 7] = "WorkflowCall"; })(EventualCallKind || (EventualCallKind = {})); var EventualCallSymbol = Symbol.for("eventual:EventualCall"); diff --git a/packages/@eventual/core-runtime/src/workflow-command.ts b/packages/@eventual/core-runtime/src/workflow-command.ts index ead9c708b..6f8a0f20f 100644 --- a/packages/@eventual/core-runtime/src/workflow-command.ts +++ b/packages/@eventual/core-runtime/src/workflow-command.ts @@ -1,7 +1,10 @@ -import { EventEnvelope } from "../../core/src/event.js"; -import { DurationSchedule, Schedule } from "../../core/src/schedule.js"; -import { WorkflowExecutionOptions } from "../../core/src/workflow.js"; -import { SignalTarget } from "../../core/src/internal/signal.js"; +import { + DurationSchedule, + EventEnvelope, + Schedule, + WorkflowExecutionOptions, +} from "@eventual/core"; +import { SignalTarget } from "@eventual/core/internal"; export type WorkflowCommand = | StartTimerCommand From 31fd5fa19d540cf6f3b5a2a0593872197d5f3572 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Mon, 6 Mar 2023 22:43:15 -0600 Subject: [PATCH 17/28] cleanup --- packages/@eventual/cli/src/commands/replay.ts | 8 +- .../@eventual/compiler/src/eventual-bundle.ts | 2 - .../__snapshots__/infer-plugin.test.ts.snap | 75 +--------- .../core-runtime/src/console-hook.ts | 2 +- .../@eventual/core-runtime/src/date-hook.ts | 2 +- .../src/handlers/activity-worker.ts | 2 +- .../core-runtime/src/handlers/orchestrator.ts | 12 +- packages/@eventual/core-runtime/src/index.ts | 2 +- .../core-runtime/src/metrics/utils.ts | 18 --- packages/@eventual/core-runtime/src/result.ts | 40 ++++++ .../core-runtime/src/workflow-executor.ts | 134 ++++++++---------- .../test/workflow-executor.test.ts | 33 ++--- .../@eventual/core/src/internal/activity.ts | 4 +- .../core/src/internal/calls/calls.ts | 2 +- .../core/src/internal/eventual-hook.ts | 3 +- .../@eventual/core/src/internal/result.ts | 50 +------ .../core/src/internal/workflow-events.ts | 8 +- .../src/providers/activity-provider.ts | 12 +- 18 files changed, 149 insertions(+), 260 deletions(-) create mode 100644 packages/@eventual/core-runtime/src/result.ts diff --git a/packages/@eventual/cli/src/commands/replay.ts b/packages/@eventual/cli/src/commands/replay.ts index dec50349b..05c183c45 100644 --- a/packages/@eventual/cli/src/commands/replay.ts +++ b/packages/@eventual/cli/src/commands/replay.ts @@ -7,17 +7,17 @@ import { isSucceededExecution, } from "@eventual/core"; import { + isFailed, + isResolved, + normalizeFailedResult, parseWorkflowName, processEvents, progressWorkflow, + resultToString, } from "@eventual/core-runtime"; import { encodeExecutionId, - isFailed, - isResolved, - normalizeFailedResult, Result, - resultToString, ServiceType, serviceTypeScope, workflows, diff --git a/packages/@eventual/compiler/src/eventual-bundle.ts b/packages/@eventual/compiler/src/eventual-bundle.ts index 189b554f0..c97a83173 100755 --- a/packages/@eventual/compiler/src/eventual-bundle.ts +++ b/packages/@eventual/compiler/src/eventual-bundle.ts @@ -64,7 +64,6 @@ export async function build({ injectedServiceSpec, name, entry, - // eventualTransform = false, sourcemap, serviceType, external, @@ -101,7 +100,6 @@ export async function build({ }), ] : []), - // ...(eventualTransform ? [eventualESPlugin] : []), ], conditions: ["module", "import", "require"], // external: ["@aws-sdk"], diff --git a/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap b/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap index 9d40707c9..81ae5b037 100644 --- a/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap +++ b/packages/@eventual/compiler/test/__snapshots__/infer-plugin.test.ts.snap @@ -443,10 +443,6 @@ __export(command_worker_exports, { }); module.exports = __toCommonJS(command_worker_exports); -var AsyncTokenSymbol = Symbol.for("eventual:AsyncToken"); - -var EventualPromiseSymbol = Symbol.for("Eventual:Promise"); - var EventualCallKind; (function(EventualCallKind2) { EventualCallKind2[EventualCallKind2["ActivityCall"] = 0] = "ActivityCall"; @@ -458,7 +454,6 @@ var EventualCallKind; EventualCallKind2[EventualCallKind2["SendSignalCall"] = 6] = "SendSignalCall"; EventualCallKind2[EventualCallKind2["WorkflowCall"] = 7] = "WorkflowCall"; })(EventualCallKind || (EventualCallKind = {})); -var EventualCallSymbol = Symbol.for("eventual:EventualCall"); var ServiceType; (function(ServiceType2) { @@ -655,27 +650,11 @@ _BaseCachingSecret_value = /* @__PURE__ */ new WeakMap(); var import_ulidx2 = __toESM(require_dist2(), 1); -function or(...conditions) { - return (a) => conditions.some((cond) => cond(a)); -} - -var ResultSymbol = Symbol.for("eventual:Result"); var ResultKind; (function(ResultKind2) { - ResultKind2[ResultKind2["Pending"] = 0] = "Pending"; - ResultKind2[ResultKind2["Resolved"] = 1] = "Resolved"; - ResultKind2[ResultKind2["Failed"] = 2] = "Failed"; + ResultKind2[ResultKind2["Resolved"] = 0] = "Resolved"; + ResultKind2[ResultKind2["Failed"] = 1] = "Failed"; })(ResultKind || (ResultKind = {})); -function isResult(a) { - return a && typeof a === "object" && ResultSymbol in a; -} -function isResolved(result) { - return isResult(result) && result[ResultSymbol] === ResultKind.Resolved; -} -function isFailed(result) { - return isResult(result) && result[ResultSymbol] === ResultKind.Failed; -} -var isResolvedOrFailed = or(isResolved, isFailed); var SignalTargetType; (function(SignalTargetType2) { @@ -683,6 +662,10 @@ var SignalTargetType; SignalTargetType2[SignalTargetType2["ChildExecution"] = 1] = "ChildExecution"; })(SignalTargetType || (SignalTargetType = {})); +function or(...conditions) { + return (a) => conditions.some((cond) => cond(a)); +} + var WorkflowEventType; (function(WorkflowEventType2) { WorkflowEventType2["ActivitySucceeded"] = "ActivitySucceeded"; @@ -704,59 +687,13 @@ var WorkflowEventType; WorkflowEventType2["WorkflowRunStarted"] = "WorkflowRunStarted"; WorkflowEventType2["WorkflowTimedOut"] = "WorkflowTimedOut"; })(WorkflowEventType || (WorkflowEventType = {})); -var isScheduledEvent = or(isActivityScheduled, isChildWorkflowScheduled, isEventsPublished, isSignalSent, isTimerScheduled); -var isSucceededEvent = or(isActivitySucceeded, isChildWorkflowSucceeded, isTimerCompleted); -var isFailedEvent = or(isActivityFailed, isActivityHeartbeatTimedOut, isChildWorkflowFailed, isWorkflowTimedOut); -var isResultEvent = or(isSucceededEvent, isFailedEvent, isSignalReceived, isWorkflowTimedOut, isWorkflowRunStarted); -function isWorkflowRunStarted(event) { - return event.type === WorkflowEventType.WorkflowRunStarted; -} -function isActivityScheduled(event) { - return event.type === WorkflowEventType.ActivityScheduled; -} -function isActivitySucceeded(event) { - return event.type === WorkflowEventType.ActivitySucceeded; -} -function isActivityFailed(event) { - return event.type === WorkflowEventType.ActivityFailed; -} -function isActivityHeartbeatTimedOut(event) { - return event.type === WorkflowEventType.ActivityHeartbeatTimedOut; -} -function isTimerScheduled(event) { - return event.type === WorkflowEventType.TimerScheduled; -} function isWorkflowSucceeded(event) { return event.type === WorkflowEventType.WorkflowSucceeded; } function isWorkflowFailed(event) { return event.type === WorkflowEventType.WorkflowFailed; } -function isChildWorkflowScheduled(event) { - return event.type === WorkflowEventType.ChildWorkflowScheduled; -} -function isChildWorkflowSucceeded(event) { - return event.type === WorkflowEventType.ChildWorkflowSucceeded; -} -function isChildWorkflowFailed(event) { - return event.type === WorkflowEventType.ChildWorkflowFailed; -} -function isTimerCompleted(event) { - return event.type === WorkflowEventType.TimerCompleted; -} var isWorkflowCompletedEvent = or(isWorkflowFailed, isWorkflowSucceeded); -function isSignalReceived(event) { - return event.type === WorkflowEventType.SignalReceived; -} -function isSignalSent(event) { - return event.type === WorkflowEventType.SignalSent; -} -function isEventsPublished(event) { - return event.type === WorkflowEventType.EventsPublished; -} -function isWorkflowTimedOut(event) { - return event.type === WorkflowEventType.WorkflowTimedOut; -} var myHandler = api.get("/", async () => { return new HttpResponse(); diff --git a/packages/@eventual/core-runtime/src/console-hook.ts b/packages/@eventual/core-runtime/src/console-hook.ts index 0e051575f..1a390476d 100644 --- a/packages/@eventual/core-runtime/src/console-hook.ts +++ b/packages/@eventual/core-runtime/src/console-hook.ts @@ -1,7 +1,7 @@ import { LogLevel } from "@eventual/core"; const originalConsole = globalThis.console; -const HOOKED_SYMBOL = Symbol.for("eventual-hooked-console"); +const HOOKED_SYMBOL = /* @__PURE__ */ Symbol.for("eventual-hooked-console"); /** * Replaces the node implementation of console.[log, info, debug, error, warn, trace] diff --git a/packages/@eventual/core-runtime/src/date-hook.ts b/packages/@eventual/core-runtime/src/date-hook.ts index 6de1fe267..2d1fd2068 100644 --- a/packages/@eventual/core-runtime/src/date-hook.ts +++ b/packages/@eventual/core-runtime/src/date-hook.ts @@ -1,5 +1,5 @@ const originalDate = globalThis.Date; -const HOOKED_SYMBOL = Symbol.for("eventual-hooked-date"); +const HOOKED_SYMBOL = /* @__PURE__ */ Symbol.for("eventual-hooked-date"); /** * Replaces the node implementation of Date with a hook to get the current datetime. diff --git a/packages/@eventual/core-runtime/src/handlers/activity-worker.ts b/packages/@eventual/core-runtime/src/handlers/activity-worker.ts index 91412bc1f..2fbf5799e 100644 --- a/packages/@eventual/core-runtime/src/handlers/activity-worker.ts +++ b/packages/@eventual/core-runtime/src/handlers/activity-worker.ts @@ -13,7 +13,6 @@ import { extendsError, isAsyncResult, isWorkflowFailed, - normalizeError, registerServiceClient, ServiceType, serviceTypeScope, @@ -32,6 +31,7 @@ import { ActivityMetrics, MetricsCommon } from "../metrics/constants.js"; import { Unit } from "../metrics/unit.js"; import { timed } from "../metrics/utils.js"; import { ActivityProvider } from "../providers/activity-provider.js"; +import { normalizeError } from "../result.js"; import { computeDurationSeconds } from "../schedule.js"; import { ActivityStore } from "../stores/activity-store.js"; import { createEvent } from "../workflow-events.js"; diff --git a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts index 2e13be39e..35dfc90af 100644 --- a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts +++ b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts @@ -15,18 +15,14 @@ import { getEventId, HistoryEvent, HistoryStateEvent, - isFailed, isHistoryEvent, isHistoryStateEvent, - isResolved, - isResult, isTimerCompleted, isWorkflowCompletedEvent, isWorkflowFailed, isWorkflowRunStarted, isWorkflowStarted, isWorkflowSucceeded, - normalizeFailedResult, Result, ServiceType, serviceTypeScope, @@ -52,6 +48,12 @@ import { MetricsLogger } from "../metrics/metrics-logger.js"; import { Unit } from "../metrics/unit.js"; import { timed } from "../metrics/utils.js"; import { WorkflowProvider } from "../providers/workflow-provider.js"; +import { + isFailed, + isResolved, + isResult, + normalizeFailedResult, +} from "../result.js"; import { ExecutionHistoryStateStore } from "../stores/execution-history-state-store.js"; import { ExecutionHistoryStore } from "../stores/execution-history-store.js"; import { WorkflowTask } from "../tasks.js"; @@ -726,8 +728,6 @@ export async function progressWorkflow( ); return await executor.start(processedEvents.startEvent.input, context); } catch (err) { - // temporary fix when the interpreter fails, but the activities are not cleared. - // clearEventualCollector(); console.debug("workflow error", inspect(err)); throw err; } finally { diff --git a/packages/@eventual/core-runtime/src/index.ts b/packages/@eventual/core-runtime/src/index.ts index cf09ac64a..46196ba46 100644 --- a/packages/@eventual/core-runtime/src/index.ts +++ b/packages/@eventual/core-runtime/src/index.ts @@ -7,10 +7,10 @@ export * from "./handlers/index.js"; export * from "./log-agent.js"; export * from "./metrics/index.js"; export * from "./providers/index.js"; +export * from "./result.js"; export * from "./schedule.js"; export * from "./stores/index.js"; export * from "./system-commands.js"; export * from "./tasks.js"; export * from "./utils.js"; export * from "./workflow-command.js"; - diff --git a/packages/@eventual/core-runtime/src/metrics/utils.ts b/packages/@eventual/core-runtime/src/metrics/utils.ts index ba6b27a0e..78cc94732 100644 --- a/packages/@eventual/core-runtime/src/metrics/utils.ts +++ b/packages/@eventual/core-runtime/src/metrics/utils.ts @@ -18,21 +18,3 @@ export async function timed( return result; } - -export function timedSync( - metricLogger: MetricsLogger, - name: string, - call: () => T -): T { - const start = new Date(); - - const result = call(); - - metricLogger.putMetric( - name, - new Date().getTime() - start.getTime(), - Unit.Milliseconds - ); - - return result; -} diff --git a/packages/@eventual/core-runtime/src/result.ts b/packages/@eventual/core-runtime/src/result.ts new file mode 100644 index 000000000..3b9150002 --- /dev/null +++ b/packages/@eventual/core-runtime/src/result.ts @@ -0,0 +1,40 @@ +import { extendsError, Failed, Resolved, Result, ResultKind, ResultSymbol } from "@eventual/core/internal"; + +export function isResult(a: any): a is Result { + return a && typeof a === "object" && ResultSymbol in a; +} + +export function isResolved( + result: Result | undefined +): result is Resolved { + return isResult(result) && result[ResultSymbol] === ResultKind.Resolved; +} + +export function isFailed(result: Result | undefined): result is Failed { + return isResult(result) && result[ResultSymbol] === ResultKind.Failed; +} + +export function normalizeFailedResult(result: Failed): { + error: string; + message: string; +} { + return normalizeError(result.error); +} + +export function normalizeError(err: any) { + const [error, message] = extendsError(err) + ? [err.name, err.message] + : ["Error", JSON.stringify(err)]; + return { error, message }; +} + +export function resultToString(result?: Result) { + if (isFailed(result)) { + const { error, message } = normalizeFailedResult(result); + return `${error}: ${message}`; + } else if (isResolved(result)) { + return result.value ? JSON.stringify(result.value) : ""; + } else { + return ""; + } +} diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index 7f98f5d01..94cf1d9f0 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -14,7 +14,6 @@ import { HistoryEvent, HistoryResultEvent, HistoryScheduledEvent, - isResolved, isResultEvent, isScheduledEvent, isSignalReceived, @@ -28,6 +27,7 @@ import { } from "@eventual/core/internal"; import { isPromise } from "util/types"; import { createEventualFromCall, isCorresponding } from "./eventual-factory.js"; +import { isResolved } from "./result.js"; import type { WorkflowCommand } from "./workflow-command.js"; /** @@ -79,13 +79,6 @@ export function createEventualPromise( } interface ExecutorOptions { - /** - * When false, the workflow will auto-cancel when it has exhausted all history events provided - * during {@link start}. - * - * When true, use {@link continue} to provide more history events. - */ - resumable?: boolean; hooks?: { /** * Callback called when a returned call matches an input event. @@ -167,68 +160,25 @@ export class WorkflowExecutor { ): Promise> { if (this.started) { throw new Error( - "Execution has already been started. If resumable is on, use continue to apply new events or create a new Interpreter" + "Execution has already been started. Use continue to apply new events or create a new Interpreter" ); } - return new Promise(async (resolve) => { - // start context with execution hook - this.started = { - resolve: (result) => { - resolve(this.flushCurrentWorkflowResult(result)); - }, - }; - // ensure the workflow hook is available to the workflow - // and tied to the workflow promise context - // Also ensure that any handlers like signal handlers returned by the workflow - // have access to the workflow hook - await this.enterWorkflowHookScope(async () => { - try { - const workflowPromise = this.workflow.definition(input, context); - workflowPromise.then( - (result) => this.endWorkflowRun(Result.resolved(result)), - (err) => this.endWorkflowRun(Result.failed(err)) - ); - } catch (err) { - // handle any synchronous errors. - this.endWorkflowRun(Result.failed(err)); - } - - // APPLY EVENTS - await this.drainHistoryEvents(); - }); - - // let everything that has started or will be started complete - // set timeout adds the closure to the end of the event loop - // this assumption breaks down when the user tries to start a promise - // to accomplish non-deterministic actions like IO. - setTimeout(() => { - this.started?.resolve(); - }); + // start a workflow run and start the workflow itself. + return this.startWorkflowRun(() => { + try { + const workflowPromise = this.workflow.definition(input, context); + workflowPromise.then( + (result) => this.endWorkflowRun(Result.resolved(result)), + (err) => this.endWorkflowRun(Result.failed(err)) + ); + } catch (err) { + // handle any synchronous errors. + this.endWorkflowRun(Result.failed(err)); + } }); } - /** - * Returns current result and commands, resetting the command array. - * - * If the workflow has additional expected events to apply, fails the workflow with a determinism error. - */ - private flushCurrentWorkflowResult( - overrideResult?: Result - ): WorkflowResult { - const newCommands = this.commandsToEmit; - this.commandsToEmit = []; - - this.result = - !this.result && !this.options?.resumable && this.expected.hasNext() - ? Result.failed( - new DeterminismError("Workflow did not return expected commands") - ) - : overrideResult ?? this.result; - - return { result: this.result, commands: newCommands }; - } - /** * Continue a previously started workflow by feeding in new {@link HistoryResultEvent}, possibly advancing the execution. * @@ -236,37 +186,46 @@ export class WorkflowExecutor { * * Events will be applied to the workflow in order. * + * Workflow will run until completion or until all of the events are exhausted. + * * @returns {@link WorkflowResult} - containing new commands and a result of one was generated. */ public async continue( ...history: HistoryResultEvent[] ): Promise> { - if (!this.options?.resumable) { - throw new Error( - "Cannot continue an execution unless resumable is set to true." - ); - } else if (!this.started) { + if (!this.started) { throw new Error("Execution has not been started, call start first."); } this.history.push(...history); - return new Promise(async (resolve) => { + return this.startWorkflowRun(); + } + + private startWorkflowRun(beforeCommitEvents?: () => void) { + return new Promise>(async (resolve) => { // start context with execution hook this.started = { resolve: (result) => { resolve(this.flushCurrentWorkflowResult(result)); }, }; - + // ensure the workflow hook is available to the workflow + // and tied to the workflow promise context // Also ensure that any handlers like signal handlers returned by the workflow // have access to the workflow hook await this.enterWorkflowHookScope(async () => { + beforeCommitEvents?.(); + // APPLY EVENTS await this.drainHistoryEvents(); }); + // let everything that has started or will be started complete + // set timeout adds the closure to the end of the event loop + // this assumption breaks down when the user tries to start a promise + // to accomplish non-deterministic actions like IO. setTimeout(() => { - resolve(this.flushCurrentWorkflowResult()); + this.endWorkflowRun(); }); }); } @@ -278,6 +237,22 @@ export class WorkflowExecutor { this.started.resolve(result); } + /** + * Returns current result and commands, resetting the command array. + * + * If the workflow has additional expected events to apply, fails the workflow with a determinism error. + */ + private flushCurrentWorkflowResult( + overrideResult?: Result + ): WorkflowResult { + const newCommands = this.commandsToEmit; + this.commandsToEmit = []; + + this.result = overrideResult ?? this.result; + + return { result: this.result, commands: newCommands }; + } + /** * Provides a scope where the workflowHook is available to the {@link Call}s. */ @@ -299,7 +274,10 @@ export class WorkflowExecutor { * One thought would be to get rid of the scheduled events * and instead maintain the call history to match against. */ - if (eventual.generateCommands && !isExpectedCall(seq, call)) { + if ( + eventual.generateCommands && + !checkExpectedCallAndAdvance(seq, call) + ) { self.commandsToEmit.push( ...normalizeToArray(eventual.generateCommands(seq)) ); @@ -331,7 +309,7 @@ export class WorkflowExecutor { * @returns false if the call is new and true if the call matches the expected events. * @throws {@link DeterminismError} when the call is not expected and there are expected events remaining. */ - function isExpectedCall(seq: number, call: EventualCall) { + function checkExpectedCallAndAdvance(seq: number, call: EventualCall) { if (self.expected.hasNext()) { const expected = self.expected.next()!; @@ -401,6 +379,14 @@ export class WorkflowExecutor { event.seq, eventHandler?.(event) ?? undefined ); + } else if (event.seq >= this.nextSeq) { + // if a workflow history event precedes the call, throw an error. + // this should never happen if the workflow is deterministic. + this.endWorkflowRun( + Result.failed( + new DeterminismError(`Call for seq ${event.seq} was not emitted.`) + ) + ); } } // resolve any eventuals should be triggered on each new event diff --git a/packages/@eventual/core-runtime/test/workflow-executor.test.ts b/packages/@eventual/core-runtime/test/workflow-executor.test.ts index 5370f29c2..0cb5b0045 100644 --- a/packages/@eventual/core-runtime/test/workflow-executor.test.ts +++ b/packages/@eventual/core-runtime/test/workflow-executor.test.ts @@ -28,6 +28,7 @@ import { SERVICE_TYPE_FLAG, SignalTargetType, } from "@eventual/core/internal"; +import { WorkflowExecutor, WorkflowResult } from "../src/workflow-executor.js"; import { activityFailed, activityHeartbeatTimedOut, @@ -49,7 +50,6 @@ import { workflowTimedOut, } from "./command-util.js"; -import { WorkflowExecutor, WorkflowResult } from "../src/workflow-executor.js"; import "../src/workflow.js"; const event = "hello world"; @@ -481,19 +481,6 @@ test("should throw when scheduled does not correspond to call", async () => { }); }); -test("should throw when there are more schedules than calls emitted", async () => { - await expect( - execute( - myWorkflow, - [activityScheduled("my-activity", 0), activityScheduled("result", 1)], - event - ) - ).resolves.toMatchObject({ - result: Result.failed({ name: "DeterminismError" }), - commands: [], - }); -}); - test("should throw when a completed precedes workflow state", async () => { await expect( execute( @@ -2649,7 +2636,7 @@ test("many events at once", async () => { describe("continue", () => { test("start a workflow with no events and feed it one after", async () => { - const executor = new WorkflowExecutor(myWorkflow, [], { resumable: true }); + const executor = new WorkflowExecutor(myWorkflow, []); await expect( executor.start(event, context) ).resolves.toEqual({ @@ -2670,11 +2657,10 @@ describe("continue", () => { }); test("start a workflow with events and feed it one after", async () => { - const executor = new WorkflowExecutor( - myWorkflow, - [activityScheduled("my-activity", 0), activitySucceeded("result", 0)], - { resumable: true } - ); + const executor = new WorkflowExecutor(myWorkflow, [ + activityScheduled("my-activity", 0), + activitySucceeded("result", 0), + ]); await expect( executor.start(event, context) ).resolves.toEqual({ @@ -2703,7 +2689,7 @@ describe("continue", () => { return "done"; }); - const executor = new WorkflowExecutor(wf, [], { resumable: true }); + const executor = new WorkflowExecutor(wf, []); await executor.start(undefined, context); @@ -2728,7 +2714,7 @@ describe("continue", () => { return "done"; }); - const executor = new WorkflowExecutor(wf, [], { resumable: true }); + const executor = new WorkflowExecutor(wf, []); await executor.start(undefined, context); @@ -2761,8 +2747,7 @@ describe("continue", () => { * We will provide expected events, but will not consume them all until all of the * succeeded events are supplied. */ - [...Array(100).keys()].map((i) => activityScheduled("myAct", i)), - { resumable: true } + [...Array(100).keys()].map((i) => activityScheduled("myAct", i)) ); await executor.start(undefined, context); diff --git a/packages/@eventual/core/src/internal/activity.ts b/packages/@eventual/core/src/internal/activity.ts index 130dde52f..a28a7ac76 100644 --- a/packages/@eventual/core/src/internal/activity.ts +++ b/packages/@eventual/core/src/internal/activity.ts @@ -5,7 +5,9 @@ import type { AsyncResult, } from "../activity.js"; -export const AsyncTokenSymbol = Symbol.for("eventual:AsyncToken"); +export const AsyncTokenSymbol = /* @__PURE__ */ Symbol.for( + "eventual:AsyncToken" +); export interface ActivityRuntimeContext { execution: ActivityExecutionContext; diff --git a/packages/@eventual/core/src/internal/calls/calls.ts b/packages/@eventual/core/src/internal/calls/calls.ts index 60142a93b..34f289899 100644 --- a/packages/@eventual/core/src/internal/calls/calls.ts +++ b/packages/@eventual/core/src/internal/calls/calls.ts @@ -28,7 +28,7 @@ export enum EventualCallKind { WorkflowCall = 7, } -const EventualCallSymbol = Symbol.for("eventual:EventualCall"); +const EventualCallSymbol = /* @__PURE__ */ Symbol.for("eventual:EventualCall"); export interface EventualCallBase< Kind extends EventualCall[typeof EventualCallSymbol] diff --git a/packages/@eventual/core/src/internal/eventual-hook.ts b/packages/@eventual/core/src/internal/eventual-hook.ts index ebef84f91..67e978cbe 100644 --- a/packages/@eventual/core/src/internal/eventual-hook.ts +++ b/packages/@eventual/core/src/internal/eventual-hook.ts @@ -13,7 +13,8 @@ declare global { | undefined; } -export const EventualPromiseSymbol = Symbol.for("Eventual:Promise"); +export const EventualPromiseSymbol = + /* @__PURE__ */ Symbol.for("Eventual:Promise"); export interface EventualPromise extends Promise { /** diff --git a/packages/@eventual/core/src/internal/result.ts b/packages/@eventual/core/src/internal/result.ts index a33a97a11..e8d5eb4d4 100644 --- a/packages/@eventual/core/src/internal/result.ts +++ b/packages/@eventual/core/src/internal/result.ts @@ -1,6 +1,4 @@ -import { extendsError, or } from "./util.js"; - -export const ResultSymbol = Symbol.for("eventual:Result"); +export const ResultSymbol = /* @__PURE__ */ Symbol.for("eventual:Result"); export type Result = Resolved | Failed; @@ -20,9 +18,8 @@ export const Result = { }; export enum ResultKind { - Pending = 0, - Resolved = 1, - Failed = 2, + Resolved = 0, + Failed = 1, } export interface Resolved { @@ -34,44 +31,3 @@ export interface Failed { [ResultSymbol]: ResultKind.Failed; error: any; } - -export function isResult(a: any): a is Result { - return a && typeof a === "object" && ResultSymbol in a; -} - -export function isResolved( - result: Result | undefined -): result is Resolved { - return isResult(result) && result[ResultSymbol] === ResultKind.Resolved; -} - -export function isFailed(result: Result | undefined): result is Failed { - return isResult(result) && result[ResultSymbol] === ResultKind.Failed; -} - -export function normalizeFailedResult(result: Failed): { - error: string; - message: string; -} { - return normalizeError(result.error); -} - -export function normalizeError(err: any) { - const [error, message] = extendsError(err) - ? [err.name, err.message] - : ["Error", JSON.stringify(err)]; - return { error, message }; -} - -export function resultToString(result?: Result) { - if (isFailed(result)) { - const { error, message } = normalizeFailedResult(result); - return `${error}: ${message}`; - } else if (isResolved(result)) { - return result.value ? JSON.stringify(result.value) : ""; - } else { - return ""; - } -} - -export const isResolvedOrFailed = or(isResolved, isFailed); diff --git a/packages/@eventual/core/src/internal/workflow-events.ts b/packages/@eventual/core/src/internal/workflow-events.ts index d9c675bf4..e09bf63e7 100644 --- a/packages/@eventual/core/src/internal/workflow-events.ts +++ b/packages/@eventual/core/src/internal/workflow-events.ts @@ -80,7 +80,7 @@ export type HistoryScheduledEvent = | SignalSent | TimerScheduled; -export const isScheduledEvent = or( +export const isScheduledEvent = /* @__PURE__ */ or( isActivityScheduled, isChildWorkflowScheduled, isEventsPublished, @@ -88,20 +88,20 @@ export const isScheduledEvent = or( isTimerScheduled ); -export const isSucceededEvent = or( +export const isSucceededEvent = /* @__PURE__ */ or( isActivitySucceeded, isChildWorkflowSucceeded, isTimerCompleted ); -export const isFailedEvent = or( +export const isFailedEvent = /* @__PURE__ */ or( isActivityFailed, isActivityHeartbeatTimedOut, isChildWorkflowFailed, isWorkflowTimedOut ); -export const isResultEvent = or( +export const isResultEvent = /* @__PURE__ */ or( isSucceededEvent, isFailedEvent, isSignalReceived, diff --git a/packages/@eventual/testing/src/providers/activity-provider.ts b/packages/@eventual/testing/src/providers/activity-provider.ts index bd37f9871..4ce6601dd 100644 --- a/packages/@eventual/testing/src/providers/activity-provider.ts +++ b/packages/@eventual/testing/src/providers/activity-provider.ts @@ -8,15 +8,17 @@ import { HeartbeatTimeout, Timeout, } from "@eventual/core"; -import { GlobalActivityProvider } from "@eventual/core-runtime"; import { - ActivityInput, - assertNever, - activities, - Failed, + GlobalActivityProvider, isFailed, isResolved, isResult, +} from "@eventual/core-runtime"; +import { + activities, + ActivityInput, + assertNever, + Failed, Resolved, Result, } from "@eventual/core/internal"; From fe4538d79e210afc0fff8e5693969203c6016bb5 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Tue, 7 Mar 2023 00:41:48 -0600 Subject: [PATCH 18/28] date hook with async local store --- .../@eventual/core-runtime/src/date-hook.ts | 68 +++++++++--- .../core-runtime/src/handlers/orchestrator.ts | 56 +++++----- .../core-runtime/test/date-hook.test.ts | 100 +++++++++++++----- 3 files changed, 156 insertions(+), 68 deletions(-) diff --git a/packages/@eventual/core-runtime/src/date-hook.ts b/packages/@eventual/core-runtime/src/date-hook.ts index 2d1fd2068..75addce56 100644 --- a/packages/@eventual/core-runtime/src/date-hook.ts +++ b/packages/@eventual/core-runtime/src/date-hook.ts @@ -1,6 +1,21 @@ +import { AsyncLocalStorage } from "async_hooks"; + const originalDate = globalThis.Date; const HOOKED_SYMBOL = /* @__PURE__ */ Symbol.for("eventual-hooked-date"); +interface DateObj { + dateOverride: number | undefined; +} + +/** + * In the case that the workflow is bundled with a different instance of eventual/core, + * put the store in globals. + */ +declare global { + // eslint-disable-next-line no-var + var eventualDateHookStore: AsyncLocalStorage | undefined; +} + /** * Replaces the node implementation of Date with a hook to get the current datetime. * @@ -11,21 +26,48 @@ const HOOKED_SYMBOL = /* @__PURE__ */ Symbol.for("eventual-hooked-date"); * Use {@link restoreDate} to unto this change. * Use {@link isDateHooked} to determine if the Date object is currently hooked */ -export function hookDate(getDate: () => number | undefined) { - globalThis.Date = class extends Date { - constructor(...args: Parameters) { - if (args.length === 0) { - super(getDate() ?? originalDate.now()); - } else { - super(...args); +export function hookDate() { + if (!isDateHooked()) { + globalThis.Date = class extends Date { + constructor(...args: Parameters) { + if (args.length === 0) { + super(getDate()); + } else { + super(...args); + } + } + + public static now() { + return getDate(); } - } + } as DateConstructor; + (globalThis.Date as any)[HOOKED_SYMBOL] = HOOKED_SYMBOL; + } +} + +function getDate() { + if (!globalThis.eventualDateHookStore) { + globalThis.eventualDateHookStore = new AsyncLocalStorage(); + } + return ( + globalThis.eventualDateHookStore.getStore()?.dateOverride ?? + originalDate.now() + ); +} - public static now() { - return getDate() ?? originalDate.now(); - } - } as DateConstructor; - (globalThis.Date as any)[HOOKED_SYMBOL] = HOOKED_SYMBOL; +export function overrideDateScope( + initialDate: number | undefined, + executor: (setDate: (date: number | undefined) => void) => T +) { + if (!globalThis.eventualDateHookStore) { + globalThis.eventualDateHookStore = new AsyncLocalStorage(); + } + const dateObject: DateObj = { dateOverride: initialDate }; + return globalThis.eventualDateHookStore.run( + dateObject, + executor, + (date) => (dateObject.dateOverride = date) + ); } /** diff --git a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts index 35dfc90af..cebf38aa9 100644 --- a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts +++ b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts @@ -40,7 +40,7 @@ import { MetricsClient } from "../clients/metrics-client.js"; import { TimerClient } from "../clients/timer-client.js"; import { WorkflowClient } from "../clients/workflow-client.js"; import { CommandExecutor } from "../command-executor.js"; -import { hookDate, restoreDate } from "../date-hook.js"; +import { hookDate, overrideDateScope, restoreDate } from "../date-hook.js"; import { isExecutionId, parseWorkflowName } from "../execution.js"; import { ExecutionLogContext, LogAgent, LogContextType } from "../log-agent.js"; import { MetricsCommon, OrchestratorMetrics } from "../metrics/constants.js"; @@ -699,34 +699,36 @@ export async function progressWorkflow( }; try { - let currentTime = new Date( - processedEvents.firstRunStarted.timestamp - ).getTime(); - hookDate(() => currentTime); - const executor = new WorkflowExecutor( - workflow, - processedEvents.interpretEvents, - { - hooks: { - /** - * Invoked for each {@link HistoryResultEvent}, or an event which - * represents the resolution of some {@link Eventual}. - * - * We use this to watch for the application of the {@link WorkflowRunStarted} event. - * Which we use to find and apply the current time to the hooked {@link Date} object. - */ - beforeApplyingResultEvent: (event) => { - if (isWorkflowRunStarted(event)) { - currentTime = new Date(event.timestamp).getTime(); - } - }, - // when an event is matched, that means all the work to this point has been completed, clear the logs collected. - // this implements "exactly once" logs with the workflow semantics. - historicalEventMatched: () => logAgent?.clearLogs(logCheckpoint), - }, + hookDate(); + return overrideDateScope( + new Date(processedEvents.firstRunStarted.timestamp).getTime(), + async (setDate) => { + const executor = new WorkflowExecutor( + workflow, + processedEvents.interpretEvents, + { + hooks: { + /** + * Invoked for each {@link HistoryResultEvent}, or an event which + * represents the resolution of some {@link Eventual}. + * + * We use this to watch for the application of the {@link WorkflowRunStarted} event. + * Which we use to find and apply the current time to the hooked {@link Date} object. + */ + beforeApplyingResultEvent: (event) => { + if (isWorkflowRunStarted(event)) { + setDate(new Date(event.timestamp).getTime()); + } + }, + // when an event is matched, that means all the work to this point has been completed, clear the logs collected. + // this implements "exactly once" logs with the workflow semantics. + historicalEventMatched: () => logAgent?.clearLogs(logCheckpoint), + }, + } + ); + return await executor.start(processedEvents.startEvent.input, context); } ); - return await executor.start(processedEvents.startEvent.input, context); } catch (err) { console.debug("workflow error", inspect(err)); throw err; diff --git a/packages/@eventual/core-runtime/test/date-hook.test.ts b/packages/@eventual/core-runtime/test/date-hook.test.ts index d8c65cea0..d8e9fd107 100644 --- a/packages/@eventual/core-runtime/test/date-hook.test.ts +++ b/packages/@eventual/core-runtime/test/date-hook.test.ts @@ -1,61 +1,105 @@ -import { hookDate, restoreDate } from "../src/date-hook.js"; +import { hookDate, overrideDateScope, restoreDate } from "../src/date-hook.js"; afterEach(() => restoreDate()); const goalDate = new Date("2020-01-01"); +const goalDate2 = new Date("2021-01-01"); const now = Date.now(); describe("hook", () => { test("new", () => { - hookDate(() => goalDate.getTime()); + hookDate(); - const d = new Date(); - expect(d.getTime()).toEqual(goalDate.getTime()); + overrideDateScope(goalDate.getTime(), () => { + const d = new Date(); + expect(d.getTime()).toEqual(goalDate.getTime()); + }); }); test("now", () => { - hookDate(() => goalDate.getTime()); + hookDate(); - expect(Date.now()).toEqual(goalDate.getTime()); + overrideDateScope(goalDate.getTime(), () => { + expect(Date.now()).toEqual(goalDate.getTime()); + }); }); - test("pass through", () => { - hookDate(() => undefined); + test("set date", () => { + hookDate(); - const d = new Date(); - expect(percOfNow(d.getTime())).toBeCloseTo(1); - // prove that an overridden time would fail this test - const d2 = new Date(goalDate); - expect(percOfNow(d2.getTime())).not.toBeCloseTo(1); + overrideDateScope(undefined, (setDate) => { + setDate(goalDate.getTime()); + expect(Date.now()).toEqual(goalDate.getTime()); + }); + }); + + test("scopes", () => { + hookDate(); + + // outer scope is goalDate + overrideDateScope(goalDate.getTime(), (setDate) => { + // should be goal date + expect(Date.now()).toEqual(goalDate.getTime()); + // innser scope is set to passthrough + overrideDateScope(undefined, (setDate2) => { + const d = new Date(); + expect(percOfNow(d.getTime())).toBeCloseTo(1); + // update to new date in inner + setDate2(goalDate2.getTime()); + expect(Date.now()).toEqual(goalDate2.getTime()); + // update outer to passthrough + setDate(undefined); + }); + // outer is now passthrough + const d = new Date(); + expect(percOfNow(d.getTime())).toBeCloseTo(1); + }); + }); + + test("pass through", () => { + hookDate(); + + overrideDateScope(undefined, () => { + const d = new Date(); + expect(percOfNow(d.getTime())).toBeCloseTo(1); + // prove that an overridden time would fail this test + const d2 = new Date(goalDate); + expect(percOfNow(d2.getTime())).not.toBeCloseTo(1); + }); }); test("pass through now", () => { - hookDate(() => undefined); + hookDate(); - expect(percOfNow(Date.now())).toBeCloseTo(1); + overrideDateScope(undefined, (setTime) => { + expect(percOfNow(Date.now())).toBeCloseTo(1); - hookDate(() => goalDate.getTime()); - // prove that an overridden time would fail this test - expect(percOfNow(Date.now())).not.toBeCloseTo(1); + setTime(goalDate.getTime()); + // prove that an overridden time would fail this test + expect(percOfNow(Date.now())).not.toBeCloseTo(1); + }); }); }); test("restore", () => { - hookDate(() => goalDate.getTime()); + hookDate(); restoreDate(); - const d = new Date(); - expect(percOfNow(d.getTime())).toBeCloseTo(1); - expect(percOfNow(Date.now())).toBeCloseTo(1); + overrideDateScope(goalDate.getTime(), (setTime) => { + const d = new Date(); + expect(percOfNow(d.getTime())).toBeCloseTo(1); + expect(percOfNow(Date.now())).toBeCloseTo(1); - hookDate(() => goalDate.getTime()); + hookDate(); + setTime(goalDate.getTime()); - // prove that an overridden time would fail this test - const d2 = new Date(); - expect(percOfNow(d2.getTime())).not.toBeCloseTo(1); - // prove that an overridden time would fail this test - expect(percOfNow(Date.now())).not.toBeCloseTo(1); + // prove that an overridden time would fail this test + const d2 = new Date(); + expect(percOfNow(d2.getTime())).not.toBeCloseTo(1); + // prove that an overridden time would fail this test + expect(percOfNow(Date.now())).not.toBeCloseTo(1); + }); }); /** From 58f34bc36fe13b79873cd69b10d86becfc63c019 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Tue, 7 Mar 2023 00:42:54 -0600 Subject: [PATCH 19/28] docs --- packages/@eventual/core-runtime/src/date-hook.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/@eventual/core-runtime/src/date-hook.ts b/packages/@eventual/core-runtime/src/date-hook.ts index 75addce56..64ae54f88 100644 --- a/packages/@eventual/core-runtime/src/date-hook.ts +++ b/packages/@eventual/core-runtime/src/date-hook.ts @@ -55,6 +55,11 @@ function getDate() { ); } +/** + * Overrides the date within the scope when Date is hooked. + * + * Also provides a setDate method to update the date while in the scope. + */ export function overrideDateScope( initialDate: number | undefined, executor: (setDate: (date: number | undefined) => void) => T From 31417008e7b90c1ecdfc3b0d10388441b5cb89be Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Tue, 7 Mar 2023 11:06:24 -0600 Subject: [PATCH 20/28] support events after workflow success --- .vscode/launch.json | 2 +- .../core-runtime/src/handlers/orchestrator.ts | 6 +- .../core-runtime/src/workflow-executor.ts | 18 ++-- .../test/workflow-executor.test.ts | 87 +++++++++++++++++++ packages/@eventual/testing/test/workflow.ts | 1 + 5 files changed, 103 insertions(+), 11 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index e4995db9c..1d947d927 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,7 @@ "outFiles": ["${workspaceFolder}/**/*.js", "!**/node_modules/**"], "runtimeArgs": ["--enable-source-maps"], "env": { "NODE_OPTIONS": "--experimental-vm-modules" }, - "args": ["--runInBand", "--watchAll=false"] + "args": ["--", "--runInBand", "--watchAll=false"] }, { "type": "node", diff --git a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts index cebf38aa9..bc74b61fa 100644 --- a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts +++ b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts @@ -133,6 +133,8 @@ export function createOrchestrator({ "Found execution ids: " + Object.keys(eventsByExecutionId).join(", ") ); + hookDate(); + // for each execution id const results = await promiseAllSettledPartitioned( Object.entries(eventsByExecutionId), @@ -154,6 +156,8 @@ export function createOrchestrator({ } ); + restoreDate(); + console.debug( "Executions succeeded: " + results.fulfilled.map(([[executionId]]) => executionId).join(",") @@ -699,7 +703,6 @@ export async function progressWorkflow( }; try { - hookDate(); return overrideDateScope( new Date(processedEvents.firstRunStarted.timestamp).getTime(), async (setDate) => { @@ -734,7 +737,6 @@ export async function progressWorkflow( throw err; } finally { // re-enable sending logs, any generated logs are new. - restoreDate(); logAgent?.enableSendingLogs(); } } diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index 94cf1d9f0..afd8fd9db 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -27,7 +27,7 @@ import { } from "@eventual/core/internal"; import { isPromise } from "util/types"; import { createEventualFromCall, isCorresponding } from "./eventual-factory.js"; -import { isResolved } from "./result.js"; +import { isFailed, isResolved } from "./result.js"; import type { WorkflowCommand } from "./workflow-command.js"; /** @@ -62,9 +62,9 @@ export function createEventualPromise( beforeResolve?: () => void ): RuntimeEventualPromise { let resolve: (r: R) => void, reject: (reason: any) => void; - const promise = new Promise((r, rr) => { - resolve = r; - reject = rr; + const promise = new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; }) as RuntimeEventualPromise; promise[EventualPromiseSymbol] = seq; promise.resolve = (result) => { @@ -152,7 +152,7 @@ export class WorkflowExecutor { /** * Starts an execution. * - * The execution will run until completion or until the events are exhausted. + * The execution will run until the events are exhausted or . */ public start( input: Input, @@ -169,7 +169,10 @@ export class WorkflowExecutor { try { const workflowPromise = this.workflow.definition(input, context); workflowPromise.then( - (result) => this.endWorkflowRun(Result.resolved(result)), + // successfully completed workflows can continue to retrieve events. + // TODO: make this configurable? + (result) => (this.result = Result.resolved(result)), + // failed workflows will stop accepting events (err) => this.endWorkflowRun(Result.failed(err)) ); } catch (err) { @@ -335,7 +338,7 @@ export class WorkflowExecutor { * before applying the next set of events. */ private async drainHistoryEvents() { - while (this.events.hasNext() && !this.result) { + while (this.events.hasNext() && !isFailed(this.result)) { const event = this.events.next()!; this.options?.hooks?.beforeApplyingResultEvent?.(event); await new Promise((resolve) => { @@ -344,7 +347,6 @@ export class WorkflowExecutor { resolve(undefined); }); }); - // TODO: do we need to use setTimeout here to go to the end of the event loop? } } diff --git a/packages/@eventual/core-runtime/test/workflow-executor.test.ts b/packages/@eventual/core-runtime/test/workflow-executor.test.ts index 0cb5b0045..56f26578c 100644 --- a/packages/@eventual/core-runtime/test/workflow-executor.test.ts +++ b/packages/@eventual/core-runtime/test/workflow-executor.test.ts @@ -2762,3 +2762,90 @@ describe("continue", () => { }); }); }); + +describe("running after result", () => { + test("signal handler works after execution completes", async () => { + const wf = workflow(async () => { + createRegisterSignalHandlerCall("signal1", () => { + createActivityCall("on signal", 1); + }); + return "hello?"; + }); + + const executor = new WorkflowExecutor(wf, []); + + await expect( + executor.start(undefined, context) + ).resolves.toEqual({ + commands: [], + result: Result.resolved("hello?"), + }); + + await expect( + executor.continue(signalReceived("signal1")) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("on signal", 1, 1)], + result: Result.resolved("hello?"), + }); + }); + + test("signal handler does not accept after a failure", async () => { + const wf = workflow(async () => { + createRegisterSignalHandlerCall("signal1", () => { + createActivityCall("on signal", 1); + }); + throw Error("AHHH"); + }); + + const executor = new WorkflowExecutor(wf, []); + + await expect( + executor.start(undefined, context) + ).resolves.toEqual({ + commands: [], + result: Result.failed(new Error("AHHH")), + }); + + await expect( + executor.continue(signalReceived("signal1")) + ).resolves.toEqual({ + commands: [], + result: Result.failed(new Error("AHHH")), + }); + }); + + test("async promises after completion", async () => { + const wf = workflow(async () => { + createRegisterSignalHandlerCall("signal1", async () => { + let n = 10; + while (n-- > 0) { + createActivityCall("on signal", 1); + } + }); + + (async () => { + await createActivityCall("in the async", undefined); + })(); + + return "hello?"; + }); + + const executor = new WorkflowExecutor(wf, []); + + await expect( + executor.start(undefined, context) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("in the async", undefined, 1)], + result: Result.resolved("hello?"), + }); + + await expect( + executor.continue(signalReceived("signal1")) + ).resolves.toEqual({ + commands: [...Array(10).keys()].map((i) => + createScheduledActivityCommand("on signal", 1, 2 + i) + ), + result: Result.resolved("hello?"), + }); + }); +}); diff --git a/packages/@eventual/testing/test/workflow.ts b/packages/@eventual/testing/test/workflow.ts index 5f0a917a4..8fb568eee 100644 --- a/packages/@eventual/testing/test/workflow.ts +++ b/packages/@eventual/testing/test/workflow.ts @@ -214,6 +214,7 @@ export const timedWorkflow = workflow( let total = 0; dataSignal.onSignal(() => { total++; + console.log(new Date()); if (new Date().getTime() >= new Date(input.startDate).getTime()) { n++; } From 87551208b4f208528b1a68873c217f9bc17ee138 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Tue, 7 Mar 2023 11:08:23 -0600 Subject: [PATCH 21/28] clean up --- .vscode/launch.json | 2 +- packages/@eventual/core-runtime/src/workflow-executor.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 1d947d927..e4995db9c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,7 @@ "outFiles": ["${workspaceFolder}/**/*.js", "!**/node_modules/**"], "runtimeArgs": ["--enable-source-maps"], "env": { "NODE_OPTIONS": "--experimental-vm-modules" }, - "args": ["--", "--runInBand", "--watchAll=false"] + "args": ["--runInBand", "--watchAll=false"] }, { "type": "node", diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index afd8fd9db..f05a90789 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -152,7 +152,7 @@ export class WorkflowExecutor { /** * Starts an execution. * - * The execution will run until the events are exhausted or . + * The execution will run until the events are exhausted or the workflow fails. */ public start( input: Input, @@ -189,7 +189,7 @@ export class WorkflowExecutor { * * Events will be applied to the workflow in order. * - * Workflow will run until completion or until all of the events are exhausted. + * The execution will run until the events are exhausted or the workflow fails. * * @returns {@link WorkflowResult} - containing new commands and a result of one was generated. */ From 169e29572ad6a0102d7ffc58340abaedded8381d Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Tue, 7 Mar 2023 14:31:45 -0600 Subject: [PATCH 22/28] fix date bug --- .../@eventual/core-runtime/src/handlers/orchestrator.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts index bc74b61fa..e3a0919e4 100644 --- a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts +++ b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts @@ -133,8 +133,6 @@ export function createOrchestrator({ "Found execution ids: " + Object.keys(eventsByExecutionId).join(", ") ); - hookDate(); - // for each execution id const results = await promiseAllSettledPartitioned( Object.entries(eventsByExecutionId), @@ -156,8 +154,6 @@ export function createOrchestrator({ } ); - restoreDate(); - console.debug( "Executions succeeded: " + results.fulfilled.map(([[executionId]]) => executionId).join(",") @@ -703,7 +699,8 @@ export async function progressWorkflow( }; try { - return overrideDateScope( + hookDate(); + return await overrideDateScope( new Date(processedEvents.firstRunStarted.timestamp).getTime(), async (setDate) => { const executor = new WorkflowExecutor( @@ -736,6 +733,7 @@ export async function progressWorkflow( console.debug("workflow error", inspect(err)); throw err; } finally { + restoreDate(); // re-enable sending logs, any generated logs are new. logAgent?.enableSendingLogs(); } From cf3069de848c42cd712be5557d1241060abbd1d3 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Tue, 7 Mar 2023 20:08:13 -0600 Subject: [PATCH 23/28] revert als --- apps/tests/aws-runtime-cdk/src/app.ts | 2 +- .../@eventual/core-runtime/src/date-hook.ts | 73 +++---------- .../core-runtime/src/handlers/orchestrator.ts | 94 ++++++++-------- .../core-runtime/src/workflow-executor.ts | 63 ++++++----- .../core-runtime/test/date-hook.test.ts | 100 +++++------------- .../test/workflow-executor.test.ts | 44 ++++---- .../core/src/internal/eventual-hook.ts | 24 ++--- 7 files changed, 156 insertions(+), 244 deletions(-) diff --git a/apps/tests/aws-runtime-cdk/src/app.ts b/apps/tests/aws-runtime-cdk/src/app.ts index 5f4b71bef..21d9b95b5 100644 --- a/apps/tests/aws-runtime-cdk/src/app.ts +++ b/apps/tests/aws-runtime-cdk/src/app.ts @@ -69,7 +69,7 @@ testQueue.grantSendMessages(testService.activities.asyncActivity); const chaosExtension = new ChaosExtension(stack, "chaos"); testService.activitiesList.map((a) => chaosExtension.addToFunction(a.handler)); -chaosExtension.addToFunction(testService.system.workflowService.orchestrator); +// chaosExtension.addToFunction(testService.system.workflowService.orchestrator); chaosExtension.grantReadWrite(role); diff --git a/packages/@eventual/core-runtime/src/date-hook.ts b/packages/@eventual/core-runtime/src/date-hook.ts index 64ae54f88..2d1fd2068 100644 --- a/packages/@eventual/core-runtime/src/date-hook.ts +++ b/packages/@eventual/core-runtime/src/date-hook.ts @@ -1,21 +1,6 @@ -import { AsyncLocalStorage } from "async_hooks"; - const originalDate = globalThis.Date; const HOOKED_SYMBOL = /* @__PURE__ */ Symbol.for("eventual-hooked-date"); -interface DateObj { - dateOverride: number | undefined; -} - -/** - * In the case that the workflow is bundled with a different instance of eventual/core, - * put the store in globals. - */ -declare global { - // eslint-disable-next-line no-var - var eventualDateHookStore: AsyncLocalStorage | undefined; -} - /** * Replaces the node implementation of Date with a hook to get the current datetime. * @@ -26,53 +11,21 @@ declare global { * Use {@link restoreDate} to unto this change. * Use {@link isDateHooked} to determine if the Date object is currently hooked */ -export function hookDate() { - if (!isDateHooked()) { - globalThis.Date = class extends Date { - constructor(...args: Parameters) { - if (args.length === 0) { - super(getDate()); - } else { - super(...args); - } +export function hookDate(getDate: () => number | undefined) { + globalThis.Date = class extends Date { + constructor(...args: Parameters) { + if (args.length === 0) { + super(getDate() ?? originalDate.now()); + } else { + super(...args); } + } - public static now() { - return getDate(); - } - } as DateConstructor; - (globalThis.Date as any)[HOOKED_SYMBOL] = HOOKED_SYMBOL; - } -} - -function getDate() { - if (!globalThis.eventualDateHookStore) { - globalThis.eventualDateHookStore = new AsyncLocalStorage(); - } - return ( - globalThis.eventualDateHookStore.getStore()?.dateOverride ?? - originalDate.now() - ); -} - -/** - * Overrides the date within the scope when Date is hooked. - * - * Also provides a setDate method to update the date while in the scope. - */ -export function overrideDateScope( - initialDate: number | undefined, - executor: (setDate: (date: number | undefined) => void) => T -) { - if (!globalThis.eventualDateHookStore) { - globalThis.eventualDateHookStore = new AsyncLocalStorage(); - } - const dateObject: DateObj = { dateOverride: initialDate }; - return globalThis.eventualDateHookStore.run( - dateObject, - executor, - (date) => (dateObject.dateOverride = date) - ); + public static now() { + return getDate() ?? originalDate.now(); + } + } as DateConstructor; + (globalThis.Date as any)[HOOKED_SYMBOL] = HOOKED_SYMBOL; } /** diff --git a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts index bc74b61fa..03da3ca40 100644 --- a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts +++ b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts @@ -40,7 +40,7 @@ import { MetricsClient } from "../clients/metrics-client.js"; import { TimerClient } from "../clients/timer-client.js"; import { WorkflowClient } from "../clients/workflow-client.js"; import { CommandExecutor } from "../command-executor.js"; -import { hookDate, overrideDateScope, restoreDate } from "../date-hook.js"; +import { hookDate, restoreDate } from "../date-hook.js"; import { isExecutionId, parseWorkflowName } from "../execution.js"; import { ExecutionLogContext, LogAgent, LogContextType } from "../log-agent.js"; import { MetricsCommon, OrchestratorMetrics } from "../metrics/constants.js"; @@ -52,12 +52,13 @@ import { isFailed, isResolved, isResult, + normalizeError, normalizeFailedResult, } from "../result.js"; import { ExecutionHistoryStateStore } from "../stores/execution-history-state-store.js"; import { ExecutionHistoryStore } from "../stores/execution-history-store.js"; import { WorkflowTask } from "../tasks.js"; -import { groupBy, promiseAllSettledPartitioned } from "../utils.js"; +import { groupBy } from "../utils.js"; import { WorkflowCommand } from "../workflow-command.js"; import { createEvent } from "../workflow-events.js"; import { WorkflowExecutor } from "../workflow-executor.js"; @@ -133,12 +134,14 @@ export function createOrchestrator({ "Found execution ids: " + Object.keys(eventsByExecutionId).join(", ") ); - hookDate(); - // for each execution id - const results = await promiseAllSettledPartitioned( - Object.entries(eventsByExecutionId), - async ([executionId, records]) => { + const succeeded: string[] = []; + const failed: Record = {}; + + for (const [executionId, records] of Object.entries( + eventsByExecutionId + )) { + try { if (!isExecutionId(executionId)) { throw new Error(`invalid ExecutionID: '${executionId}'`); } @@ -147,33 +150,32 @@ export function createOrchestrator({ throw new Error(`execution ID '${executionId}' does not exist`); } // TODO: get workflow from execution id - return orchestrateExecution( + await orchestrateExecution( workflowName, executionId, records, baseTime ); - } - ); - restoreDate(); + succeeded.push(executionId); + } catch (err) { + failed[executionId] = normalizeError(err).message; + } + } - console.debug( - "Executions succeeded: " + - results.fulfilled.map(([[executionId]]) => executionId).join(",") - ); + console.debug("Executions succeeded: " + succeeded.join(", ")); - if (results.rejected.length > 0) { + if (Object.keys(failed).length > 0) { console.error( "Executions failed: \n" + - results.rejected - .map(([[executionId], error]) => `${executionId}: ${error}`) + Object.entries(failed) + .map(([executionId, error]) => `${executionId}: ${error}`) .join("\n") ); } return { - failedExecutionIds: results.rejected.map((rejected) => rejected[0][0]), + failedExecutionIds: Object.keys(failed), }; }); @@ -703,39 +705,39 @@ export async function progressWorkflow( }; try { - return overrideDateScope( - new Date(processedEvents.firstRunStarted.timestamp).getTime(), - async (setDate) => { - const executor = new WorkflowExecutor( - workflow, - processedEvents.interpretEvents, - { - hooks: { - /** - * Invoked for each {@link HistoryResultEvent}, or an event which - * represents the resolution of some {@link Eventual}. - * - * We use this to watch for the application of the {@link WorkflowRunStarted} event. - * Which we use to find and apply the current time to the hooked {@link Date} object. - */ - beforeApplyingResultEvent: (event) => { - if (isWorkflowRunStarted(event)) { - setDate(new Date(event.timestamp).getTime()); - } - }, - // when an event is matched, that means all the work to this point has been completed, clear the logs collected. - // this implements "exactly once" logs with the workflow semantics. - historicalEventMatched: () => logAgent?.clearLogs(logCheckpoint), - }, - } - ); - return await executor.start(processedEvents.startEvent.input, context); + let currentTime = new Date( + processedEvents.firstRunStarted.timestamp + ).getTime(); + hookDate(() => currentTime); + const executor = new WorkflowExecutor( + workflow, + processedEvents.interpretEvents, + { + hooks: { + /** + * Invoked for each {@link HistoryResultEvent}, or an event which + * represents the resolution of some {@link Eventual}. + * + * We use this to watch for the application of the {@link WorkflowRunStarted} event. + * Which we use to find and apply the current time to the hooked {@link Date} object. + */ + beforeApplyingResultEvent: (event) => { + if (isWorkflowRunStarted(event)) { + currentTime = new Date(event.timestamp).getTime(); + } + }, + // when an event is matched, that means all the work to this point has been completed, clear the logs collected. + // this implements "exactly once" logs with the workflow semantics. + historicalEventMatched: () => logAgent?.clearLogs(logCheckpoint), + }, } ); + return await executor.start(processedEvents.startEvent.input, context); } catch (err) { console.debug("workflow error", inspect(err)); throw err; } finally { + restoreDate(); // re-enable sending logs, any generated logs are new. logAgent?.enableSendingLogs(); } diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index f05a90789..ea6781fcf 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -202,35 +202,36 @@ export class WorkflowExecutor { this.history.push(...history); - return this.startWorkflowRun(); + return await this.startWorkflowRun(); } private startWorkflowRun(beforeCommitEvents?: () => void) { - return new Promise>(async (resolve) => { - // start context with execution hook - this.started = { - resolve: (result) => { - resolve(this.flushCurrentWorkflowResult(result)); - }, - }; - // ensure the workflow hook is available to the workflow - // and tied to the workflow promise context - // Also ensure that any handlers like signal handlers returned by the workflow - // have access to the workflow hook - await this.enterWorkflowHookScope(async () => { - beforeCommitEvents?.(); - // APPLY EVENTS - await this.drainHistoryEvents(); - }); - - // let everything that has started or will be started complete - // set timeout adds the closure to the end of the event loop - // this assumption breaks down when the user tries to start a promise - // to accomplish non-deterministic actions like IO. - setTimeout(() => { - this.endWorkflowRun(); - }); - }); + return this.enterWorkflowHookScope( + () => + new Promise>(async (resolve) => { + // start context with execution hook + this.started = { + resolve: (result) => { + resolve(this.flushCurrentWorkflowResult(result)); + }, + }; + // ensure the workflow hook is available to the workflow + // and tied to the workflow promise context + // Also ensure that any handlers like signal handlers returned by the workflow + // have access to the workflow hook + beforeCommitEvents?.(); + // APPLY EVENTS + await this.drainHistoryEvents(); + + // let everything that has started or will be started complete + // set timeout adds the closure to the end of the event loop + // this assumption breaks down when the user tries to start a promise + // to accomplish non-deterministic actions like IO. + setTimeout(() => { + this.endWorkflowRun(); + }); + }) + ); } private endWorkflowRun(result?: Result) { @@ -259,7 +260,9 @@ export class WorkflowExecutor { /** * Provides a scope where the workflowHook is available to the {@link Call}s. */ - private async enterWorkflowHookScope(callback: (...args: any) => Res) { + private async enterWorkflowHookScope( + callback: (...args: any) => Res + ): Promise> { const self = this; const workflowHook: ExecutionWorkflowHook = { registerEventualCall>(call: EventualCall) { @@ -270,12 +273,6 @@ export class WorkflowExecutor { /** * if the call is new, generate and emit it's commands * if the eventual does not generate commands, do not check it against the expected events. - * Note: this contract is too abstracted, call => command => schedule event - * where the scheduled event is generated by the orchestrator. - * I'd love to be able to match calls to call or something - * instead of matching events to calls in {@link }. - * One thought would be to get rid of the scheduled events - * and instead maintain the call history to match against. */ if ( eventual.generateCommands && diff --git a/packages/@eventual/core-runtime/test/date-hook.test.ts b/packages/@eventual/core-runtime/test/date-hook.test.ts index d8e9fd107..d8c65cea0 100644 --- a/packages/@eventual/core-runtime/test/date-hook.test.ts +++ b/packages/@eventual/core-runtime/test/date-hook.test.ts @@ -1,105 +1,61 @@ -import { hookDate, overrideDateScope, restoreDate } from "../src/date-hook.js"; +import { hookDate, restoreDate } from "../src/date-hook.js"; afterEach(() => restoreDate()); const goalDate = new Date("2020-01-01"); -const goalDate2 = new Date("2021-01-01"); const now = Date.now(); describe("hook", () => { test("new", () => { - hookDate(); + hookDate(() => goalDate.getTime()); - overrideDateScope(goalDate.getTime(), () => { - const d = new Date(); - expect(d.getTime()).toEqual(goalDate.getTime()); - }); + const d = new Date(); + expect(d.getTime()).toEqual(goalDate.getTime()); }); test("now", () => { - hookDate(); - - overrideDateScope(goalDate.getTime(), () => { - expect(Date.now()).toEqual(goalDate.getTime()); - }); - }); - - test("set date", () => { - hookDate(); - - overrideDateScope(undefined, (setDate) => { - setDate(goalDate.getTime()); - expect(Date.now()).toEqual(goalDate.getTime()); - }); - }); + hookDate(() => goalDate.getTime()); - test("scopes", () => { - hookDate(); - - // outer scope is goalDate - overrideDateScope(goalDate.getTime(), (setDate) => { - // should be goal date - expect(Date.now()).toEqual(goalDate.getTime()); - // innser scope is set to passthrough - overrideDateScope(undefined, (setDate2) => { - const d = new Date(); - expect(percOfNow(d.getTime())).toBeCloseTo(1); - // update to new date in inner - setDate2(goalDate2.getTime()); - expect(Date.now()).toEqual(goalDate2.getTime()); - // update outer to passthrough - setDate(undefined); - }); - // outer is now passthrough - const d = new Date(); - expect(percOfNow(d.getTime())).toBeCloseTo(1); - }); + expect(Date.now()).toEqual(goalDate.getTime()); }); test("pass through", () => { - hookDate(); - - overrideDateScope(undefined, () => { - const d = new Date(); - expect(percOfNow(d.getTime())).toBeCloseTo(1); - // prove that an overridden time would fail this test - const d2 = new Date(goalDate); - expect(percOfNow(d2.getTime())).not.toBeCloseTo(1); - }); + hookDate(() => undefined); + + const d = new Date(); + expect(percOfNow(d.getTime())).toBeCloseTo(1); + // prove that an overridden time would fail this test + const d2 = new Date(goalDate); + expect(percOfNow(d2.getTime())).not.toBeCloseTo(1); }); test("pass through now", () => { - hookDate(); + hookDate(() => undefined); - overrideDateScope(undefined, (setTime) => { - expect(percOfNow(Date.now())).toBeCloseTo(1); + expect(percOfNow(Date.now())).toBeCloseTo(1); - setTime(goalDate.getTime()); - // prove that an overridden time would fail this test - expect(percOfNow(Date.now())).not.toBeCloseTo(1); - }); + hookDate(() => goalDate.getTime()); + // prove that an overridden time would fail this test + expect(percOfNow(Date.now())).not.toBeCloseTo(1); }); }); test("restore", () => { - hookDate(); + hookDate(() => goalDate.getTime()); restoreDate(); - overrideDateScope(goalDate.getTime(), (setTime) => { - const d = new Date(); - expect(percOfNow(d.getTime())).toBeCloseTo(1); - expect(percOfNow(Date.now())).toBeCloseTo(1); + const d = new Date(); + expect(percOfNow(d.getTime())).toBeCloseTo(1); + expect(percOfNow(Date.now())).toBeCloseTo(1); - hookDate(); - setTime(goalDate.getTime()); + hookDate(() => goalDate.getTime()); - // prove that an overridden time would fail this test - const d2 = new Date(); - expect(percOfNow(d2.getTime())).not.toBeCloseTo(1); - // prove that an overridden time would fail this test - expect(percOfNow(Date.now())).not.toBeCloseTo(1); - }); + // prove that an overridden time would fail this test + const d2 = new Date(); + expect(percOfNow(d2.getTime())).not.toBeCloseTo(1); + // prove that an overridden time would fail this test + expect(percOfNow(Date.now())).not.toBeCloseTo(1); }); /** diff --git a/packages/@eventual/core-runtime/test/workflow-executor.test.ts b/packages/@eventual/core-runtime/test/workflow-executor.test.ts index 56f26578c..45cac5093 100644 --- a/packages/@eventual/core-runtime/test/workflow-executor.test.ts +++ b/packages/@eventual/core-runtime/test/workflow-executor.test.ts @@ -1408,10 +1408,12 @@ test("try-catch-finally with await in catch", async () => { await createActivityCall("finally", []); } }); - expect(execute(wf, [], undefined)).resolves.toMatchObject({ + await expect(execute(wf, [], undefined)).resolves.toMatchObject(< + WorkflowResult + >{ commands: [createScheduledActivityCommand("catch", [], 0)], }); - expect( + await expect( execute( wf, [activityScheduled("catch", 0), activitySucceeded(undefined, 0)], @@ -1423,7 +1425,7 @@ test("try-catch-finally with await in catch", async () => { }); test("try-catch-finally with dangling promise in catch", async () => { - expect( + await expect( execute( workflow(async () => { try { @@ -1466,7 +1468,7 @@ test("throw error within nested function", async () => { return "returned in finally"; } }); - expect(execute(wf, [], ["good", "bad"])).resolves.toMatchObject(< + await expect(execute(wf, [], ["good", "bad"])).resolves.toMatchObject(< WorkflowResult >{ commands: [ @@ -1474,7 +1476,7 @@ test("throw error within nested function", async () => { createScheduledActivityCommand("inside", ["bad"], 1), ], }); - expect( + await expect( execute( wf, [ @@ -1488,7 +1490,7 @@ test("throw error within nested function", async () => { ).resolves.toMatchObject({ commands: [createScheduledActivityCommand("catch", [], 2)], }); - expect( + await expect( execute( wf, [ @@ -1504,7 +1506,7 @@ test("throw error within nested function", async () => { ).resolves.toMatchObject({ commands: [createScheduledActivityCommand("finally", [], 3)], }); - expect( + await expect( execute( wf, [ @@ -1539,7 +1541,7 @@ test("properly evaluate await of sub-programs", async () => { return await sub(); }); - expect(execute(wf, [], undefined)).resolves.toMatchObject({ + await expect(execute(wf, [], undefined)).resolves.toMatchObject({ commands: [ // createScheduledActivityCommand("a", [], 0), @@ -1547,7 +1549,7 @@ test("properly evaluate await of sub-programs", async () => { ], }); - expect( + await expect( execute( wf, [ @@ -1574,7 +1576,7 @@ test("properly evaluate await of Promise.all", async () => { return item; }); - expect(execute(wf, [], undefined)).resolves.toMatchObject({ + await expect(execute(wf, [], undefined)).resolves.toMatchObject({ commands: [ // createScheduledActivityCommand("a", [], 0), @@ -1582,7 +1584,7 @@ test("properly evaluate await of Promise.all", async () => { ], }); - expect( + await expect( execute( wf, [ @@ -1608,10 +1610,10 @@ test("generator function returns an ActivityCall", async () => { return createActivityCall("call-a", []); } - expect(execute(wf, [], undefined)).resolves.toMatchObject({ + await expect(execute(wf, [], undefined)).resolves.toMatchObject({ commands: [createScheduledActivityCommand("call-a", [], 0)], }); - expect( + await expect( execute( wf, [activityScheduled("call-a", 0), activitySucceeded("result", 0)], @@ -1633,17 +1635,17 @@ test("workflow calling other workflow", async () => { return result; }); - expect(execute(wf2, [], undefined)).resolves.toMatchObject({ + await expect(execute(wf2, [], undefined)).resolves.toMatchObject({ commands: [createScheduledWorkflowCommand(wf1.name, undefined, 0)], }); - expect( + await expect( execute(wf2, [workflowScheduled(wf1.name, 0)], undefined) ).resolves.toMatchObject({ commands: [], }); - expect( + await expect( execute( wf2, [workflowScheduled(wf1.name, 0), workflowSucceeded("result", 0)], @@ -1653,7 +1655,7 @@ test("workflow calling other workflow", async () => { commands: [createScheduledActivityCommand("call-b", [], 1)], }); - expect( + await expect( execute( wf2, [ @@ -1667,7 +1669,7 @@ test("workflow calling other workflow", async () => { commands: [], }); - expect( + await expect( execute( wf2, [ @@ -1683,7 +1685,7 @@ test("workflow calling other workflow", async () => { commands: [], }); - expect( + await expect( execute( wf2, [workflowScheduled(wf1.name, 0), workflowFailed("error", 0)], @@ -2453,7 +2455,9 @@ test("nestedChains", async () => { ); }); - expect(execute(wf, [], undefined)).resolves.toMatchObject({ + await expect( + execute(wf, [], undefined) + ).resolves.toMatchObject({ commands: [createStartTimerCommand(0)], }); }); diff --git a/packages/@eventual/core/src/internal/eventual-hook.ts b/packages/@eventual/core/src/internal/eventual-hook.ts index 67e978cbe..734675ed1 100644 --- a/packages/@eventual/core/src/internal/eventual-hook.ts +++ b/packages/@eventual/core/src/internal/eventual-hook.ts @@ -1,4 +1,3 @@ -import type { AsyncLocalStorage } from "async_hooks"; import { EventualCall } from "./calls/calls.js"; import { Result } from "./result.js"; @@ -8,9 +7,7 @@ import { Result } from "./result.js"; */ declare global { // eslint-disable-next-line no-var - var eventualWorkflowHookStore: - | AsyncLocalStorage - | undefined; + var eventualWorkflowHookStore: ExecutionWorkflowHook | undefined; } export const EventualPromiseSymbol = @@ -31,7 +28,7 @@ export interface ExecutionWorkflowHook { } export function tryGetWorkflowHook() { - return globalThis.eventualWorkflowHookStore?.getStore(); + return globalThis.eventualWorkflowHookStore; } export function getWorkflowHook() { @@ -48,12 +45,15 @@ export function getWorkflowHook() { export async function enterWorkflowHookScope( eventualHook: ExecutionWorkflowHook, - callback: (...args: any[]) => R -) { - if (!globalThis.eventualWorkflowHookStore) { - globalThis.eventualWorkflowHookStore = new ( - await import("async_hooks") - ).AsyncLocalStorage(); + callback: () => R +): Promise> { + if (globalThis.eventualWorkflowHookStore !== undefined) { + throw new Error("Must clear eventual hook before registering a new one."); + } + try { + globalThis.eventualWorkflowHookStore = eventualHook; + return await callback(); + } finally { + globalThis.eventualWorkflowHookStore = undefined; } - return globalThis.eventualWorkflowHookStore.run(eventualHook, callback); } From 1bce98b5d9dac5a2846572ae5467fd386a5e5779 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Wed, 8 Mar 2023 02:30:26 -0600 Subject: [PATCH 24/28] workflow failures --- .../core-runtime/src/handlers/orchestrator.ts | 1 + .../core-runtime/src/workflow-executor.ts | 144 ++++++++----- .../test/workflow-executor.test.ts | 196 +++++++++++++++++- packages/@eventual/core/src/error.ts | 19 +- packages/@eventual/core/src/internal/util.ts | 13 +- tsconfig.test.json | 1 + 6 files changed, 311 insertions(+), 63 deletions(-) diff --git a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts index 03da3ca40..89cc0a65b 100644 --- a/packages/@eventual/core-runtime/src/handlers/orchestrator.ts +++ b/packages/@eventual/core-runtime/src/handlers/orchestrator.ts @@ -111,6 +111,7 @@ export function createOrchestrator({ }: OrchestratorDependencies): Orchestrator { return async (workflowTasks, baseTime = () => new Date()) => await serviceTypeScope(ServiceType.OrchestratorWorker, async () => { + console.log(JSON.stringify(workflowTasks, null, 4)); const tasksByExecutionId = groupBy( workflowTasks, (task) => task.executionId diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index ea6781fcf..4306882de 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -1,9 +1,10 @@ import { DeterminismError, Signal, - Timeout, + SystemError, Workflow, WorkflowContext, + WorkflowTimeout, } from "@eventual/core"; import { enterWorkflowHookScope, @@ -11,6 +12,7 @@ import { EventualPromise, EventualPromiseSymbol, ExecutionWorkflowHook, + extendsSystemError, HistoryEvent, HistoryResultEvent, HistoryScheduledEvent, @@ -23,6 +25,8 @@ import { Result, SignalReceived, WorkflowEvent, + WorkflowRunStarted, + WorkflowTimedOut, _Iterator, } from "@eventual/core/internal"; import { isPromise } from "util/types"; @@ -136,6 +140,7 @@ export class WorkflowExecutor { resolve: (result?: Result) => void; }; private commandsToEmit: WorkflowCommand[]; + private stopped: boolean = false; public result?: Result; constructor( @@ -170,14 +175,14 @@ export class WorkflowExecutor { const workflowPromise = this.workflow.definition(input, context); workflowPromise.then( // successfully completed workflows can continue to retrieve events. - // TODO: make this configurable? - (result) => (this.result = Result.resolved(result)), + // TODO: make the behavior of a workflow after success or failure configurable? + (result) => this.resolveWorkflow(Result.resolved(result)), // failed workflows will stop accepting events - (err) => this.endWorkflowRun(Result.failed(err)) + (err) => this.resolveWorkflow(Result.failed(err)) ); } catch (err) { // handle any synchronous errors. - this.endWorkflowRun(Result.failed(err)); + this.result = Result.failed(err); } }); } @@ -212,7 +217,13 @@ export class WorkflowExecutor { // start context with execution hook this.started = { resolve: (result) => { - resolve(this.flushCurrentWorkflowResult(result)); + // let everything that has started or will be started complete + // set timeout adds the closure to the end of the event loop + // this assumption breaks down when the user tries to start a promise + // to accomplish non-deterministic actions like IO. + setTimeout(() => { + resolve(this.flushCurrentWorkflowResult(result)); + }); }, }; // ensure the workflow hook is available to the workflow @@ -223,22 +234,25 @@ export class WorkflowExecutor { // APPLY EVENTS await this.drainHistoryEvents(); - // let everything that has started or will be started complete - // set timeout adds the closure to the end of the event loop - // this assumption breaks down when the user tries to start a promise - // to accomplish non-deterministic actions like IO. - setTimeout(() => { - this.endWorkflowRun(); - }); + // resolve the promise with the current state. + this.started.resolve(); }) ); } - private endWorkflowRun(result?: Result) { + /** + * Sets the workflow result. When the result is a {@link SystemError}, it + * halts the workflow. + */ + private resolveWorkflow(result: Result) { if (!this.started) { - throw new Error("Execution is not started."); + throw new SystemError("Execution is not started."); + } + this.result = result; + if (isFailed(result) && extendsSystemError(result.error)) { + this.stopped = true; + this.started.resolve(result); } - this.started.resolve(result); } /** @@ -294,7 +308,7 @@ export class WorkflowExecutor { return self.activateEventual(seq, eventual) as E; } catch (err) { - self.endWorkflowRun(Result.failed(err)); + self.resolveWorkflow(Result.failed(err)); throw err; } }, @@ -316,10 +330,16 @@ export class WorkflowExecutor { self.options?.hooks?.historicalEventMatched?.(expected, call); if (!isCorresponding(expected, seq, call)) { - throw new DeterminismError( - `Workflow returned ${JSON.stringify(call)}, but ${JSON.stringify( - expected - )} was expected at ${expected?.seq}` + self.resolveWorkflow( + Result.failed( + new DeterminismError( + `Workflow returned ${JSON.stringify( + call + )}, but ${JSON.stringify(expected)} was expected at ${ + expected?.seq + }` + ) + ) ); } return true; @@ -335,13 +355,16 @@ export class WorkflowExecutor { * before applying the next set of events. */ private async drainHistoryEvents() { - while (this.events.hasNext() && !isFailed(this.result)) { + while (this.events.hasNext() && !this.stopped) { const event = this.events.next()!; this.options?.hooks?.beforeApplyingResultEvent?.(event); await new Promise((resolve) => { setTimeout(() => { - this.tryCommitResultEvent(event); - resolve(undefined); + try { + this.tryCommitResultEvent(event); + } finally { + resolve(undefined); + } }); }); } @@ -358,43 +381,54 @@ export class WorkflowExecutor { */ private tryCommitResultEvent(event: HistoryResultEvent) { if (isWorkflowTimedOut(event)) { - return this.endWorkflowRun( - Result.failed(new Timeout("Workflow timed out")) + return this.resolveWorkflow( + Result.failed(new WorkflowTimeout("Workflow timed out")) ); } else if (!isWorkflowRunStarted(event)) { - if (isSignalReceived(event)) { - const signalHandlers = - this.activeHandlers.signals[event.signalId] ?? {}; - Object.entries(signalHandlers) - .filter(([seq]) => this.isEventualActive(seq)) - .map(([seq, handler]) => { - this.tryResolveEventual(Number(seq), handler(event) ?? undefined); - }); - } else { - if (this.isEventualActive(event.seq)) { - const eventHandler = - this.activeHandlers.events[event.seq]?.[event.type]; - this.tryResolveEventual( - event.seq, - eventHandler?.(event) ?? undefined - ); - } else if (event.seq >= this.nextSeq) { - // if a workflow history event precedes the call, throw an error. - // this should never happen if the workflow is deterministic. - this.endWorkflowRun( - Result.failed( - new DeterminismError(`Call for seq ${event.seq} was not emitted.`) - ) - ); + for (const [seq, handler] of this.getHandlerForEvent(event)) { + if (this.stopped) { + break; + } + try { + this.tryResolveEventual(Number(seq), handler() ?? undefined); + } catch { + // handlers cannot throw and should not impact other handlers } } - // resolve any eventuals should be triggered on each new event - Object.entries(this.activeHandlers.afterEveryEvent) + } + } + + private *getHandlerForEvent( + event: Exclude + ): Generator< + readonly [number | string, () => Result | void], + void, + undefined + > { + if (isSignalReceived(event)) { + const signalHandlers = this.activeHandlers.signals[event.signalId] ?? {}; + yield* Object.entries(signalHandlers) .filter(([seq]) => this.isEventualActive(seq)) - .forEach(([seq, handler]) => { - this.tryResolveEventual(Number(seq), handler() ?? undefined); - }); + .map(([seq, handler]) => [seq, () => handler(event)] as const); + } else { + if (this.isEventualActive(event.seq)) { + const eventHandler = + this.activeHandlers.events[event.seq]?.[event.type]; + yield [event.seq, () => eventHandler?.(event)]; + } else if (event.seq >= this.nextSeq) { + // if a workflow history event precedes the call, throw an error. + // this should never happen if the workflow is deterministic. + this.resolveWorkflow( + Result.failed( + new DeterminismError(`Call for seq ${event.seq} was not emitted.`) + ) + ); + } } + // resolve any eventuals should be triggered on each new event + yield* Object.entries(this.activeHandlers.afterEveryEvent) + .filter(([seq]) => this.isEventualActive(seq)) + .map(([seq, handler]) => [seq, handler] as const); } /** diff --git a/packages/@eventual/core-runtime/test/workflow-executor.test.ts b/packages/@eventual/core-runtime/test/workflow-executor.test.ts index 45cac5093..7bf628410 100644 --- a/packages/@eventual/core-runtime/test/workflow-executor.test.ts +++ b/packages/@eventual/core-runtime/test/workflow-executor.test.ts @@ -1685,7 +1685,7 @@ test("workflow calling other workflow", async () => { commands: [], }); - await expect( + await expect( execute( wf2, [workflowScheduled(wf1.name, 0), workflowFailed("error", 0)], @@ -2793,7 +2793,7 @@ describe("running after result", () => { }); }); - test("signal handler does not accept after a failure", async () => { + test("signal handler accepts after a failure", async () => { const wf = workflow(async () => { createRegisterSignalHandlerCall("signal1", () => { createActivityCall("on signal", 1); @@ -2813,7 +2813,7 @@ describe("running after result", () => { await expect( executor.continue(signalReceived("signal1")) ).resolves.toEqual({ - commands: [], + commands: [createScheduledActivityCommand("on signal", 1, 1)], result: Result.failed(new Error("AHHH")), }); }); @@ -2853,3 +2853,193 @@ describe("running after result", () => { }); }); }); + +describe("failures", () => { + const asyncFuncWf = workflow(async () => { + await createActivityCall("hello", undefined); + + (async () => { + await createActivityCall("hello", undefined); + await createActivityCall("hello", undefined); + await createActivityCall("hello", undefined); + await createActivityCall("hello", undefined); + })(); + + throw new Error("AHH"); + }); + + const signalWf = workflow(async () => { + await createActivityCall("hello", undefined); + + createRegisterSignalHandlerCall("signal", () => { + createActivityCall("signalAct", undefined); + }); + + throw new Error("AHH"); + }); + + test("with events", async () => { + await expect( + execute( + asyncFuncWf, + [ + activityScheduled("hello", 0), + activitySucceeded(undefined, 0), + activityScheduled("hello", 1), + activitySucceeded(undefined, 1), + activityScheduled("hello", 2), + activitySucceeded(undefined, 2), + activityScheduled("hello", 3), + activitySucceeded(undefined, 3), + ], + undefined + ) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("hello", undefined, 4)], + result: Result.failed(Error("AHH")), + }); + }); + + test("with partial async function", async () => { + await expect( + execute( + asyncFuncWf, + [activityScheduled("hello", 0), activitySucceeded(undefined, 0)], + undefined + ) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("hello", undefined, 1)], + result: Result.failed(Error("AHH")), + }); + }); + + test("with signal handler", async () => { + await expect( + execute( + signalWf, + [ + activityScheduled("hello", 0), + activitySucceeded(undefined, 0), + signalReceived("signal"), + ], + undefined + ) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("signalAct", undefined, 2)], + result: Result.failed(Error("AHH")), + }); + }); + + test("with signal handler 2", async () => { + const wf = workflow(async () => { + await createActivityCall("hello", undefined); + + createRegisterSignalHandlerCall("signal", () => { + createActivityCall("signalAct", undefined); + }); + + await createExpectSignalCall("signal"); + + throw new Error("AHH"); + }); + + await expect( + execute( + wf, + [ + activityScheduled("hello", 0), + activitySucceeded(undefined, 0), + signalReceived("signal"), + ], + undefined + ) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("signalAct", undefined, 3)], + result: Result.failed(Error("AHH")), + }); + }); + + test("without fail", async () => { + const wf = workflow(async () => { + await createActivityCall("hello", undefined); + + createRegisterSignalHandlerCall("signal", () => { + createActivityCall("signalAct", undefined); + throw new Error("AHH"); + }); + + await createExpectSignalCall("signal"); + createActivityCall("signalAct", undefined); + }); + + await expect( + execute( + wf, + [ + activityScheduled("hello", 0), + activitySucceeded(undefined, 0), + signalReceived("signal"), + ], + undefined + ) + ).resolves.toMatchObject({ + commands: [ + createScheduledActivityCommand("signalAct", undefined, 3), + createScheduledActivityCommand("signalAct", undefined, 4), + ], + result: Result.resolved(undefined), + }); + }); + + test("with lots of events", async () => { + const wf = workflow(async () => { + await createActivityCall("hello", undefined); + + let n = 0; + while (n++ < 10) { + (async () => { + await createActivityCall("hello", undefined); + await createActivityCall("hello", undefined); + })(); + } + + throw new Error("AHH"); + }); + + await expect( + execute( + wf, + [...Array(21).keys()].flatMap((i) => [ + activityScheduled("hello", i), + activitySucceeded(undefined, i), + ]), + undefined + ) + ).resolves.toEqual({ + commands: [], + result: Result.failed(Error("AHH")), + }); + }); + + test("with continue", async () => { + const executor = new WorkflowExecutor(signalWf, [ + activityScheduled("hello", 0), + activitySucceeded(undefined, 0), + signalReceived("signal"), + ]); + + await expect( + executor.start(undefined, context) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("signalAct", undefined, 2)], + result: Result.failed(Error("AHH")), + }); + + await expect( + executor.continue(signalReceived("signal")) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("signalAct", undefined, 3)], + result: Result.failed(Error("AHH")), + }); + }); +}); diff --git a/packages/@eventual/core/src/error.ts b/packages/@eventual/core/src/error.ts index a435a023a..5a2107580 100644 --- a/packages/@eventual/core/src/error.ts +++ b/packages/@eventual/core/src/error.ts @@ -14,7 +14,14 @@ export class EventualError extends Error { }; } } -export class DeterminismError extends EventualError { + +export class SystemError extends EventualError { + constructor(name?: string, message?: string) { + super(name ?? "SystemError", message); + } +} + +export class DeterminismError extends SystemError { constructor(message?: string) { super("DeterminismError", message); } @@ -68,12 +75,16 @@ export class HeartbeatTimeout extends Timeout { this.name = "HeartbeatTimeout"; } } + /** - * Thrown when a particular context only support synchronous operations (ex: condition predicate). + * Thrown when the workflow times out. + * + * After a workflow times out, events are no longer accepted + * and commands are no longer executed. */ -export class SynchronousOperationError extends EventualError { +export class WorkflowTimeout extends SystemError { constructor(message?: string) { - super("SynchronousOperationError", message); + super("WorkflowTimeout", message); } } diff --git a/packages/@eventual/core/src/internal/util.ts b/packages/@eventual/core/src/internal/util.ts index 7115c64c7..c898cf977 100644 --- a/packages/@eventual/core/src/internal/util.ts +++ b/packages/@eventual/core/src/internal/util.ts @@ -1,3 +1,4 @@ +import { SystemError } from "../error.js"; export function assertNever(never: never, msg?: string): never { throw new Error(msg ?? `reached unreachable code with value ${never}`); @@ -33,6 +34,17 @@ export function extendsError(err: unknown): err is Error { ); } +export function extendsSystemError(err: unknown): err is SystemError { + return ( + !!err && + typeof err === "object" && + (err instanceof SystemError || + ("prototype" in err && + !!err.prototype && + Object.prototype.isPrototypeOf.call(err.prototype, SystemError))) + ); +} + export interface _Iterator { hasNext(): boolean; next(): T | undefined; @@ -101,4 +113,3 @@ export function hashCode(str: string): number { export function encodeExecutionId(executionId: string) { return Buffer.from(executionId, "utf-8").toString("base64"); } - diff --git a/tsconfig.test.json b/tsconfig.test.json index 89782544e..48e101df0 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -8,6 +8,7 @@ { "path": "packages/@eventual/compiler/tsconfig.test.json" }, { "path": "packages/@eventual/client/tsconfig.test.json" }, { "path": "packages/@eventual/core/tsconfig.test.json" }, + { "path": "packages/@eventual/core-runtime/tsconfig.test.json" }, { "path": "packages/@eventual/testing/tsconfig.test.json" } ] } From b262d57f4bfa0afdd0b7d0e2ca4cef9268238367 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Wed, 8 Mar 2023 09:50:28 -0600 Subject: [PATCH 25/28] reenable the chaos ext --- apps/tests/aws-runtime-cdk/src/app.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/tests/aws-runtime-cdk/src/app.ts b/apps/tests/aws-runtime-cdk/src/app.ts index 21d9b95b5..5f4b71bef 100644 --- a/apps/tests/aws-runtime-cdk/src/app.ts +++ b/apps/tests/aws-runtime-cdk/src/app.ts @@ -69,7 +69,7 @@ testQueue.grantSendMessages(testService.activities.asyncActivity); const chaosExtension = new ChaosExtension(stack, "chaos"); testService.activitiesList.map((a) => chaosExtension.addToFunction(a.handler)); -// chaosExtension.addToFunction(testService.system.workflowService.orchestrator); +chaosExtension.addToFunction(testService.system.workflowService.orchestrator); chaosExtension.grantReadWrite(role); From fb25b0fec3670100550f56271a1dcb6c8145b956 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Wed, 8 Mar 2023 11:32:52 -0600 Subject: [PATCH 26/28] speed and clean up --- .../core-runtime/src/eventual-factory.ts | 17 +- .../core-runtime/src/workflow-executor.ts | 197 +++++++++++------- .../test/workflow-executor.test.ts | 28 +++ 3 files changed, 162 insertions(+), 80 deletions(-) diff --git a/packages/@eventual/core-runtime/src/eventual-factory.ts b/packages/@eventual/core-runtime/src/eventual-factory.ts index 7a252afe5..24b74f91b 100644 --- a/packages/@eventual/core-runtime/src/eventual-factory.ts +++ b/packages/@eventual/core-runtime/src/eventual-factory.ts @@ -34,11 +34,13 @@ export function createEventualFromCall( Trigger.workflowEvent(WorkflowEventType.ActivityFailed, (event) => Result.failed(new EventualError(event.error, event.message)) ), - Trigger.workflowEvent(WorkflowEventType.ActivityHeartbeatTimedOut, () => + Trigger.workflowEvent( + WorkflowEventType.ActivityHeartbeatTimedOut, Result.failed(new HeartbeatTimeout("Activity Heartbeat TimedOut")) ), call.timeout - ? Trigger.promise(call.timeout, () => + ? Trigger.promise( + call.timeout, Result.failed(new Timeout("Activity Timed Out")) ) : undefined, @@ -64,7 +66,8 @@ export function createEventualFromCall( Result.failed(new EventualError(event.error, event.message)) ), call.timeout - ? Trigger.promise(call.timeout, () => + ? Trigger.promise( + call.timeout, Result.failed("Child Workflow Timed Out") ) : undefined, @@ -81,7 +84,8 @@ export function createEventualFromCall( }; } else if (isAwaitTimerCall(call)) { return { - triggers: Trigger.workflowEvent(WorkflowEventType.TimerCompleted, () => + triggers: Trigger.workflowEvent( + WorkflowEventType.TimerCompleted, Result.resolved(undefined) ), generateCommands(seq) { @@ -112,7 +116,8 @@ export function createEventualFromCall( Result.resolved(event.payload) ), call.timeout - ? Trigger.promise(call.timeout, () => + ? Trigger.promise( + call.timeout, Result.failed(new Timeout("Expect Signal Timed Out")) ) : undefined, @@ -141,7 +146,7 @@ export function createEventualFromCall( return result ? Result.resolved(result) : undefined; }), call.timeout - ? Trigger.promise(call.timeout, () => Result.resolved(false)) + ? Trigger.promise(call.timeout, Result.resolved(false)) : undefined, ], }; diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index 4306882de..14b48a953 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -31,7 +31,7 @@ import { } from "@eventual/core/internal"; import { isPromise } from "util/types"; import { createEventualFromCall, isCorresponding } from "./eventual-factory.js"; -import { isFailed, isResolved } from "./result.js"; +import { isFailed, isResolved, isResult } from "./result.js"; import type { WorkflowCommand } from "./workflow-command.js"; /** @@ -129,18 +129,33 @@ export class WorkflowExecutor { private events: _Iterator; /** - * Set when the workflow is started. + * The state of the current workflow run (start or continue are running). + * + * When undefined, the workflow is not running and can be started or accept new events (unless stopped is true). */ - private started?: { + private currentRun?: { /** * When called, resolves the workflow execution with a {@link result}. * * This will cause the current promise from start or continue to resolve with a the given value. */ resolve: (result?: Result) => void; + commandsToEmit: WorkflowCommand[]; }; - private commandsToEmit: WorkflowCommand[]; + /** + * Has the executor ever been started? + * + * When false, can call start and not continue. + * When true, can call continue and not start. + */ + private started: boolean = false; + /** + * True when the executor reached a terminal state, generally a {@link SystemError}. + */ private stopped: boolean = false; + /** + * The current result of the workflow, also returned by start and continue on completion. + */ public result?: Result; constructor( @@ -151,7 +166,6 @@ export class WorkflowExecutor { this.nextSeq = 0; this.expected = iterator(history, isScheduledEvent); this.events = iterator(history, isResultEvent); - this.commandsToEmit = []; } /** @@ -169,11 +183,12 @@ export class WorkflowExecutor { ); } + this.started = true; + // start a workflow run and start the workflow itself. return this.startWorkflowRun(() => { try { - const workflowPromise = this.workflow.definition(input, context); - workflowPromise.then( + this.workflow.definition(input, context).then( // successfully completed workflows can continue to retrieve events. // TODO: make the behavior of a workflow after success or failure configurable? (result) => this.resolveWorkflow(Result.resolved(result)), @@ -182,7 +197,7 @@ export class WorkflowExecutor { ); } catch (err) { // handle any synchronous errors. - this.result = Result.failed(err); + this.resolveWorkflow(Result.failed(err)); } }); } @@ -203,6 +218,10 @@ export class WorkflowExecutor { ): Promise> { if (!this.started) { throw new Error("Execution has not been started, call start first."); + } else if (this.currentRun) { + throw new Error( + "Workflow is already running, await the promise returned by the last start or complete call." + ); } this.history.push(...history); @@ -215,16 +234,20 @@ export class WorkflowExecutor { () => new Promise>(async (resolve) => { // start context with execution hook - this.started = { + this.currentRun = { resolve: (result) => { // let everything that has started or will be started complete // set timeout adds the closure to the end of the event loop // this assumption breaks down when the user tries to start a promise // to accomplish non-deterministic actions like IO. - setTimeout(() => { - resolve(this.flushCurrentWorkflowResult(result)); + process.nextTick(() => { + const runResult = this.completeCurrentRun(result); + if (runResult) { + resolve(runResult); + } }); }, + commandsToEmit: [], }; // ensure the workflow hook is available to the workflow // and tied to the workflow promise context @@ -235,7 +258,7 @@ export class WorkflowExecutor { await this.drainHistoryEvents(); // resolve the promise with the current state. - this.started.resolve(); + this.currentRun?.resolve(); }) ); } @@ -245,13 +268,13 @@ export class WorkflowExecutor { * halts the workflow. */ private resolveWorkflow(result: Result) { - if (!this.started) { - throw new SystemError("Execution is not started."); + if (!this.currentRun) { + throw new SystemError("Execution is not running."); } this.result = result; if (isFailed(result) && extendsSystemError(result.error)) { this.stopped = true; - this.started.resolve(result); + this.currentRun.resolve(result); } } @@ -260,15 +283,18 @@ export class WorkflowExecutor { * * If the workflow has additional expected events to apply, fails the workflow with a determinism error. */ - private flushCurrentWorkflowResult( + private completeCurrentRun( overrideResult?: Result - ): WorkflowResult { - const newCommands = this.commandsToEmit; - this.commandsToEmit = []; + ): WorkflowResult | undefined { + if (this.currentRun) { + const newCommands = this.currentRun.commandsToEmit; + this.currentRun = undefined; - this.result = overrideResult ?? this.result; + this.result = overrideResult ?? this.result; - return { result: this.result, commands: newCommands }; + return { result: this.result, commands: newCommands }; + } + return undefined; } /** @@ -292,7 +318,7 @@ export class WorkflowExecutor { eventual.generateCommands && !checkExpectedCallAndAdvance(seq, call) ) { - self.commandsToEmit.push( + self.currentRun?.commandsToEmit.push( ...normalizeToArray(eventual.generateCommands(seq)) ); } @@ -358,13 +384,13 @@ export class WorkflowExecutor { while (this.events.hasNext() && !this.stopped) { const event = this.events.next()!; this.options?.hooks?.beforeApplyingResultEvent?.(event); + // We use a promise here because... + // 1. we want the user code to finish executing before continuing to the next event + // 2. the promise allows us to use iteration instead of a recursive call stack and depth limits await new Promise((resolve) => { - setTimeout(() => { - try { - this.tryCommitResultEvent(event); - } finally { - resolve(undefined); - } + process.nextTick(() => { + this.tryCommitResultEvent(event); + resolve(undefined); }); }); } @@ -385,37 +411,49 @@ export class WorkflowExecutor { Result.failed(new WorkflowTimeout("Workflow timed out")) ); } else if (!isWorkflowRunStarted(event)) { - for (const [seq, handler] of this.getHandlerForEvent(event)) { - if (this.stopped) { + for (const { seq, handler, args } of this.getHandlerForEvent(event)) { + // stop calling handler if the workflow is stopped + // this should only happen on a SystemError + if (!this.invokeEventualHandler(seq, handler, ...args)) { break; } - try { - this.tryResolveEventual(Number(seq), handler() ?? undefined); - } catch { - // handlers cannot throw and should not impact other handlers - } } } } + private invokeEventualHandler( + seq: number | string, + handler: EventualHandler, + ...args: Args + ) { + if (this.stopped) { + return false; + } + if (this.isEventualActive(seq)) { + try { + this.tryResolveEventual( + Number(seq), + isResult(handler) ? handler : handler(...args) ?? undefined + ); + } catch { + // handlers cannot throw and should not impact other handlers + } + } + return true; + } + private *getHandlerForEvent( event: Exclude - ): Generator< - readonly [number | string, () => Result | void], - void, - undefined - > { + ): Generator, void, undefined> { if (isSignalReceived(event)) { const signalHandlers = this.activeHandlers.signals[event.signalId] ?? {}; - yield* Object.entries(signalHandlers) - .filter(([seq]) => this.isEventualActive(seq)) - .map(([seq, handler]) => [seq, () => handler(event)] as const); + yield* Object.entries(signalHandlers).map(([seq, handler]) => ({ + seq, + handler, + args: [event], + })); } else { - if (this.isEventualActive(event.seq)) { - const eventHandler = - this.activeHandlers.events[event.seq]?.[event.type]; - yield [event.seq, () => eventHandler?.(event)]; - } else if (event.seq >= this.nextSeq) { + if (event.seq >= this.nextSeq) { // if a workflow history event precedes the call, throw an error. // this should never happen if the workflow is deterministic. this.resolveWorkflow( @@ -424,11 +462,15 @@ export class WorkflowExecutor { ) ); } + const eventHandler = this.activeHandlers.events[event.seq]?.[event.type]; + if (eventHandler) { + yield { seq: event.seq, handler: eventHandler, args: [event] }; + } } // resolve any eventuals should be triggered on each new event - yield* Object.entries(this.activeHandlers.afterEveryEvent) - .filter(([seq]) => this.isEventualActive(seq)) - .map(([seq, handler]) => [seq, handler] as const); + yield* Object.entries(this.activeHandlers.afterEveryEvent).map( + ([seq, handler]) => ({ seq, handler, args: [] }) + ); } /** @@ -509,20 +551,18 @@ export class WorkflowExecutor { : promiseTrigger.promise ).then( (res) => { - if (this.isEventualActive(seq)) { - this.tryResolveEventual( - seq, - promiseTrigger.handler(Result.resolved(res)) ?? undefined - ); - } + this.invokeEventualHandler( + seq, + promiseTrigger.handler, + Result.resolved(res) + ); }, (err) => { - if (this.isEventualActive(seq)) { - this.tryResolveEventual( - seq, - promiseTrigger.handler(Result.failed(err)) ?? undefined - ); - } + this.invokeEventualHandler( + seq, + promiseTrigger.handler, + Result.failed(err) + ); } ); }); @@ -531,13 +571,10 @@ export class WorkflowExecutor { * If the eventual subscribes to a signal, add it to the map. */ const signalTriggers = triggers.filter(isSignalTrigger); - signalTriggers.forEach( - (signalTrigger) => - (this.activeHandlers.signals[signalTrigger.signalId] = { - ...(this.activeHandlers.signals[signalTrigger.signalId] ?? {}), - [seq]: signalTrigger.handler, - }) - ); + signalTriggers.forEach((signalTrigger) => { + (this.activeHandlers.signals[signalTrigger.signalId] ??= {})[seq] = + signalTrigger.handler; + }); // maintain a reference to the signals this eventual is listening for // in order to effectively remove the handlers later. activeEventual.signals = signalTriggers.map((s) => s.signalId); @@ -635,13 +672,19 @@ export const Trigger = { }, }; +type EventualHandler = + | { + (...args: Args): void | undefined | Result; + } + | Result; + export interface PromiseTrigger { promise: Promise; - handler: (val: Result) => Result | void; + handler: EventualHandler<[val: Result], OwnRes>; } export interface AfterEveryEventTrigger { - afterEvery: () => Result | void; + afterEvery: EventualHandler<[], OwnRes>; } export interface EventTrigger< @@ -649,12 +692,12 @@ export interface EventTrigger< E extends HistoryResultEvent = any > { eventType: E["type"]; - handler: (event: E) => Result | void; + handler: EventualHandler<[event: E], OwnRes>; } export interface SignalTrigger { signalId: Signal["id"]; - handler: (event: SignalReceived) => Result | void; + handler: EventualHandler<[event: SignalReceived], OwnRes>; } export function isPromiseTrigger( @@ -718,3 +761,9 @@ export function isResolvedEventualDefinition( ): eventualDefinition is ResolvedEventualDefinition { return "result" in eventualDefinition; } + +interface EventualHandlerRef { + seq: number | string; + handler: EventualHandler; + args: Args; +} diff --git a/packages/@eventual/core-runtime/test/workflow-executor.test.ts b/packages/@eventual/core-runtime/test/workflow-executor.test.ts index 7bf628410..5286356b9 100644 --- a/packages/@eventual/core-runtime/test/workflow-executor.test.ts +++ b/packages/@eventual/core-runtime/test/workflow-executor.test.ts @@ -2765,6 +2765,34 @@ describe("continue", () => { result: Result.resolved("done"), }); }); + + test("throws if previous run not complete", async () => { + const executor = new WorkflowExecutor(myWorkflow, []); + const startPromise = executor.start(event, context); + await expect( + executor.continue(activitySucceeded("result", 0)) + ).rejects.toThrowError( + "Workflow is already running, await the promise returned by the last start or complete call." + ); + await expect(startPromise).resolves.toEqual({ + commands: [createScheduledActivityCommand("my-activity", [event], 0)], + result: undefined, + }); + const continuePromise = executor.continue(activitySucceeded("result", 0)); + await expect( + executor.continue(activitySucceeded("result", 0)) + ).rejects.toThrowError( + "Workflow is already running, await the promise returned by the last start or complete call." + ); + await expect(continuePromise).resolves.toEqual({ + commands: [ + createScheduledActivityCommand("my-activity-0", [event], 1), + createStartTimerCommand(2), + createScheduledActivityCommand("my-activity-2", [event], 3), + ], + result: undefined, + }); + }); }); describe("running after result", () => { From 944dd907a6f8e634ad8342151b7ff574413b76be Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Wed, 8 Mar 2023 22:27:52 -0600 Subject: [PATCH 27/28] testing then, catch, finally --- .../core-runtime/src/workflow-executor.ts | 7 +- .../test/workflow-executor.test.ts | 478 +++++++++++++++++- 2 files changed, 479 insertions(+), 6 deletions(-) diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index 14b48a953..e530b733a 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -130,7 +130,7 @@ export class WorkflowExecutor { /** * The state of the current workflow run (start or continue are running). - * + * * When undefined, the workflow is not running and can be started or accept new events (unless stopped is true). */ private currentRun?: { @@ -361,9 +361,7 @@ export class WorkflowExecutor { new DeterminismError( `Workflow returned ${JSON.stringify( call - )}, but ${JSON.stringify(expected)} was expected at ${ - expected?.seq - }` + )}, but ${JSON.stringify(expected)} was expected at ${seq}` ) ) ); @@ -388,6 +386,7 @@ export class WorkflowExecutor { // 1. we want the user code to finish executing before continuing to the next event // 2. the promise allows us to use iteration instead of a recursive call stack and depth limits await new Promise((resolve) => { + // this is only needed when using async on immediately resolved promises, like send signal process.nextTick(() => { this.tryCommitResultEvent(event); resolve(undefined); diff --git a/packages/@eventual/core-runtime/test/workflow-executor.test.ts b/packages/@eventual/core-runtime/test/workflow-executor.test.ts index 5286356b9..869c68264 100644 --- a/packages/@eventual/core-runtime/test/workflow-executor.test.ts +++ b/packages/@eventual/core-runtime/test/workflow-executor.test.ts @@ -2228,13 +2228,50 @@ describe("signals", () => { }); }); - test("awaited sendSignal does nothing", async () => { + test("sendSignal then", async () => { const wf = workflow(async () => { + console.log("before signal"); + return createSendSignalCall( + { type: SignalTargetType.Execution, executionId: "someExecution/" }, + mySignal.id + ).then(async () => { + console.log("after signal"); + + const childWorkflow = createWorkflowCall("childWorkflow"); + + await childWorkflow.sendSignal(mySignal); + + return await childWorkflow; + }); + }); + + await expect( + execute( + wf, + [ + signalSent("someExec", "MySignal", 0), + workflowScheduled("childWorkflow", 1), + signalSent("someExecution/", "MySignal", 2), + workflowSucceeded("done", 1), + ], + undefined + ) + ).resolves.toEqual({ + result: Result.resolved("done"), + commands: [], + }); + }); + + test("awaited sendSignal does nothing 2", async () => { + const wf = workflow(async () => { + console.log("before signal"); await createSendSignalCall( { type: SignalTargetType.Execution, executionId: "someExecution/" }, mySignal.id ); + console.log("after signal"); + const childWorkflow = createWorkflowCall("childWorkflow"); await childWorkflow.sendSignal(mySignal); @@ -2253,7 +2290,7 @@ describe("signals", () => { ], undefined ) - ).resolves.toMatchObject({ + ).resolves.toEqual({ result: Result.resolved("done"), commands: [], }); @@ -3071,3 +3108,440 @@ describe("failures", () => { }); }); }); + +describe("using then, catch, finally", () => { + describe("then", () => { + test("chained result", async () => { + await expect( + execute( + workflow(async () => + createActivityCall("testme", undefined).then( + (result) => result + 1 + ) + ), + [activityScheduled("testme", 0), activitySucceeded(1, 0)], + undefined + ) + ).resolves.toEqual({ + commands: [], + result: Result.resolved(2), + }); + }); + + test("chained but does not complete", async () => { + await expect( + execute( + workflow(async () => + createActivityCall("testme", undefined).then( + (result) => result + 1 + ) + ), + [activityScheduled("testme", 0)], + undefined + ) + ).resolves.toEqual({ + commands: [], + result: undefined, + }); + }); + + test("chained using immediate resolutions", async () => { + await expect( + execute( + workflow(async () => + createSendSignalCall( + { + type: SignalTargetType.Execution, + executionId: "something", + }, + "signal" + ).then(() => "hi") + ), + [signalSent("something", "signal", 0)], + undefined + ) + ).resolves.toEqual({ + commands: [], + result: Result.resolved("hi"), + }); + }); + + test("chained using immediate resolutions and emit more", async () => { + await expect( + execute( + workflow(async () => + createSendSignalCall( + { + type: SignalTargetType.Execution, + executionId: "something", + }, + "signal" + ).then(() => { + createActivityCall("act1", undefined); + return "hi"; + }) + ), + [signalSent("something", "signal", 0)], + undefined + ) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("act1", undefined, 1)], + result: Result.resolved("hi"), + }); + }); + + test("chained using immediate resolutions and chain more", async () => { + await expect( + execute( + workflow(async () => + createSendSignalCall( + { + type: SignalTargetType.Execution, + executionId: "something", + }, + "signal" + ).then(() => createActivityCall("act1", undefined)) + ), + [ + signalSent("something", "signal", 0), + activityScheduled("act1", 1), + activitySucceeded("hi", 1), + ], + undefined + ) + ).resolves.toEqual({ + commands: [], + result: Result.resolved("hi"), + }); + }); + + test("then then then then", async () => { + await expect( + execute( + workflow(async () => + createSendSignalCall( + { + type: SignalTargetType.Execution, + executionId: "something", + }, + "signal" + ).then(() => { + let x = 0; + return Promise.all([ + createActivityCall("act1", undefined).then(() => { + x++; + return createActivityCall("boom", undefined); + }), + createSendSignalCall( + { + type: SignalTargetType.Execution, + executionId: "something", + }, + "signal2", + undefined + ).then(() => { + x++; + return createActivityCall("boom", undefined); + }), + createWorkflowCall("workflow1", undefined).then(() => { + x++; + return createActivityCall("boom", undefined); + }), + createExpectSignalCall("signal2").then(() => { + x++; + return createActivityCall("boom", undefined); + }), + createConditionCall(() => true).then(() => { + x++; + return createActivityCall("boom", undefined); + }), + createConditionCall(() => x >= 5).then(() => + createActivityCall("boom", undefined) + ), + ]); + }) + ), + [ + signalSent("something", "signal", 0), + activityScheduled("act1", 1), + signalSent("something", "signal2", 2), + workflowScheduled("workflow1", 3), + activityScheduled("boom", 7), // from signal sent + activityScheduled("boom", 8), // from condition true + activitySucceeded("hi", 1), // succeed first activity + activityScheduled("boom", 9), // after first act + workflowSucceeded("something", 3), // succeed child workflow + activityScheduled("boom", 10), // after child workflow + signalReceived("signal2"), // signal for expect + activityScheduled("boom", 11), // after expect + activityScheduled("boom", 12), // after last condition + activitySucceeded("b", 7), + activitySucceeded("e", 8), + activitySucceeded("a", 9), + activitySucceeded("c", 10), + activitySucceeded("d", 11), + activitySucceeded("f", 12), + ], + undefined + ) + ).resolves.toEqual({ + commands: [], + result: Result.resolved(["a", "b", "c", "d", "e", "f"]), + }); + }); + }); + + describe("catch", () => { + test("chained result", async () => { + await expect( + execute( + workflow(async () => + createActivityCall("testme", undefined).catch( + (result) => (result as Error).name + 1 + ) + ), + [activityScheduled("testme", 0), activityFailed(new Error(""), 0)], + undefined + ) + ).resolves.toEqual({ + commands: [], + result: Result.resolved("Error1"), + }); + }); + + test("chained but does not complete", async () => { + await expect( + execute( + workflow(async () => + createActivityCall("testme", undefined).catch( + (result) => (result as Error).name + 1 + ) + ), + [activityScheduled("testme", 0)], + undefined + ) + ).resolves.toEqual({ + commands: [], + result: undefined, + }); + }); + + test("chained using immediate resolutions", async () => { + await expect( + execute( + workflow(async () => + createSendSignalCall( + { + type: SignalTargetType.Execution, + executionId: "something", + }, + "signal" + ) + .then(() => { + throw new Error(""); + }) + .catch(() => "hi") + ), + [signalSent("something", "signal", 0)], + undefined + ) + ).resolves.toEqual({ + commands: [], + result: Result.resolved("hi"), + }); + }); + + test("chained using immediate resolutions and emit more", async () => { + await expect( + execute( + workflow(async () => + createSendSignalCall( + { + type: SignalTargetType.Execution, + executionId: "something", + }, + "signal" + ) + .then(() => { + throw new Error(""); + }) + .catch(() => { + createActivityCall("act1", undefined); + return "hi"; + }) + ), + [signalSent("something", "signal", 0)], + undefined + ) + ).resolves.toEqual({ + commands: [createScheduledActivityCommand("act1", undefined, 1)], + result: Result.resolved("hi"), + }); + }); + + test("chained using immediate resolutions and chain more", async () => { + await expect( + execute( + workflow(async () => + createSendSignalCall( + { + type: SignalTargetType.Execution, + executionId: "something", + }, + "signal" + ) + .then(() => { + throw Error(""); + }) + .catch(() => createActivityCall("act1", undefined)) + ), + [ + signalSent("something", "signal", 0), + activityScheduled("act1", 1), + activitySucceeded("hi", 1), + ], + undefined + ) + ).resolves.toEqual({ + commands: [], + result: Result.resolved("hi"), + }); + }); + + test("catch catch catch catch", async () => { + await expect( + execute( + workflow(async () => + createSendSignalCall( + { + type: SignalTargetType.Execution, + executionId: "something", + }, + "signal" + ) + .then(() => { + throw new Error(""); + }) + .catch(() => { + let x = 0; + return Promise.all([ + createActivityCall("act1", undefined).catch(() => { + x++; + return createActivityCall("boom", undefined); + }), + createSendSignalCall( + { + type: SignalTargetType.Execution, + executionId: "something", + }, + "signal2", + undefined + ) + .then(() => { + throw new Error(""); + }) + .catch(() => { + x++; + return createActivityCall("boom", undefined); + }), + createWorkflowCall("workflow1", undefined).catch(() => { + x++; + return createActivityCall("boom", undefined); + }), + createExpectSignalCall( + "signal2", + createAwaitTimerCall(Schedule.time("")) + ).catch(() => { + x++; + return createActivityCall("boom", undefined); + }), + createConditionCall( + () => false, + createAwaitTimerCall(Schedule.time("")) + ) + .then(() => { + throw new Error(""); + }) + .catch(() => { + x++; + return createActivityCall("boom", undefined); + }), + createConditionCall( + () => x >= 1000000000000, + createAwaitTimerCall(Schedule.time("")) + ) + .then(() => { + throw new Error(""); + }) + .catch(() => createActivityCall("boom", undefined)), + ]); + }) + ), + [ + signalSent("something", "signal", 0), + activityScheduled("act1", 1), + signalSent("something", "signal2", 2), + workflowScheduled("workflow1", 3), + timerScheduled(4), + timerScheduled(6), + timerScheduled(8), + activityScheduled("boom", 10), // from signal sent + activityFailed("hi", 1), // succeed first activity + activityScheduled("boom", 11), // after first act + workflowFailed("something", 3), // succeed child workflow + activityScheduled("boom", 12), // after child workflow + timerCompleted(4), + activityScheduled("boom", 13), // from expect timeout + timerCompleted(6), + activityScheduled("boom", 14), // from condition false timeout + timerCompleted(8), + activityScheduled("boom", 15), // from condition 10000000 timeout + activitySucceeded("b", 10), + activitySucceeded("a", 11), + activitySucceeded("c", 12), + activitySucceeded("d", 13), + activitySucceeded("e", 14), + activitySucceeded("f", 15), + ], + undefined + ) + ).resolves.toEqual({ + commands: [], + result: Result.resolved(["a", "b", "c", "d", "e", "f"]), + }); + }); + }); + + describe("finally", () => { + test("chained result", async () => { + await expect( + execute( + workflow(async () => { + return Promise.all([ + createActivityCall("testme", undefined).finally(() => + createActivityCall("actact", undefined) + ), + createSendSignalCall( + { type: SignalTargetType.Execution, executionId: "" }, + "signal1" + ).finally(() => createActivityCall("actact", undefined)), + ]); + }), + [ + activityScheduled("testme", 0), + signalSent("", "signal1", 1), + activityScheduled("actact", 2), + activitySucceeded("something", 0), + activitySucceeded("something1", 2), + activityScheduled("actact", 3), + activitySucceeded("something2", 3), + ], + undefined + ) + ).resolves.toEqual({ + commands: [], + result: Result.resolved(["something", undefined]), + }); + }); + }); +}); From bb6a982f1f3f8e1f172d9797ed72f9ad23bc7fa0 Mon Sep 17 00:00:00 2001 From: Sam Sussman Date: Thu, 9 Mar 2023 10:30:53 -0600 Subject: [PATCH 28/28] feedback --- .../core-runtime/src/eventual-factory.ts | 28 +-- .../core-runtime/src/workflow-executor.ts | 191 ++++++++++-------- 2 files changed, 122 insertions(+), 97 deletions(-) diff --git a/packages/@eventual/core-runtime/src/eventual-factory.ts b/packages/@eventual/core-runtime/src/eventual-factory.ts index 24b74f91b..d31050019 100644 --- a/packages/@eventual/core-runtime/src/eventual-factory.ts +++ b/packages/@eventual/core-runtime/src/eventual-factory.ts @@ -28,18 +28,18 @@ export function createEventualFromCall( if (isActivityCall(call)) { return { triggers: [ - Trigger.workflowEvent(WorkflowEventType.ActivitySucceeded, (event) => + Trigger.onWorkflowEvent(WorkflowEventType.ActivitySucceeded, (event) => Result.resolved(event.result) ), - Trigger.workflowEvent(WorkflowEventType.ActivityFailed, (event) => + Trigger.onWorkflowEvent(WorkflowEventType.ActivityFailed, (event) => Result.failed(new EventualError(event.error, event.message)) ), - Trigger.workflowEvent( + Trigger.onWorkflowEvent( WorkflowEventType.ActivityHeartbeatTimedOut, Result.failed(new HeartbeatTimeout("Activity Heartbeat TimedOut")) ), call.timeout - ? Trigger.promise( + ? Trigger.onPromiseResolution( call.timeout, Result.failed(new Timeout("Activity Timed Out")) ) @@ -58,15 +58,17 @@ export function createEventualFromCall( } else if (isWorkflowCall(call)) { return { triggers: [ - Trigger.workflowEvent( + Trigger.onWorkflowEvent( WorkflowEventType.ChildWorkflowSucceeded, (event) => Result.resolved(event.result) ), - Trigger.workflowEvent(WorkflowEventType.ChildWorkflowFailed, (event) => - Result.failed(new EventualError(event.error, event.message)) + Trigger.onWorkflowEvent( + WorkflowEventType.ChildWorkflowFailed, + (event) => + Result.failed(new EventualError(event.error, event.message)) ), call.timeout - ? Trigger.promise( + ? Trigger.onPromiseResolution( call.timeout, Result.failed("Child Workflow Timed Out") ) @@ -84,7 +86,7 @@ export function createEventualFromCall( }; } else if (isAwaitTimerCall(call)) { return { - triggers: Trigger.workflowEvent( + triggers: Trigger.onWorkflowEvent( WorkflowEventType.TimerCompleted, Result.resolved(undefined) ), @@ -112,11 +114,11 @@ export function createEventualFromCall( } else if (isExpectSignalCall(call)) { return { triggers: [ - Trigger.signal(call.signalId, (event) => + Trigger.onSignal(call.signalId, (event) => Result.resolved(event.payload) ), call.timeout - ? Trigger.promise( + ? Trigger.onPromiseResolution( call.timeout, Result.failed(new Timeout("Expect Signal Timed Out")) ) @@ -146,14 +148,14 @@ export function createEventualFromCall( return result ? Result.resolved(result) : undefined; }), call.timeout - ? Trigger.promise(call.timeout, Result.resolved(false)) + ? Trigger.onPromiseResolution(call.timeout, Result.resolved(false)) : undefined, ], }; } } else if (isRegisterSignalHandlerCall(call)) { return { - triggers: Trigger.signal(call.signalId, (event) => { + triggers: Trigger.onSignal(call.signalId, (event) => { call.handler(event.payload); }), }; diff --git a/packages/@eventual/core-runtime/src/workflow-executor.ts b/packages/@eventual/core-runtime/src/workflow-executor.ts index e530b733a..61cd62a61 100644 --- a/packages/@eventual/core-runtime/src/workflow-executor.ts +++ b/packages/@eventual/core-runtime/src/workflow-executor.ts @@ -61,22 +61,36 @@ interface ActiveEventual { seq: number; } +/** + * Provide a hooked and labelled promise for all of the {@link Eventual}s. + * + * Exposes a resolve method which accepts a {@link Result} object. Adds the seq ID to + * allow future identification of EventualPromises. + */ export function createEventualPromise( seq: number, + result?: Result, beforeResolve?: () => void ): RuntimeEventualPromise { - let resolve: (r: R) => void, reject: (reason: any) => void; - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }) as RuntimeEventualPromise; + let resolve: ((r: R) => void) | undefined, + reject: ((reason: any) => void) | undefined; + const promise = ( + isResult(result) + ? isResolved(result) + ? Promise.resolve(result.value) + : Promise.reject(result.error) + : new Promise((_resolve, _reject) => { + resolve = _resolve; + reject = _reject; + }) + ) as RuntimeEventualPromise; promise[EventualPromiseSymbol] = seq; promise.resolve = (result) => { beforeResolve?.(); if (isResolved(result)) { - resolve(result.value); + resolve?.(result.value); } else { - reject(result.error); + reject?.(result.error); } }; return promise; @@ -111,9 +125,9 @@ export class WorkflowExecutor { * All {@link EventualDefinition}s waiting on a signal. */ private activeHandlers: { - events: Record["handler"]>>; - signals: Record["handler"]>>; - afterEveryEvent: Record["afterEvery"]>; + events: EventTriggerLookup; + signals: SignalTriggerLookup; + afterEveryEvent: AfterEveryEventTriggerLookup; } = { signals: {}, events: {}, @@ -137,9 +151,14 @@ export class WorkflowExecutor { /** * When called, resolves the workflow execution with a {@link result}. * - * This will cause the current promise from start or continue to resolve with a the given value. + * This will cause the current promise from Executor.start or Executor.continue to resolve with a the given value. */ resolve: (result?: Result) => void; + /** + * Commands collected during a workflow run (start or continue method). + * + * Cleared and returned when the method's promise resolves. + */ commandsToEmit: WorkflowCommand[]; }; /** @@ -255,7 +274,7 @@ export class WorkflowExecutor { // have access to the workflow hook beforeCommitEvents?.(); // APPLY EVENTS - await this.drainHistoryEvents(); + await this.applyEvents(); // resolve the promise with the current state. this.currentRun?.resolve(); @@ -280,8 +299,6 @@ export class WorkflowExecutor { /** * Returns current result and commands, resetting the command array. - * - * If the workflow has additional expected events to apply, fails the workflow with a determinism error. */ private completeCurrentRun( overrideResult?: Result @@ -298,7 +315,7 @@ export class WorkflowExecutor { } /** - * Provides a scope where the workflowHook is available to the {@link Call}s. + * Provides a scope where the {@link ExecutionWorkflowHook} is available to the {@link Call}s. */ private async enterWorkflowHookScope( callback: (...args: any) => Res @@ -327,9 +344,10 @@ export class WorkflowExecutor { * If the eventual comes with a result, do not active it, it is already resolved! */ if (isResolvedEventualDefinition(eventual)) { - const promise = createEventualPromise(seq); - promise.resolve(eventual.result); - return promise as unknown as E; + return createEventualPromise( + seq, + eventual.result + ) as unknown as E; } return self.activateEventual(seq, eventual) as E; @@ -378,7 +396,7 @@ export class WorkflowExecutor { * Each event will find all of the applicable handlers and let them resolve * before applying the next set of events. */ - private async drainHistoryEvents() { + private async applyEvents() { while (this.events.hasNext() && !this.stopped) { const event = this.events.next()!; this.options?.hooks?.beforeApplyingResultEvent?.(event); @@ -388,7 +406,7 @@ export class WorkflowExecutor { await new Promise((resolve) => { // this is only needed when using async on immediately resolved promises, like send signal process.nextTick(() => { - this.tryCommitResultEvent(event); + this.tryApplyEvent(event); resolve(undefined); }); }); @@ -404,13 +422,13 @@ export class WorkflowExecutor { * 4. if the resolved eventuals have any dependents, try resolve them too until the queue is drained. * note: dependents are those eventuals while have declared other eventuals they care about. */ - private tryCommitResultEvent(event: HistoryResultEvent) { + private tryApplyEvent(event: HistoryResultEvent) { if (isWorkflowTimedOut(event)) { return this.resolveWorkflow( Result.failed(new WorkflowTimeout("Workflow timed out")) ); } else if (!isWorkflowRunStarted(event)) { - for (const { seq, handler, args } of this.getHandlerForEvent(event)) { + for (const { seq, handler, args } of this.getHandlersForEvent(event)) { // stop calling handler if the workflow is stopped // this should only happen on a SystemError if (!this.invokeEventualHandler(seq, handler, ...args)) { @@ -422,7 +440,7 @@ export class WorkflowExecutor { private invokeEventualHandler( seq: number | string, - handler: EventualHandler, + handler: TriggerHandler, ...args: Args ) { if (this.stopped) { @@ -441,14 +459,14 @@ export class WorkflowExecutor { return true; } - private *getHandlerForEvent( + private *getHandlersForEvent( event: Exclude - ): Generator, void, undefined> { + ): Generator, void, undefined> { if (isSignalReceived(event)) { const signalHandlers = this.activeHandlers.signals[event.signalId] ?? {}; - yield* Object.entries(signalHandlers).map(([seq, handler]) => ({ + yield* Object.entries(signalHandlers).map(([seq, trigger]) => ({ seq, - handler, + handler: trigger.handler, args: [event], })); } else { @@ -461,14 +479,14 @@ export class WorkflowExecutor { ) ); } - const eventHandler = this.activeHandlers.events[event.seq]?.[event.type]; - if (eventHandler) { - yield { seq: event.seq, handler: eventHandler, args: [event] }; + const eventTrigger = this.activeHandlers.events[event.seq]?.[event.type]; + if (eventTrigger) { + yield { seq: event.seq, handler: eventTrigger.handler, args: [event] }; } } // resolve any eventuals should be triggered on each new event yield* Object.entries(this.activeHandlers.afterEveryEvent).map( - ([seq, handler]) => ({ seq, handler, args: [] }) + ([seq, trigger]) => ({ seq, handler: trigger.afterEvery, args: [] }) ); } @@ -503,10 +521,8 @@ export class WorkflowExecutor { seq: number, eventual: UnresolvedEventualDefinition ): EventualPromise { - /** - * The promise that represents - */ - const promise = createEventualPromise(seq, () => + // The promise that represents the lifetime of the Eventual. + const promise = createEventualPromise(seq, undefined, () => // ensure the eventual is deactivated when resolved this.deactivateEventual(seq) ); @@ -532,7 +548,7 @@ export class WorkflowExecutor { this.activeHandlers.events[seq] = Object.fromEntries( workflowEventTriggers.map((eventTrigger) => [ eventTrigger.eventType, - eventTrigger.handler, + eventTrigger, ]) ); } @@ -572,7 +588,7 @@ export class WorkflowExecutor { const signalTriggers = triggers.filter(isSignalTrigger); signalTriggers.forEach((signalTrigger) => { (this.activeHandlers.signals[signalTrigger.signalId] ??= {})[seq] = - signalTrigger.handler; + signalTrigger; }); // maintain a reference to the signals this eventual is listening for // in order to effectively remove the handlers later. @@ -583,7 +599,7 @@ export class WorkflowExecutor { */ const [afterEventHandler] = triggers.filter(isAfterEveryEventTrigger); if (afterEventHandler) { - this.activeHandlers.afterEveryEvent[seq] = afterEventHandler.afterEvery; + this.activeHandlers.afterEveryEvent[seq] = afterEventHandler; } return activeEventual.promise; @@ -611,8 +627,8 @@ export class WorkflowExecutor { } } -function normalizeToArray(items?: T | T[]): T[] { - return items ? (Array.isArray(items) ? items : [items]) : []; +function normalizeToArray(items: T | T[]): T[] { + return Array.isArray(items) ? items : [items]; } export interface WorkflowResult { @@ -628,42 +644,42 @@ export interface WorkflowResult { commands: WorkflowCommand[]; } -export type Trigger = - | PromiseTrigger - | EventTrigger - | AfterEveryEventTrigger - | SignalTrigger; +export type Trigger = + | PromiseTrigger + | EventTrigger + | AfterEveryEventTrigger + | SignalTrigger; export const Trigger = { - promise: ( - promise: Promise, - handler: PromiseTrigger["handler"] - ): PromiseTrigger => { + onPromiseResolution: ( + promise: Promise, + handler: PromiseTrigger["handler"] + ): PromiseTrigger => { return { promise, handler, }; }, - afterEveryEvent: ( - handler: AfterEveryEventTrigger["afterEvery"] - ): AfterEveryEventTrigger => { + afterEveryEvent: ( + handler: AfterEveryEventTrigger["afterEvery"] + ): AfterEveryEventTrigger => { return { afterEvery: handler, }; }, - workflowEvent: ( + onWorkflowEvent: ( eventType: T, - handler: EventTrigger["handler"] - ): EventTrigger => { + handler: EventTrigger["handler"] + ): EventTrigger => { return { eventType, handler, }; }, - signal: ( + onSignal: ( signalId: Signal["id"], - handler: SignalTrigger["handler"] - ): SignalTrigger => { + handler: SignalTrigger["handler"] + ): SignalTrigger => { return { signalId, handler, @@ -671,55 +687,55 @@ export const Trigger = { }, }; -type EventualHandler = +type TriggerHandler = | { - (...args: Args): void | undefined | Result; + (...args: Args): void | undefined | Result; } - | Result; + | Result; -export interface PromiseTrigger { - promise: Promise; - handler: EventualHandler<[val: Result], OwnRes>; +export interface PromiseTrigger { + promise: Promise; + handler: TriggerHandler<[val: Result], Output>; } -export interface AfterEveryEventTrigger { - afterEvery: EventualHandler<[], OwnRes>; +export interface AfterEveryEventTrigger { + afterEvery: TriggerHandler<[], Output>; } export interface EventTrigger< - out OwnRes = any, + out Output = any, E extends HistoryResultEvent = any > { eventType: E["type"]; - handler: EventualHandler<[event: E], OwnRes>; + handler: TriggerHandler<[event: E], Output>; } -export interface SignalTrigger { +export interface SignalTrigger { signalId: Signal["id"]; - handler: EventualHandler<[event: SignalReceived], OwnRes>; + handler: TriggerHandler<[event: SignalReceived], Output>; } -export function isPromiseTrigger( - t: Trigger -): t is PromiseTrigger { +export function isPromiseTrigger( + t: Trigger +): t is PromiseTrigger { return "promise" in t; } -export function isAfterEveryEventTrigger( - t: Trigger -): t is AfterEveryEventTrigger { +export function isAfterEveryEventTrigger( + t: Trigger +): t is AfterEveryEventTrigger { return "afterEvery" in t; } -export function isEventTrigger( - t: Trigger -): t is EventTrigger { +export function isEventTrigger( + t: Trigger +): t is EventTrigger { return "eventType" in t; } -export function isSignalTrigger( - t: Trigger -): t is SignalTrigger { +export function isSignalTrigger( + t: Trigger +): t is SignalTrigger { return "signalId" in t; } @@ -761,8 +777,15 @@ export function isResolvedEventualDefinition( return "result" in eventualDefinition; } -interface EventualHandlerRef { +interface TriggerHandlerRef { seq: number | string; - handler: EventualHandler; + handler: TriggerHandler; args: Args; } + +interface EventTriggerLookup + extends Record>> {} +interface SignalTriggerLookup + extends Record>> {} +interface AfterEveryEventTriggerLookup + extends Record> {}