Skip to content

Support INSERT ... VALUES in the polyglot SQL dialect - #110321

Open
alexey-milovidov wants to merge 73 commits into
masterfrom
insert-values-polyglot
Open

Support INSERT ... VALUES in the polyglot SQL dialect#110321
alexey-milovidov wants to merge 73 commits into
masterfrom
insert-values-polyglot

Conversation

@alexey-milovidov

@alexey-milovidov alexey-milovidov commented Jul 13, 2026

Copy link
Copy Markdown
Member

Enable INSERT ... VALUES with inline data in the polyglot SQL dialect (dialect = 'polyglot').

Previously, running e.g. INSERT INTO t VALUES (1), (2), (3) with dialect = 'polyglot' failed with Multi-statement queries are not supported in polyglot dialect mode. The underlying problem is that transpiling inside the parser cannot deliver inline data to the executor: the transpiled buffer is transient, and the executor overwrites ASTInsertQuery::tail with the external input stream, so the inline-data pointers (data/end) must reference a live query buffer.

This transpiles the query up front instead of inside the parser:

  • The server (executeQuery) transpiles a foreign-dialect query to ClickHouse SQL before parsing, keeps the transpiled text alive on the query context, and parses it with the standard parser. Inline INSERT data then points into a live buffer and is processed by the normal machinery. SET queries are still parsed as-is so dialect/polyglot_dialect can always be changed back.
  • The client (clickhouse-client/clickhouse-local) parses a non-ClickHouse-dialect query into an AST — which, for a foreign dialect, means transpiling it locally only to drive client-side handling (statement classification, output format, INSERT detection) — but then sends the original query text verbatim, without splitting off inline data. The server performs the authoritative transpilation whose result is actually executed, so inline INSERT data lives in a server-owned buffer and survives parsing. The client-side transpilation is throwaway; note this means the transpiler must also be available on the client (a client built without USE_POLYGLOT fails locally with SUPPORT_IS_DISABLED), and the client and server transpilers are assumed to agree — acceptable for this experimental dialect. Every parse-time setting the query was parsed under (dialect, allow_experimental_polyglot_dialect, polyglot_dialect, allow_settings_after_format_in_insert, implicit_select, and the parse limits max_query_size, max_parser_depth, max_parser_backtracks) is pinned in the per-query settings sent along with the verbatim text, so the query's own SETTINGS clause cannot change how the server reparses that same text (it still applies to the query's execution, and a SET still takes effect for subsequent queries).

All changes are gated on the dialect, so ordinary ClickHouse INSERTs are unaffected. Validated over the HTTP interface, the native client, and clickhouse-local (multi-row and single-row VALUES, INSERT ... SELECT, and PostgreSQL literal transpilation such as true/false); SET passthrough and multi-statement rejection are preserved. External insert data combined with a foreign-dialect INSERT is rejected with NOT_IMPLEMENTED instead of being silently dropped, on both surfaces: the client rejects piped stdin and INFILE (it sends the query verbatim and cannot forward a data tail), and the server rejects a non-empty HTTP request body appended to a streaming INSERT (POST /?query=INSERT ... &dialect=polyglot with a body). A foreign-dialect INSERT is transpiled as a whole, so the body would go through neither the transpiler nor the max_query_size guard, mixing two parsing rules in one INSERT. An empty body still works, which is the normal way to run a polyglot INSERT over HTTP.

Limitations (scoped, experimental): because a foreign-dialect query is transpiled as a whole (the transpiler rewrites the inline data too and cannot know where the SQL header ends without parsing the dialect), the inline INSERT ... VALUES data counts towards max_query_size — unlike a native ClickHouse INSERT, whose inline data is streamed and is not bounded by max_query_size. An oversized payload fails-close with a dedicated, actionable error rather than silently changing the INSERT size contract; increase max_query_size to submit larger inline payloads.

Only INSERT ... VALUES inline data is transpilable by the bundled dialects. INSERT ... FORMAT ... is not: FORMAT is a ClickHouse-only extension, so a foreign-dialect parser rejects the query at the inline data that follows (empirically, postgresql/mysql/sqlite/duckdb/snowflake/bigquery all fail at the first data row after FORMAT; a hypothetical identity transpiler even drops the raw FORMAT payload rather than re-emitting it). A foreign-dialect INSERT ... FORMAT therefore fails cleanly with a syntax error and inserts nothing — like EXPLAIN INSERT ... VALUES, which is also not transpilable by the bundled dialects (rejected at the VALUES token). The server-owned transpiled buffer that carries the inline data is itself format-agnostic and would handle FORMAT data if a transpiler ever produced such a query; the parser also defensively clears the inline-data pointers of an EXPLAIN-wrapped INSERT — the same way the client unwraps it — so both forms are safe if a future transpiler supports them.

Changelog category (leave one):

  • Experimental Feature

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

Support INSERT ... VALUES with inline data when using the experimental polyglot SQL dialect.

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

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

Inline INSERT data (`INSERT ... VALUES (...)` / `... FORMAT ...`) could not be
used with `dialect = 'polyglot'`: it failed with "Multi-statement queries are
not supported". The underlying problem is that transpiling inside the parser
cannot deliver inline data to the executor - the transpiled buffer is transient,
and the executor overwrites `ASTInsertQuery::tail` with the external input
stream, so the data pointers (`data`/`end`) must reference a live query buffer.

Transpile the query up front instead of inside the parser:

- The server (`executeQuery`) transpiles a foreign-dialect query to ClickHouse
  SQL before parsing, keeps the transpiled text alive on the query context, and
  parses it with the standard parser. Inline INSERT data then points into a live
  buffer and is processed by the normal machinery. SET queries are still parsed
  as-is so `dialect`/`polyglot_dialect` can always be changed back.
- The client (`clickhouse-client`/`clickhouse-local`) sends a query written in a
  non-ClickHouse dialect verbatim (without splitting off inline data) and lets the
  server transpile and read it, so transpilation happens exactly once, server-side.

All changes are gated on the dialect, so ordinary ClickHouse INSERTs are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clickhouse-gh

clickhouse-gh Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [5d7ad7f]

Summary:

job_name test_name status info comment
LLVM Coverage FAIL
Generate LLVM Coverage Diff Report FAIL cidb
Print Uncovered Code FAIL cidb Uncovered code analysis did not run: bash ci/jobs/scripts/generate_diff_coverage_report.sh failed (its output is on the Generate LLVM Coverage Diff Report result).
Parser memory check ERROR
Resolve master binary ERROR

AI Review

Summary

This PR adds verbatim handling for dialect = 'polyglot' INSERT ... VALUES, keeps the server-owned transpiled buffer alive for inline data, and aligns the HTTP/native client behavior around parse-time setting pinning and logging. I found one remaining correctness gap: the new verbatim input() path is still inconsistent in clickhouse-local, where the local input() initializer reparses the original foreign SQL with plain ParserQuery instead of the polyglot parser and can reject valid foreign-dialect queries before reading stdin.

Findings

⚠️ Majors

  • [src/Client/ClientBase.cpp:3124] The new verbatim input() contract does not hold in clickhouse-local. ClientBase now routes foreign-dialect INSERT ... SELECT * FROM input(...) into processInsertQuery, but LocalConnection reparses state->query with plain ParserQuery and never captures allow_experimental_polyglot_dialect / polyglot_dialect. A query the client already accepted under dialect = 'polyglot' therefore still fails locally as soon as the original text uses non-ClickHouse syntax, for example PostgreSQL x::Int32 in INSERT INTO t SELECT x::Int32 FROM input('x String'). Suggested fix: teach the local input() initializer to reparse with ParserPolyglotQuery under the captured parse-time polyglot settings, or reject this surface explicitly instead of silently supporting only the identity clickhouse source dialect.
Tests
  • ⚠️ Add a focused clickhouse-local regression for a non-identity polyglot input() query, for example PostgreSQL INSERT INTO t SELECT x::Int32 FROM input('x String') with --input-format TSV, so the local verbatim path is exercised outside polyglot_dialect = 'clickhouse'.
Final Verdict

⚠️ Changes requested.

LLVM Coverage Report

⚠️ No coverage measurement for commit 5d7ad7f: bash ci/jobs/scripts/generate_diff_coverage_report.sh failed (its output is on the Generate LLVM Coverage Diff Report result).

@clickhouse-gh clickhouse-gh Bot added the pr-experimental Experimental Feature label Jul 13, 2026
Comment thread src/Parsers/Polyglot/ParserPolyglotQuery.cpp
…cted cleanly

Extend `04512_polyglot_insert_values` to cover the case where a second
statement follows inline `INSERT ... VALUES` data in the polyglot dialect
(e.g. `INSERT INTO t VALUES (1); SELECT 2`). The whole remaining buffer is
transpiled at once and the transpiler rejects the multi-statement input, so
the query fails with a clean `SYNTAX_ERROR` rather than being silently
mis-executed or reaching the server as unread `Values` tail. The test also
asserts that no partial insert happens on the rejected path.

This documents the behavior flagged in the PR review, which was verified
not to reproduce as a silent regression.
Comment thread src/Client/ClientBase.cpp Outdated
alexey-milovidov and others added 2 commits July 15, 2026 02:04
Address review: on the verbatim polyglot path the client sends the query
text as-is and never forwards external data, so piped stdin rows were
silently dropped: \`printf '(2)\n' | clickhouse-client --dialect polyglot
... -q 'INSERT INTO t VALUES (1)'\` inserted only the inline row while in
the \`clickhouse\` dialect both rows are inserted. Now a foreign-dialect
\`INSERT\` (without \`SELECT\`) with data on stdin or \`INFILE\` fails with
\`NOT_IMPLEMENTED\` before anything is sent, matching the existing checks
for \`async_insert\` and inline-insert-data modes, which throw the same
way instead of losing data. Add a test case asserting the error and that
no partial insert happens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexey-milovidov

Copy link
Copy Markdown
Member Author

The Stress test (arm_release) failure — Logical error: 'Block structure mismatch in JoinStep: [__table3.number, __table3.number] and [__table3.number] stream: different number of columns' (STID: 2228-453d) — is unrelated to this PR, which only touches dialect transpilation and the client insert path. The same STID has failed on many unrelated PRs over the last 30 days (e.g. #96225, #107650, #107567, #108096).

Tracked in #109215, and a fix is already in progress: #109114.

Comment thread src/Interpreters/executeQuery.cpp Outdated
…t dialect

`executeQueryImpl` reassigned `begin`/`end` to point at the transpiled ClickHouse
SQL when parsing a `polyglot`-dialect query, and those same local variables were
then used further down to build `query`/`query_for_logging`/`normalized_query_hash`.
As a result, `system.query_log`/processlist showed the transpiled SQL instead of
what the user actually submitted, and per-`normalized_query_hash` quotas grouped
by the transpiled form. Additionally, when an `ASTInsertQuery::data` pointer (into
the transpiled buffer) was used to cut the logged text short, it was sliced against
the original (untranspiled) `begin`, mixing pointers from two unrelated buffers.

Keep the transpile-and-reparse step local to the polyglot branch instead of
reassigning the outer `begin`/`end`, and only use `insert_query->data` to shorten
the logged query when it actually falls within `[begin, end)`. Added a regression
test asserting `system.query_log.query` for a polyglot `INSERT` matches the
original text.

Addresses review feedback on #110321
@alexey-milovidov

Copy link
Copy Markdown
Member Author

The Stateless tests (amd_asan_ubsan, distributed plan, parallel) failure (hundreds of unrelated tests failing with Code: 241. DB::Exception: (total) memory limit exceeded, e.g. 00033_aggregate_key_string, 01079_bad_alters_zookeeper_long, 02835_drop_user_during_session) is unrelated to this PR. This job is currently flaky/red on master itself (confirmed on several recent MasterCI runs, e.g. run 29466889572) due to the sanitizer memory-ratio change in #110293 backfiring under --distributed-plan's per-query memory multiplication. A fix is already in progress: #110574 (re-applies the 0.7 ratio and cuts this job's concurrency). No action needed here; this will clear once #110574 merges into master and this branch picks it up.

The style check requires `SYSTEM FLUSH LOGS log_name` instead of the
global `SYSTEM FLUSH LOGS`, and the test only reads `system.query_log`.
Comment thread src/Interpreters/executeQuery.cpp Outdated
Comment thread src/Parsers/Polyglot/ParserPolyglotQuery.cpp
alexey-milovidov and others added 3 commits July 17, 2026 03:56
For a `polyglot`-dialect inline `INSERT`, the parsed `ASTInsertQuery::data`
pointer references the transpiled buffer owned by the query context, not the
original query text `[begin, end)`. The previous logging truncation only cut
the query short when `data` fell inside `[begin, end)`, so for polyglot inline
`INSERT`s it never fired and the full `VALUES`/`FORMAT` payload was written to
`system.query_log`, the process list, and `normalized_query_hash`, breaking the
"`INSERT` logs omit inserted data" contract and potentially leaking row values.

The inline-data boundary cannot be mapped back onto the original text because
transpilation rewrites the query, so log the transpiled header up to the data
instead: it carries the `INSERT` target and column list but no row values, and
reflects what was actually executed. Non-`INSERT` polyglot queries keep logging
the original text as before.

Updated `04512_polyglot_insert_values` to assert the inline data is absent from
`system.query_log` instead of asserting the (leaky) full original text.

Addresses review feedback on #110321

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In the multi-query path, `ClientBase` computed
`insert->data - query_to_execute.data()` unconditionally for every
`ASTInsertQuery`. For a foreign-dialect (`polyglot`) `INSERT`, the polyglot
parser clears `insert->data` (the query is sent verbatim and the server reads
the data from its own transpiled buffer), so this became `nullptr - ptr`, which
is undefined behavior. The computed length was already unused on the verbatim
path (guarded by a later `insert && insert->data && !send_query_verbatim`
check), so guard the subtraction on `insert->data` being non-null.

Addresses review feedback on #110321

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/Client/ClientBase.cpp Outdated
Address review (clickhouse-gh AI verdict): the PR contract claimed
"transpilation happens exactly once, on the server", but the native client
(`clickhouse-client`/`clickhouse-local`) is fundamentally AST-driven, so for a
foreign dialect `ClientBase::parseQuery` transpiles the query locally via
`ParserPolyglotQuery` to obtain an AST for client-side handling (statement
classification, output format, INSERT detection). That transpiled text is
thrown away; the client sends the *original* query verbatim and the server
performs the authoritative transpilation whose result is actually executed.

No behavior change: this only makes the comments (and the PR description)
accurate about the two-stage transpilation and its accepted limitations for
this experimental dialect — the client requires the transpiler to be built in
(`USE_POLYGLOT`, else `SUPPORT_IS_DISABLED`), and the client and server
transpilers are assumed to agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/Parsers/Polyglot/ParserPolyglotQuery.cpp
Comment thread src/Parsers/Polyglot/ParserPolyglotQuery.cpp Outdated
alexey-milovidov and others added 5 commits July 17, 2026 12:55
Address review: `ParserPolyglotQuery::parseImpl` only cleared the dangling
`data`/`end` pointers of a top-level `ASTInsertQuery`. The client unwraps an
`ASTExplainQuery` and dereferences the nested `ASTInsertQuery::data` the same
way when it locates the inline-data boundary
(`ClientBase::analyzeMultiQueryText`), so an `EXPLAIN INSERT ... VALUES` in
polyglot mode would leave the nested insert's `data`/`end` pointing into the
transient `transpiled` string that is freed on return - a use-after-free in
`-n`/script mode.

Introduce a `findInlineDataInsert` helper that unwraps a single `EXPLAIN`
layer (mirroring the client) and clear the pointers of the explained `INSERT`
too. This is a defensive correctness fix: no bundled transpiler dialect
currently transpiles `EXPLAIN INSERT ... VALUES` (they reject it at the
`VALUES` token), so the path is not yet reachable, but the fix future-proofs
it against a transpiler that supports the form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review: a foreign-dialect `INSERT ... VALUES`/`FORMAT` is transpiled as
a whole, so its inline data counts towards `max_query_size` - unlike a native
ClickHouse `INSERT`, whose inline data is streamed and is not bounded by
`max_query_size`. This explicitly scopes the experimental feature to inline
payloads that fit the parser size limit. The oversized case now fails-close
with a dedicated, actionable error (added in the previous commit in
`transpilePolyglotToClickHouse`) instead of silently changing the `INSERT` size
contract; document the limitation on the `allow_experimental_polyglot_dialect`
setting so users can discover it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend `04512_polyglot_insert_values.sh`:
- `EXPLAIN SELECT` transpiles and runs (exercises the new `EXPLAIN`-unwrapping
  helper, which returns no inline-data `INSERT` for a non-insert).
- `EXPLAIN INSERT ... VALUES` is rejected cleanly by the transpiler (no bundled
  dialect transpiles it) with no partial insert and no use-after-free.
- An inline `INSERT` payload larger than `max_query_size` is rejected with the
  dedicated error and inserts nothing.

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

The client parses scripts with allow_multi_statements enabled, which zeroes
the generic per-query length limit, so `ParserPolyglotQuery` was constructed
with `max_query_size = 0` and the oversized-query guard never ran on the
client: an oversized polyglot inline `INSERT` in `--multiquery` mode was
fully transpiled locally before the server rejected it.

Construct the polyglot classifier with the real `max_query_size` (it always
consumes the whole remaining buffer as a single query sent verbatim, so the
per-query limit applies in every mode), and run the transpile - whose size
guard rejects oversized input up front - before touching the token stream.
Also stop the token-advance loop at `ErrorMaxQuerySizeExceeded`: the lexer
emits it on every call once a size-capped stream passes its limit (never
reaching `EndOfStream`), so iterating to the end could not terminate.

Add a --multiquery regression proving the oversized query is rejected on
the client, without a server round trip: its query_id must not appear in
`system.query_log` (a server-side rejection would be recorded there as
`ExceptionBeforeStart`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/Interpreters/Context.h Outdated
`transpiled_query` was stored as a plain `String` inside `ContextData`,
and `Context::createCopy` performs a deep copy of it. Insert paths such as
`AsynchronousInsertQueue::preprocessInsertQuery`, `StorageDistributed`
distributed writes, and `DistributedSink` clone the query context, so a
large polyglot inline `INSERT` payload could be duplicated several extra
times in memory. Hold the buffer behind `std::shared_ptr<const String>`
so context copies share it instead of cloning it; this also keeps the
buffer alive independently of which context outlives which.

Addresses the AI review finding on the PR.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

The Stress test (amd_tsan) failure — Logical error: Cannot convert nested result of function JSON_EXISTS with type UInt8 to the expected result type Tuple(UInt8, UInt8) (STID: 4811-66fc), from test 04325_sql_json_functions_with_dynamic_input.sql — is unrelated to this PR, which only touches dialect transpilation and the client insert path. The same STID has failed on many unrelated PRs and on master over the last week (e.g. #100391, #110171, #110633, #110530, #109347).

It is a master-only regression from #106877, reported in #110345, and a fix is already in progress: #109944.

The Stress test (arm_asan_ubsan, s3) hung check is the known issue #107941 (already labeled in the CI report).

…t_values`

The case set `Transfer-Encoding: chunked` as a plain header while the body
still came from `--data-binary @-`, so `curl` only relabelled a request whose
body it sent unencoded. `curl` 7.81 (the version on the CI runners) then never
emitted the terminating chunk, the server waited for a body that never arrived,
and the request died on the 60-second `--max-time`, failing the test in every
stateless job. Uploading from stdin with `-T -` makes `curl` chunk-encode the
request itself, which is what the case means to exercise.

Reproduced with `curl` 7.81 against a local server: the old form hangs and the
new one completes with `200 OK`.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 @groeneai, investigate the failure: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=110321&sha=3693dc23ea2791dd76168000106548b5ecf14f20&name_0=PR&name_1=Integration%20tests%20%28amd_tsan%2C%202%2F6%29 and provide a fix in a separate PR. If the fix is already in progress, link it here.

Every test_storage_nats/test_nats_jet_stream.py case in that shard died on a ThreadSanitizer data race in contrib/nats-io/src/adapters/libuv.h:392 in natsLibuv_Attach (the shutdown then reports Sanitizer assert found for instance), which has nothing to do with this pull request (polyglot INSERT ... VALUES). I could not find an open issue tracking it, so it probably needs one too.

The stateless reds were ours and are fixed in 253641d87a49: the chunked deferred 100 Continue case of 04512_polyglot_insert_values labelled the request Transfer-Encoding: chunked by hand while curl still sent the body unencoded, so curl 7.81 on the runners never emitted the terminating chunk and the server waited out the 60-second --max-time. The case now uploads from stdin with -T -, which makes curl chunk-encode the request for real (reproduced both ways locally against curl 7.81).

Comment thread src/Client/ClientBase.cpp Outdated
@groeneai

Copy link
Copy Markdown
Collaborator

The Integration tests (amd_tsan, 2/6) red is a defect in the vendored nats-io libuv adapter, not in your change. In the run you linked, the broker-restart case kills the server with SUMMARY: ThreadSanitizer: data race contrib/nats-io/src/adapters/libuv.h:392:21 in natsLibuv_Attach, and the other 12 cases are the teardown cascade off the dead instance. It is a different failure from the resume-after-reconnect assertion your #115343 fixes; that one carries no sanitizer report.

The fix is already in progress: ClickHouse/nats.c#5

The reported race is a symptom of a socket-close ordering violation, which is why the fix is not a lock. _evStopPolling only queues the READ/WRITE poll removals before starting the reconnect thread; that thread's natsLibuv_Attach installs the new fd into nle->socket (libuv.h:392), and when the loop thread later drains the older READ removal, uvPollUpdate sees events == 0 and calls natsConnection_ProcessCloseEvent(&nle->socket) (libuv.h:202) on the fd just installed. The #2 uvAsyncCb frame in the report is what shows the removal was already queued. A mutex around the write would silence ThreadSanitizer and leave the close in place, so the PR makes the poll state loop-thread-owned instead: the socket travels inside the ATTACH event and is assigned in uvAsyncAttach, and natsLibuv_Attach gains the nle->head != NULL queue-jump guard that the three sibling callbacks already have (libuv.h:434, :460, :486) and it alone was missing. A harness linking the real header against the real contrib/libuv under ThreadSanitizer and injecting the interleaving closes the newly installed fd in 25 of 25 runs before the change and 0 of 25 after.

It is a submodule PR against ClickHouse/v3.9.2, the branch the ClickHouse pin follows, so the gitlink bump here can only follow its merge. It has had no review since 2026-08-18.

On the tracking issue: I treated nats.c#5 as the tracking record, which is why nothing was filed here. Both defects are verbatim in current nats-io/nats.c main (write at :407, guard missing at :410 against :453, :479, :505), so an upstream report is warranted too. I need your go-ahead before opening issues, so tell me which you want and I will open it: a tracking issue here, an upstream nats-io/nats.c issue with the reproducer, or both.

Frequency of the signature, for triage: three runs across three pull requests since 2026-08-17, none on master.

… data

The client rejected external data (stdin or `INFILE`) for every verbatim
foreign-dialect `INSERT` that is not an `INSERT ... SELECT`. That is too broad:
when the transpiled statement carries no inline data at all — e.g.
`INSERT INTO t FORMAT TSV` with `polyglot_dialect = 'clickhouse'` — the server
transpilation produces no inline data either, so the data can travel in the
ordinary data packets exactly as for a native `INSERT`. Before this the client
threw `NOT_IMPLEMENTED` while the HTTP path accepted the same shape, because
`executeQuery` rejects external data only when `hasTranspiledInlineData` is true.

`ParserPolyglotQuery` now records `ASTInsertQuery::inline_data_owned_by_transpiled_query`
when it clears the inline-data pointers that referenced its transient transpiled
buffer, and the client consults that flag: such an `INSERT` still rejects external
data, while an `INSERT` without inline data goes through `processInsertQuery` and
streams stdin/`INFILE`. The old-server query-parameter substitution guard is
narrowed the same way (an `INSERT` without inline data serializes losslessly).

Addresses the review comment: #110321 (comment)
`04512_polyglot_insert_values` grew to 45 `clickhouse-client` invocations plus 8 `curl`
calls, and process startup dominates its wall time. On a sanitizer build with s3 storage and
metadata in Keeper it takes 150-172 s per run against `TEST_MAX_RUN_TIME_IN_SECONDS = 180`,
so a single slow run trips "Test runs too long" - one did, at 181 s, in a flaky check that
runs several copies at once.

Move the HTTP interface, the pinning of parse-time settings for the server-side reparse and the
streaming of external data into `05054_polyglot_insert_values_http`, which starts from its own
empty table. Every case and every assertion is kept; the expected running totals of the moved
half are shifted down by the rows the client-side half had inserted.
`url_query_starts_with_insert` skipped a CTE body only as a parenthesized
subquery or a single name, so `WITH cte AS MATERIALIZED (SELECT 1) INSERT ...`
fell back to the legacy path that concatenates the request body onto the URL
query, letting an HTTP body bypass the `NOT_IMPLEMENTED` rejection of external
data for a polyglot `INSERT` with inline data. Consume the optional
`MATERIALIZED` keyword (see `ParserWithElement`) before skipping the subquery;
a bare `MATERIALIZED` not followed by a parenthesis is still an ordinary alias
name, and such a query keeps the legacy body-concatenation path. Both shapes
are covered in 05054_polyglot_insert_values_http.

Addresses #110321 (comment)
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 Two CI failures on 2def28ac1fbd are unrelated to this PR:

  • Upgrade check (amd_release): the previous release cannot start with the test config — Unknown elements 'enable_silk_runtime', 'silk' found in config (report). The fix is already in progress: Version-gate the silk test config so the upgrade check can start the previous release #117172.
  • Stateless tests (amd_tsan, flaky check): setup failure before any test ran — prepare_stateful_data timed out (exit 124) on INSERT INTO test.hits_s3 SELECT * FROM test.hits (report). @groeneai, investigate the failure and provide a fix in a separate PR. If the fix is already in progress, link it here.

Comment thread src/Client/ClientBase.cpp
@groeneai

Copy link
Copy Markdown
Collaborator

The exit 124 is the per-statement bound I added in #115585 (merged 2026-08-28) firing, so the reporting half of this is already fixed and merged. Before it, this failure burned the full 9000 s job budget, named no statement, and uploaded no server logs. Now the job fails at 4365 s, names the statement, and the logs survive. The stall it exposed is a real server-side hang, and that part has no fix PR yet. I am investigating it and will post the separate PR here.

What the preserved logs show: INSERT INTO test.hits_s3 SELECT * FROM test.hits (query 22411ddf-da0f-4f93-90b6-c4a06af00f93) starts 17:42:48Z and writes normally for 35 s (last Wrote block with 294912 rows, total memory 8.00 GiB). Then the server stops logging at 17:43:23Z and emits nothing at all for the next 62 minutes, until the job stops it. That is not upload truncation: the file ends on a complete line at 3.4 MB, while siblings at this same commit uploaded 10.0 MB and 59.7 MB logs covering their whole run.

Two details narrow it:

  • system.metric_log and system.query_metric_log are the only 2 of 18 system logs with a Flushing system log and no matching Flushed system log. The last line in the entire log is that unfinished query_metric_log flush.
  • The statement carried --max_execution_time 1800, and no TIMEOUT_EXCEEDED was raised at 1800 s. After timeout sent TERM at exactly 3150 s, system flush logs and SYSTEM FLUSH ASYNC INSERT QUEUE each returned Code: 209 ... SOCKET_TIMEOUT. So the whole server is stuck, not just one slow insert.

The bound is not too tight either. Same commit, same tsan build, same MAX_INSERT_THREADS=4: that statement plus the rest of the prep took 133 s in Stateless tests (amd_tsan, parallel, selected tests) and 277 s in Stateless tests (amd_tsan, sequential, selected tests), against over 3150 s here.

@groeneai

Copy link
Copy Markdown
Collaborator

Separate PR is up: #117294

It does not fix the hang. It fixes what the job throws away when the hang happens.
functional_tests.py derives "did this run fail" from test_result alone, which a setup
failure leaves None, so prepare_logs ran with all=False and discarded the cores,
system-table dumps, object-storage log, dmesg and Keeper logs for exactly the runs that have
no test results to explain themselves. It also captures an external all-thread backtrace when
a prep statement hits its bound, because for this failure both in-process routes are dead: the
SIGTRAP in stop_server produced no log output at all, and SYSTEM FLUSH LOGS returned
Code: 209 SOCKET_TIMEOUT, so system.stack_trace was unreachable.

Why I stopped short of the hang itself: naming the mechanism needs a thread stack, and none of
the seven occurrences in 90 days produced one, or a core, or a fatal.log. What the logs do
establish is that the server logged normally for 35.4 s of the insert, then emitted nothing for
62.0 minutes from any of the 186 threads that had ever logged, never raised
max_execution_time, answered no new query, and ignored SIGTERM for 300 s. I also tried
bracketing the fixture load with SYSTEM STOP THREAD FUZZER and withdrew it after measuring:
the fuzzer accounts for 1.76x against a failure of more than 23.7x, its pthread wrappers are
compiled out under TSan, and perturbing the timing of the thing being diagnosed could have made
the only occurrences available disappear.

The client-side classifying parse of a `polyglot` query went through
`tryParseQuery`, which does a lexer preflight on the original buffer
(the invalid-token shortcut) and an unmatched-parentheses check over
it. Both operate on the *foreign* SQL, which is not expected to be
lexically valid ClickHouse SQL, so `clickhouse-client` and
`clickhouse-local` rejected statements that the server - which
transpiles before parsing - executes fine. Example: BigQuery spells a
single-line comment `#comment`, while the ClickHouse lexer only treats
`#` as a comment when followed by a space or `!`.

Add `IParser::consumesForeignText`, which `ParserPolyglotQuery`
overrides, and skip both original-buffer lexical checks in
`tryParseQuery` for such a parser. The text is validated by parsing the
*transpiled* ClickHouse SQL instead, exactly like on the server.

Test: `05055_polyglot_lexically_foreign_query`.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 CI status on 42e6fb428c33, and what the new tip does about it:

@groeneai, investigate the failure: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=110321&sha=42e6fb428c3380e4904795ba4e6136c94dc0a23c&name_0=PR&name_1=Stress%20test%20%28amd_debug%29 and provide a fix in a separate PR. If the fix is already in progress, link it here.

Comment thread src/Client/ClientBase.cpp
/// after transpiling it — asks for the external data exactly as it does for a native INSERT, so the
/// client streams stdin or INFILE as usual. Only an INSERT whose data lives inline in the transpiled
/// query has no room left for a data stream (it was rejected above if any was supplied).
if (insert && (!insert->select || input_function) && (!is_async_insert_with_inlined_data || input_function) && !is_inline_insert_data

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 still breaks the new verbatim input() path in clickhouse-local. processParsedSingleQuery now deliberately routes a foreign-dialect INSERT ... SELECT * FROM input(...) into processInsertQuery, but LocalConnection reparses state->query inside its input() initializer with plain ParserQuery and does not capture allow_experimental_polyglot_dialect / polyglot_dialect at all (src/Client/LocalConnection.cpp:231-246, src/Client/LocalConnection.cpp:340-356). So as soon as the original query uses any non-ClickHouse syntax, the local path rejects it before stdin is read even though the initial client-side ParserPolyglotQuery already accepted it.

A concrete shape is PostgreSQL INSERT INTO t SELECT x::Int32 FROM input('x String') with --input-format TSV: the main path transpiles x::Int32, but the local input() setup reparses the original text and fails on ::. If clickhouse-local is meant to support the same verbatim polyglot input() contract as the native client, the input() initializer needs the same polyglot parser branch/settings as ClientBase::parseQuery; otherwise this path should fail close for dialect = 'polyglot' instead of only working for the identity clickhouse source dialect. Please add a focused clickhouse-local regression once the local path is fixed.

@groeneai

groeneai commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Investigated. No fix is in flight for this shape, so I am opening a separate PR.

Two things up front: the failure is unrelated to this PR, and the master merge in 208627110de4 will not clear it.

The aborting query, from fatal.log of that run (query id f6df1847-e26c-4e96-b187-149e8f8155ee, analyzer aliases stripped for readability):

SELECT a, b, sum(b) FROM test_1.t_grouping_sets_force
GROUP BY GROUPING SETS ((), (b), (a)) ORDER BY a ASC, b ASC
UNION DISTINCT
SELECT a, b, sumOrNull(b) FROM test_1.t_grouping_sets_force
GROUP BY GROUPING SETS ((a), (b)) ORDER BY a ASC, b ASC WITH FILL FROM -26 TO 14

It is a mutated variant of the 03892_grouping_sets_force_aggregation_in_order fixture.

I reproduced it on master 88dd163466bc5e2 (debug), with a byte-identical stack (ColumnNullable.cpp:505 -> SortCursor.h:776/813 -> DistinctSortedStreamTransform.cpp:184):

CREATE TABLE t (a Int32, b Int32) ENGINE = MergeTree ORDER BY a;
INSERT INTO t VALUES (1, 2), (3, 4), (1, 5);
SET enable_analyzer = 1, group_by_use_nulls = 1;

SELECT a, b FROM t GROUP BY GROUPING SETS ((a), (b)) ORDER BY a ASC, b ASC
UNION DISTINCT
SELECT a, b FROM t GROUP BY GROUPING SETS ((a), (b)) ORDER BY a ASC, b ASC WITH FILL FROM -26 TO 14;

That commit already contains #113242, which is the merged fix this family was previously attributed to, so #113242 does not cover this one. Nothing between 88dd163466bc5e2 and today's tip 84ac4b28254a1f0c touches FillingStep, applyOrder, DistinctStep, SortCursor.h or ColumnNullable, so no merged fix covers it as of today.

Root cause: FillingStep::getTraits declares preserves_sorting = true (FillingStep.cpp:24). For UNION DISTINCT the planner adds a per-branch preliminary DistinctStep (Planner.cpp:2408); applyOrder then propagates the Sorting step's sort description straight through the Filling step into it (applyOrder.cpp:81, "Preliminary Distinct also does not break stream order"), and the resulting DistinctSortedStreamTransform binary-searches a stream whose order FillingTransform has perturbed. EXPLAIN PIPELINE shows that branch's DistinctSortedStreamTransform sitting directly on FillingTransform.

Every ingredient is load-bearing. On the same binary, the abort goes away if any one of these is changed: drop WITH FILL, or use it without explicit FROM/TO bounds; optimize_distinct_in_order = 0; group_by_use_nulls = 0; drop UNION DISTINCT (a single SELECT DISTINCT branch over the same fill does not abort); drop GROUPING SETS.

One search note, since you looked for an issue: this assertion message is bucketed across roughly 15 STIDs, so a single-STID query under-reports it. 2508-4384 itself has only 2 hits in 90 days, both on PRs, none on master.

I will post the PR link here.

@groeneai

groeneai commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Separate PR is up: #117832

Root cause of STID 2508-4384: FillingRow compares fill values as Fields, where a NULL is
unordered, while the ORDER BY positions it through nulls_direction. All three sites in
FillingTransform that place a pending generated row therefore read "unordered" around a NULL
fill key, and the row is emitted after the NULL, emitted twice, or dropped. DISTINCT in order
then sees the non-contiguous duplicate and raises the exception; in a release build it is returned
as wrong results, with no DISTINCT and no UNION needed:

SELECT number % 2 ? NULL : toNullable(toInt32(number)) AS x
FROM numbers(2) ORDER BY x ASC WITH FILL FROM 1 TO 3;
-- before: 0, 2, \N, 2   (FROM row dropped, 2 repeated after the NULL)
-- after:  0, 1, 2, \N

Unrelated to this PR, as expected, and the master merge in 208627110de4 does not clear it.

Scoped to a single NULLS LAST fill key whose placement is decidable; two or more fill keys,
NULLS FIRST and STALENESS stay as they are on master and are listed in the description.

pull Bot pushed a commit to Lobinson1/ClickHouse that referenced this pull request Sep 3, 2026
`functional_tests.py` assigns `test_result` only inside the test stage, so a
setup failure leaves it `None`. `test_run_failed` is derived from it alone, reads
`False`, and `prepare_logs` runs with `all=False`: core dumps, system-table dumps,
the object-storage service log, dmesg and the Keeper logs are discarded for
exactly the runs that have no test results to explain themselves with.

Found while investigating a `Stateless tests (amd_tsan, flaky check)` failure on
2def28a, where `prepare_stateful_data` hit its per-statement bound on
`INSERT INTO test.hits_s3 SELECT * FROM test.hits`. That job's `result.json` has
three children (`Install ClickHouse`, `Start ClickHouse Server`, `Collect logs`,
`Failures: 1/3`), and its `job.log` records `send TRAP signal to generate core
file` and `Collect logs` in the same second: the harness asked the kernel for a
core with the core, system-table, object-storage-log, dmesg and coordination
branches all switched off. Whether a core survived the ten-second wait that
follows the `SIGTRAP` cannot be told from what was uploaded, which is itself the
problem. Of the 403s an earlier investigation saw, `dmesg.log` is the one fully
explained by this defect. In CIDB over 30 days that three-child shape covers 58
job-level rows of `Stateless tests (...)` lanes, across 28 commits and 19 of those
lanes, at least 45 of them in lanes that do collect logs.

Four changes:

- Capture an external all-thread backtrace when a prep statement hits its bound.
  Both in-process routes are dead for the case that needs them: the `SIGTRAP` in
  `stop_server` produced no log output at all, and `SYSTEM FLUSH LOGS` returned
  `Code: 209 SOCKET_TIMEOUT`, so `system.stack_trace` is unreachable. Meanwhile
  the wedged server stayed alive and unexamined for ten minutes between the prep
  returning and teardown starting. `STACK_CAPTURE_TIMEOUT_S` is 300s, derived
  from measurement rather than guessed: on the failing job's own TSan binary
  (build id 66b21ff2c033) a capture took 16.7s idle over 203 threads, 20.5s and
  20.6s loaded over 347 threads, and 13.5s for the literal command emitted here
  over 381 threads, resolving 1874 `DB::` frames of which 1280 carried
  `at ./src/...` file and line. `tests/docker_scripts/stress_tests.lib` allows
  1800s for the identical command, so 300s is the conservative end. Two
  consecutive attaches during an insert left the server serving and the insert
  completed, so the capture does not disturb what it inspects.

- Skip that capture on ASan builds, where attaching a debugger disables
  LeakSanitizer. `tests/clickhouse-test` already applies this policy in
  `print_c_stacktraces` and its message is reused verbatim so the same wording
  appears in `job.log`. The test matches `"ubsan"` as well as `"asan"` so that a
  build name carrying `ubsan` without `asan` (which this repository's defs do not
  produce, though `functional_tests.py:55` and
  `src/Core/tests/gtest_protocol_packet_to_string.cpp:23` both refer to private
  `amd_ubsan` and `arm_ubsan` lanes) is skipped rather than captured. No build
  type in the tree changes behaviour, since both in-tree `ubsan` names already
  contain `asan`. `arm_fuzzers` is also built with
  `SANITIZE=address` and matches neither, but it is consumed only by
  `libFuzzer tests`, which never runs `functional_tests.py`; a branch for it would
  be dead code.

- Require `timeout`'s own TERM diagnostic before treating exit 124 as an expiry.
  124 is also the ClickHouse error code `INCORRECT_ELEMENT_OF_SET`, which the
  client returns from `main`, so the code alone does not distinguish a bound
  firing from a server exception. This mirrors the existing treatment of 137. The
  needle deliberately contains no quotes: the real diagnostic quotes the command
  name with U+2018/U+2019 under a UTF-8 locale, so a needle written with ASCII
  quotes would silently never match. The predicate reads the whole prep log rather
  than the fifteen-line tail the error message uses. `Shell.run` streams stdout and
  stderr into one file, so the diagnostic and the ERR trap are the last lines
  written and a tail normally holds them, as it did here; reading the whole log
  removes the dependence on that ordering. The tail itself is sliced with
  `io.StringIO(text).readlines()`, which is exactly what the previous
  `f.readlines()` produced, rather than `str.splitlines`, which also breaks on
  `\x0b`, `\x0c`, `\x1c`-`\x1e`, U+0085 and U+2028 and would have altered the
  recorded message.

- Produce `dmesg.log` in the full-collection path. Its only other producer is the
  OOM check, which needs test results to grade, so a setup failure never wrote
  the file that the collection list then looked for. Where that check has already
  run, its graded file is left untouched and the current buffer is written beside
  it as `dmesg.at-collect.log`, because `start` clears the ring buffer for each
  server generation: declining to capture would upload an earlier generation's
  buffer, and overwriting would leave the graded `OOM in dmesg` row disagreeing
  with the uploaded bytes. A setup failure that never reached `start` gets an
  uncleared buffer, so its dump can include lines from earlier on the host;
  nothing grades it, because the fatal scan needs test results. A `dmesg` that
  fails leaves an empty file behind, because the shell creates the redirect
  target first, so that file is removed and a warning printed rather than
  uploaded as an empty dump that reads as "no OOM".

The capture is written under `log_dir` as `stateful-prep-stacks-<pid>.log`, which
is uploaded on every collection and matches none of the `clickhouse-server*.log`,
`clickhouse-server*.err.log` or `stderr*.log` globs that
`check_fatal_messages_in_logs` greps for `<Fatal>` and sanitizer text, so a
backtrace cannot become a false blocker. `gdb` exits 0 even when an attach is
refused, so its stderr is redirected into the same file and the artifact explains
its own emptiness; `Shell.check` cannot raise, so a missing or unattachable `gdb`
leaves the job's outcome unchanged.

This does not fix the hang underneath the reported failure. That server logged
normally for 35.4s of the insert, then emitted nothing for 62.0 minutes from any
of the 186 threads that had ever logged, never raised `max_execution_time`,
answered no new query, and ignored SIGTERM for 300s. Naming the mechanism needs a
thread stack, and none of the seven occurrences in 90 days produced one, or a core,
or a `fatal.log`. An earlier revision of this work instead bracketed the fixture
load with `SYSTEM STOP/START THREAD FUZZER`; that was withdrawn after measurement,
because the fuzzer accounts for 1.76x against a failure of more than 23.7x, its
pthread wrappers are compiled out under TSan, and five arms including one at five
times the CI fuzzer strength all completed with continuous logging. Perturbing the
timing of the thing being diagnosed could also have made the only occurrences
available disappear.

Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=110321&sha=2def28ac1fbdc8166d4e6c37c00ab830b4248148&name_0=PR&name_1=Stateless%20tests%20%28amd_tsan%2C%20flaky%20check%29
Related: ClickHouse#110321

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

groeneai commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Separate PR is up: #117997

This reverses what I said here on 2026-08-31. I reported then that I had tried bracketing the
fixture load with SYSTEM STOP THREAD FUZZER and withdrew it. All three reasons I gave were
wrong or have since expired:

  • The 1.76x I quoted was measured on my own machine, where the wedge never reproduced at all
    (3 arms, worst log silence 1 s against CI's ~62 minutes). Read from CI's own job logs at one
    commit, the fuzzed amd_tsan, flaky check never finishes
    INSERT INTO test.hits_s3 SELECT * FROM test.hits, while the unfuzzed amd_tsan lane
    finishes it in 217 s on the same instance type, with the same max_insert_threads=4 and the
    same test.hits row count. The other four flaky lanes pay 7.8x to 33x.
  • I said the pthread wrappers are compiled out under TSan. They are, and that is exactly why
    the fuzzer looked inert to me. The live mechanism is the SIGPROF timer the ThreadFuzzer
    constructor arms, which sits outside every sanitizer guard: each delivery takes one of TSan's
    255 thread slots before dispatch, and one that sleeps in the handler re-attaches one. In the
    wedged job's thread dump 576 of 684 threads are in SlotLock, and none are in ClickHouse code.
  • I did not want to perturb the only occurrences before they could be diagnosed. The diagnostics
    PR Collect the full diagnostics when a stateless job fails during setup #117294 merged on 2026-09-03, so those runs now keep their cores and thread dumps.

The change stops the fuzzer across prepare_stateful_data and starts it again after, in the
flaky check only, which is what tests/docker_scripts/stress_runner.sh already does before
loading that table. I still do not claim the wedge itself is fixed: it never reproduced locally,
so the attribution rests on the lane comparison rather than on a local A/B.

@clickhouse-gh clickhouse-gh Bot added the comp-sql-syntax SQL/grammar parsing, AST nodes, syntax-level features. label Sep 4, 2026
pull Bot pushed a commit to Haofei/ClickHouse that referenced this pull request Sep 13, 2026
The comment I added in 0d48000, and that commit's own message, claimed
the flaky check is the only lane that arms `ThreadFuzzer`. That is wrong
repo-wide, and 0d48000 refutes itself further down by describing the
stress runner doing the same thing: `tests/docker_scripts/stress_runner.sh` arms
the fuzzer unconditionally at :21-23 and stops it at :96, and
`tests/integration/helpers/cluster.py` arms it per instance on request
(`DEFAULT_THREAD_FUZZER_SETTINGS` at :113, applied at :640 under
`enable_thread_fuzzer`).

The narrower claim is true, and it is the one that justifies the gate: of the
lanes this job runs, only the flaky check arms the fuzzer, because
`enable_thread_fuzzer_config` is appended only under `if is_flaky_check:`
(`ci/jobs/functional_tests.py:985-986`) and that is its sole caller. The comment
now states that.

I also reformatted the `prepare_stateful_data` signature to `black` output, so
the parameter added by 0d48000 does not leave a formatting deviation
inside this change.

Report for the failure this addresses:
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=110321&sha=2def28ac1fbdc8166d4e6c37c00ab830b4248148&name_0=PR&name_1=Stateless%20tests%20%28amd_tsan%2C%20flaky%20check%29
ClickHouse#110321
@groeneai

Copy link
Copy Markdown
Collaborator

The Integration tests (amd_tsan, 2/6) failure you asked about is fixed on master's side by
#119867. With ClickHouse/nats.c#5 and #6 merged, that
PR is only the gitlink: contrib/nats-io moves to the head of ClickHouse/v3.9.2 (cb4edba96601),
a fast-forward of four commits.

Worth knowing before you pick a merge order: bumping only to 3e3a3f10 does not fix this defect. On
a standalone ThreadSanitizer harness that links the real adapter against the real contrib/libuv,
the ordering defect reproduces 25 of 25 runs at 3e3a3f10 and 0 of 25 at the branch head, so the pin
has to be the head to carry your commit and both merged fixes. #119853's pin is an ancestor of
cb4edba9, so the one-line conflict between the two resolves to keeping cb4edba9 in either order.

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

Labels

comp-sql-syntax SQL/grammar parsing, AST nodes, syntax-level features. pr-experimental Experimental Feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants