Repository navigation
TCP converters 4a/6: write combinators for the converter layer - #671
alex-clickhouse wants to merge 10 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Variant grouping can falsely reject repeated composite values after its placement cache reaches capacity.
1 open finding
What changed in this PR
Adds internal write combinators and derivation infrastructure for the native TCP converter layer, preparing a later migration of TCP inserts.
Changes:
- Adds composite writers for arrays, maps, tuples, nullable, LowCardinality, Variant, and canonical codec-backed types.
- Adds codec storage-path detection and write derivation.
- Adds differential, concurrency, allocation, error, and wire-format tests.
| File | Description |
|---|---|
ClickHouse.Driver.Tcp.Tests/Types/Converters/ConverterDerivationTests.cs |
Updates composite refusal tests. |
ClickHouse.Driver.Tcp.Tests/Types/Converters/ConverterHarness.cs |
Propagates test column names. |
ClickHouse.Driver.Tcp.Tests/Types/Converters/VariantWriterTests.cs |
Tests Variant placement and allocation. |
ClickHouse.Driver.Tcp.Tests/Types/Converters/WriteCombinatorTests.cs |
Tests composite writer behavior. |
ClickHouse.Driver.Tcp.Tests/Types/Converters/WriteConverterRegistration.cs |
Registers differential writer coverage. |
ClickHouse.Driver.Tcp.Tests/Types/Converters/WriteDerivationTests.cs |
Tests write derivation and concurrency. |
ClickHouse.Driver.Tcp/Types/Codecs/ArrayColumnCodec.cs |
Identifies dense array storage. |
ClickHouse.Driver.Tcp/Types/Codecs/DynamicColumnCodec.cs |
Identifies dense Dynamic storage. |
ClickHouse.Driver.Tcp/Types/Codecs/FixedStringColumnCodec.cs |
Identifies compatible fixed storage. |
ClickHouse.Driver.Tcp/Types/Codecs/ILowCardinalityShape.cs |
Adds storage-write contract. |
ClickHouse.Driver.Tcp/Types/Codecs/JsonStringColumnCodec.cs |
Identifies raw JSON string storage. |
ClickHouse.Driver.Tcp/Types/Codecs/LowCardinalityColumnCodec.cs |
Delegates storage detection. |
ClickHouse.Driver.Tcp/Types/Codecs/LowCardinalityShape.cs |
Detects dense dictionaries. |
ClickHouse.Driver.Tcp/Types/Codecs/MapColumnCodec.cs |
Detects dense map storage. |
ClickHouse.Driver.Tcp/Types/Codecs/NestedColumnCodec.cs |
Marks writable Nested storage. |
ClickHouse.Driver.Tcp/Types/Codecs/NullableColumnCodec.cs |
Detects decoded nullable storage. |
ClickHouse.Driver.Tcp/Types/Codecs/NullableLowCardinalityShape.cs |
Detects nullable dictionaries. |
ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs |
Validates dense QBit storage. |
ClickHouse.Driver.Tcp/Types/Codecs/StringColumnCodec.cs |
Detects raw string storage. |
ClickHouse.Driver.Tcp/Types/Codecs/TupleColumnCodec.cs |
Detects dense tuple storage. |
ClickHouse.Driver.Tcp/Types/Codecs/VariantColumnCodec.cs |
Detects compatible Variant storage. |
ClickHouse.Driver.Tcp/Types/Converters/ArrayWriter.cs |
Writes array offsets and elements. |
ClickHouse.Driver.Tcp/Types/Converters/BytesLeafWriter.cs |
Marks byte leaves as flat. |
ClickHouse.Driver.Tcp/Types/Converters/CodecWriter.cs |
Adapts canonical values to codecs. |
ClickHouse.Driver.Tcp/Types/Converters/ColumnWriter.cs |
Adds flat-body metadata. |
ClickHouse.Driver.Tcp/Types/Converters/ConverterDerivation.Write.cs |
Derives composite writers. |
ClickHouse.Driver.Tcp/Types/Converters/ConverterDerivation.cs |
Enables composite write derivation. |
ClickHouse.Driver.Tcp/Types/Converters/DictionaryWriters.cs |
Writes LowCardinality dictionaries. |
ClickHouse.Driver.Tcp/Types/Converters/FixedLeafWriter.cs |
Marks fixed leaves as flat. |
ClickHouse.Driver.Tcp/Types/Converters/MapWriter.cs |
Writes map streams. |
ClickHouse.Driver.Tcp/Types/Converters/NullableWriters.cs |
Writes null maps and placeholders. |
ClickHouse.Driver.Tcp/Types/Converters/TupleWriters.cs |
Writes tuple field streams. |
ClickHouse.Driver.Tcp/Types/Converters/ValueSource.cs |
Carries column names into errors. |
ClickHouse.Driver.Tcp/Types/Converters/VariantWriter.cs |
Places and groups Variant values. |
ClickHouse.Driver.Tcp/Types/Converters/WriteBuffers.cs |
Centralizes pooled write buffers. |
ClickHouse.Driver.Tcp/Types/IColumnCodec.cs |
Adds storage-write detection contract. |
🧠 Review effort: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| int end = first + count; | ||
| for (int i = first + 1; i <= end; i++) | ||
| { | ||
| if (i < end && ReferenceEquals(groupedChildren[i], groupedChildren[start])) |
There was a problem hiding this comment.
Confirmed and fixed in 811dc4f. When the placement cache was full, each value of an uncached CLR type got a new child, and the run split compared the children by reference. The run split now also compares the CLR type of the children, after the reference check, so a write with cached placements has the same cost and no new allocation. A regression test fills the cache with 1,024 refused types, then writes two byte[][] values to Variant(Array(String), UInt64) and checks the bytes.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit f6ec2aa. Configure here.
|
Summary Final verdict✅ Approve Automated review by Polyglot AI (gpt-6-astra) of |
A composite writer names the column in a message about a row (a null Array or Map row), so the value source carries the column name and the composites give it to the sources of their children. A leaf writer is flat: two writes one after the other give the body of one write, which a Variant alternative that takes values of two CLR types needs. Part of ClickHouse/integrations#801.
…ap and Tuple Each combinator writes its streams across all the values, from a value source of one span or of segments: - Nullable: the null map, then the child with each NULL marked, so the child writes its canonical placeholder there. A value type is copied out of T? into a pooled buffer. - Array: the offsets, then the child over the rows' arrays as segments. - LowCardinality: a dictionary of the canonical values of the leaf (FixedInterner, or ClrKeyedByteInterner made only for a body with values) and the keys. A refused value is named by the dictionary slot that it would take, as the current writer names it. - Map: the keys and the values of all the rows in pooled buffers. - Tuple: one writer for each field, arity 1 to 7, with the marks of the source in every field. No tier calls them yet. Part of ClickHouse/integrations#801.
… Dynamic through its codec VariantWriter places a value in the alternative whose canonical CLR type is the value's type, as the Variant codec does, with ClaimsValue for a tie between alternatives that share it. A value whose type is the canonical type of no alternative goes to the alternative whose derivation writes from its type, with the same rule for a tie. A leaf alternative writes values of two CLR types in row order; a composite alternative takes one CLR type in one write. CodecWriter writes Dynamic, QBit, the geo types and Tuple() from their canonical type through their codec: Dynamic keeps DynamicTypeInference and the state that its codec shares between the prefix and the body. Part of ClickHouse/integrations#801.
The derivation builds a write tree for Nullable (T? and a reference type), Array, LowCardinality (over a fixed-width or a byte-run leaf; the dictionary of LowCardinality(Nullable(X)) holds the bare X), Map, Tuple, and Variant (from object). Nested is written only from its own dense column, so its derivation refuses. Dynamic, QBit, the geo types and Tuple() are written from their canonical type through their codec. LowCardinality(JSON) is refused, as the codec refuses it. The derivation adds two writes that the codecs refuse: LowCardinality(String) from byte[] (ClickHouse/integrations#792), and FixedString from string under every composite. No tier calls it yet. ConverterDerivationTests.Derive_CompositeWrite_RefusesWithNoConverter becomes a test of the composite refusal texts, because composite writes exist now. Part of ClickHouse/integrations#801.
IColumnCodec.WritesFromStorage(column) is the condition of each codec's dense write: a decoded String or FixedString column (its raw bytes), a decoded Nullable column, a dense Array, LowCardinality, Map, Tuple, Variant (same alternatives), Dynamic, Nested or QBit column. The insert tier will route such a column to the codec and every other column to the converters. No behavior change. Part of ClickHouse/integrations#801.
WriteConverterRegistration registers two arms for the cases whose column type is not a leaf: 'Composite write converters: Write' (349 facets: the built and read-back inputs, all rows and a tail that starts above row 0; a column that the codec writes from its own storage goes to the codec) and 'Composite write converters: CanWrite' (573 facets). It declares the three writes that the old path refuses and the derivation accepts: LowCardinality(String) and LowCardinality(Nullable(String)) from byte[] (ClickHouse/integrations#792), and LowCardinality(FixedString(4)) from string. Each gives the bytes of the case's source column, and CanWrite says true. Part of ClickHouse/integrations#801.
WriteCombinatorTests: 56 columns of every combinator, over a span, over segments and under the marks of Nullable(Tuple(...)), whole and from row 2, against the current codec; zero values; 28 refused values (type, message, parameter name), the LowCardinality ones named by their dictionary slot; the added writes (bytes that UTF-8 cannot spell, padded FixedString text, two lone surrogates in one entry); key widths of 2 and 4 bytes. WriteDerivationTests: the derivation against CanWriteElementType for 51 composite types and 63 CLR types, with the listed additions; allocation bounds for a very wide FixedString under each composite; one tree on eight threads. VariantWriterTests: placement by canonical type first, by derivation next, the ties, two CLR types in a leaf alternative, the prefix of an alternative that no value selects. Part of ClickHouse/integrations#801.
The Variant writer rented a value buffer and a writer buffer of the row count for each alternative, so a Variant of 255 alternatives and a block of 50,000 rows reserved about 25 million references for each write. It counts the values of each alternative first, then puts the values together in one array, grouped by alternative and in row order inside each alternative, with one array for their writers. The scratch is three buffers of the row count, whatever the number of alternatives. Tests: the bytes of a Variant of 64 alternatives against the current codec, for all rows and from row 3; the allocation of one write of 4,000 values with the shared pool drained (4.3 MB with the old layout, under 512 KB with the new one). Review round 1, F1. Part of ClickHouse/integrations#801.
When the placement cache of a Variant writer holds 1,024 CLR types, a placement of a type that the cache does not keep makes a new writer of the alternative for each value. The runs of an alternative were split by writer identity, so two values of one composite CLR type made two runs, and the write refused them as values of two CLR types. In one alternative, the CLR type of a value selects its writer, so the runs are now split by CLR type. Children that are the same object still compare first, so the cost of a write with cached placements stays the same. Test: 1,024 refused values of distinct ValueTuple types fill the cache of a Variant(Array(String), UInt64) writer, then values of byte[][] give the bytes of one Array(String) run. Bot review of 2026-10-11, F9. Part of ClickHouse/integrations#801. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GitHub review of the converter core (ClickHouse/integrations#801) asked about the limit of the derivation cache, whose count check does not lock. The cache of the Variant writer's placements has the same check, and its comment did not say that it can pass the limit. It now gives the bound: each call adds one entry at most after its check, so the cache passes the limit by fewer entries than the number of calls that run at the same time. The code does not change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f6ec2aa to
76b7254
Compare


This is part 4a of the stack of pull requests for ClickHouse/integrations#801, a converter layer for the native TCP
client. It is stacked on #670 (part 3, POCO reads on the converter layer), which is on #668, #667, #666 and #665. It adds
internal code only: no tier of the client calls the new writers yet, so the client behaves as before. Part 4b moves
InsertAsyncwith the caller's columns andClickHouseTcpTypes.CanWriteonto them. There is no public API change, nochangelog fragment and no docs change in this part.
What
ClickHouse.Driver.Tcp/Types/Converters/. Each writes its streams across all the valuesof a write, in the three phases of a column write (begin, state prefix, body), from a value source of one span or of
segments (the arrays of a jagged column):
Nullable: the null map, then the child with each NULL marked, so the child writes its canonical placeholder there.Array: the offsets, then the child over the arrays of the rows as segments, with no copy of the elements.LowCardinality: a dictionary of the distinct canonical values of the leaf, and one key for each value. Thedictionary of a fixed-width leaf interns the canonical value (
FixedInterner); the dictionary ofStringandFixedStringinterns the canonical bytes (ClrKeyedByteInterner), which looks astringup by its CLR value firstand stops that lookup after 1,024 lookups when more than half of them failed. Two values share an entry exactly when
they encode to the same bytes. An interner is made only for a body that has values.
Map(KeyValuePair<TKey, TValue>[]): the keys and the values of all the rows, then the offsets, the keys and thevalues.
Tuple(ValueTupleof 1 to 7 elements): one writer for each field.Variant(fromobject) andDynamic,QBit, the geo types andTuple(), which are written from their canonicaltype through their codec.
DynamickeepsDynamicTypeInferenceand the state that its codec shares between theprefix and the body.
ConverterDerivation.Derive(type, context, T, Write)builds these trees, or refuses with areason. It writes what the codecs write today, and two writes that they refuse:
LowCardinality(String)frombyte[](ClickHouse/integrations#792) and
FixedString(N)fromstringunder every composite.Nestedis written only fromits own dense column, so its derivation refuses.
IColumnCodec.WritesFromStorage(column): the condition of each codec's existing dense write (a decodedStringcolumn with its raw bytes, a dense LowCardinality, Map, Tuple, Variant, Dynamic, Nested or QBit column). Part 4b routes
such a column to the codec and every other column to the converters.
How
rule of the Variant codec, and
ClaimsValuebreaks a tie between alternatives that share the canonical type. Only avalue whose type is the canonical type of no alternative is placed by the derivation of each alternative: an
alternative that is written from the value's type takes it, with the same tie rule. So every write that works today
goes where it goes today, and
Variant(String, UInt64)also takes abyte[]value as a String. A leaf alternativewrites the values of two CLR types (
stringandbyte[]) in row order; a composite alternative takes one CLR type inone write. The scratch of a Variant write holds one entry for each value, whatever the number of alternatives: the
writer counts the values of each alternative, then puts them in one array, grouped by alternative and in row order
inside each alternative. The Variant codec's own scattered write rents one buffer of the row count for each
alternative.
ArrayorMaprow names the column and therow, and a value that a leaf refuses names its row. Under LowCardinality, a refused value is named by the dictionary slot
that it would take, as the current writer names it (a decimal past its precision, a
FixedStringvalue of the wrongwidth). One text differs: a null
stringinLowCardinality(String)throwsArgumentNullExceptionwith theparameter name
value, as theStringwrite of a null does; the current LowCardinality writer giveskey, theparameter name of its dictionary. No existing test pins either text.
Why
Today each composite codec has its own family of write shapes for each CLR type, and a LowCardinality dictionary needs
a key strategy for each pair of write type and inner type, so a missing pair is a refusal
(ClickHouse/integrations#792). With the converter layer, the leaf table converts each value to its canonical value, and
the combinators write any CLR type that converts to the leaf.
Tests
leaf (349 facets: the built and read-back columns, for all rows and for a tail that starts above row 0; a column that
the codec writes from its storage goes to the codec), and the derivation's
CanWriteanswer (573 facets), against theold path. Three writes are declared changes:
LowCardinality(String)andLowCardinality(Nullable(String))frombyte[], andLowCardinality(FixedString(4))fromstring. Each writes the bytes of the case's source column, and theanswer is true.
WriteCombinatorTestscompares 56 columns of every combinator with the current codec, over a span, over segments andunder the marks of
Nullable(Tuple(...)), for all rows and from row 2; zero values; 28 refused values by exceptiontype, message and parameter name; bytes that UTF-8 cannot spell in
LowCardinality(String); paddedFixedStringtext;two lone surrogates in one dictionary entry; keys of 2 and 4 bytes.
WriteDerivationTestscompares the derivation with the codecs' accepted CLR types for 51 composite types and 63 CLRtypes, with the listed additions; it bounds the allocation of deriving a very wide
FixedStringunder each composite,and runs one tree on eight threads.
VariantWriterTestscovers the placement by canonical type and by derivation, the ties, two CLR types in a leafalternative, the prefix of an alternative that no value selects, a Variant of 64 alternatives against the codec from
row 3, and the allocation of one write of 4,000 values with the shared pool drained.
texts, because composite writes exist.
net10.0, the offline suite (no server): 3718 passed each. The public API does not change.
Review: two rounds; fixed and pinned by tests: the Variant write scratch scaled with alternatives times rows.
Changes after review
Variantalternative groups its values by CLR type, so a full placement cache does not split one CLR type into two runs (Copilot, Cursor, Polyglot).🤖 Generated with Claude Code