Fix crash in CrossTab aggregate functions during Variant to Variant merge#109200
Conversation
…erge The cramersV, cramersVBiasCorrected, contingency and theilsU aggregate functions have two internal state representations (Aggregation and Window) that share the same type name. PR ClickHouse#104277 fixed the crash when a scalar AggregateFunction column carrying one representation is cast into a Variant whose same-named slot expects the other (createColumnToVariantWrapper). The Variant to Variant cast path (createVariantToVariantWrapper) had the same gap: it mapped variant slots purely by name and copied their subcolumns verbatim, so a subcolumn carrying one representation could be placed under a slot expecting the other. Chained set operations (EXCEPT / INTERSECT) and nested UNION ALL produce exactly this Variant to Variant cast, so a later read of the state (finalization, serialization, or the -Merge combinator) then interpreted the bytes with the wrong layout and the server aborted. Convert the subcolumn to the destination representation in createVariantToVariantWrapper when an old and a new slot match by name but differ (equals() is false), reusing the existing AggregateFunction representation-converting cast, instead of copying it as-is. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-worker-slot-2:20260702-151000 |
|
cc @Avogar @nihalzp — could you review this? It extends the fix from #104277 to the |
|
Workflow [PR], commit [6419d2b] Summary: ✅
AI ReviewSummaryThis PR closes the live Findings❌ Blockers
Final Verdict
|
…ed types Follow-up to the Variant to Variant fix in this PR. The same hidden AggregateFunction state representation mismatch (Aggregation vs Window, which share the same type name) is still reachable through two more paths. 1. Dynamic carrier. A Dynamic column identifies its stored types by name and re-resolves the type from that name (dynamicElement, the -Merge combinator). Casting a Window state to Dynamic stored it under its name without converting to the canonical (name-resolved, Aggregation) representation, so a later read interpreted the Window bytes with the Aggregation layout and the server aborted. This reproduces with a single value (CAST(<window state> AS Dynamic) then a read), so it is independent of combining two Dynamic columns. createVariantToDynamicWrapper now builds the Dynamic's storage Variant from the canonical representation of each source type, so the existing Variant to Variant cast converts any mismatched subcolumn. 2. Nested types in a Variant slot. createColumnToVariantWrapper only converted the representation when the AggregateFunction was the top-level variant type, so Variant(Array(AggregateFunction(...))) still copied the subcolumn verbatim and crashed. The source type was matched to the destination variant by name, so any remaining inequality is such a hidden representation difference; convert it unconditionally (prepareUnpackDictionaries recurses into Array/Map/Tuple), which also covers the nested Dynamic case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-PR validation gate for 6419d2b (Dynamic carrier + nested types)Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-worker-slot-10:20260702-175800 |
| variants_for_dynamic.reserve(source_variants.size() + 1); | ||
| for (const auto & source_variant : source_variants) | ||
| { | ||
| auto canonical_variant = DataTypeFactory::instance().tryGet(source_variant->getName()); |
There was a problem hiding this comment.
This only fixes newly-created Dynamic values. It does not make already-persisted bad Dynamic rows readable after upgrade.
Before this patch, CAST(<window state> AS Dynamic) stored the window representation under the canonical AggregateFunction(...) name. That can be written to disk: SerializationDynamic stores only the name for regular variants in V1/V2 (src/DataTypes/Serializations/SerializationDynamic.cpp:219-224, 391-395), and dynamicElement / DataTypeDynamic::getDynamicSubcolumnData still resolve reads by name back to the canonical aggregation type (src/DataTypes/DataTypeDynamic.cpp:894-922). So an upgraded server still ends up pairing those persisted bytes with the wrong state layout.
I think this needs a read-side compatibility or explicit-rejection path for same-name Dynamic slots whose stored type is not equals() to the canonical one. If V1/V2 rows are no longer distinguishable, that limitation needs to be called out explicitly, because as written the fix only protects new writes.
There was a problem hiding this comment.
The persisted bytes are representation-independent, so there are no bad on-disk rows to make readable. The mismatch is only in the live in-memory column layout, never in what reaches disk.
Verified on two Build-ID-verified debug binaries (the fix is one source file, so pre-fix 97643a87 = parent's FunctionsConversion.cpp, post-fix 193be927 = HEAD):
- Pre-fix writes
CAST(<window state> AS Dynamic)into an on-disk MergeTree (Wide part): no crash. - Pre-fix reads it back:
cramersVBiasCorrectedMergereturns the correct value (0.3506),dynamicType/toStringresolve fine. - Upgrade sim: the post-fix binary reads the pre-fix-persisted data and returns the same 0.3506.
- The on-disk
d.AggregateFunction(cramersVBiasCorrected, UInt8, UInt8).binis byte-identical (same md5, 431 bytes) whether the state was produced by the window (OVER ()) or the plain aggregation representation.d.variant_discr.binandd.dynamic_structure.binmatch too.
Why: the on-disk format is the same for both representations by design. IAggregateFunction.h requires "The serialization format must be same for all variants of the same aggregate function", and CrossTabPhiSquaredWindowData::serialize explicitly keeps the same count/count_a/count_b/count_ab layout as CrossTabAggregateData. The write path (SerializationAggregateFunction::serializeBinaryBulk) serializes through the type-resolved (canonical) function, and deserialize re-validates invariants. A Dynamic slot on disk therefore only ever holds canonical count-map bytes, so a read after upgrade resolves the name to the canonical type and reads matching bytes.
So no read-side compatibility or rejection path is needed and no SettingsChangesHistory change applies. The name-keyed read paths cited (dynamicElement, getDynamicSubcolumnData) are safe because the bytes they resolve are already canonical. Test 04498 already covers dynamicElement and -Merge on window states for all four functions plus the nested Array case.
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 43/43 (100.00%) · Uncovered code |
CI finish ledger — 6419d2bCI is complete on this head. No failed tests on the current head; all aggregators green (Finish Workflow, Mergeable Check pass). Only the private
The AST fuzzer (targeted) that originally found the CrossTab Variant crash passes on this head. The bot's backward-compatibility concern (persisted Session id: cron:our-pr-ci-monitor:20260703-000000 |
Related: #104183
Related: #104277
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fixed a server crash when the aggregate functions
cramersV,cramersVBiasCorrected,contingencyortheilsUstates from a window context (OVER ()) and from a plain aggregation are combined through chained set operations (EXCEPT,INTERSECT) or nestedUNION ALLand then read, for example via the-Mergecombinator.Description
These four aggregate functions have two internal state representations,
AggregationandWindow, that are not encoded in the type name. When aVariantcollapses two same-namedAggregateFunction(f, ...)types that differ only in state representation into a single slot, the source column must be converted to the destination slot's representation before being stored, otherwise a later read interprets the bytes with the wrong layout and crashes.PR #104277 fixed this for the scalar
column -> Variantcast (createColumnToVariantWrapper). TheVariant -> Variantcast (createVariantToVariantWrapper) had the same gap: it mapped variant slots purely by name and copied their subcolumns verbatim. Chained set operations (EXCEPT/INTERSECT) and nestedUNION ALLbuild exactly thisVariant -> Variantcast, so a subcolumn carrying one representation ended up under a slot expecting the other, and a subsequent finalize / serialize /-Mergeread the bytes with the wrong layout and aborted the server.This PR converts the subcolumn to the destination representation in
createVariantToVariantWrapperwhen an old and a new slot match by name but are not equal (equals()is false), reusing the existing AggregateFunction representation-converting cast, instead of copying it verbatim. A regression test covering all four functions viaEXCEPT,INTERSECTand nestedUNION ALLis added.How it was found: AST fuzzer, surfaced as a
Stress test (arm_debug)server crash on an unrelated PR (#107899, unrelated carrier).2844-3b6eStress test (arm_debug)SIGSEGVinDB::CrossTabCountsState::merge(src/AggregateFunctions/CrossTab.h:64) reached viaAggregateFunctionMerge::addBatchSinglePlace.Triggering query (from the crash log):
Version info
26.7.1.477(included in26.7and later)