nl2sql

Execution Isolation and Concurrency

This page describes how a pipeline run is bounded, cancelled, and protected in the current implementation. It is deliberately explicit about what the engine does not do, because the boundaries matter when you reason about failure.

There is no process sandbox

The engine runs entirely in one process. run_with_graph() dispatches the graph onto a concurrent.futures.ThreadPoolExecutor (pipeline/runtime.py), sized by settings.sandbox_exec_workers (SANDBOX_EXEC_WORKERS, default 4). Threads share one interpreter and one address space.

Two consequences follow, and neither is a sandbox property:

The setting is named sandbox_exec_workers for historical reasons. It controls thread-pool width, not isolation.

What the engine actually guarantees

Validation before SQL exists

This is the engine’s real safety property, and it holds. The LLM emits an Abstract Syntax Tree, never SQL text. The AST passes through LogicalValidatorNode (pipeline/nodes/validator/node.py) before the generator is reached. That node:

An unvalidated plan never becomes SQL, so it never reaches an adapter. This is a correctness and authorization gate, not a containment boundary: it prevents bad SQL from being generated, it does not contain a driver that misbehaves on SQL that passed.

Per-run cancellation

Each invocation constructs its own CancellationToken (common/cancellation.py) and threads it to nodes through config={"configurable": {"cancellation_token": token}}. Because the token is per-run, cancelling one run never affects another.

Nodes observe the token and unwind cooperatively, so a cancelled run returns a normal result carrying a CANCELLED PipelineError rather than raising. run_with_graph() cancels the token on SIGINT/SIGTERM, and on Windows interactive terminals also on Ctrl+X.

Cancellation is cooperative: a node blocked inside a driver call does not abandon that call. The token is checked between and within nodes, not inside third-party I/O.

Global timeout

settings.global_timeout_sec (GLOBAL_TIMEOUT_SEC, default 60) is enforced by future.result(timeout=...) in run_with_graph(). On expiry the caller gets a PIPELINE_TIMEOUT error and a user-facing message.

The timeout bounds how long the caller waits, not how long the work runs. Since the executor is a thread pool, the underlying thread is not killed and may continue until its own I/O completes.

One circuit breaker

VECTOR_BREAKER (common/resilience.py) is the only breaker the system defines (fail_max=5, reset_timeout=30). It wraps vector-store retrieval in indexing/vector_store.py only.

LLM calls and SQL execution are not breaker-guarded. Their failures surface as PipelineError values in graph state; see Error Handling.

Adapter-level limits

Adapters enforce row_limit and max_bytes on results (see SDK Reference). These cap payload size; they are not isolation.

Summary

Mechanism Module What it bounds
AST validation + RBAC pipeline/nodes/validator/node.py What SQL may be generated at all
CancellationToken common/cancellation.py One run, cooperatively
global_timeout_sec pipeline/runtime.py How long the caller waits
VECTOR_BREAKER common/resilience.py Vector retrieval only
ThreadPoolExecutor pipeline/runtime.py Concurrency, not isolation

If you need blast-radius containment against driver-level crashes, run the engine in a process you are willing to lose (a per-request worker, a container replica) and rely on your supervisor to restart it. The engine does not provide that boundary itself.