This is an automated email from the ASF dual-hosted git repository.
vy pushed a commit to branch 2.x
in repository https://gitbox.apache.org/repos/asf/logging-log4j2.git
The following commit(s) were added to refs/heads/2.x by this push:
new d026d3f8d4 Revamped `ListAppender` for thread-safety (#4111)
d026d3f8d4 is described below
commit d026d3f8d4802f57ba36a45441f03179881c361c
Author: Ramanathan <[email protected]>
AuthorDate: Thu May 21 16:51:13 2026 +0530
Revamped `ListAppender` for thread-safety (#4111)
Co-authored-by: Björn Michael <[email protected]>
Co-authored-by: Volkan Yazıcı <[email protected]>
---
.../log4j/core/test/appender/ListAppender.java | 77 ++++---
.../log4j/core/test/appender/ListAppenderTest.java | 224 +++++++++++++++++++++
src/changelog/.2.x.x/3926_revamp_list_appender.xml | 13 ++
3 files changed, 273 insertions(+), 41 deletions(-)
diff --git
a/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/appender/ListAppender.java
b/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/appender/ListAppender.java
index f941db1267..7d3aa43869 100644
---
a/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/appender/ListAppender.java
+++
b/log4j-core-test/src/main/java/org/apache/logging/log4j/core/test/appender/ListAppender.java
@@ -40,24 +40,24 @@ import org.awaitility.Awaitility;
/**
* This appender is primarily used for testing. Use in a real environment is
discouraged as the
- * List could eventually grow to cause an OutOfMemoryError.
+ * lists could eventually grow to cause an {@link OutOfMemoryError}.
*
- * This appender is not thread-safe.
+ * <p>This appender is thread-safe: all public methods are {@code
synchronized} on {@code this}.
+ * Callers waiting for a minimum number of messages can use
+ * {@link #getMessages(int, long, TimeUnit)} which releases the monitor while
waiting.</p>
*
- * This appender will use {@link Layout#toByteArray(LogEvent)}.
+ * <p>This appender will use {@link Layout#toByteArray(LogEvent)}.</p>
*
* @see
org.apache.logging.log4j.core.test.junit.LoggerContextRule#getListAppender(String)
ILC.getListAppender
*/
@Plugin(name = "List", category = Core.CATEGORY_NAME, elementType =
Appender.ELEMENT_TYPE, printObject = true)
public class ListAppender extends AbstractAppender {
- // Use Collections.synchronizedList rather than CopyOnWriteArrayList
because we expect
- // more frequent writes than reads.
- final List<LogEvent> events = Collections.<LogEvent>synchronizedList(new
ArrayList<LogEvent>());
+ final List<LogEvent> events = new ArrayList<>();
- private final List<String> messages =
Collections.<String>synchronizedList(new ArrayList<String>());
+ private final List<String> messages = new ArrayList<>();
- final List<byte[]> data = Collections.<byte[]>synchronizedList(new
ArrayList<byte[]>());
+ final List<byte[]> data = new ArrayList<>();
private final boolean newLine;
@@ -66,30 +66,20 @@ public class ListAppender extends AbstractAppender {
private static final String WINDOWS_LINE_SEP = "\r\n";
/**
- * CountDownLatch for asynchronous logging tests. Example usage:
- * <pre>
- * @Rule
- * public LoggerContextRule context = new
LoggerContextRule("log4j-list.xml");
- * private ListAppender listAppender;
+ * A {@link CountDownLatch} that is decremented once for every call to
{@link #append(LogEvent)}.
*
- * @Before
- * public void before() throws Exception {
- * listAppender = context.getListAppender("List");
- * }
+ * <p>Callers may assign a new latch before submitting a known number of
log events,
+ * then await that latch to block until all events have been processed.
Example:</p>
+ * <pre>{@code
+ * listAppender.countDownLatch = new CountDownLatch(1);
*
- * @Test
- * public void testSomething() throws Exception {
- * listAppender.countDownLatch = new CountDownLatch(1);
+ * logger.info("log one event asynchronously");
*
- * Logger logger = LogManager.getLogger();
- * logger.info("log one event asynchronously");
+ * // wait for the appender to finish processing this event (wait max 1
second)
+ * listAppender.countDownLatch.await(1, TimeUnit.SECONDS);
*
- * // wait for the appender to finish processing this event (wait max
1 second)
- * listAppender.countDownLatch.await(1, TimeUnit.SECONDS);
- *
- * // now assert something or do follow-up tests...
- * }
- * </pre>
+ * // now assert something or do follow-up tests...
+ * }</pre>
*/
public volatile CountDownLatch countDownLatch = null;
@@ -117,7 +107,7 @@ public class ListAppender extends AbstractAppender {
}
@Override
- public void append(final LogEvent event) {
+ public synchronized void append(final LogEvent event) {
final Layout<? extends Serializable> layout = getLayout();
if (layout == null) {
events.add(event.toImmutable());
@@ -131,12 +121,13 @@ public class ListAppender extends AbstractAppender {
} else {
write(layout.toByteArray(event));
}
- if (countDownLatch != null) {
- countDownLatch.countDown();
+ final CountDownLatch latch = countDownLatch;
+ if (latch != null) {
+ latch.countDown();
}
}
- void write(final byte[] bytes) {
+ synchronized void write(final byte[] bytes) {
if (raw) {
data.add(bytes);
return;
@@ -174,7 +165,7 @@ public class ListAppender extends AbstractAppender {
}
@Override
- public boolean stop(final long timeout, final TimeUnit timeUnit) {
+ public synchronized boolean stop(final long timeout, final TimeUnit
timeUnit) {
setStopping();
super.stop(timeout, timeUnit, false);
final Layout<? extends Serializable> layout = getLayout();
@@ -188,7 +179,7 @@ public class ListAppender extends AbstractAppender {
return true;
}
- public ListAppender clear() {
+ public synchronized ListAppender clear() {
events.clear();
messages.clear();
data.clear();
@@ -196,13 +187,13 @@ public class ListAppender extends AbstractAppender {
}
/** Returns an immutable snapshot of captured log events */
- public List<LogEvent> getEvents() {
- return Collections.<LogEvent>unmodifiableList(new ArrayList<>(events));
+ public synchronized List<LogEvent> getEvents() {
+ return Collections.unmodifiableList(new ArrayList<>(events));
}
/** Returns an immutable snapshot of captured messages */
- public List<String> getMessages() {
- return Collections.<String>unmodifiableList(new ArrayList<>(messages));
+ public synchronized List<String> getMessages() {
+ return Collections.unmodifiableList(new ArrayList<>(messages));
}
/**
@@ -211,13 +202,17 @@ public class ListAppender extends AbstractAppender {
*/
public List<String> getMessages(final int minSize, final long timeout,
final TimeUnit timeUnit)
throws InterruptedException {
- Awaitility.waitAtMost(timeout, timeUnit).until(() -> messages.size()
>= minSize);
+ Awaitility.waitAtMost(timeout, timeUnit).until(() -> {
+ synchronized (this) {
+ return messages.size() >= minSize;
+ }
+ });
return getMessages();
}
/** Returns an immutable snapshot of captured data */
- public List<byte[]> getData() {
- return Collections.<byte[]>unmodifiableList(new ArrayList<>(data));
+ public synchronized List<byte[]> getData() {
+ return Collections.unmodifiableList(new ArrayList<>(data));
}
public static ListAppender createAppender(
diff --git
a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/test/appender/ListAppenderTest.java
b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/test/appender/ListAppenderTest.java
new file mode 100644
index 0000000000..ac6fd5bc7a
--- /dev/null
+++
b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/test/appender/ListAppenderTest.java
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.logging.log4j.core.test.appender;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.impl.Log4jLogEvent;
+import org.apache.logging.log4j.core.layout.PatternLayout;
+import org.apache.logging.log4j.message.SimpleMessage;
+import org.junit.jupiter.api.RepeatedTest;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link ListAppender}.
+ */
+class ListAppenderTest {
+
+ private static LogEvent createEvent(final int workerId, final int index) {
+ return Log4jLogEvent.newBuilder()
+ .setLoggerName("worker-" + workerId)
+ .setLevel(Level.INFO)
+ .setMessage(new SimpleMessage("event-" + workerId + "-" +
index))
+ .build();
+ }
+
+ private static List<String> expectedMessages(final int workerCount, final
int eventsPerWorker) {
+ final List<String> expected = new ArrayList<>(workerCount *
eventsPerWorker);
+ for (int workerId = 0; workerId < workerCount; workerId++) {
+ for (int i = 0; i < eventsPerWorker; i++) {
+ expected.add("event-" + workerId + "-" + i);
+ }
+ }
+ return expected;
+ }
+
+ private static List<String> expectedEventKeys(final int workerCount, final
int eventsPerWorker) {
+ final List<String> expected = new ArrayList<>(workerCount *
eventsPerWorker);
+ for (int workerId = 0; workerId < workerCount; workerId++) {
+ for (int i = 0; i < eventsPerWorker; i++) {
+ expected.add("worker-" + workerId + ":event-" + workerId + "-"
+ i);
+ }
+ }
+ return expected;
+ }
+
+ @Test
+ void appendWithoutLayoutStoresEvents() {
+ final ListAppender appender = new ListAppender("test");
+ appender.start();
+
+ appender.append(createEvent(0, 0));
+ appender.append(createEvent(0, 1));
+
+ assertThat(appender.getEvents()).hasSize(2);
+ assertThat(appender.getMessages()).isEmpty();
+ }
+
+ @Test
+ void appendWithLayoutStoresMessages() {
+ final PatternLayout layout =
PatternLayout.newBuilder().setPattern("%m").build();
+ final ListAppender appender = new ListAppender("test", null, layout,
false, false);
+ appender.start();
+
+ appender.append(createEvent(0, 0));
+ appender.append(createEvent(0, 1));
+
+ assertThat(appender.getMessages()).hasSize(2);
+ assertThat(appender.getEvents()).isEmpty();
+ }
+
+ @Test
+ void clearResetsAllCollections() {
+ final ListAppender appender = new ListAppender("test");
+ appender.start();
+
+ appender.append(createEvent(0, 0));
+ assertThat(appender.getEvents()).hasSize(1);
+
+ appender.clear();
+
+ assertThat(appender.getEvents()).isEmpty();
+ assertThat(appender.getMessages()).isEmpty();
+ assertThat(appender.getData()).isEmpty();
+ }
+
+ @Test
+ void getMessagesWithTimeoutReturnsOnceMinSizeReached() throws
InterruptedException {
+ final PatternLayout layout =
PatternLayout.newBuilder().setPattern("%m").build();
+ final ListAppender appender = new ListAppender("test", null, layout,
false, false);
+ appender.start();
+
+ // Append in a background thread, after a short delay
+ final Thread producer = new Thread(() -> {
+ try {
+ Thread.sleep(50);
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ appender.append(createEvent(0, 0));
+ });
+ producer.start();
+
+ final List<String> messages = appender.getMessages(1, 5,
TimeUnit.SECONDS);
+ producer.join();
+
+ assertThat(messages).hasSize(1);
+ }
+
+ /**
+ * Hammers {@link ListAppender#append(LogEvent)} concurrently using 10
workers each appending 1,000 deterministic
+ * events, then verifies that {@link ListAppender#getEvents()} is
consistent (no events were lost or duplicated).
+ */
+ @RepeatedTest(10)
+ void appendIsThreadSafeWithoutLayout() throws InterruptedException {
+ final int workerCount = 10;
+ final int eventsPerWorker = 1_000;
+ final List<String> expectedEventKeys = expectedEventKeys(workerCount,
eventsPerWorker);
+
+ final ListAppender appender = new ListAppender("thread-safe-test");
+ appender.start();
+
+ final ExecutorService executor =
Executors.newFixedThreadPool(workerCount);
+ final CountDownLatch startGate = new CountDownLatch(1);
+
+ for (int w = 0; w < workerCount; w++) {
+ final int workerId = w;
+ executor.submit(() -> {
+ try {
+ startGate.await();
+ for (int i = 0; i < eventsPerWorker; i++) {
+ appender.append(createEvent(workerId, i));
+ }
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
+ }
+
+ startGate.countDown();
+ executor.shutdown();
+ assertThat(executor.awaitTermination(30, TimeUnit.SECONDS))
+ .as("all workers completed within timeout")
+ .isTrue();
+
+ assertThat(appender.getEvents())
+ .as("all events were captured without loss or duplication")
+ .hasSize(workerCount * eventsPerWorker);
+
+ assertThat(appender.getEvents())
+ .extracting(event ->
+ event.getLoggerName() + ":" +
event.getMessage().getFormattedMessage())
+ .as("all expected worker/message combinations are present
exactly once")
+ .containsExactlyInAnyOrderElementsOf(expectedEventKeys);
+ }
+
+ /**
+ * Hammers {@link ListAppender#append(LogEvent)} concurrently using 10
workers each appending 1,000 deterministic
+ * events with a layout, then verifies that {@link
ListAppender#getMessages()} is consistent
+ * (no messages were lost or duplicated).
+ */
+ @RepeatedTest(10)
+ void appendIsThreadSafeWithLayout() throws InterruptedException {
+ final int workerCount = 10;
+ final int eventsPerWorker = 1_000;
+ final List<String> expectedMessages = expectedMessages(workerCount,
eventsPerWorker);
+
+ final PatternLayout layout =
PatternLayout.newBuilder().setPattern("%m").build();
+ final ListAppender appender = new
ListAppender("thread-safe-layout-test", null, layout, false, false);
+ appender.start();
+
+ final ExecutorService executor =
Executors.newFixedThreadPool(workerCount);
+ final CountDownLatch startGate = new CountDownLatch(1);
+
+ for (int w = 0; w < workerCount; w++) {
+ final int workerId = w;
+ executor.submit(() -> {
+ try {
+ startGate.await();
+ for (int i = 0; i < eventsPerWorker; i++) {
+ appender.append(createEvent(workerId, i));
+ }
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
+ }
+
+ startGate.countDown();
+ executor.shutdown();
+ assertThat(executor.awaitTermination(30, TimeUnit.SECONDS))
+ .as("all workers completed within timeout")
+ .isTrue();
+
+ assertThat(appender.getMessages())
+ .as("all messages were captured without loss or duplication")
+ .hasSize(workerCount * eventsPerWorker);
+
+ assertThat(appender.getMessages())
+ .as("all expected messages are present exactly once")
+ .containsExactlyInAnyOrderElementsOf(expectedMessages);
+ }
+}
diff --git a/src/changelog/.2.x.x/3926_revamp_list_appender.xml
b/src/changelog/.2.x.x/3926_revamp_list_appender.xml
new file mode 100644
index 0000000000..e66a5fdfad
--- /dev/null
+++ b/src/changelog/.2.x.x/3926_revamp_list_appender.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<entry xmlns="https://logging.apache.org/xml/ns"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="
+ https://logging.apache.org/xml/ns
+ https://logging.apache.org/xml/ns/log4j-changelog-0.xsd"
+ type="changed">
+ <issue id="3926"
link="https://github.com/apache/logging-log4j2/pull/3926"/>
+ <issue id="4111"
link="https://github.com/apache/logging-log4j2/pull/4111"/>
+ <description format="asciidoc">
+ Revamp `ListAppender` for thread-safety and clarity.
+ </description>
+</entry>