Skip to content

Memory Leak Observed: SentryScheduleHook + Reactor Kafka Interaction #5051

@harrypennont

Description

@harrypennont

Integration

sentry-spring-boot-starter-jakarta

Java Version

21

Version

8.29.0

Steps to Reproduce

Summary

We observed a memory leak when using Sentry Java SDK with Reactor Kafka. The leak occurs even when idle (no messages), leading to OutOfMemoryError. The issue appears to stem from an interaction between the two libraries rather than either one alone.

Versions tested: Sentry 8.29.0 and 8.30.0 (same behavior)

Environment

  • Java: OpenJDK 21.0.1 LTS
  • Spring Boot: 3.5.9
  • Reactor Kafka: 1.3.25
  • OS: macOS

Our Analysis

Based on our investigation, we believe the issue arises from the following interaction:

The Mechanism

Reactor Kafka's ConsumerEventLoop.PollEvent reschedules itself infinitely on a dedicated single-threaded scheduler:

// reactor-kafka ConsumerEventLoop.java:310-412
class PollEvent implements Runnable {
    public void run() {
        consumer.poll(pollTimeout);
        if (isActive.get()) {
            schedule();  // Reschedules itself infinitely
        }
    }
    void schedule() {
        eventScheduler.schedule(this);  // Triggers SentryScheduleHook
    }
}

Each schedule() call triggers SentryScheduleHook.apply(), which creates a forked scope with a parent reference:

// SentryScheduleHook.java
public Runnable apply(Runnable runnable) {
    final IScopes newScopes = Sentry.getCurrentScopes().forkedCurrentScope("spring.scheduleHook");
    return () -> { /* ... */ };
}

// Scopes.java:119 - each fork captures the parent
public IScopes forkedCurrentScope(String creator) {
    return new Scopes(scope.clone(), isolationScope, globalScope, this, creator);
    //                                                            ^^^^ parent reference
}

Result: The dedicated scheduler thread is a permanent GC root, retaining an unbounded chain of parentScopes.

Why We Observed This Only with Reactor Kafka

We tested with Flux.interval() and other standard Reactor operations - no leak occurred. These use pooled schedulers where tasks complete and threads return to the pool, allowing scopes to be garbage collected.

Reactor Kafka differs by using a dedicated single-threaded scheduler (Schedulers.newSingle()) that:

  • Never terminates while the receiver is active
  • Reschedules the same PollEvent infinitely
  • Acts as a permanent GC root retaining all forked scopes

GC Root Path (from heap dump)

Thread "reactive-kafka-{groupId}-1" [permanent GC root]
  └─ SchedulerTask (82,915 instances after 17 min)
     └─ ReentrantLock$NonfairSync
        └─ io.sentry.Scope
           └─ io.sentry.Scopes
              └─ parentScopes → Scopes → parentScopes → ... (unbounded chain)

Test Results

Scenario Duration ReentrantLock Growth Rate/min Heap Growth
App + Sentry 8.29.0 19 min +84,349 ~4,439 +34 MB
Minimal repro + Sentry 17 min +69,758 ~4,103 +30 MB
App without Sentry (*) 15 min 0 0 0 MB

(*) Manual GC performed before heap dumps to eliminate transient objects.

Reproduction

A minimal Spring Boot application is provided in the attached ZIP. See README.md for setup instructions.

./run-test.sh  # Interactive test runner with 3 scenarios

Workaround

We disable SentryScheduleHook after Sentry initializes (see SentryWorkaroundConfiguration.kt):

@EventListener(ApplicationReadyEvent::class)  // Must run AFTER Sentry's ApplicationRunner
fun disableSentryScheduleHook() {
    Schedulers.onScheduleHook("sentry") { runnable -> runnable }
}

Trade-off: Context propagation in scheduler tasks is disabled, but error reporting continues to work.


Sentry Version: 8.29.0, 8.30.0
Reactor Kafka Version: 1.3.25
Spring Boot Version: 3.5.9


We hope this analysis is helpful. We may have misunderstood something, so please let us know if you need additional information or clarification. Happy to provide heap dumps or run additional tests if needed.

sentry-reactor-kafka-leak-reproduction.zip

Expected Result

Expected: Memory should remain stable when the application is idle with no Kafka messages.

Actual Result

Actual: Continuous growth of these objects (observable in heap dumps):

  • java.util.concurrent.locks.ReentrantLock$NonfairSync
  • io.sentry.Scope / io.sentry.Scopes
  • io.sentry.PropagationContext

Metadata

Metadata

Assignees

No one assigned

    Projects

    Status

    Waiting for: Product Owner

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions