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..07a74d5ebeb --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/FileExtensionCompressDelayTest.java @@ -0,0 +1,152 @@ +/* + * 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.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 { + + // ── 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..3570c989328 --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/GzCompressActionTest.java @@ -0,0 +1,93 @@ +/* + * 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.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 GzCompressActionTest { + + /** 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..0a3390e4f9c --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/ZipCompressActionTest.java @@ -0,0 +1,93 @@ +/* + * 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.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 { + + /** 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-test/src/test/java/org/apache/logging/log4j/core/config/plugins/processor/PluginProcessorPublicSetterTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/config/plugins/processor/PluginProcessorPublicSetterTest.java index 2b6a03d1fea..587490e2c92 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/config/plugins/processor/PluginProcessorPublicSetterTest.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/config/plugins/processor/PluginProcessorPublicSetterTest.java @@ -37,8 +37,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; public class PluginProcessorPublicSetterTest { @@ -52,10 +50,6 @@ public class PluginProcessorPublicSetterTest { @BeforeEach void setup() { - setupWithOptions(); - } - - private void setupWithOptions(final String... extraOptions) { // Instantiate the tooling final JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); diagnosticCollector = new DiagnosticCollector<>(); @@ -73,12 +67,13 @@ private void setupWithOptions(final String... extraOptions) { // get compilation units Iterable compilationUnits = fileManager.getJavaFileObjects(createdFile); - final List args = - new java.util.ArrayList<>(Arrays.asList("-proc:only", "-processor", PluginProcessor.class.getName())); - args.addAll(Arrays.asList(extraOptions)); - - JavaCompiler.CompilationTask task = - compiler.getTask(null, fileManager, diagnosticCollector, args, null, compilationUnits); + JavaCompiler.CompilationTask task = compiler.getTask( + null, + fileManager, + diagnosticCollector, + Arrays.asList("-proc:only", "-processor", PluginProcessor.class.getName()), + null, + compilationUnits); task.call(); errorDiagnostics = diagnosticCollector.getDiagnostics().stream() @@ -97,6 +92,7 @@ void tearDown() { @Test void warnWhenPluginBuilderAttributeLacksPublicSetter() { + assertThat(errorDiagnostics).anyMatch(errorMessage -> errorMessage .getMessage(Locale.ROOT) .contains("The field `attribute` does not have a public setter")); @@ -111,56 +107,4 @@ void ignoreWarningWhenSuppressWarningsIsPresent() { .contains( "The field `attributeWithoutPublicSetterButWithSuppressAnnotation` does not have a public setter")); } - - @Test - void noteEmittedByDefault() { - final List> noteDiagnostics = diagnosticCollector.getDiagnostics().stream() - .filter(d -> d.getKind() == Diagnostic.Kind.NOTE) - .collect(Collectors.toList()); - assertThat(noteDiagnostics).anyMatch(d -> d.getMessage(Locale.ROOT).contains("writing plugin descriptor")); - } - - @Test - void notesSuppressedWhenMinKindIsError() { - setupWithOptions("-A" + PluginProcessor.MIN_ALLOWED_MESSAGE_KIND_OPTION + "=ERROR"); - - final List> noteDiagnostics = diagnosticCollector.getDiagnostics().stream() - .filter(d -> d.getKind() == Diagnostic.Kind.NOTE) - .collect(Collectors.toList()); - assertThat(noteDiagnostics).noneMatch(d -> d.getMessage(Locale.ROOT).contains("writing plugin descriptor")); - } - - @Test - void errorsStillEmittedWhenMinKindIsError() { - setupWithOptions("-A" + PluginProcessor.MIN_ALLOWED_MESSAGE_KIND_OPTION + "=ERROR"); - - assertThat(errorDiagnostics).anyMatch(d -> d.getMessage(Locale.ROOT) - .contains("The field `attribute` does not have a public setter")); - } - - @ParameterizedTest - @ValueSource(strings = {"NOTE", "note"}) - void explicitNoteKindBehavesLikeDefault(final String kindValue) { - setupWithOptions("-A" + PluginProcessor.MIN_ALLOWED_MESSAGE_KIND_OPTION + "=" + kindValue); - - assertThat(errorDiagnostics).anyMatch(d -> d.getMessage(Locale.ROOT) - .contains("The field `attribute` does not have a public setter")); - - final List> noteDiagnostics = diagnosticCollector.getDiagnostics().stream() - .filter(d -> d.getKind() == Diagnostic.Kind.NOTE) - .collect(Collectors.toList()); - assertThat(noteDiagnostics).anyMatch(d -> d.getMessage(Locale.ROOT).contains("writing plugin descriptor")); - } - - @Test - void invalidKindValueEmitsWarning() { - setupWithOptions("-A" + PluginProcessor.MIN_ALLOWED_MESSAGE_KIND_OPTION + "=INVALID"); - - final List> warningDiagnostics = - diagnosticCollector.getDiagnostics().stream() - .filter(d -> d.getKind() == Diagnostic.Kind.WARNING) - .collect(Collectors.toList()); - assertThat(warningDiagnostics) - .anyMatch(d -> d.getMessage(Locale.ROOT).contains("unrecognized value `INVALID`")); - } } 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..7c4819c6fc7 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; @@ -148,6 +151,19 @@ public DefaultRolloverStrategy build() { // The config object can be null when this object is built programmatically. final StrSubstitutor nonNullStrSubstitutor = config != null ? config.getStrSubstitutor() : new StrSubstitutor(); + // Legacy constructor for backward compatibility + if (maxCompressionDelaySeconds == 0) { + return new DefaultRolloverStrategy( + minIndex, + maxIndex, + useMax, + compressionLevel, + nonNullStrSubstitutor, + customActions, + stopCustomActionsOnError, + tempCompressedFilePattern); + } + // New constructor with delay return new DefaultRolloverStrategy( minIndex, maxIndex, @@ -156,7 +172,8 @@ public DefaultRolloverStrategy build() { nonNullStrSubstitutor, customActions, stopCustomActionsOnError, - tempCompressedFilePattern); + tempCompressedFilePattern, + maxCompressionDelaySeconds); } public String getMax() { @@ -359,6 +376,11 @@ public Builder withConfig(final Configuration config) { this.config = config; return this; } + + public Builder setMaxCompressionDelaySeconds(final int maxCompressionDelaySeconds) { + this.maxCompressionDelaySeconds = maxCompressionDelaySeconds; + return this; + } } @PluginBuilderFactory @@ -380,10 +402,16 @@ public static Builder newBuilder() { * @return A DefaultRolloverStrategy. * @deprecated Since 2.9 Usage of Builder API is preferable */ + // ...existing code... + // ...existing code... + + /** + * 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 +429,38 @@ public static DefaultRolloverStrategy createStrategy( .setStopCustomActionsOnError(stopCustomActionsOnError) .setConfig(config) .build(); - // @formatter:on + } + + /** + * New factory method supporting maxCompressionDelaySeconds. + * @since 2.26.0 + */ + @PluginFactory + public static DefaultRolloverStrategy createStrategy( + @PluginAttribute("max") final String max, + @PluginAttribute("min") final String min, + @PluginAttribute("fileIndex") final String fileIndex, + @PluginAttribute("compressionLevel") final String compressionLevelStr, + @PluginElement("Actions") final Action[] customActions, + @PluginAttribute(value = "stopCustomActionsOnError", defaultBoolean = true) + final boolean stopCustomActionsOnError, + @PluginConfiguration final Configuration config, + @PluginAttribute("maxCompressionDelaySeconds") final int maxCompressionDelaySeconds) { + if (maxCompressionDelaySeconds == 0) { + // Delegate to legacy method for backward compatibility + return createStrategy( + max, min, fileIndex, compressionLevelStr, customActions, stopCustomActionsOnError, config); + } + return DefaultRolloverStrategy.newBuilder() + .setMin(min) + .setMax(max) + .setFileIndex(fileIndex) + .setCompressionLevelStr(compressionLevelStr) + .setCustomActions(customActions) + .setStopCustomActionsOnError(stopCustomActionsOnError) + .setConfig(config) + .setMaxCompressionDelaySeconds(maxCompressionDelaySeconds) + .build(); } /** @@ -419,6 +478,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 +506,8 @@ protected DefaultRolloverStrategy( strSubstitutor, customActions, stopCustomActionsOnError, - null); + null, + 0); } /** @@ -477,6 +538,30 @@ protected DefaultRolloverStrategy( this.customActions = customActions == null ? Collections.emptyList() : Arrays.asList(customActions); this.tempCompressedFilePattern = tempCompressedFilePatternString != null ? new PatternProcessor(tempCompressedFilePatternString) : null; + this.maxCompressionDelaySeconds = 0; // Default for backward compatibility + } + + // Overloaded constructor for new feature + 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; + this.useMax = useMax; + this.compressionLevel = compressionLevel; + this.stopCustomActionsOnError = stopCustomActionsOnError; + 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 +592,10 @@ public PatternProcessor getTempCompressedFilePattern() { return tempCompressedFilePattern; } + 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 +769,19 @@ public RolloverDescription rollover(final RollingFileManager manager) throws Sec } compressAction = new CompositeAction( Arrays.asList( - fileExtension.createCompressAction(renameTo, tmpCompressedName, true, compressionLevel), + ((FileExtension) fileExtension) + .createCompressAction( + renameTo, + tmpCompressedName, + true, + compressionLevel, + maxCompressionDelaySeconds), new FileRenameAction(tmpCompressedNameFile, renameToFile, true)), true); } else { - compressAction = fileExtension.createCompressAction(renameTo, compressedName, true, compressionLevel); + compressAction = ((FileExtension) 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..0cff6a3944f 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,24 @@ public Action createCompressAction( final String compressedName, final boolean deleteSource, final int compressionLevel) { - return new ZipCompressAction(source(renameTo), target(compressedName), deleteSource, compressionLevel); + return new ZipCompressAction( + new File(renameTo), new File(compressedName), deleteSource, compressionLevel, 0); + } + + @Override + public Action createCompressAction( + final String renameTo, + final String compressedName, + final boolean deleteSource, + final int compressionLevel, + final int maxCompressionDelaySeconds) { + // Issue #4012: pass maxCompressionDelaySeconds so random delay is applied before compression + return new ZipCompressAction( + new File(renameTo), + new File(compressedName), + deleteSource, + compressionLevel, + maxCompressionDelaySeconds); } }, GZ(".gz") { @@ -45,7 +62,24 @@ public Action createCompressAction( final String compressedName, final boolean deleteSource, final int compressionLevel) { - return new GzCompressAction(source(renameTo), target(compressedName), deleteSource, compressionLevel); + return new GzCompressAction( + new File(renameTo), new File(compressedName), deleteSource, compressionLevel, 0); + } + + @Override + public Action createCompressAction( + final String renameTo, + final String compressedName, + final boolean deleteSource, + final int compressionLevel, + final int maxCompressionDelaySeconds) { + // Issue #4012: pass maxCompressionDelaySeconds so random delay is applied before compression + return new GzCompressAction( + new File(renameTo), + new File(compressedName), + deleteSource, + compressionLevel, + maxCompressionDelaySeconds); } }, BZIP2(".bz2") { @@ -129,9 +163,21 @@ public static FileExtension lookupForFile(final String fileName) { this.extension = extension; } + // 4-argument version (legacy, for compatibility) public abstract Action createCompressAction( String renameTo, String compressedName, boolean deleteSource, int compressionLevel); + // 5-argument version (for delay support) + public Action createCompressAction( + String renameTo, + String compressedName, + boolean deleteSource, + int compressionLevel, + int maxCompressionDelaySeconds) { + // By default, ignore delay and call the 4-argument version + return createCompressAction(renameTo, compressedName, deleteSource, compressionLevel); + } + public String getExtension() { return extension; } 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..4b4e2f04d2d 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,11 @@ public final class GzCompressAction extends AbstractAction { */ private final int compressionLevel; + /** + * Maximum delay in seconds before compression. + */ + private final int maxDelaySeconds; + /** * Create new instance of GzCompressAction. * @@ -64,9 +69,15 @@ public final class GzCompressAction extends AbstractAction { * does not cause an exception to be thrown or affect return value. * @param compressionLevel * Gzip deflater compression level. + * @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"); @@ -74,16 +85,29 @@ public GzCompressAction( this.destination = destination; this.deleteSource = deleteSource; this.compressionLevel = compressionLevel; + this.maxDelaySeconds = maxDelaySeconds; + } + + /** + * Legacy constructor for backward compatibility. + * @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 +118,16 @@ public GzCompressAction(final File source, final File destination, final boolean */ @Override public boolean execute() throws IOException { + 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(); + } + } + } 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 f29a7391ceb..d114f4c71d7 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,11 @@ public final class ZipCompressAction extends AbstractAction { */ private final int level; + /** + * Maximum delay in seconds before compression. + */ + private final int maxDelaySeconds; + /** * Creates new instance of GzCompressAction. * @@ -59,8 +64,14 @@ public final class ZipCompressAction extends AbstractAction { * @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 maxDelaySeconds maximum delay in seconds before compression. */ - 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"); @@ -68,6 +79,20 @@ public ZipCompressAction(final File source, final File destination, final boolea this.destination = destination; this.deleteSource = deleteSource; this.level = level; + this.maxDelaySeconds = maxDelaySeconds; + } + + /** + * Creates new instance of GzCompressAction with default maxDelaySeconds. + * + * @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 TODO + */ + public ZipCompressAction(final File source, final File destination, final boolean deleteSource, final int level) { + this(source, destination, deleteSource, level, 0); } /** @@ -78,6 +103,16 @@ public ZipCompressAction(final File source, final File destination, final boolea */ @Override public boolean execute() throws IOException { + 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(); + } + } + } return execute(source, destination, deleteSource, level); } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/config/plugins/processor/PluginProcessor.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/config/plugins/processor/PluginProcessor.java index 94557a7ddb7..c01dfa5d59d 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/config/plugins/processor/PluginProcessor.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/config/plugins/processor/PluginProcessor.java @@ -35,11 +35,9 @@ import java.util.Set; import javax.annotation.processing.AbstractProcessor; import javax.annotation.processing.Messager; -import javax.annotation.processing.ProcessingEnvironment; import javax.annotation.processing.Processor; import javax.annotation.processing.RoundEnvironment; import javax.annotation.processing.SupportedAnnotationTypes; -import javax.annotation.processing.SupportedOptions; import javax.lang.model.SourceVersion; import javax.lang.model.element.Element; import javax.lang.model.element.ElementVisitor; @@ -62,7 +60,6 @@ */ @ServiceProvider(value = Processor.class, resolution = Resolution.OPTIONAL) @SupportedAnnotationTypes("org.apache.logging.log4j.core.config.plugins.Plugin") -@SupportedOptions("log4j.plugin.processor.minAllowedMessageKind") public class PluginProcessor extends AbstractProcessor { // TODO: this could be made more abstract to allow for compile-time and run-time plugin processing @@ -71,21 +68,6 @@ public class PluginProcessor extends AbstractProcessor { private static final String SUPPRESS_WARNING_PUBLIC_SETTER_STRING = "log4j.public.setter"; - /** - * Annotation processor option that controls the minimum {@link Diagnostic.Kind} of messages emitted by this - * processor. - *

- * Some build environments (e.g. Maven with {@code -Werror}) treat compiler notes or warnings as errors. - * Setting this option to {@code WARNING} or {@code ERROR} suppresses informational notes emitted during - * normal processing. - *

- *

- * Accepted values (case-insensitive): {@code NOTE}, {@code WARNING}, {@code MANDATORY_WARNING}, - * {@code ERROR}, {@code OTHER}. Defaults to {@code NOTE}. - *

- */ - static final String MIN_ALLOWED_MESSAGE_KIND_OPTION = "log4j.plugin.processor.minAllowedMessageKind"; - /** * The location of the plugin cache data file. This file is written to by this processor, and read from by * {@link org.apache.logging.log4j.core.config.plugins.util.PluginManager}. @@ -95,30 +77,6 @@ public class PluginProcessor extends AbstractProcessor { private final List processedElements = new ArrayList<>(); private final PluginCache pluginCache = new PluginCache(); - private Diagnostic.Kind minAllowedMessageKind = Diagnostic.Kind.NOTE; - - @Override - public void init(final ProcessingEnvironment processingEnv) { - super.init(processingEnv); - final String kindValue = processingEnv.getOptions().get(MIN_ALLOWED_MESSAGE_KIND_OPTION); - if (kindValue != null) { - try { - minAllowedMessageKind = Diagnostic.Kind.valueOf(kindValue.toUpperCase(Locale.ROOT)); - } catch (final IllegalArgumentException e) { - processingEnv - .getMessager() - .printMessage( - Diagnostic.Kind.WARNING, - String.format( - "%s: unrecognized value `%s` for option `%s`, using default `%s`. Valid values: %s", - PluginProcessor.class.getName(), - kindValue, - MIN_ALLOWED_MESSAGE_KIND_OPTION, - Diagnostic.Kind.NOTE, - Arrays.toString(Diagnostic.Kind.values()))); - } - } - } @Override public SourceVersion getSupportedSourceVersion() { @@ -128,28 +86,9 @@ public SourceVersion getSupportedSourceVersion() { private static final String PLUGIN_BUILDER_ATTRIBUTE_ANNOTATION = "org.apache.logging.log4j.core.config.plugins.PluginBuilderAttribute"; - /** - * Prints a message via the {@link Messager} only if {@code kind} is at least as severe as the configured - * {@link #MIN_ALLOWED_MESSAGE_KIND_OPTION minimum allowed kind}. - */ - private void printMessage(final Diagnostic.Kind kind, final String message) { - if (kind.ordinal() <= minAllowedMessageKind.ordinal()) { - processingEnv.getMessager().printMessage(kind, message); - } - } - - /** - * Prints a message via the {@link Messager} only if {@code kind} is at least as severe as the configured - * {@link #MIN_ALLOWED_MESSAGE_KIND_OPTION minimum allowed kind}. - */ - private void printMessage(final Diagnostic.Kind kind, final String message, final Element element) { - if (kind.ordinal() <= minAllowedMessageKind.ordinal()) { - processingEnv.getMessager().printMessage(kind, message, element); - } - } - @Override public boolean process(final Set annotations, final RoundEnvironment roundEnv) { + final Messager messager = processingEnv.getMessager(); final Elements elementUtils = processingEnv.getElementUtils(); // Process the elements for this round if (!annotations.isEmpty()) { @@ -166,7 +105,7 @@ public boolean process(final Set annotations, final Round // Write the cache file if (roundEnv.processingOver() && !processedElements.isEmpty()) { try { - printMessage( + messager.printMessage( Diagnostic.Kind.NOTE, String.format( "%s: writing plugin descriptor for %d Log4j Plugins to `%s`.", @@ -179,7 +118,7 @@ public boolean process(final Set annotations, final Round .append(PLUGIN_CACHE_FILE) .append("\n"); e.printStackTrace(new PrintWriter(sw)); - printMessage(Diagnostic.Kind.ERROR, sw.toString()); + processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, sw.toString()); } } // Do not claim the annotations to allow other annotation processors to run @@ -241,12 +180,14 @@ private void processBuilderAttribute(final VariableElement element) { } } // If the setter was not found generate a compiler warning. - printMessage( - Diagnostic.Kind.ERROR, - String.format( - "The field `%s` does not have a public setter, Note that @SuppressWarnings(\"%s\"), can be used on the field to suppress the compilation error. ", - fieldName, SUPPRESS_WARNING_PUBLIC_SETTER_STRING), - element); + processingEnv + .getMessager() + .printMessage( + Diagnostic.Kind.ERROR, + String.format( + "The field `%s` does not have a public setter, Note that @SuppressWarnings(\"%s\"), can be used on the field to suppress the compilation error. ", + fieldName, SUPPRESS_WARNING_PUBLIC_SETTER_STRING), + element); } } diff --git a/src/changelog/.2.x.x/plugin_processor_min_allowed_message_kind.xml b/src/changelog/.2.x.x/plugin_processor_min_allowed_message_kind.xml deleted file mode 100644 index 3663ec2850e..00000000000 --- a/src/changelog/.2.x.x/plugin_processor_min_allowed_message_kind.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - Add `log4j.plugin.processor.minAllowedMessageKind` annotation processor option to `PluginProcessor` to filter diagnostic messages by severity. - This allows builds that treat compiler notes as errors (e.g. Maven with `-Werror`) to suppress informational notes emitted during normal plugin processing. - - diff --git a/src/site/antora/modules/ROOT/pages/manual/plugins.adoc b/src/site/antora/modules/ROOT/pages/manual/plugins.adoc index f8d6b5ff4b9..6b751f07366 100644 --- a/src/site/antora/modules/ROOT/pages/manual/plugins.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/plugins.adoc @@ -215,38 +215,6 @@ The `GraalVmProcessor` requires your project's `groupId` and `artifactId` to cor Provide these values to the processor using the `log4j.graalvm.groupId` and `log4j.graalvm.artifactId` annotation processor options. ==== -.Suppressing notes from `PluginProcessor` in strict build environments -[%collapsible] -==== -Some build environments treat all compiler notes or warnings as errors (e.g., Maven with `-Werror` or Gradle with `options.compilerArgs << '-Werror'`). -By default, `PluginProcessor` emits a `NOTE`-level diagnostic when it writes the plugin descriptor, which can cause the build to fail in those environments. -To suppress these informational notes, pass the `log4j.plugin.processor.minAllowedMessageKind` annotation processor option with a value of `WARNING` or `ERROR`. -This instructs the processor to only emit diagnostics at or above the specified severity, silencing routine notes while preserving genuine warnings and errors. - -Accepted values (case-insensitive): `NOTE` (default), `WARNING`, `MANDATORY_WARNING`, `ERROR`, `OTHER`. - -[tabs] -===== -Maven:: -+ -[source,xml] ----- - - -Alog4j.plugin.processor.minAllowedMessageKind=WARNING - ----- - -Gradle:: -+ -[source,groovy] ----- -compileJava { - options.compilerArgs << '-Alog4j.plugin.processor.minAllowedMessageKind=WARNING' -} ----- -===== -==== - You need to configure your build tool as follows to use both plugin processors: [tabs]