Skip to content

DelayedExecutionFlowImpl memory leak persists in 4.10.x — defuse() returns shared flow on keep-alive connections #12940

Description

@dualc

Expected Behavior

DelayedExecutionFlowImpl memory leak persists in 4.10.x — defuse() returns shared flow on keep-alive connections

Image

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

Relationship to #10677 and PR #11546

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions