Skip to content

TCP N: Add ClickHouseTcpClient: public client API (read path + inserts) - #462

Open
alex-clickhouse wants to merge 6 commits into
tcp/epic-j4-dynamicfrom
tcp/epic-n1-client
Open

TCP N: Add ClickHouseTcpClient: public client API (read path + inserts)#462
alex-clickhouse wants to merge 6 commits into
tcp/epic-j4-dynamicfrom
tcp/epic-n1-client

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

First branch of Epic N (client API) for the native-TCP client. Adds the user-facing entry point on top of the existing raw connection, plus the options and connection-acquisition plumbing. Stacked on tcp/epic-j4-dynamic.

What's here

  • ClickHouseTcpClient[Experimental("CHTCP0001")], IAsyncDisposable, safe to share:
    • StreamAsyncIAsyncEnumerable<Block> (low-level columnar tier)
    • QueryAsyncIAsyncEnumerable<object[]> (untyped rows, boxed via IColumn.GetValue)
    • ExecuteAsync (non-result statements), InsertAsync (columnar IReadOnlyList<IColumn>), PingAsync
    • Auto-enables output_format_native_use_flattened_dynamic_and_json_serialization so Dynamic/JSON decode without the caller knowing (caller value still wins).
  • Options — all three options types are records, so a caller holding one can derive a variant with with { ... } instead of rebuilding it by hand (which is how the HTTP-side InsertOptions.WithQueryId/WithColumnTypes came to silently drop properties). ClickHouseTcpClientOptions overrides ToString to keep the plaintext Password out of logs, since a record otherwise prints every property and Client.Options is public. The Settings/CustomSettings dictionaries compare by reference, not content — documented on the types and pinned by a test.
  • OptionsClickHouseTcpClientOptions + ClickHouseTcpConnectionStringBuilder (Host/Port/Username/Password/Database/QuotaKey/DialTimeout/ReadTimeout/MaxSendBufferBytes + set_<name> custom settings), and a minimal ClickHouseTcpQueryOptions (QueryId + Settings).
  • Connection seam — internal IConnectionSource/IConnectionLease with an interim SingleConnectionSource (one connection, serialized, redials a terminated one). DialTimeout bounds connect+handshake. A real pool (Epic M) implements the same interface with no client change.
  • MaxSendBufferBytes threaded through InsertAsync as the between-column flush threshold (write memory backstop), independent of the 50 MB block-split target.
  • Block is now public (constructor + Info stayed internal so BlockInfo isn't leaked); added Block.ColumnNames.

Streaming release semantics

StreamAsync rents a connection and returns it to the source exactly once on full drain, early enumerator disposal, or exception (an Interlocked guard prevents double-return; a terminated connection is discarded and redialed on the next rent).

Tests

  • Unit: options validation + handshake mapping, connection-string parsing (set_* custom settings, defaults, round-trip), settings-merge (N1a injection / caller-wins), SingleConnectionSource lifecycle (idempotent dispose, rent-after-dispose, pre-cancelled token).
  • Integration (live server): streaming, early-dispose→redial reuse, server-error→still-usable, object[] rows + owned-row retention, ExecuteAsync round-trip, columnar insert round-trip, schema-mismatch, per-query settings, Dynamic decode without the caller setting the flag (proves N1a), tiny 4 KB send-buffer flushing 20k rows intact (proves MaxSendBufferBytes), concurrency, connection-string construction.

Full net9.0 suite green (1101 tests). Coverage ~93% line / ~87% branch on the new code.

Deferred (called out)

  • Row-oriented / POCO insert (N9) + POCO read (N5/N5a) → Branch 2.
  • Query-parameter binding & value formatting (N11) + rich per-query options (N10) → Branch 3.
  • ReadTimeout is parsed/stored but not yet enforced (it is the idle read-loop deadline of Q3).
  • The client can throw the still-internal ClickHouseServerException/ClickHouseProtocolException (callers see the base Exception) — exception hierarchy is Q1/Epic R.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Triage

Category: featureRisk: high

Summary
This PR (first branch of "Epic N") introduces ClickHouseTcpClient, a new user-facing high-level client over the native TCP protocol. It adds ClickHouseTcpClientOptions, ClickHouseTcpConnectionStringBuilder, ClickHouseTcpQueryOptions, an internal IConnectionSource/IConnectionLease seam (with a SingleConnectionSource interim implementation), and partially publicizes Block (Block.ColumnNames). The client exposes StreamAsync (columnar IAsyncEnumerable<Block>), QueryAsync (untyped object[] rows), ExecuteAsync, InsertAsync, and PingAsync. All public types are marked [Experimental("CHTCP0001")]. Diff is +1637/-5 lines, almost entirely net-new code in ClickHouse.Driver.Tcp/ and its test project.

What this impacts

  • New public API surface in ClickHouse.Driver.Tcp: ClickHouseTcpClient, ClickHouseTcpClientOptions, ClickHouseTcpConnectionStringBuilder, ClickHouseTcpQueryOptions, Block.ColumnNames (all [Experimental])
  • Internal IConnectionSource/IConnectionLease/SingleConnectionSource — the connection-lifecycle seam for the future pool
  • Test infrastructure: TcpServerFixture extended with Options(), CreateClient(), ConnectionString helpers

Concerns

  • Concurrency rule fires: SingleConnectionSource uses an Interlocked guard against double-return of a connection lease, and a serializing gate (SemaphoreSlim or equivalent) for concurrent RentAsync calls. The test RentAsync_PreCancelledToken_ThrowsOperationCanceledAndDoesNotDeadlock names a deadlock scenario explicitly — this is exactly the pattern the concurrency rubric rule targets.
  • ReadTimeout stored but not enforced: callers who set it may silently get no timeout behavior; could surprise users and should be documented at the call site or validated differently if enforcement is genuinely deferred.
  • PR is in DRAFT: not yet ready for final review; triage provided as early signal only.
  • Block partially publicized: Block.ColumnNames added to public surface. Verify PublicAPI/*.txt was updated (not visible in diff summary).

Required reviewer action

  • High risk: PR body must include an architectural description before review. The existing body is thorough (streaming release semantics, connection-seam design, deferred items); reviewer should confirm it covers the concurrency model of SingleConnectionSource (gate acquisition/release under cancellation) before approving.

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

Adds an experimental, high-level native-TCP client API (ClickHouseTcpClient) on top of the existing TCP protocol layer, including connection acquisition plumbing and client/query options, with tests covering core behaviors and live-server integration.

Changes:

  • Introduces ClickHouseTcpClient with streaming (Block), untyped row streaming (object[]), execute/insert, ping, and per-query settings merge (incl. flattened Dynamic/JSON serialization injection).
  • Adds options and parsing/building support (ClickHouseTcpClientOptions, ClickHouseTcpQueryOptions, ClickHouseTcpConnectionStringBuilder) plus a connection-source seam with an initial SingleConnectionSource.
  • Makes Block public and adds Block.ColumnNames; threads MaxSendBufferBytes through the TCP insert write path; adds unit + integration tests.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs Threads a send-buffer flush threshold through insert streaming and validates it.
ClickHouse.Driver.Tcp/Format/Block.cs Makes Block public and adds cached ColumnNames.
ClickHouse.Driver.Tcp/Client/SingleConnectionSource.cs Implements a serialized, single-connection rent/lease source with redial on terminated connections.
ClickHouse.Driver.Tcp/Client/IConnectionSource.cs Defines internal connection source + lease interfaces for pooling seam.
ClickHouse.Driver.Tcp/Client/ClickHouseTcpQueryOptions.cs Adds minimal per-query overrides (QueryId + settings).
ClickHouse.Driver.Tcp/Client/ClickHouseTcpConnectionStringBuilder.cs Adds TCP-native connection string builder/parser (incl. set_* settings).
ClickHouse.Driver.Tcp/Client/ClickHouseTcpClientOptions.cs Defines validated client-level endpoint/timeout/buffer/settings options.
ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs Adds the experimental public TCP client API and settings merge behavior.
ClickHouse.Driver.Tcp.Tests/Integration/TcpServerFixture.cs Adds fixture helpers for creating options/client and a TCP connection string.
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpClientIntegrationTests.cs Live-server integration coverage for streaming, inserts, reuse/redial, settings, and concurrency.
ClickHouse.Driver.Tcp.Tests/Client/SingleConnectionSourceTests.cs Unit tests for SingleConnectionSource lifecycle and cancellation behavior.
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpConnectionStringBuilderTests.cs Unit tests for builder parsing/defaults/custom settings/round-trip.
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpClientSettingsTests.cs Unit tests for settings merge + flattened serialization injection behavior.
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpClientOptionsTests.cs Unit tests for defaults, validation, and handshake mapping.
ClickHouse.Driver.Tcp.Tests/ClickHouse.Driver.Tcp.Tests.csproj Suppresses the experimental API diagnostic for tests.

Comment thread ClickHouse.Driver.Tcp/Client/ClickHouseTcpConnectionStringBuilder.cs Outdated
Comment thread ClickHouse.Driver.Tcp/Format/Block.cs
@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

Thanks — addressed all three (pushed as an amend to 1fa7db1):

  1. Connection-string builder typed getters (real bug) — GetIntOrDefault/GetTimeSpanSecondsOrDefault only matched string, but the typed setters store boxed int/double, so a set-then-get on the same builder returned the default. Getters now handle both the boxed typed value and the parsed string (invariant culture, NumberStyles.Integer/Float).
  2. Block.ColumnNames publication — now builds the array into a local and publishes via Volatile.Write (with Volatile.Read on the getter), so a concurrent reader can't observe the reference before its elements are written. A benign double-compute yields equivalent arrays, so only the torn publication needed guarding.
  3. Test gap — added TypedSetters_ReadBackOnSameInstance_ReturnValuesNotDefaults, which asserts Port/MaxSendBufferBytes/DialTimeout/ReadTimeout read back on the same instance (this is what catches Bump Vampire/setup-wsl from 5 to 6 #1; the prior tests only round-tripped through ConnectionString, which stringifies and masked it).

Full net9.0 suite green (1102 tests).

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

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs:234

  • Settings dictionaries can currently contain an empty key or a null value. In the native protocol, settings are encoded as (key, flags, value) triples terminated by an empty key, so an empty key can corrupt the packet; a null value will also throw when the Query packet is written. Consider validating during merge so failures are deterministic and actionable (covers both client-level and per-query settings).
            foreach (KeyValuePair<string, string> entry in clientSettings)
            {
                merged[entry.Key] = entry.Value;
            }

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

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

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Comment thread ClickHouse.Driver.Tcp/Format/Block.cs Outdated
Comment thread ClickHouse.Driver.Tcp/Client/ClickHouseTcpClient.cs
@alex-clickhouse
alex-clickhouse force-pushed the tcp/epic-n1-client branch 2 times, most recently from f623015 to 9585d2a Compare July 28, 2026 15:26
@alex-clickhouse alex-clickhouse changed the title Add ClickHouseTcpClient: native-TCP client API (read path + inserts) TCP N: Add ClickHouseTcpClient: public client API (read path + inserts) Jul 28, 2026

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

Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.

Suppressed comments (2)

ClickHouse.Driver.Tcp/Format/Block.cs:77

  • ColumnNames currently returns the cached string[] directly as IReadOnlyList. Because IReadOnlyList is castable back to the underlying array, callers can mutate the returned list and corrupt future name lookups (similar to why DynamicColumn.TypeNames wraps its array). Wrap the array with Array.AsReadOnly before returning to prevent external mutation.
            string[] existing = Volatile.Read(ref columnNames);
            if (existing is not null)
            {
                return existing;
            }

            var names = new string[Columns.Count];
            for (int i = 0; i < names.Length; i++)
            {
                names[i] = Columns[i].Name;
            }

            Volatile.Write(ref columnNames, names);
            return names;

ClickHouse.Driver.Tcp/Client/ClickHouseTcpClientOptions.cs:87

  • WithOwnedCustomSettings snapshots CustomSettings into a mutable Dictionary and then exposes it via the public Options.CustomSettings property. Callers can still downcast and mutate that dictionary after client construction, violating the “options never changes / safe to share” contract and potentially racing MergeSettings. Consider wrapping the snapshot in a read-only dictionary (similar motivation to DynamicColumn.TypeNames wrapping).
                Username = Username,
                Password = Password,
                Database = Database,
                QuotaKey = QuotaKey,
                CustomSettings = new Dictionary<string, string>(CustomSettings, StringComparer.Ordinal),
                MaxSendBufferBytes = MaxSendBufferBytes,
                DialTimeout = DialTimeout,
                ReadTimeout = ReadTimeout,

@alex-clickhouse
alex-clickhouse marked this pull request as ready for review August 14, 2026 11:33

@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 1102c7f. Configure here.

alex-clickhouse and others added 6 commits August 18, 2026 10:01
Introduces the first user-facing entry point for the native-TCP client on top
of the existing raw connection, plus the options and connection-acquisition
plumbing it needs.

- ClickHouseTcpClient ([Experimental("CHTCP0001")]): StreamAsync (block tier),
  QueryAsync (object[] rows), ExecuteAsync (non-result statements), InsertAsync
  (columnar), PingAsync. Safe to share; auto-enables the flattened
  Dynamic/JSON serialization on every operation (a caller value still wins).
- ClickHouseTcpClientOptions + ClickHouseTcpConnectionStringBuilder
  (Host/Port/Username/Password/Database/QuotaKey/DialTimeout/ReadTimeout/
  MaxSendBufferBytes + set_<name> custom settings) and a minimal
  ClickHouseTcpQueryOptions (QueryId + Settings).
- IConnectionSource/IConnectionLease seam with a single-connection interim
  source that serializes access and redials a terminated connection; a real
  pool implements the same interface later. DialTimeout bounds connect+handshake.
- MaxSendBufferBytes is threaded through InsertAsync as the between-column flush
  threshold (the write memory backstop), independent of the block-split target.
- Block is now public (its constructor and Info stayed internal); added
  Block.ColumnNames for header-order name lookup.

Covered by unit tests (options/connection-string/settings-merge/source
lifecycle) and live-server integration tests (streaming, early-dispose redial,
server-error reuse, columnar round-trip, per-query settings, Dynamic decode
without the caller setting the flag, tiny send-buffer flush, concurrency).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Asserts that writing a column from its ergonomic form produces bytes
identical to writing the dense column read back from that same wire
output, across Array/Nullable/Tuple/Map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR 462 feedback:
- Validate client-level CustomSettings at construction: reject an
  empty/null setting name (it would collide with the empty key that
  terminates the wire settings list) and a null value.
- ToOptions rejects a bare 'set_' key (empty setting name) and never
  emits a null value for a value-less set_ key.
- Copy CustomSettings into an owned dictionary in the client ctor, so a
  caller mutating their dictionary cannot fault or partially apply a
  concurrent settings merge on the shared client.
- Correct the Password doc: the native transport is unencrypted, so the
  password is not TLS-protected by this client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on, Block doc

PR 462 (round 2):
- ToOptions now formats a typed set_ value (e.g. builder["set_max_threads"]
  = 4) as an invariant string instead of silently dropping it to empty.
- MergeSettings validates per-query settings (user-provided, unlike
  client CustomSettings): an empty name would truncate the wire settings
  list and a null value cannot be written, so both are rejected.
- Document that a Block yielded by a query must not be disposed by the
  consumer — the reader owns its borrowed, pooled storage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Four related changes to the native-TCP client API.

Tests for the validation added in the last review round. The two review
commits added six branches with no test: the client-level and per-query
setting name/value guards, the bare 'set_' connection-string key, the
typed set_ value, and the constructor's settings copy. Each new test was
checked by reverting its fix and confirming it fails.

New IClickHouseTcpClient, matching the main driver's IClickHouseClient
so consumers can code against an interface and substitute a double. It
restates the block-borrowing contract, because an implementation that
lets a consumer dispose or retain a yielded Block breaks the pooling
invariant silently. An integration test drives every operation through
an interface-typed reference, which is what proves the interface is
sufficient on its own.

InsertAsync now takes a ClickHouseTcpInsertOptions instead of a loose
maxRowsPerBlock whose default came from an internal constant. The new
type extends ClickHouseTcpQueryOptions (now unsealed), so the settings
merge accepts it unchanged. Note that null becomes meaningful in two
ways here: no options means "client defaults", but an explicit
MaxRowsPerBlock of null means "one block", so the value is read through
a default instance rather than coalesced. ResolveMaxRowsPerBlock is
internal to make that testable, since one block versus several is not
observable through the client.

The client keeps its whole ClickHouseTcpClientOptions as a property
instead of copying single fields out of it, so later options need no new
field. The defensive copy of CustomSettings moves into
WithOwnedCustomSettings on the options type, next to the properties it
mirrors; a reflection-based test fails if a property added later is left
out of either the copy or the test.

Also moves the 20 read-path integration tests that do not assert
connection state onto the client: all 14 columnar read-surface tests and
6 of the 10 connection query tests. The tests that assert the connection
is left Ready stay where they are, because the client does not expose
connection state — which keeps all 605 per-type codec cases on the
connection, where the layering in AGENTS.md puts them.

Full net9.0 TCP suite green (1352 tests).

Co-Authored-By: Claude <noreply@anthropic.com>
The options types are already init-only, so a caller who holds one and wants a
variant of it must build a new one by hand. Records give them `with` instead,
and let WithOwnedCustomSettings replace one property rather than copy all ten.

That hand-copy is a bug source, not a hypothetical one: the HTTP-side twins of
these types do the same thing and drop properties. InsertOptions.WithQueryId
loses ReadValueConverter and AcceptEncoding, and WithColumnTypes loses those
plus ParameterTypeResolver. `with` makes that class of bug unrepresentable.

Two guards come with the change:

- ClickHouseTcpClientOptions overrides ToString. A record prints every property,
  which would put the plaintext Password into any log line that formats the
  options, and ClickHouseTcpClient.Options is public. The override names the
  safe properties explicitly, so a secret added later stays out by default.
- The Settings/CustomSettings dictionaries compare by reference, since the
  declared type is an interface with no value-equality contract. Documented on
  the types and pinned by a test, because record equality otherwise implies
  content comparison.

Also pins that `with` through a ClickHouseTcpQueryOptions-typed reference keeps
the runtime type, so deriving a variant of an insert options instance does not
reset MaxRowsPerBlock to its default.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants