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.rat apache-rat-plugin - 0.16.1 true 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 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 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" + + "\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" + + "\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 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 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. + *

    + * + * @since 2.27.0 + */ +@Export +@ExportTo({ + "org.apache.logging.log4j.core", + "org.apache.log4j", + "org.apache.logging.log4j.slf4j.impl", + "org.apache.logging.log4j.slf4j2.impl" +}) +@Version("2.27.0") +package org.apache.logging.log4j.util.internal; + +import aQute.bnd.annotation.jpms.ExportTo; +import org.osgi.annotation.bundle.Export; +import org.osgi.annotation.versioning.Version; diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEvent.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEvent.java index 71d1b55f5d4..2afb007387c 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEvent.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEvent.java @@ -45,6 +45,7 @@ import org.apache.logging.log4j.util.StringBuilders; import org.apache.logging.log4j.util.StringMap; import org.apache.logging.log4j.util.Strings; +import org.apache.logging.log4j.util.internal.SerializationUtil; /** * When the Disruptor is started, the RingBuffer is populated with event objects. These objects are then re-used during @@ -450,6 +451,7 @@ private Object writeReplace() throws IOException { } private void readObject(final ObjectInputStream stream) throws InvalidObjectException { + SerializationUtil.assertFiltered(stream); throw new InvalidObjectException("Proxy required"); } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/Log4jLogEvent.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/Log4jLogEvent.java index e47fa88049e..471b1cd5bea 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/Log4jLogEvent.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/Log4jLogEvent.java @@ -48,6 +48,7 @@ import org.apache.logging.log4j.util.StackLocatorUtil; import org.apache.logging.log4j.util.StringMap; import org.apache.logging.log4j.util.Strings; +import org.apache.logging.log4j.util.internal.SerializationUtil; /** * Implementation of a LogEvent. @@ -993,6 +994,7 @@ public static Log4jLogEvent deserialize(final Serializable event) { } private void readObject(final ObjectInputStream stream) throws InvalidObjectException { + SerializationUtil.assertFiltered(stream); throw new InvalidObjectException("Proxy required"); } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/MutableLogEvent.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/MutableLogEvent.java index 151f4d4bfa5..9a99eae624d 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/MutableLogEvent.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/MutableLogEvent.java @@ -41,6 +41,7 @@ import org.apache.logging.log4j.util.StringBuilders; import org.apache.logging.log4j.util.StringMap; import org.apache.logging.log4j.util.Strings; +import org.apache.logging.log4j.util.internal.SerializationUtil; /** * Mutable implementation of the {@code LogEvent} interface. @@ -493,6 +494,7 @@ protected Object writeReplace() { } private void readObject(final ObjectInputStream stream) throws InvalidObjectException { + SerializationUtil.assertFiltered(stream); throw new InvalidObjectException("Proxy required"); } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/datetime/FastDatePrinter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/datetime/FastDatePrinter.java index 7e6f426f511..20f7184122a 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/datetime/FastDatePrinter.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/datetime/FastDatePrinter.java @@ -31,6 +31,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import org.apache.logging.log4j.core.util.Throwables; +import org.apache.logging.log4j.util.internal.SerializationUtil; /** *

    FastDatePrinter is a fast and thread-safe version of @@ -639,6 +640,7 @@ public String toString() { * @throws ClassNotFoundException if a class cannot be found. */ private void readObject(final ObjectInputStream in) throws IOException, ClassNotFoundException { + SerializationUtil.assertFiltered(in); in.defaultReadObject(); init(); } diff --git a/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/nogc/OpenHashStringMap.java b/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/nogc/OpenHashStringMap.java index 5e1f3f4fc59..c03e06790c6 100644 --- a/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/nogc/OpenHashStringMap.java +++ b/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/nogc/OpenHashStringMap.java @@ -29,6 +29,7 @@ import org.apache.logging.log4j.util.ReadOnlyStringMap; import org.apache.logging.log4j.util.StringMap; import org.apache.logging.log4j.util.TriConsumer; +import org.apache.logging.log4j.util.internal.SerializationUtil; /** * Open hash map-based implementation of the {@code ReadOnlyStringMap} interface. @@ -690,6 +691,7 @@ public int hashCode() { @SuppressWarnings("unchecked") private void readObject(final ObjectInputStream s) throws IOException, ClassNotFoundException { + SerializationUtil.assertFiltered(s); s.defaultReadObject(); arraySize = HashCommon.arraySize(size, loadFactor); maxFill = HashCommon.maxFill(arraySize, loadFactor); diff --git a/log4j-slf4j-impl/src/main/java/org/apache/logging/slf4j/Log4jLogger.java b/log4j-slf4j-impl/src/main/java/org/apache/logging/slf4j/Log4jLogger.java index 2889a31d07d..3fe685cc90b 100644 --- a/log4j-slf4j-impl/src/main/java/org/apache/logging/slf4j/Log4jLogger.java +++ b/log4j-slf4j-impl/src/main/java/org/apache/logging/slf4j/Log4jLogger.java @@ -26,6 +26,7 @@ import org.apache.logging.log4j.message.ParameterizedMessage; import org.apache.logging.log4j.message.SimpleMessage; import org.apache.logging.log4j.spi.ExtendedLogger; +import org.apache.logging.log4j.util.internal.SerializationUtil; import org.slf4j.Marker; import org.slf4j.spi.LocationAwareLogger; @@ -384,6 +385,7 @@ public String getName() { * the de-serialized object. */ private void readObject(final ObjectInputStream aInputStream) throws ClassNotFoundException, IOException { + SerializationUtil.assertFiltered(aInputStream); // always perform the default de-serialization first aInputStream.defaultReadObject(); logger = LogManager.getContext().getLogger(name); diff --git a/log4j-slf4j-impl/src/test/java/org/apache/logging/slf4j/SerializeTest.java b/log4j-slf4j-impl/src/test/java/org/apache/logging/slf4j/SerializeTest.java index d8633a1863b..f16c5975799 100644 --- a/log4j-slf4j-impl/src/test/java/org/apache/logging/slf4j/SerializeTest.java +++ b/log4j-slf4j-impl/src/test/java/org/apache/logging/slf4j/SerializeTest.java @@ -20,6 +20,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import java.io.Serializable; +import java.util.Collections; import org.apache.logging.log4j.core.test.junit.LoggerContextSource; import org.junit.jupiter.api.Test; import org.slf4j.Logger; @@ -35,6 +36,9 @@ class SerializeTest { @Test void testLogger() { - assertThat((Serializable) logger, serializesRoundTrip()); + // `Log4jLogger` lives outside the `org.apache.logging.log4j.` namespace covered by + // FilteredObjectInputStream's default allow-list, so we have to enumerate it explicitly + // for the Java 8 surefire run that goes through FilteredObjectInputStream. + assertThat((Serializable) logger, serializesRoundTrip(Collections.singleton(Log4jLogger.class.getName()))); } } diff --git a/log4j-slf4j2-impl/src/main/java/org/apache/logging/slf4j/Log4jLogger.java b/log4j-slf4j2-impl/src/main/java/org/apache/logging/slf4j/Log4jLogger.java index 59607e28ed6..a5dd86240a3 100644 --- a/log4j-slf4j2-impl/src/main/java/org/apache/logging/slf4j/Log4jLogger.java +++ b/log4j-slf4j2-impl/src/main/java/org/apache/logging/slf4j/Log4jLogger.java @@ -26,6 +26,7 @@ import org.apache.logging.log4j.message.ParameterizedMessage; import org.apache.logging.log4j.message.SimpleMessage; import org.apache.logging.log4j.spi.ExtendedLogger; +import org.apache.logging.log4j.util.internal.SerializationUtil; import org.slf4j.Marker; import org.slf4j.spi.LocationAwareLogger; import org.slf4j.spi.LoggingEventBuilder; @@ -384,6 +385,7 @@ public String getName() { * the de-serialized object. */ private void readObject(final ObjectInputStream aInputStream) throws ClassNotFoundException, IOException { + SerializationUtil.assertFiltered(aInputStream); // always perform the default de-serialization first aInputStream.defaultReadObject(); logger = LogManager.getContext().getLogger(name); diff --git a/log4j-slf4j2-impl/src/test/java/org/apache/logging/slf4j/SerializeTest.java b/log4j-slf4j2-impl/src/test/java/org/apache/logging/slf4j/SerializeTest.java index cdea35ada64..1c400038e05 100644 --- a/log4j-slf4j2-impl/src/test/java/org/apache/logging/slf4j/SerializeTest.java +++ b/log4j-slf4j2-impl/src/test/java/org/apache/logging/slf4j/SerializeTest.java @@ -20,6 +20,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import java.io.Serializable; +import java.util.Collections; import org.apache.logging.log4j.core.test.junit.LoggerContextSource; import org.junit.jupiter.api.Test; import org.slf4j.Logger; @@ -35,6 +36,9 @@ class SerializeTest { @Test void testLogger() { - assertThat((Serializable) logger, serializesRoundTrip()); + // `Log4jLogger` lives outside the `org.apache.logging.log4j.` namespace covered by + // FilteredObjectInputStream's default allow-list, so we have to enumerate it explicitly + // for the Java 8 surefire run that goes through FilteredObjectInputStream. + assertThat((Serializable) logger, serializesRoundTrip(Collections.singleton(Log4jLogger.class.getName()))); } } diff --git a/src/changelog/.2.x.x/harden_message_deserialization.xml b/src/changelog/.2.x.x/harden_message_deserialization.xml new file mode 100644 index 00000000000..3088f8bf556 --- /dev/null +++ b/src/changelog/.2.x.x/harden_message_deserialization.xml @@ -0,0 +1,12 @@ + + + + + Harden `readObject(ObjectInputStream)` method argument checks in serializable API models + + From fcb6e4e7893af246e6ad0a649bf3685fbb9b02d5 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 15 Jun 2026 00:03:57 -0700 Subject: [PATCH 17/28] Fix resource leaks in `ConfigurationSource` when loading via URL (#4127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sebastien Tardif Co-authored-by: Ramanathan Co-authored-by: Piotr P. Karwasz Co-authored-by: Volkan Yazıcı --- .../core/config/ConfigurationSourceTest.java | 94 +++++++++++++++++++ .../core/config/ConfigurationSource.java | 51 ++++++---- .../fix_configuration_source_url_leak.xml | 8 ++ 3 files changed, 133 insertions(+), 20 deletions(-) create mode 100644 src/changelog/.2.x.x/fix_configuration_source_url_leak.xml diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/config/ConfigurationSourceTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/config/ConfigurationSourceTest.java index 3015f4f6271..9df40fcc4e6 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/config/ConfigurationSourceTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/config/ConfigurationSourceTest.java @@ -25,14 +25,22 @@ import com.sun.management.UnixOperatingSystemMXBean; import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.lang.management.ManagementFactory; import java.lang.management.OperatingSystemMXBean; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.URI; import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; import java.nio.file.Files; import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarOutputStream; @@ -104,6 +112,92 @@ void testLoadConfigurationSourceFromJarFile() throws Exception { } } + /** + * Verifies that a failed JAR configuration load (missing entry) does not leak file descriptors + * or hold a lock on the JAR file. + */ + @Test + void testJarFileLeakOnFailure() throws Exception { + final Path jarFile = prepareJarConfigURL(tempDir); + final String jarUriStr = "jar:" + jarFile.toUri().toASCIIString() + "!/" + java.util.UUID.randomUUID(); + final URI jarUri = URI.create(jarUriStr); + + final long expectedFdCount = getOpenFileDescriptorCount(); + + assertNull(ConfigurationSource.fromUri(jarUri)); + + assertEquals(expectedFdCount, getOpenFileDescriptorCount()); + Files.delete(jarFile); + } + + /** + * Verifies that the error-path cleanup in {@code getConfigurationSource} + * properly disconnects an {@link HttpURLConnection} on failure without + * calling {@link URLConnection#getInputStream()} a second time. + */ + @Test + void getConfigurationSource_disconnectsHttpConnection_onError() throws Exception { + final AtomicInteger getInputStreamCalls = new AtomicInteger(); + final AtomicBoolean disconnected = new AtomicBoolean(); + final AtomicInteger openConnectionCalls = new AtomicInteger(); + + final URLStreamHandler handler = new URLStreamHandler() { + @Override + protected URLConnection openConnection(final URL u) { + openConnectionCalls.incrementAndGet(); + return new HttpURLConnection(u) { + @Override + public InputStream getInputStream() throws IOException { + getInputStreamCalls.incrementAndGet(); + throw new FileNotFoundException("Mocked 404"); + } + + @Override + public void disconnect() { + disconnected.set(true); + } + + @Override + public boolean usingProxy() { + return false; + } + + @Override + public void connect() {} + }; + } + }; + + final URL url = new URL("http", "example.com", 80, "/missing.xml", handler); + + // Allow the "http" protocol for this test. + final String propKey = "log4j2.Configuration.allowedProtocols"; + final String previous = System.getProperty(propKey); + System.setProperty(propKey, "file, https, jar, http"); + try { + openConnectionCalls.set(0); + getInputStreamCalls.set(0); + + final Method method = ConfigurationSource.class.getDeclaredMethod("getConfigurationSource", URL.class); + method.setAccessible(true); + final Object result = method.invoke(null, url); + + assertTrue(openConnectionCalls.get() > 0, "Custom URLStreamHandler was not used by UrlConnectionFactory"); + + assertNull(result, "Expected null return for a 404 response"); + + assertTrue(disconnected.get(), "disconnect() must be called on the error path"); + + assertEquals(1, getInputStreamCalls.get(), "getInputStream() should be called exactly once"); + } finally { + if (previous != null) { + System.setProperty(propKey, previous); + } else { + System.clearProperty(propKey); + } + } + } + private long getOpenFileDescriptorCount() { final OperatingSystemMXBean os = ManagementFactory.getOperatingSystemMXBean(); if (os instanceof UnixOperatingSystemMXBean) { diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/config/ConfigurationSource.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/config/ConfigurationSource.java index dd50987f257..d2fdf0adf1a 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/config/ConfigurationSource.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/config/ConfigurationSource.java @@ -23,6 +23,7 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; +import java.net.HttpURLConnection; import java.net.JarURLConnection; import java.net.MalformedURLException; import java.net.URI; @@ -356,30 +357,40 @@ public String toString() { value = "PATH_TRAVERSAL_IN", justification = "The name of the accessed files is based on a configuration value.") private static /*@Nullable*/ ConfigurationSource getConfigurationSource(final URL url) { + final File file; + final URLConnection urlConnection; try { - final File file = FileUtils.fileFromUri(url.toURI()); - final URLConnection urlConnection = UrlConnectionFactory.createConnection(url); - try { - if (file != null) { - return new ConfigurationSource(urlConnection.getInputStream(), FileUtils.fileFromUri(url.toURI())); - } else if (urlConnection instanceof JarURLConnection) { - // Work around https://bugs.openjdk.java.net/browse/JDK-6956385. - URL jarFileUrl = ((JarURLConnection) urlConnection).getJarFileURL(); - File jarFile = new File(jarFileUrl.getFile()); - long lastModified = jarFile.lastModified(); - return new ConfigurationSource(urlConnection.getInputStream(), url, lastModified); - } else { - return new ConfigurationSource( - urlConnection.getInputStream(), url, urlConnection.getLastModified()); - } - } catch (FileNotFoundException ex) { - ConfigurationFactory.LOGGER.info("Unable to locate file {}, ignoring.", url.toString()); - return null; - } - } catch (IOException | URISyntaxException ex) { + file = FileUtils.fileFromUri(url.toURI()); + urlConnection = UrlConnectionFactory.createConnection(url); + } catch (final IOException | URISyntaxException ex) { ConfigurationFactory.LOGGER.warn( "Error accessing {} due to {}, ignoring.", url.toString(), ex.getMessage()); return null; } + try { + if (file != null) { + return new ConfigurationSource(urlConnection.getInputStream(), file); + } else if (urlConnection instanceof JarURLConnection) { + // Work around https://bugs.openjdk.java.net/browse/JDK-6956385. + final URL jarFileUrl = ((JarURLConnection) urlConnection).getJarFileURL(); + final File jarFile = new File(jarFileUrl.getFile()); + final long lastModified = jarFile.lastModified(); + return new ConfigurationSource(urlConnection.getInputStream(), url, lastModified); + } else { + final long lastModified = urlConnection.getLastModified(); + return new ConfigurationSource(urlConnection.getInputStream(), url, lastModified); + } + } catch (final IOException ex) { + if (ex instanceof FileNotFoundException) { + ConfigurationFactory.LOGGER.info("Unable to locate file {}, ignoring.", url.toString()); + } else { + ConfigurationFactory.LOGGER.warn( + "Error accessing {} due to {}, ignoring.", url.toString(), ex.getMessage()); + } + if (urlConnection instanceof HttpURLConnection) { + ((HttpURLConnection) urlConnection).disconnect(); + } + return null; + } } } diff --git a/src/changelog/.2.x.x/fix_configuration_source_url_leak.xml b/src/changelog/.2.x.x/fix_configuration_source_url_leak.xml new file mode 100644 index 00000000000..e7c57d5a1a1 --- /dev/null +++ b/src/changelog/.2.x.x/fix_configuration_source_url_leak.xml @@ -0,0 +1,8 @@ + + + + Fix resource leaks in `ConfigurationSource` when loading configuration via URL fails + From 4f79ad0d317f5700362a5a96570e222240e7350e Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 18 Jun 2026 10:24:04 +0200 Subject: [PATCH 18/28] Adopt the Dependabot changelog 'draft trick' (#4148) Applies the consumer-side changes from logging-parent PR #476: * `build.yaml` and `codeql-analysis.yaml` now subscribe to the `ready_for_review` pull request type, so required checks re-run when a Dependabot PR is taken out of draft. * `process-dependabot.yaml` drops the `RECURSIVE_TOKEN` PAT secret, which is no longer needed now that the reusable workflow parks the PR in draft mode instead of pushing with a privileged token. Assisted-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yaml | 7 +++++++ .github/workflows/codeql-analysis.yaml | 7 +++++++ .github/workflows/process-dependabot.yaml | 7 ++----- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 5d57675bbe1..9566bbb255f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -24,6 +24,13 @@ on: - "2.25.x" - "release/2*" pull_request: + types: + # Standard types + - opened + - synchronize + - reopened + # Used in Dependabot PRs to retrigger required workflows + - ready_for_review # Cancel in-progress runs when a newer commit lands on the same PR; pushes to 2.x run to completion. concurrency: diff --git a/.github/workflows/codeql-analysis.yaml b/.github/workflows/codeql-analysis.yaml index 139b2c782ce..cf59560a10c 100644 --- a/.github/workflows/codeql-analysis.yaml +++ b/.github/workflows/codeql-analysis.yaml @@ -22,6 +22,13 @@ on: branches: [ "2.x", "main" ] pull_request: branches: [ "2.x", "main" ] + types: + # Standard types + - opened + - synchronize + - reopened + # Used in Dependabot PRs to retrigger required workflows + - ready_for_review schedule: - cron: '32 12 * * 5' diff --git a/.github/workflows/process-dependabot.yaml b/.github/workflows/process-dependabot.yaml index 0688b148d3c..d67dfbdebda 100644 --- a/.github/workflows/process-dependabot.yaml +++ b/.github/workflows/process-dependabot.yaml @@ -39,13 +39,10 @@ jobs: }} uses: apache/logging-parent/.github/workflows/process-dependabot-reusable.yaml@gha/v0 permissions: - # The default GITHUB_TOKEN will be used to enable the "auto-merge" on the PR - # This requires the following two permissions: + # Append the changelog commit contents: write + # Convert the PR into draft pull-requests: write - secrets: - # This token will be used to push new content to the repo and trigger workflows again - RECURSIVE_TOKEN: ${{ secrets.DEPENDABOT_TOKEN }} with: # The path to the changelog directory for the current development branch. changelog-path: src/changelog/.2.x.x From 34bc8cce1bdf24790233bfd44e1c4687de1a4756 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 25 Jun 2026 07:36:49 +0200 Subject: [PATCH 19/28] Add Dependabot config for the 2.26.x maintenance branch (#4149) Configure a Maven update entry targeting the `2.26.x` branch that only proposes patch-level upgrades. All dependencies are bundled into a single `Maven patch updates` group with a 7-day cooldown. --- .github/dependabot.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 5c0cd9abb23..e7d9bf7726a 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -166,6 +166,27 @@ updates: patterns: [ "*" ] target-branch: "2.x" + # The `2.26.x` maintenance branch only receives patch-level Maven updates. + - package-ecosystem: maven + directory: "/" + schedule: + interval: "monthly" + cooldown: + default-days: 7 + groups: + # "Bump the Maven patch updates group across N directories with M updates" + Maven patch updates: + patterns: [ "*" ] + target-branch: "2.26.x" + registries: + - maven-central + ignore: + # Only allow patch-level upgrades on this maintenance branch + - dependency-name: "*" + update-types: + - "version-update:semver-major" + - "version-update:semver-minor" + - package-ecosystem: maven directory: "/" schedule: From c7103d5e75dcc0298f29995b57aa1a63f7087303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20Yaz=C4=B1c=C4=B1?= Date: Mon, 29 Jun 2026 08:21:02 +0200 Subject: [PATCH 20/28] Fix handling of non-finite numbers while encoding `MapMessage` to JSON (#4163) --- .../logging/log4j/message/MapMessageTest.java | 26 ++++++++++++++++ .../message/MapMessageJsonFormatter.java | 30 +++++++++++++++---- .../fix-MapMessage-JSON-non-finite-number.xml | 12 ++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 src/changelog/.2.x.x/fix-MapMessage-JSON-non-finite-number.xml diff --git a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/MapMessageTest.java b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/MapMessageTest.java index db29a189499..1b45f7b8a0d 100644 --- a/log4j-api-test/src/test/java/org/apache/logging/log4j/message/MapMessageTest.java +++ b/log4j-api-test/src/test/java/org/apache/logging/log4j/message/MapMessageTest.java @@ -32,6 +32,8 @@ import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.util.StringBuilderFormattable; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; /** * @@ -171,6 +173,30 @@ void testJsonFormatterNestedObjectSupport() { assertEquals(expectedJson, actualJson); } + @ParameterizedTest + @ValueSource(doubles = {Double.NEGATIVE_INFINITY, Double.NaN, Double.POSITIVE_INFINITY}) + void testJsonFormatterDoubleNonFiniteSupport(final double number) { + final String expectedJson = String.format("{'number':'%s','numbers':['%s']}", number, number) + .replace('\'', '"'); + final String actualJson = new ObjectMapMessage() + .with("number", number) + .with("numbers", new double[] {number}) + .getFormattedMessage(new String[] {"JSON"}); + assertEquals(expectedJson, actualJson); + } + + @ParameterizedTest + @ValueSource(floats = {Float.NEGATIVE_INFINITY, Float.NaN, Float.POSITIVE_INFINITY}) + void testJsonFormatterFloatNonFiniteSupport(final float number) { + final String expectedJson = String.format("{'number':'%s','numbers':['%s']}", number, number) + .replace('\'', '"'); + final String actualJson = new ObjectMapMessage() + .with("number", number) + .with("numbers", new float[] {number}) + .getFormattedMessage(new String[] {"JSON"}); + assertEquals(expectedJson, actualJson); + } + @Test void testJsonFormatterInfiniteRecursionPrevention() { final List recursiveValue = Arrays.asList(1, null); diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/message/MapMessageJsonFormatter.java b/log4j-api/src/main/java/org/apache/logging/log4j/message/MapMessageJsonFormatter.java index c5e5127f533..a309107471f 100644 --- a/log4j-api/src/main/java/org/apache/logging/log4j/message/MapMessageJsonFormatter.java +++ b/log4j-api/src/main/java/org/apache/logging/log4j/message/MapMessageJsonFormatter.java @@ -233,13 +233,13 @@ private static void formatCollection(final StringBuilder sb, final Collection + + + + Fix handling of non-finite numbers while encoding `MapMessage` to JSON + + From 70d3313ec95c44461af18417ad0106e9f4e75a43 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Mon, 29 Jun 2026 08:36:04 +0200 Subject: [PATCH 21/28] Remove `/log4j-slf4j-impl` Dependabot execution (#4151) Instead of correcting the target branch, we can safely remove it: the only dependency version defined in `log4j-slf4j-impl` is SLF4J 1.x, which is highly unlikely to see any new releases. --- .github/dependabot.yaml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index e7d9bf7726a..3c8075a44e2 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -219,22 +219,6 @@ updates: - dependency-name: "org.fusesource.jansi:jansi" update-types: [ "version-update:semver-major" ] - - package-ecosystem: maven - directories: - - "/log4j-slf4j-impl" - schedule: - interval: "monthly" - groups: - dependencies: - patterns: [ "*" ] - target-branch: "main" - registries: - - maven-central - ignore: - # SLF4J 1.7.x should only upgrade to 1.7.x and - - dependency-name: "org.slf4j:slf4j-api" - versions: [ "[1,)" ] - - package-ecosystem: github-actions directory: "/" schedule: From 16ce65bcaea32e53c488789bdb0771069f1450c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20Yaz=C4=B1c=C4=B1?= Date: Wed, 1 Jul 2026 10:42:53 +0200 Subject: [PATCH 22/28] Upgrade Maven and Maven Wrapper versions to `3.9.16` and `3.3.4`, respectively (#4167) --- .mvn/wrapper/maven-wrapper.properties | 6 +-- mvnw | 48 ++++++++++++++++++++--- mvnw.cmd | 56 +++++++++++++++++++++++---- 3 files changed, 93 insertions(+), 17 deletions(-) diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index c76af40b2f9..54c1c2a8881 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -14,7 +14,7 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -distributionSha256Sum=8351955a9acf2f83c136c4eee0f6db894ab6265fdbe0a94b32a380307dbaa3e1 +distributionSha256Sum=5af3b743dd8b876b5c45da33b676251e5f1687712644abb4ee519ca56e1d89ce distributionType=only-script -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.8/apache-maven-3.9.8-bin.zip -wrapperVersion=3.3.2 +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip +wrapperVersion=3.3.4 diff --git a/mvnw b/mvnw index 14c58dcb002..bd8896bf221 100755 --- a/mvnw +++ b/mvnw @@ -19,7 +19,7 @@ # ---------------------------------------------------------------------------- # ---------------------------------------------------------------------------- -# Apache Maven Wrapper startup batch script, version 3.3.2 +# Apache Maven Wrapper startup batch script, version 3.3.4 # # Optional ENV vars # ----------------- @@ -105,14 +105,17 @@ trim() { printf "%s" "${1}" | tr -d '[:space:]' } +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties while IFS="=" read -r key value; do case "${key-}" in distributionUrl) distributionUrl=$(trim "${value-}") ;; distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; esac -done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" -[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" case "${distributionUrl##*/}" in maven-mvnd-*bin.*) @@ -130,7 +133,7 @@ maven-mvnd-*bin.*) distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" ;; maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; -*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; esac # apply MVNW_REPOURL and calculate MAVEN_HOME @@ -252,8 +255,41 @@ if command -v unzip >/dev/null; then else tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" fi -printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" -mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" clean || : exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd index 249bdf38222..92450f93273 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -19,7 +19,7 @@ @REM ---------------------------------------------------------------------------- @REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM Apache Maven Wrapper startup batch script, version 3.3.4 @REM @REM Optional ENV vars @REM MVNW_REPOURL - repo url base for downloading maven distribution @@ -40,7 +40,7 @@ @SET __MVNW_ARG0_NAME__= @SET MVNW_USERNAME= @SET MVNW_PASSWORD= -@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) @echo Cannot start maven from wrapper >&2 && exit /b 1 @GOTO :EOF : end batch / begin powershell #> @@ -73,16 +73,30 @@ switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { # apply MVNW_REPOURL and calculate MAVEN_HOME # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ if ($env:MVNW_REPOURL) { - $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } - $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" } $distributionUrlName = $distributionUrl -replace '^.*/','' $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' -$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" + +$MAVEN_M2_PATH = "$HOME/.m2" if ($env:MAVEN_USER_HOME) { - $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" } -$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { @@ -134,7 +148,33 @@ if ($distributionSha256Sum) { # unzip and move Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null -Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null try { Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null } catch { From c8efe4b46e74196be30686cb55b452238c5b4e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Volkan=20Yaz=C4=B1c=C4=B1?= Date: Wed, 1 Jul 2026 11:20:48 +0200 Subject: [PATCH 23/28] Adds `maxRandomDelay` configure to Rolling Appenders (#4165) * Rework random delay for Rolling Appender action chain * Stabilize `RollingAppenderDirectCronTest` --- .../FileExtensionCompressDelayTest.java | 179 ------------- .../RollingAppenderDirectCronTest.java | 12 +- .../RollingAppenderRandomDelayTest.java | 235 ++++++++++++++++++ .../rolling/action/GzCompressActionTest.java | 70 ++---- .../rolling/action/ZipCompressActionTest.java | 70 +----- .../core/appender/RollingFileAppender.java | 17 +- .../RollingRandomAccessFileAppender.java | 12 + .../log4j/core/appender/package-info.java | 2 +- .../rolling/DefaultRolloverStrategy.java | 77 +----- .../core/appender/rolling/FileExtension.java | 108 +------- .../appender/rolling/RollingFileManager.java | 167 +++++++++---- .../RollingRandomAccessFileManager.java | 38 ++- .../rolling/action/AbstractAction.java | 19 -- .../rolling/action/CommonsCompressAction.java | 27 -- .../rolling/action/GzCompressAction.java | 45 +--- .../rolling/action/ZipCompressAction.java | 31 +-- .../appender/rolling/action/package-info.java | 2 +- ...elay.xml => 4012_add_max_random_delay.xml} | 2 +- .../pages/manual/appenders/rolling-file.adoc | 24 +- 19 files changed, 492 insertions(+), 645 deletions(-) delete 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/RollingAppenderRandomDelayTest.java rename src/changelog/.2.x.x/{4012_add_max_compression_delay.xml => 4012_add_max_random_delay.xml} (74%) 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 deleted file mode 100644 index 98ebf8a6915..00000000000 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/FileExtensionCompressDelayTest.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * 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/RollingAppenderDirectCronTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingAppenderDirectCronTest.java index 444016dfd1f..a1fbe28ff69 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingAppenderDirectCronTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingAppenderDirectCronTest.java @@ -50,10 +50,10 @@ class RollingAppenderDirectCronTest { @LoggerContextSource("classpath:appender/rolling/RollingAppenderDirectCronTest.xml") void testAppender(final LoggerContext ctx, @Named("RollingFile") final RollingFileAppender app) throws Exception { final Logger logger = ctx.getLogger(RollingAppenderDirectCronTest.class); + final RolloverDelay delay = new RolloverDelay(app.getManager()); int msgNumber = 1; logger.debug("This is test message number {}.", msgNumber++); assertThat(loggingPath).isNotEmptyDirectory(); - final RolloverDelay delay = new RolloverDelay(app.getManager()); delay.waitForRollover(); delay.reset(3); @@ -100,11 +100,11 @@ public void rolloverComplete(final String fileName) { final Path path = Paths.get(fileName); final Matcher matcher = FILE_PATTERN.matcher(path.getFileName().toString()); assertThat(matcher).as("Rolled file").matches(); - waitAtMost(2, TimeUnit.SECONDS).untilAsserted(() -> { - final List lines = Files.readAllLines(path); - assertThat(lines).isNotEmpty(); - assertThat(lines.get(0)).startsWith(matcher.group(1)); - }); + final List lines = Files.readAllLines(path); + if (lines.isEmpty()) { + return; + } + assertThat(lines.get(0)).startsWith(matcher.group(1)); latch.countDown(); } catch (final Exception ex) { verificationFailure = diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingAppenderRandomDelayTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingAppenderRandomDelayTest.java new file mode 100644 index 00000000000..9fd691d261a --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/RollingAppenderRandomDelayTest.java @@ -0,0 +1,235 @@ +/* + * 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.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.logging.log4j.core.appender.RollingFileAppender; +import org.apache.logging.log4j.core.appender.RollingRandomAccessFileAppender; +import org.apache.logging.log4j.core.appender.rolling.action.AbstractAction; +import org.apache.logging.log4j.core.appender.rolling.action.Action; +import org.apache.logging.log4j.core.appender.rolling.action.CompositeAction; +import org.apache.logging.log4j.core.config.Configuration; +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 RollingAppenderRandomDelayTest { + + @Test + void testRollingFileAppenderPassesMaxRandomDelayToManager(@TempDir File tempDir) { + final Configuration configuration = new NullConfiguration(); + final int maxRandomDelay = 17; + final RollingFileAppender appender = RollingFileAppender.newBuilder() + .setName("RollingFileAppender") + .setFileName(new File(tempDir, "app.log").getAbsolutePath()) + .setFilePattern(new File(tempDir, "app-%i.log.gz").getAbsolutePath()) + .setPolicy(NoOpTriggeringPolicy.INSTANCE) + .setConfiguration(configuration) + .setMaxRandomDelay(maxRandomDelay) + .build(); + assertNotNull(appender); + try { + assertEquals(maxRandomDelay, appender.getManager().getMaxRandomDelay()); + } finally { + appender.stop(); + } + } + + @Test + void testRollingRandomAccessFileAppenderPassesMaxRandomDelayToManager(@TempDir File tempDir) { + final Configuration configuration = new NullConfiguration(); + final int maxRandomDelay = 19; + final RollingRandomAccessFileAppender appender = RollingRandomAccessFileAppender.newBuilder() + .setName("RollingRandomAccessFileAppender") + .setFileName(new File(tempDir, "app-ra.log").getAbsolutePath()) + .setFilePattern(new File(tempDir, "app-ra-%i.log.gz").getAbsolutePath()) + .setPolicy(NoOpTriggeringPolicy.INSTANCE) + .setConfiguration(configuration) + .setMaxRandomDelay(maxRandomDelay) + .build(); + assertNotNull(appender); + try { + assertEquals(maxRandomDelay, appender.getManager().getMaxRandomDelay()); + } finally { + appender.stop(); + } + } + + @Test + void testRollingFileManagerSchedulesAsyncActionAfterDelayAndKeepsActionsSequential(@TempDir File tempDir) + throws Exception { + final long delayMillis = 150; + final CountDownLatch completed = new CountDownLatch(2); + final AtomicInteger executionOrder = new AtomicInteger(); + final RecordingAction firstAction = new RecordingAction(executionOrder, completed); + final RecordingAction secondAction = new RecordingAction(executionOrder, completed); + final Action asyncAction = new CompositeAction(Arrays.asList(firstAction, secondAction), true); + final RolloverStrategy strategy = + manager -> new RolloverDescriptionImpl(manager.getFileName(), false, null, asyncAction); + final File file = new File(tempDir, "scheduled.log"); + + try (TestRollingFileManager manager = new TestRollingFileManager(file, strategy, delayMillis)) { + final long rolloverStartNanos = System.nanoTime(); + manager.rollover(); + + assertTrue(completed.await(3, TimeUnit.SECONDS), "Async rollover actions did not complete"); + final long actualDelayMillis = TimeUnit.NANOSECONDS.toMillis(firstAction.startNanos - rolloverStartNanos); + assertTrue( + actualDelayMillis >= delayMillis - 10, + "Async rollover action started before the configured scheduling delay"); + assertEquals(1, firstAction.order); + assertEquals(2, secondAction.order); + assertTrue(secondAction.startNanos >= firstAction.endNanos, "Composite actions must execute sequentially"); + } + } + + @Test + void testRollingFileManagerUsesNoAsyncActionDelayByDefault(@TempDir File tempDir) { + final RolloverStrategy strategy = + manager -> new RolloverDescriptionImpl(manager.getFileName(), false, null, null); + final File file = new File(tempDir, "immediate.log"); + + try (TestRollingFileManager manager = new TestRollingFileManager(file, strategy)) { + assertEquals(0, manager.getMaxRandomDelay()); + assertEquals(0, manager.getAsyncActionDelayMillis()); + } + } + + @Test + void testRollingFileManagerRandomAsyncActionDelayDoesNotRequireCompression(@TempDir File tempDir) { + final int maxRandomDelay = 2; + final long maxDelayMillis = TimeUnit.SECONDS.toMillis(maxRandomDelay); + final RolloverStrategy strategy = + manager -> new RolloverDescriptionImpl(manager.getFileName(), false, null, null); + final File file = new File(tempDir, "no-compression-pattern.log"); + + try (TestRollingFileManager manager = new TestRollingFileManager(file, strategy)) { + manager.setMaxRandomDelay(maxRandomDelay); + boolean nonZeroDelayObserved = false; + for (int index = 0; index < 100; index++) { + final long delayMillis = manager.getAsyncActionDelayMillis(); + assertTrue(delayMillis >= 0, "Async rollover delay must not be negative"); + assertTrue(delayMillis <= maxDelayMillis, "Async rollover delay exceeded configured maximum"); + nonZeroDelayObserved |= delayMillis > 0; + } + assertTrue(nonZeroDelayObserved, "Configured async rollover delay should not require compression"); + } + } + + @Test + void testRollingFileManagerRandomAsyncActionDelayFallsWithinConfiguredRangeForCompression(@TempDir File tempDir) { + final int maxRandomDelay = 2; + final long maxDelayMillis = TimeUnit.SECONDS.toMillis(maxRandomDelay); + final RolloverStrategy strategy = + manager -> new RolloverDescriptionImpl(manager.getFileName(), false, null, null); + final File file = new File(tempDir, "random-compression-pattern.log"); + + try (TestRollingFileManager manager = + new TestRollingFileManager(file, file.getAbsolutePath() + ".%i.gz", strategy)) { + manager.setMaxRandomDelay(maxRandomDelay); + for (int index = 0; index < 100; index++) { + final long delayMillis = manager.getAsyncActionDelayMillis(); + assertTrue(delayMillis >= 0, "Async rollover delay must not be negative"); + assertTrue(delayMillis <= maxDelayMillis, "Async rollover delay exceeded configured maximum"); + } + } + } + + private static final class TestRollingFileManager extends RollingFileManager { + + private final long asyncActionDelayMillis; + + private TestRollingFileManager(final File file, final RolloverStrategy strategy) { + this(file, file.getAbsolutePath() + ".%i", strategy, -1); + } + + private TestRollingFileManager( + final File file, final RolloverStrategy strategy, final long asyncActionDelayMillis) { + this(file, file.getAbsolutePath() + ".%i", strategy, asyncActionDelayMillis); + } + + private TestRollingFileManager(final File file, final String pattern, final RolloverStrategy strategy) { + this(file, pattern, strategy, -1); + } + + private TestRollingFileManager( + final File file, + final String pattern, + final RolloverStrategy strategy, + final long asyncActionDelayMillis) { + super( + null, + file.getAbsolutePath(), + pattern, + new ByteArrayOutputStream(), + true, + false, + 0, + System.currentTimeMillis(), + NoOpTriggeringPolicy.INSTANCE, + strategy, + null, + PatternLayout.createDefaultLayout(), + null, + null, + null, + false, + ByteBuffer.allocate(256)); + this.asyncActionDelayMillis = asyncActionDelayMillis; + } + + @Override + long getAsyncActionDelayMillis() { + return asyncActionDelayMillis >= 0 ? asyncActionDelayMillis : super.getAsyncActionDelayMillis(); + } + } + + private static final class RecordingAction extends AbstractAction { + + private final AtomicInteger executionOrder; + private final CountDownLatch completed; + private volatile int order; + private volatile long startNanos; + private volatile long endNanos; + + private RecordingAction(final AtomicInteger executionOrder, final CountDownLatch completed) { + this.executionOrder = executionOrder; + this.completed = completed; + } + + @Override + public boolean execute() throws IOException { + startNanos = System.nanoTime(); + order = executionOrder.incrementAndGet(); + endNanos = System.nanoTime(); + completed.countDown(); + return true; + } + } +} 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 index 14d72c56dd0..1ac77c4bbdd 100644 --- 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 @@ -34,7 +34,7 @@ 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)); + assertThrows(IllegalArgumentException.class, () -> new GzCompressAction(source, dest, true, -2)); } @Test @@ -42,7 +42,7 @@ 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)); + assertThrows(IllegalArgumentException.class, () -> new GzCompressAction(source, dest, true, 10)); } @Test @@ -50,71 +50,39 @@ 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); + new GzCompressAction(source, dest, true, Deflater.DEFAULT_COMPRESSION); + new GzCompressAction(source, dest, true, Deflater.BEST_COMPRESSION); } - /** Issue #4012 — when maxDelaySeconds > 0, compression must be deferred by a random 0..max seconds. */ @Test - void testRandomDelayBeforeCompression(@TempDir File tempDir) throws IOException { + void testCompression(@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"); - } + writeContent(source, "test data"); - /** - * 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; + GzCompressAction action = new GzCompressAction(source, dest, true, Deflater.DEFAULT_COMPRESSION); - // No delay: must complete in well under 500ms - assertTrue(elapsed < 500, "Compression with maxDelay=0 should be instant, but took " + elapsed + "ms"); + assertTrue(action.execute()); 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 { + void testLegacyConstructor(@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; + writeContent(source, "legacy test data"); + + GzCompressAction action = new GzCompressAction(source, dest, true); - assertTrue(elapsed < 500, "Legacy constructor should have no delay, but took " + elapsed + "ms"); + assertTrue(action.execute()); assertTrue(dest.exists(), "Compressed file must exist"); assertFalse(source.exists(), "Source file must be deleted"); } + + 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/ZipCompressActionTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/ZipCompressActionTest.java index b8bc76ce538..16b8e71f4b6 100644 --- 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 @@ -23,6 +23,7 @@ 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; @@ -33,7 +34,7 @@ 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)); + assertThrows(IllegalArgumentException.class, () -> new ZipCompressAction(source, dest, true, -2)); } @Test @@ -41,7 +42,7 @@ 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)); + assertThrows(IllegalArgumentException.class, () -> new ZipCompressAction(source, dest, true, 10)); } @Test @@ -49,72 +50,27 @@ 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); + new ZipCompressAction(source, dest, true, Deflater.DEFAULT_COMPRESSION); + new ZipCompressAction(source, dest, true, Deflater.NO_COMPRESSION); + new ZipCompressAction(source, dest, true, Deflater.BEST_COMPRESSION); } - /** Issue #4012 — when maxDelaySeconds > 0, compression must be deferred by a random 0..max seconds. */ @Test - void testRandomDelayBeforeCompression(@TempDir File tempDir) throws IOException { + void testCompression(@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; + writeContent(source, "test data"); - // 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"); - } + ZipCompressAction action = new ZipCompressAction(source, dest, true, Deflater.DEFAULT_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(action.execute()); 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"); + private static void writeContent(final File file, final String content) throws IOException { + try (FileWriter writer = new FileWriter(file)) { + writer.write(content); } - 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/RollingFileAppender.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/RollingFileAppender.java index a78be3e4c55..4323661a3d3 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/RollingFileAppender.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/RollingFileAppender.java @@ -103,6 +103,9 @@ public static class Builder> extends AbstractOutputStreamAp @PluginBuilderAttribute private String fileGroup; + @PluginBuilderAttribute + private int maxRandomDelay; + @Override public RollingFileAppender build() { if (!isValid()) { @@ -151,11 +154,11 @@ public RollingFileAppender build() { advertiseUri, layout, bufferSize, - isImmediateFlush(), createOnDemand, filePermissions, fileOwner, fileGroup, + maxRandomDelay, getConfiguration()); if (manager == null) { return null; @@ -212,6 +215,10 @@ public String getFileGroup() { return fileGroup; } + public int getMaxRandomDelay() { + return maxRandomDelay; + } + /** * @since 2.26.0 */ @@ -374,6 +381,14 @@ public B setFileGroup(final String fileGroup) { return asBuilder(); } + /** + * @since 2.27.0 + */ + public B setMaxRandomDelay(final int maxRandomDelay) { + this.maxRandomDelay = maxRandomDelay; + return asBuilder(); + } + /** * @deprecated since 2.26.0 use {@link #setFilePattern(String)}. */ diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/RollingRandomAccessFileAppender.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/RollingRandomAccessFileAppender.java index 5e7361f68ff..e08deea9923 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/RollingRandomAccessFileAppender.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/RollingRandomAccessFileAppender.java @@ -93,6 +93,9 @@ public Builder() { @PluginBuilderAttribute private String fileGroup; + @PluginBuilderAttribute + private int maxRandomDelay; + @Override public RollingRandomAccessFileAppender build() { final String name = getName(); @@ -147,6 +150,7 @@ public RollingRandomAccessFileAppender build() { filePermissions, fileOwner, fileGroup, + maxRandomDelay, getConfiguration()); if (manager == null) { return null; @@ -248,6 +252,14 @@ public B setFileGroup(final String fileGroup) { return asBuilder(); } + /** + * @since 2.27.0 + */ + public B setMaxRandomDelay(final int maxRandomDelay) { + this.maxRandomDelay = maxRandomDelay; + return asBuilder(); + } + /** * @deprecated since 2.26.0 use {@link #setFileName(String)}. */ diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/package-info.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/package-info.java index a18b7715e4f..5f5331549cf 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/package-info.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/package-info.java @@ -18,7 +18,7 @@ * Log4j 2 Appenders. */ @Export -@Version("2.26.0") +@Version("2.27.0") package org.apache.logging.log4j.core.appender; import org.osgi.annotation.bundle.Export; 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 74fbbf8359d..06eefa84ceb 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,9 +109,6 @@ 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; @@ -159,8 +156,7 @@ public DefaultRolloverStrategy build() { nonNullStrSubstitutor, customActions, stopCustomActionsOnError, - tempCompressedFilePattern, - maxCompressionDelaySeconds); + tempCompressedFilePattern); } public String getMax() { @@ -363,18 +359,6 @@ 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 @@ -438,7 +422,6 @@ public static DefaultRolloverStrategy createStrategy( private final List customActions; private final boolean stopCustomActionsOnError; private final PatternProcessor tempCompressedFilePattern; - private final int maxCompressionDelaySeconds; /** * Constructs a new instance. @@ -466,8 +449,7 @@ protected DefaultRolloverStrategy( strSubstitutor, customActions, stopCustomActionsOnError, - null, - 0); + null); } /** @@ -489,40 +471,6 @@ 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; @@ -532,7 +480,6 @@ 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() { @@ -563,16 +510,6 @@ 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); } @@ -746,17 +683,11 @@ public RolloverDescription rollover(final RollingFileManager manager) throws Sec } compressAction = new CompositeAction( Arrays.asList( - fileExtension.createCompressAction( - renameTo, - tmpCompressedName, - true, - compressionLevel, - maxCompressionDelaySeconds), + fileExtension.createCompressAction(renameTo, tmpCompressedName, true, compressionLevel), new FileRenameAction(tmpCompressedNameFile, renameToFile, true)), true); } else { - compressAction = fileExtension.createCompressAction( - renameTo, compressedName, true, compressionLevel, maxCompressionDelaySeconds); + compressAction = fileExtension.createCompressAction(renameTo, compressedName, true, compressionLevel); } } 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 eb453628e86..e62419b6858 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,22 +35,7 @@ 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) { - return new ZipCompressAction( - new File(renameTo), - new File(compressedName), - deleteSource, - compressionLevel, - maxCompressionDelaySeconds); + return new ZipCompressAction(source(renameTo), target(compressedName), deleteSource, compressionLevel); } }, GZ(".gz") { @@ -60,22 +45,7 @@ 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) { - return new GzCompressAction( - new File(renameTo), - new File(compressedName), - deleteSource, - compressionLevel, - maxCompressionDelaySeconds); + return new GzCompressAction(source(renameTo), target(compressedName), deleteSource, compressionLevel); } }, BZIP2(".bz2") { @@ -85,19 +55,8 @@ 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, maxCompressionDelaySeconds); + return new CommonsCompressAction("bzip2", source(renameTo), target(compressedName), deleteSource); } }, DEFLATE(".deflate") { @@ -107,19 +66,8 @@ 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, maxCompressionDelaySeconds); + return new CommonsCompressAction("deflate", source(renameTo), target(compressedName), deleteSource); } }, PACK200(".pack200") { @@ -129,19 +77,8 @@ 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( - "pack200", source(renameTo), target(compressedName), deleteSource, maxCompressionDelaySeconds); + // One of "gz", "bzip2", "xz", "zst", "pack200", or "deflate". + return new CommonsCompressAction("pack200", source(renameTo), target(compressedName), deleteSource); } }, XZ(".xz") { @@ -151,19 +88,8 @@ 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, maxCompressionDelaySeconds); + return new CommonsCompressAction("xz", source(renameTo), target(compressedName), deleteSource); } }, ZSTD(".zst") { @@ -173,19 +99,8 @@ 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, maxCompressionDelaySeconds); + return new CommonsCompressAction("zstd", source(renameTo), target(compressedName), deleteSource); } }; @@ -217,13 +132,6 @@ 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/RollingFileManager.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/RollingFileManager.java index 0c62bfac965..75b7df81cf5 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 @@ -26,13 +26,12 @@ import java.nio.file.Path; import java.nio.file.attribute.BasicFileAttributes; import java.nio.file.attribute.FileTime; -import java.util.Collection; import java.util.Date; -import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.Semaphore; -import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReferenceFieldUpdater; import org.apache.logging.log4j.core.Layout; @@ -70,12 +69,12 @@ public class RollingFileManager extends FileManager { private volatile boolean initialized; private volatile String fileName; private final boolean directWrite; + private volatile int maxRandomDelay; private final CopyOnWriteArrayList rolloverListeners = new CopyOnWriteArrayList<>(); - /* This executor pool will create a new Thread for every work async action to be performed. Using it allows - us to make sure all the Threads are completed when the Manager is stopped. */ - private final ExecutorService asyncExecutor = - new ThreadPoolExecutor(0, Integer.MAX_VALUE, 0, TimeUnit.MILLISECONDS, new EmptyQueue(), threadFactory); + /* This executor service schedules asynchronous rollover actions and ensures they are completed when the manager + is stopped. */ + private final ScheduledExecutorService asyncExecutor = new ScheduledThreadPoolExecutor(1, threadFactory); private static final AtomicReferenceFieldUpdater triggeringPolicyUpdater = AtomicReferenceFieldUpdater.newUpdater( @@ -294,6 +293,60 @@ public static RollingFileManager getFileManager( final String fileOwner, final String fileGroup, final Configuration configuration) { + return getFileManager( + fileName, + pattern, + append, + bufferedIO, + policy, + strategy, + advertiseURI, + layout, + bufferSize, + createOnDemand, + filePermissions, + fileOwner, + fileGroup, + 0, + configuration); + } + + /** + * Returns a RollingFileManager. + * @param fileName The file name. + * @param pattern The pattern for rolling file. + * @param append true if the file should be appended to. + * @param bufferedIO true if data should be buffered. + * @param policy The TriggeringPolicy. + * @param strategy The RolloverStrategy. + * @param advertiseURI the URI to use when advertising the file + * @param layout The Layout. + * @param bufferSize buffer size to use if bufferedIO is true + * @param createOnDemand true if you want to lazy-create the file (a.k.a. on-demand.) + * @param filePermissions File permissions + * @param fileOwner File owner + * @param fileGroup File group + * @param maxRandomDelay maximum random delay in seconds before executing the asynchronous rollover action chain + * @param configuration The configuration. + * @return A RollingFileManager. + * @since 2.27.0 + */ + public static RollingFileManager getFileManager( + final String fileName, + final String pattern, + final boolean append, + final boolean bufferedIO, + final TriggeringPolicy policy, + final RolloverStrategy strategy, + final String advertiseURI, + final Layout layout, + final int bufferSize, + final boolean createOnDemand, + final String filePermissions, + final String fileOwner, + final String fileGroup, + final int maxRandomDelay, + final Configuration configuration) { if (strategy instanceof DirectWriteRolloverStrategy && fileName != null) { LOGGER.error("The fileName attribute must not be specified with the DirectWriteRolloverStrategy"); return null; @@ -351,6 +404,7 @@ public static RollingFileManager getFileManager( fileGroup, writeHeader, buffer); + rm.setMaxRandomDelay(data.getMaxRandomDelay()); if (os != null && rm.isAttributeViewEnabled()) { rm.defineAttributeView(file.toPath()); } @@ -361,7 +415,7 @@ public static RollingFileManager getFileManager( } return null; }, - new FactoryData(pattern, policy, strategy, configuration))); + new FactoryData(pattern, policy, strategy, maxRandomDelay, configuration))); } /** @@ -426,6 +480,26 @@ public boolean isRenameEmptyFiles() { return renameEmptyFiles; } + /** + * Returns the maximum random delay in seconds before the asynchronous rollover action chain is executed. + * + * @return the maximum random delay in seconds. + * @since 2.27.0 + */ + public int getMaxRandomDelay() { + return maxRandomDelay; + } + + /** + * Sets the maximum random delay in seconds before the asynchronous rollover action chain is executed. + * + * @param maxRandomDelay the maximum random delay in seconds. + * @since 2.27.0 + */ + public void setMaxRandomDelay(final int maxRandomDelay) { + this.maxRandomDelay = Math.max(0, maxRandomDelay); + } + public void setRenameEmptyFiles(final boolean renameEmptyFiles) { this.renameEmptyFiles = renameEmptyFiles; } @@ -670,8 +744,13 @@ private boolean rollover(final RolloverStrategy strategy) { } if (syncActionSuccess && descriptor.getAsynchronous() != null) { - LOGGER.debug("RollingFileManager executing async {}", descriptor.getAsynchronous()); - asyncExecutor.execute(new AsyncAction(descriptor.getAsynchronous(), this)); + final long delayMillis = getAsyncActionDelayMillis(); + LOGGER.debug( + "RollingFileManager executing async {} with delay {} ms", + descriptor.getAsynchronous(), + delayMillis); + asyncExecutor.schedule( + new AsyncAction(descriptor.getAsynchronous(), this), delayMillis, TimeUnit.MILLISECONDS); asyncActionStarted = false; } } @@ -683,6 +762,13 @@ private boolean rollover(final RolloverStrategy strategy) { } } + long getAsyncActionDelayMillis() { + final int maxRandomDelayCopy = maxRandomDelay; + return maxRandomDelayCopy > 0 + ? ThreadLocalRandom.current().nextLong(TimeUnit.SECONDS.toMillis(maxRandomDelayCopy) + 1) + : 0; + } + /** * Performs actions asynchronously. */ @@ -762,6 +848,7 @@ static class FactoryData extends ConfigurationFactoryData { private final String pattern; private final TriggeringPolicy policy; private final RolloverStrategy strategy; + private final int maxRandomDelay; /** * Creates the data for the factory. @@ -773,10 +860,20 @@ public FactoryData( final TriggeringPolicy policy, final RolloverStrategy strategy, final Configuration configuration) { + this(pattern, policy, strategy, 0, configuration); + } + + public FactoryData( + final String pattern, + final TriggeringPolicy policy, + final RolloverStrategy strategy, + final int maxRandomDelay, + final Configuration configuration) { super(configuration); this.pattern = pattern; this.policy = policy; this.strategy = strategy; + this.maxRandomDelay = maxRandomDelay; } public TriggeringPolicy getTriggeringPolicy() { @@ -790,6 +887,10 @@ public RolloverStrategy getRolloverStrategy() { public String getPattern() { return pattern; } + + public int getMaxRandomDelay() { + return maxRandomDelay; + } } /** @@ -804,6 +905,7 @@ public void updateData(final Object data) { setRolloverStrategy(factoryData.getRolloverStrategy()); setPatternProcessor(new PatternProcessor(factoryData.getPattern(), getPatternProcessor())); setTriggeringPolicy(factoryData.getTriggeringPolicy()); + setMaxRandomDelay(factoryData.getMaxRandomDelay()); } static long initialFileTime(final File file) { @@ -833,47 +935,4 @@ static long initialFileTime(final File file) { static long alignMillisToSecond(long millis) { return Math.round(millis / 1000d) * 1000; } - - private static class EmptyQueue extends ArrayBlockingQueue { - - /** - * - */ - private static final long serialVersionUID = 1L; - - EmptyQueue() { - super(1); - } - - @Override - public int remainingCapacity() { - return 0; - } - - @Override - public boolean add(final Runnable runnable) { - throw new IllegalStateException("Queue is full"); - } - - @Override - public void put(final Runnable runnable) throws InterruptedException { - /* No point in going into a permanent wait */ - throw new InterruptedException("Unable to insert into queue"); - } - - @Override - public boolean offer(final Runnable runnable, final long timeout, final TimeUnit timeUnit) - throws InterruptedException { - Thread.sleep(timeUnit.toMillis(timeout)); - return false; - } - - @Override - public boolean addAll(final Collection collection) { - if (collection.size() > 0) { - throw new IllegalArgumentException("Too many items in collection"); - } - return false; - } - } } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/RollingRandomAccessFileManager.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/RollingRandomAccessFileManager.java index abb50f587f6..66c54d26413 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/RollingRandomAccessFileManager.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/RollingRandomAccessFileManager.java @@ -160,6 +160,41 @@ public static RollingRandomAccessFileManager getRollingRandomAccessFileManager( final String fileOwner, final String fileGroup, final Configuration configuration) { + return getRollingRandomAccessFileManager( + fileName, + filePattern, + append, + immediateFlush, + bufferSize, + policy, + strategy, + advertiseURI, + layout, + filePermissions, + fileOwner, + fileGroup, + 0, + configuration); + } + + /** + * @since 2.27.0 + */ + public static RollingRandomAccessFileManager getRollingRandomAccessFileManager( + final String fileName, + final String filePattern, + final boolean append, + final boolean immediateFlush, + final int bufferSize, + final TriggeringPolicy policy, + final RolloverStrategy strategy, + final String advertiseURI, + final Layout layout, + final String filePermissions, + final String fileOwner, + final String fileGroup, + final int maxRandomDelay, + final Configuration configuration) { if (strategy instanceof DirectWriteRolloverStrategy && fileName != null) { LOGGER.error("The fileName attribute must not be specified with the DirectWriteRolloverStrategy"); return null; @@ -230,12 +265,13 @@ public static RollingRandomAccessFileManager getRollingRandomAccessFileManager( fileOwner, fileGroup, writeHeader); + rrm.setMaxRandomDelay(data.getMaxRandomDelay()); if (rrm.isAttributeViewEnabled()) { rrm.defineAttributeView(file.toPath()); } return rrm; }, - new FactoryData(filePattern, policy, strategy, configuration))); + new FactoryData(filePattern, policy, strategy, maxRandomDelay, configuration))); } /** 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 14258eec3f0..2ca052835ae 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,25 +82,6 @@ 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 c01b2cb9928..16889695782 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,11 +54,6 @@ 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. * @@ -70,33 +65,12 @@ 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; } /** @@ -107,7 +81,6 @@ 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 5747c638cd9..4166f7b12ee 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,11 +55,6 @@ 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; @@ -76,24 +71,14 @@ private static int checkCompressionLevel(final int compressionLevel) { } /** - * Create new instance of GzCompressAction. - * - * @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 compressionLevel - * Gzip deflater compression level. - * @since 2.27.0 - * @param maxDelaySeconds - * Maximum delay in seconds before compression. + * 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, - final int maxDelaySeconds) { + final File source, final File destination, final boolean deleteSource, final int compressionLevel) { Objects.requireNonNull(source, "source"); Objects.requireNonNull(destination, "destination"); @@ -101,29 +86,16 @@ public GzCompressAction( this.destination = destination; this.deleteSource = deleteSource; 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, int)}. + * @deprecated Prefer {@link GzCompressAction#GzCompressAction(File, File, boolean, int)}. */ @Deprecated public GzCompressAction(final File source, final File destination, final boolean deleteSource) { - this(source, destination, deleteSource, Deflater.DEFAULT_COMPRESSION, 0); + this(source, destination, deleteSource, Deflater.DEFAULT_COMPRESSION); } /** @@ -134,7 +106,6 @@ public GzCompressAction(final File source, final File destination, final boolean */ @Override public boolean execute() throws IOException { - blockThread(maxDelaySeconds); return execute(source, destination, deleteSource, compressionLevel); } 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 d8e7c397387..f3bd48e2ced 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,11 +51,6 @@ 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]. * @@ -71,22 +66,15 @@ private static int checkLevel(final int level) { } /** - * Creates new instance of GzCompressAction. + * 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 - * @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, - final int maxDelaySeconds) { + public ZipCompressAction(final File source, final File destination, final boolean deleteSource, final int level) { Objects.requireNonNull(source, "source"); Objects.requireNonNull(destination, "destination"); @@ -94,20 +82,6 @@ public ZipCompressAction( this.destination = destination; this.deleteSource = deleteSource; 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); } /** @@ -118,7 +92,6 @@ 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 85222541adc..37370530300 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.27.0") +@Version("2.26.0") package org.apache.logging.log4j.core.appender.rolling.action; 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_random_delay.xml similarity index 74% rename from src/changelog/.2.x.x/4012_add_max_compression_delay.xml rename to src/changelog/.2.x.x/4012_add_max_random_delay.xml index 39cc883a15d..128a8472e1c 100644 --- a/src/changelog/.2.x.x/4012_add_max_compression_delay.xml +++ b/src/changelog/.2.x.x/4012_add_max_random_delay.xml @@ -8,7 +8,7 @@ - Added support for `maxCompressionDelaySeconds` in compression actions to proactively defer compression and reduce disk I/O pressure during rollover. + Added support for `maxRandomDelay` on rolling appenders to start the asynchronous rollover action chain with a random delay and reduce disk I/O pressure during compression. 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 8dd9c91c0c1..deb0ff99a0f 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 @@ -280,6 +280,14 @@ If `true`, Log4j will lock the log file at **each** log event. Note that the effects of this setting depend on the Operating System: some systems like most POSIX OSes do not offer mandatory locking, but only advisory file locking. This setting can also reduce the performance of the appender. + +| [[RollingFileAppender-attr-maxRandomDelay]]maxRandomDelay +| `int` +| `0` +a| +Maximum random delay, in seconds, before the asynchronous rollover action chain starts. +This can spread compression workload if many applications roll over at the same time. +A value of `0` starts asynchronous actions immediately. |=== xref:plugin-reference.adoc#org-apache-logging-log4j_log4j-core_org-apache-logging-log4j-core-appender-RollingFileAppender[{plugin-reference-marker} Plugin reference for `RollingFile`] @@ -304,6 +312,14 @@ If `true`, the appender starts writing at the end of the file. This setting does not give the same atomicity guarantees as for the <>. The log file cannot be opened by multiple applications at the same time. + +| [[RollingRandomAccessFile-attr-maxRandomDelay]]maxRandomDelay +| `int` +| `0` +a| +Maximum random delay, in seconds, before the asynchronous rollover action chain starts. +This can spread compression workload if many applications roll over at the same time. +A value of `0` starts asynchronous actions immediately. |=== xref:plugin-reference.adoc#org-apache-logging-log4j_log4j-core_org-apache-logging-log4j-core-appender-RollingRandomAccessFileAppender[{plugin-reference-marker} Plugin reference for `RollingRandomAccessFile`] @@ -714,14 +730,6 @@ 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 ab98fd54beaa80919d437b65e91b9d2a2031c49a Mon Sep 17 00:00:00 2001 From: Ramanathan Date: Thu, 2 Jul 2026 16:34:43 +0530 Subject: [PATCH 24/28] Merge `2.26.1` to `2.x` (#4169) --- pom.xml | 6 ++--- src/changelog/2.26.1/.release-notes.adoc.ftl | 25 +++++++++++++++++++ src/changelog/2.26.1/.release.xml | 21 ++++++++++++++++ ...Fix_RollingFileAppender_createOnDemand.xml | 14 +++++++++++ ...tternConverter_locale_without_timezone.xml | 14 +++++++++++ .../fix-MapMessage-JSON-non-finite-number.xml | 12 +++++++++ ...fix-StructuredDataMessage-XML-encoding.xml | 12 +++++++++ ...etection-for-exceptions-with-colliding.xml | 13 ++++++++++ ...ix-log-disruptor-initialization-errors.xml | 13 ++++++++++ .../fix_configuration_source_url_leak.xml | 8 ++++++ ...x_kafka_appender_retry_error_reporting.xml | 8 ++++++ .../ROOT/pages/manual/pattern-layout.adoc | 3 +-- 12 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 src/changelog/2.26.1/.release-notes.adoc.ftl create mode 100644 src/changelog/2.26.1/.release.xml create mode 100644 src/changelog/2.26.1/LOG4J2-2006_Fix_RollingFileAppender_createOnDemand.xml create mode 100644 src/changelog/2.26.1/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml create mode 100644 src/changelog/2.26.1/fix-MapMessage-JSON-non-finite-number.xml create mode 100644 src/changelog/2.26.1/fix-StructuredDataMessage-XML-encoding.xml create mode 100644 src/changelog/2.26.1/fix-circular-reference-detection-for-exceptions-with-colliding.xml create mode 100644 src/changelog/2.26.1/fix-log-disruptor-initialization-errors.xml create mode 100644 src/changelog/2.26.1/fix_configuration_source_url_leak.xml create mode 100644 src/changelog/2.26.1/fix_kafka_appender_retry_error_reporting.xml diff --git a/pom.xml b/pom.xml index afd5a80fbd9..cbf95d3bc43 100644 --- a/pom.xml +++ b/pom.xml @@ -309,9 +309,9 @@ 2.27.0-SNAPSHOT - 2.26.0 - 2.26.0 - 2.26.0 + 2.26.1 + 2.26.1 + 2.26.1 + diff --git a/src/changelog/2.26.1/LOG4J2-2006_Fix_RollingFileAppender_createOnDemand.xml b/src/changelog/2.26.1/LOG4J2-2006_Fix_RollingFileAppender_createOnDemand.xml new file mode 100644 index 00000000000..33d6f9e9fe0 --- /dev/null +++ b/src/changelog/2.26.1/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 diff --git a/src/changelog/2.26.1/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml b/src/changelog/2.26.1/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml new file mode 100644 index 00000000000..d2c4ff718df --- /dev/null +++ b/src/changelog/2.26.1/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml @@ -0,0 +1,14 @@ + + + + + + Improve documentation for locale handling in the Pattern Layout date pattern converter + + + diff --git a/src/changelog/2.26.1/fix-MapMessage-JSON-non-finite-number.xml b/src/changelog/2.26.1/fix-MapMessage-JSON-non-finite-number.xml new file mode 100644 index 00000000000..d46ebd21333 --- /dev/null +++ b/src/changelog/2.26.1/fix-MapMessage-JSON-non-finite-number.xml @@ -0,0 +1,12 @@ + + + + + Fix handling of non-finite numbers while encoding `MapMessage` to JSON + + diff --git a/src/changelog/2.26.1/fix-StructuredDataMessage-XML-encoding.xml b/src/changelog/2.26.1/fix-StructuredDataMessage-XML-encoding.xml new file mode 100644 index 00000000000..ae324c0d21d --- /dev/null +++ b/src/changelog/2.26.1/fix-StructuredDataMessage-XML-encoding.xml @@ -0,0 +1,12 @@ + + + + + Fix encoding of `MSGID` and `SD-ID` fields of `StructuredDataMessage` to XML + + diff --git a/src/changelog/2.26.1/fix-circular-reference-detection-for-exceptions-with-colliding.xml b/src/changelog/2.26.1/fix-circular-reference-detection-for-exceptions-with-colliding.xml new file mode 100644 index 00000000000..63173fdacb7 --- /dev/null +++ b/src/changelog/2.26.1/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) + + diff --git a/src/changelog/2.26.1/fix-log-disruptor-initialization-errors.xml b/src/changelog/2.26.1/fix-log-disruptor-initialization-errors.xml new file mode 100644 index 00000000000..defea45980e --- /dev/null +++ b/src/changelog/2.26.1/fix-log-disruptor-initialization-errors.xml @@ -0,0 +1,13 @@ + + + + + + Improve logging for `LinkageError` scenarios involving the LMAX Disruptor library + + diff --git a/src/changelog/2.26.1/fix_configuration_source_url_leak.xml b/src/changelog/2.26.1/fix_configuration_source_url_leak.xml new file mode 100644 index 00000000000..e7c57d5a1a1 --- /dev/null +++ b/src/changelog/2.26.1/fix_configuration_source_url_leak.xml @@ -0,0 +1,8 @@ + + + + Fix resource leaks in `ConfigurationSource` when loading configuration via URL fails + diff --git a/src/changelog/2.26.1/fix_kafka_appender_retry_error_reporting.xml b/src/changelog/2.26.1/fix_kafka_appender_retry_error_reporting.xml new file mode 100644 index 00000000000..f3884f41e8b --- /dev/null +++ b/src/changelog/2.26.1/fix_kafka_appender_retry_error_reporting.xml @@ -0,0 +1,8 @@ + + + + Fix `KafkaAppender` reporting error to error handler even after a successful retry + 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 ebe36a6f529..4e3943066a7 100644 --- a/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/pattern-layout.adoc @@ -467,8 +467,7 @@ 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} +|%d\{dd-MMMM-yyyy}\{UTC}\{de-DE} |02 November 2012 (UTC timezone, German locale) |=== From 9aaf0743350f746ba3da54468ffcfae9adc586a5 Mon Sep 17 00:00:00 2001 From: Ramanathan Date: Mon, 6 Jul 2026 15:48:17 +0530 Subject: [PATCH 25/28] Add `2.25.5` release notes (#4174) --- src/changelog/2.25.5/.release-notes.adoc.ftl | 25 +++++++++++++++++++ src/changelog/2.25.5/.release.xml | 21 ++++++++++++++++ ...Fix_RollingFileAppender_createOnDemand.xml | 14 +++++++++++ ...tternConverter_locale_without_timezone.xml | 14 +++++++++++ .../fix-MapMessage-JSON-non-finite-number.xml | 12 +++++++++ ...fix-StructuredDataMessage-XML-encoding.xml | 12 +++++++++ ...etection-for-exceptions-with-colliding.xml | 13 ++++++++++ ...ix-log-disruptor-initialization-errors.xml | 13 ++++++++++ .../fix_configuration_source_url_leak.xml | 8 ++++++ ...x_kafka_appender_retry_error_reporting.xml | 8 ++++++ 10 files changed, 140 insertions(+) create mode 100644 src/changelog/2.25.5/.release-notes.adoc.ftl create mode 100644 src/changelog/2.25.5/.release.xml create mode 100644 src/changelog/2.25.5/LOG4J2-2006_Fix_RollingFileAppender_createOnDemand.xml create mode 100644 src/changelog/2.25.5/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml create mode 100644 src/changelog/2.25.5/fix-MapMessage-JSON-non-finite-number.xml create mode 100644 src/changelog/2.25.5/fix-StructuredDataMessage-XML-encoding.xml create mode 100644 src/changelog/2.25.5/fix-circular-reference-detection-for-exceptions-with-colliding.xml create mode 100644 src/changelog/2.25.5/fix-log-disruptor-initialization-errors.xml create mode 100644 src/changelog/2.25.5/fix_configuration_source_url_leak.xml create mode 100644 src/changelog/2.25.5/fix_kafka_appender_retry_error_reporting.xml diff --git a/src/changelog/2.25.5/.release-notes.adoc.ftl b/src/changelog/2.25.5/.release-notes.adoc.ftl new file mode 100644 index 00000000000..11e41f27be4 --- /dev/null +++ b/src/changelog/2.25.5/.release-notes.adoc.ftl @@ -0,0 +1,25 @@ +//// + 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 + + https://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. +//// + +[#release-notes-${release.version?replace("[^a-zA-Z0-9]", "-", "r")}] +== ${release.version} + +<#if release.date?has_content>Release date:: ${release.date} + +This patch release delivers certain fixes on top of `2.25.4` + +<#include "../.changelog.adoc.ftl"> diff --git a/src/changelog/2.25.5/.release.xml b/src/changelog/2.25.5/.release.xml new file mode 100644 index 00000000000..6dfdec50305 --- /dev/null +++ b/src/changelog/2.25.5/.release.xml @@ -0,0 +1,21 @@ + + + diff --git a/src/changelog/2.25.5/LOG4J2-2006_Fix_RollingFileAppender_createOnDemand.xml b/src/changelog/2.25.5/LOG4J2-2006_Fix_RollingFileAppender_createOnDemand.xml new file mode 100644 index 00000000000..33d6f9e9fe0 --- /dev/null +++ b/src/changelog/2.25.5/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 diff --git a/src/changelog/2.25.5/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml b/src/changelog/2.25.5/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml new file mode 100644 index 00000000000..d2c4ff718df --- /dev/null +++ b/src/changelog/2.25.5/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml @@ -0,0 +1,14 @@ + + + + + + Improve documentation for locale handling in the Pattern Layout date pattern converter + + + diff --git a/src/changelog/2.25.5/fix-MapMessage-JSON-non-finite-number.xml b/src/changelog/2.25.5/fix-MapMessage-JSON-non-finite-number.xml new file mode 100644 index 00000000000..d46ebd21333 --- /dev/null +++ b/src/changelog/2.25.5/fix-MapMessage-JSON-non-finite-number.xml @@ -0,0 +1,12 @@ + + + + + Fix handling of non-finite numbers while encoding `MapMessage` to JSON + + diff --git a/src/changelog/2.25.5/fix-StructuredDataMessage-XML-encoding.xml b/src/changelog/2.25.5/fix-StructuredDataMessage-XML-encoding.xml new file mode 100644 index 00000000000..ae324c0d21d --- /dev/null +++ b/src/changelog/2.25.5/fix-StructuredDataMessage-XML-encoding.xml @@ -0,0 +1,12 @@ + + + + + Fix encoding of `MSGID` and `SD-ID` fields of `StructuredDataMessage` to XML + + diff --git a/src/changelog/2.25.5/fix-circular-reference-detection-for-exceptions-with-colliding.xml b/src/changelog/2.25.5/fix-circular-reference-detection-for-exceptions-with-colliding.xml new file mode 100644 index 00000000000..63173fdacb7 --- /dev/null +++ b/src/changelog/2.25.5/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) + + diff --git a/src/changelog/2.25.5/fix-log-disruptor-initialization-errors.xml b/src/changelog/2.25.5/fix-log-disruptor-initialization-errors.xml new file mode 100644 index 00000000000..defea45980e --- /dev/null +++ b/src/changelog/2.25.5/fix-log-disruptor-initialization-errors.xml @@ -0,0 +1,13 @@ + + + + + + Improve logging for `LinkageError` scenarios involving the LMAX Disruptor library + + diff --git a/src/changelog/2.25.5/fix_configuration_source_url_leak.xml b/src/changelog/2.25.5/fix_configuration_source_url_leak.xml new file mode 100644 index 00000000000..e7c57d5a1a1 --- /dev/null +++ b/src/changelog/2.25.5/fix_configuration_source_url_leak.xml @@ -0,0 +1,8 @@ + + + + Fix resource leaks in `ConfigurationSource` when loading configuration via URL fails + diff --git a/src/changelog/2.25.5/fix_kafka_appender_retry_error_reporting.xml b/src/changelog/2.25.5/fix_kafka_appender_retry_error_reporting.xml new file mode 100644 index 00000000000..f3884f41e8b --- /dev/null +++ b/src/changelog/2.25.5/fix_kafka_appender_retry_error_reporting.xml @@ -0,0 +1,8 @@ + + + + Fix `KafkaAppender` reporting error to error handler even after a successful retry + From 1f9a5149370f01da79f820ec6c37ba757e62451f Mon Sep 17 00:00:00 2001 From: Ramanathan Date: Mon, 6 Jul 2026 23:58:28 +0530 Subject: [PATCH 26/28] Add native tracing fields to LogEvent to eliminate async MDC overhead (#4171) * Add tracing fields to RingBufferLogEvent and related classes * Add W3C trace context support with pattern converters for trace ID, span ID, and trace flags * Enhance TraceContextProviderService for exception safety and simplify trace ID retrieval * Add tests for tracing fields serialization and pattern converters * Refactor tracing metadata documentation and improve code comments for clarity * Add native W3C tracing fields to LogEvent and introduce TraceContextProvider SPI * Add benchmark for ContextDataProvider tracing approach * Enhance TraceContextProviderService to handle SecurityManager restrictions gracefully --- .../log4j/spi/TraceContextProvider.java | 52 ++++ .../logging/log4j/spi/package-info.java | 2 +- .../core/async/RingBufferLogEventTest.java | 45 ++++ .../log4j/core/impl/Log4jLogEventTest.java | 165 +++++++++++++ .../log4j/core/impl/MutableLogEventTest.java | 29 +++ .../core/impl/TestTraceContextProvider.java | 53 ++++ .../impl/TraceContextIntegrationTest.java | 233 ++++++++++++++++++ ...che.logging.log4j.spi.TraceContextProvider | 1 + .../apache/logging/log4j/core/LogEvent.java | 41 +++ .../log4j/core/async/RingBufferLogEvent.java | 71 +++++- .../async/RingBufferLogEventTranslator.java | 51 +++- .../log4j/core/async/package-info.java | 2 +- .../log4j/core/impl/Log4jLogEvent.java | 150 +++++++++-- .../log4j/core/impl/MutableLogEvent.java | 45 +++- .../logging/log4j/core/impl/package-info.java | 2 +- .../logging/log4j/core/package-info.java | 2 +- .../core/pattern/SpanIdPatternConverter.java | 43 ++++ .../pattern/TraceFlagsPatternConverter.java | 43 ++++ .../core/pattern/TraceIdPatternConverter.java | 43 ++++ .../log4j/core/pattern/package-info.java | 2 +- .../util/TraceContextProviderService.java | 131 ++++++++++ .../logging/log4j/core/util/package-info.java | 2 +- .../perf/jmh/AsyncTraceContextBenchmark.java | 181 ++++++++++++++ .../.2.x.x/1976_add_native_tracing_fields.xml | 13 + 24 files changed, 1376 insertions(+), 26 deletions(-) create mode 100644 log4j-api/src/main/java/org/apache/logging/log4j/spi/TraceContextProvider.java create mode 100644 log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/TestTraceContextProvider.java create mode 100644 log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/TraceContextIntegrationTest.java create mode 100644 log4j-core-test/src/test/resources/META-INF/services/org.apache.logging.log4j.spi.TraceContextProvider create mode 100644 log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/SpanIdPatternConverter.java create mode 100644 log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/TraceFlagsPatternConverter.java create mode 100644 log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/TraceIdPatternConverter.java create mode 100644 log4j-core/src/main/java/org/apache/logging/log4j/core/util/TraceContextProviderService.java create mode 100644 log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/jmh/AsyncTraceContextBenchmark.java create mode 100644 src/changelog/.2.x.x/1976_add_native_tracing_fields.xml diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/spi/TraceContextProvider.java b/log4j-api/src/main/java/org/apache/logging/log4j/spi/TraceContextProvider.java new file mode 100644 index 00000000000..de2ffbeb8ba --- /dev/null +++ b/log4j-api/src/main/java/org/apache/logging/log4j/spi/TraceContextProvider.java @@ -0,0 +1,52 @@ +/* + * 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.spi; + +/** + * Service Provider Interface (SPI) for retrieving distributed tracing metadata (such as W3C Trace Context) + * from the active execution context. + *

    + * Implementing this SPI allows tracing frameworks (e.g., OpenTelemetry, Micrometer, Zipkin) to pass native + * trace identifiers directly to Log4j events. This completely bypasses the {@link org.apache.logging.log4j.ThreadContext} + * map, eliminating map-cloning and garbage collection overhead during asynchronous logging. + *

    + *

    + * Log4j locates implementations of this interface using the standard Java {@link java.util.ServiceLoader} mechanism. + * To register a custom provider, create a plain-text file named + * {@code org.apache.logging.log4j.spi.TraceContextProvider} in the {@code META-INF/services/} directory + * containing the fully qualified class name of the implementation. + *

    + * + * @since 2.27.0 + */ +public interface TraceContextProvider { + + /** + * Returns the standard trace ID from the active context, or {@code null}. + */ + String getTraceId(); + + /** + * Returns the standard span ID from the active context, or {@code null}. + */ + String getSpanId(); + + /** + * Returns the standard trace flags from the active context, or {@code null}. + */ + String getTraceFlags(); +} diff --git a/log4j-api/src/main/java/org/apache/logging/log4j/spi/package-info.java b/log4j-api/src/main/java/org/apache/logging/log4j/spi/package-info.java index 3b6b5c25857..0956aad0030 100644 --- a/log4j-api/src/main/java/org/apache/logging/log4j/spi/package-info.java +++ b/log4j-api/src/main/java/org/apache/logging/log4j/spi/package-info.java @@ -19,7 +19,7 @@ * API classes. */ @Export -@Version("2.25.0") +@Version("2.26.0") package org.apache.logging.log4j.spi; import org.osgi.annotation.bundle.Export; diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/async/RingBufferLogEventTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/async/RingBufferLogEventTest.java index 5377040565c..20b2a7ec448 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/async/RingBufferLogEventTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/async/RingBufferLogEventTest.java @@ -16,10 +16,12 @@ */ package org.apache.logging.log4j.core.async; +import static org.apache.logging.log4j.core.util.ClockFactory.getClock; import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -31,6 +33,7 @@ import org.apache.logging.log4j.ThreadContext; import org.apache.logging.log4j.ThreadContext.ContextStack; import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.impl.ContextDataFactory; import org.apache.logging.log4j.core.impl.ThrowableProxy; import org.apache.logging.log4j.core.time.internal.FixedPreciseClock; import org.apache.logging.log4j.core.util.Clock; @@ -468,4 +471,46 @@ void testGettersAndClear() { // Verify interaction exhaustion verifyNoMoreInteractions(asyncLogger, message, throwable, contextData, contextStack); } + + @Test + void testRingBufferLogEventTracingFieldsAndClear() { + final RingBufferLogEvent event = new RingBufferLogEvent(); + + // Check initial state + assertThat(event.getTraceId()).isNull(); + assertThat(event.getSpanId()).isNull(); + assertThat(event.getTraceFlags()).isNull(); + + // Initialize with trace fields inside 18-parameter setValues + event.setValues( + null, + "TestLogger", + null, + "FQCN", + Level.INFO, + new SimpleMessage("msg"), + null, + ContextDataFactory.createContextData(), + ThreadContext.EMPTY_STACK, + 123L, + "thread-name", + 5, + null, + getClock(), + new DummyNanoClock(), + "trace-id-ringbuffer", + "span-id-ringbuffer", + "01"); + + // Assert values are retrievable + assertThat(event.getTraceId()).isEqualTo("trace-id-ringbuffer"); + assertThat(event.getSpanId()).isEqualTo("span-id-ringbuffer"); + assertThat(event.getTraceFlags()).isEqualTo("01"); + + // Verify clearing wipes tracing state to avoid leakage on slot reuse + event.clear(); + assertNull(event.getTraceId()); + assertNull(event.getSpanId()); + assertNull(event.getTraceFlags()); + } } diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/Log4jLogEventTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/Log4jLogEventTest.java index bc90e594d28..082519142f9 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/Log4jLogEventTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/Log4jLogEventTest.java @@ -18,6 +18,7 @@ import static org.apache.logging.log4j.test.junit.SerialUtil.deserialize; import static org.apache.logging.log4j.test.junit.SerialUtil.serialize; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; @@ -29,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.reflect.Field; +import java.util.Map; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.Marker; import org.apache.logging.log4j.MarkerManager; @@ -36,6 +38,8 @@ import org.apache.logging.log4j.ThreadContext.ContextStack; import org.apache.logging.log4j.core.LogEvent; import org.apache.logging.log4j.core.config.plugins.convert.Base64Converter; +import org.apache.logging.log4j.core.time.Instant; +import org.apache.logging.log4j.core.time.MutableInstant; import org.apache.logging.log4j.core.util.Clock; import org.apache.logging.log4j.core.util.ClockFactory; import org.apache.logging.log4j.core.util.ClockFactoryTest; @@ -45,6 +49,7 @@ import org.apache.logging.log4j.message.ReusableMessage; import org.apache.logging.log4j.message.ReusableObjectMessage; import org.apache.logging.log4j.message.SimpleMessage; +import org.apache.logging.log4j.util.ReadOnlyStringMap; import org.apache.logging.log4j.util.SortedArrayStringMap; import org.apache.logging.log4j.util.StringMap; import org.apache.logging.log4j.util.Strings; @@ -541,4 +546,164 @@ void testToString() { // Throws an NPE in 2.6.2 assertNotNull(new Log4jLogEvent().toString()); } + + @Test + public void testCustomLegacyLogEventDefaultBehavior() { + // Create an anonymous/stub class implementing LogEvent without implementing getTraceId/getSpanId/getTraceFlags + final LogEvent legacyEvent = new LogEvent() { + private static final long serialVersionUID = 1L; + + @Override + public LogEvent toImmutable() { + return this; + } + + @Override + @Deprecated + public Map getContextMap() { + return java.util.Collections.emptyMap(); + } + + @Override + public ReadOnlyStringMap getContextData() { + return ContextDataFactory.emptyFrozenContextData(); + } + + @Override + public ThreadContext.ContextStack getContextStack() { + return ThreadContext.EMPTY_STACK; + } + + @Override + public String getLoggerFqcn() { + return null; + } + + @Override + public Level getLevel() { + return Level.INFO; + } + + @Override + public String getLoggerName() { + return "LegacyLogger"; + } + + @Override + public Marker getMarker() { + return null; + } + + @Override + public Message getMessage() { + return new SimpleMessage("Legacy msg"); + } + + @Override + public long getTimeMillis() { + return 0; + } + + @Override + public Instant getInstant() { + return new MutableInstant(); + } + + @Override + public StackTraceElement getSource() { + return null; + } + + @Override + public String getThreadName() { + return "main"; + } + + @Override + public long getThreadId() { + return 1; + } + + @Override + public int getThreadPriority() { + return 5; + } + + @Override + public Throwable getThrown() { + return null; + } + + @Override + @Deprecated + public ThrowableProxy getThrownProxy() { + return null; + } + + @Override + public boolean isEndOfBatch() { + return false; + } + + @Override + public boolean isIncludeLocation() { + return false; + } + + @Override + public void setEndOfBatch(boolean endOfBatch) {} + + @Override + public void setIncludeLocation(boolean locationRequired) {} + + @Override + public long getNanoTime() { + return 0; + } + }; + + assertNull(legacyEvent.getTraceId()); + assertNull(legacyEvent.getSpanId()); + assertNull(legacyEvent.getTraceFlags()); + } + + @Test + void testTracingFieldsInBuilderAndCopy() { + final Log4jLogEvent originalEvent = Log4jLogEvent.newBuilder() + .setLoggerName("TestLogger") + .setLevel(Level.DEBUG) + .setMessage(new org.apache.logging.log4j.message.SimpleMessage("Test message")) + .setTraceId("4bf92f3577b34da6a3ce929d0e0e4736") + .setSpanId("00f067aa0ba902b7") + .setTraceFlags("01") + .build(); + + assertThat(originalEvent.getTraceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(originalEvent.getSpanId()).isEqualTo("00f067aa0ba902b7"); + assertThat(originalEvent.getTraceFlags()).isEqualTo("01"); + + final Log4jLogEvent copiedEvent = new Log4jLogEvent.Builder(originalEvent).build(); + assertThat(copiedEvent.getTraceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(copiedEvent.getSpanId()).isEqualTo("00f067aa0ba902b7"); + assertThat(copiedEvent.getTraceFlags()).isEqualTo("01"); + } + + @Test + void testTracingFieldsSerialization() throws Exception { + final Log4jLogEvent originalEvent = Log4jLogEvent.newBuilder() + .setLoggerName("SerializationLogger") + .setMessage(new org.apache.logging.log4j.message.SimpleMessage("dummy message")) + .setTraceId("trace-serialize-123") + .setSpanId("span-serialize-456") + .setTraceFlags("01") + .build(); + + final byte[] serializedBytes = serialize(originalEvent); + + final Log4jLogEvent deserializedEvent = deserialize(serializedBytes); + + assertThat(deserializedEvent.getTraceId()).isEqualTo("trace-serialize-123"); + assertThat(deserializedEvent.getSpanId()).isEqualTo("span-serialize-456"); + assertThat(deserializedEvent.getTraceFlags()).isEqualTo("01"); + } } diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/MutableLogEventTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/MutableLogEventTest.java index 092c2133e23..459c84fbaa5 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/MutableLogEventTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/MutableLogEventTest.java @@ -366,4 +366,33 @@ void testPreservesLocation() { final Log4jLogEvent immutable = mutable.toImmutable(); assertThat(immutable.getSource()).isEqualTo(source); } + + @Test + void testMutableLogEventTracingFieldsPropagationAndClear() { + final Log4jLogEvent sourceEvent = Log4jLogEvent.newBuilder() + .setLoggerName("SourceLogger") + .setTraceId("trace-xyz-123") + .setSpanId("span-abc-456") + .setTraceFlags("01") + .build(); + + final MutableLogEvent mutableEvent = new MutableLogEvent(); + + // Initially empty + assertThat(mutableEvent.getTraceId()).isNull(); + assertThat(mutableEvent.getSpanId()).isNull(); + assertThat(mutableEvent.getTraceFlags()).isNull(); + + // Verify propagation via initFrom + mutableEvent.initFrom(sourceEvent); + assertThat(mutableEvent.getTraceId()).isEqualTo("trace-xyz-123"); + assertThat(mutableEvent.getSpanId()).isEqualTo("span-abc-456"); + assertThat(mutableEvent.getTraceFlags()).isEqualTo("01"); + + // Verify clearing removes tracking state to prevent leakage on pool reuse + mutableEvent.clear(); + assertThat(mutableEvent.getTraceId()).isNull(); + assertThat(mutableEvent.getSpanId()).isNull(); + assertThat(mutableEvent.getTraceFlags()).isNull(); + } } diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/TestTraceContextProvider.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/TestTraceContextProvider.java new file mode 100644 index 00000000000..94a0d009ed1 --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/TestTraceContextProvider.java @@ -0,0 +1,53 @@ +/* + * 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.impl; + +import org.apache.logging.log4j.spi.TraceContextProvider; + +public class TestTraceContextProvider implements TraceContextProvider { + + private static final ThreadLocal TRACE_ID = new ThreadLocal<>(); + private static final ThreadLocal SPAN_ID = new ThreadLocal<>(); + private static final ThreadLocal TRACE_FLAGS = new ThreadLocal<>(); + + public static void setContext(final String traceId, final String spanId, final String traceFlags) { + TRACE_ID.set(traceId); + SPAN_ID.set(spanId); + TRACE_FLAGS.set(traceFlags); + } + + public static void clearContext() { + TRACE_ID.remove(); + SPAN_ID.remove(); + TRACE_FLAGS.remove(); + } + + @Override + public String getTraceId() { + return TRACE_ID.get(); + } + + @Override + public String getSpanId() { + return SPAN_ID.get(); + } + + @Override + public String getTraceFlags() { + return TRACE_FLAGS.get(); + } +} diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/TraceContextIntegrationTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/TraceContextIntegrationTest.java new file mode 100644 index 00000000000..0a61571cb14 --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/impl/TraceContextIntegrationTest.java @@ -0,0 +1,233 @@ +/* + * 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.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.ThreadContext; +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.async.RingBufferLogEvent; +import org.apache.logging.log4j.core.async.RingBufferLogEventTranslator; +import org.apache.logging.log4j.core.pattern.SpanIdPatternConverter; +import org.apache.logging.log4j.core.pattern.TraceFlagsPatternConverter; +import org.apache.logging.log4j.core.pattern.TraceIdPatternConverter; +import org.apache.logging.log4j.core.util.ClockFactory; +import org.apache.logging.log4j.core.util.DummyNanoClock; +import org.apache.logging.log4j.core.util.TraceContextProviderService; +import org.apache.logging.log4j.message.SimpleMessage; +import org.apache.logging.log4j.spi.TraceContextProvider; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class TraceContextIntegrationTest { + + private final TestTraceContextProvider testProvider = new TestTraceContextProvider(); + + @BeforeEach + void setUp() { + TraceContextProviderService.setActiveProvider(testProvider); + TestTraceContextProvider.setContext("4bf92f3577b34da6a3ce929d0e0e4736", "00f067aa0ba902b7", "01"); + ThreadContext.clearMap(); + } + + @AfterEach + void tearDown() { + TraceContextProviderService.setActiveProvider(null); + TestTraceContextProvider.clearContext(); + ThreadContext.clearMap(); + } + + @Test + public void testLog4jLogEventNativeCaptureWithEmptyThreadContext() { + assertThat(ThreadContext.getContext()).isEmpty(); + + final LogEvent event = Log4jLogEvent.newBuilder() + .setLoggerName("TestLogger") + .setLevel(Level.INFO) + .setMessage(new SimpleMessage("Standard event testing")) + .build(); + + assertThat(event.getTraceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(event.getSpanId()).isEqualTo("00f067aa0ba902b7"); + assertThat(event.getTraceFlags()).isEqualTo("01"); + assertThat(event.getContextData().isEmpty()).isTrue(); + } + + @Test + public void testMutableLogEventNativeCapture() { + final MutableLogEvent event = new MutableLogEvent(); + event.setMessage(new SimpleMessage("Mutable event testing")); + event.initTime(ClockFactory.getClock(), new DummyNanoClock()); + + assertThat(event.getTraceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(event.getSpanId()).isEqualTo("00f067aa0ba902b7"); + assertThat(event.getTraceFlags()).isEqualTo("01"); + assertThat(event.getContextData().isEmpty()).isTrue(); + } + + @Test + public void testRingBufferLogEventTranslatorNativeCapture() { + final RingBufferLogEvent event = new RingBufferLogEvent(); + final RingBufferLogEventTranslator translator = new RingBufferLogEventTranslator(); + + translator.setBasicValues( + null, + "Logger", + null, + "FQCN", + Level.INFO, + new SimpleMessage("Async event testing"), + null, + ThreadContext.getImmutableStack(), + null, + ClockFactory.getClock(), + new DummyNanoClock()); + + translator.translateTo(event, 0); + + assertThat(event.getTraceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(event.getSpanId()).isEqualTo("00f067aa0ba902b7"); + assertThat(event.getTraceFlags()).isEqualTo("01"); + assertThat(event.getContextData().isEmpty()).isTrue(); + } + + @Test + public void testLog4jLogEventDirectConstructorNativeCapture() { + final LogEvent event = new Log4jLogEvent( + "TestLogger", null, "FQCN", Level.INFO, new SimpleMessage("Direct constructor testing"), null, null); + + assertThat(event.getTraceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(event.getSpanId()).isEqualTo("00f067aa0ba902b7"); + assertThat(event.getTraceFlags()).isEqualTo("01"); + assertThat(event.getContextData().isEmpty()).isTrue(); + } + + @Test + public void testProviderExceptionSafety() { + TraceContextProviderService.setActiveProvider(new TraceContextProvider() { + @Override + public String getTraceId() { + throw new RuntimeException("Simulated provider failure"); + } + + @Override + public String getSpanId() { + return null; + } + + @Override + public String getTraceFlags() { + return null; + } + }); + + assertThatCode(() -> { + final LogEvent event = Log4jLogEvent.newBuilder() + .setLoggerName("BuggyLogger") + .setLevel(Level.ERROR) + .setMessage(new SimpleMessage("Exception safety test")) + .build(); + + assertThat(event.getTraceId()).isEmpty(); + }) + .doesNotThrowAnyException(); + } + + @Test + public void testTracePatternConverters() { + // Setup an event with trace data + final LogEvent event = Log4jLogEvent.newBuilder() + .setTraceId("test-trace-123") + .setSpanId("test-span-456") + .setTraceFlags("01") + .build(); + + // Test Trace ID Converter + final StringBuilder traceSb = new StringBuilder(); + org.apache.logging.log4j.core.pattern.TraceIdPatternConverter.newInstance(null) + .format(event, traceSb); + assertThat(traceSb.toString()).isEqualTo("test-trace-123"); + + // Test Span ID Converter + final StringBuilder spanSb = new StringBuilder(); + org.apache.logging.log4j.core.pattern.SpanIdPatternConverter.newInstance(null) + .format(event, spanSb); + assertThat(spanSb.toString()).isEqualTo("test-span-456"); + + // Test Trace Flags Converter + final StringBuilder flagsSb = new StringBuilder(); + org.apache.logging.log4j.core.pattern.TraceFlagsPatternConverter.newInstance(null) + .format(event, flagsSb); + assertThat(flagsSb.toString()).isEqualTo("01"); + } + + @Test + public void testTracePatternConvertersWithNullValues() { + TestTraceContextProvider.clearContext(); + + final LogEvent event = Log4jLogEvent.newBuilder().build(); + final StringBuilder sb = new StringBuilder(); + + TraceIdPatternConverter.newInstance(null).format(event, sb); + SpanIdPatternConverter.newInstance(null).format(event, sb); + TraceFlagsPatternConverter.newInstance(null).format(event, sb); + + assertThat(sb.toString()).isEmpty(); + } + + @Test + public void testSecurityManagerFallback() { + final SecurityManager originalSm = System.getSecurityManager(); + try { + // . Install a mock SecurityManager that blocks access to our global key + System.setSecurityManager(new SecurityManager() { + @Override + public void checkPropertyAccess(String key) { + if ("log4j2.activeTraceContextProvider".equals(key)) { + throw new SecurityException("Simulated enterprise security exception"); + } + } + + @Override + public void checkPermission(java.security.Permission perm) { + // Allow all other permissions so the test runner doesn't crash + } + }); + + // Assert that setting the provider does not crash the application + assertThatCode(() -> TraceContextProviderService.setActiveProvider(testProvider)) + .doesNotThrowAnyException(); + + // Assert that getting the provider safely falls back to the local variable + assertThat(TraceContextProviderService.getActiveProvider()).isEqualTo(testProvider); + + } catch (UnsupportedOperationException e) { + // Java 17+ blocks setting SecurityManager by default without specific JVM flags. + // If the JVM blocks the test, we gracefully skip it. + System.out.println("Skipping SecurityManager test because JVM restricts it."); + } finally { + // Restore the original security manager + try { + System.setSecurityManager(originalSm); + } catch (UnsupportedOperationException ignored) { + } + } + } +} diff --git a/log4j-core-test/src/test/resources/META-INF/services/org.apache.logging.log4j.spi.TraceContextProvider b/log4j-core-test/src/test/resources/META-INF/services/org.apache.logging.log4j.spi.TraceContextProvider new file mode 100644 index 00000000000..0e5fd8db11c --- /dev/null +++ b/log4j-core-test/src/test/resources/META-INF/services/org.apache.logging.log4j.spi.TraceContextProvider @@ -0,0 +1 @@ +org.apache.logging.log4j.core.impl.TestTraceContextProvider \ No newline at end of file diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/LogEvent.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/LogEvent.java index 63daa9d3bbe..f4cc136ff37 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/LogEvent.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/LogEvent.java @@ -241,4 +241,45 @@ public interface LogEvent extends Serializable { * @since Log4J 2.4 */ long getNanoTime(); + /** + * Returns the distributed tracing Trace ID from the active context (e.g., W3C Trace Context). + *

    + * A Trace ID is typically a 32-character lowercase hexadecimal string representing the + * entire trace forest. The default implementation returns {@code null}. + *

    + * + * @return the Trace ID, or {@code null} if no tracing context is active or supported. + * @since 2.27.0 + */ + default String getTraceId() { + return null; + } + + /** + * Returns the distributed tracing Span ID from the active context (e.g., W3C Trace Context). + *

    + * A Span ID is typically a 16-character lowercase hexadecimal string representing a + * single operation within a trace. The default implementation returns {@code null}. + *

    + * + * @return the Span ID, or {@code null} if no tracing context is active or supported. + * @since 2.27.0 + */ + default String getSpanId() { + return null; + } + + /** + * Returns the distributed tracing Trace Flags from the active context (e.g., W3C Trace Context). + *

    + * Trace Flags are typically a 2-character lowercase hexadecimal string representing + * tracing options (such as whether the trace is sampled). The default implementation returns {@code null}. + *

    + * + * @return the Trace Flags, or {@code null} if no tracing context is active or supported. + * @since 2.27.0 + */ + default String getTraceFlags() { + return null; + } } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEvent.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEvent.java index 2afb007387c..b64a32e1185 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEvent.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEvent.java @@ -91,6 +91,9 @@ public RingBufferLogEvent newInstance() { private String fqcn; private StackTraceElement location; private ContextStack contextStack; + private String traceId; + private String spanId; + private String traceFlags; private transient AsyncLogger asyncLogger; @@ -109,7 +112,10 @@ public void setValues( final int threadPriority, final StackTraceElement aLocation, final Clock clock, - final NanoClock nanoClock) { + final NanoClock nanoClock, + final String traceId, + final String spanId, + final String traceFlags) { this.threadPriority = threadPriority; this.threadId = threadId; this.level = aLevel; @@ -125,9 +131,50 @@ public void setValues( this.contextData = mutableContextData; this.contextStack = aContextStack; this.asyncLogger = anAsyncLogger; + this.traceId = traceId; + this.spanId = spanId; + this.traceFlags = traceFlags; this.populated = true; } + public void setValues( + final AsyncLogger anAsyncLogger, + final String aLoggerName, + final Marker aMarker, + final String theFqcn, + final Level aLevel, + final Message msg, + final Throwable aThrowable, + final StringMap mutableContextData, + final ContextStack aContextStack, + final long threadId, + final String threadName, + final int threadPriority, + final StackTraceElement aLocation, + final Clock clock, + final NanoClock nanoClock) { + setValues( + anAsyncLogger, + aLoggerName, + aMarker, + theFqcn, + aLevel, + msg, + aThrowable, + mutableContextData, + aContextStack, + threadId, + threadName, + threadPriority, + aLocation, + clock, + nanoClock, + null, // traceId + null, // spanId + null // traceFlags + ); + } + private void initTime(final Clock clock) { if (message instanceof TimestampMessage) { instant.initFromEpochMilli(((TimestampMessage) message).getTimestamp(), 0); @@ -399,6 +446,21 @@ public long getNanoTime() { return nanoTime; } + @Override + public String getTraceId() { + return traceId; + } + + @Override + public String getSpanId() { + return spanId; + } + + @Override + public String getTraceFlags() { + return traceFlags; + } + /** * Release references held by ring buffer to allow objects to be garbage-collected. */ @@ -415,6 +477,9 @@ public void clear() { this.location = null; this.contextStack = null; this.asyncLogger = null; + this.traceId = null; + this.spanId = null; + this.traceFlags = null; } private void clearMessage() { @@ -499,6 +564,8 @@ public void initializeBuilder(final Log4jLogEvent.Builder builder) { .setThreadPriority(threadPriority) // .setThrown(getThrown()) // may deserialize from thrownProxy .setInstant(instant) // - ; + .setTraceId(traceId) + .setSpanId(spanId) + .setTraceFlags(traceFlags); } } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEventTranslator.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEventTranslator.java index f6fee9fcf5a..e7bffd9084a 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEventTranslator.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/async/RingBufferLogEventTranslator.java @@ -24,6 +24,7 @@ import org.apache.logging.log4j.core.impl.ContextDataInjectorFactory; import org.apache.logging.log4j.core.util.Clock; import org.apache.logging.log4j.core.util.NanoClock; +import org.apache.logging.log4j.core.util.TraceContextProviderService; import org.apache.logging.log4j.message.Message; import org.apache.logging.log4j.util.ReadOnlyStringMap; import org.apache.logging.log4j.util.StringMap; @@ -51,6 +52,9 @@ public class RingBufferLogEventTranslator implements EventTranslator { @@ -123,9 +128,26 @@ public static class Builder implements org.apache.logging.log4j.core.util.Builde private StringMap contextData; private ThreadContext.ContextStack contextStack; + private String traceId = Strings.EMPTY; + private String spanId = Strings.EMPTY; + private String traceFlags = Strings.EMPTY; + public Builder() { this.contextData = createContextData((List) null); this.contextStack = ThreadContext.getImmutableStack(); + + final String tId = TraceContextProviderService.getTraceId(); + if (tId != null) { + this.traceId = tId; + } + final String sId = TraceContextProviderService.getSpanId(); + if (sId != null) { + this.spanId = sId; + } + final String flags = TraceContextProviderService.getTraceFlags(); + if (flags != null) { + this.traceFlags = flags; + } } /** @@ -159,6 +181,10 @@ public Builder(final LogEvent other) { // but since we are copying the event, we want to call it. this.source = other.getSource(); + this.traceId = other.getTraceId(); + this.spanId = other.getSpanId(); + this.traceFlags = other.getTraceFlags(); + Message message = other.getMessage(); this.message = message instanceof ReusableMessage ? ((ReusableMessage) message).memento() @@ -175,6 +201,21 @@ public Builder(final LogEvent other) { this.contextStack = other.getContextStack(); } + public Builder setTraceId(String traceId) { + this.traceId = traceId; + return this; + } + + public Builder setSpanId(String spanId) { + this.spanId = spanId; + return this; + } + + public Builder setTraceFlags(String traceFlags) { + this.traceFlags = traceFlags; + return this; + } + public Builder setLevel(final Level level) { this.level = level; return this; @@ -303,7 +344,10 @@ public Log4jLogEvent build() { source, instant.getEpochMillisecond(), instant.getNanoOfMillisecond(), - nanoTime); + nanoTime, + traceId, + spanId, + traceFlags); result.setIncludeLocation(includeLocation); result.setEndOfBatch(endOfBatch); return result; @@ -339,7 +383,10 @@ public Log4jLogEvent() { 0, null, CLOCK, - nanoClock.nanoTime()); + nanoClock.nanoTime(), + Strings.EMPTY, + Strings.EMPTY, + Strings.EMPTY); } /** @@ -363,7 +410,10 @@ public Log4jLogEvent(final long timestamp) { null, timestamp, 0, - nanoClock.nanoTime()); + nanoClock.nanoTime(), + Strings.EMPTY, + Strings.EMPTY, + Strings.EMPTY); } /** @@ -420,7 +470,10 @@ public Log4jLogEvent( 0, // thread priority null, // StackTraceElement source CLOCK, // - nanoClock.nanoTime()); + nanoClock.nanoTime(), + TraceContextProviderService.getTraceId(), + TraceContextProviderService.getSpanId(), + TraceContextProviderService.getTraceFlags()); } /** @@ -457,7 +510,10 @@ public Log4jLogEvent( 0, // thread priority source, // StackTraceElement source CLOCK, // - nanoClock.nanoTime()); + nanoClock.nanoTime(), + TraceContextProviderService.getTraceId(), + TraceContextProviderService.getSpanId(), + TraceContextProviderService.getTraceFlags()); } /** @@ -503,7 +559,10 @@ public Log4jLogEvent( location, timestampMillis, 0, - nanoClock.nanoTime()); + nanoClock.nanoTime(), + Strings.EMPTY, + Strings.EMPTY, + Strings.EMPTY); } /** @@ -552,7 +611,10 @@ public static Log4jLogEvent createEvent( location, timestamp, 0, - nanoClock.nanoTime()); + nanoClock.nanoTime(), + Strings.EMPTY, + Strings.EMPTY, + Strings.EMPTY); return result; } @@ -590,7 +652,10 @@ private Log4jLogEvent( final StackTraceElement source, final long timestampMillis, final int nanoOfMillisecond, - final long nanoTime) { + final long nanoTime, + final String traceId, + final String spanId, + final String traceFlags) { this( loggerName, marker, @@ -604,7 +669,10 @@ private Log4jLogEvent( threadName, threadPriority, source, - nanoTime); + nanoTime, + traceId, + spanId, + traceFlags); final long millis = message instanceof TimestampMessage ? ((TimestampMessage) message).getTimestamp() : timestampMillis; instant.initFromEpochMilli(millis, nanoOfMillisecond); @@ -624,7 +692,10 @@ private Log4jLogEvent( final int threadPriority, final StackTraceElement source, final Clock clock, - final long nanoTime) { + final long nanoTime, + final String traceId, + final String spanId, + final String traceFlags) { this( loggerName, marker, @@ -638,7 +709,10 @@ private Log4jLogEvent( threadName, threadPriority, source, - nanoTime); + nanoTime, + traceId, + spanId, + traceFlags); if (message instanceof TimestampMessage) { instant.initFromEpochMilli(((TimestampMessage) message).getTimestamp(), 0); } else { @@ -659,7 +733,10 @@ private Log4jLogEvent( final String threadName, final int threadPriority, final StackTraceElement source, - final long nanoTime) { + final long nanoTime, + final String traceId, + final String spanId, + final String traceFlags) { this.loggerName = loggerName; this.marker = marker; this.loggerFqcn = loggerFQCN; @@ -676,6 +753,9 @@ private Log4jLogEvent( ((LoggerNameAwareMessage) message).setLoggerName(loggerName); } this.nanoTime = nanoTime; + this.traceId = traceId != null ? traceId : Strings.EMPTY; + this.spanId = spanId != null ? spanId : Strings.EMPTY; + this.traceFlags = traceFlags != null ? traceFlags : Strings.EMPTY; } private static StringMap createContextData(final Map contextMap) { @@ -985,7 +1065,10 @@ public static Log4jLogEvent deserialize(final Serializable event) { proxy.source, proxy.timeMillis, proxy.nanoOfMillisecond, - proxy.nanoTime); + proxy.nanoTime, + proxy.traceId, + proxy.spanId, + proxy.traceFlags); result.setEndOfBatch(proxy.isEndOfBatch); result.setIncludeLocation(proxy.isLocationRequired); return result; @@ -1093,6 +1176,15 @@ public boolean equals(final Object o) { if (thrown != null ? !thrown.equals(that.thrown) : that.thrown != null) { return false; } + if (traceId != null ? !traceId.equals(that.traceId) : that.traceId != null) { + return false; + } + if (spanId != null ? !spanId.equals(that.spanId) : that.spanId != null) { + return false; + } + if (traceFlags != null ? !traceFlags.equals(that.traceFlags) : that.traceFlags != null) { + return false; + } return true; } @@ -1116,10 +1208,27 @@ public int hashCode() { result = 31 * result + (source != null ? source.hashCode() : 0); result = 31 * result + (includeLocation ? 1 : 0); result = 31 * result + (endOfBatch ? 1 : 0); + result = 31 * result + (traceId != null ? traceId.hashCode() : 0); + result = 31 * result + (spanId != null ? spanId.hashCode() : 0); + result = 31 * result + (traceFlags != null ? traceFlags.hashCode() : 0); // Check:ON: MagicNumber return result; } + @Override + public String getTraceId() { + return traceId; + } + + @Override + public String getSpanId() { + return spanId; + } + + @Override + public String getTraceFlags() { + return traceFlags; + } /** * Proxy pattern used to serialize the LogEvent. */ @@ -1160,6 +1269,10 @@ static class LogEventProxy implements Serializable { /** @since 2.4 */ private final transient long nanoTime; + private final String traceId; + private final String spanId; + private final String traceFlags; + public LogEventProxy(final Log4jLogEvent event, final boolean includeLocation) { this.loggerFQCN = event.loggerFqcn; this.marker = event.marker; @@ -1180,6 +1293,9 @@ public LogEventProxy(final Log4jLogEvent event, final boolean includeLocation) { this.isLocationRequired = includeLocation; this.isEndOfBatch = event.endOfBatch; this.nanoTime = event.nanoTime; + this.traceId = event.getTraceId(); + this.spanId = event.getSpanId(); + this.traceFlags = event.getTraceFlags(); } public LogEventProxy(final LogEvent event, final boolean includeLocation) { @@ -1210,6 +1326,9 @@ public LogEventProxy(final LogEvent event, final boolean includeLocation) { this.isLocationRequired = includeLocation; this.isEndOfBatch = event.isEndOfBatch(); this.nanoTime = event.getNanoTime(); + this.traceId = event.getTraceId(); + this.spanId = event.getSpanId(); + this.traceFlags = event.getTraceFlags(); } private static Message memento(final ReusableMessage message) { @@ -1257,7 +1376,10 @@ protected Object readResolve() { source, timeMillis, nanoOfMillisecond, - nanoTime); + nanoTime, + traceId, + spanId, + traceFlags); result.setEndOfBatch(isEndOfBatch); result.setIncludeLocation(isLocationRequired); result.thrownProxy = thrownProxy; diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/MutableLogEvent.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/MutableLogEvent.java index 9a99eae624d..6733d48a319 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/MutableLogEvent.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/MutableLogEvent.java @@ -30,6 +30,7 @@ import org.apache.logging.log4j.core.util.Clock; import org.apache.logging.log4j.core.util.Constants; import org.apache.logging.log4j.core.util.NanoClock; +import org.apache.logging.log4j.core.util.TraceContextProviderService; import org.apache.logging.log4j.message.Message; import org.apache.logging.log4j.message.ParameterConsumer; import org.apache.logging.log4j.message.ParameterVisitable; @@ -71,6 +72,9 @@ public class MutableLogEvent implements LogEvent, ReusableMessage, ParameterVisi StackTraceElement source; private ThreadContext.ContextStack contextStack; transient boolean reserved = false; + private String traceId; + private String spanId; + private String traceFlags; public MutableLogEvent() { // messageText and the parameter array are lazily initialized @@ -133,6 +137,10 @@ public void initFrom(final LogEvent event) { this.endOfBatch = event.isEndOfBatch(); this.includeLocation = event.isIncludeLocation(); this.nanoTime = event.getNanoTime(); + + this.traceId = event.getTraceId(); + this.spanId = event.getSpanId(); + this.traceFlags = event.getTraceFlags(); setMessage(event.getMessage()); } @@ -157,6 +165,9 @@ public void clear() { } contextStack = null; + traceId = null; + spanId = null; + traceFlags = null; // ThreadName should not be cleared: this field is set in the ReusableLogEventFactory // where this instance is kept in a ThreadLocal, so it usually does not change. // threadName = null; // no need to clear threadName @@ -359,6 +370,9 @@ void initTime(final Clock clock, final NanoClock nanoClock) { instant.initFrom(clock); } nanoTime = nanoClock.nanoTime(); + this.traceId = TraceContextProviderService.getTraceId(); + this.spanId = TraceContextProviderService.getSpanId(); + this.traceFlags = TraceContextProviderService.getTraceFlags(); } @Override @@ -485,6 +499,33 @@ public void setNanoTime(final long nanoTime) { this.nanoTime = nanoTime; } + @Override + public String getTraceId() { + return traceId; + } + + public void setTraceId(final String traceId) { + this.traceId = traceId; + } + + @Override + public String getSpanId() { + return spanId; + } + + public void setSpanId(final String spanId) { + this.spanId = spanId; + } + + @Override + public String getTraceFlags() { + return traceFlags; + } + + public void setTraceFlags(final String traceFlags) { + this.traceFlags = traceFlags; + } + /** * Creates a LogEventProxy that can be serialized. * @return a LogEventProxy. @@ -534,6 +575,8 @@ public void initializeBuilder(final Log4jLogEvent.Builder builder) { .setThreadPriority(threadPriority) // .setThrown(getThrown()) // may deserialize from thrownProxy .setInstant(instant) // - ; + .setTraceId(traceId) + .setSpanId(spanId) + .setTraceFlags(traceFlags); } } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/package-info.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/package-info.java index 666e8325ed8..2d4bdcd199c 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/package-info.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/impl/package-info.java @@ -18,7 +18,7 @@ * Log4j 2 private implementation classes. */ @Export -@Version("2.24.1") +@Version("2.25.0") package org.apache.logging.log4j.core.impl; import org.osgi.annotation.bundle.Export; diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/package-info.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/package-info.java index c09868a6667..45bc926c52c 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/package-info.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/package-info.java @@ -18,7 +18,7 @@ * Implementation of Log4j 2. */ @Export -@Version("2.25.3") +@Version("2.26.0") package org.apache.logging.log4j.core; import org.osgi.annotation.bundle.Export; diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/SpanIdPatternConverter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/SpanIdPatternConverter.java new file mode 100644 index 00000000000..94544ca945a --- /dev/null +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/SpanIdPatternConverter.java @@ -0,0 +1,43 @@ +/* + * 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.pattern; + +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.config.plugins.Plugin; + +@Plugin(name = "SpanIdPatternConverter", category = PatternConverter.CATEGORY) +@ConverterKeys({"spanId"}) +public final class SpanIdPatternConverter extends LogEventPatternConverter { + + private static final SpanIdPatternConverter INSTANCE = new SpanIdPatternConverter(); + + private SpanIdPatternConverter() { + super("SpanId", "spanId"); + } + + public static SpanIdPatternConverter newInstance(final String[] options) { + return INSTANCE; + } + + @Override + public void format(final LogEvent event, final StringBuilder toAppendTo) { + final String spanId = event.getSpanId(); + if (spanId != null) { + toAppendTo.append(spanId); + } + } +} diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/TraceFlagsPatternConverter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/TraceFlagsPatternConverter.java new file mode 100644 index 00000000000..a27502abcf2 --- /dev/null +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/TraceFlagsPatternConverter.java @@ -0,0 +1,43 @@ +/* + * 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.pattern; + +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.config.plugins.Plugin; + +@Plugin(name = "TraceFlagsPatternConverter", category = PatternConverter.CATEGORY) +@ConverterKeys({"traceFlags"}) +public final class TraceFlagsPatternConverter extends LogEventPatternConverter { + + private static final TraceFlagsPatternConverter INSTANCE = new TraceFlagsPatternConverter(); + + private TraceFlagsPatternConverter() { + super("TraceFlags", "traceFlags"); + } + + public static TraceFlagsPatternConverter newInstance(final String[] options) { + return INSTANCE; + } + + @Override + public void format(final LogEvent event, final StringBuilder toAppendTo) { + final String traceFlags = event.getTraceFlags(); + if (traceFlags != null) { + toAppendTo.append(traceFlags); + } + } +} diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/TraceIdPatternConverter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/TraceIdPatternConverter.java new file mode 100644 index 00000000000..ab1ed09acc3 --- /dev/null +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/TraceIdPatternConverter.java @@ -0,0 +1,43 @@ +/* + * 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.pattern; + +import org.apache.logging.log4j.core.LogEvent; +import org.apache.logging.log4j.core.config.plugins.Plugin; + +@Plugin(name = "TraceIdPatternConverter", category = PatternConverter.CATEGORY) +@ConverterKeys({"traceId"}) +public final class TraceIdPatternConverter extends LogEventPatternConverter { + + private static final TraceIdPatternConverter INSTANCE = new TraceIdPatternConverter(); + + private TraceIdPatternConverter() { + super("TraceId", "traceId"); + } + + public static TraceIdPatternConverter newInstance(final String[] options) { + return INSTANCE; + } + + @Override + public void format(final LogEvent event, final StringBuilder toAppendTo) { + final String traceId = event.getTraceId(); + if (traceId != null) { + toAppendTo.append(traceId); + } + } +} diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/package-info.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/package-info.java index df5bc576a25..9a21c95c796 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/package-info.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/package-info.java @@ -18,7 +18,7 @@ * Provides classes implementing format specifiers in conversion patterns. */ @Export -@Version("2.26.0") +@Version("2.27.0") package org.apache.logging.log4j.core.pattern; import org.osgi.annotation.bundle.Export; diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/TraceContextProviderService.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/TraceContextProviderService.java new file mode 100644 index 00000000000..ec6de9b1376 --- /dev/null +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/TraceContextProviderService.java @@ -0,0 +1,131 @@ +/* + * 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.util; + +import java.util.Iterator; +import java.util.ServiceLoader; +import org.apache.logging.log4j.spi.TraceContextProvider; +import org.apache.logging.log4j.status.StatusLogger; + +/** + * Service registry designed to discover, cache, and safely query the active {@link TraceContextProvider}. + */ +public final class TraceContextProviderService { + + private static final String GLOBAL_KEY = "log4j2.activeTraceContextProvider"; + private static volatile TraceContextProvider ACTIVE_PROVIDER; + + static { + TraceContextProvider found = null; + try { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + if (cl == null) { + cl = TraceContextProviderService.class.getClassLoader(); + } + + ServiceLoader loader = ServiceLoader.load(TraceContextProvider.class, cl); + Iterator iterator = loader.iterator(); + + if (!iterator.hasNext() && cl != TraceContextProviderService.class.getClassLoader()) { + loader = ServiceLoader.load( + TraceContextProvider.class, TraceContextProviderService.class.getClassLoader()); + iterator = loader.iterator(); + } + + if (iterator.hasNext()) { + found = iterator.next(); + StatusLogger.getLogger() + .info("Using TraceContextProvider: {}", found.getClass().getName()); + } + } catch (final Throwable t) { + StatusLogger.getLogger().warn("Error loading TraceContextProvider service", t); + } + ACTIVE_PROVIDER = found != null ? found : NoOpTraceContextProvider.INSTANCE; + } + + public static TraceContextProvider getActiveProvider() { + try { + final Object globalProvider = System.getProperties().get(GLOBAL_KEY); + if (globalProvider instanceof TraceContextProvider) { + return (TraceContextProvider) globalProvider; + } + } catch (final SecurityException ignored) { + // Gracefully ignore SecurityManager restrictions in strict environments + } + return ACTIVE_PROVIDER; + } + + public static void setActiveProvider(final TraceContextProvider provider) { + try { + if (provider == null) { + System.getProperties().remove(GLOBAL_KEY); + ACTIVE_PROVIDER = NoOpTraceContextProvider.INSTANCE; + } else { + System.getProperties().put(GLOBAL_KEY, provider); + ACTIVE_PROVIDER = provider; + } + } catch (final SecurityException ignored) { + // Fallback for strict environments + ACTIVE_PROVIDER = provider != null ? provider : NoOpTraceContextProvider.INSTANCE; + } + } + + public static String getTraceId() { + try { + return getActiveProvider().getTraceId(); + } catch (final Throwable t) { + return null; + } + } + + public static String getSpanId() { + try { + return getActiveProvider().getSpanId(); + } catch (final Throwable t) { + return null; + } + } + + public static String getTraceFlags() { + try { + return getActiveProvider().getTraceFlags(); + } catch (final Throwable t) { + return null; + } + } + + private static final class NoOpTraceContextProvider implements TraceContextProvider { + static final NoOpTraceContextProvider INSTANCE = new NoOpTraceContextProvider(); + + @Override + public String getTraceId() { + return null; + } + + @Override + public String getSpanId() { + return null; + } + + @Override + public String getTraceFlags() { + return null; + } + } + + private TraceContextProviderService() {} +} diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/package-info.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/package-info.java index e4ac85f5eb7..b9efa51956b 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/util/package-info.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/util/package-info.java @@ -18,7 +18,7 @@ * Log4j 2 helper classes. */ @Export -@Version("2.25.3") +@Version("2.26.0") package org.apache.logging.log4j.core.util; import org.osgi.annotation.bundle.Export; diff --git a/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/jmh/AsyncTraceContextBenchmark.java b/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/jmh/AsyncTraceContextBenchmark.java new file mode 100644 index 00000000000..53c284a7e0a --- /dev/null +++ b/log4j-perf-test/src/main/java/org/apache/logging/log4j/perf/jmh/AsyncTraceContextBenchmark.java @@ -0,0 +1,181 @@ +/* + * 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.perf.jmh; + +import java.util.concurrent.TimeUnit; +import org.apache.logging.log4j.Level; +import org.apache.logging.log4j.ThreadContext; +import org.apache.logging.log4j.core.async.RingBufferLogEvent; +import org.apache.logging.log4j.core.async.RingBufferLogEventTranslator; +import org.apache.logging.log4j.core.impl.ContextDataFactory; +import org.apache.logging.log4j.core.util.ClockFactory; +import org.apache.logging.log4j.core.util.DummyNanoClock; +import org.apache.logging.log4j.message.SimpleMessage; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.options.OptionsBuilder; + +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS) +@Measurement(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS) +@Fork(1) +@State(Scope.Benchmark) +public class AsyncTraceContextBenchmark { + + private SimpleMessage message; + + @Setup + public void setup() { + message = new SimpleMessage("Test performance message"); + } + + // Reusable thread-local state to match Disruptor events and translators. + + @State(Scope.Thread) + public static class ThreadState { + final RingBufferLogEvent event = new RingBufferLogEvent(); + final RingBufferLogEventTranslator translator = new RingBufferLogEventTranslator(); + } + + @Benchmark + public void baseline(final ThreadState state) { + state.translator.setBasicValues( + null, + "Logger", + null, + "FQCN", + Level.INFO, + message, + null, + null, + null, + ClockFactory.getClock(), + new DummyNanoClock()); + state.translator.translateTo(state.event, 0); + } + + @Benchmark + public void threadContextTracing(final ThreadState state) { + ThreadContext.put("traceId", "4bf92f3577b34da6a3ce929d0e0e4736"); + ThreadContext.put("spanId", "00f067aa0ba902b7"); + ThreadContext.put("traceFlags", "01"); + + try { + state.translator.setBasicValues( + null, + "Logger", + null, + "FQCN", + Level.INFO, + message, + null, + ThreadContext.getImmutableStack(), + null, + ClockFactory.getClock(), + new DummyNanoClock()); + state.translator.translateTo(state.event, 0); + } finally { + ThreadContext.clearMap(); + } + } + + @Benchmark + public void nativeTracing(final ThreadState state) { + state.translator.setBasicValues( + null, + "Logger", + null, + "FQCN", + Level.INFO, + message, + null, + null, + null, + ClockFactory.getClock(), + new DummyNanoClock(), + "4bf92f3577b34da6a3ce929d0e0e4736", + "00f067aa0ba902b7", + "01"); + state.translator.translateTo(state.event, 0); + } + + public static void main(final String[] args) throws Exception { + new Runner(new OptionsBuilder() + .include(AsyncTraceContextBenchmark.class.getSimpleName()) + .build()) + .run(); + } + /** + * Simulates ContextDataProvider approach. + * It avoids MDC, but forces the creation of multiple StringMaps for every log event. + */ + @Benchmark + public void contextDataProviderTracing(final ThreadState state) { + // OTel allocates a brand new map for the trace context (Allocation 1) + final org.apache.logging.log4j.util.StringMap providerMap = ContextDataFactory.createContextData(); + providerMap.putValue("traceId", "4bf92f3577b34da6a3ce929d0e0e4736"); + providerMap.putValue("spanId", "00f067aa0ba902b7"); + providerMap.putValue("traceFlags", "01"); + + // Log4j's internal ContextDataInjector sees the RingBuffer map is frozen. + // To avoid crashing, it allocates a NEW map to safely merge the data (Allocation 2) + final org.apache.logging.log4j.util.StringMap newMergedMap = ContextDataFactory.createContextData(); + newMergedMap.putAll(providerMap); // Merges the OTel data + + // Execute translator setup (keeps CPU comparison fair) + state.translator.setBasicValues( + null, + "Logger", + null, + "FQCN", + Level.INFO, + message, + null, + null, + null, + ClockFactory.getClock(), + new DummyNanoClock()); + + // Simulate translateTo() injecting the new map directly into the RingBuffer slot + state.event.setValues( + null, + "Logger", + null, + "FQCN", + Level.INFO, + message, + null, + newMergedMap, // Log4j injects the newly allocated map here + ThreadContext.getImmutableStack(), + 1L, + "main", + 5, + null, + ClockFactory.getClock(), + new DummyNanoClock()); + } +} diff --git a/src/changelog/.2.x.x/1976_add_native_tracing_fields.xml b/src/changelog/.2.x.x/1976_add_native_tracing_fields.xml new file mode 100644 index 00000000000..7894ba4c0cd --- /dev/null +++ b/src/changelog/.2.x.x/1976_add_native_tracing_fields.xml @@ -0,0 +1,13 @@ + + + + + + Add native W3C tracing fields (`traceId`, `spanId`, `traceFlags`) to `LogEvent` and introduce the `TraceContextProvider` SPI for garbage-free distributed tracing context propagation. + + \ No newline at end of file From 31b0e2d00e0453513510ec4d3f36bebf9e8c3fed Mon Sep 17 00:00:00 2001 From: Ramanathan Date: Thu, 9 Jul 2026 13:53:41 +0530 Subject: [PATCH 27/28] Fix NPE in ConfigurationScheduler by ensuring proper reset of scheduled futures --- .../config/ConfigurationSchedulerTest.java | 61 ++++++++++++++++--- .../core/config/ConfigurationScheduler.java | 26 ++++++-- 2 files changed, 72 insertions(+), 15 deletions(-) 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 index 1ae50672e9e..06d85ec9ace 100644 --- 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 @@ -16,25 +16,68 @@ */ package org.apache.logging.log4j.core.config; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Date; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; 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); + final CountDownLatch firstRunLatch = new CountDownLatch(1); + final CountDownLatch secondRunLatch = new CountDownLatch(1); + + final Runnable command = () -> { + if (firstRunLatch.getCount() > 0) { + firstRunLatch.countDown(); + } else { + secondRunLatch.countDown(); + } + }; + + final ConfigurationScheduler scheduler = new ConfigurationScheduler() { + private final AtomicBoolean first = new AtomicBoolean(true); + + @Override + public ScheduledFuture schedule(final Runnable cmd, final long delay, final TimeUnit unit) { + if (first.compareAndSet(true, false)) { + final ScheduledFuture future = super.schedule(cmd, 0, TimeUnit.MILLISECONDS); + try { + assertTrue(firstRunLatch.await(5, TimeUnit.SECONDS), "First run failed, likely NPE"); + + Thread.sleep(100); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + return future; + } + return super.schedule(cmd, delay, unit); + } + }; + + try { + scheduler.incrementScheduledItems(); + scheduler.start(); + + final CronExpression cron = new CronExpression("0/1 * * * * ?"); + + final ScheduledFuture future = scheduler.scheduleWithCron(cron, new Date(), command); + + future.cancel(false); + + assertFalse( + secondRunLatch.await(2, TimeUnit.SECONDS), + "Task ran after cancellation. Monotonic reset is broken."); + + } finally { + 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 5e765ff8fb8..26f0b7a6dbd 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,15 +147,23 @@ 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 CronScheduledFuture cronScheduledFuture = new CronScheduledFuture<>(null, fireDate); + runnable.setScheduledFuture(cronScheduledFuture); + final ScheduledFuture future = schedule(runnable, nextFireInterval(fireDate), TimeUnit.MILLISECONDS); - placeholder[0].reset(future, fireDate); + + synchronized (cronScheduledFuture) { + Date currentFireTime = cronScheduledFuture.getFireTime(); + if (currentFireTime == null || !currentFireTime.after(fireDate)) { + cronScheduledFuture.reset(future, fireDate); + } + } + LOGGER.debug( "{} scheduled cron expression {} to fire at {}", name, cronExpression.getCronExpression(), fireDate); - return placeholder[0]; + return cronScheduledFuture; } /** @@ -254,8 +262,14 @@ public void run() { name, cronExpression.getCronExpression(), fireDate); + if (scheduledFuture != null) { - scheduledFuture.reset(future, fireDate); + synchronized (scheduledFuture) { + Date currentFireTime = scheduledFuture.getFireTime(); + if (currentFireTime == null || currentFireTime.before(fireDate)) { + scheduledFuture.reset(future, fireDate); + } + } } } } From fb8abe6b37782e0e42cf9a770a3ab837122c7b92 Mon Sep 17 00:00:00 2001 From: Ramanathan Date: Thu, 9 Jul 2026 15:05:39 +0530 Subject: [PATCH 28/28] Fix NPE in ConfigurationScheduler for frequent cron schedules --- .../.2.x.x/2073_fix_ConfigurationScheduler_NPE.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/changelog/.2.x.x/2073_fix_ConfigurationScheduler_NPE.xml diff --git a/src/changelog/.2.x.x/2073_fix_ConfigurationScheduler_NPE.xml b/src/changelog/.2.x.x/2073_fix_ConfigurationScheduler_NPE.xml new file mode 100644 index 00000000000..ead17f8cfa7 --- /dev/null +++ b/src/changelog/.2.x.x/2073_fix_ConfigurationScheduler_NPE.xml @@ -0,0 +1,13 @@ + + + + + + Fix `NullPointerException` in `ConfigurationScheduler` for a frequently executing cron schedule. + + \ No newline at end of file