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/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/changelog/.2.x.x/plugin_processor_min_allowed_message_kind.xml b/src/changelog/.2.x.x/plugin_processor_min_allowed_message_kind.xml index 3663ec2850e..e78888e93da 100644 --- 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 @@ -5,10 +5,10 @@ https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-changelog-0.xsd" type="added"> - - - - 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. - - + + + + 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. + + \ No newline at end of file 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`] diff --git a/src/site/antora/modules/ROOT/pages/manual/plugins.adoc b/src/site/antora/modules/ROOT/pages/manual/plugins.adoc index f8d6b5ff4b9..5637806a362 100644 --- a/src/site/antora/modules/ROOT/pages/manual/plugins.adoc +++ b/src/site/antora/modules/ROOT/pages/manual/plugins.adoc @@ -348,4 +348,4 @@ See `@PluginElement("EventTemplateAdditionalField")` usage in {project-github-ur * Otherwise, you can use link:../javadoc/log4j-core/org/apache/logging/log4j/core/config/plugins/util/PluginUtil.html[`PluginUtil`], which is a convenient wrapper around <<#plugin-discovery,`PluginManager`>>, to discover and load plugins. -See {project-github-url}/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TemplateResolverFactories.java[`TemplateResolverFactories.java`] for example usages. +See {project-github-url}/log4j-layout-template-json/src/main/java/org/apache/logging/log4j/layout/template/json/resolver/TemplateResolverFactories.java[`TemplateResolverFactories.java`] for example usages. \ No newline at end of file