Repository navigation
feat: agg-on-join end-to-end IN/NOT IN/EXISTS/NOT EXISTS with null-aware semantics - #5005
Merged
Merged
Conversation
mithuncy
marked this pull request as draft
May 6, 2026 07:03
mithuncy
force-pushed
the
fix/4911-semi-anti-agg-on-join
branch
from
May 6, 2026 08:11
fdaeb2f to
0e5038e
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5005 +/- ##
==========================================
+ Coverage 76.85% 77.88% +1.03%
==========================================
Files 200 200
Lines 54468 54603 +135
==========================================
+ Hits 41861 42529 +668
+ Misses 12607 12074 -533
🚀 New features to boost your workflow:
|
mithuncy
added a commit
that referenced
this pull request
May 6, 2026
Adds `joinscan_notin_null_safety.sql` — a multi-table query with a NULL-bearing inner column that exercises the JoinScan path's NOT IN handling. SQL three-valued logic requires the query to return 0 rows (any NULL in the inner key column poisons NOT IN under WHERE); plain DataFusion `LeftAnti` would silently return non-zero (NOT EXISTS semantics). The pg_search-side null_aware code that makes this query return the correct answer is supplied by PR #5005 (which lifts NOT IN SubPlans to `JoinType::Anti` with `null_aware=true`). This PR adds the end-to-end regress test that exercises that code path through JoinScan's leader-worker IPC. End-to-end correctness for this test additionally requires a patched `datafusion-proto`: upstream Apache DataFusion 53.1.0 added the `null_aware` field to `LogicalPlan::Join` (PR apache/datafusion#19635) and threaded it through the optimizer + physical plan + physical HashJoinExec proto, but missed adding it to the LogicalPlan Join proto in `datafusion/proto/proto/datafusion.proto`. The encoder destructures `LogicalPlan::Join` with `..` and silently drops the flag. Most DataFusion users don't notice because they execute logical plans in-process. pg_search's JoinScan serializes its LogicalPlan to bytes via `datafusion_proto::bytes::logical_plan_to_bytes_with_extension_codec` for Postgres' parallel-execution leader→worker IPC (see `pg_search/src/scan/codec.rs::serialize_logical_plan`), so the proto round-trip drops the flag and reverts to the silent-wrong- answer behavior. Without the patched `datafusion-proto`, this test will return 89 rows (NOT EXISTS semantics) instead of the correct 0 rows. The patch is a ~10-line follow-up to apache/datafusion#19635: add `bool null_aware = 9` to `message JoinNode`, propagate in to_proto, restore in from_proto via `Join::try_new(.., null_aware=true)`. Note: the agg-on-join path (PR #5005) does NOT need the proto fork because it executes in-process and never serializes its logical plan. Only the JoinScan path is affected by the proto bug. Dependencies (must land before this PR's CI passes): 1. PR #5005 (Semi/Anti agg-on-join unlock + Path B v2 + null_aware code) — provides the pg_search-side null_aware plumbing. 2. A patched `datafusion-proto` published as `paradedb/datafusion-proto` (or equivalent), pinned via `[patch.crates-io]` in the workspace `Cargo.toml`. Once both land, this test exercises the full chain and asserts the correct (0-row) result on JoinScan-on, with `LeftAnti` (not the swapped `RightAnti`) visible in the EXPLAIN output. Refs #4911 — JoinScan NOT IN portion.
mithuncy
force-pushed
the
fix/4911-semi-anti-agg-on-join
branch
2 times, most recently
from
May 7, 2026 08:40
8b42849 to
213dfd0
Compare
…are semantics
Push down `<aggregate> over <Semi/Anti/IN/NOT IN/EXISTS/NOT EXISTS>`
queries into a single `Custom Scan (ParadeDB Aggregate Scan)` node,
including `NOT IN` against a NULL-bearing inner where SQL three-valued
logic must return zero rows.
Pre-PR the agg-on-join walker bailed on these shapes with one of:
- `unexpected node type T_FromExpr in join tree` (post-pull-up shape)
- `aggregate-on-join does not support Semi/Anti JOIN` (allow-list)
- `Aggregate-on-join does not support Anti JOIN` (translator EquiOnly)
Plus, un-pulled-up `IN`/`NOT IN` SubPlans in `baserestrictinfo` were
silently dropped by the per-RI `extract_quals` loop, producing wrong
row counts on push-down.
## Coverage
| Query shape | Result |
|--------------------------------------|-------------------------------------|
| `IN (SELECT ...)` PG-pulled-up | Pushed down (Semi) |
| `EXISTS / NOT EXISTS` | Pushed down (Semi/Anti) |
| Single-col `IN` un-pulled-up | Lifted to Semi, pushed down |
| Single-col `NOT IN`, no NULL inner | Lifted to null-aware Anti |
| Single-col `NOT IN`, NULL inner | Lifted, returns 0 rows |
| Multi-col `NOT IN` / `IN` | Declines cleanly, PG fallback |
| OR-nested SubPlan | Declines cleanly, PG fallback |
## What changed
joinscan/build.rs: `JoinType::Anti` becomes `Anti { null_aware: bool }`.
Co-locating the flag with the variant makes `(Inner, null_aware: true)`
unrepresentable in the type system instead of relying on a runtime guard.
`JoinNode.null_aware: bool` field removed (now folded into the variant).
joinscan/planning.rs: `wrap_with_semi_anti` returns
`Result<RelNode, String>` with a site-specific reason at every former
silent-skip point (no inner final RelOptInfo, inner not pushdownable,
multi-col NOT IN, IN-with-undecodable-testexpr). Both callers (the new
agg caller and the existing JoinScan caller) propagate Err to a clean
decline. Closes a latent silent-drop in the JoinScan non-LIMIT path that
`is_limit_pushdown_safe` only caught for LIMIT queries.
aggregatescan/datafusion_build.rs: `build_relnode_from_node` handles
`T_FromExpr` (the post-pull-up shape PG produces). `build_join_node`
accept-list extends to Semi/Anti/RightSemi/RightAnti, which are
unconditionally safe for aggregate pushdown because they never project
the non-preserved side. `build_scan_node` classifies baserestrictinfo
into search / top-level SubPlans / OR-nested SubPlans, batches search
predicates into a single strict `extract_quals` call (no more silent
drop on un-translatable RIs), declines on OR-nested or unliftable
SubPlans, and lifts top-level SubPlans via `wrap_with_semi_anti`.
`populate_required_fields` switches from `(rti, attno)` lookup to
`(plan_position, attno)`. rti is only unique within a single
PlannerInfo, so SubPlan-derived sources can share an rti with the outer
scan and the old lookup matched the wrong source.
datafusion/translator.rs: `build_null_aware_anti_join` lowers
`Anti { null_aware: true }` lifts to `LogicalPlan::Join` with
`null_equality=NullEqualsNothing` and `null_aware=true`, which
DataFusion's HashJoinExec uses to emit zero rows when the probe (inner)
side has any NULL. Drops the dead `JoinTypeAllowList::EquiOnly` enum
variant; both callers always passed `All` after the agg path's allow-
list flip.
aggregatescan/datafusion_exec.rs + aggregatescan/mod.rs: plumb
`ExprContext` + `PlanState` from the executor's runtime through
`build_join_aggregate_plan` to `build_relnode_df` to `build_source_df`
into each per-relation `PgSearchTableProvider`, so HeapFilter queries
(runtime expressions like `=` on a `pdb.literal`-cast column) can
resolve. Skip the ExprContext allocation under `EXEC_FLAG_EXPLAIN_ONLY`
where no expressions execute.
## Tests
tests/pg_regress/sql/aggregate_join_semi_anti.sql:
- Test 1: `IN (SELECT ...)` pulls up to Semi -> AggregateScan
- Test 2: `EXISTS / NOT EXISTS` -> AggregateScan
- Test 3: single-column `NOT IN` un-pulled-up -> null-aware Anti lift,
AggregateScan; no NULL inner so result equivalent to plain LeftAnti
- Test 4: parity with `enable_aggregate_custom_scan = off` for Test 3
- Test 5: multi-column `(a,b) NOT IN (SELECT x,y FROM t)` declines
cleanly with a precise WARNING; PG plan runs; result matches PG
custom-scan-OFF
- Test 6: single-column `NOT IN` with a NULL-bearing inner ->
AggregateScan returns zero rows (SQL three-valued logic), parity
with PG custom-scan-OFF, plus a sanity check that removing the
NULL inner row makes the same query return non-zero rows
Note: DataFusion 53.1.0's null-aware mode is restricted to a single-
column equi-key (HashJoinExec runtime code only inspects `values[0]`);
multi-column NOT IN therefore can't ride the null-aware fast path and
is left for PG to handle. Sister PR #5006 covers the JoinScan-side
end-to-end path (which additionally needs a patched `datafusion-proto`
because upstream's logical Join proto schema is missing the
`null_aware` field).
Refs #4911.
mithuncy
force-pushed
the
fix/4911-semi-anti-agg-on-join
branch
from
May 7, 2026 09:41
213dfd0 to
fc570e5
Compare
Resolve plan_position once at extraction time on every agg-on-join targetlist ref (JoinGroupColumn, JoinAggColRef, AggOrderByEntry, FilterExpr::ColumnRef) against the just-built RelNode tree, then bind columns at execution by plan_position lookup. Closes the rti-aliasing class of bug at the targetlist layer: rti is unique only within a single PlannerInfo, so post-lift trees that mix sources from sub- PlannerInfos (e.g. SubPlans lifted by wrap_with_semi_anti) need the PlannerRootId to disambiguate. Mirrors how JoinScan already uses plan_position for output column refs. Add three RelNode primitives both scans share: source_with(root_id, rti, attno), plan_position(root_id, rti, attno), and source_at_plan_position(plan_position). Bundle FILTER plan + outer_root_id into a single Option<FilterPlanResolution> on FilterExprBuildContext so the two fields can no longer go out of sync.
mithuncy
marked this pull request as ready for review
May 7, 2026 12:49
mdashti
reviewed
May 7, 2026
Six correctness, robustness, and clarity fixes responding to Moe's review plus a segfault in `wrap_with_semi_anti`'s recursion into inner SubPlan PlannerInfos. ## Segfault on volatile-correlated NOT EXISTS / NOT IN Pre-this-commit, agg-on-join's `wrap_with_semi_anti` walked into the inner SubPlan's PlannerInfo via `collect_join_sources` to enumerate join sources. For correlated SubPlans (PG kept as-SubPlan because the correlation involves a volatile expression, set ops, etc.) this triggered `JoinSourceCandidate::estimate_rows` -> `SearchIndexReader::estimate_docs` -> `HeapFilterScorer::new`, which evaluated the inner's heap filter expression at planning time. The expression contained a `PARAM_EXEC` correlation Param with no executor context -> `ExecEvalParamExec` dereferenced null `ecxt_param_exec_vals` -> SIGSEGV. Two-layer fix: - Layer A (defensive, in `estimate_rows`): extend the existing `has_postgres_expressions` shortcut to also catch `has_heap_filters`. Heap filters need executor state to evaluate; planning-time has none. Use `total_docs` as an upper-bound fallback. Closes the latent crash for any future caller that recurses into a Param-bearing PlannerInfo. - Layer B (correctness, in `wrap_with_semi_anti`): detect correlation via `(*subplan).parParam` length > 0 BEFORE recursing. Correlated SubPlans cannot be lifted to a HashJoin's right side because HashJoin builds the inner once with no outer-row state -- the inner's filter referencing outer Params would either fail to bind in DataFusion or silently treat references as NULL. PG's `nodeSubplan` already executes correlated SubPlans correctly per outer row; declining here restores the pre-PR behaviour for these shapes. ## Moe #1: NOT EXISTS conflated with NOT IN `wrap_with_semi_anti`'s `is_anti && equi_keys.len() != 1` guard rejected both NOT IN and NOT EXISTS with the same "NOT IN with N equi-keys" error, even though uncorrelated NOT EXISTS (testexpr null, equi_keys empty by definition) should lift cleanly as plain `LeftAnti` (three-valued logic doesn't apply when there's no equality test). Fix: branch on `(*subplan).testexpr.is_null()` first. NOT EXISTS gets `Anti { null_aware: false }`; NOT IN keeps the single-column equi-key requirement and `null_aware: true`. The four reachable (is_anti, testexpr) combinations are now enumerated explicitly in the match expression with comments explaining each. ## Moe #2: distill verbose WARNING messages Five user-facing `Err(format!(...))` strings carrying RTI numbers, plan_ids, and DataFusion-internal jargon distilled to one-sentence messages. Technical detail moved to `pgrx::debug1!` with structured fields. Examples: before: WARNING: Aggregate Scan (DataFusion) not used: RTI 1 (...): aggregate pushdown declined because a SubPlan could not be lifted into a Semi/Anti join; PG will handle it. Reason: SubPlan plan_id=1: NOT IN with 0 equi-keys cannot be lifted; DataFusion's null-aware Anti join requires a single-column equi-key (table: join) after: WARNING: Aggregate Scan (DataFusion) not used: multi-column NOT IN cannot be pushed into the aggregate scan (table: join) ## Moe #3+#4: HashJoinExec physical-plan assertion + correct DF rule name The doc comment on `build_null_aware_anti_join` named the wrong DataFusion optimizer rule (`decorrelate_predicate_subquery`, which lowers subqueries to joins) for the empty-on-keys+filter -> on-keys extraction. The actual rule is `ExtractEquijoinPredicate` (`datafusion-optimizer/src/extract_equijoin_predicate.rs`). Comment updated to name the right rule and point at the file so the next reader doesn't re-derive it. Added a Rust unit test `null_aware_anti_lifts_to_hash_join_with_null_equals_nothing` that builds the same LogicalPlan shape `build_null_aware_anti_join` produces, runs `create_physical_plan`, and asserts the resulting tree contains a `HashJoinExec` with `null_equality == NullEqualsNothing`. A future DataFusion bump that disables / renames / reorders the `ExtractEquijoinPredicate` rule would silently drop the join to `NestedLoopJoinExec` with the right answer and the wrong runtime; this test catches that regression. ## Moe #5: drop rti from targetlist refs; correct doc comments `rti` on `JoinGroupColumn` / `JoinAggColRef` / `AggOrderByEntry` was dead weight: recoverable as `source_at_plan_position(plan_position).rti` and only read at three matcher sites. Dropped the field. Updated all three sites to compare `plan_position` (resolved via `plan.plan_position(outer_root_id, var.rti, var.attno)`) which is more correct under rti aliasing across sub-PlannerInfos. The HAVING build context now also threads `plan_resolution: Some(...)` so the matchers have the plan tree available. `attno`, on the other hand, is load-bearing for fast-field projection (`require_fast_field`, `resolve_fast_field`, `add_field`). Doc comments that previously labelled it "for diagnostics" now correctly describe it as load-bearing. The GROUP BY dedup map at `datafusion_exec.rs:106` previously keyed by `(gc.rti, gc.field_name)`, which would collapse rti-aliased sources from sub-PlannerInfos into a single DataFusion column. Switched to `(gc.plan_position, gc.field_name)`. ## Moe #6: detect cross-table predicates that fail translation The `T_FromExpr` recursion arm (added in fc570e5) exposed nested `FromExpr.quals` from sublink pull-up to the agg-on-join walker. Moe flagged the risk that `extract_equi_keys_from_quals` silently drops anything that isn't an equi-key, so a non-equi cross-table predicate arriving via that path would be silently ignored. Addressed via defense-in-depth at three walkers rather than a guard inside `extract_equi_keys_from_expr` itself (which would false-positive on every `WHERE a.id = b.id AND a.col > 5` query — single-table non-equi quals correctly belong to baserestrictinfo): - `walk_path_restrictinfo` (path-based) gained `PathRestrictInfo.unhandled` counting; `has_non_equi_join_quals` gates the path. - `apply_search_filter_or_decline` (parse-tree Path 2 fallback) wraps the `build_search_filter` call and returns `Err` on `None` — silent drop becomes a clean decline. The helper replaces two near-identical blocks in `extract_join_tree_from_parse` that previously did `if let Some` and fell through on None. - `build_search_filter` (`datafusion_build.rs:1316`) rejects clauses referencing RTIs that aren't in `plan.output_rtis()` — covers the multi-table-filter-against-non-preserved-Semi/Anti-side case. `extract_equi_keys_from_quals` itself has a docstring (line 624) naming the three walkers and explaining why a guard there would false-positive. ## Adversarial regress (Test 7 in aggregate_join_semi_anti.sql) Three new subtests exercising the shapes that bypass PG's normal pull-up and reach the new correlation guard: - 7a: JOIN + NOT EXISTS with `random()*0` correlation (the segfault repro) - 7b: JOIN + NOT EXISTS with UNION ALL inner subquery - 7c: JOIN + correlated IN with volatile correlation Each subtest asserts (a) no crash, (b) clean WARNING, (c) result parity between `enable_aggregate_custom_scan = on` and `off`. ## Verification - 9/9 agg-on-join + adjacent regress suites pass on pg18: aggregate_join, aggregate_join_alias, aggregate_join_coverage, aggregate_join_edge_cases, aggregate_join_fallback, aggregate_join_multitable, aggregate_join_semi_anti, aggregate_join_topk, aggregate_correlated_subquery - Issue #4911 repro: still pushes down to Custom Scan (ParadeDB Aggregate Scan) with Backend: DataFusion; result correct. - DSG Q6 (paradedb-enterprise stuhood.dsg-join): still pushes down, parity confirmed. - `cargo check`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` all clean. - `cargo test -p pg_search --lib translator::tests::null_aware_anti_lifts_to_hash_join_with_null_equals_nothing`: passes. Refs #4911 and PR #5005 review comments from @mdashti.
mdashti
approved these changes
May 12, 2026
Combine #4988's mpp_ctx param with this branch's expr_context/planstate params on build_join_aggregate_plan, build_relnode_df, build_source_df. Provider keeps main's is_parallel + set_non_partitioning_index and adds set_expr_context/set_planstate.
mithuncy
force-pushed
the
fix/4911-semi-anti-agg-on-join
branch
from
May 13, 2026 04:07
7340ba5 to
8cf3448
Compare
The merge brought in main's render_df_physical_plan (from #4988), which adds a DataFusion Physical Plan section to agg-on-join EXPLAIN output. Captured against PG18-pgrx (the only configuration CI runs pg_regress on).
mithuncy
force-pushed
the
fix/4911-semi-anti-agg-on-join
branch
from
May 13, 2026 05:22
8b5396d to
804b07f
Compare
Contributor
|
Created backport PR for
Please cherry-pick the changes locally and resolve any conflicts. git fetch origin backport-5005-to-0.23.x
git worktree add --checkout .worktree/backport-5005-to-0.23.x backport-5005-to-0.23.x
cd .worktree/backport-5005-to-0.23.x
git reset --hard HEAD^
git cherry-pick -x fa4b7613b7a49e0a05075ef635fac3a6f677fd31 |
mithuncy
added a commit
that referenced
this pull request
May 13, 2026
#4988's MPP plumbing was not backported to 0.23.x, so #5005's diff could not anchor on its context lines. Drop mpp_ctx / MppPlanContext; keep expr_context / planstate plumbing; remove the 8 #4988-only methods git dragged into the 456-line conflict block in mod.rs. Adjust aggregate_join_semi_anti.out to match what 0.23.x's explain_custom_scan emits (no DataFusion Physical Plan section). cargo check / clippy clean; aggregate_join_* and join_semi_anti* regress tests pass on pg18.
mithuncy
added a commit
that referenced
this pull request
May 13, 2026
…are semantics (#5063) # Description Backport of #5005 to `0.23.x`. --------- Co-authored-by: Mithun Chicklore Yogendra <[email protected]>
philippemnoel
pushed a commit
that referenced
this pull request
Jul 28, 2026
Adds `joinscan_notin_null_safety.sql` — a multi-table query with a NULL-bearing inner column that exercises the JoinScan path's NOT IN handling. SQL three-valued logic requires the query to return 0 rows (any NULL in the inner key column poisons NOT IN under WHERE); plain DataFusion `LeftAnti` would silently return non-zero (NOT EXISTS semantics). The pg_search-side null_aware code that makes this query return the correct answer is supplied by PR #5005 (which lifts NOT IN SubPlans to `JoinType::Anti` with `null_aware=true`). This PR adds the end-to-end regress test that exercises that code path through JoinScan's leader-worker IPC. End-to-end correctness for this test additionally requires a patched `datafusion-proto`: upstream Apache DataFusion 53.1.0 added the `null_aware` field to `LogicalPlan::Join` (PR apache/datafusion#19635) and threaded it through the optimizer + physical plan + physical HashJoinExec proto, but missed adding it to the LogicalPlan Join proto in `datafusion/proto/proto/datafusion.proto`. The encoder destructures `LogicalPlan::Join` with `..` and silently drops the flag. Most DataFusion users don't notice because they execute logical plans in-process. pg_search's JoinScan serializes its LogicalPlan to bytes via `datafusion_proto::bytes::logical_plan_to_bytes_with_extension_codec` for Postgres' parallel-execution leader→worker IPC (see `pg_search/src/scan/codec.rs::serialize_logical_plan`), so the proto round-trip drops the flag and reverts to the silent-wrong- answer behavior. Without the patched `datafusion-proto`, this test will return 89 rows (NOT EXISTS semantics) instead of the correct 0 rows. The patch is a ~10-line follow-up to apache/datafusion#19635: add `bool null_aware = 9` to `message JoinNode`, propagate in to_proto, restore in from_proto via `Join::try_new(.., null_aware=true)`. Note: the agg-on-join path (PR #5005) does NOT need the proto fork because it executes in-process and never serializes its logical plan. Only the JoinScan path is affected by the proto bug. Dependencies (must land before this PR's CI passes): 1. PR #5005 (Semi/Anti agg-on-join unlock + Path B v2 + null_aware code) — provides the pg_search-side null_aware plumbing. 2. A patched `datafusion-proto` published as `paradedb/datafusion-proto` (or equivalent), pinned via `[patch.crates-io]` in the workspace `Cargo.toml`. Once both land, this test exercises the full chain and asserts the correct (0-row) result on JoinScan-on, with `LeftAnti` (not the swapped `RightAnti`) visible in the EXPLAIN output. Refs #4911 — JoinScan NOT IN portion.
mithuncy
added a commit
that referenced
this pull request
Jul 28, 2026
…n-proto) (#5006) ## Summary Adds `joinscan_notin_null_safety.sql` -- a multi-table regress test that exercises the JoinScan path's `NOT IN` handling against a NULL-bearing inner column. Asserts the result is 0 rows (matching SQL `NOT IN` three-valued logic -- any NULL in the inner key column poisons NOT IN under WHERE) and that the EXPLAIN does not show a swapped `RightAnti` (which would lose null-aware semantics). This PR is a regress test only. The pg_search-side null_aware code that makes the test return the correct answer is supplied by sister PR #5005 -- that PR adds the `null_aware` field on `JoinNode`, lifts `NOT IN` SubPlans to `JoinType::Anti` with `null_aware=true` in `wrap_with_semi_anti`, and routes through `LogicalPlanBuilder::join_detailed_with_options(.., null_aware=true)` in the translator. ## Why this regress test needs a patched `datafusion-proto` DataFusion fully implements null-aware NOT IN -- `LogicalPlan::Join` has a `null_aware: bool` field, the optimizer routes NOT IN with nullable join keys through it, the physical planner threads it down to `HashJoinExec`, and `HashJoinExec` tracks `probe_side_has_null` to emit zero rows when needed. All of this was added in [apache/datafusion#19635](apache/datafusion#19635) and ships in DataFusion 53.1.0. But that PR missed one symmetric proto change: `null_aware` was added to the **physical** `HashJoinExecNode` proto in `datafusion-proto` but **not** to the **logical** `JoinNode` proto. The encoder destructures `LogicalPlan::Join` with `..` and silently drops `null_aware` during serialization. ```protobuf // datafusion-proto-53.1.0/proto/datafusion.proto:244 (line is the JoinNode for the LogicalPlan) message JoinNode { LogicalPlanNode left = 1; LogicalPlanNode right = 2; // ... 6 more fields ... // null_aware MISSING -- should be `bool null_aware = 9;` } ``` ```rust // datafusion-proto-53.1.0/src/logical_plan/mod.rs:1316 LogicalPlan::Join(Join { left, right, on, filter, join_type, join_constraint, null_equality, .. // <- `null_aware` falls into "the rest" and is silently dropped }) => { /* serialize without null_aware */ } ``` **pg_search's JoinScan path serializes its `LogicalPlan` to bytes** for Postgres' parallel-execution leader/worker IPC (see `pg_search/src/scan/codec.rs::serialize_logical_plan`), so the round-trip drops the flag and reverts to the silent-wrong-answer behavior. The agg-on-join path (covered by #5005) does NOT serialize the LogicalPlan -- it executes in-process -- and is unaffected. Empirically demonstrated: | Configuration | Rows returned | |---|---| | Pure PG (correct ground truth) | 0 | | pg_search JoinScan ON, no proto patch | 89 (wrong -- `LeftAnti` semantics, not `NOT IN`) | | pg_search JoinScan ON, patched datafusion-proto | 0 (correct) | ## What the test covers The query is intentionally minimal -- single outer table, single inner list table with a NULL-bombed exclude set, single-column equi-key -- chosen to exercise: 1. JoinScan accepts the lifted `NOT IN` SubPlan (via #5005's `wrap_with_semi_anti` change). 2. JoinScan's translator routes `null_aware=true` through `LogicalPlanBuilder::join_detailed_with_options(.., null_aware=true)`. 3. JoinScan serializes the LogicalPlan via `datafusion_proto::bytes::logical_plan_to_bytes_with_extension_codec` for IPC. 4. The patched `datafusion-proto` preserves `null_aware` through round-trip. 5. DataFusion's physical `HashJoinExec` runs with `null_aware=true`, refuses the `LeftAnti`->`RightAnti` swap (guarded in `join_selection`), and returns 0 rows when the probe side has NULL. ## Dependencies (must land before this PR's CI passes) 1. **#5005** -- adds the pg_search-side `null_aware` plumbing and `wrap_with_semi_anti` lift. 2. **A published patched `datafusion-proto`** -- for example a `paradedb/datafusion-proto` fork containing the missing `null_aware` field on `LogicalPlan::JoinNode` plus the symmetric `to_proto` / `from_proto` code. Pinned via `[patch.crates-io]` in the workspace `Cargo.toml`. 3. **An upstream issue / PR at apache/datafusion** as a follow-up to #19635 -- to add the missing field to the logical Join proto. Filed as [apache/datafusion#22065](apache/datafusion#22065). Once merged and released, the `[patch.crates-io]` entry can be dropped. The proto patch itself is small (~10 lines): add `bool null_aware = 9` to `message JoinNode`, propagate `null_aware` through `to_proto`'s destructure, restore via `Join::try_new(.., null_aware: true)` in `from_proto` when set. Local fork at `/tmp/datafusion-fork` works empirically end-to-end; needs to be hosted somewhere reachable for CI. ## What CI will look like | State | CI result | |---|---| | #5005 not merged, no proto patch | Test returns `89` (wrong); CI fails. | | #5005 merged, no proto patch | Test returns `89` (wrong); CI fails. | | #5005 merged, proto patch in `Cargo.toml` `[patch.crates-io]` | Test returns `0` (correct); CI passes. | This PR is in the third state on the author's machine. To merge to main, the `[patch.crates-io]` `Cargo.toml` change needs to ship as a follow-up commit on this branch once the fork is published. Refs #4911 -- JoinScan NOT IN portion. The agg-on-join NOT IN portion is in #5005.
mithuncy
added a commit
that referenced
this pull request
Sep 30, 2026
) ## What In MPP, a semi, anti, outer or mark join over range co-partitioned inputs now runs task-locally (`mode=Partitioned`) instead of broadcasting or re-shuffling its inputs, as inner joins already do. A null-aware anti join (`NOT IN`) keeps its broadcast. Fixes the semi-join blocker from #5738 (repro: `stuhood.semi-join-range`, included here as its first commit). ## Why `RangePartitioningRule` stamps the same range split points on both sides of every join, but `RangeCoPartitionedJoinRule` only flipped `CollectLeft` **inner** joins to `Partitioned`: ```rust if join.join_type() != &JoinType::Inner { return Ok(Transformed::no(node)); } ``` So a co-partitioned semi join had both sides split into ranges *and* the build side broadcast to every task: ``` HashJoinExec: mode=CollectLeft, join_type=RightSemi [Stage 1] => NetworkBroadcastExec ← users sent to every task t0: PgSearchScan: table=u, partition=user_id[-∞..34) t1: PgSearchScan: table=u, partition=user_id[34..∞) ``` ## How ```rust if join.null_aware { // was: join.join_type() != &JoinType::Inner return Ok(Transformed::no(node)); } ``` **Why this is safe.** With identical split points on the join keys, every matching pair has the same key and so falls in the same partition on both sides. Whether a row matches (inner, semi, mark) or has no match (outer, anti) is therefore decided within its own partition, and each task's result is its share of the whole join. | Join type | Task-local? | |---|---| | Inner, Semi, Mark | yes: all of a row's matches are in its partition | | Left / Right / Full outer, Anti (`NOT EXISTS`) | yes: "no match here" = "no match anywhere" | | **Null-aware anti (`NOT IN`)** | **no**: one NULL key anywhere on the build side must empty every task's result, and no task can see the other partitions | `null_aware` is only ever set on single-key `LeftAnti` joins (`HashJoinExec` validation), which ParadeDB builds for `NOT IN` (`joinscan/planning.rs`, #5005). DataFusion's own planner already requires these to stay `CollectLeft` ("Null-aware joins must use CollectLeft"). The final co-partitioning check is unchanged: after flipping, DataFusion must confirm both inputs satisfy the join's distribution, otherwise the `CollectLeft` join is kept. ## Tests `mpp_range_partitioning`: - **Scenario 7** (the repro): now `mode=Partitioned` with no broadcast stage. - **Scenario 8:** semi (`IN`), anti (`NOT EXISTS`), outer (`LEFT JOIN`), `FULL JOIN` and mark (`IN` under `OR`) joins each run `Partitioned`; their rows were checked against a serial run. The `FULL JOIN` sorts unmatched rows from both sides first, so each appears exactly once. - **Scenario 9:** `NOT IN` with a NULL `user_id` stays `CollectLeft` and returns 0 rows. With the guard removed, MPP returned 67 rows against 0 serially. Existing tests whose plans improve (rows unchanged): the `LEFT JOIN`s in `mpp_joinscan` and `range_partition_null_keys` previously re-split both range-partitioned inputs by hash over the network (2 shuffles each); they now join the matching range slices task-locally with no shuffle (`range_partition_null_keys` 2 → 0). `range_partition_null_keys` includes posts with NULL owners, which still appear exactly once. The `defer_column_fetch = off` case in `mpp_joinscan` needs a decode in a stage with no scan of `f`, so it now joins on `p.id` (not the partition key) to keep its shuffle (`mpp_joinscan` 6 → 4 shuffles in total). pg_regress (PG18): 392 passed. --------- Co-authored-by: Stu Hood <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes the agg-on-join path handle
IN (SELECT ...),NOT IN (SELECT ...),EXISTS (SELECT ...), andNOT EXISTS (SELECT ...)end-to-end, including theNOT INagainst a NULL-bearing inner case, which is the hard one because of SQL's three-valued NULL logic.Target query shape - aggregate over a join with IN/NOT IN sublinks plus a BM25 search predicate:
After this PR, this shape pushes down to a single
Custom Scan (ParadeDB Aggregate Scan)node and returns correct results in both NULL-bearing and non-NULL inner cases.Coverage
IN (SELECT ...)PG-pulled-upEXISTS / NOT EXISTSINun-pulled-upNOT IN, no NULL innerNOT IN, NULL innerNOT IN/INWhat changed
Pre-PR the agg-on-join walker bailed on Semi/Anti shapes with one of:
unexpected node type T_FromExpr in join tree,aggregate-on-join does not support Semi/Anti JOIN, orAggregate-on-join does not support Anti JOIN. Separately, un-pulled-upIN/NOT INSubPlans inbaserestrictinfowere silently dropped by the per-RIextract_qualsloop, producing wrong row counts when push-down succeeded.Walker / accept-list.
build_relnode_from_noderecognizesT_FromExpr(the post-pull-up parse-tree shape PG produces) and recurses intobuild_relnode_from_fromexpr.build_join_nodeextends toSemi/Anti/RightSemi/RightAnti; all four are unconditionally safe for aggregate pushdown because they never project the non-preserved side. The translator's deadJoinTypeAllowList::EquiOnlyenum is dropped.SubPlan lifting.
build_scan_nodeclassifiesbaserestrictinfointo search predicates / top-level SubPlans / OR-nested SubPlans. Search predicates batch into one strictextract_qualscall (no silent drop). OR-nested SubPlans decline upfront. Top-level SubPlans lift via sharedwrap_with_semi_anti, which now returnsResult<RelNode, String>; every former silent-skip path returns Err with a site-specific reason. Both callers (new agg caller, existing JoinScan caller) propagate to a clean decline. Side-effect: closes a latent silent-drop window in JoinScan non-LIMIT queries thatis_limit_pushdown_safeonly caught for LIMIT.Null-aware NOT IN.
JoinType::Antibecomes a struct variantAnti { null_aware: bool }. The flag lives on the variant rather than as a separateJoinNodefield, so(JoinType::Inner, null_aware: true)is unrepresentable in the type system.wrap_with_semi_anticonstructsAnti { null_aware: is_anti }forNOT INlifts.build_null_aware_anti_joinlowers toLogicalPlan::Joinwithnull_equality=NullEqualsNothingandnull_aware=true. DataFusion'sHashJoinExecthen emits zero rows when the probe (inner) side has any NULL, matching SQL three-valued logic.plan_position-stored targetlist refs. Every agg-on-join targetlist ref (
JoinGroupColumn,JoinAggColRef,AggOrderByEntry,FilterExpr::ColumnRef) carries aplan_positionresolved once at extraction time against the just-builtRelNodetree; execution-time column binding is aplan_positionlookup.rtiis only unique within a singlePlannerInfo, so post-lift trees that mix sources from sub-PlannerInfos (e.g. SubPlans lifted bywrap_with_semi_anti) need aPlannerRootIdto disambiguate. Three new sharedRelNodeprimitives back this and unify with how JoinScan already addresses output columns:source_with(root_id, rti, attno),plan_position(root_id, rti, attno),source_at_plan_position(plan_position). The FILTER build context bundlesplan+outer_root_idintoOption<FilterPlanResolution>so the two can't go out of sync.Executor plumbing.
ExprContext+PlanStateare threaded from the executor's runtime into each per-relationPgSearchTableProvider. HeapFilter queries (runtime expressions like=on apdb.literal-cast column) need a live evaluation context. Skip theExecAssignExprContextallocation underEXEC_FLAG_EXPLAIN_ONLY.DataFusion null-aware single-column limitation
DataFusion 53.1.0's null-aware mode is restricted to a single-column equi-key. The validation in
HashJoinExec::buildrejects multi-column null-aware:The runtime stream code only inspects
state.values[0]andleft_data.values()[0]. Multi-columnNOT INtherefore can't ride the null-aware fast path; this PR declines pushdown and lets PG'snodeSubplan.c::ExecHashSubPlanhandle them.Why this works without a
datafusion-protopatchSister PR #5006 noted that
datafusion-proto 53.1.0is missingnull_awarefrom theLogicalPlan::Joinproto schema (oversight in apache/datafusion#19635; added everywhere except the logical Join proto). This bites consumers that round-tripLogicalPlanthrough the proto codec.The agg-on-join path is unaffected. The agg executor builds a
LogicalPlaninbuild_join_aggregate_plan, hands it tobuild_physical_planin the same Rust process, and runs the physical plan viaphysical_plan.execute(...). No proto serialization. Thenull_awareflag travels purely through Rust struct fields from construction to execution. The proto bug only matters for the JoinScan path (which serializes itsLogicalPlanfor parallel leader/worker IPC) and is tracked separately in #5006.Test plan
aggregate_join_semi_anti.sql- six tests covering the full feature surface:IN (SELECT ...)pulls up to Semi -> AggregateScanEXISTS / NOT EXISTS-> AggregateScanNOT INun-pulled-up -> null-aware Anti lift, AggregateScanenable_aggregate_custom_scan = offfor Test 3(a,b) NOT IN (SELECT x,y FROM t)declines cleanly with a precise WARNING; PG plan runs; result matches PG custom-scan-OFFNOT INwith a NULL-bearing inner -> AggregateScan returns zero rows (SQL three-valued logic), parity with PG custom-scan-OFF, plus a sanity check that removing the NULL inner row makes the query return non-zero rows (guards against trivially passing with zero rows for the wrong reason)All other
aggregate_join_*andjoin_*regress tests pass on PG 18 (cargo pgrx regress);cargo check+cargo clippy -- -D warningsclean.Refs #4911. Sister PR #5006 covers the JoinScan-side end-to-end via the proto fork (separate dependency).
closes #4911