3 ways to schedule tasks in Elixir I have learned in 3 years working with it

Santiago Bacaro
kommit
Published in
9 min readJun 27, 2020

About 3 years ago I started learning Elixir and working with it at kommit.co, and throughout this time, in almost every project I have worked in, we have had the need to set up recurrent or scheduled tasks. Here, I will show you three different ways of achieving this that may cover your needs whether you are running a small application in your local environment, or running a full-fledged distributed application in a production environment.

1. Keeping things simple — the GenServer

Before I could actually start working on client projects at kommit.co, I underwent a training phase, which ended with an internal project: a Telegram bot to automate some managerial tasks. The idea was a simple bot with a couple of commands to manage employees in the system, and in order to achieve it, a first crucial step was to be able to constantly retrieve updates (a.k.a. messages) sent to the bot. Initially, we were going to use an existing, relatively popular library called Nadia to do everything related to the communication with Telegram. However, after the first couple of iterations we found that we wanted to keep more than one bot, and for each of those, we would need to do a similar set of operations: retrieve updates, process them, and produce a response. Unfortunately, Nadia did not support more than one bot at that time, so we decided to roll our own solution.

Enter the GenServer
Luckily for us, out of the box, Elixir provides a way to do recurrent tasks which is pretty simple to set up: GenServers. I will assume that if you have covered the basics of Elixir, you will know what a GenServer is, but if you don’t, make sure to check out Elixir’s guide page about it, or the GenServer’s behaviour documentation. Here is a very basic example of how you can schedule tasks using a GenServer:

Example inspired by the answers to this question https://stackoverflow.com/questions/32085258/how-can-i-schedule-code-to-run-every-few-hours-in-elixir-or-phoenix-framework

The key part here is to use :timer.send_interval(3_600_000, :work) which comes from Erlang’s timer module and is shorthand for :timer.send_interval(3_600_000, self(), :work) indicating that we want the GenServer to send every 3'600.000 milliseconds (or 1 hour) a message to itself (referenced by self()) with the content :work, thus creating a recurrent task.

In the body of handle_info, we simply make a call to do_recurrent_thing/1 which is a private function we define at the end that does any processing we would like to schedule.

In the case of our bot application, this looked a little bit different, but the foundation was the same. We were constantly checking for bot updates in the endpoints Telegram provided to do so, opening a connection and keeping it open for a while until a message was received, in which case we would process the message and keep asking for updates.

Drawbacks

This solution worked great in that project because it was a very small application that was never even deployed, but there are a couple of possible pitfalls for this very simple implementation.

  1. One drawback about this approach is that :timer.send_interval/2 makes the interval constant. What this means is that it will be called, for instance, every minute, no matter what. And if it is possible that the work you need to do will take longer than that, the message queue of the GenServer will just keep growing and growing.

A workaround for these kind of cases in which what you need is to run the process a given amount of time after the same process has been done, you can use Process.send_after/3 instead, like this:

Example inspired by the answers provided to the question at: https://stackoverflow.com/questions/32085258/how-can-i-schedule-code-to-run-every-few-hours-in-elixir-or-phoenix-framework

It is very important to call schedule_work after doing your work, because if you call it before that, you are just doing the same thing :timer.send_interval does.

2. Another drawback to this approach is that, out of the box, it might not work well in applications with distributed nodes. This is because every instance of the application will start its own process for the GenServer and therefore your task might run once for each node you have deployed, which in many cases might not be ideal. As a workaround for this, check out the third option.

2. A configurable little beast: Quantum

Some time later, I started working on another project that we started developing for a client. Basically, we wanted to take some work off of another application, which was querying Mixpanel’s raw data endpoint to get information about some tracking events. However, the amount of events had grown so vast that each request to Mixpanel would take a lot of time to complete.

So, we decided to take the data we needed and store it in an Elasticsearch cluster and index it as necessary. For this purpose, we decided that an approach that fulfilled our needs would be to query the raw data, filtering only the latest events, and store them in our Elasticsearch cluster, which the other application would query. Initially, we wanted to retrieve the new data every day at 10 a.m. and then again at 4 p.m., so, we thought we could use something like a cron job, and that is how I met Quantum.

Quantum is an Elixir library that allows you to schedule tasks using cron time string format and is very easy to integrate with code you already have.

With Quantum, setting up the scheduled task was as easy as installing the dependency and adding something like this to our configuration files:

Here, a module called WorkModule would do what we want to schedule in a function :work.

If you are not familiar with the syntax of the string "* * * * *", that is a cron time string. The first element in it is minutes, * simply means “run any …”; the second element is hours; the third one is day of the month; the fourth one is month; and the last one is day of the week. So, "* * * * *" simply means that your process would run every minute of every day. But it is a super flexible format, so if you wanted to run your task at 2:30pm every Tuesday, you would do "30 14 * * 2". Or, in order to run it every 30 minutes, you would do "*/30 * * * *". Or, if you needed to run your process at 10am and 4pm everyday, like we wanted, you could do "0 10,16 * * *". As you can see, it is extremely flexible.

Drawbacks

This solution was perfect for our use case, because we had already written the code that we wanted to schedule in other modules and this was very simple to integrate with that. Plus, we had the application deployed in a single node, so we did not have the problem of more than one request being made at the same time trying to fetch the data from Mixpanel and update the ES indices, which, because of Mixpanel’s limitations, could have been a billing problem or could have caused us to run out of available requests to their API.

3. Oban, a different kind of beast

After working on that application, and on a larger project related to that, I was moved to another client’s project. This time it was an EdTech company which provides tests for middle school and high school students, and which was migrating an existing Ruby on Rails application to a new Phoenix one.

In order to make the migration process smoother, the company decided to build the core of the application with Phoenix and have it query different microservices in RoR while those are migrated. However, because the application’s traffic is rather low most of the time but really high at other times (like when students from a whole school are taking the tests), a way to alleviate the pressure on the microservice calculating the results was not to calculate them immediately, but rather to have a worker running periodically, calculating results for batches of students.

Before moving to Phoenix, they would calculate the results overnight, having a scheduled job run at midnight to calculate pending results, no matter how long it took. But when moving to Elixir, it was decided to take advantage of Elixir’s features, which allowed students to get results not in 12–24 hours, but in as little as 5 minutes. This was achieved with a GenServer not very different to the one seen at the beginning of this article, however, in this case the application runs in distributed nodes, which initially resulted in the process running more than once simultaneously, and causing a couple of transaction isolation conflicts.

This was solved without any external dependencies, using the application’s database to keep track of things like the status of a task (running, pending, run), the time when it should run next, and other relevant things.

However, the option to refactor this and other recurrent tasks that are being handled in the same way came up recently, and Oban was proposed as an alternative solution. We have not really used it in production yet, but I decided to dig into it a little bit and to try to set up a test application to take it for a spin.

Oban describes what it does as:

Robust job processing in Elixir, backed by modern PostgreSQL. Reliable,
observable and loaded with enterprise grade features.

In other words, it is a library for job processing that relies on a PostgreSQL database for multiple things, like keeping track of jobs that have already run or are currently running. Of course, you could do this by hand, like I mentioned we were doing, but Oban is designed for this and will probably make your life easier.

In order to set up the example, I had to install the dependency, generate a new migration and run it, and add something like this to my config:

config :my_app, Oban,
repo: MyApp.Repo,
queues: [default: 1],
crontab: [{"*/30 * * * *", MyApp.Worker, unique: [period: 30]}]

Here, the task to be scheduled is included under the :crontab key, and a queue named :default is created with a limit of 1, which is the limit per node. And after that I just had to add a module that uses Oban.Worker and specify that it should be run by the :default queue defined in the configuration.

And that was it! For details on how to install it, generate the migrations and everything else that might be needed to set it up, check out Oban’s documentation.

Drawbacks

This one cannot go without drawbacks. As you may see, Oban is very powerful, and if you dig into their documentation you will see what I mean. You can use cron like scheduling, or you can schedule new processes at runtime, you can handle different queues with different concurrency limits, and lots more.

But, precisely because it is so powerful, it is probably overkill for a lot of use cases. Sometimes your application will be just fine following an approach as simple as the first one, or you can use Quantum, which is a nice middle ground.

It might also be a drawback to some that it needs a database to work. Some applications do not use a DB at all but might still need to schedule some tasks, and in that cases, it might not make sense to add a DB just to be able to do so.

Final comments

As a side note, it might be useful to mention that both Quantum and Oban support some aliases to make scheduling jobs easier, like @weekly or @daily which can be used instead of the cron time strings.

The solutions shown here are heavily based on the answers to this StackOverflow question, and from both Quantum’s and Oban’s Github repositories.

On the other hand, note that a solution like Oban packs many features that you might find helpful when deploying your app to a production environment, but if you need lighter solutions, you can always do it yourself using things like the GenServer shown here and keeping track of events execution yourself, like I mentioned the EdTech company was doing.

Additionally, other type of solutions like distributed Erlang can be used to make things simpler and communication smoother, but they might require more work on the infrastructure side of things. You may take a look at this guide or check out this article to learn more about distributed Elixir applications.

That’s it. I hope you’ll find this useful for your particular use case. Feel free to ask about things that are not clear or to point out anything that doesn’t seem quite right. And if you have a different preferred way of scheduling tasks, please do let us know. You’ll be reading more from kommit.co!

--

--