Here at DAZN, we are migrating from our legacy platform into a brave new world of microfrontends and microservices. Along the way, we also discovered the delights that AWS Step Function has to offer, for example…
In some cases, we need to control the number of concurrent state machine executions that can access a shared resource. This might be a business requirement. Or it could be due to scalability concerns for the shared resource. It might also be a result of the design of our state machine which makes it difficult to parallelise.
We came up with a few solutions that fall into two general categories:
You can control the MAX number of concurrent executions by introducing a SQS queue. A CloudWatch schedule will trigger a Lambda function to…
We’re not using the new SQS trigger for Lambda here because the purpose is to slow down the creation of new executions. Whereas the SQS trigger would push tasks to our Lambda function eagerly.
Also, you should use a FIFO queue so that tasks are processed in the same order they’re added to the queue.
You can use the ListExecutions API to find out how many executions are in the RUNNING state. You can then sort them by startDate and only allow the eldest executions to transition to states that access the shared resource.
Take the following state machine for instance.
The OnlyOneShallRunAtOneTime state invokes the one-shall-pass
Lambda function and returns a proceed
flag. The Shall Pass? state then branches the flow of this execution based on the proceed
flag.
OnlyOneShallRunAtOneTime:Type: TaskResource: arn:aws:lambda:us-east-1:xxx:function:one-shall-passNext: Shall Pass?Shall Pass?:Type: ChoiceChoices:- Variable: $.proceed # check if this execution should proceedBooleanEquals: trueNext: SetWriteThroughputDeltaForScaleUpDefault: WaitToProceed # otherwise wait and try again later WaitToProceed:Type: WaitSeconds: 60Next: OnlyOneShallRunAtOneTime
The tricky thing here is how to associate the Lambda invocation with the corresponding Step Function execution. Unfortunately, Step Functions does not pass the execution ARN to the Lambda function. Instead, we have to pass the execution name as part of the input when we start the execution.
const name = uuid().replace(/-/g, '_')const input = JSON.stringify({ name, bucketName, fileName, mode }) const req = { stateMachineArn, name, input }const resp = await SFN.startExecution(req).promise()
When the one_shall_pass
function runs, it can use the execution name
from the input. It’s then able to match the invocation against the executions returned by ListExecutions.
In this particular case, only the eldest execution can proceed. All other executions would transition to the WaitToProceed state.
module.exports.handler = async (input, context) => {const executions = await listRunningExecutions()Log.info(`found ${executions.length} RUNNING executions`)
const oldest = _.sortBy(executions, x => x.startDate.getTime())[0]Log.info(`the oldest execution is [${oldest.name}]`)
if (oldest.name === input.name) {return { ...input, proceed: true }} else {return { ...input, proceed: false }}}
Let’s compare the two approaches against the following criteria:
Approach 2 (blocking executions) has two problems when you have a large number of concurrent executions.
First, you can hit the regional throttling limit on the ListExecutions
API call.
Second, if you have configured timeout on your state machine (and you should!) then they can also timeout. This creates backpressure on the system.
Approach 1 (with SQS) is far more scalable by comparison. Queued tasks are not started until they are allowed to start so no backpressure. Only the cron Lambda function needs to list executions, so you’re also unlikely to reach API limits.
Approach 1 introduces new pieces to the infrastructure — SQS, CloudWatch schedule and Lambda. Also, it forces the producers to change as well.
With approach 2, a new Lambda function is needed for the additional step, but it’s part of the state machine.
Approach 1 introduces minimal baseline cost even when there are no executions. However, we are talking about cents here…
Approach 2 introduces additional state transitions, which is around $25 per million. See the Step Functions pricing page for more details. Since each execution will incur 3 transitions per minute whilst it’s blocked, the cost of these transitions can pile up quickly.
Given the two approaches we considered here, using SQS is by far the more scalable. It is also more cost effective as the number of concurrent executions goes up.
But, you need to manage additional infrastructure and force upstream systems to change. This can impact other teams, and ultimately affects your ability to deliver on time.
If you do not expect a high number of executions, then you might be better off going with the second approach.
Hi, my name is Yan Cui. I’m an AWS Serverless Hero and the author of Production-Ready Serverless. I have run production workload at scale in AWS for nearly 10 years and I have been an architect or principal engineer with a variety of industries ranging from banking, e-commerce, sports streaming to mobile gaming. I currently work as an independent consultant focused on AWS and serverless.
You can contact me via Email, Twitter and LinkedIn.
Check out my new course, Complete Guide to AWS Step Functions.
In this course, we’ll cover everything you need to know to use AWS Step Functions service effectively. Including basic concepts, HTTP and event triggers, activities, design patterns and best practices.
Get your copy here.
Come learn about operational BEST PRACTICES for AWS Lambda: CI/CD, testing & debugging functions locally, logging, monitoring, distributed tracing, canary deployments, config management, authentication & authorization, VPC, security, error handling, and more.
You can also get 40% off the face price with the code ytcui.
Get your copy here.