Current section
Files
Jump to
Current section
Files
src/seki_bulkhead.erl
-module(seki_bulkhead).
-moduledoc """
Concurrency limiter (bulkhead pattern) using atomics for lock-free counting.
Limits the number of concurrent executions. Monitors calling processes and
auto-releases slots on crash. Use `call/2` for automatic acquire/release
or `acquire/1` + `release/1` for manual control.
## Example
seki_bulkhead:start_link(db_pool, #{max_concurrent => 25}).
{ok, Result} = seki_bulkhead:call(db_pool, fun() -> db:query(Q) end).
""".
-behaviour(gen_server).
-export([
start_link/2,
acquire/1,
acquire/2,
release/1,
call/2,
call/3,
status/1
]).
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2
]).
-record(state, {
name :: atom(),
max_concurrent :: pos_integer(),
counter :: atomics:atomics_ref(),
monitors :: #{reference() => pid()}
}).
%%----------------------------------------------------------------------
%% API
%%----------------------------------------------------------------------
-doc false.
-spec start_link(atom(), map()) -> {ok, pid()}.
start_link(Name, Opts) ->
gen_server:start_link({local, Name}, ?MODULE, {Name, Opts}, []).
-doc "Acquire a concurrency slot (non-blocking, returns immediately).".
-spec acquire(atom()) -> ok | {error, bulkhead_full}.
acquire(Name) ->
acquire(Name, 0).
-doc "Acquire a concurrency slot with a timeout.".
-spec acquire(atom(), non_neg_integer()) -> ok | {error, bulkhead_full}.
acquire(Name, Timeout) ->
gen_server:call(Name, {acquire, self()}, max(5000, Timeout + 1000)).
-doc "Release a previously acquired concurrency slot.".
-spec release(atom()) -> ok.
release(Name) ->
gen_server:cast(Name, {release, self()}).
-doc "Execute a function with automatic acquire/release.".
-spec call(atom(), fun(() -> term())) -> {ok, term()} | {error, bulkhead_full}.
call(Name, Fun) ->
call(Name, Fun, 5000).
-doc "Execute a function with automatic acquire/release and a timeout.".
-spec call(atom(), fun(() -> term()), non_neg_integer()) -> {ok, term()} | {error, bulkhead_full}.
call(Name, Fun, Timeout) ->
case acquire(Name, Timeout) of
ok ->
try
Result = Fun(),
{ok, Result}
after
release(Name)
end;
{error, bulkhead_full} = Error ->
Error
end.
-doc "Get current bulkhead status (current, max, available slots).".
-spec status(atom()) ->
#{current := non_neg_integer(), max := pos_integer(), available := non_neg_integer()}.
status(Name) ->
gen_server:call(Name, status).
%%----------------------------------------------------------------------
%% gen_server callbacks
%%----------------------------------------------------------------------
init({Name, Opts}) ->
MaxConcurrent = maps:get(max_concurrent, Opts, 10),
Counter = atomics:new(1, [{signed, false}]),
{ok, #state{
name = Name,
max_concurrent = MaxConcurrent,
counter = Counter,
monitors = #{}
}}.
handle_call({acquire, Pid}, _From, #state{counter = Counter, max_concurrent = Max} = State) ->
Current = atomics:get(Counter, 1),
case Current < Max of
true ->
atomics:add(Counter, 1, 1),
MonRef = monitor(process, Pid),
NewMonitors = maps:put(MonRef, Pid, State#state.monitors),
emit_acquire(State#state.name, Current + 1, Max),
{reply, ok, State#state{monitors = NewMonitors}};
false ->
logger:warning(
"Bulkhead ~p full (~p/~p), request rejected",
[State#state.name, Max, Max],
#{domain => [seki]}
),
emit_rejected(State#state.name, Max),
{reply, {error, bulkhead_full}, State}
end;
handle_call(status, _From, #state{counter = Counter, max_concurrent = Max} = State) ->
Current = atomics:get(Counter, 1),
{reply, #{current => Current, max => Max, available => Max - Current}, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown}, State}.
handle_cast({release, Pid}, State) ->
{noreply, do_release_by_pid(Pid, State)};
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info({'DOWN', MonRef, process, Pid, Reason}, State) ->
case maps:take(MonRef, State#state.monitors) of
{_, NewMonitors} ->
logger:warning(
"Bulkhead ~p: process ~p died (~p), releasing slot",
[State#state.name, Pid, Reason],
#{domain => [seki]}
),
atomics:sub(State#state.counter, 1, 1),
Current = atomics:get(State#state.counter, 1),
emit_release(State#state.name, Current, State#state.max_concurrent),
{noreply, State#state{monitors = NewMonitors}};
error ->
{noreply, State}
end;
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
%%----------------------------------------------------------------------
%% Internal
%%----------------------------------------------------------------------
do_release_by_pid(
Pid, #state{monitors = Monitors, counter = Counter, name = Name, max_concurrent = Max} = State
) ->
case find_monitor_by_pid(Pid, Monitors) of
{ok, MonRef} ->
demonitor(MonRef, [flush]),
atomics:sub(Counter, 1, 1),
Current = atomics:get(Counter, 1),
emit_release(Name, Current, Max),
State#state{monitors = maps:remove(MonRef, Monitors)};
error ->
State
end.
find_monitor_by_pid(Pid, Monitors) ->
case maps:to_list(maps:filter(fun(_K, V) -> V =:= Pid end, Monitors)) of
[{MonRef, _} | _] -> {ok, MonRef};
[] -> error
end.
%%----------------------------------------------------------------------
%% Telemetry
%%----------------------------------------------------------------------
emit_acquire(Name, Current, Max) ->
telemetry:execute(
[seki, bulkhead, acquire],
#{current => Current, available => Max - Current},
#{name => Name}
).
emit_release(Name, Current, Max) ->
telemetry:execute(
[seki, bulkhead, release],
#{current => Current, available => Max - Current},
#{name => Name}
).
emit_rejected(Name, Max) ->
telemetry:execute(
[seki, bulkhead, rejected],
#{current => Max, available => 0},
#{name => Name}
).