Failure + Recovery Architecture
Overview
Failure in this system is represented as structured PipelineError objects accumulated in graph state, explicit exceptions that short-circuit a node/subgraph, or runtime timeouts/cancellations handled by the orchestrator. Most node failures are captured and returned in state rather than raising, so the graph can continue unless routing logic or wrappers explicitly stop or crash. Recovery is limited to a local retry loop inside the SQL agent subgraph, and to parse-and-retry inside each structured LLM call (an answer that does not parse is asked for again, up to the agent's parse_retries, then MODEL_OUTPUT_INVALID; see Parse-and-retry); there is no graph-level replay or global retry.
Failure Domains
Input
- Missing or invalid configuration raises during context construction (e.g., missing vector store path or collection name), preventing pipeline startup.
- Explicit datasource overrides fail if the datasource is unknown or not allowed, returning
SECURITY_VIOLATIONorINVALID_STATE. - Missing LLMs for specific nodes (e.g., refiner) return
MISSING_LLMerrors. - A model provider refusing or failing a call (rejected key, rate limit, quota, timeout, outage, unknown model) returns a fatal
PROVIDER_*error from whichever LLM node made the call, in place of that node's own failure code below. See provider failures.
Retrieval
- Vector store calls are wrapped by
VECTOR_BREAKER; breaker open or retrieval errors propagate into resolver or schema retriever. - Resolver returns
SCHEMA_RETRIEVAL_FAILEDif no candidate datasources are found, or if more than one datasource is registered and there is no vector store. With one datasource registered it runs no vector search. - Resolver returns
QUESTION_NOT_ANSWERABLEwhen its answerability check finds no allowed datasource can answer the question; the run ends before the decomposer. A failed answerability call returnsSCHEMA_RETRIEVAL_FAILEDwith the model's error, or aPROVIDER_*error when the provider refused it. - Schema retriever falls back to full schema snapshot if vector retrieval yields no tables; if schema store returns
None, it silently returns an empty table list.
Planning
- Decomposer LLM failures return
ORCHESTRATOR_CRASH(critical) and empty responses; a provider's refusal returns aPROVIDER_*error instead. - AST planner LLM failures return
PLANNING_FAILUREand aNoneplan. - A decomposition that cannot be turned into an execution DAG returns
PLANNER_FAILEDand noexecution_dag; the layer router ends the run. - A
joinorcomparecombine group left with fewer than two inputs (a sub-query dropped as restricted, unresolved or unsupported) returnsPLANNER_FAILEDthe same way, before any scan runs, rather than answering from the side that remains.
Validation
- Logical validation returns structured errors for missing tables, columns, invalid plan structure, or security violations.
- Logical validation is the only validation gate; there is no physical/dry-run validation node in the graph.
Execution
- SQL generation failures return
SQL_GEN_FAILED. - Executor returns errors for missing SQL, missing datasource, missing executor, or executor crashes.
- A query the database rejected as wrong (the adapter's
ResultError.retryable) returnsEXECUTION_FAILED, which is retried through the refiner; a refusal (permission, timeout, connection, read-only) returnsDB_EXECUTION_ERROR, which is fatal. The message is the adapter'ssafe_message: one short line, no SQL, no stack trace (see the SDK). - Execution is in-process on the pipeline thread pool. A driver-level crash is not contained and terminates the process; only Python-level exceptions become
PipelineErrorvalues (see../execution/isolation.md).
Storage
- Schema store uses SQLite or in-memory storage; failures manifest as exceptions on read/write (not caught in callers).
- Artifact store is invoked by the SQL executor; aggregation loads artifacts via
PolarsDuckdbEngine.load_scan()usingArtifactRefURIs. A single store serves the local, S3, and ADLS backends; only local is verified against real storage (see../storage/artifact-store.md). - Result store and execution store are in-memory only and not used for recovery.
Node-Level Failures
How nodes fail
- Most nodes use try/except and return a
PipelineError(with severity + error code) in their response. - Some routing logic raises
PipelineExecutionErrordirectly (e.g., no compatible subgraph found) — anNL2SQLErrorcarrying thePipelineErrorpayload on.error, since a router may only return routing decisions and cannot report the failure as a state value the way a node does. - Nodes often return partial state plus errors (e.g., resolver returns a response plus errors).
Propagation
GraphState.errorsandSubgraphExecutionState.errorsaccumulate errors via list reducers.- Downstream nodes generally do not halt unless routing logic explicitly stops (SQL agent checks) or a wrapper crashes.
Local handling
- SQL agent uses retry routing based on
PipelineError.is_retryable. - Other nodes do not retry; they return errors and allow the graph to proceed unless missing required inputs causes downstream failures.
Subgraph Failures
Containment
- Each subgraph runs with its own
SubgraphExecutionState. Errors inside the subgraph are merged back into the main graph state.
Abort vs continue
- SQL agent subgraph routes to
ENDwhen: - The planner fails to produce a plan and errors are non-retryable.
- Logical validation returns non-retryable errors.
- The executor returns anything but a retryable
EXECUTION_FAILED(including success). - Retry count reaches
sql_agent_max_retrieswith a blocking (ERROR/CRITICAL) finding. - Cancellation is detected.
- If errors are retryable and retry budget remains, the subgraph loops through
retry_handler -> refiner -> planner. - Validation findings that are only
WARNING(COLUMN_NOT_FOUNDwithLOGICAL_VALIDATOR_STRICT_COLUMNS=false; it is on by default) also loop while retry budget remains, but never end the sub-query: once the budget is spent the plan proceeds to generation.
Sub-query status
wrap_subgraphderivesSubgraphOutput.statusfrom the final attempt, not from the accumulated errors:successwhen the sub-query ended with SQL and, when executing, a result artifact; otherwiseerror.SubgraphExecutionState.errorsis an additive reducer, so it keeps every attempt's errors (the planner reads them as retry feedback).SubgraphOutput.errorskeeps them too.- A successful sub-query sends those errors to the run's
warningsasRunWarnings with itssub_query_id(a recoveredERRORmarkeddetail.recovered) rather than itserrors. A failed one sends them toerrors, adding aMISSING_SQLerror if none of them was blocking.
Partial recovery
- Recovery is limited to re-planning and refining within the SQL agent. There is no partial recovery at the aggregator or graph layer.
- A sub-query that ends early (no plan, a refused plan, no SQL) is reported as
status: errorby the wrapper; it does not crash the run.
Graph-Level Failures
Request termination
run_with_graphterminates early on cancellation (CANCELLED) or global timeout (PIPELINE_TIMEOUT).- Once the run's token is cancelled, a callback refuses to start the next node or send the next model call, so a cancelled run stops at the next step instead of running to the end; the step in flight finishes.
PipelineExecutionError(raised where a router cannot return aPipelineErroras a value) is unwrapped and its structured payload returned unchanged, keeping its ownerror_code,severity,nodeandis_retryable.- Other unhandled exceptions in graph execution return
UNKNOWN_ERRORwith stack trace.
Cleanup
- Signal handlers are restored after execution. There is no explicit cleanup of artifacts or partial state.
User-facing errors
- Errors are returned in the final state, and
QueryResult.errorscarries them; a run that finished with an ERROR or CRITICAL error hasstatus: "error".nl2sql runexits 1 on it.
Retry Architecture
Retry scope
- Only the SQL agent subgraph retries: planner failures, validation errors, queries the database rejected as wrong (
EXECUTION_FAILED), and, once each, an empty result filtered on a value that does not exist when a close one does (VALUE_NOT_FOUND) and aCOUNTover rows a join repeated (JOIN_FAN_OUT) go back through the refiner. - Other nodes (resolver, decomposer, generator, aggregator, answer synthesizer) do not retry.
- A retry tells the planner and refiner about the latest attempt only, as
{node, error_code, message}.
Backoff
- Exponential backoff with jitter in
retry_handlerusing: SQL_AGENT_RETRY_BASE_DELAY_SECSQL_AGENT_RETRY_MAX_DELAY_SECSQL_AGENT_RETRY_JITTER_SEC
Idempotency
- Subquery IDs are stable hashes, so retries regenerate the same subquery IDs.
- Executor writes artifacts keyed by subquery IDs; overwrite semantics depend on the artifact store implementation (not present in this repo).
Artifact Consistency
Partial writes
- Executor returns an
ArtifactRefonly on successful adapter execution. - Aggregator expects an artifact for each scan node; missing artifacts raise and are surfaced as
AGGREGATOR_FAILED.
Overwrites
RESULT_ARTIFACT_PATH_TEMPLATErenders the artifact path for every backend from the metadata the executor supplies (tenant_id,request_id,sub_query_id,dag_node_id,subgraph_name,schema_version); an unfillable placeholder raises rather than writing a bad path (see../storage/artifact-store.md).- With the default template, paths are
<backend root>/<tenant_id>/<request_id>/<sub_query_id>.parquet: the sub-queries of one run never share a file, and repeat execution of the same sub-query with the same trace ID targets the same object. A custom template with no sub-query placeholder gets-<sub_query_id>added to its file name, so it cannot collide either. - A failed aggregation ends the run with its
AGGREGATOR_FAILEDerror; the answer synthesizer is not called on an empty result. - A
join/compareinput that reached its datasource's row cap ends the run withRESULT_TRUNCATEDbefore combining, since its missing rows could make the answer wrong; a cappedunioninput runs and adds aRESULT_TRUNCATEDwarning (seenodes/engine_aggregator_node.md#row-cap-before-a-combine).
Cleanup
- No cleanup or rollback is implemented for artifacts or partial aggregation results.
Recovery Paths
What can be retried
- SQL agent planner/validation loop for retryable errors (non-fatal error codes and non-critical severity).
What must restart
- Any graph-level failure (timeout, cancellation, unknown exception) requires a new run.
- Resolver failures, decomposer failures, generator failures, executor failures, and aggregator failures have no local recovery and require a new run.
What is unrecoverable
FATAL_ERRORSorCRITICALseverity errors (security violations, missing datasource ID, missing LLM, invalid state, and everyPROVIDER_*error, since a retry would make the same call to the same provider) terminate the subgraph or graph without retry.
Replay Support
Replay is not supported. There is no persisted graph state or execution log to re-run nodes; state is kept in memory and discarded after completion. Artifact references are stored in state only and are not used for graph replay.
Known Gaps
- No dry-run or cost-estimate gate runs before execution. The
SUPPORTS_DRY_RUN/SUPPORTS_COST_ESTIMATEcapability flags are gone with it: nothing queried them. VECTOR_BREAKERis the only circuit breaker; LLM calls and SQL execution have no breaker and no fast-fail path.- There is no process isolation around SQL execution, so a driver crash is unrecoverable at the engine level.
- Subgraph wrapper assumes executor output is present; earlier failures can cause wrapper-level errors.
- Pipeline completion does not imply success: read
QueryResult.statusanderrors, not whether the run returned. - No graph-level retries or replay; only subgraph local retries.
Related Code
packages/nl2sql/src/nl2sql/pipeline/runtime.pypackages/nl2sql/src/nl2sql/pipeline/graph.pypackages/nl2sql/src/nl2sql/pipeline/routes.pypackages/nl2sql/src/nl2sql/pipeline/graph_utils.pypackages/nl2sql/src/nl2sql/pipeline/state.pypackages/nl2sql/src/nl2sql/pipeline/subgraphs/sql_agent.pypackages/nl2sql/src/nl2sql/common/errors.pypackages/nl2sql/src/nl2sql/common/resilience.pypackages/nl2sql/src/nl2sql/common/cancellation.pypackages/nl2sql/src/nl2sql/pipeline/nodes/*/node.pypackages/nl2sql/src/nl2sql/execution/executor/sql_executor.pypackages/nl2sql/src/nl2sql/aggregation/aggregator.pypackages/nl2sql/src/nl2sql/indexing/retrieval.pypackages/nl2sql/src/nl2sql/schema/*.py