Skip to content

Fix join filter pushdown - #2249

Open
ianton-ru wants to merge 23 commits into
antalya-26.6from
fix/join-filter-pushdown-through-rename
Open

Fix join filter pushdown#2249
ianton-ru wants to merge 23 commits into
antalya-26.6from
fix/join-filter-pushdown-through-rename

Conversation

@ianton-ru

@ianton-ru ianton-ru commented Aug 21, 2026

Copy link
Copy Markdown

Changelog category (leave one):

  • Bug Fix (user-visible misbehavior in an official stable release)

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

Fix join filter pushdown

Documentation entry for user-facing changes

Solved #2245

Push left-only JOIN filters when column names do not match the join header

A left-only WHERE on count() of SELECT * … JOIN was not pushed under the JOIN. The filtered column often disappeared from the JOIN output (unused-column removal after count() of SELECT *), while the Filter DAG still referenced it — sometimes under an identifier name such as __table1.a rather than a. get_available_columns_for_filter required the name to appear in the JOIN header, so splitActionsForJOINFilterPushDown never saw it.

That is not Iceberg-specific. On MergeTree the same shape skipped Prewhere / index analysis on the left read. The filter still ran after the JOIN, so the result was correct but the left table was scanned without the predicate.

JoinStepLogical can also alias a side input (a) to a JOIN-output / filter name (__table1.a). Pushdown matches filter inputs against the available-column list, then fix_predicate_for_join_logical_step remaps aliases back to input names. The output alias has to be listed or the split never runs.

JOIN filter pushdown

In tryPushDownOverJoinStep:

  • A side column stays eligible if the Filter DAG still names it, even when it is missing from the JOIN output.
  • For JoinStepLogical, output actions that fromLeft() / fromRight() are added to that list (including aliases). The existing split and remap then push the predicate under the JOIN.

Covered by 04673_join_filter_pushdown_count_subquery.sql: MergeTree left + Memory right, count() of SELECT * … LEFT JOIN … WHERE foo.a < 40 (and the same with an extra (SELECT * FROM t_left) AS foo wrap). EXPLAIN actions = 1 must contain Prewhere.

icebergCluster file listing

icebergCluster (IStorageCluster) lists files on the initiator. The planner wraps the left cluster table so remotes do not get the JOIN (SELECT cols FROM icebergCluster). That wrap had no WHERE, so initiator listing stayed unfiltered even after JOIN pushdown.

Left-only WHERE / PREWHERE is copied onto the wrap with removeExpressionsThatDoNotDependOnTableIdentifiers (same helper as IStorageCluster::updateQueryWithJoinToSendIfNeeded). Wrap planning runs collectFiltersForAnalysis. Listing-only filters are attached on the wrap source without adding a FilterStep that would drop unused wrap columns.

Covered by test_cluster_join_filter_minmax_pruning.py: IcebergMinMaxIndexPrunedFiles for icebergS3Cluster with a plain WHERE, with JOIN … WHERE, and with outer count() of SELECT * … JOIN … WHERE.

CI/CD Options

Exclude tests:

  • Fast test
  • Integration Tests
  • Stateless tests
  • Stateful 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)

ianton-ru and others added 3 commits August 21, 2026 17:52
…in header

Unused-column removal and `JoinStepLogical` aliases can hide a one-sided `WHERE` from `get_available_columns_for_filter`. Include those names so existing split and remap can push the predicate under the JOIN.

Co-authored-by: Cursor <cursoragent@cursor.com>
…r can prune files

Initiator listing runs on the wrap subquery (`SELECT cols FROM icebergCluster`), which previously had no WHERE. A left-only predicate on `count()` of `SELECT * … JOIN` never reached min/max file listing.

Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the duplicated WHERE walker and the PK-walk-through-JOIN remapping. Wrap listing still uses collectFiltersForAnalysis and tryAddClusterWrapFilter.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Workflow [PR], commit [59af131]

@ianton-ru

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e1b754451

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +996 to +999
if (parent_query->hasWhere())
{
if (auto pred = copy_left_only(parent_query->getWhere()))
wrap_query.getWhere() = std::move(pred);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restrict copied predicates to preserved join sides

When a wrapped remote table is on the null-producing side of an outer join, copying every table-local predicate into its subquery changes join semantics. For example, with a remote right side of a LEFT JOIN, WHERE isNull(r.value) is copied below the join; rows matching a non-null r.value are then removed before the join, become null-extended unmatched rows, and incorrectly pass the original outer predicate. Check parent_join_tree and the join kind/side before copying a predicate, rather than treating every wrapped table expression as safe.

Useful? React with 👍 / 👎.

Comment on lines +996 to +999
if (parent_query->hasWhere())
{
if (auto pred = copy_left_only(parent_query->getWhere()))
wrap_query.getWhere() = std::move(pred);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid evaluating nondeterministic predicates twice

The copied predicate is added to the wrapper while the original remains above the join, and removeExpressionsThatDoNotDependOnTableIdentifiers does not reject nondeterministic expressions. Thus an IStorageCluster join with a left-only condition such as WHERE rand() % 2 = 0 evaluates independent rand calls in the wrapper and again after the join, changing the expected cardinality from roughly one half to one quarter. Listing predicates must not become an additional execution filter unless they are proven safe to duplicate.

Useful? React with 👍 / 👎.

@@ -0,0 +1,86 @@
-- Tags: no-parallel-replicas

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove the unnecessary no- test tag*

The test explicitly executes SET enable_parallel_replicas = 0, so the no-parallel-replicas tag is not needed to establish its required execution mode. Remove the tag rather than excluding the test from that runner, as repository guidance prohibits adding no-* tags unless they are strictly necessary.

AGENTS.md reference: AGENTS.md:L143-L143

Useful? React with 👍 / 👎.

@UnamedRus

Copy link
Copy Markdown
Collaborator

count()

Does it also work for non count cases?
(count carry a lot of optimizations, so can be not representative sometimes)

ianton-ru and others added 3 commits August 24, 2026 11:44
…JOIN

Copying a table-local WHERE such as `isNull(r.x)` under a LEFT JOIN remote right table changes join semantics. Use the same `isLeftOrFull` / `isRightOrFull` sides as JOIN filter pushdown.

Related: #2249
Co-authored-by: Cursor <cursoragent@cursor.com>
Share the existing `and`-conjunct filter in Analyzer/Utils so wrap copy can drop `rand` and similar after the table-local strip. Those conjuncts stay only in the original WHERE above the JOIN.

Related: #2249
Co-authored-by: Cursor <cursoragent@cursor.com>
…wn test

`SET enable_parallel_replicas = 0` already pins the EXPLAIN plan, so skipping the ParallelReplicas suite is not needed.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ianton-ru

ianton-ru commented Aug 24, 2026

Copy link
Copy Markdown
Author

Does it also work for non count cases?

Issue (and fix) also cover cases with any aggregation functions that do not use columns from 'where' condition. I have column foo.time in my repro, and have the same for SELECT max(time) FROM (SELECT ...). Or SELECT sum(1) FROM (SELECT ...).

Combination of join, select from select and cluster function removed where condition for pruning in subquery, because thought it includes only 'unused' columns.

@ianton-ru

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 78a27b3767

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Planner/PlannerJoinTree.cpp Outdated
Comment on lines +1021 to +1022
if (const auto * parent_query = select_query_info.query_tree->as<QueryNode>();
parent_query && joinTreePreservesRowsForTable(parent_query->getJoinTree(), original_table_expression))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Honor ASOF and PASTE restrictions before copying predicates

The new guard excludes null-producing outer-join sides, but it still admits join shapes where prefiltering changes which rows are joined. For example, when the remote right side of an INNER ASOF JOIN has a right-only WHERE, copying it into the wrapper can discard the nearest match and select an older matching row instead; similarly, prefiltering either side of a PASTE JOIN shifts positional alignment. The normal pushdown path in filterPushDown.cpp explicitly disables right-side ASOF pushdown and all PASTE pushdown, so this wrapper path needs equivalent restrictions before copying the predicate.

Useful? React with 👍 / 👎.

Comment thread src/Planner/PlannerJoinTree.cpp Outdated
Comment on lines +1027 to +1028
removeExpressionsThatDoNotDependOnTableIdentifiers(cloned, original_table_expression, query_context);
removeExpressionsThatAreNotDeterministicInScopeOfQuery(cloned, query_context);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exclude stateful predicates from the copied filter

This removes functions that are nondeterministic within a query, but stateful functions can still report themselves as deterministic; aiEmbed, for example, returns true from isDeterministicInScopeOfQuery while documenting that each call consumes quota and performs a potentially expensive external request. In an IStorageCluster join, such a WHERE is consequently evaluated in the wrapper and again above the join, doubling external calls and potentially exceeding ai_function_max_api_calls_per_query. The ordinary filter-pushdown path avoids this through ActionsDAG::hasStatefulFunctions, so copied predicates should apply the same exclusion.

Useful? React with 👍 / 👎.

ianton-ru and others added 4 commits August 24, 2026 14:35
Prefiltering those sides changes nearest-match and positional pairing. Skip the same sides for wrap listing so icebergCluster cannot drop the matching file either.

Co-authored-by: Cursor <cursoragent@cursor.com>
Functions such as `aiEmbed` and `timeSeriesStoreTags` can be deterministic in a query while still having side effects. Skip them in the cluster wrap the same way JOIN filter pushdown uses `hasStatefulFunctions`.

Co-authored-by: Cursor <cursoragent@cursor.com>
Wrap copy restated the same outer/`ASOF`/`PASTE`/`FULL` checks as filter
pushdown, and stripped wrap-unsafe conjuncts in two walks.

Co-authored-by: Cursor <cursoragent@cursor.com>
…andom settings

`EXPLAIN` `Prewhere` is absent when parallel replicas rewrite the plan or when
`optimize_move_to_prewhere` / `query_plan_optimize_prewhere` are randomized off.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ianton-ru

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 20181690d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

auto copy_left_only = [&](const QueryTreeNodePtr & predicate) -> QueryTreeNodePtr
{
auto cloned = predicate->clone();
removeExpressionsThatDoNotDependOnTableIdentifiers(cloned, original_table_expression, query_context);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject bare predicates from the other join side

When an eligible IStorageCluster table participates in an inner join and the outer predicate is a bare column from the other side, such as WHERE r.flag, this call leaves the predicate unchanged: filterConjunctions returns immediately when its root is not a FunctionNode, so removeExpressionsThatDoNotDependOnTableIdentifiers never invokes its dependency check. The predicate is then attached to the cluster-only wrapper despite referencing r, which is absent from that wrapper's FROM clause, causing a valid join query to fail during planning; apply the keep predicate to non-function roots as well.

Useful? React with 👍 / 👎.

`filterConjunctions` skipped a ColumnNode root, so `WHERE r.flag` was copied
onto the `IStorageCluster` wrap and planning failed.

Related: #2249
Co-authored-by: Cursor <cursoragent@cursor.com>
@ianton-ru

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a1d4502159

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Storages/IStorageCluster.cpp Outdated
Comment on lines 103 to 104
if (extension && !(predicate && !extension_has_predicate))
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rebuild the iterator when the pushed predicate changes

In the new wrapped-IStorageCluster path, tryAddClusterWrapFilter calls applyFilters while constructing the branch, before the overall query-plan optimizer can push additional predicates from enclosing subqueries down to the same source. If that early call creates an extension with predicate a, a later applyFilters carrying a AND b reaches this guard with both extension and extension_has_predicate set and is ignored, so object-storage listing never benefits from b (for example, an outer WHERE around the wrapped JOIN). Track predicate/filter changes or recreate the extension on subsequent filter applications rather than only for the null-to-non-null transition.

Useful? React with 👍 / 👎.

ianton-ru and others added 2 commits August 24, 2026 19:29
`canPrefilterJoinSide` only marks ordinary columns unavailable on the
null-producing side. Attaching a rewritten equivalent-key filter to that
child is still `allow_push_down_to_right` (dictionary / lookup / `ASOF`).

Related: #2249
Co-authored-by: Cursor <cursoragent@cursor.com>
…te changes

The cluster JOIN wrap applies the copied `WHERE` before the optimizer can push
an outer filter, so listing used to keep `a` and miss `a AND b`.

https: //github.com//pull/2249
Co-authored-by: Cursor <cursoragent@cursor.com>
@ianton-ru

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d9ebb0b72e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Analyzer/Utils.cpp Outdated
node,
[](const FunctionBasePtr & function_base)
{
return function_base->isDeterministicInScopeOfQuery() && !function_base->isStateful();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject server constants when duplicating predicates

When an IStorageCluster join predicate contains a server-local constant such as hostName, this check treats it as safe because the function is deterministic within a query and non-stateful. The original predicate is evaluated on the initiator after the join, but its copied wrapper predicate is serialized and evaluated on each remote server, where hostName can return a different value; for example, WHERE hostName() = '<initiator>' can pass locally while the wrapper removes every remote row. Also reject functions whose isServerConstant flag is set before copying the conjunct.

AGENTS.md reference: AGENTS.md:L7-L7

Useful? React with 👍 / 👎.

The wrap `WHERE` is sent to remote cluster nodes, where `hostName` can differ
from the initiator and drop every row.

https: //github.com//pull/2249
Co-authored-by: Cursor <cursoragent@cursor.com>
@ianton-ru

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf210cbe24

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/Analyzer/Utils.cpp Outdated
Comment on lines +1217 to +1219
return function_base->isDeterministicInScopeOfQuery()
&& !function_base->isStateful()
&& !function_base->isServerConstant();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep node-local dictionary calls off remote wrappers

When the predicate calls dictGet or another external-dictionary function, this check accepts it: those functions inherit the IFunction defaults of deterministic-in-query, non-stateful, and non-server-constant. The copied predicate is then evaluated by the remote IStorageCluster wrapper even though the original predicate runs on the initiator; if the dictionary exists only on the initiator the formerly valid query now fails remotely, and if replicas have different dictionary contents rows can be filtered incorrectly before the join. Treat functions that depend on node-local external state as unsafe to duplicate across servers.

Useful? React with 👍 / 👎.

The wrap `WHERE` runs on remote cluster nodes. `dictGet`, `joinGet`, and other
non-deterministic functions must stay on the initiator so a missing dictionary
or different node-local state cannot drop rows or fail the query.

https: //github.com//pull/2249
Co-authored-by: Cursor <cursoragent@cursor.com>
@ianton-ru

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 18a667b557

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ianton-ru
ianton-ru marked this pull request as ready for review August 25, 2026 10:14
@ianton-ru

Copy link
Copy Markdown
Author

Build failed, cause some CI issues:

Source:
   error sending request for url (https://s3.us-east-1.amazonaws.com/altinity-build-artifacts/ccache/sccache/.sccache_check): client error (Connect): dns error: failed to lookup address information: Try again: failed to lookup address information: Try again

@ianton-ru

Copy link
Copy Markdown
Author

@blau-ai

@blau-ai

blau-ai commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

CI triage for #2249fix/join-filter-pushdown-through-rename

Verdict: 0 of the failing checks are caused by this PR. All reds are either infra/flaky or pre-existing base-branch (antalya-26.6) issues. The PR's own code looks healthy — see the health check at the bottom.

Head SHA analyzed: 523f4a0 (merge of antalya-26.6 into the branch).

Check Class PR-caused?
Stateless (amd_asan_ubsan, distributed plan, parallel, 1/2) + all DROPPED jobs + PR gate infra / flaky No
Regression release iceberg_2 pre-existing (base branch) No
Regression release s3_export_part pre-existing (base branch) No
GrypeScanServer / -alpine infra (base-image CVE) No

1. Stateless tests (amd_asan_ubsan, distributed plan, parallel, 1/2) — infra/flaky

The only real failure in the PR workflow; everything marked DROPPED (integration, stress, other stateless shards, install/compat, AST fuzzer, SQLLogic/SQLStorm…) and the top-level PR gate are just downstream of it.

The failing step is "Start ClickHouse Server", not a test. The server's clickhouse-server.err.log shows two runner-environment problems during setup:

  • Port conflicts — a leftover server was still holding the ports:
    Listen [0.0.0.0]:9000 failed: ... Address already in use: 0.0.0.0:9000
    (same for 8123, 8443, 9181, 9009, 9010, 9440, 9022, 9004, 9005, 9988)
    
  • DNS failure to the dataset bucket while loading the TPCH fixtures in setup:
    DNSResolver: Cannot resolve host (clickhouse-datasets.s3.amazonaws.com), error 0: DNS error
    ... in query: INSERT INTO tpch.nation SELECT * FROM
        s3('https://clickhouse-datasets.s3.amazonaws.com/h/1/nation.tbl', NOSIGN, CSV)
    

Neither has anything to do with the Analyzer/join changes. The two sibling shards on the same build were green — amd_debug, parallel (10998 passed) and amd_asan_ubsan …, 2/2 (5509 passed) — as were both AST fuzzers and unit tests (12115 passed).

Fix: just re-run the PR workflow (or that one shard). No code change needed.

2. Regression iceberg_2 and s3_export_part — pre-existing, base branch

Both are EXPORT PARTITION / EXPORT PART partition-compatibility tests. This PR changes no export code — its diff is entirely join-filter-pushdown (src/Analyzer/Utils.*, src/Planner/*, filterPushDown.cpp, IStorageCluster.*, FunctionJoinGet.cpp) plus new stateless tests 0467304678.

  • s3_export_part.../export part/error handling/different partition key: the query is still rejected with exit code 36, but the message changed, so the string assertion fails:
    assert "Tables have different partition key" in results[0].output
    actual: Code: 36. ... the destination partition expression uses column 'p',
            which is not part of the source MergeTree partition key. (BAD_ARGUMENTS)
    
  • iceberg_2 → 6 scenarios under .../partition compatibility/rejected/ (reversed order, transform-vs-identity, truncate width mismatch, field-count mismatch, unsupported intDiv expression, unpartitioned destination): each expects exit code 36 (rejected) but now gets exit code 0 (accepted).

That loosening of partition-compatibility comes from base-branch work already merged into antalya-26.6#2253 (export-partition-monotonic-compat) and #2229 (allow_non_matching_schema_export_partition_by_position) — which the regression suite exercises via the merge commit. The EXPORT PARTITION "rejected" expectations and the error-message assertion are simply stale against the new behavior.

Fix (not this PR): the export-partition regression expectations need updating to match #2253/#2229 — belongs to the export-partition feature owners. Worth confirming these two suites are already red on antalya-26.6 itself.

3. GrypeScanServer (server / -alpine) — infra

Container-image CVE scans of the built server image (-alpine reports 1 high/critical). Independent of the C++ source — this PR changes no image or dependencies. Track/waive with the image maintainers; not a blocker for this PR.


Health check

The join-filter-pushdown change itself is in good shape: Fast test (9305 passed), all builds, unit tests (12115 passed), both AST fuzzers, and the two stateless shards that actually ran are green — including amd_debug, parallel, which exercises the PR's new 0467304678 cluster-wrap/pushdown tests. The only obstacles to a green board are a runner flake (re-run) and two categories of red that pre-date this PR (export-partition regression expectations on the base branch, and base-image CVE scans). I'd re-run the PR workflow and, separately, get the export-partition regression expectations refreshed against antalya-26.6.

@blau-ai · analysis only; no code changes made

@k-morozov
k-morozov self-requested a review September 1, 2026 15:30
'TSV',
'n UInt64') AS l
LEFT JOIN t_04677_right AS r ON l.n = r.n
WHERE l.n < 2 AND hostName() = hostName();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What is the goal of hostName() = hostName() ? In what cases it would be false?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It's a check that 'always true' part is properly removed from 'where' condition. See #2249 (comment)
If not removed, pruning can't be executed on initiator.
Add a test that hostName really removed from condition.

'TSV',
'n UInt64') AS l
LEFT JOIN t_wrap_right AS r ON l.n = r.n
WHERE l.n < 2 AND timeSeriesStoreTags(l.n, []) = l.n;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I need to investigate this message from ai, but seems it makes seanse.

this test does not distinguish whether the fix is present. count() is 2 either way (timeSeriesStoreTags(l.n, []) = l.n doesn't change the row set, and the cluster is single-node, so the "tags stored twice" effect is invisible). With removeExpressionsThatAreUnsafeToDuplicate turned into a no-op, the test still passes.

Suggestion: assert the side effect itself (number of tag sets in the target TimeSeries table — one row per key, not two), or use EXPLAIN on the wrap subquery to check that timeSeriesStoreTags is absent from its WHERE while n < 2 is present.

Comment thread src/Analyzer/Utils.cpp Outdated
{
if (function->isWindowFunction() || function->isAggregateFunction())
return false;
if (function->isOrdinaryFunction())

@k-morozov k-morozov Sep 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What we should to do if function not isOrdinaryFunction ? Just ignoring?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Window and aggregate functions are checked above, so here can be only ordinary or unresolved functions.
But all functions must be resolved when this method is called.
I rewrite test to be more clean, without logic change.

return;

auto filter_dag = filter_actions->clone();
const auto filter_column_name = filter_dag.getOutputs().at(0)->result_name;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: I prefer to check size before use at if we receive vector outside.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Add an exception

Comment thread src/Analyzer/Utils.cpp
Comment on lines +1218 to +1223
return function_base->isDeterministic()
&& function_base->isDeterministicInScopeOfQuery()
&& !function_base->isStateful()
&& !function_base->isServerConstant()
&& !functionIsDictGet(function_base->getName())
&& !functionIsJoinGet(function_base->getName());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

dictGet implementations already return false from isDeterministic(), and this PR does the same for the resolved FunctionJoinGet. Can we remove the explicit functionIsDictGet and functionIsJoinGet checks here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I'm not sure about all dictGet family.
For dictGetDescendants and dictGetChildren FunctionDictGetDescendantsOverloadResolverImpl::buildIml returns FunctionDictGetDescendantsBase, which does not override isDeterministic.
I don't know is it bug or not.
This additional checks do not make something worse.


if (extension)
return;
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

createExtension is no longer one-shot: if (extension) return; was replaced by a rebuild whenever filter_hash changes.
On the icebergCluster JOIN wrap path a single ReadFromCluster builds the task iterator twice: first in tryAddClusterWrapFilter for the copied predicate a, then in optimizePrimaryKeyConditionAndLimit with the final a AND b (different hash) a second time. Even without an outer WHERE this can repeat if the hash of the renamed/merged DAG differs from the hash of the DAG that optimizePrimaryKeyConditionAndLimit composes from the same a.

For Iceberg this is not free: the IcebergIterator constructor is not lazy — it walks the delete manifests synchronously and starts a producer thread over the data manifests. The first iterator does metadata I/O before it gets replaced.

Could we build the iterator once in initializePipeline, after filter pushdown is complete? The pruning test only checks the final predicate and does not catch the repeated initialization.

ianton-ru and others added 2 commits September 2, 2026 17:37
Later empty `applyFilters` must not drop a wrap `WHERE`, and recreating the iterator on each hash change listed twice. Keep the first non-empty DAG in `applyFilters` and create the extension only in `initializePipeline`.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ianton-ru

Copy link
Copy Markdown
Author

@blau-ai

@blau-ai

blau-ai commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

CI triage — 7 failing checks: 0 PR-caused, 5 infra, 2 pre-existing (+ regression flake)

Short version: nothing here points at this PR's code. The build is green everywhere, the PR's own new tests pass, and every red check is explained by infra (OOM / lost disk lease / failed git clone / fewer CPU cores on the runner) or by a crash that reproduces identically on unrelated PRs. Safe to re-run the infra ones.

Pre-existing (not this PR)

Integration tests (amd_asan_ubsan, targeted) — 12/13 and Integration tests (amd_asan_ubsan, db disk, old analyzer, 4/8) — 12/1090
All failures are test_storage_iceberg_with_spark/test_schema_inference.py. The server crashes on the first SELECT * FROM <iceberg parquet table>:

Code: 32. DB::Exception: Attempt to read after eof ... 172.16.2.12:9000 (ATTEMPT_TO_READ_AFTER_EOF)
...then every subsequent query: Code: 210 ... Connection refused (172.16.2.12:9000) (NETWORK_ERROR)

i.e. the ClickHouse node dies on the data read and never comes back. This is not a join/filter-pushdown code path — it's a bare SELECT *, single node, no WHERE, no JOIN, no cluster function, and DESC (schema inference) passes just before it. Decisive evidence it's a base-branch bug: the identical 12 test_schema_inference failures appear on unrelated PRs #2289 (alter add column) and #2294 (partition export), which touch nothing in common with #2249.
Note: the PR's own new test in that directory, test_cluster_join_filter_minmax_pruning.py, is not in the failure list (it runs before the crash and passes).
→ Worth a separate issue against antalya-26.6 for the iceberg-read crash; nothing to fix in this PR.

Infra / environment (safe to re-run)

Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 1/2) — 31 failed
The cas_s3 asan runner was resource-starved. The 31 failures are a scatter of unrelated tests, all with infra causes:

  • MEMORY_LIMIT_EXCEEDEDwould use 17.0 GiB … maximum: 15.3 GiB (OOM), e.g. 02122_parallel_formatting_*, 02918_*, 02183_combinator_if;
  • content-addressed disk 'cas_s3' -- mount lease not held … TRANSIENT unavailability (Code 210) — the CAS-S3 backend lost its lease mid-run, e.g. 01505_*, 04054_*, 03217_filtering_in_storage_merge, 03279_pr_3_way_joins_right_first, 01710_projection_array_join (these are disk errors, not result diffs);
  • Connection reset by peer — server restart after the OOM;
  • CPU-count-dependent EXPLAIN diffs — reference expects 12 threads, runner produced 8/4 (00172_early_constant_folding, 04324_limit_by_partition_and_in_order).

The same tests pass on amd_binary, cas s3 storage, amd_binary, cas storage, amd_debug, and all distributed plan configs — only this one starved runner failed.

Stateless tests (amd_asan_ubsan, cas s3 storage, parallel, 2/2) — 3 failed

  • 03402_fix_pipe_resize_with_two_level_hash — reruns 6/6 passed → transient;
  • 02981_insert_select_resize_to_max_insert_threads — reruns 0/3 reproduced → flaky;
  • 04105_limit_by_into_sort_explain — reproducible, but the only diff is stream multiplicity (× 12× 8, 24 → 16); plan shape is unchanged. This is a CPU-count mismatch in a pre-existing LIMIT BY into sort test (untouched by this PR), not a regression.

SQLLogic test — step "Clone sqllogictest repo" failed → external git clone infra.
SQLStorm test — step "Clone SQLStorm repo" failed → external git clone infra.
Source upload — step "Checkout submodules" failed → submodule fetch infra.

Regression release swarms — 1 scenario of 1521 failed (plus oauth retry noise). This is the testflows regression suite; unrelated to a query-planner change — treat as flake and re-run.

Health check

Everything that reflects this PR's code is green: all builds (amd_debug/asan_ubsan/binary/release, arm_release), Fast test, Unit tests, AST fuzzer, BuzzHouse, Stress tests, and the full stateless matrix on amd_debug (parallel + sequential + distributed-plan + s3) and amd_binary (cas / cas-s3). The PR's new stateless tests 0467304678 and the new integration test test_cluster_join_filter_minmax_pruning.py all pass. No code change is warranted from these CI results — re-running the infra-flaky jobs should clear them; the iceberg-read crash is a base-branch issue to track separately.

(Automated triage from the praktika S3 reports; I can't build/run ClickHouse in this job, so classifications are evidence-based from CI logs.)

@Selfeer

Selfeer commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

I have a pretty list of issues for this from couple of audit review rounds @ianton-ru

Audit review — PR #2249 "Fix join filter pushdown"

Repository: Altinity/ClickHouse · Base: antalya-26.6 · Head reviewed: f459b9871db29ec4591416b252822f0b864f2e22
Method: static audit (call-graph + transition matrix + logical fault injection). Nothing was executed at runtime.

Scope of the change: JOIN filter pushdown when side column names do not match the JOIN header
(filterPushDown.cpp), copying a left-only WHERE/PREWHERE onto the IStorageCluster JOIN wrap
(PlannerJoinTree.cpp, Analyzer/Utils.cpp), and reworked file-listing predicate handling in
ReadFromCluster (IStorageCluster.cpp).


Confirmed issues

1. EXPLAIN now prints cluster credentials in plaintext — High

Where: src/Storages/IStorageCluster.cpp:90-95, new ReadFromCluster::describeActions.

void ReadFromCluster::describeActions(FormatSettings & format_settings) const
{
    SourceStepWithFilter::describeActions(format_settings);
    if (query_to_send)
        format_settings.out << format_settings.detail_prefix << "Query: " << query_to_send->formatWithSecretsOneLine() << '\n';
}

What happens: formatWithSecretsOneLine is the "show everything" formatter — it calls
formatWithPossiblyHidingSensitiveData with show_secrets = true hardcoded. Everywhere else in the
server, user-facing query text goes through formatWithPossiblyHidingSecrets, which only reveals
secrets when both the format_display_secrets_in_show_and_select setting is on and the caller
holds the displaySecretsInShowAndSelect grant. This new code path bypasses both checks.

query_to_send is not a sanitized query. IStorageCluster::read runs it through
updateQueryToSendIfNeeded, which (for object-storage cluster reads) rewrites the table expression
using configuration->createArgsWithAccessData() — i.e. it deliberately injects the URL, access key
id and secret access key as literal table-function arguments so remote nodes can authenticate.

Why it is a defect: this is a fail-open on a privilege-gated path. The existing masking machinery
in ASTFunction (which hides the credential arguments of s3/s3Cluster/iceberg* when
show_secrets is false) is explicitly disabled, so the secret is printed even to users who were
never granted permission to see it.

Smallest trigger: an administrator creates a table over object storage with embedded credentials
and a cluster setting, for example
CREATE TABLE t (...) ENGINE = S3('https://bucket/data.parquet', 'AKIA…', 'secret…') SETTINGS object_storage_cluster = 'swarm'.
Any user with only SELECT on t then runs EXPLAIN actions = 1 SELECT * FROM t and reads the
secret access key from the Query: line. The same applies to a view wrapping an
icebergS3Cluster(...) / s3Cluster(...) table function.


2. Partial ("disjunction") JOIN filter pushdown can build a FilterStep referencing a column that does not exist on that side — High

Where: src/Processors/QueryPlan/Optimizations/filterPushDown.cpp:624-650, the new
logical_join->getOutputActions() loop inside get_available_columns_for_filter.

if (require_stable_types)
{
    auto resolved = output_action.resolveAliases();
    if (resolved.getNode()->type != ActionsDAG::ActionType::INPUT
        || !input_header->has(resolved.getColumnName()))
        continue;
    ...
}

try_add(output_name);          // <-- adds the JOIN-output name, not resolved.getColumnName()

What happens: the require_stable_types = true variant of this list feeds the partial-predicate
path further down:

Names left_stream_stable_columns_to_push_down = get_available_columns_for_filter(
    true, left_stream_filter_push_down_input_columns_available, /*require_stable_types=*/true);
...
auto left_partial_filter_dag = tryToExtractPartialPredicate(
    filter->getExpression(), filter->getFilterColumnName(), left_stream_stable_columns_to_push_down);
if (left_partial_filter_dag.has_value())
    addFilterOnTop(*child_node, 0, nodes, std::move(*left_partial_filter_dag));

The code validates that the output action resolves to an input present on the side
(resolved.getColumnName(), e.g. bid) but then publishes the JOIN-output name
(output_name, e.g. __table1.bid) as an available column. tryToExtractPartialPredicate matches
available_columns against the filter DAG's INPUT names, so the extracted predicate keeps
__table1.bid as its input. addFilterOnTop — as the comment right above this call site itself
warns — constructs the FilterStep directly against the JOIN child's header with no alias
remapping (fix_predicate_for_join_logical_step is only applied on the main pushdown path). That
header contains bid, not __table1.bid.

FilterStep's header computation goes to ActionsDAG::updateHeader, which calls
evaluatePartialResult(..., throw_on_error = true) and raises NOT_FOUND_COLUMN_IN_BLOCK for an
INPUT node that has no matching column in the block. The whole point of this PR is that the rename
case (bid on the stream vs __table1.bid in the JOIN output) is real, so this list can now
legitimately contain a name the child stream does not have. Before the change the loop only ever
emitted names taken from the side's own input header, so this could not happen.

Why it is a defect: an optimizer pass constructs a structurally invalid plan step and the query
fails with an exception, where previously the optimization was simply skipped. use_join_disjunctions_push_down
defaults to true, so no non-default setting is needed.

Smallest trigger: a JoinStepLogical query where (a) the side column is exposed under a
different name in the JOIN output, and (b) the filter above the JOIN is not fully consumed by the
main pushdown (so filter is still non-null when the partial-predicate block runs) — e.g. a
WHERE combining a pushable left-only predicate with a non-pushable one over the same JOIN shape
used by 04673_join_filter_pushdown_count_subquery.sql.


3. Cluster file-listing predicate is frozen at plan-build time and never improved — Medium

Where: src/Storages/IStorageCluster.cpp:99-107 (ReadFromCluster::applyFilters) together with
src/Planner/PlannerJoinTree.cpp:295-299 (tryAddClusterWrapFilter).

void ReadFromCluster::applyFilters(ActionDAGNodes added_filter_nodes)
{
    SourceStepWithFilter::applyFilters(std::move(added_filter_nodes));
    if (!filter_actions_dag || listing_filter_dag)
        return;                       // later, richer predicates are discarded

    listing_filter_dag = filter_actions_dag;
    ...
}
    source->addFilter(std::move(filter_dag), filter_column_name);
    source->SourceStepWithFilterBase::applyFilters();   // runs during plan construction

What happens: tryAddClusterWrapFilter deliberately triggers applyFilters while the plan is
still being built, using the predicate obtained from the dummy-storage analysis
(collectFiltersForAnalysis). That call is therefore the first non-empty one, so
listing_filter_dag is pinned to it. Every later applyFilters — including the one the optimizer
issues from optimizePrimaryKeyConditionAndLimit after real filter push-down — hits the
listing_filter_dag early return and is ignored. createExtension was also changed to
unconditionally return when extension already exists, so there is no second chance at pipeline
initialization either.

Why it is a defect: the listing predicate, which is exactly what drives min/max and partition
pruning for icebergCluster/s3Cluster, is now whatever the dummy-plan pass happened to derive at
planning time. Any predicate that only materialises later in optimization (conditions relocated into
PREWHERE, virtual-column _path/_file conditions added by later passes, predicates that appear
after JOIN reordering) silently does not participate in pruning. The immediately preceding commit in
this same PR (d9ebb0b7, "Rebuild IStorageCluster listing when a later applyFilters predicate
changes") added handling for precisely this ordering problem, and the final commit removed it again
while keeping the integration-test comment that documents the requirement
(test_cluster_join_filter_minmax_pruning.py: "listing must be rebuilt or the extra file with
bid=4 / datetime=2024-01-01 is not pruned"
).

This is a pruning/efficiency defect, not a wrong-results defect: an over-broad listing still returns
correct rows.


4. tryAddClusterWrapFilter attaches to any leftmost filterable source, not just cluster reads — Medium

Where: src/Planner/PlannerJoinTree.cpp:258-300.

QueryPlan::Node * node = query_plan.getRootNode();
while (node && !node->children.empty())
    node = node->children.front();

auto * source = node ? dynamic_cast<SourceStepWithFilter *>(node->step.get()) : nullptr;
if (!source)
    return;

What happens: the only guard is "leftmost leaf of the wrap plan happens to be a
SourceStepWithFilter". But wrap_read_columns_in_subquery is not exclusive to IStorageCluster:
buildQueryPlanForJoinTree also passes is_remote for any non-leftmost remote table expression, and
IStorageCluster::read itself can fall back to a plain (non-cluster) read via readFallBackToPure.
So the new code can attach a filter DAG and force an eager applyFilters() onto steps such as
ReadFromObjectStorageStep or ReadFromMergeTree.

Why it is a defect: applyFilters is not a neutral query on these steps.
ReadFromObjectStorageStep::applyFilters runs prepareEagerKeyConditionSets and
buildSetsForDAGExcludingGlobalIn, i.e. it builds IN sets (executing subqueries) during plan
construction rather than during optimization — including for plain EXPLAIN, which otherwise does
not touch data. ReadFromMergeTree::applyFilters is one-shot by construction:

void ReadFromMergeTree::applyFilters(ActionDAGNodes added_filter_nodes)
{
    ...
    if (!indexes)
    {
        ... // builds filter_actions_dag and index analysis
    }
}

Once the eager call populates indexes, the optimizer's later applyFilters with the real
push-down predicate is a no-op, so index/partition analysis stays pinned to the dummy-analysis DAG.
Additionally, ActionsDAG::mergeInplace matches the wrap filter's inputs to the rename DAG's
outputs by name only, with no type check, and keeps unmatched inputs as inputs of the merged DAG
— so a filter input that maps to neither a physical header name nor a known column identifier is
silently carried into filter_actions_dag as a dangling input.


5. LOGICAL_ERROR used for a condition the planner should tolerate — Low

Where: src/Planner/PlannerJoinTree.cpp:274-275.

if (filter_dag.getOutputs().size() != 1)
    throw Exception(ErrorCodes::LOGICAL_ERROR, "Filter DAG must have single output");

Today ActionsDAG::buildFilterActionsDAG defaults to single_output_condition_node = true, so the
DAG stored in TableExpressionData::filter_actions always has exactly one output and this branch is
unreachable. It is nevertheless a latent hard failure in the planner for a shape that every other
consumer of the same DAG handles by simply taking getOutputs().at(0) (see
ReadFromCluster::createExtension and the post_filter handling in Planner.cpp). If the
single_output_condition_node default ever changes, or a caller stores a multi-output DAG, planning
throws instead of degrading.


6. Dead / misleading filter_input_names relaxation — Low

Where: src/Processors/QueryPlan/Optimizations/filterPushDown.cpp:573-575, 602, 632.

NameSet filter_input_names;
for (const auto * input_node : filter->getExpression().getInputs())
    filter_input_names.emplace(input_node->result_name);
...
if (!in_join_output && (require_stable_types || !filter_input_names.contains(name)))
    continue;

tryPushDownFilter guarantees that parent_node is the FilterStep and child_node its only
child, so the filter's input header is the JOIN's output header, and a FilterStep's DAG inputs
must be a subset of its input header. Therefore filter_input_names is a subset of join_header
and both !in_join_output && filter_input_names.contains(name) (line 602) and
!join_header->has(output_name) && filter_input_names.contains(output_name) (line 632) can never be
true. The actual fix for the reported bug comes from the getOutputActions() loop matching names
that are in join_header.

The relaxation is therefore either dead code, or — if it ever does fire — it means the plan is
already inconsistent, and in that case the type-compatibility check is skipped precisely where it
would be needed (there is no join_header type to compare against, and the legacy JoinStep path
has no type fix-up). Either way the condition should not be there in this form.


7. New "unsafe to duplicate" filter does not cover IN <local table / dictionary> — Low

Where: src/Analyzer/Utils.cpp:1212-1225 (isSafeToDuplicateInQueryTree), used by
removeExpressionsThatAreUnsafeToDuplicate and applied in
IStorageCluster::updateQueryWithJoinToSendIfNeeded.

The helper's documented contract (src/Analyzer/Utils.h) is that node-local constructs "must stay on
the initiator: remotes can miss the dictionary, see different data, or return a different
server-local value". The walk rejects QUERY/UNION children, non-ordinary functions, and functions
that are non-deterministic / stateful / server-constant / dictGet / joinGet. A conjunct such as
WHERE l.n IN some_local_table contains only an ordinary in function plus a TableNode child, so
it passes the filter and is shipped to remote nodes that may not have that table or dictionary.

This is not a regression (the pre-existing removeExpressionsThatDoNotDependOnTableIdentifiers did
not catch it either), but it is a gap in the newly introduced safety check.

8. FunctionJoinGet::isDeterministic() = false is an unrelated global behavior change — Low

Where: src/Functions/FunctionJoinGet.cpp:85.

JoinGetOverloadResolver::isDeterministic() already returned false before this PR, so the new
override on the IFunctionBase makes the two consistent — but IFunctionBase::isDeterministic is
consulted well beyond this PR's scope (key/index condition analysis, optimize_move_to_prewhere,
projection matching, query-result cacheability). Any query using joinGet in a filter may therefore
change plan or caching behavior. The change is also redundant for this PR's purpose, since
isSafeToDuplicateInQueryTree already checks functionIsJoinGet(function_base->getName())
explicitly.

ianton-ru and others added 4 commits September 3, 2026 13:41
`formatWithSecretsOneLine` printed injected access keys to any user running `EXPLAIN actions = 1`. Use the same privilege-aware formatter as `SHOW CREATE`.

Co-authored-by: Cursor <cursoragent@cursor.com>
`addFilterOnTop` applies the extracted DAG to the child header, so identifier-renamed inputs such as `__table1.a` must be rewritten to physical names first.

Co-authored-by: Cursor <cursoragent@cursor.com>
Empty later `applyFilters` still must not drop wrap `WHERE`, but a later non-empty DAG (outer `datetime` after JOIN wrap) has to tighten initiator file listing without recreating the iterator.

Co-authored-by: Cursor <cursoragent@cursor.com>
Remote `Distributed` wraps and cluster fallback sources must keep the optimizer's later `applyFilters` so MergeTree index analysis is not pinned to the dummy-analysis DAG.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ianton-ru

Copy link
Copy Markdown
Author

Add fixes for high and medium issues from #2249 (comment)

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants