ExecutorNode
Overview
- Delegates SQL execution to an executor service based on datasource capabilities.
- Persists results via the artifact store and returns
ExecutorResponse. - Sits after
GeneratorNodein the SQL agent subgraph. - Class:
ExecutorNode - Source:
packages/nl2sql/src/nl2sql/pipeline/nodes/executor/node.py
Responsibilities
- Validate required execution inputs (SQL and datasource ID).
- Gate execution on the datasource declaring
supports_sql. - Build and submit
ExecutorRequest. - Return executor response and propagate errors.
Position in Execution Graph
Upstream:
- GeneratorNode
Downstream: - Subgraph end (artifacts returned to control graph).
Trigger conditions: - Executed when SQL generation succeeds.
flowchart LR
Generator[GeneratorNode] --> Executor[ExecutorNode] --> EndNode[SubgraphEnd]
Inputs
From SubgraphExecutionState:
generator_response.sql_draft(required)sub_query.datasource_id(required)sub_query.schema_version(optional)trace_id,subgraph_name,user_context
From Runtime:
ds_registrytenant_id
Validation performed:
- Missing SQL →
MISSING_SQL - Missing datasource ID →
MISSING_DATASOURCE_ID - No executor for datasource →
INVALID_STATE
Outputs
Mutations to SubgraphExecutionState:
executor_response(ExecutorResponse)errorsandreasoning
Side effects:
- Executes SQL against datasource via executor service.
- Reads the result a batch at a time when the adapter has
execute_batches(StreamingAdapterProtocol),settings.results.fetch_batch_sizerows per batch, and writes each batch to the artifact as it arrives (ArtifactStore.write_batches); an adapter without it is called throughexecuteand its one frame is written the same way. A failure the adapter reports between batches fails the sub-query as a failedexecutewould, and nothing is written (see artifact store).metrics.row_countandmetrics.bytes_returnedare summed over the batches. SqlExecutorServicealso takesExecutorRequest.parametersandmax_rows, which this node never sets: generated SQL carries no parameters and itsLIMITis in the text. The curated matcher sets both for a verified query: the parameters reach the adapter inAdapterRequest.parametersand are bound, and the stream stops aftermax_rowsrows, cutting the last batch and closing the adapter's stream.- Persists results to artifact store, one artifact per sub-query: the request's
node_id(the sub-query id) is passed to the store assub_query_idanddag_node_id, so two sub-queries of one run never write the same file (see artifact store). TheArtifactRefrecords it assub_query_id.
Internal Flow (Step-by-Step)
- Validate SQL and datasource ID.
- Check the adapter declares
supports_sql; otherwise returnINVALID_STATE. - Build
ExecutorRequestwith trace/subgraph identifiers and schema version. - Run
execute.before, execute the request, and runexecute.afteron the result's summary. - After
execute.after, run the answer sanity checks on the result (below), unlesssettings.pipeline.sanity_checksisfalse; a result a hook stopped is never checked. - On exception, emit
EXECUTOR_CRASH.
Answer sanity checks
Cheap checks on each sub-query's result, before the aggregator, in
pipeline/nodes/executor/sanity/: the filter values of an empty result
(values.py), join fan-out (fanout.py) and all-null columns (nulls.py).
Every probe is a sqlglot tree built from the plan and rendered by the adapter
(render_sql), bounded, and never run on a table the caller's scope denies; a
check that fails to run reports nothing, and a failure in the checks never
fails the run. A verified query is not checked: it runs in the
curated matcher, not through this node.
Probes and hooks. A probe (the value check's and the fan-out check's) is
SQL the hooks never see: it runs outside
execute.before and execute.after, and nothing publishes it. So when any
hook is attached at execute.before (an operator gating the SQL that reaches
the database), no probe runs: only the all-null check, which reads the saved
result and sends nothing to the database. settings.pipeline.sanity_checks:
false turns every check off. A probe is bounded by the datasource's timeout;
one that times out is skipped silently.
| Check | Runs on | Code | Then |
|---|---|---|---|
| Missing filter value | an empty result | VALUE_NOT_FOUND |
A close value and a retry left: an ERROR, retried once. Otherwise a WARNING. |
| Join fan-out | a result with rows | JOIN_FAN_OUT |
Under COUNT, confirmed, with a retry left: an ERROR, retried once. Under SUM or AVG, or with no retry left: a WARNING. |
| All-null column | a result with rows | ALL_NULL_COLUMN |
Always a WARNING. |
Each check retries at most once per sub-query, within
settings.pipeline.retries.max, and a pinned plan (supplied, approved or
chosen) is never retried, so its findings are warnings. Each finding carries
details.attempt, which the SQL agent routes on,
and publishes sanity.flagged (code, sub_query_id, retry; see
Events). A warning reaches the result's
warnings as a RunWarning, and the answer synthesizer says it (see
AnswerSynthesizerNode).
Join fan-out
Joining Customer to Invoice on Invoice.CustomerId repeats each customer once
per invoice, so COUNT(c.CustomerId) counts invoices and SUM(c.Credit) adds
each customer's credit several times. A table's rows are repeated when a join
in the plan reaches, on its far side, a key that is not the far table's whole
primary key (from the snapshot, through the sub-query's relevant_tables);
every table on the near side of that join is repeated. One table joined to two
others on their many side (a many-to-many sum: invoices and tickets of one
customer) is the same case. A table with no known primary key is taken as
unique, so it is never suspected: a warehouse that declares no keys
(Snowflake or BigQuery often do not, nor do views) gets no fan-out check.
A suspected COUNT, SUM or AVG over a column of a repeated table (not
already DISTINCT) is confirmed by one probe over the plan's own FROM,
joins, WHERE and GROUP BY: does any group hold one row of that table more
than once?
SELECT 1 FROM <the plan's FROM and joins>
WHERE <the plan's WHERE> AND NOT <alias>.<primary key> IS NULL
GROUP BY <the plan's groups>, <alias>.<primary key>
HAVING COUNT(*) > 1 LIMIT 1
A row confirms it. The rows an outer join null-extends have no key, so they never count, and a row that appears once in each of several groups (which a grouped aggregate counts rightly) never counts either. The probe needs a single-column primary key and every table of the plan readable by the caller. The key keeps its case as reflected and is spelled as the generator spells a column, rendered by the adapter in its dialect.
Under COUNT the planner is told to count distinct values, which the plan can
say (Expr.distinct). Under SUM or AVG the repair (aggregate the many
side first) needs a derived table the plan cannot express, so it is a warning:
"SUM(Customer.Credit) may count some Customer rows more than once: joining
Invoice repeats them." Messages name tables and columns, never values.
All-null columns
A result with rows whose column holds only nulls (usually an outer join that matched nothing, or the wrong column) is a warning. It reads the saved result's null counts, never its values; there is no probe and no fix to suggest.
An empty result's filter values
"Which customers bought Metal?" planned as Genre.Name = 'Metal' returns no
rows because Chinook's genre is 'Heavy Metal'. Reported as is, the answer says
nobody bought any. So when a query succeeds with zero rows,
sanity/values.py's check_values() looks at every string literal the plan's WHERE
compares a bare column to with =, IN or LIKE, through AND and OR
only (a negated or != comparison, a column inside a function, and a number
are left alone), at most five per sub-query:
- Does it exist?
SELECT 1 FROM <table> WHERE <column> = '<value>' LIMIT 1(LIKEfor a pattern). If yes, nothing is said: the empty result stands. - The closest values. From the column's sample values in the snapshot
when one of them is close (a case-insensitive match, then by similarity),
with no query. Otherwise a case-insensitive match first
(
LOWER(column) = LOWER('value'),DISTINCT,LIMIT 3); failing that, a boundedSELECT DISTINCT column ... LIMIT n(n is 500, or the adapter'srow_limitif lower) and the three most similar bydifflib(cutoff 0.6). - What it reports, as
VALUE_NOT_FOUND: - With a close value and a retry left, an
ERRORtelling the planner which values exist: "No Genre.Name value is 'Jaz', so the query returned no rows. The closest existing values are 'Jazz'; use one of them if it is what the question means." The SQL agent sends it back through the refiner (see SQL agent). It is raised once per sub-query: an empty result after that retry is reported as a warning instead. - Otherwise a
WARNING: "There is no Genre with Name 'Polka'." (with "; the closest is ..." when there is one). The sub-query still succeeds with its empty result, and the answer synthesizer is told the value does not exist (see AnswerSynthesizerNode).
Every probe is a SELECT built with sqlglot and rendered by the adapter
(render_sql), so no dialect is named here; each has a LIMIT no higher than
the adapter's row cap. A table the role may not read (RBAC, as the logical
validator checks it) is never probed, a probe the database refuses claims
nothing, and a failure in the check never fails the run.
Contracts & Interfaces
Implements a LangGraph node callable:
Key contracts:
ExecutorRequestExecutorResponse
Determinism Guarantees
- Deterministic request construction.
- Execution results depend on external datasource state and are not deterministic.
Error Handling
Emits PipelineError with:
MISSING_SQLMISSING_DATASOURCE_IDINVALID_STATEEXECUTION_FAILED: the database rejected the query as wrong (the adapter'sretryable); the SQL agent sends it back to the refiner while retries remainDB_EXECUTION_ERROR: the database refused or could not run the query (permission, timeout, connection); fatalEXECUTOR_CRASH: an exception escaped the adapterVALUE_NOT_FOUND: an empty result filtered on a value its column does not have; anERROR(retried once) when a close value exists, aWARNINGotherwiseJOIN_FAN_OUT: an aggregate over rows a join repeated, confirmed by the probe; anERROR(retried once) underCOUNT, aWARNINGunderSUMorAVGALL_NULL_COLUMN: a result column with no value; aWARNING
Retry + Idempotency
- No internal retry logic. The SQL agent's
check_executionroutes a retryableEXECUTION_FAILED, and this attempt'sVALUE_NOT_FOUNDorJOIN_FAN_OUTERROR, back to the refiner (see SQL agent). - Idempotency depends on datasource and query semantics.
Performance Characteristics
- External I/O dominates (database execution).
- Artifact persistence writes Parquet results.
Observability
- Logger:
executor(andsanity,sanity.values,sanity.fanoutfor skipped checks) sanity.flaggedfor each sanity finding- Propagates executor response reasoning and errors.
Configuration
tenant_idfrom settings.settings.pipeline.sanity_checks(defaulttrue):falseturns the sanity checks off.- Executor selection based on adapter capabilities.
Extension Points
- Extend
SqlExecutorServiceto alter execution behavior. - Replace node in
build_sql_agent_graph()to alter execution behavior.
Known Limitations
- Executes in‑process on the pipeline thread pool; there is no process sandbox (see
../../execution/isolation.md). - No built‑in retries on transient execution failures.
- The fan-out check reads only the select list's
COUNT,SUMandAVGof a plain column, and confirms with the plan's wholeWHEREbut noGROUP BY; aSUMorAVGfan-out is reported, not repaired. - The filter-value check covers string literals in
WHEREonly, notHAVING, and reads at most 500 distinct values to find a similar one, so on a high-cardinality column a near miss can go unsuggested (the value is then reported as missing).
Related Code
packages/nl2sql/src/nl2sql/pipeline/nodes/executor/node.pypackages/nl2sql/src/nl2sql/execution/executor/sql_executor.pypackages/nl2sql/src/nl2sql/pipeline/nodes/executor/sanity/(values.py,fanout.py,nulls.py)