Skip to content

Do not execute an IN subquery during statistics part pruning - #114121

Merged
alexey-milovidov merged 8 commits into
ClickHouse:masterfrom
groeneai:fix-statistics-pruning-no-subquery-execution
Aug 19, 2026
Merged

Do not execute an IN subquery during statistics part pruning#114121
alexey-milovidov merged 8 commits into
ClickHouse:masterfrom
groeneai:fix-statistics-pruning-no-subquery-execution

Conversation

@groeneai

@groeneai groeneai commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

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 into CHANGELOG.md):

Fixed a LOGICAL_ERROR "Not-ready Set is passed as the second argument for function globalNullIn" and the loss of the query's real error message, which could happen when statistics part pruning speculatively executed an IN/GLOBAL IN subquery during index analysis and that subquery failed.

Description

Related: #107619 (one carrier). Reported on #113904.

Statistics part pruning runs before primary-key analysis and builds its own KeyCondition per part. That constructor walks the predicate and, for an IN-family atom, called buildOrderedSetInplace, which executes the subquery. For GLOBAL IN an external table exists, so the plan-preserving clone is skipped and the plan is consumed by std::move(source). When that speculative subquery failed, the per-part best-effort handler in MergeTreeDataSelectExecutor swallowed the exception at debug level, leaving the set permanently unbuilt, so FunctionIn aborted with Not-ready Set and the real error was lost. In the cited CI run the discarded error was Code: 181 ILLEGAL_FINAL.

Part pruning is analysis-only, so it now declines an IN atom whose set is not already built, via a require_ready_sets flag on KeyCondition::BuildInfo passed only by StatisticsPartPruner. Same rule tryRewriteInTruthyCondition already applies in this file.

Cost. Pruning is preserved where no execution is needed: literal IN lists and subquery sets already built earlier, notably when the IN column is in the primary key. Primary-key, partition, minmax and skip-index analysis are untouched (the flag defaults to false). What remains: IN (subquery) on a non-key column with statistics no longer prunes, the one case whose alternative is the defect itself.

The per-part handler is deliberately unchanged: narrowing it by error code would redden 02707_skip_index_with_in.sql:25, and the protected region runs an arbitrary user subquery, so no code identifies a statistics failure. Complementary to #102192 and #102308, which target the silent-stop case: #102308 restores only in the not-created branch, so an exception still escapes with source moved from.

New test 04852_statistics_part_pruning_no_subquery_execution asserts the pruning outcome in the declined case and both preserved cases. Fails on unpatched master, passes with the fix, 100/100 green; 02707, 04209_statistics_*, 02864_statistics_* and 03788_statistics_part_pruning* unchanged. Fixes the statistics-pruning carrier, not all of #107619.


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

Version info

  • Merged into: 26.8.1.1713 (included in 26.8 and later)

Statistics part pruning runs before primary key analysis and builds its own
KeyCondition per part. That constructor walks the predicate and, for any
IN-family atom, called FutureSetFromSubquery::buildOrderedSetInplace, which
EXECUTES the subquery. For GLOBAL IN an external table exists, so the
source-preserving clone in PreparedSets is skipped and the canonical subquery
plan is consumed by `auto plan = std::move(source)`.

When that speculative subquery then failed, the exception was swallowed by the
per-part best-effort handler in MergeTreeDataSelectExecutor (logged at debug and
skipped), leaving the set permanently unbuilt with source == nullptr. No build
step was added for it later, so FunctionIn aborted with
"Not-ready Set is passed as the second argument for function 'globalNullIn'"
and the query's real error was never reported.

Part pruning is an analysis-only pass, so it now declines an IN atom whose set is
not already built instead of building one, via a require_ready_sets flag on
KeyCondition::BuildInfo passed only by StatisticsPartPruner. This is the same
discipline tryRewriteInTruthyCondition already applies in the same file.

Pruning power is preserved wherever the set does not have to be executed:
literal IN lists, StorageSet sets, and subquery sets already built by an earlier
analysis stage (notably when the IN column is in the primary key, where
buildIndexes runs before pruning). Primary key, partition, minmax and skip index
analysis are unaffected because the flag defaults to false. The remaining cost is
that an IN (subquery) on a non-key column with statistics no longer prunes parts,
which is exactly the case whose alternative is executing a user subquery during
part pruning.

The per-part handler is deliberately left unchanged: narrowing it by error code
would redden 02707_skip_index_with_in, and the protected region runs an arbitrary
user subquery so no error code can identify a statistics failure.

Related: ClickHouse#107619
Stop background merges on both fixtures so the pinned "Parts: 1/3" counts
cannot drift when the three level-0 parts are merged, and pin the settings
that reshape the EXPLAIN plan the assertions match.

optimize_use_implicit_projections is pinned to 1 rather than 0: at 0 the
implicit minmax_count projection is disabled and the first query prunes
nothing even without the fix, so the assertion passes on unpatched master
and stops detecting the bug. Verified by running the whole test against
both binaries: with the pin at 1 the unpatched arm fails and the fixed arm
passes; at 0 both pass. A file scope SET also beats runner injection, so
the arm still reddens when the test runner injects the value 0.

The first assertion's comment now states what it observes (absence of the
Statistics row) instead of claiming it proves the subquery was never run.
…n test

The test pinned optimize_use_implicit_projections but not its parent
optimize_use_projections. The effective value is the conjunction of the two
(QueryPlanOptimizationSettings.cpp:213,222), and the test runner randomizes the
parent to false with probability 0.05, so the child pin was bypassable: in that
case the first assertion passes on an unpatched binary, and the test would
report success while detecting nothing.

Verified with a negative control: with the parent pin removed and
optimize_use_projections=0 injected, the pre-fix binary passes; with the pin in
place it still fails, so the pin beats runner injection.

Also drop a comment reference to a measurement that is not part of the PR.
@groeneai

Copy link
Copy Markdown
Collaborator Author
Internal second-model review: 3 rounds, final round clean (click to expand)

Independent cold review plus a second-model gate on every round; the reviewer never edits the
source. Three rounds ran before this PR was opened. The final round returned no findings.

# Round Finding Verdict
⚠️ 1 Parts: N/M pinned on unpartitioned tables built from 3 one-row inserts, with no SYSTEM STOP MERGES. Nearest same-shape test in tree has 9 CI failures in 45 days, all on the EXPLAIN indexes block. AGREE. SYSTEM STOP MERGES added per table, proven both directions (Code: 236 while stopped, 1 part after START + OPTIMIZE FINAL).
⚠️ 1 Plan-shape assertions unpinned against 5 settings the test runner randomizes. AGREE. The four pins the sibling statistics tests use were added.
💡 1 The claim that absence of a Statistics plan row proves the subquery was not executed is stronger than that assertion supports. AGREE on the observation, DISAGREE on the proposed remedy: asserting the original error survives a failing IN returns an identical Code: 395 on both the patched and unpatched binaries, so it would be green either way. Fixed by relabelling the assertion and correcting one sentence here instead.
2 optimize_use_implicit_projections is a child setting whose effective value is optimize_use_projections AND itself, and only the child was pinned. The runner randomizes the parent, and with the parent off the first assertion passes on the unpatched binary, i.e. coverage silently lost behind a green test. AGREE, found independently by both reviewers. Parent pinned. Proven by a negative control: with the new line removed and the parent injected off, the pre-fix binary passes; with it, the same injection still fails.
💡 2 A test comment cited a measurement that lives only in internal notes, not in this PR. AGREE. Comment now states the observable only.
- 3 No findings. -

Also adjudicated: the implementer reported that one prescribed setting value made the regression
detector vacuous and pinned the opposite value instead. Re-measured and confirmed: the fix plan
was wrong, the implementer was right, and the change stands as committed.

Validated with a pre-fix and a post-fix binary, build IDs asserted per server rather than inferred:
the new test fails before the change and passes after, 100 of 100 runs green with and without
settings randomization, and 02707_skip_index_with_in, 02864_statistics_*,
03788_statistics_part_pruning* and 04209_statistics_retry_load are unchanged.

@groeneai

Copy link
Copy Markdown
Collaborator Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. SELECT count() FROM d WHERE i IN (SELECT throwIf(1)) SETTINGS use_statistics_for_part_pruning=1, use_skip_indexes=0, use_statistics=0 on a 3-part MergeTree whose i declares basic statistics and is not in the primary key. Unpatched: exactly 3 Failed to apply statistics pruning swallows (one per part). Patched: 0. Same for the GLOBAL IN spelling. Not probabilistic.
b Root cause explained? Statistics part pruning (ReadFromMergeTree.cpp:2859) runs before primary-key analysis and builds its own KeyCondition per part (StatisticsPartPruner.cpp:100). That ctor reaches tryPrepareSetIndexForIn -> buildOrderedSetInplace (KeyCondition.cpp:2617), which EXECUTES the subquery; for GLOBAL IN the external table makes PreparedSets.cpp:499 false so :379 std::move(source) consumes the plan. The failure is then swallowed at MergeTreeDataSelectExecutor.cpp:855 (debug), leaving the set unbuildable, and FunctionIn throws Not-ready Set (in.cpp:116).
c Fix matches root cause? Yes. It removes the side effect at the component that should not have it, rather than guarding the abort site or reclassifying the swallowed error. The catch is deliberately untouched.
d Test intent preserved / new tests added? New 04852_statistics_part_pruning_no_subquery_execution.sql. No existing test weakened; 02707_skip_index_with_in (which pins today's fallback behaviour) passes unchanged.
e Both directions demonstrated? Yes, twice. The swallow counter goes 3 -> 0 across the two binaries (Build IDs afab0039... base, 8c85db17... fixed, asserted via SELECT lower(buildId()) on each server). The new test FAILS on unpatched master (-0/+1) and passes with the fix.
f Fix is general across code paths? All 8 IN spellings share functionIsInOrGlobalInOperator (misc.h:24) and the same :2617 call, so the fix is spelling-agnostic by construction (verified on both IN and GLOBAL IN). MergeTreeDataSelectExecutor.cpp:855 is the only catch in the whole set-build-during-analysis chain (0 in ReadFromMergeTree.cpp and in the 10 other buildOrderedSetInplace callers), so no sibling can produce this signature. StatisticsPartPruner.cpp:67 already refuses to force-build via tryRewriteInTruthyCondition. ConditionSelectivityEstimator.cpp:301 calls the same builder but sits inside no catch, so it propagates and cannot yield this LOGICAL_ERROR; out of scope.
g Fix generalizes across inputs? This is a control-flow change on an engine-agnostic path, so type wrappers are not implicated. The set-kind matrix was measured instead: literal IN lists (FutureSetFromTuple) and StorageSet sets do not read the readiness gate and still prune (asserted in the test); already-built subquery sets still prune (the primary-key case, also asserted).
h Backward compatible? Yes. No new setting, no default change, no format or protocol change, so no SettingsChangesHistory.cpp entry. The new ctor parameter is defaulted to false, so every existing call site keeps today's behaviour; verified that only StatisticsPartPruner passes 7 positional args and the KeyDescription delegating ctor passes 6.
i Invariants and contracts preserved? The declined atom becomes FUNCTION_UNKNOWN (KeyCondition.cpp:3689), the documented "cannot analyse this atom" outcome, so pruning stays sound (never prunes a part that could match) and every other atom still contributes. BuildInfo has exactly one construction site, so the flag cannot leak into primary-key, partition, minmax or skip-index analysis; asserted empirically by the preserved-pruning rows.

Session id: cron:clickhouse-impl-slot-41:20260810-014800

@groeneai

Copy link
Copy Markdown
Collaborator Author

cc @hanfei1991 @tiandiwonder, could you review this? Statistics part pruning built its own KeyCondition per part, whose ctor reached buildOrderedSetInplace and executed the IN subquery during index analysis; for GLOBAL IN that consumed the subquery plan, and the per-part best-effort handler then swallowed the real error, so FunctionIn later aborted with "Not-ready Set". Pruning now declines an IN atom whose set is not already built.

@hanfei1991 hanfei1991 self-assigned this Aug 10, 2026
@hanfei1991 hanfei1991 added the can be tested Allows running workflows for external contributors label Aug 10, 2026
@clickhouse-gh

clickhouse-gh Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [b19249e]

Summary:


AI Review

Summary

This PR makes statistics part pruning decline IN-family atoms unless their set is already built, which removes the speculative subquery execution that could consume the set source and replace the real error with Not-ready Set. The current HEAD also carries focused stateless coverage for the non-key path, the preserved primary-key path, and the throwing-subquery regression, and I did not find any remaining correctness or metadata issue in the diff as scoped.

Final Verdict

✅ No new findings.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.80% 86.80% +0.00%
Functions 92.00% 92.00% +0.00%
Branches 79.20% 79.20% +0.00%

Changed lines: Changed C/C++ lines covered: 21/22 (95.45%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Aug 10, 2026
@clickhouse-gh

clickhouse-gh Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing b19249ebc with master e19253a90 (stripped binary size, per-symbol sizes and ThinLTO time; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes
Binary Master PR Δ
programs/clickhouse-stripped 701.97 MiB 698.95 MiB -3.02 MiB (-0.43%)

Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction.

Compile time of recompiled translation units

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

Median compile-time ratio to the baselines is ×1.05 (machine-speed difference or a change affecting every TU); per-TU deltas below are relative to that ratio.
The matched translation units cost +157.0 s (+5%) in total before that adjustment.

Job report

The reported failure used globalNullIn, but the regression test only
exercised plain IN. The guard keys on functionIsInOrGlobalInOperator, so
add a GLOBAL IN case and an explicit globalNullIn case to pin the whole
spelling family in-tree.

Shape selection was measured on both arms (fix buildId 8c85db17, base
afab0039). A local outer query with GLOBAL IN discriminates: base emits a
Statistics row, the fix emits none, and each new query discriminates in
isolation. The CI query's own shape - GLOBAL IN over a cluster() subquery -
was rejected as an oracle: its EXPLAIN contains two ReadFromMergeTree
blocks and the inner cluster read emits its own Statistics row, so the
count is 1 on both arms and the assertion would be vacuous.

An in-tree assertion on the external-table branch itself is not possible
here: setExternalTable has a single caller, in ReadFromRemote, so only a
distributed outer query creates the _data_ temporary table (confirmed from
the server log). A local GLOBAL IN covers the spelling through the shared
tryPrepareSetIndexForIn guard, which is what the fix keys on, and keeps
the test cluster-free and parallel-safe.

Verified: whole file base FAIL / fix PASS, 100/100 green over randomized
and non-randomized runs, and all six plan-shape pins still discriminate
when injected against the file-scope SET.

-- The `globalIn` family reaches the same guard, and the reported failure used `globalNullIn`.
SELECT count() FROM (EXPLAIN indexes = 1
SELECT count() FROM t_stats_prune_in WHERE c GLOBAL IN (SELECT 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.

Can you give me a case that can reproduce LOGICAL_ERROR Not-ready Set is passed as the second argument for function globalNullIn?
These SQLs can run successfully in old versions.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes. The ingredient the committed SQLs lack is a subquery that fails while running on a genuinely remote replica, which needs prefer_localhost_replica = 0. With a two-replica cluster (127.0.0.1 and 127.0.0.2) and statistics on a non-primary-key column:

SET allow_experimental_statistics = 1, allow_statistics = 1, materialize_statistics_on_insert = 1;
CREATE TABLE d (a String, i UInt64) ENGINE = MergeTree ORDER BY a
SETTINGS auto_statistics_types = 'basic', index_granularity = 1;
SYSTEM STOP MERGES d;
INSERT INTO d VALUES ('a', 1);
INSERT INTO d VALUES ('a', 100);
INSERT INTO d VALUES ('a', 200);

SELECT count() FROM d
WHERE globalNullIn(i, (SELECT i FROM cluster('two_replicas', currentDatabase(), 'd') FINAL WHERE a = 'a'))
SETTINGS use_skip_indexes = 0, use_statistics = 0, use_statistics_for_part_pruning = 1,
         prefer_localhost_replica = 0;

On master this aborts, deterministically (2/2 here):

<Debug> d (SelectExecutor): Failed to apply statistics pruning for part all_1_1_0, skipping
  statistics pruning for this part: Code: 181 ... Received from 127.0.0.2 ... doesn't support FINAL
<Fatal> : Logical error: 'Not-ready Set is passed as the second argument for function 'globalNullIn''

Same query id, 3 ms apart, and that is the CI signature from the run this PR was opened for. With the patch the query instead reports the real error, Code: 181 ILLEGAL_FINAL. The isolating control is on the unpatched binary: the same query with use_statistics_for_part_pruning = 0 returns ILLEGAL_FINAL and does not abort, so statistics part pruning is the cause rather than the spelling or the remote read.

On your second point, you are right and the reason is the default: use_statistics_for_part_pruning changed from false to true in 26.4 (SettingsChangesHistory.cpp:258), so before that release the pruner never ran and nothing reached this guard.

One clarification about what took the destructive path. buildOrderedSetInplace only preserves the plan when it can clone it, and ReadFromRemote has no clone(), so IQueryPlanStep::clone throws NOT_IMPLEMENTED and PreparedSets falls back to build(), which moves out source. That is why my earlier local-only attempts returned the subquery error on both binaries: a local plan clones, so the error survives. Two conditions reach the destructive branch, an external table or a non-clonable source, and any remote subquery is the second one.

I did not add this to 04852: it needs a multi-replica cluster and forced remote execution, and the pre-fix arm aborts the server instead of producing a diffable result, so it belongs in tests/integration rather than a stateless test. I am happy to add it there in this PR if you prefer that over keeping the stateless file focused on the pruning outcome.

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.

Please add a stateless or integration test to make sure trigger the logical error

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added it as a stateless test, in 04852 (commits 13dd8a6, 66f52d4).

It uses cluster('test_cluster_one_shard_two_replicas', ...) for the subquery plus prefer_localhost_replica = 0, and makes the subquery fail with throwIf. That is enough to hit the abort: a remote subquery has no clonable plan (ReadFromRemote declares no clone(), so IQueryPlanStep::clone throws and PreparedSets falls back to the destructive build()), pruning swallowed the subquery error, and the set stayed unbuilt. So an external table is not required, which is why the earlier local-only attempts saw nothing. The arm expects the subquery's own error, FUNCTION_THROW_IF_VALUE_IS_NON_ZERO.

Verified both directions with clickhouse-test: on the unpatched build it reports server died, and the log has the swallowed subquery error and Not-ready Set is passed as the second argument for function 'globalNullIn' under the same query_id, 3 ms apart. With the fix it passes. Also checked the arm alone in its own file, so it is not relying on the assertions that were already there. A second arm repeats the query with use_statistics_for_part_pruning = 0, where the unpatched build does not abort, which pins the abort to statistics part pruning rather than to the globalNullIn spelling or the remote read. 100/100 green with and without randomization.

One thing worth flagging: I had to pin enable_parallel_replicas = 0 and automatic_parallel_replicas_mode = 0 on that arm. Randomization picks automatic_parallel_replicas_mode = 2 about a quarter of the time, and routing the outer query through parallel replicas reshapes it so the subquery is no longer the consumed set source. Without the pins the arm passed on the unpatched build in those runs, so it would have been dead weight in CI.

On your other point, you are right that these run fine on older versions: use_statistics_for_part_pruning only became true by default in 26.4, so before that the pruner never ran and nothing reached the guard.

I also corrected the PR description, which claimed pruning was preserved for StorageSet sets. It is not: c IN ss_set gives Parts: 3/3 with no Statistics row, on both the patched and unpatched builds. No regression from this change, but the claim was wrong and is now removed.

@groeneai

Copy link
Copy Markdown
Collaborator Author

CI finish ledger - 520c06d

Every failure below has an owner: a fixing PR (mine or external), 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 / fixing PR
Stress test (amd_debug) / Hung check failed, possible deadlock found cancelled DROP TABLE prometheus never returns from DatabaseCatalog::waitTableFinallyDropped; the test-database teardown convoys behind it on the DDL guard (trunk bug, reproduced on master) a fix task owns it (root cause proven and written up at #114064 (comment), no fix PR yet); fixing-PR link to follow here

Not caused by this PR: the diff touches KeyCondition and StatisticsPartPruner, which cannot
reach TimeSeries storage or DatabaseCatalog teardown. The hung root is is_cancelled = 1 at
1133.98s and every other hung query in the same log is a strictly shorter DDL-guard victim in the
same test database.

Session id: cron:our-pr-ci-monitor:20260810-173000

The existing arms in this test pin the pruning outcome: statistics part pruning
declines an IN atom whose set is not built, and the preserved cases still prune.
None of them reaches the abort that motivated the fix, so a regression in the
error path would not be caught.

Building the set for a remote subquery consumes its plan, because ReadFromRemote
declares no clone() and IQueryPlanStep::clone throws NOT_IMPLEMENTED, which makes
PreparedSets fall back to the destructive build(). When that subquery then throws,
the per-part best-effort handler in statistics part pruning used to swallow the
error and leave the set permanently unbuilt, and FunctionIn aborted the server
with "Not-ready Set is passed as the second argument for function 'globalNullIn'".

The new arm forces the subquery onto another replica with
prefer_localhost_replica = 0 and makes it fail with throwIf. It expects the
subquery's own error. Measured on a two-replica cluster: the arm aborts the server
on the unpatched build (clickhouse-test reports "server died", the log shows the
swallowed subquery error and the logical error under the same query_id) and passes
with the fix. A second arm repeats the query with pruning off, where the unpatched
build does not abort, which attributes the abort to statistics part pruning rather
than to the globalNullIn spelling or the remote read.
Randomized runs select automatic_parallel_replicas_mode = 2 together with
enable_parallel_replicas = 1, which routes the outer query through parallel
replicas. The subquery is then no longer the consumed set source, so the arm
stopped reaching the error path: measured on the unpatched build, the arm alone
returned the expected error and left the server alive, i.e. it passed for the
wrong reason while the pre-existing assertions in the file were what failed.

Pinning enable_parallel_replicas = 0 and automatic_parallel_replicas_mode = 0
restores the abort on the unpatched build under that same randomized vector.
@groeneai

Copy link
Copy Markdown
Collaborator Author

CI finish ledger - 66f52d4

Every failure below has an owner: a fixing PR (mine or external), 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 / fixing PR
Stress test (amd_tsan) / Hung check failed, possible deadlock found already-cancelled DROP TABLE prometheus (is_cancelled: 1, elapsed 1091 s) never returns from DatabaseCatalog::waitTableFinallyDropped; DROP DATABASE ... SYNC convoys behind it on the DDL guard #113397 (external, open), issue #113954
Integration tests (arm_binary, distributed plan, 3/4) / test_nats_jet_stream.py::test_nats_restore_failed_connection_without_losses_on_write NATS JetStream subscription closed by the broker leaves the consumer with no fetch status, so messages are lost (assert at test_nats_jet_stream.py:1045); 1 failure out of 1689 in that shard #112828 (mine, open)

The hung-check owner was resolved from hung_check.log rather than by test name, since that test
name pools more than one defect.

175 jobs, 0 incomplete, 0 dropped, 155 green, 18 skipped. Config Workflow and Finish Workflow
both succeeded, last completion 2026-08-11T04:40:39Z.

Not caused by this PR: it changes statistics part pruning to stop executing an IN subquery, and
neither failure touches statistics, pruning or NATS-adjacent code.

Session id: cron:our-pr-ci-monitor:20260811-060000

auto expression = std::make_shared<ExpressionActions>(std::move(actions_dag));

auto new_key_condition = std::make_unique<KeyCondition>(filter_dag, context, column_names, expression);
/// Part pruning is analysis only: it may use an `IN` set that is already built, but must never

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.

Future readers will wonder why the comment talks about "IN sets". None of the surrounding code mentions IN sets.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Right, that comment was written for the fix rather than for the site. Reworded in b19249e to state the local invariant without reaching for anything the surrounding code does not name:

/// Pruning estimates must not run a query pipeline: only state that is already computed may be
/// read here.

const ExpressionActionsPtr key_expr;
/// All intermediate columns are used to calculate key_expr.
const NameSet key_subexpr_names;
/// If true, an `IN` atom whose set is not built yet is declined instead of building it.

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.

This looks more like a quickhack, tbh.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fair -- it is a bool threaded through a constructor to change one atom's behaviour, and that is not a nice seam.

What I was avoiding is the one-liner alternative. Passing use_index_for_in_with_subqueries = 0 for the pruning call needs no new parameter, but it also declines sets that are already built, so it silently drops pruning that works today. Measured on this branch, c IN (SELECT 1) where c is the primary key:

pruner setting parts read
this PR Parts: 1/3
use_index_for_in_with_subqueries = 0 Parts: 3/3

Primary key analysis has already built that set by the time pruning runs, so what the flag encodes is "use a ready set, never build one", which the existing setting cannot express.

If you would rather not grow the constructor, the alternative I would pick is to stop handing the query context down the pruning path at all, so building a set is unreachable rather than merely declined. That is a larger change to StatisticsPartPruner and I did not want to make it unasked. Say which you prefer and I will do that one; if you would rather an engineer took it from here, I will leave the branch at b19249e and stop pushing.

-- The assertions match plan text, so pin the settings that reshape it. Both projection settings must
-- stay 1 (the effective value is their conjunction): at 0 the first query prunes nothing even
-- without the fix.
SET enable_analyzer = 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 Does the problem reproduce locally without distribution?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, and my test was overfitted to the CI signature. It reproduces with no distribution at all. Minimal repro against pre-fix master (5c9f28ed), plain clickhouse local, no cluster(), no globalNullIn:

SET allow_experimental_statistics = 1;
SET allow_statistics = 1;
CREATE TABLE t (a String, c UInt64) ENGINE = MergeTree ORDER BY a
SETTINGS auto_statistics_types = 'basic';
INSERT INTO t VALUES ('a', 1), ('a', 100), ('a', 200);

SELECT count() FROM t
WHERE c IN (SELECT c FROM t WHERE throwIf(a = 'a') AND c IN (SELECT 1))
SETTINGS use_statistics_for_part_pruning = 1;
arm outcome
pre-fix 5c9f28ed Logical error: Not-ready Set is passed as the second argument for function 'in', abort
this PR Code: 395 FUNCTION_THROW_IF_VALUE_IS_NON_ZERO, server alive
pre-fix, use_statistics_for_part_pruning = 0 Code: 395, no abort

The third row is the control: pruning is what turns the subquery's own error into the logical error.

A remote table was never required. Pruning calls buildOrderedSetInplace, which runs the subquery against a clone of its source plan so a failed speculative build stays recoverable; a source that cannot be cloned falls back to consuming the original. DelayedCreatingSetsStep, which a nested IN adds, is deliberately non-clonable, so the inner IN above is enough. A remote read reaches the same path via ReadFromPreparedSource -- one carrier, not the requirement.

Pushed in b19249e: the cluster() block is replaced by the local form and the test no longer needs the shard tag. Test is -11 lines net. Verified both directions ([ OK ] on this branch, [ FAIL ] server died on pre-fix master), 30/30 randomized draws green, and the 13 neighbouring statistics tests still pass.

The regression test used cluster() because the reported CI failure did, but a
remote read is only one way to reach the defect. Pruning builds an IN set
through buildOrderedSetInplace, which runs the subquery against a clone of its
source plan; a source that cannot be cloned falls back to consuming the
original. DelayedCreatingSetsStep, which a nested IN adds, is non-clonable, so
a nested IN in the subquery reaches the same path locally.

Replacing cluster() with that form keeps the same assertion and lets the test
drop the shard tag. Verified both directions: the block aborts with
"Not-ready Set is passed as the second argument" on pre-fix master and returns
the subquery's own error with this change.

Also reword the comment at the pruning call site, which described IN sets at a
site where nothing else mentions them.
@groeneai groeneai added the groeneai-origin-request PR origin: a maintainer pinged or directed groeneai label Aug 19, 2026
@alexey-milovidov
alexey-milovidov added this pull request to the merge queue Aug 19, 2026
Merged via the queue into ClickHouse:master with commit 8d087c3 Aug 19, 2026
347 of 348 checks passed
@robot-ch-test-poll robot-ch-test-poll added the pr-synced-to-cloud The PR is synced to the cloud repo label Aug 19, 2026
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 groeneai-origin-request PR origin: a maintainer pinged or directed groeneai pr-bugfix Pull request with bugfix, not backported by default 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.

5 participants