Search code examples
elixirphoenix-frameworkgen-server

Call Genserver from outside of the __module__


When calling a function I want to retry the function if I don't find the data I expected. I want to retry after 10 seconds from the time that the function failed.

CURRENT IMPLEMENTATION:

SCHEDULER

def check_question do
    case question = Repo.get_by(Question, active: true, closed: false) do

    question when not(is_nil(question)) ->
      case ActiveQuestion.ready_for_answer_status(question) do
        n when n in ["complete", "closed"] ->
          question
            |> Question.changeset(%{ready_for_answer: true, closed: true})
            |> Repo.update()
      end
    _ ->
      Process.send_after(Servers.Retry, :update, 10_000)
    end
  end

GENSERVER:

defmodule Servers.Retry do
  use GenServer
  require IEx

  def start_link do
    GenServer.start_link(__MODULE__, %{})
  end

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

  def handle_info(:update, state) do
    Scheduler.check_question()
    {:noreply, state}
  end
end

As you can see I'm trying to retry the function if the case statement is not met. But this does not quite work.

OUTPUT:

#Reference<0.1408720145.4224712705.56756>

It never calls the genserver from within Servers.Retry. I'm a super GenServer noob so forgive the lack of understanding. Thank you!!


Solution

  • So there are a few things to improve here.

    First, you are trying to access your GenServer by registered name (Process.send_after(Servers.Retry...), without actually registering the name.

    The basic way register a name is to include the :name opt in your call to GenServer.start_link, e.g.:

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

    Next, from a design perspective, you've broken encapsulation of the Retry GenServer. As a quick rule of thumb:

    The atoms used within a module shouldn't need to be known by other modules unless they are explicity part of the API (like opts and structs).

    How do we fix it? Simple. Put the call to Process.send_after/3 inside the Servers.Retry module:

    defmodule Servers.Retry do
      use GenServer
    
      ### External API:
    
      def start_link do
        GenServer.start_link(__MODULE__, [], [name: __MODULE__])
      end
    
      def retry(delay \\ 10_000) do
        Process.send_after(__MODULE__, :retry, delay)
      end
    
      ### GenServer Callbacks
    
      def init(state) do
        {:ok, state}
      end
    
      def handle_info(:retry, state) do
        Scheduler.check_question()
        {:noreply, state}
      end
    end
    

    I found this aspect one of the most confusing parts of learning GenServer: Some of the code defined in this module runs in the GenServer process, and some runs in other processes. Specifically, the two API methods are intended to be called by other processes- start_link by a Supervisor, and retry by an actual client.

    By placing the Process.send_after call inside an API method, we've simplified other methods, and decoupled what the Retry server does (tries again) from how it accomplishes that (with a send_after).

    My last suggestion: Either make the Retry server more generic, or more specific. Right now, it can only help the Scheduler, because it is too specific - the action to retry is hardcoded in. One idea is to have retry accept an arity-0 function to call when it's time to retry:

    def retry(action, delay \\ 10_000) do
      Process.send_after(__MODULE__, {:retry, action}, delay)
    end
    # ...snip
    def handle_info({:retry, action}, state) do
      action.()
      {:noreply, state}
    end 
    

    Now, it can retry anything- just pass a lambda to it. On the other hand, this seems like a feature that may not rate an abstraction. In this case, just collapse the two modules into one. I can't give you a code sample because I'm not sure what else is in Scheduler, but it shouldn't be too tricking to mix them into one.