Current section

Files

Jump to
snakepit guides timeout-configuration-guide.md
Raw

guides/timeout-configuration-guide.md

# Timeout Configuration Guide
Snakepit v0.8.8 introduces a unified timeout architecture designed for reliability in production deployments. This guide covers timeout profiles, deadline propagation, and configuration strategies for different workloads.
---
## Table of Contents
1. [Overview](#overview)
2. [Timeout Profiles](#timeout-profiles)
3. [How Timeouts Work](#how-timeouts-work)
4. [Configuration Reference](#configuration-reference)
5. [Common Scenarios](#common-scenarios)
6. [Debugging Timeout Issues](#debugging-timeout-issues)
7. [Migration Guide](#migration-guide)
---
## Overview
### The Problem
Prior to v0.8.8, Snakepit had fragmented timeout configuration with 7+ independent timeout keys that didn't coordinate:
| Issue | Symptom |
|-------|---------|
| `pool_request_timeout` vs `grpc_command_timeout` confusion | Unclear which is outer, which is inner |
| Queue wait consumed budget invisibly | Inner timeouts didn't account for queue time |
| GenServer.call timeouts fired before inner timeouts | Unhandled exits instead of structured errors |
### The Solution
Snakepit now uses a **single-budget, derived deadlines** architecture:
1. **One top-level timeout budget** set at request entry
2. **Deadline propagation** tracks remaining time through the stack
3. **Inner timeouts derived** from remaining budget minus safety margins
4. **Profile-based defaults** for different deployment scenarios
---
## Timeout Profiles
Profiles provide sensible defaults for common deployment scenarios. Configure via:
```elixir
config :snakepit, timeout_profile: :production
```
### Profile Comparison
| Profile | default_timeout | stream_timeout | queue_timeout | Use Case |
|---------|-----------------|----------------|---------------|----------|
| `:balanced` | 300s (5m) | 900s (15m) | 10s | General purpose, default |
| `:production` | 300s (5m) | 900s (15m) | 10s | Production deployments |
| `:production_strict` | 60s | 300s (5m) | 5s | Latency-sensitive APIs |
| `:development` | 900s (15m) | 3600s (60m) | 60s | Local development, debugging |
| `:ml_inference` | 900s (15m) | 3600s (60m) | 60s | ML model inference |
| `:batch` | 3600s (60m) || 300s (5m) | Batch processing jobs |
### Profile Selection Guidelines
| Workload Type | Recommended Profile | Rationale |
|---------------|---------------------|-----------|
| Web API backends | `:production_strict` | Fast failure for user-facing requests |
| Background jobs | `:batch` | Long-running operations need patience |
| ML inference | `:ml_inference` | Model loading and inference are slow |
| Development | `:development` | Generous timeouts for debugging |
| Mixed workloads | `:balanced` | Good defaults for most cases |
### Using Profiles
```elixir
# config/runtime.exs
# Production API server
config :snakepit, timeout_profile: :production_strict
# ML inference service
config :snakepit, timeout_profile: :ml_inference
# Batch processing worker
config :snakepit, timeout_profile: :batch
```
---
## How Timeouts Work
### The Timeout Stack
Requests flow through multiple layers, each with its own timeout:
```
┌─────────────────────────────────────────────────────────────────┐
│ User Code: Snakepit.execute("cmd", args, timeout: 60_000) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Pool Layer │
│ ├─ GenServer.call timeout: 60_000 │
│ ├─ Queue wait (if workers busy): up to queue_timeout │
│ └─ Deadline stored: now + 60_000 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Worker Layer │
│ ├─ GenServer.call timeout: remaining - 1000ms margin │
│ └─ Forwards to gRPC adapter │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ gRPC Layer │
│ ├─ gRPC call timeout: remaining - 1200ms total margins │
│ └─ Actual Python execution │
└─────────────────────────────────────────────────────────────────┘
```
### Margin Formula
Inner timeouts are derived from the total budget minus safety margins:
```
rpc_timeout = total_timeout - worker_call_margin_ms - pool_reply_margin_ms
```
| Margin | Default | Purpose |
|--------|---------|---------|
| `worker_call_margin_ms` | 1000ms | GenServer.call overhead to worker |
| `pool_reply_margin_ms` | 200ms | Pool reply processing overhead |
**Example**: With a 60-second total budget:
- Total: 60,000ms
- Worker margin: -1,000ms
- Pool margin: -200ms
- **RPC timeout: 58,800ms**
This ensures inner timeouts expire *before* outer GenServer.call timeouts, producing structured `{:error, %Snakepit.Error{}}` returns instead of unhandled exits.
### Deadline Propagation
When a request enters the pool, a deadline is computed and stored:
```elixir
# Inside Pool.execute/3
deadline_ms = System.monotonic_time(:millisecond) + timeout
opts_with_deadline = Keyword.put(opts, :deadline_ms, deadline_ms)
```
As the request moves through the stack:
1. **Queue handler** uses `effective_queue_timeout_ms/2` to respect deadline
2. **Worker execution** uses `derive_rpc_timeout_from_opts/2` to compute remaining budget
3. **All layers** return structured errors instead of crashing on timeout
### Queue-Aware Timeouts
If a request waits in queue, that time is subtracted from the budget:
```elixir
# Request with 60s budget waits 5s in queue
# Remaining budget for execution: 55s (minus margins)
```
This prevents the common bug where queue wait + execution time exceeds the user's expected timeout.
---
## Configuration Reference
### Profile-Based Configuration (Recommended)
```elixir
# config/runtime.exs
config :snakepit,
timeout_profile: :production,
# Optional: customize margins
worker_call_margin_ms: 1000,
pool_reply_margin_ms: 200
```
### Explicit Timeout Configuration
Override profile defaults with explicit values:
```elixir
config :snakepit,
timeout_profile: :production,
# These override profile-derived values
pool_request_timeout: 120_000, # 2 minutes
pool_streaming_timeout: 600_000, # 10 minutes
pool_queue_timeout: 15_000, # 15 seconds
grpc_command_timeout: 90_000, # 90 seconds
grpc_worker_execute_timeout: 95_000 # 95 seconds
```
### Complete Timeout Options
| Option | Default | Layer | Description |
|--------|---------|-------|-------------|
| `timeout_profile` | `:balanced` | Global | Profile to use for defaults |
| `pool_request_timeout` | Profile-derived | Pool | GenServer.call timeout for execute |
| `pool_streaming_timeout` | Profile-derived | Pool | GenServer.call timeout for streaming |
| `pool_queue_timeout` | Profile-derived | Pool | Max time request waits in queue |
| `checkout_timeout` | Profile-derived | Pool | Worker checkout for streaming |
| `pool_startup_timeout` | 10,000ms | Pool | Worker startup timeout |
| `pool_await_ready_timeout` | 15,000ms | Pool | Wait for pool initialization |
| `grpc_worker_execute_timeout` | Profile-derived | Worker | GenServer.call to GRPCWorker |
| `grpc_worker_stream_timeout` | 300,000ms | Worker | Streaming GenServer.call |
| `grpc_command_timeout` | Profile-derived | Adapter | gRPC call timeout |
| `grpc_batch_inference_timeout` | 300,000ms | Adapter | Batch inference operations |
| `grpc_large_dataset_timeout` | 600,000ms | Adapter | Large dataset processing |
| `grpc_server_ready_timeout` | 30,000ms | Worker | Python server readiness |
| `worker_ready_timeout` | 30,000ms | Worker | Worker ready notification |
| `graceful_shutdown_timeout_ms` | 6,000ms | Worker | Python process shutdown |
| `worker_call_margin_ms` | 1,000ms | Margin | Worker GenServer.call overhead |
| `pool_reply_margin_ms` | 200ms | Margin | Pool reply overhead |
### Per-Call Timeout Override
Override timeouts for individual calls:
```elixir
# Use default from profile
Snakepit.execute("fast_command", %{})
# Override for slow operation
Snakepit.execute("slow_inference", %{model: "large"}, timeout: 300_000)
# Streaming with custom timeout
Snakepit.execute_stream("generate", %{}, callback, timeout: 600_000)
```
---
## Common Scenarios
### Scenario 1: LLM API Calls (60+ seconds)
**Problem**: Default timeouts are too short for LLM inference.
**Solution**: Use `:ml_inference` profile or explicit config:
```elixir
# Option A: Profile-based
config :snakepit, timeout_profile: :ml_inference
# Option B: Explicit timeouts
config :snakepit,
pool_request_timeout: 300_000,
grpc_command_timeout: 280_000,
grpc_worker_execute_timeout: 290_000
```
**Per-call override**:
```elixir
Snakepit.execute("llm_generate", %{prompt: prompt}, timeout: 120_000)
```
### Scenario 2: Fast API with Strict SLAs
**Problem**: Need fast failure for user-facing requests.
**Solution**: Use `:production_strict` profile:
```elixir
config :snakepit, timeout_profile: :production_strict
```
This gives you:
- 60-second default timeout
- 5-second queue timeout (fail fast if pool is saturated)
- Quick feedback to users
### Scenario 3: Batch Processing Jobs
**Problem**: Jobs run for hours, need infinite streaming timeout.
**Solution**: Use `:batch` profile:
```elixir
config :snakepit, timeout_profile: :batch
```
This gives you:
- 60-minute default timeout
- Infinite streaming timeout
- 5-minute queue tolerance
### Scenario 4: Mixed Workloads
**Problem**: Same pool handles fast and slow operations.
**Solution**: Use `:balanced` profile with per-call overrides:
```elixir
config :snakepit, timeout_profile: :balanced
# Fast operations use default
Snakepit.execute("lookup", %{id: id})
# Slow operations override
Snakepit.execute("batch_process", %{data: data}, timeout: 600_000)
```
### Scenario 5: Pool Initialization Takes Too Long
**Problem**: Starting 50+ workers with heavy model loading.
**Solution**: Increase startup timeouts:
```elixir
config :snakepit,
pool_startup_timeout: 120_000, # 2 min per worker
pool_await_ready_timeout: 600_000, # 10 min total
grpc_server_ready_timeout: 120_000 # 2 min for Python ready
```
### Scenario 6: Workers Killed During Shutdown
**Problem**: Python cleanup takes longer than 6 seconds.
**Solution**: Increase graceful shutdown timeout:
```elixir
config :snakepit,
graceful_shutdown_timeout_ms: 15_000 # 15 seconds
```
**Note**: This must be >= Python's shutdown envelope: `server.stop(2s) + wait_for_termination(3s) = 5s`.
---
## Debugging Timeout Issues
### Enable Debug Logging
```elixir
config :snakepit,
log_level: :debug,
log_categories: %{
pool: :debug,
grpc: :debug,
worker: :debug
}
```
### Identify Which Timeout Fired
| Log Pattern | Timeout Type |
|-------------|--------------|
| `** (exit) {:timeout, {GenServer, :call, ...}` | GenServer.call timeout |
| `gRPC error: %GRPC.RPCError{status: 4...}` | gRPC DEADLINE_EXCEEDED |
| `Request timed out after Xms` | Pool queue timeout |
| `Timeout waiting for Python gRPC server` | Server ready timeout |
| `Pool execute timed out` | Pool-level structured timeout |
### Use Telemetry
```elixir
:telemetry.attach("timeout-debug", [:snakepit, :request, :executed],
fn _name, measurements, metadata, _config ->
if measurements[:duration_us] > 30_000_000 do # > 30s
Logger.warning("Slow request: #{metadata.command} took #{measurements[:duration_us] / 1_000}ms")
end
end, nil)
```
### Check Pool Stats
```elixir
iex> Snakepit.get_stats()
%{
requests: 15432,
queued: 5, # Requests waiting in queue
queue_timeouts: 12, # Queue timeout count
pool_saturated: 3, # Times pool was at capacity
...
}
```
High `queue_timeouts` indicates you need either:
- More workers (`pool_size`)
- Higher `pool_queue_timeout`
- Faster Python operations
### Verify Timeout Derivation
```elixir
iex> alias Snakepit.Defaults
# Check current profile
iex> Defaults.timeout_profile()
:balanced
# Check derived values
iex> Defaults.default_timeout()
300_000
iex> Defaults.rpc_timeout(60_000)
58_800 # 60_000 - 1000 - 200
```
---
## Migration Guide
### From v0.8.7 and Earlier
The timeout architecture is **fully backward compatible**. Existing configurations continue to work:
```elixir
# This still works in v0.8.8+
config :snakepit,
pool_request_timeout: 60_000,
grpc_command_timeout: 30_000
```
**Behavior changes**:
- When explicit timeouts are set, they take precedence over profile-derived values
- When not set, values now derive from the active profile (default: `:balanced`)
- Default values are similar to previous versions for `:balanced` profile
### Recommended Migration Path
1. **Test with defaults**: Remove explicit timeout config, use profile defaults
2. **Select appropriate profile**: Choose based on workload type
3. **Fine-tune if needed**: Override specific values that don't fit
```elixir
# Before (v0.8.7)
config :snakepit,
pool_request_timeout: 300_000,
pool_streaming_timeout: 900_000,
pool_queue_timeout: 10_000,
grpc_command_timeout: 280_000
# After (v0.8.8+) - equivalent behavior
config :snakepit, timeout_profile: :balanced
```
### Breaking Changes
None. All existing configuration keys are honored and take precedence over profile-derived values.
---
## API Reference
### Snakepit.Defaults Functions
| Function | Returns | Description |
|----------|---------|-------------|
| `timeout_profiles/0` | `map()` | All available timeout profiles |
| `timeout_profile/0` | `atom()` | Currently configured profile |
| `default_timeout/0` | `timeout()` | Profile's default timeout |
| `stream_timeout/0` | `timeout()` | Profile's streaming timeout |
| `queue_timeout/0` | `timeout()` | Profile's queue timeout |
| `rpc_timeout/1` | `timeout()` | Derive RPC timeout from total budget |
| `worker_call_margin_ms/0` | `integer()` | Worker GenServer.call margin |
| `pool_reply_margin_ms/0` | `integer()` | Pool reply margin |
### Snakepit.Pool Functions
| Function | Returns | Description |
|----------|---------|-------------|
| `get_default_timeout_for_call/3` | `timeout()` | Get timeout for call type |
| `derive_rpc_timeout_from_opts/2` | `timeout()` | Derive RPC timeout from opts with deadline |
| `effective_queue_timeout_ms/2` | `integer()` | Queue timeout respecting deadline |
---
## Related Guides
- [Configuration Guide](configuration.md) - General configuration options
- [Worker Profiles](worker-profiles.md) - Process vs Thread profiles
- [Production Guide](production.md) - Deployment best practices