TCP Q3/Q4: an idle read deadline, and telling the server when a result is abandoned - #591
Draft
alex-clickhouse wants to merge 1 commit into
Draft
TCP Q3/Q4: an idle read deadline, and telling the server when a result is abandoned#591alex-clickhouse wants to merge 1 commit into
alex-clickhouse wants to merge 1 commit into
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Adds native TCP idle-read timeouts and best-effort server cancellation when responses are abandoned.
Changes:
- Enforces
ReadTimeoutper transport read, with zero disabling it. - Sends
Cancelbefore terminating incomplete queries/inserts. - Adds timeout, cancellation, pooling, and idle-semantics coverage.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
ClickHouse.Driver.Tcp/Protocol/ReadBuffer.cs |
Arms deadlines around transport reads. |
ClickHouse.Driver.Tcp/Protocol/IdleReadDeadline.cs |
Implements reusable idle deadlines. |
ClickHouse.Driver.Tcp/Protocol/ClickHouseTcpConnection.cs |
Integrates deadlines and cancellation packets. |
ClickHouse.Driver.Tcp/Client/IConnectionFactory.cs |
Passes configured read timeout. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpConnectionStringBuilder.cs |
Documents zero-timeout behavior. |
ClickHouse.Driver.Tcp/Client/ClickHouseTcpClientOptions.cs |
Documents and validates timeout semantics. |
ClickHouse.Driver.Tcp.Tests/Utilities/ScriptedDuplexStream.cs |
Simulates delayed transport reads. |
ClickHouse.Driver.Tcp.Tests/Protocol/ClickHouseTcpConnectionQueryTests.cs |
Tests query timeout and cancellation behavior. |
ClickHouse.Driver.Tcp.Tests/Protocol/ClickHouseTcpConnectionInsertTests.cs |
Tests insert cancellation boundaries. |
ClickHouse.Driver.Tcp.Tests/Integration/ClickHouseTcpCancellationIntegrationTests.cs |
Verifies server-side cancellation and pooling. |
ClickHouse.Driver.Tcp.Tests/Client/ClickHouseTcpClientOptionsTests.cs |
Covers updated timeout validation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
alex-clickhouse
force-pushed
the
tcp/epic-q3-timeouts
branch
from
August 26, 2026 10:01
77a0fcf to
eace85e
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-q3-timeouts
branch
from
August 26, 2026 15:24
eace85e to
7297455
Compare
alex-clickhouse
force-pushed
the
tcp/epic-q3-timeouts
branch
from
August 26, 2026 15:40
7297455 to
0cc1b31
Compare
alex-clickhouse
force-pushed
the
tcp/epic-q3-timeouts
branch
from
August 26, 2026 16:46
0cc1b31 to
e4fb2da
Compare
alex-clickhouse
force-pushed
the
tcp/epic-q3-timeouts
branch
from
August 26, 2026 19:03
e4fb2da to
3807c4c
Compare
alex-clickhouse
force-pushed
the
tcp/epic-q3-timeouts
branch
from
August 27, 2026 16:05
3807c4c to
b93b25e
Compare
alex-clickhouse
force-pushed
the
tcp/epic-q3-timeouts
branch
from
August 28, 2026 09:03
b93b25e to
35c3782
Compare
alex-clickhouse
force-pushed
the
tcp/epic-q3-timeouts
branch
from
August 28, 2026 11:08
35c3782 to
042edb7
Compare
alex-clickhouse
force-pushed
the
tcp/epic-q3-timeouts
branch
2 times, most recently
from
August 28, 2026 12:18
537a438 to
d4ec682
Compare
alex-clickhouse
force-pushed
the
tcp/epic-q3-timeouts
branch
2 times, most recently
from
August 28, 2026 16:32
981642e to
99f3349
Compare
alex-clickhouse
force-pushed
the
tcp/epic-q3-timeouts
branch
from
August 30, 2026 09:14
99f3349 to
957fd12
Compare
…s abandoned ReadTimeout was parsed, stored and read by nothing. It now bounds every read of an operation, armed immediately before each read from the transport and disarmed as soon as that read returns, so it measures silence rather than duration: a result that streams for an hour never trips it, and neither does a consumer that holds a block longer than the deadline. TimeSpan.Zero disables it, as it does for the pool's limits. Giving up on a result now sends the Cancel packet before closing the connection, so the server stops rather than finishing a query nobody reads. That covers cancellation, a read that gave up, and a consumer that breaks out of the enumeration. The insert row phase is excluded: a block is part-written there, so an appended Cancel would be read as more block bytes. Co-Authored-By: Claude <noreply@anthropic.com>
alex-clickhouse
force-pushed
the
tcp/epic-q3-timeouts
branch
from
August 30, 2026 09:21
957fd12 to
7c7ae92
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 #590 (
tcp/epic-q1-exceptions). Review only the last commit.Closes Q3 (timeout handling) and Q4 (cancellation → cancel and kill).
Q3: the deadline that nothing armed
ReadTimeoutwas parsed from the connection string, validated, stored on the options — and read by nothing. A connection dropped without a FIN mid-operation stalled until TCP gave up, about fifteen minutes on Linux, and the caller's own token was the only bound.IsReusablesaid as much in its remarks.It is now armed immediately before each read from the transport and disarmed the moment that read returns, so it measures silence, not duration. Two properties fall out of that rather than needing to be arranged:
yield, so a consumer that holds a block for ten minutes is not mistaken for a server that stopped answering.ReadBuffer's twostream.ReadAsynccalls are the only places a byte arrives, andreadsFromTransportalready distinguished them from the frame decoder's adapter buffer — so the compressed path is bounded by the transport buffer underneath it, and the decoder's own buffer correctly carries no deadline.Failure is a
TimeoutExceptionnaming ReadTimeout, followingDialTimeout's precedent rather than adding a fourth leaf to Q1's deliberately closed hierarchy. The handshake stays underDialTimeoutalone, so the two never stack on one exchange.TimeSpan.Zeronow disables itValidaterejectedZero, so a caller reading a legitimately silent stream had no opt-out. Zero is the opt-out forIdleTimeoutandMaxConnectionLifetimealready;ReadTimeoutnow matches. Negatives and the ~24.8-day timer ceiling are still rejected.Writes take the caller's token, reads take the deadline's
ReadTimeoutbounds reads, so only reads observe it. Otherwise a deadline that fires as a read completes —CancelAfter(Infinite)cannot recall a timer callback already running — would surface on the next write as anOperationCanceledExceptionfor a token the caller never cancelled.Q4: Cancel, then terminate
The packet is written and flushed from the same
finallythat already chose between Ready andTerminate, whenever the operation ended short of a packet boundary. One condition covers four cases: caller cancellation, a read that gave up onReadTimeout, a broken transport, and a consumer that simplybreaks out of theawait foreach— which previously left the server producing a result nobody would read with nothing sent to say so.writer.Reset()first, then a flush on its own two-second deadline: the operation's own token is usually the cancelled one, and flushing on it would send nothing. That flush runs before the pool lease goes back, which is the one thing to know about the constant — it bounds how long the next caller can wait for the slot, and only against a peer whose receive window is shut.What the server does with it, measured rather than assumed
The first version of the integration test asserted
QUERY_WAS_CANCELLED_BY_CLIENT(735) and failed withBroken pipe(210). The cause is real and worth writing down:ExceptionWhileProcessing, code 735 —Received 'Cancel' packet from the clientThe server reads client packets between the blocks it sends. A result large enough to fill the socket blocks it in a
write()where it reads nothing, and the close that follows is what stops it. Both stop the query at once — so Cancel is what turns a silent abandonment into an explicit one, not the only thing that ends the query. The tests use the slow shape deliberately, and say so.Not sent during the insert row phase
A block is part-written there, so an appended Cancel is read as more block bytes rather than as a packet. The flag is false for exactly that stretch and true either side of it (waiting for the schema block, and draining for end-of-stream).
Tests
3381 pass, 5 skipped (TLS, no local certificates), on net9.0 and net10.0 against a real 26.7.1.
The three tests that assert the idle semantics were mutation-checked, because that property is easy to assert vacuously:
Disarm()removed,QueryAsync_ConsumerHoldsABlockPastReadTimeout_...fails;ConsumerHoldsABlockPastReadTimeout,ResponseSlowerOverallThanReadTimeoutand the integrationQueryLongerThanReadTimeoutfail.Three earlier drafts of these passed while proving nothing, and are worth naming so they are not reintroduced: a
SELECT count() FROM (SELECT sleepEachRow(...))that the planner prunes to 4 ms; a scripted case whose 87-byte script was swallowed whole by the handshake's first 16 KiB fill, leaving the query phase reading from memory; and a third with a ~30 ms margin against its own deadline. All three now usemaxChunkto force a read per packet, and the integration case selects the rows instead of counting them.Also covered: caller cancellation while the deadline is armed reports cancellation and not a timeout; a compressed connection stalling inside a block still times out, through the decoder; the handshake is not bounded by
ReadTimeout; and a second query on the same pooled connection rearms cleanly.Coverage on the changed files:
IdleReadDeadline.csandReadBuffer.cs100%,ClickHouseTcpConnection.cs95.8%,ClickHouseTcpClientOptions.cs99.4%.One existing test changed rather than added to
Validate_NonPositiveReadTimeout_ThrowsArgumentOutOfRangeExceptionasserted thatZerothrows, which is the behaviour this change reverses. It is split intoValidate_NegativeReadTimeout_...andValidate_ZeroReadTimeout_IsAccepted.Performance
The cost is two timer-queue updates per socket read — not per row, per scalar or per block. Measured over a 240 MB streaming read (30M rows, 9 rounds, deadline on vs off) the difference is not distinguishable from run-to-run variance, which is ±40% and larger than the effect in both directions. The arithmetic agrees: a few thousand reads at ~100 ns each is under 0.1% of an 0.9 s transfer. The ad-hoc probe is not committed.
A fast path that skips the timer when the read completes synchronously was considered and rejected for now: it moves
stream.ReadAsyncoutside thetry, which would silently drop transport-failure translation for a synchronous throw — a real fidelity loss to buy something unmeasurable.Not in scope
TCPHandler.cpp:993-1012sends a clean exception, then discovers inskipDatathat the leftovers are a mid-packet tail, and closes well after the client has read the exception and judged the connection fine. With no client-visible signal this is a policy choice — Go-parity "terminate on any error", which kills a session on a typo, or a Ping probe on the error path — and it gets its own change.IAsyncEnumeratorlease (PinnedConnectionSource.cs:104-110). Cancelling the token does not reach an enumerator parked at itsyield, so nothing added here can free it; the only fix is a finalizer on the lease.max_execution_timederived from it — the surviving half of the dropped M12. ACancellationTokencarries no deadline, so Go's "context deadline replaces ReadTimeout" rule has nothing to read; a per-operation deadline is new public surface and nothing here needs it. Today the two simply stack.No changelog fragment, consistent with the rest of this stack.