#development #elixir #phoenix

When developing a web application in Elixir, you may occasionally need to schedule a task to run at regular intervals, like sending email notifications, cleaning up old records, or triggering a background process. While there are libraries like Quantum or Oban that make this task easier, sometimes you want a lightweight, custom solution without adding external dependencies.

In this post, we'll walk through how to create a simple cron-like job in an Elixir web app using built-in tools. We'll create a periodic worker that executes a task serially every minute, and you can easily customize it to your specific needs.

Why Not Use External Tools?

There are many great libraries available for scheduling jobs in Elixir, but sometimes it's overkill to introduce them, especially when your needs are minimal or you're working in a resource-constrained environment. Building a solution using just Elixir's GenServer and Process modules provides you with full control, simplicity, and no additional dependency overhead.

Setting Up the Periodic Worker

The core of our solution is a GenServer that schedules and runs tasks periodically. It executes a function, waits for a specified interval, and then repeats. This ensures that each task runs serially and won't overlap with the next one.

Let's dive into the implementation.

Step 1: Define the GenServer Module

Here's how we define our periodic worker using the GenServer behavior. This worker will schedule a task to be executed every minute (60 seconds).

 1defmodule MyApp.PeriodicWorker do
 2  use GenServer
 3
 4  @interval 60_000  # 1 minute interval (in milliseconds)
 5
 6  # Start the GenServer
 7  def start_link(_) do
 8    GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
 9  end
10
11  @impl true
12  def init(:ok) do
13    # Start the first job immediately
14    schedule_work()
15    {:ok, %{}}
16  end
17
18  @impl true
19  def handle_info(:work, state) do
20    # Perform the task
21    perform_task()
22
23    # Schedule the next job after it finishes
24    schedule_work()
25
26    {:noreply, state}
27  end
28
29  defp schedule_work() do
30    # Schedule the next execution after the specified interval
31    Process.send_after(self(), :work, @interval)
32  end
33
34  defp perform_task() do
35    # Your logic here
36    IO.puts("Executing task at #{DateTime.utc_now()}")
37    # Add your function execution logic here, e.g., calling another module's function
38  end
39end

Step 2: Add the Worker to Your Supervision Tree

To ensure that your periodic worker starts when your application boots, you need to add it to your supervision tree. Open your application.ex file and include it as part of your app's children processes.

1def start(_type, _args) do
2  children = [
3    # Other children...
4    MyApp.PeriodicWorker
5  ]
6
7  opts = [strategy: :one_for_one, name: MyApp.Supervisor]
8  Supervisor.start_link(children, opts)
9end

This ensures that the worker starts automatically and is supervised. If it crashes, the supervisor will restart it, keeping your application robust and fault-tolerant.

Step 3: Customize the Task

In the perform_task/0 function, we currently just print the time, but you can replace this with your custom logic. For example, you could:

  • Send emails.
  • Clean up outdated data.
  • Ping external APIs.
  • Any background task specific to your application.

How It Works

  • GenServer Start: The GenServer is started when your application boots.
  • Initial Task Execution: The init/1 function triggers the first task immediately using the schedule_work/0 function.
  • Periodic Execution: After the task finishes, the worker schedules the next execution after a delay of 60,000 milliseconds (1 minute) using Process.send_after/3.
  • Serial Execution: Each task is executed serially. Once a task completes, the next one is scheduled. This ensures that tasks don't overlap, preventing race conditions or unnecessary load.

Why This Approach?

Using Elixir's built-in features provides several advantages:

  • Lightweight: No need to add any external dependencies or tools.
  • Full Control: You have complete control over the task scheduling and execution.
  • Supervised: The worker is part of the supervision tree, ensuring that it's restarted if anything goes wrong.
  • Easy to Customize: You can easily adjust the interval and customize the task logic to suit your needs.

Adjusting the Interval

If you need to change the frequency of execution, you can adjust the @interval value:

1@interval 30_000  # 30 seconds interval

This flexibility allows you to fine-tune how often your task runs, whether it's every few seconds, minutes, or even hours.

Conclusion

Building a simple cron-like job in Elixir doesn't require complex tools or external libraries. With just a GenServer and a bit of process scheduling, you can create a lightweight, serial task executor that meets your needs. This approach is not only efficient but also leverages Elixir's powerful concurrency and supervision model, making it reliable and easy to maintain.

For lightweight background tasks, this method is often all you need. Of course, if your needs grow more complex, or if you need more advanced job features like retries or distributed processing, you can always explore more feature-rich libraries. But for now, you've got a simple and powerful solution built entirely with Elixir.