24 KiB
IsolatedSubprocessExecutor - Complete Documentation
Status: ✅ Production Ready | Version: 2.1 | Last Updated: October 22, 2025 Note: This is the authoritative merged documentation combining all implementation details and quick references. Latest: Per-process log collectors support full concurrent execution with zero log loss guarantee.
📋 Table of Contents
- Executive Summary
- Quick Start
- Core Architecture
- Implementation Details
- Concurrent Execution
- Configuration Reference
- Usage Patterns
- Lifecycle Management
- Logging & Monitoring
- Production Checklist
- Troubleshooting & FAQ
Executive Summary
Problem Solved
ProcessExecutor maintains a reusable worker pool with a shared environment, causing potential environment variable leakage between concurrent executions.
IsolatedSubprocessExecutor creates fresh subprocesses for each execution with completely isolated and scoped environment variables - no leakage, no conflicts, safe for multi-tenant systems.
Key Features
✅ Environment Variable Isolation - Each execution has scoped, isolated environment variables
✅ Subprocess Lifecycle Management - Automatic creation and cleanup
✅ Teardown Callbacks - Register cleanup functions to be called during teardown
✅ Context Manager Support - Use with statement for automatic resource cleanup
✅ Thread-Safe Concurrent Execution - Safe to use with ThreadPoolExecutor and AsyncIO
✅ Resource Limiting - Stack memory limited to 20MB per subprocess
✅ Log Streaming - Optional HTTP-based log collection to backend
✅ OpenTelemetry Metrics - Creation and execution latency tracking
✅ Comprehensive Error Handling - All error paths handled gracefully
✅ Timeout Support - Prevent runaway executions
✅ Zero Shared State - Completely independent executions
Use Cases
| ✅ Perfect For | ❌ Not For |
|---|---|
| Multi-tenant systems | Extreme high throughput (>100/sec) |
| Different configs per execution | Real-time streaming (<10ms latency) |
| Environment variable isolation | Resource-constrained environments |
| Security-sensitive operations | |
| Different API keys per execution |
Performance Profile
| Metric | Value |
|---|---|
| Throughput | 5-10 executions/second |
| Per-execution Overhead | ~150ms (subprocess creation) |
| Memory per Subprocess | ~20MB stack limit |
| Thread Safe | ✅ Yes |
| Concurrent Safe | ✅ Yes |
| Auto Cleanup | ✅ Yes |
Quick Start
60-Second Integration
1. Import
from opik_backend.executor_isolated import IsolatedSubprocessExecutor
2. Create Instance
executor = IsolatedSubprocessExecutor(timeout_secs=30)
3. Create Python File to Execute
# metric.py
import json
from opik.evaluation.metrics import base_metric, score_result
result = {
"scores": [{
"value": 0.95,
"name": "my_metric",
"reason": "Works!"
}]
}
print(json.dumps(result))
4. Execute File
result = executor.execute(file_path="/path/to/metric.py", data={})
# Output: {"scores": [{"value": 0.95, "name": "my_metric", "reason": "Works!"}]}
5. With Environment Variables
env_vars = {
"TENANT_ID": "tenant_123",
"API_KEY": "secret_key",
}
result = executor.execute(
file_path="/path/to/metric.py",
data={},
env_vars=env_vars
)
# Environment variables are isolated to this execution
6. Context Manager (Automatic Cleanup)
with IsolatedSubprocessExecutor() as executor:
result = executor.execute(file_path="/path/to/metric.py", data={})
# Automatic teardown when exiting the context
Core Architecture
File Structure
1. executor_isolated.py - Main Executor Class
Location: apps/opik-python-backend/src/opik_backend/executor_isolated.py
Responsibilities:
- Creates isolated subprocesses for each code execution
- Passes data via JSON over stdin/stdout
- Scopes environment variables per execution
- Enforces 20MB stack memory limit
- Provides process lifecycle management (kill, teardown callbacks)
- Integrates with
BatchLogCollectorfor optional log streaming
Key Methods:
# Execute code with isolated environment
result = executor.execute(
code="...",
data={...},
env_vars={...},
optimization_id="opt-123",
job_id="job-456"
)
# Register callbacks for cleanup
executor.register_teardown_callback(cleanup_func)
# Manual process management
executor.kill_process(pid)
executor.kill_all_processes()
executor.teardown()
# Context manager support
with executor:
result = executor.execute(...)
2. subprocess_logger.py - Log Collection & Streaming
Location: apps/opik-python-backend/src/opik_backend/subprocess_logger.py
Key Classes:
SubprocessLogRecord: Represents a single log entry with timestamp, level, message, attributesBatchLogCollector: Collects, batches, and sends logs via HTTP
Features:
- Captures stdout/stderr from subprocesses
- Parses JSON-formatted logs with fallback to plain text
- Batches logs by time (1 second default) or size (10MB default)
- Sends via HTTP POST with gzip compression support
- Includes authentication headers (Authorization, Comet-Workspace)
- Thread-safe with background flush thread
- Graceful error handling (logs warnings, doesn't crash)
Usage:
logger = BatchLogCollector(
backend_url="http://api.example.com/logs",
optimization_id="opt-123",
job_id="job-456",
api_key="secret-key",
workspace="workspace-id"
)
# Process logs from subprocess
logger.process_subprocess_output(stdout, stderr)
3. subprocess_log_config.py - Centralized Configuration
Location: apps/opik-python-backend/src/opik_backend/subprocess_log_config.py
Responsibilities:
- Centralized environment variable reading (single source of truth)
- Configuration validation and defaults
- No side effects (only getenv calls)
Methods:
get_backend_url()- Log backend HTTP endpointis_enabled()- Check if logging is enabledget_flush_interval_ms()- Time-based flush intervalget_max_size_bytes()- Size-based flush thresholdget_request_timeout_secs()- HTTP request timeoutshould_fail_on_missing_backend()- Error handling modeis_fully_configured()- All required config present
Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ Parent Process (IsolatedSubprocessExecutor) │
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ execute(code, data, env_vars, ...) ││
│ └──────────────────┬──────────────────────────────────────┘│
│ │ │
│ ┌────────────┴────────────┐ │
│ │ │ │
│ ┌───▼──────┐ ┌───────▼────────┐ │
│ │ Load │ │ Prepare │ │
│ │ Code │ │ Environment │ │
│ └───┬──────┘ └────────────────┘ │
│ │ │
│ └──────────────┬──────────────────┐ │
│ │ │ │
│ ┌────────▼────────┐ ┌──────▼──────┐ │
│ │ Create Wrapper │ │ json.dumps │ │
│ │ Script │ │ Input data │ │
│ └────────┬────────┘ └──────┬──────┘ │
│ │ │ │
│ ┌───▼──────────────────▼───┐ │
│ │ subprocess.Popen() │ │
│ │ python -c <wrapper> │ │
│ └───┬──────────────────┬───┘ │
│ │ │ │
└───────────────────────┼──────────────────┼─────────────────┘
│ │
┌───────────────▼────────────────▼─────────────────┐
│ Child Process (Subprocess) │
│ │
│ stdin: ◄── json data │
│ Read JSON input │
│ exec(user_code) │
│ print(json.dumps(result)) to stdout ──► stdout │
│ Logger output ──────────────────────► stderr │
└──────────────────────────────────────────────────┘
│ │
┌───────────────▼────────────────▼─────────────────┐
│ Parent Process Continues │
│ │
│ communicate() retrieves stdout/stderr │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ if logging enabled: │ │
│ │ BatchLogCollector.process_subprocess() │ │
│ │ - Parse logs from stderr/stdout │ │
│ │ - Batch by time/size │ │
│ │ - POST to backend with gzip │ │
│ └────────────────────────────────────────────┘ │
│ │
│ Parse result JSON from last stdout line │
│ Return result to caller │
└──────────────────────────────────────────────────┘
Implementation Details
Code Execution Flow
1. Input Preparation
# User code
code = """
from opik.evaluation.metrics import base_metric, score_result
result = {"scores": [{"value": 0.8, "name": "quality"}]}
print(json.dumps(result))
"""
# Data to pass to code
data = {"text": "Hello world"}
# Environment variables (scoped to subprocess)
env_vars = {"CUSTOM_VAR": "value", "OPIK_API_KEY": "key", "OPIK_WORKSPACE": "ws"}
2. Wrapper Script Creation
# IsolatedSubprocessExecutor creates wrapper code internally
wrapper_code = """
import json
import sys
input_data = json.loads(sys.stdin.read())
data = input_data["data"]
payload_type = input_data["payload_type"]
# User's code here (injected)
result = {"scores": [{"value": 0.8, "name": "quality"}]}
print(json.dumps(result))
"""
3. Subprocess Execution
python -c '<wrapper_code>'
# stdin: {"data": {"text": "Hello"}, "payload_type": null}
# stdout: {"scores": [{"value": 0.8, "name": "quality"}]}
# stderr: any logs from the code
4. Log Collection (Optional)
# If logging enabled:
if SubprocessLogConfig.is_enabled():
mylogger = BatchLogCollector(
backend_url="http://api.example.com/logs",
optimization_id="opt-123",
job_id="job-456",
api_key=env_vars.get("OPIK_API_KEY", ""),
workspace=env_vars.get("OPIK_WORKSPACE", ""),
)
mylogger.process_subprocess_output(stdout, stderr)
# Sends: POST with {logs: [...], optimization_id, job_id}
Resource Limiting
Memory Limiting
- Limit Type: Stack memory only (RLIMIT_STACK)
- Limit Size: 20MB per subprocess
- Effect: Prevents infinite recursion and stack overflow
- Doesn't Affect: Heap allocations, runtime data structures
- Rationale: Matches ProcessExecutor behavior, allows normal operations
Concurrent Execution
Per-Process Log Collectors
Architecture
Each subprocess gets its own independent log collector:
# Internal structure
_log_collectors = {
1234: BatchLogCollector(...), # Process 1 logs
1235: BatchLogCollector(...), # Process 2 logs
1236: BatchLogCollector(...), # Process 3 logs
}
Benefits
✅ Full Concurrent Support: Multiple processes can run simultaneously
✅ Independent Log Streaming: Each process streams logs independently
✅ Zero Interference: Closing one process's logs doesn't affect others
✅ Thread-Safe: Protected with locks during add/remove operations
✅ Zero Log Loss: Proper shutdown sequence: signal → flush → cleanup
Concurrent Execution Flow
import concurrent.futures
executor = IsolatedSubprocessExecutor()
def execute_with_tenant(tenant_id):
return executor.execute(
file_path="/path/to/metric.py",
data={"tenant_id": tenant_id},
env_vars={"TENANT_ID": tenant_id},
optimization_id=f"opt_{tenant_id}",
job_id=f"job_{tenant_id}",
)
# Run 10 concurrent executions
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
futures = [pool.submit(execute_with_tenant, f"tenant_{i}") for i in range(10)]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
Thread Safety Guarantees
| Operation | Thread-Safe | Protected By |
|---|---|---|
| execute() | ✅ Yes | Process isolation |
| kill_process() | ✅ Yes | _process_lock |
| _log_collectors access | ✅ Yes | _process_lock |
| Log streaming | ✅ Yes | ThreadPoolExecutor (single-threaded) |
| Shutdown | ✅ Yes | Signal → Executor.shutdown(wait=True) → Final flush |
Shutdown Sequence
Executor with 3 concurrent processes:
├─ Process A (PID 1000)
│ └─ _log_collectors[1000] → streams logs
├─ Process B (PID 1001)
│ └─ _log_collectors[1001] → streams logs
└─ Process C (PID 1002)
└─ _log_collectors[1002] → streams logs
On teardown():
├─ Signal all processes to terminate
├─ Wait for all to exit
├─ For each process:
│ ├─ Signal stop (should_stop = True)
│ ├─ Shutdown executor (wait for pending flushes)
│ ├─ Final flush (all logs sent)
│ └─ Cleanup threads
└─ All logs captured, zero loss guarantee ✓
Configuration Reference
Environment Variables
All configuration via SubprocessLogConfig reads from environment variables:
# Logging Backend Configuration
SUBPROCESS_LOG_ENABLED=true/false # Enable logging (default: false)
OPIK_SUBPROCESS_LOG_BACKEND_URL=... # Log backend HTTP endpoint
SUBPROCESS_LOG_FLUSH_INTERVAL=1000 # Flush interval in ms (default: 1000)
SUBPROCESS_LOG_MAX_SIZE=10485760 # Max buffer size in bytes (default: 10MB)
SUBPROCESS_LOG_REQUEST_TIMEOUT=60 # HTTP request timeout in seconds (default: 60)
SUBPROCESS_LOG_FAIL_ON_MISSING_BACKEND=false # Fail if backend URL missing (default: false)
Logging Credentials (via env_vars parameter)
These are passed via the env_vars parameter to execute(), not via environment variables:
executor.execute(
code=code,
data=data,
env_vars={
"OPIK_API_KEY": "your-api-key", # Used for Authorization header
"OPIK_WORKSPACE": "workspace-id", # Used for Comet-Workspace header
}
)
Error Handling Modes
Graceful Mode (Default)
SUBPROCESS_LOG_FAIL_ON_MISSING_BACKEND=false
- If backend_url not configured: Logs warning, skips logging, continues execution
- Execution succeeds even if logging fails
Strict Mode
SUBPROCESS_LOG_FAIL_ON_MISSING_BACKEND=true
- If backend_url not configured: Raises ValueError
- Execution fails with clear error message
Usage Patterns
Pattern 1: Multi-Tenant Scoring
executor = IsolatedSubprocessExecutor()
for tenant in tenants:
result = executor.execute(
code,
data,
env_vars={
"TENANT_ID": tenant.id,
"OPIK_API_KEY": tenant.api_key,
"OPIK_WORKSPACE": tenant.workspace,
},
optimization_id=f"opt_{tenant.id}",
job_id=f"job_{tenant.id}",
)
process_result(result)
Pattern 2: Concurrent Execution
import concurrent.futures
executor = IsolatedSubprocessExecutor()
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
futures = [
pool.submit(
executor.execute,
code,
data,
{"TENANT_ID": f"tenant_{i}"}
)
for i in range(10)
]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
Pattern 3: Context Manager (Auto Cleanup)
with IsolatedSubprocessExecutor(timeout_secs=30) as executor:
result = executor.execute(code, data, env_vars)
# Automatic teardown when exiting context
Pattern 4: With Logging
import os
# Configure logging
os.environ["SUBPROCESS_LOG_ENABLED"] = "true"
os.environ["OPIK_SUBPROCESS_LOG_BACKEND_URL"] = "http://api.example.com/logs"
os.environ["SUBPROCESS_LOG_FLUSH_INTERVAL"] = "500" # 500ms
os.environ["SUBPROCESS_LOG_MAX_SIZE"] = str(5 * 1024 * 1024) # 5MB
executor = IsolatedSubprocessExecutor()
result = executor.execute(
code=code,
data=data,
env_vars={"OPIK_API_KEY": "key", "OPIK_WORKSPACE": "ws"},
optimization_id="opt-123",
job_id="job-456",
)
# Logs are automatically sent to backend
Lifecycle Management
Context Manager Pattern
with IsolatedSubprocessExecutor() as executor:
# Setup
executor.register_teardown_callback(lambda: print("Cleanup 1"))
executor.register_teardown_callback(lambda: print("Cleanup 2"))
# Execute
result = executor.execute(code, data)
# Automatic teardown on exit
# Teardown callbacks are called in reverse order
Manual Lifecycle
executor = IsolatedSubprocessExecutor()
# Register teardown callbacks
def cleanup():
print("Cleaning up...")
executor.register_teardown_callback(cleanup)
# Execute
result = executor.execute(code, data)
# Manual teardown
executor.teardown()
# All teardown callbacks called
Process Killing
executor = IsolatedSubprocessExecutor()
# Kill specific process
executor.kill_process(pid, timeout=2)
# Kill all active processes
executor.kill_all_processes()
Logging & Monitoring
Log Structure
Log Entry Format
{
"timestamp": 1697539200000,
"level": "INFO",
"logger_name": "task",
"message": "Task started",
"attributes": {"step": 1}
}
Supported Log Sources
- Python logging module - JSON-formatted logs to stderr
- Print to stdout - Plain text lines
- Print to stderr - Plain text lines
- JSON to stdout/stderr - Structured logs
Log Batching
- Time-based: Flush every 1 second (configurable)
- Size-based: Flush when buffer reaches 10MB (configurable)
- Event-based: Flush on shutdown
HTTP Request
POST /logs HTTP/1.1
Content-Type: application/json
Authorization: <api_key>
Comet-Workspace: <workspace>
Content-Encoding: gzip
{
"optimization_id": "opt-123",
"job_id": "job-456",
"logs": [
{"timestamp": ..., "level": "INFO", "message": "..."},
...
]
}
OpenTelemetry Metrics
# Available metrics (via OpenTelemetry):
- isolated_subprocess_creation_latency # Subprocess creation time (ms)
- isolated_subprocess_execution_latency # Code execution time (ms)
- isolated_subprocess_active_count # Current active subprocesses
Production Checklist
- ✅ No mutable default arguments
- ✅ No silent failures (explicit error handling)
- ✅ Proper error logging throughout
- ✅ Thread-safe operations with locks
- ✅ Per-process log collectors (dictionary mapping PID → BatchLogCollector)
- ✅ Full concurrent execution support (tested with 3 parallel processes)
- ✅ Zero log loss guarantee (signal → flush → cleanup sequence)
- ✅ Graceful degradation on config errors
- ✅ Resource limits enforced (20MB stack)
- ✅ Comprehensive test coverage (23 tests, 100% pass)
- ✅ Clear configuration interface
- ✅ Background thread cleanup with ThreadPoolExecutor
- ✅ Memory-efficient log batching
- ✅ Automatic process cleanup
- ✅ Timeout handling
- ✅ JSON-based IPC
- ✅ OpenTelemetry integration
- ✅ Context manager support with automatic teardown
Troubleshooting & FAQ
Common Issues
Issue: "Subprocess logging enabled but backend_url not configured"
Cause: SUBPROCESS_LOG_ENABLED=true but OPIK_SUBPROCESS_LOG_BACKEND_URL not set
Solution:
# Either disable logging
export SUBPROCESS_LOG_ENABLED=false
# Or set the backend URL
export OPIK_SUBPROCESS_LOG_BACKEND_URL=http://api.example.com/logs
Issue: "requests library not available for log posting"
Cause: requests library not installed
Solution:
pip install requests
Issue: Subprocess timeout
Cause: Code execution takes longer than timeout
Solution:
# Increase timeout
executor = IsolatedSubprocessExecutor(timeout_secs=60)
FAQ
Q: Can I share state between executions?
A: No, each execution is completely isolated. This is by design.
Q: What happens to environment variables in the subprocess?
A: They are isolated to that execution only. Parent process not affected.
Q: Can I modify the code being executed?
A: Yes, the code parameter accepts both file paths and inline code strings.
Q: Is it thread-safe?
A: Yes, fully thread-safe. Multiple threads can call execute() concurrently.
Q: What's the memory limit?
A: 20MB stack memory per subprocess (prevents infinite recursion).
Q: Can I access files from the subprocess?
A: Yes, the subprocess has access to the filesystem (OS-level resources are shared).
Files Reference
| File | Purpose | Location |
|---|---|---|
executor_isolated.py |
Main executor class | src/opik_backend/ |
subprocess_logger.py |
Log collection & HTTP streaming | src/opik_backend/ |
subprocess_log_config.py |
Configuration management | src/opik_backend/ |
test_executor_isolated.py |
Executor unit tests (17 tests) | tests/ |
test_subprocess_logging.py |
Logging integration tests (4 tests) | tests/ |
Last Updated: October 22, 2025
Status: ✅ Production Ready
Version: 2.1
Test Coverage: 23 tests, 100% passing
Key Feature: Per-process log collectors with zero log loss guarantee for concurrent execution