Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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);
}
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* </p>
*
* @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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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` <<commons-compress-dep,^dep^>>
| {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 <<RolloverStrategy-attr-tempCompressedFilePattern,`tempCompressedFilePattern`>> attribute is set, the current log file:

* will be compressed and stored in the location given by `tempCompressedFilePattern`
Expand Down