Skip to content

Refuse an insert into a materialized view whose target is a view - #115985

Merged
PedroTadim merged 3 commits into
ClickHouse:masterfrom
groeneai:groeneai/refuse-insert-into-view-with-view-target
Aug 24, 2026
Merged

Refuse an insert into a materialized view whose target is a view#115985
PedroTadim merged 3 commits into
ClickHouse:masterfrom
groeneai:groeneai/refuse-insert-into-view-with-view-target

Conversation

@groeneai

@groeneai groeneai commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes: #114494

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):

Fixes a server abort in debug and sanitizer builds when inserting into a materialized view whose TO target is another materialized view. Such an insert already failed on released versions, with an uninformative Code: 1001 STD_EXCEPTION; it now reports NOT_IMPLEMENTED naming the offending view.

Description

Found by the AST fuzzer. INSERT into a materialized view declared TO <another materialized view> aborts:

std::exception. Code: 1001, type: std::out_of_range, e.what() = unordered_map::at: key not found
DB::InsertDependenciesBuilder::createPreSink(DB::StorageIDMaybeEmpty) const

observePath rejects a hop whose view does not select from its parent. That check is for a stale dependency left behind by an ALTER, and before the pushing-to-views rework it only ran while iterating dependent views. It is now also reached from the target hop, where a view never selects from the view forwarding into it, so it always rejects: the path is abandoned, no output header is recorded, and createPreSink calls output_headers.at(view_id) on a missing key. Guarding that .at() alone is not enough, because the hop has no sink either (createSinkImpl reaches UNREACHABLE() for a materialized view inner storage).

This refuses the insert with NOT_IMPLEMENTED, in the shape createRetry already uses for a forwarding path it cannot rebuild.

The refusal is limited to the view the insert addresses, on all three routes that reach the abort: INSERT ... VALUES, INSERT ... SELECT (via addInsertToSelectPipeline) and CREATE ... POPULATE (via the population insert). A view reached as a dependent still gets the existing stale-dependency skip, which is what 04216_materialized_view_reused_target_name_dependency asserts; on that route the rows are still dropped silently, so the data-loss half of #114494 is not addressed here.

Validated against a pristine master build: all three routes abort there and return NOT_IMPLEMENTED here, an ordinary-table target is byte-identical on both, and the new test fails on master (the server exits 134 on the first insert). 04652_nested_merge_prewhere_type_mismatch, the fuzzer's own carrier, creates this shape and still passes.


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

Version info

  • Backported to: 26.7.7.95, 26.6.6.4, 26.3.33.72

Inserting into a materialized view declared `TO <another materialized view>` aborted the
server in debug and sanitizer builds:

    std::exception. Code: 1001, type: std::out_of_range,
    e.what() = unordered_map::at: key not found
    DB::InsertDependenciesBuilder::createPreSink(DB::StorageIDMaybeEmpty) const

`observePath` rejects a hop whose view does not select from its parent. That check is for a
stale dependency left behind by an ALTER, and before the pushing-to-views rework it only ran
while iterating dependent views. It is now also reached from the target hop, where a view
never selects from the view forwarding into it, so the check always rejects: the path is
abandoned, no output header is recorded for the view, and `createPreSink` then calls
`output_headers.at(view_id)` on a missing key. Guarding that `.at()` alone would not help,
because the hop has no sink either - `createSinkImpl` reaches `UNREACHABLE()` for a
materialized view inner storage.

Refuse the insert with NOT_IMPLEMENTED instead, in the shape `createRetry` already uses for
a forwarding path it cannot rebuild. Reachable from `INSERT ... VALUES`, from
`INSERT ... SELECT` through `addInsertToSelectPipeline`, and from `CREATE ... POPULATE`
through `fillTableIfNeeded`; the AST fuzzer found it on the third.

The refusal is limited to the view the insert addresses. A dependent view whose target name
now resolves to a view is still skipped, which is what the stale-dependency check is for and
what 04216_materialized_view_reused_target_name_dependency asserts.
@clickhouse-gh

clickhouse-gh Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [55f2479]

Summary:


AI Review

Summary

This PR turns the std::out_of_range / server abort caused by direct inserts into a materialized view whose TO target is another materialized view into a user-facing NOT_IMPLEMENTED error, and it adds a stateless reproducer for the direct INSERT, INSERT ... SELECT, CREATE ... POPULATE, and refreshable-target variants. The control-flow change in InsertDependenciesBuilder looks coherent, but the PR is still blocked on missing bugfix-validation evidence.

Tests
  • ⚠️ tests/queries/0_stateless/04846_materialized_view_target_is_materialized_view.sql The merge-blocking new_tests_check.py thread is still unresolved and reports that all per-architecture Bugfix Validation jobs were DROPPED for this reproducer. For a Bug Fix PR that means we still do not have automated proof that master reproduces the failure and this patch fixes it on the required debug / ASan configurations. Please rewrite the reproducer into a form Bugfix Validation can observe on master, or explain why that gate cannot cover this case.
Final Verdict

Needs changes before merge: the logic looks reasonable, but the required Bugfix Validation evidence is still missing.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 87.10% 87.10% +0.00%
Functions 91.90% 91.90% +0.00%
Branches 79.40% 79.40% +0.00%

Changed lines: Changed C/C++ lines covered: 7/7 (100.00%) · 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 23, 2026
@groeneai groeneai added can be tested Allows running workflows for external contributors groeneai-origin-oranjeai PR origin: delegated from the OranjeAI sibling fleet labels Aug 23, 2026
CREATE MATERIALIZED VIEW v_view_target TO v_table_target AS SELECT a FROM src;

-- Inserting into a view whose target is another view is refused on both insert routes.
INSERT INTO v_view_target VALUES (1); -- { serverError NOT_IMPLEMENTED }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CI's merge-blocking new_tests_check.py hook still says none of the per-arch Bugfix Validation jobs validated this reproducer: the current Finish Workflow report shows all four per-arch jobs as DROPPED. For a Bug Fix PR that means we still do not have automated proof that master reproduces the failure and this patch fixes it, so this needs to be rewritten into a form Bugfix Validation can actually observe on the master debug/ASan builds, or the PR needs an explanation for why that evidence gate cannot cover this case.

@clickhouse-gh

clickhouse-gh Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Build profile diff (arm_release)

Comparing 55f24798c with master c583f98a0 (stripped binary size, per-symbol sizes and ThinLTO time; object sizes against the warmup build of 97a238dfc; compile times per translation unit against the most recent warmup build that recompiled it).

✅ No significant changes.

Binary sizes
Binary Master PR Δ
programs/clickhouse-stripped 705.78 MiB 702.76 MiB -3.02 MiB (-0.43%)

Only the stripped binary is compared: the official master build keeps debug symbols while PR builds strip them, so the other binaries differ by construction.

Compile time of recompiled translation units

7 translation units recompiled, 17 s compile time in total, 7 of them have a recent master baseline.

Job report

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.

@groeneai can you check if the same error happens with dictionaries and refreshable materialized views? What about DELETE, UPDATE, and TRUNCATE statements? ALTERs seem safe, and I can't think of others.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Checked all five on a debug build, both against pristine master and against this PR. One of them is a real carrier and is now covered; the rest are already refused elsewhere or do not reach the code at all.

Refreshable materialized views: yes, and it was uncovered. A refreshable view is a StorageMaterializedView, so it reaches the same target edge. Master aborts, this PR refuses:

CREATE MATERIALIZED VIEW v_refreshable REFRESH EVERY 1 YEAR APPEND TO tgt AS SELECT a FROM src;
CREATE MATERIALIZED VIEW v_refreshable_target TO v_refreshable AS SELECT a FROM src;
INSERT INTO v_refreshable_target VALUES (5);

master exit 134, this PR Code: 48 NOT_IMPLEMENTED. There is also a fourth route into the hop: the refresh insert of a refreshable view whose own target forwards into a view. Master aborts there too; here the refusal lands in system.view_refreshes.exception and the server stays up. Added the first as a test cell (55f2479, APPEND so it needs no Atomic database, with a target table of its own so its initial refresh does not disturb the later counts). Confirmed live: the cell alone aborts master.

Dictionaries: no. A dictionary target is refused before the header is ever needed, identically on master and here, on both the direct and the dependent route: Code: 48, Method write is not supported by storage Dictionary. INSERT INTO dict directly gives the same. A dictionary also cannot be the addressed side of the edge, since the guard needs the insert to address a materialized view.

DELETE: no. Code: 36, DELETE query is not supported for table ..., both arms.

UPDATE: no. Code: 48, Table with engine MaterializedView doesn't support lightweight updates, both arms. ALTER TABLE ... DELETE is rejected earlier still: Code: 80, MATERIALIZED VIEW targets existing table ..., execute the statement directly on it.

TRUNCATE: no, and it never reaches an insert. StorageMaterializedView::truncate is gated on has_inner_table, so on any TO <target> view it is a no-op regardless of what the target is. Measured identical on both arms, and I checked it against a view whose target is an ordinary table before claiming it: that is also a no-op, while TRUNCATE on a plain table empties it. Pre-existing behaviour, unrelated to this PR.

ALTERs: agreed. ADD COLUMN is Code: 48 on a materialized view; MODIFY QUERY succeeds and does not reach the edge, including when used to retarget.

One thing I did not add. The refusal message names both views, but the test asserts only the error code. The only way to pin the text from a .sql test is system.errors.last_error_message, which is server-global: it reads correctly in isolation and returns the wrong answer as soon as any other NOT_IMPLEMENTED is raised on the same server, which a concurrent copy of this parallel-safe test does. I left the property unpinned rather than add an assertion I had measured to be flaky. Happy to convert the file to .sh and grep the diagnostic if you would rather have it checked.

A refreshable materialized view reaches the same target edge, so master aborts
on an insert into a view that forwards into one. Measured: rc 134 on master,
NOT_IMPLEMENTED here. The refresh insert of a refreshable view is a fourth
route into the same hop and is refused the same way.

APPEND mode is used so the cell needs no Atomic database, and the refreshable
view gets a target table of its own so its initial refresh does not disturb
the row counts the later assertions read.
The population insert throws after the view is created on the database engines
that populate non-atomically, so `v_populate` survives an `Ordinary` database
and a replicated DDL entry while it does not survive the default one. Measured
one leaked row under `Ordinary`; every later assertion in the test held anyway,
so this is hygiene rather than a failure.

The comment called a skipped dependent-view target edge a stale dependency.
The test creates that topology directly, so it is live from birth; describe the
skip instead of its cause.
@groeneai

Copy link
Copy Markdown
Collaborator Author

CI finish ledger - 55f2479

CI is fully finished at this commit and no check failed, so no ledger row is owed.

Evidence: 172 unique check runs, none queued or in progress; Config Workflow, Style check and
Finish Workflow all green; no non-green check-run conclusion; praktika result_pr.json reports
the root as OK with every node in {OK, SKIPPED}. A CIDB query at this commit, filtered against
the measured healthy-token set, returns zero non-green rows against a control of 287,602 rows at
the same commit, so that zero is a real filtered subset rather than an empty query.

Session id: cron:our-pr-ci-monitor:20260823-163000

@PedroTadim PedroTadim self-assigned this Aug 24, 2026
@PedroTadim
PedroTadim added this pull request to the merge queue Aug 24, 2026
Merged via the queue into ClickHouse:master with commit 8983030 Aug 24, 2026
178 checks passed
@robot-ch-test-poll robot-ch-test-poll added the pr-synced-to-cloud The PR is synced to the cloud repo label Aug 24, 2026
@clickgapai

Copy link
Copy Markdown
Contributor
Reproducer
CREATE TABLE src (a UInt16) ENGINE = MergeTree ORDER BY tuple();
CREATE TABLE tgt (a UInt16) ENGINE = MergeTree ORDER BY tuple();
CREATE MATERIALIZED VIEW v_table_target TO tgt AS SELECT a FROM src;
CREATE MATERIALIZED VIEW v_view_target TO v_table_target AS SELECT a FROM src;

INSERT INTO v_view_target VALUES (1);
-- 25.6 .. 26.7: Code: 1001. std::out_of_range: unordered_map::at: key not found. (STD_EXCEPTION)
--               SIGABRT (exit 134) on debug/sanitizer builds
-- 25.5 and older: insert succeeds, row forwarded to tgt
-- master (after #115985): Code: 48 NOT_IMPLEMENTED naming the offending view

Results:

  • Reproduces on: 26.7, 26.6, 26.5, 26.3, 25.8
  • Does not reproduce on: master
  • Tested versions: master, 26.7, 26.6, 26.5, 26.3, 25.8
  • Also tested (unsupported, context only): 26.2 reproduces
  • Fix is on master — this PR's change fixes the bug on master. The releases listed above still have the bug and need the backport.

Backport the fix to 26.7, 26.6, 26.5, 26.3, 25.8.

CC component owner: @CheSema @al13n321

Analysis metadata

comp-materialized-view · Severity P2 · Finding phase_d_pr115985

  • Verified against master build be87a560457b (aarch64)
Introducing change: not identified
  • The one boundary found was rejected — PR Move metric #108453 only modifies FileCache/FileSegment.cpp (file caching logic), which is unrelated to materialized view INSERT handling or view target resolution. — a coincidental boundary, not the cause.
  • The source-build bisect converged to Revert "Revert "rework pushing to views #77309"" #79963, but it is not the introducer (see above) — not attributing it.
  • Walked 3 release(s) below the supported window: still reproduces down to 25.6; clean at 25.5.
  • Secondary components: comp-testing

@groeneai

Copy link
Copy Markdown
Collaborator Author

@PedroTadim could you apply v26.3-must-backport here? The requested list above misses one branch and names two that are gone, so here is what I measured.

Affectedness, with the official per-branch binaries and the reproducer above (clickhouse local, x86_64): 26.3 (26.3.26.1), 26.6 (26.6.4.35) and 26.7 (26.7.6.19) all give Code: 1001. std::out_of_range: unordered_map::at: key not found. (STD_EXCEPTION). 26.8 (26.8.1.2025) already gives Code: 48 with the new message, and so does master (26.9.1.268) as the control.

26.8 needs nothing: the merge commit 8983030 is an ancestor of 26.8 (compare reports behind_by=0), and ReleaseBranch.pre_check skips a branch on exactly that test before creating anything. So v26.3-must-backport expands to 26.3, 26.6, 26.7 and 26.8, which is the affected set once 26.8 is skipped. 25.8 and 26.5 from the list above have had no open PR targeting them since 2026-08-27, so no label reaches them.

Two things the generated PRs will need, from replaying the pick with the base cherry_pick.py uses (merge-tree --merge-base=8983030^1):

  • 26.6 and 26.7 resolve cleanly and the guard lands in the right block, but the test's TO ... POPULATE line has to be dropped there. TO together with POPULATE is still Code: 62. SYNTAX_ERROR on both branches, because Make CREATE MATERIALIZED VIEW ... POPULATE atomic #108715 removed that check only for 26.8 and newer, so that arm gets 62 rather than 48 even with the fix applied: the parser rejects the DDL before any population insert reaches observePath. The other arms are unaffected. Since the pick is clean the robot opens the backport PR directly, so this would first surface as red CI.
  • 26.3 conflicts in src/Interpreters/InsertDependenciesBuilder.cpp. The enclosing if (materialized_view && current != init_table_id) is not there yet, and ErrorCodes::NOT_IMPLEMENTED is not declared in that file, so the hunk needs adapting on the pr-cherrypick PR. If that is not worth it here, v26.6-must-backport covers 26.6 and 26.7 only.

@robot-ch-test-poll2 robot-ch-test-poll2 added the pr-must-backport-synced The `*-must-backport` labels are synced into the cloud Sync PR label Sep 12, 2026
PedroTadim added a commit that referenced this pull request Sep 12, 2026
…w whose target is a view

The incoming hunk bundles the fix with master-only changes 26.3 does not have:
the `materialized_view` block was hoisted above the map assignments there (26.3
still has the `select_table_id` check below them, so taking it would duplicate
it), `getInMemoryMetadataPtr` takes `(context, bool)` on master against `(bool)`
here, and the external dynamic metadata refresh is unrelated to this fix.

Kept this branch's `getInMemoryMetadataPtr()` and placed the `NOT_IMPLEMENTED`
throw in its own materialized-view block, in master's relative order. The throw
lands after the map assignments here, which is equivalent: it aborts the insert
and the builder is discarded. `NOT_IMPLEMENTED` is declared too, as master's
file already had it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
robot-clickhouse added a commit that referenced this pull request Sep 12, 2026
@robot-clickhouse-ci-1 robot-clickhouse-ci-1 added the pr-backports-created Backport PRs are successfully created, it won't be processed by CI script anymore label Sep 12, 2026
clickhouse-gh Bot pushed a commit that referenced this pull request Sep 13, 2026
clickhouse-gh Bot added a commit that referenced this pull request Sep 13, 2026
Backport #115985 to 26.6: Refuse an insert into a materialized view whose target is a view
clickhouse-gh Bot pushed a commit that referenced this pull request Sep 13, 2026
clickhouse-gh Bot added a commit that referenced this pull request Sep 13, 2026
Backport #115985 to 26.7: Refuse an insert into a materialized view whose target is a view
alexey-milovidov added a commit that referenced this pull request Sep 13, 2026
Backport #115985 to 26.3: Refuse an insert into a materialized view whose target is a view
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors groeneai-origin-oranjeai PR origin: delegated from the OranjeAI sibling fleet pr-backports-created Backport PRs are successfully created, it won't be processed by CI script anymore pr-bugfix Pull request with bugfix, not backported by default pr-must-backport-synced The `*-must-backport` labels are synced into the cloud Sync PR pr-synced-to-cloud The PR is synced to the cloud repo v26.3-must-backport

Projects

None yet

6 participants