simple-swf
Version:
Actually simple API layer for AWS SWF
402 lines (393 loc) • 14.7 kB
text/typescript
import { SWF } from 'aws-sdk'
import * as _ from 'lodash'
import * as async from 'async'
import { Task } from './Task'
import { Workflow } from '../entities/Workflow'
import { ActivityType } from '../entities/ActivityType'
import { FieldSerializer } from '../util/FieldSerializer'
import { CodedError, EntityTypes, TaskInput, TaskStatus } from '../interfaces'
import { EventRollup, Event, EventData, SelectedEvents } from './EventRollup'
import { ConfigOverride } from '../SWFConfig'
import { DecisionTypeAttributeMap } from '../util'
export interface Decision {
entities: EntityTypes[],
overrides: ConfigOverride
decision: SWF.Decision
}
export interface DecisionRollup {
[decisionType: string]: number
}
type SWFScheduleChild = SWF.StartChildWorkflowExecutionDecisionAttributes
type SWFScheduleTask = SWF.ScheduleActivityTaskDecisionAttributes
type SWFWorkflowStart = SWF.WorkflowExecutionStartedEventAttributes
const SWF_MAX_RETRY = 5
export class DecisionTask extends Task<SWF.DecisionTask> {
fieldSerializer: FieldSerializer
decisions: Decision[]
private executionContext: any
private rollup: EventRollup
private workflowAttrs: SWFWorkflowStart
id: string
constructor(workflow: Workflow, rawTask: SWF.DecisionTask) {
super(workflow, rawTask)
this.fieldSerializer = workflow.fieldSerializer
this.decisions = []
this.workflowAttrs = this.extractWorkflowInput(rawTask.events)
this.rollup = new EventRollup(rawTask, this.getWorkflowTaskInput().env)
this.id = rawTask.startedEventId.toString()
}
getWorkflowTaskInput(): TaskInput {
// this is hacky and ugly, but we already have deserialized stuff
// so we force input to be our TaskInput
let input = this.workflowAttrs.input as any
return input as TaskInput
}
getWorkflowInput(): any {
return this.getWorkflowTaskInput().input
}
setExecutionContext(context: any) {
this.executionContext = context
}
private buildTaskInput(input: any, overrideEnv?: any, control?: any): string {
return JSON.stringify({
input: input,
env: overrideEnv || this.getEnv(),
originWorkflow: this.getOriginWorkflow(),
control: control
} as TaskInput)
}
private encodeExecutionContext(cb: {(err: Error | null, s: string)}) {
if (!this.executionContext) return cb(null, '')
this.fieldSerializer.serialize(this.executionContext, cb)
}
private wrapDecisions(decisions: Decision[], cb: {(err: Error | null, dec: SWF.Decision[])}) {
async.map(decisions, (decision: Decision, cb: {(err: Error | null, d?: SWF.Decision)}) => {
let swfDec = decision.decision
let attrName = DecisionTypeAttributeMap[swfDec.decisionType]
let swfAttrs = swfDec[attrName]
let apiUse = {entities: decision.entities, api: 'respondDecisionTaskCompleted', attribute: attrName}
let defaults = this.config.populateDefaults(apiUse, decision.overrides)
let merged = _.defaults(swfAttrs, defaults)
this.fieldSerializer.serializeAll(merged, (err, serialized) => {
if (err) return cb(err)
swfDec[attrName] = serialized
cb(null, swfDec)
})
}, cb)
}
sendDecisions(cb) {
this.encodeExecutionContext((err, context) => {
if (err) return cb(err)
this.wrapDecisions(this.decisions, (err, decisions) => {
if (err) return cb(err)
let params: SWF.RespondDecisionTaskCompletedInput = {
taskToken: this.rawTask.taskToken,
decisions: decisions,
executionContext: context
}
this.swfClient.respondDecisionTaskCompleted(params, cb)
})
})
}
getParentWorkflowInfo(): SWF.WorkflowExecution | null {
return this.rawTask.events[0].workflowExecutionStartedEventAttributes!.parentWorkflowExecution || null
}
isChildWorkflow(): boolean {
return this.getParentWorkflowInfo() !== null
}
rescheduleTimedOutEvents(): Event[] {
let timedOut = this.rollup.getTimedOutEvents()
let actFailRe = this.rescheduleOfType<SWFScheduleTask>(
timedOut.activity,
'activityTaskScheduledEventAttributes',
this.rescheduleTask.bind(this)
)
let workFailRe = this.rescheduleOfType<SWFScheduleChild>(
timedOut.workflow,
'startChildWorkflowExecutionInitiatedEventAttributes',
this.rescheduleChild.bind(this)
)
return actFailRe.concat(workFailRe)
}
rescheduleFailedEvents(): Event[] {
let failed = this.rollup.getFailedEvents()
let actFailRe = this.rescheduleOfType<SWFScheduleTask>(
failed.activity,
'activityTaskScheduledEventAttributes',
this.rescheduleTask.bind(this)
)
let workFailRe = this.rescheduleOfType<SWFScheduleChild>(
failed.workflow,
'startChildWorkflowExecutionInitiatedEventAttributes',
this.rescheduleChild.bind(this)
)
return actFailRe.concat(workFailRe)
}
rescheduleFailedToSchedule(): Event[] {
let failed = this.rollup.getRetryableFailedToScheduleEvents()
let actFailRe = this.rescheduleOfType<SWFScheduleTask>(
failed.activity,
'activityTaskScheduledEventAttributes',
this.rescheduleTask.bind(this)
)
let workFailRe = this.rescheduleOfType<SWFScheduleChild>(
failed.workflow,
'startChildWorkflowExecutionInitiatedEventAttributes',
this.rescheduleChild.bind(this)
)
return actFailRe.concat(workFailRe)
}
private rescheduleOfType<T>(toReschedule: Event[], attrName: string, addFunc: {(T): boolean}): Event[] {
let failedReschedule: Event[] = []
for (let task of toReschedule) {
let startAttrs = _.clone(task.scheduled[attrName])
// this is an invalid option when scheduling activites and child workflows
// otherwise, the attributes from the scheduled event are the same as the attributes to schedule a new event
delete startAttrs.decisionTaskCompletedEventId
if (!addFunc(startAttrs as T)) failedReschedule.push(task)
}
return failedReschedule
}
rescheduleTask(taskAttrs: SWF.ScheduleActivityTaskDecisionAttributes): boolean {
// we don't want to rebuild the manifest, so don't put it in the normal place
let control = this.getControlDoc(taskAttrs.control)
if (control.executionCount > control.maxRetry) return false
taskAttrs.control = JSON.stringify(control)
// This is how we communicate updated control info up to the workers, via the input.control
let input = JSON.parse(taskAttrs.input || '{}')
input.control = control
taskAttrs.input = JSON.stringify(input)
this.decisions.push({
entities: ['activity'],
overrides: {},
decision: {
decisionType: 'ScheduleActivityTask',
scheduleActivityTaskDecisionAttributes: taskAttrs
}
})
return true
}
rescheduleChild(childAttrs: SWF.StartChildWorkflowExecutionDecisionAttributes): boolean {
// we don't want to rebuild the manifest, so don't put it in the normal place
let control = this.getControlDoc(childAttrs.control)
if (control.executionCount > control.maxRetry) return false
childAttrs.control = JSON.stringify(control)
// This is how we communicate updated control info up to the workers, via the input.control
let input = JSON.parse(childAttrs.input || '{}')
input.control = control
childAttrs.input = JSON.stringify(input)
this.decisions.push({
entities: ['workflow'],
overrides: {},
decision: {
decisionType: 'StartChildWorkflowExecution',
startChildWorkflowExecutionDecisionAttributes: childAttrs
}
})
return true
}
scheduleTask(activityId: string, input: any, activity: ActivityType, opts: ConfigOverride = {}, overrideEnv?: any) {
let maxRetry = opts['maxRetry'] as number || activity.maxRetry
let control = this.buildInitialControlDoc(maxRetry)
let taskInput = this.buildTaskInput(input, overrideEnv, control)
this.decisions.push({
entities: ['activity'],
overrides: opts,
decision: {
decisionType: 'ScheduleActivityTask',
scheduleActivityTaskDecisionAttributes: {
input: taskInput,
activityId: activityId,
activityType: {
name: activity.name,
version: activity.version
},
control: JSON.stringify(control)
}
}
})
}
startChildWorkflow(workflowId: string, input: any, opts: ConfigOverride = {}, overrideEnv?: any) {
let maxRetry = opts['maxRetry'] as number
let control = this.buildInitialControlDoc(maxRetry)
let taskInput = this.buildTaskInput(input, overrideEnv, control)
this.decisions.push({
entities: ['workflow', 'decision'],
overrides: opts,
decision: {
decisionType: 'StartChildWorkflowExecution',
startChildWorkflowExecutionDecisionAttributes: {
workflowId: workflowId,
workflowType: {
name: this.workflow.name,
version: this.workflow.version
},
input: taskInput,
control: JSON.stringify(control)
}
}
})
}
failWorkflow(reason: string, details: string, opts: ConfigOverride = {}) {
// when you fail workflow, the only thing that should be in it is the fail decision, any other
// decisions can cause an error! so zero them out
this.decisions = []
this.decisions.push({
entities: ['workflow'],
overrides: opts,
decision: {
decisionType: 'FailWorkflowExecution',
failWorkflowExecutionDecisionAttributes: {reason, details}
}
})
}
completeWorkflow(result: TaskStatus, opts: ConfigOverride = {}, overrideEnv?: any) {
result.env = overrideEnv || this.getEnv()
this.decisions.push({
entities: ['workflow'],
overrides: opts,
decision: {
decisionType: 'CompleteWorkflowExecution',
completeWorkflowExecutionDecisionAttributes: {
result: JSON.stringify(result)
}
}
})
}
addMarker(markerName: string, details: any, opts: ConfigOverride = {}) {
this.decisions.push({
entities: ['activity'], // this is really an activity... but call it one
overrides: opts,
decision: {
decisionType: 'RecordMarker',
recordMarkerDecisionAttributes: { markerName, details }
}
})
}
cancelWorkflow(details: any, opts: ConfigOverride = {}) {
this.decisions.push({
entities: ['workflow'],
overrides: opts,
decision: {
decisionType: 'CancelWorkflowExecution',
cancelWorkflowExecutionDecisionAttributes: {details}
}
})
}
cancelActivity(activityId: string, opts: ConfigOverride = {}) {
this.decisions.push({
entities: ['activity'],
overrides: opts,
decision: {
decisionType: 'RequestCancelActivityTask',
requestCancelActivityTaskDecisionAttributes: {activityId}
}
})
}
startTimer(timerId: string, timerLength: number, control?: any) {
this.decisions.push({
entities: ['timer'],
overrides: {},
decision: {
decisionType: 'StartTimer',
startTimerDecisionAttributes: {
timerId: timerId,
startToFireTimeout: timerLength.toString(),
control: control
}
}
})
}
cancelTimer(timerId: string) {
this.decisions.push({
entities: ['timer'],
overrides: {},
decision: {
decisionType: 'CancelTimer',
cancelTimerDecisionAttributes: {timerId: timerId}
}
})
}
continueAsNewWorkflow(overrideInput: string | null = null, opts: ConfigOverride = {}, overrideEnv?: any) {
let params: SWF.ContinueAsNewWorkflowExecutionDecisionAttributes = {
input: this.buildTaskInput(overrideInput || this.workflowAttrs.input, overrideEnv),
childPolicy: this.workflowAttrs.childPolicy,
executionStartToCloseTimeout: this.workflowAttrs.executionStartToCloseTimeout,
lambdaRole: this.workflowAttrs.lambdaRole,
tagList: this.workflowAttrs.tagList,
taskList: this.workflowAttrs.taskList,
taskPriority: this.workflowAttrs.taskPriority,
taskStartToCloseTimeout: this.workflowAttrs.taskStartToCloseTimeout,
workflowTypeVersion: this.workflow.version
}
this.decisions.push({
entities: ['workflow'],
overrides: opts,
decision: {
decisionType: 'ContinueAsNewWorkflowExecution',
continueAsNewWorkflowExecutionDecisionAttributes: params
}
})
}
scheduleLambda(lambdaName: string, id: string, input: any, opts: ConfigOverride = {}, overrideEnv?: any) {
let maxRetry = opts['maxRetry'] as number
let control = this.buildInitialControlDoc(maxRetry)
let taskInput = this.buildTaskInput(input, overrideEnv, control)
this.decisions.push({
entities: ['activity'],
overrides: opts,
decision: {
decisionType: 'ScheduleLambdaFunction',
scheduleLambdaFunctionDecisionAttributes: {
id: id,
name: lambdaName,
input: taskInput
}
}
})
}
// responds with the info made in this decision
getDecisionInfo(): DecisionRollup {
return this.decisions.reduce((rollup, decision) => {
if (rollup[decision.decision.decisionType]) {
rollup[decision.decision.decisionType] += 1
} else {
rollup[decision.decision.decisionType] = 1
}
return rollup
}, {} as DecisionRollup)
}
getGroupedEvents(): EventData {
return this.rollup.data
}
getRetryableFailedToScheduleEvents(): SelectedEvents | false {
return this.rollup.getRetryableFailedToScheduleEvents()
}
getEnv(): Object {
return this.rollup.env || {}
}
getOriginWorkflow(): string {
return this.getWorkflowTaskInput().originWorkflow
}
// TODO: implement these
// SignalExternalWorkflowExecution: 'signalExternalWorkflowExecutionDecisionAttributes',
// RequestCancelExternalWorkflowExecution: 'requestCancelExternalWorkflowExecutionDecisionAttributes',
private buildInitialControlDoc(maxRetry: number = SWF_MAX_RETRY) {
return {executionCount: 1, maxRetry}
}
private getControlDoc(existingControl: any) {
if (typeof existingControl === 'string') {
existingControl = JSON.parse(existingControl)
}
return {
executionCount: (existingControl.executionCount + 1 || 1),
maxRetry: (existingControl.maxRetry || SWF_MAX_RETRY)
}
}
private extractWorkflowInput(rawEvents: SWF.HistoryEvent[]): SWFWorkflowStart {
if (rawEvents[0].eventType !== 'WorkflowExecutionStarted') {
throw new Error('WorkflowExecutionStarted was not first event')
}
return rawEvents[0].workflowExecutionStartedEventAttributes!
}
}