Skip to content

Commit 6e2cdeb

Browse files
committed
fix: Disable sharing of formatted timestamps between threads (#3802)
This change disables the sharing of formatted timestamps between threads in `InstantPatternThreadLocalCachedFormatter`. Previously, a mutable `StringBuilder` was shared across threads via a cached object. This could lead to situations where one thread ("owner") was modifying the builder while another thread was reading from it, resulting in inaccurate or truncated timestamps. This fix ensures that only thread-local instances are used, preventing concurrency issues and improving timestamp correctness under load. This change is similar in nature to #1485 and fixes #3792.
1 parent 9577db6 commit 6e2cdeb

File tree

3 files changed

+76
-22
lines changed

3 files changed

+76
-22
lines changed

log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternThreadLocalCachedFormatterTest.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
*/
1717
package org.apache.logging.log4j.core.util.internal.instant;
1818

19+
import static java.util.concurrent.Executors.newSingleThreadExecutor;
1920
import static org.assertj.core.api.Assertions.assertThat;
2021
import static org.assertj.core.api.Assertions.assertThatThrownBy;
2122
import static org.mockito.ArgumentMatchers.any;
2223
import static org.mockito.ArgumentMatchers.eq;
2324
import static org.mockito.Mockito.doAnswer;
2425
import static org.mockito.Mockito.mock;
26+
import static org.mockito.Mockito.times;
2527
import static org.mockito.Mockito.verify;
2628
import static org.mockito.Mockito.verifyNoMoreInteractions;
2729
import static org.mockito.Mockito.when;
@@ -30,13 +32,16 @@
3032
import java.util.Locale;
3133
import java.util.Random;
3234
import java.util.TimeZone;
35+
import java.util.concurrent.ExecutionException;
36+
import java.util.concurrent.ExecutorService;
3337
import java.util.function.Function;
3438
import org.apache.logging.log4j.core.time.Instant;
3539
import org.apache.logging.log4j.core.time.MutableInstant;
3640
import org.junit.jupiter.api.Test;
3741
import org.junit.jupiter.params.ParameterizedTest;
3842
import org.junit.jupiter.params.provider.MethodSource;
3943
import org.junit.jupiter.params.provider.ValueSource;
44+
import org.junitpioneer.jupiter.Issue;
4045

4146
class InstantPatternThreadLocalCachedFormatterTest {
4247

@@ -289,4 +294,53 @@ private static MutableInstant createInstant(final long epochMillis, final int ep
289294
instant.initFromEpochMilli(epochMillis, epochMillisNanos);
290295
return instant;
291296
}
297+
298+
@Test
299+
@Issue("https://github.com/apache/logging-log4j2/issues/3792")
300+
void should_be_thread_safe() throws Exception {
301+
// Instead of randomly testing the thread safety, we test that the current implementation does not
302+
// cache results across threads.
303+
//
304+
// Modify this test if the implementation changes in the future.
305+
final InstantPatternFormatter patternFormatter = mock(InstantPatternFormatter.class);
306+
when(patternFormatter.getPrecision()).thenReturn(ChronoUnit.MILLIS);
307+
308+
final Instant instant = INSTANT0;
309+
final String output = "thread-output";
310+
doAnswer(invocation -> {
311+
StringBuilder buffer = invocation.getArgument(0);
312+
buffer.append(output)
313+
.append('-')
314+
.append(Thread.currentThread().getName());
315+
return null;
316+
})
317+
.when(patternFormatter)
318+
.formatTo(any(StringBuilder.class), eq(instant));
319+
320+
final InstantFormatter cachedFormatter =
321+
InstantPatternThreadLocalCachedFormatter.ofMilliPrecision(patternFormatter);
322+
323+
final int threadCount = 2;
324+
for (int i = 0; i < threadCount; i++) {
325+
formatOnNewThread(cachedFormatter, instant, output);
326+
}
327+
verify(patternFormatter, times(threadCount)).formatTo(any(StringBuilder.class), eq(instant));
328+
}
329+
330+
private static void formatOnNewThread(
331+
final InstantFormatter formatter, final Instant instant, final String expectedOutput)
332+
throws ExecutionException, InterruptedException {
333+
ExecutorService executor = newSingleThreadExecutor();
334+
try {
335+
executor.submit(() -> {
336+
String formatted = formatter.format(instant);
337+
assertThat(formatted)
338+
.isEqualTo(expectedOutput + "-"
339+
+ Thread.currentThread().getName());
340+
})
341+
.get();
342+
} finally {
343+
executor.shutdown();
344+
}
345+
}
292346
}

log4j-core/src/main/java/org/apache/logging/log4j/core/util/internal/instant/InstantPatternThreadLocalCachedFormatter.java

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ final class InstantPatternThreadLocalCachedFormatter implements InstantPatternFo
3838
private final ThreadLocal<Object[]> epochInstantAndBufferRef =
3939
ThreadLocal.withInitial(InstantPatternThreadLocalCachedFormatter::createEpochInstantAndBuffer);
4040

41-
private Object[] lastEpochInstantAndBuffer = createEpochInstantAndBuffer();
42-
4341
private static Object[] createEpochInstantAndBuffer() {
4442
return new Object[] {-1L, new StringBuilder()};
4543
}
@@ -89,32 +87,21 @@ public ChronoUnit getPrecision() {
8987
public void formatTo(final StringBuilder buffer, final Instant instant) {
9088
requireNonNull(buffer, "buffer");
9189
requireNonNull(instant, "instant");
92-
final Object[] prevEpochInstantAndBuffer = lastEpochInstantAndBuffer;
93-
final long prevEpochInstant = (long) prevEpochInstantAndBuffer[0];
94-
final StringBuilder prevBuffer = (StringBuilder) prevEpochInstantAndBuffer[1];
90+
final Object[] epochInstantAndBuffer = epochInstantAndBufferRef.get();
91+
final long prevEpochInstant = (long) epochInstantAndBuffer[0];
92+
final StringBuilder localBuffer = (StringBuilder) epochInstantAndBuffer[1];
9593
final long nextEpochInstant = epochInstantExtractor.apply(instant);
96-
if (prevEpochInstant == nextEpochInstant) {
97-
buffer.append(prevBuffer);
98-
} else {
99-
100-
// We could have used `StringBuilders.trimToMaxSize()` on `prevBuffer`.
94+
if (prevEpochInstant != nextEpochInstant) {
95+
// We could have used `StringBuilders.trimToMaxSize()` on `localBuffer`.
10196
// That is, we wouldn't want exploded `StringBuilder`s in hundreds of `ThreadLocal`s.
10297
// Though we are formatting instants and always expect to produce strings of more or less the same length.
10398
// Hence, no need for truncation.
10499

105-
// Populate a new cache entry
106-
final Object[] nextEpochInstantAndBuffer = epochInstantAndBufferRef.get();
107-
nextEpochInstantAndBuffer[0] = nextEpochInstant;
108-
final StringBuilder nextBuffer = (StringBuilder) nextEpochInstantAndBuffer[1];
109-
nextBuffer.setLength(0);
110-
formatter.formatTo(nextBuffer, instant);
111-
112-
// Update the effective cache entry
113-
lastEpochInstantAndBuffer = nextEpochInstantAndBuffer;
114-
115-
// Help out the request
116-
buffer.append(nextBuffer);
100+
epochInstantAndBuffer[0] = nextEpochInstant;
101+
localBuffer.setLength(0);
102+
formatter.formatTo(localBuffer, instant);
117103
}
104+
buffer.append(localBuffer);
118105
}
119106

120107
@Override
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!-- SPDX-License-Identifier: Apache-2.0 -->
3+
<entry xmlns="https://logging.apache.org/xml/ns"
4+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5+
xsi:schemaLocation="
6+
https://logging.apache.org/xml/ns
7+
https://logging.apache.org/xml/ns/log4j-changelog-0.xsd"
8+
type="fixed">
9+
<issue id="3792" link="https://github.com/apache/logging-log4j2/issues/3792"/>
10+
<description format="asciidoc">
11+
Fix timestamp formatting concurrency issue, when `log4j2.enabledThreadlocals` is `true`.
12+
</description>
13+
</entry>

0 commit comments

Comments
 (0)