Fix divergent mutation of Compact parts with 'basic' serialization info - #113588
Conversation
Two replicas holding a byte-identical Compact level-0 part produced different mutated parts from the same CLEAR COLUMN, and one of them failed with CHECKSUM_DOESNT_MATCH. `propagate_types_serialization_versions_to_nested_types` is written to `serialization.json` only when the serialization info version is at least WITH_TYPES, and `readJSONFromString` initializes it to false. A part reloaded from a BASIC `serialization.json` therefore always reported false while the storage reported the setting's own value. A mutation compares a settings object built from the source part's infos against one built from the storage settings, and `operator==` is defaulted, so this never-persistable field alone made them unequal. That fired the loop which materializes serialization info entries for every mutated column, including columns that were never written, so a part whose infos came from disk gained entries a part whose infos were still in memory did not. Normalize the field in the SerializationInfoSettings constructor, in the existing `version < WITH_TYPES` block that already defaults the string, nullable and map serialization versions for the same compatibility reason. All eight in-tree sites that build such an object from storage settings route through this constructor, so the writer, merge, mutate and JSON reload paths now agree by construction. Writing it as a threshold rather than a comparison against BASIC keeps it correct once a version above WITH_TYPES exists. Nothing changes on disk: `writeJSON` already gates the key on WITH_TYPES, and under BASIC the three sibling resets already force every nested serialization to its default, so the flag has no observable consumer there. Measured on a single server: a replicated CLEAR COLUMN over a Compact part goes from one MutatePart error 40 plus a recovery fetch to two clean mutations producing identical 519-byte parts. Reads agree and CHECK TABLE returns 1 on both replicas on both binaries, so the defect cost a failed mutation and redundant metadata rather than data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Internal second-model review: 2 rounds, 0 blockers, 0 majors outstandingI put this through an independent cold review plus a second model, over two rounds. Round 1 produced
|
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-impl-slot-42:20260805-183600 |
|
cc @rienath @CurtizJ, could you review this? Under |
|
Workflow [PR], commit [d7af3ae] AI ReviewSummaryThis PR normalizes Missing context / blind spots
Final Verdict✅ No new blockers or majors in the current diff. |
| string_serialization_version = MergeTreeStringSerializationVersion::SINGLE_STREAM; | ||
| nullable_serialization_version = MergeTreeNullableSerializationVersion::BASIC; | ||
| map_serialization_version = MergeTreeMapSerializationVersion::BASIC; | ||
| propagate_types_serialization_versions_to_nested_types = false; |
There was a problem hiding this comment.
SerializationInfoSettings::updateHash still skips map_serialization_version, so two settings objects that differ only in the Map format hash identically. That is only a benign collision for the SharedPartColumns caches, but SerializationDynamic::create pools by the 128-bit hash alone (SerializationDynamic.cpp:54-60, SerializationObjectPool.cpp:41-61). If a Dynamic value contains a Map, building a serialization once under map_serialization_version = 'basic' and then under WITH_BUCKETS will reuse the first pooled object, so the second table writes the wrong nested Map encoding. Can we include map_serialization_version in updateHash in the same fix?
There was a problem hiding this comment.
Confirmed, and it is already fixed in #113514 rather than here.
updateHash does skip map_serialization_version, and the consequence is as described:
SerializationDynamic::getHash delegates to it (SerializationDynamic.cpp:54-60) and the pool
is an absl::flat_hash_map<UInt128, std::weak_ptr<const ISerialization>> looked up by key with
no equality fallback (SerializationObjectPool.cpp:26, :46-49), so a hash collision is an
object identity collision.
That one-line addition plus its regression tests are in #113514, which touches the same
function in the same file. It carries hash.update(static_cast<int>(map_serialization_version));
and the gtest DynamicPooledSerialization.PoolKeyDistinguishesMapSerializationVersion, plus the
stateless test 04753_dynamic_map_buckets_pooled_serialization, which builds the pooled object
under map_serialization_version = 'basic' and then reads a with_buckets table, the exact
sequence described above.
I am not folding it in here, for a reason beyond one concern per PR. I measured what folding
would do: this PR's hunk is in the constructor (line 28) and #113514's is in updateHash
(line 74), so today git merge-tree reports 0 conflicts between the two. If I add the same line
here, merge-tree still reports 0 conflicts, but the resolved file contains
hash.update(static_cast<int>(map_serialization_version)); twice, once at each side's
position. Nothing flags that: it compiles, and the resulting hash silently differs from what
#113514 intends.
This PR is scoped to the constructor normalising the never-persistable
propagate_types_serialization_versions_to_nested_types flag under
serialization_info_version = 'basic', which fixes the CHECKSUM_DOESNT_MATCH mutation
divergence in #113500. The pool key hash is a distinct defect with its own CI signature
(LOGICAL_ERROR: Stream '<col>.Map(...).buckets_info ... is not found) and its own tests.
CI finish ledger — a6ed6b3Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
None is PR-caused. The 2508-30f6 breadth is decisive on its own: 79 distinct unrelated PRs and 9 Note for anyone re-deriving the STID from the database: Fix-presence probe for the Gates: (a) 175 check-runs, 0 queued/in_progress/pending/waiting; (b) Session id: cron:our-pr-ci-monitor:20260806-050000 |
|
Fixing PR for the It replaces the oracle that counted every The failure on this PR was on a build that predates that merge. Probing the test file per build (marker No action needed here beyond a rerun or a rebase onto current master. |
| EXPECT_NE(restored.tryGet("tuple"), nullptr); | ||
| } | ||
|
|
||
| namespace |
There was a problem hiding this comment.
@groeneai remove the gtest, we already have the sql one
There was a problem hiding this comment.
Done in d7af3ae. gtest_serialization_info.cpp is back to its master content.
I checked the coverage before removing, because two of the five assertions were about the scope of the normalisation rather than the fix itself, and I was not sure the SQL side reached those. It does. Every source mutation that reddens one of the assertions also reddens a .sql test:
| mutation | 04771 (new) |
03800_string_size_stream_in_nested_types |
|---|---|---|
| the added line removed (the bug) | FAIL | OK |
| the reset applied unconditionally | OK | FAIL |
else { flag = true; } for with_types |
OK | FAIL |
| shipping | OK | OK |
03800 is what makes the scope half redundant: it already creates the same ten-column nested-type table twice under serialization_info_version = 'with_types', once with propagate_types_serialization_versions_to_nested_types = 0 and once with 1, and asserts system.parts_columns.substreams for both. A normalisation that fires outside version < WITH_TYPES collapses the second block onto the first, so s.size, ns.size, ms%2Ekeys.size and the rest disappear from its reference. The third mutation also reddens 03274_dynamic_column_sizes_vertical_merge and 03262_column_sizes_with_dynamic_structure.
Each arm ran on its own server with SELECT lower(buildId()) asserted against the binary, and the source is byte-identical to before afterwards.
@Avogar this is after your approval, so the tree is not the one you looked at. The diff is now 2 source lines plus the stateless test; the deleted file is identical to master. Sorry for the extra round.
| { | ||
| /// New type specialized serialization version is valid only when using MergeTreeSerializationInfoVersion::WITH_TYPES. | ||
| /// For older versions, it is automatically defaulted to preserve compatibility. | ||
| /// This includes `propagate_types_serialization_versions_to_nested_types`, which older versions cannot persist. |
`PedroTadim` asked for the gtest to be dropped because the SQL test already covers the behaviour. Measured before removing: every source mutation that reddens one of the five gtest assertions also reddens a `.sql` test, so no mutant coverage is lost. The two assertions on the fix itself are covered by `04771_mutation_serialization_info_basic_divergence`, which fails when the constructor line is removed. The three assertions on the scope of the normalisation, that `with_types` still honours `propagate_types_serialization_versions_to_nested_types`, are covered by the existing `03800_string_size_stream_in_nested_types`: it builds the same nested-type table under `with_types` with the setting off and on and asserts `system.parts_columns.substreams` for both, so a normalisation applied unconditionally changes its reference. Making the reset unconditional, or forcing the flag on for `with_types`, both fail that test; `03274_dynamic_column_sizes_vertical_merge` and `03262_column_sizes_with_dynamic_structure` fail on the second as well. The rest of `gtest_serialization_info.cpp` is untouched and the file is now identical to master.
Head branch was pushed to by a user without write access
776dc91
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixed a mutation of a Compact part producing a different result depending on whether the part's serialization info was still in memory or had been reloaded from
serialization.json, when the table usesserialization_info_version = 'basic'. OnReplicatedMergeTreethis made two replicas holding a byte-identical source part write different mutated parts, and the mutation failed withCHECKSUM_DOESNT_MATCH. Closes #113500.Description
propagate_types_serialization_versions_to_nested_typesis written toserialization.jsononly when the serialization info version is at leastWITH_TYPES, andreadJSONFromStringinitializes it tofalse. A part reloaded from aBASICfile therefore always reportedfalsewhile the storage reported the setting's own value.A mutation compares a settings object built from the source part's infos against one built from the storage settings, and
operator==is defaulted, so this never-persistable field alone made the two unequal. That fired the loop which materializes serialization info entries for every mutated column, including ones never written, so a part whose infos came from disk gained entries an in-memory one did not and the cross-replica checksum comparison failed.The fix normalizes the field in the
SerializationInfoSettingsconstructor, inside the existingversion < WITH_TYPESblock that already defaults the string, nullable and map versions for the same reason. All eight in-tree sites that build such an object from storage settings route through this constructor, so the writer, merge, mutate and reload paths agree by construction.MutateTaskis not touched.Nothing changes on disk:
writeJSONalready gates the key onWITH_TYPES, and underBASICthe sibling resets already force every nested serialization to its default, so the flag has no observable consumer. Measured over the nested types in Wide parts, the flag on and off gives identical files and content hashes; the same probe underWITH_TYPESgives 62 files vs 50, proving it can see the flag. No setting default changes, and existing parts stay readable with no migration.Reverting the added line reddens the new stateless test. Applying the reset unconditionally, or forcing the flag on for
with_types, reddens the existing03800_string_size_stream_in_nested_types, which already asserts the substreams of that table with the setting off and on.Affected: master, 26.7, 26.6. Could someone add
v26.7-must-backportandv26.6-must-backport?Reproducer and measurements
Replication is only the carrier; two plain
MergeTreetables in one server differ only in aDETACH/ATTACH:Before:
a4 files / 208 bytes with noserialization.json;b5 files / 295 bytes carrying{"kind":"Default","name":"arr","num_defaults":0,"num_rows":0}for a column that was never written.data.binand every other file are byte-identical; onlyserialization.jsondiffers, andchecksums.txtdiffers because it checksums it. After: both 4 files / 208 bytes.Arrayis the minimal carrier because it cannot use sparse serialization, so it gets no entry in thesource part, and that is the entry the mutation invents.
Replicated arm,
system.part_logscoped to the two replicas:MutateParterrorserror=40)DownloadPartCHECK TABLEon both replicasSo the cost was a failed mutation plus a wasted mutate-and-refetch, not data loss or wrong results.