From f7a34e4e9e933fcd5913974a7d9a10321b1f3e21 Mon Sep 17 00:00:00 2001 From: David Justo Date: Thu, 6 Jan 2022 15:33:18 -0800 Subject: [PATCH 1/9] Enable linear-time replay --- .vscode/settings.json | 6 +- package.json | 4 +- src/actions/actiontype.ts | 2 + src/actions/createtimeraction.ts | 2 +- src/actions/whenallaction.ts | 12 + src/actions/whenanyaction.ts | 12 + src/aggregatederror.ts | 2 +- src/classes.ts | 7 - src/durableorchestrationbindinginfo.ts | 14 +- src/durableorchestrationcontext.ts | 729 ++----------- src/entities/responsemessage.ts | 19 +- src/extensionSchema.ts | 5 + src/iorchestratorstate.ts | 3 + src/orchestrator.ts | 228 +--- src/orchestratorstate.ts | 41 +- src/replaySchema.ts | 17 + src/taskorchestrationexecutor.ts | 493 +++++++++ src/tasks/externalTasks.ts | 103 ++ src/tasks/internalTasks.ts | 369 +++++++ src/tasks/itaskmethods.ts | 21 - src/tasks/task.ts | 103 -- src/tasks/taskfactory.ts | 84 -- src/tasks/taskfilter.ts | 56 - src/tasks/taskinterfaces.ts | 36 - src/tasks/taskset.ts | 21 - src/tasks/timertask.ts | 77 -- src/testingUtils.ts | 4 +- src/utils.ts | 13 + test/integration/orchestrator-spec.ts | 1339 +++++++++++++----------- test/testobjects/TestOrchestrations.ts | 1 + test/unit/timertask-spec.ts | 38 +- 31 files changed, 1961 insertions(+), 1900 deletions(-) create mode 100644 src/actions/whenallaction.ts create mode 100644 src/actions/whenanyaction.ts create mode 100644 src/extensionSchema.ts create mode 100644 src/replaySchema.ts create mode 100644 src/taskorchestrationexecutor.ts create mode 100644 src/tasks/externalTasks.ts create mode 100644 src/tasks/internalTasks.ts delete mode 100644 src/tasks/itaskmethods.ts delete mode 100644 src/tasks/task.ts delete mode 100644 src/tasks/taskfactory.ts delete mode 100644 src/tasks/taskfilter.ts delete mode 100644 src/tasks/taskinterfaces.ts delete mode 100644 src/tasks/taskset.ts delete mode 100644 src/tasks/timertask.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 7062f21..996ca0a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,7 @@ "azureFunctions.projectLanguage": "TypeScript", "azureFunctions.projectRuntime": "~3", "debug.internalConsoleOptions": "neverOpen", - "azureFunctions.preDeployTask": "npm prune" -} \ No newline at end of file + "azureFunctions.preDeployTask": "npm prune", + "mochaExplorer.require": "ts-node/register", + "mochaExplorer.files": "test/**/*.ts" +} diff --git a/package.json b/package.json index b4293b3..67bb91b 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "validate:samples": "npm run build && npm --prefix samples install && npm run build:samples", "build:nolint": "npm run clean && npm run stripInternalDocs && echo Done", "stripInternalDocs": "tsc --pretty -p tsconfig.nocomments", - "test": "npm run validate:samples && npm run build && mocha --recursive ./lib/test/**/*-spec.js", + "test": "npm run build && mocha --recursive ./lib/test/**/*-spec.js", "test:nolint": "npm run build:nolint && mocha --recursive ./lib/test/**/*-spec.js", "watch": "tsc --watch", "watch:test": "npm run test -- --watch", @@ -58,7 +58,7 @@ "@types/chai-string": "~1.4.1", "@types/commander": "~2.3.31", "@types/debug": "0.0.29", - "@types/mocha": "~7.0.2", + "@types/mocha": "^7.0.2", "@types/nock": "^9.3.0", "@types/rimraf": "0.0.28", "@types/sinon": "~5.0.5", diff --git a/src/actions/actiontype.ts b/src/actions/actiontype.ts index 239c996..cda2238 100644 --- a/src/actions/actiontype.ts +++ b/src/actions/actiontype.ts @@ -17,4 +17,6 @@ export enum ActionType { WaitForExternalEvent = 6, CallEntity = 7, CallHttp = 8, + WhenAny = 11, + WhenAll = 12, } diff --git a/src/actions/createtimeraction.ts b/src/actions/createtimeraction.ts index 026b6f9..e1aa000 100644 --- a/src/actions/createtimeraction.ts +++ b/src/actions/createtimeraction.ts @@ -5,7 +5,7 @@ import { ActionType, IAction } from "../classes"; export class CreateTimerAction implements IAction { public readonly actionType: ActionType = ActionType.CreateTimer; - constructor(public readonly fireAt: Date, public isCanceled: boolean = false) { + constructor(public readonly fireAt: Date, public isCancelled: boolean = false) { if (!isDate(fireAt)) { throw new TypeError(`fireAt: Expected valid Date object but got ${fireAt}`); } diff --git a/src/actions/whenallaction.ts b/src/actions/whenallaction.ts new file mode 100644 index 0000000..99c4a62 --- /dev/null +++ b/src/actions/whenallaction.ts @@ -0,0 +1,12 @@ +import { ActionType, IAction } from "../classes"; +import { ProperTask } from "../tasks/internalTasks"; + +/** @hidden */ +export class WhenAllAction implements IAction { + public readonly actionType: ActionType = ActionType.WhenAll; + public readonly compoundActions: IAction[]; + + constructor(tasks: ProperTask[]) { + this.compoundActions = tasks.map((t) => t.actionObj); + } +} diff --git a/src/actions/whenanyaction.ts b/src/actions/whenanyaction.ts new file mode 100644 index 0000000..7b356fe --- /dev/null +++ b/src/actions/whenanyaction.ts @@ -0,0 +1,12 @@ +import { ActionType, IAction } from "../classes"; +import { ProperTask } from "../tasks/internalTasks"; + +/** @hidden */ +export class WhenAnyAction implements IAction { + public readonly actionType: ActionType = ActionType.WhenAll; + public readonly compoundActions: IAction[]; + + constructor(tasks: ProperTask[]) { + this.compoundActions = tasks.map((t) => t.actionObj); + } +} diff --git a/src/aggregatederror.ts b/src/aggregatederror.ts index ad0e92c..28c7d2d 100644 --- a/src/aggregatederror.ts +++ b/src/aggregatederror.ts @@ -2,7 +2,7 @@ const separator = "-----------------------------------"; /** - * A specfic error thrown when context.df.Task.all() fails. Its message + * A specific error thrown when context.df.Task.all() fails. Its message * contains an aggregation of all the exceptions that failed. It should follow the * below format: * diff --git a/src/classes.ts b/src/classes.ts index 62ff408..848fb9c 100644 --- a/src/classes.ts +++ b/src/classes.ts @@ -42,13 +42,6 @@ export { TaskScheduledEvent } from "./history/taskscheduledevent"; export { TimerCreatedEvent } from "./history/timercreatedevent"; export { TimerFiredEvent } from "./history/timerfiredevent"; -export { ITaskMethods } from "./tasks/itaskmethods"; -export { Task } from "./tasks/task"; -export { TaskFactory } from "./tasks/taskfactory"; -export { TaskFilter } from "./tasks/taskfilter"; -export { TaskSet } from "./tasks/taskset"; -export { TimerTask } from "./tasks/timertask"; - export { OrchestratorState } from "./orchestratorstate"; export { IOrchestratorState } from "./iorchestratorstate"; diff --git a/src/durableorchestrationbindinginfo.ts b/src/durableorchestrationbindinginfo.ts index 6f03980..79fd8c7 100644 --- a/src/durableorchestrationbindinginfo.ts +++ b/src/durableorchestrationbindinginfo.ts @@ -1,12 +1,22 @@ import { HistoryEvent } from "./classes"; +import { UpperSchemaVersion } from "./replaySchema"; /** @hidden */ export class DurableOrchestrationBindingInfo { + public readonly upperSchemaVersion: UpperSchemaVersion; + constructor( public readonly history: HistoryEvent[] = [], public readonly input?: unknown, public readonly instanceId: string = "", public readonly isReplaying: boolean = false, - public readonly parentInstanceId?: string // TODO: Implement entity locking // public readonly contextLocks?: EntityId[], - ) {} + public readonly parentInstanceId?: string, + upperSchemaVersion = 0 // TODO: Implement entity locking // public readonly contextLocks?: EntityId[], + ) { + if (Object.values(UpperSchemaVersion).includes(upperSchemaVersion)) { + this.upperSchemaVersion = upperSchemaVersion; + } else { + this.upperSchemaVersion = UpperSchemaVersion.V1; + } + } } diff --git a/src/durableorchestrationcontext.ts b/src/durableorchestrationcontext.ts index 8a0eb08..39e7427 100644 --- a/src/durableorchestrationcontext.ts +++ b/src/durableorchestrationcontext.ts @@ -1,12 +1,7 @@ -import { AggregatedError } from "./aggregatederror"; import { TokenSource } from "./tokensource"; -import { DurableError } from "./durableerror"; import { EntityId, - ITaskMethods, RetryOptions, - Task, - TimerTask, CallActivityAction, CallActivityWithRetryAction, CallEntityAction, @@ -16,27 +11,22 @@ import { ContinueAsNewAction, CreateTimerAction, DurableHttpRequest, - EventRaisedEvent, - EventSentEvent, ExternalEventType, GuidManager, HistoryEvent, - HistoryEventType, - RequestMessage, - ResponseMessage, - SubOrchestrationInstanceCompletedEvent, - SubOrchestrationInstanceCreatedEvent, - SubOrchestrationInstanceFailedEvent, - TaskCompletedEvent, - TaskFactory, - TaskFailedEvent, - TaskFilter, - TaskScheduledEvent, - TimerCreatedEvent, - TimerFiredEvent, WaitForExternalEventAction, } from "./classes"; -import { CompletedTask, TaskBase } from "./tasks/taskinterfaces"; +import { TaskOrchestrationExecutor } from "./taskorchestrationexecutor"; +import { WhenAllAction } from "./actions/whenallaction"; +import { WhenAnyAction } from "./actions/whenanyaction"; +import { + WhenAllTask, + WhenAnyTask, + AtomicTask, + RetryAbleTask, + InnerTimerTask, +} from "./tasks/internalTasks"; +import { Task, TimerTask } from "./tasks/externalTasks"; /** * Parameter data for orchestration bindings that can be used to schedule @@ -49,7 +39,8 @@ export class DurableOrchestrationContext { currentUtcDateTime: Date, isReplaying: boolean, parentInstanceId: string | undefined, - input: unknown + input: unknown, + private taskOrchestratorExecutor: TaskOrchestrationExecutor ) { this.state = state; this.instanceId = instanceId; @@ -57,15 +48,12 @@ export class DurableOrchestrationContext { this.currentUtcDateTime = currentUtcDateTime; this.parentInstanceId = parentInstanceId; this.input = input; - this.newGuidCounter = 0; - this.subOrchestratorCounter = 0; } private input: unknown; private readonly state: HistoryEvent[]; private newGuidCounter: number; - private subOrchestratorCounter: number; public customStatus: unknown; /** @@ -116,69 +104,21 @@ export class DurableOrchestrationContext { * Methods to handle collections of pending actions represented by [[Task]] * instances. For use in parallelization operations. */ - public Task: ITaskMethods = { - all: (tasks: TaskBase[]) => { - let maxCompletionIndex: number | undefined; - const errors: Error[] = []; - const results: Array = []; - for (const task of tasks) { - if (!TaskFilter.isCompletedTask(task)) { - return TaskFactory.UncompletedTaskSet(tasks); - } - - if (!maxCompletionIndex) { - maxCompletionIndex = task.completionIndex; - } else if (maxCompletionIndex < task.completionIndex) { - maxCompletionIndex = task.completionIndex; - } - - if (TaskFilter.isFailedTask(task)) { - errors.push(task.exception); - } else { - results.push(task.result); - } - } - - // We are guaranteed that maxCompletionIndex is not undefined, or - // we would have alreayd returned an uncompleted task set. - const completionIndex = maxCompletionIndex as number; - - if (errors.length > 0) { - return TaskFactory.FailedTaskSet( - tasks, - completionIndex, - new AggregatedError(errors) - ); - } else { - return TaskFactory.SuccessfulTaskSet(tasks, completionIndex, results); - } + public Task = { + all: (tasks: Task[]): Task => { + const internalTasks = tasks.map((t) => t.internalTask); + const action = new WhenAllAction(internalTasks); + const innerTask = new WhenAllTask(internalTasks, action); + const task = new Task(innerTask); + return task; }, - any: (tasks: Task[]) => { - if (!tasks || tasks.length === 0) { - throw new Error("At least one yieldable task must be provided to wait for."); - } - - let firstCompleted: CompletedTask | undefined; - for (const task of tasks) { - if (TaskFilter.isCompletedTask(task)) { - if (!firstCompleted) { - firstCompleted = task; - } else if (task.completionIndex < firstCompleted.completionIndex) { - firstCompleted = task; - } - } - } - - if (firstCompleted) { - return TaskFactory.SuccessfulTaskSet( - tasks, - firstCompleted.completionIndex, - firstCompleted - ); - } else { - return TaskFactory.UncompletedTaskSet(tasks); - } + any: (tasks: Task[]): Task => { + const internalTasks = tasks.map((t) => t.internalTask); + const action = new WhenAnyAction(internalTasks); + const innerTask = new WhenAnyTask(internalTasks, action); + const task = new Task(innerTask); + return task; }, }; @@ -193,34 +133,9 @@ export class DurableOrchestrationContext { */ public callActivity(name: string, input?: unknown): Task { const newAction = new CallActivityAction(name, input); - - const taskScheduled = this.findTaskScheduled(this.state, name); - const taskCompleted = this.findTaskCompleted(this.state, taskScheduled); - const taskFailed = this.findTaskFailed(this.state, taskScheduled); - this.setProcessed([taskScheduled, taskCompleted, taskFailed]); - - if (taskCompleted) { - const result = this.parseHistoryEvent(taskCompleted); - - return TaskFactory.SuccessfulTask( - newAction, - result, - taskCompleted.Timestamp, - taskCompleted.TaskScheduledId, - this.state.indexOf(taskCompleted) - ); - } else if (taskFailed) { - return TaskFactory.FailedTask( - newAction, - taskFailed.Reason, - taskFailed.Timestamp, - taskFailed.TaskScheduledId, - this.state.indexOf(taskFailed), - new DurableError(taskFailed.Reason) - ); - } else { - return TaskFactory.UncompletedTask(newAction); - } + const innerTask = new AtomicTask(false, newAction); + const task = new Task(innerTask); + return task; } /** @@ -234,95 +149,14 @@ export class DurableOrchestrationContext { */ public callActivityWithRetry(name: string, retryOptions: RetryOptions, input?: unknown): Task { const newAction = new CallActivityWithRetryAction(name, retryOptions, input); - - let attempt = 1; - let taskScheduled: TaskScheduledEvent | undefined; - let taskFailed: TaskFailedEvent | undefined; - let taskRetryTimer: TimerCreatedEvent | undefined; - for (let i = 0; i < this.state.length; i++) { - const historyEvent = this.state[i]; - if (historyEvent.IsProcessed) { - continue; - } - - if (!taskScheduled) { - if (historyEvent.EventType === HistoryEventType.TaskScheduled) { - if ((historyEvent as TaskScheduledEvent).Name === name) { - taskScheduled = historyEvent as TaskScheduledEvent; - } - } - continue; - } - - if (historyEvent.EventType === HistoryEventType.TaskCompleted) { - if ( - (historyEvent as TaskCompletedEvent).TaskScheduledId === taskScheduled.EventId - ) { - const taskCompleted = historyEvent as TaskCompletedEvent; - this.setProcessed([taskScheduled, taskCompleted]); - const result = this.parseHistoryEvent(taskCompleted); - return TaskFactory.SuccessfulTask( - newAction, - result, - taskCompleted.Timestamp, - taskCompleted.TaskScheduledId, - i - ); - } else { - continue; - } - } - - if (!taskFailed) { - if (historyEvent.EventType === HistoryEventType.TaskFailed) { - if ( - (historyEvent as TaskFailedEvent).TaskScheduledId === taskScheduled.EventId - ) { - taskFailed = historyEvent as TaskFailedEvent; - } - } - continue; - } - - if (!taskRetryTimer) { - if (historyEvent.EventType === HistoryEventType.TimerCreated) { - taskRetryTimer = historyEvent as TimerCreatedEvent; - } else { - continue; - } - } - - if (historyEvent.EventType === HistoryEventType.TimerFired) { - if ((historyEvent as TimerFiredEvent).TimerId === taskRetryTimer.EventId) { - const taskRetryTimerFired = historyEvent as TimerFiredEvent; - this.setProcessed([ - taskScheduled, - taskFailed, - taskRetryTimer, - taskRetryTimerFired, - ]); - if (attempt >= retryOptions.maxNumberOfAttempts) { - return TaskFactory.FailedTask( - newAction, - taskFailed.Reason, - taskFailed.Timestamp, - taskFailed.TaskScheduledId, - i, - new DurableError(taskFailed.Reason) - ); - } else { - attempt++; - taskScheduled = undefined; - taskFailed = undefined; - taskRetryTimer = undefined; - } - } else { - continue; - } - } - } - - return TaskFactory.UncompletedTask(newAction); + const backingTask = new AtomicTask(false, newAction); + const innerTask = new RetryAbleTask( + backingTask, + retryOptions, + this.taskOrchestratorExecutor + ); + const task = new Task(innerTask); + return task; // We'll have to change the interface } /** @@ -335,36 +169,9 @@ export class DurableOrchestrationContext { */ public callEntity(entityId: EntityId, operationName: string, operationInput?: unknown): Task { const newAction = new CallEntityAction(entityId, operationName, operationInput); - - const schedulerId = EntityId.getSchedulerIdFromEntityId(entityId); - const eventSent = this.findEventSent(this.state, schedulerId, "op"); - let eventRaised; - if (eventSent) { - const eventSentInput = - eventSent && eventSent.Input - ? (JSON.parse(eventSent.Input) as RequestMessage) - : undefined; - eventRaised = eventSentInput - ? this.findEventRaised(this.state, eventSentInput.id) - : undefined; - } - this.setProcessed([eventSent, eventRaised]); - - if (eventRaised) { - const parsedResult = this.parseHistoryEvent(eventRaised) as ResponseMessage; - - return TaskFactory.SuccessfulTask( - newAction, - JSON.parse(parsedResult.result), - eventRaised.Timestamp, - eventSent.EventId, - this.state.indexOf(eventRaised) - ); - } - - // TODO: error handling - - return TaskFactory.UncompletedTask(newAction); + const innerTask = new AtomicTask(false, newAction); + const task = new Task(innerTask); + return task; } /** @@ -385,48 +192,9 @@ export class DurableOrchestrationContext { } const newAction = new CallSubOrchestratorAction(name, instanceId, input); - const subOrchestratorCreated = this.findSubOrchestrationInstanceCreated( - this.state, - name, - instanceId - ); - const subOrchestratorCompleted = this.findSubOrchestrationInstanceCompleted( - this.state, - subOrchestratorCreated - ); - const subOrchestratorFailed = this.findSubOrchestrationInstanceFailed( - this.state, - subOrchestratorCreated - ); - - this.setProcessed([ - subOrchestratorCreated, - subOrchestratorCompleted, - subOrchestratorFailed, - ]); - - if (subOrchestratorCompleted) { - const result = this.parseHistoryEvent(subOrchestratorCompleted); - - return TaskFactory.SuccessfulTask( - newAction, - result, - subOrchestratorCompleted.Timestamp, - subOrchestratorCompleted.TaskScheduledId, - this.state.indexOf(subOrchestratorCompleted) - ); - } else if (subOrchestratorFailed) { - return TaskFactory.FailedTask( - newAction, - subOrchestratorFailed.Reason, - subOrchestratorFailed.Timestamp, - subOrchestratorFailed.TaskScheduledId, - this.state.indexOf(subOrchestratorFailed), - new DurableError(subOrchestratorFailed.Reason) - ); - } else { - return TaskFactory.UncompletedTask(newAction); - } + const innerTask = new AtomicTask(false, newAction); + const task = new Task(innerTask); + return task; } /** @@ -457,100 +225,14 @@ export class DurableOrchestrationContext { input, instanceId ); - - let attempt = 1; - let subOrchestratorCreated: SubOrchestrationInstanceCreatedEvent | undefined; - let subOrchestratorFailed: SubOrchestrationInstanceFailedEvent | undefined; - let taskRetryTimer: TimerCreatedEvent | undefined; - for (let i = 0; i < this.state.length; i++) { - const historyEvent = this.state[i]; - if (historyEvent.IsProcessed) { - continue; - } - - if (!subOrchestratorCreated) { - if (historyEvent.EventType === HistoryEventType.SubOrchestrationInstanceCreated) { - const subOrchEvent = historyEvent as SubOrchestrationInstanceCreatedEvent; - if ( - subOrchEvent.Name === name && - (!instanceId || instanceId === subOrchEvent.InstanceId) - ) { - subOrchestratorCreated = subOrchEvent; - } - } - continue; - } - - if (historyEvent.EventType === HistoryEventType.SubOrchestrationInstanceCompleted) { - if ( - (historyEvent as SubOrchestrationInstanceCompletedEvent).TaskScheduledId === - subOrchestratorCreated.EventId - ) { - const subOrchCompleted = historyEvent as SubOrchestrationInstanceCompletedEvent; - this.setProcessed([subOrchestratorCreated, subOrchCompleted]); - const result = this.parseHistoryEvent(subOrchCompleted); - return TaskFactory.SuccessfulTask( - newAction, - result, - subOrchCompleted.Timestamp, - subOrchCompleted.TaskScheduledId, - i - ); - } else { - continue; - } - } - - if (!subOrchestratorFailed) { - if (historyEvent.EventType === HistoryEventType.SubOrchestrationInstanceFailed) { - if ( - (historyEvent as SubOrchestrationInstanceFailedEvent).TaskScheduledId === - subOrchestratorCreated.EventId - ) { - subOrchestratorFailed = historyEvent as SubOrchestrationInstanceFailedEvent; - } - } - continue; - } - - if (!taskRetryTimer) { - if (historyEvent.EventType === HistoryEventType.TimerCreated) { - taskRetryTimer = historyEvent as TimerCreatedEvent; - } - continue; - } - - if (historyEvent.EventType === HistoryEventType.TimerFired) { - if ((historyEvent as TimerFiredEvent).TimerId === taskRetryTimer.EventId) { - const taskRetryTimerFired = historyEvent as TimerFiredEvent; - this.setProcessed([ - subOrchestratorCreated, - subOrchestratorFailed, - taskRetryTimer, - taskRetryTimerFired, - ]); - if (attempt >= retryOptions.maxNumberOfAttempts) { - return TaskFactory.FailedTask( - newAction, - subOrchestratorFailed.Reason, - subOrchestratorFailed.Timestamp, - subOrchestratorFailed.TaskScheduledId, - i, - new DurableError(subOrchestratorFailed.Reason) - ); - } else { - attempt += 1; - subOrchestratorCreated = undefined; - subOrchestratorFailed = undefined; - taskRetryTimer = undefined; - } - } else { - continue; - } - } - } - - return TaskFactory.UncompletedTask(newAction); + const backingTask = new AtomicTask(false, newAction); + const innerTask = new RetryAbleTask( + backingTask, + retryOptions, + this.taskOrchestratorExecutor + ); + const task = new Task(innerTask); + return task; } /** @@ -571,35 +253,9 @@ export class DurableOrchestrationContext { const req = new DurableHttpRequest(method, uri, content as string, headers, tokenSource); const newAction = new CallHttpAction(req); - - // callHttp is internally implemented as a well-known activity function - const httpScheduled = this.findTaskScheduled(this.state, "BuiltIn::HttpActivity"); - const httpCompleted = this.findTaskCompleted(this.state, httpScheduled); - const httpFailed = this.findTaskFailed(this.state, httpScheduled); - this.setProcessed([httpScheduled, httpCompleted, httpFailed]); - - if (httpCompleted) { - const result = this.parseHistoryEvent(httpCompleted); - - return TaskFactory.SuccessfulTask( - newAction, - result, - httpCompleted.Timestamp, - httpCompleted.TaskScheduledId, - this.state.indexOf(httpCompleted) - ); - } else if (httpFailed) { - return TaskFactory.FailedTask( - newAction, - httpFailed.Reason, - httpFailed.Timestamp, - httpFailed.TaskScheduledId, - this.state.indexOf(httpFailed), - new DurableError(httpFailed.Reason) - ); - } else { - return TaskFactory.UncompletedTask(newAction); - } + const innerTask = new AtomicTask(false, newAction); + const task = new Task(innerTask); + return task; } /** @@ -607,10 +263,12 @@ export class DurableOrchestrationContext { * * @param The JSON-serializable data to re-initialize the instance with. */ - public continueAsNew(input: unknown): Task { + public continueAsNew(input: unknown): void { const newAction = new ContinueAsNewAction(input); - - return TaskFactory.UncompletedTask(newAction); + if (this.taskOrchestratorExecutor !== undefined) { + this.taskOrchestratorExecutor.addToActions(newAction); + this.taskOrchestratorExecutor.willContinueAsNew = true; + } } /** @@ -629,21 +287,9 @@ export class DurableOrchestrationContext { */ public createTimer(fireAt: Date): TimerTask { const newAction = new CreateTimerAction(fireAt); - - const timerCreated = this.findTimerCreated(this.state, fireAt); - const timerFired = this.findTimerFired(this.state, timerCreated); - this.setProcessed([timerCreated, timerFired]); - - if (timerFired) { - return TaskFactory.CompletedTimerTask( - newAction, - timerFired.Timestamp, - timerFired.TimerId, - this.state.indexOf(timerFired) - ); - } else { - return TaskFactory.UncompletedTimerTask(newAction); - } + const innerTask = new InnerTimerTask(false, newAction); + const task = new TimerTask(innerTask); + return task; } /** @@ -697,253 +343,8 @@ export class DurableOrchestrationContext { */ public waitForExternalEvent(name: string): Task { const newAction = new WaitForExternalEventAction(name, ExternalEventType.ExternalEvent); - - const eventRaised = this.findEventRaised(this.state, name); - this.setProcessed([eventRaised]); - - if (eventRaised) { - const result = this.parseHistoryEvent(eventRaised); - - return TaskFactory.SuccessfulTask( - newAction, - result, - eventRaised.Timestamp, - eventRaised.EventId, - this.state.indexOf(eventRaised) - ); - } else { - return TaskFactory.UncompletedTask(newAction); - } - } - - // =============== - /* Returns undefined if not found. */ - private findEventRaised(state: HistoryEvent[], eventName: string): EventRaisedEvent { - const returnValue = eventName - ? state.filter((val: HistoryEvent) => { - return ( - val.EventType === HistoryEventType.EventRaised && - (val as EventRaisedEvent).Name === eventName && - !val.IsProcessed - ); - })[0] - : undefined; - return returnValue as EventRaisedEvent; - } - - /* Returns undefined if not found. */ - private findEventSent( - state: HistoryEvent[], - instanceId: string, - eventName: string - ): EventSentEvent { - const returnValue = eventName - ? state.filter((val: HistoryEvent) => { - return ( - val.EventType === HistoryEventType.EventSent && - (val as EventSentEvent).InstanceId === instanceId && - (val as EventSentEvent).Name === eventName && - !val.IsProcessed - ); - })[0] - : undefined; - return returnValue as EventSentEvent; - } - - /* Returns undefined if not found. */ - private findSubOrchestrationInstanceCreated( - state: HistoryEvent[], - name: string, - instanceId: string | undefined - ): SubOrchestrationInstanceCreatedEvent | undefined { - const matches = state.filter((val: HistoryEvent) => { - return ( - val.EventType === HistoryEventType.SubOrchestrationInstanceCreated && - !val.IsProcessed - ); - }); - - if (matches.length === 0) { - return undefined; - } - - this.subOrchestratorCounter++; - - // Grab the first unprocessed sub orchestration creation event and verify that - // it matches the same function name and instance id if provided. If not, we know that - // we have nondeterministic behavior, because the callSubOrchestrator*() methods were not - // called in the same order this replay that they were scheduled in. - const returnValue = matches[0] as SubOrchestrationInstanceCreatedEvent; - if (returnValue.Name !== name) { - throw new Error( - `The sub-orchestration call (n = ${this.subOrchestratorCounter}) should be executed with a function name of ${returnValue.Name} instead of the provided function name of ${name}. Check your code for non-deterministic behavior.` - ); - } - - if (instanceId && returnValue.InstanceId !== instanceId) { - throw new Error( - `The sub-orchestration call (n = ${this.subOrchestratorCounter}) should be executed with an instance id of ${returnValue.InstanceId} instead of the provided instance id of ${instanceId}. Check your code for non-deterministic behavior.` - ); - } - return returnValue; - } - - /* Returns undefined if not found. */ - private findSubOrchestrationInstanceCompleted( - state: HistoryEvent[], - createdSubOrch: SubOrchestrationInstanceCreatedEvent | undefined - ): SubOrchestrationInstanceCompletedEvent | undefined { - if (createdSubOrch === undefined) { - return undefined; - } - - const matches = state.filter((val: HistoryEvent) => { - return ( - val.EventType === HistoryEventType.SubOrchestrationInstanceCompleted && - (val as SubOrchestrationInstanceCompletedEvent).TaskScheduledId === - createdSubOrch.EventId && - !val.IsProcessed - ); - }); - - return matches.length > 0 - ? (matches[0] as SubOrchestrationInstanceCompletedEvent) - : undefined; - } - - /* Returns undefined if not found. */ - private findSubOrchestrationInstanceFailed( - state: HistoryEvent[], - createdSubOrchInstance: SubOrchestrationInstanceCreatedEvent | undefined - ): SubOrchestrationInstanceFailedEvent | undefined { - if (createdSubOrchInstance === undefined) { - return undefined; - } - - const matches = state.filter((val: HistoryEvent) => { - return ( - val.EventType === HistoryEventType.SubOrchestrationInstanceFailed && - (val as SubOrchestrationInstanceFailedEvent).TaskScheduledId === - createdSubOrchInstance.EventId && - !val.IsProcessed - ); - }); - - return matches.length > 0 ? (matches[0] as SubOrchestrationInstanceFailedEvent) : undefined; - } - - /* Returns undefined if not found. */ - private findTaskScheduled(state: HistoryEvent[], name: string): TaskScheduledEvent | undefined { - const returnValue = name - ? (state.filter((val: HistoryEvent) => { - return ( - val.EventType === HistoryEventType.TaskScheduled && - (val as TaskScheduledEvent).Name === name && - !val.IsProcessed - ); - })[0] as TaskScheduledEvent) - : undefined; - return returnValue; - } - - /* Returns undefined if not found. */ - private findTaskCompleted( - state: HistoryEvent[], - scheduledTask: TaskScheduledEvent | undefined - ): TaskCompletedEvent | undefined { - if (scheduledTask === undefined) { - return undefined; - } - - const returnValue = scheduledTask - ? (state.filter((val: HistoryEvent) => { - return ( - val.EventType === HistoryEventType.TaskCompleted && - (val as TaskCompletedEvent).TaskScheduledId === scheduledTask.EventId - ); - })[0] as TaskCompletedEvent) - : undefined; - return returnValue; - } - - /* Returns undefined if not found. */ - private findTaskFailed( - state: HistoryEvent[], - scheduledTask: TaskScheduledEvent | undefined - ): TaskFailedEvent | undefined { - if (scheduledTask === undefined) { - return undefined; - } - - const returnValue = scheduledTask - ? (state.filter((val: HistoryEvent) => { - return ( - val.EventType === HistoryEventType.TaskFailed && - (val as TaskFailedEvent).TaskScheduledId === scheduledTask.EventId - ); - })[0] as TaskFailedEvent) - : undefined; - return returnValue; - } - - /* Returns undefined if not found. */ - private findTimerCreated(state: HistoryEvent[], fireAt: Date): TimerCreatedEvent { - const returnValue = fireAt - ? state.filter((val: HistoryEvent) => { - return ( - val.EventType === HistoryEventType.TimerCreated && - new Date((val as TimerCreatedEvent).FireAt).getTime() === fireAt.getTime() - ); - })[0] - : undefined; - return returnValue as TimerCreatedEvent; - } - - /* Returns undefined if not found. */ - private findTimerFired( - state: HistoryEvent[], - createdTimer: TimerCreatedEvent | undefined - ): TimerFiredEvent | undefined { - const returnValue = createdTimer - ? (state.filter((val: HistoryEvent) => { - return ( - val.EventType === HistoryEventType.TimerFired && - (val as TimerFiredEvent).TimerId === createdTimer.EventId - ); - })[0] as TimerFiredEvent) - : undefined; - return returnValue; - } - - private setProcessed(events: Array): void { - events.map((val: HistoryEvent | undefined) => { - if (val) { - val.IsProcessed = true; - } - }); - } - - private parseHistoryEvent(directiveResult: HistoryEvent): unknown { - let parsedDirectiveResult: unknown; - - switch (directiveResult.EventType) { - case HistoryEventType.EventRaised: - const eventRaised = directiveResult as EventRaisedEvent; - parsedDirectiveResult = - eventRaised && eventRaised.Input ? JSON.parse(eventRaised.Input) : undefined; - break; - case HistoryEventType.SubOrchestrationInstanceCompleted: - parsedDirectiveResult = JSON.parse( - (directiveResult as SubOrchestrationInstanceCompletedEvent).Result - ); - break; - case HistoryEventType.TaskCompleted: - parsedDirectiveResult = JSON.parse((directiveResult as TaskCompletedEvent).Result); - break; - default: - break; - } - - return parsedDirectiveResult; + const innerTask = new AtomicTask(name, newAction); + const task = new Task(innerTask); + return task; } } diff --git a/src/entities/responsemessage.ts b/src/entities/responsemessage.ts index 1d91d69..0c56180 100644 --- a/src/entities/responsemessage.ts +++ b/src/entities/responsemessage.ts @@ -1,7 +1,24 @@ +import { Utils } from "./../utils"; + /** @hidden */ export class ResponseMessage { - public result: string; // Result + public result?: string; // Result public exceptionType?: string; // ExceptionType + + public constructor(event: unknown) { + if (typeof event === "object" && event !== null) { + if (Utils.hasStringProperty(event, "result")) { + this.result = event.result; + } + if (Utils.hasStringProperty(event, "exceptionType")) { + this.exceptionType = event.exceptionType; + } + } else { + throw Error( + "Attempted to construct ResponseMessage event from incompatible History event. This is probably a bug in History-replay." + ); + } + } } // TODO: error deserialization diff --git a/src/extensionSchema.ts b/src/extensionSchema.ts new file mode 100644 index 0000000..8926c0d --- /dev/null +++ b/src/extensionSchema.ts @@ -0,0 +1,5 @@ +/** @hidden */ +export enum ExtensionSchema { + V1, + V2, +} diff --git a/src/iorchestratorstate.ts b/src/iorchestratorstate.ts index a55b3a0..c001b40 100644 --- a/src/iorchestratorstate.ts +++ b/src/iorchestratorstate.ts @@ -1,4 +1,5 @@ import { IAction } from "./classes"; +import { UpperSchemaVersion, ReplaySchema } from "./replaySchema"; /** @hidden */ export interface IOrchestratorState { @@ -7,4 +8,6 @@ export interface IOrchestratorState { output: unknown; error?: string; customStatus?: unknown; + replaySchema?: ReplaySchema | undefined; + schemaVersion: UpperSchemaVersion; } diff --git a/src/orchestrator.ts b/src/orchestrator.ts index 550a750..5fd44be 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -1,27 +1,18 @@ -import * as debug from "debug"; import { - ContinueAsNewAction, DurableOrchestrationBindingInfo, - EntityId, HistoryEvent, HistoryEventType, - IAction, IOrchestrationFunctionContext, - LockState, - OrchestratorState, - TaskFilter, Utils, } from "./classes"; import { DurableOrchestrationContext } from "./durableorchestrationcontext"; -import { OrchestrationFailureError } from "./orchestrationfailureerror"; -import { TaskBase } from "./tasks/taskinterfaces"; - -/** @hidden */ -const log = debug("orchestrator"); +import { TaskOrchestrationExecutor } from "./taskorchestrationexecutor"; +import { UpperSchemaVersion } from "./replaySchema"; /** @hidden */ export class Orchestrator { private currentUtcDateTime: Date; + private taskOrchestrationExecutor: TaskOrchestrationExecutor; constructor(public fn: (context: IOrchestrationFunctionContext) => IterableIterator) {} @@ -30,6 +21,7 @@ export class Orchestrator { } private async handle(context: IOrchestrationFunctionContext): Promise { + this.taskOrchestrationExecutor = new TaskOrchestrationExecutor(); const orchestrationBinding = Utils.getInstancesOf( context.bindings, new DurableOrchestrationBindingInfo() @@ -44,8 +36,12 @@ export class Orchestrator { const instanceId: string = orchestrationBinding.instanceId; // const contextLocks: EntityId[] = orchestrationBinding.contextLocks; + // The upper schema version corresponds to the maximum OOProc protocol version supported by the extension, + // we use it to determine the format of the SDK's output + const upperSchemaVersion: UpperSchemaVersion = orchestrationBinding.upperSchemaVersion; + // Initialize currentUtcDateTime - let decisionStartedEvent: HistoryEvent = Utils.ensureNonNull( + const decisionStartedEvent: HistoryEvent = Utils.ensureNonNull( state.find((e) => e.EventType === HistoryEventType.OrchestratorStarted), "The orchestrator can not execute without an OrchestratorStarted event." ); @@ -61,210 +57,12 @@ export class Orchestrator { this.currentUtcDateTime, orchestrationBinding.isReplaying, orchestrationBinding.parentInstanceId, - input + input, + this.taskOrchestrationExecutor ); } - // Setup - const gen = this.fn(context); - const actions: IAction[][] = []; - let partialResult: TaskBase; - - try { - // First execution, we have not yet "yielded" any of the tasks. - let g = gen.next(undefined); - - while (true) { - if (!TaskFilter.isYieldable(g.value)) { - if (!g.done) { - // The orchestrator must have yielded a non-Task related type, - // so just return execution flow with what they yielded back. - g = gen.next(g.value as any); - continue; - } else { - log("Iterator is done"); - // The customer returned an absolute type. - context.done( - undefined, - new OrchestratorState({ - isDone: true, - output: g.value, - actions, - customStatus: context.df.customStatus, - }) - ); - return; - } - } - - partialResult = g.value as TaskBase; - const newActions = partialResult.yieldNewActions(); - if (newActions && newActions.length > 0) { - actions.push(newActions); - } - - // Return continue as new events as completed, as the execution itself is now completed. - if ( - TaskFilter.isSingleTask(partialResult) && - partialResult.action instanceof ContinueAsNewAction - ) { - context.done( - undefined, - new OrchestratorState({ - isDone: true, - output: undefined, - actions, - customStatus: context.df.customStatus, - }) - ); - return; - } - - if (!TaskFilter.isCompletedTask(partialResult)) { - context.done( - undefined, - new OrchestratorState({ - isDone: false, - output: undefined, - actions, - customStatus: context.df.customStatus, - }) - ); - return; - } - - const completionIndex = partialResult.completionIndex; - - // The first time a task is marked as complete, the history event that finally marked the task as completed - // should not yet have been played by the Durable Task framework, resulting in isReplaying being false. - // On replays, the event will have already been processed by the framework, and IsPlayed will be marked as true. - if (state[completionIndex] !== undefined) { - context.df.isReplaying = state[completionIndex].IsPlayed; - } - - // Handles the case where an orchestration completes with a return value of a - // completed (non-faulted) task. This shouldn't generally happen as hopefully the customer - // would yield the task before returning out of the generator function. - if (g.done) { - log("Iterator is done"); - context.done( - undefined, - new OrchestratorState({ - isDone: true, - actions, - output: partialResult.result, - customStatus: context.df.customStatus, - }) - ); - return; - } - - // We want to update the `currentUtcDateTime` to be the timestamp of the - // latest (timestamp-wise) OrchestratorStarted event that occurs (position-wise) - // before our current completionIndex / our current position in the History. - const newDecisionStartedEvent = state - .filter( - (e, index) => - e.EventType === HistoryEventType.OrchestratorStarted && - e.Timestamp > decisionStartedEvent.Timestamp && - index < completionIndex - ) - .pop(); - - decisionStartedEvent = newDecisionStartedEvent || decisionStartedEvent; - context.df.currentUtcDateTime = this.currentUtcDateTime = new Date( - decisionStartedEvent.Timestamp - ); - - // The first time a task is marked as complete, the history event that finally marked the task as completed - // should not yet have been played by the Durable Task framework, resulting in isReplaying being false. - // On replays, the event will have already been processed by the framework, and IsPlayed will be marked as true. - if (state[partialResult.completionIndex] !== undefined) { - context.df.isReplaying = state[partialResult.completionIndex].IsPlayed; - } - - if (TaskFilter.isFailedTask(partialResult)) { - // We need to check if the generator has a `throw` property merely to satisfy the typechecker. - // At this point, it should be guaranteed that the generator has a `throw` and a `next` property, - // but we have not refined its type yet. - if (!gen.throw) { - throw new Error( - "Cannot properly throw the exception returned by customer code" - ); - } - g = gen.throw(partialResult.exception); - continue; - } - - g = gen.next(partialResult.result as any); - } - } catch (error) { - // Wrap orchestration state in OutOfProcErrorWrapper to ensure data - // gets embedded in error message received by C# extension. - const errorState = new OrchestratorState({ - isDone: false, - output: undefined, - actions, - error: error.message, - customStatus: context.df.customStatus, - }); - context.done(new OrchestrationFailureError(error, errorState), undefined); - return; - } - } - - private isLocked(contextLocks: EntityId[]): LockState { - return new LockState(contextLocks && contextLocks !== null, contextLocks); + await this.taskOrchestrationExecutor.execute(context, state, upperSchemaVersion, this.fn); + return; } - /* - private lock( - state: HistoryEvent[], - instanceId: string, - contextLocks: EntityId[], - entities: EntityId[] - ): DurableLock | undefined { - if (contextLocks) { - throw new Error("Cannot acquire more locks when already holding some locks."); - } - - if (!entities || entities.length === 0) { - throw new Error("The list of entities to lock must not be null or empty."); - } - - entities = this.cleanEntities(entities); - - context.df.newGuid(instanceId); - - // All the entities in entities[] need to be locked, but to avoid - // deadlock, the locks have to be acquired sequentially, in order. So, - // we send the lock request to the first entity; when the first lock is - // granted by the first entity, the first entity will forward the lock - // request to the second entity, and so on; after the last entity - // grants the last lock, a response is sent back here. - - // send lock request to first entity in the lock set - - return undefined; - } - - private cleanEntities(entities: EntityId[]): EntityId[] { - // sort entities - return entities.sort((a, b) => { - if (a.key === b.key) { - if (a.name === b.name) { - return 0; - } else if (a.name < b.name) { - return -1; - } else { - return 1; - } - } else if (a.key < b.key) { - return -1; - } else { - return 1; - } - }); - - // TODO: remove duplicates if necessary - } */ } diff --git a/src/orchestratorstate.ts b/src/orchestratorstate.ts index da9b360..d7981f3 100644 --- a/src/orchestratorstate.ts +++ b/src/orchestratorstate.ts @@ -1,4 +1,7 @@ +import { WhenAllAction } from "./actions/whenallaction"; +import { WhenAnyAction } from "./actions/whenanyaction"; import { IAction, IOrchestratorState } from "./classes"; +import { UpperSchemaVersion } from "./replaySchema"; /** @hidden */ export class OrchestratorState implements IOrchestratorState { @@ -7,11 +10,26 @@ export class OrchestratorState implements IOrchestratorState { public readonly output: unknown; public readonly error?: string; public readonly customStatus?: unknown; + public readonly schemaVersion: UpperSchemaVersion; - constructor(options: IOrchestratorState) { + private flattenCompoundActions(actions: IAction[]): IAction[] { + let v1Actions: IAction[] = []; + for (const action of actions) { + if (action instanceof WhenAllAction || action instanceof WhenAnyAction) { + const innerActionArr = this.flattenCompoundActions(action.compoundActions); + v1Actions = v1Actions.concat(innerActionArr); + } else { + v1Actions.push(action); + } + } + return v1Actions; + } + + constructor(options: IOrchestratorState, _literalActions = false) { this.isDone = options.isDone; this.actions = options.actions; this.output = options.output; + this.schemaVersion = options.schemaVersion; if (options.error) { this.error = options.error; @@ -20,5 +38,26 @@ export class OrchestratorState implements IOrchestratorState { if (options.customStatus) { this.customStatus = options.customStatus; } + // Under replay protocol V1, compound actions are treated as lists of actions. + if (options.schemaVersion === UpperSchemaVersion.V1 && !_literalActions) { + // for backwards compatibility, the actions array from schema V2 and onwards + // is a 2D array with only 1 element: an array of actions. + const actions = this.actions[0]; + const newActions: IAction[][] = []; + // check against empty array + if (actions !== undefined) { + for (const action of actions) { + let newEntry: IAction[] = []; + if (action instanceof WhenAllAction || action instanceof WhenAnyAction) { + const innerActionArr = this.flattenCompoundActions(action.compoundActions); + newEntry = newEntry.concat(innerActionArr); + } else { + newEntry.push(action); + } + newActions.push(newEntry); + } + this.actions = newActions; + } + } } } diff --git a/src/replaySchema.ts b/src/replaySchema.ts new file mode 100644 index 0000000..fea8cde --- /dev/null +++ b/src/replaySchema.ts @@ -0,0 +1,17 @@ +/** + * @hidden + * Supported OOProc DF extension protocols + */ +export enum UpperSchemaVersion { + V1 = 0, + V2 = 1, +} + +/** + * @hidden + * The support DF extension OOProc replay schemas supported by this SDK + */ +export enum ReplaySchema { + V1, + V2, +} diff --git a/src/taskorchestrationexecutor.ts b/src/taskorchestrationexecutor.ts new file mode 100644 index 0000000..f5882e9 --- /dev/null +++ b/src/taskorchestrationexecutor.ts @@ -0,0 +1,493 @@ +import { + CallEntityAction, + DurableOrchestrationContext, + EventRaisedEvent, + EventSentEvent, + HistoryEvent, + HistoryEventType, + IAction, + IOrchestrationFunctionContext, + RequestMessage, + ResponseMessage, + SubOrchestrationInstanceCompletedEvent, + TaskCompletedEvent, + WaitForExternalEventAction, +} from "./classes"; +import { OrchestrationFailureError } from "./orchestrationfailureerror"; +import { OrchestratorState } from "./orchestratorstate"; +import { + TaskBase, + InternalOnlyTask, + ProperTask, + CompoundTask, + TaskState, +} from "./tasks/internalTasks"; +import { UpperSchemaVersion } from "./replaySchema"; +import { Utils } from "./utils"; +import { Task } from "./tasks/externalTasks"; + +/** + * @hidden + * Utility class to manage orchestration replay + */ +export class TaskOrchestrationExecutor { + private context: DurableOrchestrationContext; + private currentTask: TaskBase; + private output: unknown; + private exception: Error | undefined; + private orchestratorReturned: boolean; + private generator: Generator; + private deferredTasks: Record void>; + private sequenceNumber: number; + private schemaVersion: UpperSchemaVersion; + public willContinueAsNew: boolean; + private actions: IAction[]; + protected openTasks: Record; + protected openEvents: Record; + private eventToTaskValuePayload: { [key in HistoryEventType]?: [boolean, string] }; + + constructor() { + // Map of task-completion events types to pairs of + // (1) whether that event corresponds to a successful task result, and + // (2) the field in the event type that would contain the task's ID. + this.eventToTaskValuePayload = { + [HistoryEventType.TaskCompleted]: [true, "TaskScheduledId"], + [HistoryEventType.TimerFired]: [true, "TimerId"], + [HistoryEventType.SubOrchestrationInstanceCompleted]: [true, "TaskScheduledId"], + [HistoryEventType.EventRaised]: [true, "Name"], + [HistoryEventType.TaskFailed]: [false, "TaskScheduledId"], + [HistoryEventType.SubOrchestrationInstanceFailed]: [false, "TaskScheduledId"], + }; + this.initialize(); + } + + /** + * @hidden + * + * Initialize the task orchestration executor for a brand new orchestrator invocation. + * To be called in ContinueAsNew scenarios as well. + */ + private initialize(): void { + // The very first task, to kick-start the generator, is just a dummy/no-op task + this.currentTask = new InternalOnlyTask(); + this.currentTask.SetValue(false, undefined); + + this.sequenceNumber = 0; + this.willContinueAsNew = false; + this.openTasks = {}; + this.openEvents = {}; + this.actions = []; + this.deferredTasks = {}; + + this.output = undefined; + this.exception = undefined; + this.orchestratorReturned = false; + } + + /** + * @hidden + * + * Start an orchestration's execution, replaying based on the currently-available History. + * + * @param context + * The orchestration context + * @param history + * The orchestration history + * @param schemaVersion + * The OOProc output schema version expected by the DF extension + * @param fn + * The user-code defining the orchestration + * + * @returns + * Returns void but communicates the resulting orchestrator state via the context object's handler + */ + public async execute( + context: IOrchestrationFunctionContext, + history: HistoryEvent[], + schemaVersion: UpperSchemaVersion, + fn: (context: IOrchestrationFunctionContext) => IterableIterator + ): Promise { + this.schemaVersion = schemaVersion; + this.context = context.df; + this.generator = fn(context) as Generator; // TODO: what happens if code is not a generator? + + // Execute the orchestration, using the history for replay + for (const historyEvent of history) { + this.processEvent(historyEvent); + if (this.hasExecutionCompleted()) { + break; + } + } + + // Construct current orchestration state + const actions: IAction[][] = this.actions.length == 0 ? [] : [this.actions]; + const orchestratorState = new OrchestratorState({ + isDone: this.orchestrationInvocationCompleted(), + actions: actions, + output: this.output, + error: this.exception?.message, + customStatus: this.context.customStatus, + schemaVersion: this.schemaVersion, + }); + + // Record errors, if any + let error = undefined; + let result: any = orchestratorState; + if (this.exception !== undefined) { + error = new OrchestrationFailureError(this.orchestratorReturned, orchestratorState); + result = undefined; + } + + // Communicate the orchestration's current state + context.done(error, result); + return; + } + + /** + * @hidden + * Determine if the orchestrator should exit, either successfully or through an error. + * + * @returns + * True if the orchestration's invocation completed, or if an unhandled exception was thrown. + * False otherwise. + */ + private hasExecutionCompleted(): boolean { + return this.orchestrationInvocationCompleted() || this.exception !== undefined; + } + + /** + * @hidden + * Determine if the current invocation has finished. + * + * @returns + * True if the orchestration reached a `return` statement, or a `continueAsNew`. + * False otherwise. + */ + private orchestrationInvocationCompleted(): boolean { + return this.orchestratorReturned || this.willContinueAsNew; + } + + /** + * @hidden + * Processes a History event, often by either by updating some deterministic API value, updating + * the state of a task, or resuming the user code. + * + * @param event + * The History event we're currently processing + */ + private processEvent(event: HistoryEvent): void { + const eventType = event.EventType; + switch (event.EventType) { + case HistoryEventType.OrchestratorStarted: { + const timestamp = event.Timestamp; + if (timestamp > this.context.currentUtcDateTime) { + this.context.currentUtcDateTime = timestamp; + } + break; + } + case HistoryEventType.ContinueAsNew: { + // The clear all state from the orchestration, + // as if no processing of History had taken place + this.initialize(); + break; + } + case HistoryEventType.ExecutionStarted: { + this.tryResumingUserCode(); + break; + } + case HistoryEventType.EventSent: { + // The EventSent event requires careful handling because it is re-used among + // CallEntity and WaitForExternalEvent APIs. + // For CallEntity, the EventRaised event that contains that API's result will + // expect a TaskID that is different from the TaskID found at the root of this + // EventSent event. Namely, the TaskID it expects can be found nested in the + // "Input" field of the corresponding EventSent event. Here, we handle that + // edge-case by correcting the expected TaskID in our openTask list. + const key = event.EventId; + if (key in this.openTasks) { + const task = this.openTasks[key]; + if (task.actionObj instanceof CallEntityAction) { + // extract TaskID from Input field + const eventSent = event as EventSentEvent; + const requestMessage = JSON.parse( + eventSent.Input as string + ) as RequestMessage; + + // Obtain correct Task ID and update the task to be associated with it + const eventId = requestMessage.id; + delete this.openTasks[key]; + this.openTasks[eventId] = task; + } + } + break; + } + default: + // If the current event contains task-completion data, we resolve that task to a value + if (eventType in this.eventToTaskValuePayload) { + const [isSuccess, idKey] = this.eventToTaskValuePayload[eventType] as [ + boolean, + string + ]; + // We set the corresponding task's value and attempt to resume the orchestration + this.setTaskValue(event, isSuccess, idKey); + this.tryResumingUserCode(); + } + break; + } + } + + /** + * @hidden + * Set a Task's result from a task-completion History event. + * + * @param event + * The History event containing task-completion information + * @param isSuccess + * A flag indicating if the task failed or succeeded + * @param idKey + * The property in the History event containing the Task's ID + * @returns + */ + private setTaskValue(event: HistoryEvent, isSuccess: boolean, idKey: string): void { + /** + * @hidden + * + * Extracts a task's result from its corresponding History event + * @param completionEvent + * The History event corresponding to the task's completion + * @returns + * The task's result + */ + function extractResult(completionEvent: HistoryEvent): unknown { + let taskResult: unknown; + + switch (completionEvent.EventType) { + case HistoryEventType.SubOrchestrationInstanceCompleted: + taskResult = JSON.parse( + (completionEvent as SubOrchestrationInstanceCompletedEvent).Result + ); + break; + case HistoryEventType.TaskCompleted: + taskResult = JSON.parse((completionEvent as TaskCompletedEvent).Result); + break; + case HistoryEventType.EventRaised: + const eventRaised = completionEvent as EventRaisedEvent; + taskResult = + eventRaised && eventRaised.Input + ? JSON.parse(eventRaised.Input) + : undefined; + break; + default: + break; + } + return taskResult; + } + + // First, we attempt to recover the task associated with this history event + let task: TaskBase; + const key = event[idKey as keyof typeof event]; + if (typeof key === "number" || typeof key === "string") { + if (key in this.openTasks) { + // Obtain task from open tasks + task = this.openTasks[key]; + delete this.openTasks[key]; + } else if (key in this.openEvents) { + // Obtain task from open events + const taskList = this.openEvents[key]; + delete this.openEvents[key]; + task = taskList.pop() as TaskBase; + + // We ensure openEvents only has an entry for this key if + // there's at least 1 task to consume + if (taskList.length > 0) { + this.openEvents[key] = taskList; + } + } else { + // If the task is in neither open tasks nor open events, then it must + // correspond to the response of an external event that we have yet to wait for. + // We track this by deferring the assignment of this task's result until after the task + // is scheduled. + const updateTask = function (): void { + this.setTaskValue(event, isSuccess, idKey); + return; // we return because the task is yet to be scheduled + }; + this.deferredTasks[key] = updateTask.bind(this); + return; + } + } else { + throw Error( + `Task with ID ${key} could not be retrieved from due to its ID-key being of type ${typeof key}` + + `We expect ID-keys to be of type number or string.` + + `This is probably a replay failure, please file a bug report.` + ); + } + + // After obtaining the task, we obtain its result. + let taskResult: unknown; + if (isSuccess) { + // We obtain the task's result value from its corresponding History event. + taskResult = extractResult(event); + + // CallEntity tasks need to further de-serialize its value from the + // History event, we handle that next. + const action = task.actionObj; + if (action instanceof CallEntityAction) { + const eventPayload = new ResponseMessage(taskResult); + taskResult = eventPayload.result ? JSON.parse(eventPayload.result) : undefined; + + // Due to how ResponseMessage events are serialized, we can only + // determine if they correspond to a failure at this point in + // processing. As a result, we flip the "isSuccess" flag here + // if an exception is detected. + if (eventPayload.exceptionType !== undefined) { + taskResult = Error(taskResult as string); + isSuccess = false; + } + } + } else { + // The task failed, we attempt to extract the Reason and Details from the event. + if ( + Utils.hasStringProperty(event, "Reason") && + Utils.hasStringProperty(event, "Details") + ) { + taskResult = new Error(`${event.Reason} \n ${event.Details}`); + } else { + throw Error( + `Task with ID ${task.id} failed but we could not parse its exception data.` + + `This is probably a replay failure, please file a bug report.` + ); + } + } + + // Set result to the task, and update it's isPlayed flag. + task.isPlayed = event.IsPlayed; + task.SetValue(!isSuccess, taskResult); + } + + /** + * @hidden + * Attempt to continue executing the orchestrator. + */ + private tryResumingUserCode(): void { + // If the current task does not have a result, + // then we cannot continue running the user code. + const currentTask: TaskBase = this.currentTask; + this.context.isReplaying = currentTask.isPlayed; + if (currentTask.stateObj === TaskState.Running) { + return; + } + + // The feed in the result of the current task to the generator + let newTask: TaskBase | undefined = undefined; + try { + // In the WhenAny-case, the result of the current task is another Task. + // Here, we make sure not to expose the internal task class by extracting + // the user-facing representation of the task. + let result = currentTask.result; + if (result instanceof ProperTask) { + result = result.externalTask; + } + const taskValue = result; + const taskSucceeded = currentTask.stateObj === TaskState.Completed; + + // If the task succeeded, we feed the task result as a value; + // otherwise, we feed it as an exception. + const generatorResult = taskSucceeded + ? this.generator.next(taskValue) + : this.generator.throw(taskValue); + + if (generatorResult.done) { + // If the generator returned (via a `return` statement), + // then we capture the workflow's result result. + this.orchestratorReturned = true; + this.output = generatorResult.value; + return; + } else if (generatorResult.value instanceof Task) { + // The generator yielded another task. + newTask = generatorResult.value.internalTask; + } + } catch (exception) { + // The generator threw an exception + this.exception = exception; + } + + if (newTask !== undefined) { + // The generator returned an already-completed task, + // so we try to run the user code again. + this.currentTask = newTask; + if (newTask.state !== TaskState.Running) { + this.tryResumingUserCode(); + } else { + // The task hasn't completed, we add it to the open (incomplete) task list + this.trackOpenTask(newTask); + // We only keep track of actions from user-declared tasks, not from + // tasks generated internally to facilitate history-processing. + if (this.currentTask instanceof ProperTask) { + this.addToActions(this.currentTask.actionObj); + } + } + } + } + + /** + * @hidden + * Add an action to the user-defined actions list. + * It ignores the request if the orchestrator has already + * signaled a "ContinueAsNew" operation. + * + * @param action + * User-defined action to track + */ + public addToActions(action: IAction): void { + if (!this.willContinueAsNew) { + this.actions.push(action); + } + } + + /** + * @hidden + * Tracks this task as waiting for completion. + * In the process, it assigns the task an ID if it doesn't have one already. + * + * @param task + * Task to add to open tasks or open events list + */ + public trackOpenTask(task: InternalOnlyTask | ProperTask): void { + // TODO: should there be a check for the task status as RUNNING? + + // The open tasks and open events objects only track singular tasks, not compound ones. + // Therefore, for a compound task, we recurse down to its inner sub-tasks add + // record all singular tasks. + if (task instanceof CompoundTask) { + for (const child of task.children) { + this.trackOpenTask(child); + } + } else { + if (task.id === false) { + // The task needs to be given an ID and then stored. + task.id = this.sequenceNumber++; + this.openTasks[task.id] = task; + } else if (task.actionObj instanceof WaitForExternalEventAction) { + // The ID of `WaitForExternalEvent` tasks is the name of + // the external event. Given that multiple `WaitForExternalEvent` + // tasks can be led for the same event name at once, we need + // to store these tasks as a list. + + // Obtain the current list of tasks for this external event name. + // If there's no such list, we initialize it. + const eventList = task.id in this.openEvents ? this.openEvents[task.id] : []; + + eventList.push(task); + this.openEvents[task.id] = eventList; + } + + // If the task's ID can be found in deferred tasks, then we have already processed + // the history event that contains the result for this task. Therefore, we immediately + // assign this task's result so that the user-code may proceed executing. + if (this.deferredTasks.hasOwnProperty(task.id)) { + const taskUpdateAction = this.deferredTasks[task.id]; + taskUpdateAction(); + } + } + } +} diff --git a/src/tasks/externalTasks.ts b/src/tasks/externalTasks.ts new file mode 100644 index 0000000..dacb0b1 --- /dev/null +++ b/src/tasks/externalTasks.ts @@ -0,0 +1,103 @@ +import { InnerTimerTask, ProperTask, TaskState } from "./internalTasks"; + +export class Task { + /** + * Whether the task has completed. Note that completion is not + * equivalent to success. + */ + get isCompleted(): boolean { + return this.innerTask.state === TaskState.Completed; + } + /** + * Whether the task faulted in some way due to error. + */ + get isFaulted(): boolean { + return this.innerTask.state === TaskState.Failed; + } + + /** + * The result of the task, if completed. Otherwise `undefined`. + */ + get result(): unknown | undefined { + return this.isCompleted ? this.innerTask.result : undefined; + } + + /** + * The error thrown when attempting to perform the task's action. If + * the Task has not yet completed or has completed successfully, + * `undefined`. + */ + get exception(): Error | undefined { + return this.isFaulted ? (this.innerTask.result as Error) : undefined; + } + + /** @internal */ + get internalTask(): ProperTask { + this.innerTask.externalTask = this; + return this.innerTask; + } + + /** @hidden */ + constructor(protected readonly innerTask: ProperTask) {} +} + +/** + * Returned from [[DurableOrchestrationClient]].[[createTimer]] if the call is + * not `yield`-ed. Represents a pending timer. See documentation on [[Task]] + * for more information. + * + * All pending timers must be completed or canceled for an orchestration to + * complete. + * + * @example Cancel a timer + * ```javascript + * // calculate expiration date + * const timeoutTask = context.df.createTimer(expirationDate); + * + * // do some work + * + * if (!timeoutTask.isCompleted) { + * timeoutTask.cancel(); + * } + * ``` + * + * @example Create a timeout + * ```javascript + * const now = Date.now(); + * const expiration = new Date(now.valueOf()).setMinutes(now.getMinutes() + 30); + * + * const timeoutTask = context.df.createTimer(expirationDate); + * const otherTask = context.df.callActivity("DoWork"); + * + * const winner = yield context.df.Task.any([timeoutTask, otherTask]); + * + * if (winner === otherTask) { + * // do some more work + * } + * + * if (!timeoutTask.isCompleted) { + * timeoutTask.cancel(); + * } + * ``` + */ +export class TimerTask extends Task { + /** @hidden */ + constructor(protected readonly innerTask: InnerTimerTask) { + super(innerTask); + } + + /** + * @returns Whether or not the timer has been canceled. + */ + get isCancelled(): boolean { + return this.innerTask.isCancelled; + } + + /** + * Indicates the timer should be canceled. This request will execute on the + * next `yield` or `return` statement. + */ + public cancel(): void { + return this.innerTask.cancel(); + } +} diff --git a/src/tasks/internalTasks.ts b/src/tasks/internalTasks.ts new file mode 100644 index 0000000..bd595e0 --- /dev/null +++ b/src/tasks/internalTasks.ts @@ -0,0 +1,369 @@ +import { RetryOptions } from ".."; +import { IAction, CreateTimerAction } from "../classes"; +import { TaskOrchestrationExecutor } from "../taskorchestrationexecutor"; +import { Task } from "./externalTasks"; + +/** + * @hidden + * The states a task can be in + */ +export enum TaskState { + Running, + Failed, + Completed, +} + +/** + * @hidden + * A taskID, either a `string` for external events, + * or either `false` or a `number` for un-awaited + * an awaited tasks respectively. + */ +export type TaskID = number | string | false; + +/** + * @hidden + * A backing action, either a proper action or "noOp" for an internal-only task + */ +export type BackingAction = IAction | "noOp"; + +/** + * @hidden + * Base class for all Tasks, defines the basic state transitions for all tasks. + */ +export abstract class TaskBase { + public state: TaskState; + public parent: CompoundTask | undefined; + public isPlayed: boolean; + public result: unknown; + + /** + * @hidden + * + * Construct a task. + * @param id + * The task's ID + * @param action + * The task's backing action + */ + constructor(public id: TaskID, protected action: BackingAction) { + this.state = TaskState.Running; + } + + /** Get this task's backing action */ + get actionObj(): BackingAction { + return this.action; + } + + /** Get this task's current state */ + get stateObj(): TaskState { + return this.state; + } + + /** Whether this task is not in the Running state */ + get isCompleted(): boolean { + return this.state !== TaskState.Running; + } + + /** Change this task from the Running state to a completed state */ + private changeState(state: TaskState): void { + if (state === TaskState.Running) { + throw Error("Cannot change Task to the RUNNING state."); + } + this.state = state; + } + + /** Attempt to set a result for this task, and notifies parents, if any */ + public SetValue(isError: boolean, value: unknown): void { + let newState: TaskState; + + if (isError) { + if (value instanceof Error) { + if (value instanceof TaskBase && value.result instanceof Error) { + const errMessage = `Task ID ${this.id} failed but it's value was not an Exception`; + throw new Error(errMessage); + } + } + newState = TaskState.Failed; + } else { + newState = TaskState.Completed; + } + + this.changeState(newState); + this.result = value; + this.propagate(); + } + + /** + * @hidden + * Notifies this task's parents about its state change. + */ + private propagate(): void { + const hasCompleted = this.state !== TaskState.Running; + if (hasCompleted && this.parent !== undefined) { + this.parent.handleCompletion(this); + } + } +} + +/** + * @hidden + * + * A task created only to facilitate replay, it should not communicate any + * actions to the DF extension. + * + * We internally track these kinds of tasks to reason over the completion of + * DF APIs that decompose into smaller DF APIs that the user didn't explicitly + * schedule. + */ +export class InternalOnlyTask extends TaskBase { + constructor() { + super(false, "noOp"); + } +} + +/** + * @hidden + * A task that should result in an Action being communicated to the DF extension. + */ +export class ProperTask extends TaskBase { + protected action: IAction; + public externalTask: Task | undefined; + + /** Get this task's backing action */ + get actionObj(): IAction { + return this.action; + } +} + +/** + * @hidden + * + * A task that depends on the completion of other (sub-) tasks. + */ +export abstract class CompoundTask extends ProperTask { + protected firstError: Error | undefined; + + /** + * @hidden + * Construct a Compound Task. + * Primarily sets the parent pointer of each sub-task to be `this`. + * + * @param children + * The sub-tasks that this task depends on + * @param action + * An action representing this compound task + */ + constructor(public children: TaskBase[], protected action: IAction) { + super(false, action); + children.map((c) => (c.parent = this)); + this.firstError = undefined; + + // If the task has no children, then it's completed by definition. + if (children.length == 0) { + this.state = TaskState.Completed; + } + } + + /** + * @hidden + * Tries to set this task's result based on the completion of a sub-task + * @param child + * A sub-task of this task. + */ + public handleCompletion(child: TaskBase): void { + if (!this.isPlayed) { + this.isPlayed = child.isPlayed; + } + this.trySetValue(child); + } + + /** + * @hidden + * + * Task-internal logic for attempting to set this tasks' result + * after any of its sub-tasks completes. + * @param child + * A sub-task + */ + abstract trySetValue(child: TaskBase): void; +} + +export class AtomicTask extends ProperTask {} + +/** + * @hidden + * A timer task. This is the internal interface to the user-exposed TimerTask, which + * has a more restricted API. + */ +export class InnerTimerTask extends AtomicTask { + /** + * @hidden + * Construct a Timer Task. + * + * @param id + * The task's ID + * @param action + * The backing action of this task + */ + constructor(public id: TaskID, public action: CreateTimerAction) { + super(id, action); + } + + /** Whether this timer task is cancelled */ + get isCancelled(): boolean { + return this.action.isCancelled; + } + + /** + * @hidden + * Cancel this timer task. + * It errors out if the task has already completed. + */ + public cancel(): void { + if (this.isCompleted) { + throw Error("Cannot cancel a completed task."); + } + this.action.isCancelled = true; // TODO: fix typo + } +} + +/** + * @hidden + * + * A WhenAll task. + */ +export class WhenAllTask extends CompoundTask { + /** + * @hidden + * Construct a WhenAll task. + * + * @param children + * Sub-tasks to wait on. + * @param action + * A the backing action representing this task. + */ + constructor(public children: TaskBase[], protected action: IAction) { + super(children, action); + } + + /** + * @hidden + * Attempts to set a value to this task, given a completed sub-task + * + * @param child + * The sub-task that just completed + */ + public trySetValue(child: AtomicTask): void { + if (child.stateObj === TaskState.Completed) { + // We set the result only after all sub-tasks have completed + if (this.children.every((c) => c.stateObj === TaskState.Completed)) { + // The result is a list of all sub-task's results + const results = this.children.map((c) => c.result); + this.SetValue(false, results); + } + } else { + // If any task failed, we fail the entire compound task + if (this.firstError === undefined) { + this.firstError = child.result as Error; + this.SetValue(true, this.firstError); + } + } + } +} + +/** + * @hidden + * + * A WhenAny task. + */ +export class WhenAnyTask extends CompoundTask { + /** + * @hidden + * Attempts to set a value to this task, given a completed sub-task + * + * @param child + * The sub-task that just completed + */ + public trySetValue(child: TaskBase): void { + if (this.state === TaskState.Running) { + this.SetValue(false, child); + } + } +} + +/** + * @hidden + * + * A `-WithRetry` Task. + * It is modeled after a `WhenAllTask` because it decomposes + * into several sub-tasks (a growing sequence of timers and atomic tasks) + * that all need to complete before this task reaches an end-value. + */ +export class RetryAbleTask extends WhenAllTask { + private isWaitingOnTimer: boolean; + private numAttempts: number; + private error: any; + + /** + * @hidden + * Construct a retriable task. + * + * @param innerTask + * The task representing the work to retry + * @param retryOptions + * The retrying settings + * @param executor + * The taskOrchestrationExecutor managing the replay, + * we use to to scheduling new tasks (timers and retries) + */ + constructor( + public innerTask: ProperTask, + private retryOptions: RetryOptions, + private executor: TaskOrchestrationExecutor + ) { + super([innerTask], innerTask.actionObj); + this.numAttempts = 1; + this.isWaitingOnTimer = false; + } + + /** + * @hidden + * Attempts to set a value to this task, given a completed sub-task + * + * @param child + * The sub-task that just completed + */ + public trySetValue(child: TaskBase): void { + if (this.isWaitingOnTimer) { + this.isWaitingOnTimer = false; + + // If we're out of retry attempts, we can set the output value + // of this task to be that of the last error we encountered + if (this.numAttempts >= this.retryOptions.maxNumberOfAttempts) { + this.SetValue(true, this.error); + } else { + // If we still have more attempts available, we re-schedule the + // original task. Since these sub-tasks are not user-managed, + // they are declared as internal tasks. + const rescheduledTask = new InternalOnlyTask(); + rescheduledTask.parent = this; + this.children.push(rescheduledTask); + this.executor.trackOpenTask(rescheduledTask); + this.numAttempts++; + } + } else if (child.stateObj === TaskState.Completed) { + // If we have a successful non-timer task, we accept its result + this.SetValue(false, child.result); + } else { + // If the sub-task failed, schedule timer to retry again. + // Since these sub-tasks are not user-managed, they are declared as internal tasks. + const rescheduledTask = new InternalOnlyTask(); + rescheduledTask.parent = this; + this.children.push(rescheduledTask); + this.executor.trackOpenTask(rescheduledTask); + this.isWaitingOnTimer = true; + this.error = child.result; + } + } +} diff --git a/src/tasks/itaskmethods.ts b/src/tasks/itaskmethods.ts deleted file mode 100644 index 29f05e3..0000000 --- a/src/tasks/itaskmethods.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Task } from "./task"; -import { TaskSet } from "./taskset"; - -/** - * Methods to handle collections of pending actions represented by [[Task]] - * instances. For use in parallelization operations. - */ -export interface ITaskMethods { - /** - * Similar to Promise.all. When called with `yield` or `return`, returns an - * array containing the results of all [[Task]]s passed to it. It returns - * when all of the [[Task]] instances have completed. - */ - all: (tasks: Task[]) => TaskSet; - - /** - * Similar to Promise.race. When called with `yield` or `return`, returns - * the first [[Task]] instance to complete. - */ - any: (tasks: Task[]) => TaskSet; -} diff --git a/src/tasks/task.ts b/src/tasks/task.ts deleted file mode 100644 index 86ea4b5..0000000 --- a/src/tasks/task.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { IAction } from "../classes"; -import { TaskBase } from "./taskinterfaces"; - -/** - * Represents some pending action. Similar to a native JavaScript promise in - * that it acts as a placeholder for outstanding asynchronous work, but has - * a synchronous implementation and is specific to Durable Functions. - * - * Tasks are only returned to an orchestration function when a - * [[DurableOrchestrationContext]] operation is not called with `yield`. They - * are useful for parallelization and timeout operations in conjunction with - * Task.all and Task.any. - * - * We discourage the usage of `instanceof`-style guards on this type, - * as it is subject to change in the future. - * - * @example Wait for all parallel operations to complete - * ```javascript - * const operations = context.df.callActivity("GetOperations"); - * - * const tasks = []; - * for (const operation of operations) { - * tasks.push(context.df.callActivity("DoOperation", operation)); - * } - * - * const results = yield context.df.Task.all(tasks); - * ``` - * - * @example Return the result of the first of two operations to complete - * ```javascript - * const taskA = context.df.callActivity("DoWorkA"); - * const taskB = context.df.callActivity("DoWorkB"); - * - * const firstDone = yield context.df.Task.any([taskA, taskB]); - * - * return firstDone.result; - * ``` - */ -export class Task implements TaskBase { - /** - * @hidden - * Used to keep track of how many times the task has been yielded to avoid - * scheduling the internal action multiple times _Internal use only._ - */ - private wasYielded = false; - - /** @hidden */ - constructor( - /** - * Whether the task has completed. Note that completion is not - * equivalent to success. - */ - public readonly isCompleted: boolean, - /** - * Whether the task faulted in some way due to error. - */ - public readonly isFaulted: boolean, - /** - * @hidden - * The scheduled action represented by the task. _Internal use only._ - */ - public readonly action: IAction, - /** - * The result of the task, if completed. Otherwise `undefined`. - */ - public readonly result?: unknown, - /** - * @hidden - * The timestamp of the task. - */ - public readonly timestamp?: Date, - /** - * @hidden - * The ID number of the task. _Internal use only._ - */ - public readonly id?: number, - /** - * The error thrown when attempting to perform the task's action. If - * the Task has not yet completed or has completed successfully, - * `undefined`. - */ - public readonly exception?: Error | undefined, - - /** - * @hidden - * The index in the history state where the task was marked completed. _Internal use only._ - */ - public readonly completionIndex?: number - ) {} - - /** - * @hidden - * _Internal use only._ - */ - public yieldNewActions(): IAction[] { - if (!this.wasYielded) { - this.wasYielded = true; - return [this.action]; - } - - return []; - } -} diff --git a/src/tasks/taskfactory.ts b/src/tasks/taskfactory.ts deleted file mode 100644 index abcf1da..0000000 --- a/src/tasks/taskfactory.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { CreateTimerAction, IAction } from "../classes"; -import { Task } from "./task"; -import { TaskBase } from "./taskinterfaces"; -import { TaskSet } from "./taskset"; -import { TimerTask } from "./timertask"; - -/** @hidden */ -export class TaskFactory { - public static UncompletedTask(action: IAction): Task { - return new Task(false, false, action); - } - - public static SuccessfulTask( - action: IAction, - result: unknown, - timestamp: Date, - id: number, - completedHistoryEventIndex: number - ): Task { - return new Task( - true, - false, - action, - result, - timestamp, - id, - undefined, - completedHistoryEventIndex - ); - } - - public static FailedTask( - action: IAction, - reason: string | undefined, - timestamp: Date, - id: number, - completedHistoryEventIndex: number, - exception: Error - ): Task { - return new Task( - true, - true, - action, - reason, - timestamp, - id, - exception, - completedHistoryEventIndex - ); - } - - public static CompletedTimerTask( - action: CreateTimerAction, - timestamp: Date, - id: number, - completedHistoryEventIndex: number - ): TimerTask { - return new TimerTask(true, action, timestamp, id, completedHistoryEventIndex); - } - - public static UncompletedTimerTask(action: CreateTimerAction): TimerTask { - return new TimerTask(false, action); - } - - public static SuccessfulTaskSet( - tasks: TaskBase[], - completionIndex: number, - result: unknown - ): TaskSet { - return new TaskSet(true, false, tasks, completionIndex, result, undefined); - } - - public static FailedTaskSet( - tasks: TaskBase[], - completionIndex: number, - exception: Error - ): TaskSet { - return new TaskSet(true, true, tasks, completionIndex, undefined, exception); - } - - public static UncompletedTaskSet(tasks: TaskBase[]): TaskSet { - return new TaskSet(false, false, tasks, undefined, undefined, undefined); - } -} diff --git a/src/tasks/taskfilter.ts b/src/tasks/taskfilter.ts deleted file mode 100644 index 054f2d9..0000000 --- a/src/tasks/taskfilter.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Task } from "./task"; -import { - CompletedTask, - FailedTask, - SuccessfulTask, - TaskBase, - UncompletedTask, -} from "./taskinterfaces"; -import { TaskSet } from "./taskset"; - -/** @hidden */ -export class TaskFilter { - public static CompareFinishedTime(taskA: CompletedTask, taskB: CompletedTask): -1 | 0 | 1 { - if (taskA.completionIndex > taskB.completionIndex) { - return 1; - } - if (taskA.completionIndex < taskB.completionIndex) { - return -1; - } - return 0; - } - - public static isYieldable(task: any): task is TaskBase { - const taskBase = task as TaskBase; - return ( - taskBase && - taskBase.isCompleted !== undefined && - taskBase.isFaulted !== undefined && - taskBase.yieldNewActions !== undefined - ); - } - - public static isSingleTask(task: TaskBase): task is Task { - return task instanceof Task; - } - - public static isTaskSet(task: TaskBase): task is TaskSet { - return task instanceof TaskSet; - } - - public static isCompletedTask(task: TaskBase): task is CompletedTask { - return task.isCompleted; - } - - public static isUncompletedTask(task: TaskBase): task is UncompletedTask { - return task.isCompleted === false; - } - - public static isSuccessfulTask(task: TaskBase): task is SuccessfulTask { - return task.isCompleted === true && task.isFaulted === false; - } - - public static isFailedTask(task: TaskBase): task is FailedTask { - return task.isCompleted === true && task.isFaulted === true; - } -} diff --git a/src/tasks/taskinterfaces.ts b/src/tasks/taskinterfaces.ts deleted file mode 100644 index 4aa758d..0000000 --- a/src/tasks/taskinterfaces.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { IAction } from "../classes"; - -// Base interfaces -/** @hidden */ -export interface TaskBase { - readonly isCompleted: boolean; - readonly isFaulted: boolean; - yieldNewActions(): IAction[]; -} - -/** @hidden */ -export interface UncompletedTask extends TaskBase { - readonly isCompleted: false; - readonly isFaulted: false; -} - -/** @hidden */ -export interface CompletedTask extends TaskBase { - readonly completionIndex: number; - readonly isCompleted: true; - readonly result: unknown | undefined; -} - -/** @hidden */ -export interface SuccessfulTask extends CompletedTask { - readonly isFaulted: false; - readonly result: unknown; - readonly exception: undefined; -} - -/** @hidden */ -export interface FailedTask extends CompletedTask { - readonly isFaulted: true; - readonly exception: Error; - readonly result: undefined; -} diff --git a/src/tasks/taskset.ts b/src/tasks/taskset.ts deleted file mode 100644 index e09ef4f..0000000 --- a/src/tasks/taskset.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { IAction } from "../classes"; -import { TaskBase } from "./taskinterfaces"; - -/** @hidden */ -export class TaskSet implements TaskBase { - constructor( - public readonly isCompleted: boolean, - public readonly isFaulted: boolean, - private readonly tasks: TaskBase[], - private readonly completionIndex?: number, - public result?: unknown, - public exception?: Error - ) {} - - public yieldNewActions(): IAction[] { - // Get all of the actions in subtasks and flatten into one array. - return this.tasks - .map((task) => task.yieldNewActions()) - .reduce((actions, subTaskActions) => actions.concat(subTaskActions)); - } -} diff --git a/src/tasks/timertask.ts b/src/tasks/timertask.ts deleted file mode 100644 index 76b14cb..0000000 --- a/src/tasks/timertask.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { CreateTimerAction } from "../classes"; -import { Task } from "./task"; - -/** - * Returned from [[DurableOrchestrationClient]].[[createTimer]] if the call is - * not `yield`-ed. Represents a pending timer. See documentation on [[Task]] - * for more information. - * - * All pending timers must be completed or canceled for an orchestration to - * complete. - * - * @example Cancel a timer - * ```javascript - * // calculate expiration date - * const timeoutTask = context.df.createTimer(expirationDate); - * - * // do some work - * - * if (!timeoutTask.isCompleted) { - * timeoutTask.cancel(); - * } - * ``` - * - * @example Create a timeout - * ```javascript - * const now = Date.now(); - * const expiration = new Date(now.valueOf()).setMinutes(now.getMinutes() + 30); - * - * const timeoutTask = context.df.createTimer(expirationDate); - * const otherTask = context.df.callActivity("DoWork"); - * - * const winner = yield context.df.Task.any([timeoutTask, otherTask]); - * - * if (winner === otherTask) { - * // do some more work - * } - * - * if (!timeoutTask.isCompleted) { - * timeoutTask.cancel(); - * } - * ``` - */ -export class TimerTask extends Task { - /** @hidden */ - constructor( - isCompleted: boolean, - /** - * @hidden - * The scheduled action represented by the task. _Internal use only._ - */ - public readonly action: CreateTimerAction, - timestamp?: Date, - id?: number, - completionIndex?: number - ) { - super(isCompleted, false, action, undefined, timestamp, id, undefined, completionIndex); - } - - /** - * @returns Whether or not the timer has been canceled. - */ - get isCanceled(): boolean { - return this.action.isCanceled; - } - - /** - * Indicates the timer should be canceled. This request will execute on the - * next `yield` or `return` statement. - */ - public cancel(): void { - if (!this.isCompleted) { - this.action.isCanceled = true; - } else { - throw new Error("Cannot cancel a completed task."); - } - } -} diff --git a/src/testingUtils.ts b/src/testingUtils.ts index 78c983d..58d5cd9 100644 --- a/src/testingUtils.ts +++ b/src/testingUtils.ts @@ -16,6 +16,7 @@ import { OrchestratorStartedEvent, } from "./classes"; import { IOrchestrationFunctionContext } from "./iorchestrationfunctioncontext"; +import { TaskOrchestrationExecutor } from "./taskorchestrationexecutor"; /** * An orchestration context with dummy default values to facilitate mocking/stubbing the @@ -53,7 +54,8 @@ export class DummyOrchestrationContext implements IOrchestrationFunctionContext currentUtcDateTime, isReplaying, parentInstanceId, - input + input, + new TaskOrchestrationExecutor() ); } public doneValue: IOrchestratorState | undefined; diff --git a/src/utils.ts b/src/utils.ts index dd68b57..def7eb0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -25,6 +25,19 @@ export class Utils { return times[0] * 1000 + times[1] / 1e6; } + public static hasStringProperty( + obj: X, + prop: Y + ): obj is X & Record { + if (Utils.hasOwnProperty(obj, prop)) { + const propKey = prop as keyof typeof obj; + const property = obj[propKey]; + const propertyIsString = typeof property === "string"; + return propertyIsString; + } + return false; + } + public static hasOwnProperty( obj: X, prop: Y diff --git a/test/integration/orchestrator-spec.ts b/test/integration/orchestrator-spec.ts index a71359e..2c06e64 100644 --- a/test/integration/orchestrator-spec.ts +++ b/test/integration/orchestrator-spec.ts @@ -30,6 +30,7 @@ import { IOrchestrationFunctionContext, } from "../../src/classes"; import { OrchestrationFailureError } from "../../src/orchestrationfailureerror"; +import { UpperSchemaVersion } from "../../src/replaySchema"; import { TestHistories } from "../testobjects/testhistories"; import { TestOrchestrations } from "../testobjects/TestOrchestrations"; import { TestUtils } from "../testobjects/testutils"; @@ -49,14 +50,19 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [], - output: `Hello, ${name}!`, - }) + new OrchestratorState( + { + isDone: true, + actions: [], + output: `Hello, ${name}!`, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); + /* it("handles a simple orchestration function (no activity functions), with yield of non-Task object", async () => { const orchestrator = TestOrchestrations.SayHelloInlineInproperYield; const name = "World"; @@ -80,38 +86,44 @@ describe("Orchestrator", () => { }) ); }); - - falsyValues.forEach((falsyValue) => { - it(`handles an orchestration function that returns ${ - falsyValue === "" ? "empty string" : falsyValue - }`, async () => { - const orchestrator = TestOrchestrations.PassThrough; - const mockContext = new MockContext({ - context: new DurableOrchestrationBindingInfo( - TestHistories.GetOrchestratorStart( - "PassThrough", - moment.utc().toDate(), + */ + + describe("s", () => { + for (const falsyValue of falsyValues) { + it(`handles an orchestration function that returns ${ + falsyValue === "" ? "empty string" : falsyValue + }`, async () => { + const orchestrator = TestOrchestrations.PassThrough; + const mockContext = new MockContext({ + context: new DurableOrchestrationBindingInfo( + TestHistories.GetOrchestratorStart( + "PassThrough", + moment.utc().toDate(), + falsyValue + ), falsyValue ), - falsyValue - ), - }); - orchestrator(mockContext); - - expect(mockContext.doneValue).to.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [], - output: falsyValue, - }) - ); - if (isNaN(falsyValue as number)) { - expect(isNaN(mockContext.doneValue!.output as number)).to.equal(true); - } else { - expect(mockContext.doneValue!.output).to.equal(falsyValue); - } - expect(mockContext.err).to.equal(undefined); - }); + }); + await orchestrator(mockContext); + expect(mockContext.doneValue).to.deep.equal( + new OrchestratorState( + { + isDone: true, + actions: [], + output: falsyValue, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) + ); + if (isNaN(falsyValue as number)) { + expect(isNaN(mockContext.doneValue!.output as number)).to.equal(true); + } else { + expect(mockContext.doneValue!.output).to.equal(falsyValue); + } + expect(mockContext.err).to.equal(undefined); + }); + } }); describe("Properties", () => { @@ -297,14 +309,18 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [new CallActivityAction("ThrowsErrorActivity")], - [new CallActivityAction("Hello", name)], - ], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [new CallActivityAction("ThrowsErrorActivity")], + [new CallActivityAction("Hello", name)], + ], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); @@ -327,11 +343,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [[new CallActivityAction("Hello", name)]], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [[new CallActivityAction("Hello", name)]], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -346,11 +366,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [[new CallActivityAction("ReturnsFour")]], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [[new CallActivityAction("ReturnsFour")]], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -372,11 +396,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [[new CallActivityAction("Hello", falsyValue)]], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [[new CallActivityAction("Hello", falsyValue)]], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -396,11 +424,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [[new CallActivityAction("Hello", falsyValue)]], - output: `Hello, ${falsyValue}!`, - }) + new OrchestratorState( + { + isDone: true, + actions: [[new CallActivityAction("Hello", falsyValue)]], + output: `Hello, ${falsyValue}!`, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); @@ -423,11 +455,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [[new CallActivityAction("Hello", name)]], - output: `Hello, ${name}!`, - }) + new OrchestratorState( + { + isDone: true, + actions: [[new CallActivityAction("Hello", name)]], + output: `Hello, ${name}!`, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -448,36 +484,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - output: `Hello, ${name}!`, - actions: [[new CallActivityAction("Hello", name)]], - }) - ); - }); - - it("handles a completed activity function by returning instead of yielding", async () => { - const orchestrator = TestOrchestrations.SayHelloWithActivityDirectReturn; - const name = "World"; - const mockContext = new MockContext({ - context: new DurableOrchestrationBindingInfo( - TestHistories.GetSayHelloWithActivityReplayOne( - "SayHelloWithActivityDirectReturn", - moment.utc().toDate(), - name - ), - name - ), - }); - - orchestrator(mockContext); - - expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [[new CallActivityAction("Hello", name)]], - output: `Hello, ${name}!`, - }) + new OrchestratorState( + { + isDone: true, + output: `Hello, ${name}!`, + actions: [[new CallActivityAction("Hello", name)]], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -495,15 +510,19 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [new CallActivityAction("Hello", "Tokyo")], - [new CallActivityAction("Hello", "Seattle")], - [new CallActivityAction("Hello", "London")], - ], - output: ["Hello, Tokyo!", "Hello, Seattle!", "Hello, London!"], - }) + new OrchestratorState( + { + isDone: true, + actions: [ + [new CallActivityAction("Hello", "Tokyo")], + [new CallActivityAction("Hello", "Seattle")], + [new CallActivityAction("Hello", "London")], + ], + output: ["Hello, Tokyo!", "Hello, Seattle!", "Hello, London!"], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); @@ -556,19 +575,23 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [ - new CallActivityWithRetryAction( - "Hello", - new RetryOptions(10000, 2), - name - ), + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [ + new CallActivityWithRetryAction( + "Hello", + new RetryOptions(10000, 2), + name + ), + ], ], - ], - }) + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -590,11 +613,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [[new CallActivityWithRetryAction("Hello", retryOptions, name)]], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [[new CallActivityWithRetryAction("Hello", retryOptions, name)]], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -611,19 +638,23 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [ - new CallActivityWithRetryAction( - "Hello", - new RetryOptions(10000, 2), - name - ), + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [ + new CallActivityWithRetryAction( + "Hello", + new RetryOptions(10000, 2), + name + ), + ], ], - ], - }) + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -677,19 +708,23 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [ - new CallActivityWithRetryAction( - "Hello", - new RetryOptions(10000, 2), - name - ), + new OrchestratorState( + { + isDone: true, + actions: [ + [ + new CallActivityWithRetryAction( + "Hello", + new RetryOptions(10000, 2), + name + ), + ], ], - ], - output: `Hello, ${name}!`, - }) + output: `Hello, ${name}!`, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -710,24 +745,28 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [ - new CallActivityWithRetryAction( - "Hello", - new RetryOptions(100, 5), - "Tokyo" - ), - new CallActivityWithRetryAction( - "Hello", - new RetryOptions(100, 5), - "Seattle" - ), + new OrchestratorState( + { + isDone: true, + actions: [ + [ + new CallActivityWithRetryAction( + "Hello", + new RetryOptions(100, 5), + "Tokyo" + ), + new CallActivityWithRetryAction( + "Hello", + new RetryOptions(100, 5), + "Seattle" + ), + ], ], - ], - output: ["Hello, Tokyo!", "Hello, Seattle!"], - }) + output: ["Hello, Tokyo!", "Hello, Seattle!"], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -751,11 +790,20 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [[new CallActivityWithRetryAction("Hello", retryOptions, "World")]], - output: [startingTime, moment(startingTime).add(1, "m").add(30, "s").toDate()], - }) + new OrchestratorState( + { + isDone: true, + actions: [ + [new CallActivityWithRetryAction("Hello", retryOptions, "World")], + ], + output: [ + startingTime, + moment(startingTime).add(1, "m").add(30, "s").toDate(), + ], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); @@ -778,11 +826,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [[new CallHttpAction(req)]], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [[new CallHttpAction(req)]], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -812,6 +864,7 @@ describe("Orchestrator", () => { expect(mockContext.doneValue).to.be.deep.equal({ isDone: false, output: undefined, + schemaVersion: 0, actions: [ [ { @@ -854,11 +907,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [[new CallHttpAction(req)]], - output: res, - }) + new OrchestratorState( + { + isDone: true, + actions: [[new CallHttpAction(req)]], + output: res, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); @@ -879,11 +936,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [[new CallEntityAction(expectedEntity, "set", "testString")]], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [[new CallEntityAction(expectedEntity, "set", "testString")]], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -903,11 +964,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [[new CallEntityAction(expectedEntity, "set", "testString")]], - output: "OK", - }) + new OrchestratorState( + { + isDone: true, + actions: [[new CallEntityAction(expectedEntity, "set", "testString")]], + output: "OK", + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); @@ -932,13 +997,17 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [new CallSubOrchestratorAction("SayHelloWithActivity", childId, name)], - ], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [new CallSubOrchestratorAction("SayHelloWithActivity", childId, name)], + ], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -960,13 +1029,23 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [new CallSubOrchestratorAction("SayHelloWithActivity", undefined, name)], - ], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [ + new CallSubOrchestratorAction( + "SayHelloWithActivity", + undefined, + name + ), + ], + ], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -995,109 +1074,44 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - output: [ - `Hello, ${name}_SayHelloWithActivity_0!`, - `Hello, ${name}_SayHelloInline_1!`, - `Hello, ${name}_SayHelloWithActivity_2!`, - `Hello, ${name}_SayHelloInline_3!`, - ], - actions: [ - [ - new CallSubOrchestratorAction( - "SayHelloWithActivity", - undefined, - `${name}_SayHelloWithActivity_0` - ), - new CallSubOrchestratorAction( - "SayHelloInline", - undefined, - `${name}_SayHelloInline_1` - ), - new CallSubOrchestratorAction( - "SayHelloWithActivity", - undefined, - `${name}_SayHelloWithActivity_2` - ), - new CallSubOrchestratorAction( - "SayHelloInline", - undefined, - `${name}_SayHelloInline_3` - ), + new OrchestratorState( + { + isDone: true, + output: [ + `Hello, ${name}_SayHelloWithActivity_0!`, + `Hello, ${name}_SayHelloInline_1!`, + `Hello, ${name}_SayHelloWithActivity_2!`, + `Hello, ${name}_SayHelloInline_3!`, ], - ], - }) - ); - }); - - it("replay does not match history (mismatched suborchestration name) and throws error.", async () => { - const orchestrator = TestOrchestrations.MultipleSubOrchestratorNoSubId; - const name = "World"; - const id = uuidv1(); - const mockContext = new MockContext({ - context: new DurableOrchestrationBindingInfo( - TestHistories.GetMultipleSubOrchestratorNoIdsSubOrchestrationsFinished( - moment.utc().toDate(), - orchestrator, - // The order in the sample suborchestrator is ["SayHelloWithActivity", "SayHelloInline", "SayHelloWithActivity", "SayHelloInline"] - [ - "SayHelloInline", - "SayHelloWithActivity", - "SayHelloWithActivity", - "SayHelloInline", + actions: [ + [ + new CallSubOrchestratorAction( + "SayHelloWithActivity", + undefined, + `${name}_SayHelloWithActivity_0` + ), + new CallSubOrchestratorAction( + "SayHelloInline", + undefined, + `${name}_SayHelloInline_1` + ), + new CallSubOrchestratorAction( + "SayHelloWithActivity", + undefined, + `${name}_SayHelloWithActivity_2` + ), + new CallSubOrchestratorAction( + "SayHelloInline", + undefined, + `${name}_SayHelloInline_3` + ), + ], ], - name - ), - name, - id - ), - }); - - orchestrator(mockContext); - - const expectedErr = - "The sub-orchestration call (n = 1) should be executed with a function name of SayHelloInline instead of the provided function name of SayHelloWithActivity. Check your code for non-deterministic behavior."; - - expect(mockContext.err).to.be.an.instanceOf(OrchestrationFailureError); - - const orchestrationState = TestUtils.extractStateFromError( - mockContext.err as OrchestrationFailureError - ); - - expect(orchestrationState.error).to.include(expectedErr); - }); - - it("replay does not match history (mismatched suborchestration instance id) and throws error.", async () => { - const orchestrator = TestOrchestrations.SayHelloWithSubOrchestrator; - const name = "World"; - const id = uuidv1(); - const subId = id + ":1"; - const mockContext = new MockContext({ - context: new DurableOrchestrationBindingInfo( - TestHistories.GetSayHelloWithSubOrchestratorReplayOne( - moment.utc().toDate(), - orchestrator, - "SayHelloWithActivity", - subId, - name - ), - name, - id - ), - }); - - orchestrator(mockContext); - - const expectedErr = `The sub-orchestration call (n = 1) should be executed with an instance id of ${subId} instead of the provided instance id of ${id}:0. Check your code for non-deterministic behavior.`; - - expect(mockContext.err).to.be.an.instanceOf(OrchestrationFailureError); - - const orchestrationState = TestUtils.extractStateFromError( - mockContext.err as OrchestrationFailureError + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); - - expect(orchestrationState.error).to.include(expectedErr); }); it("handles a completed suborchestrator function", async () => { @@ -1122,13 +1136,17 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [new CallSubOrchestratorAction("SayHelloWithActivity", childId, name)], - ], - output: "Hello, World!", - }) + new OrchestratorState( + { + isDone: true, + actions: [ + [new CallSubOrchestratorAction("SayHelloWithActivity", childId, name)], + ], + output: "Hello, World!", + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1225,20 +1243,24 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [ - new CallSubOrchestratorWithRetryAction( - "SayHelloInline", - new RetryOptions(10000, 2), - name, - childId - ), + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [ + new CallSubOrchestratorWithRetryAction( + "SayHelloInline", + new RetryOptions(10000, 2), + name, + childId + ), + ], ], - ], - }) + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1264,20 +1286,24 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [ - new CallSubOrchestratorWithRetryAction( - "SayHelloInline", - retryOptions, - name, - childId - ), + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [ + new CallSubOrchestratorWithRetryAction( + "SayHelloInline", + retryOptions, + name, + childId + ), + ], ], - ], - }) + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1301,20 +1327,24 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [ - new CallSubOrchestratorWithRetryAction( - "SayHelloInline", - new RetryOptions(10000, 2), - name, - childId - ), + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [ + new CallSubOrchestratorWithRetryAction( + "SayHelloInline", + new RetryOptions(10000, 2), + name, + childId + ), + ], ], - ], - }) + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1387,20 +1417,24 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [ - new CallSubOrchestratorWithRetryAction( - "SayHelloInline", - new RetryOptions(10000, 2), - name, - childId - ), + new OrchestratorState( + { + isDone: true, + actions: [ + [ + new CallSubOrchestratorWithRetryAction( + "SayHelloInline", + new RetryOptions(10000, 2), + name, + childId + ), + ], ], - ], - output: `Hello, ${name}!`, - }) + output: `Hello, ${name}!`, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1421,24 +1455,28 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [ - new CallSubOrchestratorWithRetryAction( - "SayHelloInline", - new RetryOptions(100, 5), - "Tokyo" - ), - new CallSubOrchestratorWithRetryAction( - "SayHelloInline", - new RetryOptions(100, 5), - "Seattle" - ), + new OrchestratorState( + { + isDone: true, + actions: [ + [ + new CallSubOrchestratorWithRetryAction( + "SayHelloInline", + new RetryOptions(100, 5), + "Tokyo" + ), + new CallSubOrchestratorWithRetryAction( + "SayHelloInline", + new RetryOptions(100, 5), + "Seattle" + ), + ], ], - ], - output: ["Hello, Tokyo!", "Hello, Seattle!"], - }) + output: ["Hello, Tokyo!", "Hello, Seattle!"], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); @@ -1459,12 +1497,16 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - // Is Done needs to be marked as true for 1.8.0 and later to properly process continueAsNew - isDone: true, - output: undefined, - actions: [[new ContinueAsNewAction({ value: 6 })]], - }) + new OrchestratorState( + { + // Is Done needs to be marked as true for 1.8.0 and later to properly process continueAsNew + isDone: true, + output: undefined, + actions: [[new ContinueAsNewAction({ value: 6 })]], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); @@ -1485,11 +1527,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [[new CreateTimerAction(fireAt)]], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [[new CreateTimerAction(fireAt)]], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1508,11 +1554,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [[new CreateTimerAction(fireAt)]], - output: "Timer fired!", - }) + new OrchestratorState( + { + isDone: true, + actions: [[new CreateTimerAction(fireAt)]], + output: "Timer fired!", + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); @@ -1562,11 +1612,15 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [], - output: expectedLockState, - }) + new OrchestratorState( + { + isDone: true, + actions: [], + output: expectedLockState, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1622,15 +1676,19 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.deep.eq( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [new CallActivityAction("Hello", "Tokyo")], - [new CallActivityAction("Hello", "Seattle")], - ], - customStatus: "Tokyo", - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [new CallActivityAction("Hello", "Tokyo")], + [new CallActivityAction("Hello", "Seattle")], + ], + customStatus: "Tokyo", + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); @@ -1651,13 +1709,22 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [new WaitForExternalEventAction("start", ExternalEventType.ExternalEvent)], - ], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [ + new WaitForExternalEventAction( + "start", + ExternalEventType.ExternalEvent + ), + ], + ], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1678,14 +1745,23 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [new WaitForExternalEventAction("start", ExternalEventType.ExternalEvent)], - [new CallActivityAction("Hello", name)], - ], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [ + new WaitForExternalEventAction( + "start", + ExternalEventType.ExternalEvent + ), + ], + [new CallActivityAction("Hello", name)], + ], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1706,13 +1782,22 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [new WaitForExternalEventAction("start", ExternalEventType.ExternalEvent)], - ], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [ + new WaitForExternalEventAction( + "start", + ExternalEventType.ExternalEvent + ), + ], + ], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); @@ -1734,14 +1819,18 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [new CallActivityAction("GetFileList", "C:\\Dev")], - filePaths.map((file) => new CallActivityAction("GetFileSize", file)), - ], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [new CallActivityAction("GetFileList", "C:\\Dev")], + filePaths.map((file) => new CallActivityAction("GetFileSize", file)), + ], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1761,14 +1850,18 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - output: undefined, - actions: [ - [new CallActivityAction("GetFileList", "C:\\Dev")], - filePaths.map((file) => new CallActivityAction("GetFileSize", file)), - ], - }) + new OrchestratorState( + { + isDone: false, + output: undefined, + actions: [ + [new CallActivityAction("GetFileList", "C:\\Dev")], + filePaths.map((file) => new CallActivityAction("GetFileSize", file)), + ], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1785,14 +1878,18 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [new CallActivityAction("GetFileList", "C:\\Dev")], - filePaths.map((file) => new CallActivityAction("GetFileSize", file)), - ], - output: 6, - }) + new OrchestratorState( + { + isDone: true, + actions: [ + [new CallActivityAction("GetFileList", "C:\\Dev")], + filePaths.map((file) => new CallActivityAction("GetFileSize", file)), + ], + output: 6, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1808,8 +1905,8 @@ describe("Orchestrator", () => { const expectedErr1 = "Activity function 'GetFileSize' failed: Could not find file file2.png"; - const expectedErr2 = - "Activity function 'GetFileSize' failed: Could not find file file3.csx"; + // const expectedErr2 = + // "Activity function 'GetFileSize' failed: Could not find file file3.csx"; orchestrator(mockContext); @@ -1828,7 +1925,7 @@ describe("Orchestrator", () => { }); expect(orchestrationState.error).to.include(expectedErr1); - expect(orchestrationState.error).to.include(expectedErr2); + // expect(orchestrationState.error).to.include(expectedErr2); }); it("Task.any proceeds if a scheduled parallel task completes in order", async () => { @@ -1844,16 +1941,20 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [ - new CallActivityAction("TaskA", true), - new CallActivityAction("TaskB", true), + new OrchestratorState( + { + isDone: true, + actions: [ + [ + new CallActivityAction("TaskA", true), + new CallActivityAction("TaskB", true), + ], ], - ], - output: "A", - }) + output: "A", + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1870,16 +1971,20 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [ - new CallActivityAction("TaskA", false), - new CallActivityAction("TaskB", false), + new OrchestratorState( + { + isDone: true, + actions: [ + [ + new CallActivityAction("TaskA", false), + new CallActivityAction("TaskB", false), + ], ], - ], - output: "B", - }) + output: "B", + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1896,16 +2001,20 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [ - new CallActivityAction("TaskA", true), - new CallActivityAction("TaskB", true), + new OrchestratorState( + { + isDone: true, + actions: [ + [ + new CallActivityAction("TaskA", true), + new CallActivityAction("TaskB", true), + ], ], - ], - output: "A", - }) + output: "A", + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1922,17 +2031,21 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - actions: [ - [ - new WaitForExternalEventAction("firstRequiredEvent"), - new WaitForExternalEventAction("secondRequiredEvent"), - new CreateTimerAction(initialTime.add(300, "s").toDate()), + new OrchestratorState( + { + isDone: false, + actions: [ + [ + new WaitForExternalEventAction("firstRequiredEvent"), + new WaitForExternalEventAction("secondRequiredEvent"), + new CreateTimerAction(initialTime.add(300, "s").toDate()), + ], ], - ], - output: undefined, - }) + output: undefined, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); mockContext = new MockContext({ @@ -1944,18 +2057,22 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - actions: [ - [ - new WaitForExternalEventAction("firstRequiredEvent"), - new WaitForExternalEventAction("secondRequiredEvent"), - new CreateTimerAction(initialTime.add(300, "s").toDate()), + new OrchestratorState( + { + isDone: false, + actions: [ + [ + new WaitForExternalEventAction("firstRequiredEvent"), + new WaitForExternalEventAction("secondRequiredEvent"), + new CreateTimerAction(initialTime.add(300, "s").toDate()), + ], + [new CallActivityAction("Hello", "Tokyo")], ], - [new CallActivityAction("Hello", "Tokyo")], - ], - output: undefined, - }) + output: undefined, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -1972,17 +2089,21 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - actions: [ - [ - new WaitForExternalEventAction("firstRequiredEvent"), - new WaitForExternalEventAction("secondRequiredEvent"), - new CreateTimerAction(initialTime.add(300, "s").toDate()), + new OrchestratorState( + { + isDone: false, + actions: [ + [ + new WaitForExternalEventAction("firstRequiredEvent"), + new WaitForExternalEventAction("secondRequiredEvent"), + new CreateTimerAction(initialTime.add(300, "s").toDate()), + ], ], - ], - output: undefined, - }) + output: undefined, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); mockContext = new MockContext({ @@ -1994,17 +2115,21 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [ - new WaitForExternalEventAction("firstRequiredEvent"), - new WaitForExternalEventAction("secondRequiredEvent"), - new CreateTimerAction(initialTime.add(300, "s").toDate()), + new OrchestratorState( + { + isDone: true, + actions: [ + [ + new WaitForExternalEventAction("firstRequiredEvent"), + new WaitForExternalEventAction("secondRequiredEvent"), + new CreateTimerAction(initialTime.add(300, "s").toDate()), + ], ], - ], - output: ["timeout"], - }) + output: ["timeout"], + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -2021,16 +2146,20 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [ - new CallActivityAction("TaskA", true), - new CallActivityAction("TaskB", true), + new OrchestratorState( + { + isDone: true, + actions: [ + [ + new CallActivityAction("TaskA", true), + new CallActivityAction("TaskB", true), + ], ], - ], - output: "A", - }) + output: "A", + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -2049,16 +2178,20 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - actions: [ - [ - new CreateTimerAction(currentTime.add(1, "s").toDate()), - new CallActivityAction("TaskA"), + new OrchestratorState( + { + isDone: false, + actions: [ + [ + new CreateTimerAction(currentTime.add(1, "s").toDate()), + new CallActivityAction("TaskA"), + ], ], - ], - output: undefined, - }) + output: undefined, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); // second iteration @@ -2072,17 +2205,21 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - actions: [ - [ - new CreateTimerAction(currentTime.add(1, "s").toDate()), - new CallActivityAction("TaskA"), + new OrchestratorState( + { + isDone: false, + actions: [ + [ + new CreateTimerAction(currentTime.add(1, "s").toDate()), + new CallActivityAction("TaskA"), + ], + [new CallActivityAction("TaskB")], ], - [new CallActivityAction("TaskB")], - ], - output: undefined, - }) + output: undefined, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); // third iteration @@ -2096,17 +2233,21 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - actions: [ - [ - new CreateTimerAction(currentTime.add(1, "s").toDate()), - new CallActivityAction("TaskA"), + new OrchestratorState( + { + isDone: false, + actions: [ + [ + new CreateTimerAction(currentTime.add(1, "s").toDate()), + new CallActivityAction("TaskA"), + ], + [new CallActivityAction("TaskB")], ], - [new CallActivityAction("TaskB")], - ], - output: undefined, - }) + output: undefined, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); // final iteration @@ -2120,17 +2261,21 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [ - new CreateTimerAction(currentTime.add(1, "s").toDate()), - new CallActivityAction("TaskA"), + new OrchestratorState( + { + isDone: true, + actions: [ + [ + new CreateTimerAction(currentTime.add(1, "s").toDate()), + new CallActivityAction("TaskA"), + ], + [new CallActivityAction("TaskB")], ], - [new CallActivityAction("TaskB")], - ], - output: {}, - }) + output: {}, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); @@ -2149,16 +2294,20 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: false, - actions: [ - [ - new CreateTimerAction(currentTime.add(1, "s").toDate()), - new CallActivityAction("TaskA"), + new OrchestratorState( + { + isDone: false, + actions: [ + [ + new CreateTimerAction(currentTime.add(1, "s").toDate()), + new CallActivityAction("TaskA"), + ], ], - ], - output: undefined, - }) + output: undefined, + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); // second iteration @@ -2172,16 +2321,20 @@ describe("Orchestrator", () => { orchestrator(mockContext); expect(mockContext.doneValue).to.be.deep.equal( - new OrchestratorState({ - isDone: true, - actions: [ - [ - new CreateTimerAction(currentTime.add(1, "s").toDate()), - new CallActivityAction("TaskA"), + new OrchestratorState( + { + isDone: true, + actions: [ + [ + new CreateTimerAction(currentTime.add(1, "s").toDate()), + new CallActivityAction("TaskA"), + ], ], - ], - output: "Timer finished", - }) + output: "Timer finished", + schemaVersion: UpperSchemaVersion.V1, + }, + true + ) ); }); }); diff --git a/test/testobjects/TestOrchestrations.ts b/test/testobjects/TestOrchestrations.ts index 697e921..10d2b46 100644 --- a/test/testobjects/TestOrchestrations.ts +++ b/test/testobjects/TestOrchestrations.ts @@ -165,6 +165,7 @@ export class TestOrchestrations { const input = context.df.getInput(); const task = context.df.callActivity("Hello", input); yield task; + yield task; return yield task; }); diff --git a/test/unit/timertask-spec.ts b/test/unit/timertask-spec.ts index e9a56aa..86e8d60 100644 --- a/test/unit/timertask-spec.ts +++ b/test/unit/timertask-spec.ts @@ -1,30 +1,44 @@ import { expect } from "chai"; import "mocha"; import { CreateTimerAction } from "../../src/classes"; -import { TaskFactory } from "../../src/tasks/taskfactory"; +import { TimerTask } from "../../src/tasks/externalTasks"; +import { InnerTimerTask } from "../../src/tasks/internalTasks"; describe("TimerTask", () => { it("throws cannot cancel a completed task", async () => { - const task = TaskFactory.CompletedTimerTask( - new CreateTimerAction(new Date(), false), - new Date(), - 0, - 5 - ); + const isCancelled = false; + const date = new Date(); + const action = new CreateTimerAction(date, isCancelled); + const innerTask = new InnerTimerTask(0, action); + innerTask.SetValue(false, undefined); // set value to complete task + + const task = new TimerTask(innerTask); + task.internalTask.SetValue(false, undefined); + expect(() => { task.cancel(); }).to.throw("Cannot cancel a completed task."); }); it("cancels an incomplete task", async () => { - const task = TaskFactory.UncompletedTimerTask(new CreateTimerAction(new Date())); + const isCancelled = false; + const date = new Date(); + const action = new CreateTimerAction(date, isCancelled); + const innerTask = new InnerTimerTask(0, action); + + const task = new TimerTask(innerTask); task.cancel(); - expect(task.action.isCanceled).to.equal(true); - expect(task.isCanceled).to.equal(true); + expect(task.isCancelled).to.equal(true); + expect(task.isCancelled).to.equal(true); }); it("is canceled when its action is canceled", async () => { - const task = TaskFactory.UncompletedTimerTask(new CreateTimerAction(new Date(), true)); - expect(task.isCanceled).to.equal(true); + const isCancelled = true; + const date = new Date(); + const action = new CreateTimerAction(date, isCancelled); + const innerTask = new InnerTimerTask(0, action); + + const task = new TimerTask(innerTask); + expect(task.isCancelled).to.equal(true); }); }); From 51a84cf2f1428de44e8cf34635a0042f08fbc951 Mon Sep 17 00:00:00 2001 From: David Justo Date: Thu, 6 Jan 2022 17:03:09 -0800 Subject: [PATCH 2/9] document translation from V2 to V1 actions --- src/durableorchestrationbindinginfo.ts | 5 ++- src/entities/responsemessage.ts | 3 +- src/orchestratorstate.ts | 45 +++++++++++++++++++++----- 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/durableorchestrationbindinginfo.ts b/src/durableorchestrationbindinginfo.ts index 79fd8c7..e14a0f5 100644 --- a/src/durableorchestrationbindinginfo.ts +++ b/src/durableorchestrationbindinginfo.ts @@ -13,10 +13,13 @@ export class DurableOrchestrationBindingInfo { public readonly parentInstanceId?: string, upperSchemaVersion = 0 // TODO: Implement entity locking // public readonly contextLocks?: EntityId[], ) { + // If the extension-sent upperSchemaVersion is within the range of values + // we support, we select it. Otherwise, we conclude it's higher than any + // version we support, so we default to our highest version. if (Object.values(UpperSchemaVersion).includes(upperSchemaVersion)) { this.upperSchemaVersion = upperSchemaVersion; } else { - this.upperSchemaVersion = UpperSchemaVersion.V1; + this.upperSchemaVersion = UpperSchemaVersion.V2; } } } diff --git a/src/entities/responsemessage.ts b/src/entities/responsemessage.ts index 0c56180..eb69d09 100644 --- a/src/entities/responsemessage.ts +++ b/src/entities/responsemessage.ts @@ -15,7 +15,8 @@ export class ResponseMessage { } } else { throw Error( - "Attempted to construct ResponseMessage event from incompatible History event. This is probably a bug in History-replay." + "Attempted to construct ResponseMessage event from incompatible History event. This is probably a bug in History-replay." + + "Please file a bug report." ); } } diff --git a/src/orchestratorstate.ts b/src/orchestratorstate.ts index d7981f3..8c87c7d 100644 --- a/src/orchestratorstate.ts +++ b/src/orchestratorstate.ts @@ -12,17 +12,42 @@ export class OrchestratorState implements IOrchestratorState { public readonly customStatus?: unknown; public readonly schemaVersion: UpperSchemaVersion; + /** + * @hidden + * + * It flattens an array of actions. + * By flatten, we mean that, in the presence of a compound action (WhenAll/WhenAny), + * we recursively extract all of its sub-actions into a flat sequence which is then + * put in-place of the original compound action. + * + * For example, given the array: + * [Activity, Activity, WhenAll(Activity, WhenAny(ExternalEvent, Activity))] + * We obtain: + * [Activity, Activity, Activity, ExternalEvent, Activity] + * + * This is helpful in translating the representation of user actions in + * the DF extension replay protocol V2 to V1. + * + * @param actions + * The array of actions to flatten + * @returns + * The flattened array of actions. + */ private flattenCompoundActions(actions: IAction[]): IAction[] { - let v1Actions: IAction[] = []; + let flatActions: IAction[] = []; for (const action of actions) { + // Given any compound action if (action instanceof WhenAllAction || action instanceof WhenAnyAction) { + // We obtain its inner actions as a flat array const innerActionArr = this.flattenCompoundActions(action.compoundActions); - v1Actions = v1Actions.concat(innerActionArr); + // we concatenate the inner actions to the flat array we're building + flatActions = flatActions.concat(innerActionArr); } else { - v1Actions.push(action); + // The action wasn't compound, so it's left intact + flatActions.push(action); } } - return v1Actions; + return flatActions; } constructor(options: IOrchestratorState, _literalActions = false) { @@ -38,15 +63,19 @@ export class OrchestratorState implements IOrchestratorState { if (options.customStatus) { this.customStatus = options.customStatus; } - // Under replay protocol V1, compound actions are treated as lists of actions. + // Under replay protocol V1, compound actions are treated as lists of actions and + // atomic actions are represented as a 1-element lists. + // For example, given actions list: [Activity, WhenAny(ExternalEvent, WhenAll(Timer, Activity))] + // The V1 protocol expects: [[Activity], [ExternalEvent, Timer, Activity]] if (options.schemaVersion === UpperSchemaVersion.V1 && !_literalActions) { - // for backwards compatibility, the actions array from schema V2 and onwards - // is a 2D array with only 1 element: an array of actions. + // We need to transform our V2 action representation to V1. + // In V2, actions are represented as 2D arrays (for legacy reasons) with a singular element: an array of actions. const actions = this.actions[0]; const newActions: IAction[][] = []; - // check against empty array + // guard against empty array, meaning no user actions were scheduled if (actions !== undefined) { for (const action of actions) { + // Each action is represented as an array in V1 let newEntry: IAction[] = []; if (action instanceof WhenAllAction || action instanceof WhenAnyAction) { const innerActionArr = this.flattenCompoundActions(action.compoundActions); From 7bbfd81d500ae3301db4023de7c7edcffc54bf5d Mon Sep 17 00:00:00 2001 From: David Justo Date: Thu, 6 Jan 2022 17:04:32 -0800 Subject: [PATCH 3/9] document testing helper --- src/orchestratorstate.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/orchestratorstate.ts b/src/orchestratorstate.ts index 8c87c7d..e28bbee 100644 --- a/src/orchestratorstate.ts +++ b/src/orchestratorstate.ts @@ -50,6 +50,7 @@ export class OrchestratorState implements IOrchestratorState { return flatActions; } + // literal actions is used exclusively to facilitate testing. If true, the action representation is to be left intact constructor(options: IOrchestratorState, _literalActions = false) { this.isDone = options.isDone; this.actions = options.actions; From 80e5fb7436fd3736f6e294bbf8a4c4a018eca310 Mon Sep 17 00:00:00 2001 From: David Justo Date: Mon, 24 Jan 2022 16:48:16 -0800 Subject: [PATCH 4/9] respond to PR feedback --- src/actions/actiontype.ts | 2 + src/actions/whenallaction.ts | 4 +- src/actions/whenanyaction.ts | 6 +- src/durableorchestrationbindinginfo.ts | 17 ++-- src/durableorchestrationcontext.ts | 111 +++++++++++---------- src/entities/responsemessage.ts | 4 +- src/iorchestratorstate.ts | 4 +- src/orchestrator.ts | 8 +- src/orchestratorstate.ts | 6 +- src/replaySchema.ts | 11 +-- src/{tasks/internalTasks.ts => task.ts} | 125 +++++++++++++++++++----- src/taskorchestrationexecutor.ts | 69 ++++++------- src/tasks/externalTasks.ts | 103 ------------------- test/integration/orchestrator-spec.ts | 112 ++++++++++----------- test/testobjects/TestOrchestrations.ts | 2 +- test/unit/timertask-spec.ts | 17 +--- 16 files changed, 286 insertions(+), 315 deletions(-) rename src/{tasks/internalTasks.ts => task.ts} (74%) delete mode 100644 src/tasks/externalTasks.ts diff --git a/src/actions/actiontype.ts b/src/actions/actiontype.ts index cda2238..88d6d12 100644 --- a/src/actions/actiontype.ts +++ b/src/actions/actiontype.ts @@ -17,6 +17,8 @@ export enum ActionType { WaitForExternalEvent = 6, CallEntity = 7, CallHttp = 8, + // ActionType 9 and 10 correspond to SignalEntity and ScheduledSignalEntity + // Those two are not supported yet. WhenAny = 11, WhenAll = 12, } diff --git a/src/actions/whenallaction.ts b/src/actions/whenallaction.ts index 99c4a62..27fa69c 100644 --- a/src/actions/whenallaction.ts +++ b/src/actions/whenallaction.ts @@ -1,12 +1,12 @@ import { ActionType, IAction } from "../classes"; -import { ProperTask } from "../tasks/internalTasks"; +import { DFTask } from "../task"; /** @hidden */ export class WhenAllAction implements IAction { public readonly actionType: ActionType = ActionType.WhenAll; public readonly compoundActions: IAction[]; - constructor(tasks: ProperTask[]) { + constructor(tasks: DFTask[]) { this.compoundActions = tasks.map((t) => t.actionObj); } } diff --git a/src/actions/whenanyaction.ts b/src/actions/whenanyaction.ts index 7b356fe..5056220 100644 --- a/src/actions/whenanyaction.ts +++ b/src/actions/whenanyaction.ts @@ -1,12 +1,12 @@ import { ActionType, IAction } from "../classes"; -import { ProperTask } from "../tasks/internalTasks"; +import { DFTask } from "../task"; /** @hidden */ export class WhenAnyAction implements IAction { - public readonly actionType: ActionType = ActionType.WhenAll; + public readonly actionType: ActionType = ActionType.WhenAny; public readonly compoundActions: IAction[]; - constructor(tasks: ProperTask[]) { + constructor(tasks: DFTask[]) { this.compoundActions = tasks.map((t) => t.actionObj); } } diff --git a/src/durableorchestrationbindinginfo.ts b/src/durableorchestrationbindinginfo.ts index e14a0f5..0fc23e4 100644 --- a/src/durableorchestrationbindinginfo.ts +++ b/src/durableorchestrationbindinginfo.ts @@ -1,9 +1,9 @@ import { HistoryEvent } from "./classes"; -import { UpperSchemaVersion } from "./replaySchema"; +import { LatestReplaySchema, ReplaySchema } from "./replaySchema"; /** @hidden */ export class DurableOrchestrationBindingInfo { - public readonly upperSchemaVersion: UpperSchemaVersion; + public readonly upperSchemaVersion: ReplaySchema; constructor( public readonly history: HistoryEvent[] = [], @@ -13,13 +13,16 @@ export class DurableOrchestrationBindingInfo { public readonly parentInstanceId?: string, upperSchemaVersion = 0 // TODO: Implement entity locking // public readonly contextLocks?: EntityId[], ) { - // If the extension-sent upperSchemaVersion is within the range of values - // we support, we select it. Otherwise, we conclude it's higher than any - // version we support, so we default to our highest version. - if (Object.values(UpperSchemaVersion).includes(upperSchemaVersion)) { + // It is assumed that the extension supports all schemas in range [0, upperSchemaVersion]. + // Similarly, it is assumed that this SDK supports all schemas in range [0, LatestReplaySchema]. + + // Therefore, if the extension supplies a upperSchemaVersion included in our ReplaySchema enum, we use it. + // But if the extension supplies an upperSchemaVersion not included in our ReplaySchema enum, then we + // assume that upperSchemaVersion is larger than LatestReplaySchema and therefore use LatestReplaySchema instead. + if (Object.values(ReplaySchema).includes(upperSchemaVersion)) { this.upperSchemaVersion = upperSchemaVersion; } else { - this.upperSchemaVersion = UpperSchemaVersion.V2; + this.upperSchemaVersion = LatestReplaySchema; } } } diff --git a/src/durableorchestrationcontext.ts b/src/durableorchestrationcontext.ts index 39e7427..3fc5e73 100644 --- a/src/durableorchestrationcontext.ts +++ b/src/durableorchestrationcontext.ts @@ -23,10 +23,12 @@ import { WhenAllTask, WhenAnyTask, AtomicTask, - RetryAbleTask, - InnerTimerTask, -} from "./tasks/internalTasks"; -import { Task, TimerTask } from "./tasks/externalTasks"; + RetryableTask, + TimerTask, + ITask, + ITimerTask, + DFTask, +} from "./task"; /** * Parameter data for orchestration bindings that can be used to schedule @@ -99,26 +101,47 @@ export class DurableOrchestrationContext { */ public currentUtcDateTime: Date; + /** + * @hidden + * This method informs the type-checker that an ITask[] can be treated as DFTask[]. + * This is required for type-checking in the Task.all and Task.any method bodies while + * preventing the DFTask type from being exported to users. + * @param tasks + */ + private isDFTaskArray(tasks: ITask[]): tasks is DFTask[] { + return tasks.every((x) => x instanceof DFTask); + } + /** * Just an entry point to reference the methods in [[ITaskMethods]]. * Methods to handle collections of pending actions represented by [[Task]] * instances. For use in parallelization operations. */ public Task = { - all: (tasks: Task[]): Task => { - const internalTasks = tasks.map((t) => t.internalTask); - const action = new WhenAllAction(internalTasks); - const innerTask = new WhenAllTask(internalTasks, action); - const task = new Task(innerTask); - return task; + all: (tasks: ITask[]): ITask => { + if (this.isDFTaskArray(tasks)) { + const action = new WhenAllAction(tasks); + const task = new WhenAllTask(tasks, action); + return task; + } + throw Error( + "Task.all received a non-valid input. " + + "This may occur if it somehow received a non-list input, " + + "or if the input list's Tasks were corrupted. Please review your orchestrator code and/or file an issue." + ); }, - any: (tasks: Task[]): Task => { - const internalTasks = tasks.map((t) => t.internalTask); - const action = new WhenAnyAction(internalTasks); - const innerTask = new WhenAnyTask(internalTasks, action); - const task = new Task(innerTask); - return task; + any: (tasks: ITask[]): ITask => { + if (this.isDFTaskArray(tasks)) { + const action = new WhenAnyAction(tasks); + const task = new WhenAnyTask(tasks, action); + return task; + } + throw Error( + "Task.any received a non-valid input. " + + "This may occur if it somehow received a non-list input, " + + "or if the input list's Tasks were corrupted. Please review your orchestrator code and/or file an issue." + ); }, }; @@ -131,10 +154,9 @@ export class DurableOrchestrationContext { * @returns A Durable Task that completes when the called activity * function completes or fails. */ - public callActivity(name: string, input?: unknown): Task { + public callActivity(name: string, input?: unknown): ITask { const newAction = new CallActivityAction(name, input); - const innerTask = new AtomicTask(false, newAction); - const task = new Task(innerTask); + const task = new AtomicTask(false, newAction); return task; } @@ -147,16 +169,11 @@ export class DurableOrchestrationContext { * @param input The JSON-serializable input to pass to the activity * function. */ - public callActivityWithRetry(name: string, retryOptions: RetryOptions, input?: unknown): Task { + public callActivityWithRetry(name: string, retryOptions: RetryOptions, input?: unknown): ITask { const newAction = new CallActivityWithRetryAction(name, retryOptions, input); const backingTask = new AtomicTask(false, newAction); - const innerTask = new RetryAbleTask( - backingTask, - retryOptions, - this.taskOrchestratorExecutor - ); - const task = new Task(innerTask); - return task; // We'll have to change the interface + const task = new RetryableTask(backingTask, retryOptions, this.taskOrchestratorExecutor); + return task; } /** @@ -167,10 +184,9 @@ export class DurableOrchestrationContext { * @param operationName The name of the operation. * @param operationInput The input for the operation. */ - public callEntity(entityId: EntityId, operationName: string, operationInput?: unknown): Task { + public callEntity(entityId: EntityId, operationName: string, operationInput?: unknown): ITask { const newAction = new CallEntityAction(entityId, operationName, operationInput); - const innerTask = new AtomicTask(false, newAction); - const task = new Task(innerTask); + const task = new AtomicTask(false, newAction); return task; } @@ -184,7 +200,7 @@ export class DurableOrchestrationContext { * If `instanceId` is not specified, the extension will generate an id in * the format `:<#>` */ - public callSubOrchestrator(name: string, input?: unknown, instanceId?: string): Task { + public callSubOrchestrator(name: string, input?: unknown, instanceId?: string): ITask { if (!name) { throw new Error( "A sub-orchestration function name must be provided when attempting to create a suborchestration" @@ -192,8 +208,7 @@ export class DurableOrchestrationContext { } const newAction = new CallSubOrchestratorAction(name, instanceId, input); - const innerTask = new AtomicTask(false, newAction); - const task = new Task(innerTask); + const task = new AtomicTask(false, newAction); return task; } @@ -212,7 +227,7 @@ export class DurableOrchestrationContext { retryOptions: RetryOptions, input?: unknown, instanceId?: string - ): Task { + ): ITask { if (!name) { throw new Error( "A sub-orchestration function name must be provided when attempting to create a suborchestration" @@ -226,12 +241,7 @@ export class DurableOrchestrationContext { instanceId ); const backingTask = new AtomicTask(false, newAction); - const innerTask = new RetryAbleTask( - backingTask, - retryOptions, - this.taskOrchestratorExecutor - ); - const task = new Task(innerTask); + const task = new RetryableTask(backingTask, retryOptions, this.taskOrchestratorExecutor); return task; } @@ -246,15 +256,14 @@ export class DurableOrchestrationContext { content?: string | object, headers?: { [key: string]: string }, tokenSource?: TokenSource - ): Task { + ): ITask { if (content && typeof content !== "string") { content = JSON.stringify(content); } const req = new DurableHttpRequest(method, uri, content as string, headers, tokenSource); const newAction = new CallHttpAction(req); - const innerTask = new AtomicTask(false, newAction); - const task = new Task(innerTask); + const task = new AtomicTask(false, newAction); return task; } @@ -265,10 +274,8 @@ export class DurableOrchestrationContext { */ public continueAsNew(input: unknown): void { const newAction = new ContinueAsNewAction(input); - if (this.taskOrchestratorExecutor !== undefined) { - this.taskOrchestratorExecutor.addToActions(newAction); - this.taskOrchestratorExecutor.willContinueAsNew = true; - } + this.taskOrchestratorExecutor.addToActions(newAction); + this.taskOrchestratorExecutor.willContinueAsNew = true; } /** @@ -285,10 +292,9 @@ export class DurableOrchestrationContext { * @param fireAt The time at which the timer should expire. * @returns A TimerTask that completes when the durable timer expires. */ - public createTimer(fireAt: Date): TimerTask { + public createTimer(fireAt: Date): ITimerTask { const newAction = new CreateTimerAction(fireAt); - const innerTask = new InnerTimerTask(false, newAction); - const task = new TimerTask(innerTask); + const task = new TimerTask(false, newAction); return task; } @@ -341,10 +347,9 @@ export class DurableOrchestrationContext { * External clients can raise events to a waiting orchestration instance * using [[raiseEvent]]. */ - public waitForExternalEvent(name: string): Task { + public waitForExternalEvent(name: string): ITask { const newAction = new WaitForExternalEventAction(name, ExternalEventType.ExternalEvent); - const innerTask = new AtomicTask(name, newAction); - const task = new Task(innerTask); + const task = new AtomicTask(name, newAction); return task; } } diff --git a/src/entities/responsemessage.ts b/src/entities/responsemessage.ts index eb69d09..9b3f73c 100644 --- a/src/entities/responsemessage.ts +++ b/src/entities/responsemessage.ts @@ -15,8 +15,8 @@ export class ResponseMessage { } } else { throw Error( - "Attempted to construct ResponseMessage event from incompatible History event. This is probably a bug in History-replay." + - "Please file a bug report." + "Attempted to construct ResponseMessage event from incompatible History event. " + + "This is probably a bug in History-replay. Please file a bug report." ); } } diff --git a/src/iorchestratorstate.ts b/src/iorchestratorstate.ts index c001b40..5e70118 100644 --- a/src/iorchestratorstate.ts +++ b/src/iorchestratorstate.ts @@ -1,5 +1,5 @@ import { IAction } from "./classes"; -import { UpperSchemaVersion, ReplaySchema } from "./replaySchema"; +import { ReplaySchema } from "./replaySchema"; /** @hidden */ export interface IOrchestratorState { @@ -9,5 +9,5 @@ export interface IOrchestratorState { error?: string; customStatus?: unknown; replaySchema?: ReplaySchema | undefined; - schemaVersion: UpperSchemaVersion; + schemaVersion: ReplaySchema; } diff --git a/src/orchestrator.ts b/src/orchestrator.ts index 5fd44be..09fb1c1 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -7,13 +7,17 @@ import { } from "./classes"; import { DurableOrchestrationContext } from "./durableorchestrationcontext"; import { TaskOrchestrationExecutor } from "./taskorchestrationexecutor"; -import { UpperSchemaVersion } from "./replaySchema"; +import { ReplaySchema } from "./replaySchema"; /** @hidden */ export class Orchestrator { private currentUtcDateTime: Date; private taskOrchestrationExecutor: TaskOrchestrationExecutor; + // Our current testing infrastructure depends on static unit testing helpers that don't play + // nicely with Orchestrator data being initialized in the constructor: state may preserved + // across unit test runs. + // As a result, we are currently constrained to initialize all of our data in the `handle` method. constructor(public fn: (context: IOrchestrationFunctionContext) => IterableIterator) {} public listen(): (context: IOrchestrationFunctionContext) => Promise { @@ -38,7 +42,7 @@ export class Orchestrator { // The upper schema version corresponds to the maximum OOProc protocol version supported by the extension, // we use it to determine the format of the SDK's output - const upperSchemaVersion: UpperSchemaVersion = orchestrationBinding.upperSchemaVersion; + const upperSchemaVersion: ReplaySchema = orchestrationBinding.upperSchemaVersion; // Initialize currentUtcDateTime const decisionStartedEvent: HistoryEvent = Utils.ensureNonNull( diff --git a/src/orchestratorstate.ts b/src/orchestratorstate.ts index e28bbee..ae923ce 100644 --- a/src/orchestratorstate.ts +++ b/src/orchestratorstate.ts @@ -1,7 +1,7 @@ import { WhenAllAction } from "./actions/whenallaction"; import { WhenAnyAction } from "./actions/whenanyaction"; import { IAction, IOrchestratorState } from "./classes"; -import { UpperSchemaVersion } from "./replaySchema"; +import { ReplaySchema } from "./replaySchema"; /** @hidden */ export class OrchestratorState implements IOrchestratorState { @@ -10,7 +10,7 @@ export class OrchestratorState implements IOrchestratorState { public readonly output: unknown; public readonly error?: string; public readonly customStatus?: unknown; - public readonly schemaVersion: UpperSchemaVersion; + public readonly schemaVersion: ReplaySchema; /** * @hidden @@ -68,7 +68,7 @@ export class OrchestratorState implements IOrchestratorState { // atomic actions are represented as a 1-element lists. // For example, given actions list: [Activity, WhenAny(ExternalEvent, WhenAll(Timer, Activity))] // The V1 protocol expects: [[Activity], [ExternalEvent, Timer, Activity]] - if (options.schemaVersion === UpperSchemaVersion.V1 && !_literalActions) { + if (options.schemaVersion === ReplaySchema.V1 && !_literalActions) { // We need to transform our V2 action representation to V1. // In V2, actions are represented as 2D arrays (for legacy reasons) with a singular element: an array of actions. const actions = this.actions[0]; diff --git a/src/replaySchema.ts b/src/replaySchema.ts index fea8cde..637d8fb 100644 --- a/src/replaySchema.ts +++ b/src/replaySchema.ts @@ -2,16 +2,9 @@ * @hidden * Supported OOProc DF extension protocols */ -export enum UpperSchemaVersion { +export enum ReplaySchema { V1 = 0, V2 = 1, } -/** - * @hidden - * The support DF extension OOProc replay schemas supported by this SDK - */ -export enum ReplaySchema { - V1, - V2, -} +export const LatestReplaySchema: ReplaySchema = ReplaySchema.V2; diff --git a/src/tasks/internalTasks.ts b/src/task.ts similarity index 74% rename from src/tasks/internalTasks.ts rename to src/task.ts index bd595e0..ec12dc0 100644 --- a/src/tasks/internalTasks.ts +++ b/src/task.ts @@ -1,7 +1,6 @@ -import { RetryOptions } from ".."; -import { IAction, CreateTimerAction } from "../classes"; -import { TaskOrchestrationExecutor } from "../taskorchestrationexecutor"; -import { Task } from "./externalTasks"; +import { RetryOptions } from "."; +import { IAction, CreateTimerAction } from "./classes"; +import { TaskOrchestrationExecutor } from "./taskorchestrationexecutor"; /** * @hidden @@ -27,6 +26,76 @@ export type TaskID = number | string | false; */ export type BackingAction = IAction | "noOp"; +/** + * A Durable Functions Task. + */ +export interface ITask { + /** + * Whether the task has completed. Note that completion is not + * equivalent to success. + */ + isCompleted: boolean; + /** + * Whether the task faulted in some way due to error. + */ + isFaulted: boolean; + /** + * The result of the task, if completed. Otherwise `undefined`. + */ + result?: unknown; +} + +/** + * Returned from [[DurableOrchestrationClient]].[[createTimer]] if the call is + * not `yield`-ed. Represents a pending timer. See documentation on [[Task]] + * for more information. + * + * All pending timers must be completed or canceled for an orchestration to + * complete. + * + * @example Cancel a timer + * ```javascript + * // calculate expiration date + * const timeoutTask = context.df.createTimer(expirationDate); + * + * // do some work + * + * if (!timeoutTask.isCompleted) { + * timeoutTask.cancel(); + * } + * ``` + * + * @example Create a timeout + * ```javascript + * const now = Date.now(); + * const expiration = new Date(now.valueOf()).setMinutes(now.getMinutes() + 30); + * + * const timeoutTask = context.df.createTimer(expirationDate); + * const otherTask = context.df.callActivity("DoWork"); + * + * const winner = yield context.df.Task.any([timeoutTask, otherTask]); + * + * if (winner === otherTask) { + * // do some more work + * } + * + * if (!timeoutTask.isCompleted) { + * timeoutTask.cancel(); + * } + * ``` + */ +export interface ITimerTask extends ITask { + /** + * @returns Whether or not the timer has been canceled. + */ + isCancelled: boolean; + /** + * Indicates the timer should be canceled. This request will execute on the + * next `yield` or `return` statement. + */ + cancel: () => void; +} + /** * @hidden * Base class for all Tasks, defines the basic state transitions for all tasks. @@ -61,10 +130,18 @@ export abstract class TaskBase { } /** Whether this task is not in the Running state */ - get isCompleted(): boolean { + get hasResult(): boolean { return this.state !== TaskState.Running; } + get isFaulted(): boolean { + return this.state === TaskState.Failed; + } + + get isCompleted(): boolean { + return this.state === TaskState.Completed; + } + /** Change this task from the Running state to a completed state */ private changeState(state: TaskState): void { if (state === TaskState.Running) { @@ -74,7 +151,7 @@ export abstract class TaskBase { } /** Attempt to set a result for this task, and notifies parents, if any */ - public SetValue(isError: boolean, value: unknown): void { + public setValue(isError: boolean, value: unknown): void { let newState: TaskState; if (isError) { @@ -116,7 +193,7 @@ export abstract class TaskBase { * DF APIs that decompose into smaller DF APIs that the user didn't explicitly * schedule. */ -export class InternalOnlyTask extends TaskBase { +export class NoOpTask extends TaskBase { constructor() { super(false, "noOp"); } @@ -126,9 +203,8 @@ export class InternalOnlyTask extends TaskBase { * @hidden * A task that should result in an Action being communicated to the DF extension. */ -export class ProperTask extends TaskBase { +export class DFTask extends TaskBase implements ITask { protected action: IAction; - public externalTask: Task | undefined; /** Get this task's backing action */ get actionObj(): IAction { @@ -141,7 +217,7 @@ export class ProperTask extends TaskBase { * * A task that depends on the completion of other (sub-) tasks. */ -export abstract class CompoundTask extends ProperTask { +export abstract class CompoundTask extends DFTask { protected firstError: Error | undefined; /** @@ -189,14 +265,14 @@ export abstract class CompoundTask extends ProperTask { abstract trySetValue(child: TaskBase): void; } -export class AtomicTask extends ProperTask {} +export class AtomicTask extends DFTask {} /** * @hidden * A timer task. This is the internal interface to the user-exposed TimerTask, which * has a more restricted API. */ -export class InnerTimerTask extends AtomicTask { +export class TimerTask extends AtomicTask implements ITimerTask { /** * @hidden * Construct a Timer Task. @@ -221,7 +297,7 @@ export class InnerTimerTask extends AtomicTask { * It errors out if the task has already completed. */ public cancel(): void { - if (this.isCompleted) { + if (this.hasResult) { throw Error("Cannot cancel a completed task."); } this.action.isCancelled = true; // TODO: fix typo @@ -260,13 +336,13 @@ export class WhenAllTask extends CompoundTask { if (this.children.every((c) => c.stateObj === TaskState.Completed)) { // The result is a list of all sub-task's results const results = this.children.map((c) => c.result); - this.SetValue(false, results); + this.setValue(false, results); } } else { // If any task failed, we fail the entire compound task if (this.firstError === undefined) { this.firstError = child.result as Error; - this.SetValue(true, this.firstError); + this.setValue(true, this.firstError); } } } @@ -286,8 +362,13 @@ export class WhenAnyTask extends CompoundTask { * The sub-task that just completed */ public trySetValue(child: TaskBase): void { + // For a Task to have isError = true, it needs to contain within an Exception/Error datatype. + // However, WhenAny only contains Tasks as its result, so technically it "never errors out". + // The isError flag is used simply to determine if the result of the task should be fed in + // as a value, or as a raised exception to the generator code. For WhenAny, we always feed + // in the result as a value. if (this.state === TaskState.Running) { - this.SetValue(false, child); + this.setValue(false, child); } } } @@ -300,7 +381,7 @@ export class WhenAnyTask extends CompoundTask { * into several sub-tasks (a growing sequence of timers and atomic tasks) * that all need to complete before this task reaches an end-value. */ -export class RetryAbleTask extends WhenAllTask { +export class RetryableTask extends WhenAllTask { private isWaitingOnTimer: boolean; private numAttempts: number; private error: any; @@ -318,7 +399,7 @@ export class RetryAbleTask extends WhenAllTask { * we use to to scheduling new tasks (timers and retries) */ constructor( - public innerTask: ProperTask, + public innerTask: DFTask, private retryOptions: RetryOptions, private executor: TaskOrchestrationExecutor ) { @@ -341,12 +422,12 @@ export class RetryAbleTask extends WhenAllTask { // If we're out of retry attempts, we can set the output value // of this task to be that of the last error we encountered if (this.numAttempts >= this.retryOptions.maxNumberOfAttempts) { - this.SetValue(true, this.error); + this.setValue(true, this.error); } else { // If we still have more attempts available, we re-schedule the // original task. Since these sub-tasks are not user-managed, // they are declared as internal tasks. - const rescheduledTask = new InternalOnlyTask(); + const rescheduledTask = new NoOpTask(); rescheduledTask.parent = this; this.children.push(rescheduledTask); this.executor.trackOpenTask(rescheduledTask); @@ -354,11 +435,11 @@ export class RetryAbleTask extends WhenAllTask { } } else if (child.stateObj === TaskState.Completed) { // If we have a successful non-timer task, we accept its result - this.SetValue(false, child.result); + this.setValue(false, child.result); } else { // If the sub-task failed, schedule timer to retry again. // Since these sub-tasks are not user-managed, they are declared as internal tasks. - const rescheduledTask = new InternalOnlyTask(); + const rescheduledTask = new NoOpTask(); rescheduledTask.parent = this; this.children.push(rescheduledTask); this.executor.trackOpenTask(rescheduledTask); diff --git a/src/taskorchestrationexecutor.ts b/src/taskorchestrationexecutor.ts index f5882e9..95c6744 100644 --- a/src/taskorchestrationexecutor.ts +++ b/src/taskorchestrationexecutor.ts @@ -15,16 +15,9 @@ import { } from "./classes"; import { OrchestrationFailureError } from "./orchestrationfailureerror"; import { OrchestratorState } from "./orchestratorstate"; -import { - TaskBase, - InternalOnlyTask, - ProperTask, - CompoundTask, - TaskState, -} from "./tasks/internalTasks"; -import { UpperSchemaVersion } from "./replaySchema"; +import { TaskBase, NoOpTask, DFTask, CompoundTask, TaskState } from "./task"; +import { ReplaySchema } from "./replaySchema"; import { Utils } from "./utils"; -import { Task } from "./tasks/externalTasks"; /** * @hidden @@ -39,7 +32,7 @@ export class TaskOrchestrationExecutor { private generator: Generator; private deferredTasks: Record void>; private sequenceNumber: number; - private schemaVersion: UpperSchemaVersion; + private schemaVersion: ReplaySchema; public willContinueAsNew: boolean; private actions: IAction[]; protected openTasks: Record; @@ -69,8 +62,8 @@ export class TaskOrchestrationExecutor { */ private initialize(): void { // The very first task, to kick-start the generator, is just a dummy/no-op task - this.currentTask = new InternalOnlyTask(); - this.currentTask.SetValue(false, undefined); + this.currentTask = new NoOpTask(); + this.currentTask.setValue(false, undefined); this.sequenceNumber = 0; this.willContinueAsNew = false; @@ -104,7 +97,7 @@ export class TaskOrchestrationExecutor { public async execute( context: IOrchestrationFunctionContext, history: HistoryEvent[], - schemaVersion: UpperSchemaVersion, + schemaVersion: ReplaySchema, fn: (context: IOrchestrationFunctionContext) => IterableIterator ): Promise { this.schemaVersion = schemaVersion; @@ -114,7 +107,7 @@ export class TaskOrchestrationExecutor { // Execute the orchestration, using the history for replay for (const historyEvent of history) { this.processEvent(historyEvent); - if (this.hasExecutionCompleted()) { + if (this.isDoneExecuting()) { break; } } @@ -122,7 +115,7 @@ export class TaskOrchestrationExecutor { // Construct current orchestration state const actions: IAction[][] = this.actions.length == 0 ? [] : [this.actions]; const orchestratorState = new OrchestratorState({ - isDone: this.orchestrationInvocationCompleted(), + isDone: this.hasCompletedSuccessfully(), actions: actions, output: this.output, error: this.exception?.message, @@ -151,8 +144,8 @@ export class TaskOrchestrationExecutor { * True if the orchestration's invocation completed, or if an unhandled exception was thrown. * False otherwise. */ - private hasExecutionCompleted(): boolean { - return this.orchestrationInvocationCompleted() || this.exception !== undefined; + private isDoneExecuting(): boolean { + return this.hasCompletedSuccessfully() || this.exception !== undefined; } /** @@ -163,7 +156,7 @@ export class TaskOrchestrationExecutor { * True if the orchestration reached a `return` statement, or a `continueAsNew`. * False otherwise. */ - private orchestrationInvocationCompleted(): boolean { + private hasCompletedSuccessfully(): boolean { return this.orchestratorReturned || this.willContinueAsNew; } @@ -177,7 +170,7 @@ export class TaskOrchestrationExecutor { */ private processEvent(event: HistoryEvent): void { const eventType = event.EventType; - switch (event.EventType) { + switch (eventType) { case HistoryEventType.OrchestratorStarted: { const timestamp = event.Timestamp; if (timestamp > this.context.currentUtcDateTime) { @@ -294,13 +287,12 @@ export class TaskOrchestrationExecutor { } else if (key in this.openEvents) { // Obtain task from open events const taskList = this.openEvents[key]; - delete this.openEvents[key]; task = taskList.pop() as TaskBase; // We ensure openEvents only has an entry for this key if // there's at least 1 task to consume - if (taskList.length > 0) { - this.openEvents[key] = taskList; + if (taskList.length == 0) { + delete this.openEvents[key]; } } else { // If the task is in neither open tasks nor open events, then it must @@ -317,7 +309,7 @@ export class TaskOrchestrationExecutor { } else { throw Error( `Task with ID ${key} could not be retrieved from due to its ID-key being of type ${typeof key}` + - `We expect ID-keys to be of type number or string.` + + `We expect ID-keys to be of type number or string. ` + `This is probably a replay failure, please file a bug report.` ); } @@ -361,7 +353,7 @@ export class TaskOrchestrationExecutor { // Set result to the task, and update it's isPlayed flag. task.isPlayed = event.IsPlayed; - task.SetValue(!isSuccess, taskResult); + task.setValue(!isSuccess, taskResult); } /** @@ -377,16 +369,13 @@ export class TaskOrchestrationExecutor { return; } - // The feed in the result of the current task to the generator + // We feed in the result of the current task to the generator let newTask: TaskBase | undefined = undefined; try { // In the WhenAny-case, the result of the current task is another Task. // Here, we make sure not to expose the internal task class by extracting // the user-facing representation of the task. - let result = currentTask.result; - if (result instanceof ProperTask) { - result = result.externalTask; - } + const result = currentTask.result; const taskValue = result; const taskSucceeded = currentTask.stateObj === TaskState.Completed; @@ -402,9 +391,15 @@ export class TaskOrchestrationExecutor { this.orchestratorReturned = true; this.output = generatorResult.value; return; - } else if (generatorResult.value instanceof Task) { + } else if (generatorResult.value instanceof DFTask) { // The generator yielded another task. - newTask = generatorResult.value.internalTask; + newTask = generatorResult.value; + } else { + // non-task was yielded. This isn't supported + throw Error( + `Orchestration yielded data of type ${typeof generatorResult.value}. Only Task types can be yielded.` + + "Please refactor your orchestration to yield only Tasks." + ); } } catch (exception) { // The generator threw an exception @@ -422,7 +417,7 @@ export class TaskOrchestrationExecutor { this.trackOpenTask(newTask); // We only keep track of actions from user-declared tasks, not from // tasks generated internally to facilitate history-processing. - if (this.currentTask instanceof ProperTask) { + if (this.currentTask instanceof DFTask) { this.addToActions(this.currentTask.actionObj); } } @@ -452,9 +447,7 @@ export class TaskOrchestrationExecutor { * @param task * Task to add to open tasks or open events list */ - public trackOpenTask(task: InternalOnlyTask | ProperTask): void { - // TODO: should there be a check for the task status as RUNNING? - + public trackOpenTask(task: NoOpTask | DFTask): void { // The open tasks and open events objects only track singular tasks, not compound ones. // Therefore, for a compound task, we recurse down to its inner sub-tasks add // record all singular tasks. @@ -468,9 +461,9 @@ export class TaskOrchestrationExecutor { task.id = this.sequenceNumber++; this.openTasks[task.id] = task; } else if (task.actionObj instanceof WaitForExternalEventAction) { - // The ID of `WaitForExternalEvent` tasks is the name of - // the external event. Given that multiple `WaitForExternalEvent` - // tasks can be led for the same event name at once, we need + // The ID of a `WaitForExternalEvent` task is the name of + // the external event it awaits. Given that multiple `WaitForExternalEvent` + // tasks can await the same event name at once, we need // to store these tasks as a list. // Obtain the current list of tasks for this external event name. diff --git a/src/tasks/externalTasks.ts b/src/tasks/externalTasks.ts deleted file mode 100644 index dacb0b1..0000000 --- a/src/tasks/externalTasks.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { InnerTimerTask, ProperTask, TaskState } from "./internalTasks"; - -export class Task { - /** - * Whether the task has completed. Note that completion is not - * equivalent to success. - */ - get isCompleted(): boolean { - return this.innerTask.state === TaskState.Completed; - } - /** - * Whether the task faulted in some way due to error. - */ - get isFaulted(): boolean { - return this.innerTask.state === TaskState.Failed; - } - - /** - * The result of the task, if completed. Otherwise `undefined`. - */ - get result(): unknown | undefined { - return this.isCompleted ? this.innerTask.result : undefined; - } - - /** - * The error thrown when attempting to perform the task's action. If - * the Task has not yet completed or has completed successfully, - * `undefined`. - */ - get exception(): Error | undefined { - return this.isFaulted ? (this.innerTask.result as Error) : undefined; - } - - /** @internal */ - get internalTask(): ProperTask { - this.innerTask.externalTask = this; - return this.innerTask; - } - - /** @hidden */ - constructor(protected readonly innerTask: ProperTask) {} -} - -/** - * Returned from [[DurableOrchestrationClient]].[[createTimer]] if the call is - * not `yield`-ed. Represents a pending timer. See documentation on [[Task]] - * for more information. - * - * All pending timers must be completed or canceled for an orchestration to - * complete. - * - * @example Cancel a timer - * ```javascript - * // calculate expiration date - * const timeoutTask = context.df.createTimer(expirationDate); - * - * // do some work - * - * if (!timeoutTask.isCompleted) { - * timeoutTask.cancel(); - * } - * ``` - * - * @example Create a timeout - * ```javascript - * const now = Date.now(); - * const expiration = new Date(now.valueOf()).setMinutes(now.getMinutes() + 30); - * - * const timeoutTask = context.df.createTimer(expirationDate); - * const otherTask = context.df.callActivity("DoWork"); - * - * const winner = yield context.df.Task.any([timeoutTask, otherTask]); - * - * if (winner === otherTask) { - * // do some more work - * } - * - * if (!timeoutTask.isCompleted) { - * timeoutTask.cancel(); - * } - * ``` - */ -export class TimerTask extends Task { - /** @hidden */ - constructor(protected readonly innerTask: InnerTimerTask) { - super(innerTask); - } - - /** - * @returns Whether or not the timer has been canceled. - */ - get isCancelled(): boolean { - return this.innerTask.isCancelled; - } - - /** - * Indicates the timer should be canceled. This request will execute on the - * next `yield` or `return` statement. - */ - public cancel(): void { - return this.innerTask.cancel(); - } -} diff --git a/test/integration/orchestrator-spec.ts b/test/integration/orchestrator-spec.ts index 2c06e64..4593444 100644 --- a/test/integration/orchestrator-spec.ts +++ b/test/integration/orchestrator-spec.ts @@ -30,7 +30,7 @@ import { IOrchestrationFunctionContext, } from "../../src/classes"; import { OrchestrationFailureError } from "../../src/orchestrationfailureerror"; -import { UpperSchemaVersion } from "../../src/replaySchema"; +import { ReplaySchema } from "../../src/replaySchema"; import { TestHistories } from "../testobjects/testhistories"; import { TestOrchestrations } from "../testobjects/TestOrchestrations"; import { TestUtils } from "../testobjects/testutils"; @@ -55,7 +55,7 @@ describe("Orchestrator", () => { isDone: true, actions: [], output: `Hello, ${name}!`, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -111,7 +111,7 @@ describe("Orchestrator", () => { isDone: true, actions: [], output: falsyValue, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -317,7 +317,7 @@ describe("Orchestrator", () => { [new CallActivityAction("ThrowsErrorActivity")], [new CallActivityAction("Hello", name)], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -348,7 +348,7 @@ describe("Orchestrator", () => { isDone: false, output: undefined, actions: [[new CallActivityAction("Hello", name)]], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -371,7 +371,7 @@ describe("Orchestrator", () => { isDone: false, output: undefined, actions: [[new CallActivityAction("ReturnsFour")]], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -401,7 +401,7 @@ describe("Orchestrator", () => { isDone: false, output: undefined, actions: [[new CallActivityAction("Hello", falsyValue)]], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -429,7 +429,7 @@ describe("Orchestrator", () => { isDone: true, actions: [[new CallActivityAction("Hello", falsyValue)]], output: `Hello, ${falsyValue}!`, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -460,7 +460,7 @@ describe("Orchestrator", () => { isDone: true, actions: [[new CallActivityAction("Hello", name)]], output: `Hello, ${name}!`, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -489,7 +489,7 @@ describe("Orchestrator", () => { isDone: true, output: `Hello, ${name}!`, actions: [[new CallActivityAction("Hello", name)]], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -519,7 +519,7 @@ describe("Orchestrator", () => { [new CallActivityAction("Hello", "London")], ], output: ["Hello, Tokyo!", "Hello, Seattle!", "Hello, London!"], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -588,7 +588,7 @@ describe("Orchestrator", () => { ), ], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -618,7 +618,7 @@ describe("Orchestrator", () => { isDone: false, output: undefined, actions: [[new CallActivityWithRetryAction("Hello", retryOptions, name)]], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -651,7 +651,7 @@ describe("Orchestrator", () => { ), ], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -721,7 +721,7 @@ describe("Orchestrator", () => { ], ], output: `Hello, ${name}!`, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -763,7 +763,7 @@ describe("Orchestrator", () => { ], ], output: ["Hello, Tokyo!", "Hello, Seattle!"], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -800,7 +800,7 @@ describe("Orchestrator", () => { startingTime, moment(startingTime).add(1, "m").add(30, "s").toDate(), ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -831,7 +831,7 @@ describe("Orchestrator", () => { isDone: false, output: undefined, actions: [[new CallHttpAction(req)]], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -912,7 +912,7 @@ describe("Orchestrator", () => { isDone: true, actions: [[new CallHttpAction(req)]], output: res, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -941,7 +941,7 @@ describe("Orchestrator", () => { isDone: false, output: undefined, actions: [[new CallEntityAction(expectedEntity, "set", "testString")]], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -969,7 +969,7 @@ describe("Orchestrator", () => { isDone: true, actions: [[new CallEntityAction(expectedEntity, "set", "testString")]], output: "OK", - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1004,7 +1004,7 @@ describe("Orchestrator", () => { actions: [ [new CallSubOrchestratorAction("SayHelloWithActivity", childId, name)], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1042,7 +1042,7 @@ describe("Orchestrator", () => { ), ], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1107,7 +1107,7 @@ describe("Orchestrator", () => { ), ], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1143,7 +1143,7 @@ describe("Orchestrator", () => { [new CallSubOrchestratorAction("SayHelloWithActivity", childId, name)], ], output: "Hello, World!", - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1257,7 +1257,7 @@ describe("Orchestrator", () => { ), ], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1300,7 +1300,7 @@ describe("Orchestrator", () => { ), ], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1341,7 +1341,7 @@ describe("Orchestrator", () => { ), ], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1431,7 +1431,7 @@ describe("Orchestrator", () => { ], ], output: `Hello, ${name}!`, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1473,7 +1473,7 @@ describe("Orchestrator", () => { ], ], output: ["Hello, Tokyo!", "Hello, Seattle!"], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1501,9 +1501,9 @@ describe("Orchestrator", () => { { // Is Done needs to be marked as true for 1.8.0 and later to properly process continueAsNew isDone: true, - output: undefined, + output: 6, actions: [[new ContinueAsNewAction({ value: 6 })]], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1532,7 +1532,7 @@ describe("Orchestrator", () => { isDone: false, output: undefined, actions: [[new CreateTimerAction(fireAt)]], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1559,7 +1559,7 @@ describe("Orchestrator", () => { isDone: true, actions: [[new CreateTimerAction(fireAt)]], output: "Timer fired!", - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1617,7 +1617,7 @@ describe("Orchestrator", () => { isDone: true, actions: [], output: expectedLockState, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1685,7 +1685,7 @@ describe("Orchestrator", () => { [new CallActivityAction("Hello", "Seattle")], ], customStatus: "Tokyo", - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1721,7 +1721,7 @@ describe("Orchestrator", () => { ), ], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1758,7 +1758,7 @@ describe("Orchestrator", () => { ], [new CallActivityAction("Hello", name)], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1794,7 +1794,7 @@ describe("Orchestrator", () => { ), ], ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1827,7 +1827,7 @@ describe("Orchestrator", () => { [new CallActivityAction("GetFileList", "C:\\Dev")], filePaths.map((file) => new CallActivityAction("GetFileSize", file)), ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1858,7 +1858,7 @@ describe("Orchestrator", () => { [new CallActivityAction("GetFileList", "C:\\Dev")], filePaths.map((file) => new CallActivityAction("GetFileSize", file)), ], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1886,7 +1886,7 @@ describe("Orchestrator", () => { filePaths.map((file) => new CallActivityAction("GetFileSize", file)), ], output: 6, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1951,7 +1951,7 @@ describe("Orchestrator", () => { ], ], output: "A", - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -1981,7 +1981,7 @@ describe("Orchestrator", () => { ], ], output: "B", - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2011,7 +2011,7 @@ describe("Orchestrator", () => { ], ], output: "A", - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2042,7 +2042,7 @@ describe("Orchestrator", () => { ], ], output: undefined, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2069,7 +2069,7 @@ describe("Orchestrator", () => { [new CallActivityAction("Hello", "Tokyo")], ], output: undefined, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2100,7 +2100,7 @@ describe("Orchestrator", () => { ], ], output: undefined, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2126,7 +2126,7 @@ describe("Orchestrator", () => { ], ], output: ["timeout"], - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2156,7 +2156,7 @@ describe("Orchestrator", () => { ], ], output: "A", - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2188,7 +2188,7 @@ describe("Orchestrator", () => { ], ], output: undefined, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2216,7 +2216,7 @@ describe("Orchestrator", () => { [new CallActivityAction("TaskB")], ], output: undefined, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2244,7 +2244,7 @@ describe("Orchestrator", () => { [new CallActivityAction("TaskB")], ], output: undefined, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2272,7 +2272,7 @@ describe("Orchestrator", () => { [new CallActivityAction("TaskB")], ], output: {}, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2304,7 +2304,7 @@ describe("Orchestrator", () => { ], ], output: undefined, - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) @@ -2331,7 +2331,7 @@ describe("Orchestrator", () => { ], ], output: "Timer finished", - schemaVersion: UpperSchemaVersion.V1, + schemaVersion: ReplaySchema.V1, }, true ) diff --git a/test/testobjects/TestOrchestrations.ts b/test/testobjects/TestOrchestrations.ts index 10d2b46..9c4b0c4 100644 --- a/test/testobjects/TestOrchestrations.ts +++ b/test/testobjects/TestOrchestrations.ts @@ -94,7 +94,7 @@ export class TestOrchestrations { : 0; currentValue++; - yield context.df.continueAsNew({ value: currentValue }); + context.df.continueAsNew({ value: currentValue }); return currentValue; }); diff --git a/test/unit/timertask-spec.ts b/test/unit/timertask-spec.ts index 86e8d60..3dd1d62 100644 --- a/test/unit/timertask-spec.ts +++ b/test/unit/timertask-spec.ts @@ -1,19 +1,15 @@ import { expect } from "chai"; import "mocha"; import { CreateTimerAction } from "../../src/classes"; -import { TimerTask } from "../../src/tasks/externalTasks"; -import { InnerTimerTask } from "../../src/tasks/internalTasks"; +import { TimerTask } from "../../src/task"; describe("TimerTask", () => { it("throws cannot cancel a completed task", async () => { const isCancelled = false; const date = new Date(); const action = new CreateTimerAction(date, isCancelled); - const innerTask = new InnerTimerTask(0, action); - innerTask.SetValue(false, undefined); // set value to complete task - - const task = new TimerTask(innerTask); - task.internalTask.SetValue(false, undefined); + const task = new TimerTask(0, action); + task.setValue(false, undefined); // set value to complete task expect(() => { task.cancel(); @@ -24,21 +20,18 @@ describe("TimerTask", () => { const isCancelled = false; const date = new Date(); const action = new CreateTimerAction(date, isCancelled); - const innerTask = new InnerTimerTask(0, action); + const task = new TimerTask(0, action); - const task = new TimerTask(innerTask); task.cancel(); expect(task.isCancelled).to.equal(true); - expect(task.isCancelled).to.equal(true); }); it("is canceled when its action is canceled", async () => { const isCancelled = true; const date = new Date(); const action = new CreateTimerAction(date, isCancelled); - const innerTask = new InnerTimerTask(0, action); + const task = new TimerTask(0, action); - const task = new TimerTask(innerTask); expect(task.isCancelled).to.equal(true); }); }); From b40e36f77347c2348972577276b0ee7a341d1073 Mon Sep 17 00:00:00 2001 From: David Justo Date: Mon, 24 Jan 2022 16:55:51 -0800 Subject: [PATCH 5/9] drop extensionSchema enum --- src/extensionSchema.ts | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/extensionSchema.ts diff --git a/src/extensionSchema.ts b/src/extensionSchema.ts deleted file mode 100644 index 8926c0d..0000000 --- a/src/extensionSchema.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** @hidden */ -export enum ExtensionSchema { - V1, - V2, -} From 84e331a5ecfed98de13f7c99ff4266e64de73a71 Mon Sep 17 00:00:00 2001 From: David Justo Date: Mon, 24 Jan 2022 17:07:58 -0800 Subject: [PATCH 6/9] check for non-generator orchestrations --- src/taskorchestrationexecutor.ts | 2 +- test/integration/orchestrator-spec.ts | 24 +++++++++++++++++++++++- test/testobjects/TestOrchestrations.ts | 4 ++++ test/testobjects/testhistories.ts | 17 +++++++++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/taskorchestrationexecutor.ts b/src/taskorchestrationexecutor.ts index 95c6744..dcf6c71 100644 --- a/src/taskorchestrationexecutor.ts +++ b/src/taskorchestrationexecutor.ts @@ -102,7 +102,7 @@ export class TaskOrchestrationExecutor { ): Promise { this.schemaVersion = schemaVersion; this.context = context.df; - this.generator = fn(context) as Generator; // TODO: what happens if code is not a generator? + this.generator = fn(context) as Generator; // Execute the orchestration, using the history for replay for (const historyEvent of history) { diff --git a/test/integration/orchestrator-spec.ts b/test/integration/orchestrator-spec.ts index 4593444..8d36fd6 100644 --- a/test/integration/orchestrator-spec.ts +++ b/test/integration/orchestrator-spec.ts @@ -38,6 +38,28 @@ import { TestUtils } from "../testobjects/testutils"; describe("Orchestrator", () => { const falsyValues = [false, 0, "", null, undefined, NaN]; + it("allows orchestrations with no yield-statements", async () => { + const orchestrator = TestOrchestrations.NotGenerator; + const mockContext = new MockContext({ + context: new DurableOrchestrationBindingInfo( + TestHistories.StarterHistory(moment.utc().toDate()) + ), + }); + orchestrator(mockContext); + + expect(mockContext.doneValue).to.be.deep.equal( + new OrchestratorState( + { + isDone: true, + actions: [], + output: `Hello`, + schemaVersion: ReplaySchema.V1, + }, + true + ) + ); + }); + it("handles a simple orchestration function (no activity functions)", async () => { const orchestrator = TestOrchestrations.SayHelloInline; const name = "World"; @@ -88,7 +110,7 @@ describe("Orchestrator", () => { }); */ - describe("s", () => { + describe("handle falsy values", () => { for (const falsyValue of falsyValues) { it(`handles an orchestration function that returns ${ falsyValue === "" ? "empty string" : falsyValue diff --git a/test/testobjects/TestOrchestrations.ts b/test/testobjects/TestOrchestrations.ts index 9c4b0c4..684b44c 100644 --- a/test/testobjects/TestOrchestrations.ts +++ b/test/testobjects/TestOrchestrations.ts @@ -1,6 +1,10 @@ import * as df from "../../src"; export class TestOrchestrations { + public static NotGenerator: any = df.orchestrator(function* (context: any) { + return "Hello"; + }); + public static AnyAOrB: any = df.orchestrator(function* (context: any) { const completeInOrder = context.df.getInput(); diff --git a/test/testobjects/testhistories.ts b/test/testobjects/testhistories.ts index a80bcd2..916a792 100644 --- a/test/testobjects/testhistories.ts +++ b/test/testobjects/testhistories.ts @@ -21,6 +21,23 @@ import { } from "../../src/classes"; export class TestHistories { + public static StarterHistory(timestamp: Date): HistoryEvent[] { + return [ + new OrchestratorStartedEvent({ + eventId: -1, + timestamp: timestamp, + isPlayed: false, + }), + new ExecutionStartedEvent({ + eventId: -1, + timestamp: timestamp, + isPlayed: false, + name: "", + input: JSON.stringify("input"), + }), + ]; + } + public static GetAnyAOrB(firstTimestamp: Date, completeInOrder: boolean): HistoryEvent[] { const firstMoment = moment(firstTimestamp); From 9ddde587037893e092705e9c8e33e8c8cce48e5f Mon Sep 17 00:00:00 2001 From: David Justo Date: Mon, 24 Jan 2022 19:19:31 -0800 Subject: [PATCH 7/9] Update src/taskorchestrationexecutor.ts Co-authored-by: Chris Gillum --- src/taskorchestrationexecutor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/taskorchestrationexecutor.ts b/src/taskorchestrationexecutor.ts index dcf6c71..a601ecb 100644 --- a/src/taskorchestrationexecutor.ts +++ b/src/taskorchestrationexecutor.ts @@ -308,7 +308,7 @@ export class TaskOrchestrationExecutor { } } else { throw Error( - `Task with ID ${key} could not be retrieved from due to its ID-key being of type ${typeof key}` + + `Task with ID ${key} could not be retrieved from due to its ID-key being of type ${typeof key}. ` + `We expect ID-keys to be of type number or string. ` + `This is probably a replay failure, please file a bug report.` ); From e644485cadc2decba4bd080bac40c2af7aa346ab Mon Sep 17 00:00:00 2001 From: David Justo Date: Mon, 24 Jan 2022 20:16:56 -0800 Subject: [PATCH 8/9] apply PR feedback --- src/durableorchestrationcontext.ts | 28 ++++++++++++++-------------- src/iorchestratorstate.ts | 1 - src/orchestratorstate.ts | 8 ++++---- src/task.ts | 24 ++++++++++++++---------- src/taskorchestrationexecutor.ts | 20 ++++++++++---------- test/unit/timertask-spec.ts | 8 ++++---- 6 files changed, 46 insertions(+), 43 deletions(-) diff --git a/src/durableorchestrationcontext.ts b/src/durableorchestrationcontext.ts index 3fc5e73..b2da3be 100644 --- a/src/durableorchestrationcontext.ts +++ b/src/durableorchestrationcontext.ts @@ -24,9 +24,9 @@ import { WhenAnyTask, AtomicTask, RetryableTask, + DFTimerTask, + Task, TimerTask, - ITask, - ITimerTask, DFTask, } from "./task"; @@ -108,7 +108,7 @@ export class DurableOrchestrationContext { * preventing the DFTask type from being exported to users. * @param tasks */ - private isDFTaskArray(tasks: ITask[]): tasks is DFTask[] { + private isDFTaskArray(tasks: Task[]): tasks is DFTask[] { return tasks.every((x) => x instanceof DFTask); } @@ -118,7 +118,7 @@ export class DurableOrchestrationContext { * instances. For use in parallelization operations. */ public Task = { - all: (tasks: ITask[]): ITask => { + all: (tasks: Task[]): Task => { if (this.isDFTaskArray(tasks)) { const action = new WhenAllAction(tasks); const task = new WhenAllTask(tasks, action); @@ -131,7 +131,7 @@ export class DurableOrchestrationContext { ); }, - any: (tasks: ITask[]): ITask => { + any: (tasks: Task[]): Task => { if (this.isDFTaskArray(tasks)) { const action = new WhenAnyAction(tasks); const task = new WhenAnyTask(tasks, action); @@ -154,7 +154,7 @@ export class DurableOrchestrationContext { * @returns A Durable Task that completes when the called activity * function completes or fails. */ - public callActivity(name: string, input?: unknown): ITask { + public callActivity(name: string, input?: unknown): Task { const newAction = new CallActivityAction(name, input); const task = new AtomicTask(false, newAction); return task; @@ -169,7 +169,7 @@ export class DurableOrchestrationContext { * @param input The JSON-serializable input to pass to the activity * function. */ - public callActivityWithRetry(name: string, retryOptions: RetryOptions, input?: unknown): ITask { + public callActivityWithRetry(name: string, retryOptions: RetryOptions, input?: unknown): Task { const newAction = new CallActivityWithRetryAction(name, retryOptions, input); const backingTask = new AtomicTask(false, newAction); const task = new RetryableTask(backingTask, retryOptions, this.taskOrchestratorExecutor); @@ -184,7 +184,7 @@ export class DurableOrchestrationContext { * @param operationName The name of the operation. * @param operationInput The input for the operation. */ - public callEntity(entityId: EntityId, operationName: string, operationInput?: unknown): ITask { + public callEntity(entityId: EntityId, operationName: string, operationInput?: unknown): Task { const newAction = new CallEntityAction(entityId, operationName, operationInput); const task = new AtomicTask(false, newAction); return task; @@ -200,7 +200,7 @@ export class DurableOrchestrationContext { * If `instanceId` is not specified, the extension will generate an id in * the format `:<#>` */ - public callSubOrchestrator(name: string, input?: unknown, instanceId?: string): ITask { + public callSubOrchestrator(name: string, input?: unknown, instanceId?: string): Task { if (!name) { throw new Error( "A sub-orchestration function name must be provided when attempting to create a suborchestration" @@ -227,7 +227,7 @@ export class DurableOrchestrationContext { retryOptions: RetryOptions, input?: unknown, instanceId?: string - ): ITask { + ): Task { if (!name) { throw new Error( "A sub-orchestration function name must be provided when attempting to create a suborchestration" @@ -256,7 +256,7 @@ export class DurableOrchestrationContext { content?: string | object, headers?: { [key: string]: string }, tokenSource?: TokenSource - ): ITask { + ): Task { if (content && typeof content !== "string") { content = JSON.stringify(content); } @@ -292,9 +292,9 @@ export class DurableOrchestrationContext { * @param fireAt The time at which the timer should expire. * @returns A TimerTask that completes when the durable timer expires. */ - public createTimer(fireAt: Date): ITimerTask { + public createTimer(fireAt: Date): TimerTask { const newAction = new CreateTimerAction(fireAt); - const task = new TimerTask(false, newAction); + const task = new DFTimerTask(false, newAction); return task; } @@ -347,7 +347,7 @@ export class DurableOrchestrationContext { * External clients can raise events to a waiting orchestration instance * using [[raiseEvent]]. */ - public waitForExternalEvent(name: string): ITask { + public waitForExternalEvent(name: string): Task { const newAction = new WaitForExternalEventAction(name, ExternalEventType.ExternalEvent); const task = new AtomicTask(name, newAction); return task; diff --git a/src/iorchestratorstate.ts b/src/iorchestratorstate.ts index 5e70118..ff78bb6 100644 --- a/src/iorchestratorstate.ts +++ b/src/iorchestratorstate.ts @@ -8,6 +8,5 @@ export interface IOrchestratorState { output: unknown; error?: string; customStatus?: unknown; - replaySchema?: ReplaySchema | undefined; schemaVersion: ReplaySchema; } diff --git a/src/orchestratorstate.ts b/src/orchestratorstate.ts index ae923ce..24b6516 100644 --- a/src/orchestratorstate.ts +++ b/src/orchestratorstate.ts @@ -34,14 +34,14 @@ export class OrchestratorState implements IOrchestratorState { * The flattened array of actions. */ private flattenCompoundActions(actions: IAction[]): IAction[] { - let flatActions: IAction[] = []; + const flatActions: IAction[] = []; for (const action of actions) { // Given any compound action if (action instanceof WhenAllAction || action instanceof WhenAnyAction) { // We obtain its inner actions as a flat array const innerActionArr = this.flattenCompoundActions(action.compoundActions); // we concatenate the inner actions to the flat array we're building - flatActions = flatActions.concat(innerActionArr); + flatActions.push(...innerActionArr); } else { // The action wasn't compound, so it's left intact flatActions.push(action); @@ -77,10 +77,10 @@ export class OrchestratorState implements IOrchestratorState { if (actions !== undefined) { for (const action of actions) { // Each action is represented as an array in V1 - let newEntry: IAction[] = []; + const newEntry: IAction[] = []; if (action instanceof WhenAllAction || action instanceof WhenAnyAction) { const innerActionArr = this.flattenCompoundActions(action.compoundActions); - newEntry = newEntry.concat(innerActionArr); + newEntry.push(...innerActionArr); } else { newEntry.push(action); } diff --git a/src/task.ts b/src/task.ts index ec12dc0..d9493e3 100644 --- a/src/task.ts +++ b/src/task.ts @@ -29,7 +29,7 @@ export type BackingAction = IAction | "noOp"; /** * A Durable Functions Task. */ -export interface ITask { +export interface Task { /** * Whether the task has completed. Note that completion is not * equivalent to success. @@ -61,6 +61,9 @@ export interface ITask { * // do some work * * if (!timeoutTask.isCompleted) { + * // An orchestration won't get marked as completed until all its scheduled + * // tasks have returned, or been cancelled. Therefore, it is important + * // to cancel timers when they're no longer needed * timeoutTask.cancel(); * } * ``` @@ -80,11 +83,14 @@ export interface ITask { * } * * if (!timeoutTask.isCompleted) { + * // An orchestration won't get marked as completed until all its scheduled + * // tasks have returned, or been cancelled. Therefore, it is important + * // to cancel timers when they're no longer needed * timeoutTask.cancel(); * } * ``` */ -export interface ITimerTask extends ITask { +export interface TimerTask extends Task { /** * @returns Whether or not the timer has been canceled. */ @@ -155,11 +161,9 @@ export abstract class TaskBase { let newState: TaskState; if (isError) { - if (value instanceof Error) { - if (value instanceof TaskBase && value.result instanceof Error) { - const errMessage = `Task ID ${this.id} failed but it's value was not an Exception`; - throw new Error(errMessage); - } + if (!(value instanceof Error)) { + const errMessage = `Task ID ${this.id} failed but it's value was not an Exception`; + throw new Error(errMessage); } newState = TaskState.Failed; } else { @@ -203,7 +207,7 @@ export class NoOpTask extends TaskBase { * @hidden * A task that should result in an Action being communicated to the DF extension. */ -export class DFTask extends TaskBase implements ITask { +export class DFTask extends TaskBase implements Task { protected action: IAction; /** Get this task's backing action */ @@ -269,10 +273,10 @@ export class AtomicTask extends DFTask {} /** * @hidden - * A timer task. This is the internal interface to the user-exposed TimerTask, which + * A timer task. This is the internal implementation to the user-exposed TimerTask interface, which * has a more restricted API. */ -export class TimerTask extends AtomicTask implements ITimerTask { +export class DFTimerTask extends AtomicTask implements TimerTask { /** * @hidden * Construct a Timer Task. diff --git a/src/taskorchestrationexecutor.ts b/src/taskorchestrationexecutor.ts index dcf6c71..c2446c4 100644 --- a/src/taskorchestrationexecutor.ts +++ b/src/taskorchestrationexecutor.ts @@ -197,8 +197,8 @@ export class TaskOrchestrationExecutor { // "Input" field of the corresponding EventSent event. Here, we handle that // edge-case by correcting the expected TaskID in our openTask list. const key = event.EventId; - if (key in this.openTasks) { - const task = this.openTasks[key]; + const task = this.openTasks[key]; + if (task !== undefined) { if (task.actionObj instanceof CallEntityAction) { // extract TaskID from Input field const eventSent = event as EventSentEvent; @@ -277,16 +277,15 @@ export class TaskOrchestrationExecutor { } // First, we attempt to recover the task associated with this history event - let task: TaskBase; + let task: TaskBase | undefined; const key = event[idKey as keyof typeof event]; if (typeof key === "number" || typeof key === "string") { - if (key in this.openTasks) { - // Obtain task from open tasks - task = this.openTasks[key]; + task = this.openTasks[key]; + const taskList: TaskBase[] | undefined = this.openEvents[key]; + if (task !== undefined) { + // Remove task from open tasks delete this.openTasks[key]; - } else if (key in this.openEvents) { - // Obtain task from open events - const taskList = this.openEvents[key]; + } else if (taskList !== undefined) { task = taskList.pop() as TaskBase; // We ensure openEvents only has an entry for this key if @@ -468,7 +467,8 @@ export class TaskOrchestrationExecutor { // Obtain the current list of tasks for this external event name. // If there's no such list, we initialize it. - const eventList = task.id in this.openEvents ? this.openEvents[task.id] : []; + const candidateEventList: TaskBase[] | undefined = this.openEvents[task.id]; + const eventList = candidateEventList !== undefined ? candidateEventList : []; eventList.push(task); this.openEvents[task.id] = eventList; diff --git a/test/unit/timertask-spec.ts b/test/unit/timertask-spec.ts index 3dd1d62..f74d608 100644 --- a/test/unit/timertask-spec.ts +++ b/test/unit/timertask-spec.ts @@ -1,14 +1,14 @@ import { expect } from "chai"; import "mocha"; import { CreateTimerAction } from "../../src/classes"; -import { TimerTask } from "../../src/task"; +import { DFTimerTask } from "../../src/task"; describe("TimerTask", () => { it("throws cannot cancel a completed task", async () => { const isCancelled = false; const date = new Date(); const action = new CreateTimerAction(date, isCancelled); - const task = new TimerTask(0, action); + const task = new DFTimerTask(0, action); task.setValue(false, undefined); // set value to complete task expect(() => { @@ -20,7 +20,7 @@ describe("TimerTask", () => { const isCancelled = false; const date = new Date(); const action = new CreateTimerAction(date, isCancelled); - const task = new TimerTask(0, action); + const task = new DFTimerTask(0, action); task.cancel(); expect(task.isCancelled).to.equal(true); @@ -30,7 +30,7 @@ describe("TimerTask", () => { const isCancelled = true; const date = new Date(); const action = new CreateTimerAction(date, isCancelled); - const task = new TimerTask(0, action); + const task = new DFTimerTask(0, action); expect(task.isCancelled).to.equal(true); }); From b1e62a2b2a3d34ba39b8e45d13aad7ca7c4fcc03 Mon Sep 17 00:00:00 2001 From: David Justo Date: Tue, 25 Jan 2022 11:40:38 -0800 Subject: [PATCH 9/9] simplifying timer logic --- src/task.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/task.ts b/src/task.ts index d9493e3..9f11d17 100644 --- a/src/task.ts +++ b/src/task.ts @@ -387,7 +387,7 @@ export class WhenAnyTask extends CompoundTask { */ export class RetryableTask extends WhenAllTask { private isWaitingOnTimer: boolean; - private numAttempts: number; + private attemptNumber: number; private error: any; /** @@ -408,7 +408,7 @@ export class RetryableTask extends WhenAllTask { private executor: TaskOrchestrationExecutor ) { super([innerTask], innerTask.actionObj); - this.numAttempts = 1; + this.attemptNumber = 1; this.isWaitingOnTimer = false; } @@ -420,12 +420,13 @@ export class RetryableTask extends WhenAllTask { * The sub-task that just completed */ public trySetValue(child: TaskBase): void { + // Case 1 - child is a timer task if (this.isWaitingOnTimer) { this.isWaitingOnTimer = false; // If we're out of retry attempts, we can set the output value // of this task to be that of the last error we encountered - if (this.numAttempts >= this.retryOptions.maxNumberOfAttempts) { + if (this.attemptNumber > this.retryOptions.maxNumberOfAttempts) { this.setValue(true, this.error); } else { // If we still have more attempts available, we re-schedule the @@ -435,12 +436,13 @@ export class RetryableTask extends WhenAllTask { rescheduledTask.parent = this; this.children.push(rescheduledTask); this.executor.trackOpenTask(rescheduledTask); - this.numAttempts++; } - } else if (child.stateObj === TaskState.Completed) { + } // Case 2 - child is the API to retry, and it succeeded + else if (child.stateObj === TaskState.Completed) { // If we have a successful non-timer task, we accept its result this.setValue(false, child.result); - } else { + } // Case 3 - child is the API to retry, and it failed + else { // If the sub-task failed, schedule timer to retry again. // Since these sub-tasks are not user-managed, they are declared as internal tasks. const rescheduledTask = new NoOpTask(); @@ -449,6 +451,7 @@ export class RetryableTask extends WhenAllTask { this.executor.trackOpenTask(rescheduledTask); this.isWaitingOnTimer = true; this.error = child.result; + this.attemptNumber++; } } }