If I understand correctly, if you use GenServer, you need to forfeit the selective receive functionality of Erlang.
What I mean by that is that since you give up control of the receive
loop, there’s no way to say: handle messages tagged :high
if they are present in the process mailbox, and if not, handle messages tagged :normal
.
I realise that it’s easy to starve the :normal
queue if you are not careful, and this is probably why OTP uses the process mailbox as a plain FIFO queue. I hope my assumptions are correct!
In any case, I had a use case where I wanted to serialise access to a shared resource for “normal” requests, but fulfil “priority” requests immediately. The underlying shared resource is actually capable of handling multiple concurrent requests, but I wanted to have some control over the parallelism. Here’s my proof of concept code:
defmodule Limiter do
use GenServer
defmodule Serializer do
use GenServer
def init(_) do
{:ok, nil}
end
def handle_cast({m, f, args, from}, nil) do
r = apply(m, f, args)
GenServer.reply(from, r)
{:noreply, nil}
end
end
def init({module, function}) do
{:ok, serializer} = GenServer.start_link(Serializer, [])
{:ok, {serializer, module, function}}
end
def handle_call({:normal, args}, from, {serializer, m, f}) do
GenServer.cast(serializer, {m, f, args, from})
{:noreply, {serializer, m, f}}
end
def handle_call({:priority, args}, from, {serializer, m, f}) do
process(from, m, f, args)
{:noreply, {serializer, m, f}}
end
defp process(from, m, f, args) do
spawn_link(fn() ->
r = apply(m, f, args)
GenServer.reply(from, r)
end)
end
end
I would really appreciate if someone could proof my assumptions:
- I am using a separate GenServer to act as the serialising queue; since I’m using
cast
, the normal calls don’t block. - It’s safe to use
cast
since I’m linking theSerializer
process; if it dies, theLimiter
will also die. If it’s alive,cast
will always succeed. - Similarly, using
spawn_link
for the priority calls makes the priority calls non-blocking. - Therefore, the
Limiter
GenServer can accept connections even if previous connections are still processing. Individual callers will still block, as is expected. - Since I’m using links, any downstream failure/exception will take everything down, so this can be used in a supervision tree.
Finally, I’m interested in approaches to test this. The trickiest thing to test is that normal calls do not block priority calls. My current testing approach is to use Process.sleep
to simulate a long-running job, and send a message back to the test process, then assert that we receive them in the expected order. However I had to add some voodoo sleep
s here and there, which I don’t like.
To avoid making this post any longer, my test suite is here: https://gist.github.com/orestis/ed19d0da066d3e65f919da3d76b8b224