Skip to content

Throw on a distributed query with unknown client version - #109408

Merged
alexey-milovidov merged 20 commits into
masterfrom
harden-distributed-query-client-version
Aug 4, 2026
Merged

Throw on a distributed query with unknown client version#109408
alexey-milovidov merged 20 commits into
masterfrom
harden-distributed-query-client-version

Conversation

@alexey-milovidov

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

Copy link
Copy Markdown
Member

Follow-up hardening for #90651 / #109407.

A distributed query forwarded to a remote shard must carry a known initiator version: the receiving server uses it for version-gated compatibility decisions (for example, whether to enable the analyzer, see TCPHandler). A real client always reports its version, and a server that (re-)initiates a query fills it with its own version, so a zero version is always a bug — it means the initiating query context was not populated as an initial query. Sending it silently triggers wrong compatibility downgrades on the remote and can break distributed execution (as in #90651, where the ON CLUSTER DDL context left the version at 0.0.0, so remote shards disabled the analyzer while the initiator kept it enabled).

This change makes RemoteQueryExecutor throw a LOGICAL_ERROR instead of forwarding a zero client version, so such query-context initialization bugs are caught loudly instead of causing subtle, version-dependent failures on remote shards.

To make the guard hold as an invariant, server-initiated query contexts are fixed centrally: Context::makeQueryContext fills a zero client version with this server's own version, covering every site that follows the Context::createCopy(...); makeQueryContext(); pattern (background flushes of Buffer tables, streaming consumers such as Kafka/NATS/RabbitMQ/FileLog/ObjectStorageQueue, MaterializedPostgreSQL replication, dictionary reloads, asynchronous insert flushes, ...). This never masks a client-reported version: contexts for real client queries overwrite the client info after makeQueryContext (see Session::makeQueryContextImpl, which keeps its own fill for the interfaces that do not report a version, e.g. raw HTTP), and a refreshable materialized view's refresh context, which never calls makeQueryContext, is filled at its creation site. The primary path that produced a zero version (the ON CLUSTER / distributed DDL context) was fixed the same way in #109407.

Related: #109407
Related: #90651

Changelog category (leave one):

  • Improvement

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

Added a sanity check that a distributed query always carries a known client version, throwing a logical error instead of silently forwarding a zero version to remote shards. Server-initiated queries (background flushes, streaming consumers, dictionary reloads, asynchronous insert flushes) now report the server's own version as the initiator version.

Documentation entry for user-facing changes

  • Documentation is not required for this change

A distributed query forwarded to a remote shard must carry a known
initiator version: the receiving server uses it for version-gated
compatibility decisions (e.g. whether to enable the analyzer, see
`TCPHandler`). A zero version means the initiating query context was not
populated as an initial query - a real client always reports its version,
and a server that (re-)initiates a query fills it with its own version.
Sending a zero version silently triggers wrong compatibility downgrades on
the remote and can break distributed execution (see the issue below). Fail
with a logical error instead of sending a zero version, so such context
initialization bugs are caught loudly.

Related: #109407
Related: #90651

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

clickhouse-gh Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [f659dd5]

Summary:


AI Review

Summary

This PR hardens distributed query client-version propagation: it rejects zero-version distributed queries in RemoteQueryExecutor, fills server-initiated query contexts with the local server version, normalizes stale queued distributed INSERT headers, and restores the immediate peer version from the connection hello during rolling upgrades. I reviewed the current head, the full PR diff, and all prior discussion threads against the current code, and I did not find any remaining correctness, compatibility, or test gaps that warrant a new review comment.

Final Verdict

✅ Approve

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.50% 86.50% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.70% 78.70% +0.00%

Changed lines: Changed C/C++ lines covered: 168/170 (98.82%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-improvement Pull request with some product improvements label Jul 4, 2026
The new assertion in `RemoteQueryExecutor` surfaced query-context
initialization paths that forward a zero client version to remote shards:
a query initiated over an interface that does not report a client version
(a raw HTTP request via `curl`, or a MySQL/PostgreSQL client) leaves
`client_version_*` at 0.0.0.

`Session::makeQueryContext` promoted such a context to an initial query
with `setQueryKind`, which - unlike `setInitialQuery` - does not fill the
version. This server is the real initiator of the query and of any
distributed sub-query it spawns, so fill the version with this server's
own version when it is zero. Otherwise remote shards treat the initiator
as a pre-23.3 server and disable the analyzer for "compatibility" (see
`TCPHandler`), diverging from the initiator - the same class of failure as
#90651 - and now the
assertion rejects the zero version outright.

This fixes the Fast test failures `03021_get_client_http_header`,
`01455_opentelemetry_distributed`, `02841_parallel_replicas_summary`,
`02531_two_level_aggregation_bug` and `01085_max_distributed_connections_http`,
which all issue distributed queries over HTTP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/QueryPipeline/RemoteQueryExecutor.cpp
The zero-version guard added to `RemoteQueryExecutor` also surfaced two
internal query-context constructors that synthesize an `INITIAL_QUERY`
context via `Context::createCopy` and never fill `client_version_*`, so
they forwarded a zero version to remote shards:

- `AsynchronousInsertQueue::processBatchDeadlines` - the async insert flush
  context. Reached when the flush spawns a distributed read, e.g. a
  materialized view triggered by the insert reads from a `Distributed`
  table.
- `StorageMaterializedView::createRefreshContext` - the refreshable
  materialized view refresh context. Reached when the refresh `SELECT`
  reads from a `Distributed` table.

Both are `setQueryKind(INITIAL_QUERY)` (not `setInitialQuery`), which does
not fill the version. This server is the real initiator of these queries
and of any distributed sub-query they spawn, so fill the version with this
server's own version when it is zero - the same fix already applied to
`Session::makeQueryContext`. Otherwise remote shards treat the initiator as
a pre-23.3 server and apply legacy compatibility downgrades (see
`TCPHandler`), and `RemoteQueryExecutor` now rejects the zero version
outright (a logical error that aborts a debug server from the background
refresh/flush threads).

Adds a regression test covering both paths (verified locally: the guard
fires twice without this change and not at all with it).

Related: #109407
Related: #90651

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread src/QueryPipeline/RemoteQueryExecutor.cpp
Comment thread src/QueryPipeline/RemoteQueryExecutor.cpp
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Notes on the latest AI Review "Request changes" verdict (commit 76605f58):

Blocker 1 — DDLTaskBase::makeQueryContext (ON CLUSTER ... AS SELECT over Distributed). This is exactly what #109407 fixes (it adds setClientVersion(VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH, DBMS_TCP_PROTOCOL_VERSION) in makeQueryContext). The review itself accepts the alternative "do not merge this PR until that dependency is already in master", which matches the intended merge order (this PR after #109407). I deliberately did not duplicate the DDLTask change here so the split between the two PRs stays clean.

Blocker 2 — remaining server-initiated contexts. This is the same class as the Session / async-insert / refreshable-MV contexts already patched here, but the set is open-ended. Server-(re)initiated query contexts built via Context::createCopy(...); makeQueryContext(); without filling client_version_* include at least:

  • StorageBuffer::writeBlockToDestination (src/Storages/StorageBuffer.cpp:1140)
  • StorageKafka (src/Storages/Kafka/StorageKafka.cpp:694), StorageKafka2 (src/Storages/Kafka/StorageKafka2.cpp:1422)
  • StorageNATS (src/Storages/NATS/StorageNATS.cpp:699), StorageRabbitMQ (src/Storages/RabbitMQ/StorageRabbitMQ.cpp:1248)
  • plus DatabaseReplicated, DatabaseMaterializedPostgreSQL, DistributedPlanExecutor, and others.

Each of these can end up running a distributed SELECT (e.g. a dependent materialized view whose query reads a Distributed table), so the new guard turns those into LOGICAL_ERROR. Chasing every site with an inline setClientVersion-if-zero would be whack-a-mole, so this looks like a design call on your side:

  1. Centralize the fill — a small helper that sets the server version when it is still zero, applied at the server-initiated context-creation sites (or at a single chokepoint); or
  2. Narrow the guard so it only rejects contexts that are already impossible in this branch.

Happy to implement whichever you prefer — the per-site fill for StorageBuffer + the four streaming engines the same way as the current three, or the centralized/narrowed version — just let me know. Not pushing anything until then, since the PR is blocked on the #109407 merge order anyway.

@alexey-milovidov

Copy link
Copy Markdown
Member Author

Full-CI update. The fast checks were green at my last note; the deep test suite has since run on the same commit (76605f58) and surfaced two guard-fire categories. Recording them so the red stateless/integration jobs aren't mistaken for unrelated breakage.

1. DatabaseReplicated CREATE ... AS SELECT ... FROM remote(...) via the DDL worker — the DDLTaskBase::makeQueryContext path (Blocker 1). Stack: DatabaseReplicatedDDLWorker::tryEnqueueAndExecuteEntry -> DDLWorker::processTask -> executeQuery -> InterpreterCreateQuery::fillTableIfNeeded -> InterpreterInsertQuery -> getStructureOfRemoteTable -> guard. DatabaseReplicatedTask::makeQueryContext delegates to DDLTaskBase::makeQueryContext, so this is exactly what #109407 fixes. This empirically confirms the merge-order dependency (previously a review hypothesis): land this after #109407, and no DDLTask change is needed here.

2. External dictionary loading with a remote ClickHouse source — a path not in the review's enumerated list. ClickHouseDictionarySource::createStreamForQuery and doInvalidateQuery build a fresh context via Context::createCopy(context); makeQueryContext() (no client version) and run it through RemoteQueryExecutor for non-local sources (ClickHouseDictionarySource.cpp:172-198, :208-231). So dictGet, SYSTEM RELOAD DICTIONARIES, and background cache-dictionary updates all hit the guard whenever the dictionary source is a remote/Distributed table.

(2) is the more significant finding: it is a common production feature and it sits outside the Buffer/Kafka/NATS/RabbitMQ set from Blocker 2, which reinforces that the set of server-initiated zero-version makeQueryContext sites is open-ended. This sharpens the same design fork I flagged earlier: per-site version-fill (now also ClickHouseDictionarySource) vs. centralizing the fill for server-initiated contexts vs. narrowing the guard. I'm holding off on pushing any per-site fill because it's a design call on your PR and a partial patch wouldn't turn CI green anyway (DDLTask needs #109407; Buffer/streaming remain). Happy to implement whichever direction you pick.

Comment thread src/QueryPipeline/RemoteQueryExecutor.cpp
…query-client-version

# Conflicts:
#	src/QueryPipeline/RemoteQueryExecutor.cpp
Comment thread src/QueryPipeline/RemoteQueryExecutor.cpp
Instead of patching every server-initiated query context individually
(`StorageBuffer`, `StorageKafka`, `StorageNATS`, `StorageRabbitMQ`,
`StorageFileLog`, `StorageObjectStorageQueue`,
`MaterializedPostgreSQLConsumer`, `ClickHouseDictionarySource`, the
asynchronous insert flush, ...), fill a zero client version with this
server's own version in `Context::makeQueryContext`, which all of these
sites go through via `Context::createCopy(...); makeQueryContext();`.
This server is the real initiator of such queries, so its own version is
the correct one to forward, and the `RemoteQueryExecutor` guard no
longer fires on these paths (this is what made the dictionary
integration tests red).

Contexts created for real client queries overwrite the client info
after `makeQueryContext` (see `Session::makeQueryContextImpl`), so a
client-reported version is never masked; the fix in
`Session::makeQueryContextImpl` stays because of that overwrite
ordering, and the one in `StorageMaterializedView::createRefreshContext`
stays because that context never calls `makeQueryContext`. The
now-redundant per-site fix in `AsynchronousInsertQueue` is removed.

Also add a test for the `Buffer` flush path, which previously left the
version at zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread src/QueryPipeline/RemoteQueryExecutor.cpp
The existing regressions only proved that the initiator no longer trips the
new zero-version guard locally. Check the invariant where it matters: the
`client_version_*` that the receiving server records for the forwarded
`SECONDARY_QUERY` in `system.query_log`, which is the value it gates
compatibility decisions on (disabling the analyzer for a pre-23.3 initiator in
`TCPHandler::receiveQuery`).

A synthesized server-side query context keeps the default
`ClientInfo::Interface::TCP`, and `ClientInfo::write` serializes
`client_version_*` exactly for that interface, so the zero version used to be
transmitted verbatim and overwrote the non-zero value the receiver had derived
from the hello handshake. Verified both directions against a local server: the
`Buffer` flush forwards `0.0.0` without the fix, and a forwarded query from a
context with a non-zero version arrives with that version.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

CI update on 44dfafb3dce — the centralized version fill cleared the guard-fires: the ClickHouseDictionarySource integration shards that were red by design on the previous heads are green now, and no zero-version LOGICAL_ERROR is left in the run.

The remaining reds are all unrelated, fleet-wide failures. Checked against the CI database (checks, last 10 days): each of them fails on many unrelated pull requests, and the first two also on master (pull_request_number = 0), so none of them is caused by this change.

@groeneai, these three have no tracking issue and fail across many unrelated pull requests — please investigate and provide fixes in separate pull requests, or link them here if they are already in progress:

  • 03742_nested_loop_join_longreport (also fails on master).
  • 03216_arrayWithConstant_limits — same report (also fails on master).
  • 02228_merge_tree_insert_memory_usagereport.

@groeneai

Copy link
Copy Markdown
Contributor

Checked all three against checks (30 days). They are two root causes, not three, and the 02228 one is not what it looks like.

1 + 2. 03742_nested_loop_join_long and 03216_arrayWithConstant_limits: victims of the shard memory cap, not test bugs

All 48 03216 hits on Stateless tests (amd_asan_ubsan, distributed plan, parallel) carry one identical signature:

Code: 241. (total) memory limit exceeded: would use 2.41 GiB (attempt to allocate chunk of 1.49 GiB),
current RSS: 43.48 GiB, maximum: 44.83 GiB.
OvercommitTracker decision: Query was selected to stop by OvercommitTracker.

current RSS is within 0.1 to 1 GiB of maximum in every sample, so the shard is already at its cap and the OvercommitTracker stops whichever query has the largest allocation in flight. These two tests top that list because they legitimately allocate 763 MiB to 1.49 GiB in one chunk. They are the most frequent victims, not the cause.

Scope of that signature on that shard, 30 days: 2856 distinct tests, 6264 hits, 242 pull requests. Top five victims: 03742 (62 hits / 51 PRs), 02477_single_value_data_string_regression (51 / 49), 03216 (48 / 47), 01661_extract_all_groups_throw_fast (36 / 35), 01605_adaptive_granularity_block_borders (32 / 31). 425 hits over 247 distinct tests are after #110574 merged, so headroom on that shard is still short.

That means per-test trimming only moves which test gets picked. It is worth doing for the two fattest allocators, but the class-level lever is the shard's headroom or concurrency, which is your #110574 territory rather than something I should change unilaterally.

3. 02228_merge_tree_insert_memory_usage: the negative assertion fails in the opposite direction

The arm_binary hit is not memory pressure. The insert that is supposed to run out of memory succeeds:

The query succeeded but the server error '241' was expected
(query: insert into data_02228 select number, 1, number from numbers_mt(10_000)
 settings max_memory_usage='30Mi', max_partitions_per_insert_block=1024,
 max_insert_delayed_streams_for_parallel_write=1000000; -- { serverError MEMORY_LIMIT_EXCEEDED })

The runner's own diagnosis in that report is unambiguous: 63/63 failed with the randomized settings, 0/95 failed without them, and minimization lands on three MergeTree settings, --min_bytes_for_wide_part 0 --min_bytes_for_full_part_storage 509915446 --min_columns_to_activate_adaptive_write_buffer 2. With those, the 100-partition insert stays under the 30 MiB budget, never throws, and the expected error never arrives.

5 hits over 5 pull requests carry this signature (arm_binary, amd_debug, amd_tsan, and distributed plan), 0 on master. The other 4 02228 hits, all on the distributed plan shard, are the OvercommitTracker collateral from the section above, so the two signatures were being counted as one. The fix belongs in the test: pin the write-path settings the assertion depends on instead of letting randomization make the insert cheap enough to succeed. Separate pull request.

Reports used

  • 03216 and 03742 on this pull request: report
  • 02228 on this pull request: report
  • Most recent 03216 elsewhere: report
  • Most recent 02228 elsewhere: report

I will follow up here with the two pull request links.

@groeneai

Copy link
Copy Markdown
Contributor

03216_arrayWithConstant_limits needs no pull request from me: you already fixed it in #111705 (merged fa854c7). Closing the loop because I owe a correction on it.

My earlier comment named query 3, SELECT length(arrayWithConstant(10000000, materialize(...))), as the 763 MiB to 1.49 GiB allocation. That was wrong. Every fat-chunk context names the final query instead:

FUNCTION arrayWithConstant(1000000_UInt32 :: 1, __table1.value :: 2)
  -> arrayWithConstant(1000000_UInt32, __table1.value) Array(Array(Int32))
(query: SELECT length(arrayWithConstant(1000000, value)) FROM args FORMAT NULL;)

Attribution over the 48 hits on Stateless tests (amd_asan_ubsan, distributed plan, parallel), 30 days:

chunk query 3 final query hits
762.94 MiB 0 19 19
1.49 GiB 0 15 15
128.00 MiB 5 0 5
64.00 MiB 6 0 6
32.00 MiB 2 0 2
4.00 MiB 1 0 1

All 34 fat-chunk hits are the final query. Query 3 materializes over a single row, so it allocates in per-column pieces and never asks for one 1.5 GiB chunk. Your insert-time block split is on the query that actually held the allocation.

The 14 remaining query-3 hits are collateral, not a test defect. Average headroom when query 3 was selected was current RSS 42.46 GiB against maximum 42.48 GiB, 0.02 GiB, against 42.82/43.51 for the final-query hits. At that margin a 4 MiB allocation is enough to be picked. Trimming query 3 would mean cutting the 10M element count, which is the near-limit coverage of the 1e9-byte and 1e9-element thresholds in arrayWithConstant.cpp, and those are static constexpr rather than settings, so the ratio cannot be preserved from the test side. I am not touching it.

Since fa854c7 merged: 583 OK, 0 failures across 57 pull requests, of which 30 OK and 0 failures on the distributed plan shard. Roughly six hours, so directional rather than conclusive, but the single 1.49 GiB chunk is gone by construction.

Still open from your list: 03742_nested_loop_join_long is #111644. 02228_merge_tree_insert_memory_usage is a separate root cause, the negative assertion failing in the opposite direction, and gets its own pull request.

One nearby data point, not something I am folding in here: 02477_single_value_data_string_regression is the #2 victim on that shard, 2.00 GiB in a single chunk, 55 hits over 51 pull requests, and has no fixing pull request. The #111705 recipe looks like it applies directly.

@groeneai

Copy link
Copy Markdown
Contributor

Correcting my own last paragraph: I said 02477_single_value_data_string_regression has no fixing pull request. It does, #110632, yours, open and retitled to cover it, and the #111705 block-splitting recipe does not apply there. That failure is the 2 GiB untrusted preallocation in SingleValueDataString::read surfacing as 241 where the test expects 33, and you already ruled on it when you closed #110630: the test accepts both codes and the read path stays as it is. Nothing needed from me on that one.

@groeneai

Copy link
Copy Markdown
Contributor

Fix for 02228_merge_tree_insert_memory_usage is up: #111929 (as promised in #109408 (comment)).

Root cause is what I described above: the negative MEMORY_LIMIT_EXCEEDED assertion stops firing when the randomizer sets min_bytes_for_wide_part = 0, which flips the ~1.3 KB parts from Compact to Wide and collapses the write-buffer footprint under the 30 MiB budget. The fix pins min_bytes_for_wide_part to the non-cloud default at CREATE TABLE level on both tables, so the part format the assertion is calibrated for is guaranteed. Assertion, memory budget and delayed-stream settings unchanged.

…by `current_database`

The `system.query_log` check added in the previous commit matched the shard-side row with
`query LIKE '%`db`.`agg_src`%'`, which the style check rejects: queries to `system.query_log`
must be scoped to the test's own database. The usual `current_database = currentDatabase()`
condition does not work here - the sub-query runs on the shard through the cluster connection's
own default database (measured: `current_database` is `default`), which is why `Distributed`
rewrites the query with fully qualified names. Use `has(databases, currentDatabase())` plus
`has(tables, ...)` instead, which the style check also accepts.

Verified locally against a server on port 19807: the row is still matched (`count() > 0` is 1)
and the assertion still discriminates - it yields a zero version on a binary without the fill.
Comment thread src/QueryPipeline/RemoteQueryExecutor.cpp
…uery_plan`

`04643_distributed_query_client_version_buffer_flush` failed deterministically in
`Stateless tests (amd_asan_ubsan, distributed plan, parallel)`: with
`serialize_query_plan = 1` the shard executes a deserialized query plan and never
analyses the query, so `Context::addQueryAccessInfo` is never called and the
`databases`/`tables` columns of its `system.query_log` row stay empty. The
predicate then matched nothing and the test printed `remote_version 0 0`.

Measured on a local two-port server (`test_shard_localhost` pointing at itself):
with `serialize_query_plan = 1` the shard row has `databases = []`, `tables = []`,
`current_database = 'default'`, while `query` is
``SELECT sum(`__table1`.`x`) ... FROM `test205`.`agg_src` AS `__table1``` - so
identify the forwarded sub-query by its query text, which is qualified with the
test's database in both modes, and keep `has(databases, currentDatabase())` as the
equivalent condition for the analysing case.

Verified: the test passes against a build of this branch with
`serialize_query_plan = 1`, and the predicate still yields `remote_version 1 0` on
an unfixed binary, so it keeps discriminating.
Address review: a queued (asynchronous) distributed INSERT does not go through
`RemoteQueryExecutor` at all - `DistributedAsyncInsertHeader::read` deserializes the
initiator's `ClientInfo` from the on-disk batch header and
`DistributedAsyncInsertBatch`/`DistributedAsyncInsertDirectoryQueue` hand it to
`RemoteInserter` as is. A batch file written by an older server from a
server-initiated query context (a `Buffer` flush, a streaming consumer, an
asynchronous insert flush) carries a zero client version, and such files are still
replayed after an upgrade, so the receiving shard keeps treating the initiator as an
ancient server. Fill the version with this server's own version when reading such a
header - the same normalization `Context::makeQueryContext` now does for live query
contexts. Normalizing rather than throwing, because a stale batch file on disk is not
a programming error and rejecting it would wedge the queue.

Confirmed the path is real: on an unfixed 26.7 binary, a `Buffer` flush into a
`Distributed` table over `test_cluster_two_shards` (its second shard, `127.0.0.2`, is
not a local address, so the insert goes over the network) is replayed on the shard as
`is_initial_query = 0`, `interface = TCP`, `client_version_major = 0`.

The new test `04654_distributed_async_insert_client_version` covers that path
end-to-end: it yields `remote_version 1 0` on an unfixed binary and
`remote_version 1 1` on this branch. A stale zero-version header itself cannot be
produced by a test on a fixed binary - the live path no longer writes one, and a
non-`TCP` interface never serializes the version into the header in the first place -
so that leg would need an integration test that upgrades a node with a non-empty
distributed queue.
…layouts too

The normalization added in `ef79cb137ec` only ran in the branch that deserializes an
embedded `ClientInfo`. The two legacy layouts - `DBMS_DISTRIBUTED_SIGNATURE_HEADER_OLD_FORMAT`
and the even older plain-query-size header - carry no client info at all and returned
early with a default-constructed `ClientInfo`, whose interface is `TCP` and whose version
is zero. `RemoteInserter` forwards it verbatim after only flipping `query_kind`, and `TCP`
is exactly the interface whose version `ClientInfo::write` serializes, so a node upgraded
with such batch files still on disk replayed them as `0.0.0` - the very downgrade this
change is about.

Move the fill into `DistributedAsyncInsertHeader::read`, which now wraps the parsing in a
file-local `readHeader`, so every return path is normalized and a layout added later cannot
silently miss it. Behavior for the current layout is unchanged.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Addressed the review request about the legacy queued distributed INSERT header layouts (commit 868d87252c3).

The suggested integration test — seed a legacy batch file, replay it with SYSTEM FLUSH DISTRIBUTED, assert on the receiving shard — cannot be built from a running server: a current binary never writes either legacy layout, so the file would have to be a hand-crafted binary blob checked into the repository, which pins a serialization detail in an opaque form and would silently rot. So the coverage went to the place where the layouts are actually parsed instead: a unit test src/Storages/tests/gtest_distributed_async_insert_header.cpp serializes a queue file in each of the three layouts (the plain query-size one, the DBMS_DISTRIBUTED_SIGNATURE_HEADER_OLD_FORMAT one, and the current one with an embedded zero-version ClientInfo) and checks what DistributedAsyncInsertHeader::read returns.

Verified, not just reasoned about: with the normalization disabled, FillsZeroVersionForOldestLayout, FillsZeroVersionForOldFormatLayout and FillsZeroVersionForCurrentLayout all fail with client_version_major = 0 against the expected 26, and they pass with it; KeepsNonZeroVersion passes either way, so a real initiator version is still preserved as is rather than overwritten.

The remaining wire leg — that RemoteInserter then sends this filled version and the shard records it — is already covered end-to-end by 04654_distributed_async_insert_client_version, so the two together cover the full upgrade path.

Also merged master (the branch was 2801 commits behind).

@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.

This is very good.

@alexey-milovidov alexey-milovidov self-assigned this Jul 31, 2026
Comment thread src/QueryPipeline/RemoteQueryExecutor.cpp
…on receive

During a rolling upgrade an older peer can forward a server-initiated query
whose context was never filled with a version, so `ClientInfo::read` in
`TCPHandler::processQuery` overwrites the session seed with 0.0.0. The new
`ClientInfo::setClientVersionFromConnectionIfUnknown` restores the immediate
peer's version from the connection hello (`connection_client_version_*`,
which `read` never touches), so version-gated compatibility decisions do not
wrongly downgrade and a second distributed hop does not trip the zero-version
check in `RemoteQueryExecutor`. A locally synthesized context has no
connection version, so a genuine programming error still fails loudly on the
sending side.

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

clickhouse-gh Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.50% 86.50% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 78.70% 78.80% +0.10%

Changed lines: Changed C/C++ lines covered: 167/169 (98.82%) · Uncovered code

Full report · Diff report

The queued sender is covered by `04654_distributed_async_insert_client_version`,
but the synchronous sender used when `distributed_foreground_insert = 1` takes a
different route: `DistributedSink` hands `context->getClientInfo()` straight to
`RemoteInserter` without the on-disk batch header round trip.

The new test drives that route from a server-created context: with
`async_insert = 1` the push runs from the flush context synthesized by
`AsynchronousInsertQueue`, so the version the shard records comes from a context
this pull request fills. It also covers the `remote` table function, whose
storage has no data path and therefore always inserts synchronously.

Verified on a local server: the receiving shard reports
`client_version_major = 0` without the fill and a non-zero version with it.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

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

01548_parallel_parsing_max_memory timed out in Fast test (arm_darwin). It is unrelated to this pull request and not specific to it: on 2026-08-01 the same test timed out on six different pull requests (#112650, #108290, #109408, #111690, #112745, #107113) per CIDB, and I could not find an open issue tracking it.

Comment thread tests/queries/0_stateless/04643_distributed_query_client_version_buffer_flush.sql Outdated
@groeneai

groeneai commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Investigated. Your reading is right on both counts, and I can add the cause. There is no fix pull request yet; I am preparing one and will link it here.

It is the runner's 60 s per-test cap, not a resource error. The 7 arm_darwin failures cluster within 0.8 s of 60.0 s (60040, 60050, 60070, 60070, 60210, 60220, 60810 ms). The cap is --timeout 60 in ci/jobs/fast_test.py:375 and the kill is tests/clickhouse-test:695; the clickhouse-local child is still alive when the group is killed. 30 d in checks: 8 rows across 8 distinct pull requests, 0 on master, and no carrier touches 01548, ParallelParsing, ci/, or tests/clickhouse-test.

The test did not get slower, the runner's IO tail did, and it has already receded. Per day on Fast test (arm_darwin), the median is flat between 4.9 and 5.3 s for the whole 21 d window. Only the tail moved: p99 never exceeded 8.5 s on any day through 07-28, then 19.5 s on 07-29, 21.6 s on 07-30, 32.4 s on 07-31, 60.1 s on 08-01, and back to 6.7 s on 08-02 (166 runs, 0 failures).

It was not specific to this test. Over the same window every IO-heavy arm_darwin fast test inflated: 04632_async_insert_select_general p99 8530 -> 17562 ms, 04632_async_insert_select_regression 10600 -> 18185, 04510_shared_part_schema_metadata_eviction 3410 -> 7920. 01548 crossed the cap first because it is the most disk-heavy test in the set. Code: 243 NOT_ENOUGH_SPACE on that same check appears on exactly one day out of 21, 2026-08-01 (9 rows / 4 pull requests), in the same hours as these timeouts (20:00-22:00Z). The trigger is disk pressure on those macOS runners.

Why this test is the one that breaks. The cost is the fixture, not the parse. Writing the 1 GB file takes ~1 s but flushing it costs ~7.3 s on a fast local NVMe array, while the parse is ~3.3 s. Under contended disk it is the write and flush that stretch, and the file carries no Tags: line, so it runs in the Fast test set under --jobs {nproc_fast}.

The fix I am sending: shrink the fixture, keep the assertion. The 1 GB is not load-bearing for what the test checks. Peak footprint is independent of file size: bisecting the cap, 1 GiB, 128 MiB and 64 MiB all pass at max_memory_usage=5Mi and all fail at 2Mi with the identical would use 4.11 MiB. Every probe I ran, including inflating min_chunk_bytes_for_parallel_parsing to 512 MiB, behaves the same at 64 MiB as at 1 GiB, so the reduction costs no detecting power. I keep the 50Mi cap and the max_threads=1 pin, since those are the actual oracle; only the count in the reference changes.

long is not the lever here: the Fast test runner passes --no-long, so that tag would skip the test on this check (tests/clickhouse-test:2835-2841) instead of exempting it from the cap.

@clickhouse-gh

clickhouse-gh Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

No arm_release build profile data for commit f659dd5 - the build was skipped, reused from cache, or predates profile upload.

@groeneai

groeneai commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Fix pull request: #113080

It does two things. The fixture drops from 1 GB to 128 MiB and is now removed, which is 87.5 % fewer
bytes written per run, so the test stops sitting near the 60 s cap when the runner is under disk
pressure.

While shrinking it I found the assertion had been dead since ce7eca06159 (2023-10-16). The test's
SELECT count() references no column, which sets need_only_count, and FormatFactory.cpp:558
declines parallel parsing on that flag before the max_memory_usage guard at :560-562 is reached.
The trace shows (from 0.00 B): no bytes were read. So the test now aggregates over values, pins the
four settings the guard reads, and asserts at two caps, plus a third arm that proves which input
format was actually constructed.

…ssion tests

The shard-side assertions in `04643_distributed_query_client_version_buffer_flush`,
`04654_distributed_async_insert_client_version` and
`04692_distributed_foreground_insert_client_version` only checked
`min(client_version_major) > 0`, so a regression that forwards a wrong non-zero
version (e.g. `23.1.0`, still taking the pre-`23.3.0` compatibility branch in
`TCPHandler`) would pass. Server-created contexts are filled with this server's
own version and the shard is the same server in these tests, so compare the full
`client_version_major`/`client_version_minor`/`client_version_patch` tuple
against the components of `version` exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexey-milovidov
alexey-milovidov added this pull request to the merge queue Aug 4, 2026
Merged via the queue into master with commit 3bc26f4 Aug 4, 2026
181 checks passed
@alexey-milovidov
alexey-milovidov deleted the harden-distributed-query-client-version branch August 4, 2026 15:09
@robot-clickhouse-ci-2 robot-clickhouse-ci-2 added the pr-synced-to-cloud The PR is synced to the cloud repo label Aug 4, 2026
groeneai added a commit to groeneai/ClickHouse that referenced this pull request Aug 7, 2026
groeneai added a commit to groeneai/ClickHouse that referenced this pull request Aug 8, 2026
…s discarded

A materialized CTE read behind an IN-subquery is activated through a
DelayedPortsProcessor gate that opens once the CTE has been materialized. Discarding a
totals or extremes port attached a node with no outputs, and
ExecutingGraph::initializeExecution seeds every such node before the pipeline runs.
NullSink closes its input immediately, so when that input was a gate output the gate's
paired input was closed and the reader ran before its data was there:

    Reading from materialized CTE '...' before its materialization completed -
    DelayedPortsProcessor gate is missing in the query plan. (LOGICAL_ERROR)

The discard now goes through DroppingTransform, which keeps a data output connected and
is therefore never seeded. This covers the drop entry points in Pipe, the single-stream
fallbacks, and completion with a sink or a chain, which is the path EXPLAIN ANALYZE
takes. Uniting extremes across the arms of a set operation cannot discard its input,
because the united extremes value has to be preserved, so it uses an accumulating
ExtremesOnlyTransform that needs no sink at all. Each auxiliary input takes its header
from the port it is connected to, because a totals stream may be finalized while the data
stream is not, which is what the NullSink this replaced did.

The regression tests assert row counts and extrema, which are identical whether the CTE
is materialized or inlined, so each carrier also asserts that its own shape is planned
with materialization. Every such assertion was checked to read 1 today and 0 with
enable_materialized_cte = 0. The INSERT carrier is a shell test, because its pipeline is
built by InterpreterInsertQuery and only EXPLAIN PIPELINE INSERT reflects it.

Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=109408&sha=848d6f40422826ac03b60db0219ec027ebed9b48&name_0=PR&name_1=AST%20fuzzer%20%28amd_debug%2C%20targeted%29
Carrier pull request: ClickHouse#109408

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

Labels

pr-improvement Pull request with some product improvements 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