Packages
Execute Python applications from Erlang using dirty NIFs
Retired package: Use 1.3.1 instead, missing build scripts
Current section
Files
Jump to
Current section
Files
src/py_semaphore.erl
%% Copyright 2026 Benoit Chesneau
%%
%% Licensed under the Apache License, Version 2.0 (the "License");
%% you may not use this file except in compliance with the License.
%% You may obtain a copy of the License at
%%
%% http://www.apache.org/licenses/LICENSE-2.0
%%
%% Unless required by applicable law or agreed to in writing, software
%% distributed under the License is distributed on an "AS IS" BASIS,
%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
%% See the License for the specific language governing permissions and
%% limitations under the License.
%%%-------------------------------------------------------------------
%%% @doc ETS-based counting semaphore for rate limiting Python operations.
%%%
%%% Based on the Discord semaphore pattern. Uses atomic ETS operations
%%% for high concurrency without a gen_server bottleneck.
%%%
%%% The semaphore limits concurrent Python operations to prevent:
%%% - Memory exhaustion from unbounded request queuing
%%% - Dirty scheduler pool starvation
%%% - System overload under burst traffic
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(py_semaphore).
-export([
init/0,
acquire/1,
release/0,
max_concurrent/0,
current/0,
set_max_concurrent/1
]).
-define(TABLE, py_semaphore).
-define(COUNTER_KEY, running).
-define(MAX_KEY, max).
-define(BACKOFF_MS, 5).
-define(MAX_BACKOFF_MS, 50).
%%%===================================================================
%%% API
%%%===================================================================
%% @doc Initialize the semaphore ETS table.
%% Safe to call multiple times - will not recreate if already exists.
-spec init() -> ok.
init() ->
case ets:whereis(?TABLE) of
undefined ->
%% Create table with write_concurrency for atomic counter operations
_ = ets:new(?TABLE, [
named_table,
public,
{write_concurrency, true},
{read_concurrency, true}
]),
Max = default_max_concurrent(),
ets:insert(?TABLE, [{?COUNTER_KEY, 0}, {?MAX_KEY, Max}]),
ok;
_Tid ->
ok
end.
%% @doc Acquire a slot in the semaphore.
%% Blocks with exponential backoff until a slot is available or timeout.
%% Returns ok on success, {error, max_concurrent} on timeout.
-spec acquire(timeout()) -> ok | {error, max_concurrent}.
acquire(infinity) ->
acquire_loop(infinity, 0, ?BACKOFF_MS);
acquire(Timeout) when is_integer(Timeout), Timeout > 0 ->
StartTime = erlang:monotonic_time(millisecond),
acquire_loop(Timeout, StartTime, ?BACKOFF_MS);
acquire(_) ->
acquire(30000). % Default 30s timeout
%% @doc Release a slot in the semaphore.
%% Must be called after acquire/1 completes, typically in an after clause.
-spec release() -> ok.
release() ->
%% Atomically decrement, ensuring we don't go below 0
%% {Pos, Increment, Threshold, SetValue}
%% Decrement position 2 by 1, if result < 0, set to 0
_ = ets:update_counter(?TABLE, ?COUNTER_KEY, {2, -1, 0, 0}),
ok.
%% @doc Get the maximum concurrent operations allowed.
-spec max_concurrent() -> pos_integer().
max_concurrent() ->
case ets:lookup(?TABLE, ?MAX_KEY) of
[{?MAX_KEY, Max}] -> Max;
[] -> default_max_concurrent()
end.
%% @doc Get the current number of operations in flight.
-spec current() -> non_neg_integer().
current() ->
case ets:lookup(?TABLE, ?COUNTER_KEY) of
[{?COUNTER_KEY, Count}] -> Count;
[] -> 0
end.
%% @doc Dynamically set the maximum concurrent operations.
%% Takes effect immediately for new acquire calls.
-spec set_max_concurrent(pos_integer()) -> ok.
set_max_concurrent(Max) when is_integer(Max), Max > 0 ->
ets:insert(?TABLE, {?MAX_KEY, Max}),
ok.
%%%===================================================================
%%% Internal functions
%%%===================================================================
-spec acquire_loop(timeout(), non_neg_integer(), pos_integer()) ->
ok | {error, max_concurrent}.
acquire_loop(Timeout, StartTime, Backoff) ->
Max = max_concurrent(),
%% Atomically increment and get new value
N = ets:update_counter(?TABLE, ?COUNTER_KEY, {2, 1}),
if
N =< Max ->
%% Successfully acquired slot
ok;
true ->
%% Over limit - decrement back and wait
_ = ets:update_counter(?TABLE, ?COUNTER_KEY, {2, -1}),
case check_timeout(Timeout, StartTime) of
continue ->
%% Exponential backoff with jitter
Jitter = rand:uniform(Backoff div 2 + 1),
SleepTime = Backoff + Jitter,
timer:sleep(SleepTime),
NewBackoff = min(Backoff * 2, ?MAX_BACKOFF_MS),
acquire_loop(Timeout, StartTime, NewBackoff);
timeout ->
{error, max_concurrent}
end
end.
-spec check_timeout(timeout(), non_neg_integer()) -> continue | timeout.
check_timeout(infinity, _StartTime) ->
continue;
check_timeout(Timeout, StartTime) ->
Elapsed = erlang:monotonic_time(millisecond) - StartTime,
case Elapsed >= Timeout of
true -> timeout;
false -> continue
end.
-spec default_max_concurrent() -> pos_integer().
default_max_concurrent() ->
case application:get_env(erlang_python, max_concurrent) of
{ok, N} when is_integer(N), N > 0 -> N;
_ -> erlang:system_info(schedulers) * 2 + 1
end.