Write your own CRON with Elixir
Elixir is rising in popularity and I think this is mostly due to the amazing Phoenix Framework. But for once, let's forget about WEB development and write a simple software: a complete CRON.
If you are too lazy to read, you can directly check the code on Github
Features
We want to build something simple yet production-ready. Therefore we need to be able to:
- define a list of tasks to run via a configuration file
- have a task runner to execute the tasks
- add the tasks to our Supervision Tree (so we can monitor them)
- log the result of each task
- important events should trigger emails
- ship the CRON as a binary
- have proper unit tests
In this article, we will focus on the 2 first items
Let's develop!
Setup the Elixir project
First things first. Let's create a new project:
$ mix new omycron --sup
This command creates an empty project. The --sup
option also generates an application
that is going to be the main hosts for our other processes - the first node of our supervision tree.
An Application is just a Supervisor that is automatically started by IEX by running iex -S mix
. We will soon add more children.
The tasks configuration file
Now we need to define the format of our config file. I like the simplicity of TOML so this is what we are going to use.
We will define our list of tasks using a TOML array of table
:
For now, each task must have a name, a shell command, and an interval. Optionally, we can also send an array of parameters.
Now we created the tasks config file, we need a way to dynamically retrieve its pathname in our Elixir application. While we could use environment variables, Elixir provides a Config module that is much easier to maintain.
The config for an Elixir project is usually in config/config.exs
. So let's create this file and add the following:
You can now retrieve the pathname of the config file using this command:
Application.fetch_env!(:omycron, :tasks_pathname)
Setup the tasks storage
We want to parse the TOML once and then store the list of tasks. In Elixir, the right way to store such data is with an Agent. An agent is just a process that is able to hold a state and provide some helpers to set and retrieve its state - yup, you got it right, the right way to store data in Elixir is through a sub-process!
In a folder tasks
, create a new file agent.ex
and following the documentation, initialize an empty Agent:
As you can see, the first parameter of start_link
is a function that should return the data to store. For now, it stores an empty map. All sub-processes started properly can also have a name
(2nd parameter). It allows us to retrieve the process using its name instead of its PID.
If you tried to run iex -S mix
, you might have noticed that our agent doesn't start. Since this is a sub-process, it needs to be added to our supervision tree. This is when our initial Application comes in handy! Edit omycron/application.ex
:
Now we added our newly created Agent as a sub-process of our main application. It will be automatically started by IEX. And since our Application supervisor is set with the strategy one_for_one
, if anything happens to our Agent and it crashes, it will automatically be restarted!
Time to parse!
Now everything is ready to store our tasks, we need to write our parser. We will use the librarytoml_elixir
. It's TOML 0.5 compliant. Not perfect but good enough.
Add toml_elixir
as a dependency
Once you have installed the dependency with mix deps.get
, create a new file tasks/parser.ex
that looks like this:
Ok, let's try our parser. Run iex -S mix
to start your program in an Interactive Elixir session and run Omycron.Tasks.Parser.parse
:
iex(1)> Omycron.Tasks.Parser.parse("./omc.toml")
Parsed tasks > [%OMC.Tasks.Parser.Task{command: "echo 'hello'", interval: %{"second" => 10}, name: "Hello world"}, %OMC.Tasks.Parser.Task{command: "echo 'im alive'", interval: %{"second" => 3}, name: "Im alive"}]
[
%Omycron.Tasks.Parser.Task{
command: "echo 'hello'",
interval: 10000,
name: "Hello world"
},
%Omycron.Tasks.Parser.Task{
command: "echo 'im alive'",
interval: 3000,
name: "Im alive"
}
]
iex(2)>
Alright, it's working well. Finally, we just have to merge this piece of code with the Agent we previously defined:
in tasks/agent.ex
, make the following changes to store the result of the parser into our Agent.
and in application.ex
, retrieve the config pathname from using the Config module of Elixir:
At this point, when you start the program, the tasks are loaded and stored in our agent. You can create a new file tasks.ex
where we will have a small helper to retrieve the list of tasks:
Our task runner
Let's wrap this chapter by writing our task runner. The task runner is a GenServer (understand a sub-process) that will send an event to itself every time a task should be executed.
Now everything is clear, create a runner.ex
file that looks like the following:
Now everything is ready, let's add this GenServer to our application's children. Edit application.ex
:
@impl true
def start(_type, _args) do
children = [
# Loads and store the tasks
Omycron.Tasks.Agent.child_spec([Application.fetch_env!(:omycron, :tasks_pathname)]),
Omycron.Runner.child_spec([]) # <- add this line
]
opts = [strategy: :one_for_one, name: Omycron.Supervisor]
Supervisor.start_link(children, opts)
end
Et voilà! Our CRON is finally working. You can test by running iex -S mix
.
I hope you enjoyed this article and stay tuned for the following chapter!
If you have a problem and no one else can help. Maybe you can hire the Kalvad-Team.