Search code examples
elixirphoenix-frameworkgen-server

How to create an Elixir GenServer timed event at 12am every night


I have a LiveView web app and every night outside working hours (ideally at 12PM each night) I want to perform a single action (run a piece of code)

I have a GenServer that starts when the Phoenix App starts. The problem is that the phoenix server will be started at different times during the day and I do not know what time frame to set the scheduler.

To solve the problem (using my current code) I can perform the following:

When the user starts the phoenix app, capture the current day as a date and store it in state. Run the Genserver every hour to check if the day has changed. If the day changed run the code and store the new day in state.

Repeat.

This would work fine but the problem is I don't understand GenServers well enough to store and compare values as described. I figure I might need an agent to pass data around but I'm kind of just guessing.

I read this answer at it seems really clean but I do not know how to integrate the module: https://stackoverflow.com/a/32086769/1152980

My code is below and is based on this: https://stackoverflow.com/a/32097971/1152980

defmodule App.Periodically do
  use GenServer

  def start_link(_opts) do
    GenServer.start_link(__MODULE__, DateTime)
  end

  def init(state) do
    schedule_work(state) 
    {:ok, state}
  end

  def handle_info(:work, state) do
    IO.inspect state.utc_now
    IO.inspect "_____________NEW"
    IO.inspect DateTime.utc_now
    # When phoenix app starts - store current date
    # When Genserver runs.....
    # run conditional on stored date with current date
    # If they are different THEN RUN CODE
    # If they are the same - do nothing
    

    
  IO.inspect state
    schedule_work(state) 
    {:noreply, state}
  end

  defp schedule_work(state) do
   IO.inspect "test"
    Process.send_after(self(), :work, 10000 ) # 1 sec for testing will change to 1 hour
  end
end

Solution

  • The easiest solution here would probably be to leverage an existing package such as quantum which implements a configurable recurring process that seems like it would fit the use-cases you have described. Just adjust the example Heartbeat module for your own module that you wish to call, e.g.

    config :my_app, MyApp.MySchedulerModuleThatICreatedFollowingTheDocs,
      jobs: [
        # Runs every midnight:
        {"@daily",         {MyApp.SomeModule, :some_function_name_as_an_atom, ["positional", "arguments", "to", "the", "given", "function"]}}
      ]
    

    Would translate to calling MyApp.SomeModule.some_function_name_as_an_atom("positional", "arguments", "to", "the", "given", "function") every day at midnight.

    If you want to write your own GenServer for this, you have to handle a couple edge-cases: how to handle the server restarting multiple times in a day, how to handle the case that it starts EXACTLY at midnight, something with timezones, and probably a couple others that @Aleksei would think about.

    Adjusting code from an article I wrote, you might wind up with a module something like this:

    defmodule Cronlike do
      @moduledoc """
      In your supervision tree:
    
            children = [
              Supervisor.child_spec({Cronlike, %{mod: IO, fun: :puts, args: ["Working Hard"]}, id: :job1)
            ]
            Supervisor.start_link(children, [strategy: :one_for_one, name: MyApp.Supervisor])
    
      Or, start it manually:
    
            iex> Cronlike.start_link(%{mod: IO, fun: :puts, args: ["Working Hard"]})
      """
      use GenServer
    
      def start_link(state) do
        GenServer.start_link(__MODULE__, state)
      end
    
      @impl true
      def init(state) do
        Process.send_after(self(), :do_work, ms_til_midnight())
        {:ok, state}
      end
    
      def ms_til_midnight do
        now = DateTime.utc_now()
    
        unix_now_ms = DateTime.to_unix(now, :millisecond)
    
        tomorrow = now |> DateTime.add(1, :day) |> DateTime.to_date()
        tomorrow_midnight = DateTime.new!(tomorrow, Time.new!(0, 0, 0))
    
        tomorrow_midnight_unix_ms = tomorrow_midnight |> DateTime.to_unix(:millisecond)
    
        tomorrow_midnight_unix_ms - unix_now_ms
      end
    
      @impl true
      def handle_info(:do_work, %{mod: mod, fun: fun, args: args} = state) do
        # Do the work
        apply(mod, fun, args)
        # ms in day = 86_400_000
        Process.send_after(self(), :do_work, 86_400_000)
        {:noreply, state}
      end
    end
    

    It accepts configuration parameters to define the mod, fun, and args to call periodically. For simplicity, the state could more or less be ignored and you could just hardcode a single function to be called. However, for better usability, you can imagine adding in an option (in the state) that would dictate how frequently to run the task, but this would require you to refactor the example ms_til_midnight/0 function into something more flexible. You can see the trickiest part of this is simply the math to calculate how many milliseconds until midnight, so I've left that verbose.