Skip to content

fix: InterleaveExec fallback to UnionExec when children partitioning diverges - #21827

Closed
zhuqi-lucas wants to merge 1 commit into
apache:mainfrom
zhuqi-lucas:fix/interleave-exec-fallback
Closed

zhuqi-lucas wants to merge 1 commit into
apache:mainfrom
zhuqi-lucas:fix/interleave-exec-fallback

Conversation

@zhuqi-lucas

@zhuqi-lucas zhuqi-lucas commented Apr 24, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

InterleaveExec::with_new_children panics with an assertion error when children's output partitioning diverges after optimizer rewrites. This happens in practice when multiple physical optimizer passes modify the plan between when InterleaveExec is created and when with_new_children is called.

We hit this in production with a complex UNION ALL query (5 subqueries with JOINs across partitioned tables, single-file tables, and materialized views). Our custom optimizer chain runs EnforceDistribution twice — the first pass creates InterleaveExec, later optimizers modify children's partitioning, then the second pass triggers the assertion panic.

What changes are included in this PR?

InterleaveExec::with_new_children now gracefully falls back to UnionExec when children are no longer interleavable, instead of panicking. A warning is logged when the fallback occurs.

Correctness is preserved — EnforceDistribution will add RepartitionExec as needed. Only the interleave optimization is lost.

Are these changes tested?

Yes, two unit tests added:

  • test_interleave_fallback_to_union_on_partitioning_mismatch — verifies fallback to UnionExec when one child's partitioning changes from Hash to RoundRobin
  • test_interleave_preserved_when_partitioning_matches — verifies InterleaveExec is preserved when children's Hash partitioning remains consistent

Copilot AI review requested due to automatic review settings April 24, 2026 07:48
@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Apr 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR prevents InterleaveExec::with_new_children from failing when optimizer rewrites change children’s output partitioning, by falling back to UnionExec instead of triggering an internal assertion failure. This keeps query execution correct in cases where plans evolve across multiple physical optimizer passes, at the cost of losing the interleave optimization.

Changes:

  • Replace the assert_or_internal_err!(can_interleave(..)) in InterleaveExec::with_new_children with a conditional fallback to UnionExec::try_new, and emit a warning when falling back.
  • Add unit tests covering both the fallback case (partitioning mismatch) and the preservation case (partitioning remains compatible).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…diverges

When optimizer rewrites (e.g. join_selection changing join modes, or
additional EnforceDistribution passes) modify InterleaveExec children's
plan structure, their output partitioning may no longer be consistent.
Previously this caused an assertion panic. Now it gracefully falls back
to UnionExec — correctness is preserved, only the interleave
optimization is lost.

Closes apache#21826
@zhuqi-lucas
zhuqi-lucas force-pushed the fix/interleave-exec-fallback branch from f57d747 to 16d1605 Compare April 24, 2026 07:58
@zhuqi-lucas
zhuqi-lucas requested review from alamb and xudong963 April 24, 2026 08:04
self: Arc<Self>,
children: Vec<Arc<dyn ExecutionPlan>>,
) -> Result<Arc<dyn ExecutionPlan>> {
// New children are no longer interleavable, which might be a bug of optimization rewrite.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this comment says, I think another solution is to address it in the optimizer.
This might be the root cause. Once identified, we can decide the best option: either avoiding certain rewrites on InterleaveExec or falling back to UnionExec.

@zhuqi-lucas zhuqi-lucas left a comment •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this comment says, I think another solution is to address it in the optimizer.
This might be the root cause. Once identified, we can decide the best option: either avoiding certain rewrites on InterleaveExec or falling back to UnionExec.

Good point @jonahgao . In our case the root cause is our custom optimizer chain that runs EnforceDistribution twice — the second pass modifies children that were already organized into an InterleaveExec by the first pass. This is specific to our setup, not a bug in the default DF optimizer chain.

However, the assertion in with_new_children is still fragile — any transform_up based optimizer that changes a child's output partitioning will trigger a panic. The fallback to UnionExec is a defensive fix that preserves correctness regardless of which optimizer causes the divergence. The existing comment in the code already acknowledges this possibility: "New children are no longer interleavable, which might be a bug of optimization rewrite."

@jonahgao

Copy link
Copy Markdown
Member

However, the assertion in with_new_children is still fragile — any transform_up based optimizer that changes a child's output partitioning will trigger a panic.

assert_or_internal_err only returns an internal_err, and it seems like the optimizer didn’t propagate the error correctly, causing a panic.

I think it is appropriate to return an error here. It should be up to the user (e.g., the optimizer rule) to decide whether to fall back or to preserve the partitioning properties of InterleaveExec’s children.

@zhuqi-lucas

Copy link
Copy Markdown
Contributor Author

assert_or_internal_err only returns an internal_err, and it seems like the optimizer didn't propagate the error correctly, causing a panic.

The error is propagated correctly — it bubbles up through transform_up and fails the entire optimizer rule. The problem is that with_new_children is called internally by transform_up during tree traversal, so the optimizer rule has no opportunity to catch the error and fall back. The error propagates all the way up and kills the optimization pass.

I think it is appropriate to return an error here. It should be up to the user (e.g., the optimizer rule) to decide whether to fall back

I agree in principle, but in practice the optimizer rule never gets that chance. Here's the call chain:

optimizer_rule.optimize(plan)
  → plan.transform_up(closure)
    → node.with_new_children(modified_children)  // error here
    → Err propagates up through transform_up
  → Err propagates up, entire rule fails

There's no hook point where the optimizer rule can intercept the error from with_new_children and decide to fall back. The transform_up machinery doesn't expose per-node error handling.

Falling back to UnionExec inside with_new_children is safe because UnionExec is always a valid replacement — it's semantically correct, just without the interleave optimization. EnforceDistribution will add RepartitionExec as needed downstream.

@jonahgao

Copy link
Copy Markdown
Member

I agree in principle, but in practice the optimizer rule never gets that chance. Here's the call chain:

Thanks @zhuqi-lucas for this elaboration. For this transform_up style traversal, it seems we can only fall back.
But I still think optimizer rewrites shouldn’t panic if an error occurs in the middle.

Falling back to UnionExec inside with_new_children is safe because UnionExec is always a valid replacement

I have no doubt about this. My concern is that this fallback might be a bit aggressive. It could potentially hide bugs similar to #11536 by switch err to warn log, and it could also lead to optimization loss in some other scenarios where keeping InterleaveExec would be a better choice.

Let’s wait to hear more opinions from other reviewers.

@zhuqi-lucas

Copy link
Copy Markdown
Contributor Author

I agree in principle, but in practice the optimizer rule never gets that chance. Here's the call chain:

Thanks @zhuqi-lucas for this elaboration. For this transform_up style traversal, it seems we can only fall back. But I still think optimizer rewrites shouldn’t panic if an error occurs in the middle.

Falling back to UnionExec inside with_new_children is safe because UnionExec is always a valid replacement

I have no doubt about this. My concern is that this fallback might be a bit aggressive. It could potentially hide bugs similar to #11536 by switch err to warn log, and it could also lead to optimization loss in some other scenarios where keeping InterleaveExec would be a better choice.

Let’s wait to hear more opinions from other reviewers.

It makes sense, thanks @jonahgao for clarify, let's wait more reviewers to take a look.

@alamb alamb left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this @zhuqi-lucas and @jonahgao

check_if_same_properties!(self, children);
Ok(Arc::new(InterleaveExec::try_new(children)?))
} else {
warn!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @jonahgao that flagging this with a warning only is non ideal because:

  1. It will likely get ignored by many people
  2. If someone sees this warning in their logs they can't do anything about it (it is likely a bug in DataFusion)

I would personally suggest:

  1. Revert this change (so the error is returned)
  2. At the callsite where we may change an InterleaveExec so it's chidlren are no longer interleavable (aka in EnforceDistribution) rewrite any InterleaveExec back to union

@zhuqi-lucas

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback @alamb @jonahgao. Makes sense — fixing this at the EnforceDistribution callsite is cleaner than silently falling back in with_new_children. We've addressed this internally in our fork for now. Will revisit if we find the right approach for EnforceDistribution.

jayzhan211 added a commit to jayzhan211/datafusion that referenced this pull request Sep 6, 2026
…ibution (apache#24959)

## Which issue does this PR close?

- Part of apache#21826.

## Rationale for this change

`EnsureRequirements` fails with an internal error when the plan it is
given
already contains an `InterleaveExec` whose children no longer share a
hash
(or range) partitioning:

```
Internal error: Assertion failed: can_interleave(children.iter()):
Can not create InterleaveExec: new children can not be interleaved.
```

`InterleaveExec` declares no distribution requirement of its own. It is
only
valid while its children happen to share the same partitioning, which is
why
`ensure_distribution` creates it from a `UnionExec` in the first place.
Once
it exists, several rewrites can take that partitioning away: this rule
strips
`RepartitionExec`s that nothing requires, join key reordering changes
the hash
expressions, `JoinSelection` side swaps change output partitioning, and
so on.
`PlanContext::with_new_children` rebuilds every parent from its updated
children while walking the tree, so the interleave is rebuilt over the
changed
children *before* the rule's closure sees the node, and the rebuild
fails.

This affects any pipeline that runs the rule more than once,
re-optimizes a
deserialized plan, or runs `EnforceDistribution` after other rewrites
(the
setup reported in apache#21826). It also breaks the idempotency the rule
advertises.
The discussion on apache#21827 concluded that the fix belongs at the
`EnforceDistribution` call site rather than as a silent fallback in
`InterleaveExec::with_new_children`; this PR is that fix.

## What changes are included in this PR?

- New `replace_interleave_with_union` helper in
  `ensure_requirements/enforce_distribution.rs`, which turns an
  `InterleaveExec` back into the equivalent `UnionExec`.
- `EnsureRequirements::optimize` runs it as a top-down "Phase 0" before
join
key reordering. Phase 2 then re-derives interleaves from unions wherever
the
children still qualify, exactly as it already re-derives
`RepartitionExec`,
  `CoalescePartitionsExec` and `SortPreservingMergeExec`.
- The pass is top-down on purpose: demoting a nested interleave first
would
  change its output partitioning and make the parent interleave fail to
  rebuild.
- Module docs updated with the new phase.

A minimal reproducer that fails on `main` and passes here:

```rust
// Interleave over two hash repartitions that nothing above requires.
let plan = InterleaveExec::try_new(vec![
    RepartitionExec::try_new(parquet_exec(), Partitioning::Hash(vec![col_a], 10))?,
    RepartitionExec::try_new(parquet_exec(), Partitioning::Hash(vec![col_a], 10))?,
])?;
EnsureRequirements::new().optimize(Arc::new(plan), &config)?;
```

## What is the testing strategy for this PR?

Three new tests in
`datafusion/core/tests/physical_optimizer/enforce_distribution.rs`, each
run
through the existing multi-pass harness (`DISTRIB_DISTRIB_SORT` and
`SORT_DISTRIB_DISTRIB`) so idempotency is checked as well:

- `interleave_falls_back_to_union_when_children_lose_partitioning`: the
  reproducer above; the result is a `UnionExec` over the scans.
- `interleave_fallback_still_satisfies_parent_hash_requirement`: same
input
  under a `FinalPartitioned` aggregate; the aggregate gets a single hash
  repartition above the union.
- `existing_interleave_is_kept_when_children_stay_interleavable`: an
interleave over hash partitioned aggregates is re-derived as an
interleave,
  and stays a union with `prefer_existing_union = true`.

The test harness's tree-node integrity block now applies the same
pre-pass so
it mirrors the rule. Existing `union_to_interleave` and
`union_not_to_interleave` tests are unchanged, and `union.slt` passes.

## Are there any user-facing changes?

No API changes. One behavioral note: with
`datafusion.optimizer.prefer_existing_union = true`, an `InterleaveExec`
present in the input plan is now emitted as a `UnionExec`. The rule
never
creates interleaves under that setting, and plans produced by the
default
pipeline are unaffected because `EnsureRequirements` is the only creator
of
`InterleaveExec` and runs once.
lyne7-sc pushed a commit to lyne7-sc/datafusion that referenced this pull request Sep 7, 2026
…ecutable` (apache#24963)

## Which issue does this PR close?

- Closes apache#21826. Builds on apache#24959, which handled the
`EnsureRequirements` side.

## Rationale for this change

`InterleaveExec` is only valid while every child shares the same hash
(or
range) partitioning. `EnsureRequirements` creates it from a `UnionExec`
when
that happens to hold. Any later rule that rewrites a child and changes
its
output partitioning, for example `JoinSelection` swapping a join's
sides,
makes the optimizer's tree walk rebuild the interleave over children
that no
longer match. `InterleaveExec::replace_children` asserted on that, so
the
whole rule failed:

```
join_selection
caused by
Internal error: Assertion failed: can_interleave(children.iter()):
Can not create InterleaveExec: new children can not be interleaved.
```

This is the chain reported in apache#21826: distribution enforcement builds
the
interleave, `JoinSelection` breaks it, and no rule can repair it because
the
failure happens inside the rebuild, before any rule's closure runs.

apache#24959 made `EnsureRequirements` itself immune by normalizing
interleaves back
to unions before it runs, but that does not help when the rebuild
happens
inside another rule.

The root cause is that `InterleaveExec` enforces a cross-child
distribution
property one level earlier than the rest of the framework does.
Co-partitioning
for joins is validated by
`InputDistributionRequirements::check_invariants`
only at `InvariantLevel::Executable`; a hash join rebuilt over children
that
lost their partitioning does not error at rebuild time, it becomes
temporarily
unsatisfied and is either repaired by a later distribution pass or
rejected by
`SanityCheckPlan`. This PR makes `InterleaveExec` behave the same way.

This is not the fallback proposed in apache#21827. Nothing is substituted for
a
different node and no error is downgraded to a log. The node stays an
`InterleaveExec`, the error is the same, and it is raised at the
executable
checkpoint instead of at rebuild time. The decision on how to recover is
left
to the optimizer rule, which apache#24959 already implements.

## What changes are included in this PR?

In `datafusion/physical-plan/src/union.rs`:

- `InterleaveExec::try_new` still asserts interleavability, so explicit
  construction and proto decoding keep the strict check.
- A private `try_new_unchecked` builds the node without the check.
  `replace_children` in `Recompute` mode now uses it.
- `compute_properties` only claims the shared hash / range partitioning
when
every child still has it, and reports `UnknownPartitioning` otherwise,
so
  nothing downstream can rely on a layout the node does not have.
- `InterleaveExec::check_invariants` runs the `can_interleave` check at
  `InvariantLevel::Executable`, with the same message as before.

The physical planner already checks `Always` after each rule and
`Executable`
at the end, so an unrepaired interleave is still rejected, with the same
error, by `SanityCheckPlan`.

## What is the testing strategy for this PR?

- `test_interleave_rebuild_defers_partitioning_invariant` (unit test in
`union.rs`): rebuilding over one round-robin child succeeds, `try_new`
on
the same children still fails, the node reports unknown partitioning,
the
`Always` check passes, and the `Executable` check fails with the
original
  message.
- `issue_21826_join_selection_after_distribution_pass`
  (`core/tests/physical_optimizer/enforce_distribution.rs`): the exact
reported chain. A union of two partitioned hash joins with statistics
chosen
  so `JoinSelection` swaps only one of them. On `main` this fails inside
`JoinSelection` with the error above. Here `JoinSelection` completes,
the
  interleave reports unknown partitioning, `SanityCheckPlan` rejects the
unrepaired plan, and a second `EnsureRequirements` pass produces a valid
  union that the sanity checker accepts (snapshotted).
- `interleave_broken_by_later_rewrite_is_repaired_on_next_pass`: the
same
mechanism where the disruption is a removable `CoalescePartitionsExec`,
so
the repair restores the interleave rather than demoting it, showing the
  optimization is not permanently lost.

## Are there any user-facing changes?

No API changes. Behaviorally, an `InterleaveExec` whose children lose
their
shared partitioning during optimization is now reported as an invariant
failure by `SanityCheckPlan` at the end of the pipeline instead of as an
error
from the rule that rebuilt it. The diagnostic is therefore coarser (it
names
the node, not the rule), which is the trade-off for letting rules
complete and
a later distribution pass repair the plan. Custom pipelines that run
rules
between two distribution passes no longer need a workaround.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

InterleaveExec::with_new_children panics when optimizer rewrites change children's partitioning

4 participants