Fix CREATE OR REPLACE MATERIALIZED VIEW ... POPULATE losing the source subscription by alexey-milovidov · Pull Request #108728 · ClickHouse/ClickHouse · GitHub
Skip to content

Fix CREATE OR REPLACE MATERIALIZED VIEW ... POPULATE losing the source subscription#108728

Open
alexey-milovidov wants to merge 9 commits into
masterfrom
fix-create-or-replace-mv-race
Open

Fix CREATE OR REPLACE MATERIALIZED VIEW ... POPULATE losing the source subscription#108728
alexey-milovidov wants to merge 9 commits into
masterfrom
fix-create-or-replace-mv-race

Conversation

@alexey-milovidov

@alexey-milovidov alexey-milovidov commented Jun 28, 2026

Copy link
Copy Markdown
Member

Related: #108726
Related: #108715

CREATE OR REPLACE MATERIALIZED VIEW ... POPULATE left the new view unsubscribed from its source table, so every row inserted after the replace was silently dropped.

The replace creates a temporary view, runs the POPULATE INSERT into it, then atomically swaps it with the target via EXCHANGE. The POPULATE INSERT resolves the temporary table by name and caches its name → UUID mapping in the per-query storage cache (Context::getOrCacheStorage). After the EXCHANGE that UUID is the new, live view, but the temporary name now refers to the old table that is about to be dropped. The internal DROP then resolved the temporary name through the stale cache and shut down the live view instead, detaching it from its source.

The fix makes getOrCacheStorage treat a cached entry as stale when the resolved storage no longer has the requested name (it was renamed or reassigned within the same query, e.g. by the internal EXCHANGE of CREATE OR REPLACE), or when the caller asked for a specific UUID that the name-keyed cache no longer matches. In those cases the entry is dropped and the table is re-resolved by name. This is a natural extension of the existing staleness check that already re-resolves when the cached UUID no longer exists.

With the fix the new view stays subscribed to its source after the replace, so inserts that follow the replace reach the view again.

Note on scope: this fixes the deterministic detach. A separate, much narrower window remains — the source-to-view dependency transfer inside the EXCHANGE (InterpreterRenameQuery) is not yet atomic, so a row inserted concurrently, in the brief instant the replace is swapping the tables, can still be missed (and an insert that lands exactly mid-swap can even fail with UNKNOWN_TABLE). Making that transfer atomic (and POPULATE exactly-once) is left for a follow-up. Because #108726 asks for full concurrent exactly-once delivery, which is only partially addressed here, this PR is marked Related: rather than Closes: and #108726 stays open to track that residual concurrent contract (see also #108715).

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 CREATE OR REPLACE MATERIALIZED VIEW ... POPULATE leaving the new view unsubscribed from its source table, which silently dropped every row inserted after the replace.

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

…e subscription

`CREATE OR REPLACE MATERIALIZED VIEW ... POPULATE` left the new view unsubscribed
from its source table, so every row inserted after the replace was silently dropped,
including rows inserted concurrently with the replace.

The replace creates a temporary view, runs the `POPULATE` `INSERT` into it, then
atomically swaps it with the target via `EXCHANGE`. The `POPULATE` `INSERT` resolves
the temporary table by name and caches its name -> UUID mapping in the per-query
storage cache (`Context::getOrCacheStorage`). After the `EXCHANGE` that UUID is the
new, live view, but the temporary name now refers to the old table that is about to
be dropped. The internal `DROP` resolved the temporary name through the stale cache
and shut down the live view instead, detaching it from its source.

Fix `getOrCacheStorage` to treat a cached entry as stale when the resolved storage no
longer has the requested name (it was renamed or reassigned within the same query) and
re-resolve it by name.

Closes: #108726

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

clickhouse-gh Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Jun 28, 2026
Addresses review feedback on #108728: the existing test only inserted
into the source after `CREATE OR REPLACE MATERIALIZED VIEW ... POPULATE`
had completed, so it covered the deterministic detached-view symptom but
not the concurrent scenario the issue describes (rows inserted while the
replace is in progress).

This adds `04490_..._concurrent.sh`, which inserts into the source
concurrently with the replace (the snapshot window is widened via
`merge_tree_storage_snapshot_sleep_ms` so the inserts reliably overlap
it) and asserts that no source row is lost - the set of distinct ids in
the view equals the set in the source. Verified non-flaky across many
local runs against a build that carries the fix.

The test asserts only that no row is lost (the contract this change
restores). It deliberately does not assert exactly-once delivery: plain
`POPULATE` is not atomic, so a concurrently inserted row can be both
captured by the snapshot and delivered through the subscription, i.e.
duplicated. Making `POPULATE` exactly-once is a separate change.

The existing deterministic test is renumbered from `04415` to `04489`
because the merge with master introduced colliding `04415_*` tests.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

Pushed c86d0bc3a50:

  • Merged current master (the branch was ~290 commits behind), no conflicts.
  • Added a concurrent regression test (04490_..._concurrent.sh) addressing the review feedback: it inserts into the source concurrently with CREATE OR REPLACE MATERIALIZED VIEW ... POPULATE and asserts that no source row is lost. Verified non-flaky locally (48/48 runs no loss on a build carrying the fix; the issue's exact repro went from ~23000 rows lost on master to 0 lost with the fix).
  • Renumbered the deterministic test 0441504489 (the master merge introduced colliding 04415_* tests).

On the review point about the dependency-graph window during the rename/EXCHANGE: it is real but is a separate, microsecond-wide race, distinct from the deterministic stale-cache detach this PR fixes. It does not manifest in the issue's repro even under stress. Details and evidence are in the resolved review thread. Closing that window fully (atomic dependency transfer under the source lock, alongside making POPULATE exactly-once, à la #108715) is out of scope here.

CI: the only red is Upgrade check (amd_release) → "Error message in clickhouse-server.log", which is the known 192.0.2.1 MySQL/PostgreSQL connection-failure log noise from the DatabaseMySQL/DatabasePostgreSQL engines — unrelated to this change (which only touches Context::getOrCacheStorage and tests).

Comment thread src/Interpreters/Context.cpp Outdated
alexey-milovidov and others added 3 commits June 30, 2026 02:10
The per-query storage cache (`Context::getOrCacheStorage`) is keyed by
qualified name only (`StorageCache::Shard::set` uses
`StorageID::DatabaseAndTableNameEqual`), so a cache hit can carry a UUID that
differs from the `StorageID` being resolved. After a same-name replacement the
name can map to UUID B while a caller still asks for UUID A; the freshness
check only compared names, so it would hand out UUID B instead of letting the
fresh lookup report `UNKNOWN_TABLE`/`TABLE_UUID_MISMATCH`.

Require UUID equality on the cache-hit path when the requested `StorageID` has a
UUID. This only makes the cache more conservative (more re-resolutions through
the authoritative lookup); it never returns a different table than requested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent no-loss

The concurrent regression test asserted that no source row is lost when inserts
race the replace (`uniqExact(mv) = uniqExact(src)`). That contract is not what
this PR provides: the source-to-view dependency transfer inside the internal
`EXCHANGE` (`InterpreterRenameQuery`) is not yet atomic, so a row inserted in
the brief instant the replace swaps the tables can still be missed. Under the
sanitizer and loaded CI runs that window opened reliably, so the test failed
across Fast test and every flaky check.

Rewrite it to prove only the deterministic fix this PR makes: once the
(concurrent) replace has completed, the new view is still subscribed to its
source, so a sentinel row inserted after everything settles must reach the
view. This is deterministic and independent of the residual race. Also enable
`set -e`, keep the background-insert PID and `wait` on it explicitly, so a
failed insert fails the test (previously the bare `wait` could not).

Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=108728&sha=c86d0bc3a50d1575af7560811b4db5532cbca979&name_0=PR&name_1=Fast%20test%20%28arm_darwin%29

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

Copy link
Copy Markdown
Member Author

Addressed the AI review's "Needs changes" verdict and the CI failure.

  • Cache UUID hole (Context.cpp, commit 077b422) — the name-keyed cache could return a same-name entry with a different UUID than requested. The cache-hit path now also requires it->uuid == id.uuid when the requested StorageID has a UUID, otherwise it re-resolves through the authoritative lookup. Strictly more conservative; never substitutes a different table.
  • Flaky 04490 concurrent test (commit 84561e3) — it was asserting concurrent no-loss (uniqExact(mv) = uniqExact(src)), a contract this PR does not provide: the dependency transfer inside the EXCHANGE is not yet atomic, so a row inserted in the narrow swap window can still be missed. That window opened reliably under sanitizer/loaded CI, so the test failed across Fast test and every flaky check. The test now proves only the deterministic fix — after a concurrent replace the new view is still subscribed, so a sentinel row inserted once everything settles must reach it. Also enabled set -e and an explicit wait on the background PID so a failed insert fails the test.
  • Scope narrowed — the PR description and changelog no longer claim concurrent no-loss / exactly-once. The remaining (much narrower) EXCHANGE dependency-transfer window, and making POPULATE exactly-once, are left for a follow-up.

Merged master (clean, no conflicts). All review threads resolved.

The flaky check failed `04490_..._concurrent` because the last revision enabled
`set -e` and `wait`-ed on the background inserts "so a failed insert fails the
test". But a concurrent `INSERT INTO src` that lands in the brief instant the
replace swaps the tables can legitimately fail with `UNKNOWN_TABLE` ("Target
table ... of view ... doesn't exist"): the source-to-view dependency transfer
inside the internal `EXCHANGE` is not yet atomic, so the new view's target table
is momentarily unreachable. That is precisely the residual window this PR
documents as out of scope, so failing the test on it contradicts the test's own
stated contract (assert subscription liveness, not concurrent delivery).

Make the concurrent inserts tolerate that specific `UNKNOWN_TABLE` error while
still failing the test on any other insert error. The assertion is unchanged: a
sentinel row inserted after everything settles must reach the view.

Verified against a local build carrying the fix: the rewritten test passes 50/50
runs, and it still fails (sentinel count 0) against a build without the fix, so
it continues to catch the regression. The error-handling itself was unit-checked
to tolerate `UNKNOWN_TABLE` and re-raise any other error.

Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=108728&sha=84561e343cc5b95655a0d81ef488845931b9d82b&name_0=PR&name_1=Stateless%20tests%20%28amd_debug%2C%20flaky%20check%29
PR: #108728

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

Copy link
Copy Markdown
Member Author

Pushed 84561e343cc..f62d84d4ab8 (test-only).

Fixed the flaky check on this PR's own test (04490_..._concurrent)

The previous revision enabled set -e and wait-ed on the background inserts "so a failed insert fails the test". But that turned the documented residual race into a hard test failure: a concurrent INSERT INTO src that lands in the brief instant the replace swaps the tables can legitimately throw

Code: 60. Target table '...`.inner_id.<uuid>`' of view 'src' doesn't exists.. (UNKNOWN_TABLE)

because the source-to-view dependency transfer inside the internal EXCHANGE is not yet atomic, so the new view's target table is momentarily unreachable (thrown at InsertDependenciesBuilder::observePath). That is exactly the window this PR scopes out, so failing the test on it contradicts the test's own contract (assert subscription liveness, not concurrent delivery).

The fix makes the concurrent inserts tolerate only that specific UNKNOWN_TABLE error and still fail the test on any other insert error. The assertion is unchanged — a sentinel row inserted after everything settles must reach the view.

Validation (local build carrying the fix, isolated server):

  • The rewritten test passes 50/50 runs.
  • It still fails (sentinel count 0) against a build without the fix, so it continues to catch the regression — not a vacuous pass.
  • The error handling was unit-checked: it tolerates UNKNOWN_TABLE and re-raises every other error.

Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=108728&sha=84561e343cc5b95655a0d81ef488845931b9d82b&name_0=PR&name_1=Stateless%20tests%20%28amd_debug%2C%20flaky%20check%29

Other red checks — all unrelated to this change

One open item for your call — the AI-Review "Request changes" verdict

Its sole minimum-required action is purely about the issue relationship: change Closes: #108726Related: because #108726's Expected behavior literally demands concurrent exactly-once delivery, which this PR intentionally leaves partially fixed. I did not change it — it's your deliberate, documented decision and a semantic judgment, not a code defect.

For the record, the flaky failure above is new evidence that the residual EXCHANGE window is not only "a row can be missed" but can surface a user-visible UNKNOWN_TABLE on a concurrent insert. So there's a reasonable case for Related: + a follow-up issue to keep the concurrent contract tracked. But since the issue's actual large-scale-loss reproduction (~23000 rows) is fully fixed here, Closes: is also defensible. Your call.

No master re-merge: merge-base d70da4f4137 is from today 00:38Z (<1 day), and none of the remaining reds are master regressions a merge would fix.

`master` now carries `04489_max_threads_auto_parsing_compat` and two
`04490_*` tests, which collided with the deterministic and concurrent
regression tests added here. Renumber them to the next free prefixes
`04492`/`04493` and update the cross-reference comment in the concurrent
test. Test-only change; the test bodies are unchanged.

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

Copy link
Copy Markdown
Member Author

Pushed f62d84d4ab8..a5dd2d3e2fb (test-only) and updated the PR description.

Cleared the AI-Review "Request changes" verdict

Its sole minimum-required action was the issue relationship. I changed Closes: https://github.com/ClickHouse/ClickHouse/issues/108726Related: and added a clause to the Note on scope so #108726 stays open to track the residual concurrent EXCHANGE dependency-transfer race + exactly-once POPULATE (cross-referencing #108715).

This is the conservative direction: under-claiming what the PR fixes is safer than Closes:, which would auto-close #108726 on merge even though a documented user-visible race remains (a concurrent insert mid-swap can be lost, or even surface UNKNOWN_TABLE). It is also trivially reversible — if you'd rather keep Closes: because the issue's large-scale-loss reproduction (~23000 rows) is fully fixed here, flip the one word back.

Renumbered the regression tests off a fresh master collision

master now carries 04489_max_threads_auto_parsing_compat and two 04490_* tests, colliding with the tests added here (the same recurrence that earlier forced 0441504489). Renamed to the next free prefixes and updated the cross-reference comment; test bodies unchanged:

  • 04489_..._keeps_subscription04492_..._keeps_subscription
  • 04490_..._keeps_subscription_concurrent04493_..._keeps_subscription_concurrent

CI — all reds are confirmed unrelated flakes (this PR only touches Context::getOrCacheStorage + tests)

  • 04204_global_in_join_max_rows_to_transfer_103333 (arm_binary) and 04093_msan_fiber_distributed_false_positive (amd_debug) — both are the distributed Unexpected packet from server (expected TablesStatusResponse, got ProfileInfo) desync, tracked by Fix flaky distributed query "Unexpected packet from server (expected TablesStatusResponse, got ProfileInfo)" #108854 (open). 04204 even fails on master runs (pull_request_number = 0) and its in-job auto-rerun passed 157/157.
  • 03161_clickhouse_keeper_client_create_sequential and 03988_keeper_client_autocomplete (Fast test arm_darwin) — macOS Keeper-client socket-teardown flakes (Socket is not connected in ZooKeeper::finalize); fail on master-base across many unrelated PRs (03988: 9 fails / 5 days / 7+ PRs). No Keeper code here.
  • FunctionsStress.stress / AllTests (Unit tests tsan) — chronic fuzzer flake FunctionsStress #107242 (141 master-base fails / 23 days, incl. master runs).
  • Stress test (amd_tsan / arm_tsan) — Hung check — known open Hung check failed, possible deadlock found #107941.

No master re-merge

Behind 486, but merge-base d70da4f4137 is from today 00:38Z (<1 day) and MERGEABLE with no conflicts; none of the remaining reds are master regressions a merge would fix (they reproduce on master-base).

@clickhouse-gh

clickhouse-gh Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 85.40% 85.40% +0.00%
Functions 92.60% 92.60% +0.00%
Branches 77.70% 77.70% +0.00%

Changed lines: Changed C/C++ lines covered: 7/7 (100.00%) · Uncovered code

Full report · Diff report

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.

1 participant