Add table function `obfuscate` by evillique · Pull Request #42701 · ClickHouse/ClickHouse · GitHub
Skip to content

Add table function obfuscate#42701

Draft
evillique wants to merge 43 commits into
ClickHouse:masterfrom
evillique:obfuscate-function
Draft

Add table function obfuscate#42701
evillique wants to merge 43 commits into
ClickHouse:masterfrom
evillique:obfuscate-function

Conversation

@evillique

@evillique evillique commented Oct 26, 2022

Copy link
Copy Markdown
Member

Closes: #39067

Changelog category (leave one):

  • New Feature

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Add table function obfuscate which applies the same transformation as the clickhouse-obfuscator tool to the result of an arbitrary query, e.g. SELECT * FROM obfuscate(SELECT * FROM table). The seed can be controlled with the obfuscate_seed setting; with an empty seed a fresh random one is derived per execution.

@evillique evillique marked this pull request as draft October 26, 2022 13:41
@robot-ch-test-poll2 robot-ch-test-poll2 added the pr-feature Pull request with new product feature label Oct 26, 2022
@rschu1ze rschu1ze self-assigned this Nov 2, 2022
@alexey-milovidov

Copy link
Copy Markdown
Member

@rschu1ze

Copy link
Copy Markdown
Member

Maybe another day ...

@alexey-milovidov

Copy link
Copy Markdown
Member

We need this.

@alexey-milovidov

Copy link
Copy Markdown
Member

Continued in #101027

@evillique

Copy link
Copy Markdown
Member Author

The mentioned PR is about a function that obfuscates an SQL query. This PR implements a table function that obfuscates the result of a certain query.

@evillique evillique reopened this Apr 9, 2026
@clickhouse-gh

clickhouse-gh Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [58cc809]

Summary:


AI Review

Summary

This PR adds the obfuscate table function together with its analyzer, pipeline, settings, and test coverage. The remaining correctness gap is in the new SETTINGS ... = DEFAULT handling for subqueries: the reset now takes effect for the local analyzer path, but it is still dropped when the query tree is serialized back to AST, so remote or redistributed execution paths can observe different settings than the local path. The feature also still intentionally rejects LowCardinality, which leaves a common production schema wrapper unsupported.

Missing context / blind spots
  • I reviewed the current PR diff, the current checkout, prior review threads, and issue comments. I did not execute distributed or integration repros in this environment.
Findings

❌ Blockers

  • [src/Analyzer/QueryNode.cpp:565] The new SETTINGS x = DEFAULT handling is still local-only. QueryTreeBuilder resets the copied subquery context, but QueryNode still persists only settings_changes, and QueryNode::toAST serializes only those changes. Any analyzer path that rebuilds the AST from the query tree, such as src/Interpreters/ClusterProxy/executeQuery.cpp:1137, silently drops the DEFAULT reset and can execute the same inner obfuscate call under the outer or session setting instead. Minimum fix: persist default_settings through QueryNode state, hashing, equality, and toAST, then add a remote or distributed regression.

⚠️ Majors

  • [src/Common/Obfuscator/Models.h:1064] LowCardinality columns still fail with NOT_IMPLEMENTED, so obfuscate cannot process common wrapped schemas yet. This is already tracked in an existing review thread, but it remains a real user-visible limitation until the shared model path unwraps and re-wraps LowCardinality or the feature contract is narrowed explicitly.
Tests
  • Add a focused remote or distributed regression for an inner SETTINGS obfuscate_markov_order = DEFAULT; the current tests cover only the local analyzer path.
Final Verdict

Status: ⚠️ Request changes

Minimum required actions:

  • Persist default_settings through QueryNode AST round-trips and cover a remote or distributed path.
  • Resolve the current LowCardinality limitation or narrow the feature contract explicitly before merge.

Comment thread src/Common/Obfuscator/Models.h Outdated
Comment thread src/Processors/QueryPlan/ObfuscateStep.cpp Outdated
@alexey-milovidov

Copy link
Copy Markdown
Member

Yes, that's true. Didn't notice that. The feature is still needed!

@alexey-milovidov

Copy link
Copy Markdown
Member

The flaky check failure is fixed in #102148, let's update the branch.

Comment thread src/TableFunctions/TableFunctionObfuscate.h
Comment thread src/Processors/QueryPlan/ObfuscateStep.cpp Outdated
@evillique evillique force-pushed the obfuscate-function branch from f1eaf7b to 53ed445 Compare April 23, 2026 16:48
# Conflicts:
#	programs/obfuscator/Obfuscator.cpp
#	src/Parsers/ExpressionListParsers.cpp
Comment thread src/Interpreters/Cache/QueryResultCache.cpp
Comment thread src/Common/Obfuscator/Models.h Outdated
alexey-milovidov and others added 5 commits June 20, 2026 01:15
…work

# Conflicts:
#	programs/obfuscator/Obfuscator.cpp
The Style check `test_numbers_check` failed with `Gap (4339, 5017) > 100`:
the test `05017_obfuscate_inner_settings_limit_stable` sat far above the dense
test-number range. Renumber it to `04401` so the numbering stays gap-free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`DateTimeModel` preserved the date component using `DateLUT::serverTimezoneInstance`,
but a `DateTime` column can carry its own timezone. On a server in one timezone, a
`DateTime('Asia/Tokyo')` value near local midnight could move to a neighbouring
displayed date, breaking the obfuscator contract that the date component is preserved.

Thread the column timezone (`DataTypeDateTime::getTimeZone`) from `ModelFactory`
into `DateTimeModel` and use it for both `toDate` calls. For a `DateTime` without an
explicit timezone `getTimeZone` falls back to the session/server timezone, so the
common case is unchanged. The model is shared with the `clickhouse obfuscator` tool,
so the fix applies to both and keeps them in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`SETTINGS x = DEFAULT` is parsed into `ASTSetQuery::default_settings`, not into
`changes`. Two places ignored it when normalizing a query into a cache key:
`ASTSetQuery::updateTreeHashImpl` hashed only `changes`, and
`removeQueryResultCacheSettings` dropped the whole `SETTINGS` clause once `changes`
became empty. As a result an inner `SETTINGS obfuscate_seed = DEFAULT` (which resets
the seed to the empty, non-deterministic value) hashed identically to no override at
all. With `query_cache_nondeterministic_function_handling = 'save'` the random-seeded
query could write an entry that the deterministic session-seeded query then read.

Hash `default_settings` in `updateTreeHashImpl` and keep the `SETTINGS` clause while
`default_settings` is non-empty. The `obfuscate` table function is the visible
trigger, but the fix is general to any `SETTINGS x = DEFAULT` override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The obfuscator model code is shared with the `clickhouse obfuscator` tool and does
not yet support `LowCardinality`. Instead of an exception of the "not a column" kind
or a logical error, it rejects such columns with a clean `NOT_IMPLEMENTED`. Pin this
behavior; real `LowCardinality` support is tracked as a follow-up so the table
function and the CLI tool stay in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member

Merged master (the branch was ~2.6k commits behind) and pushed the fixes for the AI Review ❌ Block verdict. The only merge conflict was in programs/obfuscator/Obfuscator.cpp (include block): kept the slim refactored body and added the ThreadPool.h / SharedThreadPools.h / scope_guard_safe.h includes that master's SCOPE_EXIT_SAFE thread-pool-shutdown block needs.

Addressed every item in Minimum required actions:

  • DEFAULT cache-key collision (9dd0814eb9e) — SETTINGS obfuscate_seed = DEFAULT is parsed into ASTSetQuery::default_settings, which was ignored by both the tree hash and removeQueryResultCacheSettings, so a random-seeded obfuscate query could share a cache key with the deterministic session-seeded one under query_cache_nondeterministic_function_handling = 'save'. Now updateTreeHashImpl hashes default_settings and the SETTINGS clause is kept while default_settings is non-empty. Test: 04402_obfuscate_query_cache_default_seed_key.
  • DateTime('TZ') date preservation (260ec88e41e) — DateTimeModel used DateLUT::serverTimezoneInstance; it now receives DataTypeDateTime::getTimeZone() from ModelFactory. Non-explicit-timezone DateTime falls back to the session/server timezone, so the common case and the clickhouse obfuscator CLI are unchanged. Test: 04403_obfuscate_datetime_timezone.
  • LowCardinality wrappers (2247a90268c) — kept as a tracked follow-up (real support belongs in the shared model code so the CLI and the table function stay in sync). The unsupported-wrapper contract is now pinned explicitly by 04404_obfuscate_unsupported_lowcardinality (clean NOT_IMPLEMENTED, not a crash/logical error).
  • Style test_numbers_check (73704832345) — renumbered 05017_obfuscate_inner_settings_limit_stable04401 to clear Gap (4339, 5017) > 100.

The two ❌ Block / ⚠️ Major inline threads are resolved; the LowCardinality thread is left open to track the follow-up. Leaving the PR in draft.

Comment thread src/Common/Obfuscator/Models.h
/// `SETTINGS obfuscate_seed = DEFAULT` is stored separately and resets the seed
/// to its default, which is the empty (non-deterministic) one.
for (const auto & name : settings_ast->default_settings)
if (name == "obfuscate_seed")

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.

This treats SETTINGS obfuscate_seed = DEFAULT as the effective empty seed, but the analyzer path that actually resolves table functions does not apply ASTSetQuery::default_settings to subquery contexts. QueryTreeBuilder only applies set_query.changes, and QueryAnalyzer::resolveTableFunction switches to a subquery context only when QueryNode::hasSettingsChanges is true. For a subquery that only has SETTINGS obfuscate_seed = DEFAULT, ObfuscateStep still sees the outer/session obfuscate_seed, while this cache check marks the same call as non-deterministic.

A deterministic repro shape is to set an invalid session value such as SET obfuscate_markov_order = 0, then run a nested SELECT ... FROM obfuscate(...) ... SETTINGS obfuscate_markov_order = DEFAULT with the analyzer. If the default were applied, it would use order 5; today the table function still sees 0 and throws from ObfuscateStep.

Please make QueryTreeBuilder apply and carry default_settings for SELECT scopes, or materialize these defaults into the settings changes used by the analyzer/table-function execution context before relying on them in the cache logic.

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.

Addressed in commit 1bdf2cf: QueryTreeBuilder::buildSelectExpression now applies ASTSetQuery::default_settings to the subquery scope via resetSettingsToDefaultValue, so SETTINGS obfuscate_seed = DEFAULT (and any other = DEFAULT) actually takes effect for the nested table function, matching what the cache logic assumes. Verified locally and covered by new test 04407_obfuscate_inner_settings_default_reset.

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.

This is still present in the current head for the case where the inner scope contains only = DEFAULT settings.

QueryTreeBuilder now resets the copied QueryNode context in src/Analyzer/QueryTreeBuilder.cpp:318, but it still leaves settings_changes empty. resolveTableFunction only switches execution_context to the nearest subquery scope when query_node->hasSettingsChanges() is true at src/Analyzer/Resolve/QueryAnalyzer.cpp:4786, so a subquery with only SETTINGS obfuscate_markov_order = DEFAULT still executes obfuscate with the outer/session setting.

The new 04407_obfuscate_inner_settings_default_reset test misses this path because it also sets obfuscate_seed = 'stable', which makes hasSettingsChanges() true. Please make the table-function context handoff account for default_settings as well, and add a regression where the inner SETTINGS clause contains only the DEFAULT reset.

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.

The local analyzer path is fixed now, but the "carry default_settings" part of the original issue is still missing.

QueryTreeBuilder resets the copied subquery context at src/Analyzer/QueryTreeBuilder.cpp:318-319, and resolveTableFunction now notices default-only inner settings. But QueryNode still stores only settings_changes, and QueryNode::toAST still serializes only those changes at src/Analyzer/QueryNode.cpp:565-570.

That means any path that rebuilds SQL from the query tree still loses an inner SETTINGS x = DEFAULT. One concrete case is src/Interpreters/ClusterProxy/executeQuery.cpp:1135-1141, which does query_tree->toAST() before shipping the query. A shape like outer/session obfuscate_markov_order = 0 plus inner SETTINGS obfuscate_markov_order = DEFAULT now succeeds locally, but the serialized AST sent to the remote side still drops the reset and falls back to 0.

So the issue is still present until default_settings are persisted through QueryNode state, hashing, equality, and toAST, not only applied to the local context.

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.

Confirmed — I traced the paths. QueryNode stores only settings_changes (QueryNode.h:715) and QueryNode::toAST rebuilds the SETTINGS clause purely from those (QueryNode.cpp:565-570), so an inner SETTINGS x = DEFAULT (parsed into ASTSetQuery::default_settings, not changes) is indeed dropped when the query tree is re-serialized to AST. The fix in 1bdf2cff849 and the resolveTableFunction handoff apply the reset to the local execution_context only, reading it back from the original AST; they intentionally do not persist it on the QueryNode (as the comment at QueryAnalyzer.cpp:4788 notes). So a toAST-based shipping path — e.g. the ClusterProxy INSERT ... SELECT at executeQuery.cpp:1136 — would ship the query without the inner reset.

Persisting default_settings through QueryNode state, hashing, equality and toAST is a broad, cross-cutting analyzer change: it touches the query-tree hash/equality contract that is used far beyond obfuscate. That is exactly why this PR keeps the reset in the local execution context rather than widening a feature PR into core analyzer plumbing. I'll keep this thread open to track it as a dedicated follow-up, together with coverage for the distributed re-serialization path — the same way real LowCardinality support is tracked separately.

For the record, this only affects the narrow combination of a distributed query, an inner-only = DEFAULT reset, and an outer/session value that differs from the default; the common local obfuscate(SELECT ...) path is correct and covered (04407_obfuscate_inner_settings_default_reset).

Comment thread src/Common/Obfuscator/Models.h Outdated
alexey-milovidov and others added 8 commits June 25, 2026 05:05
`04402_obfuscate_query_cache_default_seed_key` issues `SYSTEM DROP QUERY CACHE TAG`
(for repeatable cleanup across re-runs), which the style check `various_checks.sh`
requires to carry a `no-parallel` tag, because any `SYSTEM DROP` touches server-wide
shared state. The previous comment claimed the unique `query_cache_tag` made
`no-parallel` unnecessary; that is true for functional isolation but does not satisfy
the mechanical style rule, so the `Style check` -> `various` job failed with:
`Test with SYSTEM DROP should have no-parallel tag`. Add the tag and correct the comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`04403_obfuscate_datetime_timezone` selected `DISTINCT toDate(x, 'Asia/Tokyo')` over an
`obfuscate(...)` subquery with no `LIMIT`. Because `obfuscate` is an effectively infinite,
repeating source, the outer `SELECT DISTINCT` kept reading the stream until it hit
`max_rows_to_read`, so the `Fast test` job failed with
`Limit for rows or bytes to read exceeded ... While executing ObfuscateSource. (TOO_MANY_ROWS)`
even though the produced value (`2024-01-01`) was already correct.

Push a `LIMIT 1000` inside the subquery (next to the obfuscate settings), matching the
documented idiom used by `04103_obfuscate_table_function_settings`. One full pass over the
1000 input rows is enough to observe that every obfuscated value keeps the Tokyo date, and
the reference output is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The integer obfuscation models permute values inside their log2 bucket using
64-bit arithmetic (`getUInt`/`getInt` and a `UInt64`/`Int64` `Field`), but the
`ModelFactory` selected them for every `isInteger` type, including the wide ones.
A query such as `obfuscate(SELECT toUInt128(1))` was accepted and then failed with
an internal `BAD_GET` while inserting a `UInt64` `Field` into a `ColumnVector` of a
wide type. Reject `Int128`/`UInt128`/`Int256`/`UInt256` up front with a clean
`NOT_IMPLEMENTED` exception instead.

The generic signed path also broke the "keep sign" invariant for narrow minimum
values: only `Int64`'s minimum was special-cased, so `Int8`'s `-128` (and the other
narrow minimums) could be permuted to a magnitude in `128..255` and narrowed back
into a positive value. Pass the source type's minimum into `SignedIntegerModel` and
keep it unchanged, as is already done for `Int64`.

Both the `obfuscate` table function and the `clickhouse obfuscator` CLI tool share
this `ModelFactory`, so the fix applies to both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eTime

`DateTimeModel` keeps the source date and applies a pseudorandom permutation to the
time of day. It computed the obfuscated time as a raw "seconds since midnight" offset
inside the generated value's local day and added it to the source date's midnight.
On a DST transition day the local day is not 24 hours long, so that offset could
exceed the source day's length and the result would spill onto a neighbouring
displayed date, breaking the invariant that the obfuscator preserves the date as
displayed in the column's timezone.

Rebuild the result from the source date plus the obfuscated local wall-clock time in
the column's timezone via `DateLUTImpl::makeDateTime`, which always lands inside the
source local day. On days without an offset change the result is identical to before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`SETTINGS x = DEFAULT` is parsed into `ASTSetQuery::default_settings`, not `changes`.
When building the query tree for a `SELECT`, `QueryTreeBuilder` applied `changes` to
the subquery scope but ignored `default_settings`, so a setting reset to its default
inside a nested `SELECT` had no effect on that subquery. For the `obfuscate` table
function this meant `SELECT ... FROM obfuscate(...) SETTINGS obfuscate_seed = DEFAULT`
still ran with the outer/session value, while the query result cache logic already
treated such a reset as effective, leaving the two inconsistent.

Reset those settings to their default values in the subquery scope as well, matching
how `changes` are applied here and how top-level query settings are handled by
`InterpreterSetQuery`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…test

`04402_obfuscate_query_cache_default_seed_key` consumed `obfuscate(...)` with an
outer `count()` and no `LIMIT`. Because `ObfuscateSource` is an effectively infinite,
repeating source, the aggregate kept reading the stream until it hit a read limit, so
the test timed out (the `Fast test` `Timeout` failure on this test). Push a `LIMIT 4`
inside each inner `obfuscate` subquery, matching the other obfuscate tests; the two
queries still differ only by the inner `SETTINGS obfuscate_seed = DEFAULT`, so they
still occupy two distinct query-cache entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
After merging `master`, the development version is `26.7`, but the seven `obfuscate_*`
settings introduced by this PR were still recorded under the `26.6` block (where they
were placed when `26.6` was the development version). New settings must be registered
under the current version, so move them into the `26.7` block. The entries are
otherwise unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member

Merged master (the branch was ~3.7k commits behind and CONFLICTING) and addressed the remaining CI failures and the AI Review ⚠️ Request changes verdict. The net diff over master is exactly the obfuscate feature (no stray changes); the merge resolved cleanly.

CI fixes

  • Fast test04402_obfuscate_query_cache_default_seed_key Timeout: ObfuscateSource is an effectively infinite, repeating source, but this test consumed it with an outer count() and no LIMIT, so the aggregate read forever. Pushed a LIMIT 4 inside each inner obfuscate subquery (matching every other obfuscate test); the two queries still differ only by the inner SETTINGS obfuscate_seed = DEFAULT, so they remain two distinct cache entries.
  • Style check: the no-parallel tag (this test issues SYSTEM DROP QUERY CACHE) and the 04403 TOO_MANY_ROWS failure were already fixed by the previous commits; the version roll to 26.7 required moving the seven obfuscate_* entries in SettingsChangesHistory.cpp from the 26.6 block to the 26.7 block.

AI Review — minimum required actions (all three addressed)

  • Analyzer DEFAULT setting mismatch (QueryResultCache.cpp:214): QueryTreeBuilder now applies ASTSetQuery::default_settings to subquery scopes via resetSettingsToDefaultValue, so SETTINGS obfuscate_seed = DEFAULT (and any other = DEFAULT) actually takes effect for the nested table function, consistent with how the cache logic already treats such resets. New test 04407_obfuscate_inner_settings_default_reset.
  • Wide integer types (Models.h): ModelFactory now rejects Int128/UInt128/Int256/UInt256 up front with a clean NOT_IMPLEMENTED exception instead of a BAD_GET during generation, and the narrow signed minimums (Int8 -128, Int16, Int32) are now preserved like Int64's. New test 04405_obfuscate_integer_type_edge_cases.
  • DST date preservation (DateTimeModel): the obfuscated value is rebuilt from the source date plus the obfuscated local wall-clock time via DateLUTImpl::makeDateTime, so the displayed date stays inside the source local day even on DST-transition days; identical to before on ordinary days. New test 04406_obfuscate_datetime_dst_transition.

Verification
Built the merged tree (clickhouse, debug) — compiles cleanly. Ran the obfuscate stateless queries against the new binary with clickhouse-local:

  • 04405: toUInt128/Int128/UInt256/Int256NOT_IMPLEMENTED; toInt8(-128) kept as -128; obfuscated toInt8(-100) stays negative.
  • 04406/04403: displayed date preserved (2024-03-31 London / 2024-01-01 Tokyo).
  • 04407: inner obfuscate_markov_order = DEFAULT resets an invalid session 0 back to the default so the query succeeds; without it the query throws BAD_ARGUMENTS.
  • 04338: toInt64(min) kept; 04404: LowCardinalityNOT_IMPLEMENTED.
  • 04320/04402: the query-cache non-determinism check fires (QUERY_CACHE_USED_WITH_NONDETERMINISTIC_FUNCTIONS) for an empty/DEFAULT-reset inner seed and not for a fixed inner seed; 04402's count() now terminates. The exact system.query_cache row counts are exercised in full CI (the query result cache does not populate across statements in clickhouse-local).

The LowCardinality thread is left open as the tracked follow-up (unwrap and obfuscate the nested type); current behavior is a clean NOT_IMPLEMENTED, covered by 04404.

alexey-milovidov and others added 2 commits June 30, 2026 09:57
The AST fuzzer found that a huge `obfuscate_markov_order` (e.g.
`9223372036854775806`) makes the `obfuscate` table function throw an
internal `std::length_error` (reported as a logical error): the Markov
model preallocates a `std::vector` of `order` code points in
`MarkovModel`'s constructor, and such a request exceeds the vector's
`max_size`.

Validate the upper bound of `obfuscate_markov_order` in `ObfuscateStep`
(next to the existing `order == 0` check) and reject values above a sane
maximum of 1000 with a clear `BAD_ARGUMENTS` exception instead. The
order is the Markov context length in code points and the model also
does work proportional to it for every consumed and generated code
point, so larger values are both useless and dangerous.

Failing query:
    SELECT * FROM obfuscate(SELECT toString(number) AS s FROM numbers(8))
    LIMIT 1 SETTINGS obfuscate_markov_order = 9223372036854775806

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=42701&sha=89e32e03b37dd396290f842685b701f42a6389b7&name_0=PR&name_1=AST%20fuzzer%20%28amd_debug%2C%20targeted%29

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ecution

The AI review pointed out that `1bdf2cff849` only fixed half of the
`SETTINGS x = DEFAULT` issue: it applies the reset to the subquery's
context in `QueryTreeBuilder`, but `QueryAnalyzer::resolveTableFunction`
switches the table function's execution context to that subquery context
only when `query_node->hasSettingsChanges()` is true. `SETTINGS x =
DEFAULT` is parsed into `ASTSetQuery::default_settings`, not `changes`,
so `settings_changes` stays empty and `hasSettingsChanges()` is false
when the reset is the only inner setting. As a result a table function
such as `obfuscate` still executed with the outer/session settings, even
though the query result cache already treated the reset as effective.

`04407_obfuscate_inner_settings_default_reset` did not catch this because
its inner `SETTINGS` also sets `obfuscate_seed = 'stable'`, which makes
`hasSettingsChanges()` true and bypasses the failing path.

Detect a `DEFAULT`-only reset from the subquery's original AST and treat
it like a settings change for the purpose of selecting the execution
context. Add `04491_obfuscate_inner_default_only_reset` covering exactly
that path (an inner subquery whose only setting is `obfuscate_markov_order
= DEFAULT`), which fails before this change and passes after it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member

Pushed two commits addressing the latest CI failure and the AI Review ⚠️ Request changes verdict.

AST fuzzer (amd_debug, targeted) — real failure caused by this PR (report): SELECT * FROM obfuscate(SELECT toString(number) AS s FROM numbers(8)) LIMIT 1 SETTINGS obfuscate_markov_order = 9223372036854775806 threw an internal std::length_error (reported as a logical error). MarkovModel preallocates a std::vector of order code points, and a value near INT64_MAX exceeds the vector's max_size. 444341f6195 validates the upper bound of obfuscate_markov_order in ObfuscateStep (next to the existing order == 0 check) and rejects values above a sane maximum of 1000 with a clear BAD_ARGUMENTS (easy to bump if you prefer a different ceiling). Test 04490_obfuscate_markov_order_bounds.

AI Review blocking finding — inner SETTINGS x = DEFAULT-only reset (QueryAnalyzer.cpp): the review correctly noted that 1bdf2cff849 fixed only half of it. QueryTreeBuilder applies the reset to the subquery's context, but resolveTableFunction switches the table function's execution context to that subquery context only when query_node->hasSettingsChanges() is true. SETTINGS x = DEFAULT is parsed into ASTSetQuery::default_settings, not changes, so hasSettingsChanges() is false when the reset is the only inner setting — and the table function still ran with the outer/session settings. 04407_obfuscate_inner_settings_default_reset masked this because its inner SETTINGS also sets obfuscate_seed = 'stable', which makes hasSettingsChanges() true. a65736334f3 detects a DEFAULT-only reset from the subquery's original AST and treats it as a settings change for execution-context selection, and adds 04491_obfuscate_inner_default_only_reset (an inner subquery whose only setting is obfuscate_markov_order = DEFAULT), which fails before the change and passes after.

I verified the analyzer change compiles cleanly under the build flags (-fsyntax-only with -Werror); behaviorally it reuses the exact execution-context path that the already-passing 04407 query 1 exercises (the downstream — applying the reset context to the table function — is unchanged; only the gate that selects it now also recognizes a DEFAULT-only reset).

The LowCardinality follow-up in Models.h remains the tracked deferral (clean NOT_IMPLEMENTED for now).

@alexey-milovidov

Copy link
Copy Markdown
Member

The only remaining CI red is Fast test03360_bool_remote, an unrelated connection-pool flake (report):

Code: 102. DB::Exception: Unexpected packet from server 127.0.0.2:9000
(expected TablesStatusResponse, got ProfileInfo): While executing Remote. (UNEXPECTED_PACKET_FROM_SERVER)
(query: SELECT true AS x FROM remote('127.0.0.{1,2}', system.one) GROUP BY x;)

This is not related to the obfuscate table function:

  • 03360_bool_remote is a pre-existing master test (SELECT true AS x FROM remote('127.0.0.{1,2}', system.one) ...); this PR neither adds nor modifies it, and the diff touches no remote/Connection/networking code — it is the obfuscate feature plus its analyzer/query-cache wiring.
  • The error originates in Connection::getTablesStatus: a fresh TablesStatusRequest received a leftover ProfileInfo packet from a previous query on a pooled connection, i.e. a connection left in a broken state. This is the same flake class as the closed Connection left in a broken state (was: flaky test 03259_grouping_sets_aliases) #93018; it has nothing to do with bool over remote() — that test just happened to reuse the corrupted pooled connection. A Fast test re-run clears the gate.

The PR is otherwise AI-Review ✅ Approve on the current head a6573633. The only open review thread is the intentional LowCardinality follow-up (current behavior is a clean NOT_IMPLEMENTED, pinned by 04404_obfuscate_unsupported_lowcardinality).

@groeneai, please investigate this flake: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=42701&sha=a65736334f32081b73d21b65dfb65c43bbf335de&name_0=PR&name_1=Fast%20test — there is currently no open tracking issue for the UNEXPECTED_PACKET_FROM_SERVER (expected TablesStatusResponse, got ProfileInfo) connection-pool desync (closest is the closed #93018). If a fix is already in progress, please link it here.

@groeneai

Copy link
Copy Markdown
Contributor

@alexey-milovidov confirmed: this is the pooled-connection desync family you described (recurrence of the closed #93018; the merged #93029 did not cover the Connection::getTablesStatus path), and it is unrelated to the obfuscate change.

CIDB, last 30 days. The expected TablesStatusResponse, got ProfileInfo signature: 7 hits on master plus 51 hits across 49 distinct unrelated PRs (58 total), so it reproduces broadly and independently of any single diff. 03360_bool_remote itself: 1 master hit plus 10 PR hits. #42701 touches only obfuscate code (no remote() / Connection / ConnectionEstablisher / networking files), so the failure is orthogonal to it and a Fast test rerun clears this gate.

Fix in progress: #108854 ("Fix flaky distributed query ..."). It disconnects out-of-sync pooled connections in RemoteQueryExecutor::finish and treats UNEXPECTED_PACKET_FROM_SERVER as a soft connection-establishment failure (retry on a fresh connection) in ConnectionEstablisher::run, with a new 04489_distributed_table_status_desync_recovery regression test. There is no separate open tracking issue; #108854 is the tracker for this signature.

Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=42701&sha=a65736334f32081b73d21b65dfb65c43bbf335de&name_0=PR&name_1=Fast%20test

@alexey-milovidov

Copy link
Copy Markdown
Member

Re-ran the Fast test gate (run 28436857685) on head a6573633. The sole red — 03360_bool_remote, the unrelated Connection::getTablesStatus pooled-connection desync (expected TablesStatusResponse, got ProfileInfo, tracked by #108854) — did not recur: Fast test is now ✅, so a plain re-run was enough to clear the gate (no need to wait for #108854 to merge first).

No code changed. The Praktika re-run re-dispatched the build/test pipeline, which is re-running on the same head; the substantive blocker (the flake) is resolved. AI Review is ✅ Approve, and the only open review thread is the intentional LowCardinality follow-up (clean NOT_IMPLEMENTED, pinned by 04404_obfuscate_unsupported_lowcardinality).

@alexey-milovidov

Copy link
Copy Markdown
Member

The full CI pipeline re-dispatched by the Fast test re-run has now completed, and one red surfaced that was not yet visible when I last commented (the job was still running on this same head a65736334f3): Unit tests (asan_ubsan, function_prop_fuzzer)FunctionsStress.stress / AllTests fail on

SELECT reinterpret(CAST('1970-01-01 01:00:00.000000000' AS DateTime64(9)), CAST('DateTime64(5)' AS String));
Code: 117. DB::Exception: function returned column of unexpected type or scale: Const(DateTime64) instead of DateTime64(5). (INCORRECT_DATA)

This is not caused by this PR — it touches no reinterpret/conversion/function-property code (only Obfuscator, TableFunctions, settings, analyzer, parsers, and stateless tests). It is the master-wide reinterpret-to-Decimal/DateTime64-with-a-different-scale regression: the function-property stress test's scale validity gate was tightened in #108551 (merged 2026-06-29), which exposed a latent reinterpret bug returning a Const column of the source scale instead of a full column of the target scale. The fix is already in progress in #108878 ("Fix reinterpret to Decimal/DateTime64 with a different scale"), which is not yet merged into master, so the function_prop_fuzzer hits this on every PR until that lands. No code change is warranted here.

AI Review is ✅ Approve and the only open review thread is the intentional LowCardinality follow-up (clean NOT_IMPLEMENTED, pinned by 04404_obfuscate_unsupported_lowcardinality). Blocked only on #108878 landing in master (or a CI override of the unrelated fuzzer red) and the maintainer merge.

@alexey-milovidov

Copy link
Copy Markdown
Member

Merged the current master into this branch to clear a stale merge-conflict status.

GitHub was reporting this PR as CONFLICTING / DIRTY, which blocks merge, even though a local git merge-tree against the current master tip was clean. The stale status came from an intermediate master state where the 26.7 block of SettingsChangesHistory.cpp conflicted (another PR appended settings to the same block); master has since moved on, so the merge is now clean.

  • Merged origin/master (98ca33e5bd7) into obfuscate-function — clean, no manual conflict resolution needed (58cc809cf4f).
  • The seven obfuscate_* entries remain in the current top-most 26.7 block of SettingsChangesHistory.cpp, alongside master's new 26.7 entries.
  • Net diff vs master is unchanged (still exactly the obfuscate feature + integration points; no stray changes pulled in).
  • GitHub now recomputes the branch as MERGEABLE again (BLOCKED only because it is still a Draft).

The remaining CI red on the fresh pipeline is expected to be the master-wide Unit tests (asan_ubsan, function_prop_fuzzer) regression on reinterpret to DateTime64/Decimal with a different scale — unrelated to this PR (it touches no conversion code), tracked by the open fix #108878, which is not yet merged into master.

@clickhouse-gh

clickhouse-gh Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 85.50% 85.50% +0.00%
Functions 92.60% 92.60% +0.00%
Branches 77.60% 77.70% +0.10%

Changed lines: Changed C/C++ lines covered: 601/725 (82.90%) · Uncovered code

Full report · Diff report

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

Labels

pr-feature Pull request with new product feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Table function obfuscate

5 participants