Skip to content

Do not let an alias give randConstant a second value in one query - #115384

Merged
alexey-milovidov merged 8 commits into
masterfrom
fix-randconstant-alias-cse
Aug 22, 2026
Merged

Do not let an alias give randConstant a second value in one query#115384
alexey-milovidov merged 8 commits into
masterfrom
fix-randconstant-alias-cse

Conversation

@alexey-milovidov

@alexey-milovidov alexey-milovidov commented Aug 19, 2026

Copy link
Copy Markdown
Member

Related: #112938

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 randConstant returning two different values in one query when its calls carry different aliases. randConstant is documented to hold a single value for the whole query, and SELECT randConstant() = randConstant() already returned 1, but adding aliases as in SELECT randConstant() AS a, randConstant() AS b produced two unrelated values.

Description

SELECT randConstant() = randConstant()                                 -- 1
SELECT a = b FROM (SELECT randConstant() AS a, randConstant() AS b)    -- 0   <-- wrong

randConstant is documented to return "a single random value that remains constant across all rows in the current query execution", and its argument is documented as existing only "to prevent common subexpression elimination when the same function call is used multiple times in a query". So two bare calls must agree, and asking for a second value is spelled randConstant(1), randConstant(2).

RandomConstantOverloadResolver::buildImpl draws a new value on every call, so identical calls have to share one built FunctionBase to fold to one constant. resolveFunction arranges that with functions_cache, keyed by getTreeHash. That hash covers the alias, so the two aliased calls missed each other in the cache, each folded to its own literal, and no later common subexpression elimination could bring them back together — by then they were two different literals. With enable_analyzer = 0 the same query returns 1, so this is analyzer-only.

The key now ignores aliases. An alias renames an expression and never changes the value the FunctionBase captures, so aliased calls share what bare calls share. What separates two calls is still their arguments, which the hash covers.

Dropping the alias from the key widens what the cache can match, and stateful functions must not be matched: the cache is global across query scopes, so two scalar subqueries that differ only by an alias would have ended up sharing one blockNumber counter and reading two different numbers. Stateful functions are therefore excluded from the cache alongside getSetting and rowNumberInAllBlocks, and the last row of the test covers it by comparing the two counters (their absolute values are not a property of the fix - they count the blocks the server has already passed through the function).

The change is narrower than the cache's reach suggests. Only a function folded through its own FunctionBase can be split this way; anything evaluated per row, such as rand or generateUUIDv4, is deduplicated later by ordinary common subexpression elimination, which already ignored aliases — SELECT rand() AS a, rand() AS b, a = b reads 1 on master today. To bound it rather than argue it, I swept all 57 zero-argument non-deterministic functions, comparing each one aliased and bare, before and after: randConstant is the only function whose result changes, from 0 to 1, and no bare result moves at all.

Test 05028 fails on exactly its four aliased rows without the change. Its five controls — the bare comparison, the distinct-argument row (four arguments rather than two, so it cannot flake on a value collision), the across-rows row, and the now and rand rows — pass either way.

Verified by building and running against the branch build: 05028 plus the five existing tests that read randConstant (03611, 01418, 00912, 04327 across shards, 03047 under mutations) all pass. The 382-test analyzer family was run on both arms of an exact A/B on the same tree — the one-line change reverted, rebuilt, rerun — and the two arms are identical: 352 pass and the same 18 fail on both, all of them for missing fixtures in my local server rather than for this change.


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

`randConstant` is documented to hold one value for a whole query, and its argument is
documented as the way to ask for more than one. Yet an alias produced a second value:

```
SELECT randConstant() = randConstant()                      -- 1
SELECT a = b FROM (SELECT randConstant() AS a, randConstant() AS b)   -- 0
```

`RandomConstantOverloadResolver::buildImpl` draws a new value on every call, so identical
calls have to share one built `FunctionBase` to fold to one constant. `resolveFunction`
arranges that with `functions_cache`, keyed by `getTreeHash`. That hash covers the alias,
so the two aliased calls missed each other in the cache, each folded to its own literal,
and no later common subexpression elimination could bring them back together - by then they
were two different literals.

The key now ignores aliases. An alias renames an expression and never changes the value the
`FunctionBase` captures, so aliased calls share what bare calls share. What separates two
calls is still their arguments, which the hash covers: `randConstant(1)` and
`randConstant(2)` keep their own values.

The change is narrower than it looks. Only a function that is folded through its own
`FunctionBase` can be split this way; anything evaluated per row, such as `rand` or
`generateUUIDv4`, is deduplicated later by ordinary common subexpression elimination, which
already ignored aliases. A sweep of all 57 zero-argument non-deterministic functions
confirms it: comparing every one of them aliased and bare, before and after, `randConstant`
is the only function whose result changes, from 0 to 1, and no bare result moves at all.

The `04327` and `03047` regression checks were also run, since they read `randConstant`
across shards and under mutations, along with `03611`, `01418` and `00912`.

Test `05023` fails on exactly its four aliased rows without this change; its five controls -
the bare comparison, the distinct-argument row, the across-rows row, and the `now` and
`rand` rows - pass either way.
@clickhouse-gh

clickhouse-gh Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [6259e38]

Summary:


AI Review

Summary

This PR fixes the analyzer-specific randConstant alias bug by letting aliased calls share the same built FunctionBase, and it correctly closes the earlier blockNumber and getSettingOrDefault gaps. The remaining problem is that the shared-cache gate is still based only on query-scope determinism, so it can reuse a scope-bound FunctionBase for hasColumnInTable across aliased subqueries that run under different SQL-security contexts.

Findings
  • ❌ Blocker: [src/Analyzer/Resolve/resolveFunction.cpp:2779] The new alias-insensitive functions_cache still admits hasColumnInTable, even though its cached FunctionBase retains the first scope's Context and later performs SHOW_COLUMNS access checks and table resolution through that context. Because view expansion can analyze subqueries under different getSQLSecurityOverriddenContext identities, two aliased scalar subqueries can now share one cached instance and run the second access check under the wrong user. Either exclude context-bound constant-folded functions like hasColumnInTable from this cache, or tighten the cache contract so only scope-independent FunctionBase implementations are reusable.
Final Verdict

❌ Changes requested. The alias-insensitive cache still crosses security/context boundaries for hasColumnInTable, so the fix is not safe to merge yet.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 87.10% 87.00% -0.10%
Functions 91.90% 91.90% +0.00%
Branches 79.40% 79.30% -0.10%

Changed lines: Changed C/C++ lines covered: 4/4 (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 19, 2026
Comment thread src/Analyzer/Resolve/resolveFunction.cpp
@clickhouse-gh

clickhouse-gh Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing 6259e385f with master ecc01ef26 (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.37 MiB 702.35 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

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

Job report

The stateful row of `05023` printed the two `blockNumber` values and expected `0 0`. Where the counter
starts is not a property of the fix: it counts how many blocks the server has already passed through that
function object, so a warm server reads `6 6`, which is how the row failed in `Fast test`.

The invariant the row is about is that the two scalar subqueries do not share one `FunctionBase`: separate
instances count separately and read the same number, a shared one keeps counting and hands the second
subquery a larger number. So compare the two numbers and print only the comparison.
`master` gained two more `05023_` tests in the meantime.
Comment thread src/Analyzer/Resolve/resolveFunction.cpp Outdated
@alexey-milovidov

Copy link
Copy Markdown
Member Author

gate this cache on query-scope determinism

Yes, we should do this.

…eterminism

The alias-insensitive cache is global across the whole query, so it must
only hand out a shared `FunctionBase` for functions whose value is stable
inside a query. The name-based exceptions for `getSetting` and
`rowNumberInAllBlocks` missed `getSettingOrDefault`, which reads the
`SETTINGS` of its own scope just like `getSetting`: two scalar subqueries
differing only by alias could reuse one instance and read the first
subquery's setting value.

Replace the name list with `isDeterministicInScopeOfQuery`, which covers
all of them, and add a regression for both `getSetting` and
`getSettingOrDefault`.
/// the hash still covers: `randConstant(1)` and `randConstant(2)` keep their own values, and that
/// is the documented way to ask for two different constants in one query.
if (function && !function->isDeterministic() && !function->isStateful()
&& function->isDeterministicInScopeOfQuery())

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.

isDeterministicInScopeOfQuery() is still too weak for this cache key. hasColumnInTable inherits that default, so it now passes the new gate, but the cached FunctionBase still closes over the scope Context via FunctionToFunctionBaseAdaptor. Its executeImpl later does getContext()->checkAccess(...) and DatabaseCatalog::getTable(..., getContext()) in src/Functions/hasColumnInTable.cpp (see lines 103 and 108), so reusing the first scope's instance changes which user and settings the second scope runs under.

That becomes observable now that aliases are ignored: view expansion already analyzes inner queries under getSQLSecurityOverriddenContext in src/Storages/StorageInMemoryMetadata.cpp:146, so two aliased scalar subqueries from different security scopes can now collide in functions_cache and run the second SHOW_COLUMNS check under the first scope's context. That is a privilege/correctness bug, not just a missed optimization guard. I think this cache needs a stronger condition than "value is stable inside a query" here: either mark hasColumnInTable (and similar context-bound constant-folded functions) as not deterministic in query scope, or make the cache require that the built FunctionBase is scope-independent as well as value-stable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is out of scope.

@alexey-milovidov alexey-milovidov left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

LGTM

@alexey-milovidov alexey-milovidov self-assigned this Aug 22, 2026
@alexey-milovidov
alexey-milovidov added this pull request to the merge queue Aug 22, 2026
Merged via the queue into master with commit cec8ceb Aug 22, 2026
178 checks passed
@alexey-milovidov
alexey-milovidov deleted the fix-randconstant-alias-cse branch August 22, 2026 21:03
@robot-ch-test-poll3 robot-ch-test-poll3 added the pr-synced-to-cloud The PR is synced to the cloud repo label Aug 22, 2026
@clickgapai

Copy link
Copy Markdown
Contributor
Reproducer
SELECT randConstant() = randConstant();                              -- 1
SELECT a = b FROM (SELECT randConstant() AS a, randConstant() AS b); -- 0, expected 1
-- analyzer-only: with enable_analyzer = 0 both read 1

▶ Run on ClickHouse Fiddle

Results:

  • Reproduces on: 26.7, 26.6, 26.5, 26.3, 25.8
  • Does not reproduce on: master
  • Tested versions: master, 26.7, 26.6, 26.5, 26.3, 25.8
  • Also tested (unsupported, context only): 26.2 reproduces
  • Fix is on master — this PR's change fixes the bug on master. The releases listed above still have the bug and need the backport.

Backport the fix to 26.7, 26.6, 26.5, 26.3, 25.8.

CC component owner: @KochetovNicolai @novikd

Analysis metadata

comp-query-analyzer · Severity P2 · Finding phase_d_pr115384

  • Verified against master build 1562fec97aea (aarch64)
Introducing change: not identified
  • No clean release baseline found and no lower bound established — source-build bisect is required to pin the introducing PR (not yet performed).
  • Walked 6 release(s) below the supported window: still reproduces down to 25.2.
  • Secondary components: comp-testing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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.

3 participants