An opinionated but configurable means of quickly creating GenServer
modules that are intended to be managed and distributed via Horde
.
I found myself re-writing the same boilerplate code to create Horde-compatible processes; calling GenServer.start_link
the same way every time, handling common return values, generating a "via tuple" for the process name, etc. Rather than having to copy/paste each change, I've put that encapsulation of common functionality into a single (albiet very small) library. Maybe it can make it's way into the Horde library at some point just so people have an easy time jumping into (what I think is) the common use case.
It may not fit your particular needs, despite me trying to make it configurable and also able to handle the straightforward usage out-of-the-box. If that's the case, you can open an issue or submit a PR.
Add it to your mix.exs
dependencies.
def deps do
[
{:horde_process, "~> 0.1.0"},
]
end
You can read the full documentation at HexDocs.
Every Horde Process is assumed to be a GenServer
. At some point I might remove this particular detail so that it can be mix and matched in other ways, but for now they are assumed to be GenServer
processes.
To make a custom Horde Process simply use Horde.Process
and pass in the required supervisor and registry modules.
defmodule MyApp.User.Process do
use Horde.Process, supervisor: MyApp.User.HordeSupervisor, registry: MyApp.User.HordeRegistry
@impl Horde.Process
def process_id(%{"user_id" => user_id}), do: user_id
def process_id(%{user_id: user_id}), do: user_id
def process_id(user_id) when is_binary(user_id), do: user_id
@impl Horde.Process
def child_spec(user_id) do
%{
id: user_id,
start: {__MODULE__, :start_link, [user_id]},
restart: :transient,
shutdown: 10_000
}
end
@impl GenServer
# Set up the process state quickly and have `handle_continue/2` do the rest.
def init(user_id) do
Process.flag(:trap_exit, true)
{:ok, user_id, {:continue, :init}}
end
@impl GenServer
def handle_continue(:init, user_id) do
{:ok, user} = MyApp.User.fetch(user_id)
{:noreply, user}
end
end
Read the documentation for Horde.Process
to learn more about why using {:continue, term}
is actually an important detail of having efficient Horde Processes.
In the above example we implement the two required Horde Process callbacks, process_id/1
and child_spec/1
, and then also implement our GenServer callbacks, init/1
and handle_continue/2
.
The custom module now has some additional functions which get generated by use Horde.Process
; fetch/1
, call/2
, etc. If you wanted to get a user process, or start it if one was not already running, you could do the following:
{:ok, pid} = MyApp.User.Process.get(user_id)
Now you can call the process as you normally would with any other PID. But let's assume you wanted to use cast
or call
functions against the process. Rather than fetching the PID and then passing that through to GenServer.cast
or something, Horde.Process
imports some additional helper functions.
{:ok, reply} = MyApp.User.Process.call!(user_id, :do_something)
Or maybe you don't want to force a user process to start up if one isn't already running:
{:ok, reply} = MyApp.User.Process.call(user_id, :do_something)
Because the reply from the process is wrapped by call!/2
, if GenServer.call
would normally reply with something like {:ok, reply}
then the return value of call!/2
would be {:ok, {:ok, reply}}
. The first term of the tuple is just specifying whether a process was registered and could receive the message. This means that you could receive error replies that should be matched on {:ok, {:error, err}}
. If the first term in the tuple is not :ok
then it means something went wrong with Horde, not with the request itself.