How we handle repeated tasks using Amazon EventBridge and AWS Lambda

Mike Rudge
Big Lemon Devs
Published in
7 min readJun 8, 2022

--

It’s a classic starter challenge to build a simple todo app, however, modern todo apps have some more advanced functionality that can be tricky to implement correctly. One example of an extra feature is tasks that repeat.

Repeated tasks allow users to set an interval (daily, weekly, monthly etc) and have the task automatically repeat itself on that interval.

We’re about to delve into a simple(ish!) way of handling repeated tasks using Amazon EventBridge and AWS Lambda.

Laptop on desk with code
Photo by Emile Perron on Unsplash

Final note: We use the AWS CDK in TypeScript. There are a bunch of code samples dotted throughout this, but hopefully it’s fairly transferrable to other languages.

Let’s get started….

The user story

I want to setup a task once and have it repeat on a schedule

Let’s begin with the actual problem we’re trying to solve. Users create a task and enter the details, including a title, description, assignee, attachments, etc. They want to receive this task once a week, starting next Monday. They don’t want to recreate this task each week with the same details: instead, they should be able to set it up once and let our app do all the legwork.

Users should be able to see a history of who completed tasks each week and also be able to update the task at any point.

The overly complicated solution

When I first started thinking about this problem I jumped straight to figuring out how I was going to manage a schedule of tasks. CRON job every few minutes? Seems like a waste of resources most of the time having a (serverless) Lambda call a database every few minutes.

How about managing some kind of queue that holds onto tasks to be created and when? Querying this queue and letting users change it… sounds like a lot of work with a lot of space to go wrong.

As suggested, I was overcomplicating it!

Idiot sandwich gif

The simpler solution

Let’s start at the end: the actual solution we came up with is pretty simple (which always feels good).

All we needed from our app was this: when a user completes a task, we simply duplicate that task and set the due date to the correct date in the future. There’s really nothing extra complicated going on here.

The simplicity of our solution actually solves a few edge cases as well…

  • What if the user wants to change the schedule? No problem, we only care each time the task is complete, so no need to update a scheduler or CRON job or anything like it.
  • But what if the user doesn’t complete the task? The next task created will always be created based on the due date and the schedule. So for example, if they had a daily task and missed a few days, once they complete Monday’s task, Tuesday’s would be created, then Wednesday’s, etc. However, if they’ve missed a whole bunch of days and want to start the schedule again from today, they can simply change the due date to today’s date, and the schedule will start again. Easy!
  • What if the task needs updating? Maybe the task contains links and they need to be changed for future tasks? Again, no problem. Whatever’s changed on the task will be honoured because we’re just duplicating the completed task.

The Schema

Let’s consider roughly what a task might look like:

type TASK = {
id: string
title: string
description: string
completedAt: Date
createdAt: Date
updatedAt: Date
...
}

Pretty standard stuff so far.

To be able to handle the repeated tasks, we need to know how often the repeat should happen, so let’s add an enum to the task.

enum SCHEDULE {
DAILY
WEEKLY
MONTHLY
YEARLY
}
type TASK = {
...
repeat?: SCHEDULE
repeatEnd?: Date
}

We also inserted repeatEnd so that the user can specify a date on which the task stops recurring.

Amazon EventBridge [link]

So why didn’t we just create a new task at the same time as completing our previous task? This would be a totally valid way of doing things, and if setting up event buses isn’t right for you, you could by all means go down this route.

However, there are many advantages to having a loosely coupled system. For example, let’s say that later we will want to send a notification when a task is completed. Or how about we tell another part of our app when the todo has been completed? The task complete handler doesn’t need to know or handle any of this, it can simply let the event bus know which task has been completed.

Show me the ways!

To be able to duplicate the task, we need to listen out for anytime a task is completed and also have arepeat property on it. To do this we can create a rule with an event pattern that looks something like…

const taskCompletedRule = new event.Rule(this, "task.completed", {
ruleName: "task.completed.repeated",
eventBus: taskBus,
description: "A repeated task completed status was changed",
eventPattern: {
detail: {
operationType: ["update"],
fullDocument: {
completedAt: [{ exists: true }],
repeat: [{ exists: true }],
},
},
},
})

Your detail will vary based on how you send the event to EventBridge. In this example we are taking advantage of MongoDB triggers to deliver the event, however, the important part is using the exists keyword to find if both repeat and completedAt exist when a task has been updated.

Bring on the code!

Dog typing gif

Ok, ok, we’re almost there. Just one more bit of setup. Let’s create a Lambda function and attach it to the rule we just created. Using the CDK, this is fairly straightforward.

const taskCreatedFn = new lambda.NodejsFunction(
this,
"tasks-completed-listener",
{
entry: `taskCompleted/taskCompleted.ts`,
handler: "taskCompletedHandler",
environment: {
API_URL: api.graphqlUrl,
},
}
)
taskCompletedRule.addTarget(new targets.LambdaFunction(taskCreatedFn))

And just like that, we have a lambda function attached to our rule.

PRO TIP: if using TypeScript, check out the aws-lambda package that offers types for all sorts of events you get from AWS.

// taskCompleted.ts
import { EventBridgeHandler } from "aws-lambda"
export const taskCompletedHandler: EventBridgeHandler<
"task.completed",
...rest
> = async (event) => {
// Code to handle the event and duplicate the task
}

We’re going to do three major things in this function:

  1. Calculate the interval for the new task
  2. Make sure we honour the repeatEnd
  3. Create a new task that has most of the properties of the old one

Let’s start at the top. First, we calculate the interval until the next task:

import dayjs, { Dayjs } from "dayjs"export const calculateInterval = (
repeat: TaskRepeatOption,
date?: Date | null | undefined
): Date => {
let dueDate: Dayjs
switch (repeat) {
case "DAILY":
dueDate = dayjs(date).add(1, "day")
break
case "WEEKLY":
dueDate = dayjs(date).add(1, "week")
break
case "MONTHLY":
dueDate = dayjs(date).add(1, "month")
break
case "YEARLY":
dueDate = dayjs(date).add(1, "year")
break
default:
dueDate = dayjs(date)
break
}
// Convert it back to standard javascript date
return dueDate.startOf("day").toDate()
}

Sweet! That was nice and easy thanks to dayjs. However, we’re going to do a quick aside here. I’ve been practicing using objects instead of if statements and switch statements. Let’s see what that looks like if we refactor it.

// Refactored to use an object instead of a switchtype TaskObject = {
[K in TaskRepeatOption]: "day" | "week" | "month" | "year"
}
export const calculateInterval = (
repeat: TaskRepeatOption,
date?: Date | null | undefined
): Date => {
const dateMap: TaskObject = {
[TaskRepeatOption.Daily]: "day",
[TaskRepeatOption.Weekly]: "week",
[TaskRepeatOption.Monthly]: "month",
[TaskRepeatOption.Yearly]: "year",
}
return dayjs(date).add(1, dateMap[repeat]).startOf("day").toDate()
}

I still start by writing switch and if statements, but I do really like refactoring to objects like this. What do you think?

ANYWAY!.. Let’s continue.

The next step is to make sure we are honouring the endDate of the repeat if there is one.

export const adjustRepeatEnd = (
repeatEnd: Date | null | undefined,
repeat: TaskRepeatOption
): TaskRepeatOption | null => {
if (repeatEnd) {
const tomorrow = dayjs().add(1, "day").startOf("day")
const isBeforeTomorrow = dayjs(repeatEnd).isBefore(tomorrow, "day")
if (isBeforeTomorrow) {
// set the repeat to null so no more tasks are created
return null
} else {
// Continue duplicating tasks on this schedule
return repeat
}
}
// Continue duplicating tasks on this schedule
return repeat
}

I’m going to leave the “create task” part mostly up to you as that all depends on what database/API you are using. However, I will say that you don’t actually want to duplicate the whole previous task as it would contain a completedAt field and probably a lot of other metadata you don’t want in the new task. A clean way to remove a bunch of fields is to use lodash/omit like this:

import omit from "lodash/omit"export const removeProperties = (task: Task) =>
omit(task, [
"_id",
"id",
"createdAt",
"updatedAt",
"completedAt",
"completedBy",
"deletedAt",
"ref",
])

PRO TIP: if you’re only going to use the omit function from lodash, you can install it like this: yarn add lodash.omit

How cool is that!

Let’s put it all together…

// Get the correct nextDueDate
const nextDueDate = calculateInterval(repeat, doc.dueDate)
// Check if we need to nullify the repeated because of the `repeatEnd`
const adjustedRepeat = adjustRepeatEnd(doc.repeatEnd, repeat)
const newTask: Task = {
...doc,
dueDate: nextDueDate,
repeat: adjustedRepeat,
}
try {
// Create the task in the database
const finalTask = await createTask(newTask)
} catch (error) {
// Handle errors
}

PHEW! Coffee?

Let me tell you a bit about our sponsors … just kidding, this post has been mining for Bitcoin so don’t even worry about it! 😆

I’d love your feedback on this. Was it obvious to you that this was the solution? Or did you start by overcomplicating the process as I did? Either way, it’d be good to hear from you if you’ve implemented something similar. Share your experience!

Big Lemon is a BCorp, Tech with Purpose, value-driven company, with a team of curious product makers. We implemented this for a project called Stella, a tool for the social care industry, supporting young people when they leave the care system. You can read more about it in our case study of the web app.

--

--