Skip to content

Record the storage column type when a mutation rewrites the whole part - #112501

Merged
diegomestre2 merged 8 commits into
masterfrom
fix-writer-columns-list-type-mismatch
Aug 5, 2026
Merged

Record the storage column type when a mutation rewrites the whole part#112501
diegomestre2 merged 8 commits into
masterfrom
fix-writer-columns-list-type-mismatch

Conversation

@alexey-milovidov

Copy link
Copy Markdown
Member

MutationHelpers::getColumnsForNewDataPart took the type of every column absent from the mutation's updated_header from the source part, with the comment "It may differ from column type in storage". That is right for MutateSomePartColumnsTask, which hardlinks those columns byte for byte, but wrong for MutateAllPartColumnsTask: it re-reads every column through the interpreter pipeline, which produces it at the current type in storage.

So the new part recorded the stale type while its data had the new one. The part writer takes the serialization from the column list and the block from the pipeline, so the very first write handed a ColumnNullable to SerializationString and threw Bad cast from type DB::ColumnNullable to DB::ColumnString in MergeTreeDataPartWriterOnDisk::initColumnsSubstreamsIfNeeded - an exception in the release build, an abort in debug and sanitizer builds.

A part carries a type older than the metadata whenever an ALTER TABLE ... MODIFY COLUMN was not applied to its data, which happens both when the part is detached across the ALTER and when the type change is left to be applied on read. A later mutation that rewrites the whole part then aborts. The reported run hit it through ALTER TABLE ... MATERIALIZE PROJECTION in 03522_alter_modify_column_and_materialize_projection: the projection's required columns are only read, so they stay out of updated_header, while the _block_number materialization that enable_block_number_column adds and the automatic statistics of auto_statistics_types together make the mutation rewrite all columns.

"Rewrites every column" is now decided once, in MutationHelpers::rewritesAllPartColumns, and used both for the column list and for the task selection, so the two cannot drift apart.

Verified locally against a release build of master: the new test and 03522_alter_modify_column_and_materialize_projection abort with the exact stack trace from the report before the change and pass after it; 319 tests matching mutat, projection, alter_modify and materialize_column pass, and every remaining failure in that selection is a missing local prerequisite (no Keeper, no s3, transactions disabled, no macros).

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=09f2aea4ab4c9f6c0b85369f9fb3feec15f9bd4a&name_0=MasterCI&name_1=Stateless%20tests%20%28amd_msan%2C%20WasmEdge%2C%20parallel%2C%202%2F2%29

Closes: #112459
Related: #112417

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

Fix Bad cast from type DB::ColumnNullable to DB::ColumnString when a mutation that rewrites the whole part runs on a part whose column type is older than the one in the table metadata, for example after ALTER TABLE ... MODIFY COLUMN followed by ALTER TABLE ... MATERIALIZE PROJECTION.

`MutationHelpers::getColumnsForNewDataPart` took the type of every column
absent from the mutation's `updated_header` from the source part, with the
comment "It may differ from column type in storage". That is right for
`MutateSomePartColumnsTask`, which hardlinks those columns byte for byte, but
wrong for `MutateAllPartColumnsTask`: it re-reads every column through the
interpreter pipeline, which produces it at the current type in storage.

So the new part recorded the stale type while its data had the new one. The
part writer takes the serialization from the column list and the block from the
pipeline, so the very first write handed a `ColumnNullable` to
`SerializationString` and threw `Bad cast from type DB::ColumnNullable to
DB::ColumnString` in `initColumnsSubstreamsIfNeeded` - an exception in the
release build, an abort in debug and sanitizer builds.

A part carries a type older than the metadata whenever an `ALTER TABLE ...
MODIFY COLUMN` was not applied to its data, which happens both when the part is
detached across the `ALTER` and when the type change is left to be applied on
read. A later mutation that rewrites the whole part then aborts. The reported
run hit it through `ALTER TABLE ... MATERIALIZE PROJECTION`: the projection's
required columns are only read, so they stay out of `updated_header`, while the
`_block_number` materialization that `enable_block_number_column` adds and the
automatic statistics of `auto_statistics_types` together make the mutation
rewrite all columns.

Decide "rewrites every column" once, in `MutationHelpers::rewritesAllPartColumns`,
and use it both for the column list and for the task selection, so the two
cannot drift apart.

CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=09f2aea4ab4c9f6c0b85369f9fb3feec15f9bd4a&name_0=MasterCI&name_1=Stateless%20tests%20%28amd_msan%2C%20WasmEdge%2C%20parallel%2C%202%2F2%29

Closes: #112459
Related: #112417

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

clickhouse-gh Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [a378342]

Summary:


AI Review

Summary

This PR makes full-part mutation rewrites use the same rewrites_all_columns decision both for task selection and for new-part metadata, so untouched/read-only columns no longer keep stale source-part types or stale SerializationInfo when the rewrite actually re-reads them through the interpreter. I checked the current diff, the touched code paths in MutateTask and SerializationInfoTuple, the new stateless coverage, and the prior review discussion; I do not have remaining blocker or major findings on the current head.

Final Verdict

✅ Approve.

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: 48/51 (94.12%) · Uncovered code

Full report · Diff report

@alexey-milovidov

Copy link
Copy Markdown
Member Author

The only CI failure, AST fuzzer (amd_tsan)Logical error: Invalid number of columns in chunk pushed to OutputPort (STID: 2270-2ea4) — is unrelated to this PR: the failing query exercises the normal projection optimization with a correlated scalar subquery, while this PR only changes the mutation full-rewrite path in MutateTask. The same STID previously failed on #111521, and the fix for this failure class is already in progress: #111401 (Related: #103695). Merged master to rerun CI.

Comment thread src/Storages/MergeTree/MutateTask.cpp
…hen a mutation rewrites the whole part

The column list of the new part now records the storage type for an untouched
column when the mutation rewrites every column, but its `SerializationInfo` was
still carried over from the source part, where it describes the stale type.

`IMergeTreeDataPart::setColumns` passes that info to `getSerialization` of the
type from the column list, and `DataTypeTuple` `assert_cast`s it to
`SerializationInfoTuple` and indexes one element info per tuple element. A part
detached across `ALTER TABLE ... MODIFY COLUMN t Tuple(x String) -> Tuple(x
String, y String)` therefore read past the end of that element list on the next
full-part rewrite: an out-of-bounds access in the release build, a libc++
hardening assertion in debug and sanitizer builds.

The info is now rebuilt at the storage type in that case, the same way it is for
the columns the mutation writes, and the new test covers the tuple scenario.
Comment thread src/Storages/MergeTree/MutateTask.cpp
…alizationInfoTuple::createWithType`

`SerializationInfoTuple::createWithType` builds the element infos from the new type but carried the
element names of the old one over. `structureEquals` ignores names, so a same-arity rename of a tuple
element - `Tuple(String)` to `Tuple(y String)`, for example - reached that path and produced an info
whose elements are keyed by the names the part no longer has.

Everything that merges tuple subinfos matches them by name: `SerializationInfoTuple::replaceData`,
which the mutation uses to fold in the data actually written, and `SerializationInfoTuple::add`, which
both the per-table serialization hints and a merge use to aggregate the source parts. A renamed element
therefore looked missing and contributed all-default rows, so a merge after a full-part rewrite picked
the sparse serialization for a column without a single default value.

Both callers of `createWithType` produce an info for the new type, so taking the names from it is the
only consistent choice. Added a fourth case to
`04653_mutation_rewrite_stale_part_column_type`: without the fix the merged part stores `t.y` as
`Sparse` although all 105 of its rows are non-default.
@alexey-milovidov

Copy link
Copy Markdown
Member Author

🕵 The CI reds on 1e2f9ca1751 were all unrelated to this PR (it only touches the mutation path and SerializationInfoTuple::createWithType):

@groeneai, please investigate the two stateless failures above and provide a fix in a separate PR; if a fix is already in progress, link it here.

Comment thread src/Storages/MergeTree/MutateTask.cpp
@groeneai

groeneai commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Both investigated. One already has a merged fix (yours); the other I could not attribute to a product defect, and I found a separate real harness defect while looking.

04648_geohashes_in_box_cancellation (amd_tsan) is fixed by #112823.

The failing shape was expanding kill blocked 5004ms against the test's 4000ms bound. At 1e2f9ca1751 the source still had items_between_cancellation_checks = 1'000'000, so a cancelled query had up to a full million-item span left to finish; #112823 cut that to 100'000. Since it merged (4e72099285b2, 2026-08-01T14:25:48Z, now an ancestor of master), CIDB shows 3945 runs of this test with 4 failures, and all 4 ran the pre-fix file (their context still defines ROWS_DEGENERATE, removed by that PR): 0 failures on the current file. b717f55e6a8 already contains both halves, so no action is needed here.

02105_backslash_letter_commands (amd_debug): I cannot attribute this to a product defect, so I am not proposing a fix for it.

From the expect debuglog: the client answered SHOW DATABASES in 2ms (10:40:20.251), rendered the table to the pty as far as row 35, then produced nothing for the remaining 60s of the timeout. The child stayed alive throughout, which the log distinguishes: a dead child under this script's eof { exp_continue } handler fails as spawn id ... not open, not as the clean expect: timed out recorded here. The job ran --jobs 19 on a debug build, and metric_log over that stall window shows repeated CPU and IO starvation (OSCPUWaitMicroseconds peaks 2.71s within one second at 10:41:03, OSIOWaitMicroseconds 2.10s at 10:41:19). Breadth is 8 rows over 4 PRs in 90 days with 0 master hits, and the runner's own rerun was 73/73. I have no local reproduction, so anything I changed in the client would be a guess.

Separate finding, real and mechanical: match_max is a no-op in 53 of the 54 .expect tests.

Every one of them sets match_max 100000 before spawn, but without -d that call applies to the current spawn id, and there is none yet, so each spawned process keeps the 2000-byte default:

$ expect -c 'match_max 100000; spawn true; puts [match_max]'   -> 2000
$ expect -c 'match_max -d 100000; spawn true; puts [match_max]' -> 100000

Truncation is visibly happening in this very run: expect's match buffer starts at the sent command, then later restarts mid-table around row 8. It is not the cause of the failure above, because expect discards the front of the buffer and the prompt arrives last, which I confirmed by replaying a 9.4KB burst ending in the prompt 25 times at each limit (25/25 matched either way). So this is a latent defect that weakens the harness rather than an explanation for the red.

Happy to send that one-word-per-file match_max fix as a separate PR if you want it; I did not open it unprompted since it demonstrably does not fix the failure it came from.

@clickhouse-gh

clickhouse-gh Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 86.60% 86.50% -0.10%
Functions 91.90% 91.90% +0.00%
Branches 78.80% 78.70% -0.10%

Changed lines: Changed C/C++ lines covered: 40/41 (97.56%) · Uncovered code

Full report · Diff report

… too

`getColumnsForNewDataPart` has three carriers of the source part's column
type for a column that is not in `updated_header`: the column the mutation
renames into this name, the stale name a renamed column still has in the
source part, and the same-named column. Only the last one was guarded by
`rewrites_all_columns`, so a full-part rewrite of a stale part whose column
was renamed after the type change still recorded the source part's type.

Reproduced: detach a part, `MODIFY COLUMN b Nullable(String)`, attach it,
`RENAME COLUMN b TO d` - the part then carries `d` as `String` while the
table says `Nullable(String)`. A following full-part rewrite reads `d`
through the rename branch and the new part claimed `String` for a
`ColumnNullable`, so the writer threw
`Bad cast from type DB::ColumnNullable to DB::ColumnString`.

Added a fifth case to `04653_mutation_rewrite_stale_part_column_type`.

@diegomestre2 diegomestre2 left a comment

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.

The implementation fixes the Bad cast from DB::ColumnNullable to DB::ColumnString do to an issue between the metadata and the data parts after an alter table. I would consider just a reduction on the amount of comments but this can be done later, for now the main issue is addressed. Not sure how this would apply when two replicas have to do this change in parallel but maybe the tests are already covered.

@diegomestre2
diegomestre2 added this pull request to the merge queue Aug 5, 2026
Merged via the queue into master with commit 6b8c168 Aug 5, 2026
180 checks passed
@diegomestre2
diegomestre2 deleted the fix-writer-columns-list-type-mismatch branch August 5, 2026 13:08
@robot-ch-test-poll robot-ch-test-poll added the pr-synced-to-cloud The PR is synced to the cloud repo label Aug 5, 2026
continue;
}

new_serialization_infos.emplace(new_name, old_info);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

emplace(new_name, old_info) hands the new part the same SerializationInfo object the source part owns, and the full-rewrite finalizer then mutates it in place.

SHould we clone old_info instead to prevent this? Because in some cases both parts point at the same object and the type guard in updateSerializationHintsForPart passes for both, so the two operations cancel exactly: the new part's numbers are never added and the source part's original numbers are never removed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes, clone it. The aliasing you describe is real and I confirmed the exact intersection by pointer identity, not by reading.

I instrumented three sites on master (0eb5eba20b9) and logged the SerializationInfo addresses: the emplace(new_name, old_info) here, the serialization_infos.replaceData(new_serialization_infos) in MergedBlockOutputStream::fillChecksums, and the add/remove in updateSerializationHintsForPart. Case 5 of 04653_mutation_rewrite_stale_part_column_type reaches all three on one object:

ALIAS_TAKEN            part=all_3_3_0_4 col=a ptr=0x781b6ab07018 rewrites_all=true
MBOS_REPLACEDATA (pre) part=all_3_3_0_5 col=a ptr=0x781b6ab07018 nrows=0 ndef=0
MBOS_REPLACEDATA (post)part=all_3_3_0_5 col=a ptr=0x781b6ab07018 nrows=1 ndef=0
HINT_ADD               part=all_3_3_0_5 col=a ptr=0x781b6ab07018 nrows=1 ndef=0
HINT_REMOVE            part=all_3_3_0_4 col=a ptr=0x781b6ab07018 nrows=1 ndef=0

So the source part and the new part do hold the same object, the full-rewrite finalizer does mutate it in place, and both hint operations then read the post-mutation state. The add for the new part and the remove for the source part cancel exactly, and the source part's original contribution is never subtracted, which is what you said.

What I could not do is turn that into a wrong serialization_hint. In every shape I got to the alias line with rewrites_all_columns, the two counts were equal anyway, so the cancellation was a no-op. I tried 23 shapes (partial and full rewrites; wide, compact and packed storage; row TTL and column TTL; RENAME/CLEAR/DROP/MODIFY COLUMN; MATERIALIZE COLUMN/INDEX/PROJECTION/TTL; APPLY PATCHES; lightweight and ALTER DELETE), and the ones that change a column's row count or defaults inside a full rewrite all pull the column into updated_header, so they take the branch above instead of this one. My oracle was the in-memory hint against the same hint rebuilt from the on-disk parts by DETACH/ATTACH; it agreed in all 23.

So I have no user-visible repro, but the invariant is broken and the in-place write is real, so the arithmetic is only correct by accident of the counts matching. old_info->clone() is one allocation per untouched column on a path that is already rewriting the whole part, and it makes the two parts independent regardless of what a future path does to the counts. I would clone.

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 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.

[CI crash] Invalid type cast during serialization

5 participants