Sandbox Execution
Overview
Section titled “Overview”When you call run_model, Bridge Town executes your model’s run.py entry point—or the Python file selected by path—inside an isolated Docker container. path is relative to the repository root. A bare name such as lineage_check or lineage_check.py checks lineage_check.py at the root first, then the legacy model/lineage_check.py location. A nested path such as checks/lineage_check.py is exact. Synchronous calls return the results inline. The sandbox blocks network access and constrains filesystem access to mounted runtime paths only.
Security constraints
Section titled “Security constraints”| Constraint | Value |
|---|---|
| Network access | --network none — no outbound or inbound connections |
| Root filesystem | Read-only container root filesystem |
| Mounted paths | /repo (read-only), /data (read-only), /outputs (writable tmpfs), /tmp (writable tmpfs), /upstream (writable tmpfs, model runs only) |
| Timeout | 5 minutes (hard cap) |
| Memory | Capped per container |
| Packages | Standard library + numpy, pandas, openpyxl pre-installed |
The server resolves one resource contract for each run and applies the effective
CPU, memory, and execution limit to the container or ECS task. The default
profile remains 1000 millicores and 512 MiB on Docker, and the task definition’s
current 2000 millicores and 4096 MiB on ECS. Entitled run_model calls can
request a larger profile with cpu_profile=2, 4, or 8; these profiles use
2 vCPUs / 4 GiB, 4 vCPUs / 8 GiB, and 8 vCPUs / 16 GiB respectively. A profile
must be enabled by the deployment, present in its supported-profile allowlist,
and admitted against shared host and per-tenant CPU and memory budgets. Docker
admission also respects the existing host_sandbox_budget_mb; raise that budget
and sandbox_host_memory_mib together only when the host can support the larger
containers. Larger profiles require the shared Redis coordinator. Active Pro,
Team, and Enterprise plans can request them; sensitivity batches do not accept
cpu_profile.
The model can read the effective contract from these environment variables:
| Variable | Meaning |
|---|---|
BT_CPU_LIMIT_MILLICORES | Effective CPU quota in millicores; this is a container limit, not the host’s logical CPU count. |
BT_MEMORY_LIMIT_BYTES | Effective memory cap in bytes. |
BT_EXECUTION_LIMIT_SECONDS | Admitted model execution duration in seconds. Setup and queue time do not consume this budget. |
BT_EXECUTION_STARTED_AT | UTC timestamp recorded immediately before the model entrypoint starts. |
BT_EXECUTION_DEADLINE | UTC timestamp at which the execution budget expires. |
BT_EXECUTION_DEADLINE_MONOTONIC_SECONDS | Process-local monotonic deadline for reliable in-process checks. |
BT_CONTAINER_STARTED_AT_UNIX_SECONDS | Container setup start in Unix seconds, used to keep setup timing separate from execution. |
BT_EXECUTION_WARNING_SECONDS | Optional number of seconds before the deadline when a model should checkpoint or stop work. |
BT_EXECUTION_WARNING_DEADLINE | Optional UTC timestamp for that checkpoint warning. |
BT_RESOURCE_BACKEND / BT_RUNTIME_IDENTITY | Backend and concrete runtime identity used for this execution. |
BT_RESOURCE_CONTRACT_JSON | JSON with requested, admitted, effective, and backend ceiling values, plus the execution clock policy. |
BT_CPU_PROFILE_VCPU | Selected profile size when a larger profile was requested. |
BT_MAX_WORKERS / BT_MAX_PROCESSES | Maximum worker count and total process count, including the model process. |
BT_NATIVE_THREAD_LIMIT | Maximum native library threads per process. |
Bounded parallel workers
Section titled “Bounded parallel workers”Models can use the sandbox’s spawned process pool for independent tasks:
from bridge_town.parallel import parallel_map
def score_portfolio(portfolio: dict[str, object]) -> dict[str, object]: # Keep the worker at module scope so spawned workers can import it. score = sum((int(portfolio["seed"]) + step) % 97 for step in range(1000)) return {"portfolio_id": portfolio["id"], "score": score}
scores = parallel_map( score_portfolio, [{"id": "portfolio-a", "seed": 11}, {"id": "portfolio-b", "seed": 29}],)Worker functions must be importable top-level functions. Inputs and results
must be JSON-compatible; one item or result is limited to 8 MiB, and each call
is limited to 10,000 items and 8 MiB of aggregate input and output. The pool
uses spawn, allows only one pool at a time, and cannot create nested worker
pools. Each child installs the sandbox import, filesystem, and network
restrictions before loading the worker module. Process creation APIs remain
blocked outside bridge_town.parallel.parallel_map.
BT_MAX_WORKERS follows the enforced CPU quota. Native BLAS and solver thread
settings use that same limit in the model process, and each worker sets native
thread limits to one so process workers do not multiply native thread pools.
The worker processes share the container’s memory cap. A failed task, abrupt
worker exit, or execution timeout terminates and reaps the rest of that pool.
For controlled independent-work measurements, copy
tests/fixtures/sandbox_cpu_profile_benchmark/ into a model repository and
run it with run_model(mode='sync', cpu_profile=4) (or another supported
profile). Set cpu_hour_price_usd in benchmark_config.json to report an
estimated compute cost. The output cpu_profile_benchmark.json records worker
CPU time, utilization against assigned vCPUs, throughput, exact result quality,
and estimated vCPU-hours and cost. Compare runs with the same task count and
rounds; the measurement does not promise linear scaling or include non-CPU
cloud charges.
The runner invokes registered warning callbacks before the hard deadline. A
trusted supervisor enforces that deadline for the full process tree. Read the
resource contract and usage record from get_run. Usage reports wall time and
cgroup process-tree CPU time in seconds, and peak memory in bytes. Each metric
states whether it was measured, estimated, or unavailable with a reason.
Historic runs without these measurements return null instead of a fabricated
zero.
Checkpointing before the deadline
Section titled “Checkpointing before the deadline”When a warning window is configured, the trusted runner calls registered callbacks before the hard execution deadline. The warning does not extend the admitted limit. Register a short, synchronous callback to flush a solver’s current checkpoint or completed output files:
import jsonimport osfrom pathlib import Path
from bridge_town.execution import on_deadline_warning
def save_checkpoint() -> None: output = Path("/outputs/checkpoint.json") temporary = output.with_suffix(".json.tmp") with temporary.open("w", encoding="utf-8") as stream: json.dump(current_solver_state(), stream) stream.flush() os.fsync(stream.fileno()) os.replace(temporary, output)
on_deadline_warning(save_checkpoint)Callbacks run in the process that registered them. The main model process and
bounded bridge_town.parallel workers receive the warning; workers must
register their own callback if they own state that needs flushing. A callback
should finish quickly and write only to the run’s local /outputs/ directory.
Use a temporary filename and atomic rename so a checkpoint is either complete
or excluded. Do not rely on an asynchronous callback finishing after the
warning.
At the deadline, the supervisor first gives the runner a bounded cleanup
window, then terminates the whole process group. It validates output files
after workers stop. Only complete, regular files within the existing file and
byte budgets are retained; symlinks, invalid JSON, temporary files, changing
files, and over-budget files are listed in output_collection. The run keeps
its truthful terminal status (timed_out, cancelled, or failed) and marks
retained data with partial: true and outputs_available. Read a named file
with get_run_output and the exact run_id; KPI summaries, automatic
latest-success selection, output-view following, and comparisons do not use
partial results.
Docker can make one final bounded read from its trusted host-mounted output directory after the container stops. ECS collects inside the task and sends the bounded result through its existing CloudWatch transport. If the supervisor or whole ECS task is killed by OOM/SIGKILL, or result transport/storage fails, the platform reports outputs unavailable when it cannot verify retention. It does not claim that in-memory state or an incomplete file was recovered.
Execution flow
Section titled “Execution flow”- Pull code — the model archive is fetched at the specified commit and mounted read-only at
/repo/ - Mount data — every connected data source’s latest snapshot is mounted read-only at
/data/ - Prepare writable scratch/output paths —
/outputs/and/tmp/are provided as writable tmpfs mounts; forrun_model(mode='sync')calls,/upstream/is also mounted as a writable tmpfs so pipeline models can exchange intermediate results - Run — the sandbox executes
run.pywhenpathis omitted, or resolves the relative Python path before execution - Capture — stdout, stderr, and files written to
/outputs/are collected - Record — a
ModelRunrecord is written with status, duration, and results - Return — all terminal results are returned inline to the MCP client
/data/ layout with multiple data sources
Section titled “/data/ layout with multiple data sources”Every connected data source gets its own collision-free canonical location:
/data/_sources/<data-source-id>/<file> # always present, one root per source/data/_sources.json # manifest: name, id, type, opaque # snapshot ID, safe handle, metadata, # canonical root, and files/data/<file> # short path when the file name is uniqueWhenever exactly one connected source produces a given relative path, that path
is also available directly under /data/ as a zero-copy short path to the
canonical file, not a duplicate download. A single-source model can therefore
read /data/<tab>.csv directly.
If two connected sources happen to produce the same relative path — e.g. two
CSV uploads that both contain a data.csv — the flat alias for that path is
omitted rather than one source silently winning or the run failing. Both
sources’ files are still mounted at their canonical /data/_sources/<id>/
paths, and the run proceeds normally. Read /data/_sources.json to resolve
the ambiguity explicitly, or rename one of the source tabs so the flat alias
comes back. In short: adding a second, even unrelated, data source can never
make an otherwise-runnable model unrunnable.
For browser-uploaded CSVs, prefer the stable runtime API instead of depending on the filesystem layout:
from bridge_town.data import read_csv
forecast = read_csv("spv_forecasts_v2")The API resolves only the immutable manifest reserved for this run. It never
looks up the latest source by name, exposes storage prefixes, or permits source
UUID/path guessing. Use source_metadata(name) when a model needs the opaque
snapshot ID, schema, row count, or safe runtime handle.
Output
Section titled “Output”run_model(mode='sync') and run_model(mode='sync', path='checks/<name>.py') return results synchronously:
{ "run_id": "uuid", "model_name": "my-model", "branch": "main", "commit_sha": "abc123", "status": "success", "exit_code": 0, "stdout": "...", "stderr": "...", "stdout_truncated": false, "stderr_truncated": false, "outputs": {"forecast.json": {"q1": 1200000}}, "partial": false, "outputs_available": true, "duration_seconds": 3.2, "data_snapshot_ref": "s3://..."}stdout and stderr are capped at 4 KB each in the inline response. When you need one named output from a completed run, call get_run_output with the returned run_id and output_name; it returns that output inline up to 10 MiB. Use get_run when you need the full run envelope or status details.
If a non-successful run retained validated files, get_run reports
partial: true, outputs_available: true, and an output_collection receipt.
The exact run’s named files remain inspectable with get_run_output, whose
response also says partial: true. These values are incomplete and are never
presented as a successful run.
Synchronous vs. asynchronous execution
Section titled “Synchronous vs. asynchronous execution”Primary path — synchronous (run_model with mode='sync'):
run_model(mode='sync') executes the model’s run.py entrypoint and waits for completion, returning all results inline. With path, it runs the selected repository-relative Python file directly. Bare script names resolve to <name>.py at the repository root first and fall back to model/<name>.py; nested paths must end in .py. No follow-up get_run call is needed.
Background path — asynchronous (run_model with mode='async' / get_run / list_runs):
run_model(mode='async') dispatches execution via Celery and returns a run_id immediately without waiting for the container to finish. Poll get_run until the status reaches a terminal state (success, failed, timed_out, or cancelled). Use this path when you need to queue many runs concurrently or want to submit work without blocking.
To review run history or locate a previous run’s run_id, use list_runs — it returns run summaries for a model ordered most-recent-first, with optional status filtering. Pass the run_id from list_runs to get_run_output for one named output or to get_run for the full run envelope.
Related guides
Section titled “Related guides”- Multi-File Pipelines — chaining files with
PIPELINEand/upstreamtransport