Fix clickhouse-data: make BlockingPipedOutputStream.close() idempotent - #3056
Conversation
The check of the `closed` flag and the closing handshake were not atomic, so two threads closing the same stream both put the end-of-stream marker into the queue. Once the reader stopped consuming, the second one blocked on the full queue and failed with "Close stream timed out after <n> ms". Claim the close with an AtomicBoolean so exactly one caller performs the handshake. Fixes: #3055
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 819baa5. Configure here.
Client V2 CoverageCoverage Report
Class Coverage
|
JDBC V2 CoverageCoverage Report
Class Coverage
|
JDBC V1 CoverageCoverage Report
Class Coverage
|
Client V1 CoverageCoverage Report
Class Coverage
|
…g buffer fails Flushing the pending buffer sat outside the try/finally which sets closed and runs the post close action. With the new compareAndSet claim a failing flush left the claim taken while the stream was still open, so every later close() returned immediately and the stream could never be closed. Move the flush inside the try block, matching ClickHouseOutputStream.close() and NonBlockingPipedOutputStream.close(). Fixes: #3055
TriageCategory: Summary What this impacts
Concerns
Required reviewer action
|
|
CI note: the Evidence:
The flake is a shared-connection-manager teardown race in the R2DBC test lifecycle. We have logged it separately for investigation; it is out of scope for this PR. The two |
|




Description
Fixes #3055.
BlockingPipedOutputStream.close()checked the inheritedvolatile boolean closedand then performed the closing handshake as two separate steps. Two threads that close the same stream — for example the writer thread callingout.close()while a try-with-resources block closes the same object, which is whatBlockingPipedOutputStreamTest.testPipedStreamdoes — can both pass theif (closed)guard. Both thenoffer(EMPTY_BUFFER, timeout, MILLISECONDS)the end-of-stream marker into the queue. The reader stops consuming as soon as it receives the first marker, so on a capacity-1ArrayBlockingQueuethe second caller waits out the full timeout and fails withClose stream timed out after 10000 ms;postCloseActionalso runs once per caller. That is the intermittent CI failure inclickhouse-data, which turns every downstream module red withmodule not found: com.clickhouse.data.The close is now claimed with an
AtomicBoolean.compareAndSet, so exactly one caller performs the handshake and runs the post-close action; any concurrent or repeatedclose()returns immediately.compareAndSetis used rather than makingclose()synchronized:postCloseActionmay block for an unbounded time (ClickHouseClient.getAsyncRequestOutputStreampasses an action that callslatch.await()with no timeout until the piping task finishes), so holding the instance monitor across the handshake would turn the second caller's bounded timeout into an unbounded wait.Changes
clickhouse-data/.../stream/BlockingPipedOutputStream.java: added aclosingAtomicBooleanand claim it inclose(); the losing caller returns without touching the queue. No other method or signature changed.CHANGELOG.md: bug-fix entry under0.11.0-rc1.clickhouse-data/.../stream/BlockingPipedOutputStreamTest.java: newtestConcurrentClose.The identical non-atomic guard in
NonBlockingPipedOutputStream.close()is deliberately left alone: its queue never blocks, so the failure mode is different, and it is being looked at separately rather than mixed into this fix.Test
testConcurrentClosefills the single slot of a capacity-1 stream, then releases four threads through aCyclicBarrierintoclose(). It asserts that the post-close action ran exactly once, that exactly one caller failed and only withClose stream timed out, that no extra buffer was queued, and that a laterclose()is a no-op. Onmainit fails withStream should have been closed exactly once expected [1] but found [4]; with the fix it passes.Verified against the reproduction from the issue (4-vCPU container): 8 parallel copies of
testPipedStreamwith 4 CPU burners, which fail about 33% of the time onmain, passed 8/8 with the fix. The fullBlockingPipedOutputStreamTestclass (6 tests) passes; no existing test was changed.Pre-PR validation gate
testConcurrentClosefails onmain)AGENTS.md(mvn -pl clickhouse-data test, CHANGELOG updated, no issue numbers or narrative in test code, Java 8 compatible)docs/features.mdnot affected (clickhouse-data, notclient-v2/jdbc-v2)docs/changes_checklist.mdConcurrency/behavior change inside one existing method: the change is not compatibility-sensitive — no public API, config key, default, output format or serialization is touched.
close()keeps its signature and its observable outcome for a single closer (including the timeout error it raises when the queue stays full); only the redundant second close changes, from a 10 s stall plus spurious failure to an immediate return. The new field is private and final. Idempotentclose()matches thejava.io.Closeablecontract.