Skip to content

Type IN (subquery) over LowCardinality consistently in the old analyzer - #114856

Merged
fm4v merged 3 commits into
masterfrom
in-subquery-lowcardinality-type-old-analyzer
Aug 23, 2026
Merged

Type IN (subquery) over LowCardinality consistently in the old analyzer#114856
fm4v merged 3 commits into
masterfrom
in-subquery-lowcardinality-type-old-analyzer

Conversation

@fm4v

@fm4v fm4v commented Aug 14, 2026

Copy link
Copy Markdown
Member

Related: #114229
Related: #100226

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 LOGICAL_ERROR: Unexpected return type from tuple. Expected Tuple(..., UInt8). Got Tuple(..., LowCardinality(UInt8)) when a LowCardinality column compared with IN (subquery) is read through tuple(...) across a subquery boundary and enable_analyzer = 0. With the old analyzer, x IN (subquery) over a LowCardinality column now returns LowCardinality(UInt8), the same type as x IN (literal list) and the same as with enable_analyzer = 1.

Description

The old-analyzer half of #114229, whose test is tagged no-old-analyzer.

Reproducer, broken since 26.6 and still broken on master:

CREATE TABLE t (s LowCardinality(String)) ENGINE = MergeTree ORDER BY s;
INSERT INTO t VALUES ('a');
SET enable_analyzer = 0;
SELECT tuple(*) FROM (SELECT s, s IN (SELECT 'a') AS f FROM t);
-- Code: 49. Unexpected return type from tuple. Expected Tuple(LowCardinality(String), UInt8).
--           Got Tuple(LowCardinality(String), LowCardinality(UInt8)). (LOGICAL_ERROR)

Root cause. The general typing rule wraps a result in LowCardinality only when at most one argument is a full LowCardinality column and no argument is a full ordinary column (IFunctionOverloadResolver::getReturnType, src/Functions/IFunction.cpp:797). The two passes over the same expression disagree:

The outer query records UInt8 for that column and the pipeline delivers LowCardinality(UInt8), so columnMatchesType rejects the tuple result in executeActionForPartialResult during ActionsDAG::updateHeader. DESCRIBE (subquery) and EXPLAIN header = 1 disagreeing on the type is the quick diagnostic. In a release build this is a query error; in debug and sanitizer builds it aborts the server.

Change. The stand-in for the missing set is a constant UInt8 instead of the left operand. It is indistinguishable from the real Set argument in every dimension the typing rule inspects — it is constant, so it is skipped by both LowCardinality counters; it is not Nullable, so it does not affect the null wrapper; and FunctionIn::getReturnTypeImpl only reads arguments[0]. Analysis and execution therefore agree by construction, for all eight in / notIn / nullIn / notNullIn × global variants and for every operand shape, not only the one in the reproducer. The IgnoreSet variants never look at their second argument.

A Set-typed stand-in would mirror execution more literally, but getRootActionsNoMakeSet feeds window-definition expressions into an executed plan (src/Interpreters/ExpressionAnalyzer.cpp:1562), which can be serialized for distributed execution, and a ColumnSet with no set behind it would be dereferenced there. A UInt8 constant cannot be.

Visible type change, mirroring what #114229 did for the analyzer: with enable_analyzer = 0, toTypeName(x IN (subquery)), DESCRIBE of such a subquery, EXPLAIN headers, and column types of CREATE ... AS SELECT over such an expression now show LowCardinality(UInt8) instead of UInt8. Values do not change.

Validation. New stateless test 04894_in_subquery_lowcardinality_type_old_analyzer asserts, for both analyzers, that the literal-set and subquery-set forms have the same type, that the type recorded for the subquery column is LowCardinality(UInt8), and that tuple(*) over that column across a subquery boundary works, with IN / NOT IN count() controls. Its first two arms fail and the tuple(*) arm aborts a debug server without the change.


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

Version info

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

…d analyzer

The old analyzer replaces `in` with `inIgnoreSet` while it only needs the
types, passing the left operand as the stand-in for the set that is not built
yet. The general typing rule wraps the result in `LowCardinality` only when
every other argument is a constant, so two full `LowCardinality` arguments
typed the expression as plain `UInt8`, while executing the real `in` against
the constant `Set` column yields `LowCardinality(UInt8)`.

A query reading such a column across a subquery boundary then recorded
`UInt8` in the outer `ActionsDAG` and got `LowCardinality(UInt8)` in the
header, failing the type check in `ActionsDAG::updateHeader`:

    SET enable_analyzer = 0;
    SELECT tuple(*) FROM (SELECT s, s IN (SELECT 'a') AS f FROM t);
    -- Unexpected return type from tuple. Expected Tuple(LowCardinality(String), UInt8).
    --          Got Tuple(LowCardinality(String), LowCardinality(UInt8)). (LOGICAL_ERROR)

Pass a constant `UInt8` as the stand-in instead. It is indistinguishable from
the real `Set` argument in every dimension the typing rule inspects, so
analysis and execution agree by construction for all eight `in` variants and
every operand shape. A `Set`-typed stand-in would mirror execution more
literally, but the window-definition expressions built by
`getRootActionsNoMakeSet` end up in an executed plan, and a `ColumnSet` with
no set behind it would be dereferenced when such a plan is serialized.

`allow_experimental_analyzer = 1` was fixed for the same root cause in
#114229, whose test is tagged
`no-old-analyzer`; the divergence appeared when `ColumnSet` became always
constant in #100226.
@clickhouse-gh

clickhouse-gh Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [4d1214f]

Summary:


AI Review

Summary

This PR fixes the old-analyzer LowCardinality type mismatch for IN (subquery) across subquery boundaries by routing in*IgnoreSet through a one-argument placeholder, and it adds good regression coverage for the original reproducer plus the lambda capture case. The reproducer itself looks covered, but the same placeholder still flows into an executed window-expression chain, so enable_analyzer = 0 window keys containing IN (subquery) remain incorrect.

Findings

⚠️ Majors

  • [src/Interpreters/ActionsVisitor.cpp:1331] appendWindowFunctionsArguments() builds executable pre-window actions with getRootActionsNoMakeSet(), and WindowStep then consumes those produced columns as the actual PARTITION BY / ORDER BY keys. FunctionIn::executeImpl still returns an all-zero column for every *IgnoreSet call, so row_number() OVER (PARTITION BY s IN (SELECT 'a') ORDER BY s) under enable_analyzer = 0 still computes the wrong partition key instead of the real IN result. The new one-argument placeholder fixes the header/type mismatch, but it leaves this executed path semantically wrong.
Final Verdict

⚠️ Needs changes before merge. The old-analyzer window-function path still executes IN (subquery) placeholders as zero-valued keys, so this fix does not yet cover all executed no_makeset consumers of the invariant it changes.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 87.10% 87.10% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 79.40% 79.40% +0.00%

Changed lines: Changed C/C++ lines covered: 12/12 (100.00%) · 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 14, 2026
Comment thread src/Interpreters/ActionsVisitor.cpp Outdated
@clickhouse-gh

clickhouse-gh Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing 4d1214fe5 with master c583f98a0 (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 705.78 MiB 702.75 MiB -3.03 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

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

Job report

Adding a constant column to stand in for the set that is not built yet broke
`arrayFilter(x -> x IN (subquery), arr)`: the stand-in becomes part of the
captured arguments of the lambda, and later analysis passes that never created
it fail with `Unknown column: __ignored_set` (`02178_column_function_insert_from`
in the old-analyzer coverage shard).

Take the left operand alone instead. A single argument gives the same result
type as the real `in`, whose set argument is always a constant column and so is
skipped by the same `LowCardinality` bookkeeping, and it leaves the actions DAG
untouched. `inIgnoreSet(x, set)` written explicitly keeps working, hence the
variadic arity.
Comment thread src/Functions/in.cpp
return function_name;
}

/// The `IgnoreSet` variants are called with the left operand alone during type analysis, but

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.

appendWindowFunctionsArguments() executes the no_makeset DAG before WindowStep, so this in*IgnoreSet node is not limited to type inference. FunctionIn::executeImpl still returns an all-zero UInt8 column whenever ignore_set is true (src/Functions/in.cpp:101-102), which means enable_analyzer = 0 queries such as SELECT row_number() OVER (PARTITION BY s IN (SELECT 'a') ORDER BY s) ... still collapse every row into the same partition instead of evaluating the real IN key. The one-argument placeholder fixes the LowCardinality type mismatch, but it does not make the executed window path semantically correct. This path needs either a real key computation or a way to keep IgnoreSet out of executable window-expression DAGs.

@fm4v
fm4v added this pull request to the merge queue Aug 23, 2026
Merged via the queue into master with commit f16824e Aug 23, 2026
178 checks passed
@fm4v
fm4v deleted the in-subquery-lowcardinality-type-old-analyzer branch August 23, 2026 19:53
@robot-clickhouse robot-clickhouse added pr-synced-to-cloud The PR is synced to the cloud repo pr-must-backport-synced The `*-must-backport` labels are synced into the cloud Sync PR labels Aug 23, 2026
@robot-clickhouse-ci-2 robot-clickhouse-ci-2 added the pr-backports-created Backport PRs are successfully created, it won't be processed by CI script anymore label Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-backports-created Backport PRs are successfully created, it won't be processed by CI script anymore pr-bugfix Pull request with bugfix, not backported by default pr-must-backport-synced The `*-must-backport` labels are synced into the cloud Sync PR pr-synced-to-cloud The PR is synced to the cloud repo v26.6-must-backport

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants