Powered by AppSignal & Oban Pro

GenServer

gen_server.livemd

GenServer

We learned that we can use Agents to keep state and Tasks to execute possibly long-running jobs with limited interaction. For more complex scenarios, that don’t fit these abstractions, GenServer is usually the way to go.

GenServer stands for generic server and abstracts the common client-server interaction. The GenServer process acts as a server - it receives requests from clients and sends responses back. Any other process can be a client, and there can be multiple clients. Client can send two kinds of requests to the server:

  • call - the client sends the request and waits for response,
  • cast - the client sends the request and continues its work (fire & forget).

Let’s try using GenServer to implement a key-value store, as we did in the ‘State and Agents’ chapter.

defmodule KV do
  # Firstly, we must `use` GenServer.
  # We learned about `use` in the chapter about modules.
  # For details of what exactly `use GenServer` does, consult
  # GenServer documentation.
  use GenServer

  # GenServer is a behaviour and here we implement some of its callbacks.
  # First one is handle_init, that receives options and initializes the
  # server's state.
  @impl true
  def init(_options) do
    state = %{}
    {:ok, state}
  end

  # handle_call is executed whenever the server receives a call request.
  # The server replies by returning a `{:reply, replied_value, new_server_state}`
  # tuple.
  @impl true
  def handle_call({:get, key}, _from, state) do
    value = Map.get(state, key)
    {:reply, value, state}
  end

  # handle_cast is executed whenever the server receives a cast request.
  # The server updates its state and returns it in a `{:noreply, new_state}` tuple.
  @impl true
  def handle_cast({:put, key, value}, state) do
    state = Map.put(state, key, value)
    {:noreply, state}
  end
end

Let’s start our server:

# GenServer.start_link accepts the server module and options.
# We don't pass any options, so put an empty list there.
{:ok, pid} = GenServer.start_link(KV, [])

and put something into the store:

# Put a new key-value pair
:ok = GenServer.cast(pid, {:put, :hello, :world})

# Get the value under :hello key
GenServer.call(pid, {:get, :hello})

💡 Try putting and getting different keys and values from the store

As you can see, we achieved similar functionality as we did with Agent. However, with GenServer we can go further. Let’s change our key-value store into a cache. The cache will also keep key-value pairs, but they will automatically expire after a given timeout.

defmodule Cache do
  use GenServer

  @impl true
  def init(options) do
    # If a key-value pair isn't updated in expiration_time milliseconds,
    # it will be removed from the cache. If expiration_time isn't provided,
    # we default to 5 seconds.
    expiration_time = Keyword.get(options, :expiration_time, 5000)

    # Send a `:cleanup` message to self after `expiration_time`
    Process.send_after(self(), :cleanup, expiration_time)

    # We need to keep both the content of the cache and the expiration_time,
    # so we put them under separate keys in the state
    state = %{data: %{}, expiration_time: expiration_time}
    {:ok, state}
  end

  @impl true
  def handle_call({:get, key}, _from, state) do
    time = System.monotonic_time(:millisecond)
    value =
      case Map.get(state.data, key) do
        {value, timestamp} when time - timestamp < state.expiration_time ->
          # The value is there and it hasn't expired
          value
        _other ->
          # The value isn't there or it expired
          nil
      end

    {:reply, value, state}
  end

  @impl true
  def handle_cast({:put, key, value}, state) do
    # When storing a value, we also keep the timestamp
    timestamp = System.monotonic_time(:millisecond)
    data = Map.put(state.data, key, {value, timestamp})
    state = %{state | data: data}
    {:noreply, state}
  end

  # `Process.send_after` that we called in `init` doesn't use
  # GenServer API - it just sends a regular message like `send/2`.
  # To process such a message with GenServer, we use another
  # callback: handle_info.
  @impl true
  def handle_info(:cleanup, state) do
    # Schedule the next cleanup after expiration_time
    Process.send_after(self(), :cleanup, state.expiration_time)

    # Remove all expired entries
    time = System.monotonic_time(:millisecond)
    data = Map.filter(state.data, fn {_key, {_value, timestamp}} ->
      time - timestamp < state.expiration_time
    end)

    state = %{state | data: data}
    {:noreply, state}
  end
end

Note that we used a new callback - handle_info/2. This callbacks allows you to receive a regular message, sent with send/2, Process.send_after/3 or other functions that aren’t aware of the GenServer API. What’s the difference between send (and handle_info/2) and cast (and handle_cast/2) then? Well, there’s no much difference. It’s there mostly to separate messages that constitute GenServer’s API from all other messages.

Let’s start our cache! The API is almost the same as in KV - we just pass the expiration_time option to GenServer.start_link/2.

{:ok, pid} = GenServer.start_link(Cache, expiration_time: 3000)

Let’s put something into the cache:

:ok = GenServer.cast(pid, {:put, :hello, :world})

GenServer.call(pid, {:get, :hello})

💡 Try putting and getting different keys and values from the cache

💡 Run the following cell. Did the value expire? If not, run it again after a few seconds.

GenServer.call(pid, {:get, :hello})

Our cache is almost ready - the only thing left is to wrap calls and casts with public functions. While not necessary, it’s good from both future-proofing and developer experience points of view.

defmodule BetterCache do
  use GenServer

  @doc """
  Starts a new cache.

  Accepts `expiration_time` option - time in millisecods after
  which the key-value pairs expire.
  """
  @spec start_link([expiration_time: pos_integer()]) :: {:ok, pid}
  def start_link(options) do
    # __MODULE__ resolves to the current module name, namely `BetterCache`
    GenServer.start_link(__MODULE__, options)
  end

  @doc """
  Puts the given value under the given key in the cache.
  """
  def put(cache, key, value) do
    # Note that `put` is a regular function, and it runs in the calling process
    IO.puts("`BetterCache.put/3` is run in the calling process: #{inspect(self())}")
    GenServer.cast(cache, {:put, key, value})
  end

  # 💡 Implement get/2 function

  @impl true
  def init(options) do
    expiration_time = Keyword.get(options, :expiration_time, 5000)
    Process.send_after(self(), :cleanup, expiration_time)
    state = %{data: %{}, expiration_time: expiration_time}
    {:ok, state}
  end

  @impl true
  def handle_call({:get, key}, _from, state) do
    time = System.monotonic_time(:millisecond)
    value =
      case Map.get(state.data, key) do
        {value, timestamp} when time - timestamp < state.expiration_time ->
          value
        _other ->
          nil
      end

    {:reply, value, state}
  end

  @impl true
  def handle_cast({:put, key, value}, state) do
    # The `handle_cast` callback is still executed in the GenServer process
    IO.puts("`BetterCache.handle_cast/3` is run in the GenServer process: #{inspect(self())}")
    timestamp = System.monotonic_time(:millisecond)
    data = Map.put(state.data, key, {value, timestamp})
    state = %{state | data: data}
    {:noreply, state}
  end

  @impl true
  def handle_info(:cleanup, state) do
    Process.send_after(self(), :cleanup, state.expiration_time)
    time = System.monotonic_time(:millisecond)

    data = Map.filter(state.data, fn {_key, {_value, timestamp}} ->
      time - timestamp < state.expiration_time
    end)

    state = %{state | data: data}
    {:noreply, state}
  end
end

Now, we can start our cache with BetterCache.start_link/1:

{:ok, cache} = BetterCache.start_link(timeout: 3000)

And use dedicated functions to interface with it:

BetterCache.put(cache, :hello, :world)

# 💡 Implement `BetterCache.get/2` and check if it works

# Wait until `BetterCache.put` is finished
Process.sleep(100)

Note that BetterCache.put/3 is a regular function, and is therefore executed in the same process that called it. The BetterCache.handle_call/3 callback, on the other hand, is called by the GenServer and runs in the GenServer’s process.