Skip to content

test: JoinScan NOT IN null-safety regress (requires patched datafusion-proto) - #5006

Merged
mithuncy merged 2 commits into
mainfrom
fix/joinscan-not-in-null-safety
Jul 28, 2026
Merged

mithuncy merged 2 commits into
mainfrom
fix/joinscan-not-in-null-safety

Conversation

@mithuncy

@mithuncy mithuncy commented May 6, 2026 •

Copy link
Copy Markdown
Contributor

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

// 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;`
}
// 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 feat: agg-on-join end-to-end IN/NOT IN/EXISTS/NOT EXISTS with null-aware semantics #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. feat: agg-on-join end-to-end IN/NOT IN/EXISTS/NOT EXISTS with null-aware semantics #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. 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
mithuncy requested a review from a team as a code owner May 6, 2026 07:03
@mithuncy
mithuncy requested a review from stuhood May 6, 2026 07:03
@mithuncy
mithuncy marked this pull request as draft May 6, 2026 07:03
@mithuncy
mithuncy force-pushed the fix/joinscan-not-in-null-safety branch from 8b84ec6 to dc90d61 Compare May 6, 2026 07:24
@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

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 78.14%. Comparing base (fb90b2b) to head (f1b4613).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #5006      +/-   ##
==========================================
+ Coverage   77.79%   78.14%   +0.35%     
==========================================
  Files         218      217       -1     
  Lines       59821    59605     -216     
==========================================
+ Hits        46538    46579      +41     
+ Misses      13283    13026     -257     

see 26 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 mithuncy self-assigned this May 6, 2026
@mithuncy
mithuncy force-pushed the fix/joinscan-not-in-null-safety branch from dc90d61 to 8a84020 Compare May 6, 2026 13:18
@mithuncy mithuncy changed the title fix: don't lift NOT IN SubPlans to LeftAnti — silent NULL-semantics divergence fix: correct NOT IN semantics with null-aware LeftAnti May 6, 2026
@stuhood
stuhood requested review from mdashti and removed request for stuhood May 6, 2026 15:51
@mithuncy
mithuncy force-pushed the fix/joinscan-not-in-null-safety branch from 8a84020 to 7d802ff Compare May 6, 2026 15:55
@mithuncy mithuncy changed the title fix: correct NOT IN semantics with null-aware LeftAnti test: JoinScan NOT IN null-safety regress (requires patched datafusion-proto) May 6, 2026
mithuncy added a commit that referenced this pull request May 7, 2026
…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 — these 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` → `build_relnode_df` → `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 added a commit that referenced this pull request May 7, 2026
…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 added a commit that referenced this pull request May 7, 2026
…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 added a commit that referenced this pull request May 13, 2026
…are semantics (#5005)

## 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:

```sql
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:

```rust
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](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).
@philippemnoel

Copy link
Copy Markdown
Member

Unless this is in a rush, I'd prefer if we get this DataFusion patch upstreamed first

@philippemnoel

Copy link
Copy Markdown
Member

DataFusion PR got merged, so we should be good to drop our patch branch here

@mithuncy

mithuncy commented May 21, 2026 •

Copy link
Copy Markdown
Contributor Author

The most recent published tag on apache/datafusion is 53.1.0 (the one currently pinned in pg_search/Cargo.toml:80). The fix landed after 53.1.0, so it lives only on main for now. cargo cannot pick it up from crates.io until the next DataFusion release? @philippemnoel @stuhood

@philippemnoel

Copy link
Copy Markdown
Member

The most recent published tag on apache/datafusion is 53.1.0 (the one currently pinned in pg_search/Cargo.toml:80). The fix landed after 53.1.0, so it lives only on main for now. cargo cannot pick it up from crates.io until the next DataFusion release? @philippemnoel @stuhood

You can pin a commit in the Cargo.toml in the meantime

@philippemnoel philippemnoel added cherry-pick/0.24.x Request that this PR to `main` should get an automatic cherry-pick PR to `0.24.x` after it lands. and removed 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. labels Jun 5, 2026
@philippemnoel

philippemnoel commented Jun 19, 2026 •

Copy link
Copy Markdown
Member

The merged PR this depends on is slated for release in DataFusion 54.1.0

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.
@philippemnoel
philippemnoel force-pushed the fix/joinscan-not-in-null-safety branch from dca3843 to b33abce Compare July 28, 2026 10:05
@philippemnoel
philippemnoel marked this pull request as ready for review July 28, 2026 10:06
@philippemnoel

Copy link
Copy Markdown
Member

We merged DataFusion 55 so this should be ready?

@philippemnoel philippemnoel added Do Not Cherry Pick PR should not be cherry-picked to other branches and removed cherry-pick/0.24.x Request that this PR to `main` should get an automatic cherry-pick PR to `0.24.x` after it lands. labels Jul 28, 2026
@mithuncy
mithuncy merged commit 28f8113 into main Jul 28, 2026
25 checks passed
@mithuncy
mithuncy deleted the fix/joinscan-not-in-null-safety branch July 28, 2026 11:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Do Not Cherry Pick PR should not be cherry-picked to other branches

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants