Skip to content

feat: agg-on-join end-to-end IN/NOT IN/EXISTS/NOT EXISTS with null-aware semantics - #5005

Merged
mithuncy merged 8 commits into
mainfrom
fix/4911-semi-anti-agg-on-join
May 13, 2026
Merged

mithuncy merged 8 commits into
mainfrom
fix/4911-semi-anti-agg-on-join

Conversation

@mithuncy

@mithuncy mithuncy commented May 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Makes the agg-on-join path handle IN (SELECT ...), NOT IN (SELECT ...), EXISTS (SELECT ...), and NOT EXISTS (SELECT ...) end-to-end, including the NOT IN against 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:

SELECT contact_job_title, COUNT(*) AS doc_count
FROM contacts
WHERE contact_id IN     (SELECT ldf_id FROM contact_list WHERE list_id IN ('include_list'))
  AND contact_id NOT IN (SELECT ldf_id FROM contact_list WHERE list_id IN ('exclude_list'))
  AND contact_id @@@ paradedb.boolean(...)
GROUP BY contact_job_title
ORDER BY doc_count DESC LIMIT 10;

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

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

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, or Aggregate-on-join does not support Anti JOIN. Separately, un-pulled-up IN/NOT IN SubPlans in baserestrictinfo were silently dropped by the per-RI extract_quals loop, producing wrong row counts when push-down succeeded.

Walker / accept-list. build_relnode_from_node recognizes T_FromExpr (the post-pull-up parse-tree shape PG produces) and recurses into build_relnode_from_fromexpr. build_join_node extends to Semi/Anti/RightSemi/RightAnti; all four are unconditionally safe for aggregate pushdown because they never project the non-preserved side. The translator's dead JoinTypeAllowList::EquiOnly enum is dropped.

SubPlan lifting. build_scan_node classifies baserestrictinfo into search predicates / top-level SubPlans / OR-nested SubPlans. Search predicates batch into one strict extract_quals call (no silent drop). OR-nested SubPlans decline upfront. Top-level SubPlans lift via shared wrap_with_semi_anti, which now returns Result<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 that is_limit_pushdown_safe only caught for LIMIT.

Null-aware NOT IN. JoinType::Anti becomes a struct variant Anti { null_aware: bool }. The flag lives on the variant rather than as a separate JoinNode field, so (JoinType::Inner, null_aware: true) is unrepresentable in the type system. wrap_with_semi_anti constructs Anti { null_aware: is_anti } for NOT IN lifts. build_null_aware_anti_join lowers to LogicalPlan::Join with null_equality=NullEqualsNothing and null_aware=true. DataFusion's HashJoinExec then 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 a plan_position resolved once at extraction time against the just-built RelNode tree; execution-time column binding is a plan_position lookup. rti is only unique within a single PlannerInfo, so post-lift trees that mix sources from sub-PlannerInfos (e.g. SubPlans lifted by wrap_with_semi_anti) need a PlannerRootId to disambiguate. Three new shared RelNode primitives 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 bundles plan + outer_root_id into Option<FilterPlanResolution> so the two can't go out of sync.

Executor plumbing. ExprContext + PlanState are threaded from the executor's runtime into each per-relation PgSearchTableProvider. HeapFilter queries (runtime expressions like = on a pdb.literal-cast column) need a live evaluation context. Skip the ExecAssignExprContext allocation under EXEC_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::build rejects multi-column null-aware:

if exec.null_aware && on.len() != 1 {
    return plan_err!("null_aware anti join only supports single column join key, got {} columns", on.len());
}

The runtime stream code only inspects state.values[0] and left_data.values()[0]. Multi-column NOT IN therefore can't ride the null-aware fast path; this PR declines pushdown and lets PG's nodeSubplan.c::ExecHashSubPlan handle them.

Why this works without a datafusion-proto patch

Sister PR #5006 noted that datafusion-proto 53.1.0 is missing null_aware from the LogicalPlan::Join proto schema (oversight in apache/datafusion#19635; added everywhere except the logical Join proto). This bites consumers that round-trip LogicalPlan through the proto codec.

The agg-on-join path is unaffected. The agg executor builds a LogicalPlan in build_join_aggregate_plan, hands it to build_physical_plan in the same Rust process, and runs the physical plan via physical_plan.execute(...). No proto serialization. The null_aware flag travels purely through Rust struct fields from construction to execution. The proto bug only matters for the JoinScan path (which serializes its LogicalPlan for parallel leader/worker IPC) and is tracked separately in #5006.

Test plan

aggregate_join_semi_anti.sql - six tests covering the full feature surface:

  • 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
  • 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 query return non-zero rows (guards against trivially passing with zero rows for the wrong reason)

All other aggregate_join_* and join_* regress tests pass on PG 18 (cargo pgrx regress); cargo check + cargo clippy -- -D warnings clean.

Refs #4911. Sister PR #5006 covers the JoinScan-side end-to-end via the proto fork (separate dependency).

closes #4911

@mithuncy
mithuncy requested a review from a team as a code owner May 6, 2026 06:57
@mithuncy
mithuncy requested a review from philippemnoel May 6, 2026 06:57
@mithuncy
mithuncy marked this pull request as draft May 6, 2026 07:03
@mithuncy
mithuncy force-pushed the fix/4911-semi-anti-agg-on-join branch from fdaeb2f to 0e5038e Compare May 6, 2026 08:11
@mithuncy mithuncy added the cherry-pick/0.25.x Request that this PR to `main` should get an automatic cherry-pick PR to `0.25.x` after it lands. label May 6, 2026
@codecov

codecov Bot commented May 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.43678% with 59 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.88%. Comparing base (b09f4be) to head (804b07f).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...tgres/customscan/aggregatescan/datafusion_build.rs 88.37% 20 Missing ⚠️
...h/src/postgres/customscan/datafusion/translator.rs 80.68% 17 Missing ⚠️
...earch/src/postgres/customscan/joinscan/planning.rs 64.51% 11 Missing ⚠️
...stgres/customscan/aggregatescan/join_targetlist.rs 79.54% 9 Missing ⚠️
...h/src/postgres/customscan/aggregatescan/privdat.rs 50.00% 1 Missing ⚠️
.../postgres/customscan/joinscan/visibility_filter.rs 0.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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     
Files with missing lines Coverage Δ
...stgres/customscan/aggregatescan/datafusion_exec.rs 85.88% <100.00%> (+1.25%) ⬆️
...earch/src/postgres/customscan/aggregatescan/mod.rs 90.32% <100.00%> (-0.25%) ⬇️
...g_search/src/postgres/customscan/joinscan/build.rs 86.69% <100.00%> (+1.31%) ⬆️
pg_search/src/postgres/customscan/joinscan/mod.rs 91.50% <100.00%> (+0.08%) ⬆️
...arch/src/postgres/customscan/joinscan/predicate.rs 76.56% <100.00%> (ø)
...rch/src/postgres/customscan/joinscan/scan_state.rs 86.74% <100.00%> (-0.15%) ⬇️
...h/src/postgres/customscan/aggregatescan/privdat.rs 96.77% <50.00%> (ø)
.../postgres/customscan/joinscan/visibility_filter.rs 57.60% <0.00%> (-0.48%) ⬇️
...stgres/customscan/aggregatescan/join_targetlist.rs 83.09% <79.54%> (+0.21%) ⬆️
...earch/src/postgres/customscan/joinscan/planning.rs 86.24% <64.51%> (+0.32%) ⬆️
... and 2 more

... and 25 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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 mithuncy changed the title fix: accept Semi/Anti joins in agg-on-join (#4911) feat: agg-on-join end-to-end IN/NOT IN/EXISTS/NOT EXISTS with null-aware semantics May 6, 2026
@mithuncy
mithuncy force-pushed the fix/4911-semi-anti-agg-on-join branch 2 times, most recently from 8b42849 to 213dfd0 Compare May 7, 2026 08:40
…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
mithuncy force-pushed the fix/4911-semi-anti-agg-on-join branch from 213dfd0 to fc570e5 Compare May 7, 2026 09:41
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
mithuncy marked this pull request as ready for review May 7, 2026 12:49
@mithuncy
mithuncy requested a review from mdashti May 7, 2026 12:50
@stuhood
stuhood removed the request for review from philippemnoel May 7, 2026 16:11
mithuncy added 2 commits May 10, 2026 21:15
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 mdashti 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.

LGTM

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
mithuncy force-pushed the fix/4911-semi-anti-agg-on-join branch from 7340ba5 to 8cf3448 Compare May 13, 2026 04:07
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
mithuncy force-pushed the fix/4911-semi-anti-agg-on-join branch from 8b5396d to 804b07f Compare May 13, 2026 05:22
@mithuncy
mithuncy merged commit fa4b761 into main May 13, 2026
19 checks passed
@mithuncy
mithuncy deleted the fix/4911-semi-anti-agg-on-join branch May 13, 2026 05:47
@paradedb-github-bot

Copy link
Copy Markdown
Contributor

Created backport PR for 0.23.x:

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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cherry-pick/0.25.x Request that this PR to `main` should get an automatic cherry-pick PR to `0.25.x` after it lands.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Aggregates: aggregate-on-join rejects Semi/Anti joins (IN / NOT IN)

2 participants