Help with structure of two GenServers in an application

I am working on a simple web application that allows people to join a virtual room. Each room has a video player that synchronises the state of the video to everyone connected and there is a live chat next to it. All communication is done with Phoenix (channels).

One of my requirements is that each room expires after 24 hours of inactivity. The timeout feature of GenServers seems appropriate here.

My question is how should I structure the room component/application?

Current structure

The Dynamic Supervisor will spawn a Supervisor per room (resolved using :global and a name) that supervises two GenServers (Chat.Server and Player.Server).

I have a working prototype with the following structure (inspiration from Dave Thomas):

lib
├── room
│   ├── application.ex
│   ├── chat
│   │   ├── impl.ex
│   │   ├── message.ex
│   │   ├── server.ex
│   │   └── user.ex
│   ├── chat.ex
│   ├── dynamic_supervisor.ex
│   ├── player
│   │   ├── impl.ex
│   │   └── server.ex
│   ├── player.ex
│   └── supervisor.ex
└── room.ex

With this structure lib/room.ex is the public API. All this module does (should) is forwarding calls to the Chat and Player modules. This feels nice: I could easily refactor those modules (Chat and Player) into their own components/applications.

HOWEVER! I have two problems (1) where do I put the GenServer timeout? It is not really the chat itself or the player that expires. It is the room. The room is just a supervisor though, not a GenServer and (2) there is a slight relation between the chat and the player. When all users leave, the player should pause the video. This is currently handled directly in lib/room.ex module and looks like this:

alias Room.{Chat, Player}

###

def leave(name, user_id) do
  users_left = name
  |> Chat.leave(user_id)
  |> Chat.count_users()

  if users_left == 0 do
    Player.pause(name)
  end

  name
end

This logic feels a bit dirty to me and I don’t like it there.

Alternative structure

One alternative is to combine the state of the two Chat and Player GenServers into a single Room GenServer. Something like this:

lib
├── room
│   ├── application.ex
│   ├── chat
│   │   ├── impl.ex
│   │   ├── message.ex
│   │   └── user.ex
│   ├── chat.ex
│   ├── dynamic_supervisor.ex
│   ├── impl.ex # only for `leave`?
│   ├── player
│   │   ├── impl.ex
│   ├── player.ex
│   ├── server.ex
└── room.ex

The implementation of Chat and Player is still unknown to Room. This also solves problem (1) with the timeout and kind of (2) since I won’t have that logic in the public API.

It still feels a bit wrong though: One advantage of current solution is that an error in the chat GenServer doesn’t bring the player GenServer down. Now an error in chat will also take the player down and restart the entire room basically.

I plan to open source this project but I would prefer to have solved this problem (and clean up) first. If it’s much easier to help with the current source code, I can make the repo public.

A few thoughts:

  • What’s the user flow? The user logs in, and that makes them join both GenServers?

  • In regard to timeouts, your concern is that one could timeout before the other? You could have the one send a message to the other on leave.

  • There is no sign in/up, it is all anonymous. A client enters the room, a new user with a random ID is generated in the chat GenServer, chat state is updated and this new random user is assigned to the user’s socket. There is no change of state in the player GenServer.
  • Assume there is one user in the room. They pause the video. New user enter five minutes later. If both GenServers have a timeout, the player will be killed five minutes before the chat, which is not intended. How do I send a message to the other without making them aware of each other? Can they go through the supervisor somehow?

handle_info/2 – on handling a :timeout

You could send a :pause message from here to your Player.Server (to which you’d add a handle_info(:pause, state) clause)

Same for when user leaves Chat.Server