Skip to content

TCP R1-R4 + C9: declare and enforce the public API surface - #592

Open
alex-clickhouse wants to merge 7 commits into
tcp/epic-q3-timeoutsfrom
tcp/epic-r-public-api
Open

TCP R1-R4 + C9: declare and enforce the public API surface#592
alex-clickhouse wants to merge 7 commits into
tcp/epic-q3-timeoutsfrom
tcp/epic-r-public-api

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #591. Epic R1-R4 plus C9.

The native client's public surface is now declared, enforced by an analyzer, and fully documented. Along the way this settles the open questions about what the client should expose.

The public API

Everything public lives in ClickHouse.Driver.Tcp, one namespace, with the single exception of the DI extension class in Microsoft.Extensions.DependencyInjection. 45 public types, 538 declared API entries.

The shape is three tiers over one operation interface. You pick a tier per call, not per client: the same object serves a borrowed-columnar read, a boxed row read and a mapped POCO read.

Entry points

// A pooled client. The usual entry point.
new ClickHouseTcpClient(string connectionString);
new ClickHouseTcpClient(ClickHouseTcpClientOptions options);

// The single owner of a client and its pool. Hand this to a container.
new ClickHouseTcpDataSource(string connectionString);
new ClickHouseTcpDataSource(ClickHouseTcpClientOptions options);

ClickHouseTcpDataSource implements IDisposable as well as IAsyncDisposable, because a synchronous ServiceProvider.Dispose() rejects an async-only singleton.

// Microsoft.Extensions.DependencyInjection
IServiceCollection AddClickHouseTcpDataSource(this IServiceCollection services, string connectionString, object serviceKey = null);
IServiceCollection AddClickHouseTcpDataSource(this IServiceCollection services, ClickHouseTcpClientOptions options, object serviceKey = null);
IServiceCollection AddClickHouseTcpDataSource(this IServiceCollection services, Func<IServiceProvider, ClickHouseTcpClientOptions> optionsFactory, object serviceKey = null);
IServiceCollection AddClickHouseTcpDataSource(this IServiceCollection services, Func<IServiceProvider, object, ClickHouseTcpDataSource> dataSourceFactory, object serviceKey = null);

Every overload registers one ClickHouseTcpDataSource as a singleton and resolves IClickHouseTcpDataSource, IClickHouseTcpClient and IClickHouseTcpOperations from it, so the application shares one pool. Singleton is the only lifetime offered: the pool has to outlive every consumer.

The operation surface

IClickHouseTcpOperations is the whole verb set, and both the client and a session implement it — so code that just runs queries can take either.

public interface IClickHouseTcpOperations : IAsyncDisposable
{
    ClickHouseTcpClientOptions Options { get; }

    // Read. `options` carries the query id, settings, parameters and callbacks; null takes the client's.
    IAsyncEnumerable<Block>    StreamAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default);
    IAsyncEnumerable<object[]> QueryAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default);
    IAsyncEnumerable<T>        QueryAsync<T>(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default) where T : class;
    ValueTask<object>          ExecuteScalarAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default);
    ValueTask                  ExecuteAsync(string sql, ClickHouseTcpQueryOptions options = null, CancellationToken cancellationToken = default);

    // Write. All three take `INSERT INTO t (…) VALUES` with no inline VALUES literal.
    ValueTask InsertAsync(string sql, IReadOnlyList<IColumn> columns, ClickHouseTcpInsertOptions options = null, CancellationToken cancellationToken = default);
    ValueTask InsertRowsAsync<T>(string sql, IReadOnlyList<T> rows, ClickHouseTcpInsertOptions options = null, CancellationToken cancellationToken = default) where T : class;
    ValueTask InsertRowsAsync(string sql, IReadOnlyList<object[]> rows, ClickHouseTcpInsertOptions options = null, CancellationToken cancellationToken = default);

    ValueTask PingAsync(CancellationToken cancellationToken = default);
    ValueTask<ClickHouseTcpServerInfo> GetServerInfoAsync(CancellationToken cancellationToken = default);
}

public interface IClickHouseTcpClient : IClickHouseTcpOperations
{
    // Reserves one pooled connection, so temporary tables and SET survive across operations.
    ValueTask<IClickHouseTcpSession> OpenSessionAsync(CancellationToken cancellationToken = default);
}

public interface IClickHouseTcpSession : IClickHouseTcpOperations
{
    bool IsOpen { get; }   // once false, stays false — open a new session
}

public interface IClickHouseTcpDataSource : IAsyncDisposable, IDisposable
{
    ClickHouseTcpClientOptions Options { get; }
    IClickHouseTcpClient GetClient();   // shared; the data source's to dispose, never the caller's
    ValueTask<IClickHouseTcpSession> OpenSessionAsync(CancellationToken cancellationToken = default);
}

Reading a result

Call Yields What it costs
StreamAsync IAsyncEnumerable<Block> Nothing per row. Columns come as spans over the block's own buffers, valid for that iteration only.
QueryAsync IAsyncEnumerable<object[]> A boxed value per cell. Each array is owned and safe to retain.
QueryAsync<T> IAsyncEnumerable<T> One T per row, properties filled from same-named columns, ignoring case and then underscores.
ExecuteScalarAsync ValueTask<object> The first column of the first row, boxed.
ExecuteAsync ValueTask Nothing — result blocks are drained and discarded.

Two contracts a caller has to know, both now stated in the XML docs:

  • Blocks are borrowed. A Block and everything reachable from it — its columns, an IColumn<T>.Values span — is valid only for the current iteration. Copy out what must outlive the loop body, and do not dispose a yielded block yourself; the reader owns it. Enumerate with await foreach so the connection is released.
  • ExecuteScalarAsync reads the whole result, not just the first row. Later values are discarded but still cross the wire. Stopping early is not an option: abandoning a result cancels the query and closes the connection — a reconnect per call on a pooled client, and the loss of a session's temporary tables. Write a query that returns one row, and reach for StreamAsync to read part of a large result.

Inserting

InsertAsync is the columnar tier: data arrives already grouped by column, so nothing is transposed and nothing is boxed. ClickHouseTcpColumn is how a caller outside the assembly builds those columns.

public static class ClickHouseTcpColumn
{
    public static IColumn<T> Create<T>(string name, T[] values);          // taken over as is, not copied
    public static IColumn<T> Create<T>(string name, IEnumerable<T> values);
}

Columns are matched to the target by name, so order is free, and the statement's column list — not the set of columns you build — decides what is inserted. A built column reports a null TypeName: the server sends the target's schema before any row data and that is what values are serialized as, so requiring a type string from the caller would only invite a wrong answer that we then ignore. A column read out of a Block can be passed straight back to an insert.

InsertRowsAsync<T> maps by property name, with [ClickHouseTcpColumn(Name = …)] to rename and [ClickHouseTcpNotMapped] to exclude. InsertRowsAsync(…, IReadOnlyList<object[]>) matches by position. Both convert one wire block at a time (ClickHouseTcpInsertOptions.MaxRowsPerBlock), so the row list is read as the insert runs rather than copied up front.

The block tier

public sealed class Block : IDisposable
{
    string Name { get; }
    int RowCount { get; }
    int ColumnCount { get; }
    IReadOnlyList<string> ColumnNames { get; }
    IReadOnlyList<IColumn> Columns { get; }
    IColumn this[int index] { get; }
    IColumn this[string name] { get; }
    IColumn<T> Column<T>(int index);
    IColumn<T> Column<T>(string name);
    bool TryGetColumn(string name, out IColumn column);
}

public interface IColumn : IDisposable
{
    string Name { get; }
    string TypeName { get; }        // null for a column built by ClickHouseTcpColumn.Create
    int RowCount { get; }
    Type ElementType { get; }
    object GetValue(int row);       // boxed; prefer IColumn<T>.Values on the fast path
}

public interface IColumn<T> : IColumn
{
    ReadOnlySpan<T> Values { get; } // borrowed; recomputed per access, so hoist it in a hot loop
    T this[int row] { get; }
}

Eleven shape interfaces let a caller cast down to a composite's internal representation instead of materializing it. Each one is : IColumn (or : IColumn<…>) plus the members below.

Interface Adds
IArrayColumn<TElement> Inner, InnerValues, Offsets — the flat child and its offsets, with no jagged array built
INullableColumn<T> Inner, NullMap
ILowCardinalityColumn<T> Dictionary, Keys, ReservedSlotCount
IMapColumn<TKey, TValue> KeyColumn, ValueColumn, Offsets
INestedColumn FieldCount, FieldNames, GetField(int), GetField(string), Offsets
ITupleColumn Children, FieldNames
IVariantColumn Discriminators, LocalIndices, TypeCount, GetTypeColumn(int), NullDiscriminator
IDynamicColumn the same, plus TypeNames
IQBitColumn BitWidth, BytesPerRow, Dimension, GroupCount, Stride, GetPlane(int), GetPlane(int, int)
IDateTimeColumn Scale, TimeZone, GetDateTimeOffset(int), ToDateTimeOffsets()
ITimeColumn Scale, GetTimeSpan(int), ToTimeSpans()

The rest of the surface

Group Types
Configuration ClickHouseTcpClientOptions (record, init properties, FromConnectionString), ClickHouseTcpConnectionStringBuilder (DbConnectionStringBuilder, ToOptions()), ClickHouseTcpPoolReusePolicy
Per-call options ClickHouseTcpQueryOptions (query id, settings, parameters, callbacks), ClickHouseTcpInsertOptions : ClickHouseTcpQueryOptions (MaxRowsPerBlock, DeduplicationToken)
Parameters ClickHouseTcpParameter(string Name, object Value, string ClickHouseType = null), ClickHouseTcpParameterCollection
Server feedback ClickHouseTcpServerInfo, ClickHouseTcpQueryCallbacks (OnProgress, OnProfileInfo, OnProfileEvents, OnTotals, OnExtremes, OnLog, OnBlockWritten), ClickHouseTcpProgress, ClickHouseTcpProfileInfo, ClickHouseTcpBlockWritten
Numerics Int256, UInt256, ClickHouseTcpDecimal — readonly structs, with ToBigInteger / ToDecimal / TryToDecimal escape hatches
Errors ClickHouseTcpException (abstract, : DbException) with ClickHouseTcpServerException, ClickHouseTcpTransportException and ClickHouseTcpProtocolException under it; ClickHouseErrorCode, 56 named values
POCO mapping ClickHouseTcpColumnAttribute, ClickHouseTcpNotMappedAttribute
Diagnostics ClickHouseTcpDiagnostics — the activity source name plus three ILogger category names

ClickHouseTcpClientOptions is the one wide type. Its properties group into endpoint (Host, Port, ResolvedPort, Database, Username, Password, QuotaKey), TLS (UseTls, TlsServerName, TlsCaCertificatePath, TlsAllowInvalidCertificates, ConfigureTls), pool (MinPoolSize, MaxPoolSize, PoolTimeout, PoolReusePolicy, MaxConnectionLifetime, IdleTimeout, SweepInterval), timeouts (DialTimeout, ReadTimeout), wire (Compressor, MaxSendBufferBytes, StatementMaxLength), observability (LoggerFactory, IncludeSqlInActivityTags) and CustomSettings.

The client, session and data source types carry [Experimental("CHTCP0001")]; the data types do not.

What this PR adds to that surface

  • ClickHouseTcpColumn.Create(name, values) — closes a real hole. Every concrete column was internal and there was no factory, so InsertAsync(string, IReadOnlyList<IColumn>, …) was public but uncallable from outside the assembly. Tests only reached it through InternalsVisibleTo.
  • GetServerInfoAsync / ClickHouseTcpServerInfo — version, protocol revision, timezone and display name off the handshake, so callers can branch on a server version without parsing SELECT version(). Nothing exposed this before. It costs no round trip once a connection is open, though it opens one if the pool is empty.
  • ExecuteScalarAsync — the one-value counterpart of QueryAsync, for a count() or an EXISTS.
  • ClickHouseTcpDataSource / IClickHouseTcpDataSource — owns a client and hands out views whose DisposeAsync does nothing, so a scoped service that disposes what it was injected cannot take the shared pool down with it.
  • AddClickHouseTcpDataSource — the DI registration above.
  • IDateTimeColumn and ITimeColumn — let the block tier read a temporal column as a calendar value rather than as raw ticks, without leaving the columnar path.
  • ClickHouseTcpBlockWritten and the OnBlockWritten callback — per-block insert progress.
  • ClickHouseDecimalClickHouseTcpDecimal, resolving the collision with the HTTP driver's type of the same name.

IDynamicColumn and IVariantColumn were public already but declared inside their concrete column files; they move to their own files here, unchanged.

Surface decisions

Question Answer
How does a caller build columns for InsertAsync? A static factory. The concrete columns stay internal.
ClickHouseDecimal collides with the HTTP driver's type Rename the TCP one to ClickHouseTcpDecimal
Int256/UInt256 vs the HTTP driver's BigInteger Keep them separate
ClickHouseErrorCode Stays TCP-only
Exception naming Keep the Tcp infix and the separate hierarchy
[Experimental] scope The client, session and data source, not every type
The eleven column shape interfaces Stay public; typed access is worth the surface
Block's borrowed lifetime Documented contract, not enforced in the type
Namespaces Everything public in ClickHouse.Driver.Tcp

ref struct, a scoped callback and a disposable lease were all considered for making Block's borrowing visible in the type. A ref struct cannot cross an await, which rules out IAsyncEnumerable<Block>; the other two cost the await foreach or add an allocation per block to turn silent corruption into a runtime throw. None earns its price.

C9: one-time little-endian guard

The column codecs reinterpret wire bytes as CLR values with MemoryMarshal.Cast, which is correct only on a little-endian host. The per-codec guards were removed as redundant; this replaces them with one check at client and connection construction. A guard, not a fallback — every runtime .NET currently targets is little-endian.

The analyzer, and what enabling it uncovered

Microsoft.CodeAnalysis.PublicApiAnalyzers was never actually wired up in this repo: no package reference, no AdditionalFiles, no CI job. The PublicAPI/*.txt files have been inert, despite AGENTS.md describing them as analyzer-enforced.

Wiring it up surfaced why the drift went unnoticed. The analyzer only checks the surface when handed exactly one Shipped/Unshipped pair, and both mis-configurations fail silently:

AdditionalFiles given RS0016 fires? What you see instead
One pair yes correct
One file alone no RS0048
Two pairs (e.g. a per-TFM folder) no RS0025 only

Measured on this repo: deleting a public type from a single-file configuration produced no RS0016; ClickHouse.Driver on net10.0 reports 0 RS0016 with both its pairs loaded and 228 errors with only the root pair.

So RS0048 is escalated to error alongside RS0016/RS0017/RS0024 in the new project-level .editorconfig files. It is the only signal that the checks have been switched off.

Scope: this PR wires ClickHouse.Driver.Tcp and ClickHouse.Driver.Common only. Both are genuinely in sync. ClickHouse.Driver is left exactly as it was — its PublicAPI/net8.0/ folder is a second pair, so it has tracked nothing for a long time and has roughly 230 undeclared public symbols, ClickHouseClient and IClickHouseClient among them. Since ClickHouse.Driver/.editorconfig already raises RS0016 to error, wiring it without declaring those first breaks release.yml and examples.yml, neither of which passes WarningLevel=0. That cleanup is filed separately.

R2 and R4

R2 needed no change; the packaging was already right. Verified from the built package: ClickHouse.Driver.Tcp.dll in lib/net8.0|net9.0|net10.0 and correctly absent from lib/net6.0, neither Tcp nor Common leaking into the nuspec dependency list, and the third-party notices shipping as content.

R4 follows the HTTP driver: no GenerateDocumentationFile anywhere. Every public TCP member is documented — verified by an ad-hoc -p:GenerateDocumentationFile=true build reporting zero CS1591, which required documenting 20 operators on the numeric structs. Turning generation on repo-wide is a separate task; the HTTP driver has around 540 undocumented public members.

Testing

3483 pass, 1 skipped (a QBit(Int8) case, correctly gated off against a 26.6 server).

New coverage: PublicSurfaceIntegrationTests round-trips factory-built columns through a real insert across the fixed-width, string, nullable and jagged-array shapes, and covers name-matching, column subsets and the wrong-CLR-type error; ClickHouseTcpDataSourceIntegrationTests proves the ownership split against a live pool; ClickHouseTcpColumnTests and ClickHouseTcpServerInfoTests cover what a server round-trip cannot reach.

The analyzer gate was verified by breaking it on purpose — deleting a type from the API file produces error RS0016 — rather than by assuming a clean build meant a working check.

🤖 Generated with Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9040442. Configure here.

Comment thread ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs

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

Declares and enforces the native TCP client’s public API while adding missing public entry points and centralizing platform validation.

Changes:

  • Adds column factories, scalar queries, server information, and data-source ownership APIs.
  • Moves public TCP types into the root namespace and renames the TCP decimal type.
  • Enables Public API analyzers and adds integration/unit coverage.

Reviewed changes

Copilot reviewed 55 out of 56 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Directory.Packages.props Pins the API analyzer.
ClickHouse.Driver.Tcp/Types/VariantColumn.cs Extracts the public interface.
ClickHouse.Driver.Tcp/Types/IVariantColumn.cs Declares the variant surface.
ClickHouse.Driver.Tcp/Types/ITupleColumn.cs Moves interface namespace.
ClickHouse.Driver.Tcp/Types/IQBitColumn.cs Moves interface namespace.
ClickHouse.Driver.Tcp/Types/INullableColumn.cs Moves interface namespace.
ClickHouse.Driver.Tcp/Types/INestedColumn.cs Moves interface namespace.
ClickHouse.Driver.Tcp/Types/IMapColumn.cs Moves interface namespace.
ClickHouse.Driver.Tcp/Types/ILowCardinalityColumn.cs Moves interface namespace.
ClickHouse.Driver.Tcp/Types/IDynamicColumn.cs Declares the dynamic surface.
ClickHouse.Driver.Tcp/Types/IColumn.cs Moves and documents column APIs.
ClickHouse.Driver.Tcp/Types/IArrayColumn.cs Moves interface namespace.
ClickHouse.Driver.Tcp/Types/DynamicColumn.cs Extracts the public interface.
ClickHouse.Driver.Tcp/Types/ColumnElementTypes.cs Extracts internal type resolution.
ClickHouse.Driver.Tcp/Types/ColumnCodecRegistry.cs Updates namespace usage.
ClickHouse.Driver.Tcp/Types/Codecs/DynamicTypeInference.cs Applies decimal rename.
ClickHouse.Driver.Tcp/Types/Codecs/DecimalColumnCodec.cs Uses the renamed decimal type.
ClickHouse.Driver.Tcp/Types/ClickHouseTcpColumn.cs Adds public column factories.
ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Unshipped.txt Declares the TCP API surface.
ClickHouse.Driver.Tcp/PublicAPI/PublicAPI.Shipped.txt Establishes shipped API tracking.
ClickHouse.Driver.Tcp/Protocol/HostEndianness.cs Adds little-endian validation.
ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs Guards connection construction.
ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryWriter.cs Updates namespace usage.
ClickHouse.Driver.Tcp/Protocol/ClickHouseBinaryReader.cs Updates namespace usage.
ClickHouse.Driver.Tcp/Poco/PocoScatterTier.cs Updates API documentation reference.
ClickHouse.Driver.Tcp/Parameters/TcpParameterFormatter.cs Formats renamed decimals.
ClickHouse.Driver.Tcp/Parameters/ParameterTypeInference.cs Infers renamed decimals.
ClickHouse.Driver.Tcp/Numerics/UInt256.cs Moves and documents operators.
ClickHouse.Driver.Tcp/Numerics/Int256.cs Moves and documents operators.
ClickHouse.Driver.Tcp/Numerics/ClickHouseTcpDecimal.cs Renames and documents decimal API.
ClickHouse.Driver.Tcp/Format/Block.cs Moves Block to the root namespace.
ClickHouse.Driver.Tcp/Client/IClickHouseTcpOperations.cs Adds scalar and server-info APIs.
ClickHouse.Driver.Tcp/Client/ClickHouseTcpSession.cs Delegates the new operations.
ClickHouse.Driver.Tcp/Client/ClickHouseTcpServerInfo.cs Adds handshake information model.
ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs Adds shared-client ownership API.
ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs Implements new operations and guard.
ClickHouse.Driver.Tcp/ClickHouse.Driver.Tcp.csproj Enables API analysis.
ClickHouse.Driver.Tcp/.editorconfig Enforces API diagnostics.
ClickHouse.Driver.Tcp.Tests/Utilities/InsertRoundTripCase.cs Updates decimal test cases.
ClickHouse.Driver.Tcp.Tests/Types/NullableColumnCodecTests.cs Updates namespace usage.
ClickHouse.Driver.Tcp.Tests/Types/FixedWidthColumnCodecTests.cs Updates namespace usage.
ClickHouse.Driver.Tcp.Tests/Types/DynamicTypeInferenceTests.cs Updates decimal inference tests.
ClickHouse.Driver.Tcp.Tests/Types/DecimalColumnCodecTests.cs Updates decimal codec tests.
ClickHouse.Driver.Tcp.Tests/Types/ClickHouseTcpColumnTests.cs Tests column factory behavior.
ClickHouse.Driver.Tcp.Tests/Protocol/ClickHouseBinaryReaderWriterTests.cs Updates namespace usage.
ClickHouse.Driver.Tcp.Tests/Parameters/TcpParameterFormatterTests.cs Updates decimal formatting tests.
ClickHouse.Driver.Tcp.Tests/Parameters/TcpParameterFormatterEdgeCaseTests.cs Updates inference edge cases.
ClickHouse.Driver.Tcp.Tests/Numerics/Int256Tests.cs Updates namespace usage.
ClickHouse.Driver.Tcp.Tests/Numerics/ClickHouseTcpDecimalTests.cs Tests renamed decimal type.
ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs Exercises the new public APIs.
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpDataSourceIntegrationTests.cs Tests data-source ownership.
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpClientQueryIntegrationTests.cs Updates namespace usage.
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpServerInfoTests.cs Tests server information behavior.
ClickHouse.Driver.Common/ClickHouse.Driver.Common.csproj Enables API analysis.
ClickHouse.Driver.Common/.editorconfig Enforces API diagnostics.
changelog.d/418-tcp-native-client.features.md Documents the release feature.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ClickHouse.Driver.Tcp/Client/ClickHouseTcpDataSource.cs Outdated
Comment thread ClickHouse.Driver.Tcp.Tests/Integration/PublicSurfaceIntegrationTests.cs Outdated
@codecov

codecov Bot commented Aug 31, 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-r-public-api branch 2 times, most recently from 9185665 to 54f0751 Compare September 1, 2026 08:23
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-r-public-api branch 2 times, most recently from b9dd50e to bad7226 Compare September 1, 2026 18:20
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-r-public-api branch 2 times, most recently from 9a2dc16 to 9dfc536 Compare September 2, 2026 11:07
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-r-public-api branch 2 times, most recently from 0dc50ee to 32669a5 Compare September 4, 2026 09:01
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-r-public-api branch 2 times, most recently from e7d484c to 9edbef9 Compare September 9, 2026 11:53
alex-clickhouse and others added 6 commits September 9, 2026 12:03
…tion

Epic R1-R4 plus C9. The TCP client's public surface is now declared and
enforced, and every public member is documented.

Surface changes, from the review of what to expose:

- Every public type moves into the ClickHouse.Driver.Tcp namespace, so a
  caller needs one using rather than four. IDynamicColumn, IVariantColumn
  and ColumnElementTypes get their own files to allow it.
- ClickHouseDecimal becomes ClickHouseTcpDecimal. The HTTP driver already
  publishes a ClickHouseDecimal, and one package cannot carry two public
  types of that name without confusing every caller.
- ClickHouseTcpColumn.Create builds the columns InsertAsync takes. Until
  now every concrete column was internal and there was no factory, so the
  method was public but uncallable from outside the assembly. Columns built
  this way carry no type name: the insert reads the target's type from the
  server's schema, so asking the caller for one only invites a wrong answer
  we would ignore.
- GetServerInfoAsync reports the handshake's version, protocol revision and
  timezone, which nothing exposed before.
- ExecuteScalarAsync returns the first cell of the first row.
- ClickHouseTcpDataSource owns a client and hands out views whose disposal
  does nothing, so a scoped consumer cannot close a pool it does not own.

C9: the column codecs reinterpret wire bytes as CLR values, which holds
only on a little-endian host. One check at client and connection
construction replaces the per-codec guards.

The analyzer covers ClickHouse.Driver.Tcp and ClickHouse.Driver.Common,
each with an .editorconfig raising RS0016/RS0017/RS0024 to error. RS0048
is an error too: the analyzer needs exactly one Shipped/Unshipped pair and
runs no surface check at all when given one file or two pairs, and RS0048
is the only sign it has stopped looking. ClickHouse.Driver is left alone;
wiring it needs ~230 symbols declared first, and is filed separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Returning at the first value left the stream early, which is the abandon
path: the connection sends Cancel and terminates. On a session that
destroyed the session outright, temporary tables and settings included,
for a query as small as SELECT count(); on a pooled client it cost a
reconnect on every call.

Draining to completion keeps the connection. Every row now crosses the
wire, so the remarks say so and point large reads at StreamAsync.

The test that claimed to prove connection reuse only proved the pool could
redial after a termination. Both scalar-lifetime tests now run on a session
with a temporary table as the marker, which is the one thing that cannot
survive a replaced connection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AddClickHouseTcpDataSource registers the data source and the client it owns,
mirroring the HTTP AddClickHouseDataSource: a connection string, options, an
options factory, or a data source factory, each with an optional service key.
Only singletons, because the pool has to outlive every consumer.

The data source hands out the client itself rather than a view that swallows
disposal, so a consumer that disposes what it was injected closes the pool. The
docs say so, and the pool teardown is idempotent, so the container disposing
both the data source and the client at shutdown closes it once.

ClickHouseTcpClient implements IDisposable alongside IAsyncDisposable so a
container can dispose it under either path. A synchronous
ServiceProvider.Dispose rejects a tracked service that offers only
IAsyncDisposable, and rejects it instead of disposing the rest of its list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A DateTime column surfaces as IColumn<uint> and a DateTime64 as IColumn<long>,
because that is the count the wire carried. Turning a count into an instant
needs the timezone the column type declared, which no IColumn member reports and
which the concrete column classes hold internally, so a caller on the block tier
could not do it at all: only QueryAsync<T> into a POCO converted. Time and
Time64 were the same, minus the timezone.

IDateTimeColumn and ITimeColumn expose that conversion the way IQBitColumn and
IVariantColumn already expose their layouts — a public interface over an
internal column, reached by pattern-matching. Scale says which unit the raw
count is in, so a caller reading Values directly knows what it has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ITimeColumn test queried Time without enabling it. On 25.8, the floor of the
CI matrix, the type is setting-gated, so the test would have failed there while
passing on a local 26.6 where it has graduated. Every Time case in
InsertRoundTripCase already passes both flags. With them set, toTime resolves to
toTimeWithFixedDate, which takes a Date or DateTime rather than a String, so the
query builds its value with a cast instead.

Two calendar assertions formatted without a culture, so a non-Gregorian current
culture would render a different year and fail on correct values.

IDateTimeColumn.TimeZone claimed a type naming no timezone resolves to the
server's. A query's session_timezone comes first. The projections also count
100 ns ticks, so scale 8 and 9 truncate; the concrete columns document that and
the interfaces a consumer actually sees did not.

Found by a codex review of 0d099f6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writing the 30 native examples exercised the frozen surface as a consumer and
found 46 items. These are sections C through F, less the ones marked do-not-fix.

Behaviour:

- ClickHouseTcpDecimal.ToString(format, provider) rejects a format it cannot
  render instead of returning differently formatted text than was asked for.
- ITupleColumn.FieldNames is empty, not null, for an unnamed tuple. The server
  refuses a partly-named tuple, so a non-empty list holds no nulls.
- The columnar write mismatch names the CLR element type supplied and the types
  the target accepts, matching the row tier, rather than an internal class.
- A null Array(T) row reports the column argument, not an internal local.
- ClickHouseTcpServerException.Message drops the class-name prefix the server
  repeats from its name field.
- ClickHouseErrorCode names code 26, CANNOT_PARSE_QUOTED_STRING.
- The client generates a query id when the caller names none. The native
  protocol never sends the server's own id back, so an operation that left the
  field empty could not afterwards be found in system.query_log; the id in force
  reaches every log line and the trace span.

Surface:

- ClickHouseTcpInsertOptions.DeduplicationToken carries
  insert_deduplication_token, which is what makes a retried insert safe.
- ClickHouseTcpQueryCallbacks.OnBlockWritten reports each block an insert sends
  with its rows and its size before and after compression. A native insert gets
  no Progress packets, so this is its only progress, and the only place
  MaxRowsPerBlock is observable.
- ClickHouseTcpServerInfo separates the advertised, negotiated and client
  protocol revisions; the connection log line says which one is in force.
- IClickHouseTcpDataSource, registered alongside the concrete data source.
- ClickHouseTcpClientOptions.ResolvedPort is public.

Docs, each measured against 26.6.1.1193:

- Compressor governs what an insert writes, not what the server sends: the
  server frames with its own network_compression_method (1,630,538 bytes of
  result under lz4 against 742,764 under zstd, client codec fixed), and that
  setting leaves the insert size untouched.
- The statement's column list picks the inserted subset, not the columns built.
- QBit(Int8, N) needs 26.7, and a strided QBit cannot be reached below it.
- ProfileInfo.Bytes is the server's in-memory measure: 464 bytes for ten rows
  whose uncompressed body is 90.
- OnProgress fires on the server's interactive_delay, default 100,000 us.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review September 11, 2026 11:33

@kavirajk kavirajk left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM

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.

3 participants