Elixir Nightly Tasks with a GenServer

Recently on my side project Grapevine, I wanted to start shuffling up the featured games on the homepage. I wanted to pick a mix of popular games, games that were using features of Grapevine (its web client and chat network), and a random sampling of other games. This should provide the home page with a relatively static set of games but one that still rotates games in and out.

To do this, I set up a nightly process that does the re-ordering. This is a GenServer that schedules a task to run every night at 6 AM.

I went with a GenServer to see if I could get a task running at a specific time of day similar to a cron job, and without pulling in a heavier library like Quantum (an Elixir cron-like library). This method of performing tasks uses OTP built-in primitives to achieve a cron-like job and Timex, a time manipulating library that you most likely already have installed.

Calculating the Delay

Before getting to the GenServer, I started with calculating the number of milliseconds to the next run. This is necessary because we're going to use Process.send_after/4 to send ourselves a message at the correct time. This function requires the number of milliseconds the message should be delayed and not a time to send it at.

Calculating the millisecond delay is trivial with Timex. Below is the code to calculate the difference. The only extra part of this is checking if the adjusted timestamp is already in the future, which might happen if the process restarts (for any reason) after midnight UTC but before 6AM.

def calculate_next_cycle_delay(now) do  
  now
  |> Timex.set([hour: 6, minute: 0, second: 0])
  |> maybe_shift_a_day(now)
  |> Timex.diff(now, :milliseconds)
end

defp maybe_shift_a_day(next_run, now) do  
  case Timex.before?(now, next_run) do
    true ->
      next_run

    false ->
      Timex.shift(next_run, days: 1)
  end
end  

Scheduling with Handle Continue

With the delay easily calculated we can now use handle_continue/2 to hook into scheduling the next run.

It's very simple to use. You return a tuple with {:continue, msg} at the end of most standard return values and the matching handle_continue/3 function will be guaranteed to run before looking at new process messages.

Below are the important pieces of the GenServer:

defmodule Grapevine.Featured do  
  def init(_) do
    # return a continue tuple last to schedule on process boot
    {:ok, %{}, {:continue, :schedule_next_run}}
  end

  def handle_info(:select_featured, state) do
    # process featured games
    # return a continue tuple last to schedule the next day's run
    {:noreply, state, {:continue, :schedule_next_run}}
  end

  # this callback triggers on any continue tuple
  def handle_continue(:schedule_next_run, state) do
    next_run_delay =
      Implementation.calculate_next_cycle_delay(Timex.now())
    Process.send_after(self(), :select_featured, next_run_delay)
    {:noreply, state}
  end
end  

See the docs for handle_continue.

Conclusion

Using the built-in primitives that Erlang and Elixir give us is very easy and robust. You can see the pull request that this was taken from up on GitHub.

I also worked on this during the SmartLogic TV live stream this week. Catch up on the episode here:

comments powered by Disqus