Packages
snakepit
0.6.10
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/adapters/grpc_python.ex
defmodule Snakepit.Adapters.GRPCPython do
@moduledoc """
gRPC-based Python adapter for Snakepit.
This adapter replaces the stdin/stdout protocol with gRPC for better performance,
streaming capabilities, and more robust communication.
## Configuration
Application.put_env(:snakepit, :adapter_module, Snakepit.Adapters.GRPCPython)
Application.put_env(:snakepit, :grpc_config, %{
base_port: 50051,
port_range: 100 # Will use ports 50051-50151
})
## Features
- Native streaming support for progressive results
- HTTP/2 multiplexing for concurrent requests
- Built-in health checks and monitoring
- Better error handling with gRPC status codes
- Binary data support without base64 encoding
## Streaming Examples
# Stream ML inference results
Snakepit.execute_stream("batch_inference", %{
batch_items: ["image1.jpg", "image2.jpg", "image3.jpg"]
}, fn chunk ->
IO.puts("Processed: \#{chunk["item"]} - \#{chunk["confidence"]}")
end)
# Stream large dataset processing with progress
Snakepit.execute_stream("process_large_dataset", %{
total_rows: 10000,
chunk_size: 500
}, fn chunk ->
IO.puts("Progress: \#{chunk["progress_percent"]}%")
end)
"""
@behaviour Snakepit.Adapter
require Logger
alias Snakepit.Logger, as: SLog
alias Snakepit.Bridge.ToolChunk
@impl true
def executable_path do
# Priority order for finding Python:
# 1. Explicit configuration (highest priority)
# 2. Environment variable
# 3. Auto-detect virtual environment (dev convenience)
# 4. System Python (fallback for production)
Application.get_env(:snakepit, :python_executable) ||
System.get_env("SNAKEPIT_PYTHON") ||
find_venv_python() ||
System.find_executable("python3") || System.find_executable("python")
end
defp find_venv_python do
candidates = [
# Project root venv
".venv/bin/python3",
# Parent directory venv (for examples/ subdirectory)
"../.venv/bin/python3",
# VIRTUAL_ENV variable (if venv activated)
System.get_env("VIRTUAL_ENV") &&
Path.join([System.get_env("VIRTUAL_ENV"), "bin", "python3"])
]
Enum.find_value(candidates, fn path ->
path && File.exists?(Path.expand(path)) && Path.expand(path)
end)
end
@impl true
def script_path do
# Get the application directory
app_dir = Application.app_dir(:snakepit)
# Check if we should use threaded server based on adapter args
# Check both old pool_config format and new pools format
pool_config = Application.get_env(:snakepit, :pool_config, %{})
pools_config = Application.get_env(:snakepit, :pools, [])
# Get adapter args from either source
adapter_args = Map.get(pool_config, :adapter_args, [])
# Also check if any pool is configured with --max-workers in pools config
has_max_workers =
Enum.any?(adapter_args, fn arg ->
is_binary(arg) and String.contains?(arg, "--max-workers")
end) or
Enum.any?(pools_config, fn pool_cfg ->
pool_adapter_args = Map.get(pool_cfg, :adapter_args, [])
Enum.any?(pool_adapter_args, fn arg ->
is_binary(arg) and String.contains?(arg, "--max-workers")
end)
end)
# Use threaded server if --max-workers is specified (indicates threaded mode)
script_name =
if has_max_workers do
"grpc_server_threaded.py"
else
"grpc_server.py"
end
Path.join([app_dir, "priv", "python", script_name])
end
@impl true
def script_args do
# Check if custom adapter args are provided in pool config
pool_config = Application.get_env(:snakepit, :pool_config, %{})
adapter_args = Map.get(pool_config, :adapter_args, nil)
if adapter_args do
# Use custom adapter args if provided
adapter_args
else
# Default to ShowcaseAdapter - fully functional reference implementation
# For custom adapters, set pool_config.adapter_args or use TemplateAdapter as starting point
["--adapter", "snakepit_bridge.adapters.showcase.ShowcaseAdapter"]
end
end
@impl true
def supported_commands do
# Check if we're using DSPy adapter
pool_config = Application.get_env(:snakepit, :pool_config, %{})
adapter_args = Map.get(pool_config, :adapter_args, [])
base_commands = [
"ping",
"echo",
"compute",
"info",
# Enhanced Python API
"call",
"store",
"retrieve",
"list_stored",
"delete_stored"
]
streaming_commands = [
"ping_stream",
"batch_inference",
"process_large_dataset",
"tail_and_analyze"
]
dspy_commands = [
"configure_lm",
"create_program",
"create_gemini_program",
"execute_program",
"execute_gemini_program",
"list_programs",
"delete_program",
"get_stats",
"cleanup",
"reset_state",
"get_program_info",
"cleanup_session",
"shutdown"
]
# Include DSPy commands if using DSPy adapter
if Enum.any?(adapter_args, &String.contains?(&1, "dspy")) do
base_commands ++ streaming_commands ++ dspy_commands
else
base_commands ++ streaming_commands
end
end
@impl true
def validate_command(command, _args) do
supported = supported_commands()
if command in supported do
:ok
else
{:error, "Unsupported command: #{command}"}
end
end
# Optional callbacks for gRPC-specific functionality
@doc """
Get the gRPC port for this adapter instance.
ROBUST FIX: Use port 0 to let the OS dynamically assign an available port.
This completely eliminates:
- Port collision races
- TIME_WAIT conflicts
- Manual port range management
- Port leak tracking
Python will bind to an OS-assigned port and report it back via GRPC_READY.
"""
def get_port do
# Port 0 = "OS, please assign me any available port"
0
end
@doc """
Check if gRPC dependencies are available at runtime.
"""
def grpc_available? do
Code.ensure_loaded?(GRPC.Channel) and Code.ensure_loaded?(Protobuf)
end
@doc """
Initialize gRPC connection for the worker.
Called by GRPCWorker during initialization.
CRITICAL FIX: This includes retry logic to handle the race condition where
the Python process signals GRPC_READY before the OS socket is fully bound
and accepting connections. This is common in polyglot systems where the
external process startup timing is non-deterministic.
"""
def init_grpc_connection(port) do
unless grpc_available?() do
{:error, :grpc_not_available}
else
# Retry up to 5 times with exponential backoff + jitter
# This handles the startup race condition gracefully
retry_connect(port, 5, 50, 1)
end
end
# Exponential backoff with jitter to prevent thundering herd during concurrent worker startup.
# base_delay: initial retry delay (50ms)
# backoff: multiplier for exponential growth (doubles each retry)
defp retry_connect(_port, 0, _base_delay, _backoff) do
# All retries exhausted
SLog.error("gRPC connection failed after all retries")
{:error, :connection_failed_after_retries}
end
defp retry_connect(port, retries_left, base_delay, backoff) do
case Snakepit.GRPC.Client.connect(port) do
{:ok, channel} ->
# Connection successful!
SLog.debug("gRPC connection established to port #{port}")
{:ok, %{channel: channel, port: port}}
{:error, reason} when reason in [:connection_refused, :unavailable, :internal] ->
# Socket not ready yet - retry with exponential backoff + jitter
delay = min(base_delay * backoff, 500)
# Add ±25% jitter to prevent synchronized retries (thundering herd)
jitter = :rand.uniform(div(max(delay, 4), 4))
actual_delay = delay + jitter
SLog.debug(
"gRPC connection to port #{port} #{reason}. " <>
"Retrying in #{actual_delay}ms... (#{retries_left - 1} retries left)"
)
# OTP-idiomatic non-blocking wait
receive do
after
actual_delay -> :ok
end
retry_connect(port, retries_left - 1, base_delay, backoff * 2)
{:error, reason} ->
# For any other error, fail immediately (no retry)
SLog.error(
"gRPC connection to port #{port} failed with unexpected reason: #{inspect(reason)}"
)
{:error, reason}
end
end
@doc """
Execute a command via gRPC.
"""
def grpc_execute(connection, session_id, command, args, timeout \\ 30_000) do
unless grpc_available?() do
{:error, :grpc_not_available}
else
Snakepit.GRPC.Client.execute_tool(
connection.channel,
session_id,
command,
args,
timeout: timeout
)
end
end
@doc """
Execute a streaming command via gRPC with callback.
"""
def grpc_execute_stream(connection, session_id, command, args, callback_fn, timeout \\ 300_000)
when is_function(callback_fn, 1) do
unless grpc_available?() do
{:error, :grpc_not_available}
else
connection.channel
|> Snakepit.GRPC.Client.execute_streaming_tool(
session_id,
command,
args,
timeout: timeout
)
|> consume_stream(callback_fn)
end
end
@doc """
Check if this adapter uses gRPC.
Returns true only if gRPC dependencies are actually available.
"""
def uses_grpc?, do: grpc_available?()
# Compatibility functions for existing adapter interface
@impl true
def prepare_args(_command, args), do: args
@impl true
def process_response(_command, response), do: {:ok, response}
@impl true
# 5 minutes for ML inference
def command_timeout("batch_inference", _args), do: 300_000
# 10 minutes for large datasets
def command_timeout("process_large_dataset", _args), do: 600_000
# Default 30 seconds
def command_timeout(_command, _args), do: 30_000
defp consume_stream({:ok, stream}, callback_fn) do
Enum.reduce_while(stream, :ok, fn message, _acc ->
process_stream_message(message, callback_fn)
end)
end
defp consume_stream({:error, reason}, _callback_fn), do: {:error, reason}
defp process_stream_message({:ok, %ToolChunk{} = chunk}, callback_fn) do
deliver_chunk(chunk, callback_fn)
end
defp process_stream_message(%ToolChunk{} = chunk, callback_fn) do
deliver_chunk(chunk, callback_fn)
end
defp process_stream_message({:error, reason}, _callback_fn), do: {:halt, {:error, reason}}
defp process_stream_message(other, _callback_fn),
do: {:halt, {:error, {:unexpected_stream_item, other}}}
defp deliver_chunk(%ToolChunk{} = chunk, callback_fn) do
payload = build_payload(chunk)
try do
case callback_fn.(payload) do
:halt -> {:halt, :ok}
{:halt, reason} -> {:halt, {:error, reason}}
_ -> {:cont, :ok}
end
rescue
exception ->
{:halt, {:error, {:callback_exception, exception, __STACKTRACE__}}}
end
end
defp build_payload(%ToolChunk{} = chunk) do
base =
case decode_chunk_data(chunk.data) do
:empty -> %{}
{:ok, %{} = map} -> map
{:ok, value} -> %{"data" => value}
{:error, raw} -> %{"raw_data_base64" => raw}
end
base
|> Map.put("is_final", chunk.is_final)
|> maybe_with_metadata(chunk.metadata)
end
defp decode_chunk_data(data) when is_binary(data) do
if data == "" do
:empty
else
case Jason.decode(data) do
{:ok, decoded} -> {:ok, decoded}
{:error, _} -> {:error, Base.encode64(data)}
end
end
end
defp decode_chunk_data(_), do: :empty
defp maybe_with_metadata(payload, metadata) when metadata in [%{}, nil], do: payload
defp maybe_with_metadata(payload, metadata) do
metadata
|> Map.new()
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
|> case do
%{} = cleaned when map_size(cleaned) > 0 -> Map.put(payload, "_metadata", cleaned)
_ -> payload
end
end
end