TCP TT: a test audit of the native client, and the defects it found - #609
Draft
alex-clickhouse wants to merge 41 commits into
Draft
TCP TT: a test audit of the native client, and the defects it found#609alex-clickhouse wants to merge 41 commits into
alex-clickhouse wants to merge 41 commits into
Conversation
alex-clickhouse
force-pushed
the
tcp/epic-tt-test-audit
branch
from
September 1, 2026 18:26
a1400b9 to
e9e7ab4
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
Audits the native TCP client with expanded real-server coverage and fixes defects uncovered across codecs, type parsing, parameters, transport, and diagnostics.
Changes:
- Fixes Variant, timezone, enum, alias, time, and parameter handling.
- Adds readonly-profile serialization controls and public API support.
- Expands integration coverage for TLS, interruptions, framing, state, and edge cases.
Reviewed changes
Copilot reviewed 64 out of 64 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
examples/Tcp/Types/Tcp_004_VariantDynamicJson.cs |
Documents serialization settings. |
examples/Tcp/Types/Tcp_002_DateTimeAndTimezones.cs |
Demonstrates TimeOnly support. |
ClickHouse.Driver.Tcp/Types/TypeTokenizer.cs |
Handles quoted spans and whitespace. |
ClickHouse.Driver.Tcp/Types/TypeAliases.cs |
Defines server type aliases. |
ClickHouse.Driver.Tcp/Types/QuotedText.cs |
Adds shared quoted-text decoding. |
ClickHouse.Driver.Tcp/Types/NamedElementParser.cs |
Parses quoted field names. |
ClickHouse.Driver.Tcp/Types/IDateTimeColumn.cs |
Documents unrepresentable timezones. |
ClickHouse.Driver.Tcp/Types/DateTimeColumn.cs |
Defers timezone failures. |
ClickHouse.Driver.Tcp/Types/DateTime64Column.cs |
Defers timezone failures. |
ClickHouse.Driver.Tcp/Types/ColumnValueProjections.cs |
Adds TimeOnly projections. |
ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs |
Resolves aliases and case variants. |
ClickHouse.Driver.Tcp/Types/Codecs/VariantColumnCodec.cs |
Corrects dense Variant writes. |
ClickHouse.Driver.Tcp/Types/Codecs/TimeColumnCodec.cs |
Adds TimeOnly support. |
ClickHouse.Driver.Tcp/Types/Codecs/Time64ColumnCodec.cs |
Adds scaled TimeOnly support. |
ClickHouse.Driver.Tcp/Types/Codecs/QBitColumnCodec.cs |
Clarifies client limitations. |
ClickHouse.Driver.Tcp/Types/Codecs/EnumColumnCodec.cs |
Fixes labels and bare Enum sizing. |
ClickHouse.Driver.Tcp/Types/Codecs/DynamicColumnCodec.cs |
Clarifies zero-row behavior. |
ClickHouse.Driver.Tcp/Types/Codecs/DateTimeZones.cs |
Represents unresolved timezones safely. |
ClickHouse.Driver.Tcp/Types/Codecs/DateTimeColumnCodec.cs |
Delays timezone use during reads/writes. |
ClickHouse.Driver.Tcp/Types/Codecs/DateTime64ColumnCodec.cs |
Adds range checks and delayed timezone use. |
ClickHouse.Driver.Tcp/Types/Codecs/AggregateFunctionColumnCodecs.cs |
Corrects aggregate-function diagnostics. |
ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt |
Tracks the new public option. |
ClickHouse.Driver.Tcp/Parameters/TcpParameterFormatter.cs |
Expands parameter formatting parity. |
ClickHouse.Driver.Tcp/Parameters/ParameterTypeInference.cs |
Adds time and map inference. |
ClickHouse.Driver.Tcp/Format/BlockReader.cs |
Clarifies per-block state handling. |
ClickHouse.Driver.Tcp/Compression/FramedPackets.cs |
Documents exception framing. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpConnectionStringBuilder.cs |
Exposes serialization configuration. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpClientOptions.cs |
Defines the new serialization option. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs |
Applies conditional setting injection. |
ClickHouse.Driver.Tcp.Tests/Utilities/TlsTerminatingProxy.cs |
Adds a real TLS test proxy. |
ClickHouse.Driver.Tcp.Tests/Utilities/NestedArrayShape.cs |
Extends nested-array depth coverage. |
ClickHouse.Driver.Tcp.Tests/Types/VariantColumnCodecTests.cs |
Tests mismatched dense Variants. |
ClickHouse.Driver.Tcp.Tests/Types/TypeParserTests.cs |
Tests quoting and whitespace parsing. |
ClickHouse.Driver.Tcp.Tests/Types/TupleColumnCodecTests.cs |
Tests quoted tuple fields. |
ClickHouse.Driver.Tcp.Tests/Types/NestedColumnCodecTests.cs |
Tests quoted nested fields. |
ClickHouse.Driver.Tcp.Tests/Types/EnumColumnCodecTests.cs |
Tests enum escape decoding. |
ClickHouse.Driver.Tcp.Tests/Types/DateTimeZonesTests.cs |
Tests deferred timezone failures. |
ClickHouse.Driver.Tcp.Tests/Types/DateTimeColumnCodecTests.cs |
Covers unrepresentable timezone behavior. |
ClickHouse.Driver.Tcp.Tests/Types/DateTime64ColumnCodecTests.cs |
Covers ranges and timezone behavior. |
ClickHouse.Driver.Tcp.Tests/Types/ColumnReadProjectionTests.cs |
Tests TimeOnly projections. |
ClickHouse.Driver.Tcp.Tests/Types/ColumnCodecRegistryTests.cs |
Tests aliases and diagnostics. |
ClickHouse.Driver.Tcp.Tests/Types/ClickHouseTcpTypesTests.cs |
Tests alias capability queries. |
ClickHouse.Driver.Tcp.Tests/Parameters/TcpParameterFormatterEdgeCaseTests.cs |
Covers new parameter edge cases. |
ClickHouse.Driver.Tcp.Tests/Integration/TlsTransportIntegrationTests.cs |
Exercises TLS end to end. |
ClickHouse.Driver.Tcp.Tests/Integration/TimezoneColumnIntegrationTests.cs |
Verifies timezone writes against the server. |
ClickHouse.Driver.Tcp.Tests/Integration/ReadonlyUserIntegrationTests.cs |
Verifies readonly-user operation. |
ClickHouse.Driver.Tcp.Tests/Integration/MultiBlockStateIntegrationTests.cs |
Tests state across multiple blocks. |
ClickHouse.Driver.Tcp.Tests/Integration/MidStreamFailureIntegrationTests.cs |
Tests errors after data blocks. |
ClickHouse.Driver.Tcp.Tests/Integration/InsertInterruptionIntegrationTests.cs |
Tests interrupted inserts and pool recovery. |
ClickHouse.Driver.Tcp.Tests/Integration/FloatSpecialValueIntegrationTests.cs |
Verifies signed-zero preservation. |
ClickHouse.Driver.Tcp.Tests/Integration/ComputedColumnInsertIntegrationTests.cs |
Covers computed-column insert schemas. |
ClickHouse.Driver.Tcp.Tests/Integration/ColumnReadProjectionIntegrationTests.cs |
Tests timezone projection failures. |
ClickHouse.Driver.Tcp.Tests/Integration/ColumnarReadSurfaceIntegrationTests.cs |
Covers Nullable(Nothing) alignment. |
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpTypesIntegrationTests.cs |
Compares aliases with the server. |
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpParameterIntegrationTests.cs |
Verifies parameter parity end to end. |
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpExceptionIntegrationTests.cs |
Expands protocol-failure coverage. |
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpConnectionMetadataIntegrationTests.cs |
Updates metadata coverage documentation. |
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpConnectionInsertIntegrationTests.cs |
Tests dense Variant reinsertion. |
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpCancellationIntegrationTests.cs |
Verifies read deadlines. |
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpConnectionStringBuilderTests.cs |
Tests serialization option parsing. |
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpClientSettingsTests.cs |
Tests conditional setting injection. |
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpClientOptionsTests.cs |
Tests option copying. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
alex-clickhouse
force-pushed
the
tcp/epic-tt-test-audit
branch
from
September 2, 2026 09:09
e46ee66 to
8892d44
Compare
alex-clickhouse
force-pushed
the
tcp/epic-tt-test-audit
branch
from
September 2, 2026 11:07
8892d44 to
0659ffc
Compare
alex-clickhouse
force-pushed
the
tcp/epic-tt-test-audit
branch
from
September 3, 2026 09:25
0659ffc to
bfa02f5
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
alex-clickhouse
force-pushed
the
tcp/epic-tt-test-audit
branch
2 times, most recently
from
September 3, 2026 09:46
7601597 to
1585ff3
Compare
alex-clickhouse
force-pushed
the
tcp/epic-tt-test-audit
branch
from
September 3, 2026 14:35
1585ff3 to
d264330
Compare
alex-clickhouse
force-pushed
the
tcp/epic-tt-test-audit
branch
from
September 4, 2026 09:02
d264330 to
05b03dc
Compare
The server quotes an identifier it cannot spell bare — a JSON typed path, a Tuple or Nested field name — and normalizes a double-quoted one into backticks, so a real header carries `a(b` or `a,b`. Breaking on the comma or the paren inside it splits the argument list, and the read fails on the header before a row decodes. QuotedText scans a quoted span once, for its boundaries and for its text, so the tokenizer and the two parsers agree on where a span ends. The escape set is the server's own, checked against 26.6: \a \b \e \f \n \r \t \v \0 and \xHH decode, a backslash before a quote, a backslash, a double quote or a slash yields that character, and an undefined escape keeps both characters. A doubled backtick is one backtick, which the server accepts on input although it prints the backslash form. NamedElementParser splits a quoted field name at its closing backtick and decodes it, so a caller reads the name the server holds while the type name keeps the wire spelling an insert header has to echo. A run of only whitespace yields no token, so Array( Array(Int32) ), the spaced empty tuple Tuple( ) and a trailing space parse like their compact spellings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A label arrives in the header with the server's escaping: a label holding a newline is spelled 'a\nb', and on 26.6 its stored bytes are 61 0A 62. Keeping the character after the backslash and dropping the backslash decodes that to "anb", so the label a caller reads is wrong and writing the real label throws NoSuchLabel. Only \\ and \' came out right. ParseMember scans the label through QuotedText, the same scanner the tokenizer uses, so a label and an identifier decode by one rule. That also accepts the doubled quote in 'it''s', which the server accepts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ClickHouse accepts fixed offsets TimeZoneInfo cannot hold, and 26.6 applies them: Fixed/UTC+19:00:00 and Fixed/UTC-18:00:00 are past its ±14 hours, and Fixed/UTC+05:30:15 is not a whole number of minutes. Resolving the zone while building the codec made each of those a dead read — BlockReader wraps the FormatException, so no column and no row arrived, including for a caller that only wanted the epoch counts the zone does not affect. ResolvedTimeZone carries either the zone or the reason there is none, and DateTimeZones.Resolve stops throwing. The counts read as they did; TimeZone, GetDateTimeOffset, ToDateTimeOffsets, a DateTime or DateTimeOffset projection and writing an Unspecified DateTime are the calendar uses, and each reports the zone with its offset named. Formatting a parameter into a declared timezone is a calendar use too, so that path keeps failing where it did. The three components of a synthetic name are summed as written rather than each restricted to 00-59, which is what the server does: it applies Fixed/UTC+05:00:60 as +05:01:00 and Fixed/UTC+05:70:00 as +06:10:00, keeping the name it was given. A name it produces and .NET can hold therefore resolves instead of falling through to a lookup that calls it unknown. An unknown zone id defers for the same reason: missing tzdata does not stop the counts arriving. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Some states carry a leading version in the type: 26.6 reports sumMapState(...) as AggregateFunction(1, sumMap, Array(UInt64), Array(UInt64)), and minMap, maxMap and sumMapFiltered([1, 2]) the same way. Taking the first argument as the function made the refusal say "the '1' aggregate function" and suggest 'SELECT 1Merge(column)', which is not a function. It now names the argument after the version, so the suggestion is sumMapMerge(column) — sumMapFilteredMerge([1, 2])(column) with parameters, both of which run on 26.6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Array(Boolean) reported "ClickHouse type 'Boolean' is not supported", because
resolution recurses and a child node knows nothing about where it sits. Boolean
is not a name the caller wrote — the server spells the type Bool — so the
message sent them looking for something that is not in their code. Resolve now
keeps the child's own refusal and adds the type it was given.
The message also drops "yet". Some of what lands there is not a type any
supported server has: Object('json') was removed from ClickHouse and MultiPoint
never existed on any version, so promising it later is wrong.
The exception type stays exactly NotSupportedException. A subclass carrying the
node would be tidier, but NUnit's Assert.Throws<T> matches the type exactly, so
every existing catch site would have had to change with it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The formatter's default arm answered "Cannot convert value of type 'System.String' (abc) to ClickHouse type VARCHAR" for two unrelated failures: a type name this client does not know, and a known type whose arm declined the value's shape. For the first, the value was never the problem, and the message sent a caller to look at it. An unknown name now says so and says how to find the right one, with the note that ClickHouse spells most type names case-sensitively. The registry answers whether a name is known, so the two stay in step as codecs are added. Three names the registry knows have no formatting arm although the server takes a text value for each (checked on 26.6): Dynamic, Geometry and SimpleAggregateFunction. They refuse with whose limit it is, rather than implying the type does not exist. AggregateFunction is the fourth, and the server rejects a parameter of that type too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
{p:VARCHAR}, {p:BIGINT}, {p:DEC(4,2)}, {p:Boolean} and {p:json} all threw
client-side, before the query was sent, on hints the shipped HTTP driver takes
and the server resolves. A caller moving a query from the HTTP client had to
respell their types to move it.
TypeAliases is system.data_type_families on 26.6: every row with a non-empty
alias_to, plus the families the server matches without regard to case. The
registry consults it when an exact lookup misses, and resolves under the
canonical name, so DEC(4, 2) reports itself as Decimal(4, 2). Child nodes come
back through the same lookup, which is what makes Array(Map(String, Tuple(Int32,
BIGINT))) resolve. The formatter maps the name before its dispatch, so its
"JSON" or "Json" arm collapses to one.
Case is per family, not global. The server marks 15 families case_insensitive =
1 and the rest 0, so datetime64(3) resolves while string, int64, array(uint8),
nullable(string), tuple(uint8) and geometry are unknown families to it —
matching every name loosely would accept what the server rejects. GEOMETRY is
the one alias it marks case-sensitive, and it has its own table.
A header never carries any of this: the server always reports the canonical
name, so the read path keeps its single exact lookup and the second one costs a
miss it was already taking.
The table is a copy of the server's, so an integration test compares the two in
both directions — an alias the server has and the table lacks, and a spelling
the table has that the server does not report. That is what caught the entries
this commit would otherwise have mistyped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A TimeOnly reached neither time arm of the parameter formatter: Time
called Convert.ToInt32 on it, which a TimeOnly does not support, and
Time64 matched only a TimeSpan. Type inference had no arm for it
either, so a parameter with no {name:Type} hint could not be sent.
Both arms now take it, and inference groups it with TimeSpan at
Time64(9), which holds every tick either type can carry. The shipped
HTTP driver accepts a TimeOnly for both types, so a query moving from
it keeps working.
The column layer and the Variant alternative match still take no
TimeOnly; both are tracked in the TCP TODO.
A bare Enum('A' = 1, 'B' = 2) is a type the server accepts and the
shipped HTTP driver resolves, so a query that moves to this client
stopped working on it. The client now picks the width the server
would: Enum8 while every ordinal is in the Int8 range, Enum16
otherwise. A header always names a width, so this only ever comes
from a caller's {p:Type} hint or a CanRead/CanWrite question.
The bare name also joins the parameter formatter's string-like arm
and the Variant alternative match, which the hint path needs.
One test now pins the nine server-legal forms this client refuses on
purpose, and that each refusal quotes what the caller wrote.
The alias table carried a copy of which families ClickHouse matches without regard to case, so that a spelling the server rejects was rejected here first. That copy is prejudice: the rule moves between versions and settings, and resolution serves reads, so a stale copy refuses a header the server legitimately sent. The table now holds aliases only, and the codec registry matches any case of a name it knows. A name with no codec is still refused, which is this client's own limit rather than the server's.
Every other integration error case fails at submit time, before any data block, and the mid-stream case was scripted bytes only. The Exception packet body is unframed while the data blocks around it are framed, so run it uncompressed, under LZ4 and under ZSTD. Asserts the rows arrive before the error, the raw code and the message text, that the connection is retired rather than pooled, and that its pool permit comes back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An insert writes every block before reading anything, so a cancel or a server rejection part-way through leaves blocks the socket already took. Two cases: cancelled from OnBlockWritten, which puts the cancel inside the data phase with one block out, and a constraint that rejects every block after the first. Both commit nothing and give the one pool slot back. The commit count is the assertion that matters: the cancellation exception surfaces either way, so it says nothing about whether rows landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An insert under input_format_defaults_for_omitted_fields = 1 makes the server send TableColumns before the schema block. The client decodes it only to stay aligned, so the packet had scripted-byte coverage alone. Verified on 26.6: one packet per insert, an empty external-table name and a description naming all five columns. Two inserts on one connection, then a read on it, so a decoder that under-reads mis-frames what follows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The insert schema block names the insertable columns only, so a caller who builds columns from the table definition supplies more than the target has. Three cases: an insert with no column list, a supplied MATERIALIZED column, and one named in the statement. The two mistakes fail on different sides. The client refuses the extra column by name, writes no row block, and keeps the connection; naming it in the statement is the server's refusal instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The one existing test proves the deadline does not fire. Making it fire needs real silence: the server sends a Progress packet every interactive_delay microseconds, and that counts as speaking, so SELECT sleep(3) finishes under a one-second deadline. Raising interactive_delay past the sleep buys the silence. Both cases are covered, so the surprising one carries its explanation. Asserts the exception type rather than elapsed time, and that the cancel attempt on the way out returns the pool slot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The socket probe in IsReusable had no test where a real server does the hanging up. idle_connection_timeout makes it, with the client's own timeouts at their defaults, so nothing but the probe keeps the next query alive. The null case is the control: the same wait without the setting keeps the connection.
The pool's retirement was covered only against a double whose "closed" is a flag the test sets. Churn forty connections under a one-tick lifetime, count the distinct client ports system.query_log recorded them arriving on, then read the server's own TCPConnection metric back down to the baseline.
The error-path tests run at the default MaxPoolSize of 20, where losing a permit per failure still leaves nineteen. Repeat each failure kind at a pool of one: a query the server refuses, an insert of a column the table has not, and an insert whose columns disagree on row count.
The default suite never ran a query, a block or an insert through an SslStream: the factory tests answer with a canned Hello, and the Cloud fixture is skipped unless a service is configured. TlsTerminatingProxy puts a loopback TLS endpoint in front of the suite's own server, so the client side is real and needs no server configuration.
The inner column has no rows while the block has, so the array writes and reads the prefix and the zero-row body path runs with that prefix already consumed. The reason given in the Dynamic codec for its zero-row return only held for a zero-row block; state what the wire actually carries.
Every timezone-carrying integration column was UTC, and the corpus
compares the read-back with a value the same conversion produced, so the
assertion holds even if the direction is inverted or this machine's tzdata
disagrees with the server's. Write an Unspecified wall clock into
DateTime('Europe/Amsterdam') and DateTime64(3, 'America/New_York'), then
take both renderings from the server. The ambiguous-hour case pins the
earlier-occurrence rule against the server's tzdata rather than a
hard-coded instant.
Added to the bare, Nullable and Array cases for Float32, Float64 and BFloat16, with a null beside a special in the Nullable ones so the placeholder a null row contributes cannot be confused with the NaN next to it. Signed zero gets its own integration test: the corpus comparison cannot see the sign, because (-0.0).Equals(0.0) is true, while the server's text form can.
Corpus values at 2100 and at the 2106-02-07 06:28:15 bound for DateTime, in the bare, Nullable, Array and DateTimeOffset forms, plus Int64.MaxValue and Int64.MinValue counts for DateTime64(9) and the latest instant a DateTimeOffset can name at that scale. A DateTimeOffset past that scale's range reported a bare OverflowException from the multiply. Range-check it and name the value and the column, as the DateTime path already does.
- UInt256/Int256 at 2^255 and at both ends, so the top limb and the sign bit are pinned rather than only the limb order. - Decimal at scale 0, at scale == precision, and a max-magnitude Decimal(76, 0). - A fully populated IPv6, plus the IPv4-mapped form an IPv4 address takes when written to an IPv6 column. - LowCardinality over UInt8, Int64 and UUID, whose dictionaries are one, eight and sixteen bytes wide against the four every earlier case used. - Array(Array(LowCardinality(String))) and its Nullable variant: a dictionary leaf under two levels of offsets. - Map with a LowCardinality key and with a Tuple key, where the key column's own state is written before the value's. - SimpleAggregateFunction(anyLast, JSON), the first with a prefix-carrying inner. - QBit(Float32, 768), an embedding width against the 17 the suite reached.
- SELECT NULL read from a server, with a column after it to prove the Nullable(Nothing) placeholder run is one byte per row. The layout was otherwise only ever read from bytes a test wrote itself. - Enum labels holding the grammar's own separators: a comma, an escaped quote and an equals sign. - The nested-array ladder to depth 7, so the block-splitting tests stack six flattening views. - A Tuple of eight read from a server: the client declines it with the column, the count and the limit, and the pool stays usable. That is the second type a real server produces that the client refuses, which the neighbouring comment claimed was only AggregateFunction.
The corpus note said no Nullable(Tuple(...)) could be written, since the null wrapper handed the outer column to the tuple's state-prefix phase. Both spellings now round-trip on 26.6 behind enable_nullable_tuple_type, so the cases replace the note. The only coverage before this called WriteFull directly, which skips that phase. Also say on FramedPackets that it governs block-bearing packets only, an Exception body being read straight from the raw stream.
ParameterTypeInference.Accepts had an arm for the instant types and none for the time ones, so a TimeSpan or TimeOnly matched a Time64 alternative and never a Time one. A Variant that matches nothing is refused whole rather than by the arm, so Variant(Time, String) took no time value at all. One arm by analogy with the DateTime one. The matcher takes the first accepting alternative, here and for the instant types alike, so a sub-second value lands rounded on a Time arm declared before a Time64 one. Pinned as it stands.
All three were refused client-side while the server takes a text value for each. SimpleAggregateFunction writes as its inner type, and the other two write the value's own text, leaving the server's parse to decide. A Geometry is ambiguous by construction, but a Ring and a LineString have the same text, so the client never has to choose. Also version-gate what the 25.8 run turned up: the Nullable(Tuple) and Geometry cases, the GEOMETRY alias row (25.8 points it at String, having no Geometry type), and the signed-zero test's table, since 25.8's part writer normalizes a negative zero away where a Memory table keeps it.
Date takes a DateOnly, so a caller reasonably expects Time to take a TimeOnly; it took int/long and TimeSpan only, so ClickHouseTcpColumn.Create over TimeOnly values had no codec. Added to CanWrite, the writable and readable lists, the null placeholder and both write paths, with corpus cases for the bare and Nullable forms of each type. Reading as a TimeOnly is a narrowing: a column value may be negative or past 24 hours, and such a row is refused rather than reduced modulo a day. The readable list mirrors the writable one, which an existing test requires.
Deferring the resolution was half a fix. ToUtc consults the zone for an
Unspecified DateTime alone, but both call sites read ResolvedTimeZone.Value
before it could look at Kind, so a column declaring an offset TimeZoneInfo
cannot hold still failed a write that does not use it: a Utc or Local
DateTime into DateTime('Fixed/UTC+19:00:00'), and a null row too, whose
placeholder DateTime.UnixEpoch is Utc. 26.6 accepts such a column, so the
whole column was unwritable for values that name their own instant.
ToUtc takes the resolved zone and asks for it after the Kind test. Both
codecs are covered, since only one of them holds the method.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The check ran on the TimeSpan, and the shift to 100 ns ticks truncates toward zero, so a negative count finer than a tick reached zero and passed: every raw count from -1 to -99 at scale 9, and -1 to -9 at scale 8, read back as midnight. That is the value the refusal exists to prevent, a negative duration presented as a time of day. Checked on the raw count against [0, 86400 * 10^scale), with the message naming the value in seconds so a sub-tick count is legible. The Time codec keeps its own check, which is exact: whole seconds scale up without loss. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two holes on the same seam, where a value is matched against a type before
it is formatted.
Accepts compared node.Name as written, so canonicalizing at the formatter's
dispatch did nothing for a Variant: {p:Variant(BIGINT, String)} accepted no
Int64 at all, and a Variant that matches nothing is refused whole. The
server resolves that declaration to Variant(Int64, String) and takes the
value. Canonicalized at the top of Accepts, which every recursion re-enters,
so an alias nested in Variant(Array(BIGINT), String) resolves too.
Infer had no arm for a pair sequence, the shape a Map column reads back as,
so it fell to the Array arm, whose element inference has no reading for a
KeyValuePair. A Dynamic parameter asks Infer what the value is, so a row
read from a Map column could not be sent back as one. The arm mirrors the
one Accepts already had, and an empty sequence takes the fallback an empty
dictionary takes.
Also corrects a claim in the neighbouring test. The case named "truncated to
the scale" used a value that rounding and truncation agree on, so it could
not tell them apart; the arm rounds. Replaced with midpoint cases, and the
end-of-day pin now says why it stands: 24:00:00 is what the shipped HTTP
driver sends for the same TimeOnly. That the two paths disagree is in the
TODO, not settled here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both tests passed with the code they cover reverted, which is the failure mode a test-heavy branch is most likely to have. The mismatched-Variant insert used Variant(Date, Int64) into Variant(Date32, Int64). The alternative keeps its index across those two types and Date and Date32 both surface as IColumn<DateOnly>, so the arity-only shortcut this replaced reached the same answer: reverting the gate left the test green. Now Variant(Int64, String) into Variant(Bool, Int64), where the Int64 moves from alternative 0 to alternative 1, and reverting the gate fails it with the InvalidCastException the fix was for. Both rows are Int64 so both fit the target, while the source still declares the String alternative that makes the lists differ. The readonly-profile test read one server-computed string: the server evaluated the two toString calls and the concat, so the result arrived through the String decoder alone and the test would pass with the DateTime and Array codecs broken. It now selects DateTime, Array, LowCardinality and Decimal columns as themselves and reads them through the typed accessors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The idle-disconnect test waited four seconds against a one-second server timeout and then took one reading. The server notices an idle connection on its own schedule and the framework suites share the server, so a late sweep reuses a live session and the marker reads 1 where 0 was expected: a red run that says nothing, and a longer sleep only lowers the odds. Polls to a bounded budget instead. Each gap is longer than the server timeout, so a poll that finds the connection alive resets the timer and still leaves the next gap long enough to expire it. The control has nothing to wait for and takes the first reading, after the same gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The examples are where a caller reads what this client accepts, and neither addition appeared in one. The Time example wrote a TimeSpan only, and its comment said Time and Time64 surface as TimeSpan, which is now half the story. It writes a TimeOnly as well and states the narrowing on the way back: a value outside a day is refused, not reduced modulo one. The Variant/Dynamic/JSON example sets SendJsonAndDynamicSerializationSettings explicitly, since that is the pair of settings it is about, with what clearing it costs and who has to clear it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The image grants the user it creates no access management, so CREATE USER and GRANT are refused, and ReadonlyUserIntegrationTests failed at OneTimeSetUp on every CI job. It passed everywhere it was developed: a server started by hand for local runs carries CLICKHOUSE_SKIP_USER_SETUP, which makes default a superuser, and no ClickHouse version reproduces the difference. Only the fixture's own Testcontainers path shows it, which is the path CI takes. CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT is what the image reads for this, and examples.yml already grants the same thing for the same reason. Verified on 25.8 by removing it again: the whole suite through Testcontainers is 4000 passed, and without it the three readonly tests fail with the CI message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e too
The write side deferred the lookup; the read side still took it while
building the projection, where the resolved zone became a baked constant. So
CanRead("DateTime('Fixed/UTC+19:00:00')", typeof(DateTimeOffset)) threw
instead of answering, and the message it threw says only a calendar value
needs the zone, which is exactly what CanRead does not ask for. The POCO
tier discovers a mapping through the same call, so a mapping that never
reads a calendar value could fail on a column it does not use that way.
The four calendar projections take the resolved zone and dereference it
inside, so the zone is asked for on the row. DateTimeToOffset already calls
TimeZoneInfo.ConvertTime per row, which a null check does not measurably
add to. The two column accessors that pass through here are calendar uses
and keep reporting as before.
Raised by Copilot on the pull request.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
alex-clickhouse
force-pushed
the
tcp/epic-tt-test-audit
branch
from
September 4, 2026 09:36
05b03dc to
2da91dd
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
A test audit of the native client, and the defects it found.
The suite up to this point grew alongside the epics: each epic proved its own feature. This PR
attacks it from the other side, asking of each area what a test would have to do to fail, and
writing that test. 57 backlog items, one commit each where the item needed one, plus six commits
closing a review pass over the result.
Roughly half the commits change client behavior. Those came first as a failing test against a
real server, so every one of them is a defect an existing test did not catch.
Correctness fixes
A dense
Variantcould be written as a differentVariantof the same arity.ClickHouse.Driver.Tcp/Types/Codecs/VariantColumnCodec.cs. The dense write gate compared thenumber of alternatives, then reused the source column's own discriminator bytes. Reading
Variant(Int64, String)and inserting it intoVariant(Bool, Int64)passedCanWrite, sentdiscriminators that named the wrong alternative, and then hit an
InvalidCastExceptioninside thebody, after the discriminators were already on the wire. The gate now compares the alternative names
in order; a column that does not match is scattered by each value's own type, so a read-back of one
Variantlands in the right alternative of another.A backticked identifier in a type string broke the header parse.
Types/QuotedText.cs(new),Types/TypeTokenizer.cs,Types/NamedElementParser.cs. The serverquotes an identifier it cannot spell bare — a JSON typed path, a
TupleorNestedfield name — soa real header carries
`a(b`or`a,b`. Breaking on the comma or the paren inside it splitthe argument list and the read failed on the header, before a row decoded.
QuotedTextscans aquoted span once, for its boundaries and its text, so the tokenizer and both parsers agree on where
a span ends.
Enum labels decoded wrong.
Types/Codecs/EnumColumnCodec.cs. A label holding a newline arrivesspelled
'a\nb'; dropping the backslash and keeping the next character decoded that toanb, sothe label a caller read was wrong and writing the real label threw. Only
\\and\'came outright. Labels now go through the same
QuotedTextscanner as identifiers.A timezone .NET cannot represent killed the whole column.
Types/Codecs/DateTimeZones.cs,DateTimeColumn.cs,DateTime64Column.cs. ClickHouse acceptsfixed offsets
TimeZoneInfocannot hold and 26.6 applies them:Fixed/UTC+19:00:00is past .NET's±14 hours and
Fixed/UTC+05:30:15is not a whole number of minutes. Resolving the zone whilebuilding the codec made each of those a dead read — no column and no row arrived, including for a
caller who only wanted the epoch counts, which the zone does not affect.
ResolvedTimeZonenowcarries either the zone or the reason there is none, and only the calendar uses (
TimeZone,GetDateTimeOffset, aDateTime/DateTimeOffsetprojection, writing anUnspecifiedDateTime)report it.
DateTime64reported a bareOverflowException.Types/Codecs/DateTime64ColumnCodec.cs. ADateTimeOffsetpast the scale's range fell out of the multiply with no context. Range-checkedbefore scaling, naming the value and the column, as the
DateTimepath already did.A
UtcorLocalDateTimecould not be written to a column whose zone .NET cannot represent.Same file plus
DateTimeColumnCodec.cs.ToUtcconsults the zone only forUnspecified, but bothcallers evaluated
ResolvedTimeZone.Valuebefore it could look atKind, so the check above did notfinish the job: writing a UTC instant to
DateTime('Fixed/UTC+19:00:00')still threw, and so did anull row, whose placeholder is
DateTime.UnixEpoch.ToUtcnow takes the resolved zone and asks forit after the
Kindtest.A negative
Time64finer than a tick read back as midnight.Types/ColumnValueProjections.cs.Time64ToTimeOnlyshifted the raw count to 100 ns ticks and thenchecked it was a time of day. The shift truncates toward zero, so every count from −1 to −99 at scale
9 reached zero and passed the check — a negative value presented as
00:00, which is the exactoutcome the refusal exists to prevent. Checked on the raw count against
[0, 86400 · 10^scale).Diagnostics
Array(Boolean)said "ClickHouse type 'Boolean' is not supported". Resolution recurses and achild node knows nothing about where it sits, so the message named a type the caller never wrote
(the server spells it
Bool).Types/ColumnCodecRegistry.csnow keeps the child's refusal andadds the type it was handed. Also drops "yet":
Object('json')was removed from ClickHouse andMultiPointnever existed, so promising them later is wrong.AggregateFunctionrefusal named the serialization version as the function. 26.6 reportssumMapState(...)asAggregateFunction(1, sumMap, ...), so the message said "the '1' aggregatefunction" and suggested
SELECT 1Merge(column), which is not a function. NowsumMapMerge(column), andsumMapFilteredMerge([1, 2])(column)with parameters — both run.Parameters/TcpParameterFormatter.csanswered "Cannot convert value of type 'System.String' (abc) to ClickHouse type VARCHAR" both for
a type name this client does not know and for a known type whose arm declined the value. For the
first the value was never the problem. Split, with the registry answering whether a name is known
so the two stay in step as codecs are added.
QBitelement switch and theVariantrefusal of a
Dynamicalternative. Neither is a server rule. Wording only.HTTP-client parity
A query written against the shipped HTTP driver should move to this client without respelling its
types. Five things stopped that:
{p:VARCHAR},{p:BIGINT},{p:DEC(4,2)},{p:Boolean},{p:json}allthrew client-side, before the query was sent.
Types/TypeAliases.cs(new) issystem.data_type_familieson 26.6: every row with a non-emptyalias_to. The registry consultsit when an exact lookup misses and resolves under the canonical name, so
DEC(4, 2)reportsitself as
Decimal(4, 2). Child nodes come back through the same lookup, which is what makesArray(Map(String, Tuple(Int32, BIGINT)))resolve. An integration test compares the table againstthe live server in both directions, which is what caught the entries this would otherwise have
mistyped.
copy of which families the server matches case-insensitively: that rule moves between versions,
and resolution serves reads, so a stale copy would refuse a header the server legitimately sent.
Variant. Picking the alternative is a separate match from formatting thevalue, and it read the name as the caller wrote it, so
{p:Variant(BIGINT, String)}accepted noInt64at all while the server resolves that declaration toVariant(Int64, String).ParameterTypeInference.Acceptsnow canonicalizes first, as the formatter's dispatch already did.Enum('A' = 1, 'B' = 2), which the server accepts and the HTTP driver resolves. Theclient now picks the width the server would:
Enum8while every ordinal fitsInt8,Enum16otherwise. A header always names a width, so this only comes from a caller's hint.
TimeOnlyas a parameter. It reached neither time arm:TimecalledConvert.ToInt32on it,which a
TimeOnlydoes not support, andTime64matchedTimeSpanonly.Dynamic,GeometryandSimpleAggregateFunctionparameters, all three refused client-sidewhile the server takes a text value for each (checked on 26.6).
SimpleAggregateFunctionwritesas its inner type; the other two write the value's own text and leave the parse to the server. For
Dynamicthe server's answer turns out to beStringwhatever the text is — it reportsdynamicTypeString for a bare42too — so what this buys is that the value arrives, not aparticular runtime type.
Mapcolumn, sent back as a parameter. AMaprow surfaces asKeyValuePair<K, V>[]so duplicate keys and pair order survive, and the explicitMap(K, V)formatter already took that shape — but
ParameterTypeInference.Inferhad no arm for it, so itfell to the
Arrayarm, whose element inference has no reading for aKeyValuePair. ADynamicparameter asks
Inferwhat the value is, so a Map read-back could not be sent as one.Ergonomics
TimeandTime64now take aTimeOnly.Datetakes aDateOnly, so a caller reasonablyexpects the symmetry;
ClickHouseTcpColumn.CreateoverTimeOnlyvalues had no codec at all.Reading back as a
TimeOnlyis the one narrowing in the read surface — aTimevalue may benegative or past 24 hours and is then not a time of day — so such a row is refused with a message
pointing at
TimeSpan, rather than reduced modulo a day, which would present a different value asthe stored one.
Variant(Time, String)accepted no time value at all.Parameters/ParameterTypeInference.cshad an arm for the instant types and none for the time ones,so a
TimeSpanmatched aTime64alternative and never aTimeone, and aVariantthat matchesnothing is refused whole.
New option:
SendJsonAndDynamicSerializationSettingsClickHouseTcpClientOptionsand the connection string. Public API surface updated.Every query, insert and execute from the high-level client carried
output_format_native_use_flattened_dynamic_and_json_serializationandoutput_format_native_write_json_as_string. Both server defaults are 0, so each operation was asetting modification, and a user under a
readonlyprofile got Code 164 for all of them — thehigh-level client was unusable for such a user while
ClickHouseTcpConnectionworked. The option ison by default and turns the injection off when cleared. Sending the settings as
"0"is not analternative: the server counts that as a modification too. The cost of clearing it is that a
JSONor
Dynamiccolumn may arrive in a serialization this client does not read; no other type isaffected.
Coverage added
Most of the diff. New integration fixtures, all against a real server:
MidStreamFailureIntegrationTestsExceptionpacket after data blocks — its body is unframed while the blocks around it are framed, so uncompressed, LZ4 and ZSTDInsertInterruptionIntegrationTestsOnBlockWritten, and a constraint rejecting every block after the firstComputedColumnInsertIntegrationTestsMultiBlockStateIntegrationTestsLowCardinalitydictionary, aDynamictype list and a version word across more than one block — the only multi-block reads were ofUInt64ConnectionPoolIntegrationTests(+250)idle_connection_timeout, polled for with a bounded budget rather than slept past); a retired connection closed on the server, bysystem.query_logclient ports and theTCPConnectionmetric; permit accounting at a pool of oneTlsTransportIntegrationTests+TlsTerminatingProxySslStream. The existing TLS tests answer with a cannedHello, and the Cloud fixture is skipped unless a service is configuredTimezoneColumnIntegrationTestsFloatSpecialValueIntegrationTests(-0.0).Equals(0.0)ReadonlyUserIntegrationTestsClickHouseTcpCancellationIntegrationTestsPlus ~330 lines of
InsertRoundTripCasevalues:UInt256/Int256at 2^255 and both ends;Decimalat scale 0, at scale == precision, andDecimal(76, 0); a fully populated IPv6 and theIPv4-mapped form;
LowCardinalityover 1-, 8- and 16-byte dictionaries; a dictionary leaf under twolevels of offsets;
Mapwith aLowCardinalitykey and with aTuplekey;QBit(Float32, 768);DateTimepast 2038; bothInt64ends ofDateTime64(9);NaN/±∞/−0 on all three float widths inbare,
NullableandArrayform;Nullable(Tuple(...))andNullable(Point), which the corpusnote had said could not be written.
Two things worth flagging to a reviewer
Some coverage was written to make a test able to fail, not to add a case. Three examples: the
read-deadline test could not fire because the server's
Progresspacket everyinteractive_delaycounts as speaking, so
SELECT sleep(3)finished under a one-second deadline — raisinginteractive_delaypast the sleep buys the silence. The timezone assertions previously compared theread-back against a value the same conversion produced, so they held even with the direction
inverted. The signed-zero test asserts stored bits through
reinterpretAsUInt32, because[assembly: DefaultFloatingPointTolerance(1e-15)]andEqualsboth hide the sign.Three backlog claims did not survive checking, and are closed on evidence rather than a fix. The
BFloat161-ULP disagreement (client and server truncate identically, bit for bit), theArray(Dynamic)zero-row desync (no desync; the codec's stated reason was wrong and is corrected,the behavior was right), and >255
Dynamicruntime types (the server capsmax_typesat 254, sothe wide discriminator is unreachable end to end).
What a review pass then found
The branch was reviewed by Codex with the repository's own review criteria, a live 26.6 to run
against, and licence to mutate the code to test a claim. It found nine things; four were real defects
in the branch, three were tests that could not support their names, and two are recorded rather than
fixed. The four defects and the three test repairs are in the commits above and below.
The most useful finding is one my own mutation discipline missed: the mismatched-
Variantinserttest passed with the fix reverted. It used
Variant(Date, Int64)→Variant(Date32, Int64), wherethe alternative keeps its index and both surface as
IColumn<DateOnly>, so the old arity-onlyshortcut reached the same answer. It is now
Variant(Int64, String)→Variant(Bool, Int64), whichmoves the alternative from index 0 to index 1; reverting the gate now fails it with the
InvalidCastExceptionthe fix's commit message describes.Two findings are declined, with the reasoning on record:
Time/Time64parameters round where the binary write truncates, soTimeOnly.MaxValueformats as
24:00:00and stores 86400 seconds, which no longer reads back as aTimeOnly. Real,but the rounding is deliberate parity with the shipped HTTP driver, which formats
TimeOnlythrough the identical
Math.Round(TotalSeconds)path, and the divergence applies toTimeSpanequally, so it predates this branch. Changing one client and not the other would reopen the parity
gap five commits here exist to close. Filed as its own TODO item; the test that pinned it now says
why it is pinned, and the case named "truncated to the scale" — which used a value that could not
tell rounding from truncation — is replaced by midpoint cases that can.
docs/update.docs/has two files and no native-client page at all; documentation landsfor the client as a whole. The examples half of that finding was valid and is done: the
Time/Time64example now writes aTimeOnlyand states the read-back narrowing, and theVariant/Dynamic/JSON example documents the new option and the readonly trade-off.
Verification
Full suite, both ends of the supported range:
The 25.8 run is the reason for the version gates in the last commit. It found a pre-existing red
test, two ungated cases, and one server behavior change: 25.8's MergeTree part writer normalizes a
negative zero away, where a
Memorytable keeps it, so the signed-zero test usesMemory.Every behavior change was verified by mutation — revert the fix, watch the new test fail. Three
mutations were inert on the first attempt and the tests were redesigned until they bit; the third of
those is the
Variantcase the review caught rather than I did.Not in this PR
docs/has no native-client page yet; documentation lands for the client as a whole. Thetwo user-visible additions are documented in the examples instead, which is where the client's
usage documentation currently lives.
tcp/**epic stack.native protocol carries no role field,
SET ROLEis SQL — so the work is coverage plus adocumented pattern: over a pooled client,
SET ROLEhas to run insideOpenSessionAsyncor thenext statement lands on another connection with the role unset.
client does not gate on server type rules.