Skip to content

minor: migrate Druid from Netty 3.10 to Netty 4.1 - #19754

Open
sklochkov2 wants to merge 33 commits into
apache:masterfrom
sklochkov2:master
Open

minor: migrate Druid from Netty 3.10 to Netty 4.1#19754
sklochkov2 wants to merge 33 commits into
apache:masterfrom
sklochkov2:master

Conversation

@sklochkov2

Copy link
Copy Markdown

Description

Even though AI was used extensively to create this PR, please do not dismiss it as another case of AI slop. The patches have been battle-tested.

The netty 3.10 -> 4.1 migration code has been running in production for about 7 months - albeit as part of an earlier Druid version (31.0.2). The migration was performed as part of meeting the mandatory CVE remediation deadlines.

This PR contains the same tested patchset, adapted for druid-39-CURRENT.

The aim is zero behaviour changes compared to netty 3 code.

Co-Authored-By: Claude 4.5 (original patchset), Claude 4.8 (adapting it to druid-39-CURRENT).


Key changed/added classes in this PR

Core HTTP client (processing):

  • NettyHttpClient — ported to the Netty 4 inbound handler model (channelRead/HttpContent
    rather than messageReceived/HttpChunk); also surfaces failed DecoderResults as exceptions,
    which Netty 4 reports on the message instead of throwing.
  • NettyHttpClient.TimerReadTimeoutHandler (added) — replaces Netty's ReadTimeoutHandler,
    scheduling read timeouts on the shared HashedWheelTimer instead of the channel's event loop.
  • ChannelResourceFactory — Netty 4 Bootstrap-based pooled connection factory; the proxy
    CONNECT handshake is now explicitly bounded (it previously had no timeout and could block
    callers of NettyHttpClient#go indefinitely).
  • HttpClientInit — builds a Bootstrap + NioEventLoopGroup in place of Netty 3's
    NioClientBossPool/NioWorkerPool.
  • HttpClientPipelineFactory — now extends ChannelInitializer<SocketChannel> instead of
    implementing ChannelPipelineFactory.
  • HttpResponseHandlerbreaking API change: handleChunk now takes
    io.netty.handler.codec.http.HttpContent instead of org.jboss.netty...HttpChunk. Extensions
    implementing this interface must be updated.
  • RequestChannelBuffer/HeapChannelBufferFactory to ByteBuf/Unpooled, and Netty's
    Base64 to java.util.Base64.
  • SequenceInputStreamResponseHandlerByteBuf lifecycle handling, so retained buffers are
    released rather than leaked when the consumer is interrupted.
  • HttpClientConfig, StatusResponseHandler, BytesFullResponseHandler,
    InputStreamResponseHandler, InputStreamFullResponseHandler, StringFullResponseHandler,
    FrameFileHttpResponseHandler
    Server / RPC:
  • DirectDruidClient, DataServerResponseHandler, ServiceClientImpl, StandardRetryPolicy,
    LookupCoordinatorManager, HttpLoadQueuePeon, IgnoreHttpResponseHandler
  • WorkerHolder (indexing-service), SketchResponseHandler (multi-stage-query)
    Build / dependencies:
  • Root pom.xml (Netty 4.1 BOM, drops the netty3.version property and the io.netty:netty
    artifact, adds requireUpperBoundDeps exclusions), 15 module poms declaring the per-feature
    Netty 4 artifacts they use, and licenses.yaml.
    Tests:
  • TimerReadTimeoutHandlerTest (added) — EmbeddedChannel-based coverage of the read-timeout
    handler's scheduling, rescheduling, and teardown paths.
  • JankyServersTest — adds testSilentProxyServer, covering a proxy that accepts but never
    answers CONNECT.
  • FriendlyServersTest — mock proxy no longer self-destructs by calling Assert.fail off-thread.
    The remaining main-source files are mechanical API renames (getStatus() to status(),
    HttpHeaders.Names.* to HttpHeaderNames.*, ChannelBuffer to ByteBuf) with no behavior change.

This PR has:

  • [x ] been self-reviewed.
  • added documentation for new or modified features or behaviors.
  • a release note entry in the PR description.
  • added Javadocs for most classes and all non-trivial methods. Linked related entities via Javadoc links.
  • added or updated version, license, or notice information in licenses.yaml
  • added comments explaining the "why" and the intent of the code wherever would not be obvious for an unfamiliar reader.
  • added unit tests or modified existing tests to cover new code paths, ensuring the threshold for code coverage is met.
  • added integration tests.
  • [ x ] been tested in a test Druid cluster.

sklochkov and others added 19 commits July 20, 2026 20:09
- Pin netty4.version to 4.1.135.Final and drop the netty3.version property
- Replace the io.netty:netty (v3) artifacts with explicit netty4 modules
  (netty-common/buffer/codec-http/handler/transport) per module
- Remove obsolete direct netty3 deps (quidem-ut, ranger-security,
  rabbit-stream-indexing-service, kafka-indexing-service); keep defensive
  io.netty:netty exclusions so transitive netty3 stays off the classpath

Co-authored-by: Cursor <cursoragent@cursor.com>
Migrate the Netty HTTP client stack to io.netty 4.1: NettyHttpClient,
HttpClientInit/Config, Request, pool/ChannelResourceFactory, the pipeline
factory, all response/* handlers and holders, and FrameFileHttpResponseHandler.
ChannelBuffer->ByteBuf, HttpChunk->HttpContent, event-model and status-API
updates; content is now delivered via handleChunk. Preserves latest-only
features (e.g. TrafficCop.abort()).

Co-authored-by: Cursor <cursoragent@cursor.com>
Migrate the RPC framework (RequestBuilder/ServiceClientImpl/retry policies/
OverlordClientImpl/IgnoreHttpResponseHandler), clients (DirectDruidClient,
CoordinatorClientImpl, DataServerClient, BrokerClientImpl, MessageRelayClientImpl),
coordinator loading/sync (HttpLoadQueuePeon, ChangeRequestHttpSyncer,
LookupCoordinatorManager) and lookups to io.netty 4.1. Status/header API
updates and HttpResponseStatus.code()/reasonPhrase().

Co-authored-by: Cursor <cursoragent@cursor.com>
Migrate RemoteTaskActionClient, HttpShuffleClient, ParallelIndex* clients,
WorkerTaskRunnerQueryAdapter, hrtr/HttpRemoteTaskRunner + WorkerHolder,
SeekableStreamIndexTaskClientAsyncImpl, WorkerTaskManager, TaskRunnerUtils and
OverlordCompactionResource to io.netty 4.1 (status API + content handling).

Co-authored-by: Cursor <cursoragent@cursor.com>
Migrate sql SystemSchema/SystemServerPropertiesTable and services
CoordinatorRuleManager to io.netty 4.1 (status/content API; read body from
StringFullResponseHolder).

Co-authored-by: Cursor <cursoragent@cursor.com>
Migrate multi-stage-query (Dart clients, BaseWorkerClientImpl, IndexerController
client + SketchResponseHandler, SqlStatementResource, retry policies) and
extensions (druid-basic-security, druid-catalog, druid-kerberos incl.
RetryIfUnauthorizedResponseHandler) to io.netty 4.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
Migrate all unit tests and shared test infrastructure to io.netty 4.1.
Update TestHttpClient and MockServiceClient to mimic the real Netty pipeline
(headers via handleResponse, body via handleChunk) so content reaches handlers
without double-counting; populate response holders where built directly; migrate
DefaultHttpResponse.setContent()/getContent() overrides to
DefaultFullHttpResponse/content(); freshly migrate the embedded-tests module.

Co-authored-by: Cursor <cursoragent@cursor.com>
Pinning Netty to 4.1.x makes the managed version lower than the 4.2.7
that the test-scoped docker-java-transport-netty:3.7.0 requests, which
trips the maven-enforcer RequireUpperBoundDeps rule at the validate phase
and aborts every CI build. Exclude the five Netty artifacts docker-java
declares from that rule (the netty-bom already converges them to a single
version); enforcer 3.6.2 ignores groupId-only excludes, so each is listed
as groupId:artifactId.

Also bump netty4.version 4.1.135 -> 4.1.136 (no incompatible changes) and
reconcile the licenses.yaml Netty entry to 4.1.136, dropping the 4.2-only
codec-split modules that do not exist on the 4.1 line.

Co-authored-by: Cursor <cursoragent@cursor.com>
…nse check

Addresses the second batch of CI failures on the Netty 4.1 branch.

JDK 25 forked-VM crash (AsyncHttpClientTest):
- The mock server's accept loop ran on a non-daemon thread and busy-spun at
  100% CPU once the ServerSocket was closed (Thread.sleep() could swallow the
  interrupt, so accept() on a closed socket threw immediately in a tight loop).
  Under the CI JFR agent this flooded allocations and killed the VM.
- Fix: run the accept loop on a daemon thread and also exit when the socket is
  closed (!serverSocket.isClosed()).

JDK 25 read-timeout hang (JankyServersTest, [T*,F*,G*,J*] shard timeout):
- Netty 4's ReadTimeoutHandler schedules on the NioEventLoop, whose select()/
  epoll_wait can be interrupted by signals (e.g. the profiler agent), delaying
  or dropping scheduled timeouts on JDK 25. Read timeouts are now driven off the
  shared HashedWheelTimer (via a new TimerReadTimeoutHandler), restoring the
  Netty-3-era timer-thread semantics that are immune to the event-loop issue.
- While fixing this, found that Netty's public ReadTimeoutException(String)
  constructor trips `assert shared` (ChannelException) and throws AssertionError
  when assertions are enabled (surefire runs with -ea). That caused
  setException() to throw and left the response future uncompleted, hanging get().
  We now raise the exception via the no-arg ReadTimeoutException() constructor.

Resource-leak hardening (SequenceInputStreamResponseHandler):
- Release retained ByteBuf/close streams if the queue put is interrupted, so an
  interrupt during chunk delivery can't leak direct memory.

Deprecated Netty 4 API cleanup:
- Replace HttpResponse.getStatus() with status() in StandardRetryPolicy,
  BytesAccumulatingResponseHandler, and LookupCoordinatorManager.
- Wrap ByteBufInputStream usage in try-with-resources in RequestBuilderTest.

License check:
- Bump netty-tcnative to 2.0.78.Final in licenses.yaml to match the version
  pulled in by the Netty 4.1.136 BOM.

Co-authored-by: Cursor <cursoragent@cursor.com>
…eclare deps

Third batch of CI fixes on the Netty 4.1 branch.

Malformed-response handling (JankyServersTest.testHttpEchoServer):
- Netty 3's HTTP decoder threw during decoding of a malformed response, which
  propagated to exceptionCaught and failed the request future. Netty 4's decoder
  instead emits a message flagged with a failed DecoderResult and never throws,
  so the malformed response was processed as if valid and the caller only saw a
  later "Channel disconnected". NettyHttpClient now inspects decoderResult() on
  inbound HttpObjects and surfaces the underlying cause (e.g.
  "invalid version format: GET"), restoring the pre-Netty-4 behavior.

Deprecated-API check (HttpClientConfig):
- The worker-count default touched the deprecated JvmUtils.getRuntimeInfo() (the
  fork's thread-cap change modified that line, so the incremental deprecation
  check flagged it). Compute the default from Runtime.getRuntime()
  .availableProcessors() instead; the MAX_WORKER_THREADS cap is preserved.

Dependency analysis (druid-server):
- dependency:analyze reported netty-buffer, netty-codec-http, and netty-transport
  as used-but-undeclared. Declare them explicitly (versions come from netty-bom).

Co-authored-by: Cursor <cursoragent@cursor.com>
Fourth batch of CI fixes on the Netty 4.1 branch.

Dependency analysis (druid-processing):
- dependency:analyze reported io.netty:netty-codec as used-but-undeclared
  (it is pulled in directly, including by the DecoderResult handling added in the
  previous commit). Declare it explicitly; the version comes from netty-bom.

Diff branch-coverage:
- The branch's diff coverage fell just under the required 50% because the new
  HashedWheelTimer-based read-timeout handler (TimerReadTimeoutHandler) was only
  exercised along its "fire once" path by the existing socket-based tests; its
  reschedule-on-read, channelActive, channelRead, handler-added-while-inactive,
  and teardown paths were untested.
- Add TimerReadTimeoutHandlerTest, which drives the handler through an
  EmbeddedChannel and a real HashedWheelTimer to cover those branches
  deterministically. To enable this, TimerReadTimeoutHandler is made
  package-private (no behavior change).

Co-authored-by: Cursor <cursoragent@cursor.com>
…analyze

Netty 4 splits the former single io.netty:netty jar into per-feature
artifacts, and dependency:analyze (run with -DfailOnWarning=true) requires
each module to explicitly declare every Netty artifact it references at the
bytecode level, not just the ones it imports. For example, calling
HttpResponse#content() pulls in netty-buffer even without an io.netty.buffer
import.

Rather than fix these one CI round-trip at a time, a full-reactor
dependency:analyze pass (all 78 modules, without failOnWarning) was used to
surface every missing declaration at once. This adds the exact artifacts each
module was using transitively:

  - indexing-service: netty-buffer, netty-codec-http
  - sql: netty-codec-http
  - services: netty-codec-http
  - druid-kerberos: netty-buffer
  - kubernetes-overlord-extensions: netty-codec-http

Versions are inherited from the managed Netty BOM in core modules and follow
the existing explicit ${netty4.version}/provided pattern in the extensions.
No module had an unused Netty declaration, so nothing was removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
Resolves a single conflict in StreamIndexFaultToleranceTest: an upstream
commit added a test that imports HttpMethod (as the Netty 3
org.jboss.netty.handler.codec.http.HttpMethod) plus org.hamcrest.Matchers,
while this branch had already migrated the file's import to the Netty 4
io.netty.handler.codec.http.HttpMethod. Kept the Netty 4 import and the new
Matchers import, dropped the reintroduced Netty 3 import.

Verified the merged tree contains no remaining org.jboss.netty references in
sources or Netty 3 declarations in poms, and that the root pom still pins
netty4.version 4.1.136.Final with the enforcer exclusions intact.

Co-authored-by: Cursor <cursoragent@cursor.com>
…hang

FriendlyServersTest repeatedly consumed the unit-test job's entire 60 minute
budget rather than failing. The hang reproduces locally and deterministically
when AsyncHttpClientTest runs before FriendlyServersTest in the same fork; a
thread dump of the stuck JVM shows the main thread parked in

  NettyHttpClient.go(NettyHttpClient.java:142)  <- awaitUninterruptibly()
  FriendlyServersTest.testFriendlyProxyHttpServer(...:171)

waiting on the proxy CONNECT promise, while all eight Netty worker threads sit
idle in EPoll.wait having used ~16ms of CPU over 83s. There is no pending I/O,
so this is not the event-loop wakeup problem that the read-timeout work in this
branch dealt with; it is an unbounded wait.

Two defects combine to produce it.

The product defect is in ChannelResourceFactory: the proxy CONNECT exchange is
an ordinary application-level request/response and nothing bounds it. The SSL
path immediately below it calls setHandshakeTimeoutMillis, but the CONNECT path
has no equivalent, and ChannelOption.CONNECT_TIMEOUT_MILLIS only covers the TCP
connect, which succeeds. A proxy that accepts the connection but never replies
therefore leaves proxyConnectPromise uncompleted forever, and because
NettyHttpClient#go waits on it uninterruptibly the caller can never recover.
This is a production hazard too, not just a test artifact: any Druid process
talking through a wedged HTTP proxy could park a thread permanently.

The test defect is that the mock proxy called Assert.fail() from a background
thread. The AssertionError it throws is not an Exception, so it escaped the
accept loop and died inside a Future nobody inspects, silently stopping the
server. The ServerSocket stayed open, so the client's connect still succeeded
into the listen backlog and simply never received an answer.

Fixes:

  - ChannelResourceFactory bounds the CONNECT handshake at 10s, mirroring the
    adjacent SSL handshake timeout. The deadline is tracked on the shared
    HashedWheelTimer rather than the channel's event loop, consistent with the
    read-timeout handling earlier in this branch, and is cancelled as soon as
    the promise completes.
  - The mock proxy records failures instead of calling Assert.fail, keeps
    serving (a pooled client may legitimately open and drop connections), and
    its readLine loops now handle EOF instead of spinning or throwing NPE. A
    recorded server-side error is attached as a suppressed exception when the
    request fails, so the informative error is not lost.
  - JankyServersTest gains testSilentProxyServer, pointing the client at the
    existing silent server, and the proxy test gets @test(timeout = 60_000L) so
    a regression fails the test instead of the whole CI job.

Attribution was verified rather than assumed. With the original test but the new
timeout, the test fails in 10.1s with "Timed out after [10,000] ms waiting for a
CONNECT response from proxy[anotherHost:8080]", confirming both that the timeout
works and that the server was dying before answering CONNECT. With both changes
the previously always-hanging combination passed 10/10 runs, and the full
org.apache.druid.java.util.http.client package is 49/49 green on JDK 25 with no
checkstyle or forbidden-apis violations.

Co-authored-by: Cursor <cursoragent@cursor.com>
java.net.URL's string constructor has been deprecated since Java 20, and this
branch now compiles at release 25, so the two call sites added by the previous
commit produce deprecation warnings. Replace them with
URI.create(...).toURL(), which is the documented replacement and performs the
same RFC 2396 parsing.

Only the lines introduced by the previous commit are changed. The surrounding
tests still use new URL(...) in places this branch never touched, so URL
remains imported; converting those is left to a separate cleanup rather than
mixing unrelated churn into the CONNECT-timeout fix.

Co-authored-by: Cursor <cursoragent@cursor.com>
Now that the loop conditions null-check the line first, the null-safe
"".equals(line) idiom buys nothing, so check the string directly. Prefer
isEmpty() over length() != 0: it states the intent, and it is what the rest of
the codebase uses (303 files under processing/src use isEmpty(), none use
length() != 0).

Co-authored-by: Cursor <cursoragent@cursor.com>
Migrate the Druid codebase to Netty 4.1

@FrankChen021 FrankChen021 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity Findings
P0 0
P1 2
P2 1
P3 0
Total 3
Severity Findings
P0 0
P1 2
P2 1
P3 0
Total 3

Reviewed 167 of 167 changed files.


This is an automated review by Codex GPT-5.6-Sol

Findings that could not be attached inline:

  • processing/src/main/java/org/apache/druid/java/util/http/client/NettyHttpClient.java:327 - [P2] Release inbound messages when handlers throw. Normal branches release msg, but handler, parsing, or limit exceptions jump here first. ChannelInboundHandlerAdapter does not auto-release, so exceptional responses leak their pooled buffer. Release the inbound message in a finally block.

{
final int length = buffer.readableBytes();
return new InputStreamHolder(new ChannelBufferInputStream(buffer), chunkNum, length);
buffer.retain();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Release retained chunks when abandoning streams

fromByteBuf retains every buffer, but query close/error paths discard queued holders with queue.clear() without closing them. Repeated cancelled or failed queries can therefore exhaust pooled direct memory. Drain and close queued and current streams before clearing them.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed this in a subsequent commit.

method,
urlFile.isEmpty() ? "/" : urlFile
urlFile.isEmpty() ? "/" : urlFile,
request.hasContent() ? request.getContent() : Unpooled.EMPTY_BUFFER

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Preserve request bodies for Kerberos retries

DefaultFullHttpRequest takes ownership of this buffer and Netty releases it after encoding. A subsequent Kerberos 401 retry calls request.copy() on the released buffer, causing IllegalReferenceCountException for requests with bodies. Use independent outbound content or non-reference-counted body storage.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed this in a subsequent commit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks—the retry itself now preserves the bytes, but buffer ownership is still unresolved. retainedDuplicate() lets Netty release only its duplicate, leaving Request.content at refCnt == 1 after every send. Request has no close/release lifecycle, and Request.copy() creates another independently reference-counted buffer for each Kerberos retry, so pooled/direct request bodies can remain allocated indefinitely. This also contradicts the existing Request#setContent(byte[]) comment that the body is released after the write. Please store the reusable body in non-reference-counted form and create an outbound buffer per attempt, or add an explicit lifecycle that releases the original and retry copies after the final attempt.

Reviewed 167 of 167 files in the supplied full diff.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good find, pushed a fix.

@FrankChen021

Copy link
Copy Markdown
Member

there's already a PR doing the same thing: #19567
cc @clintropolis

@sklochkov2

Copy link
Copy Markdown
Author

there's already a PR doing the same thing: #19567 cc @clintropolis

Yes, I saw that. Decided to post this patchset for review anyway because it has been running on production druid clusters for a while (albeit being applied on top of version 31.0.2) so the risk may be somewhat lowered, what do you think?

sklochkov and others added 5 commits July 27, 2026 15:14
NettyHttpClient handed request.getContent() straight to DefaultFullHttpRequest.
That gives Netty ownership of the caller's buffer: HttpObjectEncoder extends
MessageToMessageEncoder, which releases whatever it encodes, and the socket
write advances the buffer's reader index. After a single send the caller was
therefore left holding a released, fully consumed buffer.

Callers do reuse a Request. KerberosHttpClient resends request.copy() after a
401, and ClientUtils copies a request's content to retarget it at another
server; both call ByteBuf.copy(), which throws IllegalReferenceCountException
once refCnt has reached zero. Even if it had not thrown, the consumed reader
index means the retry would have sent an empty body.

Pass a retainedDuplicate() instead. It shares the bytes but carries its own
reader/writer indices, so Netty's release balances our retain and the caller's
buffer is left untouched and resendable.

The new FriendlyServersTest case fails without this change (refCnt 0 rather
than 1 after sending) and covers the retry itself, asserting that a resent
request.copy() still carries its body.

Co-authored-by: Cursor <cursoragent@cursor.com>
DirectDruidClient buffers response chunks as InputStreamHolders, and
InputStreamHolder.fromByteBuf retains the Netty buffer it wraps, handing it to a
ByteBufInputStream that releases it only when closed. That retain is what keeps
the bytes alive past NettyHttpClient, which releases each inbound HttpContent as
soon as handleChunk returns.

Both teardown paths discarded those holders with queue.clear(), which drops the
references without closing them, so the retained buffers were never released.
Every cancelled or failed query leaked its buffered chunks, and since these come
from socket reads they are pooled direct memory. Under Netty 3 the equivalent
buffers were GC'd heap buffers, so clearing was harmless; refcounting makes it a
leak. The pre-existing comment claiming the buffers "can be released" here was
describing the intent, not the behavior.

Drain and close instead, in three places:

  - The early-close path now closes each queued chunk. It also drains when done
    is already set, instead of returning first: a response that finished but was
    never fully read left its remaining chunks buffered.
  - The current chunk (already handed to SequenceInputStream, which only closes
    a stream when it advances to the next one) is closed too. This is done only
    from close(), on the consumer's own thread, since another thread may still
    be reading it. Double-closing is safe: ByteBufInputStream.close() is
    idempotent in Netty 4.1.
  - enqueue() re-checks discard after queueing, closing the race where close()
    drains just after a chunk passed the initial check.

The failure path only drains the queue; it must not close the current chunk,
because the consumer may still be reading it and will reach the error holder
next.

The new test drives the response handler directly and fails without this change
(refCnt 2 rather than 1 after abandoning an unread chunk).

Co-authored-by: Cursor <cursoragent@cursor.com>
channelRead released msg at the end of each branch, so any exception thrown
while processing it skipped straight to the catch block with the message still
unreleased. ChannelInboundHandlerAdapter does not release inbound messages for
us (unlike SimpleChannelInboundHandler), so the pooled buffer leaked.

This is reachable from ordinary operation rather than only from corrupt input:
handleResponse and handleChunk are where DirectDruidClient enforces its limits,
so a query exceeding maxScatterGatherBytes or hitting its timeout leaks the
buffer that tripped it.

Move the release into a finally block and drop the per-branch calls, leaving
exactly one release that covers the early returns, the unknown-message-type
throw, and anything a handler throws. Handlers are already expected to copy or
retain whatever they still need, which is what the previous per-branch releases
assumed too, so this does not change the ownership contract.

Also adds a test for a handler that throws mid-response, which was previously
uncovered, asserting the request settles instead of hanging.

Co-authored-by: Cursor <cursoragent@cursor.com>
Release the inbound message even when a response handler throws
@sklochkov2

Copy link
Copy Markdown
Author
  • processing/src/main/java/org/apache/druid/java/util/http/client/NettyHttpClient.java:327 - [P2] Release inbound messages when handlers throw. Normal branches release msg, but handler, parsing, or limit exceptions jump here first. ChannelInboundHandlerAdapter does not auto-release, so exceptional responses leak their pooled buffer. Release the inbound message in a finally block.

Addressed this one as well.

pipeline.addLast("inflater", new HttpContentDecompressor());

return pipeline;
pipeline.addLast("inflater", new HttpContentDecompressor(false));

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed this in a subsequent commit.

asyncRequest,
holder -> {
if (holder.getResponse().getStatus().getCode() == HttpStatus.NO_CONTENT_204) {
if (holder.getResponse().getStatus().code() == HttpStatus.NO_CONTENT_204) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed this in a subsequent commit.

Two kinds of deprecation warning, both introduced by the move to Netty 4 rather
than by anything that was already wrong.

HttpResponse.getStatus() is the Netty 3 spelling; Netty 4 deprecated it in
favour of status(). Most call sites were converted along with the rest of their
files, but five were missed because swapping the org.jboss.netty import for
io.netty leaves the call itself compiling unchanged. Two are in files this
branch otherwise rewrote (MessageRelayClientImpl, DataServerResponseHandler);
in SpecificTaskRetryPolicy the import swap is the only change to the file, which
is why a line-level review does not flag them.

HttpContentDecompressor deprecates both its no-argument and its boolean
constructor in 4.1.136, keeping the (int) and (boolean, int) overloads. The
added argument is maxAllocation, a cap on the decompression buffer, and the
deprecated constructor delegates to this(strict, 0), so passing 0 explicitly
preserves the uncapped behaviour exactly. A bare 0 reads as arbitrary, so the
call carries a comment saying what it means.

Neither change affects behaviour and no tests are added; the existing coverage
for these paths (MessageRelayClientImplTest, DataServerClientTest,
SpecificTaskServiceLocatorTest, and the http client suites that build this
pipeline) passes unchanged.

This clears every Netty deprecation in the code this branch touches. One
remains in HttpEmitterModule, whose JdkSslContext constructor is deprecated in
4.2 as well, so pinning 4.1 did not cause it; that file is untouched here and
left alone.

Co-authored-by: Cursor <cursoragent@cursor.com>

@FrankChen021 FrankChen021 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity Findings
P0 0
P1 2
P2 0
P3 0
Total 2

The queued-stream cleanup in DirectDruidClient fixes the first follow-up. The Kerberos retry now preserves bytes but still needs an ownership fix, so I replied inline. Two additional P1 regressions remain in retained data-server chunk cleanup and chunked lookup-response accumulation.

Reviewed 167 of 167 files in the supplied full diff.


This is an automated review by Codex GPT-5.6-Sol

// Increment queuedByteCount before queueing the object, so queuedByteCount is at least as high as
// the actual number of queued bytes at any particular time.
final InputStreamHolder holder = InputStreamHolder.fromChannelBuffer(buffer, chunkNum);
final InputStreamHolder holder = InputStreamHolder.fromByteBuf(buffer, chunkNum);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Close retained data-server chunks before clearing the queue

InputStreamHolder.fromByteBuf now retains every inbound buffer, but setupResponseReadFailure still discards queued holders with queue.clear(). Query timeouts and transport failures therefore drop the only objects capable of closing those streams after NettyHttpClient releases its own inbound references, leaking pooled direct memory. Drain and close every queued holder here, as the updated DirectDruidClient now does.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed this in a subsequent commit.

return null;
}
BytesFullResponseHolder holder = delegateResponse.getObj();
InputStream stream = holder == null ? null : new ByteArrayInputStream(holder.getContent());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Avoid copying the accumulated lookup response on every chunk

toInputStream is called after every handleChunk, and holder.getContent() concatenates every chunk received so far into a new byte array each time. A response split into n chunks therefore performs quadratic copying and temporary allocation; large lookup-state responses can cause extreme GC pressure or OOM. Preserve the previous streaming handler, or construct the combined byte array only once from done().

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed this in a subsequent commit.

sklochkov and others added 6 commits July 28, 2026 15:59
Resolves one conflict in the root pom, where four version properties sit on
adjacent lines and three unrelated changes landed on them. Each side keeps what
it owns: this branch deletes netty3.version and pins netty4.version to
4.1.136.Final, upstream bumps postgresql to 42.7.12 and protobuf to 4.33.0.
Nothing else in the range touched Netty, and no org.jboss.netty usage was
reintroduced.

The merge does require widening the enforcer exclusion, though, because it
brings async-http-client 3.0.2 -> 3.0.10. The older release built against Netty
4.1.119, so our 4.1.136 pin was an upgrade and only a few test-scoped artifacts
tripped RequireUpperBoundDeps. 3.0.10 builds against 4.2.13, so the pin is now a
downgrade for every artifact async-http-client touches, and seven more started
failing the check.

That is worth more than a suppression, since async-http-client is production
code: HttpEmitterModule builds a DefaultAsyncHttpClient for the HTTP emitter. I
checked that 3.0.10 still links against 4.1.136 by constructing the client the
way HttpEmitterModule does, including the JdkSslContext branch, and driving a
GET and a POST through it against a local server. Both complete normally, so the
pin is safe and the check is only bookkeeping.

The exclusion now names every io.netty artifact in the dependency graph rather
than just the ones failing today. The rule rejects wildcards (io.netty:* matches
nothing, silently un-excluding what the explicit list covered), and the netty-bom
already converges the whole group to one version, so the check has nothing left
to catch here. Listing the group in full keeps the next upstream bump of a
Netty-based dependency from reopening this. The block goes away with the 4.2
upgrade.

Co-authored-by: Cursor <cursoragent@cursor.com>
Same leak as the one fixed in DirectDruidClient, in the other handler that
buffers InputStreamHolders. fromByteBuf retains the Netty buffer and hands it to
a ByteBufInputStream that releases it only on close, so setupResponseReadFailure
dropping the holders with queue.clear() left those retains stranded. Every query
that timed out or hit a transport error leaked its buffered chunks, and since
they come from socket reads that is pooled direct memory.

Drain and close instead. Only the queued chunks are closed, never the one the
consumer already dequeued, because that one may still be being read on another
thread. This is the same split DirectDruidClient's failure path uses.

The new tests cover both routes into the teardown, exceptionCaught and an
elapsed query timeout; both fail without this change with the chunk still at
refCnt 2. They also cover the FullHttpResponse case in handleResponse, where the
body arrives with the headers rather than in a later chunk.

Co-authored-by: Cursor <cursoragent@cursor.com>
This migration had replaced LookupCoordinatorManager's streaming response
handler with one that wraps BytesFullResponseHandler and rebuilds an
InputStream after each chunk. BytesFullResponseHolder.getContent() concatenates
every chunk received so far into a fresh array, so an n-chunk response copied
its accumulated body n times and produced n throwaway arrays. Lookup state
responses grow with the number of lookups, so this is exactly the response that
should not be copied quadratically.

Restore the original SequenceInputStreamResponseHandler, which streams chunks
through untouched. That also puts the file back to a plain Netty 3 -> 4
translation of upstream's code.

Doing so requires fixing that handler first, which is why the two changes are
together. SequenceInputStream's constructor eagerly pulls the first element of
its Enumeration, and this Enumeration's nextElement() blocks on queue.take().
Under Netty 3 the queue was never empty at that point, because getContent()
always returned a buffer, empty or not. Netty 4 splits headers from body, so a
plain HttpResponse carries no content, nothing was queued, and handleResponse
blocked forever on the Netty I/O thread that was supposed to deliver the chunks
that would unblock it. Queue an empty stream in that case, restoring the
invariant the Netty 3 code relied on.

That deadlock is why simply reverting to the streaming handler was not enough,
and likely why it was swapped out during the migration rather than translated.
LookupCoordinatorManager is its only production caller, so nothing else was
affected in the meantime.

The existing tests here all stub out makeResponseHandler, which is how the real
one went uncovered. The added test drives it: it asserts the status is recorded,
that the handler still streams rather than buffers, and that a chunked body
comes back intact. It hangs against the unfixed handler, so it carries a timeout
rather than stalling CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
The diff coverage check failed at 33% branch coverage, all of it from this
class. Swapping the deprecated HttpResponse.getStatus() for status() touched the
two lines of isTaskMismatch, which pulled four already-uncovered branches into
the diff; the class had no test at all.

The behaviour is worth pinning down regardless: a 400 or 404 carrying another
task's id means this task moved and something else took its port, so the request
should be retried rather than surfaced as a failure. The test covers both status
codes, our own id (including the URL-encoded form ids are actually sent in), a
missing id header, an unrelated status, and that the base policy still decides
everything else.

Takes the two changed lines to full line and branch coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This lists group IDs that extension authors should mark provided to avoid
colliding with the versions Druid bundles. Druid no longer ships org.jboss.netty
after this migration, so that entry is stale and io.netty is what now needs the
provided scope.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the Netty 4 APIs this migration left deprecated
sklochkov and others added 2 commits July 28, 2026 22:23
Request has no close or release step, yet since this migration it held a
ByteBuf, so nothing in the class could say who owned the body. Sending handed
that buffer to Netty, whose HttpObjectEncoder releases whatever it encodes,
which broke the Kerberos 401 retry. The earlier fix for that passed a retained
duplicate instead, leaving the caller's buffer at refCnt 1 after every send with
nothing able to release it. Neither arrangement is a lifecycle.

Before the migration the body was a ChannelBuffer from HeapChannelBufferFactory.
ChannelBuffer has no reference counting at all, so there was nothing to own and
no lifecycle to get wrong. Restore that: Request holds a byte[], and
NettyHttpClient wraps a fresh outbound buffer around those bytes for each
attempt, which Netty is then free to release. copy() can share the array,
since nothing writes to a body in place; KerberosHttpClient only rewrites the
Cookie header before retrying, and ClientUtils only changes the URL. Retries
therefore stop duplicating the body as well.

To be clear about the blast radius: nothing was leaking. Every caller already
supplied a byte[], which setContent wrapped with Unpooled.wrappedBuffer, so the
bodies were unpooled heap buffers that release nothing and are reclaimed by GC.
No pooled or direct memory was ever reachable through a Request. Dropping the
two ByteBuf setContent overloads, whose only caller was ClientUtils, means none
can be from now on either.

getContent() returning byte[] is not an extra break for extensions: it returned
org.jboss.netty.buffer.ChannelBuffer before this migration, so the signature was
already changing.

The retry test asserted on refCnt and readableBytes, which no longer describe
anything; it now compares the body bytes after sending, which is the invariant
it was always about.

Co-authored-by: Cursor <cursoragent@cursor.com>
Hold request bodies as plain bytes rather than a Netty buffer
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.

4 participants