GenServer : stopping periodic tasks

I have a simple GenServer timer that asks for timer interval and start printing messages with given intervals.
This works and messages are coming, but how can I make so that this could be interrupted / stopped?
Id like to be able to stop timer and then start again and provide another interval.

defmodule Timer do
  use GenServer

  #######
  #Client
  #######

  def start_link do
    GenServer.start_link(__MODULE__, [])
  end

  def newTimer() do
    start_link
  end

  #######
  #Server
  #######  

  def init(state) do
    {time, _} = IO.gets("Enter timer inter (ms): ") |> String.trim() |> Integer.parse()   
    Process.send_after(self(), :work, time)
    {:ok, [time, 1]}
  end

  def handle_info(:work, state) do
    IO.puts"Message #{Enum.at(state,1)}: Interval #{Enum.at(state, 0)} ms"
    Process.send_after(self(), :work, Enum.at(state,0))
    {:noreply, [Enum.at(state,0), Enum.at(state,1)+1]}
  end  

end


Process.send_after returns a reference, you can use it to cancel the timer:

timer = Process.send_after(...)

...

Process.cancel_timer(timer)

Put it in state if you cancel it later.

S. https://hexdocs.pm/elixir/Process.html#cancel_timer/2

3 Likes

Thank you, I can stop it now, BUT what id also like to be able to do is stop via some sort user input.
Right now I can cancel for example I set some condition (message > 5 for example) then run cancel_timer. This works, but what if I want timer to run indefinitely and “listen” to some input.
Something like event listener in javascript, can I do that ?

Hi,

You can register your genserver to a Registry. Or, as you control the input, you can just “tell” your server to cancel its timer, or to stop.

what if I want timer to run indefinitely and “listen” to some input.

If I understand you correctly, it’s nothing special, use genserver’s state and message passing. Something like that when setting the timer:

timer = Process.send_after(...)
Map.merge(state, %{timer: timer})
# that depends on the kind of callback
{:noreply, state}

Then something like that on user input:

def handle_call(:something_on_input, _from, state) do
    Process.cancel_timer(state.timer)
    
    # do something, set new timer, put it in state

    {:reply, :ok, state}
  end

And as part of public API you can have a normal function to isolate all the genserver-ing:

  def do_something() do
    # you can also pass user input specific to this call as part of the second artument and match on it in handle_call, {:something_on_input, :anything}
    GenServer.call(__MODULE__, :something_on_input)
  end

Thank you,
I understand, sending a stopping call function to GenServer will stop timer. I got this to work.
Pardon if my question seems simple I’m just beginning to learn Elixir / Functional programming, but my issue is that once a user starts timer, id want user to be able to press a button which then calls GenServer to stop timer.

This is my updated code

defmodule Timer do
  use GenServer

  #######
  #Client
  #######

  def start_link do
    GenServer.start_link(__MODULE__, [])
  end

  def newTimer() do
    start_link()
  end

  def stopTimer() do
    GenServer.call(__MODULE__, :cancel)    
  end

  #######
  #Server
  #######  

  def init(state) do
    {time, _} = IO.gets("Enter timer interval (ms): ") |> String.trim() |> Integer.parse()
    {intervals, _} = IO.gets("Enter number of intervals (0 = indefinitely): ") |> String.trim() |> Integer.parse()
    ref = Process.send_after(self(), :work, time)
    {:ok, [time, 1, ref, intervals]}
  end

  def handle_info(:work, state) do    
    ref = Process.send_after(self(), :work, Enum.at(state,0))
    IO.puts"Message #{Enum.at(state,1)}: Interval #{Enum.at(state, 0)} ms"
    cond do
        Enum.at(state,3) == 0 -> "continue"
        Enum.at(state, 1) >= Enum.at(state, 3) -> timerFinished(ref)
        true -> "test"
    end
    
    {:noreply, [Enum.at(state,0), Enum.at(state,1)+1, ref, Enum.at(state,3)]}  
  end

  def handle_call(:cancel, _from, state) do
      IO.puts"test"
      Process.cancel_timer(Enum.at(state,2))             
      {:reply, :ok, state}    
  end

  def timerFinished(ref) do
    Process.cancel_timer(ref)
    IO.puts"Timer stopped"
    IO.puts""
    Interface.begin()
  end

end

defmodule Interface do

  def begin() do
    IO.puts"Options"
    IO.puts"1: Setup & Start Timer"
    input = IO.gets("Enter Option Key: ") |> String.trim()
    cond do
      input == "1" -> Timer.newTimer()
      true -> IO.puts "Exiting program..."
    end
  end


end

Interface.begin()

Currently I start program, user sets up timer (time and interval number) and it executes well. I need to add one more functionality : that is Im missing this link that I want Interface module to be able to “listen” to a keypress and if pressed send timer terminating call to GenServer.
Youve explained well how to stop timer, what I dont know is how to get this signal from user and then call GenServer to stop.
Should I try to execute some of the functions in separate processes and send messages between, would that be a way to solve it ?

Maybe it helps to write that this is an exercise for University course (functional programming with elixir), I was given this task

Use a GenServer to produce general purpose periodical timer

  • An interface to start periodical timer with period in milliseconds and function to be called when timer triggers.
  • Option to cancel the timer per return value (:ok or :cancel) of the passed function.
  • Option to cancel the timer via public interface.

I think I’ve gotten first part done, 2-3 are what I need to understand and implement.

My guess is that when they say “interface” they mean public API of your module and not an actual command line interface. So for this excercise I’d concentrate on starting the genserver and creating few public functions which can manage the timer.

Your solution close enough, one thing kind of wrong there is that you do not remove the reference of the cancelled timer from the state, it’s better to set it to nil after cancelling. Also you might want to keep your state as a map so that your code is more readable (Process.cancel_timer(state.timer) instead of Process.cancel_timer(Enum.at(state,2))).

You can then interact with your program from cli by running it with console iex -S mix, calling these public functions and looking at their return values, that would be the easier answer to “how to get this signal from user”.

Also you might want to pass more parameters to the function that starts the timer, such as a callback to execute (see below).

Option to cancel the timer per return value (:ok or :cancel) of the passed function.

What they mean here is that you must pass a function as parameter when starting the timer and that function will be called when the time comes, it would let the caller define what work is actually done. This function then returns either :ok (new timer will be set) or :cancel (no new timer is set).

Option to cancel the timer via public interface.

That would be your stopTimer/0.

Basically try to keep everything in state, you have access to it most of the time, so define some defaults, then set what needs to be set when starting timer, change what needs to be changed when canceling it and when executing the callback.

On a side note: if you wanted a proper command line interface, having it in another process that calls public functions of your genserver would be fine for the purposes of that task.

1 Like

Thank you so much yurko for taking time and explainigg! I’m starting to understand what is going on :slight_smile:

I took your advice and I modified my code simpler, now I run iex.bat filename and write functions to interactive shell

{:ok, pid} = Timer.start_link
Timer.setupTimer(pid) setup timer interval
Timer.workTimer(pid)

I made it now that there is a function called status() which generates random number each interval, it either returns :ok or :cancel , does this look ok?

I no longer send_after but just Process.sleep()

Also, I tried to pass status as parameter but I’ve not done that before in course, how can I do that ?

New code

defmodule Timer do
  use GenServer

  #######
  #Client
  #######
    
  def start_link do
    GenServer.start_link(__MODULE__, [])
  end

  def status() do
    number = :rand.uniform(100)
    IO.inspect(number)
    cond do
      number > 10 -> :ok
      number <= 10 -> :cancel
    end
  end

  def workTimer(pid) do
    GenServer.call(pid, :sleep)
    proceed = status()
    if proceed == :ok do
      workTimer(pid)
    else
      "Status == :cancel"
    end
  end

  def stopTimer(pid) do
    GenServer.call(pid, :cancel)    
  end

  def setupTimer(pid) do
    GenServer.call(pid, :setup)
  end

  #######
  #Server
  #######  

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

 def handle_call(:setup, _from, state) do
      {time, _} = IO.gets("Enter timer interval (ms): ") |> String.trim() |> Integer.parse()
      {:reply, :ok, [time]}    
  end

  def handle_call(:cancel, _from, state) do
      IO.puts"Timer Stopped"
      {:reply, :ok, state}    
  end

  def handle_call(:sleep, _from, state) do
      Process.sleep(Enum.at(state,0))
      {:reply, :ok, state}
  end

end


Now state holds only timer interval value

with the above code I tried to implement stopTimer(0), for instance I added to state “true” , I tried to change this to false in the middle of timer with another call … but they are in same process so stopTimer does not go through before work is done :frowning:
Seems like im only able to setup and start timer

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 (&)

Thank you for writing example!
I studied it, I understand what’s going on. Some part of syntax was difficult to get at first but I got it.
I modified some part’s to try out things, it’s working.
Thanks again for taking time to help me understand.

1 Like