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_async_pool.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 Worker pool manager for async Python execution.
%%%
%%% Manages a pool of async workers that have background asyncio event loops.
%%% Distributes async requests across workers using round-robin scheduling.
%%%
%%% @private
-module(py_async_pool).
-behaviour(gen_server).
-export([
start_link/1,
request/1,
get_stats/0
]).
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2
]).
-record(state, {
workers :: queue:queue(pid()) | undefined,
num_workers :: non_neg_integer(),
pending :: non_neg_integer(),
worker_sup :: pid() | undefined,
supported :: boolean() %% whether async workers are supported
}).
%%% ============================================================================
%%% API
%%% ============================================================================
-spec start_link(pos_integer()) -> {ok, pid()} | {error, term()}.
start_link(NumWorkers) ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [NumWorkers], []).
%% @doc Submit an async request to be executed by a worker.
-spec request(term()) -> ok.
request(Request) ->
gen_server:cast(?MODULE, {request, Request}).
%% @doc Get pool statistics.
-spec get_stats() -> map().
get_stats() ->
gen_server:call(?MODULE, get_stats).
%%% ============================================================================
%%% gen_server callbacks
%%% ============================================================================
init([NumWorkers]) ->
process_flag(trap_exit, true),
%% Start worker supervisor
{ok, WorkerSup} = py_async_worker_sup:start_link(),
%% Try to start workers - may fail on free-threaded Python
case start_workers(WorkerSup, NumWorkers) of
{ok, Workers} ->
{ok, #state{
workers = queue:from_list(Workers),
num_workers = NumWorkers,
pending = 0,
worker_sup = WorkerSup,
supported = true
}};
{error, _Reason} ->
%% Async workers not supported (e.g., free-threaded Python)
%% Pool starts but all requests will return an error
{ok, #state{
workers = undefined,
num_workers = 0,
pending = 0,
worker_sup = WorkerSup,
supported = false
}}
end.
handle_call(get_stats, _From, State) ->
AvailWorkers = case State#state.workers of
undefined -> 0;
Q -> queue:len(Q)
end,
Stats = #{
num_workers => State#state.num_workers,
pending_requests => State#state.pending,
available_workers => AvailWorkers,
supported => State#state.supported
},
{reply, Stats, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
handle_cast({request, Request}, #state{supported = false} = State) ->
{Ref, Caller, _} = extract_ref_caller(Request),
Caller ! {py_error, Ref, async_not_supported},
{noreply, State};
handle_cast({request, Request}, State) ->
case queue:out(State#state.workers) of
{{value, Worker}, Rest} ->
%% Send request to worker
Worker ! {py_async_request, Request},
%% Put worker at end of queue (round-robin)
NewWorkers = queue:in(Worker, Rest),
{noreply, State#state{
workers = NewWorkers,
pending = State#state.pending + 1
}};
{empty, _} ->
error_logger:warning_msg("py_async_pool: no workers available~n"),
{Ref, Caller, _} = extract_ref_caller(Request),
Caller ! {py_error, Ref, no_workers_available},
{noreply, State}
end;
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info({worker_done, _WorkerPid}, State) ->
{noreply, State#state{pending = max(0, State#state.pending - 1)}};
handle_info({'EXIT', _Pid, _Reason}, #state{supported = false} = State) ->
{noreply, State};
handle_info({'EXIT', Pid, Reason}, State) ->
error_logger:error_msg("py_async_pool: worker ~p died: ~p~n", [Pid, Reason]),
%% Remove dead worker from queue and start a new one
Workers = queue:filter(fun(W) -> W =/= Pid end, State#state.workers),
case py_async_worker_sup:start_worker(State#state.worker_sup) of
{ok, NewWorker} ->
NewWorkers = queue:in(NewWorker, Workers),
{noreply, State#state{workers = NewWorkers}};
{error, _} ->
%% Can't restart worker, continue with remaining workers
{noreply, State#state{workers = Workers}}
end;
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, #state{workers = undefined}) ->
ok;
terminate(_Reason, State) ->
%% Shutdown all workers
Workers = queue:to_list(State#state.workers),
lists:foreach(fun(W) -> W ! shutdown end, Workers),
ok.
%%% ============================================================================
%%% Internal functions
%%% ============================================================================
start_workers(Sup, N) ->
start_workers(Sup, N, []).
start_workers(_Sup, 0, Acc) ->
{ok, lists:reverse(Acc)};
start_workers(Sup, N, Acc) ->
case py_async_worker_sup:start_worker(Sup) of
{ok, Pid} ->
start_workers(Sup, N - 1, [Pid | Acc]);
{error, Reason} ->
%% Failed to start worker, shutdown any already started
lists:foreach(fun(W) -> W ! shutdown end, Acc),
{error, Reason}
end.
extract_ref_caller({async_call, Ref, Caller, _, _, _, _}) -> {Ref, Caller, async_call};
extract_ref_caller({async_gather, Ref, Caller, _}) -> {Ref, Caller, async_gather};
extract_ref_caller({async_stream, Ref, Caller, _, _, _, _}) -> {Ref, Caller, async_stream}.