From 2df0b4615ec2a051214498c2ae4d45e8181bc588 Mon Sep 17 00:00:00 2001
From: Ramanathan
Date: Thu, 7 May 2026 20:57:51 +0530
Subject: [PATCH 01/28] Fix NPE in ConfigurationScheduler by ensuring
scheduledFuture is checked for null before accessing its methods; add test
for race condition in scheduling.
---
.../config/ConfigurationSchedulerTest.java | 40 +++++++++++++++++++
.../core/config/ConfigurationScheduler.java | 20 +++++++---
2 files changed, 54 insertions(+), 6 deletions(-)
create mode 100644 log4j-core-test/src/test/java/org/apache/logging/log4j/core/config/ConfigurationSchedulerTest.java
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/config/ConfigurationSchedulerTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/config/ConfigurationSchedulerTest.java
new file mode 100644
index 00000000000..1ae50672e9e
--- /dev/null
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/config/ConfigurationSchedulerTest.java
@@ -0,0 +1,40 @@
+/*
+ * 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.config;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Date;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.apache.logging.log4j.core.util.CronExpression;
+import org.junit.jupiter.api.Test;
+
+class ConfigurationSchedulerTest {
+ @Test
+ void testScheduleWithCronRaceCondition() throws Exception {
+ ConfigurationScheduler scheduler = new ConfigurationScheduler();
+ scheduler.incrementScheduledItems();
+ scheduler.start();
+ CronExpression cron = new CronExpression("* * * * * ?");
+ CountDownLatch latch = new CountDownLatch(1);
+ Runnable task = latch::countDown;
+ scheduler.scheduleWithCron(cron, new Date(), task);
+ assertTrue(latch.await(2, TimeUnit.SECONDS), "Task did not run in time");
+ scheduler.stop(1, TimeUnit.SECONDS);
+ }
+}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/config/ConfigurationScheduler.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/config/ConfigurationScheduler.java
index c71911990a9..5e765ff8fb8 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/config/ConfigurationScheduler.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/config/ConfigurationScheduler.java
@@ -147,13 +147,15 @@ public CronScheduledFuture> scheduleWithCron(final CronExpression cronExpressi
public CronScheduledFuture> scheduleWithCron(
final CronExpression cronExpression, final Date startDate, final Runnable command) {
final Date fireDate = cronExpression.getNextValidTimeAfter(startDate == null ? new Date() : startDate);
+ final CronScheduledFuture>[] placeholder = new CronScheduledFuture>[1];
final CronRunnable runnable = new CronRunnable(command, cronExpression);
+ placeholder[0] = new CronScheduledFuture<>(null, fireDate);
+ runnable.setScheduledFuture(placeholder[0]);
final ScheduledFuture> future = schedule(runnable, nextFireInterval(fireDate), TimeUnit.MILLISECONDS);
- final CronScheduledFuture> cronScheduledFuture = new CronScheduledFuture<>(future, fireDate);
- runnable.setScheduledFuture(cronScheduledFuture);
+ placeholder[0].reset(future, fireDate);
LOGGER.debug(
"{} scheduled cron expression {} to fire at {}", name, cronExpression.getCronExpression(), fireDate);
- return cronScheduledFuture;
+ return placeholder[0];
}
/**
@@ -231,7 +233,8 @@ public void setScheduledFuture(final CronScheduledFuture> future) {
@Override
public void run() {
try {
- final long millis = scheduledFuture.getFireTime().getTime() - System.currentTimeMillis();
+ Date fireTime = (scheduledFuture != null) ? scheduledFuture.getFireTime() : null;
+ long millis = (fireTime != null) ? (fireTime.getTime() - System.currentTimeMillis()) : 0L;
if (millis > 0) {
LOGGER.debug("{} Cron thread woke up {} millis early. Sleeping", name, millis);
try {
@@ -251,13 +254,18 @@ public void run() {
name,
cronExpression.getCronExpression(),
fireDate);
- scheduledFuture.reset(future, fireDate);
+ if (scheduledFuture != null) {
+ scheduledFuture.reset(future, fireDate);
+ }
}
}
@Override
public String toString() {
- return "CronRunnable{" + cronExpression.getCronExpression() + " - " + scheduledFuture.getFireTime();
+ String fireTimeStr = (scheduledFuture != null && scheduledFuture.getFireTime() != null)
+ ? scheduledFuture.getFireTime().toString()
+ : "unassigned";
+ return "CronRunnable{" + cronExpression.getCronExpression() + " - " + fireTimeStr + "}";
}
}
From 50690c6148751184bfdc984b0efabfb64f004d5d Mon Sep 17 00:00:00 2001
From: Ramanathan
Date: Sun, 10 May 2026 15:16:57 +0530
Subject: [PATCH 02/28] =?UTF-8?q?Restrict=20JUnit=20dependencies=20to=20be?=
=?UTF-8?q?low=20version=206.x=20to=20maintain=20compatib=E2=80=A6=20(#412?=
=?UTF-8?q?0)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Restrict JUnit dependencies to below version 6.x to maintain compatibility with Java 8
* Update JUnit dependency restrictions for compatibility with Java 8 on 2.x
---
.github/dependabot.yaml | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml
index 3dfa7865b77..5c0cd9abb23 100644
--- a/.github/dependabot.yaml
+++ b/.github/dependabot.yaml
@@ -131,6 +131,15 @@ updates:
# Kafka 4.x is not compatible with our appender
- dependency-name: "org.apache.kafka:*"
versions: [ "[4,)" ]
+ # Keep JUnit below 6.x on 2.x: JUnit 6+ requires Java 17 and breaks Java 8 test runs
+ - dependency-name: "org.junit:junit-bom"
+ versions: [ "[6,)" ]
+ - dependency-name: "org.junit.jupiter:*"
+ versions: [ "[6,)" ]
+ - dependency-name: "org.junit.platform:*"
+ versions: [ "[6,)" ]
+ - dependency-name: "org.junit.vintage:*"
+ versions: [ "[6,)" ]
- package-ecosystem: maven
directories:
From bc3e896e624207707fe12091a836693115125e88 Mon Sep 17 00:00:00 2001
From: "Piotr P. Karwasz"
Date: Fri, 15 May 2026 15:10:52 +0200
Subject: [PATCH 03/28] Don't override `apache-rat-plugin` version (#4123)
Remove the version override for `apache-rat-plugin` so it falls back to the version provided by the ASF Parent POM.
Apache RAT `0.18`, for which Dependabot opened an update PR, contains a bug ([RAT-552](https://issues.apache.org/jira/browse/RAT-552)) that effectively disables the `excludeSubProjects` plugin option whenever `` is present in the configuration.
Upgrading to `0.18` will require converting our `` to ``, which depends on a new `logging-parent` release.
---
pom.xml | 1 -
1 file changed, 1 deletion(-)
diff --git a/pom.xml b/pom.xml
index 23b6eb7bcd4..afd5a80fbd9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -566,7 +566,6 @@
org.apache.ratapache-rat-plugin
- 0.16.1true
From 3be9e4c6b3dad16f5d82e51212a33fd5649f5d63 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 18 May 2026 11:02:47 +0200
Subject: [PATCH 04/28] Bump actions/stale from 9.1.0 to 10.2.0 in the
dependencies group across 1 directory (#4121)
* Bump actions/stale in the dependencies group across 1 directory
Bumps the dependencies group with 1 update in the / directory: [actions/stale](https://github.com/actions/stale).
Updates `actions/stale` from 9.1.0 to 10.2.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/5bef64f19d7facfb25b37b414482c7164d639639...b5d41d4e1d5dceea10e7104786b73624c18a190f)
---
updated-dependencies:
- dependency-name: actions/stale
dependency-version: 10.2.0
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: dependencies
...
Signed-off-by: dependabot[bot]
* Generate changelog entries for #4121
---------
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
.github/workflows/close-stale.yaml | 2 +-
src/changelog/.2.x.x/update_actions_stale.xml | 8 ++++++++
2 files changed, 9 insertions(+), 1 deletion(-)
create mode 100644 src/changelog/.2.x.x/update_actions_stale.xml
diff --git a/.github/workflows/close-stale.yaml b/.github/workflows/close-stale.yaml
index 5978d1aac5d..50870630b7c 100644
--- a/.github/workflows/close-stale.yaml
+++ b/.github/workflows/close-stale.yaml
@@ -28,7 +28,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
+ - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
# Labels need to match with the ones in `labeler.yaml`!
only-labels: waiting-for-user
diff --git a/src/changelog/.2.x.x/update_actions_stale.xml b/src/changelog/.2.x.x/update_actions_stale.xml
new file mode 100644
index 00000000000..4d772217e16
--- /dev/null
+++ b/src/changelog/.2.x.x/update_actions_stale.xml
@@ -0,0 +1,8 @@
+
+
+
+ Update `actions/stale` to version `10.2.0`
+
From 25829865c41d567b394f74887da91053c35ebd66 Mon Sep 17 00:00:00 2001
From: Sebastien Tardif
Date: Tue, 19 May 2026 22:55:55 -0700
Subject: [PATCH 05/28] Fix KafkaAppender reporting error after successful
retry (#4125)
* Fix KafkaAppender reporting error after successful retry
When retryCount is configured and the initial tryAppend() fails, the
retry loop uses break to exit on success. However, break only exits the
while loop and execution always reaches the error() call afterward. This
causes spurious error notifications for transient Kafka failures that
were successfully recovered by a retry.
Replace break with return so that a successful retry exits append()
without reporting an error. Retry exceptions are now logged at DEBUG
level for diagnostics instead of being silently discarded.
Also remove dead code in Builder.getRetryCount() where Integer.valueOf(int)
was wrapped in a NumberFormatException catch that can never fire.
The bug was introduced in #315.
Signed-off-by: Sebastien Tardif
* Add changelog entry for KafkaAppender retry fix
Signed-off-by: Sebastien Tardif
* Use CloseableThreadContext in testRetrySuccessDoesNotReportError
Adopt reviewer suggestion to use try-with-resources CloseableThreadContext
instead of manual ThreadContext.put/clearMap.
---------
Signed-off-by: Sebastien Tardif
---
.../appender/mom/kafka/KafkaAppenderTest.java | 53 ++++++++++++++++---
.../appender/mom/kafka/KafkaAppender.java | 20 ++++---
...x_kafka_appender_retry_error_reporting.xml | 8 +++
3 files changed, 64 insertions(+), 17 deletions(-)
create mode 100644 src/changelog/.2.x.x/fix_kafka_appender_retry_error_reporting.xml
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/mom/kafka/KafkaAppenderTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/mom/kafka/KafkaAppenderTest.java
index ef8c24edf00..b265126cd20 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/mom/kafka/KafkaAppenderTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/mom/kafka/KafkaAppenderTest.java
@@ -18,6 +18,7 @@
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
@@ -30,14 +31,18 @@
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
import org.apache.kafka.clients.producer.MockProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.apache.kafka.common.serialization.ByteArraySerializer;
import org.apache.kafka.common.serialization.Serializer;
+import org.apache.logging.log4j.CloseableThreadContext;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.ThreadContext;
import org.apache.logging.log4j.core.Appender;
+import org.apache.logging.log4j.core.ErrorHandler;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.impl.Log4jLogEvent;
import org.apache.logging.log4j.core.test.categories.Appenders;
@@ -55,6 +60,8 @@ public class KafkaAppenderTest {
private static final Serializer SERIALIZER = new ByteArraySerializer();
+ private static final AtomicInteger retrySendCount = new AtomicInteger(0);
+
private static final MockProducer kafka =
new MockProducer(true, SERIALIZER, SERIALIZER) {
@@ -65,12 +72,13 @@ public synchronized Future send(final ProducerRecord> history = kafka.history();
+ assertEquals(2, history.size());
+ assertFalse("Error should not be reported when retry succeeds", errorReported.get());
+ } finally {
+ ((KafkaAppender) appender).setHandler(originalHandler);
+ }
+ }
+
@Test
public void testAppenderNoEventTimestamp() {
final Appender appender = ctx.getRequiredAppender("KafkaAppenderNoEventTimestamp");
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/kafka/KafkaAppender.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/kafka/KafkaAppender.java
index a9788a2a59b..2fbccff7b04 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/kafka/KafkaAppender.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/mom/kafka/KafkaAppender.java
@@ -94,13 +94,7 @@ public KafkaAppender build() {
}
public Integer getRetryCount() {
- Integer intRetryCount = null;
- try {
- intRetryCount = Integer.valueOf(retryCount);
- } catch (NumberFormatException e) {
-
- }
- return intRetryCount;
+ return retryCount;
}
public String getTopic() {
@@ -216,16 +210,20 @@ public void append(final LogEvent event) {
try {
tryAppend(event);
} catch (final Exception e) {
-
if (this.retryCount != null) {
int currentRetryAttempt = 0;
while (currentRetryAttempt < this.retryCount) {
currentRetryAttempt++;
try {
tryAppend(event);
- break;
- } catch (Exception e1) {
-
+ return;
+ } catch (final Exception retryException) {
+ LOGGER.debug(
+ "Unable to write to Kafka in appender [{}], retry attempt [{}/{}].",
+ getName(),
+ currentRetryAttempt,
+ this.retryCount,
+ retryException);
}
}
}
diff --git a/src/changelog/.2.x.x/fix_kafka_appender_retry_error_reporting.xml b/src/changelog/.2.x.x/fix_kafka_appender_retry_error_reporting.xml
new file mode 100644
index 00000000000..f3884f41e8b
--- /dev/null
+++ b/src/changelog/.2.x.x/fix_kafka_appender_retry_error_reporting.xml
@@ -0,0 +1,8 @@
+
+
+
+ Fix `KafkaAppender` reporting error to error handler even after a successful retry
+
From 0edc9c2615f4ade544054673b28d689d236adf02 Mon Sep 17 00:00:00 2001
From: Ramanathan
Date: Thu, 21 May 2026 16:51:13 +0530
Subject: [PATCH 06/28] Revamped `ListAppender` for thread-safety (#4111)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Björn Michael
Co-authored-by: Volkan Yazıcı
---
.../core/test/appender/ListAppender.java | 77 +++---
.../core/test/appender/ListAppenderTest.java | 224 ++++++++++++++++++
.../.2.x.x/3926_revamp_list_appender.xml | 13 +
3 files changed, 273 insertions(+), 41 deletions(-)
create mode 100644 log4j-core-test/src/test/java/org/apache/logging/log4j/core/test/appender/ListAppenderTest.java
create mode 100644 src/changelog/.2.x.x/3926_revamp_list_appender.xml
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 f941db12674..7d3aa438693 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 @@
/**
* 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.
+ *
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.
*
- * This appender will use {@link Layout#toByteArray(LogEvent)}.
+ *
This appender will use {@link Layout#toByteArray(LogEvent)}.
*
* @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 events = Collections.synchronizedList(new ArrayList());
+ final List events = new ArrayList<>();
- private final List messages = Collections.synchronizedList(new ArrayList());
+ private final List messages = new ArrayList<>();
- final List data = Collections.synchronizedList(new ArrayList());
+ final List 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:
- *
- * @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");
- * }
+ *
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:
+ *
{@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...
- * }
- *
+ * // now assert something or do follow-up tests...
+ * }
*/
public volatile CountDownLatch countDownLatch = null;
@@ -117,7 +107,7 @@ public ListAppender(
}
@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 void append(final LogEvent event) {
} 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 @@ void write(final byte[] bytes) {
}
@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 boolean stop(final long timeout, final TimeUnit timeUnit) {
return true;
}
- public ListAppender clear() {
+ public synchronized ListAppender clear() {
events.clear();
messages.clear();
data.clear();
@@ -196,13 +187,13 @@ public ListAppender clear() {
}
/** Returns an immutable snapshot of captured log events */
- public List getEvents() {
- return Collections.unmodifiableList(new ArrayList<>(events));
+ public synchronized List getEvents() {
+ return Collections.unmodifiableList(new ArrayList<>(events));
}
/** Returns an immutable snapshot of captured messages */
- public List getMessages() {
- return Collections.unmodifiableList(new ArrayList<>(messages));
+ public synchronized List getMessages() {
+ return Collections.unmodifiableList(new ArrayList<>(messages));
}
/**
@@ -211,13 +202,17 @@ public List getMessages() {
*/
public List 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 getData() {
- return Collections.unmodifiableList(new ArrayList<>(data));
+ public synchronized List 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 00000000000..ac6fd5bc7aa
--- /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 expectedMessages(final int workerCount, final int eventsPerWorker) {
+ final List 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 expectedEventKeys(final int workerCount, final int eventsPerWorker) {
+ final List 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 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 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 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 00000000000..e66a5fdfadf
--- /dev/null
+++ b/src/changelog/.2.x.x/3926_revamp_list_appender.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Revamp `ListAppender` for thread-safety and clarity.
+
+
From ed78b989a315457ce91847500087ca06b30d0587 Mon Sep 17 00:00:00 2001
From: Ramanathan
Date: Fri, 22 May 2026 00:58:55 +0530
Subject: [PATCH 07/28] Improve `CronExpression` tests to cover daylight saving
and scheduling logic (#4081)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Piotr P. Karwasz
Co-authored-by: Volkan Yazıcı
---
.../log4j/core/util/CronExpressionTest.java | 126 ++++++++++++++++++
.../log4j/core/util/CronExpression.java | 9 +-
...r_daylight_saving_and_scheduling_logic.xml | 13 ++
3 files changed, 147 insertions(+), 1 deletion(-)
create mode 100644 src/changelog/.2.x.x/LOG4J2-3660_Improve_CronExpression_tests_to_cover_daylight_saving_and_scheduling_logic.xml
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/CronExpressionTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/CronExpressionTest.java
index 8a93e2b58c0..e5f19c992c2 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/CronExpressionTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/util/CronExpressionTest.java
@@ -16,12 +16,21 @@
*/
package org.apache.logging.log4j.core.util;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.text.SimpleDateFormat;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoUnit;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
+import java.util.TimeZone;
+import org.assertj.core.presentation.Representation;
+import org.assertj.core.presentation.StandardRepresentation;
import org.junit.jupiter.api.Test;
/**
@@ -171,4 +180,121 @@ void testTimeBeforeMilliseconds() throws Exception {
final Date expected = new GregorianCalendar(2015, 10, 1, 0, 0, 0).getTime();
assertEquals(expected, fireDate, "Dates not equal.");
}
+
+ /**
+ * Test that the next valid time after a fallback at 2:00 am from Daylight Saving Time
+ */
+ @Test
+ void daylightSavingChangeAtTwoAm() throws Exception {
+ ZoneId zoneId = ZoneId.of("Australia/Sydney");
+ Representation representation = new ZoneOffsetRepresentation(ZoneOffset.ofHours(11));
+ // The beginning of the day when daylight saving time ends in Australia in 2025 (switch from UTC+11 to UTC+10).
+ Instant april5 =
+ ZonedDateTime.of(2025, 4, 4, 13, 0, 0, 0, ZoneOffset.UTC).toInstant();
+ Instant april6 = april5.plus(24, ChronoUnit.HOURS);
+ Instant april7 = april6.plus(25, ChronoUnit.HOURS);
+
+ final CronExpression expression = new CronExpression("0 0 0 * * ?");
+ expression.setTimeZone(TimeZone.getTimeZone(zoneId));
+ // Check the next valid time after 23:59:59.999 on the day before DST ends.
+ Date currentTime = Date.from(april6.minusMillis(1));
+ Instant previousTime = expression.getPrevFireTime(currentTime).toInstant();
+ assertThat(previousTime).withRepresentation(representation).isEqualTo(april5);
+ Instant nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
+ assertThat(nextTime).withRepresentation(representation).isEqualTo(april6);
+ // Check the next valid time after 00:00:00.001 on the day DST ends.
+ currentTime = Date.from(april6.plusMillis(1));
+ previousTime = expression.getPrevFireTime(currentTime).toInstant();
+ assertThat(previousTime).withRepresentation(representation).isEqualTo(april6);
+ nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
+ assertThat(nextTime).withRepresentation(representation).isEqualTo(april7);
+ }
+
+ /**
+ * Test that the next valid time after a fallback at 0:00 am from Daylight Saving Time
+ */
+ @Test
+ void daylightSavingChangeAtMidnight() throws Exception {
+ ZoneId zoneId = ZoneId.of("America/Santiago");
+ Representation representation = new ZoneOffsetRepresentation(ZoneOffset.ofHours(-3));
+ // The beginning of the day when daylight saving time ends in Chile in 2025 (switch from UTC-3 to UTC-4).
+ Instant april5 =
+ ZonedDateTime.of(2025, 4, 5, 3, 0, 0, 0, ZoneOffset.UTC).toInstant();
+ // Midnight according to Daylight Saving Time.
+ Instant april6Dst = april5.plus(24, ChronoUnit.HOURS);
+ // Midnight according to Standard Time.
+ Instant april6 = april6Dst.plus(1, ChronoUnit.HOURS);
+ Instant april7 = april6.plus(24, ChronoUnit.HOURS);
+
+ final CronExpression expression = new CronExpression("0 0 0 * * ?");
+ expression.setTimeZone(TimeZone.getTimeZone(zoneId));
+ // Check the next valid time after 23:59:59.999 DST (22:59.59.999 standard) on the day before DST ends.
+ Date currentTime = Date.from(april6Dst.minusMillis(1));
+ Instant previousTime = expression.getPrevFireTime(currentTime).toInstant();
+ assertThat(previousTime).withRepresentation(representation).isEqualTo(april5);
+ Instant nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
+ assertThat(nextTime).withRepresentation(representation).isEqualTo(april6);
+ // Check the next valid time after 23:59:59.999 on the day before DST ends.
+ currentTime = Date.from(april6.minusMillis(1));
+ previousTime = expression.getPrevFireTime(currentTime).toInstant();
+ assertThat(previousTime).withRepresentation(representation).isEqualTo(april5);
+ nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
+ assertThat(nextTime).withRepresentation(representation).isEqualTo(april6);
+ // Check the next valid time after 00:00:00.001 on the day DST ends.
+ currentTime = Date.from(april6.plusMillis(1));
+ previousTime = expression.getPrevFireTime(currentTime).toInstant();
+ assertThat(previousTime).withRepresentation(representation).isEqualTo(april6);
+ nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
+ assertThat(nextTime).withRepresentation(representation).isEqualTo(april7);
+ }
+
+ /**
+ * Test that the next valid time after a spring forward (23-hour day) is correct.
+ * Sydney spring-forward: Oct 5 2025 02:00 UTC+10 → 03:00 UTC+11. Day is only 23 hours long.
+ */
+ @Test
+ void daylightSavingSpringForward() throws Exception {
+ ZoneId zoneId = ZoneId.of("Australia/Sydney");
+ Representation representation = new ZoneOffsetRepresentation(ZoneOffset.ofHours(10));
+ // Midnight UTC+10 on Oct 5 (the spring-forward day; clocks jump at 2AM → day is 23h).
+ Instant oct5 =
+ ZonedDateTime.of(2025, 10, 4, 14, 0, 0, 0, ZoneOffset.UTC).toInstant();
+ // Next midnight is only 23 hours later (UTC+11 offset after the jump).
+ Instant oct6 = oct5.plus(23, ChronoUnit.HOURS);
+ Instant oct7 = oct6.plus(24, ChronoUnit.HOURS);
+
+ final CronExpression expression = new CronExpression("0 0 0 * * ?");
+ expression.setTimeZone(TimeZone.getTimeZone(zoneId));
+
+ // Check the next valid time after 23:59:59.999 on the spring-forward day.
+ Date currentTime = Date.from(oct6.minusMillis(1));
+ Instant previousTime = expression.getPrevFireTime(currentTime).toInstant();
+ assertThat(previousTime).withRepresentation(representation).isEqualTo(oct5);
+ Instant nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
+ assertThat(nextTime).withRepresentation(representation).isEqualTo(oct6);
+
+ // Check the next valid time after 00:00:00.001 on the day after spring-forward.
+ currentTime = Date.from(oct6.plusMillis(1));
+ previousTime = expression.getPrevFireTime(currentTime).toInstant();
+ assertThat(previousTime).withRepresentation(representation).isEqualTo(oct6);
+ nextTime = expression.getNextValidTimeAfter(currentTime).toInstant();
+ assertThat(nextTime).withRepresentation(representation).isEqualTo(oct7);
+ }
+
+ private static class ZoneOffsetRepresentation extends StandardRepresentation {
+
+ private final ZoneOffset zoneOffset;
+
+ private ZoneOffsetRepresentation(final ZoneOffset zoneOffset) {
+ this.zoneOffset = zoneOffset;
+ }
+
+ @Override
+ public String toStringOf(final Object object) {
+ if (object instanceof Instant) {
+ return ZonedDateTime.ofInstant((Instant) object, zoneOffset).toString();
+ }
+ return super.toStringOf(object);
+ }
+ }
}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/CronExpression.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/CronExpression.java
index 96fd5551534..6e1433c6604 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/CronExpression.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/CronExpression.java
@@ -1591,6 +1591,11 @@ protected Date getTimeBefore(final Date targetDate) {
}
public Date getPrevFireTime(final Date targetDate) {
+ // Cron expressions have second precision. If the input has a millisecond fraction,
+ // include fire times at that same wall-clock second by shifting into the next second.
+ if (targetDate.getTime() % 1000 != 0) {
+ return getTimeBefore(new Date(targetDate.getTime() + 999));
+ }
return getTimeBefore(targetDate);
}
@@ -1610,7 +1615,9 @@ private long findMinIncrement() {
} else if (hours.first() == ALL_SPEC_INT) {
return 3600000;
}
- return 86400000;
+ // DST spring-forward days can be 23 hours, so using 24 hours here can skip
+ // a valid previous fire time around midnight transitions.
+ return 23L * 60L * 60L * 1000L;
}
private int minInSet(final TreeSet set) {
diff --git a/src/changelog/.2.x.x/LOG4J2-3660_Improve_CronExpression_tests_to_cover_daylight_saving_and_scheduling_logic.xml b/src/changelog/.2.x.x/LOG4J2-3660_Improve_CronExpression_tests_to_cover_daylight_saving_and_scheduling_logic.xml
new file mode 100644
index 00000000000..60f2cf065ce
--- /dev/null
+++ b/src/changelog/.2.x.x/LOG4J2-3660_Improve_CronExpression_tests_to_cover_daylight_saving_and_scheduling_logic.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Improve DST handling in `CronExpression`, including boundary and short-day behavior, with added DST transition tests
+
+
\ No newline at end of file
From ba36fe6a08648a172dc4b5179dabd496d8ca9cb6 Mon Sep 17 00:00:00 2001
From: Ramanathan
Date: Fri, 22 May 2026 01:03:29 +0530
Subject: [PATCH 08/28] Fix `createOnDemand` for Rolling File Appender (#4072)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Piotr P. Karwasz
Co-authored-by: Volkan Yazıcı
---
.../RollingFileManagerCreateOnDemandTest.java | 72 +++++++++++++++++++
.../appender/rolling/RollingFileManager.java | 27 +++----
...Fix_RollingFileAppender_createOnDemand.xml | 14 ++++
3 files changed, 100 insertions(+), 13 deletions(-)
create mode 100644 log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingFileManagerCreateOnDemandTest.java
create mode 100644 src/changelog/.2.x.x/LOG4J2-2006_Fix_RollingFileAppender_createOnDemand.xml
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingFileManagerCreateOnDemandTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingFileManagerCreateOnDemandTest.java
new file mode 100644
index 00000000000..6a2e16f457a
--- /dev/null
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingFileManagerCreateOnDemandTest.java
@@ -0,0 +1,72 @@
+/*
+ * 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.appender.rolling;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.logging.log4j.core.config.NullConfiguration;
+import org.apache.logging.log4j.core.layout.PatternLayout;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class RollingFileManagerCreateOnDemandTest {
+ @Test
+ void testCreateOnDemandDoesNotCreateDirectoryOrFile(@TempDir Path tempDir) throws Exception {
+ Path logDir = tempDir.resolve("onDemand");
+ String logFile = logDir.resolve("test.log").toString();
+ File dir = logDir.toFile();
+ File file = new File(logFile);
+ assertFalse(dir.exists(), "Directory should not exist before logging");
+ assertFalse(file.exists(), "File should not exist before logging");
+
+ RollingFileManager manager = RollingFileManager.getFileManager(
+ logFile,
+ logFile + ".%d{yyyy-MM-dd}",
+ true,
+ false,
+ NoOpTriggeringPolicy.INSTANCE,
+ DefaultRolloverStrategy.newBuilder().build(),
+ null,
+ PatternLayout.createDefaultLayout(),
+ 0,
+ true,
+ true,
+ null,
+ null,
+ null,
+ new NullConfiguration());
+ assertNotNull(manager);
+ // Directory and file should still not exist
+ assertFalse(dir.exists(), "Directory should not exist after manager creation");
+ assertFalse(file.exists(), "File should not exist after manager creation");
+
+ // Log a message
+ manager.writeToDestination("Hello Log4j2".getBytes(), 0, "Hello Log4j2".length());
+ manager.close();
+
+ // Now directory and file should exist
+ assertTrue(dir.exists(), "Directory should exist after first log event");
+ assertTrue(file.exists(), "File should exist after first log event");
+ String content = new String(Files.readAllBytes(file.toPath()));
+ assertTrue(content.contains("Hello Log4j2"));
+ }
+}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/RollingFileManager.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/RollingFileManager.java
index afdb7416585..0c62bfac965 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/RollingFileManager.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/RollingFileManager.java
@@ -310,14 +310,17 @@ public static RollingFileManager getFileManager(
if (fileName != null) {
file = new File(fileName);
- try {
- FileUtils.makeParentDirs(file);
- final boolean created = createOnDemand ? false : file.createNewFile();
- LOGGER.trace("New file '{}' created = {}", name, created);
- } catch (final IOException ioe) {
- LOGGER.error("Unable to create file {}", name, ioe);
- return null;
+ if (!createOnDemand) {
+ try {
+ FileUtils.makeParentDirs(file);
+ final boolean created = file.createNewFile();
+ LOGGER.trace("New file '{}' created = {}", name, created);
+ } catch (final IOException ioe) {
+ LOGGER.error("Unable to create file {}", name, ioe);
+ return null;
+ }
}
+
size = append ? file.length() : 0;
}
@@ -391,12 +394,10 @@ public String getFileName() {
@Override
protected void createParentDir(File file) {
- if (directWrite) {
- final File parent = file.getParentFile();
- // If the parent is null the file is in the current working directory.
- if (parent != null) {
- parent.mkdirs();
- }
+ try {
+ FileUtils.makeParentDirs(file);
+ } catch (IOException e) {
+ LOGGER.error("Unable to create parent directories for file {}", file, e);
}
}
diff --git a/src/changelog/.2.x.x/LOG4J2-2006_Fix_RollingFileAppender_createOnDemand.xml b/src/changelog/.2.x.x/LOG4J2-2006_Fix_RollingFileAppender_createOnDemand.xml
new file mode 100644
index 00000000000..33d6f9e9fe0
--- /dev/null
+++ b/src/changelog/.2.x.x/LOG4J2-2006_Fix_RollingFileAppender_createOnDemand.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Fix the `createOnDemand` behavior of `RollingFileAppender` to correctly defer file and directory creation
+ until the first log event, while preserving eager creation when disabled.
+
+
\ No newline at end of file
From e8c23fef4f708475b36015589a8c65afdd4f47d3 Mon Sep 17 00:00:00 2001
From: Ramanathan
Date: Thu, 28 May 2026 14:51:18 +0530
Subject: [PATCH 09/28] Add support for max compression delay in compression
actions (#4071)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Volkan Yazıcı
---
.../FileExtensionCompressDelayTest.java | 179 ++++++++++++++++++
.../rolling/action/GzCompressActionTest.java | 120 ++++++++++++
.../rolling/action/ZipCompressActionTest.java | 120 ++++++++++++
.../rolling/DefaultRolloverStrategy.java | 84 +++++++-
.../core/appender/rolling/FileExtension.java | 108 ++++++++++-
.../rolling/action/AbstractAction.java | 19 ++
.../rolling/action/CommonsCompressAction.java | 27 +++
.../rolling/action/GzCompressAction.java | 50 ++++-
.../rolling/action/ZipCompressAction.java | 47 ++++-
.../appender/rolling/action/package-info.java | 2 +-
.../core/appender/rolling/package-info.java | 2 +-
.../.2.x.x/4012_add_max_compression_delay.xml | 14 ++
.../pages/manual/appenders/rolling-file.adoc | 9 +
13 files changed, 758 insertions(+), 23 deletions(-)
create mode 100644 log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/FileExtensionCompressDelayTest.java
create mode 100644 log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/GzCompressActionTest.java
create mode 100644 log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/ZipCompressActionTest.java
create mode 100644 src/changelog/.2.x.x/4012_add_max_compression_delay.xml
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/FileExtensionCompressDelayTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/FileExtensionCompressDelayTest.java
new file mode 100644
index 00000000000..98ebf8a6915
--- /dev/null
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/FileExtensionCompressDelayTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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.appender.rolling;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import org.apache.logging.log4j.core.appender.rolling.action.Action;
+import org.apache.logging.log4j.core.appender.rolling.action.GzCompressAction;
+import org.apache.logging.log4j.core.appender.rolling.action.ZipCompressAction;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Issue #4012 — verifies that FileExtension.GZ and FileExtension.ZIP correctly pass
+ * maxCompressionDelaySeconds through to the compression action.
+ *
+ * This was the root cause of the bug: the 5-argument createCompressAction() in GZ and ZIP
+ * fell back to the 4-argument version, silently dropping the delay value.
+ */
+class FileExtensionCompressDelayTest {
+
+ @Test
+ void testGzCreateCompressActionRejectsInvalidCompressionLevel(@TempDir File tempDir) {
+ File source = new File(tempDir, "invalid-level.log");
+ File dest = new File(tempDir, "invalid-level.log.gz");
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> FileExtension.GZ.createCompressAction(source.getPath(), dest.getPath(), true, -2, 0));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> FileExtension.GZ.createCompressAction(source.getPath(), dest.getPath(), true, 10, 0));
+ }
+
+ @Test
+ void testZipCreateCompressActionRejectsInvalidCompressionLevel(@TempDir File tempDir) {
+ File source = new File(tempDir, "invalid-level.log");
+ File dest = new File(tempDir, "invalid-level.log.zip");
+
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> FileExtension.ZIP.createCompressAction(source.getPath(), dest.getPath(), true, -2, 0));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> FileExtension.ZIP.createCompressAction(source.getPath(), dest.getPath(), true, 10, 0));
+ }
+
+ // ── GZ ────────────────────────────────────────────────────────────────
+
+ /**
+ * FileExtension.GZ.createCompressAction(5-args) must produce a GzCompressAction
+ * that applies the random delay — not fall back to 0.
+ */
+ @Test
+ void testGzCreateCompressActionWithDelay(@TempDir File tempDir) throws Exception {
+ File source = new File(tempDir, "app.log");
+ File dest = new File(tempDir, "app.log.gz");
+ writeContent(source, "gz test content");
+
+ int maxDelay = 2;
+ Action action = FileExtension.GZ.createCompressAction(source.getPath(), dest.getPath(), true, -1, maxDelay);
+
+ // Must return a GzCompressAction (not some other type)
+ assertInstanceOf(GzCompressAction.class, action, "Expected GzCompressAction");
+
+ long start = System.currentTimeMillis();
+ action.execute();
+ long elapsed = System.currentTimeMillis() - start;
+
+ // Must finish within maxDelay + margin (delay IS applied via FileExtension)
+ assertTrue(
+ elapsed <= (maxDelay * 1000L) + 500,
+ "GZ compress via FileExtension exceeded maxDelay=" + maxDelay + "s: " + elapsed + "ms");
+ assertTrue(dest.exists(), "Compressed .gz file must exist");
+ assertFalse(source.exists(), "Source must be deleted after compression");
+ }
+
+ /**
+ * FileExtension.GZ.createCompressAction(5-args, delay=0) must behave identically
+ * to the 4-arg version — instant compression, no delay.
+ */
+ @Test
+ void testGzCreateCompressActionNoDelay(@TempDir File tempDir) throws Exception {
+ File source = new File(tempDir, "app-nodelay.log");
+ File dest = new File(tempDir, "app-nodelay.log.gz");
+ writeContent(source, "gz no-delay content");
+
+ Action action = FileExtension.GZ.createCompressAction(source.getPath(), dest.getPath(), true, -1, 0);
+
+ assertInstanceOf(GzCompressAction.class, action);
+
+ long start = System.currentTimeMillis();
+ action.execute();
+ long elapsed = System.currentTimeMillis() - start;
+
+ assertTrue(elapsed < 500, "GZ with delay=0 should be instant, took " + elapsed + "ms");
+ assertTrue(dest.exists(), "Compressed .gz file must exist");
+ assertFalse(source.exists(), "Source must be deleted");
+ }
+
+ // ── ZIP ───────────────────────────────────────────────────────────────
+
+ /**
+ * FileExtension.ZIP.createCompressAction(5-args) must produce a ZipCompressAction
+ * that applies the random delay — not fall back to 0.
+ */
+ @Test
+ void testZipCreateCompressActionWithDelay(@TempDir File tempDir) throws Exception {
+ File source = new File(tempDir, "app.log");
+ File dest = new File(tempDir, "app.log.zip");
+ writeContent(source, "zip test content");
+
+ int maxDelay = 2;
+ Action action = FileExtension.ZIP.createCompressAction(source.getPath(), dest.getPath(), true, 0, maxDelay);
+
+ assertInstanceOf(ZipCompressAction.class, action, "Expected ZipCompressAction");
+
+ long start = System.currentTimeMillis();
+ action.execute();
+ long elapsed = System.currentTimeMillis() - start;
+
+ assertTrue(
+ elapsed <= (maxDelay * 1000L) + 500,
+ "ZIP compress via FileExtension exceeded maxDelay=" + maxDelay + "s: " + elapsed + "ms");
+ assertTrue(dest.exists(), "Compressed .zip file must exist");
+ assertFalse(source.exists(), "Source must be deleted after compression");
+ }
+
+ /**
+ * FileExtension.ZIP.createCompressAction(5-args, delay=0) must behave identically
+ * to the 4-arg version — instant compression, no delay.
+ */
+ @Test
+ void testZipCreateCompressActionNoDelay(@TempDir File tempDir) throws Exception {
+ File source = new File(tempDir, "app-nodelay.log");
+ File dest = new File(tempDir, "app-nodelay.log.zip");
+ writeContent(source, "zip no-delay content");
+
+ Action action = FileExtension.ZIP.createCompressAction(source.getPath(), dest.getPath(), true, 0, 0);
+
+ assertInstanceOf(ZipCompressAction.class, action);
+
+ long start = System.currentTimeMillis();
+ action.execute();
+ long elapsed = System.currentTimeMillis() - start;
+
+ assertTrue(elapsed < 500, "ZIP with delay=0 should be instant, took " + elapsed + "ms");
+ assertTrue(dest.exists(), "Compressed .zip file must exist");
+ assertFalse(source.exists(), "Source must be deleted");
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────
+
+ private static void writeContent(final File file, final String content) throws IOException {
+ try (FileWriter writer = new FileWriter(file)) {
+ writer.write(content);
+ }
+ }
+}
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/GzCompressActionTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/GzCompressActionTest.java
new file mode 100644
index 00000000000..14d72c56dd0
--- /dev/null
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/GzCompressActionTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.appender.rolling.action;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.zip.Deflater;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class GzCompressActionTest {
+
+ @Test
+ void testRejectsCompressionLevelLowerThanDefault(@TempDir File tempDir) {
+ File source = new File(tempDir, "invalid-low.log");
+ File dest = new File(tempDir, "invalid-low.log.gz");
+
+ assertThrows(IllegalArgumentException.class, () -> new GzCompressAction(source, dest, true, -2, 0));
+ }
+
+ @Test
+ void testRejectsCompressionLevelHigherThanBest(@TempDir File tempDir) {
+ File source = new File(tempDir, "invalid-high.log");
+ File dest = new File(tempDir, "invalid-high.log.gz");
+
+ assertThrows(IllegalArgumentException.class, () -> new GzCompressAction(source, dest, true, 10, 0));
+ }
+
+ @Test
+ void testAcceptsDeflaterRangeBounds(@TempDir File tempDir) {
+ File source = new File(tempDir, "valid.log");
+ File dest = new File(tempDir, "valid.log.gz");
+
+ new GzCompressAction(source, dest, true, Deflater.DEFAULT_COMPRESSION, 0);
+ new GzCompressAction(source, dest, true, Deflater.BEST_COMPRESSION, 0);
+ }
+
+ /** Issue #4012 — when maxDelaySeconds > 0, compression must be deferred by a random 0..max seconds. */
+ @Test
+ void testRandomDelayBeforeCompression(@TempDir File tempDir) throws IOException {
+ File source = new File(tempDir, "test.log");
+ File dest = new File(tempDir, "test.log.gz");
+ try (FileWriter writer = new FileWriter(source)) {
+ writer.write("test data");
+ }
+ int maxDelay = 2; // seconds
+ GzCompressAction action = new GzCompressAction(source, dest, true, 0, maxDelay);
+ long start = System.currentTimeMillis();
+ action.execute();
+ long elapsed = System.currentTimeMillis() - start;
+
+ // Must complete within maxDelay + small margin
+ assertTrue(
+ elapsed <= (maxDelay * 1000L) + 500,
+ "Compression should not exceed maxDelay=" + maxDelay + "s, but took " + elapsed + "ms");
+ // Destination must be created
+ assertTrue(dest.exists(), "Compressed file must exist after execute()");
+ // Source must be deleted (deleteSource=true)
+ assertFalse(source.exists(), "Source file must be deleted after compression");
+ }
+
+ /**
+ * Issue #4012 — when maxDelaySeconds=0, no delay is applied (backward compatibility).
+ * Compression must complete well under 500ms.
+ */
+ @Test
+ void testNoDelayWhenMaxDelayIsZero(@TempDir File tempDir) throws IOException {
+ File source = new File(tempDir, "test-nodelay.log");
+ File dest = new File(tempDir, "test-nodelay.log.gz");
+ try (FileWriter writer = new FileWriter(source)) {
+ writer.write("test data no delay");
+ }
+ GzCompressAction action = new GzCompressAction(source, dest, true, 0, 0);
+ long start = System.currentTimeMillis();
+ action.execute();
+ long elapsed = System.currentTimeMillis() - start;
+
+ // No delay: must complete in well under 500ms
+ assertTrue(elapsed < 500, "Compression with maxDelay=0 should be instant, but took " + elapsed + "ms");
+ assertTrue(dest.exists(), "Compressed file must exist after execute()");
+ assertFalse(source.exists(), "Source file must be deleted after compression");
+ }
+
+ /** Legacy 4-arg constructor must still work with no delay (backward compatibility). */
+ @Test
+ void testLegacyConstructorNoDelay(@TempDir File tempDir) throws IOException {
+ File source = new File(tempDir, "test-legacy.log");
+ File dest = new File(tempDir, "test-legacy.log.gz");
+ try (FileWriter writer = new FileWriter(source)) {
+ writer.write("legacy test data");
+ }
+ GzCompressAction action = new GzCompressAction(source, dest, true, 0);
+ long start = System.currentTimeMillis();
+ action.execute();
+ long elapsed = System.currentTimeMillis() - start;
+
+ assertTrue(elapsed < 500, "Legacy constructor should have no delay, but took " + elapsed + "ms");
+ assertTrue(dest.exists(), "Compressed file must exist");
+ assertFalse(source.exists(), "Source file must be deleted");
+ }
+}
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/ZipCompressActionTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/ZipCompressActionTest.java
new file mode 100644
index 00000000000..b8bc76ce538
--- /dev/null
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/ZipCompressActionTest.java
@@ -0,0 +1,120 @@
+/*
+ * 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.appender.rolling.action;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class ZipCompressActionTest {
+
+ @Test
+ void testRejectsCompressionLevelBelowRange(@TempDir File tempDir) {
+ File source = new File(tempDir, "invalid-low.log");
+ File dest = new File(tempDir, "invalid-low.log.zip");
+
+ assertThrows(IllegalArgumentException.class, () -> new ZipCompressAction(source, dest, true, -2, 0));
+ }
+
+ @Test
+ void testRejectsCompressionLevelAboveRange(@TempDir File tempDir) {
+ File source = new File(tempDir, "invalid-high.log");
+ File dest = new File(tempDir, "invalid-high.log.zip");
+
+ assertThrows(IllegalArgumentException.class, () -> new ZipCompressAction(source, dest, true, 10, 0));
+ }
+
+ @Test
+ void testAcceptsCompressionLevelRangeBounds(@TempDir File tempDir) {
+ File source = new File(tempDir, "valid.log");
+ File dest = new File(tempDir, "valid.log.zip");
+
+ new ZipCompressAction(source, dest, true, -1, 0);
+ new ZipCompressAction(source, dest, true, 0, 0);
+ new ZipCompressAction(source, dest, true, 9, 0);
+ }
+
+ /** Issue #4012 — when maxDelaySeconds > 0, compression must be deferred by a random 0..max seconds. */
+ @Test
+ void testRandomDelayBeforeCompression(@TempDir File tempDir) throws IOException {
+ File source = new File(tempDir, "test.log");
+ File dest = new File(tempDir, "test.log.zip");
+ try (FileWriter writer = new FileWriter(source)) {
+ writer.write("test data");
+ }
+ int maxDelay = 2; // seconds
+ ZipCompressAction action = new ZipCompressAction(source, dest, true, 0, maxDelay);
+ long start = System.currentTimeMillis();
+ action.execute();
+ long elapsed = System.currentTimeMillis() - start;
+
+ // Must complete within maxDelay + small margin
+ assertTrue(
+ elapsed <= (maxDelay * 1000L) + 500,
+ "Compression should not exceed maxDelay=" + maxDelay + "s, but took " + elapsed + "ms");
+ // Destination must be created
+ assertTrue(dest.exists(), "Compressed file must exist after execute()");
+ // Source must be deleted (deleteSource=true)
+ assertFalse(source.exists(), "Source file must be deleted after compression");
+ }
+
+ /**
+ * Issue #4012 — when maxDelaySeconds=0, no delay is applied (backward compatibility).
+ * Compression must complete well under 500ms.
+ */
+ @Test
+ void testNoDelayWhenMaxDelayIsZero(@TempDir File tempDir) throws IOException {
+ File source = new File(tempDir, "test-nodelay.log");
+ File dest = new File(tempDir, "test-nodelay.log.zip");
+ try (FileWriter writer = new FileWriter(source)) {
+ writer.write("test data no delay");
+ }
+ ZipCompressAction action = new ZipCompressAction(source, dest, true, 0, 0);
+ long start = System.currentTimeMillis();
+ action.execute();
+ long elapsed = System.currentTimeMillis() - start;
+
+ // No delay: must complete in well under 500ms
+ assertTrue(elapsed < 500, "Compression with maxDelay=0 should be instant, but took " + elapsed + "ms");
+ assertTrue(dest.exists(), "Compressed file must exist after execute()");
+ assertFalse(source.exists(), "Source file must be deleted after compression");
+ }
+
+ /** Legacy 4-arg constructor must still work with no delay (backward compatibility). */
+ @Test
+ void testLegacyConstructorNoDelay(@TempDir File tempDir) throws IOException {
+ File source = new File(tempDir, "test-legacy.log");
+ File dest = new File(tempDir, "test-legacy.log.zip");
+ try (FileWriter writer = new FileWriter(source)) {
+ writer.write("legacy test data");
+ }
+ ZipCompressAction action = new ZipCompressAction(source, dest, true, 0);
+ long start = System.currentTimeMillis();
+ action.execute();
+ long elapsed = System.currentTimeMillis() - start;
+
+ assertTrue(elapsed < 500, "Legacy constructor should have no delay, but took " + elapsed + "ms");
+ assertTrue(dest.exists(), "Compressed file must exist");
+ assertFalse(source.exists(), "Source file must be deleted");
+ }
+}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/DefaultRolloverStrategy.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/DefaultRolloverStrategy.java
index ea1bae76696..74fbbf8359d 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/DefaultRolloverStrategy.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/DefaultRolloverStrategy.java
@@ -109,6 +109,9 @@ public static class Builder implements org.apache.logging.log4j.core.util.Builde
@PluginBuilderAttribute(value = "tempCompressedFilePattern")
private String tempCompressedFilePattern;
+ @PluginBuilderAttribute("maxCompressionDelaySeconds")
+ private int maxCompressionDelaySeconds = 0;
+
@PluginConfiguration
private Configuration config;
@@ -156,7 +159,8 @@ public DefaultRolloverStrategy build() {
nonNullStrSubstitutor,
customActions,
stopCustomActionsOnError,
- tempCompressedFilePattern);
+ tempCompressedFilePattern,
+ maxCompressionDelaySeconds);
}
public String getMax() {
@@ -359,6 +363,18 @@ public Builder withConfig(final Configuration config) {
this.config = config;
return this;
}
+
+ /**
+ * Defines maximum delay in seconds before compression.
+ *
+ * @param maxCompressionDelaySeconds maximum delay in seconds before compression.
+ * @return This builder for chaining convenience
+ * @since 2.27.0
+ */
+ public Builder setMaxCompressionDelaySeconds(final int maxCompressionDelaySeconds) {
+ this.maxCompressionDelaySeconds = maxCompressionDelaySeconds;
+ return this;
+ }
}
@PluginBuilderFactory
@@ -380,10 +396,14 @@ public static Builder newBuilder() {
* @return A DefaultRolloverStrategy.
* @deprecated Since 2.9 Usage of Builder API is preferable
*/
+
+ /**
+ * Legacy factory method for backward compatibility (no delay parameter).
+ * @deprecated Since 2.9 Usage of Builder API is preferable
+ */
@PluginFactory
@Deprecated
public static DefaultRolloverStrategy createStrategy(
- // @formatter:off
@PluginAttribute("max") final String max,
@PluginAttribute("min") final String min,
@PluginAttribute("fileIndex") final String fileIndex,
@@ -401,7 +421,6 @@ public static DefaultRolloverStrategy createStrategy(
.setStopCustomActionsOnError(stopCustomActionsOnError)
.setConfig(config)
.build();
- // @formatter:on
}
/**
@@ -419,6 +438,7 @@ public static DefaultRolloverStrategy createStrategy(
private final List customActions;
private final boolean stopCustomActionsOnError;
private final PatternProcessor tempCompressedFilePattern;
+ private final int maxCompressionDelaySeconds;
/**
* Constructs a new instance.
@@ -446,7 +466,8 @@ protected DefaultRolloverStrategy(
strSubstitutor,
customActions,
stopCustomActionsOnError,
- null);
+ null,
+ 0);
}
/**
@@ -468,6 +489,40 @@ protected DefaultRolloverStrategy(
final Action[] customActions,
final boolean stopCustomActionsOnError,
final String tempCompressedFilePatternString) {
+ this(
+ minIndex,
+ maxIndex,
+ useMax,
+ compressionLevel,
+ strSubstitutor,
+ customActions,
+ stopCustomActionsOnError,
+ tempCompressedFilePatternString,
+ 0);
+ }
+
+ /**
+ * Constructs a new instance.
+ *
+ * @param minIndex The minimum index.
+ * @param maxIndex The maximum index.
+ * @param customActions custom actions to perform asynchronously after rollover
+ * @param stopCustomActionsOnError whether to stop executing asynchronous actions if an error occurs
+ * @param tempCompressedFilePatternString File pattern of the working file
+ * used during compression, if null no temporary file are used
+ * @param maxCompressionDelaySeconds maximum delay in seconds before compression.
+ * @since 2.27.0
+ */
+ protected DefaultRolloverStrategy(
+ final int minIndex,
+ final int maxIndex,
+ final boolean useMax,
+ final int compressionLevel,
+ final StrSubstitutor strSubstitutor,
+ final Action[] customActions,
+ final boolean stopCustomActionsOnError,
+ final String tempCompressedFilePatternString,
+ final int maxCompressionDelaySeconds) {
super(strSubstitutor);
this.minIndex = minIndex;
this.maxIndex = maxIndex;
@@ -477,6 +532,7 @@ protected DefaultRolloverStrategy(
this.customActions = customActions == null ? Collections.emptyList() : Arrays.asList(customActions);
this.tempCompressedFilePattern =
tempCompressedFilePatternString != null ? new PatternProcessor(tempCompressedFilePatternString) : null;
+ this.maxCompressionDelaySeconds = maxCompressionDelaySeconds;
}
public int getCompressionLevel() {
@@ -507,6 +563,16 @@ public PatternProcessor getTempCompressedFilePattern() {
return tempCompressedFilePattern;
}
+ /**
+ * Returns the maximum delay in seconds before compression.
+ *
+ * @return maximum delay in seconds before compression.
+ * @since 2.27.0
+ */
+ public int getMaxCompressionDelaySeconds() {
+ return maxCompressionDelaySeconds;
+ }
+
private int purge(final int lowIndex, final int highIndex, final RollingFileManager manager) {
return useMax ? purgeAscending(lowIndex, highIndex, manager) : purgeDescending(lowIndex, highIndex, manager);
}
@@ -680,11 +746,17 @@ public RolloverDescription rollover(final RollingFileManager manager) throws Sec
}
compressAction = new CompositeAction(
Arrays.asList(
- fileExtension.createCompressAction(renameTo, tmpCompressedName, true, compressionLevel),
+ fileExtension.createCompressAction(
+ renameTo,
+ tmpCompressedName,
+ true,
+ compressionLevel,
+ maxCompressionDelaySeconds),
new FileRenameAction(tmpCompressedNameFile, renameToFile, true)),
true);
} else {
- compressAction = fileExtension.createCompressAction(renameTo, compressedName, true, compressionLevel);
+ compressAction = fileExtension.createCompressAction(
+ renameTo, compressedName, true, compressionLevel, maxCompressionDelaySeconds);
}
}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/FileExtension.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/FileExtension.java
index e62419b6858..eb453628e86 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/FileExtension.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/FileExtension.java
@@ -35,7 +35,22 @@ public Action createCompressAction(
final String compressedName,
final boolean deleteSource,
final int compressionLevel) {
- return new ZipCompressAction(source(renameTo), target(compressedName), deleteSource, compressionLevel);
+ return createCompressAction(renameTo, compressedName, deleteSource, compressionLevel, 0);
+ }
+
+ @Override
+ public Action createCompressAction(
+ final String renameTo,
+ final String compressedName,
+ final boolean deleteSource,
+ final int compressionLevel,
+ final int maxCompressionDelaySeconds) {
+ return new ZipCompressAction(
+ new File(renameTo),
+ new File(compressedName),
+ deleteSource,
+ compressionLevel,
+ maxCompressionDelaySeconds);
}
},
GZ(".gz") {
@@ -45,7 +60,22 @@ public Action createCompressAction(
final String compressedName,
final boolean deleteSource,
final int compressionLevel) {
- return new GzCompressAction(source(renameTo), target(compressedName), deleteSource, compressionLevel);
+ return createCompressAction(renameTo, compressedName, deleteSource, compressionLevel, 0);
+ }
+
+ @Override
+ public Action createCompressAction(
+ final String renameTo,
+ final String compressedName,
+ final boolean deleteSource,
+ final int compressionLevel,
+ final int maxCompressionDelaySeconds) {
+ return new GzCompressAction(
+ new File(renameTo),
+ new File(compressedName),
+ deleteSource,
+ compressionLevel,
+ maxCompressionDelaySeconds);
}
},
BZIP2(".bz2") {
@@ -55,8 +85,19 @@ public Action createCompressAction(
final String compressedName,
final boolean deleteSource,
final int compressionLevel) {
+ return createCompressAction(renameTo, compressedName, deleteSource, compressionLevel, 0);
+ }
+
+ @Override
+ public Action createCompressAction(
+ final String renameTo,
+ final String compressedName,
+ final boolean deleteSource,
+ final int compressionLevel,
+ final int maxCompressionDelaySeconds) {
// One of "gz", "bzip2", "xz", "zst", "pack200", or "deflate".
- return new CommonsCompressAction("bzip2", source(renameTo), target(compressedName), deleteSource);
+ return new CommonsCompressAction(
+ "bzip2", source(renameTo), target(compressedName), deleteSource, maxCompressionDelaySeconds);
}
},
DEFLATE(".deflate") {
@@ -66,8 +107,19 @@ public Action createCompressAction(
final String compressedName,
final boolean deleteSource,
final int compressionLevel) {
+ return createCompressAction(renameTo, compressedName, deleteSource, compressionLevel, 0);
+ }
+
+ @Override
+ public Action createCompressAction(
+ final String renameTo,
+ final String compressedName,
+ final boolean deleteSource,
+ final int compressionLevel,
+ final int maxCompressionDelaySeconds) {
// One of "gz", "bzip2", "xz", "zst", "pack200", or "deflate".
- return new CommonsCompressAction("deflate", source(renameTo), target(compressedName), deleteSource);
+ return new CommonsCompressAction(
+ "deflate", source(renameTo), target(compressedName), deleteSource, maxCompressionDelaySeconds);
}
},
PACK200(".pack200") {
@@ -77,8 +129,19 @@ public Action createCompressAction(
final String compressedName,
final boolean deleteSource,
final int compressionLevel) {
- // One of "gz", "bzip2", "xz", "zst", "pack200", or "deflate".
- return new CommonsCompressAction("pack200", source(renameTo), target(compressedName), deleteSource);
+ return createCompressAction(renameTo, compressedName, deleteSource, compressionLevel, 0);
+ }
+
+ @Override
+ public Action createCompressAction(
+ final String renameTo,
+ final String compressedName,
+ final boolean deleteSource,
+ final int compressionLevel,
+ final int maxCompressionDelaySeconds) {
+ // One of "gz", "bzip2", "xz", "zstd", "pack200", or "deflate".
+ return new CommonsCompressAction(
+ "pack200", source(renameTo), target(compressedName), deleteSource, maxCompressionDelaySeconds);
}
},
XZ(".xz") {
@@ -88,8 +151,19 @@ public Action createCompressAction(
final String compressedName,
final boolean deleteSource,
final int compressionLevel) {
+ return createCompressAction(renameTo, compressedName, deleteSource, compressionLevel, 0);
+ }
+
+ @Override
+ public Action createCompressAction(
+ final String renameTo,
+ final String compressedName,
+ final boolean deleteSource,
+ final int compressionLevel,
+ final int maxCompressionDelaySeconds) {
// One of "gz", "bzip2", "xz", "zstd", "pack200", or "deflate".
- return new CommonsCompressAction("xz", source(renameTo), target(compressedName), deleteSource);
+ return new CommonsCompressAction(
+ "xz", source(renameTo), target(compressedName), deleteSource, maxCompressionDelaySeconds);
}
},
ZSTD(".zst") {
@@ -99,8 +173,19 @@ public Action createCompressAction(
final String compressedName,
final boolean deleteSource,
final int compressionLevel) {
+ return createCompressAction(renameTo, compressedName, deleteSource, compressionLevel, 0);
+ }
+
+ @Override
+ public Action createCompressAction(
+ final String renameTo,
+ final String compressedName,
+ final boolean deleteSource,
+ final int compressionLevel,
+ final int maxCompressionDelaySeconds) {
// One of "gz", "bzip2", "xz", "zstd", "pack200", or "deflate".
- return new CommonsCompressAction("zstd", source(renameTo), target(compressedName), deleteSource);
+ return new CommonsCompressAction(
+ "zstd", source(renameTo), target(compressedName), deleteSource, maxCompressionDelaySeconds);
}
};
@@ -132,6 +217,13 @@ public static FileExtension lookupForFile(final String fileName) {
public abstract Action createCompressAction(
String renameTo, String compressedName, boolean deleteSource, int compressionLevel);
+ public abstract Action createCompressAction(
+ String renameTo,
+ String compressedName,
+ boolean deleteSource,
+ int compressionLevel,
+ int maxCompressionDelaySeconds);
+
public String getExtension() {
return extension;
}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/AbstractAction.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/AbstractAction.java
index 2ca052835ae..14258eec3f0 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/AbstractAction.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/AbstractAction.java
@@ -82,6 +82,25 @@ public synchronized void close() {
interrupted = true;
}
+ /**
+ * Blocks the current thread for a random delay up to {@code maxDelaySeconds}.
+ *
+ * @param maxDelaySeconds maximum delay in seconds before returning.
+ * @since 2.27.0
+ */
+ static void blockThread(final int maxDelaySeconds) {
+ if (maxDelaySeconds > 0) {
+ int delay = java.util.concurrent.ThreadLocalRandom.current().nextInt(maxDelaySeconds + 1);
+ if (delay > 0) {
+ try {
+ Thread.sleep(delay * 1000L);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+ }
+
/**
* Tests if the action is complete.
*
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/CommonsCompressAction.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/CommonsCompressAction.java
index 16889695782..c01b2cb9928 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/CommonsCompressAction.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/CommonsCompressAction.java
@@ -54,6 +54,11 @@ public final class CommonsCompressAction extends AbstractAction {
*/
private final boolean deleteSource;
+ /**
+ * Maximum delay in seconds before compression.
+ */
+ private final int maxDelaySeconds;
+
/**
* Creates new instance of Bzip2CompressAction.
*
@@ -65,12 +70,33 @@ public final class CommonsCompressAction extends AbstractAction {
*/
public CommonsCompressAction(
final String name, final File source, final File destination, final boolean deleteSource) {
+ this(name, source, destination, deleteSource, 0);
+ }
+
+ /**
+ * Creates new instance of Bzip2CompressAction.
+ *
+ * @param name the compressor name. One of "gz", "bzip2", "xz", "zst", "pack200", or "deflate".
+ * @param source file to compress, may not be null.
+ * @param destination compressed file, may not be null.
+ * @param deleteSource if true, attempt to delete file on completion. Failure to delete does not cause an exception
+ * to be thrown or affect return value.
+ * @param maxDelaySeconds maximum delay in seconds before compression.
+ * @since 2.27.0
+ */
+ public CommonsCompressAction(
+ final String name,
+ final File source,
+ final File destination,
+ final boolean deleteSource,
+ final int maxDelaySeconds) {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(destination, "destination");
this.name = name;
this.source = source;
this.destination = destination;
this.deleteSource = deleteSource;
+ this.maxDelaySeconds = maxDelaySeconds;
}
/**
@@ -81,6 +107,7 @@ public CommonsCompressAction(
*/
@Override
public boolean execute() throws IOException {
+ blockThread(maxDelaySeconds);
return execute(name, source, destination, deleteSource);
}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/GzCompressAction.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/GzCompressAction.java
index acad1f5116f..5747c638cd9 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/GzCompressAction.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/GzCompressAction.java
@@ -55,6 +55,26 @@ public final class GzCompressAction extends AbstractAction {
*/
private final int compressionLevel;
+ /**
+ * Maximum delay in seconds before compression.
+ */
+ private final int maxDelaySeconds;
+
+ private static int checkCompressionLevel(final int compressionLevel) {
+ final int minCompressionLevel = Deflater.DEFAULT_COMPRESSION;
+ final int maxCompressionLevel = Deflater.BEST_COMPRESSION;
+
+ if (compressionLevel < minCompressionLevel || compressionLevel > maxCompressionLevel) {
+ throw new IllegalArgumentException("GZIP compression level must be in the range ["
+ + minCompressionLevel
+ + ", "
+ + maxCompressionLevel
+ + "], got: "
+ + compressionLevel);
+ }
+ return compressionLevel;
+ }
+
/**
* Create new instance of GzCompressAction.
*
@@ -64,26 +84,46 @@ public final class GzCompressAction extends AbstractAction {
* does not cause an exception to be thrown or affect return value.
* @param compressionLevel
* Gzip deflater compression level.
+ * @since 2.27.0
+ * @param maxDelaySeconds
+ * Maximum delay in seconds before compression.
*/
public GzCompressAction(
- final File source, final File destination, final boolean deleteSource, final int compressionLevel) {
+ final File source,
+ final File destination,
+ final boolean deleteSource,
+ final int compressionLevel,
+ final int maxDelaySeconds) {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(destination, "destination");
this.source = source;
this.destination = destination;
this.deleteSource = deleteSource;
- this.compressionLevel = compressionLevel;
+ this.compressionLevel = checkCompressionLevel(compressionLevel);
+ this.maxDelaySeconds = maxDelaySeconds;
+ }
+
+ /**
+ * Creates a new instance.
+ * @param source file to compress, may not be null.
+ * @param destination compressed file, may not be null.
+ * @param deleteSource if true, attempt to delete file on completion.
+ * @param compressionLevel Gzip deflater compression level.
+ */
+ public GzCompressAction(
+ final File source, final File destination, final boolean deleteSource, final int compressionLevel) {
+ this(source, destination, deleteSource, compressionLevel, 0);
}
/**
* Prefer the constructor with compression level.
*
- * @deprecated Prefer {@link GzCompressAction#GzCompressAction(File, File, boolean, int)}.
+ * @deprecated Prefer {@link GzCompressAction#GzCompressAction(File, File, boolean, int, int)}.
*/
@Deprecated
public GzCompressAction(final File source, final File destination, final boolean deleteSource) {
- this(source, destination, deleteSource, Deflater.DEFAULT_COMPRESSION);
+ this(source, destination, deleteSource, Deflater.DEFAULT_COMPRESSION, 0);
}
/**
@@ -94,6 +134,7 @@ public GzCompressAction(final File source, final File destination, final boolean
*/
@Override
public boolean execute() throws IOException {
+ blockThread(maxDelaySeconds);
return execute(source, destination, deleteSource, compressionLevel);
}
@@ -129,6 +170,7 @@ public static boolean execute(final File source, final File destination, final b
public static boolean execute(
final File source, final File destination, final boolean deleteSource, final int compressionLevel)
throws IOException {
+ checkCompressionLevel(compressionLevel);
if (source.exists()) {
try (final FileInputStream fis = new FileInputStream(source);
final OutputStream fos = new FileOutputStream(destination);
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/ZipCompressAction.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/ZipCompressAction.java
index f29a7391ceb..d8e7c397387 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/ZipCompressAction.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/ZipCompressAction.java
@@ -51,6 +51,25 @@ public final class ZipCompressAction extends AbstractAction {
*/
private final int level;
+ /**
+ * Maximum delay in seconds before compression.
+ */
+ private final int maxDelaySeconds;
+
+ /**
+ * Validates that the compression level is in the valid range [-1, 9].
+ *
+ * @param level the compression level to validate
+ * @return the level if valid
+ * @throws IllegalArgumentException if level is not in the range [-1, 9]
+ */
+ private static int checkLevel(final int level) {
+ if (level < java.util.zip.Deflater.DEFAULT_COMPRESSION || level > 9) {
+ throw new IllegalArgumentException("Compression level must be in the range [-1, 9], got: " + level);
+ }
+ return level;
+ }
+
/**
* Creates new instance of GzCompressAction.
*
@@ -58,16 +77,37 @@ public final class ZipCompressAction extends AbstractAction {
* @param destination compressed file, may not be null.
* @param deleteSource if true, attempt to delete file on completion. Failure to delete does not cause an exception
* to be thrown or affect return value.
- * @param level TODO
+ * @param level the compression level
+ * @param maxDelaySeconds maximum delay in seconds before compression.
+ * @since 2.27.0
*/
- public ZipCompressAction(final File source, final File destination, final boolean deleteSource, final int level) {
+ public ZipCompressAction(
+ final File source,
+ final File destination,
+ final boolean deleteSource,
+ final int level,
+ final int maxDelaySeconds) {
Objects.requireNonNull(source, "source");
Objects.requireNonNull(destination, "destination");
this.source = source;
this.destination = destination;
this.deleteSource = deleteSource;
- this.level = level;
+ this.level = checkLevel(level);
+ this.maxDelaySeconds = maxDelaySeconds;
+ }
+
+ /**
+ * Creates new instance.
+ *
+ * @param source file to compress, may not be null.
+ * @param destination compressed file, may not be null.
+ * @param deleteSource if true, attempt to delete file on completion. Failure to delete does not cause an exception
+ * to be thrown or affect return value.
+ * @param level the compression level
+ */
+ public ZipCompressAction(final File source, final File destination, final boolean deleteSource, final int level) {
+ this(source, destination, deleteSource, level, 0);
}
/**
@@ -78,6 +118,7 @@ public ZipCompressAction(final File source, final File destination, final boolea
*/
@Override
public boolean execute() throws IOException {
+ blockThread(maxDelaySeconds);
return execute(source, destination, deleteSource, level);
}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/package-info.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/package-info.java
index 37370530300..85222541adc 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/package-info.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/package-info.java
@@ -18,7 +18,7 @@
* Support classes for the Rolling File Appender.
*/
@Export
-@Version("2.26.0")
+@Version("2.27.0")
package org.apache.logging.log4j.core.appender.rolling.action;
import org.osgi.annotation.bundle.Export;
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/package-info.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/package-info.java
index 9f4ea078048..21a4fd72e43 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/package-info.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/package-info.java
@@ -18,7 +18,7 @@
* Rolling File Appender and support classes.
*/
@Export
-@Version("2.26.0")
+@Version("2.27.0")
package org.apache.logging.log4j.core.appender.rolling;
import org.osgi.annotation.bundle.Export;
diff --git a/src/changelog/.2.x.x/4012_add_max_compression_delay.xml b/src/changelog/.2.x.x/4012_add_max_compression_delay.xml
new file mode 100644
index 00000000000..39cc883a15d
--- /dev/null
+++ b/src/changelog/.2.x.x/4012_add_max_compression_delay.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Added support for `maxCompressionDelaySeconds` in compression actions to proactively defer compression and reduce disk I/O pressure during rollover.
+
+
+
diff --git a/src/site/antora/modules/ROOT/pages/manual/appenders/rolling-file.adoc b/src/site/antora/modules/ROOT/pages/manual/appenders/rolling-file.adoc
index 1982182fd20..8dd9c91c0c1 100644
--- a/src/site/antora/modules/ROOT/pages/manual/appenders/rolling-file.adoc
+++ b/src/site/antora/modules/ROOT/pages/manual/appenders/rolling-file.adoc
@@ -713,6 +713,15 @@ Minimum value for the <> conversion pattern.
Maximum value for the <> conversion pattern.
This attribute is **ignored** if <> is set to `nomax`.
+
+| [[DefaultRolloverStrategy-attr-maxCompressionDelaySeconds]]maxCompressionDelaySeconds
+| `int`
+| `0`
+|
+Maximum random delay, in seconds, before compressing archived log files.
+
+Use this attribute to spread compression workload when many applications roll over at the same time.
+A value of `0` disables the delay and starts compression immediately.
|===
xref:plugin-reference.adoc#org-apache-logging-log4j_log4j-core_org-apache-logging-log4j-core-appender-rolling-DefaultRolloverStrategy[{plugin-reference-marker} Plugin reference for `DefaultRolloverStrategy`]
From 7fe84f922e28ebf1a75b644b07bcef2074df6561 Mon Sep 17 00:00:00 2001
From: Ramanathan
Date: Fri, 29 May 2026 16:54:39 +0530
Subject: [PATCH 10/28] Improve logging for `LinkageError` scenarios involving
the LMAX Disruptor library (#4124)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Volkan Yazıcı
---
.../log4j/core/async/DisruptorUtilTest.java | 63 +++++++++++++++++++
.../log4j/core/async/DisruptorUtil.java | 10 ++-
...ix-log-disruptor-initialization-errors.xml | 13 ++++
3 files changed, 83 insertions(+), 3 deletions(-)
create mode 100644 log4j-core-test/src/test/java/org/apache/logging/log4j/core/async/DisruptorUtilTest.java
create mode 100644 src/changelog/.2.x.x/fix-log-disruptor-initialization-errors.xml
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/async/DisruptorUtilTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/async/DisruptorUtilTest.java
new file mode 100644
index 00000000000..4bd10487157
--- /dev/null
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/async/DisruptorUtilTest.java
@@ -0,0 +1,63 @@
+/*
+ * 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.async;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.status.StatusData;
+import org.apache.logging.log4j.test.ListStatusListener;
+import org.apache.logging.log4j.test.junit.UsingStatusListener;
+import org.junit.jupiter.api.Test;
+
+@UsingStatusListener
+class DisruptorUtilTest {
+
+ @Test
+ void detectDisruptorMajorVersion_returnsValidVersion() throws Exception {
+ final Method method = DisruptorUtil.class.getDeclaredMethod("detectDisruptorMajorVersion");
+ method.setAccessible(true);
+ final int detectedVersion = (int) method.invoke(null);
+
+ assertThat(detectedVersion).isIn(3, 4);
+ }
+
+ @Test
+ void detectDisruptorMajorVersion_logsVersionDetection(final ListStatusListener statusListener) throws Exception {
+ final Method method = DisruptorUtil.class.getDeclaredMethod("detectDisruptorMajorVersion");
+ method.setAccessible(true);
+ final int detectedVersion = (int) method.invoke(null);
+
+ final List debugData =
+ statusListener.findStatusData(Level.DEBUG).collect(Collectors.toList());
+
+ if (detectedVersion == 4) {
+ // v4 path: ClassNotFoundException caught, falls through to LOGGER.debug()
+ assertThat(debugData)
+ .anySatisfy(data -> assertThat(data.getMessage().getFormattedMessage())
+ .contains("LMAX Disruptor version detected: 4"));
+ } else {
+ // v3 path: early `return 3` inside try — LOGGER.debug() is never reached
+ assertThat(debugData)
+ .noneSatisfy(data -> assertThat(data.getMessage().getFormattedMessage())
+ .contains("LMAX Disruptor version detected:"));
+ }
+ }
+}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/async/DisruptorUtil.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/async/DisruptorUtil.java
index f115e2ae378..11479928f61 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/async/DisruptorUtil.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/async/DisruptorUtil.java
@@ -55,13 +55,17 @@ final class DisruptorUtil {
// TODO: replace with LoaderUtil.isClassAvailable() when TCCL is removed
// See: https://github.com/apache/logging-log4j2/issues/3706
private static int detectDisruptorMajorVersion() {
+ int version = 4;
try {
Class.forName(
- "com.lmax.disruptor.SequenceReportingEventHandler", true, DisruptorUtil.class.getClassLoader());
+ "com.lmax.disruptor.SequenceReportingEventHandler", false, DisruptorUtil.class.getClassLoader());
+ version = 3;
return 3;
- } catch (final ClassNotFoundException e) {
- return 4;
+ } catch (final ClassNotFoundException ignored) {
+ // Do nothing
}
+ LOGGER.debug("LMAX Disruptor version detected: {}", version);
+ return version;
}
private DisruptorUtil() {}
diff --git a/src/changelog/.2.x.x/fix-log-disruptor-initialization-errors.xml b/src/changelog/.2.x.x/fix-log-disruptor-initialization-errors.xml
new file mode 100644
index 00000000000..c47b11fb809
--- /dev/null
+++ b/src/changelog/.2.x.x/fix-log-disruptor-initialization-errors.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Improve logging for `LinkageError` scenarios involving the LMAX Disruptor library
+
+
From ba691e01b3f8caa06e501d7e1c42b7cc1715ad3d Mon Sep 17 00:00:00 2001
From: Ramanathan
Date: Mon, 1 Jun 2026 19:44:58 +0530
Subject: [PATCH 11/28] Fix changelog issue and PR references for Disruptor
initialization error logging (#2250, #4124, #4134)
---
.../.2.x.x/fix-log-disruptor-initialization-errors.xml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/changelog/.2.x.x/fix-log-disruptor-initialization-errors.xml b/src/changelog/.2.x.x/fix-log-disruptor-initialization-errors.xml
index c47b11fb809..defea45980e 100644
--- a/src/changelog/.2.x.x/fix-log-disruptor-initialization-errors.xml
+++ b/src/changelog/.2.x.x/fix-log-disruptor-initialization-errors.xml
@@ -5,8 +5,8 @@
https://logging.apache.org/xml/ns
https://logging.apache.org/xml/ns/log4j-changelog-0.xsd"
type="changed">
-
-
+
+
Improve logging for `LinkageError` scenarios involving the LMAX Disruptor library
From b02c79d5c2baf3ce6f560c3f7a4de84c4d1a0100 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Volkan=20Yaz=C4=B1c=C4=B1?=
Date: Fri, 5 Jun 2026 10:13:02 +0200
Subject: [PATCH 12/28] Fix encoding of `MSGID` and `SD-ID` fields of
`StructuredDataMessage` to XML (#4136)
---
.../message/StructuredDataMessageTest.java | 58 +++++++++++++++++++
.../log4j/message/StructuredDataMessage.java | 20 ++++++-
...fix-StructuredDataMessage-XML-encoding.xml | 12 ++++
3 files changed, 88 insertions(+), 2 deletions(-)
create mode 100644 src/changelog/.2.x.x/fix-StructuredDataMessage-XML-encoding.xml
diff --git a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/StructuredDataMessageTest.java b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/StructuredDataMessageTest.java
index f02d95eefef..3d3f403bef7 100644
--- a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/StructuredDataMessageTest.java
+++ b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/StructuredDataMessageTest.java
@@ -73,6 +73,64 @@ void testMsgXml() {
assertEquals(expected, result);
}
+ @Test
+ void testXmlEncodingOfIdAndType1() {
+ final String id = "i<&d>" + XmlFixture.TEXT;
+ final String type = "t>ypt>yp<e&" + XmlFixture.ENCODED_TEXT
+ + "\n"
+ + "i<&d>" + XmlFixture.ENCODED_TEXT
+ + "\n"
+ // Following part is encoded by `MapMessage::asXml`, hence, fuzzed & tested elsewhere
+ + "\n"
+ + "\n";
+ assertEquals(expectedXml, actualXml);
+ }
+
+ @Test
+ void testXmlEncodingOfIdAndType2() {
+ final String idName = "id&<-name>" + XmlFixture.TEXT;
+ final String idEnterpriseNumber = "id&<-enterprise-number>" + XmlFixture.TEXT;
+ final String[] idRequired = {"id&<-required>" + XmlFixture.TEXT};
+ final String[] idOptional = {"id&<-optional>" + XmlFixture.TEXT};
+ final String type = "t>ypt>yp<e&" + XmlFixture.ENCODED_TEXT
+ + "\n"
+ + "" + "id&<-name>" + XmlFixture.ENCODED_TEXT
+ + "@id&<-enterprise-number>" + XmlFixture.ENCODED_TEXT
+ + "\n"
+ // Following part is encoded by `MapMessage::asXml`, hence, fuzzed & tested elsewhere
+ + "\n"
+ + "\n";
+ assertEquals(expectedXml, actualXml);
+ }
+
+ private enum XmlFixture {
+ ;
+
+ private static final String TEXT;
+
+ private static final String ENCODED_TEXT;
+
+ static {
+ final String notBmp = new String(Character.toChars(0x10000));
+ final String invalid = "A\uD800B\uDE00C\0\1\2\3";
+ final String encodedInvalid = "A\uFFFDB\uFFFDC\uFFFD\uFFFD\uFFFD\uFFFD";
+ TEXT = " '\"\t\r\n" + notBmp + invalid;
+ ENCODED_TEXT = " '"\t\r\n" + notBmp + encodedInvalid;
+ }
+ }
+
@Test
void testBuilder() {
final String testMsg = "Test message {}";
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/message/StructuredDataMessage.java b/log4j-api/src/main/java/org/apache/logging/log4j/message/StructuredDataMessage.java
index 46b757d54f2..69fa3fb8cbe 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/message/StructuredDataMessage.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/message/StructuredDataMessage.java
@@ -356,10 +356,26 @@ public final void asString(final Format format, final StructuredDataId structure
}
private void asXml(final StructuredDataId structuredDataId, final StringBuilder sb) {
+
sb.append("\n");
- sb.append("").append(type).append("\n");
- sb.append("").append(structuredDataId).append("\n");
+
+ // Encode type
+ sb.append("");
+ int start = sb.length();
+ sb.append(type);
+ StringBuilders.escapeXml(sb, start);
+ sb.append("\n");
+
+ // Encode ID
+ sb.append("");
+ start = sb.length();
+ structuredDataId.formatTo(sb);
+ StringBuilders.escapeXml(sb, start);
+ sb.append("\n");
+
+ // Encode the rest
super.asXml(sb);
+
sb.append("\n\n");
}
diff --git a/src/changelog/.2.x.x/fix-StructuredDataMessage-XML-encoding.xml b/src/changelog/.2.x.x/fix-StructuredDataMessage-XML-encoding.xml
new file mode 100644
index 00000000000..ae324c0d21d
--- /dev/null
+++ b/src/changelog/.2.x.x/fix-StructuredDataMessage-XML-encoding.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ Fix encoding of `MSGID` and `SD-ID` fields of `StructuredDataMessage` to XML
+
+
From fe388079a65c0e3a85d584ac71c2af90669567aa Mon Sep 17 00:00:00 2001
From: Ramanathan
Date: Thu, 11 Jun 2026 17:45:03 +0530
Subject: [PATCH 13/28] Fix stack trace rendering for exceptions with identity
malfunction (#4133)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Fix circular reference detection for exceptions with colliding equals/hashCode implementations
* Add changelog entry for circular reference detection fix with colliding equals/hashCode exceptions
* Add changelog entry for circular reference detection fix with colliding equals/hashCode exceptions
* Add test for ThrowableProxy serialization with colliding equals/hashCode implementations
* Refactor ThrowableProxy serialization test to use modern Java I/O classes
* Use `TestFriendlyException` to exercise the malfunction
* Update changelog
* Improve comments
* Remove redundant change
---------
Co-authored-by: Volkan Yazıcı
---
.../test/java/foo/TestFriendlyException.java | 76 +++++++++++++++++--
.../log4j/core/impl/ThrowableProxy.java | 14 +++-
.../pattern/ThrowableStackTraceRenderer.java | 16 ++--
...etection-for-exceptions-with-colliding.xml | 13 ++++
4 files changed, 104 insertions(+), 15 deletions(-)
create mode 100644 src/changelog/.2.x.x/fix-circular-reference-detection-for-exceptions-with-colliding.xml
diff --git a/log4j-core-test/src/test/java/foo/TestFriendlyException.java b/log4j-core-test/src/test/java/foo/TestFriendlyException.java
index 7c791dc20d2..c024db10aed 100644
--- a/log4j-core-test/src/test/java/foo/TestFriendlyException.java
+++ b/log4j-core-test/src/test/java/foo/TestFriendlyException.java
@@ -20,6 +20,12 @@
import java.net.Socket;
import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedList;
+import java.util.Queue;
+import java.util.Set;
import java.util.stream.Stream;
import org.apache.logging.log4j.util.Constants;
@@ -33,6 +39,7 @@
*
Suppressed exceptions
*
Clutter-free stack trace (i.e., elements from JUnit, JDK, etc.)
*
Stack trace elements from named modules3
+ *
Exceptions with malfunctioning (e.g., colliding) {@link Object#equals(Object) equals()} and {@link Object#hashCode() hashCode()} implementations in the causal chain
*
*
* 1 Helps with observing stack trace manipulation effects of Log4j.
@@ -80,20 +87,63 @@ private static StackTraceElement namedModuleStackTraceElement() {
"java.lang", "jdk.internal", "org.junit", "sun.reflect"
};
- public static final TestFriendlyException INSTANCE = create("r", 0, 2, new boolean[] {false}, new boolean[] {true});
+ public static final TestFriendlyException INSTANCE =
+ create("r", 0, 2, new boolean[] {false}, new boolean[] {true}, new int[] {5});
+
+ static {
+ ensureIdentityMalfunctionAtDifferentDepths();
+ }
+
+ /**
+ * Ensure we have identity malfunctioning exceptions that have different stack trace lengths.
+ *
+ * @see #3933
+ */
+ private static void ensureIdentityMalfunctionAtDifferentDepths() {
+ final Set visitedExceptions = Collections.newSetFromMap(new IdentityHashMap<>());
+ final Set identityMalfunctioningExceptionStackTraceDepths = new HashSet<>();
+ final Queue exceptions = new LinkedList<>();
+ exceptions.add(INSTANCE);
+ while (!exceptions.isEmpty()) {
+
+ // Process the exception
+ final TestFriendlyException exception = exceptions.remove();
+ if (!visitedExceptions.add(exception) || !exception.identityMalfunctioning) {
+ continue;
+ }
+ identityMalfunctioningExceptionStackTraceDepths.add(exception.getStackTrace().length);
+
+ // Enqueue the cause
+ final TestFriendlyException cause = (TestFriendlyException) exception.getCause();
+ if (cause != null) {
+ exceptions.add(cause);
+ }
+
+ // Enqueue the suppressed
+ for (final Throwable suppressed : exception.getSuppressed()) {
+ exceptions.add((TestFriendlyException) suppressed);
+ }
+ }
+ assertThat(identityMalfunctioningExceptionStackTraceDepths)
+ .describedAs("# of visited exceptions = %s", visitedExceptions.size())
+ .hasSizeGreaterThan(1);
+ }
private static TestFriendlyException create(
final String name,
final int depth,
final int maxDepth,
final boolean[] circular,
- final boolean[] namedModuleAllowed) {
- final TestFriendlyException error = new TestFriendlyException(name, namedModuleAllowed);
+ final boolean[] namedModuleAllowed,
+ final int[] maxIdentityMalfunctionCount) {
+ final TestFriendlyException error =
+ new TestFriendlyException(name, namedModuleAllowed, maxIdentityMalfunctionCount);
if (depth < maxDepth) {
- final TestFriendlyException cause = create(name + "_c", depth + 1, maxDepth, circular, namedModuleAllowed);
+ final TestFriendlyException cause =
+ create(name + "_c", depth + 1, maxDepth, circular, namedModuleAllowed, maxIdentityMalfunctionCount);
error.initCause(cause);
final TestFriendlyException suppressed =
- create(name + "_s", depth + 1, maxDepth, circular, namedModuleAllowed);
+ create(name + "_s", depth + 1, maxDepth, circular, namedModuleAllowed, maxIdentityMalfunctionCount);
error.addSuppressed(suppressed);
final boolean circularAllowed = depth + 1 == maxDepth && !circular[0];
if (circularAllowed) {
@@ -105,8 +155,12 @@ private static TestFriendlyException create(
return error;
}
- private TestFriendlyException(final String message, final boolean[] namedModuleAllowed) {
+ private final boolean identityMalfunctioning;
+
+ private TestFriendlyException(
+ final String message, final boolean[] namedModuleAllowed, final int[] maxIdentityMalfunctionCount) {
super(message);
+ this.identityMalfunctioning = --maxIdentityMalfunctionCount[0] > 0;
removeExcludedStackTraceElements(namedModuleAllowed);
}
@@ -171,4 +225,14 @@ private static Stream namedModuleIncludedStackTraceElement(fi
public String getLocalizedMessage() {
return getMessage() + " [localized]";
}
+
+ @Override
+ public int hashCode() {
+ return identityMalfunctioning ? 0 : super.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ return identityMalfunctioning || super.equals(obj);
+ }
}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/ThrowableProxy.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/ThrowableProxy.java
index 61d292dc487..d051a4fc206 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/ThrowableProxy.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/ThrowableProxy.java
@@ -18,9 +18,10 @@
import java.io.Serializable;
import java.util.Arrays;
+import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
-import java.util.HashSet;
+import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -113,11 +114,16 @@ public ThrowableProxy(final Throwable throwable) {
this.extendedStackTrace =
ThrowableProxyHelper.toExtendedStackTrace(this, stack, map, null, throwable.getStackTrace());
final Throwable throwableCause = throwable.getCause();
- final Set causeVisited = new HashSet<>(1);
+ // `IdentityHashMap` is needed for exceptions with identity malfunction.
+ // Consider `equals()` and `hashCode()` implementations causing collisions.
+ final Set causeVisited = Collections.newSetFromMap(new IdentityHashMap<>(1));
+ final Set suppressedVisited =
+ visited == null ? Collections.newSetFromMap(new IdentityHashMap<>()) : visited;
+
this.causeProxy = throwableCause == null
? null
- : new ThrowableProxy(throwable, stack, map, throwableCause, visited, causeVisited);
- this.suppressedProxies = ThrowableProxyHelper.toSuppressedProxies(throwable, visited);
+ : new ThrowableProxy(throwable, stack, map, throwableCause, suppressedVisited, causeVisited);
+ this.suppressedProxies = ThrowableProxyHelper.toSuppressedProxies(throwable, suppressedVisited);
}
/**
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java
index 4d210213213..cafb2a2a936 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/ThrowableStackTraceRenderer.java
@@ -16,8 +16,8 @@
*/
package org.apache.logging.log4j.core.pattern;
-import java.util.HashMap;
-import java.util.HashSet;
+import java.util.Collections;
+import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -53,7 +53,10 @@ public final void renderThrowable(
if (maxLineCount > 0) {
try {
C context = createContext(throwable);
- renderThrowable(buffer, throwable, context, new HashSet<>(), lineSeparator);
+ // `IdentityHashMap` is needed for exceptions with identity malfunction.
+ // Consider `equals()` and `hashCode()` implementations causing collisions.
+ final Set visitedThrowables = Collections.newSetFromMap(new IdentityHashMap<>());
+ renderThrowable(buffer, throwable, context, visitedThrowables, lineSeparator);
} catch (final Exception error) {
if (error != MAX_LINE_COUNT_EXCEEDED) {
throw error;
@@ -292,8 +295,11 @@ private Metadata(
}
static Map ofThrowable(final Throwable throwable) {
- final Map metadataByThrowable = new HashMap<>();
- populateMetadata(metadataByThrowable, new HashSet<>(), null, throwable);
+ // `IdentityHashMap` is needed for exceptions with identity malfunction.
+ // Consider `equals()` and `hashCode()` implementations causing collisions.
+ final Map metadataByThrowable = new IdentityHashMap<>();
+ final Set visitedThrowables = Collections.newSetFromMap(new IdentityHashMap<>());
+ populateMetadata(metadataByThrowable, visitedThrowables, null, throwable);
return metadataByThrowable;
}
diff --git a/src/changelog/.2.x.x/fix-circular-reference-detection-for-exceptions-with-colliding.xml b/src/changelog/.2.x.x/fix-circular-reference-detection-for-exceptions-with-colliding.xml
new file mode 100644
index 00000000000..1633dcdb8ab
--- /dev/null
+++ b/src/changelog/.2.x.x/fix-circular-reference-detection-for-exceptions-with-colliding.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Fix stack trace rendering for exceptions with identity malfunction (e.g., colliding `equals()` and/or `hashCode()` implementations)
+
+
From f298ff1f90e72b86596948981edfbfea751b9356 Mon Sep 17 00:00:00 2001
From: Ramanathan
Date: Thu, 11 Jun 2026 21:37:56 +0530
Subject: [PATCH 14/28] Remove the `patternFlags` attribute from `RegexFilter`
(#4119)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Jeff Thomas
Co-authored-by: Volkan Yazıcı
---
.../log4j/core/filter/AbstractFilterTest.java | 1 -
.../core/filter/AbstractFilterableTest.java | 24 +-
.../log4j/core/filter/RegexFilterTest.java | 133 +++++-
.../log4j/core/filter/AbstractFilter.java | 28 ++
.../log4j/core/filter/RegexFilter.java | 393 ++++++++++++++----
.../log4j/core/filter/package-info.java | 2 +-
.../logging/log4j/core/util/Builder.java | 4 +
.../regexfilter_patternflags_builder.xml | 15 +
8 files changed, 469 insertions(+), 131 deletions(-)
create mode 100644 src/changelog/.2.x.x/regexfilter_patternflags_builder.xml
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/AbstractFilterTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/AbstractFilterTest.java
index 8996b9f2f16..fc42abe2937 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/AbstractFilterTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/AbstractFilterTest.java
@@ -34,7 +34,6 @@ class AbstractFilterTest {
@Test
void testUnrolledBackwardsCompatible() {
final ConcreteFilter filter = new ConcreteFilter();
- final Filter.Result expected = Filter.Result.DENY;
verifyMethodsWithUnrolledVarargs(filter, Filter.Result.DENY);
filter.testResult = Filter.Result.ACCEPT;
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/AbstractFilterableTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/AbstractFilterableTest.java
index 4f117a1ccf5..bd3d3f51b20 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/AbstractFilterableTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/AbstractFilterableTest.java
@@ -54,7 +54,7 @@ void testAddMultipleSimpleFilters() {
// into a CompositeFilter.class
filterable.addFilter(filter);
assertInstanceOf(CompositeFilter.class, filterable.getFilter());
- assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFilters().size());
+ assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFiltersArray().length);
}
@Test
@@ -67,7 +67,7 @@ void testAddMultipleEqualSimpleFilter() {
// into a CompositeFilter.class
filterable.addFilter(filter);
assertInstanceOf(CompositeFilter.class, filterable.getFilter());
- assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFilters().size());
+ assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFiltersArray().length);
}
@Test
@@ -93,7 +93,7 @@ void testAddMultipleCompositeFilters() {
// into a CompositeFilter.class
filterable.addFilter(compositeFilter);
assertInstanceOf(CompositeFilter.class, filterable.getFilter());
- assertEquals(6, ((CompositeFilter) filterable.getFilter()).getFilters().size());
+ assertEquals(6, ((CompositeFilter) filterable.getFilter()).getFiltersArray().length);
}
@Test
@@ -109,7 +109,7 @@ void testAddSimpleFilterAndCompositeFilter() {
// into a CompositeFilter.class
filterable.addFilter(compositeFilter);
assertInstanceOf(CompositeFilter.class, filterable.getFilter());
- assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFilters().size());
+ assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFiltersArray().length);
}
@Test
@@ -125,7 +125,7 @@ void testAddCompositeFilterAndSimpleFilter() {
// into a CompositeFilter.class
filterable.addFilter(notInCompositeFilterFilter);
assertInstanceOf(CompositeFilter.class, filterable.getFilter());
- assertEquals(3, ((CompositeFilter) filterable.getFilter()).getFilters().size());
+ assertEquals(3, ((CompositeFilter) filterable.getFilter()).getFiltersArray().length);
}
@Test
@@ -170,7 +170,7 @@ void testRemoveSimpleEqualFilterFromMultipleSimpleFilters() {
filterable.addFilter(filterCopy);
filterable.removeFilter(filterCopy);
assertInstanceOf(CompositeFilter.class, filterable.getFilter());
- assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFilters().size());
+ assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFiltersArray().length);
filterable.removeFilter(filterCopy);
assertEquals(filterOriginal, filterable.getFilter());
filterable.removeFilter(filterOriginal);
@@ -224,7 +224,7 @@ void testRemoveSimpleFilterFromCompositeAndSimpleFilter() {
// should not remove internal filter of compositeFilter
filterable.removeFilter(anotherFilter);
assertInstanceOf(CompositeFilter.class, filterable.getFilter());
- assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFilters().size());
+ assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFiltersArray().length);
}
@Test
@@ -247,9 +247,9 @@ void testRemoveFiltersFromComposite() {
filterable.addFilter(compositeFilter);
filterable.addFilter(anotherFilter);
- assertEquals(3, ((CompositeFilter) filterable.getFilter()).getFilters().size());
+ assertEquals(3, ((CompositeFilter) filterable.getFilter()).getFiltersArray().length);
filterable.removeFilter(filter1);
- assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFilters().size());
+ assertEquals(2, ((CompositeFilter) filterable.getFilter()).getFiltersArray().length);
filterable.removeFilter(filter2);
assertSame(anotherFilter, filterable.getFilter());
}
@@ -274,11 +274,7 @@ public boolean equals(final Object o) {
final EqualFilter that = (EqualFilter) o;
- if (key != null ? !key.equals(that.key) : that.key != null) {
- return false;
- }
-
- return true;
+ return key != null ? key.equals(that.key) : that.key == null;
}
@Override
diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/RegexFilterTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/RegexFilterTest.java
index 671d998258b..0f5cc2231e1 100644
--- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/RegexFilterTest.java
+++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/filter/RegexFilterTest.java
@@ -19,8 +19,11 @@
import static org.hamcrest.CoreMatchers.equalTo;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.apache.logging.log4j.Level;
@@ -37,19 +40,23 @@
class RegexFilterTest {
@BeforeAll
static void before() {
- StatusLogger.getLogger().setLevel(Level.OFF);
+ StatusLogger.getLogger().getFallbackListener().setLevel(Level.OFF);
}
@Test
void testRegexFilterDoesNotThrowWithAllTheParametersExceptRegexEqualNull() {
assertDoesNotThrow(() -> {
- RegexFilter.createFilter(".* test .*", null, null, null, null);
+ RegexFilter.newBuilder().setRegex(".* test .*").build();
});
}
@Test
void testThresholds() throws Exception {
- RegexFilter filter = RegexFilter.createFilter(".* test .*", null, false, null, null);
+ RegexFilter filter = RegexFilter.newBuilder()
+ .setRegex(".* test .*")
+ .setUseRawMsg(false)
+ .build();
+ assertNotNull(filter);
filter.start();
assertTrue(filter.isStarted());
assertSame(
@@ -65,16 +72,16 @@ void testThresholds() throws Exception {
.setMessage(new SimpleMessage("test")) //
.build();
assertSame(Filter.Result.DENY, filter.filter(event));
- filter = RegexFilter.createFilter(null, null, false, null, null);
- assertNull(filter);
+ final RegexFilter.Builder filterBuilder = RegexFilter.newBuilder();
+ assertThrows(IllegalArgumentException.class, filterBuilder::build);
}
@Test
void testDotAllPattern() throws Exception {
final String singleLine = "test single line matches";
final String multiLine = "test multi line matches\nsome more lines";
- final RegexFilter filter = RegexFilter.createFilter(
- ".*line.*", new String[] {"DOTALL", "COMMENTS"}, false, Filter.Result.DENY, Filter.Result.ACCEPT);
+ final RegexFilter filter =
+ RegexFilter.createFilter("(?xs).*line.*", null, false, Filter.Result.DENY, Filter.Result.ACCEPT);
final Result singleLineResult = filter.filter(null, null, null, (Object) singleLine, null);
final Result multiLineResult = filter.filter(null, null, null, (Object) multiLine, null);
assertThat(singleLineResult, equalTo(Result.DENY));
@@ -82,9 +89,17 @@ void testDotAllPattern() throws Exception {
}
@Test
- void testNoMsg() throws Exception {
- final RegexFilter filter = RegexFilter.createFilter(".* test .*", null, false, null, null);
+ void testNoMsg() {
+
+ final RegexFilter filter = RegexFilter.newBuilder()
+ .setRegex(".* test .*")
+ .setUseRawMsg(false)
+ .build();
+
+ assertNotNull(filter);
+
filter.start();
+
assertTrue(filter.isStarted());
assertSame(Filter.Result.DENY, filter.filter(null, Level.DEBUG, null, (Object) null, null));
assertSame(Filter.Result.DENY, filter.filter(null, Level.DEBUG, null, (Message) null, null));
@@ -92,28 +107,100 @@ void testNoMsg() throws Exception {
}
@Test
- void testParameterizedMsg() throws Exception {
+ void testParameterizedMsg() {
final String msg = "params {} {}";
final Object[] params = {"foo", "bar"};
// match against raw message
- final RegexFilter rawFilter = RegexFilter.createFilter(
- "params \\{\\} \\{\\}",
- null,
- true, // useRawMsg
- Result.ACCEPT,
- Result.DENY);
+ final RegexFilter rawFilter = RegexFilter.newBuilder()
+ .setRegex("params \\{\\} \\{\\}")
+ .setUseRawMsg(true)
+ .setOnMatch(Result.ACCEPT)
+ .setOnMismatch(Result.DENY)
+ .build();
+
+ assertNotNull(rawFilter);
+
final Result rawResult = rawFilter.filter(null, null, null, msg, params);
assertThat(rawResult, equalTo(Result.ACCEPT));
// match against formatted message
- final RegexFilter fmtFilter = RegexFilter.createFilter(
- "params foo bar",
- null,
- false, // useRawMsg
- Result.ACCEPT,
- Result.DENY);
+ final RegexFilter fmtFilter = RegexFilter.newBuilder()
+ .setRegex("params foo bar")
+ .setUseRawMsg(false)
+ .setOnMatch(Result.ACCEPT)
+ .setOnMismatch(Result.DENY)
+ .build();
+
+ assertNotNull(fmtFilter);
+
final Result fmtResult = fmtFilter.filter(null, null, null, msg, params);
assertThat(fmtResult, equalTo(Result.ACCEPT));
}
+
+ /**
+ * A builder with no 'regex' expression should both be invalid and return null on 'build()'.
+ */
+ @Test
+ void testWithValidRegex() {
+
+ final String regex = "^[a-zA-Z0-9_]+$"; // matches alphanumeric with underscores
+
+ final RegexFilter.Builder builder = RegexFilter.newBuilder()
+ .setRegex(regex)
+ .setUseRawMsg(false)
+ .setOnMatch(Result.ACCEPT)
+ .setOnMismatch(Result.DENY);
+
+ final RegexFilter filter = builder.build();
+
+ assertNotNull(filter);
+
+ assertEquals(Result.ACCEPT, filter.filter("Hello_123"));
+
+ assertEquals(Result.DENY, filter.filter("Hello@123"));
+
+ assertEquals(regex, filter.getRegex());
+ }
+
+ @Test
+ void testRegexFilterGetters() {
+
+ final String regex = "^[a-zA-Z0-9_]+$"; // matches alphanumeric with underscores
+
+ final RegexFilter filter = RegexFilter.newBuilder()
+ .setRegex(regex)
+ .setUseRawMsg(false)
+ .setOnMatch(Result.ACCEPT)
+ .setOnMismatch(Result.DENY)
+ .build();
+
+ assertNotNull(filter);
+
+ assertEquals(regex, filter.getRegex());
+ assertFalse(filter.isUseRawMessage());
+ assertEquals(Result.ACCEPT, filter.getOnMatch());
+ assertEquals(Result.DENY, filter.getOnMismatch());
+ assertNotNull(filter.getPattern());
+ assertEquals(regex, filter.getPattern().pattern());
+ }
+
+ /**
+ * A builder with no 'regex' expression should both be invalid and return null on 'build()'.
+ */
+ @Test
+ void testBuilderWithoutRegexNotValid() {
+ final RegexFilter.Builder builder = RegexFilter.newBuilder();
+ assertThrows(IllegalArgumentException.class, builder::build);
+ }
+
+ /**
+ * A builder with an invalid 'regex' expression should return null on 'build()'.
+ */
+ @Test
+ void testBuilderWithInvalidRegexNotValid() {
+ final RegexFilter.Builder builder = RegexFilter.newBuilder();
+ builder.setRegex("[a-z");
+ assertThrows(IllegalArgumentException.class, builder::build);
+ }
}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/AbstractFilter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/AbstractFilter.java
index 397390bcbc3..13ae124072d 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/AbstractFilter.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/AbstractFilter.java
@@ -16,6 +16,8 @@
*/
package org.apache.logging.log4j.core.filter;
+import java.util.Objects;
+import java.util.Optional;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.Marker;
import org.apache.logging.log4j.core.AbstractLifeCycle;
@@ -43,16 +45,30 @@ public abstract static class AbstractFilterBuilder builder) {
+ Objects.requireNonNull(builder, "builder");
+ this.onMatch = Optional.ofNullable(builder.onMatch).orElse(Result.NEUTRAL);
+ this.onMismatch = Optional.ofNullable(builder.onMismatch).orElse(Result.DENY);
+ }
+
@Override
protected boolean equalsImpl(final Object obj) {
if (this == obj) {
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/RegexFilter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/RegexFilter.java
index 62d41b31f59..67dbbaf8709 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/RegexFilter.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/RegexFilter.java
@@ -16,10 +16,7 @@
*/
package org.apache.logging.log4j.core.filter;
-import java.lang.reflect.Field;
-import java.util.Arrays;
-import java.util.Comparator;
-import java.util.regex.Matcher;
+import java.util.Objects;
import java.util.regex.Pattern;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.Marker;
@@ -29,68 +26,212 @@
import org.apache.logging.log4j.core.config.Node;
import org.apache.logging.log4j.core.config.plugins.Plugin;
import org.apache.logging.log4j.core.config.plugins.PluginAttribute;
-import org.apache.logging.log4j.core.config.plugins.PluginElement;
-import org.apache.logging.log4j.core.config.plugins.PluginFactory;
+import org.apache.logging.log4j.core.config.plugins.PluginBuilderAttribute;
+import org.apache.logging.log4j.core.config.plugins.PluginBuilderFactory;
+import org.apache.logging.log4j.core.config.plugins.validation.constraints.Required;
+import org.apache.logging.log4j.core.util.Assert;
import org.apache.logging.log4j.message.Message;
import org.apache.logging.log4j.message.MessageFormatMessage;
import org.apache.logging.log4j.message.ParameterizedMessage;
import org.apache.logging.log4j.message.StringFormattedMessage;
import org.apache.logging.log4j.message.StructuredDataMessage;
+import org.apache.logging.log4j.util.Strings;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
/**
- * A filter that matches the given regular expression pattern against messages.
+ * This filter returns the {@code onMatch} result if the message exactly matches the configured
+ * "{@code regex}" regular-expression pattern; otherwise, it returns the {@code onMismatch} result.
*/
@Plugin(name = "RegexFilter", category = Node.CATEGORY, elementType = Filter.ELEMENT_TYPE, printObject = true)
+@NullMarked
public final class RegexFilter extends AbstractFilter {
- private static final int DEFAULT_PATTERN_FLAGS = 0;
+ /** The pattern compiled from the regular-expression. */
private final Pattern pattern;
+
+ /** Flag: if {@code true} use message format-pattern / field for the match target. */
private final boolean useRawMessage;
- private RegexFilter(final boolean raw, final Pattern pattern, final Result onMatch, final Result onMismatch) {
- super(onMatch, onMismatch);
- this.pattern = pattern;
- this.useRawMessage = raw;
+ /**
+ * Constructs a new {@code RegexFilter} configured by the given builder.
+ * @param builder the builder
+ * @throws IllegalArgumentException if the regular expression is not configured or cannot be compiled to a pattern
+ */
+ private RegexFilter(final Builder builder) {
+
+ super(builder);
+
+ if (Strings.isBlank(builder.regex)) {
+ throw new IllegalArgumentException("The `regex` attribute must not be null or empty.");
+ }
+
+ this.useRawMessage = Boolean.TRUE.equals(builder.useRawMsg);
+
+ try {
+ this.pattern = Pattern.compile(builder.regex);
+ } catch (final Exception ex) {
+ throw new IllegalArgumentException("Unable to compile regular expression: `" + builder.regex + "`.", ex);
+ }
}
+ /**
+ * Returns the compiled regular-expression pattern.
+ * @return the pattern (will never be {@code null}
+ * @since 2.27.0
+ */
+ public Pattern getPattern() {
+ return this.pattern;
+ }
+
+ /**
+ * Returns the regular-expression.
+ * @return the regular-expression (it may be an empty string but never {@code null})
+ * @since 2.27.0
+ */
+ public String getRegex() {
+ return this.pattern.pattern();
+ }
+
+ /**
+ * Returns whether the raw-message should be used.
+ * @return {@code true} if the raw message should be used; otherwise, {@code false}
+ * @since 2.27.0
+ */
+ public boolean isUseRawMessage() {
+ return this.useRawMessage;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * This implementation performs the filter evaluation against the given message formatted with
+ * the given parameters.
+ *
+ *
+ * The following method arguments are ignored by this filter method implementation:
+ *
+ *
{@code logger}
+ *
{@code level}
+ *
{@code marker}
+ *
+ *
+ */
@Override
public Result filter(
- final Logger logger, final Level level, final Marker marker, final String msg, final Object... params) {
- if (useRawMessage || params == null || params.length == 0) {
- return filter(msg);
- }
- return filter(ParameterizedMessage.format(msg, params));
+ final @Nullable Logger logger,
+ final @Nullable Level level,
+ final @Nullable Marker marker,
+ final @Nullable String msg,
+ final @Nullable Object @Nullable ... params) {
+
+ return (useRawMessage || params == null || params.length == 0)
+ ? filter(msg)
+ : filter(ParameterizedMessage.format(msg, params));
}
+ /**
+ * {@inheritDoc}
+ *
+ * This implementation performs the filter evaluation against the given message.
+ *
+ *
+ * The following method arguments are ignored by this filter method implementation:
+ *
+ *
{@code logger}
+ *
{@code level}
+ *
{@code marker}
+ *
{@code throwable}
+ *
+ *
+ */
@Override
public Result filter(
- final Logger logger, final Level level, final Marker marker, final Object msg, final Throwable t) {
- if (msg == null) {
- return onMismatch;
- }
- return filter(msg.toString());
+ final @Nullable Logger logger,
+ final @Nullable Level level,
+ final @Nullable Marker marker,
+ final @Nullable Object message,
+ final @Nullable Throwable throwable) {
+
+ return (message == null) ? this.onMismatch : filter(message.toString());
}
+ /**
+ * {@inheritDoc}
+ *
+ * This implementation performs the filter evaluation against the given message.
+ *
+ *
+ * The following method arguments are ignored by this filter method implementation:
+ *
+ *
{@code logger}
+ *
{@code level}
+ *
{@code marker}
+ *
{@code throwable}
+ *
+ *
+ */
@Override
public Result filter(
- final Logger logger, final Level level, final Marker marker, final Message msg, final Throwable t) {
- if (msg == null) {
- return onMismatch;
- }
- final String text = targetMessageTest(msg);
- return filter(text);
+ final @Nullable Logger logger,
+ final @Nullable Level level,
+ final @Nullable Marker marker,
+ final @Nullable Message message,
+ final @Nullable Throwable throwable) {
+ return (message == null) ? this.onMismatch : filter(getMessageTextByType(message));
}
+ /**
+ * {@inheritDoc}
+ *
+ * @throws NullPointerException if the {@code event} argument is {@code null}
+ */
@Override
public Result filter(final LogEvent event) {
- final String text = targetMessageTest(event.getMessage());
- return filter(text);
+ Objects.requireNonNull(event, "event");
+ return filter(getMessageTextByType(event.getMessage()));
}
- // While `Message#getFormat()` is broken in general, it still makes sense for certain types.
- // Hence, suppress the deprecation warning.
+ /**
+ * Apply the filter to the given message and return the {@code onMatch} result if the entire
+ * message matches the configured regex pattern; otherwise, {@code onMismatch}.
+ *
+ * If the given '{@code msg}' is {@code null} the configured {@code onMismatch} result will be returned.
+ *
+ * @param msg the message
+ * @return the {@code onMatch} result if the pattern matches; otherwise, the {@code onMismatch} result
+ */
+ Result filter(final @Nullable String msg) {
+ return (msg != null && pattern.matcher(msg).matches()) ? onMatch : onMismatch;
+ }
+
+ /**
+ * Tests the filter pattern against the given Log4j {@code Message}.
+ *
+ * If the raw-message flag is enabled and message is an instance of the following, the raw message format
+ * will be returned.
+ *
+ *
+ *
{@link MessageFormatMessage}
+ *
{@link ParameterizedMessage}
+ *
{@link StringFormattedMessage}
+ *
{@link StructuredDataMessage}
+ *
+ *
+ * If the '{@code useRawMessage}' flag is disabled OR the message is not one of the above
+ * implementations, the message's formatted message will be returned.
+ *
+ *
Developer Note
+ *
+ * While `Message#getFormat()` is broken in general, it still makes sense for certain types.
+ * Hence, suppress the deprecation warning.
+ *
+ *
+ * @param message the message
+ * @return the target message based on configuration and message-type
+ */
@SuppressWarnings("deprecation")
- private String targetMessageTest(final Message message) {
+ private String getMessageTextByType(final Message message) {
return useRawMessage
&& (message instanceof ParameterizedMessage
|| message instanceof StringFormattedMessage
@@ -100,78 +241,146 @@ private String targetMessageTest(final Message message) {
: message.getFormattedMessage();
}
- private Result filter(final String msg) {
- if (msg == null) {
- return onMismatch;
+ /** {@inheritDoc} */
+ @Override
+ public String toString() {
+ return "useRawMessage=" + useRawMessage + ", pattern=" + pattern;
+ }
+
+ /**
+ * Creates a new builder instance.
+ * @return the new builder instance
+ * @since 2.27.0
+ */
+ @PluginBuilderFactory
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ /**
+ * A {@link RegexFilter} builder instance.
+ * @since 2.27.0
+ */
+ public static final class Builder extends AbstractFilterBuilder
+ implements org.apache.logging.log4j.core.util.Builder {
+
+ /**
+ * The regular expression to match.
+ */
+ @PluginBuilderAttribute
+ @Required(message = "No `regex` provided for `RegexFilter`")
+ private @Nullable String regex;
+
+ /**
+ * If {@code true}, for {@link ParameterizedMessage}, {@link StringFormattedMessage},
+ * and {@link MessageFormatMessage}, the message format pattern; for {@link StructuredDataMessage},
+ * the message field will be used as the match target.
+ */
+ @PluginBuilderAttribute
+ private @Nullable Boolean useRawMsg;
+
+ /** Private constructor. */
+ private Builder() {
+ super();
+ }
+
+ /**
+ * Sets the regular-expression.
+ *
+ * @param regex the regular-expression
+ * @return this builder
+ */
+ public Builder setRegex(final String regex) {
+ this.regex = Assert.requireNonEmpty(regex, "The `regex` attribute must not be null or empty.");
+ return this;
+ }
+
+ /**
+ * Sets the use raw msg flag.
+ *
+ * @param useRawMsg {@code true} if the message format-patter/field will be used as match target;
+ * otherwise, {@code false}
+ * @return this builder
+ */
+ public Builder setUseRawMsg(final boolean useRawMsg) {
+ this.useRawMsg = useRawMsg;
+ return this;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean isValid() {
+ return Strings.isNotEmpty(this.regex);
+ }
+
+ /**
+ * Builds and returns a {@link RegexFilter} instance configured by this builder.
+ *
+ * @return the created {@link RegexFilter} or {@code null} if the builder is misconfigured
+ */
+ @Override
+ public RegexFilter build() {
+
+ // validate the "regex" attribute
+ if (Strings.isEmpty(this.regex)) {
+ throw new IllegalArgumentException(
+ "Unable to create `RegexFilter`: The `regex` attribute be set to a non-empty string.");
+ }
+
+ // build with *safety* to not throw exceptions
+ try {
+ return new RegexFilter(this);
+ } catch (final Exception ex) {
+ throw new IllegalArgumentException("Unable to create `RegexFilter`.", ex);
+ }
}
- final Matcher m = pattern.matcher(msg);
- return m.matches() ? onMatch : onMismatch;
}
- @Override
- public String toString() {
- final StringBuilder sb = new StringBuilder();
- sb.append("useRaw=").append(useRawMessage);
- sb.append(", pattern=").append(pattern.toString());
- return sb.toString();
+ /**
+ * @deprecated use {@link RegexFilter.Builder} instead
+ */
+ @SuppressWarnings("unused")
+ @Deprecated
+ private RegexFilter(
+ final String regex,
+ final boolean useRawMessage,
+ // `patternFlags` has never worked, and removed in `2.27.0`.
+ // We're keeping this field for binary backward compatibility.
+ final @Nullable String @Nullable [] patternFlags,
+ final @Nullable Result onMatch,
+ final @Nullable Result onMismatch) {
+ super(onMatch, onMismatch);
+ Objects.requireNonNull(regex, "regex");
+ this.pattern = Pattern.compile(regex);
+ this.useRawMessage = useRawMessage;
}
/**
* Creates a Filter that matches a regular expression.
*
- * @param regex
- * The regular expression to match.
- * @param patternFlags
- * An array of Strings where each String is a {@link Pattern#compile(String, int)} compilation flag.
- * @param useRawMsg
- * If {@code true}, for {@link ParameterizedMessage}, {@link StringFormattedMessage}, and {@link MessageFormatMessage}, the message format pattern; for {@link StructuredDataMessage}, the message field will be used as the match target.
- * @param match
- * The action to perform when a match occurs.
- * @param mismatch
- * The action to perform when a mismatch occurs.
+ * @param regex The regular expression to match.
+ * @param patternFlags Ignored, kept for backward compatibility.
+ * @param useRawMsg If {@code true}, for {@link ParameterizedMessage}, {@link StringFormattedMessage},
+ * and {@link MessageFormatMessage}, the message format pattern; for {@link StructuredDataMessage},
+ * the message field will be used as the match target.
+ * @param match The action to perform when a match occurs.
+ * @param mismatch The action to perform when a mismatch occurs.
* @return The RegexFilter.
- * @throws IllegalAccessException When there is no access to the definition of the specified member.
+ * @throws IllegalAccessException When there is no access to the definition of the specified member.
* @throws IllegalArgumentException When passed an illegal or inappropriate argument.
+ * @deprecated use {@link #newBuilder} to instantiate builder
*/
- // TODO Consider refactoring to use AbstractFilter.AbstractFilterBuilder
- @PluginFactory
+ @Deprecated
public static RegexFilter createFilter(
- // @formatter:off
@PluginAttribute("regex") final String regex,
- @PluginElement("PatternFlags") final String[] patternFlags,
- @PluginAttribute("useRawMsg") final Boolean useRawMsg,
- @PluginAttribute("onMatch") final Result match,
- @PluginAttribute("onMismatch") final Result mismatch)
- // @formatter:on
- throws IllegalArgumentException, IllegalAccessException {
- if (regex == null) {
- LOGGER.error("A regular expression must be provided for RegexFilter");
- return null;
- }
- return new RegexFilter(
- Boolean.TRUE.equals(useRawMsg), Pattern.compile(regex, toPatternFlags(patternFlags)), match, mismatch);
- }
-
- private static int toPatternFlags(final String[] patternFlags)
+ // `patternFlags` has never worked, and removed in `2.27.0`.
+ // We're keeping this field for binary backward compatibility.
+ final String @Nullable [] patternFlags,
+ @PluginAttribute("useRawMsg") final @Nullable Boolean useRawMsg,
+ @PluginAttribute("onMatch") final @Nullable Result match,
+ @PluginAttribute("onMismatch") final @Nullable Result mismatch)
throws IllegalArgumentException, IllegalAccessException {
- if (patternFlags == null || patternFlags.length == 0) {
- return DEFAULT_PATTERN_FLAGS;
- }
- final Field[] fields = Pattern.class.getDeclaredFields();
- final Comparator comparator = (f1, f2) -> f1.getName().compareTo(f2.getName());
- Arrays.sort(fields, comparator);
- final String[] fieldNames = new String[fields.length];
- for (int i = 0; i < fields.length; i++) {
- fieldNames[i] = fields[i].getName();
- }
- int flags = DEFAULT_PATTERN_FLAGS;
- for (final String test : patternFlags) {
- final int index = Arrays.binarySearch(fieldNames, test);
- if (index >= 0) {
- final Field field = fields[index];
- flags |= field.getInt(Pattern.class);
- }
- }
- return flags;
+ Objects.requireNonNull(regex, "regex");
+ return new RegexFilter(regex, Boolean.TRUE.equals(useRawMsg), patternFlags, match, mismatch);
}
}
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/package-info.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/package-info.java
index 38173ef4735..6794a54f74d 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/package-info.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/filter/package-info.java
@@ -22,7 +22,7 @@
* {@link org.apache.logging.log4j.core.Filter#ELEMENT_TYPE filter}.
*/
@Export
-@Version("2.25.3")
+@Version("2.27.0")
package org.apache.logging.log4j.core.filter;
import org.osgi.annotation.bundle.Export;
diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Builder.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Builder.java
index 10bc1a9f52e..8a772dc7330 100644
--- a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Builder.java
+++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/Builder.java
@@ -44,6 +44,10 @@ public interface Builder {
*/
T build();
+ /**
+ * Validates that the builder is properly configured to build.
+ * @return {@code true} if the builder configuration is valid; otherwise, {@code false}
+ */
default boolean isValid() {
return PluginBuilder.validateFields(this, getErrorPrefix());
}
diff --git a/src/changelog/.2.x.x/regexfilter_patternflags_builder.xml b/src/changelog/.2.x.x/regexfilter_patternflags_builder.xml
new file mode 100644
index 00000000000..9ba2fc89824
--- /dev/null
+++ b/src/changelog/.2.x.x/regexfilter_patternflags_builder.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+ Remove the `patternFlags` attribute from `RegexFilter`.
+ It was not documented and it has never worked
+ Note that pattern flags can be provided as a part of the pattern (e.g., `(?s)`, `(?m)`) in the `regex` attribute.
+
+
+
From 3784f08de322a8146c079969057775b069af7d59 Mon Sep 17 00:00:00 2001
From: Ramanathan
Date: Fri, 12 Jun 2026 00:10:40 +0530
Subject: [PATCH 15/28] Fix `DatePatternConverter` locale parsing when timezone
is omitted (#4130)
---
...DatePatternConverter_locale_without_timezone.xml | 13 +++++++++++++
.../modules/ROOT/pages/manual/pattern-layout.adoc | 12 ++++++++++--
2 files changed, 23 insertions(+), 2 deletions(-)
create mode 100644 src/changelog/.2.x.x/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml
diff --git a/src/changelog/.2.x.x/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml b/src/changelog/.2.x.x/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml
new file mode 100644
index 00000000000..52238f30fb0
--- /dev/null
+++ b/src/changelog/.2.x.x/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Improved documentation for locale handling in the Pattern Layout date pattern converter.
+
+
diff --git a/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc b/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc
index 0199783f1d0..ebe36a6f529 100644
--- a/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc
+++ b/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc
@@ -389,8 +389,12 @@ Outputs the instant of the log event
.link:../javadoc/log4j-core/org/apache/logging/log4j/core/pattern/DatePatternConverter.html[`DatePatternConverter`] specifier grammar
[source,text]
----
-d{pattern}[{timezone}]
-date{pattern}[{timezone}]
+d{pattern}
+d{pattern}{timezone}
+d{pattern}{timezone}{locale}
+date{pattern}
+date{pattern}{timezone}
+date{pattern}{timezone}{locale}
----
The date conversion specifier may be followed by a set of braces containing a date and time formatting pattern per https://docs.oracle.com/javase/{java-target-version}/docs/api/java/time/format/DateTimeFormatter.html[`DateTimeFormatter`].
@@ -462,6 +466,10 @@ You can also define custom date formats, see following examples:
|%d{yyyy-mm-dd'T'HH:mm:ss.SSS'Z'}\{UTC}
|2012-11-02T14:34:02.123Z
+
+
+|%d{dd-MMMM-yyyy}\{UTC}\{de-DE}
+|02 November 2012 (UTC timezone, German locale)
|===
`%d\{UNIX}` outputs the epoch time in seconds, i.e., the difference in seconds between the current time and 1970-01-01 00:00:00 (UTC).
From 2ca444b53ec929319ed4dbc5059f1c8d05825da4 Mon Sep 17 00:00:00 2001
From: SunWeb3Sec <107249780+SunWeb3Sec@users.noreply.github.com>
Date: Fri, 12 Jun 2026 17:31:14 +0800
Subject: [PATCH 16/28] Harden `readObject(ObjectInputStream)` method argument
checks (#4098)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: SunWeb3Sec
Co-authored-by: Volkan Yazıcı
---
.../src/main/java/org/apache/log4j/Level.java | 2 +
.../log4j/util/SerializationTestHelper.java | 21 ++++++++-
.../log4j/test/SerializableMatchers.java | 23 +++++++++-
.../logging/log4j/test/junit/SerialUtil.java | 44 +++++++++++++++++--
.../log4j/test/junit/package-info.java | 2 +-
.../logging/log4j/test/package-info.java | 2 +-
.../log4j/message/FormattedMessageTest.java | 16 ++-----
.../log4j/message/LocalizedMessageTest.java | 7 ++-
.../log4j/message/ObjectArrayMessageTest.java | 13 ++++++
.../message/StringFormattedMessageTest.java | 16 ++-----
.../log4j/message/FormattedMessage.java | 2 +
.../log4j/message/LocalizedMessage.java | 2 +
.../log4j/message/MessageFormatMessage.java | 2 +
.../log4j/message/ObjectArrayMessage.java | 2 +
.../logging/log4j/message/SimpleMessage.java | 2 +
.../log4j/message/StringFormattedMessage.java | 2 +
.../log4j/message/ThreadDumpMessage.java | 2 +
.../log4j/util/internal/package-info.java | 40 +++++++++++++++++
.../log4j/core/async/RingBufferLogEvent.java | 2 +
.../log4j/core/impl/Log4jLogEvent.java | 2 +
.../log4j/core/impl/MutableLogEvent.java | 2 +
.../core/util/datetime/FastDatePrinter.java | 2 +
.../log4j/perf/nogc/OpenHashStringMap.java | 2 +
.../org/apache/logging/slf4j/Log4jLogger.java | 2 +
.../apache/logging/slf4j/SerializeTest.java | 6 ++-
.../org/apache/logging/slf4j/Log4jLogger.java | 2 +
.../apache/logging/slf4j/SerializeTest.java | 6 ++-
.../.2.x.x/harden_message_deserialization.xml | 12 +++++
28 files changed, 196 insertions(+), 42 deletions(-)
create mode 100644 log4j-api/src/main/java/org/apache/logging/log4j/util/internal/package-info.java
create mode 100644 src/changelog/.2.x.x/harden_message_deserialization.xml
diff --git a/log4j-1.2-api/src/main/java/org/apache/log4j/Level.java b/log4j-1.2-api/src/main/java/org/apache/log4j/Level.java
index 7bc068a3faf..5b63adcd824 100644
--- a/log4j-1.2-api/src/main/java/org/apache/log4j/Level.java
+++ b/log4j-1.2-api/src/main/java/org/apache/log4j/Level.java
@@ -25,6 +25,7 @@
import java.io.Serializable;
import org.apache.log4j.helpers.OptionConverter;
import org.apache.logging.log4j.util.Strings;
+import org.apache.logging.log4j.util.internal.SerializationUtil;
/**
* Defines the minimum set of levels recognized by the system, that is
@@ -214,6 +215,7 @@ public static Level toLevel(final String sArg, final Level defaultLevel) {
* @throws ClassNotFoundException if class not found.
*/
private void readObject(final ObjectInputStream s) throws IOException, ClassNotFoundException {
+ SerializationUtil.assertFiltered(s);
s.defaultReadObject();
level = s.readInt();
syslogEquivalent = s.readInt();
diff --git a/log4j-1.2-api/src/test/java/org/apache/log4j/util/SerializationTestHelper.java b/log4j-1.2-api/src/test/java/org/apache/log4j/util/SerializationTestHelper.java
index e58fa6436e2..ab9b497de99 100644
--- a/log4j-1.2-api/src/test/java/org/apache/log4j/util/SerializationTestHelper.java
+++ b/log4j-1.2-api/src/test/java/org/apache/log4j/util/SerializationTestHelper.java
@@ -24,9 +24,14 @@
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
+import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
+import java.util.Arrays;
+import java.util.Collection;
import org.apache.commons.io.FileUtils;
+import org.apache.logging.log4j.util.Constants;
+import org.apache.logging.log4j.util.FilteredObjectInputStream;
/**
* Utiities for serialization tests.
@@ -103,11 +108,23 @@ public static void assertStreamEquals(
* @throws Exception thrown on IO or deserialization exception.
*/
public static Object deserializeStream(final String witness) throws Exception {
- try (final ObjectInputStream objIs = new ObjectInputStream(new FileInputStream(witness))) {
+ try (final ObjectInputStream objIs = newObjectInputStream(new FileInputStream(witness))) {
return objIs.readObject();
}
}
+ private static ObjectInputStream newObjectInputStream(final InputStream in) throws IOException {
+ if (Constants.JAVA_MAJOR_VERSION == 8) {
+ // FilteredObjectInputStream's default allow-list covers `org.apache.logging.log4j.` but
+ // not the `org.apache.log4j.` 1.2-compatibility namespace, so we have to enumerate the
+ // 1.2 classes that the tests in this module deserialize on Java 8.
+ final Collection allowedLog4j12Classes =
+ Arrays.asList("org.apache.log4j.Level", "org.apache.log4j.LevelTest$CustomLevel");
+ return new FilteredObjectInputStream(in, allowedLog4j12Classes);
+ }
+ return new ObjectInputStream(in);
+ }
+
/**
* Creates a clone by serializing object and deserializing byte stream.
*
@@ -123,7 +140,7 @@ public static Object serializeClone(final Object obj) throws IOException, ClassN
}
final ByteArrayInputStream src = new ByteArrayInputStream(memOut.toByteArray());
- final ObjectInputStream objIs = new ObjectInputStream(src);
+ final ObjectInputStream objIs = newObjectInputStream(src);
return objIs.readObject();
}
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/SerializableMatchers.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/SerializableMatchers.java
index 6fc46f6f936..a5af542e862 100644
--- a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/SerializableMatchers.java
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/SerializableMatchers.java
@@ -20,7 +20,9 @@
import static org.hamcrest.core.IsInstanceOf.any;
import java.io.Serializable;
-import org.apache.commons.lang3.SerializationUtils;
+import java.util.Collection;
+import java.util.Collections;
+import org.apache.logging.log4j.test.junit.SerialUtil;
import org.hamcrest.FeatureMatcher;
import org.hamcrest.Matcher;
@@ -32,10 +34,19 @@
public final class SerializableMatchers {
public static Matcher serializesRoundTrip(final Matcher matcher) {
+ return serializesRoundTrip(matcher, Collections.emptySet());
+ }
+
+ /**
+ * Same as {@link #serializesRoundTrip(Matcher)} but extends the default deserialization
+ * allow-list on Java 8 (see {@link SerialUtil#deserialize(byte[], Collection)}).
+ */
+ public static Matcher serializesRoundTrip(
+ final Matcher matcher, final Collection allowedExtraClasses) {
return new FeatureMatcher(matcher, "serializes round trip", "serializes round trip") {
@Override
protected T featureValueOf(final T actual) {
- return SerializationUtils.roundtrip(actual);
+ return SerialUtil.deserialize(SerialUtil.serialize(actual), allowedExtraClasses);
}
};
}
@@ -52,5 +63,13 @@ public static Matcher super Serializable> serializesRoundTrip() {
return serializesRoundTrip(any(Serializable.class));
}
+ /**
+ * Same as {@link #serializesRoundTrip()} but extends the default deserialization allow-list on
+ * Java 8 (see {@link SerialUtil#deserialize(byte[], Collection)}).
+ */
+ public static Matcher super Serializable> serializesRoundTrip(final Collection allowedExtraClasses) {
+ return serializesRoundTrip(any(Serializable.class), allowedExtraClasses);
+ }
+
private SerializableMatchers() {}
}
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/SerialUtil.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/SerialUtil.java
index 707fee87d3e..34600f0c831 100644
--- a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/SerialUtil.java
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/SerialUtil.java
@@ -24,6 +24,8 @@
import java.io.ObjectOutput;
import java.io.ObjectOutputStream;
import java.io.Serializable;
+import java.util.Collection;
+import java.util.Collections;
import org.apache.logging.log4j.test.internal.annotation.SuppressFBWarnings;
import org.apache.logging.log4j.util.Constants;
import org.apache.logging.log4j.util.FilteredObjectInputStream;
@@ -68,11 +70,25 @@ public static byte[] serialize(final Serializable... objs) {
* @param data byte array representing the serialized object
* @return the deserialized object
*/
- @SuppressWarnings("unchecked")
@SuppressFBWarnings("OBJECT_DESERIALIZATION")
public static T deserialize(final byte[] data) {
+ return deserialize(data, Collections.emptySet());
+ }
+
+ /**
+ * Deserialize an object from the specified byte array using a {@link FilteredObjectInputStream}
+ * extended with the supplied allow-list (Java 8 only — Java 9+ uses the JVM's serialization
+ * filter, so the allow-list is ignored).
+ * @param data byte array representing the serialized object
+ * @param allowedExtraClasses fully-qualified class names to add to {@link
+ * FilteredObjectInputStream}'s default allow-list on Java 8
+ * @return the deserialized object
+ */
+ @SuppressWarnings("unchecked")
+ @SuppressFBWarnings("OBJECT_DESERIALIZATION")
+ public static T deserialize(final byte[] data, final Collection allowedExtraClasses) {
try {
- final ObjectInputStream ois = getObjectInputStream(data);
+ final ObjectInputStream ois = getObjectInputStream(data, allowedExtraClasses);
return (T) ois.readObject();
} catch (final Exception ex) {
throw new IllegalStateException("Could not deserialize", ex);
@@ -86,8 +102,18 @@ public static T deserialize(final byte[] data) {
*/
@SuppressFBWarnings("OBJECT_DESERIALIZATION")
public static ObjectInputStream getObjectInputStream(final byte[] data) throws IOException {
+ return getObjectInputStream(data, Collections.emptySet());
+ }
+
+ /**
+ * Creates an {@link ObjectInputStream} adapted to the current Java version, extended with the
+ * supplied allow-list on Java 8.
+ */
+ @SuppressFBWarnings("OBJECT_DESERIALIZATION")
+ public static ObjectInputStream getObjectInputStream(
+ final byte[] data, final Collection allowedExtraClasses) throws IOException {
final ByteArrayInputStream bas = new ByteArrayInputStream(data);
- return getObjectInputStream(bas);
+ return getObjectInputStream(bas, allowedExtraClasses);
}
/**
@@ -97,8 +123,18 @@ public static ObjectInputStream getObjectInputStream(final byte[] data) throws I
*/
@SuppressFBWarnings("OBJECT_DESERIALIZATION")
public static ObjectInputStream getObjectInputStream(final InputStream stream) throws IOException {
+ return getObjectInputStream(stream, Collections.emptySet());
+ }
+
+ /**
+ * Creates an {@link ObjectInputStream} adapted to the current Java version, extended with the
+ * supplied allow-list on Java 8.
+ */
+ @SuppressFBWarnings("OBJECT_DESERIALIZATION")
+ public static ObjectInputStream getObjectInputStream(
+ final InputStream stream, final Collection allowedExtraClasses) throws IOException {
return Constants.JAVA_MAJOR_VERSION == 8
- ? new FilteredObjectInputStream(stream)
+ ? new FilteredObjectInputStream(stream, allowedExtraClasses)
: new ObjectInputStream(stream);
}
}
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/package-info.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/package-info.java
index f048e320fe0..b6456b64d53 100644
--- a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/package-info.java
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/junit/package-info.java
@@ -15,7 +15,7 @@
* limitations under the license.
*/
@Export
-@Version("2.25.3")
+@Version("2.27.0")
package org.apache.logging.log4j.test.junit;
import org.osgi.annotation.bundle.Export;
diff --git a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/package-info.java b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/package-info.java
index a867f216a55..aebee2d75b3 100644
--- a/log4j-api-test/src/main/java/org/apache/logging/log4j/test/package-info.java
+++ b/log4j-api-test/src/main/java/org/apache/logging/log4j/test/package-info.java
@@ -15,7 +15,7 @@
* limitations under the license.
*/
@Export
-@Version("2.25.3")
+@Version("2.27.0")
package org.apache.logging.log4j.test;
import org.osgi.annotation.bundle.Export;
diff --git a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/FormattedMessageTest.java b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/FormattedMessageTest.java
index f2a35b2474a..f49bb1d08b2 100644
--- a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/FormattedMessageTest.java
+++ b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/FormattedMessageTest.java
@@ -19,13 +19,9 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
import java.util.Locale;
import org.apache.logging.log4j.test.junit.Mutable;
+import org.apache.logging.log4j.test.junit.SerialUtil;
import org.apache.logging.log4j.util.Constants;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceAccessMode;
@@ -158,15 +154,9 @@ void testSafeAfterGetFormattedMessageIsCalled() { // LOG4J2-763
}
@Test
- void testSerialization() throws IOException, ClassNotFoundException {
+ void testSerialization() {
final FormattedMessage expected = new FormattedMessage("Msg", "a", "b", "c");
- final ByteArrayOutputStream baos = new ByteArrayOutputStream();
- try (final ObjectOutputStream out = new ObjectOutputStream(baos)) {
- out.writeObject(expected);
- }
- final ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
- final ObjectInputStream in = new ObjectInputStream(bais);
- final FormattedMessage actual = (FormattedMessage) in.readObject();
+ final FormattedMessage actual = SerialUtil.deserialize(SerialUtil.serialize(expected));
assertEquals(expected, actual);
assertEquals(expected.getFormat(), actual.getFormat());
assertEquals(expected.getFormattedMessage(), actual.getFormattedMessage());
diff --git a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/LocalizedMessageTest.java b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/LocalizedMessageTest.java
index 832230d53c8..7449acf714e 100644
--- a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/LocalizedMessageTest.java
+++ b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/LocalizedMessageTest.java
@@ -18,10 +18,9 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
-import java.io.Serializable;
import java.util.Locale;
-import org.apache.commons.lang3.SerializationUtils;
import org.apache.logging.log4j.test.junit.Mutable;
+import org.apache.logging.log4j.test.junit.SerialUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceAccessMode;
import org.junit.jupiter.api.parallel.ResourceLock;
@@ -33,8 +32,8 @@
@ResourceLock(value = Resources.LOCALE, mode = ResourceAccessMode.READ)
class LocalizedMessageTest {
- private T roundtrip(final T msg) {
- return SerializationUtils.roundtrip(msg);
+ private LocalizedMessage roundtrip(final LocalizedMessage msg) {
+ return SerialUtil.deserialize(SerialUtil.serialize(msg));
}
@Test
diff --git a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/ObjectArrayMessageTest.java b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/ObjectArrayMessageTest.java
index cdfb2c9bd26..8acd13b3d71 100644
--- a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/ObjectArrayMessageTest.java
+++ b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/ObjectArrayMessageTest.java
@@ -19,6 +19,7 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import org.apache.logging.log4j.test.junit.SerialUtil;
import org.junit.jupiter.api.Test;
/**
@@ -38,4 +39,16 @@ void testGetParameters() {
void testGetThrowable() {
assertNull(OBJECT_ARRAY_MESSAGE.getThrowable());
}
+
+ /**
+ * Round-trips through a filtered stream (see {@link SerialUtil#getObjectInputStream})
+ * to verify that {@code readObject}'s new {@code SerializationUtil.assertFiltered}
+ * check accepts streams that carry a filter.
+ */
+ @Test
+ void testSerializableRoundTripThroughFilteredStream() {
+ final ObjectArrayMessage original = new ObjectArrayMessage("A", "B", "C");
+ final ObjectArrayMessage restored = SerialUtil.deserialize(SerialUtil.serialize(original));
+ assertArrayEquals(original.getParameters(), restored.getParameters());
+ }
}
diff --git a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/StringFormattedMessageTest.java b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/StringFormattedMessageTest.java
index 29309b36592..3c3ea2345e4 100644
--- a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/StringFormattedMessageTest.java
+++ b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/StringFormattedMessageTest.java
@@ -20,13 +20,9 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.ObjectInputStream;
-import java.io.ObjectOutputStream;
import java.util.Locale;
import org.apache.logging.log4j.test.junit.Mutable;
+import org.apache.logging.log4j.test.junit.SerialUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceAccessMode;
import org.junit.jupiter.api.parallel.ResourceLock;
@@ -115,15 +111,9 @@ void testSafeAfterGetFormattedMessageIsCalled() { // LOG4J2-763
}
@Test
- void testSerialization() throws IOException, ClassNotFoundException {
+ void testSerialization() {
final StringFormattedMessage expected = new StringFormattedMessage("Msg", "a", "b", "c");
- final ByteArrayOutputStream baos = new ByteArrayOutputStream();
- try (final ObjectOutputStream out = new ObjectOutputStream(baos)) {
- out.writeObject(expected);
- }
- final ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
- final ObjectInputStream in = new ObjectInputStream(bais);
- final StringFormattedMessage actual = (StringFormattedMessage) in.readObject();
+ final StringFormattedMessage actual = SerialUtil.deserialize(SerialUtil.serialize(expected));
assertEquals(expected, actual);
assertEquals(expected.getFormat(), actual.getFormat());
assertEquals(expected.getFormattedMessage(), actual.getFormattedMessage());
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/message/FormattedMessage.java b/log4j-api/src/main/java/org/apache/logging/log4j/message/FormattedMessage.java
index 31c4040251d..fa3a514b374 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/message/FormattedMessage.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/message/FormattedMessage.java
@@ -23,6 +23,7 @@
import java.text.MessageFormat;
import java.util.Arrays;
import java.util.Locale;
+import org.apache.logging.log4j.util.internal.SerializationUtil;
/**
* Handles messages that contain a format String. Dynamically determines if the format conforms to
@@ -243,6 +244,7 @@ public int hashCode() {
}
private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
+ SerializationUtil.assertFiltered(in);
in.defaultReadObject();
formattedMessage = in.readUTF();
messagePattern = in.readUTF();
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/message/LocalizedMessage.java b/log4j-api/src/main/java/org/apache/logging/log4j/message/LocalizedMessage.java
index c3152224d93..86193938225 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/message/LocalizedMessage.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/message/LocalizedMessage.java
@@ -23,6 +23,7 @@
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import org.apache.logging.log4j.status.StatusLogger;
+import org.apache.logging.log4j.util.internal.SerializationUtil;
/**
* Provides some level of compatibility with Log4j 1.x and convenience but is not the recommended way to Localize
@@ -283,6 +284,7 @@ private void writeObject(final ObjectOutputStream out) throws IOException {
}
private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
+ SerializationUtil.assertFiltered(in);
in.defaultReadObject();
formattedMessage = in.readUTF();
key = in.readUTF();
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/message/MessageFormatMessage.java b/log4j-api/src/main/java/org/apache/logging/log4j/message/MessageFormatMessage.java
index 609bf77f4e8..b767962d2ba 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/message/MessageFormatMessage.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/message/MessageFormatMessage.java
@@ -25,6 +25,7 @@
import java.util.Locale;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.status.StatusLogger;
+import org.apache.logging.log4j.util.internal.SerializationUtil;
/**
* Handles messages that consist of a format string conforming to java.text.MessageFormat.
@@ -164,6 +165,7 @@ private void writeObject(final ObjectOutputStream out) throws IOException {
}
private void readObject(final ObjectInputStream in) throws IOException {
+ SerializationUtil.assertFiltered(in);
parameters = null;
throwable = null;
formattedMessage = in.readUTF();
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/message/ObjectArrayMessage.java b/log4j-api/src/main/java/org/apache/logging/log4j/message/ObjectArrayMessage.java
index ffd83974b0a..b30b51f647d 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/message/ObjectArrayMessage.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/message/ObjectArrayMessage.java
@@ -21,6 +21,7 @@
import java.io.ObjectOutputStream;
import java.util.Arrays;
import org.apache.logging.log4j.util.Constants;
+import org.apache.logging.log4j.util.internal.SerializationUtil;
/**
* Handles messages that contain an Object[].
@@ -117,6 +118,7 @@ public int hashCode() {
}
private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
+ SerializationUtil.assertFiltered(in);
in.defaultReadObject();
array = (Object[]) in.readObject();
}
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/message/SimpleMessage.java b/log4j-api/src/main/java/org/apache/logging/log4j/message/SimpleMessage.java
index f7f1dd1308d..48399021172 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/message/SimpleMessage.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/message/SimpleMessage.java
@@ -21,6 +21,7 @@
import java.io.ObjectOutputStream;
import java.util.Objects;
import org.apache.logging.log4j.util.StringBuilderFormattable;
+import org.apache.logging.log4j.util.internal.SerializationUtil;
/**
* The simplest possible implementation of Message. It just returns the String given as the constructor argument.
@@ -152,6 +153,7 @@ private void writeObject(final ObjectOutputStream out) throws IOException {
}
private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
+ SerializationUtil.assertFiltered(in);
in.defaultReadObject();
charSequence = message;
}
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/message/StringFormattedMessage.java b/log4j-api/src/main/java/org/apache/logging/log4j/message/StringFormattedMessage.java
index d1eba763d1c..7e27bf519a1 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/message/StringFormattedMessage.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/message/StringFormattedMessage.java
@@ -24,6 +24,7 @@
import java.util.Locale;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.status.StatusLogger;
+import org.apache.logging.log4j.util.internal.SerializationUtil;
/**
* Handles messages that consist of a format string conforming to {@link java.util.Formatter}.
@@ -172,6 +173,7 @@ private void writeObject(final ObjectOutputStream out) throws IOException {
}
private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException {
+ SerializationUtil.assertFiltered(in);
in.defaultReadObject();
formattedMessage = in.readUTF();
messagePattern = in.readUTF();
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/message/ThreadDumpMessage.java b/log4j-api/src/main/java/org/apache/logging/log4j/message/ThreadDumpMessage.java
index 88ac55fca8c..2473c01dcc0 100644
--- a/log4j-api/src/main/java/org/apache/logging/log4j/message/ThreadDumpMessage.java
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/message/ThreadDumpMessage.java
@@ -33,6 +33,7 @@
import org.apache.logging.log4j.util.ServiceLoaderUtil;
import org.apache.logging.log4j.util.StringBuilderFormattable;
import org.apache.logging.log4j.util.Strings;
+import org.apache.logging.log4j.util.internal.SerializationUtil;
/**
* Captures information about all running Threads.
@@ -131,6 +132,7 @@ protected Object writeReplace() {
}
private void readObject(final ObjectInputStream stream) throws InvalidObjectException {
+ SerializationUtil.assertFiltered(stream);
throw new InvalidObjectException("Proxy required");
}
diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/util/internal/package-info.java b/log4j-api/src/main/java/org/apache/logging/log4j/util/internal/package-info.java
new file mode 100644
index 00000000000..423ce7faa4f
--- /dev/null
+++ b/log4j-api/src/main/java/org/apache/logging/log4j/util/internal/package-info.java
@@ -0,0 +1,40 @@
+/*
+ * 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.
+ */
+/**
+ * Utilities for safely serializing and deserializing Log4j objects.
+ *
Internal usage only!
+ *
+ * This package is intended only for internal Log4j usage.
+ * Log4j users should not use this package!
+ * This package is not subject to any backward compatibility concerns.
+ *