Disabling Joins in DataFusion with Custom Optimizer Rules
I wanted to start making changes to Apache DataFusion, and I needed a first project small enough to finish but big enough to make me read real code. I picked a slightly silly one: turn off joins. Any query that needs a join should fail with a clear error.
Nobody wants a query engine without joins. The point was that joins show up in more places than you would guess, so chasing them down forces you through most of the planner. That turned out to be true, and I learned more from the places where my plan was wrong than from the parts that worked.
The plan I started with
My first sketch had three steps:
- Fail at the SQL parser. Low hanging fruit.
- The planners sometimes rewrite queries into joins (a cross product, for example), so tell them to stop doing that.
- Brace for a noisy test suite: wrong results, missing plan nodes, that kind of thing.
All three changed.
The parser idea died first. DataFusion doesn’t own its SQL parser. Parsing is done by the external sqlparser-rs crate, so failing there means forking a different project. The next layer down, SqlToRel in datafusion/sql, turns the parsed SQL into a logical plan, and I thought about putting the check there instead. Then I remembered that SQL is only one way into DataFusion. The DataFrame API builds logical plans directly, and so does datafusion-substrait when it reads a Substrait plan. A check in SqlToRel would only ever see SQL. It could give SQL users a nicer early error, but it couldn’t enforce anything.
The second idea was wrong in a more interesting way. A cross product doesn’t get rewritten into a join. It already is one. SELECT * FROM a, b becomes a Join node with no condition (DataFusion folded the old CrossJoin plan node into Join a while ago). And “tell the planner not to make joins” falls apart when you ask what it should make instead. For a lot of queries a join is the only way DataFusion knows how to compute the answer. Blocking the rewrite doesn’t make those queries run without a join; they just fail later, with a worse error message.
So the contract got simpler: any query that needs a join fails, with an error that says so.
The third worry mostly went away once I decided not to fork anything. DataFusion is built to be extended. You can register your own optimizer rules on a session, and the rest of the engine, tests included, never notices. I decided to write two rules: a logical OptimizerRule first, then a PhysicalOptimizerRule, and compare how each one behaves.
Version one: a logical optimizer rule
The rule looks at each node in the logical plan and refuses joins:
| |
Returning Some(ApplyOrder::TopDown) tells the optimizer to walk the tree for me and call rewrite on every node, root included, so the rule only has to look at the node it’s given. AsOfJoin is its own LogicalPlan variant on main, so it needs matching alongside Join.
Registered with ctx.add_optimizer_rule(...), it did what I wanted for a simple query. Before the rule, the optimized plan looked like this:
| |
After it, I got:
| |
I also had to decide whether to throw an error or rewrite the plan. An optimizer rewrite has to preserve the query’s results, and there’s no join-free plan that computes a join. Any “rewrite” would be a join under another name or a wrong answer, so the rule throws.
Where joins come from when you don’t write JOIN
This was the part I enjoyed most. Several queries get rejected even though they never say JOIN:
SELECT * FROM customers, ordersis a join from the moment the plan is built.INTERSECTandEXCEPTare also built as joins right away. Indatafusion/expr/src/logical_plan/builder.rs,LogicalPlanBuilder::intersectbuilds aLeftSemijoin andexceptbuilds aLeftAntijoin. I didn’t expect that one at all.IN (SELECT ...),EXISTS (...)and correlated scalar subqueries start out as subquery expressions. The optimizer then rewrites them into semi joins or left joins (the rules live indecorrelate_predicate_subquery.rsandscalar_subquery_to_join.rsindatafusion/optimizer). DataFusion has no way to run those without a join, so the rule rejects them too. Uncorrelated scalar subqueries are the exception: by default (theenable_physical_uncorrelated_scalar_subqueryoption) DataFusion runs them with a dedicatedScalarSubqueryExecoperator, no join involved.
That ordering detail matters. Subquery joins don’t exist until partway through optimization, so a rule that runs earlier can’t see them. Custom optimizer rules get appended after the built-in ones, which puts mine after decorrelation.
The join that got away
Running after the built-in rules has a downside, and I found it with this query:
| |
It has JOIN right there in the text, and my rule let it through. Here’s the plan my rule saw:
| |
The orders table is gone completely, along with the join. That’s the built-in EliminateJoin rule at work (datafusion/optimizer/src/eliminate_join.rs). It noticed that no column from orders is used above the join and that DISTINCT makes duplicate rows irrelevant. A left join keeps every customer anyway, so orders can’t affect the result, and the rule replaced the join with its left input. Separately, DISTINCT became a group-by with no aggregates, which is how DataFusion runs it.
The rewrite is correct, and my rule did its job: by the time it ran, the plan had no join in it.
Picking a contract
This query forced me to decide what “disable joins” means. I had two options.
Option A: no join is ever executed. Keep the rule where it is. The DISTINCT query passing is correct, because the engine never runs a join for it.
Option B: no query that uses a join is accepted. Then the check has to run before the optimizer, as an AnalyzerRule, while the join is still in the plan. Analyzer errors also always propagate, which helps with a problem I’ll get to below. The cost is that subqueries haven’t been decorrelated yet at that point, so an analyzer rule would also need to reject IN, EXISTS and scalar subquery expressions by hand.
I went with option A. It describes what the engine does instead of what the user typed, and it was a smaller rule.
The hole: skip_failed_rules
DataFusion has a setting, datafusion.optimizer.skip_failed_rules. It’s false by default, which is why I saw my error. When it’s true, the optimizer treats a rule error as “this optimization didn’t work out”: it logs the error, restores the previous plan and keeps going. The join runs.
Anyone can turn that on, including with a SQL SET. A logical optimizer rule is a fine place to optimize and a weak place to enforce, because the optimizer is allowed to give up on it. That gap is the main reason for a second rule.
Version two: a physical optimizer rule
The physical optimizer doesn’t have that escape hatch. In physical_planner.rs, optimize_physical_plan wraps any rule error with the rule’s name and returns it, every time. A physical rule also looks at the plan that will run.
The rule has the same shape as the logical one, with two differences. ExecutionPlan is a trait object, not an enum, so you can’t match on it; you check each node’s concrete type at runtime. And optimize is called once with the whole plan, so the rule walks the tree itself.
| |
Wiring it up
ctx.add_optimizer_rule only takes logical rules, and SessionContext has no method for physical ones. A physical rule goes in when the session state is built. Use with_physical_optimizer_rule, which appends to the built-in rules; the plural with_physical_optimizer_rules replaces the whole list and drops every built-in optimization.
| |
collect() and show() go through the same steps before executing, so the rule fires there too. With the logical rule switched off, this one caught the joins on its own.
What I took away
I expected a small project about one rule. Instead I got a decent map of how a query moves through DataFusion: analyzer, logical optimizer passes, physical planning, physical optimizer. Along the way I learned that rule order decides what each rule can see, that apply_order decides who walks the tree, and that the same check behaves differently depending on which layer it runs in. The logical rule is simple and future-proof but can be skipped. The physical rule can’t be skipped but has to list every join operator by name.