Skip to content

Do not prune by a min/max range when a positive IN set holds NaN - #117453

Open
alexey-milovidov wants to merge 1 commit into
masterfrom
fix-in-nan-minmax-pruning-116927
Open

Do not prune by a min/max range when a positive IN set holds NaN#117453
alexey-milovidov wants to merge 1 commit into
masterfrom
fix-in-nan-minmax-pruning-116927

Conversation

@alexey-milovidov

@alexey-milovidov alexey-milovidov commented Sep 1, 2026

Copy link
Copy Markdown
Member

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

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

Fixed WHERE f IN (nan) silently losing the NaN rows of a floating-point column: statistics-based part pruning and the minmax skip index use ranges that exclude NaN, so a part or granule holding NaN was pruned even though IN matches NaN.

Documentation entry for user-facing changes

IN matches NaN bit-exactly — SELECT nan IN (nan) is 1 — unlike a comparison, which is always false for NaN. But min/max-based pruning works with ranges produced by IColumn::getExtremes, which deliberately skips NaN, so a part or granule that holds NaN next to finite values gets a NaN-free range, the set-vs-range intersection finds no overlap, and it is pruned even though row-wise evaluation of the same filter matches those rows.

Both arms fire at pure default settings on a plain MergeTree table — automatic Basic statistics are materialized on INSERT and use_statistics_for_part_pruning defaults to 1:

CREATE TABLE s1 (k UInt64, f Float64) ENGINE = MergeTree ORDER BY k;
SYSTEM STOP MERGES s1;
INSERT INTO s1 SELECT number, if(number < 13, nan, 1.5) FROM numbers(100000);
INSERT INTO s1 SELECT number + 200000, 2.5 FROM numbers(1000);

SELECT count() FROM s1 WHERE f IN (nan);       -- 0, the truth is 13
SELECT count() FROM s1 WHERE f IN (nan, 2.5);  -- 1000, the truth is 1013

The fix for #113417 taught the statistics pruner to skip a floating-point column under a negating function, on the assumption that only a negation can make a floating-point predicate true for NaN. A positive IN is the counterexample: it is a set-membership test, not a comparison. It now skips such a column too — whether the set actually holds a NaN is not knowable there, since it may come from a subquery.

KeyCondition can inspect the elements, so it declines the set atom only when the set really contains a NaN and a key column is floating-point. The atom serves every index at once, so declining it also forgoes the primary key for that predicate, which is the same trade has already makes for floating-point arrays.

Closes: #116927
Related: #113417


Workflow [PR]
Sync PR [sync-upstream/pr/117453]

`IN` matches `NaN` bit-exactly - `SELECT nan IN (nan)` is `1` - unlike a
comparison, which is always false for `NaN`. But min/max-based pruning works
with ranges produced by `IColumn::getExtremes`, which deliberately skips
`NaN`, so a part or granule that holds `NaN` next to finite values gets a
`NaN`-free range, the set-vs-range intersection finds no overlap, and it is
pruned even though row-wise evaluation of the same filter matches those rows.

Both arms fire at pure default settings on a plain `MergeTree` table -
automatic `Basic` statistics are materialized on `INSERT` and
`use_statistics_for_part_pruning` defaults to `1`:

    CREATE TABLE s1 (k UInt64, f Float64) ENGINE = MergeTree ORDER BY k;
    SYSTEM STOP MERGES s1;
    INSERT INTO s1 SELECT number, if(number < 13, nan, 1.5) FROM numbers(100000);
    INSERT INTO s1 SELECT number + 200000, 2.5 FROM numbers(1000);

    SELECT count() FROM s1 WHERE f IN (nan);       -- 0, the truth is 13
    SELECT count() FROM s1 WHERE f IN (nan, 2.5);  -- 1000, the truth is 1013

The fix for #113417 taught the statistics pruner to skip a floating-point
column under a negating function, on the assumption that only a negation can
make a floating-point predicate true for `NaN`. A positive `IN` is the
counterexample: it is a set-membership test, not a comparison. It now skips
such a column too - whether the set actually holds a `NaN` is not knowable
there, since it may come from a subquery.

`KeyCondition` can inspect the elements, so it declines the set atom only
when the set really contains a `NaN` and a key column is floating-point. The
atom serves every index at once, so declining it also forgoes the primary key
for that predicate, which is the same trade `has` already makes for
floating-point arrays.

Closes: #116927
Related: #113417
@clickhouse-gh

clickhouse-gh Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [4440da6]

Summary:

job_name test_name status info comment
Docs examples FAIL
Documentation examples FAIL cidb
Finish Workflow FAIL
python3 ./ci/jobs/scripts/workflow_hooks/new_tests_check.py FAIL
Bugfix validation (functional tests, amd64) ERROR
Bugfix validation (functional tests, aarch64) ERROR
Integration tests (amd_asan_ubsan, db disk, old analyzer, 6/6) ERROR
Container memory budget exceeded (/docker) ERROR cidb

AI Review

Summary

The PR fixes the wrong-result case where a positive IN set containing NaN was pruned by minmax-style range checks even though row-wise evaluation matches those rows. The KeyCondition side looks like the right fix, but the StatisticsPartPruner change overcorrects and disables statistics-based pruning for every floating-point IN, including ready NaN-free sets that were previously safe to prune. That is a default-path performance regression, so I would request changes.

Findings

⚠️ Majors

  • [src/Storages/Statistics/StatisticsPartPruner.cpp:134-135] Adding isSetMembershipFunction here drops every floating-point IN column from stats_column_name_to_type_map, so use_statistics_for_part_pruning stops pruning safe cases like f IN (1.5) and already-built subquery sets without NaN. StatisticsPartPruner already constructs KeyCondition with require_ready_sets = true, and KeyCondition::tryPrepareSetIndexForIn now has the precise setElementsContainNaN guard, so the blanket exclusion is unnecessary and regresses common float IN filters to full part scans. Suggested fix: keep the statistics-side exclusion limited to the negation family and let KeyCondition decide based on set readiness and actual contents.
Tests
  • ⚠️ Add an EXPLAIN indexes = 1 case that proves statistics-based part pruning still fires for a floating-point IN with a ready NaN-free set, for example f IN (1.5). The new test only pins the minmax path, so this regression is currently invisible.
Final Verdict
  • Status: ⚠️ Request changes
  • Minimum required actions: remove the blanket statistics-side bailout for positive floating-point IN predicates, and add focused coverage that keeps use_statistics_for_part_pruning working for ready NaN-free sets.

LLVM Coverage Report

Measured on commit 4440da6.

Metric Baseline Current Δ
Lines 88.60% 88.60% +0.00%
Functions 92.10% 92.10% +0.00%
Branches 80.90% 80.90% +0.00%

Changed lines: Changed C/C++ lines covered: 41/49 (83.67%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Sep 1, 2026
|| (node.type == ActionsDAG::ActionType::FUNCTION && node.function_base
&& isNegatingFunction(node.function_base->getName()));
&& (isNegatingFunction(node.function_base->getName())
|| isSetMembershipFunction(node.function_base->getName())));

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 broadens the NaN exclusion from the genuinely unsafe cases to every positive set-membership predicate on a floating-point column. As soon as isSetMembershipFunction matches, StatisticsPartPruner drops the column from stats_column_name_to_type_map, so use_statistics_for_part_pruning no longer prunes safe queries like f IN (1.5) or a ready subquery set without NaN.

The branch already has the precise machinery to avoid that. StatisticsPartPruner::getKeyConditionForEstimates builds KeyCondition with require_ready_sets = true, so unbuilt subqueries are already declined safely, and this PR adds setElementsContainNaN in KeyCondition::tryPrepareSetIndexForIn, so ready sets that actually contain NaN are declined there too. By dropping the column earlier, we never reach that narrower check and regress every float IN back to a full part scan. Please keep the statistics-side exclusion limited to the negation family, and add an EXPLAIN indexes = 1 case for a float IN (1.5) query with use_statistics_for_part_pruning = 1 so this stays covered.

@clickhouse-gh

clickhouse-gh Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing 4440da68a with master 38a9ca207 (stripped binary size, per-symbol sizes and ThinLTO time; object sizes against the warmup build of 9f35a2b2a; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes

programs/clickhouse-stripped: smaller than the master baseline by the known offset between the two builds, so the difference is not shown. A delta that differs from the offset by more than 50% of it is shown, in either direction.

The official master build is compiled with -g and a pull request build is not, and XRay counts debug instructions towards its instrumentation threshold, so master instruments thousands of functions more and its binary is ~0.4% larger no matter what the pull request does.

Compile time of recompiled translation units

8 translation units recompiled, 23 s compile time in total, 8 of them have a recent master baseline.

Job report

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

Labels

pr-bugfix Pull request with bugfix, not backported by default

1 participant