DataFusion for local execution¶
Status: accepted. It adds an execution adapter and changes what the execution port carries. It supersedes nothing: the dialect layer keeps the job of rendering SQL for a remote data system, and federation is still later rather than now.
Context¶
The local path is the one a person is on the first time they try this: a file on the same machine, no server, no login. The first-party models decision is why that path exists at all, and the generator is what makes it work, producing the whole statement from a model in the dialect of whatever will run it.
Building that generator against a local file produced one kind of bug over and over, and every one of them was a SQL-generation bug rather than a bug about what a metric means:
DATE_TRUNCover a date returned aTIMESTAMP, so the period column came back as a type the adapter refused.GROUP BYemittedx AS ywhen it was handed an aliased projection, which is not a grouping key.- Aliases were emitted unquoted, because the dialect layer's force-quote flag does not cover them.
- Placeholders rendered as
?for every dialect, including the one that needs$1.
Not one of those is about semantics. They are all about turning a plan into text correctly for a target, and on this path the target is a process on the same machine as the caller. Which raises the question this decision answers: why is there any text?
Decision¶
DataFusion is THE execution engine. Not a second one beside DuckDB: the intended end state is one
cohesive engine, DataFusion for execution and polyglot-sql for rendering SQL when a query is pushed
down to a remote data system, taking concepts from the projects already surveyed in
Architecture - a semantic layer that compiles to a
plan, and a federation layer that decides where a subplan runs. Federation is explicitly later.
DuckDB's role changes accordingly, and it is worth being precise rather than polite about it. It
is not a peer engine to be maintained in parallel; two engines to keep in step is a cost, not a
feature. It is a data source - a place data already lives, that somebody wants queried - and its
driver is a development dependency rather than something the binary ships. What it earns its keep for
today is that it is the only thing in the repository that EXECUTES the SQL we render: the engine
never produces a statement, so without a real SQL engine somewhere in the loop the whole rendering
half of the compiler would be vouched for by parsing alone, which is weaker than it sounds. One plan
computed locally and pushed down as SQL, rows compared, is a cheap regression net for one bug class -
a statement that is valid SQL with different semantics. It caught two real disagreements the first
time it ran, both shallow: column labels and row order.
The argument that carries this is not that DataFusion is fast, or good. It is that for local
execution DataFusion generates no SQL at all. You build a logical plan and execute it over Arrow.
Every bug in the list above is a bug an adapter that never renders SQL cannot have - not one it is
less likely to have. That is a whole class of defect removed from the path that actually runs, which
is a different and better claim than a performance one.
What made it possible: the port carries a plan¶
This is the part worth reading twice, because it is the actual change and the engine is the consequence of it.
Warehouse used to take a rendered statement. That signature quietly asserted that every data
system speaks SQL, and one does not: an in-process engine executes a logical plan over Arrow and
never sees a string. So the port takes a QueryPlan, and how to execute it is the adapter's own
business - render a statement and send it, or build a plan of its own.
The plan therefore moved into sutura-domain, because a port speaks domain types, and a port naming
a compiler type would invert the direction
the layout exists to keep. QueryPlan and
Warehouse there are the primary statement of this, and their module documentation says it at the
length it deserves.
Two smaller properties fall out, and they are what makes two adapters comparable at all:
- A plan still holds no SQL, and its serialized form is what a golden snapshot pins. What we decided shows up as a reviewable diff rather than as a different number.
- The result labels are defined once, on the plan. An adapter that builds an Arrow schema and an adapter that renders a projection cannot disagree about what the columns are called, because neither of them decides.
What this costs¶
Stated plainly, because the weight is real and a reader deciding whether to keep this should see it.
It is the heaviest thing in the repository. Cargo.lock held 206 packages before this adapter,
and a bare datafusion measured at roughly 281, so one adapter adds about a third again as many
packages as everything else in the workspace resolved to put together. It brings arrow and tokio with
it.
It lives in an adapter crate only. The dependency-boundary check holds sutura-domain to serde,
thiserror and the proc-macro chain their derives need, over the whole transitive tree, and that does
not change: datafusion, arrow and tokio are three of the names it exists to fail on. So the
domain still compiles nothing heavy, and its test suite is still the inner loop.
DataFusion's own SQL unparser has known dialect defects, DATE_TRUNC argument order and
identifier quoting among them. That is not an argument against using it for local execution, where it
emits no SQL. It is precisely why rendering for a remote data system stays with the dialect layer
rather than moving to DataFusion. The reference implementation of this shape says the same thing from
the other side: wren-core is DataFusion-based, and a prior investigation found its dialect
correctness actually living downstream in Python rather than in its Rust core. Adopting DataFusion
therefore buys an execution engine and not a dialect layer, and planning as though it bought both
would be planning on somebody else's Python.
There is real version skew between DataFusion and datafusion-federation. One more reason
federation is later, on top of the reason that already governed it: federation is a second identity
to satisfy, and
a plan that cannot run as one subject in both places
is refused rather than run partly as somebody else.
What does not change¶
| Guarantee | Still held by |
|---|---|
| A mono answer resolves to one data system | The plan stage's source set. One is a mono plan; exactly two are split into a fact leg and a lookup leg (answer executes them where the registered adapter declares EXECUTES_LEGS - this engine does - and refuses as FederationNotExecutable where it does not); three or more refuse as PlanSpansTooManySources. A second data system reachable from one process is federation once it is split, and does not merge with a mono plan by being convenient |
| We never re-parse SQL we did not generate | Stronger here rather than weaker: an adapter that emits no SQL has none to re-parse, and the dialect layer's transpile feature is still not compiled, so a call to it does not build |
| No value from a question reaches the statement as text | For an adapter that renders, GeneratedQuery keeps the statement and the parameters in separate fields with no constructor that merges them. For an adapter that renders nothing there is no text for a value to reach at all: the plan carries typed parameters and each predicate names the one it binds by index |
| The domain acquires no framework dependency | The dependency-boundary check, over the whole transitive tree, unchanged. The plan moving into the domain moved a type, not a dependency: QueryPlan names no engine |
| No result cache | Nothing here adds one. Materializing into Arrow is a thing this class of engine is good at, and it is the half of the neighbouring project we decline - see below |
| Refusal is a result, not an error | Unchanged. An adapter's failure is its own typed error; a question that may not be asked is still refused before an adapter is reached |
| Every generated statement is well formed in the dialect it was generated for | Unchanged for the adapters that generate one: the goldens parse each statement with its target dialect, parse only, never re-emitting. Narrower than "the data system accepts it", and deliberately so - the dialect layer's parser is not gated on the dialect for every construct, so acceptance is vouched for by execution rather than by parsing. For an adapter that generates nothing there is no statement to parse, and what stands in its place is the differential test below: the same plan executed locally and pushed down as SQL, rows compared |
| Every query runs as the calling principal | Not held on this path, and this decision does not change that either way. A local file has no login, so there is nobody else to be, and CredentialBroker is still absent |
Consequences¶
- The local path stops being where dialect bugs are found, and that is a loss as well as a gain. Executing against a local file was the cheapest way to exercise the generator end to end. With the local path no longer rendering SQL, the goldens carry that weight alone: they are what parse every statement in its target dialect, and they are now the only thing that does before a real remote data system is in the picture.
- One plan, answered both ways, rows compared. Built, as
crates/sutura-app/tests/differential.rs: the same question over the same files, once executed locally over Arrow by the engine and once rendered as SQL and pushed down to a data source. Worth reading for what it does not claim - its own module doc calls it a cheap regression net rather than a proof of correctness, because the two sides are not two implementations of one thing. What it covers is the one class of bug nothing else here catches: a rendered statement that is valid SQL with different semantics - a truncated date coming back as a timestamp, an integer division silently truncating, a week starting on the wrong day. It caught two real disagreements the first time it ran, both shallow: the column labels, and the row order. - The port is synchronous, so the adapter owns its runtime. DataFusion's execution is async and
tokio arrives with it;
Warehouse::executeis a plain function, so the adapter blocks internally. That keeps tokio out of the domain and out of the service, at the price of an adapter that has to be careful about being called from inside somebody else's runtime. - Unlike DuckDB, it links no C library. So it does not constrain the musl artifacts the way
libduckdbdoes, and it needs no feature to sit behind: it is a plain dependency ofsutura-cliand ships in every artifact.DuckDBwent the other way and is a development dependency, ofsutura-app's test suite - present to prove the SQL we render actually runs, not to be linked into an artifact that has no musllibduckdbto link against.
Alternatives considered¶
Keep the hand-written generator and DuckDB only. The least new weight by a wide margin, and the option that needs no defence on size. Rejected because it leaves dialect correctness for local execution as our burden, and that is exactly where the bugs were: four of them in one sitting, none of them about what a metric means. An engine that needs no dialect removes the burden instead of making us better at carrying it.
Adopt Spice as well. Rust, Apache-2.0, DataFusion-based, and its federation and connector layer is the most exercised implementation of the stage we want next, which makes this genuinely attractive. The acceleration half we cannot take, and not as a matter of configuration: a materialized copy is read under whoever refreshed it, so under row-level security it is a cross-user leak with a refresh schedule. Its front door is also SQL, where ours has no field for one. So it stays a reference for the federation stage rather than becoming a dependency.