TCP O: sessions — one pinned connection for temporary tables and SET - #578
Open
alex-clickhouse wants to merge 5 commits into
Open
TCP O: sessions — one pinned connection for temporary tables and SET#578alex-clickhouse wants to merge 5 commits into
alex-clickhouse wants to merge 5 commits into
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Adds native TCP sessions that pin one pooled connection to preserve temporary tables and SET state.
Changes:
- Splits shared operations into
IClickHouseTcpOperations. - Adds session lifecycle and pinned-connection handling.
- Adds comprehensive integration and concurrency tests.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
PinnedConnectionSource.cs |
Manages pinned leases and disposal races. |
IClickHouseTcpSession.cs |
Defines the public session contract. |
IClickHouseTcpOperations.cs |
Extracts shared TCP operations. |
IClickHouseTcpClient.cs |
Adds session creation. |
ClickHouseTcpSession.cs |
Delegates session operations to the client. |
ClickHouseTcpClient.cs |
Creates sessions and shares POCO plans. |
ClickHouseTcpSessionIntegrationTests.cs |
Tests session behavior against ClickHouse. |
PinnedConnectionSourceTests.cs |
Tests lease lifecycle and concurrency. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
alex-clickhouse
marked this pull request as ready for review
August 19, 2026 14:48
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
from
August 21, 2026 13:07
dbde60c to
b650e47
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
2 times, most recently
from
August 22, 2026 17:06
4c07e99 to
53e1052
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
from
August 22, 2026 17:25
53e1052 to
b91aa0b
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
from
August 26, 2026 08:59
b91aa0b to
41f0c05
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
2 times, most recently
from
August 26, 2026 15:40
b21612a to
3044804
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
from
August 26, 2026 16:39
3044804 to
1c28f0f
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
from
August 26, 2026 19:00
1c28f0f to
d64b565
Compare
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 d64b565. Configure here.
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
2 times, most recently
from
August 28, 2026 12:18
d691d01 to
82674ce
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
2 times, most recently
from
August 28, 2026 16:32
a45877e to
fa44fe8
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
from
August 30, 2026 09:08
fa44fe8 to
54e6650
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
2 times, most recently
from
August 31, 2026 17:20
3e2bb3e to
d590438
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
from
September 1, 2026 08:23
d590438 to
95fa0af
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
2 times, most recently
from
September 2, 2026 11:07
d70dc37 to
82ff960
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
from
September 3, 2026 09:25
82ff960 to
1b8247f
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
2 times, most recently
from
September 3, 2026 14:11
6458935 to
7ab080b
Compare
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
from
September 4, 2026 09:00
7ab080b to
f0aa2c3
Compare
Every member moves to a new IClickHouseTcpOperations, which IClickHouseTcpClient now extends and adds nothing to yet. No behaviour changes and no caller changes: the members are inherited, so code written against IClickHouseTcpClient still compiles. This is preparation for sessions. A session runs the same operations as a client but over one pinned connection, so both need the operation surface while only one of them can hand out sessions. Naming that surface separately lets code which merely runs operations take either. Co-Authored-By: Claude <noreply@anthropic.com>
A session pins one pooled connection for its lifetime, so the state a single connection owns — temporary tables, and the settings a SET changes — is still there for the next operation. Open one with client.OpenSessionAsync and dispose it to end it. It is built on PinnedConnectionSource, an IConnectionSource that hands out the one lease it holds. Because that is the same seam the pool plugs into, a session runs the ordinary client code over it and implements no operation of its own, so it cannot drift from the client. The inner client shares the parent's POCO registry, so a session costs no second plan compile. Disposal terminates the connection *before* returning the lease. That order is the guarantee: the pool decides a returned connection's fate from its state, so one still Ready on return is one the pool keeps, carrying this session's temporary tables into an unrelated caller's queries. Disposal during an operation cannot terminate — that would return the reader's pooled buffers underneath a live read — so it aborts the transport, which is safe to race with, and the operation completes the return as it unwinds. The pinned connection is tested for liveness at both ends of an operation. On release that catches what the operation did to it; before the next operation it catches a connection dropped while the session sat idle, which is the case release cannot see because at release nothing has had time to go wrong. Two traps that follow, both pinned by tests: a connection mid-operation is not reusable by definition, so both callers must exclude the busy case or every busy session is condemned, and the answer must latch or IsOpen could say the session is finished and the next operation disagree. Known caveat, documented and pinned rather than fixed: aborting frees an operation parked on the socket, but a stream enumerator nobody disposes is parked at its yield, so nothing resumes it and its slot stays out until the client is disposed. The plain client has the same hazard for a leaked enumerator. Co-Authored-By: Claude <noreply@anthropic.com>
The IsOpen contract said the session only tests its connection at the end of an operation. It tests at both ends, and a test pins that, so the doc was wrong. Also add the abandoned-enumerator caveat to the public session contract, where a caller can see it, instead of only on the internal source. Trim the comments on the new session code by about a third. The facts stay: the terminate-before-return order, why disposal aborts instead of closing, the pool slot an abandoned enumerator holds, and the TLS false-positive risk. The reasoning that belongs in the pull request description goes.
alex-clickhouse
force-pushed
the
tcp/epic-o-sessions
branch
from
September 4, 2026 09:35
f0aa2c3 to
7dee051
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Stacked on #577. Review only the last two commits; the base carries the rest of the stack.
Epic O of the native-TCP client. A session pins one pooled connection for its lifetime, so the state a single connection owns — temporary tables, and the settings a
SETchanges — is still there for the next operation.How it works
PinnedConnectionSourceis anIConnectionSourcethat hands out the one lease it holds. Because that is the same seam the pool plugs into, a session runs the ordinary client code over it and implements no operation of its own, so it cannot drift from the client. The inner client shares the parent's POCO registry, so a session costs no second plan compile.The interface is split three ways.
IClickHouseTcpOperationsholds every operation;IClickHouseTcpClientaddsOpenSessionAsyncandIClickHouseTcpSessionaddsIsOpen. So code that only runs operations takes the base interface and accepts either, and a session cannot offer a nestedOpenSessionAsyncthat would silently pin a different connection. The first commit is that move on its own — no behaviour change, and callers still compile because the members are inherited. The concrete session class isinternal, sinceOpenSessionAsyncreturns the interface and a public sealed class nobody can construct or name in a signature would add nothing.Disposal terminates the connection before returning the lease. That order is the guarantee, not a detail: the pool decides a returned connection's fate from its state, so one still
Readyon return is one the pool keeps, carrying this session's temporary tables into an unrelated caller's queries. Disposal during an operation cannot terminate — that would return the reader's pooled buffers underneath a live read — so it aborts the transport, which is safe to race with, and the operation completes the return as it unwinds.Liveness is tested at both ends of an operation. On release that catches what the operation did to the connection; before the next operation it catches one dropped while the session sat idle, which release cannot see because at release nothing has had time to go wrong. That is what turns the connection's internal "terminated and cannot be reused" into a session-aware message. Two traps follow, both pinned by tests: a connection mid-operation is not reusable by definition, so both callers must exclude the busy case or every busy session is condemned; and the answer must latch, or
IsOpencould say the session is finished and the next operation disagree.Known caveat, documented and pinned rather than fixed
Aborting frees an operation parked on the socket, but a stream enumerator nobody disposes is parked at its
yield, so nothing resumes it, its lease is never returned, and the pool is short a slot until the client itself is disposed — whileDisposeAsyncreturns promptly. Returning the lease from disposal instead is worse: the pool would then terminate a connection whose buffers a live operation may still point at.The trigger is narrower than it looks.
await foreachcompiles to atry/finallythat disposes the enumerator, so an ordinarybreakis fine; it takes a hand-rolledGetAsyncEnumeratorthat is never disposed, which already leaks a slot on the plain client.DisposeAsync_WithAStreamNobodyAdvancesOrDisposes_DoesNotGetTheSlotBackpins the behaviour atMaxPoolSize = 1.Tests
31 new tests, all green; whole TCP suite 3123 passing.
Integration (15) asserts everything that defines a session against a real server, using a temporary table as the marker — the server scopes one to the connection that made it, so its visibility reports which connection ran a query without the client having to claim anything about its own pool. Kill-on-dispose is proved at
MaxPoolSize = 1, where the next query must land on whatever the session gave back.Unit (16) covers only what a live session cannot show: the terminate-before-return order, disposal with an operation still running, and a 200-iteration three-way race on disposal, since over-releasing the pool's permit is the failure that would not announce itself.
Each load-bearing behaviour was mutated away and confirmed to fail exactly its own tests. One mutation caught a weak assertion: the concurrency test was satisfied by the connection's busy guard rather than the session's, so it now pins the session's own message.
Notes for review
docs/covers the TCP client yet.🤖 Generated with Claude Code