Current section
Files
Jump to
Current section
Files
lib/jobspool.ex
defmodule JobsPool do
@moduledoc """
A simple concurrent jobs pool.
There are two use cases for this module:
* Multiple processes spawning jobs in the same pool:
{:ok, pool} = JobsPool.start_link(10)
Enum.each 1..99, fn(_) ->
spawn fn ->
JobsPool.run!(pool, fn -> :timer.sleep(100) end)
end
end
JobsPool.run!(pool, fn -> :timer.sleep(100) end)
JobsPool.join(pool) # Should take ~1s
`JobsPool.run!/4` blocks until the job is done and returns the job's
result, or reraise if it had an error.
* One process spawning jobs asynchronously:
{:ok, pool} = JobsPool.start_link(10)
Enum.each 1..100, fn(_) ->
JobsPool.async(pool, fn -> :timer.sleep(100) end)
end
JobsPool.join(pool) # Should take ~1s
There is currently no way to retrieve an async job's result.
`JobsPool.start_link/2` second argument is an array of options passed to
`GenServer.start_link/3`. For example to create a named pool:
JobsPool.start_link(10, name: :jobs_pool)
JobsPool.run!(:jobs_pool, fn -> :timer.sleep(100) end)
"""
use GenServer
defmodule State do
defstruct [
max_concurrent_jobs: 1,
active_jobs: %{},
queued_jobs: :queue.new(),
queued_keys: HashSet.new(),
waiters: %{},
joiners: [],
]
end
# --------------------------------------------------------------------------
# Public API
@doc """
"""
def start_link(max_concurrent_jobs, genserver_options \\ []) do
state = %State{max_concurrent_jobs: max_concurrent_jobs}
GenServer.start_link(__MODULE__, state, genserver_options)
end
@doc """
Execute `fun` and block until it's complete, or `timeout` exceeded.
Return `fun` return value, or raise in the current process if it encountered
an error.
"""
def run!(server, fun, key \\ nil, timeout \\ :infinity) do
GenServer.call(server, {:run, fun, key}, timeout)
|> maybe_reraise()
end
@doc """
Execute `fun` asynchronously.
Return the task key.
"""
def async(server, fun, key \\ nil) do
GenServer.call(server, {:async, fun, key})
end
@doc """
Wait until all jobs are finished.
"""
def join(server, timeout \\ :infinity) do
GenServer.call(server, {:join}, timeout)
end
# --------------------------------------------------------------------------
# GenServer implementation
@doc false
def handle_call({:run, fun, key}, from, state) do
key = maybe_create_key(key)
state = state
|> run_job(fun, key)
|> add_waiter(key, from)
{:noreply, state}
end
@doc false
def handle_call({:async, fun, key}, _from, state) do
key = maybe_create_key(key)
state = run_job(state, fun, key)
{:reply, key, state}
end
@doc false
def handle_call({:join}, from, state) do
if Map.size(state.active_jobs) > 0 or :queue.len(state.queued_jobs) > 0 do
state = add_joiner(state, from)
{:noreply, state}
else
{:reply, :done, state}
end
end
@doc false
def handle_info(message, state) do
active_tasks = Map.values(state.active_jobs)
case Task.find(active_tasks, message) do
{{key, result}, _task} ->
state = state
|> remove_active_job(key)
|> notify_waiters(key, result)
|> run_next_job()
nil -> :ok
end
{:noreply, state}
end
# --------------------------------------------------------------------------
# Helpers
defp notify_waiters(state, key, result) do
if Map.has_key?(state.waiters, key) do
Enum.each state.waiters[key], fn(waiter) ->
GenServer.reply(waiter, result)
end
update_in(state.waiters, &Map.delete(&1, key))
else
state
end
end
defp add_waiter(state, key, waiter) do
waiters = Map.get(state.waiters, key, HashSet.new())
|> HashSet.put(waiter)
update_in(state.waiters, &Map.put(&1, key, waiters))
end
defp remove_active_job(state, key) do
update_in(state.active_jobs, &Map.delete(&1, key))
end
defp unqueue_job(state, new_queued_jobs, key) do
state = put_in(state.queued_jobs, new_queued_jobs)
update_in(state.queued_keys, &Set.delete(&1, key))
end
defp add_joiner(state, joiner) do
update_in(state.joiners, &([joiner | &1]))
end
defp run_next_job(state) do
case :queue.out(state.queued_jobs) do
{{:value, {key, fun}}, new_queued_jobs} ->
state
|> unqueue_job(new_queued_jobs, key)
|> run_job(fun, key)
{:empty, _} ->
if Map.size(state.active_jobs) == 0 do
state
|> notify_joiners()
|> clear_joiners()
else
state
end
end
end
defp notify_joiners(state) do
Enum.each state.joiners, fn(joiner) ->
GenServer.reply(joiner, :done)
end
state
end
defp clear_joiners(state) do
put_in(state.joiners, [])
end
defp maybe_create_key(key) do
unless key do
UUID.uuid4()
end
end
defp run_job(state, fun, key) do
# Check a job with `key` is not already active or queued
if not Map.has_key?(state.active_jobs, key) and not Set.member?(state.queued_keys, key) do
if Map.size(state.active_jobs) < state.max_concurrent_jobs do
# There are slots available, execute job now
# Wrap fun to catch exceptions, since we want to reraise them in the client
# processes, not in this GenServer
wrapped_fun = fn ->
try do
{key, {:ok, fun.()}}
rescue
exception ->
stacktrace = System.stacktrace()
{key, {:exception, exception, stacktrace}}
end
end
# Create Task and put in in active jobs
task = Task.async(wrapped_fun)
state = update_in(state.active_jobs, &Map.put(&1, key, task))
else
# No slots available, queue job
state = update_in(state.queued_jobs, &:queue.in({key, fun}, &1))
state = update_in(state.queued_keys, &Set.put(&1, key))
end
end
state
end
defp maybe_reraise({:ok, result}), do: result
defp maybe_reraise({:exception, exception, stacktrace}), do: reraise(exception, stacktrace)
end