Fix QBit column structureEquals (logical error in writeSlice)#103084
Conversation
ColumnQBit::structureEquals previously delegated the comparison to the
inner tuple by calling tuple->structureEquals(rhs). But the rhs is a
ColumnQBit, not a ColumnTuple, so ColumnTuple::structureEquals failed
the typeid_cast up-front and returned false for any pair of ColumnQBit
columns.
This broke any operation using structureEquals to validate two
structurally equivalent QBit columns. The most visible case was
writeSlice(GenericArraySlice, GenericArraySink) (src/Functions/
GatherUtils/Algorithms.h:75-84) throwing a logical error:
Function writeSlice expects same column types for GenericArraySlice
and GenericArraySink.
A fuzzed workload that crashed on master via BuzzHouse (STID 2670-0e56,
experimental serverfuzz amd_msan, 2026-04-17) distills to:
CREATE TABLE t (c0 Tuple(Map(String, Nullable(QBit(Float64, 4)))))
ENGINE = Memory;
INSERT INTO t SELECT NULL;
The INSERT goes through ConvertingTransform -> ifNull -> if ->
executeTuple -> executeMap -> executeGenericArray -> writeSlice. The
executeGenericArray path builds a GenericArraySink from the declared
result type and a GenericArraySource from the argument column; both
carry structurally identical ColumnNullable(ColumnQBit) elements, but
the broken structureEquals made writeSlice reject them.
Fix ColumnQBit::structureEquals to follow the standard wrapper-column
pattern (cf. ColumnSparse, ColumnReplicated, ColumnLowCardinality,
ColumnNullable): typeid_cast the rhs to ColumnQBit first, then delegate
the inner comparison to the unwrapped tuples on both sides. Also
require dimension to match, because two QBit columns with different
dimensions that pad to the same FixedString size (e.g. dimension 1 and
8 both pad to 1 byte) would otherwise be reported as structurally
equal.
CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=4c74bee46e8b3d257df5320ac90f735593ef61bc&name_0=MasterCI&name_1=BuzzHouse%20%28experimental%2C%20serverfuzz%2C%20amd_msan%29
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
cc @rienath @Avogar @alexey-milovidov — could you review this? Fixes a logical error in |
| @@ -0,0 +1,40 @@ | |||
| -- Tags: no-fasttest | |||
| -- Tag `no-fasttest`: `QBit` depends on non-fasttest libraries. | |||
There was a problem hiding this comment.
What's the library QBit depends on that is not available in fasttest?
|
Workflow [PR], commit [7bbb4a8] Summary: ❌
AI ReviewSummaryThis PR fixes ClickHouse Rules
Final VerdictStatus: ✅ Approve |
…e tests Feedback from @rienath on PR ClickHouse#103084: - Remove the 'no-fasttest' tag; QBit has no fasttest-excluded dependencies (sibling tests 03363-03374 do not use the tag either). - Collapse the long description into a three-line regression-test blurb. - Drop the inline comments before the BuzzHouse-shape and ifNull queries. - Add negative tests that CAST between QBit columns with non-matching dimensions. Dimensions 1 and 8 are the interesting boundary because both pad to a single byte inside the tuple's FixedString storage, so the dimension field is what structureEquals relies on to tell them apart. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
@rienath thanks for the review, pushed
Session: cron:clickhouse-ci-task-worker:20260420-121500 |
| -- Negative cases: QBits with non-matching dimensions are different types and must | ||
| -- not be conflated. Dimensions 1 and 8 are particularly interesting because both | ||
| -- pad to a single byte in the underlying `FixedString` storage, so the dimension | ||
| -- field is what `structureEquals` relies on to tell them apart. | ||
| SELECT CAST(CAST([1.0] AS QBit(Float64, 1)) AS QBit(Float64, 8)); -- { serverError TYPE_MISMATCH } | ||
| SELECT CAST(CAST([1., 2., 3., 4., 5., 6., 7., 8.] AS QBit(Float64, 8)) AS QBit(Float64, 1)); -- { serverError TYPE_MISMATCH } | ||
| SELECT CAST(CAST([1., 2., 3., 4.] AS QBit(Float64, 4)) AS QBit(Float64, 8)); -- { serverError TYPE_MISMATCH } |
There was a problem hiding this comment.
This doesn't test structureEquals path, does it? @groeneai
@rienath pointed out that the previous CAST TYPE_MISMATCH cases did not exercise ColumnQBit::structureEquals — they are rejected at FunctionCast type resolution (DataTypeQBit::equals) before any column-level structureEquals call. The positive regression cases in the SQL test already walk through structureEquals on same- dimension QBits (via writeSlice in the ifNull/INSERT paths), so the negative dimension-mismatch case needs a direct unit test rather than a SQL test. New gtest_column_qbit.cpp covers: - StructureEqualsSameDimensionIsTrue: fails without the typeid_cast<ColumnQBit> rewrite (the original bug). - StructureEqualsDifferentDimensionIsFalse: exercises the `dimension ==` guard with the 1-vs-8 pair where both padded FixedStrings are 1 byte wide, so the inner ColumnTuple would otherwise claim the columns match. Also covers 4-vs-8 as a routine mismatch. - StructureEqualsDifferentElementTypeIsFalse: defensive coverage — different element types give different tuple widths. - StructureEqualsNonQBitRhsIsFalse: typeid_cast guard for any non-QBit rhs. Verified both directions locally: - Without the PR fix (return tuple->structureEquals(rhs)): the SameDimension test fails. - With the PR fix but the `dimension ==` guard removed: DifferentDimension fails with d1->structureEquals(d8) == true and d4->structureEquals(d8) == true. - With the full fix: all four pass. The SQL regression test drops the CAST TYPE_MISMATCH block and points at the gtest instead; the positive ifNull/INSERT scenarios that exercised the original writeSlice crash are kept unchanged. 50x runs of tests/queries/0_stateless/04104_qbit_writeslice_structure_equals and all 16 tests/queries/0_stateless/*qbit* still pass.
|
@rienath you're right — the New
I verified both directions locally with
Session: |
|
@groeneai delete the gtest, it is artificial. It is not clear how to test for size mismatch through SQL. Let's leave the dimension check as is for safety. Do not modify other files. Just delete the gtest |
Per @rienath's request on PR ClickHouse#103084: the gtest is artificial and it is not clear how to test for dimension mismatch through SQL, so drop the unit test. The dimension check in `ColumnQBit::structureEquals` is left in place for safety, and the positive SQL regression test in `tests/queries/0_stateless/04104_qbit_writeslice_structure_equals.sql` remains as coverage for the original `writeSlice` logical error.
|
@rienath done in 7bbb4a8 — Verified locally (Clang 21 debug, Linux):
Ready for re-review. |
LLVM Coverage Report
Changed lines: 60.00% (3/5) · Uncovered code |
6bfa37d

Motivation
Master
BuzzHouse (experimental, serverfuzz, amd_msan)hit a logical error inwriteSliceon 2026-04-17 (STID2670-0e56, commit4c74bee46e8b):CI report: https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=4c74bee46e8b3d257df5320ac90f735593ef61bc&name_0=MasterCI&name_1=BuzzHouse%20%28experimental%2C%20serverfuzz%2C%20amd_msan%29
Root cause
ColumnQBit::structureEqualsignored the right-hand sideColumnQBitwrapper:this->tupleis aColumnTuple;rhsis anotherColumnQBit.ColumnTuple::structureEqualsstarts withtypeid_cast<const ColumnTuple *>(&rhs)and returnsfalseimmediately when the argument is any other column type. As a result,QBitcolumns compared against themselves always reported "different structure", breaking every consumer that usesstructureEqualsto validate column compatibility.The visible symptom on master was a fuzzed INSERT pattern:
ConvertingTransformrewrites this asifNull(NULL::Nullable(Nothing), default_tuple). The dispatch descends throughFunctionIfNull::executeImpl->if->FunctionIf::executeTuple->executeImpl->FunctionIf::executeMap->executeImpl->FunctionIf::executeGenericArray->GatherUtils::conditional->writeSlice.writeSlice(GenericArraySlice, GenericArraySink)atsrc/Functions/GatherUtils/Algorithms.h:75-84checksslice.elements->structureEquals(sink.elements)and throws onfalse. The slice elements and sink elements are bothColumnNullable(ColumnQBit), structurally identical, but the brokenColumnQBit::structureEqualsmade the check fail.Fix
Follow the standard wrapper-column pattern used by
ColumnSparse,ColumnReplicated,ColumnLowCardinality, andColumnNullable:typeid_casttherhstoColumnQBitfirst, then delegate the inner comparison to the unwrappedtupleon both sides.Also require
dimensionto match: twoQBitcolumns with different dimensions can pad to the sameFixedStringbyte length (e.g. dimension1and dimension8both pad to 1 byte), so the inner tuple structure alone does not distinguish them.Verification
Before fix — server crashes on the minimal reproducer:
After fix — same queries succeed and the server stays alive:
Existing QBit stateless tests (
03363...03379) all pass (9/9).Regression test added:
tests/queries/0_stateless/04104_qbit_writeslice_structure_equals.{sql,reference}.Scope
The change is localized to
ColumnQBit::structureEquals. No behavior change for existingQBitworkloads that already worked; only operations that previously failed withstructureEquals-based validation will now proceed correctly.Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fix a logical error
Function writeSlice expects same column types for GenericArraySlice and GenericArraySinkraised when evaluatingif/ifNullover tuples, maps, or arrays containing aQBitelement.ColumnQBit::structureEqualsincorrectly compared the inner tuple of oneQBitcolumn against the outer wrapper of the other, so structurally identicalQBitcolumns were reported as different.Documentation entry for user-facing changes
Version info
26.5.1.8