Tracks
/
Elixir
Elixir
/
Exercises
/
Take-A-Number Deluxe
Take-A-Number Deluxe

Take-A-Number Deluxe

Learning Exercise

Introduction

GenServer

GenServer (generic server) is a behaviour that abstracts common client-server interactions between Elixir processes.

Remember the receive loop from when we learned about processes? The GenServer behaviour provides abstractions for implementing such loops, and for exchanging messages with a process that runs such a loop. It makes it easier to keep state and execute asynchronous code.

Note

Be warned that the name GenServer is loaded. It is also used to describe a module that uses the GenServer behaviour, as well as a process that was started from a module that uses the GenServer behaviour.

The GenServer behaviour defines one required callback, init/1, and a few interesting optional callbacks: handle_call/3, handle_cast/2, and handle_info/3. The clients using a GenServer aren't supposed to call those callbacks directly. Instead, the GenServer module provides functions that clients can use to communicate with a GenServer process.

Often, a single module defines both a client API, a set of functions that other parts of your Elixir app can call to communicate with this GenServer process, and server callback implementations, which contain this GenServer's logic.

Let's take a look at a simple example of a GenServer first, and then learn what each callback means.

Example

This is an example server that can respond to the repetitive inquisitions of annoying passengers during a long road trip, more exactly the question: "are we there yet?". It keeps track of how many times this question has been asked, returning increasingly more annoyed responses.

defmodule AnnoyingPassengerAutoresponder do
  use GenServer
  # Client API

  def start_link(init_arg) do
    GenServer.start_link(__MODULE__, init_arg)
  end

  def are_we_there_yet?(pid) do
    GenServer.call(pid, :are_we_there_yet?)
  end

  # Server callbacks

  @impl GenServer
  def init(_init_arg) do
    # the initial count of questions asked is always 0
    state = 0
    {:ok, state}
  end

  @impl GenServer
  def handle_call(:are_we_there_yet?, _from, state) do
    reply =
      cond do
        state <= 3 -> "No."
        state <= 10 -> "I told you #{state} times already. No."
        true -> "..."
      end

    # increase the count of questions asked
    new_state = state + 1
    # reply to the caller
    {:reply, reply, new_state}
  end
end

Callbacks

init/1

A server can be started by calling GenServer.start/3 or GenServer.start_link/3. We learned about the difference between those functions in the links concept.

Those two functions:

  • Accept a module implementing the GenServer behaviour as the first argument.
  • Accept anything as the second argument called init_arg. As the name suggest, this argument gets passed to the init/1 callback.
  • Accept an optional third argument with advanced options for running the process that we won't cover now.

Starting a server by calling GenServer.start/3 or GenServer.start_link/3 will invoke the init/1 callback in a blocking way. The return value of init/1 dictates if the server can be started successfully.

The init/1 callback usually returns one of those values:

  • {:ok, state}. The server will start its receive loop using state as its initial state. state can be of any type.
  • {:stop, reason}. reason can be of any type. The server will not start its receive loop. The process will exit with the given reason.

There are also more advanced possibilities that we won't cover now.

If the server's receive loop starts, the functions GenServer.start/3 and GenServer.start_link/3 return an {:ok, pid} tuple. Otherwise they return {:error, reason}

handle_call/3

A message that requires a reply can be sent to a server process with GenServer.call/2. This function expects the pid of a running server process as the first argument, and the message as the second argument. The message can be of any type.

The handle_call/3 callback is responsible for handling and responding to synchronous messages. It receives three arguments:

  1. message - the value passed as the second argument to GenServer.call/2.
  2. from - the pid of the process calling GenServer.call/2. Most often this argument can be ignored.
  3. state - the current state of the server. Remember that its initial value was set in the init/1 callback.

The handle_call/3 callback usually returns a 3 tuple of {:reply, reply, state}. This means that the second element in the tuple, a reply that can be of any type, will be sent back to the caller. The third element in the tuple, state, is the new state of the server after handling this message.

There are also more advanced possibilities that we won't cover now.

Note

To memorize what this callback does by its name, think of it as "calling" somebody on the phone.

If that person is available, you'll receive a reply immediately (synchronously).

handle_cast/2

A message that doesn't require a reply can be sent to a server process with GenServer.cast/2. Its arguments are identical to those of GenServer.call/2.

The handle_cast/2 callback is responsible for handling those messages. It receives two arguments, message and state, which are the same arguments as in the handle_call/3 callback (except for from).

The handle_cast/2 callback usually returns a 2 tuple of {:noreply, state}.

There are also more advanced possibilities that we won't cover now.

Note

To memorize what this callback does by its name, remember that "to cast" also means "to throw".

If you throw a message in a bottle into the sea, you don't expect to receive a reply immediately, or maybe ever.

Should I use call or cast?

Almost always use call even if your client code doesn't need the reply from the server.

Using call waits for the reply, which serves as a backpressure mechanism (to prevent clients from sending too many messages at once). Receiving a reply from the server is also the only way to be sure that the server received and handled the client's message.

handle_info/2

Messages can also end up in the server's inbox by means other than calling GenServer.call/2 or GenServer.cast/2, for example calling the plain send/2 function.

To handle such messages, use the handle_info/2 callback. This callback works in exactly the same way as handle_cast/2.

The GenServer behaviour provides a catch-all implementation of handle_info/2 that logs errors about unexpected messages. If you override that default implementation, make sure to always include your own catch-all implementation. If you forget, the server will crash if it receives an unexpected message.

Timeouts

The return value of each of the four callbacks described above can be extended by one more tuple element, a timeout. E.g. instead of returning {:ok, state} from init/1, return {:ok, state, timeout}.

The timeout can be used to detect a lack of messages in the mailbox for a specific period. If the server returns a timeout from one of its callbacks, and the specified number of milliseconds have elapsed with no message arriving, handle_info/2 is called with :timeout as the first argument.

Instructions

The basic Take-A-Number machine was selling really well, but some users were complaining about its lack of advanced features compared to other models available on the market.

The manufacturer listened to user feedback and decided to release a deluxe model with more features, and you once again were tasked with writing the software for this machine.

The new features added to the deluxe model include:

  • Keeping track of currently queued numbers.
  • Setting the minimum and maximum number. This will allow using multiple deluxe Take-A-Number machines for queueing customers to different departments at the same facility, and to tell apart the departments by the number range.
  • Allowing certain numbers to skip the queue to provide priority service to pregnant women and the elderly.
  • Auto shutdown to prevent accidentally leaving the machine on for the whole weekend and wasting energy.

The business logic of the machine was already implemented by your colleague and can be found in the module TakeANumberDeluxe.State. Now your task is to wrap it in a GenServer.

1. Start the machine

Use the GenServer behaviour in the TakeANumberDeluxe module.

Implement the start_link/1 function and the necessary GenServer callback.

The argument passed to start_link/1 is a keyword list. It contains the keys :min_number and :max_number. The values under those keys need to be passed to the function TakeANumberDeluxe.State.new/2.

If TakeANumberDeluxe.State.new/2 returns an {:ok, state} tuple, the machine should start, using the returned state as its state. If it returns an {:error, error} tuple instead, the machine should stop, giving the returned error as the reason for stopping.

TakeANumberDeluxe.start_link(min_number: 1, max_number: 9)
# => {:ok, #PID<0.174.0>}

TakeANumberDeluxe.start_link(min_number: 9, max_number: 1)
# => {:error, :invalid_configuration}

You might have noticed that the function TakeANumberDeluxe.State.new/2 also takes an optional third argument, auto_shutdown_timeout. We will use it in the last step of this exercise.

2. Report machine state

Implement the report_state/1 function and the necessary GenServer callback. The machine should reply to the caller with its current state.

{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.report_state(machine)
# => %TakeANumberDeluxe.State{
#      max_number: 10,
#      min_number: 1,
#      queue: %TakeANumberDeluxe.Queue{in: [], out: []},
#      auto_shutdown_timeout: :infinity,
#    }

3. Queue new numbers

Implement the queue_new_number/1 function and the necessary GenServer callback.

It should call the TakeANumberDeluxe.State.queue_new_number/1 function with the current state of the machine.

If TakeANumberDeluxe.State.queue_new_number/1 returns an {:ok, new_number, new_state} tuple, the machine should reply to the caller with the new number and set the new state as its state. If it returns a {:error, error} tuple instead, the machine should reply to the caller with the error and not change its state.

{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 2)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}

TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 2}

TakeANumberDeluxe.queue_new_number(machine)
# => {:error, :all_possible_numbers_are_in_use}

4. Serve next queued number

Implement the serve_next_queued_number/2 function and the necessary GenServer callback.

It should call the TakeANumberDeluxe.State.serve_next_queued_number/2 function with the current state of the machine and its second optional argument, priority_number.

If TakeANumberDeluxe.State.serve_next_queued_number/2 returns an {:ok, next_number, new_state} tuple, the machine should reply to the caller with the next number and set the new state as its state. If it returns a {:error, error} tuple instead, the machine should reply to the caller with the error and not change its state.

{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)
TakeANumberDeluxe.queue_new_number(machine)
# => {:ok, 1}

TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:ok, 1}

TakeANumberDeluxe.serve_next_queued_number(machine)
# => {:error, :empty_queue}

5. Reset state

Implement the reset_state/1 function and the necessary GenServer callback.

It should call the TakeANumberDeluxe.State.new/2 function to create a new state using the current state's min_number and max_number. The machine should set the new state as its state. It should not reply to the caller.

{:ok, machine} = TakeANumberDeluxe.start_link(min_number: 1, max_number: 10)

TakeANumberDeluxe.reset_state(machine)
# => :ok

6. Implement auto shutdown

Modify starting the machine. It should read the value under the key :auto_shutdown_timeout in the keyword list passed as init_arg and pass it as the third argument to TakeANumberDeluxe.State.new/3. Use the default value of :infinity if :auto_shutdown_timeout was not given.

Modify resetting the machine state to also pass auto_shutdown_timeout to TakeANumberDeluxe.State.new/3.

Modify the return values of all implemented callbacks (init/1 and all handle_* callbacks) to set a timeout. Use the value under the key :auto_shutdown_timeout in the current machine state. Do not add the timeout to the {:stop, reason} return value of init/1 - timeouts only apply after the server has started its receive loop.

Implement a GenServer callback to handle the :timeout message that will be sent to the machine if it doesn't receive any other messages within the given timeout. It should exit the process with reason :normal.

Make sure to also handle any unexpected messages by ignoring them.

{:ok, machine} =
  TakeANumberDeluxe.start_link(
    min_number: 1,
    max_number: 10,
    auto_shutdown_timeout: :timer.hours(2)
  )

# after 3 hours...

TakeANumberDeluxe.queue_new_number(machine)
# => ** (exit) exited in: GenServer.call(#PID<0.171.0>, :queue_new_number, 5000)
#        ** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started
#       (elixir 1.13.0) lib/gen_server.ex:1030: GenServer.call/3
Edit via GitHub The link opens in a new window or tab
Elixir Exercism

Ready to start Take-A-Number Deluxe?

Sign up to Exercism to learn and master Elixir with 57 concepts, 158 exercises, and real human mentoring, all for free.