From df605d5757cf6d4a45181b3fbc942f176fb3b0f6 Mon Sep 17 00:00:00 2001 From: "Shetye, Anup" Date: Mon, 16 Mar 2026 21:45:08 -0400 Subject: [PATCH] Fix unbounded StringBuilder retention in ThreadLocal (memory leak) getMessageStringBuilder() called setLength(0) which resets the logical length but never shrinks the internal char[] buffer. In thread-pool environments, a single large trace permanently bloats the buffer for that thread's lifetime, causing cumulative heap pressure. Add a capacity threshold check (8 KB) that discards oversized buffers and replaces them with fresh instances, while still reusing normally-sized buffers for zero-allocation steady state. Fixes #381 --- .../co/elastic/logging/EcsJsonSerializer.java | 18 +++++++++-- .../logging/EcsJsonSerializerTest.java | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/ecs-logging-core/src/main/java/co/elastic/logging/EcsJsonSerializer.java b/ecs-logging-core/src/main/java/co/elastic/logging/EcsJsonSerializer.java index dfb7411d..528bb46d 100644 --- a/ecs-logging-core/src/main/java/co/elastic/logging/EcsJsonSerializer.java +++ b/ecs-logging-core/src/main/java/co/elastic/logging/EcsJsonSerializer.java @@ -37,6 +37,8 @@ public class EcsJsonSerializer { private static final ThreadLocal messageStringBuilder = new ThreadLocal(); private static final String NEW_LINE = System.getProperty("line.separator"); private static final Pattern NEW_LINE_PATTERN = Pattern.compile("\\r\\n|\\n|\\r"); + private static final int INITIAL_BUFFER_CAPACITY = 1024; + static final int MAX_BUFFER_CAPACITY = 8192; public static CharSequence toNullSafeString(final CharSequence s) { return s == null ? "" : s; @@ -335,13 +337,23 @@ public static boolean endsWith(StringBuilder sb, String ending) { return true; } + /** + * Returns a thread-local {@link StringBuilder} for temporary message formatting. + *

+ * If the buffer has grown beyond {@link #MAX_BUFFER_CAPACITY} (e.g. due to a large stack trace), + * it is discarded and replaced with a fresh instance to prevent unbounded memory retention in + * long-lived thread-pool threads. + * + * @see #381 + */ public static StringBuilder getMessageStringBuilder() { StringBuilder result = messageStringBuilder.get(); - if (result == null) { - result = new StringBuilder(1024); + if (result == null || result.capacity() > MAX_BUFFER_CAPACITY) { + result = new StringBuilder(INITIAL_BUFFER_CAPACITY); messageStringBuilder.set(result); + } else { + result.setLength(0); } - result.setLength(0); return result; } diff --git a/ecs-logging-core/src/test/java/co/elastic/logging/EcsJsonSerializerTest.java b/ecs-logging-core/src/test/java/co/elastic/logging/EcsJsonSerializerTest.java index 3eca5e9e..a6b611ae 100644 --- a/ecs-logging-core/src/test/java/co/elastic/logging/EcsJsonSerializerTest.java +++ b/ecs-logging-core/src/test/java/co/elastic/logging/EcsJsonSerializerTest.java @@ -192,6 +192,38 @@ void serializeExceptionWithNullMessage() throws JsonProcessingException { assertThat(jsonNode.get(ERROR_MESSAGE)).isNull(); } + @Test + void getMessageStringBuilderDiscardsOversizedBuffer() { + // First call: initializes the thread-local StringBuilder + StringBuilder sb1 = EcsJsonSerializer.getMessageStringBuilder(); + assertThat(sb1.capacity()).isLessThanOrEqualTo(EcsJsonSerializer.MAX_BUFFER_CAPACITY); + + // Simulate a large log message / stack trace that bloats the buffer + sb1.append("x".repeat(EcsJsonSerializer.MAX_BUFFER_CAPACITY + 1)); + assertThat(sb1.capacity()).isGreaterThan(EcsJsonSerializer.MAX_BUFFER_CAPACITY); + + // Next call should detect the oversized buffer and replace it + StringBuilder sb2 = EcsJsonSerializer.getMessageStringBuilder(); + assertThat(sb2).isNotSameAs(sb1); + assertThat(sb2.capacity()).isLessThanOrEqualTo(EcsJsonSerializer.MAX_BUFFER_CAPACITY); + assertThat(sb2.length()).isZero(); + } + + @Test + void getMessageStringBuilderReuseNormallySizedBuffer() { + // First call: initializes the thread-local StringBuilder + StringBuilder sb1 = EcsJsonSerializer.getMessageStringBuilder(); + + // Append something that stays within the threshold + sb1.append("small message"); + assertThat(sb1.capacity()).isLessThanOrEqualTo(EcsJsonSerializer.MAX_BUFFER_CAPACITY); + + // Next call should reuse the same instance (just cleared) + StringBuilder sb2 = EcsJsonSerializer.getMessageStringBuilder(); + assertThat(sb2).isSameAs(sb1); + assertThat(sb2.length()).isZero(); + } + private void assertRemoveIfEndsWith(String builder, String ending, String expected) { StringBuilder sb = new StringBuilder(builder); EcsJsonSerializer.removeIfEndsWith(sb, ending);