Skip to content

Do not deserialize a skip index whose on-disk type is stale - #112484

Merged
alexey-milovidov merged 20 commits into
ClickHouse:masterfrom
groeneai:fix-killed-mutation-stale-set-index
Aug 15, 2026
Merged

Do not deserialize a skip index whose on-disk type is stale#112484
alexey-milovidov merged 20 commits into
ClickHouse:masterfrom
groeneai:fix-killed-mutation-stale-set-index

Conversation

@groeneai

@groeneai groeneai commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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 reading a stale secondary (skip) index after an ALTER TABLE ... MODIFY COLUMN type change whose mutation never ran, for example because KILL MUTATION removed it: the granules on disk were written with the old type but decoded with the new one, raising LOGICAL_ERROR, requesting multi-exabyte allocations, or silently returning a wrong result. Also fixes a wrong result from an index over an expression whose meaning changes while every stored byte stays identical, so no mutation is created at all: a MODIFY COLUMN altering only a DateTime timezone, or only a custom type name such as UInt8 to Bool. Such an index is now skipped for the affected part. Closes #112213.

Description

MODIFY COLUMN updates table metadata at once and schedules a mutation to rewrite the parts. Until it runs, a part's granules hold bytes written with the OLD type while index analysis decodes them with the NEW one.

The read gate canUseIndex infers that mismatch from a pending mutation entry and fails open when there is none. Six measured ways reach the deserializer anyway, including KILL MUTATION having removed the entry (the report) and conversions for which no mutation is ever created, being metadata-only for the column but not for the index. The top-k minmax read had no gate at all.

The fix asks the durable question instead: do the part's bytes match the type about to decode them? It lands in IMergeTreeIndex::getDeserializedFormat, which already receives the part, so one predicate covers every read path. Physical discovery splits off into a virtual getPhysicalFormat, leaving getDeserializedFormat non-virtual so no override bypasses it. Each required column's part-side type, taken from the part's own list rather than the type-erasing interned cache, is compared against the metadata type: only representation-preserving differences pass, plus a getName() same-meaning check on the equals-equal path. The walk recurses pairwise through Array, Nullable and LowCardinality; adding or dropping a wrapper is refused. A non-trivial expression index is refused on any difference.

Over-firing costs pruning, not correctness. Two MergeTask text-index sites share the predicate, so a stale text index is rebuilt during a merge instead of hardlinked forward. Out of scope: index identity staleness (a changed expression, a name reused after a killed DROP INDEX), which no type check detects.

Measured symptoms on master, and validation
target type observed on master
Nullable(UInt64) LOGICAL_ERROR: Sizes of nested column and null map ... not equal after deserialization
Nullable(UInt64) release / UInt64 / absent-column part Code: 241, 4 / 2 / 4 EiB
Array(UInt64) Code: 33, "read just 38 of 18005230136"
Int8 to Enum8 wrong: prunes a granule the unindexed read rejects (Code: 691)
DateTime('UTC') to DateTime('Asia/Tokyo'), INDEX toHour(dt) wrong: 0 vs 3
UInt8 to Bool, INDEX toString(v) wrong: 0 vs 32
Tuple(x UInt8) to Tuple(x Bool), INDEX toString(p.x) wrong: 0 vs 32

04165_skip_index_stale_type_after_alter.sql: 29 cases covering the above, whose 21 control
fixtures carry 25 explain ILIKE granule assertions pinning the over-fire direction (a simple
single-column index across the timezone and Bool ALTERs, an unchanged subcolumn index, an
unchanged JSON(a DateTime) column).

On the base commit the test kills the server with the reported stack
(SerializationNullable.cpp:178 <- MergeTreeIndexGranuleSet::deserializeBinary <-
MergeTreeIndexReader::read <- filterMarksUsingIndex); with the fix it passes 50/50.
36 mutations each confirm one line is load-bearing. A 462-test A/B sweep against pure HEAD leaves
the failure set unchanged (27 in both arms, all needing infra this sandbox lacks). Perf over 250
parts and 4 indexes is inside noise. No setting or format changes. #110050 overlaps these files and its helper asks the
physical question, so it should forward to getPhysicalFormat on rebase. My #109616 has since
merged and independently reached the same physical-vs-usability split, so it needs no forwarding.

Report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=108096&sha=25f51f1fddd05350d9c5aa1231aa0e7ee6fc676f&name_0=PR&name_1=Stress%20test%20%28arm_tsan%29

Version info

  • Merged into: 26.8.1.1470 (included in 26.8 and later)

groeneai and others added 12 commits July 28, 2026 06:22
An ALTER MODIFY COLUMN type change updates the table metadata immediately and
schedules a mutation to rewrite the parts. Until that mutation runs, a part's
set/minmax skip index granules still hold bytes serialized with the OLD column
type, while index analysis builds the index sample block from the CURRENT
metadata snapshot and decodes those bytes with the NEW type.

The read gate meant to prevent that, MergeTreeDataSelectExecutor::canUseIndex,
infers the mismatch from the existence of a pending mutation ENTRY, and fails
open when that set is empty. Measured, the set is empty in four independent
ways: the mutation is pending but dropped from the storage snapshot; the
mutation was removed by KILL MUTATION; no mutation is created at all (lazy JSON
typed-path hints); or the column conversion is representation-preserving while
the index EXPRESSION's type still changes. Independently, the WHERE-clause top-k
minmax read has no canUseIndex guard at all.

The symptom depends only on the target type: a LOGICAL_ERROR in
SerializationNullable that aborts debug and sanitizer builds, a multi-exabyte
allocation request in a release build, CANNOT_READ_ALL_DATA, or, for
Int8 -> Enum8 with a value outside the new enum, a silently wrong result.

Ask the right question instead: do the part's bytes match the type they are
about to be decoded with? Mutation entries are mutable third-party state; the
part's own recorded type is not. IMergeTreeIndex::getDeserializedFormat already
receives the part and already performs a per-part schema rejection for
invalidated system columns, so the check belongs there, covering all thirteen
call sites without signature changes or scattered guards.

Since {0, {}} would then mean both "not materialized" and "materialized but not
usable", split the two questions: getPhysicalFormat (virtual) keeps the existing
checksum- and storage-aware discovery, including legacy layouts, and
getDeserializedFormat (non-virtual, so no format override can bypass the check)
performs the usability checks and delegates to it. The two existing overrides
are renamed with their discovery bodies unchanged.
removeIndexMarksFromCache moves to the physical accessor so marks cached before
the ALTER are still evicted; the prewarm loop deliberately stays on the
usability accessor, because prewarming a refused index only wastes I/O.

The type predicate is fail-closed and directional, allowing only
representation-preserving differences: identical types, Array/Nullable
recursion, enum value-set extension, and the width-preserving Enum8/Int8,
Enum16/Int16, DateTime/UInt32 and Date/UInt16 pairs. Int8 -> Enum8 is
deliberately excluded, since that direction is the wrong-result case above.
A non-trivial expression index is refused on any type difference, because
deriving the expression's part-side result type would require running the
analyzer per part on the query path. This intentionally duplicates part of
isMetadataOnlyConversion rather than calling it: that predicate answers whether
ALTER must rewrite the data, which is weaker, and reusing it fails open on JSON
type hints.

canUseIndex is left untouched. Over-firing costs pruning, not correctness.

Closes: ClickHouse#112213
isPartTypeCompatible skipped its check when the part's column list held no
entry for a column the index requires, on the assumption that such a part
cannot hold a granule for that index. It can: a part carries index files for
an index whose required column was never written to it, which is the state
MutateTask's absent-index-column handling exists to serve (issue ClickHouse#104872).
A killed retype of that column then decoded the old granule under the new
type through this second door, requesting exabytes.

Reorder getDeserializedFormat to discover the on-disk format first and run
the type check only when a format was found. The check is then only ever
asked about a part that has the index, so the absent-column branch becomes
an unconditional refusal and needs no metadata snapshot. A part that merely
predates ADD INDEX is unaffected: it has no index files, so the physical
accessor reports it as not materialized before the type check runs.

Test: pin the runner-randomized top-k settings per statement, since without
them the top-k assertions can run with the optimization disabled and assert
nothing. Reach MergeTreeIndexReadResultPool by combining WHERE with
ORDER BY and LIMIT, which the previous lines did not do, and assert the
analysis-time top-k step's own description so the control proves pruning is
retained rather than only that the answer is right. Tag no-parallel-replicas:
the test asserts exact granule counts and sets use_skip_indexes_on_data_read.
The three statements in 04165 that set use_skip_indexes_on_data_read = 1 could
never construct MergeTreeIndexReadResultPool under the stateless test profile,
so they asserted only that the answer was right, which it also is without the
pool.

tests/config/users.d/limits.yaml sets max_rows_to_read = 20000000 in the default
profile and install.sh symlinks it unconditionally, read_overflow_mode defaults
to throw, and ReadFromMergeTree::supportsSkipIndexesOnDataRead() returns false
on exactly that pair. Neutralize max_rows_to_read per statement, matching the
in-tree idiom (03533_skip_index_on_data_reading.sql and seven other tests pair
the two settings). max_rows_to_read_leaf is absent from that profile, so the
symmetric guard is inert and needs no pin.

Add an observable so this cannot rot back into a result-only check: when the
read-time pool takes over, index analysis stops filtering and the
ReadFromMergeTree summary line reports every granule, so asserting
"Parts: 1 | Granules: 16" on the unaltered control fails if the pool is ever
silently declined again. Measured 8/8 with the pool and 0/3 under the profile
limit, and stable when prewhere, lazy materialization and bulk filtering are
disabled. EXPLAIN ANALYZE is required because InterpreterExplainQuery
force-disables use_skip_indexes_on_data_read for every non-ANALYZE EXPLAIN.

Verified in both directions on the same binary: reverting the per-part type
check makes the read-time statement fail with CANNOT_READ_ALL_DATA out of
MergeTreeSelect(pool: ReadPoolInOrder) where the fix returns the unindexed
answer, and removing the pins reddens only the new assertion.

No source change; src is byte-identical to the previous commit.
DataTypeDateTime::equals and DataTypeDateTime64::equals deliberately ignore the
timezone, so ALTER TABLE ... MODIFY COLUMN dt DateTime('Asia/Tokyo') over a
DateTime('UTC') column is metadata-only and creates no mutation at all. The
per-part type check in IMergeTreeIndex::isPartTypeCompatible short-circuited on
that equality and never reached the expression-index refusal, so an index whose
expression reads the timezone (toHour, toStartOfDay, toDate, toYYYYMMDD, ...)
kept granules computed in the OLD zone while the query evaluates the expression
in the NEW one. The result is a wrong count: granules that genuinely match are
pruned away. Nothing downstream can notice, because the expression's result type
(UInt8 for toHour) does not change.

The equality fast path is now timezone-aware. A new file-local predicate reports
a difference when two equals-equal types attribute their values to different
timezones, walking Nullable, Array, LowCardinality, Map, Tuple and Variant
pairwise the same way those types' own equals does, so a DateTime nested in a
wrapper is reached. Two implicit-timezone types are the same type and compare
equal. The fall-through is fail-closed but scoped by reachability, so a type
that carries no timezone is never refused.

A simple single-column index is still accepted across the same ALTER: its
set/minmax granule stores the raw epoch value, which no timezone alters, and
refusing it would be a pruning regression on a very common ALTER.

DataTypeDateTime and DataTypeDateTime64 are the only types under src/DataTypes
whose equals drops a semantically load-bearing parameter; every other
implementation compares its parameters or recurses into its children. Time and
Time64 carry no per-type timezone at all, so they cannot drift.
IDataType::equals() answers "is the on-disk representation the same?". Three
implementations deliberately drop an attribute that no byte of the representation
depends on, but that a skip index EXPRESSION does read, so the stale-index check
took its equality fast path and accepted granules that no longer mean what the
query computes:

  - DataTypeDateTime and DataTypeDateTime64 ignore the timezone. Round 3 handled
    this for the column itself; it is also reachable through an AggregateFunction
    argument, because DataTypeAggregateFunction::equals() compares argument_types
    with equals() in turn. An index over finalizeAggregation(v) then keeps
    granules computed in the old zone. A bare AggregateFunction column cannot be
    indexed, but minmaxIndexValidator inspects the expression's RESULT type, so
    finalizeAggregation() of one is accepted.
  - A custom name over a plain type is invisible to the underlying
    DataTypeNumber<T>::equals(), which compares only typeid. Bool is a custom
    name plus a custom serialization over UInt8, so MODIFY COLUMN v Bool is
    metadata-only while an index over toString(v) holds '0'/'1' and the query now
    evaluates 'false'/'true'.

Both are the same defect as the timezone case, reached through a different
dropped attribute, so they are fixed by one predicate rather than a branch each:
IDataType::getName() is the one projection that carries every such attribute,
because it returns the custom name when present and each composite renders its
children's names. That replaces the per-type timezone walk with a single
comparison, and covers wrappers and nested arguments without enumerating them.

The comparison is only valid on the equals-equal path. On the NOT-equals path a
name difference is a real conversion, where isRepresentationPreservingConversion
decides and a name test would refuse the whole allow-list (Enum8('a'=1) -> Int8
and friends). It also stays scoped to non-trivial expression indexes: a granule
over the bare column holds the raw byte, which neither a timezone nor a custom
name alters, so a simple single-column index must keep pruning.

Fixing only that would still have depended on load order. The part-side type was
read through IMergeTreeDataPart::tryGetColumn(), which answers from
columns_description -- a storage-wide interning cache whose key equality is
NamesAndTypesList::operator== -> IDataType::equals(), and whose hash ignores
types entirely. Two parts whose column lists differ only by a dropped attribute
therefore share one entry, and whichever part loads first decides what both
report; part loading is concurrent and shuffled. The type now comes from the
part's own uncached list, with tryGetColumn() kept as the existence test because
it is also the only accessor that resolves a subcolumn such as j.a.

The previous round's fail-closed fallback -- "is a timezone reachable from either
side" for a shape with no pairwise branch -- over-fired in the other direction:
DataTypeObject implements forEachChild, so an UNCHANGED JSON column with a typed
DateTime path reported a difference with no ALTER at all, and every non-trivial
index over such a column lost pruning on every part forever. Comparing names
answers correctly for two identical types, so that fallback is gone.

Test cases 15-18 cover the four carriers plus an over-fire control for each. Case
15 makes the cache collision deterministic with ATTACH PARTITION FROM: the
destination interns its Asia/Tokyo column list first, then the UTC-written part
arrives and hits that entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous commit reads the part-side type from the part's own column list so the
storage-wide ColumnsDescription interning cache cannot erase an attribute that
IDataType::equals() drops. That covered top-level columns only; a subcolumn
requirement such as p.x is not a top-level entry, so it fell back to the cached
description on the argument that "a subcolumn cannot itself be a top-level column
of the part, so no carrier is left on the cached path".

That argument is false. The carrier is the subcolumn's PARENT, whose type is exactly
what the cache erases and what the subcolumn's type is derived from.
DataTypeTuple::equals() recurses through its elements into
DataTypeNumber<UInt8>::equals(), a bare typeid test, so Tuple(x UInt8) and
Tuple(x Bool) compare equal and MODIFY COLUMN between them is metadata-only with no
mutation. The two parts then share one interned entry, whose subcolumn index was
built from the winning parent, so p.x resolved through it reports the other part's
type. A stale toString(p.x) granule holding '0'/'1' is accepted while the query now
evaluates 'false'/'true'. Part loading is concurrent and shuffled, so which of the
two types both parts report varies run to run.

Resolve a subcolumn from the part's own parent instead: look up
getNameInStorage() in the part's list and ask that type for the subcolumn via
IDataType::tryGetSubcolumnType(). The pair passed in comes from
IMergeTreeDataPart::tryGetColumn(), which has already split the name, because
"a.b.c" is ambiguous between column a with subcolumn b.c and column a.b with
subcolumn c and only the resolver knows which.

When the parent is present but offers no such subcolumn, refuse rather than fall
back to the cached type: that is a difference, not an unknown, and falling back
would reintroduce the load-order dependency. The refusal is scoped to subcolumns so
a top-level column keeps its existing fallback.

Both routes into that refusal are currently closed upstream (ATTACH PARTITION FROM
compares column lists with sizeOfDifference, and the ALTER validator rejects a
tuple element rename that breaks a skip index), so it is fail-closed rather than
test-covered.
…as staleness

A skip index over a subcolumn is refused for a part whose own column list cannot
describe that subcolumn. For a subcolumn of the DECLARED type that is right: the
part recorded a different parent type, so the granule holds bytes of the old type.
But a subcolumn can also exist only because the metadata-side type carries a custom
SERIALIZATION. A Quantized codec column is the case in tree today:
ColumnsDescription::attachQuantizeSerializationIfNeeded attaches
SerializationQuantizedVector to that column's own type instance, and
ColumnsDescription::addSubcolumns enumerates subcolumns through
getDefaultSerialization(), which returns the custom serialization when one is set,
so <col>.quantized is produced by the serialization and not by Array(Float32).

A part's own NamesAndTypesList is reconstructed from columns.txt, which round-trips
only the bare type name. Such a part therefore cannot describe <col>.quantized at
all, and that silence is an unrepresentable-in-columns.txt fact rather than a type
difference. Reading it as staleness made the index stop pruning for every part after
a reload, so the same query answered differently before and after a restart.

The refusal is reachable through two branches, and which one fires depends on part
load order rather than on anything durable: no IDataType::equals() implementation
compares custom_serialization, so a part whose own list is uncustomized shares the
interned ColumnsDescription of a customized one. tryGetColumn() then succeeds while
the part's own parent still offers no subcolumn. Both branches now ask the same
question -- does the metadata-side parent carry a custom serialization -- so the
answer cannot depend on which part loaded first.

The predicate is the generic property, not a type name: nothing here knows about
SerializationQuantizedVector, and no custom serialization is reconstructed on the
query path. A subcolumn of the declared type is unaffected, so a Tuple element and a
JSON typed path are still compared by the parent's equals() and a stale hint is
still caught; a genuinely absent column, and a subcolumn whose parent the part does
not carry at all, both still refuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…parent

The escape hatch that lets a part stay silent about a subcolumn its columns.txt
structurally cannot express answered a weaker question than it needed to: "does
the first resolvable dotted prefix of this name carry a custom serialization?".

Two gaps followed. A required name was split even when it is itself a physical
column, although ColumnsDescription::addSubcolumns documents that a physical
b.x may coexist with a b that has subcolumns of its own. And a custom
serialization on the resolved prefix was accepted without checking that the
prefix actually defines the requested suffix - Bool carries one (SerializationBool)
while defining no subcolumn at all, so an unrelated neighbour waved through a
required column the part is simply missing, skipping every compatibility check
for it and letting a stale granule prune all 16 granules and return 0 instead
of 64.

Both helpers now demand three things before the hatch fires: the exact name must
not be a physical column, the resolved parent must carry a custom serialization,
and that parent's own type must offer the requested suffix. A split that resolves
but fails either type clause continues to the next split, so a dotted parent name
such as `a.b`.quantized still resolves correctly. IDataType::tryGetSubcolumnType
is the right primitive here because IDataType builds its SubstreamData from
getDefaultSerialization(), which returns the custom serialization when one is
attached.

Also record in the getDeserializedFormat doc block that merge and mutate decide
index carry-forward from file existence, not from that verdict.

Found by Gate B review of the previous round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The paragraph added last round lumped merge in with mutate. Mutate does decide
carry-forward from file existence alone (MutateTask.cpp:441, :2235, :3180, and
MergeTreeBlockReadUtils.cpp:67 for the read side), but merge's two text-index
sites (MergeTask.cpp:2355 in MergeTextIndexStage::prepare, and :2932 in
addBuildTextIndexesStep) both call getDeserializedFormat and are governed by
this verdict, so a part whose text index it refuses is rebuilt during the merge
instead of being carried forward.

Comment only, no behaviour change.
The escape hatch for a subcolumn that only a parent's custom serialization defines asked whether the
metadata-side parent defines the suffix, but not whether THIS part holds that parent at all. A part
that never received the parent therefore skipped the type check entirely, and its granule, written
for the column's older type, was used to prune.

Reachable today through a backticked index expression. `ADD INDEX idx `vec.8`` is one identifier
rather than the dot operator, so the index requires the QBit bit-plane SUBCOLUMN, which no part list
can express; on a part that carries no `vec`, a killed `MODIFY COLUMN vec QBit(Float64, 4)` then left
the stale granule in use and the indexed count answered 38 against an unindexed 62. The unbackticked
`vec.8` spelling requires the physical parent instead and was already correct, which is why the
earlier carrier sweep over types missed this: reachability follows the spelling, not the type.

`hasSerializationDefinedSubcolumns` now also requires the part's own column list to carry the
resolved parent, and both call sites ask the identical question, since which of them fires depends on
part load order. The predicate names no type: the discriminator is parent presence. A subcolumn the
part cannot express for a parent it does hold still keeps pruning.
The previous commit message wrote the backticked index name inside a single-backtick span, which
cannot contain a backtick. The expression is:

    ALTER TABLE t ADD INDEX idx `vec.8` TYPE set(100) GRANULARITY 1

No change to the tree.
Case 24 asserts that a skip index over a serialization-defined subcolumn whose
parent the part does not carry refuses to prune. On its own that assertion is
also satisfied in a world where a backticked QBit subcolumn index never prunes
at all, so it cannot attribute the refusal to parent absence. The in-tree
coverage does not close the gap either: 04403 declares its index with the
unbackticked vec.8, which is the dot operator and requires the physical parent,
so it never exercises this spelling. A future change refusing every backticked
QBit subcolumn index would leave case 24 green while shipping a pruning
regression.

Case 25 adds the missing side: the same backticked index on a part that does
carry the parent, with no stale type, must still prune. The probe byte and the
granule count were measured on the fixture rather than copied - of the nine
distinct vec.8 values only 02 is selective, giving Granules: 1/16, while case
24's 00 spans ten granules and would have made the assertion nearly
unfalsifiable.

Each half of the pair is now separately load-bearing. Dropping the
parent-presence clause, so the escape hatch is taken unconditionally, reddens
only case 24; refusing a serialization-defined subcolumn on the type-compatible
path reddens only case 25.

Two comments are corrected. Case 24's claimed that a QBit bit plane cannot be
expressed in any part list, which is false: DataTypeQBit's constructor sets its
custom serialization unconditionally and NamesAndTypesList::readText rebuilds
the type through DataTypeFactory::get, so a reconstructed parent does offer the
subcolumn. The reason to refuse is parent absence; the unrepresentable case is
Quantized, whose serialization comes from a codec that columns.txt drops. Case
21 records that MergeTreeData::checkAlterIsPossible rejects any ALTER of a
Quantized codec, so no stale-type shape is constructible for that carrier.

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

Copy link
Copy Markdown
Collaborator Author

Internal second-model review

12 review rounds, 68 findings adjudicated. Final round: 1 blocker refuted by measurement, 2 self-fixes applied. Click to expand.

This PR was written by one model and reviewed by a different one on every round, with the reviewer
forbidden from editing the source. Findings below are from the final round; earlier rounds are
summarized at the end.

❌ Refuted with evidence

Quantized ATTACH codec mismatch is real, but pre-existing and index-independent (raised as a
blocker). The claim was that this PR's serialization-defined-subcolumn escape hatch lets a part
attached from a table with different Quantized(...) codec parameters be pruned with stale
granules. Measured on the pristine base commit and on this branch, three arms:

arm base commit this branch
index present, indexed read Code: 131 Code: 131 (byte-identical)
index present, use_skip_indexes = 0 Code: 33 Code: 33
no skip index in either schema at all Code: 33 Code: 33

The third arm is decisive: SELECT count() FROM dst WHERE \vec.quantized` = ...fails withCannot read all data of type FixedString. Bytes read:48. String size:20 (while reading column
vec.quantized)` with no index anywhere in the schema. The failure is in the column read, so no
index-usability predicate can be its cause, and nothing here was introduced by this PR.

Root cause is separate: MergeTreeData::checkStructureAndGetMergeTreeData compares physical column
types, keys, format_version and index/projection definition ASTs, but not column codecs, while
a Quantized codec's companion subcolumn derives its FixedString width from the codec parameters.
The mismatch is invisible to type-level checks (toTypeName is FixedString(20) on both sides).
Tracked separately; a fix belongs in ATTACH validation, and has to avoid over-rejecting legitimate
compression-only codec differences.

A prescribed mutation was structurally incapable of testing what it was asked to test. The fix
plan asked for a mutation making isSerializationDefinedSubcolumn return false, predicting it would
redden the new over-fire control. It cannot: that control's part carries its QBit parent, so
tryGetColumn succeeds and the part's own parent type offers the subcolumn, which means both escape
hatch call sites are bypassed on that path. The implementation reported this with a code trace
instead of adjusting the assertion, and supplied a mutation that refuses on the compatible path
instead. Verified independently; the reviewer's own prediction was wrong and is retracted.

⚠️ Fixed this round

Four counts in this description were stale after the last round added a test case: cases 24 to
25, control fixtures 18 to 19, explain ILIKE assertions 20 to 21, mutations 30 to 32. Each was
re-derived by grep rather than carried over. The changelog entry was also rewritten from past to
present tense per docs/changelog_entry_guidelines.md.

💡 Noted, not blocking

The new over-fire control's Granules: 1/16 assertion was checked for two silent-failure modes:
the probe byte is selective (the alternative byte would have matched 10 of 16 granules, a much
weaker pin), and Granules: is printed unconditionally by describeIndexes, so unlike a plan
Read type: assertion it cannot stop matching under compatibility randomization.

Earlier rounds

68 findings across 12 rounds, 42 agreed and fixed. The substantive ones: the part-side type had to
come from the part's own column list rather than the interned ColumnsDescription, whose key
equality erases the very attributes being compared and which is shared between parts by load order;
getDeserializedFormat had to become non-virtual with physical discovery split into
getPhysicalFormat, so no override can bypass the check; a same-meaning check was needed because
IDataType::equals deliberately ignores timezones and custom type names; the escape hatch for
serialization-defined subcolumns needed a parent-presence clause, without which an index over a
subcolumn of a column the part never received would skip the type check entirely; and one round's
review bounce was itself wrong and was retracted after the implementation refuted it.

@groeneai

Copy link
Copy Markdown
Collaborator Author
Pre-PR validation gate (click to expand)
# Question Answer
a Deterministic repro? Yes, 100%, no randomization needed. CREATE TABLE t (k UInt64, value String, INDEX idx value TYPE set(100) GRANULARITY 1) ENGINE = MergeTree ORDER BY k SETTINGS index_granularity = 4; then insert 64 rows, SYSTEM STOP MERGES, ALTER TABLE t MODIFY COLUMN value Nullable(UInt64) SETTINGS mutations_sync = 0, alter_sync = 0, KILL MUTATION WHERE table = 't' ... SYNC, then SELECT count() FROM t WHERE value = 3. On the base commit this aborts a debug server with the reported LOGICAL_ERROR. All twenty-four test cases are deterministic, including those added for the interning cache, an AggregateFunction argument timezone, a custom type name (UInt8 to Bool), an unchanged JSON column, a SUBCOLUMN index (toString(p.x) over p Tuple(x UInt8) becoming Tuple(x Bool)) whose cache collision is made deterministic with ATTACH PARTITION FROM, a subcolumn defined by a custom SERIALIZATION rather than by the declared type (vec.quantized under a Quantized codec, reached through a DETACH/ATTACH reload, through an ATTACH PARTITION FROM interning collision, and through a dotted parent name), and a subcolumn whose parent the part does not carry at all, and an absent PHYSICAL column whose name splits onto a custom-serialized neighbour that offers no such suffix (b.x UInt8 beside b Bool, plus the three-way-ambiguous `a.b`.x whose shortest split resolves to that neighbour while its true parent is a longer split).
b Root cause explained? ALTER ... MODIFY COLUMN changes the metadata type immediately and schedules a mutation to rewrite parts. MergeTreeDataSelectExecutor::canUseIndex, the gate that should exclude an index whose column type changed, infers the mismatch from a pending mutation ENTRY (AlterConversions::getAllUpdatedColumns()) and fails open when that set is empty. The set is empty in four measured ways: the mutation is pending but dropped from the storage snapshot (need_alter_mutations); the mutation was KILLED; no mutation is created at all (lazy JSON type hints); or the column conversion is representation-preserving while the index EXPRESSION's type still changes. Index analysis then builds the sample block from the CURRENT metadata and decodes the OLD bytes with the NEW type in MergeTreeIndexGranuleSet::deserializeBinary, so SerializationNullable reads a garbage length: null map size = N, nested column size = 0. Separately, the WHERE-clause top-k minmax read has no canUseIndex guard at all. Byte equality is also not sufficient: IDataType::equals answers "is the on-disk representation the same?" and deliberately drops attributes an index EXPRESSION does read -- the DateTime/DateTime64 timezone, the same timezone nested in an AggregateFunction argument (its equals compares argument_types with equals in turn), and a custom name over a plain type (Bool over UInt8, where DataTypeNumber<T>::equals is only a typeid test). Finally the part-side type itself was read through IMergeTreeDataPart::tryGetColumn, which answers from a storage-wide ColumnsDescription interning cache whose key equality is that same equals and whose hash ignores types entirely, so two parts differing only by a dropped attribute share one entry and whichever loads first decides what both report -- non-deterministically, since part loading is shuffled and concurrent. The same erasure reaches an index over a SUBCOLUMN one level down: DataTypeTuple::equals recurses into DataTypeNumber<UInt8>::equals, a bare typeid test, so Tuple(x UInt8) and Tuple(x Bool) are equals-equal and share an entry whose subcolumn index was built from the winning parent, and p.x resolved through it reports the other part's type while toString(p.x) changes '0'/'1' to 'false'/'true'.
c Fix matches root cause? Yes. It replaces the wrong question ("is a mutation pending?", mutable third-party state) with the right one ("do the part's bytes match the type they are about to be decoded with?", the part's own recorded type) at the layer that already receives the part and already exists to answer per-part usability, IMergeTreeIndex::getDeserializedFormat. It is not a guard at the crash site: no defensive check was added in deserializeBinary, which would have needed four separate guards for the four spellings and would have masked genuinely corrupt granules. The meaning half is the same shape: rather than a branch per type that drops an attribute, the equality check is paired with an IDataType::getName() comparison, the one projection that carries every such attribute (it returns the custom name when present and each composite renders its children's names), and the part-side type is read from the part's own uncached column list so the interning cache cannot erase the attribute first -- for a subcolumn by resolving it against the part's OWN parent type (getNameInStorage in the part's list, then IDataType::tryGetSubcolumnType), since the carrier of the erased attribute is the subcolumn's parent. That replaced roughly 100 lines of per-type recursion with one comparison.
d Test intent preserved / new tests added? New test 04165_skip_index_stale_type_after_alter with 25 cases: 11 failing cases (all four root-cause paths, the analysis-time and read-time top-k reads, the wrong-result enum direction, a part that carries the index files while recording no type for the required column, and an expression index over a timezone-dependent expression across a timezone-only MODIFY COLUMN), an interning-cache collision made deterministic with ATTACH PARTITION FROM, a timezone nested in an AggregateFunction argument, and a custom type name), over-fire controls (19 control fixtures carrying 21 EXPLAIN indexes = 1 assertions, 19 of them plain granule counts) asserting pruning is still applied and the top-k step's own Filter TopK Granules description, 1 case pinning the current behaviour of a deliberately out-of-scope known gap, and 1 case asserting that a subcolumn index whose PARENT the part does not carry at all still refuses (the same shape as the absent-column case, one nesting level down), and 1 case (two fixtures) asserting that an absent PHYSICAL column whose name merely SPLITS onto a custom-serialized neighbour still refuses -- the unrepresentable-in-columns.txt escape hatch must consider the exact name first and must require the resolved parent to actually offer the requested suffix, since Bool carries a custom serialization while defining no subcolumn at all. Case 25 is case 24's over-fire control: the same backticked `vec.8` index on a part that DOES carry the QBit parent with no stale type must still prune (Granules: 1/16, measured), so the refusal in case 24 is attributable to parent absence rather than to that spelling never pruning. No existing test was modified, weakened, or removed. The runner randomizes the top-k settings, so the top-k statements pin them explicitly; without that pinning the discriminating assertion silently passes on 7 draws in 20 and asserts nothing on the other 13. The timezone case pins use_query_condition_cache = 0 and both timezones in the DDL: the cache is keyed on the condition rather than on the index, so its oracle would otherwise re-serve the indexed statement's verdict, and session_timezone is randomized so an implicit-timezone fixture is not reproducible. No existing test was modified, weakened, or removed.
e Both directions demonstrated? Yes, at the strongest level. On the base commit (4a44dcefc451d3da, build id 138a79b8...) the test does not merely fail: the server dies with Logical error: 'Sizes of nested column and null map of Nullable column are not equal after deserialization (null map size = 141, nested column size = 0)' and the exact reported stack (SerializationNullable.cpp:178 from MergeTreeIndexGranuleSet::deserializeBinary from MergeTreeIndexReader::read from filterMarksUsingIndex). With the fix (build id e122c070..., server buildId() verified equal to the ELF Build ID) it passes, and 50/50 under randomized settings. The timezone carrier is measured the same way on two binaries built from the same tree: with the source reverted it returns 0 where its use_skip_indexes = 0 oracle returns 3, and with the fix both are 3, for bare DateTime, DateTime64, Nullable(DateTime), LowCardinality(DateTime) and Array(DateTime) alike. Thirty-two mutation runs each turn exactly the predicted case red and nothing else, and the restore rebuilt bit-identically to the pre-mutation binary. The round-4 carriers are measured the same way: on the previous head each returns 0 (or loses all pruning) where its oracle returns 3, 3, 32 and Granules: 0/16, and with the fix each matches its oracle.
f Fix is general across code paths? Yes, by chokepoint rather than by scattered guards. grep -rn getDeserializedFormat src/ --include=*.cpp gives 13 call sites; one non-virtual base wrapper covers ordinary marks filtering, both top-k paths (including MergeTreeIndexReadResultPool, which had no guard), data-read-time filtering, text direct read, patch parts and both merge-path sites. The wrapper is deliberately non-virtual so no present or future format override can bypass the check; mutation m6' demonstrates this by re-adding an override that forgets the check and showing only the minmax case turn red. All 13 consumers were classified usability vs physical (12 usability, 1 physical), and MergeTreeIndexReader's 3 construction sites were re-audited. A second door into the same defect was closed too: when the part's column list records no type for a required column the check now refuses instead of skipping, because a part can carry index files for an index whose required column was never written to it (MutateTask.cpp:420-450, issue #104872). That state is reachable and was measured to request 4 EiB before the guard. canUseIndex was left untouched, which removes the riskiest edit. hasSameMeaning and the part's-own-list accessor each have exactly one caller, isPartTypeCompatible, so they are covered by the same chokepoint. The subcolumn branch is likewise type-agnostic -- it asks whatever parent type the part recorded -- so Tuple, JSON, Map, Array sizes, a Nullable null-map and any future subcolumn-bearing type are covered with no per-type branch; measured on Tuple and JSON. Because the meaning comparison is attribute-agnostic there is no per-type branch that can be forgotten for a sibling: the three types with no forEachChild (QBit, Dynamic, Function) and any future dropped attribute are covered with no code change. One more code path was closed for the same reason: a subcolumn that exists only because the metadata-side parent carries a custom SERIALIZATION is unrepresentable in columns.txt, and the part's resulting silence reaches the check through TWO different branches depending on part load order -- tryGetColumn returning nothing, or tryGetColumn succeeding via the interned description while the part's own parent still offers no such subcolumn (no IDataType::equals implementation compares custom_serialization, so an uncustomized part can share a customized entry). Both branches were measured live on two different parts of one table with an instrumented build, and both now consult the SAME predicate, so the answer cannot depend on which part loaded first.
g Fix generalizes across inputs? Yes, and this is asserted in both directions. Refused: Nullable(UInt64), plain UInt64, Array(UInt64), every JSON/Object/Dynamic/Variant and LowCardinality difference, expression indexes on any type difference, expression indexes across a DateTime/DateTime64 timezone change (which IDataType::equals calls equal, so the bytes are identical while the expression's meaning is not), enum SHRINK, and the absent-column case where the part records no type at all. Allowed so pruning is preserved: identical types, Array/Nullable recursion, enum value-set EXTENSION, the width-preserving pairs Enum8/Int8, Enum16/Int16, DateTime/UInt32, Date/UInt16, and a SIMPLE single-column index across a timezone change (its granule holds the raw epoch value, which no timezone alters). The meaning comparison reaches an attribute nested at any depth -- inside Nullable/Array/LowCardinality/Map/Tuple/Variant, and inside an AggregateFunction argument -- because each composite's name renders its children's names; it treats two implicit-timezone types as equal, and it never refuses a Date, Time, Time64 or any type whose name is unchanged. Two more carriers are fixed on top of the timezone: AggregateFunction(max, DateTime('UTC')) to Asia/Tokyo under INDEX toHour(finalizeAggregation(v)), and UInt8 to Bool under INDEX toString(v) (both metadata-only, 0 mutations). Two more non-carriers are pinned: an UNCHANGED JSON(a DateTime('UTC')) column must keep pruning, and so must its JSON(a UInt64) twin. One more carrier is fixed one nesting level down: an index over the SUBCOLUMN p.x of p Tuple(x UInt8) becoming Tuple(x Bool), with two matching over-fire controls (a bare p.x TYPE minmax and an expression toString(p.x) TYPE set(100), both unchanged, both required to keep pruning at Granules: 4/17); reverting the subcolumn resolution reddens only that carrier (m21) and refusing every subcolumn requirement instead of comparing the resolved subcolumn types reddens only those controls (m22). Mutations pin every edge: blind to the timezone reddens the no-op-MODIFY control (m13a); blind to an aggregate argument or a custom name reddens exactly those carriers (m16, m18); testing merely "is this an AggregateFunction" instead of comparing its arguments reddens that carrier's over-fire control (m17); dropping the simple-index gate reddens the simple-index granule-count controls (m13b, m19); and restoring the old "is a timezone reachable" fallback reddens the unchanged-JSON control (m20). m18 and m19 together are the direct evidence the name comparison is correctly confined to the equals-equal path: it keeps the whole allow-list intact where it belongs, and kills it the moment it is allowed to reach the not-equals path. Directionality is load-bearing: Int8 to Enum8 is refused because the reverse allowance makes a stale granule silently prune away a granule the unindexed read rejects with Code: 691 (a wrong result, mutation m4). Controls 7-9 assert the optimization is PRESERVED, not just that results are correct, which is exactly the silent-regression class of #105384 to #107868. Index types covered: set, minmax (both current and legacy layout), bloom_filter, text. The custom-serialization predicate is likewise generic rather than type-named: grep -rn setCustomization src/ gives 7 sites, and the only one attaching a SERIALIZATION to a column's own type instance is ColumnsDescription::attachQuantizeSerializationIfNeeded; Bool attaches one through DataTypeFactory but exposes no subcolumn, and the geo types attach a NAME only, so neither can reach the predicate. Name shapes are covered in all three forms -- undotted, single-dot, and a dotted PARENT (`a.b`.quantized, which pins the name split: substituting a shortest-prefix find('.') for Nested::getAllColumnAndSubcolumnPairs reddens exactly that fixture). The over-fire direction is pinned by the absent-parent case: degrading the predicate from "the parent carries a custom serialization" to "the parent exists" makes a genuinely absent parent skip the type check, and the granule then decodes as garbage (Code: 241, measured). A custom serialization on the resolved parent is likewise not sufficient on its own: it must define the REQUESTED suffix, which IDataType::tryGetSubcolumnType answers correctly because IDataType builds its SubstreamData from getDefaultSerialization() (measured: b Bool exposes zero subcolumns, vec Array(Float32) CODEC(Quantized(...)) exposes vec.quantized). Dropping that suffix requirement makes a stale granule survive and allocate 64 GiB of garbage (Code: 241, measured on the `a.b`.x fixture, which is the shape where it is the only defender); the exact-name clause is kept for the same fail-closed reason and matches the ordering Nested::tryGetColumnNameInStorage already uses, although its unique carrier (a physical column shadowing a genuine serialization-defined subcolumn) cannot be materialized -- MATERIALIZE INDEX on it fails with Code: 6 because the mutation reads the subcolumn's bytes. The parent-presence clause is bracketed from both sides: dropping it (so the hatch is taken unconditionally) reddens only the refusal case 24 (indexed count 62 -> 38), and refusing a serialization-defined subcolumn on the type-compatible path reddens only the over-fire case 25 -- each arm reddens exactly one of the pair, so neither case can pass for the wrong reason.
h Backward compatible? Yes. No setting is added or changed, so SettingsChangesHistory.cpp needs no entry; no serialization format changes, so no up/downgrade path is needed; nothing is newly rejected. The only behaviour change is that a query which previously aborted, requested exabytes, or returned a wrong count (including the timezone case, whose ALTER creates no mutation at all, so KILL MUTATION is not even involved) now answers correctly with that one index skipped for that one stale part. Over-firing costs pruning, never correctness -- and one round-4 change moves pruning the other way: an UNCHANGED JSON column with a typed DateTime path previously lost pruning on every part forever, with no ALTER at all, and now prunes normally.
i Invariants and contracts preserved? The invariant added is: no skip-index granule is deserialized unless the part records, for every required column, a physical type that is both representation-compatible with the metadata type AND names the same thing. The part-side type is read from the part's own column list, so the invariant cannot be defeated by an interning cache keyed on the very comparison it exists to strengthen; a subcolumn is resolved from the part's own parent type rather than from the cached description, because the carrier of an erased attribute is the subcolumn's parent; if that parent is present but offers no such subcolumn the check REFUSES rather than falling back to the cache, since that is a difference and not an unknown. Stated that way it is fail-closed, so a part that records no type for a required column is refused rather than waved through. It holds on all paths because the single enforcement point is the non-virtual wrapper every read path already calls. The pre-existing contract of {0, {}} ("not materialized in this part") was the real hazard: extending it to also mean "materialized but not usable" would break consumers that need the physical fact. Rather than relying on remembering that, the two questions are split into separate accessors, and removeIndexMarksFromCache was moved to the physical one so index marks cached before the ALTER are still evicted. Mutation m7 measures that leak from SQL (system.metrics / IndexMarkCacheFiles: 1 to 0 with the fix, 1 to 1 without; 2 to 0 vs 2 to 2 for a legacy-layout minmax part). The renamed overrides keep their discovery bodies byte-for-byte, so physical discovery stays legacy- and packed-stream-aware. The one exception the invariant now admits is stated positively rather than as a fallback: when the metadata-side parent carries a custom serialization, the part's list cannot express the subcolumn at all, so its silence is an unrepresentable fact and not a type difference -- everything else, an absent column and an absent parent included, still refuses. Reading it needs metadata_snapshot, which is an IMergeTreeIndex member set by every creator (all 28 MergeTreeIndexFactory call sites were enumerated; none passes a null snapshot, and unguarded dereference is the established idiom in this subsystem); it is hoisted once outside the per-column loop, so the check costs one map lookup only on the branches that were about to refuse.

Session id: cron:clickhouse-impl-slot-44:20260729-043002

@groeneai

Copy link
Copy Markdown
Collaborator Author

cc @shankar-iyer @ahmadov, could you review this? The gate that should exclude a skip index whose column type changed infers staleness from a pending mutation entry, so a killed mutation (or a conversion that creates no mutation) leaves index analysis decoding a part's old bytes with the new type; this compares the part's own recorded type against the metadata type inside getDeserializedFormat instead, which also covers the text index and both merge-path sites.

@alexey-milovidov alexey-milovidov added the can be tested Allows running workflows for external contributors label Jul 30, 2026
@clickhouse-gh

clickhouse-gh Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [3623a24]

Summary:


AI Review

Summary

This PR moves skip-index usability checks into IMergeTreeIndex::getDeserializedFormat and closes most of the stale-type paths the report describes. I still see one remaining wrong-result hole when a required name changes carrier between a physical column and a subcolumn while keeping the same leaf type; because that point was already dismissed on-thread, I am keeping it in the summary instead of reopening or re-commenting inline.

Findings

❌ Blockers

  • [src/Storages/MergeTree/MergeTreeIndices.cpp:312-352] [dismissed by author -- https://github.com/Do not deserialize a skip index whose on-disk type is stale #112484#discussion_r3694489554] isPartTypeCompatible still accepts a same-spelled column from the wrong carrier. part.tryGetColumn(column) resolves exact physical names before subcolumns and tryGetPartOwnType then compares only the resulting type, but the row-read path binds the name through the current metadata carrier (src/Storages/MergeTree/MergeTreeBlockReadUtils.cpp:102-130). If an old part still has a Tuple(b UInt8) while metadata now has physical `a.b` UInt8 (or the reverse), the compatibility check accepts the old carrier on type alone, a stale set/minmax granule remains usable, and the query is evaluated against the new carrier or its default value instead. That can still prune away matching rows and return a wrong result. The previous reply refuted one probe, but it did not close this exact-name-hit path because there is still no getNameInStorage() / subcolumn-carrier equality check against the metadata-side resolution.
    Suggested fix: resolve the metadata-side NameAndTypePair once from metadata_columns, and refuse whenever the part-side resolution comes from a different getNameInStorage() / subcolumn carrier than the metadata-side one, except for the existing serialization-defined-subcolumn escape hatch.
Tests
  • ⚠️ Add one focused regression where an indexed a.b flips between a physical column and a Tuple subcolumn with the same leaf type, then query for a value that exists only under the new carrier/default. The current suite covers missing-parent and serialization-defined-subcolumn cases, but not this same-name carrier drift.
Final Verdict

Changes requested: the stale-type gate still has a same-name shadowing case that can misprune rows after a killed ALTER.

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.70% 86.80% +0.10%
Functions 92.00% 92.00% +0.00%
Branches 79.20% 79.20% +0.00%

Changed lines: Changed C/C++ lines covered: 94/106 (88.68%) · Uncovered code

Full report · Diff report

@clickhouse-gh clickhouse-gh Bot added the pr-bugfix Pull request with bugfix, not backported by default label Jul 30, 2026
Comment thread src/Storages/MergeTree/MergeTreeIndices.cpp
@groeneai

groeneai commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator Author

CI finish ledger - b3e80b1

Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
whose fixing-PR link will be posted here when it opens. Only CH Inc sync is exempt.

Check / test Reason Owner / fixing PR
Stateless tests (amd_debug, distributed plan, s3 storage, parallel) / Logical error: Bad cast from DB::ColumnNullable to DB::ColumnString (STID 5793-5883) trunk bug, master-reachable, many unrelated PR carriers #112501 (external, open)
Stateless tests (amd_debug, distributed plan, s3 storage, parallel) / Server died startup abort cascade of the same logical error above, one event #112501 (external, open)
Stress test (arm_debug) / Logical error: RWLockImpl::getLock(): Cannot acquire exclusive lock while RWLock is already locked (STID 2043-3c5c) trunk bug, unrelated to this diff a fix task owns this (investigating at full effort, fixing-PR link to follow on this PR)

The Bugfix validation (integration tests, amd64/aarch64) skipped rows are the documented
no-integration-test-updates case for a PR that adds only a functional test; all three run
Bugfix validation jobs report success on this commit.

Session id: cron:our-pr-ci-monitor:20260730-163853

groeneai added 5 commits July 31, 2026 16:55
Resolves the semantic overlap with the now-merged ClickHouse#109616, which introduced
IMergeTreeIndex::getAllSubstreamsInPart. That accessor answers the physical
question independently (its own getSubstreams + indexFileExistsInChecksums
walk, with an explicit comment that it is deliberately not routed through
getDeserializedFormat), so it needs no redirection onto this branch's new
getPhysicalFormat: the union-of-on-disk-versions property that mutation
cleanup and size accounting depend on holds by construction after the merge.

The only textual conflict was the MergeTreeIndexMinMax declaration block,
where this branch renames the virtual to getPhysicalFormat and master adds a
getAllSubstreamsInPart override. Both are kept.

Master adds no new getDeserializedFormat call site (13 before, 13 after), so
the usability-versus-physical audit of this branch still covers every caller.
Merging master shifted both getDeserializedFormat calls in MergeTask.cpp, so the
line numbers this comment quoted no longer resolved. Naming the enclosing
operations keeps the reference valid across unrelated churn in that file.
…heck

The compatibility helper unwrapped Array and Nullable but not LowCardinality, so a
dictionary-side representation-preserving conversion such as LowCardinality(DateTime)
to LowCardinality(UInt32) was judged stale and pruning was skipped on every affected
part although the granules stay valid. Measured on the merge base the index prunes
1/16 granules there; without this it reads 16/16.

LowCardinality keeps its own framing (a dictionary plus indexes) whatever the
dictionary type is, and SerializationLowCardinality forwards value bytes to the
dictionary type's own serialization, so the dictionary-side question is exactly the
one the existing allow list already answers.

The unwrap is pairwise like the wrappers above it. Adding or dropping the wrapper
changes the framing itself rather than the values, and no allow-list entry may wave
that through: with a one-sided unwrap the dropped-wrapper case reads a dictionary as
if it were values and fails with PARAMETER_OUT_OF_BOUND, which is what master does
today. Cases 26 and 27 cover the restored pruning, 28 and 29 pin the refusal.

Reported by clickhouse-gh[bot] on ClickHouse#112484.
The mechanism (LowCardinality forwards value bytes to the dictionary type's own
serialization, so a dictionary-side representation-preserving conversion leaves every
byte alone) belongs in the commit message, not beside the code. What the reader needs
at this line is why the match has to be pairwise.
Cases 28 and 29 asserted only the result, which also passes when a stale index is used
but happens not to misprune the probe value. Both now assert that no granule is dropped,
so the index is provably refused rather than merely harmless here.

That also makes case 28 catch the one-sided unwrap: dropping the pairwise requirement
reintroduces the PARAMETER_OUT_OF_BOUND master produces today, and the new granule
assertion is where the test now fails. The two cases refuse with or without the
LowCardinality branch, so their comment no longer claims otherwise.
Comment thread src/Storages/MergeTree/MergeTreeIndices.cpp
@clickhouse-gh

clickhouse-gh Bot commented Aug 1, 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: 99/107 (92.52%) · Uncovered code

Full report · Diff report

@alexey-milovidov

Copy link
Copy Markdown
Member

Can't wait... I will also take it for review.

@alexey-milovidov alexey-milovidov self-assigned this Aug 15, 2026
@alexey-milovidov
alexey-milovidov added this pull request to the merge queue Aug 15, 2026
Merged via the queue into ClickHouse:master with commit 2409fdc Aug 15, 2026
181 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 15, 2026
@clickgapai

Copy link
Copy Markdown
Contributor

@groeneai @alexey-milovidov @shankar-iyer — ClickGap found the following in this PR:

Close anything that's wrong or already addressed.

groeneai added a commit to groeneai/ClickHouse that referenced this pull request Aug 17, 2026
ClickHouse#112484 renamed the virtual IMergeTreeIndex::getDeserializedFormat to
getPhysicalFormat and made getDeserializedFormat a non-virtual wrapper that adds
usability checks. That rewrote the same four sites this branch touches.

Resolution: keep master's rename everywhere (both overrides and the doc block),
keep this branch's getPotentialSubstreams beside it. The eviction call site drops
master's getPhysicalFormat call entirely, which is the point of the branch: that
call is what does object-storage I/O during part destruction. getPhysicalFormat
keeps its other caller in getDeserializedFormat, so nothing is orphaned.

getPotentialSubstreams remains a superset of every layout getPhysicalFormat can
report, for both overriding index types: minmax returns .idx2 and legacy .idx
against master's two probes, and text returns .idx/.dct/.pst/.pos against
master's v1/v2 pair. The evicted keys therefore still cover every key
loadIndexMarksToCache can insert, which is the contract that could break
silently.

Also folded master's NOTE on getSubstreams into one sentence naming both
methods, and updated the gtest comment to the new method name.
groeneai added a commit to groeneai/ClickHouse that referenced this pull request Aug 17, 2026
Two things the master merge left inconsistent, both comment-only.

The doc block introduced by ClickHouse#112484 names "evicting index marks" as the exemplar
caller that needs physical discovery, but this branch removes exactly that
caller: resolving what a part holds probes the part's storage, which must not
happen while destroying a part. Left as-is the comment invites reintroducing the
object-storage read it exists to prevent, so it now names the constraint and
points at getPotentialSubstreams instead.

CLAUDE.md also asks for `f` rather than `f()` when a comment refers to a
function itself; applied to the lines this branch writes. Pre-existing text
elsewhere in these files is left alone so the diff stays about the substream API.
groeneai added a commit to groeneai/ClickHouse that referenced this pull request Sep 1, 2026
Refresh the branch against master d466df7 so it is ready
to review. The two CI failures the finish ledger owned have since been fixed on master
(ClickHouse#108855 and ClickHouse#112484), which is what was blocking Mergeable Check.

No conflict: the clone() triple copy still applies verbatim and master's newer restores in
the same function are preserved.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

blocker This issue / pr blocks a new release can be tested Allows running workflows for external contributors pr-bugfix Pull request with bugfix, not backported by default pr-synced-to-cloud The PR is synced to the cloud repo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stale set skip index read after ALTER MODIFY COLUMN whose mutation was killed aborts index analysis

6 participants