Skip to content

Do not disable the query condition cache for materialized lightweight deletes - #112947

Merged
fm4v merged 8 commits into
masterfrom
nik/fix-qcc-lightweight-delete
Aug 5, 2026
Merged

Do not disable the query condition cache for materialized lightweight deletes#112947
fm4v merged 8 commits into
masterfrom
nik/fix-qcc-lightweight-delete

Conversation

@fm4v

@fm4v fm4v commented Aug 1, 2026

Copy link
Copy Markdown
Member

Related: #107145
Related: #113239
Related: #83259
Related: #104985

Changelog category (leave one):

  • Performance Improvement

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

Fixed a performance regression where a single lightweight DELETE disabled the query condition cache for the whole table. Repeated selective queries over a table that had ever been touched by a lightweight delete stopped pruning granules and fell back to reading every mark.

Description

The regression

Deleting one row stops the query condition cache from pruning anything, for the entire table, permanently.

CREATE TABLE t (id UInt64, val String) ENGINE = MergeTree ORDER BY id;
INSERT INTO t SELECT number, toString(number % 97) FROM numbers(1000000);

DELETE FROM t WHERE id = 0;          -- the whole trigger

SYSTEM DROP QUERY CONDITION CACHE;
SELECT count() FROM t WHERE val = 'nope_nope';   -- run this 3 times

ProfileEvents['SelectedMarks'] per run:

version run 1 run 2 run 3
26.3 (before the backport) 122 0 0
26.4 / 26.6 / master 122 122 122

Same behaviour with mutations_sync = 2, so this is not a pending-mutation window: it persists after the delete is fully materialized.

This was found on a production table (24M rows, 5 parts) where exactly one part carried has_lightweight_delete = 1. That part has 4318 marks; the four small parts have 6 between them. The affected version pruned the four small parts and read all 4318 marks of the big one on every repetition, turning a 0.03 s query into 0.8-2.0 s. An INSERT ... SELECT * copy of the same table into a fresh table did not reproduce, which is what pointed at _row_exists rather than the schema or the data.

Bisected to #107145.

Root cause

MergeTreeReadPoolBase appends two different kinds of thing to the same mutation_steps list:

if (reader_settings.apply_deleted_mask && has_lightweight_delete)
    read_task_info.mutation_steps.push_back(createLightweightDeleteStep(...)); // committed part data
if (read_task_info.alter_conversions->hasMutations())
    ...append...                                                              // pending, per-query

and appliesMutationsBeforePrewhere() was !info->mutation_steps.empty() || !info->patch_parts.empty().

The reasoning in #107145 is correct for anything whose effect varies between queries: a mark emptied by an on-fly mutation must not be attributed to the predicate, or a later apply_mutations_on_fly = 0 query reusing that entry loses rows. A materialized _row_exists mask is not that. It is committed part data, every query reading the part observes exactly the same rows, and a mark it empties is attributable to the predicate like any other.

The read path already draws this line correctly - it bypasses on hasDataMutations() and hasPatchParts(), but not on hasLightweightDeletedMask(). So the write side was not actually made symmetric with the read side; it was made stricter.

The fix

Part 1 - key the check off the source of the filter, not the resulting step list (MergeTreeReadTask.cpp).

Patch parts and mutations coming from the mutations snapshot stay disqualifying. alter_conversions->hasLightweightDelete() keeps an unmaterialized lightweight delete disqualifying, because that one really is applied from the snapshot at read time and varies with apply_mutations_on_fly. A materialized mask no longer disables the cache. This is a narrowing of an over-broad condition; every case #107145 set out to cover is still covered.

Part 2 - keep apply_deleted_mask = 0 out of the cache (MergeTreeIOSettings.cpp, MergeTreeDataSelectExecutor.cpp).

Part 1 leaves exactly one way for the mask to vary between queries. Only one direction is unsound:

  • apply_deleted_mask = 1 writes, = 0 reads: unsound. The writer saw fewer rows and may record "no match" for a granule whose only matching rows are deleted; the reader must return those rows.
  • = 0 writes, = 1 reads: sound. The writer saw a superset, so its verdict is conservative.

apply_deleted_mask is a debugging aid, so such queries simply do not read or write the cache. That is cheaper and easier to reason about than splitting the key space, and it costs nothing on the default path.

Tests

New 04669_query_condition_cache_lightweight_delete:

  • a materialized lightweight delete still prunes on a repeat query (fails before this change: the second run reads every mark)

  • an apply_deleted_mask = 0 read does not consume an entry written by a normal read, and the reverse order is also correct

  • results stay correct with the cache warm

  • a repeated apply_deleted_mask = 0 query does not prune, which is the one behaviour Part 2 gives up. Pinned deliberately: keying entries by apply_deleted_mask instead of excluding them (Key query condition cache entries by apply_deleted_mask #113239) has to update that block.

The effectiveness assertions demand that the repeat reads no marks rather than fewer marks than the first run. Only the part holding the deleted row carries the mask, so with a weaker assertion a multi-part table would satisfy it through the other parts pruning and the test would pass with the bug present.

New 04670_query_condition_cache_unique_key covers UNIQUE KEY tables, which is where the one internal reader that turns the mask off lives (building a part's dense index reads its UNIQUE KEY columns with apply_deleted_mask = 0, in UniqueKeyDenseIndexOps::readUniqueKeyColumns). Two independent reasons keep such tables away from anything this PR changes, and the test pins both:

  • a UNIQUE KEY read never uses the cache. ReadFromMergeTree disables it for both the write and the consult side, because the cache is CSN-oblivious while the delete bitmap is not; there is a TODO(unique-key) to revisit it with a snapshot-aware cache.
  • no UNIQUE KEY part can carry a materialized mask, because mutation-class commands are rejected on such tables, DELETE FROM included.

Re-enabling the cache for UNIQUE KEY reads now has to come past this test, which is the point: that work has to look at the mask and bitmap interaction rather than only flipping the flag.

The unmaterialized/on-fly direction is already covered by 03229_query_condition_cache_on_fly_mutations, added by #107145, which must keep passing.

Backports

#107145 was backported to 26.5.6.46, 26.4.5.134 and 26.3.17.50, so every one of those lines is affected and needs this fix. Note 26.3 is LTS and is affected from 26.3.17.50 onward.

Version info

  • Backported to: 26.7.3.13, 26.6.3.2

… deletes

`appliesMutationsBeforePrewhere` treated a non-empty `mutation_steps` as proof that a
mutation filtered rows ahead of PREWHERE. That list also holds the step applying an already
materialized `_row_exists` mask, which is committed part data rather than a pending mutation,
so a single lightweight-deleted row disabled the query condition cache for the whole table.

Key the check off the source of the filter instead: patch parts and mutations taken from the
mutations snapshot (including an unmaterialized lightweight delete) stay disqualifying, a
materialized delete mask does not. This matches the read path, which bypasses the cache on
`hasDataMutations()` / `hasPatchParts()` but not on `hasLightweightDeletedMask()`.

`apply_deleted_mask = 0` is the one case where the mask does vary between queries, so such
queries no longer read or write the cache.
@clickhouse-gh

clickhouse-gh Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [4c06f45]

Summary:

job_name test_name status info comment
Stress test (amd_msan) FAIL
Logical error: Not-ready Set is passed as the second argument for function 'A (STID: 0250-41a5) FAIL cidb, issue ISSUE EXISTS
Stress test (arm_tsan) FAIL
Logical error: ColumnBLOB should be converted to a regular column before usage (STID: 3059-3284) FAIL cidb, issue ISSUE CREATED

AI Review

Summary

This PR correctly narrows the original lightweight-delete regression and keeps apply_deleted_mask = 0 out of the query condition cache, and the new UNIQUE KEY reasoning/tests are coherent. The remaining problem is that the write-side gate in appliesMutationsBeforePrewhere is still broader than the invariant it now documents: output-only on-fly UPDATEs and output-only patch parts still disable cache population even though they cannot make a mark stop matching before PREWHERE.

Findings

⚠️ Majors

  • [src/Storages/MergeTree/MergeTreeReadTask.cpp:513] has_on_fly_mutation_steps still conflates “this query executes some mutation expression” with “this mutation can filter rows before PREWHERE”. AlterConversions::filterMutationCommands keeps UPDATEs for any selected column, and MutationsInterpreter lowers them to if(cond, new, old) rewrites rather than row filters. A query like SELECT w FROM t WHERE v = 123456789 SETTINGS apply_mutations_on_fly = 1 with a pending UPDATE w = 0 WHERE id = 1 still returns true here, so it never warms the cache for the later apply_mutations_on_fly = 0 read even though the predicate on v is the only reason the marks are non-matching. This needs to distinguish row-dropping / predicate-affecting mutation steps from output-only rewrites, plus a regression test for “updated output column, unrelated predicate”.
  • [src/Storages/MergeTree/MergeTreeReadTask.cpp:508] patch_parts has the same over-broad gate. AlterConversions::getPatchesForColumns includes a patch whenever any read column is present in the patch, so an output-only lightweight update patch keeps patch_parts non-empty even when row eligibility for the predicate is unchanged. appliesMutationsBeforePrewhere() then suppresses cache writes for safe apply_patch_parts = 1 primes that a later apply_patch_parts = 0 query could reuse. The gate needs to key off row-affecting patches (_row_exists / predicate columns), not every selected-column patch, and it should be pinned by a focused stateless test.
Final Verdict

⚠️ Needs changes. The new “only filters that vary between queries count” rule is still not implemented consistently for output-only on-fly UPDATEs and output-only patch parts, so the query condition cache remains disabled for real mixed-setting cases that should be safe.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.40% 86.50% +0.10%
Functions 91.90% 91.90% +0.00%
Branches 78.70% 78.70% +0.00%

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

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-performance Pull request with some performance improvements label Aug 1, 2026
Comment thread src/Storages/MergeTree/MergeTreeReadTask.cpp Outdated
fm4v added 2 commits August 2, 2026 01:08
…lete test

The flaky check failed 10/10 runs with the reuse row reading every mark. Replaying the exact randomized settings of a failing run against the PR-built binary showed the prime query logging `Part all_1_1_0_2 pruned by statistics`: the randomized `auto_statistics_types` MergeTree setting (together with `materialize_statistics_on_insert`) builds column statistics that prune the whole part for the never-matching predicate, so nothing is read, nothing is written to the query condition cache, and the granule accounting becomes vacuous. Pinning `auto_statistics_types = ''` on the table removes the statistics; the full failing settings combination now passes.

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=112947&sha=45d5f5dce00ca07ccf1c321e8be3d9bc14484c7c&name_0=PR&name_1=Stateless%20tests%20%28amd_asan_ubsan%2C%20flaky%20check%29
PR: #112947
…ery does not read

`appliesMutationsBeforePrewhere` keyed the write-side disqualification off `AlterConversions::hasMutations`, which is true for any pending on-fly mutation. But `AlterConversions::filterMutationCommands` drops `UPDATE` commands whose assignments touch none of the columns the query reads, so such a mutation contributes no read-chain step, rewrites nothing the query observes, and caching its marks is sound. Keying off `hasMutations` therefore suppressed cache writes more broadly than the read chain it models: with `apply_mutations_on_fly = 1` a pending `UPDATE` of an unrelated column disabled cache population for every predicate on the table.

Track whether any steps produced from the mutations snapshot actually made it into `mutation_steps` (`has_on_fly_mutation_steps`) and disqualify on that instead. DELETE-typed commands are always kept by `filterMutationCommands`, so the poisoning direction of #107145 stays disqualifying, as does an unmaterialized lightweight delete via `hasLightweightDelete`.

The new test section proves the corner: with a pending `UPDATE w` and a query reading only `v`, the `apply_mutations_on_fly = 1` prime must populate the cache and an `apply_mutations_on_fly = 0` reuse must consume it (the read path skips the cache while a data mutation is pending, so the `= 0` side is the one that can hit). The section fails before this commit (verified against the PR-built binary: no entry is written) and the read side was verified to hit once an entry exists.

PR: #112947
@fm4v

fm4v commented Aug 1, 2026

Copy link
Copy Markdown
Member Author

Pushed two commits addressing the flaky check and the review finding.

Flaky check root cause. Replaying the exact randomized settings of a failing run against the PR-built CI binary reproduced the miss deterministically, and delta-debugging the option list pointed at the table, not the session: the part directory of the failing table contained statistics.packed. The randomized auto_statistics_types MergeTree setting (with materialize_statistics_on_insert) builds column statistics, and the prime query then logs

Part all_1_1_0_2 pruned by statistics
Statistics pruning: 1 parts -> 0 parts

so the never-matching predicate reads nothing, writes nothing into the query condition cache, and the reuse row degrades to 0 0. Both failing jobs had non-empty auto_statistics_types; the fix itself was never at fault. The test now pins auto_statistics_types = '' on its tables; the full failing settings combination passes locally against the PR binary.

Review finding (over-broad hasMutations). The AI review was right that alter_conversions->hasMutations() is broader than the read chain it models: AlterConversions::filterMutationCommands drops UPDATE commands whose assignments touch none of the columns the query reads, so such a pending mutation contributes no read-chain step and caching is sound, yet the check still disabled the write. The gate now keys off whether any steps produced from the mutations snapshot actually made it into mutation_steps (has_on_fly_mutation_steps). DELETE-typed commands are always kept by filterMutationCommands, so the poisoning direction of #107145 stays disqualifying, as does an unmaterialized lightweight delete via hasLightweightDelete.

The new test section covers exactly that corner: with a pending UPDATE w and a query reading only v, the apply_mutations_on_fly = 1 prime must populate the cache and an apply_mutations_on_fly = 0 reuse must consume it (the read path skips the cache while a data mutation is pending, so the = 0 side is the one that can hit). Verified to fail against the pre-refinement PR binary and the read side verified to hit once an entry exists; the write side of the refinement is covered by this section in CI.

@clickhouse-gh

clickhouse-gh Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

📊 Cloud Performance Report

✅ AI verdict: no_change — no significant changes across 36 queries analysed

no significant changes detected. K_source=6 K_base=30 flagged=0/65

clickbench

🟢 No significant changes

tpch_adapted_1_official

🟢 No significant changes

Debug info
  • StressHouse run: 97b86e1a-3072-48d3-9b22-61335835369d
  • MIRAI run: 684f8733-bf07-4299-b2e6-d7c935f2eefc
  • PR check IDs:
    • clickbench_1637227_1785847062
    • clickbench_1637238_1785847062
    • clickbench_1637244_1785847062
    • tpch_adapted_1_official_1637252_1785847062
    • tpch_adapted_1_official_1637270_1785847062
    • tpch_adapted_1_official_1637296_1785847062

The query condition cache requires the analyzer on both the write side (`MergeTreeReaderSettings::createFromContext`) and the read side (`filterPartsByQueryConditionCache`), so in the old-analyzer CI configuration the cache never functions and both reuse assertions degrade to `0 0`. Reproduced locally with `--allow_experimental_analyzer 0` and fixed by an explicit `SET enable_analyzer = 1`, same as `03229_query_condition_cache_profile_events`.

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=112947&sha=bc1862263fc1e3bf6bb3fdcaebe8804f1d9dca5f&name_0=PR&name_1=Stateless%20tests%20%28amd_llvm_coverage%2C%20old%20analyzer%2C%20s3%20storage%2C%20DBReplicated%2C%20WasmEdge%2C%20sequential%2C%202%2F2%29
PR: #112947
/// commands touch none of the columns this query reads is filtered out entirely (see
/// `AlterConversions::filterMutationCommands`), so it rewrites nothing this query observes and
/// must not disable the cache. `hasMutations()` would be too broad here.
if (info->has_on_fly_mutation_steps)

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.

has_on_fly_mutation_steps is still broader than the invariant this helper is enforcing. AlterConversions::filterMutationCommands keeps any UPDATE whose assignment target is read anywhere in the query, but MutationsInterpreter lowers those UPDATEs to if(cond, new, old) expressions rather than row filters. So a pending UPDATE that only rewrites post-filter output columns can still make this return true even though it cannot be the reason a mark became non-matching before PREWHERE.

A concrete case is a pending UPDATE w = 0 WHERE id = 1 with SELECT w FROM t WHERE v = 123456789 SETTINGS apply_mutations_on_fly = 1: the zero-row result is still caused entirely by the predicate on v, so warming QCC here is safe and a later apply_mutations_on_fly = 0 query could reuse it. With the new boolean we still suppress that write, so the cache remains disabled for a real mixed-setting case. I think this gate needs to distinguish row-dropping / filter-affecting mutation steps from output-only rewrites instead of treating any surviving mutation step as disqualifying.

@clickhouse-gh

clickhouse-gh Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

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

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

Full report · Diff report

fm4v added 2 commits August 4, 2026 01:14
…te fix

The comments were far longer than the code they explain and narrated the
reasoning instead of stating the rule. Cut them roughly in half and keep
only the facts a reader of these lines needs.
Assert that the repeated query reads no marks at all instead of merely
fewer marks than the first run. Only the part holding the deleted row
carries the mask, so if the insert ever lands in more than one part, the
weaker assertion is satisfied by the other parts pruning and the test
passes with the bug present.

Pin the one behaviour this change gives up: an `apply_deleted_mask = 0`
query neither writes nor consumes the cache, so a repeated one does not
prune. A follow-up that keys entries by `apply_deleted_mask` rather than
disabling them has to update that block deliberately.

Add 04670_query_condition_cache_unique_key for the interaction with
UNIQUE KEY tables. Building a part's dense index is the one internal
reader that turns the mask off (`UniqueKeyDenseIndexOps::readUniqueKeyColumns`
sets `apply_deleted_mask = 0`), and mutation-class commands - `DELETE FROM`
included - are rejected on such tables, so no UNIQUE KEY part can carry a
materialized mask. The test pins both halves of that, so the day either
one changes the cache write path gets revisited.
…elete reference

The apply_deleted_mask = 0 block added in the previous commit prints two
counts before the ProfileEvents rows; the reference listed only the rows.
https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=112947&sha=810129a2d8561d78c9b8098df49e579226873dde&name_0=PR&name_1=Fast%20test
/// Not `alter_conversions->hasMutations()`: a pending mutation that touches no column this
/// query reads produces no step and rewrites nothing the query observes.
if (info->has_on_fly_mutation_steps)
return true;

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.

getPatchesForColumns is already query-specific, so !info->patch_parts.empty() is still too broad for the same reason the old hasMutations() check was. A lightweight update patch on an output-only column makes patch_parts non-empty whenever that column is selected, even though it cannot make any mark stop matching the query predicate.

Concrete case: ALTER TABLE t UPDATE w = 0 WHERE id = 1 SETTINGS alter_update_mode = 'lightweight_force', enable_lightweight_update = 1, then SELECT w FROM t WHERE v = 123456789 SETTINGS apply_patch_parts = 1. The patch is kept because w is read, but row eligibility for v = 123456789 is unchanged, so an apply_patch_parts = 1 prime should still be able to warm QCC for a later apply_patch_parts = 0 query. This unconditional return keeps the cache disabled for that mixed-setting case.

I think this needs the same refinement as the on-fly-mutation path: distinguish row-affecting patches (_row_exists or predicate columns) from output-only rewrites, and add a focused regression test.

The test asserted that an ordinary query on a UNIQUE KEY table still
prunes on a repeat. It does not, and that is deliberate: `ReadFromMergeTree`
turns the query condition cache off for UNIQUE KEY reads on both the write
and the consult side, because the cache is CSN-oblivious while the delete
bitmap is not, so a mark recorded as non-matching after a bitmap drop could
be skipped by a reader pinned at an older snapshot whose rows are live.

Assert that instead. Together with the `DELETE FROM` rejection it is the
full reason such tables cannot reach the materialized-mask handling, and
re-enabling the cache for UNIQUE KEY reads now has to come past this test.

Also add the two count rows the reference was missing.

https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=112947&sha=c65d4acc47b740369199c3be82a8656495c9efd0&name_0=PR
@fm4v
fm4v added this pull request to the merge queue Aug 5, 2026
Merged via the queue into master with commit 1efba1c Aug 5, 2026
177 of 180 checks passed
@fm4v
fm4v deleted the nik/fix-qcc-lightweight-delete branch August 5, 2026 11:00
@robot-ch-test-poll3 robot-ch-test-poll3 added the pr-must-backport-synced The `*-must-backport` labels are synced into the cloud Sync PR label Aug 5, 2026
@robot-clickhouse-ci-1 robot-clickhouse-ci-1 added the pr-synced-to-cloud The PR is synced to the cloud repo label Aug 5, 2026
fm4v added a commit that referenced this pull request Aug 5, 2026
Backport #112947 to 26.6: Do not disable the query condition cache for materialized lightweight deletes
fm4v added a commit that referenced this pull request Aug 5, 2026
Backport #112947 to 26.7: Do not disable the query condition cache for materialized lightweight deletes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-must-backport-synced The `*-must-backport` labels are synced into the cloud Sync PR pr-performance Pull request with some performance improvements pr-synced-to-cloud The PR is synced to the cloud repo v26.4-must-backport

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants