diff --git a/parquet-common/src/main/java/org/apache/parquet/hadoop/metadata/CompressionCodecName.java b/parquet-common/src/main/java/org/apache/parquet/hadoop/metadata/CompressionCodecName.java index 18818c3c21..176328a382 100644 --- a/parquet-common/src/main/java/org/apache/parquet/hadoop/metadata/CompressionCodecName.java +++ b/parquet-common/src/main/java/org/apache/parquet/hadoop/metadata/CompressionCodecName.java @@ -27,7 +27,7 @@ public enum CompressionCodecName { SNAPPY("org.apache.parquet.hadoop.codec.SnappyCodec", CompressionCodec.SNAPPY, ".snappy"), GZIP("org.apache.hadoop.io.compress.GzipCodec", CompressionCodec.GZIP, ".gz"), LZO("com.hadoop.compression.lzo.LzoCodec", CompressionCodec.LZO, ".lzo"), - BROTLI("org.apache.hadoop.io.compress.BrotliCodec", CompressionCodec.BROTLI, ".br"), + BROTLI("org.apache.parquet.hadoop.codec.BrotliCodec", CompressionCodec.BROTLI, ".br"), LZ4("org.apache.hadoop.io.compress.Lz4Codec", CompressionCodec.LZ4, ".lz4hadoop"), ZSTD("org.apache.parquet.hadoop.codec.ZstandardCodec", CompressionCodec.ZSTD, ".zstd"), LZ4_RAW("org.apache.parquet.hadoop.codec.Lz4RawCodec", CompressionCodec.LZ4_RAW, ".lz4raw"); @@ -39,17 +39,31 @@ public static CompressionCodecName fromConf(String name) { return valueOf(name.toUpperCase(Locale.ENGLISH)); } + // Brotli codec class from the removed jbrotli-based brotli-codec artifact. Still accepted so that + // jobs configured through mapred.output.compression.codec keep writing Brotli. + private static final String LEGACY_BROTLI_CODEC_CLASS = "org.apache.hadoop.io.compress.BrotliCodec"; + public static CompressionCodecName fromCompressionCodec(Class clazz) { if (clazz == null) { return UNCOMPRESSED; } - String name = clazz.getName(); + CompressionCodecName codec = fromCompressionCodecClassName(clazz.getName()); + if (codec == null) { + throw new CompressionCodecNotSupportedException(clazz); + } + return codec; + } + + static CompressionCodecName fromCompressionCodecClassName(String name) { for (CompressionCodecName codec : CompressionCodecName.values()) { if (name.equals(codec.getHadoopCompressionCodecClassName())) { return codec; } } - throw new CompressionCodecNotSupportedException(clazz); + if (LEGACY_BROTLI_CODEC_CLASS.equals(name)) { + return BROTLI; + } + return null; } public static CompressionCodecName fromParquet(CompressionCodec codec) { diff --git a/parquet-common/src/test/java/org/apache/parquet/hadoop/metadata/TestCompressionCodecName.java b/parquet-common/src/test/java/org/apache/parquet/hadoop/metadata/TestCompressionCodecName.java new file mode 100644 index 0000000000..43b8b4681c --- /dev/null +++ b/parquet-common/src/test/java/org/apache/parquet/hadoop/metadata/TestCompressionCodecName.java @@ -0,0 +1,65 @@ +/* + * 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.parquet.hadoop.metadata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.apache.parquet.hadoop.codec.CompressionCodecNotSupportedException; +import org.junit.jupiter.api.Test; + +public class TestCompressionCodecName { + + @Test + public void testLegacyBrotliCodecClassMapsToBrotli() { + assertThat(CompressionCodecName.fromCompressionCodecClassName("org.apache.hadoop.io.compress.BrotliCodec")) + .isEqualTo(CompressionCodecName.BROTLI); + } + + @Test + public void testBrotliCodecClassMapsToBrotli() { + assertThat(CompressionCodecName.fromCompressionCodecClassName("org.apache.parquet.hadoop.codec.BrotliCodec")) + .isEqualTo(CompressionCodecName.BROTLI); + } + + @Test + public void testCodecClassNamesRoundTrip() { + for (CompressionCodecName codec : CompressionCodecName.values()) { + if (codec.getHadoopCompressionCodecClassName() != null) { + assertThat(CompressionCodecName.fromCompressionCodecClassName( + codec.getHadoopCompressionCodecClassName())) + .isEqualTo(codec); + } + } + } + + @Test + public void testUnknownCodecClass() { + assertThat(CompressionCodecName.fromCompressionCodecClassName(String.class.getName())) + .isNull(); + assertThatThrownBy(() -> CompressionCodecName.fromCompressionCodec(String.class)) + .isInstanceOf(CompressionCodecNotSupportedException.class) + .hasMessage("codec not supported: java.lang.String"); + } + + @Test + public void testNullCodecClassIsUncompressed() { + assertThat(CompressionCodecName.fromCompressionCodec(null)).isEqualTo(CompressionCodecName.UNCOMPRESSED); + } +} diff --git a/parquet-hadoop/pom.xml b/parquet-hadoop/pom.xml index f2997a1ae9..3a24bb673a 100644 --- a/parquet-hadoop/pom.xml +++ b/parquet-hadoop/pom.xml @@ -174,6 +174,12 @@ zstd-jni ${zstd-jni.version} + + com.aayushatharva.brotli4j + brotli4j + ${brotli4j.version} + true + com.google.guava @@ -246,33 +252,4 @@ - - - - non-aarch64 - - - !aarch64 - - - - - jitpack.io - https://jitpack.io - Jitpack.io repository - - - - - - com.github.rdblue - brotli-codec - ${brotli-codec.version} - runtime - true - - - - - diff --git a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/CodecFactory.java b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/CodecFactory.java index 9024954bb9..10b9960ef1 100644 --- a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/CodecFactory.java +++ b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/CodecFactory.java @@ -41,6 +41,7 @@ import org.apache.parquet.compression.CompressionCodecFactory; import org.apache.parquet.conf.HadoopParquetConfiguration; import org.apache.parquet.conf.ParquetConfiguration; +import org.apache.parquet.hadoop.codec.BrotliCodec; import org.apache.parquet.hadoop.codec.Lz4RawCodec; import org.apache.parquet.hadoop.codec.ZstandardCodec; import org.apache.parquet.hadoop.metadata.CompressionCodecName; @@ -178,7 +179,13 @@ public BytesInput decompress(BytesInput bytes, int decompressedSize) throws IOEx if (decompressor != null) { decompressor.reset(); } - InputStream is = codec.createInputStream(bytes.toInputStream(), decompressor); + InputStream is; + if (codec instanceof BrotliCodec) { + // Brotli is decoded in one shot; bound the output by the size declared in the page header. + is = ((BrotliCodec) codec).createInputStream(bytes.toInputStream(), decompressedSize); + } else { + is = codec.createInputStream(bytes.toInputStream(), decompressor); + } // Eagerly materialize the decompressed stream for codecs that require all input in a single buffer. // ZSTD: releases off-heap resources early to avoid fragmentation (see parquet-format#398). diff --git a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/DirectCodecFactory.java b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/DirectCodecFactory.java index 4788c5f040..f51e0fa379 100644 --- a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/DirectCodecFactory.java +++ b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/DirectCodecFactory.java @@ -136,6 +136,10 @@ protected BytesDecompressor createDecompressor(final CompressionCodecName codecN if (codec == null) { return NO_OP_DECOMPRESSOR; } + if (codec.getDecompressorType() == null) { + // Stream-only codecs (for example Brotli) do not expose a Decompressor. + return new HeapBytesDecompressor(codec); + } DirectCodecPool.CodecPool pool = DirectCodecPool.INSTANCE.codec(codec); if (pool.supportsDirectDecompression()) { return new FullDirectDecompressor(pool.borrowDirectDecompressor()); diff --git a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/codec/BrotliCodec.java b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/codec/BrotliCodec.java new file mode 100644 index 0000000000..d9d05960f7 --- /dev/null +++ b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/codec/BrotliCodec.java @@ -0,0 +1,151 @@ +/* + * 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.parquet.hadoop.codec; + +import com.aayushatharva.brotli4j.Brotli4jLoader; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import org.apache.hadoop.conf.Configurable; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.io.compress.CompressionCodec; +import org.apache.hadoop.io.compress.CompressionInputStream; +import org.apache.hadoop.io.compress.CompressionOutputStream; +import org.apache.hadoop.io.compress.Compressor; +import org.apache.hadoop.io.compress.Decompressor; +import org.apache.parquet.hadoop.BadConfigurationException; + +/** + * Hadoop codec for Brotli, backed by the maintained brotli4j native bindings. + * + *

The previous implementation depended on the abandoned jbrotli-based brotli-codec artifact, + * which bundled an old Brotli decoder. This codec is loaded reflectively by {@link + * org.apache.parquet.hadoop.CodecFactory} and only requires brotli4j when Brotli is actually used. + */ +public class BrotliCodec implements CompressionCodec, Configurable { + + public static final String BROTLI_QUALITY = "compression.brotli.quality"; + public static final int DEFAULT_QUALITY = 1; + private static final int DEFAULT_MAX_OUTPUT_SIZE = Integer.MAX_VALUE - 8; + + private Configuration conf; + private int quality = DEFAULT_QUALITY; + + @Override + public void setConf(Configuration conf) { + this.conf = conf; + if (conf != null) { + quality = conf.getInt(BROTLI_QUALITY, DEFAULT_QUALITY); + } + } + + @Override + public Configuration getConf() { + return conf; + } + + @Override + public CompressionOutputStream createOutputStream(OutputStream out) throws IOException { + if (quality < 0 || quality > 11) { + throw new BadConfigurationException("Invalid Brotli compression quality: " + quality + ". Expected 0..11."); + } + ensureBrotliAvailable(); + try { + return new BrotliCompressionOutputStream(out, quality); + } catch (LinkageError e) { + throw missingBrotli(e); + } + } + + @Override + public CompressionOutputStream createOutputStream(OutputStream out, Compressor compressor) throws IOException { + return createOutputStream(out); + } + + @Override + public Class getCompressorType() { + return null; + } + + @Override + public Compressor createCompressor() { + return null; + } + + /** + * Decodes a complete Brotli stream into memory. Callers that handle untrusted data should use + * {@link #createInputStream(InputStream, int)} so the decoded output is bounded by the size they + * expect. + */ + @Override + public CompressionInputStream createInputStream(InputStream in) throws IOException { + return createInputStream(in, DEFAULT_MAX_OUTPUT_SIZE); + } + + /** + * Creates a decoder with an upper bound on the decompressed size. Parquet passes the size from + * the page header, so a crafted page cannot expand without limit during decoding. + * + * @throws IOException if the bound is negative or the decoded output exceeds it + */ + public CompressionInputStream createInputStream(InputStream in, int maxOutputSize) throws IOException { + ensureBrotliAvailable(); + try { + return new BrotliCompressionInputStream(in, maxOutputSize); + } catch (LinkageError e) { + throw missingBrotli(e); + } + } + + @Override + public CompressionInputStream createInputStream(InputStream in, Decompressor decompressor) throws IOException { + return createInputStream(in); + } + + @Override + public Class getDecompressorType() { + return null; + } + + @Override + public Decompressor createDecompressor() { + return null; + } + + @Override + public String getDefaultExtension() { + return ".br"; + } + + private static void ensureBrotliAvailable() throws IOException { + try { + Brotli4jLoader.ensureAvailability(); + } catch (LinkageError e) { + throw missingBrotli(e); + } + } + + private static IOException missingBrotli(LinkageError cause) { + return new IOException( + "Brotli codec requires the optional brotli4j dependency and its native library " + + "for this platform on the classpath", + cause); + } +} diff --git a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/codec/BrotliCompressionInputStream.java b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/codec/BrotliCompressionInputStream.java new file mode 100644 index 0000000000..9cfa50a01e --- /dev/null +++ b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/codec/BrotliCompressionInputStream.java @@ -0,0 +1,104 @@ +/* + * 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.parquet.hadoop.codec; + +import com.aayushatharva.brotli4j.decoder.Decoder; +import java.io.IOException; +import java.io.InputStream; +import org.apache.hadoop.io.compress.CompressionInputStream; + +class BrotliCompressionInputStream extends CompressionInputStream { + + private final byte[] decompressed; + private int position; + + BrotliCompressionInputStream(InputStream in, int maxOutputSize) throws IOException { + super(in); + if (maxOutputSize < 0) { + throw new IOException( + "Invalid Brotli maximum output size: " + maxOutputSize + ". Expected a non-negative size."); + } + byte[] compressed = in.readAllBytes(); + if (compressed.length == 0) { + this.decompressed = new byte[0]; + return; + } + // The native decoder requires a positive limit, including for an empty decoded stream. + int outputLimit = Math.max(maxOutputSize, 1); + try { + // brotli4j's streaming BrotliInputStream fails on valid streams whose output crosses the + // encoder window (hyperxpro/Brotli4j#267). Parquet feeds one page at a time, so use the + // bounded one-shot decoder, which handles those streams without allowing unbounded output. + byte[] decoded = Decoder.decompress(compressed, 0, compressed.length, outputLimit); + if (decoded.length > maxOutputSize) { + throw new IOException( + "Brotli decompressed size " + decoded.length + " exceeds maximum size " + maxOutputSize); + } + this.decompressed = decoded; + } catch (IOException | RuntimeException e) { + throw new IOException("Failed to decompress Brotli stream", e); + } + } + + @Override + public int read() { + if (position >= decompressed.length) { + return -1; + } + return decompressed[position++] & 0xFF; + } + + @Override + public int read(byte[] b, int off, int len) { + if (len == 0) { + return 0; + } + if (position >= decompressed.length) { + return -1; + } + int count = Math.min(len, decompressed.length - position); + System.arraycopy(decompressed, position, b, off, count); + position += count; + return count; + } + + @Override + public int available() { + return decompressed.length - position; + } + + @Override + public void resetState() throws IOException { + throw new IOException("BrotliCompressionInputStream does not support resetState"); + } + + @Override + public void seek(long pos) { + if (pos < 0 || pos > decompressed.length) { + throw new IllegalArgumentException("Invalid position: " + pos); + } + position = (int) pos; + } + + @Override + public long getPos() { + return position; + } +} diff --git a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/codec/BrotliCompressionOutputStream.java b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/codec/BrotliCompressionOutputStream.java new file mode 100644 index 0000000000..8783b77ff6 --- /dev/null +++ b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/codec/BrotliCompressionOutputStream.java @@ -0,0 +1,170 @@ +/* + * 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.parquet.hadoop.codec; + +import com.aayushatharva.brotli4j.encoder.BrotliOutputStream; +import com.aayushatharva.brotli4j.encoder.Encoder; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import org.apache.hadoop.io.compress.CompressionOutputStream; + +class BrotliCompressionOutputStream extends CompressionOutputStream { + + private final BrotliOutputStream encoder; + private boolean finished; + private boolean closed; + private IOException failure; + + BrotliCompressionOutputStream(OutputStream out, int quality) throws IOException { + super(out); + this.encoder = new BrotliOutputStream(new NonClosingOutputStream(out), Encoder.Parameters.create(quality)); + } + + @Override + public void write(int b) throws IOException { + ensureWritable(); + try { + encoder.write(b); + } catch (IOException e) { + failure = e; + throw e; + } + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + ensureWritable(); + try { + encoder.write(b, off, len); + } catch (IOException e) { + failure = e; + throw e; + } + } + + @Override + public void flush() throws IOException { + ensureNoFailure(); + try { + if (!finished) { + encoder.flush(); + } + out.flush(); + } catch (IOException e) { + failure = e; + throw e; + } + } + + @Override + public void finish() throws IOException { + ensureNoFailure(); + finishEncoder(); + } + + private void finishEncoder() throws IOException { + if (finished) { + return; + } + try { + encoder.close(); + finished = true; + } catch (IOException e) { + if (failure == null) { + failure = e; + } + throw e; + } + } + + @Override + public void resetState() throws IOException { + throw new IOException("BrotliCompressionOutputStream does not support resetState"); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + // Use a distinct exception so try-with-resources can suppress it onto the original failure. + IOException closeFailure = + failure == null ? null : new IOException("Previous Brotli stream operation failed", failure); + try { + // Release native resources even when a previous write or flush has failed. + finishEncoder(); + } catch (IOException e) { + if (closeFailure == null) { + closeFailure = e; + } else if (closeFailure != e && closeFailure.getCause() != e) { + closeFailure.addSuppressed(e); + } + } + try { + out.close(); + } catch (IOException e) { + if (closeFailure == null) { + closeFailure = e; + } else if (closeFailure != e && closeFailure.getCause() != e) { + closeFailure.addSuppressed(e); + } + } + closed = true; + if (closeFailure != null) { + throw closeFailure; + } + } + + private void ensureWritable() throws IOException { + ensureNoFailure(); + if (finished) { + throw new IOException("Stream is finished"); + } + } + + private void ensureNoFailure() throws IOException { + if (failure != null) { + throw new IOException("Previous Brotli stream operation failed", failure); + } + } + + /** + * BrotliOutputStream closes its target when it finalizes the Brotli stream. Hadoop finishes a + * codec stream without closing the caller's target, so swallow close here and only flush. The + * codec's own close() closes the target explicitly. + */ + private static final class NonClosingOutputStream extends FilterOutputStream { + + NonClosingOutputStream(OutputStream out) { + super(out); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + } + + @Override + public void close() throws IOException { + flush(); + } + } +} diff --git a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestDirectCodecFactory.java b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestDirectCodecFactory.java index 1d9171897f..6db09cde2b 100644 --- a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestDirectCodecFactory.java +++ b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestDirectCodecFactory.java @@ -17,7 +17,6 @@ */ package org.apache.parquet.hadoop; -import static org.apache.parquet.hadoop.metadata.CompressionCodecName.BROTLI; import static org.apache.parquet.hadoop.metadata.CompressionCodecName.GZIP; import static org.apache.parquet.hadoop.metadata.CompressionCodecName.LZ4; import static org.apache.parquet.hadoop.metadata.CompressionCodecName.LZ4_RAW; @@ -216,11 +215,6 @@ public void compressionCodecs() { Set codecsToSkip = new HashSet<>(); codecsToSkip.add(LZO); // not distributed because it is GPL codecsToSkip.add(LZ4); // not distributed in the default version of Hadoop - final String arch = System.getProperty("os.arch"); - if ("aarch64".equals(arch)) { - // PARQUET-1975 brotli-codec does not have natives for ARM64 architectures - codecsToSkip.add(BROTLI); - } for (final int size : sizes) { for (final boolean useOnHeapComp : comp) { diff --git a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/codec/TestBrotliCodec.java b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/codec/TestBrotliCodec.java new file mode 100644 index 0000000000..e7d2375c97 --- /dev/null +++ b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/codec/TestBrotliCodec.java @@ -0,0 +1,407 @@ +/* + * 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.parquet.hadoop.codec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.aayushatharva.brotli4j.encoder.BrotliOutputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.util.Random; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.io.compress.CompressionInputStream; +import org.apache.hadoop.io.compress.CompressionOutputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.hadoop.BadConfigurationException; +import org.apache.parquet.hadoop.CodecFactory; +import org.apache.parquet.hadoop.metadata.CompressionCodecName; +import org.junit.jupiter.api.Test; + +public class TestBrotliCodec { + + @Test + public void testRoundTrip() throws Exception { + byte[] input = new byte[64 * 1024]; + new Random(42).nextBytes(input); + + BrotliCodec codec = newCodec(5); + try (CompressionInputStream in = codec.createInputStream(compress(codec, input))) { + assertThat(in.readAllBytes()).isEqualTo(input); + } + } + + @Test + public void testLargeStreamCrossingEncoderWindow() throws Exception { + // brotli4j's streaming decoder fails once the decompressed output crosses the 4 MiB encoder + // window (hyperxpro/Brotli4j#267). Parquet pages can be larger than that, so keep a regression + // test for the one-shot decoder path. + byte[] input = largeCompressibleInput(); + BrotliCodec codec = newCodec(5); + + try (CompressionInputStream in = codec.createInputStream(compress(codec, input))) { + assertThat(in.readAllBytes()).isEqualTo(input); + } + } + + @Test + public void testLargeStreamThroughCodecFactory() throws Exception { + byte[] input = largeCompressibleInput(); + CodecFactory factory = new CodecFactory(new Configuration(false), 64 * 1024); + try { + BytesInput compressed = + factory.getCompressor(CompressionCodecName.BROTLI, 5).compress(BytesInput.from(input)); + BytesInput decompressed = + factory.getDecompressor(CompressionCodecName.BROTLI).decompress(compressed, input.length); + assertThat(decompressed.toByteArray()).isEqualTo(input); + } finally { + factory.release(); + } + } + + @Test + public void testCloseClosesTarget() throws Exception { + TrackingOutputStream target = new TrackingOutputStream(); + BrotliCodec codec = newCodec(5); + try (CompressionOutputStream out = codec.createOutputStream(target)) { + out.write(new byte[] {1, 2, 3}); + } + assertThat(target.closed).isTrue(); + } + + @Test + public void testFlushThenFinishRoundTrips() throws Exception { + byte[] input = new byte[1024 * 1024]; + new Random(11).nextBytes(input); + BrotliCodec codec = newCodec(5); + TrackingOutputStream target = new TrackingOutputStream(); + + try (CompressionOutputStream out = codec.createOutputStream(target)) { + out.write(input, 0, input.length / 2); + out.flush(); + assertThat(target.flushes).isGreaterThan(0); + assertThat(target.size()).isGreaterThan(0); + + out.write(input, input.length / 2, input.length / 2); + out.finish(); + } + + try (CompressionInputStream in = codec.createInputStream(new ByteArrayInputStream(target.toByteArray()))) { + assertThat(in.readAllBytes()).isEqualTo(input); + } + } + + @Test + public void testBoundedDecompressionRejectsBomb() throws Exception { + byte[] zeros = new byte[16 * 1024 * 1024]; + byte[] compressed = compressBytes(newCodec(5), zeros); + + assertThatThrownBy(() -> newCodec(5).createInputStream(new ByteArrayInputStream(compressed), 1024)) + .isInstanceOf(IOException.class) + .hasMessage("Failed to decompress Brotli stream") + .hasStackTraceContaining("exceeds maximum size"); + + CodecFactory factory = new CodecFactory(new Configuration(false), 64 * 1024); + try { + assertThatThrownBy(() -> factory.getDecompressor(CompressionCodecName.BROTLI) + .decompress(BytesInput.from(compressed), 1024)) + .isInstanceOf(IOException.class) + .hasMessage("Failed to decompress Brotli stream") + .hasStackTraceContaining("exceeds maximum size"); + } finally { + factory.release(); + } + } + + @Test + public void testInvalidQualityOnlyRejectsCompression() throws Exception { + byte[] input = new byte[1024]; + new Random(13).nextBytes(input); + byte[] compressed = compressBytes(newCodec(5), input); + + BrotliCodec codec = newCodec(99); + try (CompressionInputStream in = codec.createInputStream(new ByteArrayInputStream(compressed), input.length)) { + assertThat(in.readAllBytes()).isEqualTo(input); + } + + assertThatThrownBy(() -> codec.createOutputStream(new ByteArrayOutputStream())) + .isInstanceOf(BadConfigurationException.class) + .hasMessage("Invalid Brotli compression quality: 99. Expected 0..11."); + } + + @Test + public void testEmptyAndZeroSizeStreams() throws Exception { + byte[] compressed = compressBytes(newCodec(5), new byte[0]); + try (CompressionInputStream in = newCodec(5).createInputStream(new ByteArrayInputStream(compressed), 0)) { + assertThat(in.readAllBytes()).isEmpty(); + } + } + + @Test + public void testZeroSizeRejectsOneByteOutput() throws Exception { + byte[] compressed = compressBytes(newCodec(5), new byte[] {1}); + + assertThatThrownBy(() -> newCodec(5).createInputStream(new ByteArrayInputStream(compressed), 0)) + .isInstanceOf(IOException.class) + .hasMessage("Failed to decompress Brotli stream") + .hasRootCauseMessage("Brotli decompressed size 1 exceeds maximum size 0"); + + CodecFactory factory = new CodecFactory(new Configuration(false), 64 * 1024); + try { + assertThatThrownBy(() -> factory.getDecompressor(CompressionCodecName.BROTLI) + .decompress(BytesInput.from(compressed), 0)) + .isInstanceOf(IOException.class) + .hasMessage("Failed to decompress Brotli stream") + .hasRootCauseMessage("Brotli decompressed size 1 exceeds maximum size 0"); + } finally { + factory.release(); + } + } + + @Test + public void testNegativeSizeIsRejectedForEmptyAndNonEmptyInput() throws Exception { + byte[][] inputs = {new byte[0], compressBytes(newCodec(5), new byte[] {1})}; + for (byte[] input : inputs) { + assertThatThrownBy(() -> newCodec(5).createInputStream(new ByteArrayInputStream(input), -1)) + .isInstanceOf(IOException.class) + .hasMessage("Invalid Brotli maximum output size: -1. Expected a non-negative size."); + } + } + + @Test + public void testCloseAfterFlushFailureClosesEncoderAndTarget() throws Exception { + FailingOutputStream target = new FailingOutputStream(); + CompressionOutputStream out = newCodec(5).createOutputStream(target); + BrotliOutputStream encoder = encoderOf(out); + out.write(new byte[] {1, 2, 3}); + target.failOnFlush = true; + + assertThatThrownBy(out::flush).isInstanceOf(IOException.class).hasMessage("flush failed"); + assertThatThrownBy(out::close).isInstanceOf(IOException.class).hasMessageContaining("failed"); + assertThatThrownBy(encoder::flush).isInstanceOf(IOException.class).hasMessage("write after close"); + out.close(); + assertThat(target.closes).isEqualTo(1); + } + + @Test + public void testCloseAfterWriteFailureClosesEncoderAndTarget() throws Exception { + FailingOutputStream target = new FailingOutputStream(); + CompressionOutputStream out = newCodec(1).createOutputStream(target); + BrotliOutputStream encoder = encoderOf(out); + byte[] input = new byte[1024 * 1024]; + new Random(17).nextBytes(input); + target.failOnWrite = true; + + assertThatThrownBy(() -> out.write(input)) + .isInstanceOf(IOException.class) + .hasMessage("write failed"); + assertThatThrownBy(out::close).isInstanceOf(IOException.class).hasMessageContaining("failed"); + assertThatThrownBy(encoder::flush).isInstanceOf(IOException.class).hasMessage("write after close"); + out.close(); + assertThat(target.closes).isEqualTo(1); + } + + @Test + public void testClosePreservesFailureWhenCleanupAlsoFails() throws Exception { + FailingOutputStream target = new FailingOutputStream(); + CompressionOutputStream out = newCodec(5).createOutputStream(target); + out.write(new byte[] {1, 2, 3}); + target.failOnFlush = true; + target.failOnClose = true; + + assertThatThrownBy(out::flush).isInstanceOf(IOException.class).hasMessage("flush failed"); + assertThatThrownBy(out::close) + .isInstanceOf(IOException.class) + .hasMessage("Previous Brotli stream operation failed") + .hasCause(target.flushFailure) + .hasSuppressedException(target.closeFailure); + out.close(); + assertThat(target.closes).isEqualTo(1); + } + + @Test + public void testTryWithResourcesPreservesWriteFailure() throws Exception { + FailingOutputStream target = new FailingOutputStream(); + byte[] input = new byte[1024 * 1024]; + new Random(19).nextBytes(input); + target.failOnWrite = true; + + assertThatThrownBy(() -> { + try (CompressionOutputStream out = newCodec(1).createOutputStream(target)) { + out.write(input); + } + }) + .isInstanceOf(IOException.class) + .hasMessage("write failed") + .isSameAs(target.writeFailure); + assertThat(target.writeFailure.getSuppressed()).hasSize(1); + assertThat(target.writeFailure.getSuppressed()[0]) + .isInstanceOf(IOException.class) + .hasMessage("Previous Brotli stream operation failed") + .hasCause(target.writeFailure); + assertThat(target.closes).isEqualTo(1); + } + + @Test + public void testCloseAfterFinishFailureIsIdempotent() throws Exception { + FailingOutputStream target = new FailingOutputStream(); + BrotliCodec codec = newCodec(5); + CompressionOutputStream out = codec.createOutputStream(target); + out.write(new byte[100 * 1024]); + target.failOnFlush = true; + + assertThatThrownBy(out::finish).isInstanceOf(IOException.class).hasMessage("flush failed"); + assertThatThrownBy(out::close).isInstanceOf(IOException.class).hasMessageContaining("failed"); + out.close(); + assertThat(target.closes).isEqualTo(1); + } + + @Test + public void testFinishFailureIsReportedOnRetry() throws Exception { + FailingOutputStream target = new FailingOutputStream(); + BrotliCodec codec = newCodec(5); + CompressionOutputStream out = codec.createOutputStream(target); + out.write(new byte[100 * 1024]); + target.failOnFlush = true; + + assertThatThrownBy(out::finish).isInstanceOf(IOException.class).hasMessage("flush failed"); + assertThatThrownBy(out::finish) + .isInstanceOf(IOException.class) + .hasMessage("Previous Brotli stream operation failed") + .hasCause(target.flushFailure); + assertThatThrownBy(out::close).isInstanceOf(IOException.class).hasMessageContaining("failed"); + } + + @Test + public void testPositionAndSeek() throws Exception { + byte[] input = new byte[100]; + new Random(7).nextBytes(input); + BrotliCodec codec = newCodec(5); + + try (CompressionInputStream in = codec.createInputStream(compress(codec, input))) { + assertThat(in.read()).isEqualTo(input[0] & 0xFF); + assertThat(in.getPos()).isEqualTo(1); + + in.seek(0); + assertThat(in.getPos()).isZero(); + assertThat(in.readAllBytes()).isEqualTo(input); + assertThatThrownBy(in::resetState) + .isInstanceOf(IOException.class) + .hasMessage("BrotliCompressionInputStream does not support resetState"); + } + } + + private static BrotliCodec newCodec(int quality) { + Configuration conf = new Configuration(false); + conf.setInt(BrotliCodec.BROTLI_QUALITY, quality); + BrotliCodec codec = new BrotliCodec(); + codec.setConf(conf); + return codec; + } + + private static BrotliOutputStream encoderOf(CompressionOutputStream out) throws ReflectiveOperationException { + // Verify the native encoder's lifecycle without exposing it through the production codec API. + Field field = BrotliCompressionOutputStream.class.getDeclaredField("encoder"); + field.setAccessible(true); + return (BrotliOutputStream) field.get(out); + } + + private static ByteArrayInputStream compress(BrotliCodec codec, byte[] input) throws IOException { + return new ByteArrayInputStream(compressBytes(codec, input)); + } + + private static byte[] compressBytes(BrotliCodec codec, byte[] input) throws IOException { + ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + try (CompressionOutputStream out = codec.createOutputStream(compressed)) { + out.write(input); + out.finish(); + } + return compressed.toByteArray(); + } + + private static byte[] largeCompressibleInput() { + StringBuilder builder = new StringBuilder(); + String line = "at com.example.SomeClass.someMethod(SomeClass.java:123)\n"; + while (builder.length() < 5 * 1024 * 1024) { + builder.append(line); + } + return builder.toString().getBytes(StandardCharsets.UTF_8); + } + + private static final class TrackingOutputStream extends ByteArrayOutputStream { + private boolean closed; + private int flushes; + + @Override + public void close() throws IOException { + closed = true; + super.close(); + } + + @Override + public void flush() throws IOException { + flushes++; + super.flush(); + } + } + + private static final class FailingOutputStream extends OutputStream { + private final IOException writeFailure = new IOException("write failed"); + private final IOException flushFailure = new IOException("flush failed"); + private final IOException closeFailure = new IOException("close failed"); + private boolean failOnWrite; + private boolean failOnFlush; + private boolean failOnClose; + private int closes; + + @Override + public void write(int b) throws IOException { + if (failOnWrite) { + throw writeFailure; + } + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + if (failOnWrite) { + throw writeFailure; + } + } + + @Override + public void flush() throws IOException { + if (failOnFlush) { + throw flushFailure; + } + } + + @Override + public void close() throws IOException { + closes++; + if (failOnClose) { + throw closeFailure; + } + } + } +} diff --git a/pom.xml b/pom.xml index 8564229b44..5064390172 100644 --- a/pom.xml +++ b/pom.xml @@ -95,7 +95,7 @@ 1.7.33 1.12.2 33.7.2-jre - 0.1.1 + 1.24.0 3.27.7 6.1.3 3.27.7