Help me modelling my GenServer with LiveView

Can someone give me some pointers, help me evaluate my design.

I have 2 different LiveView pages that have to share some state / have to be able to interact with some shared things.
I’ve written a GenServer to handle this shared state.
The shared state is different for different users. There is some key to find the correct state.

My requirements are that if a user hits a certain page, the GenServer starts based on the key. If the GenServer is already started, I return the same pid.

Currently I do something like this:

    with {:ok, pid} <-
           GenServer.start_link(__MODULE__, state,
             name: {:via, Registry, {Registry.Via, key}},
           ) do
      {:ok, pid}
    else
      {:error, {:already_started, pid}} ->
        {:ok, pid}
    end

Both LiveViews call this with the same key, so the first one to call will start it, and if it’s already running it will just return the correct pid.

So far, so good, but here are some extra properties that I’d like to have:

  1. I don’t want my LiveView process to crash when my GenServer crashes - I just like to have the GenServer restarted and the LiveView get the correct pid from somewhere.
  2. When both LiveViews are stopped, I’d like the GenServer to be stopped as well.

For 1. I’m thinking DynamicSupervisor in combination with Registry to find the correct GenServer.
But I currently don’t have good ideas for 2.

How would you solve this? What are the recommendations?

Let the LVs register themselfes on the genserver. The gen_server sets up monitors for all registered processes and shuts down once there are none anymore.

3 Likes

This works, however, then the genserver need to know who is using it and could be using it. Imagine the OP add a 3rd LV, 4th LV, etc. I’d just have the genserver to hibernate after some idle time (to save memory) and to persist the state and quit after a longer idle time. You must have on-demand start anyway.

I’d consider the LV never explicitly knowing the pid of the GenServer, but instead always referencing it using the key/opts required to start it. These opts would be passed to every public function on the server and would be used to lazily start it. The server would monitor every process that calls it and would shut down when they exit (optionally after a timeout).

Here’s a quick sketch with some example usage at the end.

defmodule MyServer do
  use GenServer, restart: :transient

  @doc false
  def registry, do: MyServer.Registry
  @doc false
  def supervisor, do: MyServer.DynamicSupervisor

  @doc "Get state"
  def get(server_opts) do
    server_opts
    |> server()
    |> GenServer.call(:get)
  end

  @doc "Put state"
  def put(server_opts, value) do
    server_opts
    |> server()
    |> GenServer.call({:put, value})
  end

  defp server(opts) do
    case DynamicSupervisor.start_child(supervisor(), {__MODULE__, opts}) do
      {:ok, pid} -> pid
      {:error, {:already_started, pid}} -> pid
    end
  end

  @doc false
  def start_link(opts) do
    {key, opts} = Keyword.pop(opts, :key)
    GenServer.start_link(__MODULE__, opts, name: {:via, Registry, {registry(), key}})
  end

  @doc false
  def alive?(opts) do
    GenServer.whereis({:via, Registry, {registry(), opts[:key]}}) != nil
  end

  @impl true
  def init(_opts) do
    {:ok, %{value: nil, refs: %{}, pids: []}}
  end

  @impl true
  def handle_call(:get, from, state) do
    state = monitor(state, from)
    {:reply, state.value, state}
  end

  @impl true
  def handle_call({:put, value}, from, state) do
    state = monitor(state, from)
    {:reply, :ok, Map.put(state, :value, value)}
  end

  @impl true
  def handle_info({:DOWN, ref, :process, _, _}, state) do
    state =
      case Map.pop(state.refs, ref) do
        {pid, refs} when is_pid(pid) ->
          %{state | refs: refs, pids: state.pids -- [pid]}

        _ ->
          state
      end

    if state.pids == [] do
      # alternatively, return {:noreply, state, timeout} and add a
      # handle_info(:timeout, state) that stops the server
      {:stop, :shutdown, state}
    else
      {:noreply, state}
    end
  end

  defp monitor(state, {pid, _}) do
    if pid in state.pids do
      state
    else
      ref = Process.monitor(pid)
      %{state | refs: Map.put(state.refs, ref, pid), pids: [pid | state.pids]}
    end
  end
end

ExUnit.start()

defmodule MyServerTest do
  use ExUnit.Case

  setup do
    start_supervised!({Registry, name: MyServer.registry(), keys: :unique})
    start_supervised!({DynamicSupervisor, name: MyServer.supervisor()})
    :ok
  end

  test "get/put" do
    opts1 = [key: :key_1]
    opts2 = [key: :key_2]

    assert nil == MyServer.get(opts1)
    assert :ok = MyServer.put(opts1, :value_1)
    assert :value_1 = MyServer.get(opts1)

    assert nil == MyServer.get(opts2)
    assert :ok = MyServer.put(opts2, :value_2)
    assert :value_2 = MyServer.get(opts2)

    # :key_1 unaffected
    assert :value_1 = MyServer.get(opts1)

    assert 2 = DynamicSupervisor.count_children(MyServer.supervisor()).specs
  end

  test "server shuts down when all callers exit" do
    test = self()
    opts = [key: :server_key]

    p1 =
      spawn(fn ->
        # on first message, put :value
        receive(do: (:put -> MyServer.put(opts, :value)))
        # on second message, exit
        receive(do: (:exit -> :ok))
      end)

    p2 =
      spawn(fn ->
        # on first message, send current value to test proc
        receive(do: (:get -> send(test, {self(), MyServer.get(opts)})))
        # on second message, send current value to test proc
        receive(do: (:get -> send(test, {self(), MyServer.get(opts)})))
        # on third message, exit
        receive(do: (:exit -> :ok))
      end)

    send(p2, :get)
    assert_receive {^p2, nil}

    send(p1, :put)
    send(p2, :get)
    assert_receive {^p2, :value}

    # both p1 and p2 should be alive
    assert MyServer.alive?(opts)

    send(p1, :exit)
    # p2 should still be alive, so server should be
    Process.sleep(10)
    assert MyServer.alive?(opts)

    send(p2, :exit)
    # p1 and p2 exited, so server should shut down (after a delay)
    Process.sleep(10)
    refute MyServer.alive?(opts)
  end
end
4 Likes

Since the question was about evaluating the design, I’ll throw some thoughts (in the form of questions).

What kind of state is being shared between the two LiveViews?

How is the state updated? Single writer? One of the LVs, both or external process?

What is the consequence if the state is lost?

Are the two LVs rendered in a single HTML page? If not, how are users expected to work with them? Two browser tabs?

What happens to existing user sessions during application deployment?

I wonder if the GenServer solution is really appropriate, or if keeping state solely in memory might cause problems for the use case, thus some of the questions above.

Thanks, good questions. Let me answer.

I’m not sure what you mean exactly with this question? What do you mean with “kind”?

All state updates happen in the GenServer right now, but both LVs can trigger actions on it. Right now there are no other external processes that can trigger state changes.

A history of changes are saved in the db. So nothing get’s lost, just restarted. But it’s a complex read model that takes a while to startup. Not like really long, but long enough that it would delay every action taken by 200-500ms.

2 different users. So each LV has a single page.

I don’t really care. They get disconnected and have to wait for the deploy to be done.

Aha, so there’s already DB involved, but IIUC querying the DB from the LVs is too slow?

When I asked about what kind of state I was trying to learn more about what’s the app about, trying to get a feel for the constraints you have.

If both LVs can change state through events, how do you handle conflicts?

So far I have in mind two pages, two LV processes, one user at one page and another in the second page, and they can observe and modify some shared state in real-time.

I’m wondering if PubSub could simplify the design by giving the two LiveViews a way to consume updates without having to worry about setup and teardown of the shared GenServer.

The state in the database and the state involved to make decisions are 2 different things.
I’m transforming the state from the database into a state that I can make decisions on / show to user.
That transformation is slow(ish) - again like a couple of 100ms.

I could also save that transformed state in the database. So that would be a different solution.

The model that I’m using here is close to eventsourcing. I’m storing the decisions in the database, but I’m making a transformed state on those decisions to make a decision.

Both go through 1 GenServer, so there are no conflicts.
Also the kind of things the 2 LVs are doing are having a low change of actually having real conflicts. In the case of both users doing something that’s now not possible anymore (because 1 user was first), the other user will get an error and they have to redo/retry something.

There is one more thing that’s important (but which doesn’t need a GenServer), and that is, that there is quite a bit of domain logic involved that I don’t want to live in the LiveView. The GenServer now is responsible for wrapping around that as well.

I’m out of ideas for now, and understand you already have a solution from earlier in the thread. Thanks for the exchange :purple_heart:

1 Like