At commit 8e3846991d30a9a577dae9664996c036895f04cc in the Spring Framework, the NoTransactionInContextException
was made a static instance. While this change can reduce overhead in non-reactive contexts, it introduces a potential issue in reactive pipelines: it can cause OutOfMemoryError
(OOM) due to suppressed exceptions being accumulated.
In the Reactor Core implementation, as seen in the file FluxOnAssembly.java, the problem is appears at line 612:
Here, the NoTransactionInContextException
is reused statically, and when it is thrown inside a reactive pipeline, it retains references to OnAssemblyException
instance. This can lead to suppressed exceptions being stored indefinitely, creating a memory leak.
The behavior can be reproduced with the following Kotlin code:
/**
* Stackless variant of [NoTransactionException] for reactive flows.
*/
private class NoTransactionInContextException : NoTransactionException("No transaction in context") {
@Synchronized
override fun fillInStackTrace(): Throwable {
// Stackless exception
return this
}
}
private val NO_TRANSACTION_IN_CONTEXT_EXCEPTION = NoTransactionInContextException()
fun main() {
repeat(1000) {
runCatching {
Flux.fromIterable((1..2))
.flatMap<Unit> { Mono.error(NO_TRANSACTION_IN_CONTEXT_EXCEPTION) }
.blockLast()
}
}
System.gc()
}
Explanation of the Issue:
-
Each time the static
NO_TRANSACTION_IN_CONTEXT_EXCEPTION
is thrown within a reactive flow, theOnAssemblyException
created by Reactor is added to the suppressed exceptions of NO_TRANSACTION_IN_CONTEXT_EXCEPTION. -
Since the exception is static, it retains all these references, causing memory consumption to grow unbounded.
-
This pattern can quickly lead to an OutOfMemo`ryError, especially in high-throughput reactive applications.
Comment From: sdeleuze
Reverted after double-checking with @chemicL that the deferred exception accumulation mechanism is the founding technology for checkpoint()
for instance, or used in block()
, so static exception should be avoided with Reactor, as recently documented.