A worker service that executes Python code snippets on behalf of a frontend gateway. Jobs arrive over NATS JetStream; results are published back to a reply subject and the message is ACKed. Multiple workers can run in parallel against the same consumer group to scale throughput.
For an end-to-end Runway setup, start with the repository README.
NATS JetStream (execution.requests)
→ _worker_loop() pull batch of messages
→ _handle_message() deserialize ExecuteSnippetRequest
→ execute_snippet() subprocess per job (safe mode)
or execute_snippet_unsafe() ProcessPoolExecutor (unsafe mode)
→ _respond() publish ExecuteSnippetResponse to replyTo
→ _ack() JetStream ACK
The worker maintains a single durable pull consumer. On each loop iteration it fetches a batch (NATS_BATCH_SIZE), fans the messages out with asyncio.gather,
publishes each response to the replyTo subject in the original request, then ACKs. Fetch timeouts are treated as empty batches and retried immediately;
NATS errors trigger a 1-second back-off.
Execution modes (set per-job via executionType):
| Mode | Mechanism | Isolation | Timeout |
|---|---|---|---|
safe (default) |
asyncio.create_subprocess_exec |
separate process, RLIMIT_AS memory cap |
asyncio.wait_for |
unsafe |
exec() in ProcessPoolExecutor |
shared process space | SIGALRM (Unix) |
Both modes return exit code 124 on timeout and write tracebacks to stderr on unhandled exceptions.
- Python 3.12+
- uv — install with
curl -LsSf https://astral.sh/uv/install.sh | sh - A NATS server with JetStream enabled
- The
execution_requestsJetStream stream must exist before the worker starts (the frontend gateway creates it in production)
Start a local NATS server:
docker run -p 4222:4222 nats:latest -jsCreate the stream for local development using the NATS CLI:
nats stream add execution_requests \
--subjects "execution.requests" \
--storage memory \
--retention limits \
--defaultsuv syncInstall pre-commit hooks (optional, for contributors):
uv run pre-commit installStart the worker:
uv run uvicorn executor.app:app --reloadThe worker connects to NATS on startup and begins consuming jobs. Health check: http://localhost:8000/health.
Send a test job with the NATS CLI:
nats pub execution.requests \
'{"jobId":"test-1","snippet":"print(\"hello\")","replyTo":"results.test-1"}'Subscribe to see the result before publishing:
nats sub "results.test-1" &
nats pub execution.requests \
'{"jobId":"test-1","snippet":"print(\"hello\")","replyTo":"results.test-1"}'Build:
docker build -t runway-python-worker .Run (pointing at a local NATS server):
docker run \
-e NATS_URL=nats://host.docker.internal:4222 \
-p 8000:8000 \
runway-python-workerThe published image is available at ghcr.io/<owner>/runway-python-worker (where <owner> is the GitHub organization or user hosting this repository).
All settings are provided via environment variables:
| Variable | Default | Description |
|---|---|---|
NATS_URL |
nats://localhost:4222 |
NATS server URL |
NATS_STREAM |
execution_requests |
JetStream stream name |
NATS_SUBJECT |
execution.requests |
Subject to consume from |
NATS_DURABLE |
execution-workers |
Durable consumer name (shared across worker instances) |
NATS_ACK_WAIT |
10 |
Seconds before an unACKed message is redelivered |
NATS_MAX_DELIVER |
3 |
Maximum delivery attempts before a message is dropped |
NATS_BATCH_SIZE |
1 |
Messages fetched per pull request |
EXEC_MEMORY_LIMIT_MB |
768 |
Virtual memory cap per execution (MB); 0 to disable |
LOG_LEVEL |
INFO |
DEBUG, INFO, WARNING, or ERROR |
Published to the execution.requests subject (or whatever NATS_SUBJECT is set to).
{
"jobId": "unique-job-id",
"snippet": "print('hello')",
"timeoutMs": 30000,
"stdin": "optional input",
"executionType": "safe",
"replyTo": "results.unique-job-id"
}| Field | Type | Required | Description |
|---|---|---|---|
jobId |
string | yes | Unique job identifier, echoed in logs |
snippet |
string | yes | Python source code to execute |
timeoutMs |
integer | no | Execution deadline in milliseconds (default: 30000) |
stdin |
string | no | Text written to the process stdin before execution |
executionType |
"safe" | "unsafe" |
no | Execution mode (default: "safe") |
replyTo |
string | no | NATS subject where the response is published; if absent, the result is discarded and the message is still ACKed |
Published to the replyTo subject.
{
"exitCode": 0,
"stdout": "hello\n",
"stderr": ""
}| Field | Type | Description |
|---|---|---|
exitCode |
integer | Process exit code. 0 = success, 124 = timeout, 1 = unhandled exception or worker error |
stdout |
string | Captured standard output |
stderr |
string | Captured standard error; contains the traceback on exception |
| Method | Path | Response |
|---|---|---|
GET |
/health |
{"status": "healthy"} — used for liveness/readiness probes |
Tests mock NATS and run entirely without a live server:
uv run pytest # all tests
uv run pytest tests/test_main.py # execution logic only
uv run pytest tests/test_nats_service.py # NATS service only
uv run pytest -k "syntax_error or runtime_exception" # filter by name
uv run pytest -v # verbose output| File | Covers |
|---|---|
tests/test_main.py |
execute_snippet and execute_snippet_unsafe: success, timeout with partial output, stdin round-trip, non-blocking event loop, runtime exceptions, syntax errors, stderr capture, mixed stdout/stderr, explicit exit codes, empty snippets. Parametrized over both execution modes. |
tests/test_nats_service.py |
NatsService lifecycle (start/stop), message routing to safe/unsafe executors, validation errors, missing reply_to, execution exceptions, NATS publish/ack error handling, multi-batch worker loop, stdin passthrough, and load_nats_settings env-var defaults. |
tests/test_app.py |
FastAPI app lifespan and GET /health endpoint. |
tests/test_models.py |
Pydantic model serialization/deserialization, camelCase↔snake_case conversion, ExecutionType enum, and timeout_seconds() helper. |
MIT — see ../LICENSE.