Expected Behavior
DelayedExecutionFlowImpl memory leak persists in 4.10.x — defuse() returns shared flow on keep-alive connections
Expected Behavior
On a long-lived HTTP/1.1 keep-alive connection, each request should use an independent ExecutionFlow. Memory usage should remain stable regardless of how many requests are served on a single connection.
Actual Behavior
DelayedExecutionFlowImpl step nodes (Map, FlatMap, OnErrorResume, OnComplete) accumulate indefinitely on keep-alive connections, growing by ~8 steps per request until OOM. This is the same symptom reported in #10677.
Heap histogram from production (Micronaut 4.10.26, ~20 million requests served):
59,821,130 io.micronaut.core.execution.DelayedExecutionFlowImpl$Map
59,821,124 io.micronaut.core.execution.DelayedExecutionFlowImpl$OnErrorResume
39,880,756 io.micronaut.core.execution.DelayedExecutionFlowImpl$FlatMap
19,940,378 io.micronaut.core.execution.DelayedExecutionFlowImpl$OnComplete
PR #11546 added cleanup of DelayedExecutionFlowImpl internal state after completion. This helps when a flow completes normally. However, in the scenario described below, the shared flow never completes, so the cleanup never runs, and step nodes accumulate without bound.
Root Cause Analysis
The root cause is in ReactorExecutionFlowImpl.defuse() (source):
// ReactorExecutionFlowImpl.defuse()
if (publisher instanceof FlowAsMono<T> flowAsMono) {
return flowAsMono.flow; // <-- returns the SAME underlying flow every time
}
When a route handler returns a reactive type (e.g., Mono<HttpResponse>), Micronaut's request pipeline converts it through:
Mono → ReactorExecutionFlowImpl → (filter chain) → FlowAsMono → defuse() → underlying flow
The problem is that on a keep-alive connection, defuse() unwraps FlowAsMono and returns the same underlying DelayedExecutionFlowImpl from a previous request. This flow was created for the first request and never completed (the completion signal was consumed by the subscriber path, not the flow path).
Each subsequent request then appends its filter chain steps to this shared flow:
Request 1: flow → Map → OnErrorResume → Map → OnErrorResume → Map → OnComplete → Map → FlatMap
Request 2: → Map → OnErrorResume → Map → OnErrorResume → Map → OnComplete → Map → FlatMap
Request 3: → Map → OnErrorResume → ...
...
Request N: chain grows by ~8 nodes per request, never GC'd until connection closes
The responses are still written correctly through the Reactor subscriber path (FlowAsMono.subscribe()), so the server functions normally — it's a silent memory leak.
Proposed Fix
In ReactorExecutionFlowImpl.defuse(), when unwrapping a FlowAsMono, only return the underlying flow if it has already completed (immutable, safe to reuse). Otherwise, fall through to the subscriber path which creates an independent flow:
if (publisher instanceof FlowAsMono<T> flowAsMono) {
ImperativeExecutionFlow<?> completed = flowAsMono.flow.tryComplete();
if (completed != null) {
return (ExecutionFlow<T>) completed;
}
// fall through to subscriber path — creates independent flow
}
This preserves the optimization for completed flows while preventing step accumulation on incomplete/shared flows.
Workaround
We added a Netty ChannelDuplexHandler that limits max requests per keep-alive connection (default 10,000). When the limit is reached, it adds Connection: close to the response, forcing the client to create a new connection and breaking the step accumulation chain.
Environment Information
- Micronaut: 4.10.26
- JDK: 21
- OS: Linux (Kubernetes)
- Application: Kestra fork, webserver mode
- Production traffic: ~78K req/s across multiple pods, keep-alive connections from nginx upstream
Actual Behaviour
No response
Steps To Reproduce
No response
Environment Information
No response
Example Application
No response
Version
4.10.26
Expected Behavior
DelayedExecutionFlowImpl memory leak persists in 4.10.x —
defuse()returns shared flow on keep-alive connectionsExpected Behavior
On a long-lived HTTP/1.1 keep-alive connection, each request should use an independent
ExecutionFlow. Memory usage should remain stable regardless of how many requests are served on a single connection.Actual Behavior
DelayedExecutionFlowImplstep nodes (Map,FlatMap,OnErrorResume,OnComplete) accumulate indefinitely on keep-alive connections, growing by ~8 steps per request until OOM. This is the same symptom reported in #10677.Heap histogram from production (Micronaut 4.10.26, ~20 million requests served):
Relationship to #10677 and PR #11546
PR #11546 added cleanup of
DelayedExecutionFlowImplinternal state after completion. This helps when a flow completes normally. However, in the scenario described below, the shared flow never completes, so the cleanup never runs, and step nodes accumulate without bound.Root Cause Analysis
The root cause is in
ReactorExecutionFlowImpl.defuse()(source):When a route handler returns a reactive type (e.g.,
Mono<HttpResponse>), Micronaut's request pipeline converts it through:The problem is that on a keep-alive connection,
defuse()unwrapsFlowAsMonoand returns the same underlyingDelayedExecutionFlowImplfrom a previous request. This flow was created for the first request and never completed (the completion signal was consumed by the subscriber path, not the flow path).Each subsequent request then appends its filter chain steps to this shared flow:
The responses are still written correctly through the Reactor subscriber path (
FlowAsMono.subscribe()), so the server functions normally — it's a silent memory leak.Proposed Fix
In
ReactorExecutionFlowImpl.defuse(), when unwrapping aFlowAsMono, only return the underlying flow if it has already completed (immutable, safe to reuse). Otherwise, fall through to the subscriber path which creates an independent flow:This preserves the optimization for completed flows while preventing step accumulation on incomplete/shared flows.
Workaround
We added a Netty
ChannelDuplexHandlerthat limits max requests per keep-alive connection (default 10,000). When the limit is reached, it addsConnection: closeto the response, forcing the client to create a new connection and breaking the step accumulation chain.Environment Information
Actual Behaviour
No response
Steps To Reproduce
No response
Environment Information
No response
Example Application
No response
Version
4.10.26