Building distributed applications with step functions

The whole idea behind step functions is to make it easier for developers to write and orchestrate multiple Lambda functions with ease. This comes in really handy when you have a really large number of Lambda functions, each performing a very specific task, and you wish to coordinate the actions among them. In this scenario, we will be taking our monolithic calculator example code from the earlier deployed SAM template and re-engineer it to work with Step Functions. To begin with, let us examine the Lambda functions that we have created for this scenario. First up, are the basic addition, subtraction, and multiplication functions that all follow the same code as shown below:

'use strict'; 
console.log('Loading the Addition function'); 
 
exports.handler = function(event, context, callback) { 
    console.log('Received event:', JSON.stringify(event, null, 2)); 
    let operand1 = event.a; 
    let operand2 = event.b; 
    let operator = event.op; 
 
    let res = {}; 
    res.a = Number(operand1); 
    res.b = Number(operand2); 
    res.op = operator; 
 
    res.c = res.a + res.b; 
    console.log("result: "+res.c); 
    callback(null, res); 
}; 

The function is very simple to understand and implement. It takes three parameters (operand1, operand2 and operator) as events, calculates the results, and simply passes all the details to an object as a callback().

The function code for dividing is a bit different, in the sense that it does a few checks before actually running a divide operation on the two operands as shown in the snippet below:

function ZeroDivisorError(message) { 
    this.name = "ZeroDivisorError"; 
    this.message = message; 
} 
 
    if(res.b === 0){ 
        console.log("The divisor cannot be 0"); 
        const zeroDivisortError =
new ZeroDivisorError("The divisor cannot be 0!"); callback(zeroDivisortError); } else{ res.c = res.a/res.b; console.log("result: "+res.c); callback(null, res); }

Apart from these two functions, we have also gone ahead and created a function that basically checks whether all the required values are entered or not, as well as, whether the operands are numbers or not. If the operands clear the checks, they are then passed to the respective Lambda function for further calculation.

'use strict'; 
console.log('Loading the Calc function'); 
 
function InvalidInputError(message) { 
    this.name = "InvalidInputError"; 
    this.message = message; 
} 
 
function InvalidOperandError(message) { 
    this.name = "InvalidOperandError"; 
    this.message = message; 
} 
 
exports.handler = function(event, context, callback) { 
    console.log('Received event:', JSON.stringify(event, null, 2)); 
    let operand1 = event.operand1; 
    let operand2 = event.operand2; 
    let operator = event.operator; 
 
    InvalidInputError.prototype = new Error(); 
     
    if (operand1 === undefined || operand2 === undefined
|| operator === undefined) { console.log("Invalid Input"); const invalidInputError =
new InvalidInputError("Invalid Input!"); return callback(invalidInputError); } let res = {}; res.a = Number(operand1); res.b = Number(operand2); res.op = operator; InvalidOperandError.prototype = new Error(); if (isNaN(operand1) || isNaN(operand2)) { console.log("Invalid Operand"); const invalidOperandError =
new InvalidOperandError("Invalid Operand!"); return callback(invalidOperandError); } callback(null, res); };

Once the calculations are all completed, the final results are stored in a predefined DynamoDB table using the final function code snippet as shown below:

let item = { 
        "calcAnswer": event.c, 
        "operand1": event.a, 
        "operand2": event.b, 
        "operator": event.op 
    }; 
 
    let params = { 
        "TableName": tableName, 
        "Item": item 
    }; 
 
    dynamo.putItem(params, (err, data) => { 
        if (err){ 
            console.log("An error occured while writing to Db: ",err); 
            callback(err); 
        } 
        else{ 
            console.log("Successfully wrote result to DB"); 
            callback(null, "success!"); 
        } 

So, all in all, we have taken our standard calculator code and split it's functionality into six different Lambda functions! Once all the functions are deployed to Lambda, the last thing left is to go ahead and create the associated state machine for step functions! Let us have a look at the state machine one section at a time:

The first section is where we are defining the starting state FetchAndCheck that will basically accept the operands and operator as events and pass them through a series of validation checks. If an error is found, the FailState is invoked with the appropriate error message else the execution continues with the invocation of the ChoiceStateX.

{ 
  "Comment": "An example of the Amazon States Language using
an AWS Lambda Functions", "StartAt": "FetchAndCheck", "States": { "FetchAndCheck": { "Type": "Task", "Resource": "arn:aws:lambda:us-east-1:12345678910:
function:fetchandCheckLambda", "Next": "ChoiceStateX", "Catch": [ { "ErrorEquals": ["InvalidInputError",
"InvalidOperandError"], "Next": "FailState" } ] },

The ChoiceStateX state decides the task to invoke, based on the operator parameter passed during the state machine's execution

    "ChoiceStateX": { 
      "Type": "Choice", 
      "Choices": [ 
        { 
          "Variable": "$.op", 
          "StringEquals": "add", 
          "Next": "Addition" 
        }, 
        { 
          "Variable": "$.op", 
          "StringEquals": "sub", 
          "Next": "Subtraction" 
        }, 
        { 
          "Variable": "$.op", 
          "StringEquals": "mul", 
          "Next": "Multiplication" 
        }, 
        { 
          "Variable": "$.op", 
          "StringEquals": "div", 
          "Next": "Division" 
        } 
      ], 
      "Default": "DefaultState" 
    }, 

With the choice state defined, the next step in the state machine's definition is the individual task itself. Here, we will provide the individual Lambda function ARN's that we created earlier:

    "Addition": { 
      "Type" : "Task", 
      "Resource": "arn:aws:lambda:us-east-1:12345678910:
function:additionLambda", "Next": "InsertInDB" }, "Subtraction": { "Type" : "Task", "Resource": "arn:aws:lambda:us-east-1:12345678910:
function:subtractionLambda", "Next": "InsertInDB" }, "Multiplication": { "Type" : "Task", "Resource": "arn:aws:lambda:us-east-1:12345678910:
function:multiplication", "Next": "InsertInDB" }, "Division": { "Type" : "Task", "Resource": "arn:aws:lambda:us-east-1:12345678910:
function:divisionLambda", "Next": "InsertInDB", "Catch": [ { "ErrorEquals": ["ZeroDivisorError"], "Next": "FailState" } ] }, "DefaultState": { "Type": "Pass", "Next": "FailState" },

The InsertInDB state is the final state in the state machine's execution where the operands, the operator, and the calculated value are stored in a pre-defined DynamoDB table:

    "InsertInDB": { 
      "Type": "Task", 
      "Resource": "arn:aws:lambda:us-east-1:12345678910:
function:insertInDBLambda", "Next": "SuccessState", "Catch": [ { "ErrorEquals": ["States.ALL"], "Next": "FailState" } ] }, "FailState": { "Type": "Fail" }, "SuccessState": { "Type": "Succeed" } } }

With the code pasted, click on the Preview option to visually see the state machine. You should see a flowchart similar to the one shown as follows:

Once completed, click on Create State Machine option to select the IAM role for our state machine. Unlike the previous IAM role for our example, we need to add few specific policies for logging the events to CloudWatch, as well as inserting items to a specific DynamoDB table. Note that, in my case, I'm reusing one of my table's for this exercise.

{ 
    "Version": "2012-10-17", 
    "Statement": [ 
        { 
            "Sid": "Stmt1497293444000", 
            "Effect": "Allow", 
            "Action": [ 
                "logs:CreateLogGroup", 
                "logs:CreateLogStream", 
                "logs:PutLogEvents" 
            ], 
            "Resource": [ 
                "*" 
            ] 
        }, 
        { 
            "Sid": "Stmt1497293498000", 
            "Effect": "Allow", 
            "Action": [ 
                "dynamodb:PutItem" 
            ], 
            "Resource": [ 
                "arn:aws:dynamodb:us-east-1:12345678910:
table/myCalcResults" ] } ] }

With the IAM role created and assigned to the state machine, you can now go ahead and create an execution for the same.

Click on the New execution option and pass the following event in the events pane as shown:

{ 
  "operand1": "3", 
  "operand2": "5", 
  "operator": "mul" 
} 

With the event parameters passed, you should see a flowchart along with the Input and Output values as shown in the following flowchart:

You can run similar permutations and combinations of executions on the following state machine as per your requirements. Make sure to check the DynamoDB table whether the records are inserted or not, as well as the CloudWatch logs of your individual Lambda functions for troubleshooting and error checking.