How to Trigger an Event on a Timer with EventBridge Scheduler and Python

EventBridge Scheduler is a new addition to the EventBridge suite of services that allow you to trigger an event at a specific moment in time. Previously, triggering an event at a particular time was a painful process that required creating a Step Function and adding Wait Task that could read off the input to schedule an event in the future. This old process was overkill for such a seemingly trivial task.

With the new EventBridge Scheduler feature released in November 2022, this job is much much easier. However, there are a bunch of traps you can fall in to that can mysteriously lead to EventBridge not firing your event in the first place–and worse yet, no logs get emitted either. 

In this post, I’ll show you how to set up EventBridge Scheduler to trigger a Lambda Function at a specific time in the future. I’ll also explain the specific IAM permissions you need in order to trigger your function successfully. All of this will be explained using the Python SDK, boto3, but the concepts I’m about to explain apply regardless of which language you use–just the syntax may be a bit different.

The first thing we need to understand is the IAM relationship of EventBridge Scheduler and how it relates to triggering an event.

One thing to note: throughout this article I use the term ‘schedule’ quite a bit. This is AWS’ term, not mine. When I say this, I am referring to the literal object in EventBridge Scheduler that contains the configuration details for our delayed event.

IAM Requirements for EventBridge Scheduler

When using EventBridge Scheduler, there are two IAM concepts to be aware of, the caller and the callee. The caller refers to the person or process that is creating the schedule (aka the future event). This can be done through the AWS CLI, via a Lambda Function, or through some other application system or process.

The callee refers to the infrastructure component that will be invoked as a result of this event firing. For example, if I were to create an event via the CLI that fires on September 12th, 2023 at 05:00 which triggers a Lambda Function, the caller would be the CLI and the callee would be the specific Lambda. 

In between the caller and the callee sits the middleman. The middleman is the EventBridge Scheduler service itself that uses a role provided by the caller to invoke the callee. This means that the role we provide as input during the schedule creation process needs to have the right permissions to invoke our Lambda function. 

Below are the policy documents followed by a brief explanation. 

Caller 

Permissions (AWS CLI)
------------------
{
    "Version":"2012-10-17",
    "Statement":[
        {
            "Sid":"VisualEditor0",
            "Effect":"Allow",
            "Action":[
                "scheduler:CreateSchedule",
                "iam:PassRole"
            ],
            "Resource":"*"
        }
    ]
}
Middleman Role - Used by EventBridge Scheduler, but the ARN of this role is provided by the Caller via Input to the createSchedule API

Permissions
------------------
{
    "Version":"2012-10-17",
    "Statement":[
        {
            "Sid":"VisualEditor0",
            "Effect":"Allow",
            "Action":[
                "lambda:InvokeFunction"
            ],
            "Resource":"*"
        }
    ]
}


Trust Policy
------------------
{
    "Version":"2012-10-17",
    "Statement":[
        {
            "Effect":"Allow",
            "Principal":{
                "Service":[
                    "scheduler.amazonaws.com"
                ]
            },
            "Action":"sts:AssumeRole"
        }
    ]
}

For the caller, the schedule:CreateSchedule is not a surprising item–this is simply just the permission we require to create the schedule instance. What is interesting is the iam:PassRole permission. This item grants the CLI user the permission to provide a role to a service that will be assumed later on behalf of ourselves, this is the EventBridge Scheduler service. This will become more important later when we look at the code and see how permission passing works when instantiating our schedule.

For the middleman, which is actually EventBridge Scheduler behind the scenes, first this Role first needs the permission to invoke the callee, a Lambda Function in our case. Second, in its Trust Policy, it needs to be granted the ability to be assumed by the EventBridge Scheduler service, or in aws’ vernacular scheduler.amazonaws.com

That’s all we need to know for passing around IAM permissions. We can now move on to actually calling the CreateSchedule API.

Using the Create Schedule API

Note that I’ll be using Python and boto3 in this example with documentation here. Note that the input names are going to be quite similar to other languages such as in Javascript, Java, Ruby, etc. However, the exact syntax may be slightly different. 

In this example, we’ll be working with two separate Lambda Functions. One will create the instance of the schedule, call this Lambda A, and one that will be invoked when we hit a time in the future, call this Lambda B. Using our same terminology as previous, Lambda A will be the caller, and Lambda B will be the callee

The below code and explanation is for Lambda A, which will be doing the majority of the work. Lets explain this line by line, with the full code block located at the bottom of the article.

First, we need to do basic imports and client instantiation. On our first couple of lines, we import boto3, the UUID library (which will be used later), and create a instance of the scheduler client. 

import boto3
import uuid

scheduler = boto3.client('scheduler')

In our `lambda_handler` which is our function’s entry point, we call the `create_scheduler` method, and begin providing it the required inputs. 


def lambda_handler(event, context):
	response = scheduler.create_schedule(
    		FlexibleTimeWindow={
        			'Mode':'OFF' # OFF | FLEXIBLE
    	},

Our first required input is FlexibleTimeWindow. If set to FLEXIBLE, this feature allows EventBridge scheduler to delay the invocation of callee by a time specified in the input. This is useful in some circumstances where you’d like to prevent a stampede of events that all trigger at a certain time. Since in this case we do want to trigger a specific time, we leave this set to OFF.

Next, we provide two more scheduling related inputs.

    	ScheduleExpression='at(2023-09-11T17:15:00)', # at(iso), rate(unit), cron(expression)
    	ScheduleExpressionTimezone='UTC-04:00',

These two fields tell Scheduler to trigger our event at a specific moment in time – in our case, September 11th, 2023, at 17:15:00 using the yyyy-mm-ddThh:mm:ss format. Note that we are using the at syntax which allows us to specify that time. Also supported is rate and cron which are more useful for continuous and periodic triggers which persist over time. Also note that we are providing the ScheduleExpressionTimezone which is very helpful especially during testing. By default, the scheduler appears to use UTC or GMT time zone so be careful to set this to the right zone if that’s not what your application is expecting. 

Next, we need to set some input telling Scheduler what to do when the timer does fire.

Target={
        'Arn':'arn:aws:lambda:us-east-1:755314965794:function:test',
        'Input': '{"hey": "there"}',
        'RoleArn': 'arn:aws:iam::755314965794:role/service-role/DelayedEventsDemo-role-y2xeci4s'
    	},

The Target field contains a bunch of options that specify who to invoke and what to invoke them with when the event fires. First is the Arn which is the AmazonResourceName for the component we would like to trigger, our callee in this case, which is Lambda Function B. We also provide it with an Input field which can be hardcoded or dynamic based on the context of our application. Finally is the RoleArn which corresponds to the IAM Role that the middleman (EventBridgeScheduler) will use to invoke the callee (Lambda B). 

Last are some final details around the naming of our schedule. 

     Name=str(uuid.uuid4())

The Name field gives our schedule a name so that we can track it in the console. Note that this value needs to be unique and if a duplicate is provided, a ValidationException will be thrown to the caller. To make sure we are always using a unique value, we use the uuid library with the uuid4() method which generates a 32 character or so string. We then convert it to a string to ensure its accepted as input. In a real application, I would suggest appending some kind of identifier to the name in addition to a UUID–perhaps something like a CustomerId, OrderId, or any other ID that can help you track down the specific schedule that triggers a function for a certain instance.

One last thing I’d like to discuss briefly is a new optional feature that isn’t yet provided in the Lambda boto3 library, but is available in the most recent library, ActionAfterCompletion

     ActionAfterCompletion='NONE', # NONE | DELETE

The ActionAfterCompletion field may appear simple, but its incredibly useful as a preventative measure to ensure you don’t breach EventBridge Scheduler’s limits. EventBridge Scheduler currently has a hard limit on the number of schedules you could create – 1,000,000 to be precise. This may not seem like a small number, but any application that works with large volumes of data can easily breach this number in a short time frame. 

As a workaround, developers needed to set up cron jobs to clean up old schedules that had already passed their invocation time. This was a massive pain that led to a lot of developer criticism, and honestly made me question why they launched the service in the first place with this limitation in effect.

Thankfully, the EventBridge team added a new feature which manifests as the ActionAfterCompletion field. This new field, when set to DELETE, will automatically delete the instance of your schedule after the callee has been triggered. This isn’t perfect–I would much prefer it if it just tombstoned the record and allowed us to keep it–but it’s better than nothing. 

By default, this setting is set to NONE so that records persist over time. So if you leave it as default, be aware of the 1,000,000 limit and maybe set up an alarm to make sure you can track the quantity over time.

So that’s it for the code that’s needed to make this kitten purr. After we trigger our code, printing the response object yields the following result:

{
    "ResponseMetadata":{
        "RequestId":"b5331f6f-219b-4426-bed5-fda9156d2ea2",
        "HTTPStatusCode":200,
        "HTTPHeaders":{
            "x-amzn-requestid":"b5331f6f-219b-4426-bed5-fda9156d2ea2",
            "content-type":"application/json",
            "content-length":"112",
            "date":"Mon, 11 Sep 2023 21:01:40 GMT"
        },
        "RetryAttempts":0
    },
    "ScheduleArn":"arn:aws:scheduler:us-east-1:755314965794:schedule/default/0a0de394-870b-4419-a905-bb514de7fcdc"
}

If we go into the EventBridge Scheduler console, we can see the instance of the schedule as seen below:

And if we take a look at Lambda Function B (our callee) at 17:15 – which simply prevents the incoming event, we can see the following in our logs. 

2023-09-11T17:15:29.230-04:00	INIT_START Runtime Version: python:3.11.v12 Runtime Version ARN: arn:aws:lambda:us-east-1::runtime:a88e0b49a8989d1296c62d7e745c09c8ddfe9f7faeaa45c1bc4e361cc74fa3b6

2023-09-11T17:15:29.355-04:00	START RequestId: 5264ff83-54dd-4b07-b913-51fd36c0b1dd Version: $LATEST

2023-09-11T17:15:29.356-04:00	{'hey': 'there'}

2023-09-11T17:15:29.357-04:00	END RequestId: 5264ff83-54dd-4b07-b913-51fd36c0b1dd

2023-09-11T17:15:29.357-04:00	REPORT RequestId: 5264ff83-54dd-4b07-b913-51fd36c0b1dd Duration: 1.73 ms Billed Duration: 2 ms Memory Size: 128 MB Max Memory Used: 40 MB Init Duration: 121.02 ms

Note that our event wasn’t triggered at exactly 17:15 which we specified in the input. This, according to Eventbridge, is working as intended. It currently works as a best effort basis to trigger at the specified time, which usually comes pretty close, but isn’t guaranteed.

And finally, here’s the entire code block used in Lambda A:

import boto3
import uuid

scheduler = boto3.client('scheduler')

def lambda_handler(event, context):
    response = scheduler.create_schedule(
        # ActionAfterCompletion='NONE', # NONE | DELETE
        FlexibleTimeWindow={
            'Mode':'OFF' # OFF | FLEXIBLE
        },
        ScheduleExpression='at(2023-09-11T17:15:00)', # at(iso), rate(unit), cron(expression)
        ScheduleExpressionTimezone='UTC-04:00',
        Target={
            'Arn':'arn:aws:lambda:us-east-1:755314965794:function:test',
            'Input': '{"hey": "there"}',
            'RoleArn': 'arn:aws:iam::755314965794:role/service-role/DelayedEventsDemo-role-y2xeci4s'
            
        },
        Name=str(uuid.uuid4())
    )
    
    print(response)

Exit mobile version