I’ll just go ahead and implement a simple server the does what you want it to do, I might have missed some points or got something wrong but the idea is to have a server which you can control by passing messages - should make things clearer. Feel free to as if you have questions.
defmodule Timer do
@moduledoc false
use GenServer
require Logger
@default_state %{timer: nil, callback: nil, interval: 5000}
@spec start_link(map(), list()) :: :ignore | {:error, any()} | {:ok, pid()}
def start_link(_state \\ %{}, _opts \\ []) do
GenServer.start_link(__MODULE__, @default_state, name: __MODULE__)
end
@spec run_now() :: any()
def run_now() do
GenServer.call(__MODULE__, :run_now)
end
@spec set_timer(integer(), fun()) :: any()
def set_timer(interval, callback) do
GenServer.call(__MODULE__, {:set_timer, %{interval: interval, callback: callback}})
end
@spec cancel_timer() :: any()
def cancel_timer() do
GenServer.call(__MODULE__, :cancel_timer)
end
# GenServer callbacks
@impl true
@spec init(map()) :: {:ok, map()}
def init(state) do
{:ok, state}
end
@impl true
@spec handle_call(atom(), tuple(), map()) :: {:reply, :ok, map()}
def handle_call({:set_timer, extra_state}, _from, state) do
{:reply, :ok, state |> Map.merge(extra_state) |> schedule_work()}
end
@impl true
def handle_call(:cancel_timer, _from, state) do
if state[:timer] do
Process.cancel_timer(state.timer)
end
{:reply, :ok, Map.merge(state, %{timer: nil})}
end
def handle_call(:run_now, _from, state) do
{:reply, :ok, run(state)}
end
@impl true
@spec handle_info(atom(), list()) :: {:noreply, map()}
def handle_info(:run, state) do
{:noreply, run(state)}
end
def handle_info(message, state) do
Logger.info("Timer server got an unknown message: #{inspect(message)}")
{:noreply, state}
end
# Private API
defp run(state) do
case state.callback.() do
:ok -> schedule_work(state)
:cancel -> Map.merge(state, %{timer: nil})
end
end
defp schedule_work(state) do
Logger.debug("Scheduling the next task in #{state[:interval]} ms")
if state[:timer] do
Process.cancel_timer(state.timer)
end
timer = Process.send_after(self(), :run, state[:interval])
Map.merge(state, %{timer: timer})
end
end
And here’s the usage - starting the server, interacting with it, using different types of callbacks etc.:
iex -S mix
Erlang/OTP 23 [erts-11.1] [source] [64-bit] [smp:24:24] [ds:24:24:10] [async-threads:1] [hipe]
Interactive Elixir (1.11.2) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> Timer.start_link
{:ok, #PID<0.141.0>}
iex(2)> Timer.set_timer(5000, fn() -> IO.puts "hello"
...(2)> :cancel end)
:ok
16:44:01.562 [debug] Scheduling the next task in 5000 ms
hello
iex(3)> Timer.set_timer(1000, fn() -> IO.puts "hello again"
...(3)> :ok end)
:ok
16:44:35.817 [debug] Scheduling the next task in 1000 ms
hello again
iex(4)>
16:44:36.818 [debug] Scheduling the next task in 1000 ms
hello again
iex(4)>
16:44:37.819 [debug] Scheduling the next task in 1000 ms
hello again
iex(4)>
16:44:38.820 [debug] Scheduling the next task in 1000 ms
hello again
...
iex(5)> Timer.cancel_timer
16:44:48.830 [debug] Scheduling the next task in 1000 ms
:ok
iex(6)> Timer.run_now
hello again
16:46:15.385 [debug] Scheduling the next task in 1000 ms
:ok
hello again
iex(7)>
...
iex(8)> Timer.cancel_timer
16:46:20.390 [debug] Scheduling the next task in 1000 ms
:ok
iex(9)>
Few points:
-
You register the server under an atom which is its model name so it’s automatically unique
-
You hide the server behind normal API and pass anything to callbacks by wrapping them into a tuple
-
Functions are just another data type, so you can pass them as parameters, you can use fn
syntax or captures (&
)