Skip to content

TCP TT: a test audit of the native client, and the defects it found - #609

Draft
alex-clickhouse wants to merge 41 commits into
tcp/epic-t1-t2-benchmarksfrom
tcp/epic-tt-test-audit
Draft

TCP TT: a test audit of the native client, and the defects it found#609
alex-clickhouse wants to merge 41 commits into
tcp/epic-t1-t2-benchmarksfrom
tcp/epic-tt-test-audit

Conversation

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

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 Variant could be written as a different Variant of the same arity.
ClickHouse.Driver.Tcp/Types/Codecs/VariantColumnCodec.cs. The dense write gate compared the
number of alternatives, then reused the source column's own discriminator bytes. Reading
Variant(Int64, String) and inserting it into Variant(Bool, Int64) passed CanWrite, sent
discriminators that named the wrong alternative, and then hit an InvalidCastException inside the
body, 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
Variant lands 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 server
quotes an identifier it cannot spell bare — a JSON typed path, a Tuple or Nested field name — so
a real header carries `a(b` or `a,b`. Breaking on the comma or the paren inside it split
the argument list and the read failed on the header, before a row decoded. QuotedText scans a
quoted 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 arrives
spelled 'a\nb'; dropping the backslash and keeping the next character decoded that to anb, so
the label a caller read was wrong and writing the real label threw. Only \\ and \' came out
right. Labels now go through the same QuotedText scanner as identifiers.

A timezone .NET cannot represent killed the whole column.
Types/Codecs/DateTimeZones.cs, DateTimeColumn.cs, DateTime64Column.cs. ClickHouse accepts
fixed offsets TimeZoneInfo cannot hold and 26.6 applies them: Fixed/UTC+19:00:00 is past .NET's
±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 — no column and no row arrived, including for a
caller who only wanted the epoch counts, which the zone does not affect. ResolvedTimeZone now
carries either the zone or the reason there is none, and only the calendar uses (TimeZone,
GetDateTimeOffset, a DateTime/DateTimeOffset projection, writing an Unspecified DateTime)
report it.

DateTime64 reported a bare OverflowException. Types/Codecs/DateTime64ColumnCodec.cs. A
DateTimeOffset past the scale's range fell out of the multiply with no context. Range-checked
before scaling, naming the value and the column, as the DateTime path already did.

A Utc or Local DateTime could not be written to a column whose zone .NET cannot represent.
Same file plus DateTimeColumnCodec.cs. ToUtc consults the zone only for Unspecified, but both
callers evaluated ResolvedTimeZone.Value before it could look at Kind, so the check above did not
finish the job: writing a UTC instant to DateTime('Fixed/UTC+19:00:00') still threw, and so did a
null row, whose placeholder is DateTime.UnixEpoch. ToUtc now takes the resolved zone and asks for
it after the Kind test.

A negative Time64 finer than a tick read back as midnight.
Types/ColumnValueProjections.cs. Time64ToTimeOnly shifted the raw count to 100 ns ticks and then
checked 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 exact
outcome 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 a
    child node knows nothing about where it sits, so the message named a type the caller never wrote
    (the server spells it Bool). Types/ColumnCodecRegistry.cs now keeps the child's refusal and
    adds the type it was handed. Also drops "yet": Object('json') was removed from ClickHouse and
    MultiPoint never existed, so promising them later is wrong.
  • An AggregateFunction refusal named the serialization version as the function. 26.6 reports
    sumMapState(...) as AggregateFunction(1, sumMap, ...), so the message said "the '1' aggregate
    function" and suggested SELECT 1Merge(column), which is not a function. Now
    sumMapMerge(column), and sumMapFilteredMerge([1, 2])(column) with parameters — both run.
  • One parameter message covered two unrelated failures. Parameters/TcpParameterFormatter.cs
    answered "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.
  • Two client limits read as copies of a server rule. The QBit element switch and the Variant
    refusal of a Dynamic alternative. 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:

  • Type-name aliases. {p:VARCHAR}, {p:BIGINT}, {p:DEC(4,2)}, {p:Boolean}, {p:json} all
    threw client-side, before the query was sent. Types/TypeAliases.cs (new) is
    system.data_type_families on 26.6: every row with a non-empty alias_to. 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. An integration test compares the table against
    the live server in both directions, which is what caught the entries this would otherwise have
    mistyped.
  • Case. The registry now matches any case of a name it knows. It deliberately does not carry a
    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.
  • The same, inside a Variant. Picking the alternative is a separate match from formatting the
    value, and it read the name as the caller wrote it, so {p:Variant(BIGINT, String)} accepted no
    Int64 at all while the server resolves that declaration to Variant(Int64, String).
    ParameterTypeInference.Accepts now canonicalizes first, as the formatter's dispatch already did.
  • A bare Enum('A' = 1, 'B' = 2), which the server accepts and the HTTP driver resolves. The
    client now picks the width the server would: Enum8 while every ordinal fits Int8, Enum16
    otherwise. A header always names a width, so this only comes from a caller's hint.
  • TimeOnly as a parameter. It reached neither time arm: Time called Convert.ToInt32 on it,
    which a TimeOnly does not support, and Time64 matched TimeSpan only.
  • Dynamic, Geometry and SimpleAggregateFunction parameters, all three refused client-side
    while the server takes a text value for each (checked on 26.6). SimpleAggregateFunction writes
    as its inner type; the other two write the value's own text and leave the parse to the server. For
    Dynamic the server's answer turns out to be String whatever the text is — it reports
    dynamicType String for a bare 42 too — so what this buys is that the value arrives, not a
    particular runtime type.
  • A value read from a Map column, sent back as a parameter. A Map row surfaces as
    KeyValuePair<K, V>[] so duplicate keys and pair order survive, and the explicit Map(K, V)
    formatter already took that shape — but ParameterTypeInference.Infer had no arm for it, 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 Map read-back could not be sent as one.

Ergonomics

  • Time and Time64 now take a TimeOnly. Date takes a DateOnly, so a caller reasonably
    expects the symmetry; ClickHouseTcpColumn.Create over TimeOnly values had no codec at all.
    Reading back as a TimeOnly is the one narrowing in the read surface — a Time value may be
    negative 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 as
    the stored one.
  • Variant(Time, String) accepted no time value at all.
    Parameters/ParameterTypeInference.cs had an arm for the instant types and none for the time ones,
    so a TimeSpan matched a Time64 alternative and never a Time one, and a Variant that matches
    nothing is refused whole.

New option: SendJsonAndDynamicSerializationSettings

ClickHouseTcpClientOptions and 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_serialization and
output_format_native_write_json_as_string. Both server defaults are 0, so each operation was a
setting modification
, and a user under a readonly profile got Code 164 for all of them — the
high-level client was unusable for such a user while ClickHouseTcpConnection worked. The option is
on by default and turns the injection off when cleared. Sending the settings as "0" is not an
alternative: the server counts that as a modification too. The cost of clearing it is that a JSON
or Dynamic column may arrive in a serialization this client does not read; no other type is
affected.

Coverage added

Most of the diff. New integration fixtures, all against a real server:

Fixture What it reaches that nothing did
MidStreamFailureIntegrationTests an Exception packet after data blocks — its body is unframed while the blocks around it are framed, so uncompressed, LZ4 and ZSTD
InsertInterruptionIntegrationTests a data phase that does not finish: cancelled from OnBlockWritten, and a constraint rejecting every block after the first
ComputedColumnInsertIntegrationTests the insert schema block naming fewer columns than the table has
MultiBlockStateIntegrationTests a LowCardinality dictionary, a Dynamic type list and a version word across more than one block — the only multi-block reads were of UInt64
ConnectionPoolIntegrationTests (+250) the server hanging up on an idle connection (idle_connection_timeout, polled for with a bounded budget rather than slept past); a retired connection closed on the server, by system.query_log client ports and the TCPConnection metric; permit accounting at a pool of one
TlsTransportIntegrationTests + TlsTerminatingProxy a query, a block and an insert through a real SslStream. The existing TLS tests answer with a canned Hello, and the Cloud fixture is skipped unless a service is configured
TimezoneColumnIntegrationTests a non-UTC column asserted against the server's own rendering, including the ambiguous DST hour
FloatSpecialValueIntegrationTests signed zero, which the corpus comparison cannot see because (-0.0).Equals(0.0)
ReadonlyUserIntegrationTests a user who is not the fixture superuser
ClickHouseTcpCancellationIntegrationTests the read deadline actually firing

Plus ~330 lines of InsertRoundTripCase values: UInt256/Int256 at 2^255 and both ends;
Decimal at scale 0, at scale == precision, and Decimal(76, 0); a fully populated IPv6 and the
IPv4-mapped form; LowCardinality over 1-, 8- and 16-byte dictionaries; a dictionary leaf under two
levels of offsets; Map with a LowCardinality key and with a Tuple key; QBit(Float32, 768);
DateTime past 2038; both Int64 ends of DateTime64(9); NaN/±∞/−0 on all three float widths in
bare, Nullable and Array form; Nullable(Tuple(...)) and Nullable(Point), which the corpus
note 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 Progress packet every interactive_delay
counts as speaking, so SELECT sleep(3) finished under a one-second deadline — raising
interactive_delay past the sleep buys the silence. The timezone assertions previously compared the
read-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)] and Equals both hide the sign.

Three backlog claims did not survive checking, and are closed on evidence rather than a fix. The
BFloat16 1-ULP disagreement (client and server truncate identically, bit for bit), the
Array(Dynamic) zero-row desync (no desync; the codec's stated reason was wrong and is corrected,
the behavior was right), and >255 Dynamic runtime types (the server caps max_types at 254, so
the 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-Variant insert
test passed with the fix reverted.
It used Variant(Date, Int64)Variant(Date32, Int64), where
the alternative keeps its index and both surface as IColumn<DateOnly>, so the old arity-only
shortcut reached the same answer. It is now Variant(Int64, String)Variant(Bool, Int64), which
moves the alternative from index 0 to index 1; reverting the gate now fails it with the
InvalidCastException the fix's commit message describes.

Two findings are declined, with the reasoning on record:

  • Time/Time64 parameters round where the binary write truncates, so TimeOnly.MaxValue
    formats as 24:00:00 and stores 86400 seconds, which no longer reads back as a TimeOnly. Real,
    but the rounding is deliberate parity with the shipped HTTP driver, which formats TimeOnly
    through the identical Math.Round(TotalSeconds) path, and the divergence applies to TimeSpan
    equally, 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.
  • No docs/ update. docs/ has two files and no native-client page at all; documentation lands
    for the client as a whole. The examples half of that finding was valid and is done: the
    Time/Time64 example now writes a TimeOnly and states the read-back narrowing, and the
    Variant/Dynamic/JSON example documents the new option and the readonly trade-off.

Verification

Full suite, both ends of the supported range:

Server Result
26.6.1.1193 4065 passed, 6 skipped, 0 failed
25.8.32.4 (the CI floor) 4001 passed, 12 skipped, 0 failed

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 Memory table keeps it, so the signed-zero test uses Memory.

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 Variant case the review caught rather than I did.

Not in this PR

  • Docs. docs/ has no native-client page yet; documentation lands for the client as a whole. The
    two user-visible additions are documented in the examples instead, which is where the client's
    usage documentation currently lives.
  • No changelog fragment, per the standing rule for the tcp/** epic stack.
  • Roles and grants. Deferred to a post-0.1 item. There is no protocol feature to add — the
    native protocol carries no role field, SET ROLE is SQL — so the work is coverage plus a
    documented pattern: over a pooled client, SET ROLE has to run inside OpenSessionAsync or the
    next statement lands on another connection with the role unset.
  • Five backlog items are triaged as will-not-fix, four of them under the standing rule that the
    client does not gate on server type rules.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread ClickHouse.Driver.Tcp/Types/Codecs/DateTime64ColumnCodec.cs Outdated
Comment thread ClickHouse.Driver.Tcp/Types/Codecs/DateTimeColumnCodec.cs Outdated
@codecov

codecov Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-tt-test-audit branch 2 times, most recently from 7601597 to 1585ff3 Compare September 3, 2026 09:46
alex-clickhouse and others added 10 commits September 4, 2026 11:18
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.
alex-clickhouse and others added 27 commits September 4, 2026 11:18
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants