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