Skip to content

Sandbox Execution

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.

ConstraintValue
Network access--network none — no outbound or inbound connections
Root filesystemRead-only container root filesystem
Mounted paths/repo (read-only), /data (read-only), /outputs (writable tmpfs), /tmp (writable tmpfs), /upstream (writable tmpfs, model runs only)
Timeout5 minutes (hard cap)
MemoryCapped per container
PackagesStandard 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:

VariableMeaning
BT_CPU_LIMIT_MILLICORESEffective CPU quota in millicores; this is a container limit, not the host’s logical CPU count.
BT_MEMORY_LIMIT_BYTESEffective memory cap in bytes.
BT_EXECUTION_LIMIT_SECONDSAdmitted model execution duration in seconds. Setup and queue time do not consume this budget.
BT_EXECUTION_STARTED_ATUTC timestamp recorded immediately before the model entrypoint starts.
BT_EXECUTION_DEADLINEUTC timestamp at which the execution budget expires.
BT_EXECUTION_DEADLINE_MONOTONIC_SECONDSProcess-local monotonic deadline for reliable in-process checks.
BT_CONTAINER_STARTED_AT_UNIX_SECONDSContainer setup start in Unix seconds, used to keep setup timing separate from execution.
BT_EXECUTION_WARNING_SECONDSOptional number of seconds before the deadline when a model should checkpoint or stop work.
BT_EXECUTION_WARNING_DEADLINEOptional UTC timestamp for that checkpoint warning.
BT_RESOURCE_BACKEND / BT_RUNTIME_IDENTITYBackend and concrete runtime identity used for this execution.
BT_RESOURCE_CONTRACT_JSONJSON with requested, admitted, effective, and backend ceiling values, plus the execution clock policy.
BT_CPU_PROFILE_VCPUSelected profile size when a larger profile was requested.
BT_MAX_WORKERS / BT_MAX_PROCESSESMaximum worker count and total process count, including the model process.
BT_NATIVE_THREAD_LIMITMaximum native library threads per process.

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.

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 json
import os
from 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.

  1. Pull code — the model archive is fetched at the specified commit and mounted read-only at /repo/
  2. Mount data — every connected data source’s latest snapshot is mounted read-only at /data/
  3. Prepare writable scratch/output paths — /outputs/ and /tmp/ are provided as writable tmpfs mounts; for run_model(mode='sync') calls, /upstream/ is also mounted as a writable tmpfs so pipeline models can exchange intermediate results
  4. Run — the sandbox executes run.py when path is omitted, or resolves the relative Python path before execution
  5. Capture — stdout, stderr, and files written to /outputs/ are collected
  6. Record — a ModelRun record is written with status, duration, and results
  7. Return — all terminal results are returned inline to the MCP client

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 unique

Whenever 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.

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.

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.