Skip to content

Skip sharding key IN rewrite when the sharding key contains a set - #110463

Open
groeneai wants to merge 10 commits into
ClickHouse:masterfrom
groeneai:fix-sharding-key-rewrite-in-not-ready-set
Open

Skip sharding key IN rewrite when the sharding key contains a set#110463
groeneai wants to merge 10 commits into
ClickHouse:masterfrom
groeneai:fix-sharding-key-rewrite-in-not-ready-set

Conversation

@groeneai

Copy link
Copy Markdown
Contributor

Related: #107619

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

Fix the Not-ready Set is passed as the second argument for function 'in' logical error that could be thrown when a Distributed table's sharding key expression itself contained an IN/subquery (a set) and optimize_skip_unused_shards_rewrite_in was enabled.

Description

Found by the AST fuzzer (STID 0250-4e52, tracked in #107619). Reported on the arm_debug Stress test of PR #101039; per @ alexey-milovidov the failure is unrelated to that PR, so this is a separate fix.

Reproducer (aborts in debug/sanitizer builds):

SELECT * FROM remoteSecure('127.{1..8}', view(SELECT number AS id FROM numbers(0)),
    bitAnd(1025, murmurHash3_32(minus(id, (2 IN (SELECT 1)))))) WHERE id IN (2, 3) LIMIT 444
SETTINGS optimize_skip_unused_shards = 1, optimize_skip_unused_shards_rewrite_in = 1,
    allow_nondeterministic_optimize_skip_unused_shards = 1;

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=101039&sha=8b127de6068682ddc50262e918ab1a7dd3a0a307&name_0=PR&name_1=Stress%20test%20%28arm_debug%29

Root cause: OptimizeShardingKeyRewriteIn prunes shards by executing the standalone sharding-key ExpressionActions on constant values (shardContains -> executeFunctionOnField). The sharding-key expression is built directly from the sharding-key AST, so an IN/subquery in the sharding key becomes a FunctionIn whose set is never populated during planning. Executing it hits FunctionIn::executeImpl with an unbuilt set and throws the Not-ready Set logical error (OptimizeShardingKeyRewriteInVisitor.cpp:36 -> Functions/in.cpp:116).

Fix: skip the rewrite when the sharding-key expression contains a set, and query all shards instead. This is the same safe fallback already used for non-deterministic sharding keys, and the optimization was never valid for a set-containing key anyway (it cannot be constant-folded per value). Both the analyzer and old-analyzer call sites are guarded.

Distinct from the other in-flight Not-ready Set fixes (#109722, #102192), which address the buildOrderedSetInplace / QueryPlan path; neither touches OptimizeShardingKeyRewriteIn.

OptimizeShardingKeyRewriteIn executes the standalone sharding-key
ExpressionActions on constant values to prune shards. When the sharding
key itself contains an IN/subquery (a set), that set is never populated
during planning, so the execution hits FunctionIn with an unbuilt set
and throws "Not-ready Set is passed as the second argument for function
'in'" (LOGICAL_ERROR, aborts in debug/sanitizer builds).

Skip the rewrite when the sharding key expression contains a set and
query all shards instead, the same safe fallback already used for
non-deterministic sharding keys.

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. clickhouse-local (fake 2-shard cluster) EXPLAIN SELECT count() FROM dist WHERE dummy IN (0,1) where the sharding key is bitAnd(dummy + (0 IN (SELECT 1)), 1), with optimize_skip_unused_shards[_rewrite_in]=1. Aborts every time on master HEAD (exit 134), also reproduced with the exact fuzzer query on a real server.
b Root cause explained? The sharding-key ExpressionActions is built standalone from the sharding-key AST. OptimizeShardingKeyRewriteIn::shardContains executes it on constant Fields to prune shards. When the sharding key contains an IN/subquery, that becomes a FunctionIn whose set is never built during planning, so execution hits FunctionIn::executeImpl with an unbuilt set and throws Not-ready Set (in.cpp:116, via OptimizeShardingKeyRewriteInVisitor.cpp:36).
c Fix matches root cause? Yes. Skip the rewrite when the sharding-key expression contains a set (scan the DAG for a Set-typed node); query all shards, the same safe fallback already used for non-deterministic keys.
d Test intent preserved / new test added? New regression test 04545_shardkey_rewrite_in_subquery_set.sh (modelled on the #104478 empty-tuple test 04243). No existing test weakened.
e Demonstrated both directions? Yes. Pristine master: abort (exit 134). With fix: EXPLAIN exit 0, server stays alive, test outputs OK. Verified via clickhouse-test.
f General, not a narrow patch? Both invocation sites (analyzer + old-analyzer) are guarded via one shared helper. OptimizeShardingKeyRewriteIn is the only consumer of the standalone-execute path.
g Generalizes across inputs? The guard scans the whole sharding DAG for any Set-typed node, so it catches a set anywhere in the expression (nested inside functions, tuple-IN, NOT IN, globalIn, subquery-IN), not just the one fuzzer shape.
h Backward compatible? Yes. Only disables an optimization for a query class that previously aborted; for such keys the optimization was never valid. No setting default, on-disk/wire/replication format, or new-validation change. Result correctness is unaffected (all shards queried).
i Invariants preserved? Yes. OptimizeShardingKeyRewriteIn's contract (only prune shards it can prove exclude the value) is upheld: when it cannot evaluate the key it now queries all shards, matching the existing non-deterministic-key fallback.

Session id: cron:clickhouse-worker-slot-1:20260714-220600

@groeneai

Copy link
Copy Markdown
Contributor Author

cc @azat @devcrafter — could you review? This skips the OptimizeShardingKeyRewriteIn shard-pruning rewrite when the Distributed sharding key itself contains an IN/subquery (a set), which otherwise executes the standalone sharding-key expression against an unbuilt set and throws the Not-ready Set logical error (#107619, STID 0250-4e52). All shards are queried in that case, matching the non-deterministic-key fallback.

@alexey-milovidov alexey-milovidov added the can be tested Allows running workflows for external contributors label Jul 15, 2026
@clickhouse-gh

clickhouse-gh Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [8440d3b]

Summary:

job_name test_name status info comment
Finish Workflow FAIL
python3 ./ci/jobs/scripts/workflow_hooks/new_tests_check.py FAIL
Config Workflow ERROR
Dockers Build (amd) DROPPED
Dockers Build (arm) DROPPED
Dockers Build (multiplatform manifest) DROPPED
Style check DROPPED
Code Review DROPPED
Docs check DROPPED
Docs check (Mintlify) DROPPED
Fast test DROPPED

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Jul 15, 2026
Comment thread src/Interpreters/ClusterProxy/executeQuery.cpp Outdated
Comment thread src/Interpreters/ClusterProxy/executeQuery.cpp Outdated
…nalyzer path

Address review on the shard-pruning fix:

1. The previous guard bailed on every DataTypeSet node in the sharding key,
   so shard pruning regressed for safe constant-set keys like
   bitAnd(dummy + (0 IN (1, 2)), 1). Tuple/storage sets are materialized
   eagerly during planning (FutureSet::get() is non-null); only
   subquery-backed sets stay unbuilt. Bail out only for the latter
   (shardingKeyExpressionContainsNotReadySet), keeping the optimization for
   ready sets.

2. The guard lived only on the analyzer rewrite path in ClusterProxy. When
   allow_experimental_analyzer = 0, StorageDistributed::skipUnusedShards
   still runs evaluateExpressionOverConstantCondition on the sharding key,
   which ends in target_expr->execute and hits FunctionIn with the unbuilt
   set (Not-ready Set logical error) before executeQuery is reached. Guard
   both skipUnusedShards and skipUnusedShardsWithAnalyzer with the same
   fallback.

The helper moved to OptimizeShardingKeyRewriteInVisitor so both call sites
share it. Regression test extended to cover the old-analyzer path and the
ready tuple-set key.

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

Copy link
Copy Markdown
Contributor Author

Both addressed in e6b9b24.

  1. The guard now bails only for an unready set. The set-typed node is a ColumnConst(ColumnSet); tuple/storage sets are materialized eagerly (FutureSet::get() non-null) and are kept, only subquery-backed sets (get() null) trigger the fallback. Renamed to shardingKeyExpressionContainsNotReadySet, so pruning still applies to keys like bitAnd(dummy + (0 IN (1, 2)), 1).

  2. Guarded the legacy path too. Moved the helper into OptimizeShardingKeyRewriteInVisitor and added the same early return to StorageDistributed::skipUnusedShards and skipUnusedShardsWithAnalyzer before they call evaluateExpressionOverConstantCondition.

Verified locally: with allow_experimental_analyzer = 0 and the subquery-set key, master HEAD aborts during planning (exit 134), the fix reaches shard connection instead (no abort). Regression test extended to cover the old-analyzer path and the ready tuple-set case.

# Conflicts:
#	ci/jobs/scripts/workflow_hooks/check_report_messages.py
#	tests/queries/0_stateless/04402_with_fill_interpolate_fill_column_overlap.sql
Comment thread tests/queries/0_stateless/04545_shardkey_rewrite_in_subquery_set.sh Outdated
groeneai and others added 2 commits July 15, 2026 04:27
Address review: the test only distinguished exit 134 (SIGABRT), so an
unfixed release build that throws Not-ready Set (non-134 exit) took the
same OK branch as the intended fake-shard network error, and the ready
tuple cases passed even if the helper regressed to always-fallback.

Unready-set cases now assert the query gets PAST planning: fail on exit
134 (debug/sanitizer abort) OR Not-ready Set in stderr (release throw).
Ready-tuple cases assert shard pruning is preserved by querying values
that map to shard 1 (127.0.0.2); a regression to always-fallback would
contact shard 0 (127.0.0.1) first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…in Fast test

The 127.0.0.x fake shards are a live server in the single-node Fast test box,
so the ready-tuple query succeeded with empty stderr and the shard-pruning
discriminator could not see which shard was contacted. Switch to RFC5737
TEST-NET addresses (192.0.2.x), unreachable in every environment, and add a
low failover connect timeout so the connection-error host is always observable.

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

Copy link
Copy Markdown
Contributor Author

CI finish ledger — 2e65b38

Every failure below has an owner. Only CH Inc sync is exempt.

Check / test Reason Owner / fixing PR
Stateless tests (amd_asan_ubsan, distributed plan, parallel) — 158 unrelated tests host-OOM ((total) memory limit exceeded, server-global OvercommitTracker; fleet-wide 01:00-04:45 UTC 2026-07-15, hit master + 15+ PRs, none in this PR's diff) #110503 (ours, open)
Finish Workflow / Post Hooks consequence of the distributed-plan job above #110503 (ours, open)
CH Inc sync - CH Inc sync (private, not actionable)

The distributed plan, parallel lane runs at the default 0.6 concurrency while its sibling ASan lanes were cut to 0.4 for exactly this reason; today ~19 co-scheduled queries pushed server RSS past the shared cap and the (total) tracker killed dozens of trivial unrelated tests. None of this PR's changes are involved. Fix PR #110503 adds "(total) memory limit exceeded" to MESSAGES_TO_RETRY in tests/clickhouse-test.

Session id: cron:our-pr-ci-monitor:20260715-090000

Comment thread src/Interpreters/OptimizeShardingKeyRewriteInVisitor.cpp
groeneai added 3 commits July 29, 2026 08:15
An AI review comment suggested the guard misses lambda-based sharding keys,
on the grounds that a lambda body lives in a nested DAG that
shardingKeyExpressionContainsNotReadySet does not walk.

The nesting premise is right but the conclusion does not follow. The IN
right-hand side is not part of the lambda body DAG: ActionsVisitor creates the
ColumnConst(ColumnSet) through ScopeStack::addColumn, which always adds it to
stack[0] (the outer DAG) and only projects an input into the inner levels. So
the set node is visible to the existing outer scan at any lambda nesting depth,
and it is reported as unready there.

Measured on this branch with an instrumented build: for
arrayExists(x -> x IN (SELECT 1), [dummy]) and for the doubly nested variant the
outer DAG has exactly one set-typed node and it is classified unready, on both
the analyzer and the old-analyzer path. Dropping the guard makes the
old-analyzer lambda cases abort with Not-ready Set, so the shapes are genuinely
reachable and worth pinning.

Add them to the test rather than changing the guard: two lambda cases and two
doubly nested cases on the unready side, plus two lambda cases holding a ready
tuple set on the pruning side so an over-broad bail would be caught too.
The unready-set cases read any exit other than 134 without the Not-ready Set
text as a successful fallback, so an unrelated planning failure that never
reaches a shard would also pass. Require stderr to name one of the configured
TEST-NET hosts, which only happens once planning finished and remote execution
started, and report NO-SHARD-CONTACTED otherwise.

Also correct the lambda comments. They claimed the outer-DAG scan misses a set
inside a lambda body; it does not, because ScopeStack::addColumn puts the
ColumnConst(ColumnSet) in the outermost DAG and only projects an input into the
nested scopes.
Two of the unready-set cases passed on an unfixed build, so they pinned
nothing. Measured against the pristine master snapshot, the analyzer plus
lambda-key rows both printed the expected marker without the fix.

The reason is that the two guarded paths fail differently. On the old
analyzer the sharding key expression is executed and FunctionIn throws, so
the query dies and the previous "did we get past planning" assertion held.
On the analyzer path nothing throws: the unbuilt set is read as a constant,
the key is const-folded to a single value and the query is pruned to one
shard, so rows on the other shard are dropped with no error at all. Getting
past planning is therefore not evidence of a correct fallback.

Assert the fallback itself instead. With skip_unavailable_shards the first
unreachable shard no longer aborts the query, so every shard the planner
selected reports its own connection failure and the whole selected set is
visible in stderr; requiring both configured hosts distinguishes the
all-shards fallback from a single-shard prune. The abort and throw shapes
are still rejected explicitly.

Measured on the pristine master snapshot, all six unready rows now fail
(three PARTIAL-SHARDS on the analyzer path, three NOT-READY-SET-LEAK on the
old analyzer) and all six pass with the fix.

The oracle for the shard sets: with 0 IN (SELECT 1) reading as unbuilt, a
key of arrayExists(x -> x IN (SELECT 1), [dummy]) requires both shards for
dummy IN (1, 3), while master selects only one.
@clickhouse-gh

clickhouse-gh Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.30% 86.40% +0.10%
Functions 91.90% 91.90% +0.00%
Branches 78.50% 78.50% +0.00%

Changed lines: Changed C/C++ lines covered: 37/40 (92.50%) · Uncovered code

Full report · Diff report

@groeneai

Copy link
Copy Markdown
Contributor Author

CI finish ledger - ff79dd8

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
Stateless tests (amd_llvm_coverage, ParallelReplicas, s3 storage, parallel) / 01666_merge_tree_max_query_limit trunk regression, not caused by this diff (fires on master and on ~50 unrelated PRs in the same window) #112385 (mine, merged 2026-07-29 15:37Z); this build predates it, so a rerun after the next master merge picks it up
Sync - CH Inc sync (private, not actionable)

Session id: cron:our-pr-ci-monitor:20260729-170000

@groeneai

Copy link
Copy Markdown
Contributor Author

CI finish ledger — ff79dd8

Every failure below has an owner: a fixing PR (ours 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
Stateless tests (amd_llvm_coverage, ParallelReplicas, s3 storage, parallel) / 01666_merge_tree_max_query_limit fails 3/3 reruns under parallel replicas on coverage builds #112385 (ours, merged 2026-07-29 15:37Z) - this build predates the fix

Session id: cron:our-pr-ci-monitor:20260729-164745

@azat azat left a comment

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.

Why the set is not ready?

This patch is no good

@groeneai

groeneai commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

The set is permanently unready, by construction.

The sharding key is table metadata, not part of a query. It becomes a standalone ExpressionActions once, at attach time: buildShardingKeyExpression (StorageDistributed.cpp:277-282) uses the 3-argument ExpressionAnalyzer ctor, which passes no prepared_sets (ExpressionAnalyzer.h:118-119), so the analyzer allocates its own (ExpressionAnalyzer.cpp:200-204) and registers the IN (SELECT ...) there as a FutureSetFromSubquery. That analyzer is a temporary: only the ExpressionActionsPtr escapes, getPreparedSets() is never read, so nothing ever adds a CreatingSetStep or calls buildSetInplace (PreparedSets.h:137-148). There is no pipeline at attach time in which the subquery could run.

So it is not "not ready yet". A subquery in a sharding key can never become ready.

You are right that this patch is no good. It guards one consumer of a state that should not exist, and it is also incomplete: the INSERT selector still aborts on this branch. Measured on head 8440d3b1e2b0, debug build:

CREATE TABLE loc (id UInt64) ENGINE = MergeTree ORDER BY id;
-- `two_shards`: any cluster with 2 shards
CREATE TABLE dsub (id UInt64) ENGINE = Distributed(two_shards, currentDatabase(), loc,
    bitAnd(id + (0 IN (SELECT 1)), 1));
INSERT INTO dsub SELECT number FROM numbers(4);

aborts with Not-ready Set through DistributedSink.cpp:727 -> in.cpp:116, same stack as on pristine master. The tuple form bitAnd(id + (0 IN (1, 2)), 1) inserts fine.

What I would do instead: reject a subquery-backed set in the sharding key where the key is validated, in checkShardingKeyExistsAndIsNumeric (:284-300), which is reached from both the constructor (:480) and ALTER (:1514) and already rejects correlated subqueries next door at :637. Gated on mode <= LoadingStrictnessLevel::CREATE, as :502 does, so tables already on disk still attach. Then the pruning paths and the INSERT selector are all correct with no guards at all.

I will rework the PR that way unless you would rather the check live somewhere else.

@groeneai

groeneai commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Following up on the root cause above, with a design question I would rather ask than guess at.

Rejecting this at DDL time splits into two designs, and the split is structural rather than a matter of taste. A sharding key id IN some_table is one AST node either way: EXPLAIN AST gives Function in -> Identifier for both a Set table and an ordinary MergeTree table. Only the catalog separates them, and they behave differently today:

CREATE TABLE ms (id UInt64) ENGINE = Set;      -- INSERT INTO dist ... works
CREATE TABLE loc (id UInt64) ENGINE = MergeTree ORDER BY id;  -- INSERT aborts, Not-ready Set

So:

A. Syntactic check. Reuse checkExpressionDoesntContainSubqueries (extractKeyExpressionList.cpp), which already guards ORDER BY, PARTITION BY, INDEX and TTL, and apply it unconditionally as those call sites do. One line, replica-stable, no gating. Cost: it also rejects a Set-table key, which works today, and existing tables with such a key stop loading.

B. Exact check. Reject only when getPreparedSets()->hasSubqueries() after the key expression is built, gated on mode <= CREATE like the sanity check at StorageDistributed.cpp:501. Cost: the classification reads the local catalog, so it is an initiator-only guarantee, and a full-definition ATTACH still slips through. Closing that needs attach_short_syntax, which ITableFunction::execute does not carry, so remote() would need provenance plumbed through it.

B keeps Set-table keys and legacy tables working; A is smaller and replica-stable. I lean B, because 01527_dist_sharding_key_dictGet_reload shows a mutable sharding key is intended behaviour, so A's over-rejection would remove something supported, and because A makes an existing table unloadable rather than merely broken on INSERT.

Which would you prefer? I will also fold in the DistributedSink INSERT path, which the current patch misses entirely.

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-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants