Fix data race on DataTypeAggregateFunction version during Native serialization - #110997
Fix data race on DataTypeAggregateFunction version during Native serialization#110997groeneai wants to merge 9 commits into
Conversation
…alization A single DataTypeAggregateFunction is shared across query result blocks (one instance in the table's column description, aliased by shallow ColumnWithTypeAndName copies). NativeWriter/NativeReader call setVersionToAggregateFunctions, which reached the leaf and wrote its `mutable version` field in place. Two concurrent Native serializations of the same aggregate-function-typed column then read and wrote that field without synchronization, producing a ThreadSanitizer data race. Instead of mutating the shared type object, replace the versioned leaf with a copy that carries the version via the constructor (the same way the binary encoding decode path already builds versioned types), and drop the in-place setVersion/updateVersionFromRevision mutators and the `mutable` qualifier so no future caller can reintroduce the race. This also fixes a latent issue where the first serializer's revision-derived version stuck on the shared object and could be reused by a later client with a different revision. callOnNestedSimpleTypes now propagates the transformed type back through its DataTypePtr& argument, which is required for the replacement to take effect and matches its replacement-capable signature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Internal second-model review (click to expand)An independent second model reviewed this diff before submission. Findings and dispositions: ❌ Blockers: none
Session id: cron:clickhouse-author-slot-1:20260719-133600 |
Pre-PR validation gate (click to expand)
Session id: cron:clickhouse-author-slot-1:20260719-133600 |
|
cc @Avogar @KochetovNicolai — could you review this? It fixes an arm_tsan data race (STID 3977-4818) where |
|
Workflow [PR], commit [bf456fb] Summary: ✅
AI ReviewSummaryThis PR removes the shared Findings❌ Blockers
Final Verdict
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 193/195 (98.97%) · Uncovered code |
setVersionToAggregateFunctions ran callOnNestedSimpleTypes on every column type during Native serialization. callOnNestedSimpleTypes rebuilds wrapper types (Array/Tuple/Map/Nullable) via make_shared, which drops custom type names. For a type with no aggregate function this rewrote e.g. the geometry type Point (a named Tuple(Float64, Float64)) to a plain Tuple, breaking GeoJSON output detection (getName() based) with BAD_ARGUMENTS "requires exactly one geometry-typed column ... but found none", and losing the Nested custom name on ATTACH. Skip the whole traversal when the type has no aggregate function, leaving it (and its custom name) untouched. The versioning path for aggregate functions is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Fixed a Fast test regression this PR introduced ( The Fix: skip the traversal entirely when the type has no aggregate function ( Verified over the client->server Native path (build IDs |
callOnNestedSimpleTypes now propagates the rebuilt type tree only when the callback actually replaced a leaf. A wrapper whose only aggregate child is unversioned (e.g. Nested(s AggregateFunction(uniq, UInt64))) passes the hasAggregateFunctionType guard but replaces nothing, so rebuilding the outer Array/Tuple would drop the Nested custom name and rewrite the Native type name and ATTACH encoding from Nested(...) to plain Array(Tuple(...)). Keep the original object when no leaf changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI finish ledger — 2d1f27aEvery failure below has an owner. Both genuine failures are pre-existing trunk bugs (unrelated to this PR's DataTypeAggregateFunction version-race fix), each already tracked by an open fixing PR.
Neither trunk failure touches the DataTypeAggregateFunction / NativeWriter version path changed here. The prior Fast-test GeoJSON regression (PR-caused, head ea6b18a) was fixed on faff8c3/2d1f27a and does not recur on this head. Session id: cron:our-pr-ci-monitor:20260720-040000 |
Assigning a serialization version replaces the versioned leaf instead of mutating it, so the rebuilt type is what the caller ends up with. Rebuilding the wrappers through `transformTypesRecursively` recreates `Array`/`Tuple`/`Map` via `make_shared` and therefore dropped custom type names, which turned `Nested(x AggregateFunction(sumMap, ...))` into `Array(Tuple(...))` and `SimpleAggregateFunction(anyLast, AggregateFunction(sumMap, ...))` into a plain `AggregateFunction(...)`. Both are user-visible: the type is sent to the client over `Native`, and on `ATTACH` it becomes the column type in the table metadata. Losing the `SimpleAggregateFunction` name is worse than cosmetic - `AggregatingSortedAlgorithm` and `SummingSortedAlgorithm` recognise such a column by `dynamic_cast` on that very name object, so the column would silently start merging as a plain aggregate function state. `setVersionToAggregateFunctions` now walks the type itself, over the same wrappers `transformTypesRecursively` descended into, returning the original pointer when no leaf changes. `Nested` is rebuilt with its custom name kept in sync with the new element types, directly rather than through `createNested`: the latter derives the type from the printed name, and version 0 is deliberately not printed, so a name round trip would turn a leaf explicitly pinned to version 0 back into an unversioned one using the latest version. `DataTypeCustomNamePtr` becomes a `shared_ptr` so that a copy of a type can carry the very same custom name object, via the new `IDataType::cloneCustomization`. `callOnNestedSimpleTypes` had no other caller and is removed.
|
The same race just hit master again: https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=b59441bd06c2fcb6b103a30874528cc398afc723&name_0=MasterCI&name_1=Stress%20test%20%28azure%2C%20amd_tsan%29 (STID 3977-4818, third occurrence — PR 109496 on Jul 19, PR 110084 on Jul 25, master on Jul 26). I pushed 36fc4ff on top, because the previous revision would have regressed two column types. Since the leaf is replaced rather than mutated, the rebuilt tree is what the caller keeps, and
The second one is not just cosmetic: The walk is now done directly in One subtlety worth flagging for review: Verification: 8 gtests pass; new stateless test 04612 round-trips both types through The two red checks on the previous run are unrelated and pre-existing:
|
Two gaps in the replacement traversal, both found by round-tripping types through Native against a master build: - The custom name does not have to sit on the versioned leaf. In SimpleAggregateFunction(anyLast, Array(AggregateFunction(sumMap, ...))) it sits on the Array while the replaced leaf is one level below, so the Array/Tuple/Map branches rebuilt the wrapper without its customization and the type came back as a plain Array(AggregateFunction(...)) - the same degradation as for a leaf-hosted name, and it likewise stops AggregatingSortedAlgorithm and SummingSortedAlgorithm from recognising the column. - transformTypesRecursively descended into Nullable before anything else. A state cannot be directly inside Nullable, but a Tuple can, and Nullable(Tuple(AggregateFunction(sumMap, ...))) is reachable with enable_nullable_tuple_type. Without the branch the leaf stayed unversioned and the type came back as version 1 where master keeps it unversioned. Both types now round-trip through Native byte-identically to a master build, and each new gtest fails when its clause alone is reverted. 04612 additionally carries a non-empty state and asserts the decoded values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
You are right on both counts, and the On the I verified your commit rather than just reading it, and found two gaps. Pushed 3cd48c6 on top: 1. The custom name does not have to sit on the leaf. In 2. Verification: each affected shape now round-trips through One pre-existing bug I hit while testing this, which I deliberately did not fold in: a non-empty |
CI finish ledger - 3cd48c6Every failure below has an owner: a fixing PR (ours or external), or a full-effort fix task
I am keeping this PR in the CI cycle rather than advancing it: the Session id: cron:our-pr-ci-monitor:20260727-000000 |
…tefunction-version-race
… shared type The existing coverage is a gtest and a round-trip test whose observable output is byte-identical to master by design, so the per-arch Bugfix Validation jobs found nothing that reproduces on master HEAD and the new_tests_check post-hook rejected the PR. There is a deterministic, single-threaded consequence to assert instead. The type object lives in the table's column description and is shared, so forcing version 0 for one connection leaves it there: a `Native` response asked for at a revision below the one that introduced versioning pins version 0 on the column type of the table for the rest of the server's lifetime. With `sumMap` over `Decimal32` that changes the values, not only the printed version, because version 0 stores 4-byte values where version 1 stores 16-byte ones. A later plain `SELECT finalizeAggregation(s)` reads `([1,2],[10.5,20.25])` back as `([1],[10.5])`, and `system.columns` reports the column without its version. On master HEAD the test fails on both the type name and the values. The revision is pinned explicitly and the type name the response carries is part of the output, so the test cannot pass without that path having run: `Native` writes the name uncompressed after the column name, and version 0 is the one version it does not print. Both ways of not running it were checked against the test - a request that fails, and a version assignment that does nothing - and each turns the count to 0 rather than passing silently.
|
Pushed The Because the version was written onto the type object shared by the whole table, one
Also corrected one sentence in the description that had gone stale against the code: it said Master is merged to pick up #111991, which owns the one unrelated red on the previous run |
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 192/195 (98.46%) · Uncovered code |
CI finish ledger - c032b6bNo failures to own on this head. All 174 check-runs are complete, The two The previous head's Session id: cron:our-pr-ci-monitor:20260727-073000 |
|
Re-checked the re-asserted AI Review blocker on head
DataTypePtr storage_type = DataTypeFactory::instance().get(argument_types[0]->getName()); // re-parsed copy
... std::make_unique<DataTypeCustomSimpleAggregateFunction>(function, argument_types, ...) // original vectorand Making the name follow the version would rewrite the
Resolving both threads on that basis. CI on this head is green across all 174 check-runs and the PR is mergeable; leaving the merge to a human. |
…tefunction-version-race
|
🕵 The only CI failure on head 5ce1c10 was @groeneai, investigate the failure: https://s3.amazonaws.com/clickhouse-test-reports/json.html?PR=110997&sha=5ce1c10802e941bcf7a07b908bc4dd4ef5f54f61&name_0=PR&name_1=Stress%20test%20%28amd_debug%29 and provide a fix in a separate PR (the analyzer-resolution and Master merged into the branch (bf456fb) to retrigger CI. |
|
I read
Plan-time analysis of
On #112203: same class, different site. That issue is per-value evaluation during constant folding; this is the per-child loop inside |
|
Fixing PR for the I had a fix for this on a local branch, but @alexey-milovidov opened #113415 with the identical |
CI finish ledger — bf456fbEvery failure below has an owner: a fixing PR (mine or external), or a full-effort fix task
Session id: cron:our-pr-ci-monitor:20260805-170000 |
Related: found by the
arm_tsanandazure, amd_tsanStress tests (STID 3977-4818, ThreadSanitizer data race). No existing issue.Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Fix wrong results reading
AggregateFunctionstates after one client requested them at an older protocol revision. The serialization version chosen for that one response was written onto the data type object shared by the whole table, so it stayed there: every later query read the states at that version, and forsumMapoverDecimal32that returns wrong values, while the column also lost the version insystem.columnsand on the wire. The same in-place write was a data race between concurrent queries serializing such a column in theNativeformat.Description
A single
DataTypeAggregateFunctioninstance is shared across query result blocks: it lives once in the table's column description and is aliased by shallow column copies.NativeWriter/NativeReadercalledsetVersionToAggregateFunctions, which walked to the leaf type and wrote itsmutable versionfield in place. Two concurrentNativeserializations of the same aggregate-function-typed column then raced on that field.Reports:
Both racing stacks are
setVersionToAggregateFunctions->DataTypeAggregateFunctionversion setter, viaNativeWriter::write->TCPHandler::processOrdinaryQuery/sendData.Fix: instead of mutating the shared type object, replace the versioned leaf with a copy that carries the version via the constructor (the same way the binary-encoding decode path builds versioned types). The in-place
setVersion/updateVersionFromRevisionmutators and themutablequalifier are removed so the object is immutable after construction.This also removes a latent issue that was worse than the race itself.
NativeWriterpassesif_empty = falsefor a client older thanDBMS_MIN_REVISION_WITH_AGGREGATE_FUNCTIONS_VERSIONING, which unconditionally forced version 0 onto the shared type. Every later query then kept that 0 (if_empty = truesees a version already set), so it advertised a type name without a version while serializing version-0 states, and the receiving client - deriving the version from the server revision - deserialized them as version 1.Preserving custom type names
Because the leaf is now replaced rather than mutated, the rebuilt tree is what the caller ends up with, so the rebuild must not lose anything. Rebuilding the wrappers through
transformTypesRecursivelyrecreatesArray/Tuple/Mapviamake_sharedand drops custom type names:Nested(x AggregateFunction(sumMap, ...))Array(Tuple(...))SimpleAggregateFunction(anyLast, AggregateFunction(sumMap, ...))AggregateFunction(...)Both are user-visible: the type is sent to the client over
Native, and onATTACHit becomes the column type in the table metadata. Losing theSimpleAggregateFunctionname is worse than cosmetic -AggregatingSortedAlgorithmandSummingSortedAlgorithmrecognise such a column bydynamic_caston that very name object, so the column would silently start merging as a plain aggregate function state.So
setVersionToAggregateFunctionswalks the type itself, over exactly the wrapperstransformTypesRecursivelydescended into, and returns the original pointer when no leaf changes.Nullableis among them: a state cannot be directly insideNullable, but aTuplecan, andNullable(Tuple(AggregateFunction(...)))is reachable withenable_nullable_tuple_type. A custom name can also sit on the wrapper rather than on the leaf, as inSimpleAggregateFunction(anyLast, Array(AggregateFunction(...))), so a rebuilt wrapper carries the customization of the original too.DataTypeCustomNamePtrbecomes ashared_ptrso a copy of a type can carry the very same custom name object, via the newIDataType::cloneCustomization.Nestedis rebuilt with its custom name kept in sync with the new element types, directly rather than throughcreateNested: the latter derives the type from the printed name, and version 0 is deliberately not printed, so a name round trip would turn a leaf explicitly pinned to version 0 back into an unversioned one using the latest version.callOnNestedSimpleTypeshad no other caller and is removed, sotransformTypesRecursively(shared with schema inference) is left untouched.Testing
gtest_aggregate_function_version_racecovers the shared-object mutation, nested types, both custom-name cases above, and stress-tests concurrent version assignment over one shared type object.04612_aggregate_function_version_custom_type_namesround-trips both types throughNative(which assigns the version in the writer and again in the reader) and throughDETACH/ATTACH.04613_aggregate_function_version_not_stickyis the one that fails onmasterHEAD. The two tests above assert output that is byte-identical tomasterby design, so neither can. It asks for aNativeresponse at a revision below the one that introduced versioning, then checks the column again: onmasterthe version 0 forced for that one response stays on the shared type, so a later plainSELECT finalizeAggregation(s)reads([1,2],[10.5,20.25])back as([1],[10.5])andsystem.columnsloses the version. The revision is pinned explicitly and the type name the response carries is asserted, so the test cannot pass without that path having run - checked against both ways of it not running, a request that fails and a version assignment that does nothing.Nativewire type names are byte-identical tomasterfor both types.simple_aggregate,nested,native,geo,point,polygon,aggregate_function) were run against this build and against amasterbuild on the same server config: the failure sets are identical, i.e. no test fails only with this change.