Yet another serverless solution for invoking AWS Lambda at a sub-minute frequency

Triggering a Lambda function by an EventBridge Events rule can be used as a serverless replacement of cron job. The highest frequency of it is one invocation per minute so that it cannot be used directly if you need to schedule a Lambda function more frequently. For example, it may be refreshing an application with real time metrics from an Amazon Connect instance where some metrics are updated every 15 seconds. There is a post in the AWS Architecture Blog and it suggests using AWS Step Functions. Or a usual recommendation is using Amazon EC2. Albeit being serverless, the former gets a bit complicated especially in order to handle the hard quota of 25,000 entries in the execution history. And the latter is not an option if you look for a serverless solution. In this post, I’ll demonstrate another serverless solution of scheduling a Lambda function at a sub-minute frequency using Amazon SQS.

Architecture

The solution contains 2 Lambda functions and each of them has its own event source: EventBridge Events rule and SQS.

  1. A Lambda function (sender) is invoked every minute by an EventBridge Events rule.

  2. The function sends messages to a queue with different delay seconds values. For example, if we want to invoke the consumer Lambda function every 10 seconds, we can send 6 messages with delay seconds values of 0, 10, 20, 30, 40 and 50.

  3. The consumer is invoked after the delay seconds as the messages are visible. 

I find this architecture is simpler than other options.

Lambda Functions

The sender Lambda function sends messages with different delay second values to a queue. An array of those values are generated by generateDelaySeconds(), given an interval value. Note that this function works well if the interval value is less than or equal to 30. If we want to set up a higher interval value, we should update the function together with the EventBridge Event rule. The source can be found in the GitHub repository.

 

// src/sender.js
const AWS = require(“aws-sdk”);

const sqs = new AWS.SQS({
  apiVersion: “2012-11-05”,
  region: process.env.AWS_REGION || “us-east-1”,
});

/**
* Generate delay seconds by an interval value.
*
* @example
* // returns [ 0, 30 ]
* generateDelaySeconds(30)
* // returns [ 0, 20, 40 ]
* generateDelaySeconds(20)
* // returns [ 0, 15, 30, 45 ]
* generateDelaySeconds(15)
* // returns [ 0, 10, 20, 30, 40, 50 ]
* generateDelaySeconds(10)
*/
const generateDelaySeconds = (interval) => {
  const numElem = Math.round(60 / interval);
  const array = Array.apply(0, Array(numElem + 1)).map((_, index) => {
    return index;
  });
  const min = Math.min(…array);
  const max = Math.max(…array);
  return array
    .map((a) => Math.round(((a – min) / (max – min)) * 60))
    .filter((a) => a < 60);
};

const handler = async () => {
  const interval = process.env.SCHEDULE_INTERVAL || 30;
  const delaySeconds = generateDelaySeconds(interval);
  for (const ds of delaySeconds) {
    const params = {
      MessageBody: JSON.stringify({ delaySecond: ds }),
      QueueUrl: process.env.QUEUE_URL,
      DelaySeconds: ds,
    };
    await sqs.sendMessage(params).promise();
  }
  console.log(`send messages, delay seconds – ${delaySeconds.join(“, “)}`);
};

module.exports = { handler };

The consumer Lambda function simply polls the messages. It is set to finish after 1 second followed by logging the delay second value.

// src/consumer.js
const sleep = (ms) => {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
};

const handler = async (event) => {
  for (const rec of event.Records) {
    const body = JSON.parse(rec.body);
    console.log(`delay second – ${body.delaySecond}`);
    await sleep(1000);
  }
};

module.exports = { handler };

Serverless Service

Two Lambda functions (sender and consumer) and a queue are created by Serverless Framework. As discussed earlier the sender function has an EventBridge Event rule trigger and it invokes the function at the rate of 1 minute. The schedule interval is set to 10, which is used to create delay seconds values. The consumer is set to be triggered by the queue. 

# serverless.yml
service: ${self:custom.serviceName}

plugins:
  – serverless-iam-roles-per-function

custom:
  serviceName: lambda-schedule
  scheduleInterval: 10
  queue:
    name: ${self:custom.serviceName}-queue-${self:provider.stage}
    url: !Ref Queue
    arn: !GetAtt Queue.Arn


provider:
  name: aws
  runtime: nodejs12.x
  stage: ${opt:stage, ‘dev’}
  region: ${opt:region, ‘us-east-1’}
  lambdaHashingVersion: 20201221
  memorySize: 128
  logRetentionInDays: 7
  deploymentBucket:
    tags:
      OWNER: ${env:owner}
  stackTags:
    OWNER: ${env:owner}

functions:
  sender:
    handler: src/sender.handler
    name: ${self:custom.serviceName}-sender-${self:provider.stage}
    events:
      – eventBridge:
          schedule: rate(1 minute)
          enabled: true
    environment:
      SCHEDULE_INTERVAL: ${self:custom.scheduleInterval}
      QUEUE_URL: ${self:custom.queue.url}
    iamRoleStatements:
      – Effect: Allow
        Action:
          – sqs:SendMessage
        Resource:
          – ${self:custom.queue.arn}
  consumer:
    handler: src/consumer.handler
    name: ${self:custom.serviceName}-consumer-${self:provider.stage}
    events:
      – sqs:
          arn: ${self:custom.queue.arn}

resources:
  Resources:
    Queue:
      Type: AWS::SQS::Queue
      Properties:
        QueueName: ${self:custom.queue.name}

Performance

We can filter the log of the consumer function in the CloudWatch page. The function is invoked as expected but I see the interval gets shortened periodically especially when the delay second value is 0. We’ll have a closer look at that below.

 

I created a chart that shows delay (milliseconds) by invocation. It shows periodic downward spikes and they correspond to the invocations where the delay seconds value is 0. For some early invocations, the delay values are more than 1000 milliseconds, which means that the consumer function’s intervals are less than 9 seconds. The delays get stable at or after the 200 invocation. The table in the right hand side shows the summary statistics of delays after that invocation. It shows the consumer invocation delays spread in a range of 300 milliseconds in general.

Caveats

An EventBridge Events rule can be triggered more than once and a message in an Amazon SQS queue can be delivered more than once as well. Therefore it is important to design the consumer Lambda function to be idempotent.

Conclusion

In this post, I demonstrated a serverless solution for scheduling a Lambda function at a sub-minute frequency with Amazon SQS. The architecture of the serverless solution is simpler than other options and its performance is acceptable in spite of some negative delays. Due to the at-least-once delivery feature of EventBridge Events and Amazon SQS, it is important to design the application to be idempotent. I hope this post is useful to build a Lambda scheduling solution.

Enjoyed this blog?

Share it with your network!

Move faster with confidence