TCP N: Add ClickHouseTcpClient: public client API (read path + inserts) - #462
TCP N: Add ClickHouseTcpClient: public client API (read path + inserts)#462alex-clickhouse wants to merge 6 commits into
Conversation
TriageCategory: Summary What this impacts
Concerns
Required reviewer action
|
There was a problem hiding this comment.
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
ClickHouseTcpClientwith 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 initialSingleConnectionSource. - Makes
Blockpublic and addsBlock.ColumnNames; threadsMaxSendBufferBytesthrough 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. |
ec2c0ca to
1fa7db1
Compare
|
Thanks — addressed all three (pushed as an amend to
Full net9.0 suite green (1102 tests). |
1fa7db1 to
293f045
Compare
There was a problem hiding this comment.
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;
}
293f045 to
e27b97c
Compare
e27b97c to
5e94cd5
Compare
5e94cd5 to
87403eb
Compare
f623015 to
9585d2a
Compare
9585d2a to
f4b752e
Compare
2b6fead to
5778060
Compare
5778060 to
750a651
Compare
750a651 to
9e866d7
Compare
9e866d7 to
71565b9
Compare
4f39da4 to
c307083
Compare
c307083 to
badf0d2
Compare
badf0d2 to
2f178d2
Compare
There was a problem hiding this comment.
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,
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1102c7f. Configure here.
1102c7f to
d02ae1d
Compare
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>

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:StreamAsync→IAsyncEnumerable<Block>(low-level columnar tier)QueryAsync→IAsyncEnumerable<object[]>(untyped rows, boxed viaIColumn.GetValue)ExecuteAsync(non-result statements),InsertAsync(columnarIReadOnlyList<IColumn>),PingAsyncoutput_format_native_use_flattened_dynamic_and_json_serializationsoDynamic/JSONdecode without the caller knowing (caller value still wins).records, so a caller holding one can derive a variant withwith { ... }instead of rebuilding it by hand (which is how the HTTP-sideInsertOptions.WithQueryId/WithColumnTypescame to silently drop properties).ClickHouseTcpClientOptionsoverridesToStringto keep the plaintextPasswordout of logs, since a record otherwise prints every property andClient.Optionsis public. TheSettings/CustomSettingsdictionaries compare by reference, not content — documented on the types and pinned by a test.ClickHouseTcpClientOptions+ClickHouseTcpConnectionStringBuilder(Host/Port/Username/Password/Database/QuotaKey/DialTimeout/ReadTimeout/MaxSendBufferBytes+set_<name>custom settings), and a minimalClickHouseTcpQueryOptions(QueryId+Settings).IConnectionSource/IConnectionLeasewith an interimSingleConnectionSource(one connection, serialized, redials a terminated one).DialTimeoutbounds connect+handshake. A real pool (Epic M) implements the same interface with no client change.MaxSendBufferBytesthreaded throughInsertAsyncas the between-column flush threshold (write memory backstop), independent of the 50 MB block-split target.Blockis now public (constructor +Infostayed internal soBlockInfoisn't leaked); addedBlock.ColumnNames.Streaming release semantics
StreamAsyncrents a connection and returns it to the source exactly once on full drain, early enumerator disposal, or exception (anInterlockedguard prevents double-return; a terminated connection is discarded and redialed on the next rent).Tests
set_*custom settings, defaults, round-trip), settings-merge (N1a injection / caller-wins),SingleConnectionSourcelifecycle (idempotent dispose, rent-after-dispose, pre-cancelled token).object[]rows + owned-row retention,ExecuteAsyncround-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 (provesMaxSendBufferBytes), concurrency, connection-string construction.Full net9.0 suite green (1101 tests). Coverage ~93% line / ~87% branch on the new code.
Deferred (called out)
ReadTimeoutis parsed/stored but not yet enforced (it is the idle read-loop deadline of Q3).internalClickHouseServerException/ClickHouseProtocolException(callers see the baseException) — exception hierarchy is Q1/Epic R.🤖 Generated with Claude Code