Fix wrong results when a sorting key expression's type depends on a session setting - #119385
Conversation
A key expression is resolved twice: by the table, under its own settings, which is what each part's physical row order follows, and by the query, under the session's. Both matchers that decide read-in-order compared the two derivations without comparing the types they resolve to. matchTrees mapped them on structure alone, that is function name plus arity, comparing result types only for constant children, while its contract states that directly mapped nodes represent equal calculations. When a setting changes the expression's result type the two derivations are different functions with different NULL handling, so they are different orders. With cast_keep_nullable = 1 a key of CAST(json.b, 'String') is Nullable(String) in the query and String in the table, and read-in-order then advertises an order the parts do not have: rows come back unsorted, aggregation-in-order splits groups and reports wrong counts, and read_in_order_use_virtual_row = 1 fails with "Virtual row has different type", which is the only place the two types are compared and so the only place that reports anything at all. Requiring the mapped pair's result types to agree cannot lose a sound match, because a deterministic function of equal-typed arguments has one result type, so a disagreeing pair was never an equal calculation. It extends the type identity already applied to constant children at the same site. This tightens the matcher for all nine of its callers, which is intended: each uses the same claim to substitute one expression for another. The check is applied at the FUNCTION mapping only. No reachable divergence was found for the INPUT mapping: a Merge table unifying a UInt32 and a Nullable(UInt32) column under one header, the most plausible shape, maps consistently, so an INPUT check would have shipped without a test. ReadInOrderOptimizer is the second matcher, selected under the old analyzer when query_plan_read_in_order is off, and it compared the key by name alone. It now also requires the storage key's type to equal the query side's, at the exact-match branch and at the monotonic branch's argument. That covers a divergence at the top of the key expression. It does not cover one nested under an equal-typed parent, such as ifNull(CAST(json.b, 'String'), 'zzz'), because this matcher tests a single name and never walks the expression; walking is what the plan-based path does, and that path is covered by the matchTrees change above. Each test arm pins which matcher it exercises instead of inheriting the session's, so neither runner injection nor a later default flip can move an arm to the other matcher without the arm saying so. Found on master by CI: Stress test (amd_tsan), sha ad8622e. Related: ClickHouse#109196 Related: ClickHouse#119168 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Internal second-model review (click to expand)An independent review pass plus a second-model gate ran over this change across two rounds before it was pushed. Verdicts and the evidence behind them: ❌ Blocker, agreed and fixed in this PR: a second matcher decided read-in-order by name alone. ❌ Blocker, agreed, contract narrowed instead of code weakened: ❌ Blocker, disagreed, with evidence: an What I disagree with is that it belongs to this change. Both comparisons here hold the query resolution against the table metadata resolution, which is what the changelog claims, and in that scenario the two agree: the divergence is between the metadata and the parts, created upstream by the The remedy the gate asks for, preserving the storage-resolved key identity across unrelated alters, is the whole subject of #109196, which adds 💡 Noted, not blocking: the legacy guard is stricter than the 💡 Noted, not blocking: the
Session id: cron:clickhouse-review-slot-9:20260911-021300 |
|
Workflow [PR], commit [18e3966] Summary: ✅
AI ReviewSummaryThis PR tightens read-in-order matching when a sorting-key expression resolves to a different result type between table metadata and the query session. The Findings❌ Blockers
Tests
Final VerdictStatus: ❌ Block Minimum required action: close the remaining old-analyzer nested-expression gap in LLVM Coverage ReportMeasured on commit 18e3966.
Changed lines: Changed C/C++ lines covered: 17/29 (58.62%) · Uncovered code |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-impl-slot-5:20260910-234800 |
|
cc @vdimir @CurtizJ, could you review this? A sorting key expression is resolved twice, by the table under its own settings and by the query under the session's, and both read-in-order matchers treated the two as one calculation without comparing the types they resolve to, so with |
| /// give the two resolutions different result types; equal names then denote different | ||
| /// calculations, with different NULL handling, and so different orders. | ||
| const auto * node = elements_actions.getActionsDAG().tryFindInOutputs(sort_column.column_name); | ||
| if (node && !node->result_type->equals(*sorting_key_type)) |
There was a problem hiding this comment.
Checking only the outer node's type here still leaves the legacy matcher unsound for nested divergences. With query_plan_read_in_order = 0, ORDER BY ifNull(CAST(json.b, 'String'), 'zzz') hits this exact-name branch, node->result_type and sorting_key_type are both String, and the optimization is accepted even though the inner CAST is Nullable(String) in the query and String in the table. The plan-based path rejects that shape, but the old ReadInOrderOptimizer path still reads parts in the wrong order for it.
This branch needs to compare the whole key expression, not just the top-level output type. Reusing matchTrees here would close the gap; a conservative fallback is to reject exact-name matches when the output is a non-trivial expression.
There was a problem hiding this comment.
Confirmed, and reproduced on this head: at enable_analyzer = 0, query_plan_read_in_order = 0, cast_keep_nullable = 1, ORDER BY ifNull(CAST(json.b, 'String'), 'zzz') returns a, c, zzz, b where a full sort gives a, b, c, zzz. The check is at the top of the expression only, so the inner CAST is invisible to it.
Two scope points from the same run. query_plan_read_in_order = 0 alone does not reach it, since this matcher is built only under the old analyzer (ExpressionAnalyzer.cpp:2307); with the analyzer on, that query returns a, b, c, zzz. And the plan-based path does reject the shape after this change: that is arm 2 of the test here, pinned at query_plan_read_in_order = 1, with a b c zzz in the reference.
Reusing matchTrees is reachable. Each ORDER BY element already gets its own DAG over the source columns (ExpressionAnalyzer.cpp:1776-1785), so the whole tree is at this site, and matchTrees is already called from three files under src/Storages/. The cost I cannot bound without measuring is over-rejection: it maps ambiguous nodes arbitrarily and does not support aliases (actionsDAGUtils.h:69-71), so such a veto would also decline pairs that fail to map for reasons unrelated to this bug, and a lost match here is a silent performance regression.
The conservative fallback I would decline: rejecting exact-name matches whenever the output is a non-trivial expression declines every expression sorting key on this path, including those whose types agree. Arm 7 pins one of them as still reading Read type: InOrder.
So this PR keeps the top-level check, which narrows a hole older than it, and states the nested case in the description. If closing it here is preferred to a follow-up, I will extend it to the tree walk and bring old-analyzer suite numbers for the over-rejection question.
Build profile diff (arm_release)Comparing ✅ No significant changes. Binary sizes
The official master build is compiled with Compile time of recompiled translation units8 translation units recompiled, 16 s compile time in total, 8 of them have a recent master baseline. |
CI finish ledger - 739fdffEvery failure below has an owner: a fixing PR (mine or external), or a full-effort fix task
Not caused by this pull request. This branch changes how a sorting key expression whose type depends Session id: cron:our-pr-ci-monitor:20260911-093124 |
| if (parent->type == ActionsDAG::ActionType::FUNCTION && func_name == parent->function_base->getName()) | ||
| /// One function name resolves to different result types depending on the settings | ||
| /// the DAG was built with, and differently-typed results are not one calculation. | ||
| if (parent->type == ActionsDAG::ActionType::FUNCTION && func_name == parent->function_base->getName() |
There was a problem hiding this comment.
When can we have different result type for same arguments?
There was a problem hiding this comment.
CAST with cast_keep_nullable. The setting is not part of the arguments: it is read from the context when the overload resolver is built (CastOverloadResolver.cpp:192-194, and again into FunctionConvertSettings at FunctionsConversion.h:151), so one function name over the same argument types resolves to a different return type in each DAG. The target type is the second argument and is a constant, and the constant branch below this one already compares those by result type and field value (:194, :232), so that is not what differs here.
Measured on this branch:
SELECT toTypeName(CAST(json.b, 'String')) FROM t;
-- cast_keep_nullable = 0 -> String
-- cast_keep_nullable = 1 -> Nullable(String)
SELECT toTypeName(CAST(materialize(toNullable(1)), 'String'));
-- 0 -> String, 1 -> Nullable(String)In the read-in-order callers (optimizeReadInOrder.cpp:523 and :806) the left tree is sorting_key.expression's DAG, resolved when the table's KeyDescription was built, and the right one is the query DAG built under the session's settings. So ORDER BY CAST(json.b, 'String') with cast_keep_nullable = 1 in the session is String on the left and Nullable(String) on the right. A JSON path needs no Nullable argument for this: the argument is Dynamic, which the setting treats as nullable (FunctionsConversion.h:3167-3170).
The difference is in the values too, not only the wrapper: where b is absent the String form yields '' and the Nullable(String) form yields NULL, which sorts last, so the parts are not in the order the query assumes. Without the type check that query returns a c \N b where a b c \N is correct (test arm 1), and at read_in_order_use_virtual_row = 1 it hits the setVirtualRow type assert quoted in the description. Arm 5 pins that the mapping is still made, and read-in-order still chosen, when the types agree.
|
Got a small reproducible: https://fiddle.clickhouse.com/225727bb-5bfb-40ea-8892-a01e8e816cf9 @Avogar, the logical error this PR is trying to fix is happening in stress tests since my update yesterday. Maybe you can review? |
|
@groeneai merge with master |
…rees-result-type-agreement
|
Merged with master: Your fiddle runs both ways on that head (debug build,
Arm 4 of the test here is the same shape ( On the frequency: in CIDB the message is 38 rows over 36 distinct pull requests plus one master run across 8 checks, all since 2026-09-10 16:22:34Z, with one isolated hit before that on 2026-07-17. |
Avogar
left a comment
There was a problem hiding this comment.
LGTM, let's merge it asap as it failes in lots of PRs. I think it started to happen in upgrade check because in 26.8 we enabled read_in_order_use_virtual_row by default.
@vdimir I see you tool a quick look already, if you have any objections - feel free to add.
…ade load Since ClickHouse#116783 the stress randomization enables `cast_keep_nullable = 1` in a third of the runs, and, unlike its sibling arms, without the `not upgrade_check` guard. The upgrade check runs that load against the previous release's server (26.8.2.7), which predates ClickHouse#119385: it matches the sorting key `CAST(json.b, 'String')` to the same expression in `ORDER BY` by name and arity only, although under `cast_keep_nullable = 1` the query types it `Nullable(String)` while the key is `String`. Read-in-order with `read_in_order_use_virtual_row = 1` then aborts the shipped 26.8 server in `setVirtualRow` with `Logical error: Virtual row has different type` while running `03277_json_subcolumns_in_primary_key`, so `Upgrade check (amd_release)` went red on dozens of unrelated pull requests (57 on 2026-09-11, 23 on 2026-09-12, 7 on 2026-09-13). A master fix cannot clear it, because the exception is raised by the old binary. Guard the arm like the sibling `serialize_query_plan` arm (5620afa). CI: https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=99495&sha=c84da07b7cc18cc399ef06b7fc656f99a72b713a&name_0=PR&name_1=Upgrade%20check%20(amd_release) PR: ClickHouse#99495 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ade load Since #116783 the stress randomization enables `cast_keep_nullable = 1` in a third of the runs, and, unlike its sibling arms, without the `not upgrade_check` guard. The upgrade check runs that load against the previous release's server (26.8.2.7), which predates #119385: it matches the sorting key `CAST(json.b, 'String')` to the same expression in `ORDER BY` by name and arity only, although under `cast_keep_nullable = 1` the query types it `Nullable(String)` while the key is `String`. Read-in-order with `read_in_order_use_virtual_row = 1` then aborts the shipped 26.8 server in `setVirtualRow` with `Logical error: Virtual row has different type` while running `03277_json_subcolumns_in_primary_key`, so `Upgrade check (amd_release)` went red on dozens of unrelated pull requests (57 on 2026-09-11, 23 on 2026-09-12, 7 on 2026-09-13). A master fix cannot clear it, because the exception is raised by the old binary. Guard the arm like the sibling `serialize_query_plan` arm (5620afa). CI: https://s3.amazonaws.com/clickhouse-test-reports/praktika.html?PR=99495&sha=c84da07b7cc18cc399ef06b7fc656f99a72b713a&name_0=PR&name_1=Upgrade%20check%20(amd_release) PR: #99495 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 6b1566c)
Related: #109196
Related: #119168
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixed wrong results when a table's sorting key expression resolves to a different result type in the query than in the table, for example a key of
CAST(json.b, 'String')read withcast_keep_nullable = 1. Read-in-order treated the two as interchangeable, so rows could come back out of order,optimize_aggregation_in_ordercould split groups and report wrong aggregates, andread_in_order_use_virtual_row = 1could fail withLOGICAL_ERROR: Virtual row has different type.Description
A key expression is resolved twice: by the table, under its own settings, which each part's physical row order follows, and by the query, under the session's.
matchTreesmapped the two on structure alone, function name plus arity, though its contract claims mapped nodes are equal calculations, so a setting that changes the result type makes read-in-order advertise an order the parts lack.ORDER BY CAST(json.b, 'String')is the ordinary shape, since a JSON path cannot be a key column; atcast_keep_nullable = 1the query types itNullable(String), the keyString. Found on master by CI, no issue filed: Stress test (amd_tsan).Reproducer
Returns
a, c, \N, bbefore this change anda, b, c, \Nafter.The fix requires the mapped pair's result types to agree, extending the type identity already applied to constant children; a deterministic function of equal-typed arguments has one result type, so a disagreeing pair was never one calculation.
ReadInOrderOptimizer, the pre-analyzer matcher reached withquery_plan_read_in_order = 0, matched the key by name alone and now compares types too.matchTreeshas nine callers, so this tightens all of them, including projections and sharding keys; intended, since each relies on the same claim and a lost match only costs an optimization. The test pins that read-in-order still applies when the types agree; 940 tests, plus 267 replayed under the old analyzer, are identical on both binaries. I can contain it to the two read-in-order builders behind a parameter, if you prefer.Two cases stay open.
equalsignores theDateTimetime zone, so a key whose values rather than type follow the session is uncovered (toDateTime(s)under a differentsession_timezone); #117161'shaveSameExpressionIdentitycompares the zone and closes it on rebase. The name-only matcher cannot see a divergence nested under an equal-typed parent, since it never walks the expression.Workflow [PR]
Sync PR [sync-upstream/pr/119385]