Skip to content

Add object_storage_cluster_join_mode='distributed' for whole-query … - #2383

Open
VighneshPath wants to merge 20 commits into
Altinity:antalya-26.6from
VighneshPath:feature/antalya-26.6/object-storage-cluster-distributed-join
Open

VighneshPath wants to merge 20 commits into
Altinity:antalya-26.6from
VighneshPath:feature/antalya-26.6/object-storage-cluster-distributed-join

Conversation

@VighneshPath

Copy link
Copy Markdown

…JOIN dispatch

Introduces whole-query dispatch for JOINs involving a DataLake-catalog table backed by object_storage_cluster: findDistributedObjectStorageCandidate() walks the left spine from the root query to find such a driver table where every other table reachable in the same JOIN/subquery tree also safely resolves through a DataLake catalog, and buildDistributedObjectStorageQueryPlan() rewrites the driver's table expression into a cluster table function via IStorageCluster::buildClusterTableFunctionAST() (using QueryTree::cloneAndReplace(), not in-place mutation) and dispatches the whole enclosing query to the driver's cluster nodes.

IStorageCluster::readPreparedClusterQuery() and ReadFromCluster gained an is_whole_query_dispatch mode: driver-side filter/task-iterator pruning is suppressed (safe fallback, since the shipped query already carries its own WHERE), and query_info's table_expression/planner_context -- which describe a single table for an ordinary per-table read -- are reset before ReadFromCluster is constructed, since they otherwise get consulted by ReadFromCluster::applyFilters() as if they described the whole dispatched query, corrupting filter pushdown and, if the resulting exception path is ever hit, dereferencing a null table_expression.

Verified against a live Iceberg/MinIO-backed cluster with q17- and q21-shaped JOIN queries: correct, disjoint fan-out results across cluster nodes, filter pushdown into ReadFromCluster survives real query plan optimization, no crash.

Changelog category (leave one):

  • New Feature
  • Experimental Feature
  • Improvement
  • Performance Improvement
  • Backward Incompatible Change
  • Build/Testing/Packaging Improvement
  • Documentation (changelog entry is not required)
  • Critical Bug Fix (crash, data loss, RBAC)
  • Bug Fix (user-visible misbehavior in an official stable release)
  • CI Fix or Improvement (changelog entry is not required)
  • Not for changelog (changelog entry is not required)

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

...

Documentation entry for user-facing changes

...

CI/CD Options

Exclude tests:

  • Fast test
  • Integration Tests
  • Stateless tests
  • Stateful tests
  • Unit tests
  • Performance tests
  • Aarch64 tests
  • All with ASAN
  • All with TSAN
  • All with MSAN
  • All with UBSAN
  • All with Coverage
  • All Regression
  • Disable CI Cache

Regression jobs to run:

  • Fast suites (mostly <1h)
  • Aggregate Functions (2h)
  • Alter (1.5h)
  • Benchmark (30m)
  • CAS (content-addressed storage; Antalya only)
  • ClickHouse Keeper (1h)
  • Iceberg (2h)
  • LDAP (1h)
  • OAuth (5m)
  • Parquet (1.5h)
  • RBAC (1.5h)
  • SSL Server (1h)
  • S3 (2h)
  • S3 Export (2h)
  • Swarms (30m)
  • Tiered Storage (2h)

VighneshPath and others added 3 commits September 16, 2026 15:33
…JOIN dispatch

When a JOIN's driving table is a `DataLake` catalog table distributed via
`object_storage_cluster`, send the whole query to that table's cluster and merge
the partial aggregates on the initiator, instead of reading every table back to
the initiator and joining there. The JOIN and any `GROUP BY` then run on every
node of the cluster rather than on one.

`findDistributedObjectStorageCandidate` decides eligibility. It walks down the
left side of the join tree to find the driving table, passing through a subquery
only when that subquery does not itself aggregate, deduplicate, sort or limit --
each worker runs it against its own slice of the driver, so anything that
finalizes across rows would turn a partial result into a final one. Every other
table reachable in the query must resolve through a `DataLake` catalog, carry no
row policy, and be readable by the current user.

`buildDistributedObjectStorageQueryPlan` then replaces the driver with an
explicit `*Cluster()` table function, keyed on the exact query tree node, and
reads the result back at `WithMergeableState` through a single `ReadFromCluster`
step, so the planner's ordinary finalization applies on top. It follows
`buildQueryPlanForParallelReplicas` step for step, including the position-based
conversion from the rewritten query's header back to the original's.

Only the driving table is partitioned across the cluster; every other table in
the query is read and recomputed in full on each node. That cost is not
estimated when deciding to dispatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: VighneshPath <pathrikarvighnesh@gmail.com>
Reuse mechanisms that already exist rather than adding parallel ones:
`DatabaseCatalog::isDatalakeCatalog` in place of a per-storage marker,
`StorageObjectStorageCluster::getClusterName` in place of a second
worker-localization path in `DatabaseDataLake`, and
`SourceStepWithFilterBase::applyFilters` in place of clearing fields on the
`SelectQueryInfo` handed to `ReadFromCluster`. `DatabaseDataLake.cpp` is no
longer touched by this feature at all.

The setting's documentation described a search for the highest eligible
enclosing query, and a fallback for the level an ineligible table appears at.
Neither exists: dispatch is attempted only for the outermost `SELECT` of an
initial query, and is all-or-nothing. It now says so, and states that only the
driving table is partitioned while the rest is recomputed in full per node.

`allWorkerLocalReferencesAreSafe` is renamed to
`allWorkerLocalTableReferencesAreSafe`, because it proves nothing about ordinary
functions: `dictGet`, a user-defined function or `hostName` move from the
initiator to the workers unexamined, as they do for `Distributed`.

Tests cover both shapes against a real `DataLake` catalog -- the driver as the
JOIN's leftmost table, and the driver behind a subquery with the aggregation on
the enclosing query and a nested JOIN on the right -- in derived-table and CTE
spellings, comparing results against `object_storage_cluster_join_mode='allow'`.
They assert that the whole query reaches a worker, that only the driver becomes
a cluster function, and that no partner table fans out again from a worker. A
local `Memory` JOIN partner must fall back to ordinary planning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: VighneshPath <pathrikarvighnesh@gmail.com>
…cluster-distributed-join

Signed-off-by: VighneshPath <pathrikarvighnesh@gmail.com>
@VighneshPath
VighneshPath force-pushed the feature/antalya-26.6/object-storage-cluster-distributed-join branch from 037f342 to 10f70a2 Compare September 16, 2026 10:06
@filimonov

Copy link
Copy Markdown
Member

@ianton-ru

VighneshPath and others added 15 commits September 22, 2026 12:59
Under `object_storage_cluster_join_mode='distributed'` a worker resolves every
DataLake-catalog table through `DatabaseDataLake::tryGetTableImpl`, which builds a
fresh `StorageObjectStorageCluster` from the query context. Its constructor decides
there and then whether the inner plain storage consumes the initiator's file-task
queue, from `collaborate_with_initiator` and the parallel-replicas settings alone --
it does not consider which table this is.

Under whole-query dispatch the driver owns that queue, so a partner table answering
yes as well would read the driver's files under its own schema. Nothing on the path
`getClusterName` -> `readFallBackToPure` -> `StorageObjectStorage::read` ->
`createFileIterator` -> `ReadTaskIterator` re-checks this, and `ReadTaskIterator`
takes whatever the callback returns without filtering by table.

The new test pins the combination of settings that makes the constructor's condition
true for every catalog table in the query, and checks the result against ordinary
planning. It also asserts the query was still dispatched, so it cannot silently stop
covering anything if the candidate is rejected whenever those settings are set.

Not yet executed: this machine's container runtime is podman, and
`compose/docker_compose_keeper.yml` relies on shell-style default expansion in
`entrypoint` that podman-compose does not implement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: VighneshPath <pathrikarvighnesh@gmail.com>
The previous commit described this test as covering a suspected defect. Running it
against a local two-node cluster with a real Iceberg REST catalog shows there is no
defect: the partner table is constructed on the worker but never consumes the
driver's file-task queue, and `ReadTaskIterator` does not appear in the worker log
at all. Results match ordinary planning in every combination tried, including with
`object_storage_cluster` set as a `DatabaseDataLake` database setting.

Two guards are responsible, and neither was traced fully when the concern was
raised. `DatabaseDataLake::tryGetTableImpl` only falls back to the parallel-replicas
cluster when `!is_secondary_query`, so on a worker the cluster name stays empty and
`can_use_parallel_replicas` is false. A dispatched worker query also contains a
`*Cluster` table function, which makes the context distributed and fails the
`!isDistributed` term.

The test stays, as a guard rather than a reproducer: both conditions are incidental
to this feature, and removing either would silently turn a partner into a queue
consumer reading the driver's files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: VighneshPath <pathrikarvighnesh@gmail.com>
…tion

`object_storage_cluster_join_mode='distributed'` identified the driving table to a
worker structurally: the planner rewrote that one table expression into an explicit
`icebergS3Cluster(...)` call, which the worker then resolved to a task-consuming
storage. That worked, but it is why the dispatch path had to fabricate a throwaway
SELECT to extract a table function from, re-run `QueryAnalysisPass` on the synthesized
node, `cloneAndReplace` it into a copy of the tree, and reconcile two headers built
from two different trees.

The structural encoding was needed because no name survives the trip. The analyzer
assigns every table expression a fresh `__tableN` alias in
`createUniqueAliasesIfNecessary`, overwriting whatever the SQL carried, and
`queryNodeToDistributedSelectQuery` inlines CTE bodies, so neither an alias nor a
position is stable from initiator to worker.

A `StorageID` is stable. The query now crosses the wire exactly as written, and the
driving table is named alongside it in `object_storage_distributed_driver_database`
and `object_storage_distributed_driver_table`. A worker reads that one table from the
initiator's file-task queue and every other table in full, locally.

That name has to be unambiguous, so the planner counts the driver's occurrences in the
serialized query -- after CTE inlining, which is where a single tree node can become
two table references -- and declines to dispatch unless it appears exactly once. A
self-join or a twice-referenced CTE over the driver falls back to ordinary planning
rather than risk two readers of one queue, which would be silently wrong.

On the worker side this replaces an inference with a fact. `getClusterName` keyed off
`SECONDARY_QUERY && collaborate_with_initiator && hasClusterFunctionReadTaskCallback()
&& join_mode == DISTRIBUTED` -- none of which is unique to this feature -- and now keys
off the announcement. `distributed_processing` stays a construction-time property, so
`totalRows`, `totalBytes` and `getPathSample` continue to agree with it;
`DatabaseDataLake` builds a fresh storage per resolution, so the query context is
available where the decision is made.

Verified on a two-node cluster against an Iceberg REST catalog. With a six-file driver
and a three-row partner, each node reads nine driver rows and the whole partner, and
the merged result matches `join_mode='allow'` exactly. A self-join declines dispatch
and returns the same answer through ordinary planning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: VighneshPath <pathrikarvighnesh@gmail.com>
`ReadFromCluster` reads one table: it carries that table's `StorageSnapshot`, its
required columns, and a `SelectQueryInfo` describing its table expression, and it
pushes filters into all three. Whole-query dispatch reused it with an
`is_whole_query_dispatch` flag, which left the same members meaning two different
things -- for a dispatched query the snapshot and columns describe the driver while
the step's output is the query's result. Each place that noticed needed a branch:
`applyFilters` had to fall back to the base implementation because the per-table
column mapping throws, `createExtension` had to suppress the filter because it is not
a predicate over the driver, and `updateSettings` had to scrub a setting only in one
mode.

`ReadFromClusterQuery` holds only what dispatching a query needs: the query, the
cluster, the output header, and the storage that hands out file tasks. It derives from
`ISourceStep`, so there is no filter-pushdown surface to special-case and no
`required_source_columns` to disagree with the output. The three branches and the flag
are gone rather than better documented.

The transport both steps share -- replicas as shards, one connection each, all pulling
from one task iterator -- is extracted verbatim into `buildClusterFunctionRemotePipe`.
Nothing about it changes.

Also removes `buildClusterTableFunctionAST`, unused since the driver stopped being
rewritten. With it goes the throwaway `SELECT` it built to run the per-engine rewrite
against, and `extractTableFunctionFromSelectQuery`'s last caller on this path.

The gtest asserting `required_source_columns` are the driver's columns is replaced
rather than fixed: the mismatch it guarded is now unrepresentable, so it instead pins
that dispatch produces `ReadFromClusterQuery` and no `ReadFromCluster`.

Driver-only file pruning is still not recovered -- `getTaskIteratorExtension` is
called with no predicate, so every file of the driver is listed. That needs the
conjuncts whose columns all come from the driver, and is now a self-contained change
in one place; the comment there says so.

Verified on the two-node cluster: same results as `join_mode='allow'`, driver files
still split evenly, and `EXPLAIN` shows `ReadFromClusterQuery` under
`MergingAggregated`. 41/41 unit tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: VighneshPath <pathrikarvighnesh@gmail.com>
`QueryConditionCache` is keyed by `(table uuid, part name, condition hash)`. For `MergeTree` the part name
is unique within a table, so passing a name is enough. Object storage paths are hierarchical, and
`StorageObjectStorageSource` was passing `ObjectInfo::getFileName`, which is only the last component.

A Hive-partitioned dataset repeats the same file name in every partition -- `day=2025-02-05/part-00000.parquet`
and `day=2025-02-27/part-00000.parquet` -- so all of them shared a single cache entry. The first partition
whose row groups all failed the condition wrote "nothing matches" under that shared key, and every later query
read it back for its namesakes and skipped them without opening them. The result was a table that answered
correctly once and returned no rows from those files afterwards, with no error and nothing logged above debug
level.

Only reachable when the table id is stable across queries, so a table (or a data lake catalog table) hits it
while a table function does not: a table function gets a fresh id per query, and its entries never collide.

Pass `ObjectInfo::getPath` on both the read and the write side instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A data lake catalog table is constructed with `lazy_init`, so its configuration -- and with it the path the
listing walks -- is resolved on first use rather than at construction. Ordinary reads reach the listing through
`StorageObjectStorage`, which does that resolution itself. Whole-query dispatch calls `getTaskIteratorExtension`
directly and skips it, so a driver that nothing else had touched yet would list no files, hand every worker an
empty queue, and return no rows without an error.

`lazyInitializeIfNeeded` is a no-op once the configuration is resolved, so this is safe on the paths that
already did it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inherited from `buildQueryPlanForParallelReplicas`, which needs it: that one rewrites the table expression
before serializing the query, so the header it computes and the header the remote plan produces come from
different trees and can disagree.

Whole-query dispatch sends the query as written, and `readPreparedClusterQuery` is given `remote_header` as
the source step's own output header, so the plan's current header is already that block and the conversion is
an identity. It is not free: it puts an `ExpressionStep` over blocks carrying aggregate state at
`WithMergeableState` for no benefit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`object_storage_distributed_driver_database` and `_table` name the one table whose files the initiator hands
out. Whole-query dispatch sets them on its own context, from its own step. Nothing marks them internal --
ClickHouse has no such tier -- so a user can set them by hand on any query.

On an ordinary cluster read that is mostly harmless, because they are only read on a worker, and a worker only
reaches that state when the initiator supplied a task iterator. `ReadFromCluster` does supply one: under
parallel replicas its workers see `collaborate_with_initiator`, so a name that reached them would make the
table so named read this step's file-task queue instead of listing its own files.

Clear both in `ReadFromCluster::updateSettings`, and again from the query's own `SETTINGS` clause, which a
worker applies on top of the context settings and would otherwise use to restore them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-join' from fork

Picks up the antalya-26.6 merge pushed to the branch (CI scripts and test compose
files only; no change to the dispatch code).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ReadFromCluster` cleared the driver announcement from both the context and the query text, but whole-query
dispatch cleared neither from the text. Its own announcement travels in the context, and a worker applies the
query's `SETTINGS` clause on top of that -- so a name written into the query by hand won the tie and the
initiator's choice was overridden. The table the user named, not the one the planner picked, would then read
the file-task queue, silently returning wrong rows rather than failing.

The three helpers that did this were applied ad hoc per path, which is what made it easy to give one path a
subset of the other's. Replace them with one function per kind of remote read, named for that kind, so the
set of normalizations is chosen once:

- `prepareOrdinaryClusterQueryForRemoteExecution` -- downgrade the join mode, drop the driver name
- `prepareWholeQueryDispatchForRemoteExecution`  -- drop the cluster, drop the driver name

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`object_storage_distributed_driver_database` and `_table` tell a worker which table reads from the initiator's
file-task queue. A server that does not know a setting ignores it with a warning unless it is `IMPORTANT`, so
during a rolling upgrade a new initiator could dispatch to an older worker that silently dropped the
announcement, found no driver, read every file of every table, and returned each row once per node.

`IMPORTANT` makes that worker refuse the query instead. The tier bits and the `IMPORTANT` bit are disjoint
(`BaseSettingsHelpers::getTier` masks with `Flags::TIER`), so the settings stay `EXPERIMENTAL`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two mechanisms can put a storage on an initiator's file-task queue: being the announced driver of a
whole-query dispatch, or being an ordinary cluster read under parallel replicas. They were combined with
`||`, so both could answer yes for different tables in the same query -- and there is one queue, holding one
table's files. A partner that qualified for parallel replicas would read the driver's files under its own
schema.

The reachability of that depended on conditions no invariant pinned: the database's own
`object_storage_cluster` leaving `cluster_name_` non-empty on a worker, plus the parallel-replica settings.
Rather than rely on those not lining up, make the dispatch decide alone while it is in effect: its announced
driver consumes the queue, every other table in that query lists its own files.

This is the shape parallel replicas already uses for the same hazard -- one table is chosen, and the
mechanism is explicitly disabled for every other (`PlannerJoinTree.cpp`, `parallel_replicas_table`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The integration test still asserted that a dispatched query contains `icebergS3Cluster(...)` -- the previous
design, where the driver was rewritten into a cluster table function. The current one sends the driver as the
catalog table the user wrote and names it in the settings, so that assertion could only fail. It was not
caught because the branch's `amd_debug` build died in Checkout Submodules and the integration jobs never ran.

Rewrite `_assert_dispatched_whole` around what is now true: a worker received the whole query carrying the
announcement for the expected driver, no query contains a cluster table function, and no secondary query
lacks the JOIN (which is what a table fanning out again from a worker would look like).

In the unit tests the same check was made by proxy -- a fake table function was registered so the plan text
could be searched for it. Replace that with a tripwire on the rewrite hook itself, which is both the thing
being asserted and enough to delete `FakeDriverTableFunction`, its factory registration and its AST surgery.
It is reset per plan so it keeps meaning something for the tests that fall back to an ordinary cluster read,
where calling the hook is correct.

Also corrects comments in the candidate finder and its test that still described the driver as being
rewritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dispatch handed out every file of the driver. The step's own filter is no help -- its output is the whole
query's result, so a predicate over that says nothing about which files are needed -- and nothing went looking
for the driver's predicate elsewhere, so a query selecting one day out of sixty still distributed all sixty
days' files and had each worker open its share only to discard them.

The predicate is in the query tree, in the `WHERE` of the query nodes crossed on the way down to the driver.
Collect those, split them into `and` atoms, keep the atoms whose every column comes from the driver, and build
an `ActionsDAG` from them with `buildFilterInfo` -- the same helper row policies and additional table filters
use. An atom that reads a partner's column, or contains a subquery, is left alone and simply does not prune.

Safe because of two restrictions the candidate finder already enforces: the driver sits on the left spine of
INNER ALL / LEFT joins only, so a driver row dropped here cannot have produced a result row; and every query
node crossed to reach it is partition-preserving, so dropping a row cannot change what the surviving rows
compute. Relaxing either would make this unsound, and the comment says so.

Measured on a 600-file, 60-day-partitioned driver with a one-day window: 600 file tasks before, 10 after, with
the result unchanged. A query with no driver predicate, and one whose only predicate mixes driver and partner
columns, both still distribute all 600.

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

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants