Packages
snakepit
0.8.1
0.13.0
0.12.0
0.11.1
0.11.0
0.10.1
0.10.0
0.9.1
0.9.0
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.1
0.5.0
0.4.3
0.4.2
0.4.1
0.4.0
0.3.3
0.3.2
0.3.1
0.3.0
0.2.1
0.2.0
0.1.2
0.1.1
0.1.0
High-performance pooler and session manager for external language integrations. Supports Python, Node.js, Ruby, and more with gRPC streaming, session management, and production-ready process cleanup.
Current section
Files
Jump to
Current section
Files
lib/snakepit/grpc_worker.ex
defmodule Snakepit.GRPCWorker do
@moduledoc """
A GenServer that manages gRPC connections to external processes.
This worker can handle both traditional request/response and streaming operations
via gRPC instead of stdin/stdout communication.
## Features
- Automatic gRPC connection management
- Health check monitoring
- Streaming support with callback-based API
- Session affinity for stateful operations
- Graceful fallback to traditional workers if gRPC unavailable
## Usage
# Start a gRPC worker
{:ok, worker} = Snakepit.GRPCWorker.start_link(adapter: Snakepit.Adapters.GRPCPython)
# Simple execution
{:ok, result} = Snakepit.GRPCWorker.execute(worker, "ping", %{})
# Streaming execution
Snakepit.GRPCWorker.execute_stream(worker, "batch_inference", %{
batch_items: ["img1.jpg", "img2.jpg"]
}, fn chunk ->
handle_chunk(chunk)
end)
"""
use GenServer
require Logger
alias Snakepit.Adapters.GRPCPython
alias Snakepit.Error
alias Snakepit.GRPC.Client
alias Snakepit.Logger, as: SLog
alias Snakepit.Logger.Redaction
alias Snakepit.Pool.ProcessRegistry
alias Snakepit.Pool.Registry, as: PoolRegistry
alias Snakepit.Telemetry.Correlation
alias Snakepit.Telemetry.GrpcStream
alias Snakepit.Worker.LifecycleManager
require OpenTelemetry.Tracer, as: Tracer
def child_spec(opts) when is_list(opts) do
%{
id: Keyword.get(opts, :id, __MODULE__),
start: {__MODULE__, :start_link, [opts]},
restart: :transient,
type: :worker
}
end
@type worker_state :: %{
adapter: module(),
connection: map() | nil,
port: integer(),
process_pid: integer() | nil,
pgid: integer() | nil,
process_group?: boolean(),
server_port: port() | nil,
id: String.t(),
pool_name: atom() | pid(),
health_check_ref: reference() | nil,
heartbeat_monitor: pid() | nil,
heartbeat_config: map(),
ready_file: String.t(),
stats: map(),
session_id: String.t(),
worker_config: map()
}
@base_heartbeat_defaults %{
enabled: true,
ping_interval_ms: 2_000,
timeout_ms: 10_000,
max_missed_heartbeats: 3,
ping_fun: nil,
test_pid: nil,
initial_delay_ms: 0,
dependent: true
}
@heartbeat_known_keys Map.keys(@base_heartbeat_defaults)
@heartbeat_known_key_strings Enum.map(@heartbeat_known_keys, &Atom.to_string/1)
@log_category :grpc
# Client API
@doc """
Start a gRPC worker with the given adapter.
"""
def start_link(opts) do
worker_id = Keyword.get(opts, :id)
pool_name = Keyword.get(opts, :pool_name, Snakepit.Pool)
pool_identifier = resolve_pool_identifier(opts, pool_name)
metadata =
%{worker_module: __MODULE__, pool_name: pool_name}
|> maybe_put_pool_identifier(pool_identifier)
opts_with_metadata =
opts
|> Keyword.put(:registry_metadata, metadata)
|> maybe_put_pool_identifier_opt(pool_identifier)
name = build_worker_name(worker_id)
GenServer.start_link(__MODULE__, opts_with_metadata, name: name)
end
@doc """
Execute a command and return the result.
"""
# Header for default values
def execute(worker, command, args, timeout \\ 30_000)
def execute(worker_id, command, args, timeout) when is_binary(worker_id) do
case PoolRegistry.get_worker_pid(worker_id) do
{:ok, pid} ->
GenServer.call(pid, {:execute, command, args, timeout}, timeout + 1_000)
{:error, _} ->
{:error,
Error.worker_error("Worker not found", %{worker_id: worker_id, command: command})}
end
end
def execute(worker_pid, command, args, timeout) when is_pid(worker_pid) do
GenServer.call(worker_pid, {:execute, command, args, timeout}, timeout + 1_000)
end
@doc """
Execute a streaming command with callback.
"""
def execute_stream(worker, command, args, callback_fn, timeout \\ 300_000)
def execute_stream(worker_id, command, args, callback_fn, timeout) when is_binary(worker_id) do
case PoolRegistry.get_worker_pid(worker_id) do
{:ok, pid} ->
GenServer.call(
pid,
{:execute_stream, command, args, callback_fn, timeout},
timeout + 1_000
)
{:error, _} ->
{:error,
Error.worker_error("Worker not found", %{worker_id: worker_id, command: command})}
end
end
def execute_stream(worker_pid, command, args, callback_fn, timeout) when is_pid(worker_pid) do
GenServer.call(
worker_pid,
{:execute_stream, command, args, callback_fn, timeout},
timeout + 1_000
)
end
@doc """
Execute a command in a specific session.
"""
def execute_in_session(worker, session_id, command, args, timeout \\ 30_000) do
GenServer.call(
worker,
{:execute_session, session_id, command, args, timeout},
timeout + 1_000
)
end
@doc """
Get worker health and statistics.
"""
def get_health(worker) do
GenServer.call(worker, :get_health)
end
@doc """
Get worker information and capabilities.
"""
def get_info(worker) do
GenServer.call(worker, :get_info)
end
@doc """
Get the gRPC channel for direct client usage.
"""
def get_channel(worker) do
GenServer.call(worker, :get_channel)
end
@doc """
Get the session ID for this worker.
"""
def get_session_id(worker) do
GenServer.call(worker, :get_session_id)
end
defp resolve_pool_identifier(opts, pool_name) do
case Keyword.get(opts, :pool_identifier) do
identifier when is_atom(identifier) ->
identifier
identifier when is_binary(identifier) ->
string_to_existing_atom_safe(identifier)
_ ->
infer_pool_identifier(pool_name)
end
end
defp string_to_existing_atom_safe(identifier) do
String.to_existing_atom(identifier)
rescue
ArgumentError -> nil
end
defp infer_pool_identifier(pool_name) when is_atom(pool_name), do: pool_name
defp infer_pool_identifier(pool_name) when is_pid(pool_name) do
case Process.info(pool_name, :registered_name) do
{:registered_name, name} when is_atom(name) -> name
_ -> nil
end
end
defp infer_pool_identifier(_), do: nil
defp build_worker_name(nil), do: nil
defp build_worker_name(worker_id) do
{:via, Registry, {Snakepit.Pool.Registry, worker_id}}
end
defp maybe_put_pool_identifier(metadata, nil), do: metadata
defp maybe_put_pool_identifier(metadata, identifier),
do: Map.put(metadata, :pool_identifier, identifier)
defp maybe_put_pool_identifier_opt(opts, nil), do: opts
defp maybe_put_pool_identifier_opt(opts, identifier),
do: Keyword.put(opts, :pool_identifier, identifier)
defp ensure_registry_metadata(metadata, pool_name, pool_identifier) do
metadata
|> Map.put(:worker_module, __MODULE__)
|> Map.put(:pool_name, pool_name)
|> maybe_put_pool_identifier(pool_identifier)
end
defp maybe_attach_registry_metadata(worker_id, metadata) when is_binary(worker_id) do
case PoolRegistry.put_metadata(worker_id, metadata) do
:ok ->
:ok
{:error, :not_registered} ->
SLog.debug(
@log_category,
"Pool.Registry missing entry for #{worker_id} while attaching metadata"
)
:ok
end
rescue
_ -> :ok
end
defp maybe_attach_registry_metadata(_worker_id, _metadata), do: :ok
defp normalize_worker_config(config, pool_name, adapter_module, pool_identifier) do
config
|> Map.put(:worker_module, __MODULE__)
|> Map.put_new(:adapter_module, adapter_module)
|> Map.put(:pool_name, pool_name)
|> maybe_put_pool_identifier(pool_identifier)
end
defp current_process_memory_bytes do
case Process.info(self(), :memory) do
{:memory, bytes} when is_integer(bytes) and bytes >= 0 -> bytes
_ -> 0
end
end
# Server callbacks
@impl true
def init(opts) do
# CRITICAL: Trap exits so terminate/2 is called on shutdown
# Without this, the GenServer is brutally killed and Python processes are orphaned!
Process.flag(:trap_exit, true)
adapter = Keyword.fetch!(opts, :adapter)
worker_id = Keyword.fetch!(opts, :id)
pool_name = Keyword.get(opts, :pool_name, Snakepit.Pool)
pool_identifier = Keyword.get(opts, :pool_identifier)
Logger.metadata(worker_id: worker_id, pool_name: pool_name, adapter: adapter)
metadata =
opts
|> Keyword.get(:registry_metadata, %{})
|> ensure_registry_metadata(pool_name, pool_identifier)
maybe_attach_registry_metadata(worker_id, metadata)
case ProcessRegistry.reserve_worker(worker_id) do
:ok ->
init_worker(opts, adapter, worker_id, pool_name, pool_identifier)
{:error, reason} ->
SLog.error(
@log_category,
"Failed to reserve worker slot for #{worker_id}: #{inspect(reason)}"
)
{:stop, {:reservation_failed, reason}}
end
end
defp init_worker(opts, adapter, worker_id, pool_name, pool_identifier) do
SLog.debug(@log_category, "Reserved worker slot for #{worker_id}")
session_id = generate_session_id()
Logger.metadata(session_id: session_id)
elixir_address = build_elixir_address()
port = adapter.get_port()
worker_config =
opts
|> Keyword.get(:worker_config, %{})
|> normalize_worker_config(pool_name, adapter, pool_identifier)
heartbeat_config =
worker_config
|> get_worker_config_section(:heartbeat)
|> normalize_heartbeat_config()
spawn_config =
build_spawn_config(
adapter,
worker_config,
heartbeat_config,
port,
elixir_address,
worker_id
)
server_port = spawn_grpc_server(spawn_config)
process_pid = extract_and_log_pid(server_port, port)
{pgid, process_group?} = resolve_process_group(process_pid, spawn_config)
register_worker_pid(worker_id, process_pid, pgid, process_group?)
state_params = %{
worker_id: worker_id,
pool_name: pool_name,
adapter: adapter,
port: port,
server_port: server_port,
process_pid: process_pid,
pgid: pgid,
process_group?: process_group?,
session_id: session_id,
worker_config: worker_config,
heartbeat_config: heartbeat_config,
ready_file: spawn_config.ready_file
}
state = build_initial_state(state_params)
{:ok, state, {:continue, :connect_and_wait}}
end
defp generate_session_id do
"session_#{:erlang.unique_integer([:positive, :monotonic])}_#{:erlang.system_time(:microsecond)}"
end
defp build_elixir_address do
elixir_grpc_host = Application.get_env(:snakepit, :grpc_host, "localhost")
elixir_grpc_port = Application.get_env(:snakepit, :grpc_port, 50_051)
"#{elixir_grpc_host}:#{elixir_grpc_port}"
end
defp build_spawn_config(
adapter,
worker_config,
heartbeat_config,
port,
elixir_address,
worker_id
) do
python_executable = adapter.executable_path()
adapter_args = resolve_adapter_args(adapter, worker_config)
adapter_env = worker_config |> Map.get(:adapter_env, []) |> merge_with_default_adapter_env()
heartbeat_env_json = encode_heartbeat_env(heartbeat_config)
ready_file = build_ready_file(worker_id)
script_path = determine_script_path(adapter, adapter_args)
args = build_spawn_args(adapter_args, port, elixir_address)
SLog.info(
@log_category,
"Starting gRPC server: #{python_executable} #{script_path || ""} #{Enum.join(args, " ")}"
)
%{
executable: python_executable,
script_path: script_path,
args: args,
process_group?: process_group_spawn?(),
adapter_env: adapter_env,
heartbeat_env_json: heartbeat_env_json,
ready_file: ready_file
}
end
defp resolve_adapter_args(adapter, worker_config) do
worker_adapter_args = Map.get(worker_config, :adapter_args, [])
if worker_adapter_args == [] do
adapter.script_args() || []
else
worker_adapter_args
end
end
defp determine_script_path(adapter, adapter_args) do
if Enum.any?(adapter_args, fn arg ->
is_binary(arg) and String.contains?(arg, "--max-workers")
end) do
app_dir = Application.app_dir(:snakepit)
Path.join([app_dir, "priv", "python", "grpc_server_threaded.py"])
else
adapter.script_path()
end
end
defp build_spawn_args(adapter_args, port, elixir_address) do
adapter_args
|> maybe_add_arg("--port", to_string(port))
|> maybe_add_arg("--elixir-address", elixir_address)
|> add_run_id_arg()
end
defp build_ready_file(worker_id) do
base_dir = System.tmp_dir() || File.cwd!()
safe_worker_id =
worker_id
|> to_string()
|> String.replace(~r/[^a-zA-Z0-9_.-]/, "_")
unique = :erlang.unique_integer([:positive, :monotonic])
Path.join(base_dir, "snakepit_ready_#{safe_worker_id}_#{unique}")
end
defp maybe_add_arg(args, flag, value) do
if Enum.any?(args, &String.contains?(&1, flag)) do
args
else
args ++ [flag, value]
end
end
defp add_run_id_arg(args) do
run_id = ProcessRegistry.get_beam_run_id()
args ++ ["--snakepit-run-id", run_id]
end
defp spawn_grpc_server(%{
executable: executable,
script_path: script_path,
args: args,
adapter_env: adapter_env,
heartbeat_env_json: heartbeat_env_json,
ready_file: ready_file
}) do
{spawn_args, port_opts} = build_port_config(script_path, args)
port_opts = apply_env_to_port_opts(port_opts, adapter_env, heartbeat_env_json, ready_file)
server_port = Port.open({:spawn_executable, executable}, [{:args, spawn_args} | port_opts])
Port.monitor(server_port)
server_port
end
defp build_port_config(script_path, args) do
case script_path do
path when is_binary(path) and byte_size(path) > 0 ->
{
[path | args],
[:binary, :exit_status, :use_stdio, :stderr_to_stdout, {:cd, Path.dirname(path)}]
}
_ ->
{args, [:binary, :exit_status, :use_stdio, :stderr_to_stdout]}
end
end
defp process_group_spawn? do
Application.get_env(:snakepit, :process_group_kill, true) and
Snakepit.ProcessKiller.process_group_supported?()
end
defp resolve_process_group(process_pid, %{process_group?: true})
when is_integer(process_pid) do
with {:ok, pgid} <- Snakepit.ProcessKiller.get_process_group_id(process_pid),
true <- pgid == process_pid do
{pgid, true}
else
_ -> {nil, false}
end
end
defp resolve_process_group(_process_pid, _spawn_config), do: {nil, false}
defp apply_env_to_port_opts(port_opts, adapter_env, heartbeat_env_json, ready_file) do
env_entries =
adapter_env
|> maybe_put_heartbeat_env(heartbeat_env_json)
|> maybe_put_ready_file_env(ready_file)
|> maybe_put_log_level_env()
if env_entries != [] do
env_tuples = Enum.map(env_entries, &to_env_tuple/1)
port_opts ++ [{:env, env_tuples}]
else
port_opts
end
end
defp extract_and_log_pid(server_port, port) do
case Port.info(server_port, :os_pid) do
{:os_pid, pid} ->
SLog.info(@log_category, "Started gRPC server process, will listen on TCP port #{port}")
pid
error ->
SLog.error(@log_category, "Failed to get gRPC server process PID: #{inspect(error)}")
nil
end
end
defp register_worker_pid(_worker_id, nil, _pgid, _process_group?), do: :ok
defp register_worker_pid(worker_id, process_pid, pgid, process_group?) do
case ProcessRegistry.activate_worker(worker_id, self(), process_pid, "grpc_worker",
pgid: pgid,
process_group?: process_group?
) do
:ok ->
SLog.debug(
@log_category,
"Registered Python PID #{process_pid} for worker #{worker_id} in ProcessRegistry"
)
{:error, reason} ->
SLog.error(
@log_category,
"Failed to register Python PID #{process_pid} for worker #{worker_id}: #{inspect(reason)}"
)
end
end
defp build_initial_state(%{
worker_id: worker_id,
pool_name: pool_name,
adapter: adapter,
port: port,
server_port: server_port,
process_pid: process_pid,
pgid: pgid,
process_group?: process_group?,
session_id: session_id,
worker_config: worker_config,
heartbeat_config: heartbeat_config,
ready_file: ready_file
}) do
%{
id: worker_id,
pool_name: pool_name,
adapter: adapter,
port: port,
server_port: server_port,
process_pid: process_pid,
pgid: pgid,
process_group?: process_group?,
session_id: session_id,
requested_port: port,
worker_config: worker_config,
heartbeat_config: heartbeat_config,
ready_file: ready_file,
heartbeat_monitor: nil,
connection: nil,
health_check_ref: nil,
python_output_buffer: "",
stats: %{
requests: 0,
errors: 0,
start_time: System.monotonic_time()
}
}
end
defp resolve_pool_pid(pool_name) when is_atom(pool_name), do: Process.whereis(pool_name)
defp resolve_pool_pid(pool_name), do: pool_name
defp verify_pool_alive(nil, worker_id), do: {:error, {:pool_dead, worker_id}}
defp verify_pool_alive(pool_pid, worker_id) do
if Process.alive?(pool_pid) do
:ok
else
{:error, {:pool_dead, worker_id}}
end
end
defp complete_worker_initialization(state, connection, actual_port) do
health_ref = schedule_health_check()
LifecycleManager.track_worker(state.pool_name, state.id, self(), state.worker_config)
SLog.info(
@log_category,
"✅ gRPC worker #{state.id} initialization complete and acknowledged."
)
maybe_initialize_session(connection, state.session_id)
register_telemetry_stream(connection, state)
emit_worker_spawned_telemetry(state, actual_port)
new_state =
state
|> Map.put(:connection, connection)
|> Map.put(:port, actual_port)
|> Map.put(:health_check_ref, health_ref)
|> maybe_start_heartbeat_monitor()
{:noreply, new_state}
end
defp emit_worker_spawned_telemetry(state, actual_port) do
start_time = Map.get(state.stats, :start_time, System.monotonic_time())
:telemetry.execute(
[:snakepit, :pool, :worker, :spawned],
%{
duration: System.monotonic_time() - start_time,
system_time: System.system_time()
},
%{
node: node(),
pool_name: state.pool_name,
worker_id: state.id,
worker_pid: self(),
python_port: actual_port,
python_pid: state.process_pid,
mode: :process
}
)
end
@impl true
def handle_continue(:connect_and_wait, state) do
with {:ok, actual_port} <- wait_for_server_ready(state.server_port, state.ready_file, 30_000),
{:ok, connection} <-
wrap_grpc_connection_result(state.adapter.init_grpc_connection(actual_port)),
pool_pid <- resolve_pool_pid(state.pool_name),
:ok <- verify_pool_alive(pool_pid, state.id),
:ok <- notify_pool_ready(pool_pid, state.id) do
complete_worker_initialization(state, connection, actual_port)
else
{:error, :shutdown} ->
SLog.debug(@log_category, "gRPC server exited during startup (shutdown)")
{:stop, :shutdown, state}
{:error, {:exit_status, status}} when status in [137] ->
SLog.error(@log_category, "gRPC server exited during startup with status #{status}")
{:stop, {:grpc_server_failed, {:exit_status, status}}, state}
{:error, {:pool_dead, _}} ->
SLog.debug(
@log_category,
"Worker #{state.id} finished starting but Pool is shut down. Stopping gracefully."
)
{:stop, :normal, state}
{:error, {:pool_handshake_failed, reason}} ->
SLog.debug(
@log_category,
"Pool handshake failed for worker #{state.id}: #{inspect(reason)}"
)
{:stop, :shutdown, state}
{:error, {:grpc_connection_failed, reason}} ->
SLog.error(@log_category, "Failed to connect to gRPC server: #{reason}")
{:stop, {:grpc_connection_failed, reason}, state}
{:error, reason} ->
SLog.error(@log_category, "Failed to start gRPC server: #{inspect(reason)}")
{:stop, {:grpc_server_failed, reason}, state}
end
end
defp wrap_grpc_connection_result({:ok, connection}), do: {:ok, connection}
defp wrap_grpc_connection_result({:error, reason}),
do: {:error, {:grpc_connection_failed, reason}}
@impl true
def handle_call({:execute, command, args, timeout}, _from, state) do
args_with_corr = ensure_correlation(args)
case instrument_execute(
:execute,
state,
command,
args_with_corr,
timeout,
fn instrumented_args ->
state.adapter.grpc_execute(
state.connection,
state.session_id,
command,
instrumented_args,
timeout
)
end
) do
{:ok, result} ->
new_state = update_stats(state, :success)
{:reply, {:ok, result}, new_state}
{:error, reason} ->
new_state = update_stats(state, :error)
{:reply, {:error, reason}, new_state}
end
end
@impl true
def handle_call({:execute_stream, command, args, callback_fn, timeout}, _from, state) do
SLog.debug(
@log_category,
"[GRPCWorker] execute_stream #{command} with args #{Redaction.describe(args)}"
)
args_with_corr = ensure_correlation(args)
result =
state.adapter.grpc_execute_stream(
state.connection,
state.session_id,
command,
args_with_corr,
callback_fn,
timeout
)
SLog.debug(@log_category, "[GRPCWorker] execute_stream result: #{Redaction.describe(result)}")
new_state =
case result do
:ok -> update_stats(state, :success)
{:error, _reason} -> update_stats(state, :error)
end
{:reply, result, new_state}
end
@impl true
def handle_call(:get_port, _from, state) do
{:reply, {:ok, state.port}, state}
end
@impl true
def handle_call(:get_port_metadata, _from, state) do
info = %{
current_port: state.port,
requested_port: Map.get(state, :requested_port, state.port)
}
{:reply, {:ok, info}, state}
end
@impl true
def handle_call({:execute_session, session_id, command, args, timeout}, _from, state) do
session_args =
args
|> Map.put(:session_id, session_id)
|> ensure_correlation()
case instrument_execute(
:execute_session,
state,
command,
session_args,
timeout,
fn instrumented_args ->
state.adapter.grpc_execute(
state.connection,
state.session_id,
command,
instrumented_args,
timeout
)
end
) do
{:ok, result} ->
new_state = update_stats(state, :success)
{:reply, {:ok, result}, new_state}
{:error, reason} ->
new_state = update_stats(state, :error)
{:reply, {:error, reason}, new_state}
end
end
@impl true
def handle_call(:get_health, _from, state) do
# Make gRPC health check call
health_result = make_health_check(state)
{:reply, health_result, state}
end
@impl true
def handle_call(:get_info, _from, state) do
# Make gRPC info call
info_result = make_info_call(state)
{:reply, info_result, state}
end
@impl true
def handle_call(:get_channel, _from, state) do
if state.connection do
{:reply, {:ok, state.connection.channel}, state}
else
{:reply,
{:error,
Error.grpc_error(:not_connected, "Not connected to gRPC server", %{worker_id: state.id})},
state}
end
end
@impl true
def handle_call(:get_session_id, _from, state) do
{:reply, {:ok, state.session_id}, state}
end
def handle_call(:get_memory_usage, _from, state) do
{:reply, {:ok, current_process_memory_bytes()}, state}
end
@impl true
def handle_info(:health_check, state) do
case make_health_check(state) do
{:ok, _health} ->
# Health check passed, schedule next one
health_ref = schedule_health_check()
{:noreply, %{state | health_check_ref: health_ref}}
{:error, reason} ->
SLog.warning(@log_category, "Health check failed: #{reason}")
# Could implement reconnection logic here
health_ref = schedule_health_check()
{:noreply, %{state | health_check_ref: health_ref}}
end
end
@impl true
def handle_info({:DOWN, _ref, :port, port, reason}, %{server_port: port} = state) do
SLog.error(@log_category, "External gRPC process died: #{inspect(reason)}")
{:stop, {:external_process_died, reason}, state}
end
@impl true
def handle_info({:EXIT, monitor_pid, exit_reason}, %{heartbeat_monitor: monitor_pid} = state) do
SLog.warning(
@log_category,
"Heartbeat monitor for #{state.id} exited with #{inspect(exit_reason)}; terminating worker"
)
{:stop, {:shutdown, exit_reason}, %{state | heartbeat_monitor: nil}}
end
@impl true
def handle_info({port, {:data, data}}, %{server_port: port} = state) do
output = to_string(data)
buffer = append_startup_output(state.python_output_buffer, output)
if log_python_output?() do
trimmed = String.trim(output)
if trimmed != "" do
SLog.info(@log_category, "gRPC server output: #{trimmed}")
end
end
{:noreply, %{state | python_output_buffer: buffer}}
end
@impl true
def handle_info({port, {:exit_status, status}}, %{server_port: port} = state) do
# DIAGNOSTIC: Drain any remaining error output from the port buffer
remaining_output = drain_port_buffer(port, 200)
last_output =
state.python_output_buffer
|> append_startup_output(remaining_output)
|> String.trim()
last_output =
if last_output == "" do
"<no output>"
else
last_output
end
SLog.error(@log_category, """
🔴 Python gRPC server exited with status #{status}
Worker: #{state.id}
Port: #{state.port}
PID: #{state.process_pid}
Last output: #{last_output}
""")
{:stop, {:grpc_server_exited, status}, state}
end
@impl true
def handle_info(msg, state) do
SLog.debug(@log_category, "Unexpected message: #{inspect(msg)}")
{:noreply, state}
end
# Define graceful shutdown timeout - configurable
# 2000ms to accommodate Python server's async shutdown
@graceful_shutdown_timeout 2000
@impl true
def terminate(reason, state) do
SLog.debug(
@log_category,
"GRPCWorker.terminate/2 called for #{state.id}, reason: #{inspect(reason)}, PID: #{state.process_pid}"
)
SLog.debug(@log_category, "gRPC worker #{state.id} terminating: #{inspect(reason)}")
emit_worker_terminated_telemetry(state, reason)
cleanup_heartbeat(state, reason)
kill_python_process(state, reason)
cleanup_ready_file(state.ready_file)
cleanup_resources(state)
:ok
end
defp emit_worker_terminated_telemetry(state, reason) do
start_time = Map.get(state.stats, :start_time, 0)
total_commands = Map.get(state.stats, :requests, 0)
:telemetry.execute(
[:snakepit, :pool, :worker, :terminated],
%{
lifetime: System.monotonic_time() - start_time,
total_commands: total_commands
},
%{
node: node(),
pool_name: state.pool_name,
worker_id: state.id,
worker_pid: self(),
reason: reason,
planned: reason in [:shutdown, :normal]
}
)
end
defp cleanup_heartbeat(state, reason) do
maybe_stop_heartbeat_monitor(state.heartbeat_monitor)
maybe_notify_test_pid(state.heartbeat_config, {:heartbeat_monitor_stopped, state.id, reason})
end
defp kill_python_process(%{process_pid: nil}, _reason), do: :ok
defp kill_python_process(state, reason) when reason in [:shutdown, :normal] do
SLog.debug(
@log_category,
"Starting graceful shutdown of external gRPC process PID: #{state.process_pid}..."
)
result =
if use_process_group_kill?(state) do
Snakepit.ProcessKiller.kill_process_group_with_escalation(
state.pgid,
@graceful_shutdown_timeout
)
else
Snakepit.ProcessKiller.kill_with_escalation(
state.process_pid,
@graceful_shutdown_timeout
)
end
case result do
:ok ->
SLog.debug(@log_category, "✅ gRPC server PID #{state.process_pid} terminated gracefully")
{:error, kill_reason} ->
SLog.warning(
@log_category,
"Failed to gracefully kill #{state.process_pid}: #{inspect(kill_reason)}"
)
end
end
defp kill_python_process(state, reason) do
SLog.warning(
@log_category,
"Non-graceful termination (#{inspect(reason)}), immediately killing PID #{state.process_pid}"
)
result =
if use_process_group_kill?(state) do
Snakepit.ProcessKiller.kill_process_group(state.pgid, :sigkill)
else
Snakepit.ProcessKiller.kill_process(state.process_pid, :sigkill)
end
case result do
:ok ->
SLog.debug(@log_category, "✅ Immediately killed gRPC server PID #{state.process_pid}")
{:error, kill_reason} ->
SLog.warning(
@log_category,
"Failed to kill #{state.process_pid}: #{inspect(kill_reason)}"
)
end
end
defp use_process_group_kill?(%{process_group?: true, pgid: pgid})
when is_integer(pgid) do
Application.get_env(:snakepit, :process_group_kill, true)
end
defp use_process_group_kill?(_state), do: false
defp cleanup_resources(state) do
disconnect_connection(state.connection)
cancel_health_check_timer(state.health_check_ref)
close_server_port(state.server_port)
GrpcStream.unregister_worker(state.id)
ProcessRegistry.unregister_worker(state.id)
end
defp cancel_health_check_timer(nil), do: :ok
defp cancel_health_check_timer(health_check_ref) do
Process.cancel_timer(health_check_ref)
end
defp close_server_port(nil), do: :ok
defp close_server_port(server_port) do
safe_close_port(server_port)
end
defp notify_pool_ready(nil, worker_id),
do:
{:error,
{:pool_handshake_failed, Error.pool_error("Pool not found", %{worker_id: worker_id})}}
defp notify_pool_ready(pool_pid, worker_id) when is_pid(pool_pid) do
GenServer.call(pool_pid, {:worker_ready, worker_id}, 30_000)
catch
:exit, {:noproc, _} ->
{:error,
{:pool_handshake_failed,
Error.pool_error("Pool not found", %{worker_id: worker_id, pool_pid: pool_pid})}}
:exit, {:shutdown, _} = reason ->
{:error, {:pool_handshake_failed, reason}}
:exit, {:killed, _} = reason ->
{:error, {:pool_handshake_failed, reason}}
:exit, {:timeout, _} = reason ->
{:error, {:pool_handshake_failed, reason}}
:exit, reason ->
{:error, {:pool_handshake_failed, reason}}
else
:ok ->
:ok
other ->
{:error, {:pool_handshake_failed, {:unexpected_reply, other}}}
end
defp maybe_start_heartbeat_monitor(state) do
config = normalize_heartbeat_config(state.heartbeat_config)
cond do
not config[:enabled] ->
maybe_stop_heartbeat_monitor(state.heartbeat_monitor)
%{state | heartbeat_config: config, heartbeat_monitor: nil}
heartbeat_monitor_running?(state.heartbeat_monitor) ->
%{state | heartbeat_config: config}
state.connection == nil ->
%{state | heartbeat_config: config}
true ->
monitor_opts = [
{:worker_pid, self()},
{:worker_id, state.id},
{:ping_interval_ms, config[:ping_interval_ms]},
{:timeout_ms, config[:timeout_ms]},
{:max_missed_heartbeats, config[:max_missed_heartbeats]},
{:initial_delay_ms, config[:initial_delay_ms]},
{:dependent, config[:dependent]},
{:ping_fun, config[:ping_fun] || build_default_ping_fun(state, config)}
]
case Snakepit.HeartbeatMonitor.start_link(monitor_opts) do
{:ok, monitor_pid} ->
maybe_notify_test_pid(config, {:heartbeat_monitor_started, state.id, monitor_pid})
%{state | heartbeat_monitor: monitor_pid, heartbeat_config: config}
{:error, {:already_started, monitor_pid}} when is_pid(monitor_pid) ->
maybe_notify_test_pid(config, {:heartbeat_monitor_started, state.id, monitor_pid})
%{state | heartbeat_monitor: monitor_pid, heartbeat_config: config}
{:error, reason} ->
SLog.error(
@log_category,
"Failed to start heartbeat monitor for #{state.id}: #{inspect(reason)}"
)
maybe_notify_test_pid(config, {:heartbeat_monitor_failed, state.id, reason})
%{state | heartbeat_monitor: nil, heartbeat_config: config}
end
end
end
defp heartbeat_monitor_running?(pid) when is_pid(pid) do
Process.alive?(pid)
end
defp heartbeat_monitor_running?(_), do: false
defp build_default_ping_fun(state, config) do
connection = state.connection
adapter = state.adapter
session_id = state.session_id
channel = connection && Map.get(connection, :channel)
fn timestamp ->
result =
cond do
function_exported?(adapter, :grpc_heartbeat, 3) ->
adapter.grpc_heartbeat(connection, session_id, config)
function_exported?(adapter, :grpc_heartbeat, 2) ->
adapter.grpc_heartbeat(connection, session_id)
heartbeat_channel_available?(channel) ->
Client.heartbeat(channel, session_id, timeout: config[:timeout_ms])
true ->
{:error,
Error.grpc_error(:no_heartbeat_transport, "No heartbeat transport available", %{
adapter: adapter,
session_id: session_id
})}
end
handle_heartbeat_response(self(), timestamp, result)
end
end
defp heartbeat_channel_available?(channel) when is_map(channel), do: true
defp heartbeat_channel_available?(channel) when is_struct(channel), do: true
defp heartbeat_channel_available?(channel) when is_reference(channel), do: true
defp heartbeat_channel_available?(channel) when is_pid(channel), do: true
defp heartbeat_channel_available?(channel) when is_binary(channel), do: byte_size(channel) > 0
defp heartbeat_channel_available?(_), do: false
defp maybe_initialize_session(connection, session_id) do
channel = connection && Map.get(connection, :channel)
if heartbeat_channel_available?(channel) do
try do
_ = Client.initialize_session(channel, session_id, %{})
:ok
rescue
exception ->
SLog.debug(
@log_category,
"Heartbeat session initialization failed: #{inspect(exception)}"
)
:error
catch
:exit, reason ->
SLog.debug(@log_category, "Heartbeat session initialization exited: #{inspect(reason)}")
:error
end
else
:error
end
end
defp register_telemetry_stream(connection, state) do
channel = connection && Map.get(connection, :channel)
if channel do
try do
worker_ctx = %{
worker_id: state.id,
pool_name: state.pool_name,
python_pid: state.process_pid
}
GrpcStream.register_worker(channel, worker_ctx)
SLog.debug(@log_category, "Registered telemetry stream for worker #{state.id}")
:ok
rescue
exception ->
SLog.warning(
@log_category,
"Failed to register telemetry stream for worker #{state.id}: #{inspect(exception)}"
)
:error
catch
:exit, reason ->
SLog.warning(
@log_category,
"Telemetry stream registration exited for worker #{state.id}: #{inspect(reason)}"
)
:error
end
else
SLog.debug(@log_category, "No channel available for telemetry stream registration")
:error
end
end
defp handle_heartbeat_response(monitor_pid, timestamp, :ok) do
Snakepit.HeartbeatMonitor.notify_pong(monitor_pid, timestamp)
:ok
end
defp handle_heartbeat_response(monitor_pid, timestamp, {:ok, %{success: success}})
when success in [true, true, 1] do
Snakepit.HeartbeatMonitor.notify_pong(monitor_pid, timestamp)
:ok
end
defp handle_heartbeat_response(_monitor_pid, _timestamp, {:ok, %{success: false} = payload}) do
{:error, {:heartbeat_failed, payload}}
end
defp handle_heartbeat_response(monitor_pid, timestamp, {:ok, _response}) do
Snakepit.HeartbeatMonitor.notify_pong(monitor_pid, timestamp)
:ok
end
defp handle_heartbeat_response(_monitor_pid, _timestamp, {:error, reason}) do
{:error, reason}
end
defp handle_heartbeat_response(_monitor_pid, _timestamp, other) do
{:error, other}
end
defp maybe_notify_test_pid(%{test_pid: pid}, message) when is_pid(pid) do
send(pid, message)
:ok
end
defp maybe_notify_test_pid(%{"test_pid" => pid}, message) when is_pid(pid) do
send(pid, message)
:ok
end
defp maybe_notify_test_pid(_config, _message), do: :ok
defp maybe_stop_heartbeat_monitor(nil), do: :ok
defp maybe_stop_heartbeat_monitor(pid) when is_pid(pid) do
if Process.alive?(pid) do
try do
GenServer.stop(pid, :shutdown)
catch
:exit, _ -> :ok
end
end
:ok
end
defp normalize_heartbeat_config(config) when is_map(config) do
defaults = default_heartbeat_config()
normalized =
Enum.reduce(@heartbeat_known_keys, %{}, fn key, acc ->
Map.put(acc, key, get_config_value(config, key, Map.get(defaults, key)))
end)
extras =
config
|> Enum.reject(fn {key, _value} -> heartbeat_known_key?(key) end)
|> Map.new()
Map.merge(extras, normalized)
end
defp normalize_heartbeat_config(_config), do: default_heartbeat_config()
defp default_heartbeat_config do
Map.merge(
@base_heartbeat_defaults,
Snakepit.Config.heartbeat_defaults(),
fn _key, _base, override -> override end
)
end
defp get_config_value(config, key, default) when is_atom(key) do
cond do
Map.has_key?(config, key) ->
Map.get(config, key)
Map.has_key?(config, Atom.to_string(key)) ->
Map.get(config, Atom.to_string(key))
true ->
default
end
end
defp heartbeat_known_key?(key) when is_atom(key) do
key in @heartbeat_known_keys
end
defp heartbeat_known_key?(key) when is_binary(key) do
key in @heartbeat_known_key_strings
end
defp disconnect_connection(nil), do: :ok
defp disconnect_connection(%{channel: %GRPC.Channel{} = channel}) do
GRPC.Stub.disconnect(channel)
rescue
_ -> :ok
catch
:exit, _ -> :ok
end
defp disconnect_connection(%{channel: channel}) when not is_nil(channel), do: :ok
defp disconnect_connection(_), do: :ok
defp get_worker_config_section(config, key) when is_map(config) and is_atom(key) do
cond do
Map.has_key?(config, key) ->
Map.get(config, key)
Map.has_key?(config, Atom.to_string(key)) ->
Map.get(config, Atom.to_string(key))
true ->
nil
end
end
defp get_worker_config_section(_config, _key), do: nil
defp encode_heartbeat_env(config) when is_map(config) do
config
|> Map.new()
|> Map.take([
:enabled,
:ping_interval_ms,
:timeout_ms,
:max_missed_heartbeats,
:initial_delay_ms,
:dependent
])
|> Enum.reduce(%{}, fn
{:ping_interval_ms, value}, acc -> Map.put(acc, "interval_ms", value)
{key, value}, acc -> Map.put(acc, to_string(key), value)
end)
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()
|> case do
%{} = map when map == %{} -> nil
map -> Jason.encode!(map)
end
end
defp normalize_adapter_env_entries(nil), do: []
defp normalize_adapter_env_entries(env) when is_map(env) do
env
|> Map.to_list()
|> normalize_adapter_env_entries()
end
defp normalize_adapter_env_entries(env) when is_list(env) do
Enum.flat_map(env, fn
{key, value} -> [{to_string(key), to_string(value)}]
key when is_binary(key) -> [{key, ""}]
key when is_atom(key) -> [{Atom.to_string(key), ""}]
_ -> []
end)
end
defp merge_with_default_adapter_env(env) do
existing = normalize_adapter_env_entries(env)
defaults = default_adapter_env()
existing_keys =
existing
|> Enum.map(fn {key, _} -> String.downcase(key) end)
|> MapSet.new()
defaults
|> Enum.reject(fn {key, _value} -> MapSet.member?(existing_keys, String.downcase(key)) end)
|> Kernel.++(existing)
end
defp default_adapter_env do
priv_python =
:code.priv_dir(:snakepit)
|> to_string()
|> Path.join("python")
repo_priv_python =
Path.join(File.cwd!(), "priv/python")
snakebridge_priv_python =
case :code.priv_dir(:snakebridge) do
{:error, _} -> nil
priv_dir -> Path.join([to_string(priv_dir), "python"])
end
path_sep = path_separator()
pythonpath =
[System.get_env("PYTHONPATH"), priv_python, repo_priv_python, snakebridge_priv_python]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.uniq()
|> Enum.join(path_sep)
interpreter =
Application.get_env(:snakepit, :python_executable) ||
System.get_env("SNAKEPIT_PYTHON") ||
GRPCPython.executable_path()
process_group_env =
if Application.get_env(:snakepit, :process_group_kill, true) and
Snakepit.ProcessKiller.process_group_supported?() do
[{"SNAKEPIT_PROCESS_GROUP", "1"}]
else
[]
end
base =
[]
|> maybe_cons("PYTHONPATH", pythonpath)
|> maybe_cons("SNAKEPIT_PYTHON", interpreter)
extra_env =
Snakepit.PythonRuntime.config()
|> Map.get(:extra_env, %{})
|> normalize_adapter_env_entries()
base ++ process_group_env ++ extra_env ++ Snakepit.PythonRuntime.runtime_env()
end
defp maybe_cons(acc, _key, value) when value in [nil, ""], do: acc
defp maybe_cons(acc, key, value), do: [{key, value} | acc]
defp path_separator do
case :os.type() do
{:win32, _} -> ";"
_ -> ":"
end
end
defp elixir_to_python_level(:debug), do: "debug"
defp elixir_to_python_level(:info), do: "info"
defp elixir_to_python_level(:warning), do: "warning"
defp elixir_to_python_level(:error), do: "error"
defp elixir_to_python_level(:none), do: "none"
defp elixir_to_python_level(_), do: "error"
defp maybe_put_heartbeat_env(entries, nil), do: entries
defp maybe_put_heartbeat_env(entries, json),
do: maybe_put_env(entries, "SNAKEPIT_HEARTBEAT_CONFIG", json)
defp maybe_put_ready_file_env(entries, ready_file),
do: maybe_put_env(entries, "SNAKEPIT_READY_FILE", ready_file)
defp maybe_put_log_level_env(entries) do
level = Application.get_env(:snakepit, :log_level, :error)
maybe_put_env(entries, "SNAKEPIT_LOG_LEVEL", elixir_to_python_level(level))
end
defp maybe_put_env(entries, _key, value) when value in [nil, ""], do: entries
defp maybe_put_env(entries, key, value) do
filtered =
Enum.reject(entries, fn {existing_key, _value} ->
String.downcase(existing_key) == String.downcase(key)
end)
[{key, value} | filtered]
end
defp to_env_tuple({key, value}) do
{String.to_charlist(key), String.to_charlist(value)}
end
# CRITICAL FIX: Defensive port cleanup that handles all exit scenarios
defp safe_close_port(port) do
Port.close(port)
rescue
# ArgumentError is raised if the port is already closed
ArgumentError -> :ok
# Catch any other exceptions
_ -> :ok
catch
# Handle exits (e.g., from brutal :kill)
:exit, _ -> :ok
# Handle throws
:throw, _ -> :ok
end
# Private functions
##
# defp wait_for_server_ready(port, expected_port, timeout) do
# receive do
# {^port, {:data, data}} ->
# output = to_string(data)
# cond do
# String.contains?(output, "gRPC Bridge started") ->
# SLog.info(@log_category, "gRPC worker started successfully on port #{expected_port}")
# SLog.info(@log_category, "gRPC server output: #{String.trim(output)}")
# {:ok, port}
# true ->
# # Keep waiting for the ready message
# wait_for_server_ready(port, expected_port, timeout)
# end
# {:DOWN, _ref, :port, ^port, reason} ->
# {:error, "gRPC server crashed during startup: #{inspect(reason)}"}
# after
# timeout ->
# Port.close(port)
# {:error, "gRPC server failed to start within #{timeout}ms"}
# end
# end
# Drain remaining output from port buffer to capture error messages
defp drain_port_buffer(port, timeout) do
drain_port_buffer(port, timeout, [])
end
defp drain_port_buffer(port, timeout, acc) do
receive do
{^port, {:data, data}} ->
output = to_string(data)
drain_port_buffer(port, timeout, [output | acc])
after
timeout ->
# No more data, return accumulated output
acc
|> Enum.reverse()
|> Enum.join("")
|> String.trim()
end
end
defp wait_for_server_ready(port, ready_file, timeout, output_buffer \\ "") do
deadline = System.monotonic_time(:millisecond) + timeout
wait_for_server_ready_loop(port, ready_file, deadline, timeout, output_buffer)
end
defp wait_for_server_ready_loop(port, ready_file, deadline, timeout, output_buffer) do
case read_ready_file(ready_file) do
{:ok, actual_port} ->
cleanup_ready_file(ready_file)
{:ok, actual_port}
{:error, reason} ->
cleanup_ready_file(ready_file)
log_startup_output(output_buffer)
SLog.error(@log_category, "Failed to read readiness file: #{inspect(reason)}")
{:error, {:ready_file, reason}}
:not_ready ->
remaining = max(deadline - System.monotonic_time(:millisecond), 0)
if remaining == 0 do
cleanup_ready_file(ready_file)
log_startup_output(output_buffer)
SLog.error(
@log_category,
"Timeout waiting for Python gRPC server to start after #{timeout}ms"
)
{:error, :timeout}
else
receive do
{^port, {:data, data}} ->
output = to_string(data)
output_buffer = append_startup_output(output_buffer, output)
if String.trim(output) != "" do
SLog.debug(
@log_category,
"Python server output during startup: #{String.trim(output)}"
)
end
wait_for_server_ready_loop(port, ready_file, deadline, timeout, output_buffer)
{^port, {:exit_status, status}} ->
cleanup_ready_file(ready_file)
case status do
0 ->
SLog.debug(
@log_category,
"Python gRPC server process exited with status 0 during startup (shutdown)"
)
{:error, :shutdown}
143 ->
SLog.debug(
@log_category,
"Python gRPC server process exited with status 143 during startup (shutdown)"
)
{:error, :shutdown}
_ ->
log_startup_output(output_buffer)
SLog.error(
@log_category,
"Python gRPC server process exited with status #{status} during startup"
)
{:error, {:exit_status, status}}
end
{:DOWN, _ref, :port, ^port, reason} ->
cleanup_ready_file(ready_file)
log_startup_output(output_buffer)
SLog.error(
@log_category,
"Python gRPC server port died during startup: #{inspect(reason)}"
)
{:error, {:port_died, reason}}
after
min(remaining, 50) ->
wait_for_server_ready_loop(port, ready_file, deadline, timeout, output_buffer)
end
end
end
end
defp read_ready_file(path) do
case File.read(path) do
{:ok, contents} ->
case Integer.parse(String.trim(contents)) do
{port, _} -> {:ok, port}
:error -> {:error, {:invalid_ready_file, contents}}
end
{:error, :enoent} ->
:not_ready
{:error, reason} ->
{:error, reason}
end
end
defp cleanup_ready_file(path) do
File.rm(path)
:ok
rescue
_ -> :ok
end
defp append_startup_output(buffer, output) do
max_bytes = 4096
buffer = buffer <> output
if byte_size(buffer) > max_bytes do
String.slice(buffer, byte_size(buffer) - max_bytes, max_bytes)
else
buffer
end
end
defp log_startup_output(buffer) do
trimmed = String.trim(buffer)
if trimmed != "" do
SLog.error(@log_category, "Python server output during startup:\n#{trimmed}")
end
end
defp log_python_output? do
Application.get_env(:snakepit, :log_python_output, false)
end
defp schedule_health_check do
# Health check every 30 seconds
Process.send_after(self(), :health_check, 30_000)
end
defp make_health_check(state) do
case Client.health(state.connection.channel, inspect(self())) do
{:ok, health_response} ->
{:ok, health_response}
{:error, reason} ->
{:error,
Error.grpc_error(:health_check_failed, "Health check failed", %{
worker_id: state.id,
reason: reason
})}
end
end
defp make_info_call(state) do
case Client.get_info(state.connection.channel) do
{:ok, info_response} ->
{:ok, info_response}
end
end
defp update_stats(state, result) do
stats =
case result do
:success ->
%{state.stats | requests: state.stats.requests + 1}
:error ->
%{
state.stats
| requests: state.stats.requests + 1,
errors: state.stats.errors + 1
}
end
%{state | stats: stats}
end
defp instrument_execute(kind, state, command, args, timeout, fun) when is_function(fun, 1) do
correlation_id = correlation_id_from(args)
metadata = base_execute_metadata(kind, state, command, args, correlation_id, timeout)
span_name = otel_span_name(kind, command)
span_attributes = otel_start_attributes(state, command, args, correlation_id, timeout)
:telemetry.span([:snakepit, :grpc_worker, kind], metadata, fn ->
Tracer.with_span span_name, %{attributes: span_attributes, kind: :client} do
start_time = System.monotonic_time()
result = fun.(args)
duration_native = System.monotonic_time() - start_time
duration_ms = System.convert_time_unit(duration_native, :native, :millisecond)
measurements =
%{duration_ms: duration_ms, executions: 1}
|> maybe_track_error_measurement(result)
stop_metadata = build_stop_metadata(metadata, result)
Tracer.set_attributes(otel_stop_attributes(result, duration_ms, stop_metadata))
maybe_set_span_status(result, stop_metadata)
{result, measurements, stop_metadata}
end
end)
end
defp build_stop_metadata(metadata, {:error, {kind, reason}}) do
metadata
|> Map.put(:status, :error)
|> Map.put(:error_kind, kind)
|> Map.put(:error, reason)
end
defp build_stop_metadata(metadata, {:error, reason}) do
metadata
|> Map.put(:status, :error)
|> Map.put(:error, reason)
end
defp build_stop_metadata(metadata, _result) do
Map.put(metadata, :status, :ok)
end
defp maybe_track_error_measurement(measurements, {:error, _reason}) do
Map.put(measurements, :errors, 1)
end
defp maybe_track_error_measurement(measurements, _result), do: measurements
defp ensure_correlation(nil) do
id = Correlation.new_id()
%{"correlation_id" => id, correlation_id: id}
end
defp ensure_correlation(args) when is_map(args) do
existing =
Map.get(args, :correlation_id) ||
Map.get(args, "correlation_id")
id = Correlation.ensure(existing)
args
|> Map.put(:correlation_id, id)
|> Map.put("correlation_id", id)
end
defp ensure_correlation(args) when is_list(args) do
args
|> Map.new()
|> ensure_correlation()
end
defp correlation_id_from(%{} = args) do
args
|> Map.get(:correlation_id)
|> case do
nil -> Map.get(args, "correlation_id")
value -> value
end
|> Correlation.ensure()
end
defp base_execute_metadata(kind, state, command, args, correlation_id, timeout) do
session_id =
Map.get(args, :session_id) ||
Map.get(args, "session_id") ||
state.session_id
%{
operation: kind,
worker_id: state.id,
worker_pid: self(),
command: command,
adapter: adapter_name(state.adapter),
adapter_module: state.adapter,
pool: state.pool_name,
session_id: session_id,
correlation_id: correlation_id,
timeout_ms: timeout,
span_kind: :client,
rpc_system: :grpc,
telemetry_source: :snakepit_grpc_worker
}
end
defp otel_span_name(kind, command) do
operation = kind |> Atom.to_string() |> String.replace("_", "-")
"snakepit.grpc.#{operation}.#{command}"
end
defp otel_start_attributes(state, command, args, correlation_id, timeout) do
session_id = Map.get(args, :session_id) || Map.get(args, "session_id") || state.session_id
[
{"snakepit.worker.id", state.id},
{"snakepit.pool", pool_attribute(state.pool_name)},
{"snakepit.command", command},
{"snakepit.session_id", session_id},
{"snakepit.correlation_id", correlation_id},
{"snakepit.timeout_ms", timeout}
]
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
end
defp otel_stop_attributes(result, duration_ms, metadata) do
[
{"snakepit.grpc.duration_ms", duration_ms},
{"snakepit.grpc.status", metadata[:status]},
{"snakepit.grpc.error", format_reason(metadata[:error] || error_from_result(result))},
{"snakepit.grpc.error_kind", metadata[:error_kind]}
]
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
end
defp maybe_set_span_status({:error, _}, metadata) do
reason = format_reason(metadata[:error]) || "snakepit.grpc.error"
Tracer.set_status(:error, reason)
end
defp maybe_set_span_status(_result, _metadata), do: :ok
defp pool_attribute(nil), do: nil
defp pool_attribute(pool) when is_atom(pool), do: Atom.to_string(pool)
defp pool_attribute(pool), do: inspect(pool)
defp format_reason(nil), do: nil
defp format_reason({kind, reason}), do: "#{inspect(kind)}: #{inspect(reason)}"
defp format_reason(reason), do: inspect(reason)
defp error_from_result({:error, {kind, reason}}), do: {kind, reason}
defp error_from_result({:error, reason}), do: reason
defp error_from_result(_), do: nil
defp adapter_name(module) when is_atom(module) do
module
|> Atom.to_string()
|> String.replace_prefix("Elixir.", "")
end
defp adapter_name(other), do: inspect(other)
end