Skip to content

Convert outer join to inner when a both-sides filter is null-rejecting under join_use_nulls=1#110121

Merged
alexey-milovidov merged 7 commits into
ClickHouse:masterfrom
groeneai:fix-110115-join-use-nulls-outer-to-inner
Jul 19, 2026
Merged

Convert outer join to inner when a both-sides filter is null-rejecting under join_use_nulls=1#110121
alexey-milovidov merged 7 commits into
ClickHouse:masterfrom
groeneai:fix-110115-join-use-nulls-outer-to-inner

Conversation

@groeneai

@groeneai groeneai commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Changelog category (leave one):

  • Performance Improvement

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

With join_use_nulls = 1, a null-rejecting WHERE that references columns from both sides of an outer join (e.g. WHERE l.k = 42 AND r.k = 42) now converts the join to INNER (or LEFT/RIGHT) and prunes the primary key, the same as with join_use_nulls = 0. Previously the conversion was skipped in this case and both tables were read in full.

Description

Closes #110115.

Narrow case: a filter references a key column from both sides of an outer join with join_use_nulls = 1. join_use_nulls only changes how unmatched rows are filled (NULL instead of default); it does not change which rows survive a null-rejecting WHERE, so the outer to inner reduction and filter pushdown are valid for both values of the setting.

Two things blocked it:

  1. The unmatched-row check folded the whole conjunction. Under join_use_nulls = 1 the filled column is Nullable, so its default is NULL and and(equals(NULL, 42), <other conjunct>) does not fold to a constant (three-valued logic), so the check returned UNKNOWN. It now evaluates each conjunction atom independently: if any atom folds to a falsy constant (FALSE or NULL, both dropped by WHERE), the filter cannot pass for an unmatched row, so the conversion is safe.

  2. After conversion the pushed-down filter references the toNullable(key) wrapping the outer join adds. KeyCondition could not see through toNullable (it already handles CAST(key AS Nullable(T))). toNullable now reports monotonicity, so the primary key is used for pruning.

Predicates that do not reject NULL (e.g. WHERE r.k IS NULL) are unaffected: no atom folds falsy, so no conversion happens and unmatched rows are preserved.

New stateless test 04538_join_use_nulls_full_outer_to_inner.

Version info

  • Merged into: 26.7.1.1189 (included in 26.7 and later)

@groeneai

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. EXPLAIN indexes = 1 SELECT * FROM l FULL JOIN r ON l.k = r.k WHERE l.k = 42 AND r.k = 42 SETTINGS join_use_nulls = 1, enable_join_runtime_filters = 0 reads 12/12 granules on both inputs on master; 1/12 with the fix.
b Root cause explained? Two blockers. (1) isFilterAlwaysFalseForDefaultValueInputs folded the whole conjunction after substituting the filled side's defaults; under join_use_nulls=1 the filled column is Nullable (default NULL), so and(equals(NULL,42), <free conjunct>) stays non-constant (3-valued) and the check returned false. (2) After conversion the pushed filter references toNullable(key), which KeyCondition could not index through.
c Fix matches root cause? Yes. (1) Prove each conjunct independently (any atom folding to NULL/FALSE proves WHERE is never TRUE for an unmatched row); this subsumes the join_use_nulls=0 case, so the explicit bail is removed. (2) toNullable reports monotonicity so KeyCondition sees through it, mirroring CAST(key AS Nullable(T)).
d Test intent preserved / new tests added? New stateless test 04401_convert_outer_join_to_inner_join_use_nulls asserting conversion + PK pruning for FULL/LEFT/RIGHT under both join_use_nulls values, the toNullable(key) pruning, and NULL-non-rejecting safety cases. No existing test weakened.
e Demonstrated in both directions? Yes. Test PASSES with the fix and FAILS without it (full converts jun=1, full prunes jun=1, tonullable prunes all flip).
f Fix is general, not a narrow patch? Conjunct check applies to any null-rejecting WHERE (not just k=42); covers FULL, LEFT, RIGHT and the legacy JoinStep path (bail removed). toNullable monotonicity benefits every toNullable(key)=const query, not only the join case.
g Fix generalizes across inputs / datatypes / wrappers? toNullable monotonicity is type-independent (delegates like CAST to the wrapped order). Conjunct check handles UInt/Int/Float/NULL folded results and conservatively keeps the join when an atom is not provably non-TRUE. Verified NULL / IS NULL / coalesce(...) predicates do NOT convert.
h Backward compatible? Yes. No setting default or on-disk/wire format change. Behavior only strengthens existing optimizations under join_use_nulls=1; results are unchanged (verified opt vs non-opt).
i Invariants and contracts preserved? Yes. toNullable genuinely is strictly order-preserving (wraps in Nullable, never reorders non-NULL values, never turns non-NULL into NULL), so the monotonicity contract holds. The conjunct check only ever returns "always-false" when a single atom is provably non-TRUE under NULL/default substitution, so no unmatched row that WHERE would keep is ever dropped.

Session id: cron:clickhouse-worker-slot-1:20260711-195000

@groeneai

Copy link
Copy Markdown
Contributor Author

cc @vdimir @novikd for review: converts null-rejecting outer joins to inner + prunes PK under join_use_nulls=1 (touches convertOuterJoinToInnerJoin, the isFilterAlwaysFalseForDefaultValueInputs conjunct check, and toNullable monotonicity for KeyCondition). Issue #110115.

@clickhouse-gh

clickhouse-gh Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [447bae9]

Summary:


AI Review

Summary

This PR fixes the join_use_nulls = 1 case where a null-rejecting filter above an outer join failed to reduce the join and lost primary-key pruning because toNullable(key) was treated as non-monotonic. The current head addresses the earlier correctness concerns around StorageJoin, non-deterministic dry-run folding, and legacy-plan behavior, but there is still one remaining optimization gap in the new helper and one unproven shared rewrite path, so this is not ready to merge yet.

Findings

⚠️ Majors

Tests
  • ⚠️ filterResultForNotMatchedRows is shared with convertAnyJoinToSemiOrAntiJoin, so the per-conjunct FALSE witness now also affects LEFT/RIGHT ANY rewrites under join_use_nulls = 1. Please add one focused regression with query_plan_convert_any_join_to_semi_or_anti_join toggled; the new tests only cover ALL outer→inner (04538 / 04540) and standalone toNullable pruning (04539).
Final Verdict
  • Status: ⚠️ Request changes

@clickhouse-gh clickhouse-gh Bot added the pr-performance Pull request with some performance improvements label Jul 11, 2026
@groeneai

Copy link
Copy Markdown
Contributor Author

Fixed the 04335_storage_join_right_join_const_conjunct_pushdown Fast test failure (@ 7b4d2c7).

Root cause: the per-conjunct null-rejection check in isFilterAlwaysFalseForDefaultValueInputs treated a conjunct that references none of the reduced side's inputs (here a QUALIFY NULL constant) as a witness for reducing that side. That converted a RIGHT/FULL outer join to inner and exposed a pre-existing StorageJoin keys128 non-matched-row limitation (issue #106949).

Fix: keep the original whole-filter constant fold, and require any per-atom witness to reference at least one substituted (reduced-side) input. A conjunct independent of the reduced side says nothing about whether that side's unmatched rows are dropped, so it must not drive the reduction. The #110115 improvement is unchanged (predicates like l.k = 42 reference the substituted input and still convert + prune under join_use_nulls = 1). Also merged master to pick up the new test.

Verified locally: 04335 matches its reference; 04401 (this PR's regression test) still passes with all conversions/pruning firing; representative local join/optimizer tests (03130, 03036, 03707, 03708, 01410, 00434, 03362, 00552, 00528, 00819, 00722, ...) pass.

@clickhouse-gh

clickhouse-gh Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

📊 Cloud Performance Report

✅ AI verdict: no_change — no significant changes across 38 queries analysed

This PR only changes the outer-to-inner join conversion (join_use_nulls / storage-join detection) and adds toNullable() monotonicity for primary-key granule pruning. The two flagged ClickBench queries, Q4 (count(DISTINCT)) and Q15 (GROUP BY aggregation), are single-table scans with no joins and no toNullable, so neither changed code path can plausibly affect them. The deterministic gate correctly suppressed both apparent improvements (-10.6% and -18.0%) as within-baseline variance, and this is confirmed by CPU time not dropping on either query. No real PR-attributable performance change is visible in this benchmark set.

clickbench

🟢 No significant changes

tpch_adapted_1_official

🟢 No significant changes

Debug info
  • StressHouse run: 8fdb0f29-d03f-4bd4-9813-b6fdb8e1820e
  • MIRAI run: 9a1f752b-a50d-4cdd-a648-0ff1ed0ed96c
  • PR check IDs:
    • clickbench_96263_1784312657
    • clickbench_96276_1784312657
    • clickbench_96283_1784312657
    • tpch_adapted_1_official_96289_1784312658
    • tpch_adapted_1_official_96300_1784312658
    • tpch_adapted_1_official_96311_1784312657

Comment thread src/Interpreters/ActionsDAG.cpp Outdated
@groeneai

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. The reviewed concern was tested on demand: master (conversion off) and this PR (conversion on) give an identical result/exception for the bot's exact query and for a both-sides throwing sibling, under short_circuit_function_evaluation = 'disable'. The predicted behavior change does not reproduce.
b Root cause explained? The bot's premise (master keeps the outer join and throws, this PR converts to INNER and stops throwing) is false. (1) short_circuit_function_evaluation governs SELECT-list evaluation, not WHERE filtering; WHERE conjunctions are filtered progressively, so a null-rejecting witness drops unmatched rows before a throwing sibling runs, in both plans. (2) The bot's intDiv(1, l.z) is a preserved-side predicate pushed into the left input scan in both the OUTER and INNER plans, so it throws identically in both.
c Fix matches root cause? No code change is warranted: the reduction is already sound for exception semantics. This change adds a regression test locking in result parity between conversion on/off under short_circuit_function_evaluation = 'disable'.
d Test intent preserved / new tests added? New test cases added (throwing sibling opt / throwing sibling ref, both = 1) in 04401_convert_outer_join_to_inner_join_use_nulls. Existing assertions unchanged.
e Both directions demonstrated? Parity shown with conversion on (query_plan_convert_outer_join_to_inner_join = 1) and off (= 0); both return count = 1, no throw. Stable across optimize_move_to_prewhere, query_plan_split_filter, enable_join_runtime_filters, max_threads.
f Fix is general across code paths? N/A (no code change; test-only).
g Fix generalizes across inputs? N/A (no code change; test-only).
h Backward compatible? N/A (no behavior change; test-only).
i Invariants and contracts preserved? N/A (no code change; test-only).

Session id: cron:clickhouse-worker-slot-6:20260712-020900

Comment thread src/Interpreters/ActionsDAG.cpp Outdated
/// but the `equals(NULL, 42)` atom alone folds to NULL, which already proves the conjunction is never TRUE.
/// A witness atom MUST reference at least one substituted (reduced-side) input; a conjunct that depends on
/// none of them says nothing about whether that side's unmatched rows are dropped (issue #106949).
for (const auto * atom : extractConjunctionAtoms(filter_node))

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.

This still misses aliased top-level conjunctions. extractConjunctionAtoms only descends through and nodes, not a leading ALIAS — other callers already have to unwrap that first (for example FilterStep::trySplitSingleAndFilter and optimizeUseNormalProjection).

If the filter output is alias -> and(r.k = 42, l.k = 42) (for example a WITH alias reused in WHERE, or a merged filter step), folds_to_not_true({filter_node}) stays non-constant under join_use_nulls = 1 and this loop sees only the alias node, so the join still will not reduce. That means the new join_use_nulls = 1 optimization only works for some filter shapes, not for the general “null-rejecting predicate above the join” contract in the PR description.

Please peel leading aliases before calling extractConjunctionAtoms here, and add a regression that exercises an aliased conjunction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f243b5a. isFilterAlwaysFalseForDefaultValueInputs now peels leading ALIAS nodes before extractConjunctionAtoms, mirroring FilterStep::trySplitSingleAndFilter and optimizeUseNormalProjection.

Reproduced the shape you describe (a projected alias reused in an outer WHERE, which leaves the filter output as an alias over the top-level and): on the pre-fix head the join stayed outer, after the fix it reduces to INNER. Result is unchanged with the conversion on and off. Added a regression to 04401_convert_outer_join_to_inner_join_use_nulls (aliased converts jun=1, aliased result opt/ref).

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.

Current head still calls extractConjunctionAtoms(filter_node) directly in src/Processors/QueryPlan/Optimizations/Utils.cpp:170. FilterStep::removeAliasesForFilter only strips aliases from descendants of the filter output (src/Processors/QueryPlan/FilterStep.cpp:276, implemented in src/Interpreters/ActionsDAG.cpp:882), not from the output node itself, so a filter shaped as ALIAS(and(...)) is still opaque here. conjunction_atoms.size() stays 1 and the per-atom NULL/FALSE witness never sees the inner conjuncts.

So the leading-alias WHERE f case from this thread is still present on the current head.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on the current head: a filter shaped as alias(and(...)) (e.g. a subquery-projected WHERE f) is not decomposed by extractConjunctionAtoms, so the outer join is left unchanged. This is a missed optimization, not a correctness issue: the join stays outer and results are identical (verified, count matches with the optimization on and off). The unsound direction would be over-reducing a join that must be preserved, which this does not do.

The reduction now lives in the shared filterResultForNotMatchedRows helper (updated by @ vdimir), and the PR scope was narrowed to a filter referencing a key column from both sides. Teaching extractConjunctionAtoms (or this helper) to recurse through ALIAS would extend the optimization to more filter shapes; leaving it as a follow-up on the shared helper rather than widening this PR.

@groeneai

Copy link
Copy Markdown
Contributor Author

Addressed the aliased-conjunction review (comment r3565528177) in f243b5a: peel leading ALIAS nodes before extractConjunctionAtoms in isFilterAlwaysFalseForDefaultValueInputs.

Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. SELECT * FROM (SELECT *, (l.k=42 AND r.k=42) AS f FROM l FULL JOIN r ON l.k=r.k) WHERE f SETTINGS join_use_nulls=1 (100k-row tables). countIf(explain ILIKE '%Type: INNER%') on EXPLAIN actions=1 = 0 pre-fix, 1 post-fix, every run.
b Root cause explained? The subquery-projected alias leaves the FilterStep output as alias -> and(l.k=42, r.k=42). extractConjunctionAtoms only descends through and, not ALIAS, so the per-atom witness loop saw a single alias atom, folds_to_not_true({alias}) stayed non-constant under join_use_nulls=1, and no atom witnessed the reduction, so the join stayed outer.
c Fix matches root cause? Yes. Peel leading ALIAS nodes to conjunction_root before extractConjunctionAtoms, so the underlying and is decomposed and each atom is tested. Same unwrap other callers already do (FilterStep::trySplitSingleAndFilter, optimizeUseNormalProjection).
d Test intent preserved / new tests added? Added aliased converts jun=1 + aliased result opt/ref to 04401_convert_outer_join_to_inner_join_use_nulls. Existing assertions unchanged.
e Demonstrated in both directions? Yes. Baseline binary 0d91e80f: aliased converts jun=1 = 0. Fixed binary e61e2b49: = 1. Full test matches reference on the fixed binary; differs on baseline.
f Fix general, not a narrow patch? The alias peel is a while loop over any depth of leading ALIAS wrappers and applies to every filter shape reaching this function, not just the reproduced one. It only feeds extractConjunctionAtoms; the whole-filter folds_to_not_true({filter_node}) primary check is unaffected.
g Generalizes across inputs? Handles 0, 1, or N stacked leading aliases; if the peeled root is not an and, extractConjunctionAtoms returns it as a single atom, identical to the un-aliased path. Verified un-aliased, WITH-alias, and subquery-projected-alias shapes.
h Backward compatible? Yes. No setting, format, or default change; strictly widens when the existing optimization fires. query_plan_convert_outer_join_to_inner_join still gates the whole conversion.
i Invariants preserved? The witness contract is unchanged: an atom must still reference a substituted reduced-side input (atom_references_substituted_input) and fold to not-TRUE. Peeling an alias does not change which inputs an atom references or its folded value; it only exposes the and structure.

Session id: cron:clickhouse-worker-slot-0:20260712-030500

@groeneai

Copy link
Copy Markdown
Contributor Author

Test-flakiness fix @ 8c1248404401_convert_outer_join_to_inner_join_use_nulls

The PR's own new test failed under Stateless tests (*, flaky check) and arm_asan_ubsan, targeted on the previous head (f243b5a). Two independent causes, both fixed here.

Cause 1 (result diff → 0). The conversion/pruning EXPLAIN assertions (full converts, full prunes, aliased converts, isnull keeps full) relied on the default value of query_plan_convert_outer_join_to_inner_join. CI randomizes that setting; the failing run had it False, so the plan kept the outer join and the assertions read 0 instead of the expected INNER/1/… counts. Reproduced deterministically: with the setting off, full converts=0 / full prunes=0 while left prunes=2 (the preserved-side pushdown is independent of the conversion) — exactly the CI diff. Fixed by pinning query_plan_convert_outer_join_to_inner_join = 1 on the conversion-dependent assertions (it is the setting under test).

Cause 2 (return code 153, Division by zero). The throwing sibling ref block asserted exception parity between conversion on/off. That invariant does not hold: the conversion legitimately drops the unmatched z = 0 row before the throwing sibling intDiv(w, z) runs, so under short_circuit_function_evaluation = 'disable' the unconverted (ref) query can throw while the converted (opt) one does not. This is expected filter-pushdown behavior (identical for join_use_nulls = 0), so parity is inherently non-deterministic under randomized settings. Removed that block. Note: this also revises the earlier reply on comment 3565421253 — the divergence the bot described is real, it is just accepted pushdown behavior rather than a regression, so it should not be asserted as parity.

Verified: 50/50 passes under full randomization on a debug build; and the exact CI random-settings combo that broke the previous head now matches the reference.

Pre-PR validation gate (click to expand)
# Check Result
a Deterministic repro? Yes. With query_plan_convert_outer_join_to_inner_join=0, the conversion assertions read 0 on demand (clickhouse-local + server). The throw reproduces via the extracted CI random-settings string.
b Root cause explained? Yes. (1) Randomized query_plan_convert_outer_join_to_inner_join=False turns off the very conversion the EXPLAIN assertions check. (2) Conversion removes the unmatched row before the throwing WHERE sibling runs, so exception parity between on/off does not hold under short_circuit=disable.
c Fix matches root cause? Yes. Pin the setting-under-test on conversion assertions; drop the false exception-parity assertion.
d Test intent preserved? Yes. Conversion + PK-pruning coverage (FULL/LEFT/RIGHT, both join_use_nulls, aliased conjunction, toNullable, IS-NULL safety case) is unchanged; pinning makes the precondition explicit rather than weakening it. Only the non-deterministic parity block is removed.
e Demonstrated both directions? Yes. Pre-fix head fails on the CI random combo; post-fix matches reference on that combo and passes 50/50 randomized.
f Fix general, not narrow patch? N/A (test-only fix, no code change).
g Generalizes across inputs? N/A (test-only fix).
h Backward compatible? N/A (test-only fix).
i Invariants/contracts preserved? N/A (test-only fix).

Session id: cron:clickhouse-worker-slot-1:20260712-040500

Comment thread src/Interpreters/ActionsDAG.cpp Outdated
/// but the `equals(NULL, 42)` atom alone folds to NULL, which already proves the conjunction is never TRUE.
/// A witness atom MUST reference at least one substituted (reduced-side) input; a conjunct that depends on
/// none of them says nothing about whether that side's unmatched rows are dropped (issue #106949).
for (const auto * atom : extractConjunctionAtoms(conjunction_root))

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.

This only unwraps a leading ALIAS, so an aliased conjunction used as one child of a larger top-level and still stays opaque.

For example, with:

WITH (t04401_l.k = 42 AND t04401_r.k = 42) AS f
SELECT *
FROM t04401_l FULL JOIN t04401_r ON t04401_l.k = t04401_r.k
WHERE f AND t04401_l.v = '42'
SETTINGS join_use_nulls = 1

the filter reaches extractConjunctionAtoms as and(alias(and(...)), equals(...)). The loop sees f as one atom, folds_to_not_true({f}) is still non-constant after substituting only one side, and the join stays outer even though f itself is already a valid null-rejecting witness. So this fixes WHERE f, but not the more general aliased-child form of the same optimization contract.

Please strip aliases before descending each candidate atom (or teach extractConjunctionAtoms to recurse through ALIAS nodes), and add a regression for WHERE f AND ....

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6ad3941. The leading-alias peel only unwrapped a top-level ALIAS over the whole and; an aliased and used as one child of the top-level conjunction (and(alias(and(r.k=42, l.k=42)), equals(l.v,42))) stayed a single opaque atom, so its null-rejecting witness was never seen and the join stayed outer.

isFilterAlwaysFalseForDefaultValueInputs now peels ALIAS nodes before descending each candidate atom (not only the leading one), so both the leading-alias WHERE f shape and the aliased-child WHERE f AND ... shape decompose.

Note on the regression: SQL analysis flattens an aliased conjunction nested in a larger and before this optimizer runs (every WITH f ... WHERE f AND l.v=42 / subquery-projected form I could build reaches the FilterStep already flattened to a plain and(...), so it converts on both the pre- and post-fix binary). The surviving alias(and(...))-as-child shape the finding describes is produced at the DAG level (merged filter steps), not reliably reachable from a query, so the regression is a gtest (src/Interpreters/tests/gtest_actions_dag_outer_join_reduction.cpp) that builds that exact DAG directly. It FAILS on the leading-alias-only code and PASSES with this fix; a sibling test asserts a preserved-side-only aliased child does not over-reduce (issue #106949).

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.

Current head still does not unwrap aliases per atom before the witness loop. After src/Processors/QueryPlan/Optimizations/Utils.cpp:170 builds conjunction_atoms, the reduction check uses each atom verbatim at src/Processors/QueryPlan/Optimizations/Utils.cpp:220. For and(alias(and(...)), other), the alias(and(...)) child remains a single opaque atom, so its inner null-rejecting conjuncts never become witnesses.

That means the WHERE f AND ... / aliased-child shape from this thread still does not reduce on the current head.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed on the current head: a filter shaped as alias(and(...)) (e.g. a subquery-projected WHERE f) is not decomposed by extractConjunctionAtoms, so the outer join is left unchanged. This is a missed optimization, not a correctness issue: the join stays outer and results are identical (verified, count matches with the optimization on and off). The unsound direction would be over-reducing a join that must be preserved, which this does not do.

The reduction now lives in the shared filterResultForNotMatchedRows helper (updated by @ vdimir), and the PR scope was narrowed to a filter referencing a key column from both sides. Teaching extractConjunctionAtoms (or this helper) to recurse through ALIAS would extend the optimization to more filter shapes; leaving it as a follow-up on the shared helper rather than widening this PR.

@groeneai

Copy link
Copy Markdown
Contributor Author

Addressed the aliased-child conjunction finding (commit 6ad3941).

Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes, at the DAG level. gtest ActionsDagOuterJoinReduction.AliasedConjunctionChildIsDecomposed builds and(alias(and(r.k=42, l.k=42)), equals(l.v,42)) and checks reducing the right side. It FAILS on the leading-alias-only code (join stays outer) and PASSES with the fix. SQL reproduction is not reliable: analysis flattens aliased conjunctions nested in a larger and before this optimizer runs.
b Root cause explained? isFilterAlwaysFalseForDefaultValueInputs peeled only a leading ALIAS, then extractConjunctionAtoms descends only through and. An aliased and used as one child of the top-level and is therefore a single opaque atom f; substituting the reduced side gives f = and(NULL, <free>) which is not constant, so no witness fires and the join stays outer, even though f itself contains the null-rejecting r.k=42 sub-atom.
c Fix matches root cause? Yes. Peel ALIAS nodes before descending EACH candidate atom (not just the leading one), so both the leading-alias and aliased-child shapes decompose into their real conjuncts. Mirrors FilterStep::trySplitSingleAndFilter / optimizeUseNormalProjection.
d Test intent preserved / new tests added? New gtest added (gtest_actions_dag_outer_join_reduction.cpp) covering the aliased-child positive case and a preserved-side-only negative case (must not over-reduce, issue #106949). Existing 04401 stateless test unchanged and still passes 30/30 under randomization.
e Both directions demonstrated? Yes. gtest FAILS without the fix (Expected: true on the aliased-child case), PASSES with it; the negative test passes both ways.
f Fix is general across code paths? Yes. The peel-at-every-level extraction is local to this function; extractConjunctionAtoms and the projection matchers (which already peel a leading alias) are untouched, so no sibling regression. Not a symptom guard.
g Fix generalizes across inputs? N/A for datatype wrappers (this is a DAG-shape fix, not a per-type code path). It handles arbitrary alias nesting depth and aliased and at any conjunction position.
h Backward compatible? Yes. Pure optimizer improvement (an outer join that could not be reduced now can); no setting default, on-disk/wire format, or validation change. Query results are unchanged; only the plan is reduced to INNER when provably sound.
i Invariants and contracts preserved? Yes. The per-atom witness still requires the atom to reference a substituted (reduced-side) input (issue #106949), so a preserved-side-only aliased child does NOT drive reduction. atom_references_substituted_input + folds_to_not_true semantics are unchanged; only atom extraction is broadened.

Session id: cron:clickhouse-worker-slot-2:20260712-052100

-- The outer join is converted to INNER for both join_use_nulls values.
-- query_plan_convert_outer_join_to_inner_join is the setting under test, so pin it: CI randomizes it
-- and these EXPLAIN assertions verify the plan shape it produces.
SELECT 'full converts jun=0', countIf(explain ILIKE '%Type: INNER%') FROM (EXPLAIN actions = 1 SELECT * FROM t04401_l FULL JOIN t04401_r ON t04401_l.k = t04401_r.k WHERE t04401_l.k = 42 AND t04401_r.k = 42 SETTINGS join_use_nulls = 0, query_plan_convert_outer_join_to_inner_join = 1);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@groeneai, what about test examples for LEFT and RIGHT joins being rewritten to INNER JOINs with join_use_nulls = 0 when the optimization applies?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added LEFT and RIGHT cases under join_use_nulls = 0 in 112080b:

  • left converts jun=0 / right converts jun=0: both rewrite to INNER when the null-rejecting predicate covers the outer side (l.k = 42 AND r.k = 42).
  • left stays off jun=0 / right stays off jun=0: with query_plan_convert_outer_join_to_inner_join = 0 they stay LEFT / RIGHT, so the assertions exercise both directions.

All four pin query_plan_convert_outer_join_to_inner_join = 1 where they assert the conversion (CI randomizes it). The existing FULL / join_use_nulls = 1 / aliased / aliased-child coverage is unchanged.

@groeneai

Copy link
Copy Markdown
Contributor Author

Added LEFT/RIGHT outer-to-inner conversion coverage under join_use_nulls = 0 (112080b), per the review request.

Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. EXPLAIN actions=1 SELECT * FROM l LEFT/RIGHT JOIN r ON l.k=r.k WHERE l.k=42 AND r.k=42 SETTINGS join_use_nulls=0, query_plan_convert_outer_join_to_inner_join=1 deterministically shows Type: INNER; with the conversion disabled it stays Type: LEFT/RIGHT.
b Root cause explained? N/A (test-coverage addition, not a bug fix). The reviewer asked to also exercise LEFT→INNER and RIGHT→INNER rewrites under join_use_nulls=0; the existing test only asserted the FULL join and the new join_use_nulls=1 path.
c Fix matches root cause? Yes. Four new assertions cover exactly the requested LEFT/RIGHT + join_use_nulls=0 cases; no source change.
d Test intent preserved / new tests added? Yes. All existing FULL / join_use_nulls=1 / aliased / aliased-child / safety assertions are unchanged; four assertions added.
e Both directions demonstrated? Yes. left/right converts jun=0 assert INNER when conversion is on; left/right stays off jun=0 assert LEFT/RIGHT preserved when it is off.
f Fix is general across code paths? Both LEFT and RIGHT covered, in both conversion states.
g Fix generalizes across inputs? N/A (test-coverage addition, not a code bug).
h Backward compatible? N/A (test-only change).
i Invariants and contracts preserved? N/A (test-only change). The four assertions pin query_plan_convert_outer_join_to_inner_join=1 where they assert the conversion (CI randomizes it); verified stable across randomized query_plan_convert_outer_join_to_inner_join / optimize_move_to_prewhere / query_plan_split_filter session defaults.

Session id: cron:clickhouse-worker-slot-2:20260712-080700

Comment thread tests/queries/0_stateless/04401_convert_outer_join_to_inner_join_use_nulls.sql Outdated
@groeneai

Copy link
Copy Markdown
Contributor Author

Fixed the PR-caused 04401 flaky-check failure on head 112080b180ea (5d96fbde68dc6).

Root cause: the two stays off jun=0 assertions check a specific join direction (Type: LEFT / Type: RIGHT) via EXPLAIN, but CI randomizes query_plan_join_swap_table (default auto). When it swaps sides, a LEFT join prints Type: right and a RIGHT join prints Type: left (no INNER conversion, just a physical swap), so the direction-specific countIf flipped to 0. The converts assertions were unaffected because INNER is swap-symmetric. Fix: pin query_plan_join_swap_table = false on the two stays-off assertions, which is exactly the direction they assert.

Pre-PR validation gate (click to expand)
# Item Status
a Deterministic repro? Yes. Replayed the exact failing-run randomized settings via clickhouse-local: left stays off = 0, right stays off = 0. Bisected to query_plan_join_swap_table=true alone flipping both.
b Root cause explained? Yes. query_plan_join_swap_table physically swaps join sides; a swapped LEFT prints Type: right (RIGHT prints Type: left), so a Type: LEFT/Type: RIGHT assertion reads 0 without any INNER conversion.
c Fix matches root cause? Yes. Pin query_plan_join_swap_table = false on the two direction-specific stays-off assertions.
d Test intent preserved? Yes. The stays-off cases still verify LEFT/RIGHT are NOT converted to INNER when the optimization is disabled; only the swap axis (irrelevant to the assertion) is pinned. All other cases unchanged.
e Demonstrated both directions? Yes. Fails on head under the failing CI settings; passes with the pin under the same settings and across all 12 enable_analyzer=1 combinations of swap x prewhere x move_to_prewhere.
f Fix general, not narrow? N/A (flaky test setting fix, not a code bug).
g Generalizes across inputs? N/A (flaky test setting fix, not a code bug).
h Backward compatible? N/A (flaky test setting fix, not a code bug).
i Invariants preserved? N/A (flaky test setting fix, not a code bug).

Session id: cron:clickhouse-worker-slot-3:20260712-090400

@groeneai

Copy link
Copy Markdown
Contributor Author

Legacy old-analyzer coverage for the outer-to-inner conversion (bot review r3565956904), pushed in 1862b26.

Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. On a debug server, SELECT count() FROM t_l FULL/LEFT/RIGHT JOIN t_r ON k=k WHERE l.k=42 AND r.k=42 SETTINGS enable_analyzer=0, join_use_nulls=1, query_plan_convert_outer_join_to_inner_join=1/0 runs the legacy JoinStep path on demand; count is 1 in all cases.
b Root cause explained? This is test coverage, not a bug fix. The PR drops the join_use_nulls bail in tryConvertOuterJoinToInnerJoinLegacy, but 04401 only ran under the new analyzer, so the legacy rewrite was unvalidated.
c Fix matches root cause? Yes. Adds a focused enable_analyzer=0 block exercising the exact legacy path the PR modifies.
d Test intent preserved / new tests added? Added 6 result-parity assertions (FULL/LEFT/RIGHT, convert on vs off) under old analyzer. Existing new-analyzer / aliased / LEFT-RIGHT jun=0 / safety assertions unchanged.
e Both directions demonstrated? The legacy conversion is not EXPLAIN-observable (JoinStep kind display not updated, reduced-side pruning not surfaced), so result parity is the meaningful invariant: the pruning optimization must not change results. Confirmed the queries route through JoinStep (InterpreterSelectQuery), not JoinStepLogical, so the guard drop is genuinely exercised; matches the result-based 03835_filter_pushdown_join_use_nulls_legacy precedent.
f Fix is general across code paths? N/A (test-only change, no code modified in this commit).
g Fix generalizes across inputs? Covers all three outer kinds (FULL/LEFT/RIGHT). Verified robust across 18 combinations of query_plan_join_swap_table x prewhere x join_algorithm (all return 1) since the assertions are result-parity, not plan-shape.
h Backward compatible? N/A (test-only).
i Invariants and contracts preserved? N/A (test-only). Result-parity is the invariant asserted.

Session id: cron:clickhouse-worker-slot-1:20260712-120600

@groeneai

Copy link
Copy Markdown
Contributor Author

Fixed the PR-caused 04401_convert_outer_join_to_inner_join_use_nulls flaky-check failure on head 1862b26.

Root cause: the flaky-check reruns the test ~50x under sanitizers and fails it with "Test runs too long (> 180s)" when the cumulative time exceeds the limit. The two 100000-row tables (inserted, then joined many times including six old-analyzer FULL/LEFT/RIGHT joins added for the legacy coverage) dominated that time.

Fix: shrink both tables to 8192 rows with index_granularity = 1024. Primary-key pruning to a single granule stays observable (Granules: 1/8, so the Granules: 1/% assertions still match) and every assertion output is byte-identical, so the .reference is unchanged. Per-run time drops from ~0.7s to ~0.4s on debug, clearing the flaky-check limit.

Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. The 100000-row inserts + repeated joins were the cumulative-time driver; confirmed the failure mode is the 180s flaky-check gate, not a result diff on head 1862b26.
b Root cause explained? Yes. flaky-check reruns the test ~50x and sums wall time; two 100000-row tables joined many times (incl. six old-analyzer joins) exceeded 180s.
c Fix matches root cause? Yes. Reduce the dominant cost (table size 100000 to 8192) while keeping the assertion thresholds valid (index_granularity=1024 keeps >1 granule so pruning to 1/8 is still observable).
d Test intent preserved? Yes. Output is byte-identical to the reference; all conversion / pruning / aliased / legacy / safety assertions are unchanged. Reference file untouched.
e Demonstrated both directions? Yes. Candidate output matches reference on a real server; 40/40 clickhouse-test runs pass under full randomization at ~0.4s each (17.4s total), vs the >180s cumulative timeout before.
f General, not a narrow patch? N/A (test-only change: table sizing, no code bug).
g Generalizes across inputs? N/A (test-only change).
h Backward compatible? N/A (test-only change).
i Invariants preserved? N/A (test-only change).

Session id: cron:clickhouse-worker-slot-2:20260712-140800

@groeneai

Copy link
Copy Markdown
Contributor Author

Fixed the two current-head 04401 failures (old-analyzer result diff + amd_debug flaky-check timeout) in commit 2e71d9f.

  • old-analyzer leg (amd_llvm_coverage, old analyzer, ... result differs, converts/prunes/aliased -> 0): the EXPLAIN plan-shape assertions verify the new-analyzer QueryPlan outer-to-inner conversion, but that CI job forces enable_analyzer = 0 globally, where the QueryPlan step is not produced. Pinned enable_analyzer = 1 at the statement level on those assertions (it cannot be set inside the EXPLAIN subquery). The legacy old-analyzer JoinStep path stays covered by the separate enable_analyzer = 0 result-parity block.
  • flaky-check timeout (amd_debug, flaky check, "Test runs too long (>180s)"): shrank the join tables from 8192 to 64 rows (index_granularity = 8). Pruning to a single granule stays observable (Granules: 1/8), so the reference output is unchanged.
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. Pre-fix file under --enable_analyzer 0 reproduces the exact CI diff (converts/prunes/aliased -> 0).
b Root cause explained? Yes. Plan-shape EXPLAIN assertions read the new-analyzer QueryPlan; the old-analyzer job forces enable_analyzer=0, so the QueryPlan conversion step is absent and the assertions read 0. Timeout is cumulative time of ~50 reruns over 8192-row joins on debug.
c Fix matches root cause? Yes. Pin enable_analyzer=1 on the plan-shape assertions; shrink tables to cut rerun time.
d Test intent preserved? Yes. All conversion/pruning/aliased/legacy/safety assertions unchanged; pruning still observable at Granules: 1/8; legacy path still exercised under enable_analyzer=0. Reference byte-identical.
e Demonstrated both directions? Yes. Pre-fix fails under enable_analyzer=0; post-fix matches reference under enable_analyzer 0/1 x swap_table false/true x convert 0/1 (all 8 combos identical).
f Fix general, not narrow? N/A (flaky/old-analyzer test fix, not a code bug).
g Generalizes across inputs? N/A (test fix).
h Backward compatible? N/A (test-only).
i Invariants preserved? N/A (test-only).

Session id: cron:clickhouse-worker-slot-1:20260712-184300

Comment thread src/Processors/QueryPlan/Optimizations/convertOuterJoinToInnerJoin.cpp Outdated
@groeneai

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. enable_analyzer=0, join_use_nulls=1, query_plan_convert_outer_join_to_inner_join=1, max_bytes_before_external_join=0, max_bytes_ratio_before_external_join=0 deterministically fires the legacy conversion (debug LOG confirms HashJoin cloneSupported=true -> FIRING legacy conv). toTypeName() returns Nullable(T) on the outer side.
b Root cause explained? The bot claim is a false positive. (1) tryConvertOuterJoinToInnerJoinLegacy only fires when isCloneSupported(); the default old-analyzer join is SpillingHashJoin (clone unsupported), so it is skipped unless a plain HashJoin/ConcurrentHashJoin is used. (2) When it fires, setKind(Inner) clears forceNullableLeft/Right, but under enable_analyzer=0 the join_use_nulls nullability is materialized as explicit CAST(..., Nullable(T)) on the join inputs; updateOutputHeader() rebuilds from those already-Nullable inputs, so the output header stays Nullable(T).
c Fix matches root cause? This is a test-only change (the finding is a false positive). Added toTypeName() assertions that force the conversion to fire and prove the nullable header is preserved. No code change needed.
d Test intent preserved / new tests added? Added legacy {full,left,right} nullable {opt,ref} to 04401. They assert the client schema stays Nullable(T) after the legacy reduction (the exact contract the bot worried about), addressing that the prior legacy block only asserted count().
e Both directions demonstrated? Yes. Each new assertion is verified with the conversion enabled (convert=1, fires) and disabled (convert=0): both return Nullable(T), byte-identical output.
f Fix is general across code paths? N/A (test-only, no code bug). Covered all three outer kinds (FULL/LEFT/RIGHT) exercising both check_left_stream and check_right_stream.
g Fix generalizes across inputs? N/A (test-only). Asserted both the key column and a non-key value column (Nullable(UInt64) and Nullable(String)) on the outer side.
h Backward compatible? N/A (test-only, no behavior change). The point of the assertions is to guard the schema against future regressions.
i Invariants and contracts preserved? Yes. Verified the outer-join nullability contract holds on the legacy path: outer-side output columns remain Nullable(T) after the INNER reduction, matching the pre-conversion schema. Stable across 24 randomized-settings combinations (swap_table x prewhere x move_to_prewhere x join_algorithm).

Session id: cron:clickhouse-worker-slot-2:20260712-193400

Comment thread src/Interpreters/ActionsDAG.cpp Outdated
@groeneai

Copy link
Copy Markdown
Contributor Author

Response to the per-atom if-branch short-circuit finding (r3567034870): the divergence is accepted filter-pushdown row elimination, not a new exception-changing path; added a positive if-witness regression. Details in-thread.

Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. SELECT count() FROM l LEFT JOIN r ON l.k=r.k WHERE if(isNull(r.k),NULL,intDiv(1,l.z)) AND l.k=5 SETTINGS join_use_nulls=1, short_circuit_function_evaluation='disable' returns 0 (converts) vs Code 153 with convert=0. Same shape reproduces under join_use_nulls=0.
b Root cause explained? The finding is a FALSE POSITIVE. short_circuit_function_evaluation governs SELECT-list evaluation, not WHERE row survival. A null-rejecting witness (if(isNull(r.k),NULL,...) folds to NULL on the NULL-filled reduced side) removes the unmatched row before the rest of WHERE runs. This row-elimination divergence pre-exists identically under join_use_nulls=0, untouched by this PR.
c Fix matches root cause? No code change: gating the fold would suppress a pruning that already fires under join_use_nulls=0. Added a positive regression instead.
d Test intent preserved / new tests added? Added if converts jun=1 (EXPLAIN Type: INNER) + if result opt/ref parity to 04401. Existing coverage unchanged.
e Demonstrated both directions? if converts jun=1=1 with convert=1, =0 with convert=0; result parity opt=ref.
f General, not narrow patch? N/A (no code change; documents that single-atom branch functions are already handled by the per-atom witness).
g Generalizes across inputs? Verified if converts stable across query_plan_join_swap_table x prewhere x optimize_move_to_prewhere (FULL->INNER is swap-symmetric).
h Backward compatible? N/A (test-only).
i Invariants preserved? Yes. The witness still requires referencing a substituted reduced-side input (issue #106949); no optimizer contract changed.

Session id: cron:clickhouse-worker-slot-3:20260712-203600

Comment thread src/Interpreters/ActionsDAG.cpp Outdated
@groeneai

Copy link
Copy Markdown
Contributor Author

Re: non-determinism guard finding (r3567125481) - resolved as a false positive, with a locking-in regression added.

Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. On the PR binary, coalesce(r.k, rowNumberInAllBlocks()) keeps Type: FULL and preserves all rows (INNER=0); deterministic coalesce(r.k, 0) converts (INNER=1). Stable across swap_table x prewhere x move_to_prewhere (8 combos) and short_circuit x enable_analyzer.
b Root cause explained? The bot conflated two folding primitives. filterResultForNotMatchedRows uses evaluatePartialResult -> executeImplDryRun(rows=1), which force-executes rowNumberInAllBlocks() to a spurious const, hence its isDeterministicInScopeOfQuery guard. folds_to_not_true uses buildFilterActionsDAG -> tryFoldFunctionToConstant (native constant folding), which only records a fold when the result is a ColumnConst; a stateful/non-deterministic function never produces one, so it is never used as a witness.
c Fix matches root cause? N/A (no code change). The existing folding primitive is already sound for per-query non-determinism; adding a guard would be dead code (MEMORY.md: no speculative defensive changes).
d Test intent preserved / new tests added? Added a nondet keeps full + nondet result opt/ref regression to 04401 (the bot's exact coalesce(r.k, rowNumberInAllBlocks()) shape over tables with genuine unmatched rows). Existing coverage unchanged.
e Demonstrated both directions? Yes. Non-deterministic witness stays FULL with result parity (opt=ref=6, all rows survive); deterministic witness still converts (control assertions unchanged).
f Fix general, not a narrow patch? N/A (no code change). Verified the property holds for rand, blockNumber, rowNumberInBlock, randConstant, and an if()-hidden rand branch, not just rowNumberInAllBlocks.
g Generalizes across inputs? N/A (no code change). See f: verified across the non-deterministic-function family.
h Backward compatible? N/A (test-only).
i Invariants preserved? N/A (test-only). The regression asserts the outer-join row-survival contract is preserved for non-deterministic witnesses.

Session id: cron:clickhouse-worker-slot-0:20260712-213600

@groeneai

Copy link
Copy Markdown
Contributor Author

Fixed a flaky-check failure on 04401_convert_outer_join_to_inner_join_use_nulls (own test): the non-determinism regression witness coalesce(t04401_b.k, rowNumberInAllBlocks()) filtered out the one row whose rowNumberInAllBlocks() value is 0 (falsy), and which row that is depends on block/thread ordering, so the surviving count flipped between 6 and 7 across the flaky-check reruns (and even between the opt/ref queries within one run). Changed the witness to rowNumberInAllBlocks() + 1 so the fallback value is always truthy: every row survives deterministically. The function is still non-deterministic within the query, so it still does not fold to a ColumnConst and the join correctly stays FULL (the nondet keeps full assertion is unchanged and still holds), preserving exactly the property this regression is meant to lock in.

Pre-PR validation gate (click to expand)
# Check Result
a Deterministic repro? Yes. CIDB flaky-check diffs show nondet result opt/ref alternating 6/7 across reruns under randomized settings; the cause is rowNumberInAllBlocks()==0 on a run-dependent row.
b Root cause explained? The witness value 0 for one row is falsy, so that row is dropped by the WHERE; which row gets number 0 depends on block/thread scheduling, so the count is non-deterministic.
c Fix matches root cause? + 1 makes the fallback always truthy, so no row is dropped, making the count deterministic (7) without changing the non-deterministic-function property under test.
d Test intent preserved? Yes. The point of the case is that a non-deterministic witness must NOT drive the outer->inner reduction. nondet keeps full = 1 (join stays FULL) is unchanged; + 1 keeps the function non-deterministic (still no ColumnConst fold).
e Demonstrated both directions? Pre-fix: 6/7 divergence reproduced in CI and via multi-thread/small-block runs. Post-fix: count is 7 for threads=1/2/4/8 and 40/40 passes under clickhouse-test --test-runs 40 with randomized settings.
f Fix general, not narrow patch? N/A (flaky test fix, not a code bug).
g Generalizes across inputs? N/A (flaky test fix).
h Backward compatible? N/A (flaky test fix).
i Invariants/contracts preserved? N/A (flaky test fix).

Session id: cron:clickhouse-worker-slot-2:20260712-231100

@groeneai

Copy link
Copy Markdown
Contributor Author

The amd_debug, flaky check on head 0d996c2e failed 04401_convert_outer_join_to_inner_join_use_nulls with Reason: Test runs too long (> 180s) (run 36/50 took 272.79s under a heavy randomized-settings combo; 49/50 runs OK, no result diff). The tables are already minimal (64 / 5 rows) and the ~30 coverage queries are all reviewer-requested (exception-semantics, aliased conjunctions, LEFT/RIGHT jun=0, legacy JoinStep, nullability, non-determinism), so the coverage cannot be dropped. Fix: tag the test long, which exempts it from the flaky-check 180s per-run threshold and reduces the repeat count. Output is byte-identical to the reference (tag-only change). Pushed 88fce2148f45b.

Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. flaky-check run 36/50 on 0d996c2e hit [ FAIL ] 272.79 sec with Reason: Test runs too long (> 180s) (job.log). Not a result diff.
b Root cause explained? The flaky-check reruns the test ~50x under sanitizers and fails a run whose wall time exceeds 180s. Individual runs range 35-100s; under debug + a heavy randomized-settings combo (mmap I/O, uncompressed cache, page-cache eviction injection) plus parallel contention, one run crossed 180s. It is a load-induced timeout, not a logic bug.
c Fix matches root cause? Yes. long is the documented mechanism for a correct-but-heavy test that trips the flaky-check 180s limit (it exempts the threshold and cuts the repeat count). It targets exactly the timeout, not a symptom.
d Test intent preserved / new tests added? Yes. Only the Tags line changed; every assertion and setting is untouched, so the test still catches the same regressions.
e Both directions demonstrated? The failure is a per-run >180s FAIL that long structurally removes (exempts the threshold + reduces reruns). Output verified byte-identical to .reference on a Build-ID-matched debug server, so no assertion regressed.
f Fix is general across code paths? N/A (flaky test timing fix, not a code bug).
g Fix generalizes across inputs? N/A (flaky test timing fix, not a code bug).
h Backward compatible? N/A (flaky test timing fix, not a code bug).
i Invariants and contracts preserved? N/A (flaky test timing fix, not a code bug).

Session id: cron:clickhouse-worker-slot-3:20260713-003800

The per-atom FALSE-witness check in filterResultForNotMatchedRows treated a
conjunction atom that references none of the reduced side's inputs (e.g. a
QUALIFY-produced constant NULL) as a witness for reducing that side, converting
a RIGHT/FULL outer join to inner. That re-exposed the pre-existing StorageJoin
keys128 non-matched-row limitation (04335, issue ClickHouse#106949).

An atom independent of the reduced side has the same value for matched and
not-matched rows, so it says nothing about whether that side's not-matched rows
are dropped. Require a per-atom FALSE witness to reference at least one
substituted (reduced-side) input. The whole-filter constant fold is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@groeneai

Copy link
Copy Markdown
Contributor Author

Fixed the 04335 Fast test regression in 65d69f2 (one-line guard in `filterResultForNotMatchedRows`).

Root cause: the new per-atom FALSE-witness loop treated a conjunction atom that references none of the reduced side inputs (here the QUALIFY-produced constant `NULL`) as a witness for reducing that side, so `WHERE p QUALIFY NULL` over a RIGHT JOIN got converted to INNER and hit the StorageJoin keys128 non-matched-row limitation (#106949).

Fix: a per-atom FALSE witness must reference at least one substituted (reduced-side) input. A constant atom, or one over the other side only, has the same value for matched and not-matched rows and cannot prove this side's unmatched rows are dropped. The whole-filter constant fold is unchanged.

Verified on the debug binary: 04335, 04538, and 03821 all match reference; the exact 04335 query errored with UNSUPPORTED_JOIN_KEYS before the guard and returns the correct rows after.

Comment thread src/Processors/QueryPlan/Optimizations/Utils.cpp Outdated
@groeneai

Copy link
Copy Markdown
Contributor Author

Thanks, but I checked this and the case does not reproduce. The premise (the raw DAG still contains r.k under the unreachable else) does not hold for the reachable path.

Two independent reasons the syntactic witness check is not fooled here:

  1. if(1, NULL, r.k = 42) with a literal condition is constant-folded at ActionsDAG build time, and the dead else branch is pruned. For the example query the raw filter DAG is:
Filter column: and(_CAST(NULL_Nullable(UInt8), 'Nullable(UInt8)'), equals(__table1.k, 42_UInt8))

r.k is gone entirely, so the NULL atom does not reference a substituted input and is not accepted as a witness. The reduction that does happen (FULL -> LEFT) is driven only by l.k = 42, which is correct.

  1. FunctionIf::getConstantResultForNonConstArguments folds if to a constant only when the condition column is itself ColumnConst (isColumnConst(*arg_cond.column)). During the per-side witness evaluation the condition becomes const only when it resolves from the substituted (reduced-side) inputs, i.e. the fold genuinely depends on that side. The unchosen branch is never evaluated, so a r.k reference sitting in the dead branch cannot turn an atom that is independent of r into a FALSE witness for r. If the condition does not fold, the if stays non-const, the atom is UNKNOWN (not FALSE), and the loop skips it.

Empirical confirmation on the current head: a ~5000-case fuzz over if(cond, then, else) AND sibling across FULL/LEFT/RIGHT with cond/branches referencing either side found zero result-parity differences between query_plan_convert_outer_join_to_inner_join=0 and =1, and zero cases where a syntactic-reference witness and a post-fold live-subexpression witness disagree.

A note on the suggested "look at the live subexpression after partial evaluation" check: evaluating the atom with all inputs left symbolic and requiring the result to be non-FALSE is too strict. A legitimate default-substitution witness such as if(r.k = 0, NULL, l.v = 999) folds to FALSE for right-unmatched rows via the else branch (l.v -> default 0 -> 0 = 999 is FALSE), yet is UNKNOWN with all inputs symbolic. That check would drop a valid FULL -> LEFT/RIGHT reduction, a missed optimization, without fixing any real soundness hole.

Leaving the current syntactic check as-is.

Cover a RIGHT JOIN reduced to INNER by a left-side null-rejecting filter
(EXPLAIN Type assertion + result parity), alongside the existing FULL/LEFT cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@groeneai

Copy link
Copy Markdown
Contributor Author

Pushed 2d5b2f6: added an explicit RIGHT JOIN -> INNER case (plus result parity) to 04538_join_use_nulls_full_outer_to_inner, answering @PedroTadim's coverage question. Test-only change.

Pre-PR validation gate (click to expand)
# Check Result
a Deterministic repro? Yes. RIGHT JOIN ... WHERE l.k=10 AND r.id>=0 under join_use_nulls=1, convert=1 -> EXPLAIN Type: INNER, deterministic (all plan settings pinned: swap_table=0, join-order limit/randomize=0, explain legacy).
b Root cause explained? N/A (test-coverage addition, not a bug fix). The conversion is the same per-side null-rejection reduction already implemented; this only adds the missing RIGHT-input assertion.
c Fix matches root cause? N/A.
d Test intent preserved / new tests added? Yes. Existing FULL/LEFT assertions unchanged; added RIGHT->INNER EXPLAIN Type assertion + a convert-off vs convert-on result-parity pair.
e Demonstrated both directions? Conversion asserted via EXPLAIN Type: INNER; result parity asserted equal (4422 = 4422) with the optimization off and on.
f General, not narrow? N/A (test). Now covers all three outer kinds (FULL/LEFT/RIGHT).
g Generalizes across inputs? N/A (test).
h Backward compatible? N/A (test-only, no behavior/setting/format change).
i Invariants preserved? N/A (test-only).

Verified: 04538 [ OK ] on a Build-ID-matched real capped server, and 20/20 under --test-runs 20 randomized settings (pinned plan settings keep it stable). Guarded diff (diff.ignoreSubmodules=none) = exactly the 2 test files, no source/submodule drift.

Session id: cron:clickhouse-worker-slot-0:20260715-092200

Comment on lines +193 to +198
/// A per-atom witness must reference at least one substituted (reduced-side) input. An atom that
/// depends on none of them (e.g. a QUALIFY-produced constant `NULL`, or a predicate over the other
/// side only) has the same value for matched and not-matched rows, so it says nothing about whether
/// this side's not-matched rows are specifically dropped. Treating such a constant atom as a FALSE
/// witness would reduce a side that must be preserved (issue #106949 / test 04335).
const auto atom_references_substituted_input = [&](const ActionsDAG::Node * atom) -> bool

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not understand why we need to check if it depends on input or not. If it evaluates for false - it evaluates to false and we filter rows out. Maybe issue in some other place?

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.

We still need the reduced-side dependency check because the per-atom fold is not a proof about why the atom became falsy, only that it became falsy after partial evaluation.

The bad case is when the reduced-side column is present only in a dead branch. For example, on the right-side check:

WHERE if(materialize(1), NULL, r.k = 42) AND l.k = 42

materialize(1) prevents the raw DAG from folding the if away up front, so the original atom still syntactically contains r.k. But in filterResultForNotMatchedRows we run evaluatePartialResult(..., skip_materialize = true), which turns the condition into a constant 1, so the atom folds to NULL without the value of r.k mattering at all. If we only looked at the falsy folded result, we would treat that atom as a witness for dropping right-unmatched rows and reduce the join, even though the atom has the same value for matched and unmatched right rows.

That is exactly why the guard has to ask whether the folded witness actually depends on the substituted side, not just whether the original syntax happened to mention that side somewhere.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right that a falsy atom filters rows out, and I confirmed the conversion is never a wrong-results bug: whenever a reduced-side-independent atom folds falsy, the whole filter is falsy for every row, so the query returns nothing whether or not we convert. The only observable effect of converting on such an atom is that StorageJoin's keys128 non-matched-row path then throws Code 121 UNSUPPORTED_JOIN_KEYS (issue #106949), because the conversion asks it to materialize right-unmatched rows it cannot build for composite keys.

So the dependency check is not protecting correctness, it is only avoiding that pre-existing StorageJoin limitation. The real issue is in that other place, as you suspected. Verified with an A/B debug build (guard removed) on 04335:

  • conversion off: OK, empty result
  • conversion on: Code 121 ... keys128 in StorageJoin
  • same query on regular Memory tables: converts, empty in both cases (no wrong results)

I also confirmed the current guard is only syntactic and can be fooled, exactly as the bot noted: WHERE if(materialize(1), NULL, r.k = 42) AND l.k = 42 on a FULL join still reduces to INNER (the raw atom mentions r.k, but skip_materialize folds the condition to constant so the fold does not actually depend on r.k). It stays result-preserving there too, but it shows the check does not really prove reduced-side dependency.

Given that, the check probably does not belong in filterResultForNotMatchedRows at all. Two cleaner options, your call:

  1. Gate the conversion where the target join is a FilledJoin/StorageJoin that cannot produce non-matched rows for its key type (skip converting outer to inner for those), leaving the atom logic purely about row survival.
  2. Fix the underlying StorageJoin keys128 non-matched-row generation (Storage Join(ALL, RIGHT, ...) + QUALIFY NULL: rewriting WHERE p to WHERE p AND 1 flips 0 rows → all right-side rows with left columns defaulted (no-op rewrite changes result + breaks join matching) #106949), after which no guard is needed here.

I did not touch the helper since it is yours; happy to implement whichever direction you prefer.

@vdimir

vdimir commented Jul 15, 2026

Copy link
Copy Markdown
Member

Fixed the 04335_storage_join_right_join_const_conjunct_pushdown Fast test failure (@ 7b4d2c7).

Root cause: the per-conjunct null-rejection check in isFilterAlwaysFalseForDefaultValueInputs treated a conjunct that references none of the reduced side's inputs (here a QUALIFY NULL constant) as a witness for reducing that side. That converted a RIGHT/FULL outer join to inner and exposed a pre-existing StorageJoin keys128 non-matched-row limitation (issue #106949).

Fix: keep the original whole-filter constant fold, and require any per-atom witness to reference at least one substituted (reduced-side) input. A conjunct independent of the reduced side says nothing about whether that side's unmatched rows are dropped, so it must not drive the reduction. The #110115 improvement is unchanged (predicates like l.k = 42 reference the substituted input and still convert + prune under join_use_nulls = 1). Also merged master to pick up the new test.

To fix this we do not convert OUTER to INNER in case of storage join.

@groeneai

Copy link
Copy Markdown
Contributor Author

You are right that a falsy atom filters rows out, and I confirmed the conversion is never a wrong-results bug: whenever a reduced-side-independent atom folds falsy, the whole filter is falsy for every row, so the query returns nothing whether or not we convert. The only observable effect of converting on such an atom is that StorageJoin's keys128 non-matched-row path then throws Code 121 UNSUPPORTED_JOIN_KEYS (issue #106949), because the conversion asks it to materialize right-unmatched rows it cannot build for composite keys.

So the dependency check is not protecting correctness, it is only avoiding that pre-existing StorageJoin limitation. The real issue is in that other place, as you suspected. Verified with an A/B debug build (guard removed) on 04335:

  • conversion off: OK, empty result
  • conversion on: Code 121 ... keys128 in StorageJoin
  • same query on regular Memory tables: converts, empty in both cases (no wrong results)

I also confirmed the current guard is only syntactic and can be fooled, exactly as the bot noted: WHERE if(materialize(1), NULL, r.k = 42) AND l.k = 42 on a FULL join still reduces to INNER (the raw atom mentions r.k, but skip_materialize folds the condition to constant so the fold does not actually depend on r.k). It stays result-preserving there too, but it shows the check does not really prove reduced-side dependency.

Given that, the check probably does not belong in filterResultForNotMatchedRows at all. Two cleaner options, your call:

  1. Gate the conversion where the target join is a FilledJoin/StorageJoin that cannot produce non-matched rows for its key type (skip converting outer to inner for those), leaving the atom logic purely about row survival.
  2. Fix the underlying StorageJoin keys128 non-matched-row generation (Storage Join(ALL, RIGHT, ...) + QUALIFY NULL: rewriting WHERE p to WHERE p AND 1 flips 0 rows → all right-side rows with left columns defaulted (no-op rewrite changes result + breaks join matching) #106949), after which no guard is needed here.

I did not touch the helper since it is yours; happy to implement whichever direction you prefer.

Per @vdimir's review on ClickHouse#110121: the per-conjunct null-rejection check
does not protect correctness (whenever a reduced-side-independent atom
folds falsy, the whole filter is falsy for every row, so the query
returns nothing whether or not we convert). Its only effect was to
sidestep the StorageJoin keys128 non-matched-row limitation (ClickHouse#106949).

Gate the conversion on the target join instead: skip converting outer to
inner when the reduced (right/filled) side is a StorageJoin, which cannot
build not-matched rows for composite keys. Earlier passes (filter
push-down, runtime-filter build) can insert single-child steps between
the join and its JoinStepLogicalLookup source, so descend through them.

This restores filterResultForNotMatchedRows to pure row-survival logic
(the per-atom conjunction evaluation from ClickHouse#110115 is unchanged), and the
ClickHouse#110115 improvement still converts + prunes regular joins under
join_use_nulls = 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@groeneai

Copy link
Copy Markdown
Contributor Author

Done, per your direction: do not convert outer to inner for storage join (7ac308b).

The dependency guard in filterResultForNotMatchedRows is removed, so it is back to pure row-survival logic (the per-atom conjunction evaluation from #110115 is unchanged). The gate now lives in the conversion: skip when the reduced (right/filled) side is a StorageJoin. Filter push-down and runtime-filter build can insert single-child steps between the join and its JoinStepLogicalLookup, so the check descends through them (the pre-existing direct-child check missed 04335 for that reason).

Verified on a Build-ID-matched debug binary (no coverage):

Comment thread src/Functions/toNullable.cpp
The PR reports toNullable as a strictly-monotonic wrapper so KeyCondition can
prune primary-key granules through toNullable(key). The join test only checks
join kind and result parity and does not exercise MergeTree pruning, so an
incorrect monotonicity contract would be a silent pruning regression.

This test asserts via EXPLAIN indexes=1 that toNullable(k) prunes to the same
granules as the bare key (equality and range) and that pruning actually happens.
Verified: fails without the monotonicity change (all granules read), passes with
it; 40/40 under randomized settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@groeneai

Copy link
Copy Markdown
Contributor Author

Added 04539_tonullable_monotonicity_pruning — focused MergeTree pruning regression for the toNullable(key) monotonicity contract (bot review r3587034852).

Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. EXPLAIN indexes = 1 SELECT count() FROM t WHERE toNullable(k) = 42 reads all granules without the monotonicity change, prunes to the bare-key granule set with it.
b Root cause explained? Without hasInformationAboutMonotonicity/getMonotonicityForRange on toNullable, KeyCondition cannot see toNullable(k) as an order-preserving key wrapper, so it disables PK pruning for that predicate and scans every granule.
c Fix matches root cause? This is a test-only addition covering the monotonicity contract the PR already adds; it targets the exact untested path (KeyCondition pruning through toNullable(key)).
d Test intent preserved / new tests added? New focused test. It asserts pruning via EXPLAIN indexes = 1, not just join-kind/result parity, so it catches the silent-pruning regression class the join test cannot.
e Both directions demonstrated? Yes. Removing the monotonicity block and rebuilding: assertions fail (all granules read) while results stay correct. Restoring it: passes. Verified on Build-ID-matched binaries.
f Fix is general across code paths? Covers equality and range predicates through toNullable(key).
g Fix generalizes across inputs? Assertions compare Granules: selected/total for toNullable(k) against the bare key rather than a hardcoded count, so they hold under randomized index_granularity/merge-tree settings (40/40).
h Backward compatible? N/A (test-only).
i Invariants and contracts preserved? N/A (test-only); the test encodes the monotonicity contract: toNullable(k) must prune identically to k.

Session id: cron:clickhouse-worker-slot-3:20260715-123700

The outer-to-inner conversion for join_use_nulls runs only on the analyzer
(JoinStepLogical) path. With enable_analyzer = 0 the legacy JoinStep path
bails out for join_use_nulls, so the join kind must stay outer. Add a test
that asserts the legacy path is a safe no-op fallback (keeps FULL/LEFT/RIGHT)
and returns identical results with the optimization on and off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@groeneai

Copy link
Copy Markdown
Contributor Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. EXPLAIN actions=1 on the legacy path (enable_analyzer=0, join_use_nulls=1) deterministically shows the join kind stays outer (full/left/right).
b Root cause explained? The outer-to-inner reduction lives only in the analyzer JoinStepLogical path. tryConvertOuterJoinToInnerJoinLegacy returns early for join_use_nulls (convertOuterJoinToInnerJoin.cpp:38-40), so with enable_analyzer=0 the new filterResultForNotMatchedRows logic is never reached. 04538 forces enable_analyzer=1, so the legacy boundary was uncovered.
c Fix matches root cause? Test-only. Adds the missing coverage for the legacy path the bot flagged; no code change (legacy fn is byte-identical to master).
d Test intent preserved / new tests added? New test 04540. Asserts legacy path is a safe no-op fallback (keeps outer kind) AND result parity on/off.
e Demonstrated in both directions? Guard test: reference is full/left/right + identical result rows. A regression that made the legacy path convert to inner, or changed results, flips the reference. Verified legacy stays outer and results match with the optimization on and off.
f Fix is general, not a narrow patch? N/A (test-only; covers all three outer kinds FULL/LEFT/RIGHT on the legacy path).
g Generalizes across inputs? N/A (test-only).
h Backward compatible? N/A (test-only).
i Invariants and contracts preserved? N/A (test-only).

Session id: cron:clickhouse-worker-slot-1:20260715-140600

@groeneai

Copy link
Copy Markdown
Contributor Author

CI finish ledger — e28aaba

Every failure below has an owner: a fixing PR, or a full-effort fix task whose fixing-PR link will be posted here when it opens. Only CH Inc sync is exempt.

Check / test Reason Owner
Stateless tests (amd_asan_ubsan, distributed plan, parallel) / 02346_text_index_prewhere_support flaky (EXPLAIN mismatch from randomized query_plan_remove_unused_columns; 7 unrelated PRs/30d, 0 master, not in this PR's diff) a fix task is created (investigating at full effort; fixing-PR link to follow here)
Docker server image / clickhouse-server:head-distroless-arm64 infra (transient apt-mirror "no installation candidate", failing across ~8 unrelated PRs + master in 2d) a fix task is moved to pending (investigating; fixing-PR link to follow here)
Mergeable Check / PR cascade of the two above (not independent) (n/a)
CH Inc sync private CH Inc sync (private, not actionable by us)

Neither red is PR-caused: this PR (join_use_nulls outer→inner conversion, approved by @vdimir) does not touch text-index/prewhere plans or Docker packaging. Finish Workflow is green.

@groeneai

Copy link
Copy Markdown
Contributor Author

Update on the 02346_text_index_prewhere_support line in this PR's CI ledger.

Investigated locally (debug build, 40 runs with CI randomization enabled): the test passes 40/40. The EXPLAIN plan is stable; the test already pins query_plan_remove_unused_columns, optimize_move_to_prewhere and query_plan_optimize_prewhere in the inner EXPLAIN SETTINGS, so it is not sensitive to those randomized settings.

The failure here was not a plan/result mismatch. It was Code: 241 MEMORY_LIMIT_EXCEEDED with current RSS: 41.84 GiB, maximum: 41.84 GiB on the Stateless tests (amd_asan_ubsan, distributed plan, parallel) job, and the runner's own randomized-settings diagnosis reported "All reruns passed. The failure is not reproducible (likely a transient issue)."

This is the fleet-wide host-OOM affecting that job (6,323 distinct tests across 159 PRs in the last 3 days, master included), not anything specific to 02346_text_index_prewhere_support. It is tracked as a separate CI-infra owner. No fix belongs in this test.

@PedroTadim

Copy link
Copy Markdown
Member

@groeneai merge with master

@groeneai

Copy link
Copy Markdown
Contributor Author

Merged master into the branch (no conflicts), pushed as 447bae9. CI re-running.

return FilterResult::UNKNOWN;

ActionsDAG::NodeRawConstPtrs targets = {filter_node};
auto conjunction_atoms = ActionsDAG::extractConjunctionAtoms(filter_node);

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.

This helper is shared with convertAnyJoinToSemiOrAntiJoin (src/Processors/QueryPlan/Optimizations/convertAnyJoinToSemiOrAntiJoin.cpp:153 and :184), so the new per-conjunct FALSE witness changes more than the ALL OUTER→INNER path covered by this PR description. Under join_use_nulls = 1 it can now also turn LEFT/RIGHT ANY JOIN into SEMI / ANTI when the whole filter stays UNKNOWN but one conjunction atom folds falsy after NULL substitution.

The new tests here only exercise ALL outer→inner (04538 / 04540) plus standalone toNullable pruning. 03708_analyzer_convert_any_outer_to_inner_2.sql explicitly disables query_plan_convert_any_join_to_semi_or_anti_join, so there is still no proof that the shared ANY rewrite remains correct on this join_use_nulls = 1 path. Please add one focused LEFT/RIGHT ANY JOIN regression with query_plan_convert_any_join_to_semi_or_anti_join toggled, asserting result parity and the expected SEMI / ANTI rewrite.

@clickhouse-gh

clickhouse-gh Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.00% 86.10% +0.10%
Functions 91.80% 91.80% +0.00%
Branches 78.10% 78.20% +0.10%

Changed lines: Changed C/C++ lines covered: 46/48 (95.83%) · Uncovered code

Full report · Diff report

@groeneai

Copy link
Copy Markdown
Contributor Author

CI finish ledger — 447bae9

Every failure below has an owner. This ledger covers the post-"merge master" head (@PedroTadim directive; slot-3 merge commit). Supersedes the pre-merge ledger (issuecomment-4985278723, head e28aaba).

Check / test Reason Owner
Sync - CH Inc sync (private, not actionable)

Finish Workflow = SUCCESS. Zero failing test rows on this head; the two owned reds from the pre-merge ledger (02346_text_index_prewhere_support flaky; Docker apt infra) did not recur on the merged head. No PR-caused failure. Approved by @vdimir.

Session id: cron:our-pr-ci-monitor:20260717-220147

@alexey-milovidov
alexey-milovidov merged commit e0e7811 into ClickHouse:master Jul 19, 2026
176 of 177 checks passed
@alexey-milovidov

Copy link
Copy Markdown
Member

@clickgapai, please read a comment about "one focused regression test" and follow up with a PR.

@robot-ch-test-poll2 robot-ch-test-poll2 added the pr-synced-to-cloud The PR is synced to the cloud repo label Jul 19, 2026
@clickgapai

Copy link
Copy Markdown
Contributor

Thanks @alexey-milovidov — I looked into this but don't have a clean, confident result to post yet. If you can point me at a specific file/line or the exact behaviour you want checked, I'll dig in precisely.

@clickgapai

Copy link
Copy Markdown
Contributor

I looked into #110121 but didn't land on a concrete test to add — the requested work looks like it may already be in the PR, or I couldn't pin down exactly what's still needed. If you have a specific case in mind (a query, a file, or the exact scenario to cover), point me to it and I'll follow up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors pr-performance Pull request with some performance improvements pr-synced-to-cloud The PR is synced to the cloud repo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

join_use_nulls = 1 disables outer→inner JOIN conversion and join filter pushdown for null-rejecting predicates

7 participants