Skip to content

fix: reject probe rows routed to a canceled hash join partition - #27092

Open
adriangb wants to merge 8 commits into
apache:mainfrom
adriangb:partitioned-filter-cancel-as-empty
Open

adriangb wants to merge 8 commits into
apache:mainfrom
adriangb:partitioned-filter-cancel-as-empty

Conversation

@adriangb

@adriangb adriangb commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

A Partitioned hash join cancels one of its partitions when a parent operator drops that partition's stream before it reports its build side. For example, a parent join partition whose own build side is empty drops its probe-side stream unpolled. The dynamic filter keeps every probe row routed to a canceled partition, because that partition's build keys are unknown.

Keeping those rows buys nothing. A stream starts to probe only after its report is delivered, so a canceled stream has emitted no row and never will. The shared probe input still reads the rows routed to it, and RepartitionExec then discards them. Keeping them also costs something:

What changes are included in this PR?

Three commits:

  1. test: Two dynamic filter tests checked range routing for -0.0 and for composite nullable keys through a canceled partition's CASE branch. The commit moves both cases into RangeRouter tests, before the next commit removes that branch.
  2. fix: A canceled partition counts as empty, so the filter rejects the rows routed to it. The commit rewrites the tests that asserted the old behavior.
  3. refactor: The CASE fallback has no input left. The commit removes it, the per-partition filters it used, and the test assertions that a filter is not a CASE. When exactly one partition holds rows, the filter is still that partition's own IN list or hash lookup, so the probe scan keeps bloom filter pruning.
  4. fix: Restores the branch for a lone non-empty partition, which the refactor first dropped. Adds a test for it and for a canceled partition next to live ones, and removes the remaining CASE wording.

This is +155 / −345 lines.

What is the testing strategy for this PR?

  • The new RangeRouter tests keep the routing cases that the removed tests covered.
  • partitioned_canceled_and_empty_partitions_reject_every_row and partitioned_canceled_partition_does_not_widen_filter cover the new behavior.
  • test_partitioned_dynamic_filter_reports_empty_canceled_partitions, the regression test for bug: TPCH 18 query hangs #21625, still passes.
  • All datafusion-physical-plan tests, all sqllogictests, and core_integration pass.

I also checked the behavior on a query that cancels partitions. In SELECT x.k FROM (SELECT big.k FROM mid JOIN big ON mid.k = big.k) x WHERE x.k IN (SELECT k FROM tiny) with 4 partitions, the parent RightSemi join cancels two child partitions. The result is the same with and without this PR.

Are there any user-facing changes?

No.

🤖 Generated with Claude Code

adriangb and others added 5 commits November 4, 2026 09:15
…er keys

No hj query runs a `Partitioned` join on integer keys whose build side is
large enough for a hash table or an `ArrayMap` per partition. So the suite
does not measure how the build map type changes such a join.

This adds two queries over TPC-H `orders`, joined to itself on `o_custkey`
in `Partitioned` mode. Q26 builds from the orders of the first 40K
customers: dense keys, about 10 rows per key. Q27 builds from customers
whose key is a multiple of 37: sparse keys. Both probe 15M rows with about
a 2.7% hit rate. The runner now also registers `orders`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `Partitioned` hash join merges its partitions' build maps into one routed
`hash_lookup` filter. The routed lookup accepted only hash tables, so a join
whose partitions built an `ArrayMap` (dense integer keys) fell back to a
`CASE` over one filter per partition.

The routed lookup now probes an `ArrayMap` partition with the key values of
the rows routed to it, because an `ArrayMap` is keyed by value, not hash.
Every non-empty partition can now merge. The `CASE` fallback remains only
for canceled partitions. The routed lookup fuzz test now builds an
`ArrayMap` for about half of the partitions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two hash join dynamic filter tests checked these range routing cases
through the `CASE` branch of a canceled partition. The next commit makes a
canceled partition reject its rows, which leaves those tests nothing to
route. This adds both cases as `RangeRouter` tests, where they belong: a
`0.0` split point separates `-0.0` from `0.0`, and each key of a composite
key places its NULLs by its own `nulls_first`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A `Partitioned` hash join marks a partition canceled when its stream is
dropped before it reports its build data. The dynamic filter kept every
probe row routed to a canceled partition, because the partition's build
keys were unknown. That choice was safe but bought nothing: a stream probes
only after its report is delivered, so a canceled stream has emitted no row
and never will. Every row routed to it is discarded either way.

It also cost something. A canceled partition forced the routed `CASE`
fallback instead of the merged lookup. Under `NullEqualsNull` it also made
every live partition keep probe-side NULL rows.

A canceled partition now counts as empty. The filter drops the flag that
tracked cancellation and the `ELSE true` it fed. The `CASE` fallback is now
unreachable. The next commit removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ilter

The partitioned dynamic filter built a `CASE` over one filter per partition
when a partition could not merge into the routed lookup. After the previous
two commits every partition merges: an `ArrayMap` is routed, and a canceled
partition counts as empty. The `CASE` builder, its per-partition filters,
and the single-partition branch had no input left to handle. One
non-empty partition already becomes a direct lookup in the merged path.

This removes that code and the test assertions that a filter is not a
`CASE`. No behavior changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Nov 4, 2026
…itioned-filter-cancel-as-empty

# Conflicts:
#	datafusion/physical-plan/src/joins/hash_join/partitioned_hash_eval.rs
#	datafusion/physical-plan/src/joins/hash_join/shared_bounds.rs

@jayzhan211 jayzhan211 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.

@adriangb thanks for this! Treating a canceled partition as empty looks correct to me: I compared results against the filter turned off and against main on queries that cancel partitions, and found no differences.

The one thing that needs to change is the single-partition branch, covered inline. Could you also update the "No behavior changes" line in the PR description once that's settled?

Optional cleanup: a few comments and names still describe the CASE: shared_bounds.rs around lines 225 and 487, exec.rs around line 10021, and the test names partitioned_hash_map_partitions_route_without_case and partitioned_one_real_partition_with_rest_empty_skips_case.

With every partition merged into one routed lookup, a build where only
one partition holds rows lost its IN list. The merged IN-list limit counts
duplicate rows, so one key with many build rows became a hash_lookup and
the probe scan lost bloom filter pruning. This restores the branch that
uses that partition's own filter, and adds tests for it and for a
canceled partition next to live ones.

It also drops the remaining comments and test names that describe the
removed CASE, and documents why HashExpr stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayzhan211
jayzhan211 previously approved these changes Nov 7, 2026
@jayzhan211
jayzhan211 dismissed their stale review November 7, 2026 03:24

Holding this approval until #27091 merges, because this branch includes its commit.

…itioned-filter-cancel-as-empty

# Conflicts:
#	datafusion/physical-plan/src/joins/hash_join/partitioned_hash_eval.rs
#	datafusion/physical-plan/src/joins/hash_join/shared_bounds.rs
@adriangb

Copy link
Copy Markdown
Contributor Author

@jayzhan211 #27091 merged now :)

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.

bug: TPCH 18 query hangs

2 participants