Skip to content

Fix missing materialized-CTE gate when a Merge table has several children - #113558

Open
alexey-milovidov wants to merge 1 commit into
masterfrom
fix-materialized-cte-gate-merge
Open

Fix missing materialized-CTE gate when a Merge table has several children#113558
alexey-milovidov wants to merge 1 commit into
masterfrom
fix-materialized-cte-gate-merge

Conversation

@alexey-milovidov

Copy link
Copy Markdown
Member

Related: #113184
Related: #113489
Related: #111194
Related: #113043
Related: #108924

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 into CHANGELOG.md):

Fixed the LOGICAL_ERROR Reading from materialized CTE '...' before its materialization completed - DelayedPortsProcessor gate is missing in the query plan raised when a Merge table with more than one child reads a materialized CTE that the outer query references.

Description

ReadFromMerge::createChildrenPlans optimizes every child plan on its own, and resolveMaterializingCTEs claims a materialized CTE globally through MaterializedCTE::is_materialization_planned. The first child plan to be optimized therefore moved the CTE's plan into its own tree, and every other DelayedMaterializingCTEsStep for that CTE - in the sibling children and in the outer plan - degenerated into a gate-less MaterializingCTEsStep. The CTE's writer then sat in one child's pipeline while readers sat in another, with no DelayedPortsProcessor between them, so a sibling's in-place IN-set build read the storage while it was still empty and ReadFromMemoryStorageStep raised the exception (in debug and sanitizer builds it aborts the server).

Reproducer on master, deterministic (20/20), also with max_threads = 1, and a plain EXPLAIN is enough because the set is built during plan optimization:

SET enable_analyzer = 1, enable_materialized_cte = 1;

CREATE TABLE t (x UInt64) ENGINE = MergeTree ORDER BY x;
INSERT INTO t SELECT number FROM numbers(10);
CREATE TABLE tdist AS t ENGINE = Distributed(test_shard_localhost, currentDatabase(), t);
CREATE VIEW tconst AS SELECT toUInt64(1) AS x;

WITH t AS MATERIALIZED (SELECT number AS c FROM numbers(2))
SELECT count() FROM merge(currentDatabase(), '^(tconst|tdist)$')
WHERE (x IN (t)) AND (x NOT IN (t));

Children are visited in table-name order, so tconst is planned first and claims the CTE, and the Distributed child that follows builds the set in place while the CTE is unbuilt. Renaming so the Distributed child sorts first makes the same query pass, which is what pins the mechanism.

Fix. A child plan no longer claims a CTE that the outer query references as well. removeDelayedMaterializingCTEsStepFor strips those steps from the child plan before it is optimized, leaving the outer plan - whose MaterializingCTEsStep sits above the whole merge - as the single owner that gates every child. This is the same reasoning DelayedCreatingSetsStep::makePlansForSets already applies to pre-built IN-subquery plans. The set of CTEs to strip comes from walking the outer query_info.query_tree, so a CTE defined inside one child (a View with its own WITH ... AS MATERIALIZED) is left owned by that child - it is the only reader, and stripping it unconditionally would leave it with no materialization at all.

Validation. New 04811_materialized_cte_merge_child_gate covers the failing child order, the explicit-subquery form, a satisfiable predicate that pins the data rather than only the absence of the exception, the EXPLAIN route, the reverse child order that always worked, and the view-owned-CTE case that must keep materializing inside the child. Every failing arm reproduces 5/5 on a master binary and passes 5/5 after the change. The materialized_cte suite is green.

This is one shape of a recurring family - the same assertion is also reported in #113184 and addressed for other shapes by #113489, #111194 and #113043 - so the underlying is_materialization_planned claim being global while the gate is per-plan is worth revisiting separately. It surfaces constantly in the AST fuzzer; found via
https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=9d0b1a25ba7aa4579c95a65baca002d1dd7a1e47&name_0=MasterCI&name_1=AST%20fuzzer%20%28amd_debug%29

…ildren

`ReadFromMerge::createChildrenPlans` optimizes every child plan on its own, and
`resolveMaterializingCTEs` claims a materialized CTE globally through
`MaterializedCTE::is_materialization_planned`. The first child plan to be
optimized therefore moved the CTE's plan into *its own* tree, and every other
`DelayedMaterializingCTEsStep` for that CTE - in the sibling children and in the
outer plan - degenerated into a gate-less `MaterializingCTEsStep`. The CTE's
writer then sat in one child's pipeline while readers sat in another, with no
`DelayedPortsProcessor` between them, so a sibling's in-place `IN`-set build read
the storage while it was still empty and `ReadFromMemoryStorageStep` raised
`Reading from materialized CTE '...' before its materialization completed -
DelayedPortsProcessor gate is missing in the query plan`.

Reproducer on master (deterministic, also with `max_threads = 1`, and a plain
`EXPLAIN` is enough because the set is built during plan optimization):

    SET enable_analyzer = 1, enable_materialized_cte = 1;
    CREATE TABLE t (x UInt64) ENGINE = MergeTree ORDER BY x;
    CREATE TABLE tdist AS t ENGINE = Distributed(test_shard_localhost, currentDatabase(), t);
    CREATE VIEW tconst AS SELECT toUInt64(1) AS x;

    WITH t AS MATERIALIZED (SELECT number AS c FROM numbers(2))
    SELECT count() FROM merge(currentDatabase(), '^(tconst|tdist)$')
    WHERE (x IN (t)) AND (x NOT IN (t));

Children are visited in table-name order, so `tconst` is planned first and claims
the CTE; the `Distributed` child that follows then builds the set in place while
the CTE is unbuilt. Renaming so the `Distributed` child sorts first makes the same
query pass, which is what pins the mechanism.

Fix: a child plan no longer claims a CTE that the outer query references as well.
`removeDelayedMaterializingCTEsStepFor` strips those steps from the child plan
before it is optimized, leaving the outer plan - whose `MaterializingCTEsStep`
sits above the whole merge - as the single owner that gates every child. This is
the same reasoning `DelayedCreatingSetsStep::makePlansForSets` already applies to
pre-built `IN`-subquery plans. A CTE defined inside one child (a `View` with its
own `WITH ... AS MATERIALIZED`) is not referenced by the outer query, so that
child keeps owning it - it is the only reader.

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

clickhouse-gh Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [f69033e]

Summary:

job_name test_name status info comment
AST fuzzer (amd_debug, targeted, old_compatibility) FAIL
Logical error: 'database_name != "_temporary_and_external_tables"' (STID: 2508-48e6) FAIL cidb

AI Review

Summary

This PR fixes the Merge-child materialized-CTE ownership bug by stripping outer-query DelayedMaterializingCTEsStep ownership from each child plan before child optimization, while preserving child-local materialized CTEs. I reviewed the current diff, the surrounding materialized-CTE planning/optimization paths, the existing PR discussion (there were no prior review threads), and the current CI state; I did not find a correctness, concurrency, compatibility, or test-coverage issue that warrants an inline review comment.

Final Verdict

✅ No findings. The Bug Fix changelog metadata matches the change, the changelog entry is specific and user-facing, and the green CI matrix plus the added stateless regression test provide sufficient evidence for this fix.

LLVM Coverage Report

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

Changed lines: Changed C/C++ lines covered: 40/47 (85.11%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Aug 5, 2026
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 The only red check, AST fuzzer (amd_debug, targeted, old_compatibility) — Logical error: 'database_name != "_temporary_and_external_tables"' on SELECT count() FROM merge(REGEXP('^'), '^') — is unrelated to this change: this PR only strips DelayedMaterializingCTEsStep from Merge child plans for queries that reference materialized CTEs, and the failing query has none. The exception is the pre-existing StorageMerge enumeration of the internal _temporary_and_external_tables database; CIDB shows the same failure on master and on many unrelated PRs over the past days. The targeted fuzzer hit it here because this PR touches StorageMerge.cpp.

Fix in progress: #113224

@novikd novikd left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

Comment on lines +199 to +200
static void removeDelayedMaterializingCTEsStepIf(
QueryPlan & plan, const std::function<bool(DelayedMaterializingCTEsStep &)> & should_splice_out)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
static void removeDelayedMaterializingCTEsStepIf(
QueryPlan & plan, const std::function<bool(DelayedMaterializingCTEsStep &)> & should_splice_out)
static void removeDelayedMaterializingCTEsStepIf(
QueryPlan & plan,
const std::function<bool(DelayedMaterializingCTEsStep &)> & predicate)

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

Labels

pr-bugfix Pull request with bugfix, not backported by default

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants