Current section
Files
Jump to
Current section
Files
lib/pool_sup.ex
use Croma
defmodule PoolSup do
@moduledoc """
This module defines a supervisor process that is specialized to manage pool of workers.
- Process defined by this module behaves as a `:simple_one_for_one` supervisor.
- Worker processes are spawned using a callback module that implements `PoolSup.Worker` behaviour.
- The `PoolSup` process manages which child processes are in use and which are not.
- Functions to request pid of a child process that is not in use are also defined.
## Example
Suppose we have a module that implements both `GenServer` and `PoolSup.Worker` behaviours.
iex(1)> defmodule MyWorker do
...(1)> @behaviour PoolSup.Worker
...(1)> use GenServer
...(1)> def start_link(arg) do
...(1)> GenServer.start_link(__MODULE__, arg)
...(1)> end
...(1)> # definitions of gen_server callbacks...
...(1)> end
When we want to have 3 processes that run `MyWorker` server:
iex(2)> {:ok, pid} = PoolSup.start_link(MyWorker, {:worker, :arg}, 3, [name: :my_pool])
Each child process is started by `MyWorker.start_link({:worker, :arg})`.
Then we can get a pid of a child currently not in use.
iex(3)> child_pid = PoolSup.checkout(:my_pool)
iex(4)> do_something(child_pid)
iex(5)> PoolSup.checkin(:my_pool, child_pid)
Don't forget to return the `child_pid` when finished; for simple use cases `PoolSup.transaction/3` comes in handy.
### Usage within supervision tree
The following code snippet spawns a supervisor that has `PoolSup` process as one of its children.
The `PoolSup` process manages 5 worker processes and they will be started by `MyWorker.start_link({:worker, :arg})`.
chilldren = [
...
Supervisor.Spec.supervisor(PoolSup, [MyWorker, {:worker, :arg}, 5]),
...
]
Supervisor.start_link(children, [strategy: :one_for_one])
"""
alias Supervisor, as: S
alias GenServer, as: GS
use GS
defmodule Callback do
@moduledoc false
# The sole purpose of this module is to suppress dialyzer warning;
# using `Supervisor.Default` results in a warning due to (seemingly) incorrect typespec of
# `supervisor:init/1` (which is an implementation of `gen_server:init/1` callback, not callback of supervisor behaviour).
@behaviour :supervisor
def init([arg]), do: arg
end
defmodule PidSet do
@moduledoc false
@type t :: %{pid => true}
defun new :: t , do: %{}
defun member?(set :: t, pid :: pid) :: boolean, do: Map.has_key?(set, pid)
defun put(set :: t, pid :: pid) :: t , do: Map.put(set, pid, true)
defun delete(set :: t, pid :: pid) :: t , do: Map.delete(set, pid)
defun from_list(pids :: [pid]) :: t , do: Enum.into(pids, %{}, &{&1, true})
end
@type pool :: pid | GS.name
@type options :: [name: GS.name]
@typep pid_queue :: :queue.queue(pid)
@typep sup_state :: any
require Record
Record.defrecordp :state, [
:all,
:working,
:available,
:capacity_to_decrease,
:waiting,
:sup_state,
]
@typep state :: record(:state,
all: PidSet.t,
working: PidSet.t,
available: [pid],
capacity_to_decrease: non_neg_integer,
waiting: pid_queue,
sup_state: sup_state,
)
#
# external API
#
@doc """
Starts a `PoolSup` process linked to the calling process.
## Arguments
- `worker_module` is the callback module of `PoolSup.Worker`.
- `worker_init_arg` is the value passed to `worker_module.start_link/1` callback function.
- `capacity` is the initial number of workers this `PoolSup` process holds.
- Currently only `:name` option is supported for name registration.
"""
defun start_link(worker_module :: g[module], worker_init_arg :: term, capacity :: g[non_neg_integer], options :: options \\ []) :: GS.on_start do
GS.start_link(__MODULE__, {worker_module, worker_init_arg, capacity, options}, gen_server_opts(options))
end
defunp gen_server_opts(opts :: options) :: [name: GS.name] do
case opts[:name] do
nil -> []
name -> [name: name]
end
end
@doc """
Checks out a worker pid that is currently not used.
If no available worker process exists, the caller is blocked until either
- any process becomes available, or
- timeout is reached.
"""
defun checkout(pool :: pool, timeout :: timeout \\ 5000) :: nil | pid do
try do
GenServer.call(pool, :checkout, timeout)
catch
:exit, {:timeout, _} = reason ->
GenServer.cast(pool, {:cancel_waiting, self})
:erlang.raise(:exit, reason, :erlang.get_stacktrace)
end
end
@doc """
Checks out a worker pid in a nonblocking manner, i.e. if no available worker found this returns `nil`.
"""
defun checkout_nonblock(pool :: pool, timeout :: timeout \\ 5000) :: nil | pid do
GenServer.call(pool, :checkout_nonblock, timeout)
end
@doc """
Checks in an in-use worker process and make it available to others.
"""
defun checkin(pool :: pool, pid :: g[pid]) :: :ok do
GenServer.cast(pool, {:checkin, pid})
end
@doc """
Temporarily checks out a worker pid, executes the given function using the pid, and checks in the pid.
The `timeout` parameter is used only in the checkout step; time elapsed during other steps are not counted.
"""
defun transaction(pool :: pool, f :: (pid -> a), timeout :: timeout \\ 5000) :: a when a: any do
pid = checkout(pool, timeout)
try do
f.(pid)
after
checkin(pool, pid)
end
end
@doc """
Query current status of a pool.
"""
defun status(pool :: pool) :: %{current_capacity: nni, desired_capacity: nni, available: nni, working: nni} when nni: non_neg_integer do
GenServer.call(pool, :status)
end
@doc """
Changes capacity (number of worker processes) of a pool.
If `new_capacity` is more than the current capacity, new processes are immediately spawned and become available.
Note that, as is the same throughout the OTP framework, spawning processes under supervisor is synchronous operation.
Therefore increasing large number of capacity at once may make a pool unresponsive for a while.
If `new_capacity` is less than the current capacity, the pool tries to shutdown workers that are not in use.
Processes currently in use are never interrupted.
If number of in-use workers is more than `new_capacity`, reducing further is delayed until any worker process is checked in.
"""
defun change_capacity(pool :: pool, new_capacity :: g[non_neg_integer]) :: :ok do
GenServer.call(pool, {:change_capacity, new_capacity})
end
#
# gen_server callbacks
#
def init({mod, init_arg, capacity, opts}) do
{:ok, sup_state} = :supervisor.init(supervisor_init_arg(mod, init_arg, opts))
{:ok, make_state(capacity, sup_state)}
end
defp supervisor_init_arg(mod, init_arg, opts) do
sup_name = opts[:name] || :self
worker_spec = S.Spec.worker(mod, [init_arg], [restart: :temporary, shutdown: 5000])
spec = S.Spec.supervise([worker_spec], strategy: :simple_one_for_one, max_restarts: 0, max_seconds: 1)
{sup_name, Callback, [spec]}
end
defunp make_state(capacity :: non_neg_integer, sup_state :: sup_state) :: state do
{pids, new_sup_state} = prepare_children(capacity, [], sup_state)
all = PidSet.from_list(pids)
state(all: all, working: PidSet.new, available: pids, capacity_to_decrease: 0, waiting: :queue.new, sup_state: new_sup_state)
end
defunp prepare_children(capacity :: non_neg_integer, pids :: [pid], sup_state :: sup_state) :: {[pid], sup_state} do
if capacity == 0 do
{pids, sup_state}
else
{pid, new_sup_state} = start_child(sup_state)
prepare_children(capacity - 1, [pid | pids], new_sup_state)
end
end
def handle_call(:checkout_nonblock, _from, state(available: available) = s) do
case available do
[pid | pids] -> reply_with_pid(pid, pids, s)
[] -> {:reply, nil, s}
end
end
def handle_call(:checkout, from, state(available: available, waiting: waiting) = s) do
case available do
[pid | pids] -> reply_with_pid(pid, pids, s)
[] ->
new_state = state(s, waiting: :queue.in(from, waiting))
{:noreply, new_state}
end
end
def handle_call(:status, _from,
state(all: all, available: available, working: working, capacity_to_decrease: to_decrease) = s) do
current_capacity = map_size(all)
r = %{
current_capacity: current_capacity,
desired_capacity: current_capacity - to_decrease,
available: length(available),
working: map_size(working),
}
{:reply, r, s}
end
def handle_call({:change_capacity, new_capacity}, _from, state(all: all) = s) do
case new_capacity - map_size(all) do
0 ->
{:reply, :ok, state(s, capacity_to_decrease: 0)}
to_increase when to_increase > 0 ->
new_state = increase_children(to_increase, s)
{:reply, :ok, new_state}
to_decrease ->
new_state = decrease_children(-to_decrease, s)
{:reply, :ok, new_state}
end
end
def handle_call({:start_child, _}, _from, s) do
{:reply, {:error, :pool_sup}, s}
end
def handle_call({:terminate_child, _}, _from, s) do
# returns `:simple_one_for_one` to obey type contract of `Supervisor.terminate_child/2`
{:reply, {:error, :simple_one_for_one}, s}
end
def handle_call(msg, from, state(sup_state: sup_state) = s) do
{:reply, reply, new_sup_state} = :supervisor.handle_call(msg, from, sup_state)
{:reply, reply, state(s, sup_state: new_sup_state)}
end
defunp reply_with_pid(pid :: pid, pids :: [pid], state(working: working) = s :: state) :: {:reply, pid, state} do
{:reply, pid, state(s, working: PidSet.put(working, pid), available: pids)}
end
defunp increase_children(to_increase :: non_neg_integer, state(all: all, available: available, sup_state: sup_state) = s :: state) :: state do
if to_increase == 0 do
state(s, capacity_to_decrease: 0)
else
{pid, new_sup_state} = start_child(sup_state)
new_state = state(s, all: PidSet.put(all, pid), available: [pid | available], sup_state: new_sup_state)
increase_children(to_increase - 1, new_state)
end
end
defunp start_child(sup_state :: sup_state) :: {pid, sup_state} do
{:reply, {:ok, pid}, new_sup_state} = :supervisor.handle_call({:start_child, []}, self, sup_state)
{pid, new_sup_state}
end
defunp decrease_children(to_decrease :: non_neg_integer, state(all: all, available: available, sup_state: sup_state) = s :: state) :: state do
if to_decrease == 0 do
state(s, capacity_to_decrease: 0)
else
case available do
[] -> state(s, capacity_to_decrease: to_decrease)
[pid | pids] ->
new_sup_state = terminate_child(pid, sup_state)
new_state = state(s, all: PidSet.delete(all, pid), available: pids, sup_state: new_sup_state)
decrease_children(to_decrease - 1, new_state)
end
end
end
defunp terminate_child(pid :: pid, sup_state :: sup_state) :: sup_state do
{:reply, :ok, new_sup_state} = :supervisor.handle_call({:terminate_child, pid}, self, sup_state)
new_sup_state
end
def handle_cast({:checkin, pid},
state(all: all,
working: working,
available: available,
capacity_to_decrease: to_decrease,
waiting: waiting,
sup_state: sup_state) = s) do
if PidSet.member?(working, pid) do
new_state =
if to_decrease == 0 do
case :queue.out(waiting) do
{{:value, wait_pid}, waiting2} ->
GenServer.reply(wait_pid, pid)
state(s, waiting: waiting2)
{:empty, _} ->
working2 = PidSet.delete(working, pid)
state(s, working: working2, available: [pid | available])
end
else
working2 = PidSet.delete(working, pid)
new_sup_state = terminate_child(pid, sup_state)
state(s, all: PidSet.delete(all, pid), working: working2, capacity_to_decrease: to_decrease - 1, sup_state: new_sup_state)
end
{:noreply, new_state}
else
{:noreply, s}
end
end
def handle_cast({:cancel_waiting, pid}, state(waiting: waiting) = s) do
new_waiting = :queue.filter(&(&1 == pid), waiting)
{:noreply, state(s, waiting: new_waiting)}
end
def handle_info(msg, state(sup_state: sup_state) = s) do
{:noreply, new_sup_state} = :supervisor.handle_info(msg, sup_state)
s2 = state(s, sup_state: new_sup_state)
s3 = case msg do
{:EXIT, pid, _reason} -> handle_exit(s2, pid)
_ -> s2
end
{:noreply, s3}
end
defunp handle_exit(state(all: all) = s :: state, pid :: pid) :: state do
if PidSet.member?(all, pid) do
handle_child_exited(s, pid)
else
s
end
end
defunp handle_child_exited(state(all: all,
working: working,
available: available,
capacity_to_decrease: to_decrease,
waiting: waiting,
sup_state: sup_state) = s :: state,
child_pid :: pid) :: state do
{working2, available2} =
case PidSet.member?(working, child_pid) do
true -> {PidSet.delete(working, child_pid), available}
false -> {working, List.delete(available, child_pid)}
end
all2 = PidSet.delete(all, child_pid)
if to_decrease == 0 do
{new_child_pid, new_sup_state} = start_child(sup_state)
all3 = PidSet.put(all2, new_child_pid)
case :queue.out(waiting) do
{{:value, wait_pid}, waiting2} ->
GenServer.reply(wait_pid, new_child_pid)
working3 = PidSet.put(working2, new_child_pid)
state(s, all: all3, working: working3, available: available2, waiting: waiting2, sup_state: new_sup_state)
{:empty, _} ->
available3 = [new_child_pid | available2]
state(s, all: all3, working: working2, available: available3, sup_state: new_sup_state)
end
else
state(s, all: all2, working: working2, available: available2, capacity_to_decrease: to_decrease - 1)
end
end
def terminate(reason, state(sup_state: sup_state)) do
:supervisor.terminate(reason, sup_state)
end
def code_change(old_vsn, state(sup_state: sup_state) = s, extra) do
case :supervisor.code_change(old_vsn, sup_state, extra) do
{:ok, new_sup_state} -> {:ok, state(s, sup_state: new_sup_state)}
{:error, reason} -> {:error, reason}
end
end
# We need to define `format_status` to pretend as if it's an ordinary supervisor when `sys:get_status/1` is called
@doc false
def format_status(:terminate, [_pdict, s ]), do: s
def format_status(:normal , [_pdict, state(sup_state: sup_state)]), do: [{:data, [{'State', sup_state}]}]
end