FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

Fix `input()` read twice when the parallel-replicas row estimate builds an IN set by groeneai · Pull Request #121283 · ClickHouse/ClickHouse · GitHub

Fix input() read twice when the parallel-replicas row estimate builds an IN set - #121283

Open
groeneai wants to merge 1 commit into
ClickHouse:masterfrom
groeneai:fix-pr-row-estimate-unbuilt-in-set
Open

groeneai wants to merge 1 commit into
ClickHouse:masterfrom
groeneai:fix-pr-row-estimate-unbuilt-in-set

Conversation

groeneai commented Sep 21, 2026
edited
Loading

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 the parallel-replicas row estimate running an IN (subquery) while planning, which made an INSERT ... SELECT whose filter reads input() fail with INVALID_USAGE_OF_INPUT when parallel_replicas_min_number_of_rows_per_replica was not zero.

Description

Related: #71028 (comment)

Not introduced by #71028. The same test failed with 477 on v25.11.1.1 in the CI database; that PR's randomizer only makes it visible.

Root cause. With that setting a throwaway planner with its own PreparedSets collects the filters in the Planner constructor, so the real read's DAG carries a ColumnSet nobody will build. The replica-count estimate at PlannerJoinTree.cpp:2669 calls selectRangesToRead() just to read selected_rows; index analysis then reaches KeyCondition::tryPrepareSetIndexForIn, builds that set and runs the IN subquery: read one. The query's own set reads input() again.

The fix. That estimate declines when the filter DAG references a set that is not built yet, reusing QueryPlanOptimizations::dagContainsNonReadySet. For a ReadFromMergeTree step it is the only index analysis before filter push-down: every other selectRangesToRead() caller runs after applyFilters populated the step's indexes. Cost: such a query no longer refines its replica count, so it can use more replicas, never fewer.

Not fixed here. A plain IN (subquery) is re-executed on every follower, which has no client input stream and throws there; the initiator usually answers from its local plan first and discards that error, so whether the client sees it is a race. GLOBAL IN evaluates the subquery on the initiator, so that is the spelling the test's transport cases use. With parallel_replicas_mode = custom_key_sampling a follower can still be asked to read input(). And collectFiltersForAnalysis hands the same unbuilt-set DAG to cluster storages irrespective of this setting, whose task iterators build sets from it (traced by reading only).

Validation. New 05237_parallel_replicas_input_table_function.sh covers both client transports and the automatic route, asserting per case that the route was really taken; each input() case fails on the base commit with 477. 500 randomized runs are stable and leave no follower INVALID_USAGE_OF_INPUT behind; the regression batch shows no reference movement.

groeneai added can be tested Allows running workflows for external contributors groeneai-origin-request PR origin: a maintainer pinged or directed groeneai labels Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author
Internal second-model review: adjudication log (click to expand)

Four rounds by an independent model (engine: codex) plus my own cold read of the code each round:
3 gate findings, 14 of my own. Rounds 2, 3 and 4 changed no src/ code line, only the text that ships.

# Sev Finding Verdict Evidence / action
1 ⚠️ The regression cases assert result rows only, so they stay green if parallel-replica planning silently declines for set-bearing queries AGREE, fixed in this PR parallel_replicas_allow_in_with_subquery = 0 makes Planner.cpp:2684 decline the route for all three input() cases while they insert the same rows; each case now prints a route observable, measured to read 0 under exactly that bypass.
2 ⚠️ The in-diff comment claimed the estimate "only chooses how many replicas to use" and that answering it "builds any set the filter references" AGREE, fixed in this PR Both were wider than the code: KeyCondition builds an IN set only after analyzeKeyExpressionForSetIndex maps the left argument to key columns (KeyCondition.cpp:3230, ReadFromMergeTree.cpp:3324-3326), while the guard declines for any unbuilt ColumnSet (Optimizations/Utils.cpp:75-89). The comment now states only the pre-existing invariant; the cost stays in the commit message.
3 ⚠️ The commit message claimed this estimate "is the only builder of the escaped set" AGREE, fixed in this PR collectFiltersForAnalysis collects the same unbuilt-set DAG for IStorageCluster with no parallel-replica or min_rows condition (Planner.cpp:284-285, IStorageCluster.cpp:69, :83), and its task iterators build sets from it (VirtualColumnUtils.cpp:85-89). The rationale is now scoped to ReadFromMergeTree and the sibling path is named in the PR body; the fix is unchanged.
4 ⚠️ "Rejected alternatives" omitted KeyCondition's require_ready_sets, the in-tree flag for "analyse only already-built IN sets" (KeyCondition.h:83) AGREE on the gap, DISAGREE on switching to it Both entry points this call site can take pass the step's own indexes by reference (ReadFromMergeTree.cpp:2735, :2763), so analysing that way memoizes a set-less KeyCondition; applyFilters then returns early at if (!indexes) and the executed read loses IN-set primary-key pruning. Declining leaves indexes empty, so applyFilters runs in full. The refutation is in the commit message.
5 💡 03232_pr_not_ready_set (min_rows = 10 plus an IN subquery) no longer reaches this estimate AGREE, noted not blocking It still passes on its empty reference, which asserts only that the query does not throw, and the path becomes unreachable rather than unguarded. Narrowing it is the point of this change, so I record it here rather than editing another test.
6 💡 An earlier round of mine recorded case 0 of the new test as an over-breadth detector. It is not one AGREE, record corrected Case 0 reads 20000 rows with min_rows = 1, so neither the disable nor the reduce branch (PlannerJoinTree.cpp:2703-2714) fires whether the estimate runs or not. No coverage gap follows: 02784_parallel_replicas_automatic_decision.sh greps "It is enough work for", which only the estimate block emits (:2697-2701). Case 0 keeps its job as the transport/route control.
7 💡 With query_plan_optimize_primary_key = 0 no applyFilters runs, so the escaped analysis DAG survives and a later estimate could still build the set DISAGREE, disclosed optimizePrimaryKeyConditionAndLimit is the only caller of applyFilters and is gated on that setting (optimizeTree.cpp:265-266). I could not reproduce it, the setting is not randomized by tests/clickhouse-test, and the real remedy is the in-tree TODO of removing query_info.filter_actions_dag (ReadFromMergeTree.cpp:3259-3262) rather than a second guard.
8 💡 estimate_reading_step re-casts a step already known to be a ReadFromMergeTree, and the block body casts it again DISAGREE Deliberate: a local named reading_step already exists in a sibling block (PlannerJoinTree.cpp:2682), and keeping the existing condition clause and the whole body byte-identical is what makes the src/ diff purely additive. assert_cast would trade a fail-safe nullptr for release-mode UB on a cast that only gates a skip.
9 💡 The set-readiness predicate is evaluated even when parallel_replicas_min_number_of_rows_per_replica is 0, where it cannot matter DISAGREE It is reached only inside the parallel-replicas-on-initiator branch and walks the collected-filter DAG once per planned table read; every lazy form costs more diff than the walk.

Severity: ❌ blocker / ⚠️ major / 💡 nit. DISAGREE verdicts carry recorded evidence and are
terminal per finding.

Also measured and deliberately not raised as separate work: join runtime filters do not trip the new
predicate (they build a FutureSetFromTuple, whose get() is never null); leaving the route enabled
cannot turn the old hard error into silent row loss, because both arms of
ReadFromInput::initializePipeline throw (StorageInput.cpp:126-131, :141); every other caller of
selectRangesToRead() runs after optimizePrimaryKeyConditionAndLimit (optimizeTree.cpp:266), where
the step's indexes are already populated; the old-analyzer twin (InterpreterSelectQuery.cpp:1223) is
unreachable, enable_analyzer being obsolete and frozen at 1 since v26.9; and the
custom_key_sampling failure named in the description is out of scope here, behaving identically
without this change.

Session id: cron:clickhouse-review-slot-9:20260921-062200

clickhouse-gh Bot closed this Sep 21, 2026
clickhouse-gh Bot reopened this Sep 21, 2026

Copy link
Copy Markdown
Collaborator Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes. INSERT INTO dst SELECT k, v FROM src WHERE k IN (SELECT n FROM input('n UInt64')) with parallel_replicas_min_number_of_rows_per_replica = 1 on a 3-replica localhost cluster fails every time, over both HTTP and the native protocol, on two independent fixtures.
b Root cause explained? Yes. The advisory replica-count estimate at PlannerJoinTree.cpp:2669 calls selectRangesToRead() to read selected_rows; index analysis reaches KeyCondition::tryPrepareSetIndexForIn, which builds the escaped ColumnSet and executes the IN subquery. The base binary's own stack trace shows the chain down to FutureSetFromSubquery::buildOrderedSetInplace.
c Fix matches root cause? Yes. The cost model declines at its own call site when the filter DAG references a set that is not built yet, using the in-tree QueryPlanOptimizations::dagContainsNonReadySet. No widened bound, no test tag, no guard at the point of the second read, no user setting overwritten.
d Test intent preserved / new tests added? Yes. New 05237_parallel_replicas_input_table_function.sh with an exact-row oracle per case plus a per-case route-liveness counter: under parallel_replicas_allow_in_with_subquery = 0 all three input() cases insert exactly the same rows while every counter reads 0, so the row oracles alone would pass over a dead path. No existing test weakened or retagged.
e Both directions demonstrated? Yes. The new test fails on the base binary with Code: 477 and passes with the change; the estimate's own trace line is present on base but absent with the fix for a query carrying an unbuilt set, while present in both for a query without one.
f Fix is general across code paths? Yes. Both routes that reach this estimate (the direct planner path and the automatic-parallel-replicas probe plan) go through the one call site, each measured failing before and passing after. The old-analyzer twin is unreachable since v26.9, the custom-key branch never consults this setting, and make_distributed_plan disables the feature itself.
g Fix generalizes across inputs (params/datatypes/wrappers)? Yes, by construction: the predicate is over the DAG and cannot distinguish set shapes. Measured across IN, GLOBAL IN, GLOBAL LEFT JOIN, a reused MATERIALIZED CTE, a plain JOIN, key and non-key columns, min_rows 0 and 1, serialize_query_plan 0 and 1, automatic_parallel_replicas_mode 0/1/2, custom-key mode, and both client transports.
h Backward compatible? (maintainer-approved exception only) Yes. No new setting, no changed default, no format change, so no SettingsChangesHistory.cpp entry. The only behaviour change is a cost-model decision, stated in the PR description.
i Invariants and contracts preserved? Yes. The estimate is advisory, so skipping it leaves max_parallel_replicas and allow_experimental_parallel_reading_from_replicas at the user's values and removes no eligibility (ParallelReplicasUsedCount does not drop on any measured shape). The skipped call is const and only selected_rows was read from it; ReadFromMergeTree::applyFilters still rebuilds query_info.filter_actions_dag for the executed read. No lock, concurrency contract or on-disk state is involved.

Session id: cron:clickhouse-impl-slot-40:20260921-035600

clickhouse-gh Bot commented Sep 21, 2026
edited
Loading

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [946f7d1]

Summary:

job_name test_name status info comment
Stateless tests (amd_asan_ubsan, flaky check) FAIL
05237_parallel_replicas_input_table_function FAIL cidb
Stateless tests (amd_tsan, flaky check) FAIL
05237_parallel_replicas_input_table_function FAIL cidb
05237_parallel_replicas_input_table_function FAIL cidb
05237_parallel_replicas_input_table_function FAIL cidb
05237_parallel_replicas_input_table_function FAIL cidb
Stateless tests (amd_msan, flaky check) FAIL
05237_parallel_replicas_input_table_function FAIL cidb
05237_parallel_replicas_input_table_function FAIL cidb
Stateless tests (amd_debug, flaky check) FAIL
05237_parallel_replicas_input_table_function FAIL cidb
05237_parallel_replicas_input_table_function FAIL cidb
05237_parallel_replicas_input_table_function FAIL cidb
05237_parallel_replicas_input_table_function FAIL cidb
Stateless tests (amd_binary, flaky check) FAIL
05237_parallel_replicas_input_table_function FAIL cidb

AI Review

Summary

This PR fixes the planner-side double-read of input() by skipping the parallel-replicas row estimate when the read filter still carries an unbuilt set, and the src/ change itself looks coherent. The remaining problem is in the new regression test: it is not safe to rerun in the same database, so a failed first attempt can make the retry fail with TABLE_ALREADY_EXISTS instead of re-exercising the original bug.

Findings

⚠️ Majors

  • [tests/queries/0_stateless/05237_parallel_replicas_input_table_function.sh:19] The new shell test creates src, dst_http, dst_tcp, and dst_auto unconditionally and never drops them, even though the script already acknowledges that retries can reuse the same database. A retry after any mid-test failure will therefore fail during setup with TABLE_ALREADY_EXISTS, which makes the regression non-reproducible under --test-runs and CI retries. Suggested fix: DROP TABLE IF EXISTS before the CREATE TABLEs and clean the tables up at the end.
Tests
  • Not run locally. The PR CI report is all green, including the ParallelReplicas stateless suites.
Final Verdict

Request changes.

clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Sep 21, 2026
PR_DIRECT="$PR, automatic_parallel_replicas_mode = 0"
PR_AUTO="$PR, automatic_parallel_replicas_mode = 2"

$CLICKHOUSE_CLIENT -m -q "

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.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

This regression test already assumes the harness may rerun it in the same database (QUERY_ID is regenerated for exactly that reason a few lines below), but the four tables are created unconditionally and never dropped. If the first attempt fails after these CREATE TABLEs, the retry will stop with TABLE_ALREADY_EXISTS before it reaches the input() path you are trying to reproduce. Please make the setup idempotent here, e.g. DROP TABLE IF EXISTS before the CREATE TABLEs (and preferably clean them up at the end too).

clickhouse-gh Bot added the comp-parallel-replicas Parallel reading from replicas for query speed (not data replication consistency). label Sep 21, 2026

clickhouse-gh Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing 946f7d194 with master de0734db6 (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

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

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

Job report

An INSERT SELECT whose filter references input() failed with
INVALID_USAGE_OF_INPUT when parallel replicas were enabled together with a
non-zero parallel_replicas_min_number_of_rows_per_replica.

With that setting, collectFiltersForAnalysis runs in the Planner constructor
over a throwaway planner that owns its own PreparedSets, so the filter DAG
handed to the real read carries a ColumnSet for a FutureSetFromSubquery that
planner will never build. The advisory replica-count estimate then called
selectRangesToRead() purely to read selected_rows; index analysis reached
KeyCondition::tryPrepareSetIndexForIn, which built that set and so executed the
IN subquery. For input(), a one-shot stream from the client, that was the first
read, and the query's own set then read it a second time.

Gating the estimate is sufficient for this read because nothing else builds the
escaped set on a ReadFromMergeTree step: applyFilters rebuilds
filter_actions_dag from the real plan's pushed-down nodes and overwrites
query_info.filter_actions_dag with it, and every other selectRangesToRead()
caller runs after that.

The estimate now declines when the filter DAG references a set that is not
built yet, reusing QueryPlanOptimizations::dagContainsNonReadySet, which
convertAnyJoinToSemiOrAntiJoin and Optimizations/Utils already use to make a
plan-time decision decline rather than force a build. Both routes that reach
this call site are covered: the direct planner path and the
automatic-parallel-replicas probe plan.

One consequence beyond the bug: the estimate no longer prunes with an IN
(subquery) set, so it can use more replicas, never fewer for the same data,
and, where index analysis would have built that set, it no longer runs the
subquery an extra time while planning. The guard is coarser on purpose: it
declines for any unbuilt set, while KeyCondition builds only key-column ones.

Rejected alternatives. Disabling parallel replicas for any tree that reads
input(): GLOBAL IN, a GLOBAL JOIN's materialized side, a reused MATERIALIZED
CTE and every plain join are all materialized on the initiator, work today, and
would lose the route; the exact predicate is only available after
buildQueryTreeForShard, which executes the GLOBAL subqueries and so cannot be
evaluated speculatively. Overriding
parallel_replicas_min_number_of_rows_per_replica for such trees: silently
changes a user setting and still leaves every other query executing its IN
subquery twice. Buffering the client stream in StorageInput: a memory
regression, and it guards the symptom rather than the cause. Running the
estimate with KeyCondition's require_ready_sets instead of declining it: both
entry points this call site can take pass the step's own `indexes` member by
reference (ReadFromMergeTree.cpp:2735, :2763), so an estimate analysed that way
memoizes a set-less KeyCondition on the step; the whole body of
ReadFromMergeTree::applyFilters is then guarded by `if (!indexes)` and does
nothing, and the executed read inherits that weaker analysis, losing IN-set
primary-key pruning at read time. Declining leaves `indexes` empty, so
applyFilters runs in full and the executed read analyses against the query's own
set.

Not addressed here, all of it outside the estimate this change gates. A plain
IN (subquery) is re-executed on every follower, since
parallel_replicas_allow_in_with_subquery defaults to 1, and a follower has no
client input stream, so it throws INVALID_USAGE_OF_INPUT there; the initiator
usually answers from its local plan first and discards that error, which makes
the client seeing it a race. GLOBAL IN evaluates the subquery on the initiator
and ships a temporary table, so the test uses that spelling for the two cases
that read from a replica; without this change they still fail on the double read
described above.
With parallel_replicas_mode = custom_key_sampling a follower can still be asked
to read input() and fails with "Input stream is not initialized", identically
without this change. And collectFiltersForAnalysis collects the same
unbuilt-set DAG for IStorageCluster irrespective of this setting
(Planner.cpp:284), where ReadFromCluster::applyFilters falls back to
query_info.filter_actions_dag and the task iterators build sets from it through
VirtualColumnUtils::buildSetsForDAG. I traced that last one by reading and
have not run it.

Related: ClickHouse#71028 (comment)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
groeneai force-pushed the fix-pr-row-estimate-unbuilt-in-set branch from 946f7d1 to 659c1b2 Compare September 21, 2026 09:11

Copy link
Copy Markdown
Collaborator Author

Flaky check on 946f7d1: the test's own input() cases were racy, fixed in 659c1b2

12 of 250 flaky-check runs failed, always on the first input() case, with Code: 477 ... Input stream is not initialized raised on a follower (127.0.0.2:9000) rather than on the initiator.

Cause, measured on this branch's binary: a plain IN (subquery) is re-executed on every follower, and a follower has no client input stream. Twenty runs of each direct case left a follower ExceptionBeforeStart with code 477 in system.query_log on 7/20 (HTTP) and 16/20 (native), while the initiator answered from its local plan and the client saw nothing. Whether that error reaches the client is a race; with parallel_replicas_local_plan = 0 it is deterministic, on this branch and on the parent commit alike. That is a follower-side branch this change does not reach, so the test must not depend on it.

Fix: the two cases that read from a replica now use GLOBAL IN, which evaluates the subquery on the initiator and ships a temporary table, so no follower is asked to read input(). The statistics-only case keeps a plain IN, because that mode contacts no replica at all. The row and route oracles and the .reference file are unchanged, and each input() case still fails on the parent commit through the same estimate.

500 runs of the fixed test, eight at a time, 250 with the CI settings blob forced and 250 with default randomization: no failures, and zero follower 477 rows across all of them. No src/ line changed, so the src/ diff is byte-identical to 946f7d1. The changelog entry and the "Not fixed here" paragraph are now scoped to what this change repairs.

This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
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 comp-parallel-replicas Parallel reading from replicas for query speed (not data replication consistency). groeneai-origin-request PR origin: a maintainer pinged or directed groeneai pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant


Back | FazBrowse Home | New Git URL