Logger.configure(level: :none)
defmodule JobQueue do
use GenServer
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: opts[:name])
# A job is an id and a function of the attempt number.
def enqueue(queue, id, fun), do: GenServer.call(queue, {:enqueue, id, fun})
# Waits until every job has finished or been given up
# on, then returns what happened to each.
def drain(queue), do: GenServer.call(queue, :drain, :infinity)
@impl true
def init(opts) do
state = %{
tasks: Keyword.fetch!(opts, :tasks),
max_running: Keyword.get(opts, :max_running, 2),
max_attempts: Keyword.get(opts, :max_attempts, 3),
backoff: Keyword.get(opts, :backoff, 10),
pending: :queue.new(),
running: %{},
retrying: 0,
history: %{},
dead: [],
drainers: []
}
{:ok, state}
end
@impl true
def handle_call({:enqueue, id, fun}, _from, state) do
state = %{state | pending: :queue.in({id, fun, 1}, state.pending)}
{:reply, :ok, start_jobs(state)}
end
# Deferred reply: answered once the queue is idle.
def handle_call(:drain, from, state) do
{:noreply, maybe_idle(%{state | drainers: [from | state.drainers]})}
end
# A task finished and returned a value.
@impl true
def handle_info({ref, result}, state) when is_reference(ref) do
Process.demonitor(ref, [:flush])
{job, running} = Map.pop!(state.running, ref)
{:noreply, %{state | running: running} |> finished(job, result) |> start_jobs()}
end
# A task crashed. Its exception arrives as a `:DOWN`.
def handle_info({:DOWN, ref, :process, _pid, {exception, _stacktrace}}, state) do
{job, running} = Map.pop!(state.running, ref)
result = {:error, {:crashed, Exception.message(exception)}}
{:noreply, %{state | running: running} |> finished(job, result) |> start_jobs()}
end
# A backoff timer fired: the job goes back in line.
def handle_info({:retry, job}, state) do
state = %{state | pending: :queue.in(job, state.pending), retrying: state.retrying - 1}
{:noreply, start_jobs(state)}
end
defp start_jobs(state) do
with true <- map_size(state.running) < state.max_running,
{{:value, {_id, fun, attempt} = job}, pending} <- :queue.out(state.pending) do
task = Task.Supervisor.async_nolink(state.tasks, fn -> fun.(attempt) end)
start_jobs(%{state | pending: pending, running: Map.put(state.running, task.ref, job)})
else
_ -> maybe_idle(state)
end
end
defp finished(state, {id, _fun, attempt} = job, result) do
case result do
{:ok, _} ->
record(state, id, attempt, result, :done)
{:error, _} when attempt < state.max_attempts ->
# Exponential backoff: 10 ms, 20 ms, 40 ms, ...
delay = state.backoff * Integer.pow(2, attempt - 1)
Process.send_after(self(), {:retry, put_elem(job, 2, attempt + 1)}, delay)
state = %{state | retrying: state.retrying + 1}
record(state, id, attempt, result, {:retry_in_ms, delay})
{:error, _} ->
state = %{state | dead: state.dead ++ [id]}
record(state, id, attempt, result, :dead_letter)
end
end
defp record(state, id, attempt, result, decision) do
entry = {attempt, result, decision}
%{state | history: Map.update(state.history, id, [entry], &(&1 ++ [entry]))}
end
defp maybe_idle(state) do
idle? = :queue.is_empty(state.pending) and state.running == %{} and state.retrying == 0
if idle? and state.drainers != [] do
for from <- state.drainers, do: GenServer.reply(from, Map.take(state, [:history, :dead]))
%{state | drainers: []}
else
state
end
end
end