[
https://issues.apache.org/jira/browse/CASSANDRA-21766?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18128281#comment-18128281
]
Jaydeepkumar Chovatia edited comment on CASSANDRA-21766 at 10/17/26 11:49 PM:
------------------------------------------------------------------------------
Update: the V5 (framed) path appears to have the same class of leak, with a
different buffer. This is from code inspection of
CQLMessageHandler/Flusher/FrameEncoder; I have not reproduced it yet.
On V5 the response Envelope itself is not leaked:
Flusher.processFramedResponse stages it in a ```FlushBuffer,
FlushBuffer.finish()``` copies it into a `FrameEncoder.Payload`, and
`flushWrittenChannels()` then calls `item.release() ->
CQLMessageHandler.release ->
responseEnvelope.release() unconditionally`. However, the Payload wraps a
BufferPool off-heap buffer (`FrameEncoder.Payload constructor,
bufferPool.getAtLeast(..., OFF_HEAP)`) and is a plain class (not
ReferenceCounted). Its release depends on FrameEncoder.write()
running: FrameEncoder.encode() takes ownership of the buffer and wraps it so
it returns to the pool on release. Flusher.writeAndFlush() calls
channel.writeAndFlush(payload, voidPromise) with no isOpen()/isActive() check.
If the client disconnects while the
request is still on a SEP worker, the channel is closed and the pipeline
destroyed (FrameEncoder removed) before the Flusher runs on the event loop. The
Payload then reaches Netty's head with outboundBuffer == null,
AbstractUnsafe.write() calls
ReferenceCountUtil.release(payload), which is a no-op for a
non-ReferenceCounted object, and bufferPool.put(buffer) is never called. Only
the unused tail has been returned (Payload.finish() -> putUnusedPortion), so
the used region of the BufferPool slice stays
allocated.
Differences from the pre-V5 case: the leaked memory is a BufferPool slice
rather than a Netty-pooled ByteBuf, so it is not visible to Netty's
ResourceLeakDetector, and BufferPool chunks are allocated via
ByteBuffer.allocateDirect so they would be counted in the
JMX direct-memory metric. I have not verified how BufferPool reclaims or
bounds unreturned slices (the LocalPoolRef cleaner only helps if the owning
LocalPool becomes unreachable, and the Payload is allocated on the event loop
thread).
was (Author: [email protected]):
Update: the V5 (framed) path appears to have the same class of leak, with a
different buffer. This is from code inspection of
CQLMessageHandler/Flusher/FrameEncoder; I have not reproduced it yet.
On V5 the response Envelope itself is not leaked:
Flusher.processFramedResponse stages it in a `FlushBuffer,
FlushBuffer.finish()` copies it into a `FrameEncoder.Payload`, and
`flushWrittenChannels()` then calls `item.release() ->
CQLMessageHandler.release ->
responseEnvelope.release() unconditionally`. However, the Payload wraps a
BufferPool off-heap buffer (`FrameEncoder.Payload constructor,
bufferPool.getAtLeast(..., OFF_HEAP)`) and is a plain class (not
ReferenceCounted). Its release depends on FrameEncoder.write()
running: FrameEncoder.encode() takes ownership of the buffer and wraps it so
it returns to the pool on release. Flusher.writeAndFlush() calls
channel.writeAndFlush(payload, voidPromise) with no isOpen()/isActive() check.
If the client disconnects while the
request is still on a SEP worker, the channel is closed and the pipeline
destroyed (FrameEncoder removed) before the Flusher runs on the event loop. The
Payload then reaches Netty's head with outboundBuffer == null,
AbstractUnsafe.write() calls
ReferenceCountUtil.release(payload), which is a no-op for a
non-ReferenceCounted object, and bufferPool.put(buffer) is never called. Only
the unused tail has been returned (Payload.finish() -> putUnusedPortion), so
the used region of the BufferPool slice stays
allocated.
Differences from the pre-V5 case: the leaked memory is a BufferPool slice
rather than a Netty-pooled ByteBuf, so it is not visible to Netty's
ResourceLeakDetector, and BufferPool chunks are allocated via
ByteBuffer.allocateDirect so they would be counted in the
JMX direct-memory metric. I have not verified how BufferPool reclaims or
bounds unreturned slices (the LocalPoolRef cleaner only helps if the owning
LocalPool becomes unreachable, and the Payload is allocated on the event loop
thread).
> PreV5Handlers.toFlushItem may leak memory on disconnected channel
> -----------------------------------------------------------------
>
> Key: CASSANDRA-21766
> URL: https://issues.apache.org/jira/browse/CASSANDRA-21766
> Project: Apache Cassandra
> Issue Type: Bug
> Reporter: Yuqi Yan
> Priority: Normal
> Attachments: image-2026-10-16-15-58-48-345.png
>
> Time Spent: 10m
> Remaining Estimate: 0h
>
> We patch the recent fix CASSANDRA-21508 to our Cassandra 4.1 branch and
> seeing a rise in off heap memory usage that correlates with the deployment:
> !image-2026-10-16-15-58-48-345.png|width=390,height=180!
> We spent some time to investigate that and it seems that CASSANDRA-21508
> introduced a Netty memory leaking scenario when the connection is destroyed
> when there is still query in flight. This seems only affecting PreV5
> protocols.
> * CASSANDRA-21508 moved response encoding for native protocol V4 and earlier
> off the Netty pipeline (PreV5Handlers.ProtocolEncoder) and onto the request
> worker.
> ** PreV5Handlers.LegacyDispatchHandler.toFlushItem now calls
> {*}response.encode(...){*}, which allocates the response body as a pooled
> direct ByteBuf (CBUtil.allocator) before the item reaches the Flusher.
> * Flusher.processUnframedResponse then calls channel.write(responseEnvelope,
> channel.voidPromise()). Envelope does not implement ReferenceCounted.
> ** Releasing its body depends on Envelope.Encoder in the pipeline turning it
> into a ByteBuf that Netty owns.
> * Suppose the client disconnects while a request is in flight. By the time
> the Flusher task runs on the event loop, the channel can be closed and
> deregistered. On channelUnregistered, DefaultChannelPipeline removes all
> handlers (HeadContext.channelUnregistered → destroy()).
> * The write then skips Envelope.Encoder and goes straight to the head
> context and AbstractUnsafe.write. There outboundBuffer is null, so Netty
> calls ReferenceCountUtil.release(msg), which does nothing for a
> non-ReferenceCounted Envelope. Because the write uses the void promise, the
> failure is silent.
> * LegacyDispatchHandler.releaseItem releases only the request envelope. Its
> comment still says the pipeline releases the response, which stopped being
> true with CASSANDRA-21508. Nothing else holds the response body, so it leaks.
> Note : the above summary is generated with help from AI, but I myself read
> through the code path carefully.
> In my own word, pre 21508, the byte buffer was allocated and freed inside the
> event loop task, and since destroying the connection and the pipelines also
> happen in the event loop, the buffer lifecycle is handled correctly. Post
> 21508, the buffer is allocated outside of event loop and there is no check on
> the channel status before attempting to write into the
> already-destroyed-pipeline, causing the leak.
>
> From the existing comment it says
> {code:java}
> private void releaseItem(Flusher.FlushItem<Message.Response> item)
> {
> // Note: in contrast to the equivalent for V5 protocol,
> CQLMessageHandler::release(FlushItem item),
> // this does not release the FlushItem's Message.Response. In V4, the
> buffers for the response's body
> // and serialised header are emitted directly down the Netty pipeline
> from Envelope.Encoder, so
> // releasing them is handled by the pipeline itself.
> long itemSize = item.request.header.bodySizeInBytes;
> item.request.release(); {code}
> But when the pipeline is destroyed, no one will be responsible to free the
> buffers for the response's body allocated outside of the event loop
> I have a simple unit test that can reproduce this leak, will attach in link
> soon.
> As for the fix, a naive thinking will be checking the channel status before
> trying to flush any item there, i.e.
> {code:java}
> private void processUnframedResponse(FlushItem.Unframed flush)
> {
> if (!flush.channel.isOpen())
> {
> flush.responseEnvelope.release();
> return;
> }
> flush.channel.write(flush.responseEnvelope, flush.channel.voidPromise());
> channels.add(flush.channel);
> }{code}
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]