From 29cccdfa2032dae1126ff00ccc5e6b169d66f316 Mon Sep 17 00:00:00 2001 From: whya5448 Date: Sat, 1 Aug 2026 21:48:34 +0900 Subject: [PATCH] Add ZstdCompressAction to support configurable Zstandard compression levels --- .../action/ZstdCompressActionTest.java | 125 +++++++++++ .../core/appender/rolling/FileExtension.java | 13 +- .../rolling/action/ZstdCompressAction.java | 208 ++++++++++++++++++ .../appender/rolling/action/package-info.java | 2 +- .../pages/manual/appenders/rolling-file.adoc | 5 +- 5 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/ZstdCompressActionTest.java create mode 100644 log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/ZstdCompressAction.java diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/ZstdCompressActionTest.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/ZstdCompressActionTest.java new file mode 100644 index 00000000000..61a9d92e56b --- /dev/null +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/appender/rolling/action/ZstdCompressActionTest.java @@ -0,0 +1,125 @@ +/* + * 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.assertEquals; +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.apache.commons.compress.compressors.zstandard.ZstdConstants; +import org.apache.logging.log4j.core.appender.rolling.FileExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ZstdCompressActionTest { + + @Test + void testRejectsCompressionLevelZero(@TempDir File tempDir) { + // Level 0 is below the minimum supported level (1) + File source = new File(tempDir, "invalid-zero.log"); + File dest = new File(tempDir, "invalid-zero.log.zst"); + + assertThrows(IllegalArgumentException.class, () -> new ZstdCompressAction(source, dest, true, 0)); + } + + /** + * Negative (fast-compression) Zstd levels are intentionally out of scope for this change. + * Support may be added in a future release; see the discussion at + * https://github.com/apache/logging-log4j2/discussions/2950. + */ + @Test + void testRejectsUnsupportedNegativeLevel_NotYetImplemented(@TempDir File tempDir) { + File source = new File(tempDir, "invalid-neg.log"); + File dest = new File(tempDir, "invalid-neg.log.zst"); + + assertThrows(IllegalArgumentException.class, () -> new ZstdCompressAction(source, dest, true, -1)); + } + + @Test + void testRejectsCompressionLevelAboveMax(@TempDir File tempDir) { + // Level 23 is above the currently supported maximum level (22) + File source = new File(tempDir, "invalid-high.log"); + File dest = new File(tempDir, "invalid-high.log.zst"); + + assertThrows(IllegalArgumentException.class, () -> new ZstdCompressAction(source, dest, true, 23)); + } + + /** + * Pins the level bounds this test class (and the {@code rolling-file.adoc} documentation) assume. + * A zstd-jni/commons-compress upgrade that changes these values won't be caught by the other tests here, + * since they exercise the range relative to {@link ZstdConstants}, not against a fixed expectation. + * If this fails, update the documented range and this test's hardcoded boundary values accordingly. + */ + @Test + void testAssumedZstdLevelBoundsHaveNotChanged() { + assertEquals(22, ZstdConstants.ZSTD_CLEVEL_MAX); + assertEquals(3, ZstdConstants.ZSTD_CLEVEL_DEFAULT); + } + + /** + * Uses hardcoded boundary values (not {@link ZstdConstants} or {@link ZstdCompressAction#MIN_COMPRESSION_LEVEL}) + * on purpose: referencing the same constants the code under test derives its bounds from would make this + * test trivially pass regardless of what those constants actually resolve to. If a zstd-jni/commons-compress + * upgrade shifts the actual default/max, or {@code MIN_COMPRESSION_LEVEL} is changed, this test should fail + * alongside {@link #testAssumedZstdLevelBoundsHaveNotChanged()} rather than silently re-deriving new bounds + * and asserting nothing meaningful. + */ + @Test + void testAcceptsZstdRangeBounds(@TempDir File tempDir) { + File source = new File(tempDir, "valid.log"); + File dest = new File(tempDir, "valid.log.zst"); + + new ZstdCompressAction(source, dest, true, 1); + new ZstdCompressAction(source, dest, true, 22); + } + + @Test + void testCompression(@TempDir File tempDir) throws IOException { + File source = new File(tempDir, "test.log"); + File dest = new File(tempDir, "test.log.zst"); + writeContent(source, "test data"); + + ZstdCompressAction action = new ZstdCompressAction(source, dest, true, ZstdConstants.ZSTD_CLEVEL_DEFAULT); + + assertTrue(action.execute()); + assertTrue(dest.exists(), "Compressed file must exist after execute()"); + assertFalse(source.exists(), "Source file must be deleted after compression"); + } + + @Test + void testFileExtensionUnspecifiedLevelMapping() { + // Verify FileExtension.ZSTD maps log4j2's framework-wide "unspecified compression level" sentinel (-1) + // to ZSTD_CLEVEL_DEFAULT (3). Passing the literal -1 here, not Deflater.DEFAULT_COMPRESSION: the mapping + // in FileExtension.ZSTD compares against the literal -1 sentinel value, not against that JDK constant. + ZstdCompressAction action = + (ZstdCompressAction) FileExtension.ZSTD.createCompressAction("source.log", "target.log.zst", true, -1); + + // Hardcoded, not ZstdConstants.ZSTD_CLEVEL_DEFAULT: both sides would drift together otherwise, + // making this assertion trivially true regardless of what the constant actually resolves to. + assertEquals(3, action.getCompressionLevel()); + } + + 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/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..af1b8b7689b 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 @@ -18,10 +18,12 @@ import java.io.File; import java.util.Objects; +import org.apache.commons.compress.compressors.zstandard.ZstdConstants; import org.apache.logging.log4j.core.appender.rolling.action.Action; import org.apache.logging.log4j.core.appender.rolling.action.CommonsCompressAction; import org.apache.logging.log4j.core.appender.rolling.action.GzCompressAction; import org.apache.logging.log4j.core.appender.rolling.action.ZipCompressAction; +import org.apache.logging.log4j.core.appender.rolling.action.ZstdCompressAction; import org.apache.logging.log4j.core.internal.annotation.SuppressFBWarnings; /** @@ -99,8 +101,15 @@ public Action createCompressAction( final String compressedName, final boolean deleteSource, final int compressionLevel) { - // One of "gz", "bzip2", "xz", "zstd", "pack200", or "deflate". - return new CommonsCompressAction("zstd", source(renameTo), target(compressedName), deleteSource); + // -1 (Deflater.DEFAULT_COMPRESSION) is the framework-wide sentinel for 'unspecified compression level'. + // Unlike GZ/ZIP, where -1 has no meaning other than 'use Deflater's default' (java.util.zip.Deflater + // natively treats -1 as its own default-compression sentinel), Zstd defines -1 as a real, distinct + // fast-compression level. So the sentinel has to be mapped explicitly here to Zstd's own default level. + // Negative Zstd fast-compression levels are intentionally out of scope for now; see the + // discussion on generalizing compressionLevel at + // https://github.com/apache/logging-log4j2/discussions/2950. + final int level = compressionLevel == -1 ? ZstdConstants.ZSTD_CLEVEL_DEFAULT : compressionLevel; + return new ZstdCompressAction(source(renameTo), target(compressedName), deleteSource, level); } }; diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/ZstdCompressAction.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/ZstdCompressAction.java new file mode 100644 index 00000000000..22c5ab6b42f --- /dev/null +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/appender/rolling/action/ZstdCompressAction.java @@ -0,0 +1,208 @@ +/* + * 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 java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.Objects; +import org.apache.commons.compress.compressors.zstandard.ZstdCompressorOutputStream; +import org.apache.commons.compress.compressors.zstandard.ZstdConstants; + +/** + * Compresses a file using Zstandard compression. + *

+ * Supports positive compression levels in the range [{@value #MIN_COMPRESSION_LEVEL}, {@link ZstdConstants#ZSTD_CLEVEL_MAX}]. + * Negative (fast-compression) levels are not currently supported; this may change in a future release. + *

+ * + * @apiNote An explicitly configured level of -1 currently resolves to the Zstd default level (3). + * This is provisional behavior tied to the current lack of negative-level support and may change + * in a future release without a corresponding API signature change. + */ +public final class ZstdCompressAction extends AbstractAction { + + private static final int BUF_SIZE = 8192; + + /** + * Minimum supported Zstd compression level. Negative (fast-compression) levels are intentionally + * out of scope for now; see the discussion at + * https://github.com/apache/logging-log4j2/discussions/2950. + */ + static final int MIN_COMPRESSION_LEVEL = 1; + + /** + * Source file. + */ + private final File source; + + /** + * Destination file. + */ + private final File destination; + + /** + * If true, attempt to delete file on completion. + */ + private final boolean deleteSource; + + /** + * Zstandard compression level to use. + * + * @see ZstdCompressorOutputStream.Builder#setLevel(int) + */ + private final int compressionLevel; + + /** + * Validates that the compression level is a positive integer in the range [{@value #MIN_COMPRESSION_LEVEL}, {@link ZstdConstants#ZSTD_CLEVEL_MAX}]. + * + * @param compressionLevel Zstandard compression level + * @return the compression level if valid + * @throws IllegalArgumentException if compressionLevel is not in the range [{@value #MIN_COMPRESSION_LEVEL}, {@link ZstdConstants#ZSTD_CLEVEL_MAX}] + */ + private static int checkCompressionLevel(final int compressionLevel) { + final int minCompressionLevel = MIN_COMPRESSION_LEVEL; + final int maxCompressionLevel = ZstdConstants.ZSTD_CLEVEL_MAX; + + if (compressionLevel < minCompressionLevel || compressionLevel > maxCompressionLevel) { + if (compressionLevel < 0) { + throw new IllegalArgumentException( + "Negative Zstd fast-compression levels are not yet supported by Log4j2 (got: " + + compressionLevel + + "). Only the standard range [" + + minCompressionLevel + + ", " + + maxCompressionLevel + + "] is currently supported."); + } + throw new IllegalArgumentException("Zstd compression level must be in the range [" + + minCompressionLevel + + ", " + + maxCompressionLevel + + "], got: " + + compressionLevel); + } + return compressionLevel; + } + + /** + * 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 Zstandard compression level. + */ + public ZstdCompressAction( + final File source, final File destination, final boolean deleteSource, final int compressionLevel) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(destination, "destination"); + + this.source = source; + this.destination = destination; + this.deleteSource = deleteSource; + this.compressionLevel = checkCompressionLevel(compressionLevel); + } + + /** + * Compress. + * + * @return true if successfully compressed. + * @throws IOException on IO exception. + */ + @Override + public boolean execute() throws IOException { + return execute(source, destination, deleteSource, compressionLevel); + } + + /** + * Compress a file. + * + * @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 Zstandard compression level. + * @return true if source file compressed. + * @throws IOException on IO exception. + */ + 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); + final OutputStream zstdOut = ZstdCompressorOutputStream.builder() + .setOutputStream(fos) + .setLevel(compressionLevel) + .get(); + // Reduce native invocations by buffering data into ZstdCompressorOutputStream + final OutputStream os = new BufferedOutputStream(zstdOut, BUF_SIZE)) { + final byte[] inbuf = new byte[BUF_SIZE]; + int n; + + while ((n = fis.read(inbuf)) != -1) { + os.write(inbuf, 0, n); + } + } + + if (deleteSource && !source.delete()) { + LOGGER.warn("Unable to delete {}.", source); + } + + return true; + } + + return false; + } + + /** + * Capture exception. + * + * @param ex exception. + */ + @Override + protected void reportException(final Exception ex) { + LOGGER.warn("Exception during compression of '" + source.toString() + "'.", ex); + } + + @Override + public String toString() { + return ZstdCompressAction.class.getSimpleName() + '[' + source + " to " + destination + ", deleteSource=" + + deleteSource + ']'; + } + + public File getSource() { + return source; + } + + public File getDestination() { + return destination; + } + + public boolean isDeleteSource() { + return deleteSource; + } + + public int getCompressionLevel() { + return compressionLevel; + } +} 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/src/site/antora/modules/ROOT/pages/manual/appenders/rolling-file.adoc b/src/site/antora/modules/ROOT/pages/manual/appenders/rolling-file.adoc index deb0ff99a0f..7ecf1ef5425 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 @@ -974,11 +974,14 @@ algorithm | https://commons.apache.org/proper/commons-compress/apidocs/org/apache/commons/compress/compressors/xz/package-summary.html[XZ] algorithm | [[RolloverStrategy-compress-zst]]`.zst` <> -| {x-mark} +| {check-mark} | https://commons.apache.org/proper/commons-compress/apidocs/org/apache/commons/compress/compressors/zstandard/package-summary.html[ZStandard] algorithm |=== +For Zstd (`.zst`), `compressionLevel` accepts the standard positive range up to the zstd library's own maximum, currently `[1, 22]` (enforced at runtime via `ZstdConstants.ZSTD_CLEVEL_MAX`, which may change with the underlying zstd-jni version). If unset, or if explicitly set to `-1`, the Zstd default level (currently `3`) is used. +This mapping for the value `-1` specifically is provisional: if negative (fast-compression) Zstd levels are supported in a future release, an explicitly configured `compressionLevel=-1` will then be interpreted as a literal fast compression level rather than as the default, changing the resulting compression behavior. + If the <> attribute is set, the current log file: * will be compressed and stored in the location given by `tempCompressedFilePattern`