How to implement a resetable countdown timer with a GenServer in Elixir or Erlang
Asked Answered
P

2

8

How do we implement a reset-able countdown timer with a GenServer?

1) perform a task after fixed amount of time, say every 60 seconds

2) have a way to reset the countdown back to 60 seconds before the timer elapses

I have looked at How to perform actions periodically with Erlang's gen_server? but it doesn't quite cover the aspect of resting the timer before the countdown elapses.

Thanks.

Phyletic answered 22/1, 2016 at 13:52 Comment(0)
A
16

How can I schedule code to run every few hours in Elixir or Phoenix framework? defines how to do a periodic job.

To implement a cancel using that as a base you could do the following:

defmodule MyApp.Periodically do
  use GenServer

  def start_link(_name \\ nil) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  def reset_timer() do
    GenServer.call(__MODULE__, :reset_timer)
  end

  def init(state) do
    timer = Process.send_after(self(), :work, 60_000)
    {:ok, %{timer: timer}}
  end

  def handle_call(:reset_timer, _from, %{timer: timer}) do
    :timer.cancel(timer)
    timer = Process.send_after(self(), :work, 60_000)
    {:reply, :ok, %{timer: timer}}
  end

  def handle_info(:work, state) do
    # Do the work you desire here

    # Start the timer again
    timer = Process.send_after(self(), :work,60_000)

    {:noreply, %{timer: timer}}
  end

  # So that unhanded messages don't error
  def handle_info(_, state) do
    {:ok, state}
  end
end

This maintains a reference to the timer, allowing it to be cancelled. Every time the :work message is received, a new timer is created and stored in the state of the GenServer.

Algology answered 22/1, 2016 at 13:59 Comment(2)
The timer ref returned from Process.send_after(self(), :work, 60_000) does cannot be cancelled with :timer.cancel(timer) as it returns {:error, :badarg}. However, I can use :erlang.cancel_timer(timer). I am using Elixir v1.1.1.Encincture
With Elixir 1.5+, cancelling the timer can be done with Process.cancel_timer(timer)Inter
U
4

If you did it in Erlang, as per the other question you referenced...

You save the timer reference and call erlang:cancel_timer/1 to stop it from firing (if it hasn't already). You have to watch out for this 'fired already' race condition, where the trigger message is already in your message queue when you cancel the timer. You might or might not care about this, but if it's important that you never carry out the action after the trigger has been cancelled then you need to create a reference (or you could use a counter) when you setup a timed message, and when you get a trigger you have to check that it relates to the latest one.

The code from the other question then becomes:

-define(INTERVAL, 60000). % One minute

init(Args) ->
    ...
    % Start first timer
    MyRef = erlang:make_ref(),
    {ok, TRef} = erlang:send_after(?INTERVAL, self(), {trigger, MyRef}),
    ...
    {ok, State#your_record{
        timer  = TRef,
        latest = MyRef
        }}.

% Trigger only when the reference in the trigger message is the same as in State
handle_info({trigger, MyRef}, State = #your_record{latest=MyRef}) ->
    % Do the action
    ...
    % Start next timer
    MyRef = erlang:make_ref(),
    {ok, TRef} = erlang:send_after(?INTERVAL, self(), trigger),
    ...
    {ok, State#your_record{
        timer  = TRef,
        latest = MyRef
        }}.

% Ignore this trigger, it has been superceeded!
handle_info({trigger, _OldRef}, State) ->
    {ok, State}.

And something like this to reset the timer:

handle_info(reset, State = #your_record{timer=TRef}) ->
    % Cancel old timer
    erlang:cancel_timer(TRef),
    % Start next timer
    MyNewRef = erlang:make_ref(),
    {ok, NewTRef} = erlang:send_after(?INTERVAL, self(), trigger),
    {ok, State#your_record{
        timer  = NewTRef,
        latest = MyNewRef
        }}.

A call might be more appropriate for the cancel function, but that depends on your app so it's up to you.

Technically, it's not necessary to cancel the timer, because once you've created a new state with your new reference, even if the old timer carries on, it'll be ignored when it fires, but I think it's best to tidy up really.

Underplot answered 22/1, 2016 at 14:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.