[FLINK-40667][network] Fix buffer ref-count leak in recovery channel-state filtering - #29189
Conversation
rkhachatryan
left a comment
There was a problem hiding this comment.
AI-generated review — Claude Opus 5 via Claude Code
/code-review(default effort). Findings were manually verified against the source at61f4d35; treat as input, not a verdict.
The diagnosis looks right: Java evaluates buffer.retainBuffer() before segmentSerializerFor(getMappedChannels(...)) and before the dispatcher's two IllegalStateExceptions, so any of those throws leaves refCnt at 2, the finally drops it only to 1, the recycler never fires, preFilterBufferInUse stays true, and closeInternal() later free()s a segment still wrapped by a live NetworkBuffer. Real bug, right place to fix it.
Two things to address before merge (inline), plus two non-blocking notes:
- The change breaks the existing
GateFilterHandlerBufferOwnershipTest#testCloseRecyclesDeserializerHeldBufferAfterError. - The two
filterAndRewriteoverloads end up with opposite ownership contracts, undocumented. - (non-blocking, pre-existing) The
free()-under-live-buffer hazard survives on the post-transfer throw path. - (non-blocking) The new test exercises a branch that can't occur in production.
| oldSubtaskIndex, | ||
| channelInfo.getInputChannelIdx(), | ||
| buffer.retainBuffer(), | ||
| buffer, |
There was a problem hiding this comment.
Non-blocking, and pre-existing rather than introduced here: the same free()-under-live-buffer hazard survives on the post-transfer throw path.
If filterAndRewrite throws after vc.setNextBuffer (e.g. a DataOutputSerializer IOException in emitAggregated), the deserializer still holds the reference, this finally goes 2 → 1, and preFilterBufferInUse stays true. In SequentialChannelStateReaderImpl#readInputData the inner try-with-resources closes stateHandler first, so closeInternal() calls preFilterSegment.free() while the deserializer's NetworkBuffer is still live; the outer filteringHandler.close() only clears it afterwards.
Harmless today (the task is already failing and nothing reads the freed segment), but it is the same bug class this PR closes. Either swap the close order, or have closeInternal() refuse to free while preFilterBufferInUse. Fine as a follow-up.
There was a problem hiding this comment.
Agreed. Ownership stays intact here — the buffer is still recycled by close() → clear(); it's only a free-vs-recycle ordering window on the already-failing teardown path, with no leak or lasting effect. Leaving it as a follow-up.
61f4d35 to
b26675e
Compare
1996fanrui
left a comment
There was a problem hiding this comment.
Thanks @rkhachatryan for the review, all comments are addressed or replied.
rkhachatryan
left a comment
There was a problem hiding this comment.
Thanks for the PR, LGTM
What is the purpose of the change
During checkpointing-during-recovery,
SpillingWithFilteringHandler.recover()retains thepre-filter buffer at the call site, before
segmentSerializerFor()and theChannelStateFilteringHandlerdispatcher's early throws (invalid gateIndex / null gatehandler) run. Any of those throws orphans the retained reference, so its refCount never
reaches 0 and
closeInternal()laterfree()s aMemorySegmentstill wrapped by a liveNetworkBuffer.Brief change log
recover()passes the base buffer instead ofbuffer.retainBuffer().GateFilterHandler,whose
sourceBufferOwnershipTransferredguard already recycles on throw.Verifying this change
Added
InputChannelRecoveredStateHandlerTest#testPreFilterBufferRecycledWhenFilterAndRewriteThrows:asserts the pre-filter buffer is recycled when
filterAndRewritethrows. Fails before the fix,passes after.
Does this pull request potentially affect one of the following parts:
@Public(Evolving): noDocumentation