Skip to content

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

Merged
adriangb merged 5 commits into
apache:mainfrom
adriangb:partitioned-filter-cancel-as-empty
Nov 12, 2026
Merged

adriangb merged 5 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

@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Nov 4, 2026

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

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.

@adriangb

Copy link
Copy Markdown
Contributor Author

@jayzhan211 #27091 merged now :)

@adriangb
adriangb force-pushed the partitioned-filter-cancel-as-empty branch from 8215a21 to 291e472 Compare November 12, 2026 06:46

@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 LGTM, but I think you need to fix conflicts, not compile after rebase

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 <[email protected]>
@adriangb
adriangb force-pushed the partitioned-filter-cancel-as-empty branch from 291e472 to b087758 Compare November 12, 2026 15:10
@adriangb
adriangb enabled auto-merge November 12, 2026 15:12
@adriangb
adriangb added this pull request to the merge queue Nov 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 12, 2026
@adriangb
adriangb force-pushed the partitioned-filter-cancel-as-empty branch from b087758 to 9238d50 Compare November 12, 2026 15:50
@adriangb
adriangb enabled auto-merge November 12, 2026 15:51
@adriangb
adriangb added this pull request to the merge queue Nov 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 12, 2026
@adriangb
adriangb added this pull request to the merge queue Nov 12, 2026
@adriangb
adriangb removed this pull request from the merge queue due to a manual request Nov 12, 2026
adriangb and others added 4 commits November 12, 2026 09:55
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 <[email protected]>
…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 <[email protected]>
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 <[email protected]>
The inline comment explained why a canceled partition's keys cannot matter
in terms of report delivery. It now uses the same plain wording as the
cancellation docs on `BuildReportHandle`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@adriangb
adriangb force-pushed the partitioned-filter-cancel-as-empty branch from 9238d50 to 9b022b8 Compare November 12, 2026 16:57
@adriangb
adriangb added this pull request to the merge queue Nov 12, 2026
Merged via the queue into apache:main with commit 954552d Nov 12, 2026
44 checks passed
@adriangb
adriangb deleted the partitioned-filter-cancel-as-empty branch November 12, 2026 20:07
@adriangb

Copy link
Copy Markdown
Contributor Author

Thanks @jayzhan211 !

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 v57.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: TPCH 18 query hangs

2 participants