diff --git a/parquet-benchmarks/pom.xml b/parquet-benchmarks/pom.xml index 44c81fdd5a..9eb9ed9508 100644 --- a/parquet-benchmarks/pom.xml +++ b/parquet-benchmarks/pom.xml @@ -95,6 +95,7 @@ org.apache.maven.plugins maven-compiler-plugin + full org.openjdk.jmh diff --git a/parquet-benchmarks/src/main/java/org/apache/parquet/benchmarks/AlpDecodingBenchmark.java b/parquet-benchmarks/src/main/java/org/apache/parquet/benchmarks/AlpDecodingBenchmark.java new file mode 100644 index 0000000000..5173874b0f --- /dev/null +++ b/parquet-benchmarks/src/main/java/org/apache/parquet/benchmarks/AlpDecodingBenchmark.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.benchmarks; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.TimeUnit; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.HeapByteBufferAllocator; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.alp.AlpValuesReaderForDouble; +import org.apache.parquet.column.values.alp.AlpValuesReaderForFloat; +import org.apache.parquet.column.values.alp.AlpValuesWriter; +import org.apache.parquet.column.values.plain.PlainValuesReader; +import org.apache.parquet.column.values.plain.PlainValuesWriter; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OperationsPerInvocation; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; + +/** + * Decoding-level micro-benchmarks for ALP, with PLAIN on the same data as a baseline. The data sets + * are those of {@link AlpEncodingBenchmark}. + * + *

Each invocation decodes {@value #VALUE_COUNT} values; throughput is reported per-value via + * {@link OperationsPerInvocation}. + */ +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Fork(1) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@State(Scope.Thread) +public class AlpDecodingBenchmark { + + static final int VALUE_COUNT = 100_000; + private static final int INIT_SLAB_SIZE = 64 * 1024; + private static final int PAGE_SIZE = 4 * 1024 * 1024; + + @Param({"decimal", "random"}) + public String data; + + private byte[] alpDoublePage; + private byte[] plainDoublePage; + private byte[] alpFloatPage; + private byte[] plainFloatPage; + + @Setup(Level.Trial) + public void setup() throws IOException { + double[] doubles = AlpEncodingBenchmark.doubles(data, VALUE_COUNT); + float[] floats = AlpEncodingBenchmark.floats(data, VALUE_COUNT); + alpDoublePage = page( + new AlpValuesWriter.DoubleAlpValuesWriter(INIT_SLAB_SIZE, PAGE_SIZE, new HeapByteBufferAllocator()), + doubles); + plainDoublePage = + page(new PlainValuesWriter(INIT_SLAB_SIZE, PAGE_SIZE, new HeapByteBufferAllocator()), doubles); + alpFloatPage = page( + new AlpValuesWriter.FloatAlpValuesWriter(INIT_SLAB_SIZE, PAGE_SIZE, new HeapByteBufferAllocator()), + floats); + plainFloatPage = page(new PlainValuesWriter(INIT_SLAB_SIZE, PAGE_SIZE, new HeapByteBufferAllocator()), floats); + } + + private static byte[] page(ValuesWriter w, double[] values) throws IOException { + for (double v : values) { + w.writeDouble(v); + } + byte[] bytes = w.getBytes().toByteArray(); + w.close(); + return bytes; + } + + private static byte[] page(ValuesWriter w, float[] values) throws IOException { + for (float v : values) { + w.writeFloat(v); + } + byte[] bytes = w.getBytes().toByteArray(); + w.close(); + return bytes; + } + + private static void init(ValuesReader r, byte[] page) throws IOException { + r.initFromPage(VALUE_COUNT, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + } + + @Benchmark + @OperationsPerInvocation(VALUE_COUNT) + public void decodeDouble(Blackhole bh) throws IOException { + AlpValuesReaderForDouble r = new AlpValuesReaderForDouble(); + init(r, alpDoublePage); + for (int i = 0; i < VALUE_COUNT; i++) { + bh.consume(r.readDouble()); + } + } + + @Benchmark + @OperationsPerInvocation(VALUE_COUNT) + public void decodeDoublePlain(Blackhole bh) throws IOException { + PlainValuesReader.DoublePlainValuesReader r = new PlainValuesReader.DoublePlainValuesReader(); + init(r, plainDoublePage); + for (int i = 0; i < VALUE_COUNT; i++) { + bh.consume(r.readDouble()); + } + } + + @Benchmark + @OperationsPerInvocation(VALUE_COUNT) + public void decodeFloat(Blackhole bh) throws IOException { + AlpValuesReaderForFloat r = new AlpValuesReaderForFloat(); + init(r, alpFloatPage); + for (int i = 0; i < VALUE_COUNT; i++) { + bh.consume(r.readFloat()); + } + } + + @Benchmark + @OperationsPerInvocation(VALUE_COUNT) + public void decodeFloatPlain(Blackhole bh) throws IOException { + PlainValuesReader.FloatPlainValuesReader r = new PlainValuesReader.FloatPlainValuesReader(); + init(r, plainFloatPage); + for (int i = 0; i < VALUE_COUNT; i++) { + bh.consume(r.readFloat()); + } + } +} diff --git a/parquet-benchmarks/src/main/java/org/apache/parquet/benchmarks/AlpEncodingBenchmark.java b/parquet-benchmarks/src/main/java/org/apache/parquet/benchmarks/AlpEncodingBenchmark.java new file mode 100644 index 0000000000..09a6fc5b37 --- /dev/null +++ b/parquet-benchmarks/src/main/java/org/apache/parquet/benchmarks/AlpEncodingBenchmark.java @@ -0,0 +1,138 @@ +/* + * 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.benchmarks; + +import java.io.IOException; +import java.util.Random; +import java.util.concurrent.TimeUnit; +import org.apache.parquet.bytes.HeapByteBufferAllocator; +import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.alp.AlpValuesWriter; +import org.apache.parquet.column.values.plain.PlainValuesWriter; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OperationsPerInvocation; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Encoding-level micro-benchmarks for ALP, with PLAIN on the same data as a baseline. + * + *

{@code decimal} data is what ALP is designed for; {@code random} data is almost all + * exceptions, its worst case. + * + *

Each invocation encodes {@value #VALUE_COUNT} values; throughput is reported per-value via + * {@link OperationsPerInvocation}. + */ +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Fork(1) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@State(Scope.Thread) +public class AlpEncodingBenchmark { + + // Enough values that each run includes both the full parameter search, used until enough + // vectors are sampled, and the preset search that follows, as a column chunk would. + static final int VALUE_COUNT = 1_000_000; + private static final int INIT_SLAB_SIZE = 64 * 1024; + private static final int PAGE_SIZE = 16 * 1024 * 1024; + + @Param({"decimal", "random"}) + public String data; + + private double[] doubleData; + private float[] floatData; + + @Setup(Level.Trial) + public void setup() { + doubleData = doubles(data, VALUE_COUNT); + floatData = floats(data, VALUE_COUNT); + } + + static double[] doubles(String data, int count) { + Random random = new Random(42); + double[] values = new double[count]; + for (int i = 0; i < count; i++) { + values[i] = "decimal".equals(data) ? Math.round(random.nextDouble() * 1e6) / 100.0 : random.nextDouble(); + } + return values; + } + + static float[] floats(String data, int count) { + Random random = new Random(42); + float[] values = new float[count]; + for (int i = 0; i < count; i++) { + values[i] = "decimal".equals(data) ? Math.round(random.nextFloat() * 1e6f) / 100.0f : random.nextFloat(); + } + return values; + } + + @Benchmark + @OperationsPerInvocation(VALUE_COUNT) + public byte[] encodeDouble() throws IOException { + return encode( + new AlpValuesWriter.DoubleAlpValuesWriter(INIT_SLAB_SIZE, PAGE_SIZE, new HeapByteBufferAllocator())); + } + + @Benchmark + @OperationsPerInvocation(VALUE_COUNT) + public byte[] encodeDoublePlain() throws IOException { + return encode(new PlainValuesWriter(INIT_SLAB_SIZE, PAGE_SIZE, new HeapByteBufferAllocator())); + } + + @Benchmark + @OperationsPerInvocation(VALUE_COUNT) + public byte[] encodeFloat() throws IOException { + return encodeFloats( + new AlpValuesWriter.FloatAlpValuesWriter(INIT_SLAB_SIZE, PAGE_SIZE, new HeapByteBufferAllocator())); + } + + @Benchmark + @OperationsPerInvocation(VALUE_COUNT) + public byte[] encodeFloatPlain() throws IOException { + return encodeFloats(new PlainValuesWriter(INIT_SLAB_SIZE, PAGE_SIZE, new HeapByteBufferAllocator())); + } + + private byte[] encode(ValuesWriter w) throws IOException { + for (double v : doubleData) { + w.writeDouble(v); + } + byte[] bytes = w.getBytes().toByteArray(); + w.close(); + return bytes; + } + + private byte[] encodeFloats(ValuesWriter w) throws IOException { + for (float v : floatData) { + w.writeFloat(v); + } + byte[] bytes = w.getBytes().toByteArray(); + w.close(); + return bytes; + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/ColumnProperty.java b/parquet-column/src/main/java/org/apache/parquet/column/ColumnProperty.java index e89cbc9bdb..c2491ba2d5 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/ColumnProperty.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/ColumnProperty.java @@ -19,9 +19,11 @@ package org.apache.parquet.column; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Objects; +import java.util.Set; import org.apache.parquet.hadoop.metadata.ColumnPath; /** @@ -62,13 +64,19 @@ private MultipleColumnProperty(T defaultValue, Map values) { @Override public T getValue(ColumnPath columnPath) { - T value = values.get(columnPath); - if (value != null) { - return value; + // containsKey rather than a null check, so a column can be given an explicit null value + // that overrides the default rather than falling back to it. + if (values.containsKey(columnPath)) { + return values.get(columnPath); } return getDefaultValue(); } + @Override + Set getColumnPaths() { + return values.keySet(); + } + @Override public String toString() { return Objects.toString(getDefaultValue()) + ' ' + values.toString(); @@ -125,6 +133,14 @@ public static Builder builder(ColumnProperty toCopy) { public abstract T getValue(ColumnPath columnPath); + /** + * @return the column paths that have an explicit per-column value set (empty when the property + * only carries a default value) + */ + Set getColumnPaths() { + return Collections.emptySet(); + } + public T getValue(String columnPath) { return getValue(ColumnPath.fromDotString(columnPath)); } diff --git a/parquet-column/src/main/java/org/apache/parquet/column/Encoding.java b/parquet-column/src/main/java/org/apache/parquet/column/Encoding.java index 874c99fded..587fcdd300 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/Encoding.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/Encoding.java @@ -29,6 +29,8 @@ import org.apache.parquet.bytes.BytesUtils; import org.apache.parquet.column.page.DictionaryPage; import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.column.values.alp.AlpValuesReaderForDouble; +import org.apache.parquet.column.values.alp.AlpValuesReaderForFloat; import org.apache.parquet.column.values.bitpacking.ByteBitPackingValuesReader; import org.apache.parquet.column.values.bytestreamsplit.ByteStreamSplitValuesReaderForDouble; import org.apache.parquet.column.values.bytestreamsplit.ByteStreamSplitValuesReaderForFLBA; @@ -147,6 +149,27 @@ public ValuesReader getValuesReader(ColumnDescriptor descriptor, ValuesType valu } }, + /** + * ALP (Adaptive Lossless floating-Point) encoding for FLOAT and DOUBLE types. + * + * @see ALP specification + * @see ALP blog post + */ + ALP { + @Override + public ValuesReader getValuesReader(ColumnDescriptor descriptor, ValuesType valuesType) { + switch (descriptor.getType()) { + case FLOAT: + return new AlpValuesReaderForFloat(); + case DOUBLE: + return new AlpValuesReaderForDouble(); + default: + throw new ParquetDecodingException( + "ALP encoding is only supported for FLOAT and DOUBLE, not " + descriptor.getType()); + } + } + }, + /** * @deprecated This is no longer used, and has been replaced by {@link #RLE} * which is combination of bit packing and rle diff --git a/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java b/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java index 8fe45e01ef..66c0607f66 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/ParquetProperties.java @@ -25,6 +25,8 @@ import java.util.Objects; import java.util.OptionalDouble; import java.util.OptionalLong; +import java.util.Set; +import java.util.StringJoiner; import org.apache.parquet.Preconditions; import org.apache.parquet.bytes.ByteBufferAllocator; import org.apache.parquet.bytes.CapacityByteArrayOutputStream; @@ -33,14 +35,17 @@ import org.apache.parquet.column.impl.ColumnWriteStoreV2; import org.apache.parquet.column.page.PageWriteStore; import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.alp.AlpConfig; import org.apache.parquet.column.values.bitpacking.DevNullValuesWriter; import org.apache.parquet.column.values.bloomfilter.BloomFilterWriteStore; import org.apache.parquet.column.values.factory.DefaultValuesWriterFactory; import org.apache.parquet.column.values.factory.ValuesWriterFactory; import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridEncoder; import org.apache.parquet.column.values.rle.RunLengthBitPackingHybridValuesWriter; +import org.apache.parquet.hadoop.metadata.ColumnPath; import org.apache.parquet.hadoop.metadata.CompressionCodecName; import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName; /** * This class represents all the configurable Parquet properties. @@ -133,6 +138,7 @@ public static WriterVersion fromString(String name) { private final int pageRowCountLimit; private final boolean pageWriteChecksumEnabled; private final ColumnProperty byteStreamSplitEnabled; + private final ColumnProperty alp; private final Map extraMetaData; private final ColumnProperty statistics; private final ColumnProperty sizeStatistics; @@ -167,6 +173,7 @@ private ParquetProperties(Builder builder) { this.pageRowCountLimit = builder.pageRowCountLimit; this.pageWriteChecksumEnabled = builder.pageWriteChecksumEnabled; this.byteStreamSplitEnabled = builder.byteStreamSplitEnabled.build(); + this.alp = builder.alp.build(); this.extraMetaData = builder.extraMetaData; this.statistics = builder.statistics.build(); this.sizeStatistics = builder.sizeStatistics.build(); @@ -264,11 +271,89 @@ public boolean isByteStreamSplitEnabled(ColumnDescriptor column) { } } + /** + * Get the ALP configuration for the given column. ALP encoding is only supported for FLOAT and + * DOUBLE types, so any other type is never ALP encoded. + * + * @param column the column descriptor + * @return the ALP configuration for this column, or null if the column is not ALP encoded + */ + public AlpConfig getAlpConfig(ColumnDescriptor column) { + switch (column.getPrimitiveType().getPrimitiveTypeName()) { + case FLOAT: + case DOUBLE: + return alp.getValue(column); + default: + return null; + } + } + + /** + * Check if ALP encoding is enabled for the given column. + * + * @param column the column descriptor + * @return true if ALP encoding is enabled for this column + */ + public boolean isAlpEnabled(ColumnDescriptor column) { + return getAlpConfig(column) != null; + } + public ByteBufferAllocator getAllocator() { return allocator; } + /** + * Checks the ALP configuration against the schema it will be used with. ALP only applies to FLOAT + * and DOUBLE columns, and a column cannot be both ALP and BYTE_STREAM_SPLIT encoded, so rather + * than silently picking one, both cases are rejected before anything is written. + * + * @param schema the schema being written + * @throws IllegalArgumentException if a column is misconfigured + */ + private void validateAlp(MessageType schema) { + Set alpColumns = alp.getColumnPaths(); + if (alp.getDefaultValue() == null && alpColumns.isEmpty()) { + return; + } + + for (ColumnDescriptor column : schema.getColumns()) { + ColumnPath path = ColumnPath.get(column.getPath()); + PrimitiveTypeName type = column.getPrimitiveType().getPrimitiveTypeName(); + + boolean enabledExplicitly = alpColumns.contains(path) && alp.getValue(path) != null; + if (enabledExplicitly && type != PrimitiveTypeName.FLOAT && type != PrimitiveTypeName.DOUBLE) { + throw new IllegalArgumentException("ALP encoding is enabled for column " + path.toDotString() + + " of type " + type + ", but ALP only supports FLOAT and DOUBLE columns"); + } + + if (isAlpEnabled(column) && isByteStreamSplitEnabled(column)) { + throw new IllegalArgumentException( + "Column " + path.toDotString() + + " has both ALP and BYTE_STREAM_SPLIT encoding enabled, but a column can only use one of them"); + } + } + } + + /** Renders the ALP property, showing "off" rather than "null" wherever ALP is not configured. */ + private String alpToString() { + AlpConfig defaultConfig = alp.getDefaultValue(); + String rendered = defaultConfig == null ? "off" : defaultConfig.toString(); + + Set columns = alp.getColumnPaths(); + if (columns.isEmpty()) { + return rendered; + } + + StringJoiner perColumn = new StringJoiner(", ", " {", "}"); + for (ColumnPath column : columns) { + AlpConfig columnConfig = alp.getValue(column); + perColumn.add(column.toDotString() + "=" + (columnConfig == null ? "off" : columnConfig)); + } + return rendered + perColumn; + } + public ColumnWriteStore newColumnWriteStore(MessageType schema, PageWriteStore pageStore) { + validateAlp(schema); switch (writerVersion) { case PARQUET_1_0: return new ColumnWriteStoreV1(schema, pageStore, this); @@ -281,6 +366,7 @@ public ColumnWriteStore newColumnWriteStore(MessageType schema, PageWriteStore p public ColumnWriteStore newColumnWriteStore( MessageType schema, PageWriteStore pageStore, BloomFilterWriteStore bloomFilterWriteStore) { + validateAlp(schema); switch (writerVersion) { case PARQUET_1_0: return new ColumnWriteStoreV1(schema, pageStore, bloomFilterWriteStore, this); @@ -415,7 +501,8 @@ public String toString() { + "Page row count limit to " + getPageRowCountLimit() + '\n' + "Writing page checksums is: " + (getPageWriteChecksumEnabled() ? "on" : "off") + '\n' + "Statistics enabled: " + statisticsEnabled + '\n' - + "Size statistics enabled: " + sizeStatisticsEnabled; + + "Size statistics enabled: " + sizeStatisticsEnabled + '\n' + + "ALP: " + alpToString(); String perColumn = ""; if (!columnCodecs.toString().equals(Objects.toString(columnCodecs.getDefaultValue()))) { perColumn = "Per-column codecs: " + columnCodecs; @@ -455,6 +542,7 @@ public static class Builder { private int pageRowCountLimit = DEFAULT_PAGE_ROW_COUNT_LIMIT; private boolean pageWriteChecksumEnabled = DEFAULT_PAGE_WRITE_CHECKSUM_ENABLED; private final ColumnProperty.Builder byteStreamSplitEnabled; + private final ColumnProperty.Builder alp; private Map extraMetaData = new HashMap<>(); private final ColumnProperty.Builder statistics; private final ColumnProperty.Builder sizeStatistics; @@ -468,6 +556,7 @@ private Builder() { DEFAULT_IS_BYTE_STREAM_SPLIT_ENABLED ? ByteStreamSplitMode.FLOATING_POINT : ByteStreamSplitMode.NONE); + alp = ColumnProperty.builder().withDefaultValue(null); bloomFilterEnabled = ColumnProperty.builder().withDefaultValue(DEFAULT_BLOOM_FILTER_ENABLED); bloomFilterNDVs = ColumnProperty.builder().withDefaultValue(null); bloomFilterFPPs = ColumnProperty.builder().withDefaultValue(DEFAULT_BLOOM_FILTER_FPP); @@ -504,6 +593,7 @@ private Builder(ParquetProperties toCopy) { this.numBloomFilterCandidates = ColumnProperty.builder(toCopy.numBloomFilterCandidates); this.maxBloomFilterBytes = toCopy.maxBloomFilterBytes; this.byteStreamSplitEnabled = ColumnProperty.builder(toCopy.byteStreamSplitEnabled); + this.alp = ColumnProperty.builder(toCopy.alp); this.extraMetaData = toCopy.extraMetaData; this.statistics = ColumnProperty.builder(toCopy.statistics); this.statisticsEnabled = toCopy.statisticsEnabled; @@ -585,6 +675,71 @@ public Builder withExtendedByteStreamSplitEncoding(boolean enable) { return this; } + /** + * Enable ALP encoding for FLOAT and DOUBLE columns, using the default configuration. + * + * @return this builder for method chaining. + */ + public Builder withAlp() { + return withAlp(AlpConfig.DEFAULT); + } + + /** + * Enable ALP encoding for FLOAT and DOUBLE columns with the given configuration. + * + * @param config the ALP configuration + * @return this builder for method chaining. + */ + public Builder withAlp(AlpConfig config) { + this.alp.withDefaultValue(Objects.requireNonNull(config, "ALP config cannot be null")); + return this; + } + + /** + * Enable ALP encoding for the specified column, using the default configuration. + * + * @param columnPath the path of the column (dot-string) + * @return this builder for method chaining. + */ + public Builder withAlp(String columnPath) { + return withAlp(columnPath, AlpConfig.DEFAULT); + } + + /** + * Enable ALP encoding for the specified column with the given configuration. + * + * @param columnPath the path of the column (dot-string) + * @param config the ALP configuration + * @return this builder for method chaining. + */ + public Builder withAlp(String columnPath, AlpConfig config) { + this.alp.withValue(columnPath, Objects.requireNonNull(config, "ALP config cannot be null")); + return this; + } + + /** + * Disable ALP encoding for the specified column, overriding any default set by + * {@link #withAlp()}. + * + * @param columnPath the path of the column (dot-string) + * @return this builder for method chaining. + */ + public Builder withoutAlp(String columnPath) { + this.alp.withValue(columnPath, null); + return this; + } + + /** + * Disable ALP encoding by default. Columns enabled individually by + * {@link #withAlp(String)} keep it. + * + * @return this builder for method chaining. + */ + public Builder withoutAlp() { + this.alp.withDefaultValue(null); + return this; + } + /** * Set the Parquet format dictionary page size. * diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpCodec.java b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpCodec.java new file mode 100644 index 0000000000..931bbb2731 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpCodec.java @@ -0,0 +1,313 @@ +/* + * 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.column.values.alp; + +import static org.apache.parquet.column.values.alp.AlpConstants.DOUBLE_MAX_EXPONENT; +import static org.apache.parquet.column.values.alp.AlpConstants.FLOAT_MAX_EXPONENT; + +import org.apache.parquet.bytes.BytesUtils; + +/** + * Encoding multiplies by 10^e and then by the reciprocal of 10^f; decoding multiplies by 10^f and then + * by the reciprocal of 10^e. Reordering or dividing rounds differently, which changes which values + * become exceptions, so the bytes would no longer match other implementations. + */ +final class AlpCodec { + + static final float MAGIC_FLOAT = 12_582_912.0f; // 2^22 + 2^23 + static final double MAGIC_DOUBLE = 6_755_399_441_055_744.0; // 2^51 + 2^52 + + static final float[] FLOAT_POW10 = {1e0f, 1e1f, 1e2f, 1e3f, 1e4f, 1e5f, 1e6f, 1e7f, 1e8f, 1e9f, 1e10f}; + + static final double[] DOUBLE_POW10 = { + 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14, 1e15, 1e16, 1e17, 1e18 + }; + + static final float[] FLOAT_POW10_NEGATIVE = { + 1e0f, 1e-1f, 1e-2f, 1e-3f, 1e-4f, 1e-5f, 1e-6f, 1e-7f, 1e-8f, 1e-9f, 1e-10f + }; + + static final double[] DOUBLE_POW10_NEGATIVE = { + 1e0, 1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7, 1e-8, 1e-9, 1e-10, 1e-11, 1e-12, 1e-13, 1e-14, 1e-15, 1e-16, + 1e-17, 1e-18 + }; + + static final int FLOAT_NEGATIVE_ZERO_BITS = 0x80000000; + static final long DOUBLE_NEGATIVE_ZERO_BITS = 0x8000000000000000L; + + // Beyond these a value could still round-trip through the cast to long or int, so the limits, which + // match other implementations, are what make it an exception. + private static final double DOUBLE_ENCODING_UPPER_LIMIT = 9223372036854774784.0; + private static final double DOUBLE_ENCODING_LOWER_LIMIT = -9223372036854774784.0; + private static final float FLOAT_ENCODING_UPPER_LIMIT = 2147483520.0f; + private static final float FLOAT_ENCODING_LOWER_LIMIT = -2147483520.0f; + + private AlpCodec() { + // Utility class + } + + /** NaN, Inf, and -0.0 can never be encoded regardless of exponent/factor. */ + static boolean isIntrinsicFloatException(float value) { + if (Float.isNaN(value)) { + return true; + } + if (Float.isInfinite(value)) { + return true; + } + return Float.floatToRawIntBits(value) == FLOAT_NEGATIVE_ZERO_BITS; + } + + /** Must use the same expression, in the same order, as {@link #encodeFloat}. */ + static void tryEncodeFloat(float value, int exponent, int factor, EncodeResult out) { + if (isIntrinsicFloatException(value)) { + out.isException = true; + return; + } + // Check before rounding: overflow or non-finite after scaling + float scaled = value * FLOAT_POW10[exponent] * FLOAT_POW10_NEGATIVE[factor]; + if (!Float.isFinite(scaled) || scaled > FLOAT_ENCODING_UPPER_LIMIT || scaled < FLOAT_ENCODING_LOWER_LIMIT) { + out.isException = true; + return; + } + int encoded = fastRoundFloat(scaled); + float decoded = decodeFloat(encoded, exponent, factor); + if (Float.floatToRawIntBits(value) != Float.floatToRawIntBits(decoded)) { + out.isException = true; + return; + } + out.encoded = encoded; + out.isException = false; + } + + /** Round float to nearest integer using magic-number trick with sign branching. */ + static int fastRoundFloat(float value) { + if (value >= 0) { + return (int) ((value + MAGIC_FLOAT) - MAGIC_FLOAT); + } else { + return (int) ((value - MAGIC_FLOAT) + MAGIC_FLOAT); + } + } + + static int encodeFloat(float value, int exponent, int factor) { + return fastRoundFloat(value * FLOAT_POW10[exponent] * FLOAT_POW10_NEGATIVE[factor]); + } + + static float decodeFloat(int encoded, int exponent, int factor) { + return encoded * FLOAT_POW10[factor] * FLOAT_POW10_NEGATIVE[exponent]; + } + + /** NaN, Inf, and -0.0 can never be encoded regardless of exponent/factor. */ + static boolean isIntrinsicDoubleException(double value) { + if (Double.isNaN(value)) { + return true; + } + if (Double.isInfinite(value)) { + return true; + } + return Double.doubleToRawLongBits(value) == DOUBLE_NEGATIVE_ZERO_BITS; + } + + /** Must use the same expression, in the same order, as {@link #encodeDouble}. */ + static void tryEncodeDouble(double value, int exponent, int factor, EncodeResult out) { + if (isIntrinsicDoubleException(value)) { + out.isException = true; + return; + } + // Check before rounding: overflow or non-finite after scaling + double scaled = value * DOUBLE_POW10[exponent] * DOUBLE_POW10_NEGATIVE[factor]; + if (!Double.isFinite(scaled) || scaled > DOUBLE_ENCODING_UPPER_LIMIT || scaled < DOUBLE_ENCODING_LOWER_LIMIT) { + out.isException = true; + return; + } + long encoded = fastRoundDouble(scaled); + double decoded = decodeDouble(encoded, exponent, factor); + if (Double.doubleToRawLongBits(value) != Double.doubleToRawLongBits(decoded)) { + out.isException = true; + return; + } + out.encoded = encoded; + out.isException = false; + } + + /** Round double to nearest integer using magic-number trick with sign branching. */ + static long fastRoundDouble(double value) { + if (value >= 0) { + return (long) ((value + MAGIC_DOUBLE) - MAGIC_DOUBLE); + } else { + return (long) ((value - MAGIC_DOUBLE) + MAGIC_DOUBLE); + } + } + + static long encodeDouble(double value, int exponent, int factor) { + return fastRoundDouble(value * DOUBLE_POW10[exponent] * DOUBLE_POW10_NEGATIVE[factor]); + } + + static double decodeDouble(long encoded, int exponent, int factor) { + return encoded * DOUBLE_POW10[factor] * DOUBLE_POW10_NEGATIVE[exponent]; + } + + /** + * Outcome of a single {@link #tryEncodeFloat} or {@link #tryEncodeDouble} call. Mutable and + * meant to be reused by the caller so the per-value hot path allocates nothing; an Optional or a + * freshly allocated result here would box on every value. Not thread safe, so each writer keeps + * its own. + */ + static final class EncodeResult { + /** The encoded value, meaningful only when {@link #isException} is false. */ + long encoded; + + boolean isException; + } + + static final class EncodingParams { + final int exponent; + final int factor; + final int numExceptions; + + EncodingParams(int exponent, int factor, int numExceptions) { + this.exponent = exponent; + this.factor = factor; + this.numExceptions = numExceptions; + } + } + + // Index positions within an (exponent, factor) pair array. + private static final int E = 0; + private static final int F = 1; + + // Every valid (exponent, factor) pair, e ascending then f ascending. The order decides the early + // exit and the fallback when every value is an exception. + static final int[][] ALL_VALID_FLOAT_PAIRS = buildAllPairs(FLOAT_MAX_EXPONENT); + static final int[][] ALL_VALID_DOUBLE_PAIRS = buildAllPairs(DOUBLE_MAX_EXPONENT); + + private static int[][] buildAllPairs(int maxExponent) { + int count = (maxExponent + 1) * (maxExponent + 2) / 2; + int[][] pairs = new int[count][]; + int idx = 0; + for (int e = 0; e <= maxExponent; e++) { + for (int f = 0; f <= e; f++) { + pairs[idx++] = new int[] {e, f}; + } + } + return pairs; + } + + /** + * Scores each (exponent, factor) pair by estimated compressed size and returns the best. + * + *

Estimated size (in bits) = {@code length * bitWidth + exceptions * (Float.SIZE + Short.SIZE)}, + * where bitWidth is the number of bits needed to represent the signed range (max - min) of + * non-exception encoded values after frame-of-reference subtraction, matching the writer's FOR + * packing. This produces better compression ratios than minimizing exception count alone. Ties in + * size are broken toward the higher exponent, then the higher factor. The first pair (in {@code + * pairs} order) that encodes every value into a single FOR value with zero exceptions wins + * outright. When no pair yields any non-exception values, {@code pairs[0]} is returned. + */ + static EncodingParams findBestFloatParams(float[] values, int offset, int length, int[][] pairs) { + int bestExponent = pairs[0][E]; + int bestFactor = pairs[0][F]; + int bestExceptions = length; + long bestEstimatedSize = Long.MAX_VALUE; + + // One reusable result for the whole search: every value below is encoded exactly once. + EncodeResult result = new EncodeResult(); + for (int[] pair : pairs) { + int e = pair[E]; + int f = pair[F]; + int exceptions = 0; + int minEncoded = Integer.MAX_VALUE; + int maxEncoded = Integer.MIN_VALUE; + for (int i = 0; i < length; i++) { + tryEncodeFloat(values[offset + i], e, f, result); + if (result.isException) { + exceptions++; + } else { + int encoded = (int) result.encoded; + if (encoded < minEncoded) minEncoded = encoded; + if (encoded > maxEncoded) maxEncoded = encoded; + } + } + int nonExceptions = length - exceptions; + if (nonExceptions == 0) continue; + // Signed subtraction gives the true FOR span; getWidthFromMaxLong yields the correct + // unsigned bit width, and an overflowing large range is penalized with 64 bits. This matches + // the writer's FOR packing. + long delta = (nonExceptions < 2) ? 0 : ((long) maxEncoded - (long) minEncoded); + int bitsPerValue = BytesUtils.getWidthFromMaxLong(delta); + long estimatedSize = (long) length * bitsPerValue + (long) exceptions * (Float.SIZE + Short.SIZE); + if (estimatedSize < bestEstimatedSize + || (estimatedSize == bestEstimatedSize + && (e > bestExponent || (e == bestExponent && f > bestFactor)))) { + bestEstimatedSize = estimatedSize; + bestExponent = e; + bestFactor = f; + bestExceptions = exceptions; + if (bestExceptions == 0 && bitsPerValue == 0) { + return new EncodingParams(bestExponent, bestFactor, 0); + } + } + } + return new EncodingParams(bestExponent, bestFactor, bestExceptions); + } + + /** Double counterpart to {@link #findBestFloatParams}; see it for the scoring and tie-break rules. */ + static EncodingParams findBestDoubleParams(double[] values, int offset, int length, int[][] pairs) { + int bestExponent = pairs[0][E]; + int bestFactor = pairs[0][F]; + int bestExceptions = length; + long bestEstimatedSize = Long.MAX_VALUE; + + // One reusable result for the whole search: every value below is encoded exactly once. + EncodeResult result = new EncodeResult(); + for (int[] pair : pairs) { + int e = pair[E]; + int f = pair[F]; + int exceptions = 0; + long minEncoded = Long.MAX_VALUE; + long maxEncoded = Long.MIN_VALUE; + for (int i = 0; i < length; i++) { + tryEncodeDouble(values[offset + i], e, f, result); + if (result.isException) { + exceptions++; + } else { + long encoded = result.encoded; + if (encoded < minEncoded) minEncoded = encoded; + if (encoded > maxEncoded) maxEncoded = encoded; + } + } + int nonExceptions = length - exceptions; + if (nonExceptions == 0) continue; + long delta = (nonExceptions < 2) ? 0 : (maxEncoded - minEncoded); + int bitsPerValue = BytesUtils.getWidthFromMaxLong(delta); + long estimatedSize = (long) length * bitsPerValue + (long) exceptions * (Double.SIZE + Short.SIZE); + if (estimatedSize < bestEstimatedSize + || (estimatedSize == bestEstimatedSize + && (e > bestExponent || (e == bestExponent && f > bestFactor)))) { + bestEstimatedSize = estimatedSize; + bestExponent = e; + bestFactor = f; + bestExceptions = exceptions; + if (bestExceptions == 0 && bitsPerValue == 0) { + return new EncodingParams(bestExponent, bestFactor, 0); + } + } + } + return new EncodingParams(bestExponent, bestFactor, bestExceptions); + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpConfig.java b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpConfig.java new file mode 100644 index 0000000000..d7f46f2876 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpConfig.java @@ -0,0 +1,70 @@ +/* + * 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.column.values.alp; + +/** + * Immutable per-column ALP encoding configuration. + *

+ * Whether a column is ALP encoded at all is not part of this configuration: a column is ALP + * encoded when it has an {@code AlpConfig} and is not when it has none. + */ +public final class AlpConfig { + + /** Default values per encoded vector. */ + public static final int DEFAULT_VECTOR_SIZE = AlpConstants.DEFAULT_VECTOR_SIZE; + + /** Configuration using the default vector size. */ + public static final AlpConfig DEFAULT = new AlpConfig(DEFAULT_VECTOR_SIZE); + + private final int vectorSize; + + /** + * @param vectorSize values per encoded vector; must be a power of 2 in the supported range + * @throws IllegalArgumentException if {@code vectorSize} is not a supported vector size + */ + public AlpConfig(int vectorSize) { + AlpConstants.validateVectorSize(vectorSize); + this.vectorSize = vectorSize; + } + + public int getVectorSize() { + return vectorSize; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + return vectorSize == ((AlpConfig) o).vectorSize; + } + + @Override + public int hashCode() { + return Integer.hashCode(vectorSize); + } + + @Override + public String toString() { + return "AlpConfig{vectorSize=" + vectorSize + '}'; + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpConstants.java b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpConstants.java new file mode 100644 index 0000000000..bdabd2e069 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpConstants.java @@ -0,0 +1,67 @@ +/* + * 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.column.values.alp; + +import org.apache.parquet.Preconditions; + +/** Constants for the ALP (Adaptive Lossless floating-Point) encoding. */ +final class AlpConstants { + + private AlpConstants() { + // Utility class + } + + // Page header fields + static final int ALP_COMPRESSION_MODE = 0; + static final int ALP_INTEGER_ENCODING_FOR = 0; + static final int ALP_HEADER_SIZE = 7; + + static final int DEFAULT_VECTOR_SIZE = 1024; + + // BytePacker packs/unpacks 8 values at a time (pack8Values/unpack8Values). + static final int PACK_GROUP_SIZE = 8; + + // At 2^15, a vector of all exceptions still fits num_exceptions, a uint16. + static final int MAX_LOG_VECTOR_SIZE = 15; + static final int MIN_LOG_VECTOR_SIZE = 3; + + static final int FLOAT_MAX_EXPONENT = 10; + static final int DOUBLE_MAX_EXPONENT = 18; + + // Per-vector metadata sizes in bytes + static final int ALP_INFO_SIZE = 4; // exponent(1) + factor(1) + num_exceptions(2) + static final int FLOAT_FOR_INFO_SIZE = 5; // frame_of_reference(4) + bit_width(1) + static final int DOUBLE_FOR_INFO_SIZE = 9; // frame_of_reference(8) + bit_width(1) + + /** Validates vector size: must be a power of 2 in [2^MIN_LOG .. 2^MAX_LOG]. */ + static void validateVectorSize(int vectorSize) { + Preconditions.checkArgument( + vectorSize > 0 && Integer.bitCount(vectorSize) == 1, + "Vector size must be a power of 2, got: %s", + vectorSize); + int logSize = Integer.numberOfTrailingZeros(vectorSize); + Preconditions.checkArgument( + logSize >= MIN_LOG_VECTOR_SIZE && logSize <= MAX_LOG_VECTOR_SIZE, + "Vector size log2 must be between %s and %s, got: %s (vectorSize=%s)", + MIN_LOG_VECTOR_SIZE, + MAX_LOG_VECTOR_SIZE, + logSize, + vectorSize); + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesReader.java b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesReader.java new file mode 100644 index 0000000000..e47832335e --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesReader.java @@ -0,0 +1,401 @@ +/* + * 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.column.values.alp; + +import static org.apache.parquet.column.values.alp.AlpConstants.ALP_COMPRESSION_MODE; +import static org.apache.parquet.column.values.alp.AlpConstants.ALP_HEADER_SIZE; +import static org.apache.parquet.column.values.alp.AlpConstants.ALP_INFO_SIZE; +import static org.apache.parquet.column.values.alp.AlpConstants.ALP_INTEGER_ENCODING_FOR; +import static org.apache.parquet.column.values.alp.AlpConstants.MAX_LOG_VECTOR_SIZE; +import static org.apache.parquet.column.values.alp.AlpConstants.MIN_LOG_VECTOR_SIZE; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesUtils; +import org.apache.parquet.column.values.ValuesReader; +import org.apache.parquet.io.ParquetDecodingException; + +/** + * Abstract base class for ALP values readers with lazy per-vector decoding. + * + *

Page layout: + *

+ * ┌─────────┬──────────────────────┬──────────────┬──────────────┬─────┐
+ * │ Header  │ Offset Array         │ Vector 0     │ Vector 1     │ ... │
+ * │ 7 bytes │ 4B × numVectors      │              │              │     │
+ * └─────────┴──────────────────────┴──────────────┴──────────────┴─────┘
+ * 
+ * + *

Each vector is decoded lazily on first access. Skipping values does not + * trigger decoding of intermediate vectors. + */ +abstract class AlpValuesReader extends ValuesReader { + + private static final int BEFORE_FIRST_VECTOR = -1; + + protected int valuesPerVector; + protected int totalValueCount = 0; + protected int numVectors; + protected int pageValueIndex = 0; + protected int currentVectorIndex = BEFORE_FIRST_VECTOR; + + // One entry per vector plus a trailing entry for the end of the vector data, so the entry after + // any vector's offset is always where that vector ends. + protected int[] vectorOffsets; + protected ByteBuffer vectorsData; + + // Scratch buffer for exception positions within a vector; shared by both readers (int[] in each). + protected int[] excPositionsBuffer; + + /** Reused holder so readForInfo hands its result to decodePackedValues explicitly. */ + protected final ForInfo forInfo = new ForInfo(); + + /** + * A vector's ForInfo header: the frame-of-reference base and the bit width of the packed values. + * The base is held as a long for both value types; the float reader narrows it on use. + */ + protected static final class ForInfo { + long base; + int bitWidth; + } + + @Override + public void initFromPage(int valuesCount, ByteBufferInputStream stream) + throws ParquetDecodingException, IOException { + readPageHeader(stream, valuesCount); + int minVectorBytes = ALP_INFO_SIZE + forInfoSize(); + readOffsetArray(stream, minVectorBytes); + int vectorDataSize = sliceVectorData(stream); + // The trailing entry is filled here rather than in readOffsetArray because the vector data + // size is only known once the data has been sliced. + vectorOffsets[numVectors] = offsetArraySize() + vectorDataSize; + validateVectorOffsets( + vectorOffsets, minVectorBytes, maxVectorBytes(valuesPerVector, minVectorBytes, exceptionValueSize())); + + ensureDecodedCapacity(valuesPerVector); + if (excPositionsBuffer == null || excPositionsBuffer.length < valuesPerVector) { + this.excPositionsBuffer = new int[valuesPerVector]; + } + } + + /** + * Reads and validates the fixed 7-byte page header, setting {@link #valuesPerVector} and + * {@link #totalValueCount}. + * + *

The reads are relative on purpose. {@link ByteBufferInputStream#slice} does not promise a + * buffer positioned at 0, so the absolute {@code get(index)} form would read the wrong bytes for + * a page backed by a larger buffer. + */ + private void readPageHeader(ByteBufferInputStream stream, int valuesCount) throws IOException { + ByteBuffer headerBuf = stream.slice(ALP_HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN); + int compressionMode = headerBuf.get() & 0xFF; + int integerEncoding = headerBuf.get() & 0xFF; + int logVectorSize = headerBuf.get() & 0xFF; + int numElements = headerBuf.getInt(); + + if (compressionMode != ALP_COMPRESSION_MODE) { + throw new ParquetDecodingException("Unsupported ALP compression mode: " + compressionMode); + } + if (integerEncoding != ALP_INTEGER_ENCODING_FOR) { + throw new ParquetDecodingException("Unsupported ALP integer encoding: " + integerEncoding); + } + if (logVectorSize < MIN_LOG_VECTOR_SIZE || logVectorSize > MAX_LOG_VECTOR_SIZE) { + throw new ParquetDecodingException("Invalid ALP log vector size: " + logVectorSize + ", must be between " + + MIN_LOG_VECTOR_SIZE + " and " + MAX_LOG_VECTOR_SIZE); + } + if (numElements < 0) { + throw new ParquetDecodingException("Invalid ALP element count: " + numElements); + } + // ALP's num_elements is the count of non-null values that went through encoding; + // valuesCount is the page row count, which is larger when the column has nulls. + // The two are equal only for required (non-null) columns. + if (numElements > valuesCount) { + throw new ParquetDecodingException( + "ALP header element count " + numElements + " exceeds page valuesCount " + valuesCount); + } + + this.valuesPerVector = 1 << logVectorSize; + this.totalValueCount = numElements; + this.pageValueIndex = 0; + this.currentVectorIndex = BEFORE_FIRST_VECTOR; + } + + /** + * Reads the offset array, one entry per vector, after bounding the vector count and checking the + * first offset. Leaves room for the trailing end-of-data entry, which the caller fills in. + */ + private void readOffsetArray(ByteBufferInputStream stream, int minVectorBytes) throws IOException { + // Bound the vector count against the bytes actually present before allocating anything. + // numElements is only capped by valuesCount, which a forged page can set as high as + // Integer.MAX_VALUE, so computing this in int would overflow to a negative count, and even + // without overflow a large count would drive a huge int[] and a huge stream.slice allocation. + // Every vector costs a 4-byte offset entry plus at least its ALP and FOR headers, which gives + // a hard ceiling on how many vectors the remaining bytes can possibly describe. + long remainingBytes = stream.available(); + this.numVectors = Math.toIntExact(((long) totalValueCount + valuesPerVector - 1) / valuesPerVector); + long minRemainingBytes = (long) numVectors * (Integer.BYTES + minVectorBytes); + if (minRemainingBytes > remainingBytes) { + throw new ParquetDecodingException("ALP header element count " + totalValueCount + + " requires at least " + minRemainingBytes + " bytes but only " + remainingBytes + + " remain in the page"); + } + + int offsetArraySize = offsetArraySize(); + ByteBuffer offsetBuf = stream.slice(offsetArraySize).order(ByteOrder.LITTLE_ENDIAN); + if (numVectors == 0) { + this.vectorOffsets = new int[1]; + return; + } + // Offsets are measured from the start of the offset array, so the first vector must begin + // exactly where the array ends. Check that before allocating and decoding the rest. + int firstOffset = offsetBuf.getInt(); + if (firstOffset != offsetArraySize) { + throw new ParquetDecodingException( + "ALP first vector offset " + firstOffset + " must equal the offset array size " + offsetArraySize); + } + this.vectorOffsets = new int[numVectors + 1]; + vectorOffsets[0] = firstOffset; + for (int v = 1; v < numVectors; v++) { + vectorOffsets[v] = offsetBuf.getInt(); + } + } + + /** + * Slices the remaining page bytes into a view positioned at 0, so the decode path can use + * absolute {@code get(pos)} reads. + */ + private int sliceVectorData(ByteBufferInputStream stream) throws IOException { + int vectorDataSize = (int) stream.available(); + ByteBuffer rawSlice = stream.slice(vectorDataSize); + this.vectorsData = rawSlice.slice().order(ByteOrder.LITTLE_ENDIAN); + return vectorDataSize; + } + + private int offsetArraySize() { + return numVectors * Integer.BYTES; + } + + /** + * Validates the offset array before any vector is decoded. {@code vectorOffsets} holds one entry + * per vector followed by the end of the vector data, all measured from the start of the offset + * array. The first entry is checked when the array is read, so this starts at entry 1: each + * vector, including the last, must span at least its fixed headers and no more than + * {@code maxVectorBytes}. Without this a forged offset can point into another vector and + * silently decode the wrong values. + */ + static void validateVectorOffsets(int[] vectorOffsets, int minVectorBytes, int maxVectorBytes) { + int numVectors = vectorOffsets.length - 1; + for (int v = 1; v <= numVectors; v++) { + long span = (long) vectorOffsets[v] - vectorOffsets[v - 1]; + if (span < minVectorBytes) { + throw new ParquetDecodingException("ALP vector " + (v - 1) + " at offset " + vectorOffsets[v - 1] + + " spans " + span + " bytes, fewer than the " + minVectorBytes + " its headers need"); + } + if (span > maxVectorBytes) { + throw new ParquetDecodingException("ALP vector " + (v - 1) + " at offset " + vectorOffsets[v - 1] + + " spans " + span + " bytes, more than the " + maxVectorBytes + " any vector can occupy"); + } + } + } + + /** + * The most bytes one vector can occupy: its fixed headers, every value bit-packed at the full + * width of the type, and every value also stored as an exception. Vector sizes are multiples of + * the 8-value packing group, so the packed section needs no padding allowance. + */ + static int maxVectorBytes(int valuesPerVector, int minVectorBytes, int valueSize) { + int packed = valuesPerVector * valueSize; + int exceptions = valuesPerVector * (Short.BYTES + valueSize); + return minVectorBytes + packed + exceptions; + } + + /** + * The position in the vector data just past the last byte vector {@code vectorIndex} may read, + * taken from the next entry of the offset array; the trailing entry makes this valid for the last + * vector too. + */ + int getVectorEndPosition(int vectorIndex) { + if (vectorIndex < 0 || vectorIndex >= numVectors) { + throw new IllegalArgumentException( + "ALP vector index " + vectorIndex + " is out of range for " + numVectors + " vectors"); + } + return vectorOffsets[vectorIndex + 1] - offsetArraySize(); + } + + /** + * The position of vector {@code vectorIndex} in the vector data. This is not an offset as the spec + * defines one: those are measured from the start of the offset array, which precedes the vector + * data. + */ + protected int getVectorDataPosition(int vectorIndex) { + return vectorOffsets[vectorIndex] - offsetArraySize(); + } + + /** Whether {@code vectorIndex} is the last vector in the page. */ + static boolean isLast(int vectorIndex, int numVectors) { + return vectorIndex == numVectors - 1; + } + + /** + * The number of values held by vector {@code vectorIndex}. Every vector holds + * {@code valuesPerVector} values except the last, which holds whatever remains and so may be + * partial. This is a value count, not a byte count. + */ + static int getVectorSize(int vectorIndex, int valuesPerVector, int totalValueCount, int numVectors) { + if (isLast(vectorIndex, numVectors)) { + int lastVectorSize = totalValueCount % valuesPerVector; + return lastVectorSize == 0 ? valuesPerVector : lastVectorSize; + } + return valuesPerVector; + } + + @Override + public void skip() { + skip(1); + } + + @Override + public void skip(int n) { + // Compare against the remaining count rather than adding to pageValueIndex: a large n would + // overflow the sum, pass the check, and leave pageValueIndex negative. + if (n < 0 || n > totalValueCount - pageValueIndex) { + throw new ParquetDecodingException(String.format( + "Cannot skip this many elements. Current index: %d. Skip %d. Total count: %d", + pageValueIndex, n, totalValueCount)); + } + pageValueIndex += n; + } + + protected void ensureVectorDecoded() { + int vectorIndex = pageValueIndex / valuesPerVector; + if (vectorIndex != currentVectorIndex) { + decodeVector(vectorIndex); + currentVectorIndex = vectorIndex; + } + } + + /** + * Decodes one vector, following the division the ALP spec makes: a vector header of AlpInfo and + * ForInfo, then a data section of PackedValues, ExceptionPositions and ExceptionValues. Runs once + * per vector, so the virtual hook calls stay off the per-value hot path. + */ + protected final void decodeVector(int vectorIndex) { + int vectorSize = getVectorSize(vectorIndex, valuesPerVector, totalValueCount, numVectors); + int vectorEnd = getVectorEndPosition(vectorIndex); + int pos = getVectorDataPosition(vectorIndex); + + // Vector header, part one: AlpInfo, which is the same for both value types. + int exponent = BytesUtils.readUnsignedByte(vectorsData, pos); + int factor = BytesUtils.readUnsignedByte(vectorsData, pos + 1); + int numExceptions = BytesUtils.readUnsignedShortLittleEndian(vectorsData, pos + 2); + pos += ALP_INFO_SIZE; + validateAlpInfo(vectorIndex, exponent, factor, numExceptions, vectorSize); + + // Vector header, part two: ForInfo, whose width depends on the value type. + pos = readForInfo(pos, vectorIndex, forInfo); + + // Data section, part one: PackedValues. Their width comes from this vector's own ForInfo, so + // confirm they fit before unpacking; otherwise this vector would decode the next one's bytes, + // or for the last vector read past the end of the page. + int packedEnd = pos + BytesUtils.paddedByteCountFromBits(vectorSize * forInfo.bitWidth); + if (packedEnd > vectorEnd) { + throw new ParquetDecodingException("ALP vector " + vectorIndex + " packed values end at " + packedEnd + + ", past the end of the vector at " + vectorEnd); + } + pos = decodePackedValues(pos, vectorSize, exponent, factor, forInfo); + + // Data section, parts two and three: ExceptionPositions then ExceptionValues. + if (numExceptions > 0) { + long exceptionBytes = (long) numExceptions * (Short.BYTES + exceptionValueSize()); + if (pos + exceptionBytes > vectorEnd) { + throw new ParquetDecodingException( + "ALP vector " + vectorIndex + " declares " + numExceptions + " exceptions needing " + + exceptionBytes + " bytes, past the end of the vector at " + vectorEnd); + } + applyExceptions(pos, numExceptions, vectorSize); + } + } + + /** Validates the AlpInfo fields of a vector header against this type's limits. */ + private void validateAlpInfo(int vectorIndex, int exponent, int factor, int numExceptions, int vectorSize) { + if (exponent > maxExponent()) { + throw new ParquetDecodingException("Invalid ALP " + typeName() + " exponent " + exponent + " in vector " + + vectorIndex + ", max is " + maxExponent()); + } + if (factor > exponent) { + throw new ParquetDecodingException("Invalid ALP " + typeName() + " factor " + factor + " > exponent " + + exponent + " in vector " + vectorIndex); + } + if (numExceptions > vectorSize) { + throw new ParquetDecodingException("Invalid ALP numExceptions " + numExceptions + " > vectorSize " + + vectorSize + " in vector " + vectorIndex); + } + } + + /** + * Reads {@code numExceptions} little-endian uint16 exception positions into {@code positionsOut} + * and returns the position just past them. Static so the exception block's two halves can be + * followed without tracing fields. + */ + static int readExceptionPositions(ByteBuffer data, int pos, int numExceptions, int vectorSize, int[] positionsOut) { + for (int e = 0; e < numExceptions; e++) { + positionsOut[e] = BytesUtils.readUnsignedShortLittleEndian(data, pos); + if (positionsOut[e] >= vectorSize) { + throw new ParquetDecodingException( + "ALP exception position " + positionsOut[e] + " out of bounds for vectorSize " + vectorSize); + } + pos += Short.BYTES; + } + return pos; + } + + /** Grows the decode buffers to hold {@code capacity} values, keeping them if they already do. */ + protected abstract void ensureDecodedCapacity(int capacity); + + /** The maximum exponent for this value type (used to validate the header). */ + protected abstract int maxExponent(); + + /** The value type name ("float" / "double") used in decoding error messages. */ + protected abstract String typeName(); + + /** Size in bytes of this type's frame-of-reference header (base + bit width). */ + protected abstract int forInfoSize(); + + /** Size in bytes of one exception value of this type, as stored in the exception block. */ + protected abstract int exceptionValueSize(); + + /** + * Reads this vector's ForInfo header into {@code out} and returns the position just past it. + */ + protected abstract int readForInfo(int pos, int vectorIndex, ForInfo out); + + /** + * Decodes the PackedValues of the data section into the type-specific decoded buffer and returns + * the position just past them. + */ + protected abstract int decodePackedValues(int pos, int vectorSize, int exponent, int factor, ForInfo forInfo); + + /** + * Reads the ExceptionPositions and ExceptionValues of the data section and applies them over the + * decoded values. + */ + protected abstract void applyExceptions(int pos, int numExceptions, int vectorSize); +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesReaderForDouble.java b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesReaderForDouble.java new file mode 100644 index 0000000000..7c5b688bd2 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesReaderForDouble.java @@ -0,0 +1,154 @@ +/* + * 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.column.values.alp; + +import static org.apache.parquet.column.values.alp.AlpConstants.DOUBLE_FOR_INFO_SIZE; +import static org.apache.parquet.column.values.alp.AlpConstants.DOUBLE_MAX_EXPONENT; +import static org.apache.parquet.column.values.alp.AlpConstants.PACK_GROUP_SIZE; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import org.apache.parquet.bytes.BytesUtils; +import org.apache.parquet.column.values.bitpacking.BytePackerForLong; +import org.apache.parquet.column.values.bitpacking.Packer; +import org.apache.parquet.io.ParquetDecodingException; + +/** ALP values reader for DOUBLE type with lazy per-vector decoding. */ +public class AlpValuesReaderForDouble extends AlpValuesReader { + + private double[] decodedValues; + private long[] deltasBuffer; + private final long[] unpackPadBuf = new long[PACK_GROUP_SIZE]; + private final byte[] unpackByteBuf = new byte[Long.SIZE]; // 8 values x 64 bits = 64 bytes + + @Override + protected void ensureDecodedCapacity(int capacity) { + if (decodedValues == null || decodedValues.length < capacity) { + this.decodedValues = new double[capacity]; + this.deltasBuffer = new long[capacity]; + } + } + + @Override + protected int maxExponent() { + return DOUBLE_MAX_EXPONENT; + } + + @Override + protected String typeName() { + return "double"; + } + + @Override + protected int forInfoSize() { + return DOUBLE_FOR_INFO_SIZE; + } + + @Override + protected int exceptionValueSize() { + return Double.BYTES; + } + + @Override + public double readDouble() { + if (pageValueIndex >= totalValueCount) { + throw new ParquetDecodingException("ALP double data was already exhausted."); + } + ensureVectorDecoded(); + int vectorValueIndex = pageValueIndex % valuesPerVector; + pageValueIndex++; + return decodedValues[vectorValueIndex]; + } + + @Override + protected int readForInfo(int pos, int vectorIndex, ForInfo out) { + out.base = vectorsData.getLong(pos); + out.bitWidth = BytesUtils.readUnsignedByte(vectorsData, pos + Long.BYTES); + if (out.bitWidth > Long.SIZE) { + throw new ParquetDecodingException( + "Invalid ALP double bitWidth " + out.bitWidth + " > 64 in vector " + vectorIndex); + } + return pos + DOUBLE_FOR_INFO_SIZE; + } + + @Override + protected int decodePackedValues(int pos, int vectorSize, int exponent, int factor, ForInfo forInfo) { + if (forInfo.bitWidth > 0) { + pos = unpackLongsWithBytePacker(vectorsData, pos, deltasBuffer, vectorSize, forInfo.bitWidth); + } else { + Arrays.fill(deltasBuffer, 0, vectorSize, 0L); + } + + for (int i = 0; i < vectorSize; i++) { + long encoded = deltasBuffer[i] + forInfo.base; + decodedValues[i] = AlpCodec.decodeDouble(encoded, exponent, factor); + } + return pos; + } + + @Override + protected void applyExceptions(int pos, int numExceptions, int vectorSize) { + pos = readExceptionPositions(vectorsData, pos, numExceptions, vectorSize, excPositionsBuffer); + applyExceptionValues(vectorsData, pos, numExceptions, excPositionsBuffer, decodedValues); + } + + /** + * Writes {@code numExceptions} raw double values over the decoded values at the given positions. + * Static and fully parameterised so it can be exercised directly. + */ + static void applyExceptionValues( + ByteBuffer data, int pos, int numExceptions, int[] positions, double[] decodedValues) { + for (int e = 0; e < numExceptions; e++) { + decodedValues[positions[e]] = data.getDouble(pos); + pos += Double.BYTES; + } + } + + private int unpackLongsWithBytePacker(ByteBuffer buf, int pos, long[] output, int count, int bitWidth) { + BytePackerForLong packer = Packer.LITTLE_ENDIAN.newBytePackerForLong(bitWidth); + int numFullGroups = count / PACK_GROUP_SIZE; + int remaining = count % PACK_GROUP_SIZE; + + for (int g = 0; g < numFullGroups; g++) { + packer.unpack8Values(buf, pos, output, g * PACK_GROUP_SIZE); + pos += bitWidth; + } + + // Last group might have fewer than 8 values; zero-pad and unpack, + // but only advance pos by the actual bytes in the page. + if (remaining > 0) { + int totalPackedBytes = BytesUtils.paddedByteCountFromBits(count * bitWidth); + int alreadyRead = numFullGroups * bitWidth; + int partialBytes = totalPackedBytes - alreadyRead; + + for (int i = 0; i < partialBytes; i++) { + unpackByteBuf[i] = buf.get(pos + i); + } + for (int i = partialBytes; i < bitWidth; i++) { + unpackByteBuf[i] = 0; + } + + packer.unpack8Values(unpackByteBuf, 0, unpackPadBuf, 0); + System.arraycopy(unpackPadBuf, 0, output, numFullGroups * PACK_GROUP_SIZE, remaining); + pos += partialBytes; + } + + return pos; + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesReaderForFloat.java b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesReaderForFloat.java new file mode 100644 index 0000000000..0a0cc90349 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesReaderForFloat.java @@ -0,0 +1,154 @@ +/* + * 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.column.values.alp; + +import static org.apache.parquet.column.values.alp.AlpConstants.FLOAT_FOR_INFO_SIZE; +import static org.apache.parquet.column.values.alp.AlpConstants.FLOAT_MAX_EXPONENT; +import static org.apache.parquet.column.values.alp.AlpConstants.PACK_GROUP_SIZE; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import org.apache.parquet.bytes.BytesUtils; +import org.apache.parquet.column.values.bitpacking.BytePacker; +import org.apache.parquet.column.values.bitpacking.Packer; +import org.apache.parquet.io.ParquetDecodingException; + +/** ALP values reader for FLOAT type with lazy per-vector decoding. */ +public class AlpValuesReaderForFloat extends AlpValuesReader { + + private float[] decodedValues; + private int[] deltasBuffer; + private final int[] unpackPadBuf = new int[PACK_GROUP_SIZE]; + private final byte[] unpackByteBuf = new byte[Integer.SIZE]; // 8 values x 32 bits = 32 bytes + + @Override + protected void ensureDecodedCapacity(int capacity) { + if (decodedValues == null || decodedValues.length < capacity) { + this.decodedValues = new float[capacity]; + this.deltasBuffer = new int[capacity]; + } + } + + @Override + protected int maxExponent() { + return FLOAT_MAX_EXPONENT; + } + + @Override + protected String typeName() { + return "float"; + } + + @Override + protected int forInfoSize() { + return FLOAT_FOR_INFO_SIZE; + } + + @Override + protected int exceptionValueSize() { + return Float.BYTES; + } + + @Override + public float readFloat() { + if (pageValueIndex >= totalValueCount) { + throw new ParquetDecodingException("ALP float data was already exhausted."); + } + ensureVectorDecoded(); + int vectorValueIndex = pageValueIndex % valuesPerVector; + pageValueIndex++; + return decodedValues[vectorValueIndex]; + } + + @Override + protected int readForInfo(int pos, int vectorIndex, ForInfo out) { + out.base = vectorsData.getInt(pos); + out.bitWidth = BytesUtils.readUnsignedByte(vectorsData, pos + Integer.BYTES); + if (out.bitWidth > Integer.SIZE) { + throw new ParquetDecodingException( + "Invalid ALP float bitWidth " + out.bitWidth + " > 32 in vector " + vectorIndex); + } + return pos + FLOAT_FOR_INFO_SIZE; + } + + @Override + protected int decodePackedValues(int pos, int vectorSize, int exponent, int factor, ForInfo forInfo) { + // Narrow the base before adding: the sum must wrap in 32 bits exactly as the writer's did. + int frameOfReference = (int) forInfo.base; + if (forInfo.bitWidth > 0) { + pos = unpackIntsWithBytePacker(vectorsData, pos, deltasBuffer, vectorSize, forInfo.bitWidth); + } else { + Arrays.fill(deltasBuffer, 0, vectorSize, 0); + } + + for (int i = 0; i < vectorSize; i++) { + int encoded = deltasBuffer[i] + frameOfReference; + decodedValues[i] = AlpCodec.decodeFloat(encoded, exponent, factor); + } + return pos; + } + + @Override + protected void applyExceptions(int pos, int numExceptions, int vectorSize) { + pos = readExceptionPositions(vectorsData, pos, numExceptions, vectorSize, excPositionsBuffer); + applyExceptionValues(vectorsData, pos, numExceptions, excPositionsBuffer, decodedValues); + } + + /** + * Writes {@code numExceptions} raw float values over the decoded values at the given positions. + * Static and fully parameterised so it can be exercised directly. + */ + static void applyExceptionValues( + ByteBuffer data, int pos, int numExceptions, int[] positions, float[] decodedValues) { + for (int e = 0; e < numExceptions; e++) { + decodedValues[positions[e]] = data.getFloat(pos); + pos += Float.BYTES; + } + } + + private int unpackIntsWithBytePacker(ByteBuffer buf, int pos, int[] output, int count, int bitWidth) { + BytePacker packer = Packer.LITTLE_ENDIAN.newBytePacker(bitWidth); + int numFullGroups = count / PACK_GROUP_SIZE; + int remaining = count % PACK_GROUP_SIZE; + + for (int g = 0; g < numFullGroups; g++) { + packer.unpack8Values(buf, pos, output, g * PACK_GROUP_SIZE); + pos += bitWidth; + } + + if (remaining > 0) { + int totalPackedBytes = BytesUtils.paddedByteCountFromBits(count * bitWidth); + int alreadyRead = numFullGroups * bitWidth; + int partialBytes = totalPackedBytes - alreadyRead; + + for (int i = 0; i < partialBytes; i++) { + unpackByteBuf[i] = buf.get(pos + i); + } + for (int i = partialBytes; i < bitWidth; i++) { + unpackByteBuf[i] = 0; + } + + packer.unpack8Values(unpackByteBuf, 0, unpackPadBuf, 0); + System.arraycopy(unpackPadBuf, 0, output, numFullGroups * PACK_GROUP_SIZE, remaining); + pos += partialBytes; + } + + return pos; + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesWriter.java b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesWriter.java new file mode 100644 index 0000000000..95e46583f1 --- /dev/null +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/alp/AlpValuesWriter.java @@ -0,0 +1,591 @@ +/* + * 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.column.values.alp; + +import static org.apache.parquet.column.values.alp.AlpConstants.ALP_COMPRESSION_MODE; +import static org.apache.parquet.column.values.alp.AlpConstants.ALP_HEADER_SIZE; +import static org.apache.parquet.column.values.alp.AlpConstants.ALP_INTEGER_ENCODING_FOR; +import static org.apache.parquet.column.values.alp.AlpConstants.DEFAULT_VECTOR_SIZE; +import static org.apache.parquet.column.values.alp.AlpConstants.PACK_GROUP_SIZE; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.parquet.bytes.ByteBufferAllocator; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.BytesUtils; +import org.apache.parquet.bytes.CapacityByteArrayOutputStream; +import org.apache.parquet.column.Encoding; +import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.bitpacking.BytePacker; +import org.apache.parquet.column.values.bitpacking.BytePackerForLong; +import org.apache.parquet.column.values.bitpacking.Packer; + +/** + * ALP (Adaptive Lossless floating-Point) values writer. + * + *

Writing is incremental: values are buffered in a fixed-size vector buffer, + * and each full vector is encoded and flushed to the output stream immediately. + * On {@link #getBytes()}, any remaining partial vector is flushed, and the + * final page bytes are assembled. + * + *

Page layout: + *

+ * ┌─────────┬──────────────────────┬──────────────┬──────────────┬─────┐
+ * │ Header  │ Offset Array         │ Vector 0     │ Vector 1     │ ... │
+ * │ 7 bytes │ 4B × numVectors      │              │              │     │
+ * └─────────┴──────────────────────┴──────────────┴──────────────┴─────┘
+ * 
+ * + *

Each vector holds AlpInfo(4B) + ForInfo(5B/9B) + PackedValues + ExceptionPositions + + * ExceptionValues. + */ +public abstract class AlpValuesWriter extends ValuesWriter { + + // The spec's sampling parameters: 8 sampled vectors per row group and up to 5 preset + // combinations. The row group size comes from the C++ implementation. + static final int SAMPLER_ROWGROUP_SIZE = 122_880; + static final int SAMPLER_SAMPLE_VECTORS_PER_ROWGROUP = 8; + static final int MAX_PRESET_COMBINATIONS = 5; + + protected final int valuesPerVector; + protected int bufferCount = 0; + private int totalValueCount = 0; + private final CapacityByteArrayOutputStream encodedVectors; + private final List vectorByteSizes = new ArrayList<>(); + private final int[] exceptionPositions; + private final ByteBuffer littleEndian = ByteBuffer.allocate(Long.BYTES).order(ByteOrder.LITTLE_ENDIAN); + + private final int sampleJump; + private int vectorsProcessed = 0; + private final List sampledParams = new ArrayList<>(); + private int[][] presets; + + AlpValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator, int vectorSize) { + AlpConstants.validateVectorSize(vectorSize); + this.valuesPerVector = vectorSize; + this.encodedVectors = new CapacityByteArrayOutputStream(initialCapacity, pageSize, allocator); + this.exceptionPositions = new int[vectorSize]; + this.sampleJump = Math.max(1, SAMPLER_ROWGROUP_SIZE / SAMPLER_SAMPLE_VECTORS_PER_ROWGROUP / vectorSize); + } + + @Override + public Encoding getEncoding() { + return Encoding.ALP; + } + + /** Counts the value just stored at {@link #bufferCount} and flushes the vector once it is full. */ + protected final void addedValue() { + bufferCount++; + totalValueCount++; + if (bufferCount == valuesPerVector) { + encodeAndFlushVector(); + } + } + + private void encodeAndFlushVector() { + int vectorSize = bufferCount; + AlpCodec.EncodingParams params = chooseParams(vectorSize); + int numExceptions = encodeVector(vectorSize, params.exponent, params.factor, exceptionPositions); + long frameOfReference = subtractFrameOfReference(vectorSize); + int bitWidth = deltaBitWidth(vectorSize); + + long startSize = encodedVectors.size(); + writeLittleEndian(params.exponent, 1); + writeLittleEndian(params.factor, 1); + writeLittleEndian(numExceptions, Short.BYTES); + writeLittleEndian(frameOfReference, valueSize()); + writeLittleEndian(bitWidth, 1); + if (bitWidth > 0) { + writePackedValues(vectorSize, bitWidth, encodedVectors); + } + for (int i = 0; i < numExceptions; i++) { + writeLittleEndian(exceptionPositions[i], Short.BYTES); + } + for (int i = 0; i < numExceptions; i++) { + writeLittleEndian(exceptionValueBits(i), valueSize()); + } + vectorByteSizes.add((int) (encodedVectors.size() - startSize)); + bufferCount = 0; + } + + /** + * Searches every (exponent, factor) pair until enough vectors have been sampled, then only the + * presets built from those samples. + */ + private AlpCodec.EncodingParams chooseParams(int vectorSize) { + AlpCodec.EncodingParams params; + if (presets == null) { + params = findBestParams(vectorSize, allPairs()); + if (vectorsProcessed % sampleJump == 0 && sampledParams.size() < SAMPLER_SAMPLE_VECTORS_PER_ROWGROUP) { + sampledParams.add(new int[] {params.exponent, params.factor}); + } + if (sampledParams.size() >= SAMPLER_SAMPLE_VECTORS_PER_ROWGROUP) { + presets = buildPresets(sampledParams, MAX_PRESET_COMBINATIONS); + sampledParams.clear(); + } + } else { + params = findBestParams(vectorSize, presets); + } + vectorsProcessed++; + return params; + } + + /** The (exponent, factor) pairs that won the most samples, most frequent first. */ + static int[][] buildPresets(List sampledParams, int maxPresets) { + Map counts = new HashMap<>(); + for (int[] params : sampledParams) { + counts.merge(((long) params[0] << Integer.SIZE) | params[1], 1, Integer::sum); + } + // Ties keep HashMap order. Changing it changes which preset comes first, and so the bytes. + List> byCount = new ArrayList<>(counts.entrySet()); + byCount.sort(Map.Entry.comparingByValue().reversed()); + int[][] presets = new int[Math.min(byCount.size(), maxPresets)][]; + for (int i = 0; i < presets.length; i++) { + long key = byCount.get(i).getKey(); + presets[i] = new int[] {(int) (key >>> Integer.SIZE), (int) key}; + } + return presets; + } + + private void writeLittleEndian(long value, int byteCount) { + littleEndian.clear(); + littleEndian.putLong(value); + encodedVectors.write(littleEndian.array(), 0, byteCount); + } + + @Override + public long getBufferedSize() { + return encodedVectors.size() + (long) bufferCount * estimatedBytesPerBufferedValue(); + } + + @Override + public BytesInput getBytes() { + if (bufferCount > 0) { + encodeAndFlushVector(); + } + + ByteBuffer header = ByteBuffer.allocate(ALP_HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN); + header.put((byte) ALP_COMPRESSION_MODE); + header.put((byte) ALP_INTEGER_ENCODING_FOR); + header.put((byte) Integer.numberOfTrailingZeros(valuesPerVector)); + header.putInt(totalValueCount); + if (totalValueCount == 0) { + return BytesInput.from(header.array()); + } + + int numVectors = vectorByteSizes.size(); + ByteBuffer offsets = ByteBuffer.allocate(numVectors * Integer.BYTES).order(ByteOrder.LITTLE_ENDIAN); + int offset = numVectors * Integer.BYTES; + for (int vectorByteSize : vectorByteSizes) { + offsets.putInt(offset); + offset += vectorByteSize; + } + return BytesInput.concat( + BytesInput.from(header.array()), BytesInput.from(offsets.array()), BytesInput.from(encodedVectors)); + } + + @Override + public void reset() { + bufferCount = 0; + totalValueCount = 0; + encodedVectors.reset(); + vectorByteSizes.clear(); + // The sampling state is kept: a sampled row group spans many pages. + } + + /** Visible for testing: whether enough vectors have been sampled to build the presets. */ + boolean hasPresetCache() { + return presets != null; + } + + @Override + public void close() { + encodedVectors.close(); + } + + @Override + public long getAllocatedSize() { + return (long) valuesPerVector * valueSize() + encodedVectors.getCapacity(); + } + + @Override + public String memUsageString(String prefix) { + return String.format( + "%s %s %d values, %d bytes allocated", + prefix, getClass().getSimpleName(), totalValueCount, getAllocatedSize()); + } + + /** Size in bytes of one value of this type, and of its frame of reference. */ + protected abstract int valueSize(); + + /** A rough size for a value not yet encoded; the exact size is known once its vector is. */ + protected abstract int estimatedBytesPerBufferedValue(); + + /** Every valid (exponent, factor) pair for this type, searched until the presets are built. */ + protected abstract int[][] allPairs(); + + protected abstract AlpCodec.EncodingParams findBestParams(int vectorSize, int[][] pairs); + + /** + * Encodes the buffered values, recording exceptions in {@code exceptionPositions} and the + * type's exception values, and returns how many there are. + */ + protected abstract int encodeVector(int vectorSize, int exponent, int factor, int[] exceptionPositions); + + protected abstract long subtractFrameOfReference(int vectorSize); + + protected abstract int deltaBitWidth(int vectorSize); + + protected abstract void writePackedValues(int vectorSize, int bitWidth, CapacityByteArrayOutputStream out); + + /** The raw bits of exception {@code index}, as recorded by {@link #encodeVector}. */ + protected abstract long exceptionValueBits(int index); + + /** Float writer. Buffers one vector at a time, encodes and flushes when full. */ + public static class FloatAlpValuesWriter extends AlpValuesWriter { + private final float[] vectorBuffer; + private final int[] encoded; + private final float[] exceptionValues; + private final byte[] packed = new byte[Integer.SIZE]; + private final int[] padded = new int[PACK_GROUP_SIZE]; + private final AlpCodec.EncodeResult encodeResult = new AlpCodec.EncodeResult(); + + public FloatAlpValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator) { + this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE); + } + + public FloatAlpValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator, int vectorSize) { + super(initialCapacity, pageSize, allocator, vectorSize); + this.vectorBuffer = new float[vectorSize]; + this.encoded = new int[vectorSize]; + this.exceptionValues = new float[vectorSize]; + } + + @Override + public void writeFloat(float v) { + vectorBuffer[bufferCount] = v; + addedValue(); + } + + @Override + protected int valueSize() { + return Float.BYTES; + } + + @Override + protected int estimatedBytesPerBufferedValue() { + return 3; + } + + @Override + protected int[][] allPairs() { + return AlpCodec.ALL_VALID_FLOAT_PAIRS; + } + + @Override + protected AlpCodec.EncodingParams findBestParams(int vectorSize, int[][] pairs) { + return AlpCodec.findBestFloatParams(vectorBuffer, 0, vectorSize, pairs); + } + + @Override + protected int encodeVector(int vectorSize, int exponent, int factor, int[] exceptionPositions) { + return encode( + vectorBuffer, + vectorSize, + exponent, + factor, + encoded, + exceptionPositions, + exceptionValues, + encodeResult); + } + + @Override + protected long subtractFrameOfReference(int vectorSize) { + return subtractMin(encoded, vectorSize); + } + + @Override + protected int deltaBitWidth(int vectorSize) { + return bitWidth(encoded, vectorSize); + } + + @Override + protected void writePackedValues(int vectorSize, int bitWidth, CapacityByteArrayOutputStream out) { + pack(encoded, vectorSize, bitWidth, packed, padded, out); + } + + @Override + protected long exceptionValueBits(int index) { + return Float.floatToRawIntBits(exceptionValues[index]); + } + + /** + * Encodes {@code count} values into {@code encoded}, recording each exception's position and + * value, and returns how many there are. + */ + static int encode( + float[] values, + int count, + int exponent, + int factor, + int[] encoded, + int[] exceptionPositions, + float[] exceptionValues, + AlpCodec.EncodeResult scratch) { + int numExceptions = 0; + int placeholder = 0; + boolean havePlaceholder = false; + for (int i = 0; i < count; i++) { + AlpCodec.tryEncodeFloat(values[i], exponent, factor, scratch); + if (scratch.isException) { + exceptionPositions[numExceptions] = i; + exceptionValues[numExceptions] = values[i]; + numExceptions++; + } else { + encoded[i] = (int) scratch.encoded; + if (!havePlaceholder) { + placeholder = encoded[i]; + havePlaceholder = true; + } + } + } + // Exception slots take an encoding already in the vector, so they don't widen the deltas. + for (int k = 0; k < numExceptions; k++) { + encoded[exceptionPositions[k]] = placeholder; + } + return numExceptions; + } + + /** Bits needed for the largest delta, read as unsigned. */ + static int bitWidth(int[] deltas, int count) { + int maxDelta = 0; + for (int i = 0; i < count; i++) { + if (Integer.compareUnsigned(deltas[i], maxDelta) > 0) { + maxDelta = deltas[i]; + } + } + return BytesUtils.getWidthFromMaxInt(maxDelta); + } + + /** Subtracts the minimum from every value, wrapping as unsigned, and returns the minimum. */ + static int subtractMin(int[] values, int count) { + int min = Integer.MAX_VALUE; + for (int i = 0; i < count; i++) { + min = Math.min(min, values[i]); + } + for (int i = 0; i < count; i++) { + values[i] -= min; + } + return min; + } + + /** + * Packs {@code count} values in groups of 8, writing only the bytes the last, partial group + * needs. + */ + static void pack( + int[] values, int count, int bitWidth, byte[] packed, int[] padded, CapacityByteArrayOutputStream out) { + BytePacker packer = Packer.LITTLE_ENDIAN.newBytePacker(bitWidth); + int fullGroups = count / PACK_GROUP_SIZE; + for (int g = 0; g < fullGroups; g++) { + packer.pack8Values(values, g * PACK_GROUP_SIZE, packed, 0); + out.write(packed, 0, bitWidth); + } + int remaining = count % PACK_GROUP_SIZE; + if (remaining > 0) { + System.arraycopy(values, fullGroups * PACK_GROUP_SIZE, padded, 0, remaining); + Arrays.fill(padded, remaining, PACK_GROUP_SIZE, 0); + packer.pack8Values(padded, 0, packed, 0); + out.write(packed, 0, BytesUtils.paddedByteCountFromBits(count * bitWidth) - fullGroups * bitWidth); + } + } + } + + /** Double writer. Buffers one vector at a time, encodes and flushes when full. */ + public static class DoubleAlpValuesWriter extends AlpValuesWriter { + private final double[] vectorBuffer; + private final long[] encoded; + private final double[] exceptionValues; + private final byte[] packed = new byte[Long.SIZE]; + private final long[] padded = new long[PACK_GROUP_SIZE]; + private final AlpCodec.EncodeResult encodeResult = new AlpCodec.EncodeResult(); + + public DoubleAlpValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator) { + this(initialCapacity, pageSize, allocator, DEFAULT_VECTOR_SIZE); + } + + public DoubleAlpValuesWriter(int initialCapacity, int pageSize, ByteBufferAllocator allocator, int vectorSize) { + super(initialCapacity, pageSize, allocator, vectorSize); + this.vectorBuffer = new double[vectorSize]; + this.encoded = new long[vectorSize]; + this.exceptionValues = new double[vectorSize]; + } + + @Override + public void writeDouble(double v) { + vectorBuffer[bufferCount] = v; + addedValue(); + } + + @Override + protected int valueSize() { + return Double.BYTES; + } + + @Override + protected int estimatedBytesPerBufferedValue() { + return 5; + } + + @Override + protected int[][] allPairs() { + return AlpCodec.ALL_VALID_DOUBLE_PAIRS; + } + + @Override + protected AlpCodec.EncodingParams findBestParams(int vectorSize, int[][] pairs) { + return AlpCodec.findBestDoubleParams(vectorBuffer, 0, vectorSize, pairs); + } + + @Override + protected int encodeVector(int vectorSize, int exponent, int factor, int[] exceptionPositions) { + return encode( + vectorBuffer, + vectorSize, + exponent, + factor, + encoded, + exceptionPositions, + exceptionValues, + encodeResult); + } + + @Override + protected long subtractFrameOfReference(int vectorSize) { + return subtractMin(encoded, vectorSize); + } + + @Override + protected int deltaBitWidth(int vectorSize) { + return bitWidth(encoded, vectorSize); + } + + @Override + protected void writePackedValues(int vectorSize, int bitWidth, CapacityByteArrayOutputStream out) { + pack(encoded, vectorSize, bitWidth, packed, padded, out); + } + + @Override + protected long exceptionValueBits(int index) { + return Double.doubleToRawLongBits(exceptionValues[index]); + } + + /** + * Encodes {@code count} values into {@code encoded}, recording each exception's position and + * value, and returns how many there are. + */ + static int encode( + double[] values, + int count, + int exponent, + int factor, + long[] encoded, + int[] exceptionPositions, + double[] exceptionValues, + AlpCodec.EncodeResult scratch) { + int numExceptions = 0; + long placeholder = 0; + boolean havePlaceholder = false; + for (int i = 0; i < count; i++) { + AlpCodec.tryEncodeDouble(values[i], exponent, factor, scratch); + if (scratch.isException) { + exceptionPositions[numExceptions] = i; + exceptionValues[numExceptions] = values[i]; + numExceptions++; + } else { + encoded[i] = scratch.encoded; + if (!havePlaceholder) { + placeholder = encoded[i]; + havePlaceholder = true; + } + } + } + // Exception slots take an encoding already in the vector, so they don't widen the deltas. + for (int k = 0; k < numExceptions; k++) { + encoded[exceptionPositions[k]] = placeholder; + } + return numExceptions; + } + + /** Bits needed for the largest delta, read as unsigned. */ + static int bitWidth(long[] deltas, int count) { + long maxDelta = 0; + for (int i = 0; i < count; i++) { + if (Long.compareUnsigned(deltas[i], maxDelta) > 0) { + maxDelta = deltas[i]; + } + } + return BytesUtils.getWidthFromMaxLong(maxDelta); + } + + /** Subtracts the minimum from every value, wrapping as unsigned, and returns the minimum. */ + static long subtractMin(long[] values, int count) { + long min = Long.MAX_VALUE; + for (int i = 0; i < count; i++) { + min = Math.min(min, values[i]); + } + for (int i = 0; i < count; i++) { + values[i] -= min; + } + return min; + } + + /** + * Packs {@code count} values in groups of 8, writing only the bytes the last, partial group + * needs. + */ + static void pack( + long[] values, + int count, + int bitWidth, + byte[] packed, + long[] padded, + CapacityByteArrayOutputStream out) { + BytePackerForLong packer = Packer.LITTLE_ENDIAN.newBytePackerForLong(bitWidth); + int fullGroups = count / PACK_GROUP_SIZE; + for (int g = 0; g < fullGroups; g++) { + packer.pack8Values(values, g * PACK_GROUP_SIZE, packed, 0); + out.write(packed, 0, bitWidth); + } + int remaining = count % PACK_GROUP_SIZE; + if (remaining > 0) { + System.arraycopy(values, fullGroups * PACK_GROUP_SIZE, padded, 0, remaining); + Arrays.fill(padded, remaining, PACK_GROUP_SIZE, 0L); + packer.pack8Values(padded, 0, packed, 0); + out.write(packed, 0, BytesUtils.paddedByteCountFromBits(count * bitWidth) - fullGroups * bitWidth); + } + } + } +} diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV1ValuesWriterFactory.java b/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV1ValuesWriterFactory.java index e0d12c2878..f33da196c6 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV1ValuesWriterFactory.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV1ValuesWriterFactory.java @@ -24,6 +24,7 @@ import org.apache.parquet.column.Encoding; import org.apache.parquet.column.ParquetProperties; import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.alp.AlpValuesWriter; import org.apache.parquet.column.values.bytestreamsplit.ByteStreamSplitValuesWriter; import org.apache.parquet.column.values.plain.BooleanPlainValuesWriter; import org.apache.parquet.column.values.plain.FixedLenByteArrayPlainValuesWriter; @@ -147,7 +148,13 @@ private ValuesWriter getInt96ValuesWriter(ColumnDescriptor path) { private ValuesWriter getDoubleValuesWriter(ColumnDescriptor path) { final ValuesWriter fallbackWriter; - if (this.parquetProperties.isByteStreamSplitEnabled(path)) { + if (this.parquetProperties.isAlpEnabled(path)) { + fallbackWriter = new AlpValuesWriter.DoubleAlpValuesWriter( + parquetProperties.getInitialSlabSize(), + parquetProperties.getPageSizeThreshold(), + parquetProperties.getAllocator(), + parquetProperties.getAlpConfig(path).getVectorSize()); + } else if (this.parquetProperties.isByteStreamSplitEnabled(path)) { fallbackWriter = new ByteStreamSplitValuesWriter.DoubleByteStreamSplitValuesWriter( parquetProperties.getInitialSlabSize(), parquetProperties.getPageSizeThreshold(), @@ -164,7 +171,13 @@ private ValuesWriter getDoubleValuesWriter(ColumnDescriptor path) { private ValuesWriter getFloatValuesWriter(ColumnDescriptor path) { final ValuesWriter fallbackWriter; - if (this.parquetProperties.isByteStreamSplitEnabled(path)) { + if (this.parquetProperties.isAlpEnabled(path)) { + fallbackWriter = new AlpValuesWriter.FloatAlpValuesWriter( + parquetProperties.getInitialSlabSize(), + parquetProperties.getPageSizeThreshold(), + parquetProperties.getAllocator(), + parquetProperties.getAlpConfig(path).getVectorSize()); + } else if (this.parquetProperties.isByteStreamSplitEnabled(path)) { fallbackWriter = new ByteStreamSplitValuesWriter.FloatByteStreamSplitValuesWriter( parquetProperties.getInitialSlabSize(), parquetProperties.getPageSizeThreshold(), diff --git a/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV2ValuesWriterFactory.java b/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV2ValuesWriterFactory.java index c50b4e49c5..b330e3834f 100644 --- a/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV2ValuesWriterFactory.java +++ b/parquet-column/src/main/java/org/apache/parquet/column/values/factory/DefaultV2ValuesWriterFactory.java @@ -25,6 +25,7 @@ import org.apache.parquet.column.Encoding; import org.apache.parquet.column.ParquetProperties; import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.alp.AlpValuesWriter; import org.apache.parquet.column.values.bytestreamsplit.ByteStreamSplitValuesWriter; import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriterForInteger; import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriterForLong; @@ -159,7 +160,13 @@ private ValuesWriter getInt96ValuesWriter(ColumnDescriptor path) { private ValuesWriter getDoubleValuesWriter(ColumnDescriptor path) { final ValuesWriter fallbackWriter; - if (this.parquetProperties.isByteStreamSplitEnabled(path)) { + if (this.parquetProperties.isAlpEnabled(path)) { + fallbackWriter = new AlpValuesWriter.DoubleAlpValuesWriter( + parquetProperties.getInitialSlabSize(), + parquetProperties.getPageSizeThreshold(), + parquetProperties.getAllocator(), + parquetProperties.getAlpConfig(path).getVectorSize()); + } else if (this.parquetProperties.isByteStreamSplitEnabled(path)) { fallbackWriter = new ByteStreamSplitValuesWriter.DoubleByteStreamSplitValuesWriter( parquetProperties.getInitialSlabSize(), parquetProperties.getPageSizeThreshold(), @@ -176,7 +183,13 @@ private ValuesWriter getDoubleValuesWriter(ColumnDescriptor path) { private ValuesWriter getFloatValuesWriter(ColumnDescriptor path) { final ValuesWriter fallbackWriter; - if (this.parquetProperties.isByteStreamSplitEnabled(path)) { + if (this.parquetProperties.isAlpEnabled(path)) { + fallbackWriter = new AlpValuesWriter.FloatAlpValuesWriter( + parquetProperties.getInitialSlabSize(), + parquetProperties.getPageSizeThreshold(), + parquetProperties.getAllocator(), + parquetProperties.getAlpConfig(path).getVectorSize()); + } else if (this.parquetProperties.isByteStreamSplitEnabled(path)) { fallbackWriter = new ByteStreamSplitValuesWriter.FloatByteStreamSplitValuesWriter( parquetProperties.getInitialSlabSize(), parquetProperties.getPageSizeThreshold(), diff --git a/parquet-column/src/test/java/org/apache/parquet/column/TestParquetProperties.java b/parquet-column/src/test/java/org/apache/parquet/column/TestParquetProperties.java index 6d51f67cb0..79336d90a4 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/TestParquetProperties.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/TestParquetProperties.java @@ -24,6 +24,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.apache.parquet.column.page.mem.MemPageStore; +import org.apache.parquet.column.values.alp.AlpConfig; import org.apache.parquet.schema.MessageType; import org.apache.parquet.schema.MessageTypeParser; import org.junit.jupiter.api.BeforeEach; @@ -138,4 +140,202 @@ public void copyBuilder_preservesColumnCodecAndLevel() { assertThat(copy.getColumnCompressionLevel(colB)).isNull(); assertThat(copy.getColumnCodec(colC)).isNull(); } + + private static void createWriteStore(ParquetProperties props) { + props.newColumnWriteStore(SCHEMA, new MemPageStore(0)); + } + + @Test + public void alp_notSet_isDisabledAndHasNoConfig() { + ParquetProperties props = ParquetProperties.builder().build(); + assertThat(props.isAlpEnabled(colC)).isFalse(); + assertThat(props.getAlpConfig(colC)).isNull(); + } + + @Test + public void alp_enabledByDefault_appliesOnlyToFloatAndDoubleColumns() { + ParquetProperties props = ParquetProperties.builder().withAlp().build(); + + assertThat(props.isAlpEnabled(colC)).isTrue(); + assertThat(props.getAlpConfig(colC).getVectorSize()).isEqualTo(AlpConfig.DEFAULT_VECTOR_SIZE); + assertThat(props.isAlpEnabled(colA)).isFalse(); + assertThat(props.isAlpEnabled(colB)).isFalse(); + + createWriteStore(props); + } + + @Test + public void alp_enabledPerColumn_usesGivenConfig() { + ParquetProperties props = ParquetProperties.builder() + .withAlp("col_c", new AlpConfig(4096)) + .build(); + + assertThat(props.getAlpConfig(colC).getVectorSize()).isEqualTo(4096); + createWriteStore(props); + } + + @Test + public void withoutAlp_clearsTheDefault() { + ParquetProperties props = + ParquetProperties.builder().withAlp().withoutAlp().build(); + + assertThat(props.isAlpEnabled(colC)).isFalse(); + } + + @Test + public void alp_onNonFloatOrDoubleColumn_fails() { + ParquetProperties props = ParquetProperties.builder().withAlp("col_b").build(); + + assertThatThrownBy(() -> createWriteStore(props)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("col_b") + .hasMessageContaining("only supports FLOAT and DOUBLE"); + } + + @Test + public void alp_withByteStreamSplitOnTheSameColumn_fails() { + ParquetProperties props = ParquetProperties.builder() + .withAlp() + .withByteStreamSplitEncoding(true) + .build(); + + assertThatThrownBy(() -> createWriteStore(props)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("col_c") + .hasMessageContaining("BYTE_STREAM_SPLIT"); + } + + @Test + public void alp_withByteStreamSplitOnDifferentColumns_isAllowed() { + ParquetProperties props = ParquetProperties.builder() + .withAlp("col_c") + .withByteStreamSplitEncoding("col_b", true) + .build(); + + assertThat(props.isAlpEnabled(colC)).isTrue(); + assertThat(props.isByteStreamSplitEnabled(colB)).isTrue(); + + createWriteStore(props); + } + + @Test + public void copyBuilder_preservesAlp() { + ParquetProperties original = ParquetProperties.builder() + .withAlp("col_c", new AlpConfig(2048)) + .build(); + + ParquetProperties copy = ParquetProperties.copy(original).build(); + + assertThat(copy.getAlpConfig(colC).getVectorSize()).isEqualTo(2048); + } + + @Test + public void toString_showsAlpOffWhenNotConfigured() { + assertThat(ParquetProperties.builder().build()).asString().contains("ALP: off"); + } + + @Test + public void toString_showsAlpConfigWhenEnabled() { + String text = + ParquetProperties.builder().withAlp(new AlpConfig(2048)).build().toString(); + assertThat(text).contains("ALP: ").contains("2048").doesNotContain("ALP: off"); + } + + @Test + public void toString_showsAlpOffWithTheColumnWhenOnlySetPerColumn() { + String text = ParquetProperties.builder().withAlp("col_c").build().toString(); + assertThat(text).contains("ALP: off {col_c=AlpConfig{vectorSize=1024}}"); + } + + @Test + public void toString_neverRendersAlpAsNull() { + assertThat(ParquetProperties.builder().build()).asString().doesNotContain("ALP: null"); + assertThat(ParquetProperties.builder().withAlp().build()).asString().doesNotContain("ALP: null"); + assertThat(ParquetProperties.builder().withAlp("col_c").build()) + .asString() + .doesNotContain("ALP: null"); + assertThat(ParquetProperties.builder() + .withAlp() + .withoutAlp() + .withAlp("col_c") + .build()) + .asString() + .doesNotContain("ALP: null"); + } + + @Test + public void withoutAlpForColumn_overridesTheEnabledDefault() { + ParquetProperties props = + ParquetProperties.builder().withAlp().withoutAlp("col_c").build(); + + assertThat(props.isAlpEnabled(colC)).isFalse(); + assertThat(props.getAlpConfig(colC)).isNull(); + createWriteStore(props); + } + + @Test + public void withoutAlpForColumn_onNonFloatColumnIsAccepted() { + ParquetProperties props = + ParquetProperties.builder().withAlp().withoutAlp("col_b").build(); + + assertThat(props.isAlpEnabled(colB)).isFalse(); + createWriteStore(props); + } + + @Test + public void withoutAlpForColumn_leavesOtherColumnsEnabled() { + MessageType twoDoubles = + MessageTypeParser.parseMessageType("message t { required double d1; required double d2; }"); + ParquetProperties props = + ParquetProperties.builder().withAlp().withoutAlp("d1").build(); + + assertThat(props.isAlpEnabled(twoDoubles.getColumns().get(0))).isFalse(); + assertThat(props.isAlpEnabled(twoDoubles.getColumns().get(1))).isTrue(); + } + + @Test + public void withAlpForColumn_afterWithoutAlpForColumn_reEnablesIt() { + ParquetProperties props = ParquetProperties.builder() + .withAlp() + .withoutAlp("col_c") + .withAlp("col_c", new AlpConfig(2048)) + .build(); + + assertThat(props.getAlpConfig(colC).getVectorSize()).isEqualTo(2048); + } + + @Test + public void toString_showsDisabledColumnAsOffNotNull() { + String text = ParquetProperties.builder() + .withAlp() + .withoutAlp("col_c") + .build() + .toString(); + + assertThat(text).contains("col_c=off").doesNotContain("col_c=null"); + } + + @Test + public void copyBuilder_preservesAColumnDisabledByName() { + ParquetProperties original = + ParquetProperties.builder().withAlp().withoutAlp("col_c").build(); + + assertThat(ParquetProperties.copy(original).build().isAlpEnabled(colC)).isFalse(); + } + + /** + * Compression level is the only per-column property that can hold a null, so it pins the + * ColumnProperty change that lets an explicit null override a default. + */ + @Test + public void columnCompressionLevel_explicitNullStillResolvesToNull() { + ParquetProperties props = ParquetProperties.builder() + .withCompressionLevel("col_a", null) + .withCompressionLevel("col_b", 7) + .build(); + + assertThat(props.getColumnCompressionLevel(colA)).isNull(); + assertThat(props.getColumnCompressionLevel(colB)).isEqualTo(7); + assertThat(props.getColumnCompressionLevel(colC)).isNull(); + } } diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpAdversarialTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpAdversarialTest.java new file mode 100644 index 0000000000..b39d18d660 --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpAdversarialTest.java @@ -0,0 +1,501 @@ +/* + * 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.column.values.alp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.DirectByteBufferAllocator; +import org.apache.parquet.io.ParquetDecodingException; +import org.assertj.core.api.ThrowableAssert.ThrowingCallable; +import org.junit.jupiter.api.Test; + +/** + * Adversarial tests for ALP readers: feed malformed page bytes and assert the reader + * fails cleanly rather than crashing, producing silent garbage, or hanging. + * + *

"Fails cleanly" means raising a meaningful exception — preferably + * {@link ParquetDecodingException}, but at minimum a typed exception (not a JVM-level + * crash, infinite loop, wrong answer, or an allocation large enough to exhaust the heap). + * The tests cover both: + *

    + *
  • Header and offset validation — the reader explicitly rejects these with a + * ParquetDecodingException carrying an explanatory message. These tests pin + * the validation behavior in place. + *
  • Truncation inside a vector body — the reader relies on the underlying ByteBuffer + * to surface IndexOutOfBoundsException or BufferUnderflowException. These tests + * assert a clean typed failure so the mode stays "loud" even without a message. + *
+ */ +public class AlpAdversarialTest { + + // --------------------------------------------------------------------------- + // Helpers: build a known-good encoded page, then mutate copies of it + // --------------------------------------------------------------------------- + + /** Build a valid ALP-encoded double page with N clean values. */ + private static byte[] validDoublePage(int valueCount, int vectorSize) throws Exception { + AlpValuesWriter.DoubleAlpValuesWriter writer = null; + try { + int cap = Math.max(512, valueCount * 16); + writer = new AlpValuesWriter.DoubleAlpValuesWriter(cap, cap, new DirectByteBufferAllocator(), vectorSize); + // 2-decimal values — the ALP sweet spot, ensures no exceptions + for (int i = 0; i < valueCount; i++) { + writer.writeDouble((i % 1000) / 100.0); + } + BytesInput bi = writer.getBytes(); + ByteBuffer bb = bi.toByteBuffer(); + byte[] out = new byte[bb.remaining()]; + bb.duplicate().get(out); + return out; + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + /** Build a valid ALP-encoded float page with N clean values. */ + private static byte[] validFloatPage(int valueCount, int vectorSize) throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + int cap = Math.max(256, valueCount * 8); + writer = new AlpValuesWriter.FloatAlpValuesWriter(cap, cap, new DirectByteBufferAllocator(), vectorSize); + for (int i = 0; i < valueCount; i++) { + writer.writeFloat((i % 1000) / 100.0f); + } + BytesInput bi = writer.getBytes(); + ByteBuffer bb = bi.toByteBuffer(); + byte[] out = new byte[bb.remaining()]; + bb.duplicate().get(out); + return out; + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + /** Sanity baseline: the known-good page actually decodes cleanly. */ + @Test + public void sanityBaselineDecodesClean() throws Exception { + byte[] page = validDoublePage(32, 16); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < 32; i++) reader.readDouble(); + } + + // --------------------------------------------------------------------------- + // Header-level validation (already-validated paths) + // --------------------------------------------------------------------------- + + @Test + public void rejectsBadCompressionMode() throws Exception { + byte[] page = validDoublePage(32, 16); + page[0] = (byte) 0x99; // mode is at byte 0 + assertThatThrownBy(() -> new AlpValuesReaderForDouble() + .initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page)))) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Unsupported ALP compression mode"); + } + + @Test + public void rejectsBadIntegerEncoding() throws Exception { + byte[] page = validDoublePage(32, 16); + page[1] = (byte) 0x99; // integer_encoding is at byte 1 + assertThatThrownBy(() -> new AlpValuesReaderForDouble() + .initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page)))) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Unsupported ALP integer encoding"); + } + + @Test + public void rejectsLogVectorSizeTooLarge() throws Exception { + byte[] page = validDoublePage(32, 16); + page[2] = (byte) 99; // log_vector_size at byte 2 + assertThatThrownBy(() -> new AlpValuesReaderForDouble() + .initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page)))) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Invalid ALP log vector size"); + } + + @Test + public void rejectsLogVectorSizeTooSmall() throws Exception { + byte[] page = validDoublePage(32, 16); + page[2] = (byte) 2; // below MIN_LOG_VECTOR_SIZE=3 + assertThatThrownBy(() -> new AlpValuesReaderForDouble() + .initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page)))) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Invalid ALP log vector size"); + } + + @Test + public void rejectsNegativeNumElements() throws Exception { + byte[] page = validDoublePage(32, 16); + // num_elements is int32 LE at bytes 3..6 — write -1 + page[3] = (byte) 0xFF; + page[4] = (byte) 0xFF; + page[5] = (byte) 0xFF; + page[6] = (byte) 0xFF; + assertThatThrownBy(() -> new AlpValuesReaderForDouble() + .initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page)))) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Invalid ALP element count"); + } + + @Test + public void rejectsNumElementsGreaterThanValuesCount() throws Exception { + byte[] page = validDoublePage(32, 16); + // num_elements stays 32; pass valuesCount=10 (smaller than encoded count) + assertThatThrownBy(() -> new AlpValuesReaderForDouble() + .initFromPage(10, ByteBufferInputStream.wrap(ByteBuffer.wrap(page)))) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("exceeds page valuesCount"); + } + + // --------------------------------------------------------------------------- + // Vector-level validation (already-validated paths, surface lazily on decode) + // --------------------------------------------------------------------------- + + /** Helper: find the byte position where the first vector's metadata starts. */ + private static int firstVectorOffset(byte[] page) { + // header (7) + first 4 bytes of offset array = the offset value itself + int firstVectorOff = + ByteBuffer.wrap(page, 7, 4).order(ByteOrder.LITTLE_ENDIAN).getInt(); + // offsets are measured from the start of the compression body (after the 7B header) + return 7 + firstVectorOff; + } + + @Test + public void rejectsExponentTooHighDouble() throws Exception { + byte[] page = validDoublePage(32, 16); + int v0 = firstVectorOffset(page); + page[v0] = (byte) 99; // exponent byte + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThatThrownBy(reader::readDouble) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Invalid ALP double exponent"); + } + + @Test + public void rejectsExponentTooHighFloat() throws Exception { + byte[] page = validFloatPage(32, 16); + int v0 = firstVectorOffset(page); + page[v0] = (byte) 99; + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThatThrownBy(reader::readFloat) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Invalid ALP float exponent"); + } + + @Test + public void rejectsFactorGreaterThanExponent() throws Exception { + byte[] page = validDoublePage(32, 16); + int v0 = firstVectorOffset(page); + page[v0] = (byte) 2; // exponent + page[v0 + 1] = (byte) 5; // factor > exponent + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThatThrownBy(reader::readDouble) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Invalid ALP double factor"); + } + + @Test + public void rejectsTooManyExceptions() throws Exception { + byte[] page = validDoublePage(32, 16); + int v0 = firstVectorOffset(page); + // num_exceptions at v0+2, uint16 LE — set to 9999, way more than vectorLen=16 + page[v0 + 2] = (byte) (9999 & 0xFF); + page[v0 + 3] = (byte) ((9999 >>> 8) & 0xFF); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThatThrownBy(reader::readDouble) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Invalid ALP numExceptions"); + } + + @Test + public void rejectsBitWidthTooLargeDouble() throws Exception { + byte[] page = validDoublePage(32, 16); + int v0 = firstVectorOffset(page); + // Layout: ALP_INFO(4) + frameOfReference(8) then bitWidth byte at v0+12. 99 > 64. + page[v0 + 12] = (byte) 99; + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThatThrownBy(reader::readDouble) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Invalid ALP double bitWidth"); + } + + @Test + public void rejectsBitWidthTooLargeFloat() throws Exception { + byte[] page = validFloatPage(32, 16); + int v0 = firstVectorOffset(page); + // Layout: ALP_INFO(4) + frameOfReference(4) then bitWidth byte at v0+8. 99 > 32. + page[v0 + 8] = (byte) 99; + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThatThrownBy(reader::readFloat) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Invalid ALP float bitWidth"); + } + + /** + * A bit width that is legal on its own but larger than the vector actually used makes the packed + * values overrun into the next vector. Decoding another vector's bytes would be a silent wrong + * answer, so this must be rejected. + */ + @Test + public void rejectsPackedValuesRunningPastVectorEnd() throws Exception { + // 16 vectors, so the inflated packed body stays inside the page and crosses into the vectors + // that follow; the last-vector case, which would run off the end of the page, is below. + byte[] page = validDoublePage(256, 16); + int v0 = firstVectorOffset(page); + // bitWidth lives after ALP_INFO(4) + frameOfReference(8). 64 is the maximum legal width, so it + // passes the width check and then claims far more packed bytes than this vector holds. + page[v0 + 12] = (byte) 64; + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(256, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThatThrownBy(reader::readDouble) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("packed values end at"); + } + + /** + * The same forged bit width on the last vector would read past the end of the page. It must be + * rejected as a decoding error before unpacking, not surface as an IndexOutOfBoundsException. + */ + @Test + public void rejectsPackedValuesRunningPastEndOfLastVector() throws Exception { + byte[] page = validDoublePage(20, 8); // 3 vectors, the last holding 4 values + int lastOffset = ByteBuffer.wrap(page).order(ByteOrder.LITTLE_ENDIAN).getInt(7 + 2 * Integer.BYTES); + page[7 + lastOffset + 12] = (byte) 64; + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(20, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + reader.skip(16); + assertThatThrownBy(reader::readDouble) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("ALP vector 2 packed values end at"); + } + + /** + * An exception count that is legal against the vector size can still declare an exception block + * larger than the vector holds. numExceptions == vectorSize passes the AlpInfo check and then + * needs 16 * (2 + 8) bytes, far more than this vector's span. + */ + @Test + public void rejectsExceptionBlockLargerThanVector() throws Exception { + byte[] page = validDoublePage(256, 16); + int v0 = firstVectorOffset(page); + page[v0 + 2] = (byte) 16; // num_exceptions, uint16 LE + page[v0 + 3] = (byte) 0; + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(256, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThatThrownBy(reader::readDouble) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("exceptions needing"); + } + + /** + * An exception position at or past the end of the vector would write outside the decoded values. + * Exercises the static reader directly, which is the whole point of it taking its inputs + * explicitly rather than reaching for fields. + */ + @Test + public void rejectsExceptionPositionOutOfBounds() { + ByteBuffer data = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN); + data.putShort(0, (short) 3); // in range + data.putShort(2, (short) 16); // == vectorSize, so one past the last valid slot + int[] positions = new int[4]; + + assertThatThrownBy(() -> AlpValuesReader.readExceptionPositions(data, 0, 2, 16, positions)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("ALP exception position 16 out of bounds for vectorSize 16"); + } + + /** Each vector must span at least its fixed headers, the last one included via the end entry. */ + @Test + public void rejectsVectorShorterThanItsHeaders() { + // offset array of 2 entries (8 bytes), then vectors at 8 and 30, data ending at 40. + assertThatThrownBy(() -> AlpValuesReader.validateVectorOffsets(new int[] {8, 30, 40}, 17, 1000)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("ALP vector 1 at offset 30 spans 10 bytes, fewer than the 17"); + } + + /** No vector can be longer than every value packed at full width and also stored as an exception. */ + @Test + public void rejectsVectorLongerThanAnyVectorCanBe() { + int max = AlpValuesReader.maxVectorBytes(16, 17, Double.BYTES); + assertThat(max).isEqualTo(17 + 16 * 8 + 16 * (2 + 8)); + AlpValuesReader.validateVectorOffsets(new int[] {8, 8 + max, 8 + max + 17}, 17, max); + assertThatThrownBy(() -> AlpValuesReader.validateVectorOffsets(new int[] {8, 9 + max, 9 + max + 17}, 17, max)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("ALP vector 0 at offset 8 spans " + (max + 1) + " bytes, more than the " + max); + } + + /** The end entry makes every in-range index valid, and anything else is a caller error. */ + @Test + public void vectorEndPositionRejectsOutOfRangeIndex() throws Exception { + byte[] page = validDoublePage(32, 16); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThat(reader.getVectorEndPosition(1)).isEqualTo(page.length - 7 - 2 * Integer.BYTES); + for (int bad : new int[] {-1, 2, Integer.MAX_VALUE}) { + assertThatThrownBy(() -> reader.getVectorEndPosition(bad)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("out of range for 2 vectors"); + } + } + + /** The last vector is partial whenever the value count is not a whole number of vectors. */ + @Test + public void vectorSizeAccountsForAPartialLastVector() { + // 3 vectors of 16 holding 40 values: two full, one holding the remaining 8. + assertThat(AlpValuesReader.getVectorSize(0, 16, 40, 3)).isEqualTo(16); + assertThat(AlpValuesReader.getVectorSize(1, 16, 40, 3)).isEqualTo(16); + assertThat(AlpValuesReader.getVectorSize(2, 16, 40, 3)).isEqualTo(8); + // An exact multiple leaves the last vector full rather than empty. + assertThat(AlpValuesReader.getVectorSize(1, 16, 32, 2)).isEqualTo(16); + assertThat(AlpValuesReader.isLast(1, 2)).isTrue(); + assertThat(AlpValuesReader.isLast(0, 2)).isFalse(); + } + + // --------------------------------------------------------------------------- + // Truncation and corrupted offsets + // Each of these must fail cleanly: a catchable decoding exception, never an OutOfMemoryError + // from an unbounded allocation and never a silent wrong-value decode. See assertFailsCleanly. + // --------------------------------------------------------------------------- + + /** Page with only the 7-byte header — nothing else. */ + @Test + public void rejectsHeaderOnlyPage() { + byte[] tiny = new byte[] {0x00, 0x00, 0x0A, 0x20, 0x00, 0x00, 0x00}; // 32 elements, log_vec=10 + assertFailsCleanly(() -> + new AlpValuesReaderForDouble().initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(tiny)))); + } + + @Test + public void rejectsPageTruncatedMidOffsetArray() throws Exception { + byte[] page = validDoublePage(32, 16); + // num_vectors = ceil(32/16) = 2, so offset array is 8 bytes. Truncate to chop the 2nd offset. + byte[] truncated = new byte[7 + 4]; // header + first offset only + System.arraycopy(page, 0, truncated, 0, truncated.length); + assertFailsCleanly(() -> new AlpValuesReaderForDouble() + .initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(truncated)))); + } + + @Test + public void rejectsPageTruncatedMidVectorData() throws Exception { + byte[] page = validDoublePage(32, 16); + // chop the last 20 bytes — guaranteed to land in the middle of the second vector + byte[] truncated = new byte[page.length - 20]; + System.arraycopy(page, 0, truncated, 0, truncated.length); + // The offset array still describes a second vector that the truncated body cannot hold, so + // this is now caught up front when the offsets are validated against the body length. + assertFailsCleanly(() -> new AlpValuesReaderForDouble() + .initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(truncated)))); + } + + @Test + public void rejectsCorruptedOffsetPointingPastEnd() throws Exception { + byte[] page = validDoublePage(32, 16); + // Offset array starts at byte 7. Overwrite the first offset (uint32 LE) with a huge value. + page[7] = (byte) 0xFF; + page[8] = (byte) 0xFF; + page[9] = (byte) 0xFF; + page[10] = (byte) 0x7F; + // Offsets are validated against the body length in initFromPage, so a bogus offset is + // rejected before any vector is decoded rather than reading out of bounds later. + assertFailsCleanly(() -> + new AlpValuesReaderForDouble().initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page)))); + } + + // --------------------------------------------------------------------------- + // skip() / read() bounds + // --------------------------------------------------------------------------- + + @Test + public void rejectsSkipPastEnd() throws Exception { + byte[] page = validDoublePage(32, 16); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThatThrownBy(() -> reader.skip(33)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Cannot skip this many elements"); + } + + @Test + public void rejectsNegativeSkip() throws Exception { + byte[] page = validDoublePage(32, 16); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(32, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + assertThatThrownBy(() -> reader.skip(-1)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Cannot skip this many elements"); + } + + @Test + public void rejectsReadPastEnd() throws Exception { + byte[] page = validDoublePage(8, 8); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(8, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < 8; i++) reader.readDouble(); + assertThatThrownBy(reader::readDouble) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("ALP double data was already exhausted"); + } + + // --------------------------------------------------------------------------- + // Utility + // --------------------------------------------------------------------------- + + /** + * Requires {@code action} to fail, and requires that failure to be a clean one. + * + *

The accepted types are deliberately narrow. An {@link OutOfMemoryError} means a corrupt + * length drove an unbounded allocation, and an {@link AssertionError} means an assertion inside + * {@code action} failed; accepting any {@link Throwable} here would let either count as a + * successful rejection. + * + *

No message is asserted because these failures come from two sources: the reader's own + * validation, which carries an explanatory message, and the underlying {@link ByteBuffer}, whose + * {@link IndexOutOfBoundsException} and {@link BufferUnderflowException} have none. What these + * tests pin down is that the failure is loud and typed, not what it says. + */ + private static void assertFailsCleanly(ThrowingCallable action) { + assertThatThrownBy(action) + .isInstanceOfAny( + ParquetDecodingException.class, + IOException.class, + IndexOutOfBoundsException.class, + BufferUnderflowException.class, + IllegalArgumentException.class); + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpBitPackingTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpBitPackingTest.java new file mode 100644 index 0000000000..c26bb50d11 --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpBitPackingTest.java @@ -0,0 +1,298 @@ +/* + * 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.column.values.alp; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.DirectByteBufferAllocator; +import org.apache.parquet.column.values.bitpacking.BytePacker; +import org.apache.parquet.column.values.bitpacking.BytePackerForLong; +import org.apache.parquet.column.values.bitpacking.Packer; +import org.junit.jupiter.api.Test; + +/** + * Tests for bit-packing behavior in the ALP encoding pipeline. + */ +public class AlpBitPackingTest { + + @Test + public void testBytePackerIntRoundTrip() { + // Verify BytePacker pack/unpack round-trip for every bit width, including the + // full 32-bit width where the frame-of-reference delta uses all int bits. + for (int bitWidth = 1; bitWidth <= 32; bitWidth++) { + BytePacker packer = Packer.LITTLE_ENDIAN.newBytePacker(bitWidth); + // Largest value representable in bitWidth bits (unsigned). At width 32 that is + // all bits set (-1); (1L << 32) - 1 would not fit in an int, so special-case it. + int maxVal = (bitWidth == 32) ? -1 : (int) ((1L << bitWidth) - 1); + + int[] input = new int[8]; + for (int i = 0; i < 7; i++) { + input[i] = (maxVal >>> 3) * i; // spread of values, each fitting in bitWidth bits + } + input[7] = maxVal; // exercise the top bit of this width + + byte[] packed = new byte[bitWidth]; + packer.pack8Values(input, 0, packed, 0); + + int[] unpacked = new int[8]; + ByteBuffer buf = ByteBuffer.wrap(packed); + packer.unpack8Values(buf, 0, unpacked, 0); + + for (int i = 0; i < 8; i++) { + assertThat(unpacked[i]) + .as("BitWidth=" + bitWidth + " index=" + i) + .isEqualTo(input[i]); + } + } + } + + @Test + public void testBytePackerForLongRoundTrip() { + // Verify BytePackerForLong pack/unpack round-trip for every bit width, including the + // full 64-bit width where the frame-of-reference delta uses all long bits. + for (int bitWidth = 1; bitWidth <= 64; bitWidth++) { + BytePackerForLong packer = Packer.LITTLE_ENDIAN.newBytePackerForLong(bitWidth); + // Largest value representable in bitWidth bits (unsigned). At width 64 that is + // all bits set (-1L); (1L << 64) - 1 would wrap to 0, so special-case it. + long maxVal = (bitWidth == 64) ? -1L : (1L << bitWidth) - 1; + + long[] input = new long[8]; + for (int i = 0; i < 7; i++) { + input[i] = (maxVal >>> 3) * i; // spread of values, each fitting in bitWidth bits + } + input[7] = maxVal; // exercise the top bit of this width + + byte[] packed = new byte[bitWidth]; + packer.pack8Values(input, 0, packed, 0); + + long[] unpacked = new long[8]; + ByteBuffer buf = ByteBuffer.wrap(packed); + packer.unpack8Values(buf, 0, unpacked, 0); + + for (int i = 0; i < 8; i++) { + assertThat(unpacked[i]) + .as("BitWidth=" + bitWidth + " index=" + i) + .isEqualTo(input[i]); + } + } + } + + @Test + public void testSimpleTwoFloats() throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + writer.writeFloat(1.0f); + writer.writeFloat(2.0f); + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(2, ByteBufferInputStream.wrap(input.toByteBuffer())); + + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(1.0f)); + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(2.0f)); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testThreeFloatsWithNegative() throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + writer.writeFloat(1.0f); + writer.writeFloat(-1.0f); + writer.writeFloat(2.0f); + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(3, ByteBufferInputStream.wrap(input.toByteBuffer())); + + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(1.0f)); + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(-1.0f)); + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(2.0f)); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testEncoderDecoderDirectly() { + float value = 1.0f; + int exponent = 2; + int factor = 0; + + AlpCodec.EncodeResult result = new AlpCodec.EncodeResult(); + AlpCodec.tryEncodeFloat(value, exponent, factor, result); + assertThat(result.isException).isFalse(); + int encoded = AlpCodec.encodeFloat(value, exponent, factor); + float decoded = AlpCodec.decodeFloat(encoded, exponent, factor); + assertThat(Float.floatToRawIntBits(decoded)).isEqualTo(Float.floatToRawIntBits(value)); + } + + @Test + public void testHeaderFormatValidation() throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + writer.writeFloat(1.0f); + writer.writeFloat(2.0f); + + BytesInput input = writer.getBytes(); + byte[] bytes = input.toByteArray(); + + // Parse and validate header (7 bytes: mode + encoding + logVectorSize + count) + ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + int compressionMode = buf.get() & 0xFF; + int integerEncoding = buf.get() & 0xFF; + int logVectorSize = buf.get() & 0xFF; + int numElements = buf.getInt(); + + assertThat(compressionMode).isEqualTo(AlpConstants.ALP_COMPRESSION_MODE); + assertThat(integerEncoding).isEqualTo(AlpConstants.ALP_INTEGER_ENCODING_FOR); + assertThat(logVectorSize).isEqualTo(10); + assertThat(numElements).isEqualTo(2); + + // Verify offset array follows header (1 vector = 1 offset entry) + int offset0 = buf.getInt(); + // First vector starts right after the offset array (1 vector * 4 bytes = 4) + assertThat(offset0).isEqualTo(Integer.BYTES); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testPartialGroupPacking() throws Exception { + // Test with fewer than 8 values to exercise partial group packing/unpacking + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + float[] values = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f}; + for (float v : values) { + writer.writeFloat(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (float expected : values) { + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(expected)); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testBitWidthZeroPacking() throws Exception { + // All identical values should result in bitWidth=0 (no packed data) + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + for (int i = 0; i < 10; i++) { + writer.writeFloat(5.0f); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(10, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (int i = 0; i < 10; i++) { + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(5.0f)); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testDoublePackingRoundTrip() throws Exception { + // Verify double packing round-trip with varying values + AlpValuesWriter.DoubleAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.DoubleAlpValuesWriter(512, 512, new DirectByteBufferAllocator()); + double[] values = {1.0, -1.0, 2.0, 100.5, 0.0, 3.14, 42.0, 99.99, 0.001, 7.77}; + for (double v : values) { + writer.writeDouble(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (double expected : values) { + assertThat(Double.doubleToRawLongBits(reader.readDouble())) + .isEqualTo(Double.doubleToRawLongBits(expected)); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testExactEightValues() throws Exception { + // Exactly 8 values = one full packing group, no partial group + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + float[] values = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f}; + for (float v : values) { + writer.writeFloat(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (float expected : values) { + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(expected)); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpCodecTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpCodecTest.java new file mode 100644 index 0000000000..4d88c2ba65 --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpCodecTest.java @@ -0,0 +1,460 @@ +/* + * 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.column.values.alp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +import org.junit.jupiter.api.Test; + +/** + * Tests for the core ALP encoder/decoder logic. + */ +public class AlpCodecTest { + + // ========== Float Encoding/Decoding Tests ========== + + @Test + public void testFloatRoundTrip() { + float[] testValues = {0.0f, 1.0f, -1.0f, 3.14159f, 100.5f, 0.001f, 1234567.0f}; + + for (float value : testValues) { + for (int exponent = 0; exponent <= AlpConstants.FLOAT_MAX_EXPONENT; exponent++) { + for (int factor = 0; factor <= exponent; factor++) { + if (!isException(value, exponent, factor)) { + int encoded = AlpCodec.encodeFloat(value, exponent, factor); + float decoded = AlpCodec.decodeFloat(encoded, exponent, factor); + assertThat(Float.floatToRawIntBits(decoded)) + .as("Round-trip failed for value=" + value + ", exponent=" + exponent + ", factor=" + + factor) + .isEqualTo(Float.floatToRawIntBits(value)); + } + } + } + } + } + + @Test + public void testFloatExceptionDetection() { + assertThat(AlpCodec.isIntrinsicFloatException(Float.NaN)) + .as("NaN should be an exception") + .isTrue(); + assertThat(AlpCodec.isIntrinsicFloatException(Float.POSITIVE_INFINITY)) + .as("Positive infinity should be an exception") + .isTrue(); + assertThat(AlpCodec.isIntrinsicFloatException(Float.NEGATIVE_INFINITY)) + .as("Negative infinity should be an exception") + .isTrue(); + assertThat(AlpCodec.isIntrinsicFloatException(-0.0f)) + .as("Negative zero should be an exception") + .isTrue(); + + assertThat(AlpCodec.isIntrinsicFloatException(1.0f)) + .as("1.0f should not be a basic exception") + .isFalse(); + assertThat(AlpCodec.isIntrinsicFloatException(0.0f)) + .as("0.0f should not be a basic exception") + .isFalse(); + } + + @Test + public void testFloatEncoding() { + assertThat(AlpCodec.encodeFloat(1.23f, 2, 0)).isEqualTo(123); + assertThat(AlpCodec.encodeFloat(12.3f, 2, 1)).isEqualTo(123); + assertThat(AlpCodec.encodeFloat(0.0f, 5, 0)).isZero(); + } + + @Test + public void testFloatDecoding() { + assertThat(AlpCodec.decodeFloat(123, 2, 0)).isCloseTo(1.23f, within(1e-6f)); + assertThat(AlpCodec.decodeFloat(123, 2, 1)).isCloseTo(12.3f, within(1e-6f)); + assertThat(AlpCodec.decodeFloat(0, 5, 0)).isEqualTo(0.0f); + } + + @Test + public void testFloatEncodeRounding() { + // Verify rounding behavior (magic number trick rounds to nearest) + assertThat(AlpCodec.encodeFloat(5.4f, 0, 0)).isEqualTo(5); + assertThat(AlpCodec.encodeFloat(5.6f, 0, 0)).isEqualTo(6); + assertThat(AlpCodec.encodeFloat(-5.4f, 0, 0)).isEqualTo(-5); + assertThat(AlpCodec.encodeFloat(-5.6f, 0, 0)).isEqualTo(-6); + assertThat(AlpCodec.encodeFloat(0.0f, 0, 0)).isZero(); + } + + @Test + public void testFloatEncodeDecodeWithFactor() { + // Verify that encode/decode with non-zero factor works correctly. + // The key correctness property: encode uses value * POW10[e] * POW10_NEGATIVE[f], + // and decode uses encoded * POW10[f] * POW10_NEGATIVE[e]. + float value = 12.3f; + int encoded = AlpCodec.encodeFloat(value, 2, 1); + assertThat(encoded).isEqualTo(123); // 12.3 * 100 * 0.1 = 123 + float decoded = AlpCodec.decodeFloat(encoded, 2, 1); + assertThat(Float.floatToRawIntBits(decoded)).isEqualTo(Float.floatToRawIntBits(value)); + } + + // ========== Double Encoding/Decoding Tests ========== + + @Test + public void testDoubleRoundTrip() { + double[] testValues = {0.0, 1.0, -1.0, 3.14159265358979, 100.5, 0.001, 12345678901234.0}; + + for (double value : testValues) { + for (int exponent = 0; exponent <= Math.min(AlpConstants.DOUBLE_MAX_EXPONENT, 10); exponent++) { + for (int factor = 0; factor <= exponent; factor++) { + if (!isException(value, exponent, factor)) { + long encoded = AlpCodec.encodeDouble(value, exponent, factor); + double decoded = AlpCodec.decodeDouble(encoded, exponent, factor); + assertThat(Double.doubleToRawLongBits(decoded)) + .as("Round-trip failed for value=" + value + ", exponent=" + exponent + ", factor=" + + factor) + .isEqualTo(Double.doubleToRawLongBits(value)); + } + } + } + } + } + + @Test + public void testDoubleExceptionDetection() { + assertThat(AlpCodec.isIntrinsicDoubleException(Double.NaN)) + .as("NaN should be an exception") + .isTrue(); + assertThat(AlpCodec.isIntrinsicDoubleException(Double.POSITIVE_INFINITY)) + .as("Positive infinity should be an exception") + .isTrue(); + assertThat(AlpCodec.isIntrinsicDoubleException(Double.NEGATIVE_INFINITY)) + .as("Negative infinity should be an exception") + .isTrue(); + assertThat(AlpCodec.isIntrinsicDoubleException(-0.0)) + .as("Negative zero should be an exception") + .isTrue(); + + assertThat(AlpCodec.isIntrinsicDoubleException(1.0)) + .as("1.0 should not be a basic exception") + .isFalse(); + assertThat(AlpCodec.isIntrinsicDoubleException(0.0)) + .as("0.0 should not be a basic exception") + .isFalse(); + } + + @Test + public void testDoubleEncoding() { + assertThat(AlpCodec.encodeDouble(1.23, 2, 0)).isEqualTo(123L); + assertThat(AlpCodec.encodeDouble(12.3, 2, 1)).isEqualTo(123L); + assertThat(AlpCodec.encodeDouble(0.0, 5, 0)).isZero(); + } + + @Test + public void testDoubleDecoding() { + assertThat(AlpCodec.decodeDouble(123, 2, 0)).isCloseTo(1.23, within(1e-10)); + assertThat(AlpCodec.decodeDouble(123, 2, 1)).isCloseTo(12.3, within(1e-10)); + assertThat(AlpCodec.decodeDouble(0, 5, 0)).isEqualTo(0.0); + } + + @Test + public void testDoubleEncodeRounding() { + // Verify rounding behavior (magic number trick rounds to nearest) + assertThat(AlpCodec.encodeDouble(5.4, 0, 0)).isEqualTo(5L); + assertThat(AlpCodec.encodeDouble(5.6, 0, 0)).isEqualTo(6L); + assertThat(AlpCodec.encodeDouble(-5.4, 0, 0)).isEqualTo(-5L); + assertThat(AlpCodec.encodeDouble(-5.6, 0, 0)).isEqualTo(-6L); + assertThat(AlpCodec.encodeDouble(0.0, 0, 0)).isZero(); + } + + @Test + public void testDoubleEncodeDecodeWithFactor() { + // Verify that encode/decode with non-zero factor works correctly. + // The key correctness property: encode uses value * POW10[e] * POW10_NEGATIVE[f], + // and decode uses encoded * POW10[f] * POW10_NEGATIVE[e]. + double value = 12.3; + long encoded = AlpCodec.encodeDouble(value, 2, 1); + assertThat(encoded).isEqualTo(123L); // 12.3 * 100 * 0.1 = 123 + double decoded = AlpCodec.decodeDouble(encoded, 2, 1); + assertThat(Double.doubleToRawLongBits(decoded)).isEqualTo(Double.doubleToRawLongBits(value)); + } + + @Test + public void testDoubleEncodeDecodeArithmeticOrder() { + // This test verifies that the exact order of operations in encode/decode + // is critical for IEEE 754 correctness. The encode uses + // fastRound(value * POW10[e] * POW10_NEGATIVE[f]) and decode uses + // (encoded * POW10[f] * POW10_NEGATIVE[e]), both as single expressions. + // Splitting the multiplies or reordering changes rounding. + double[] testValues = {0.123456789, 1.23456789, 12.3456789, 123.456789, 1234.56789}; + for (double value : testValues) { + for (int e = 0; e <= 10; e++) { + for (int f = 0; f <= e; f++) { + if (!isException(value, e, f)) { + long encoded = AlpCodec.encodeDouble(value, e, f); + double decoded = AlpCodec.decodeDouble(encoded, e, f); + assertThat(Double.doubleToRawLongBits(decoded)) + .as("Roundtrip failed for " + value + " (e=" + e + ", f=" + f + ")") + .isEqualTo(Double.doubleToRawLongBits(value)); + } + } + } + } + } + + // ========== Best Parameters Tests ========== + + @Test + public void testFindBestFloatParams() { + float[] values = {1.23f, 4.56f, 7.89f, 10.11f, 12.13f}; + AlpCodec.EncodingParams params = + AlpCodec.findBestFloatParams(values, 0, values.length, AlpCodec.ALL_VALID_FLOAT_PAIRS); + + assertThat(params).isNotNull(); + assertThat(params.exponent).isBetween(0, AlpConstants.FLOAT_MAX_EXPONENT); + assertThat(params.factor).isBetween(0, params.exponent); + + for (float v : values) { + if (!isException(v, params.exponent, params.factor)) { + int encoded = AlpCodec.encodeFloat(v, params.exponent, params.factor); + float decoded = AlpCodec.decodeFloat(encoded, params.exponent, params.factor); + assertThat(Float.floatToRawIntBits(decoded)).isEqualTo(Float.floatToRawIntBits(v)); + } + } + } + + @Test + public void testFindBestFloatParamsWithAllExceptions() { + float[] values = {Float.NaN, Float.NaN, Float.NaN}; + AlpCodec.EncodingParams params = + AlpCodec.findBestFloatParams(values, 0, values.length, AlpCodec.ALL_VALID_FLOAT_PAIRS); + + assertThat(params).isNotNull(); + assertThat(params.numExceptions).isEqualTo(values.length); + } + + @Test + public void testFindBestDoubleParams() { + double[] values = {1.23, 4.56, 7.89, 10.11, 12.13}; + AlpCodec.EncodingParams params = + AlpCodec.findBestDoubleParams(values, 0, values.length, AlpCodec.ALL_VALID_DOUBLE_PAIRS); + + assertThat(params).isNotNull(); + assertThat(params.exponent).isBetween(0, AlpConstants.DOUBLE_MAX_EXPONENT); + assertThat(params.factor).isBetween(0, params.exponent); + + for (double v : values) { + if (!isException(v, params.exponent, params.factor)) { + long encoded = AlpCodec.encodeDouble(v, params.exponent, params.factor); + double decoded = AlpCodec.decodeDouble(encoded, params.exponent, params.factor); + assertThat(Double.doubleToRawLongBits(decoded)).isEqualTo(Double.doubleToRawLongBits(v)); + } + } + } + + @Test + public void testFindBestDoubleParamsWithAllExceptions() { + double[] values = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}; + AlpCodec.EncodingParams params = + AlpCodec.findBestDoubleParams(values, 0, values.length, AlpCodec.ALL_VALID_DOUBLE_PAIRS); + + assertThat(params).isNotNull(); + assertThat(params.numExceptions).isEqualTo(values.length); + } + + @Test + public void testFindBestParamsWithOffset() { + float[] values = {Float.NaN, Float.NaN, 1.23f, 4.56f, 7.89f, Float.NaN}; + AlpCodec.EncodingParams params = AlpCodec.findBestFloatParams(values, 2, 3, AlpCodec.ALL_VALID_FLOAT_PAIRS); + + assertThat(params).isNotNull(); + assertThat(params.numExceptions).isZero(); + } + + // ========== Preset-Based Parameter Search Tests ========== + + @Test + public void testFindBestFloatParamsOverPresets() { + float[] values = {1.23f, 4.56f, 7.89f, 10.11f, 12.13f}; + int[][] presets = {{2, 0}, {3, 0}, {4, 1}}; + AlpCodec.EncodingParams params = AlpCodec.findBestFloatParams(values, 0, values.length, presets); + + assertThat(params).isNotNull(); + // Should select one of the preset combinations + boolean foundMatch = false; + for (int[] preset : presets) { + if (params.exponent == preset[0] && params.factor == preset[1]) { + foundMatch = true; + break; + } + } + assertThat(foundMatch) + .as("Result should be one of the preset combinations") + .isTrue(); + } + + @Test + public void testFindBestDoubleParamsOverPresets() { + double[] values = {1.23, 4.56, 7.89, 10.11, 12.13}; + int[][] presets = {{2, 0}, {3, 0}, {4, 1}}; + AlpCodec.EncodingParams params = AlpCodec.findBestDoubleParams(values, 0, values.length, presets); + + assertThat(params).isNotNull(); + boolean foundMatch = false; + for (int[] preset : presets) { + if (params.exponent == preset[0] && params.factor == preset[1]) { + foundMatch = true; + break; + } + } + assertThat(foundMatch) + .as("Result should be one of the preset combinations") + .isTrue(); + } + + @Test + public void testPresetsProduceSameResultAsFullSearch() { + float[] values = {1.23f, 4.56f, 7.89f}; + AlpCodec.EncodingParams fullResult = + AlpCodec.findBestFloatParams(values, 0, values.length, AlpCodec.ALL_VALID_FLOAT_PAIRS); + + // Include the best params in presets + int[][] presets = {{fullResult.exponent, fullResult.factor}, {0, 0}, {1, 0}}; + AlpCodec.EncodingParams presetResult = AlpCodec.findBestFloatParams(values, 0, values.length, presets); + + assertThat(presetResult.numExceptions) + .as("Preset result should be at least as good as full search") + .isLessThanOrEqualTo(fullResult.numExceptions); + } + + @Test + public void findBestFloatParamsMixedSignUsesSignedDeltaRange() { + // Mixed-sign, non-integer values are losslessly encodable at some (e, f). A buggy estimator + // that computes the FOR range with unsigned subtraction inflates every mixed-sign combo to a + // 64-bit width, making it prefer combos that turn values into exceptions. With the correct + // signed range (max - min), the estimator finds the lossless encoding with zero exceptions. + float[] values = {-3.14f, 2.71f, 100.5f, -50.25f}; + AlpCodec.EncodingParams params = + AlpCodec.findBestFloatParams(values, 0, values.length, AlpCodec.ALL_VALID_FLOAT_PAIRS); + assertThat(params.numExceptions).isZero(); + } + + @Test + public void findBestFloatParamsOverPresetsMixedSignUsesSignedDeltaRange() { + // Restricting to two presets removes the equivalent-scaling ties, so the chosen exponent is + // deterministic: e=0,f=0 has the smallest signed FOR span and wins. A buggy unsigned estimator + // would rate both presets at 64 bits, tie, and pick the higher exponent (e=1) instead. + float[] values = {-1.0f, 1.0f}; + int[][] presets = {{0, 0}, {1, 0}}; // e=0,f=0 has the smallest signed FOR span + AlpCodec.EncodingParams params = AlpCodec.findBestFloatParams(values, 0, values.length, presets); + assertThat(params.exponent).isZero(); + assertThat(params.factor).isZero(); + assertThat(params.numExceptions).isZero(); + } + + /** + * tryEncode computes the exception flag and the encoded value in one pass, reusing the scaled + * intermediate. It must agree exactly with the older isException-then-encode pair it replaced, + * including the raw bits of the encoding, since those bytes go on the wire. + */ + @Test + public void testTryEncodeMatchesSeparateExceptionCheckAndEncode() { + float[] floats = { + 0.0f, + -0.0f, + 1.0f, + -1.0f, + 0.01f, + 1234.56f, + 1e-8f, + 1e9f, + Float.MIN_VALUE, + Float.MAX_VALUE, + Float.NaN, + Float.POSITIVE_INFINITY, + Float.NEGATIVE_INFINITY, + 3.14159f, + -273.15f + }; + AlpCodec.EncodeResult result = new AlpCodec.EncodeResult(); + for (float value : floats) { + for (int e = 0; e <= AlpConstants.FLOAT_MAX_EXPONENT; e++) { + for (int f = 0; f <= e; f++) { + AlpCodec.tryEncodeFloat(value, e, f, result); + if (!result.isException) { + assertThat((int) result.encoded) + .as("float encoding for %s at e=%s f=%s", value, e, f) + .isEqualTo(AlpCodec.encodeFloat(value, e, f)); + } + } + } + } + + double[] doubles = { + 0.0, + -0.0, + 1.0, + -1.0, + 0.01, + 1234.56, + 1e-15, + 1e17, + Double.MIN_VALUE, + Double.MAX_VALUE, + Double.NaN, + Double.POSITIVE_INFINITY, + Double.NEGATIVE_INFINITY, + Math.PI, + -273.15 + }; + for (double value : doubles) { + for (int e = 0; e <= AlpConstants.DOUBLE_MAX_EXPONENT; e++) { + for (int f = 0; f <= e; f++) { + AlpCodec.tryEncodeDouble(value, e, f, result); + if (!result.isException) { + assertThat(result.encoded) + .as("double encoding for %s at e=%s f=%s", value, e, f) + .isEqualTo(AlpCodec.encodeDouble(value, e, f)); + } + } + } + } + } + + /** ±2^63 and ±2^31 round-trip through the cast to long or int, so only the limits reject them. */ + @Test + public void testEncodingLimitsDecideExceptions() { + assertThat(isException(Math.scalb(1.0, 62), 0, 0)).isFalse(); + assertThat(isException(-Math.scalb(1.0, 62), 0, 0)).isFalse(); + assertThat(isException(Math.scalb(1.0, 63), 0, 0)).isTrue(); + assertThat(isException(-Math.scalb(1.0, 63), 0, 0)).isTrue(); + + assertThat(isException(Math.scalb(1.0f, 30), 0, 0)).isFalse(); + assertThat(isException(-Math.scalb(1.0f, 30), 0, 0)).isFalse(); + assertThat(isException(Math.scalb(1.0f, 31), 0, 0)).isTrue(); + assertThat(isException(-Math.scalb(1.0f, 31), 0, 0)).isTrue(); + } + + private static boolean isException(float value, int exponent, int factor) { + AlpCodec.EncodeResult result = new AlpCodec.EncodeResult(); + AlpCodec.tryEncodeFloat(value, exponent, factor, result); + return result.isException; + } + + private static boolean isException(double value, int exponent, int factor) { + AlpCodec.EncodeResult result = new AlpCodec.EncodeResult(); + AlpCodec.tryEncodeDouble(value, exponent, factor, result); + return result.isException; + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpPageBytesTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpPageBytesTest.java new file mode 100644 index 0000000000..a9017324d6 --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpPageBytesTest.java @@ -0,0 +1,137 @@ +/* + * 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.column.values.alp; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import org.apache.parquet.bytes.DirectByteBufferAllocator; +import org.junit.jupiter.api.Test; + +/** + * Pins the exact bytes the writer produces. Round-trip tests pass for any valid encoding, so they + * miss changes that still decode, such as different exceptions, placeholders or preset order. + */ +public class AlpPageBytesTest { + + @Test + public void testDoublePage() throws Exception { + assertThat(doublePage(1.5, 2.25, 3.0)) + .containsExactly(bytes( + "00 00 03 03000000", // header: mode, integer encoding, log vector size 3, 3 values + "04000000", // offset array + "12 10 0000", // AlpInfo: exponent 18, factor 16, no exceptions + "9600000000000000 08", // ForInfo: frame of reference 150, bit width 8 + "00 4b 96")); // deltas 0, 75, 150 + } + + @Test + public void testDoublePageWithException() throws Exception { + assertThat(doublePage(0.5, Double.NaN, 1.25)) + .containsExactly(bytes( + "00 00 03 03000000", + "04000000", + "12 10 0100", // one exception + "3200000000000000 07", // frame of reference 50, bit width 7 + "00 c0 12", // deltas 0, 0 (the NaN's slot holds the first encoded value), 75 + "0100", // exception position 1 + "000000000000f87f")); // NaN + } + + @Test + public void testFloatPage() throws Exception { + assertThat(floatPage(1.5f, 2.25f, 3.0f)) + .containsExactly(bytes( + "00 00 03 03000000", + "04000000", + "08 06 0000", // exponent 8: at 9, 2.25 and 3.0 would overflow an int + "96000000 08", // 4-byte frame of reference + "00 4b 96")); + } + + @Test + public void testPageWithTwoVectors() throws Exception { + assertThat(doublePage(1, 2, 3, 4, 5, 6, 7, 8, 9)) + .containsExactly(bytes( + "00 00 03 09000000", + "08000000 18000000", // offsets of the two vectors + "10 10 0000", + "0100000000000000 03", + "88 c6 fa", // deltas 0..7 at 3 bits each + "00 00 0000", // the second vector holds one value + "0900000000000000 00")); // bit width 0, so no packed values follow + } + + @Test + public void testEmptyPage() throws Exception { + assertThat(doublePage()).containsExactly(bytes("00 00 03 00000000")); + } + + /** Ties keep HashMap order, which here differs from the C++ order (larger exponent first). */ + @Test + public void testPresetTieOrder() { + List samples = new ArrayList<>(); + for (int i = 0; i < 4; i++) { + samples.add(new int[] {18, 18}); + samples.add(new int[] {16, 16}); + } + int[][] presets = AlpValuesWriter.buildPresets(samples, AlpValuesWriter.MAX_PRESET_COMBINATIONS); + assertThat(presets).hasDimensions(2, 2); + assertThat(presets[0]).containsExactly(16, 16); + assertThat(presets[1]).containsExactly(18, 18); + } + + private static byte[] doublePage(double... values) throws Exception { + AlpValuesWriter.DoubleAlpValuesWriter writer = + new AlpValuesWriter.DoubleAlpValuesWriter(256, 256, new DirectByteBufferAllocator(), 8); + try { + for (double v : values) { + writer.writeDouble(v); + } + return writer.getBytes().toByteArray(); + } finally { + writer.reset(); + writer.close(); + } + } + + private static byte[] floatPage(float... values) throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = + new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator(), 8); + try { + for (float v : values) { + writer.writeFloat(v); + } + return writer.getBytes().toByteArray(); + } finally { + writer.reset(); + writer.close(); + } + } + + private static byte[] bytes(String... hex) { + String digits = String.join("", hex).replace(" ", ""); + byte[] out = new byte[digits.length() / 2]; + for (int i = 0; i < out.length; i++) { + out[i] = (byte) Integer.parseInt(digits.substring(2 * i, 2 * i + 2), 16); + } + return out; + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpValuesEndToEndTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpValuesEndToEndTest.java new file mode 100644 index 0000000000..7489761f93 --- /dev/null +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/alp/AlpValuesEndToEndTest.java @@ -0,0 +1,2637 @@ +/* + * 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.column.values.alp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Arrays; +import java.util.Random; +import java.util.concurrent.TimeUnit; +import org.apache.parquet.bytes.ByteBufferInputStream; +import org.apache.parquet.bytes.BytesInput; +import org.apache.parquet.bytes.DirectByteBufferAllocator; +import org.apache.parquet.column.values.bitpacking.BytePacker; +import org.apache.parquet.column.values.bitpacking.Packer; +import org.apache.parquet.io.ParquetDecodingException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** + * End-to-end tests for ALP encoding and decoding pipeline. + */ +public class AlpValuesEndToEndTest { + + private static final int DEFAULT_VECTOR_SIZE = AlpConstants.DEFAULT_VECTOR_SIZE; + + // ========== Helper Methods ========== + + /** + * Round-trip with STRICT raw-bit comparison for every value, including NaN payloads (the regular + * roundTrip helpers only assert isNaN, which would hide a NaN-payload-preservation bug). ALP must + * be losslessly bit-exact for every possible IEEE-754 bit pattern. + */ + private void roundTripDoubleStrict(double[] values) throws Exception { + AlpValuesWriter.DoubleAlpValuesWriter writer = null; + try { + int capacity = Math.max(512, values.length * 16); + writer = new AlpValuesWriter.DoubleAlpValuesWriter( + capacity, capacity, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + for (double v : values) { + writer.writeDouble(v); + } + BytesInput input = writer.getBytes(); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + for (int i = 0; i < values.length; i++) { + long exp = Double.doubleToRawLongBits(values[i]); + long act = Double.doubleToRawLongBits(reader.readDouble()); + assertThat(act) + .as("Raw-bit mismatch at index " + i + " expectedBits=0x" + Long.toHexString(exp) + + " actualBits=0x" + Long.toHexString(act)) + .isEqualTo(exp); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + private void roundTripFloatStrict(float[] values) throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + int capacity = Math.max(256, values.length * 8); + writer = new AlpValuesWriter.FloatAlpValuesWriter( + capacity, capacity, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + for (float v : values) { + writer.writeFloat(v); + } + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + for (int i = 0; i < values.length; i++) { + int exp = Float.floatToRawIntBits(values[i]); + int act = Float.floatToRawIntBits(reader.readFloat()); + assertThat(act) + .as("Raw-bit mismatch at index " + i + " expectedBits=0x" + Integer.toHexString(exp) + + " actualBits=0x" + Integer.toHexString(act)) + .isEqualTo(exp); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== Full-bit-space fuzz (lossless invariant) ========== + + @Test + public void testDoubleFullBitSpaceFuzz() throws Exception { + // Sweep the entire double bit space: random raw longs -> double. Covers all subnormals, every + // NaN payload, +/-0, +/-Inf, extreme exponents, chaotic mixed magnitudes within a vector. + Random rng = new Random(0x9E3779B97F4A7C15L); + for (int v = 0; v < 300; v++) { // ~300k values + double[] values = new double[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = Double.longBitsToDouble(rng.nextLong()); + } + roundTripDoubleStrict(values); + } + } + + @Test + public void testFloatFullBitSpaceFuzz() throws Exception { + Random rng = new Random(0x9E3779B9L); + for (int v = 0; v < 300; v++) { + float[] values = new float[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = Float.intBitsToFloat(rng.nextInt()); + } + roundTripFloatStrict(values); + } + } + + @Test + public void testDoubleMixedFuzz() throws Exception { + // Mix ALP-friendly decimal-ish values with random raw bits, so FOR encoding AND the exception + // path are both exercised on chaotic data within the same vector. + Random rng = new Random(0xD1CE5EEDL); + for (int v = 0; v < 300; v++) { + double[] values = new double[1024]; + for (int i = 0; i < values.length; i++) { + if (rng.nextInt(4) == 0) { + values[i] = Double.longBitsToDouble(rng.nextLong()); // ~25% chaotic (mostly exceptions) + } else { + values[i] = Math.round(rng.nextDouble() * 1_000_000.0) / 100.0; // 2-decimal, ALP-friendly + } + } + roundTripDoubleStrict(values); + } + } + + @Test + public void testDoubleDistributionShiftWithinRowGroup() throws Exception { + // First vectors are clean 2-decimal data (the sampler builds its preset (e,f) cache from these), + // then later vectors switch to high-precision / very different magnitude that the cached presets + // fit poorly. Once presets are cached the writer only tries those, so ALP must still stay lossless + // (the exception path catches every preset mismatch). + Random rng = new Random(7); + int totalVectors = 40; // well past SAMPLER_SAMPLE_VECTORS_PER_ROWGROUP so presets are in use + double[] values = new double[totalVectors * 1024]; + for (int i = 0; i < values.length; i++) { + int vec = i / 1024; + if (vec < 12) { + values[i] = Math.round(rng.nextDouble() * 10000.0) / 100.0; // clean cents + } else { + values[i] = rng.nextDouble() * 1e12 + rng.nextDouble(); // high-precision, big magnitude + } + } + roundTripDoubleStrict(values); + } + + // ========== Sampler / preset cache ========== + + @Test + public void testPresetCacheBuildsAcrossPageBoundaries() { + // The sampler collects one sample every SAMPLER_ROWGROUP_SIZE / SAMPLER_SAMPLE_VECTORS / + // vectorSize vectors and needs SAMPLER_SAMPLE_VECTORS_PER_ROWGROUP of them before it can lock + // in the preset combinations. Its unit is a rowgroup, but reset() runs at every page boundary, + // which is much smaller. Clearing the sampling state there meant the threshold was never + // reached and the preset cache never built, so every vector paid for a full parameter search. + AlpValuesWriter.DoubleAlpValuesWriter writer = new AlpValuesWriter.DoubleAlpValuesWriter( + 65536, 65536, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + + int vectorsPerPage = 19; // roughly what the default 20k row page limit allows + int pages = 12; + double value = 0.0; + for (int page = 0; page < pages; page++) { + for (int i = 0; i < vectorsPerPage * DEFAULT_VECTOR_SIZE; i++) { + value += 0.01; + writer.writeDouble(Math.round(value * 100.0) / 100.0); + } + writer.getBytes(); + writer.reset(); + } + + assertThat(writer.hasPresetCache()) + .as("preset cache must build once enough vectors are sampled, even though the samples " + + "are spread across pages") + .isTrue(); + writer.close(); + } + + // ========== Reader robustness against malformed input ========== + + private void readAllDoubles(byte[] bytes, int valueCount) throws Exception { + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(valueCount, ByteBufferInputStream.wrap(ByteBuffer.wrap(bytes))); + for (int i = 0; i < valueCount; i++) { + reader.readDouble(); + } + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + public void testReaderRejectsCorruptInputCleanly() throws Exception { + // Build a valid ALP double page, then feed corrupted variants. The reader must fail cleanly + // (a catchable exception) and never hang, OOM, or read out of bounds silently. + double[] values = new double[2048]; + for (int i = 0; i < values.length; i++) { + values[i] = (i % 100) / 100.0; + } + AlpValuesWriter.DoubleAlpValuesWriter writer = new AlpValuesWriter.DoubleAlpValuesWriter( + 65536, 65536, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + for (double v : values) { + writer.writeDouble(v); + } + byte[] valid = writer.getBytes().toByteArray(); + writer.reset(); + writer.close(); + + // Sanity: the valid page reads fine. + readAllDoubles(valid, values.length); + + // Corruptions that must each fail cleanly (never crash/hang/OOB): + java.util.List corrupt = new java.util.ArrayList<>(); + corrupt.add(java.util.Arrays.copyOf(valid, valid.length / 2)); // truncated to half + corrupt.add(java.util.Arrays.copyOf(valid, 3)); // truncated to a stub + corrupt.add(new byte[0]); // empty + for (int pos : new int[] {0, 1, 2, 5, 7, 11, 20, valid.length - 1}) { + byte[] c = valid.clone(); + c[pos] = (byte) ~c[pos]; // flip a header/body byte + corrupt.add(c); + } + for (byte[] c : corrupt) { + try { + readAllDoubles(c, values.length); + // Some single-byte flips may still decode to (wrong but in-bounds) values without throwing; + // that is acceptable here. What matters is no crash/hang/OOB, which we reached this line. + } catch (Exception e) { + // Any ordinary catchable exception (EOFException, ParquetDecodingException, IndexOutOfBounds, + // BufferUnderflow, ...) is a clean failure: no JVM crash, no OOB, no hang. Errors are + // deliberately not caught here: an OutOfMemoryError from an allocation bomb, or an + // AssertionError from a failed check, must fail this test rather than count as success. + } + } + } + + /** Overwrites the little-endian num_elements field (bytes 3..6) of an ALP page header. */ + private static byte[] withForgedElementCount(byte[] page, int numElements) { + byte[] forged = page.clone(); + ByteBuffer.wrap(forged).order(ByteOrder.LITTLE_ENDIAN).putInt(3, numElements); + return forged; + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + public void testForgedElementCountIsRejectedWithoutHugeAllocation() throws Exception { + // A forged num_elements must be bounded against the bytes actually present. Claiming a huge + // count on a small page previously overflowed the vector-count arithmetic (negative count) or + // drove a multi-hundred-megabyte offset-array allocation before any byte was validated. + double[] values = new double[4096]; + for (int i = 0; i < values.length; i++) { + values[i] = i / 8.0; + } + AlpValuesWriter.DoubleAlpValuesWriter writer = new AlpValuesWriter.DoubleAlpValuesWriter( + 65536, 65536, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + for (double v : values) { + writer.writeDouble(v); + } + byte[] valid = writer.getBytes().toByteArray(); + writer.reset(); + writer.close(); + + // Counts large enough that the vectors they claim cannot fit in the bytes present. The last + // two overflow (numElements + vectorSize - 1) in int arithmetic, which used to yield a + // negative vector count. valuesCount is passed as Integer.MAX_VALUE so the numElements <= + // valuesCount guard cannot be what rejects these — the allocation bound has to. + int[] unfittableCounts = {1 << 20, Integer.MAX_VALUE / 2, Integer.MAX_VALUE - 1, Integer.MAX_VALUE}; + for (int forgedCount : unfittableCounts) { + byte[] forged = withForgedElementCount(valid, forgedCount); + // The message pins which check rejected it: the allocation bound, not a later one. + assertThatThrownBy(() -> readAllDoubles(forged, Integer.MAX_VALUE)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("remain in the page"); + } + + // A count only slightly too large still fits within the bytes present, so the allocation bound + // lets it through; the offset array then no longer describes the vectors it claims and the + // offset validation rejects it. Either way the failure is clean and no vector is decoded. + byte[] slightlyOver = withForgedElementCount(valid, values.length + 1); + assertThatThrownBy(() -> readAllDoubles(slightlyOver, Integer.MAX_VALUE)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("ALP first vector offset"); + } + + @Test + @Timeout(value = 30, unit = TimeUnit.SECONDS) + public void testForgedVectorOffsetsAreRejected() throws Exception { + // Offsets are trusted to locate each vector. A forged offset that points into a different + // vector must be rejected rather than silently decoding the wrong values. + double[] values = new double[4096]; // 4 vectors at the default vector size + for (int i = 0; i < values.length; i++) { + values[i] = i / 8.0; + } + AlpValuesWriter.DoubleAlpValuesWriter writer = new AlpValuesWriter.DoubleAlpValuesWriter( + 65536, 65536, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + for (double v : values) { + writer.writeDouble(v); + } + byte[] valid = writer.getBytes().toByteArray(); + writer.reset(); + writer.close(); + + int offsetArrayStart = AlpConstants.ALP_HEADER_SIZE; + ByteBuffer validOffsets = ByteBuffer.wrap(valid).order(ByteOrder.LITTLE_ENDIAN); + int firstOffset = validOffsets.getInt(offsetArrayStart); + int secondOffset = validOffsets.getInt(offsetArrayStart + Integer.BYTES); + + // First offset must point exactly past the offset array. + byte[] badFirst = valid.clone(); + ByteBuffer.wrap(badFirst).order(ByteOrder.LITTLE_ENDIAN).putInt(offsetArrayStart, firstOffset + 8); + assertThatThrownBy(() -> readAllDoubles(badFirst, values.length)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("ALP first vector offset"); + + // Offsets must increase: pointing vector 1 back at vector 0 would decode vector 0 twice. + byte[] nonIncreasing = valid.clone(); + ByteBuffer.wrap(nonIncreasing) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(offsetArrayStart + Integer.BYTES, firstOffset); + assertThatThrownBy(() -> readAllDoubles(nonIncreasing, values.length)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("ALP vector 0 at offset " + firstOffset + " spans 0 bytes, fewer than"); + + // An offset past the end of the page body must be rejected, not read out of bounds. The + // trailing end-of-data entry means the vector before it is then too long or the one after it + // runs backwards; here vector 0 claims more bytes than any vector can occupy. + byte[] pastEnd = valid.clone(); + ByteBuffer.wrap(pastEnd) + .order(ByteOrder.LITTLE_ENDIAN) + .putInt(offsetArrayStart + Integer.BYTES, valid.length * 4); + assertThatThrownBy(() -> readAllDoubles(pastEnd, values.length)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("ALP vector 0 at offset " + firstOffset + " spans") + .hasMessageContaining("more than the"); + + // Pointing the last vector just past the end of the data leaves it negative room: the + // trailing entry turns this into a span check instead of an out-of-bounds read. + byte[] lastPastEnd = valid.clone(); + int lastEntry = offsetArrayStart + 3 * Integer.BYTES; + int dataEnd = valid.length - offsetArrayStart; + ByteBuffer.wrap(lastPastEnd).order(ByteOrder.LITTLE_ENDIAN).putInt(lastEntry, dataEnd + 1); + assertThatThrownBy(() -> readAllDoubles(lastPastEnd, values.length)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("ALP vector 3 at offset " + (dataEnd + 1) + " spans -1 bytes"); + + // Sanity: the untouched page still decodes, so the checks above are not rejecting everything. + assertThat(secondOffset).isGreaterThan(firstOffset); + readAllDoubles(valid, values.length); + } + + /** + * The per-vector span bound must be generous enough for the largest vector ALP can legitimately + * produce: values needing the full FOR bit width, with a high exception rate on top. If the bound + * were computed too tightly this would fail to read back. + */ + @Test + public void testWorstCaseVectorSizeIsAccepted() throws Exception { + double[] values = new double[DEFAULT_VECTOR_SIZE]; + for (int i = 0; i < values.length; i++) { + // Alternate values that force a wide frame of reference with values ALP cannot represent, + // so the vector carries both a full-width packed body and a large exception block. + values[i] = (i % 2 == 0) ? (i % 4 == 0 ? -8e18 : 8e18) : Double.longBitsToDouble(0x7FF8000000000000L + i); + } + AlpValuesWriter.DoubleAlpValuesWriter writer = new AlpValuesWriter.DoubleAlpValuesWriter( + 1 << 20, 1 << 20, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + for (double v : values) { + writer.writeDouble(v); + } + byte[] page = writer.getBytes().toByteArray(); + writer.reset(); + writer.close(); + + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < values.length; i++) { + assertThat(Double.doubleToRawLongBits(reader.readDouble())) + .as("raw bits differ at %s", i) + .isEqualTo(Double.doubleToRawLongBits(values[i])); + } + } + + /** + * Sweeps page shapes against the per-vector span bound: several vector sizes, value counts that + * land on and around vector boundaries so the last vector is often partial, and three data + * profiles (narrow bit width, full bit width, mostly exceptions). A bound computed too tightly + * would reject one of these instead of reading it back. + */ + @Test + public void testSpanBoundAcceptsEveryLegitimatePageShape() throws Exception { + Random rnd = new Random(42); + int shapes = 0; + for (int perVector : new int[] {8, 16, 1024, 4096}) { + for (int count : + new int[] {1, 2, perVector - 1, perVector, perVector + 1, 2 * perVector + 1, 5 * perVector + 3}) { + for (int profile = 0; profile < 3; profile++) { + double[] values = new double[count]; + for (int i = 0; i < count; i++) { + if (profile == 0) { + values[i] = i / 8.0; + } else if (profile == 1) { + values[i] = (i % 2 == 0) ? 8e18 : -8e18; + } else { + values[i] = Double.longBitsToDouble(rnd.nextLong()); + } + } + assertDoublesRoundTripExactly(values, perVector, profile); + shapes++; + } + } + } + assertThat(shapes).isEqualTo(84); + } + + /** The float counterpart of the sweep above: the span bound is computed per value type. */ + @Test + public void testFloatSpanBoundAcceptsEveryLegitimatePageShape() throws Exception { + Random rnd = new Random(42); + int shapes = 0; + for (int perVector : new int[] {8, 16, 1024, 4096}) { + for (int count : + new int[] {1, 2, perVector - 1, perVector, perVector + 1, 2 * perVector + 1, 5 * perVector + 3}) { + for (int profile = 0; profile < 3; profile++) { + float[] values = new float[count]; + for (int i = 0; i < count; i++) { + if (profile == 0) { + values[i] = i / 8.0f; + } else if (profile == 1) { + values[i] = (i % 2 == 0) ? 2e9f : -2e9f; + } else { + values[i] = Float.intBitsToFloat(rnd.nextInt()); + } + } + assertFloatsRoundTripExactly(values, perVector, profile); + shapes++; + } + } + } + assertThat(shapes).isEqualTo(84); + } + + private void assertFloatsRoundTripExactly(float[] values, int perVector, int profile) throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = + new AlpValuesWriter.FloatAlpValuesWriter(1 << 22, 1 << 22, new DirectByteBufferAllocator(), perVector); + byte[] page; + try { + for (float v : values) { + writer.writeFloat(v); + } + page = writer.getBytes().toByteArray(); + } finally { + writer.reset(); + writer.close(); + } + + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < values.length; i++) { + assertThat(Float.floatToRawIntBits(reader.readFloat())) + .as("perVector=%s count=%s profile=%s index=%s", perVector, values.length, profile, i) + .isEqualTo(Float.floatToRawIntBits(values[i])); + } + } + + private void assertDoublesRoundTripExactly(double[] values, int perVector, int profile) throws Exception { + AlpValuesWriter.DoubleAlpValuesWriter writer = + new AlpValuesWriter.DoubleAlpValuesWriter(1 << 22, 1 << 22, new DirectByteBufferAllocator(), perVector); + byte[] page; + try { + for (double v : values) { + writer.writeDouble(v); + } + page = writer.getBytes().toByteArray(); + } finally { + writer.reset(); + writer.close(); + } + + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + for (int i = 0; i < values.length; i++) { + assertThat(Double.doubleToRawLongBits(reader.readDouble())) + .as("perVector=%s count=%s profile=%s index=%s", perVector, values.length, profile, i) + .isEqualTo(Double.doubleToRawLongBits(values[i])); + } + } + + /** + * A reader keeps its decode buffers across pages when they fit and grows them when they don't. + * Exceptions move between pages, so state left over from the previous page would read back wrong. + */ + @Test + public void testReaderReusesBuffersAcrossPages() throws Exception { + AlpValuesReaderForDouble doubleReader = new AlpValuesReaderForDouble(); + AlpValuesReaderForFloat floatReader = new AlpValuesReaderForFloat(); + int[] previousDoubleBuffer = null; + int[] previousFloatBuffer = null; + int largestVectorSize = 0; + int page = 0; + for (int perVector : new int[] {1024, 1024, 16, 4096}) { + int count = 3 * perVector + 5; + double[] doubles = new double[count]; + float[] floats = new float[count]; + for (int i = 0; i < count; i++) { + // Dense enough that a 4096-value vector holds more exceptions than a 1024-entry buffer. + boolean exception = (i + page) % 3 == 0; + doubles[i] = exception ? Double.NaN : (i + page) / 100.0; + floats[i] = exception ? Float.NaN : (i + page) / 100.0f; + } + + AlpValuesWriter.DoubleAlpValuesWriter doubleWriter = new AlpValuesWriter.DoubleAlpValuesWriter( + 1 << 20, 1 << 20, new DirectByteBufferAllocator(), perVector); + AlpValuesWriter.FloatAlpValuesWriter floatWriter = new AlpValuesWriter.FloatAlpValuesWriter( + 1 << 20, 1 << 20, new DirectByteBufferAllocator(), perVector); + byte[] doublePage; + byte[] floatPage; + try { + for (int i = 0; i < count; i++) { + doubleWriter.writeDouble(doubles[i]); + floatWriter.writeFloat(floats[i]); + } + doublePage = doubleWriter.getBytes().toByteArray(); + floatPage = floatWriter.getBytes().toByteArray(); + } finally { + doubleWriter.reset(); + doubleWriter.close(); + floatWriter.reset(); + floatWriter.close(); + } + + doubleReader.initFromPage(count, ByteBufferInputStream.wrap(ByteBuffer.wrap(doublePage))); + floatReader.initFromPage(count, ByteBufferInputStream.wrap(ByteBuffer.wrap(floatPage))); + for (int i = 0; i < count; i++) { + assertThat(Double.doubleToRawLongBits(doubleReader.readDouble())) + .as("double page=%s index=%s", page, i) + .isEqualTo(Double.doubleToRawLongBits(doubles[i])); + assertThat(Float.floatToRawIntBits(floatReader.readFloat())) + .as("float page=%s index=%s", page, i) + .isEqualTo(Float.floatToRawIntBits(floats[i])); + } + + if (page > 0 && perVector <= largestVectorSize) { + assertThat(doubleReader.excPositionsBuffer).isSameAs(previousDoubleBuffer); + assertThat(floatReader.excPositionsBuffer).isSameAs(previousFloatBuffer); + } else if (page > 0) { + assertThat(doubleReader.excPositionsBuffer).isNotSameAs(previousDoubleBuffer); + assertThat(doubleReader.excPositionsBuffer).hasSizeGreaterThanOrEqualTo(perVector); + assertThat(floatReader.excPositionsBuffer).isNotSameAs(previousFloatBuffer); + } + previousDoubleBuffer = doubleReader.excPositionsBuffer; + previousFloatBuffer = floatReader.excPositionsBuffer; + largestVectorSize = Math.max(largestVectorSize, perVector); + page++; + } + } + + /** + * The reader must not assume the page buffer starts at position 0. Wrapping the same page behind + * some leading bytes exercises the paths where an absolute {@code get(index)} would silently read + * the wrong bytes, which is how a page arrives from a real column reader. + */ + @Test + public void testReadsPageBackedByBufferWithNonZeroPosition() throws Exception { + double[] values = new double[3000]; + for (int i = 0; i < values.length; i++) { + values[i] = i / 16.0; + } + AlpValuesWriter.DoubleAlpValuesWriter writer = new AlpValuesWriter.DoubleAlpValuesWriter( + 1 << 20, 1 << 20, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + byte[] page; + try { + for (double v : values) { + writer.writeDouble(v); + } + page = writer.getBytes().toByteArray(); + } finally { + writer.reset(); + writer.close(); + } + + int lead = 37; // deliberately not a multiple of 4 or 8 + byte[] padded = new byte[lead + page.length]; + Arrays.fill(padded, 0, lead, (byte) 0xA5); + System.arraycopy(page, 0, padded, lead, page.length); + ByteBuffer buffer = ByteBuffer.wrap(padded); + buffer.position(lead); + + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(buffer)); + for (int i = 0; i < values.length; i++) { + assertThat(reader.readDouble()).as("value at %s", i).isEqualTo(values[i]); + } + } + + @Test + public void testSkipRejectsOverflowingCount() throws Exception { + double[] values = new double[2048]; + for (int i = 0; i < values.length; i++) { + values[i] = i / 4.0; + } + AlpValuesWriter.DoubleAlpValuesWriter writer = new AlpValuesWriter.DoubleAlpValuesWriter( + 65536, 65536, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + for (double v : values) { + writer.writeDouble(v); + } + byte[] page = writer.getBytes().toByteArray(); + writer.reset(); + writer.close(); + + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(ByteBuffer.wrap(page))); + reader.readDouble(); + + // pageValueIndex + n overflows to a negative number for these, which previously slipped past + // the bounds check and left the reader at a negative index. + for (int n : new int[] {Integer.MAX_VALUE, Integer.MAX_VALUE - 1, values.length}) { + assertThatThrownBy(() -> reader.skip(n)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Cannot skip this many elements"); + } + assertThatThrownBy(() -> reader.skip(-1)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Cannot skip this many elements"); + + // A legal skip still works after the rejections. + reader.skip(10); + assertThat(reader.readDouble()).isEqualTo(values[11]); + } + + // ========== Large scale + allocator leak ========== + + /** Wraps an allocator and tracks outstanding (allocated but not released) buffers. */ + private static final class CountingAllocator implements org.apache.parquet.bytes.ByteBufferAllocator { + private final org.apache.parquet.bytes.ByteBufferAllocator delegate; + final java.util.concurrent.atomic.AtomicInteger outstanding = new java.util.concurrent.atomic.AtomicInteger(); + final java.util.concurrent.atomic.AtomicInteger peakOutstanding = + new java.util.concurrent.atomic.AtomicInteger(); + + CountingAllocator(org.apache.parquet.bytes.ByteBufferAllocator delegate) { + this.delegate = delegate; + } + + @Override + public ByteBuffer allocate(int size) { + int cur = outstanding.incrementAndGet(); + peakOutstanding.getAndAccumulate(cur, Math::max); + return delegate.allocate(size); + } + + @Override + public void release(ByteBuffer b) { + outstanding.decrementAndGet(); + delegate.release(b); + } + + @Override + public boolean isDirect() { + return delegate.isDirect(); + } + } + + @Test + public void testWriterReleasesAllAllocatorBuffersOverManyPages() throws Exception { + // Simulate many pages (write -> getBytes -> reset, repeated) then close. A per-page slab leak + // (the GH-3628-style unreleased-buffer bug) would leave outstanding allocations > 0 after close. + CountingAllocator alloc = new CountingAllocator(new DirectByteBufferAllocator()); + AlpValuesWriter.DoubleAlpValuesWriter writer = + new AlpValuesWriter.DoubleAlpValuesWriter(1024, 1024, alloc, DEFAULT_VECTOR_SIZE); + Random rng = new Random(1); + try { + for (int page = 0; page < 200; page++) { + for (int i = 0; i < 5000; i++) { + writer.writeDouble(Math.round(rng.nextDouble() * 100000.0) / 100.0); + } + writer.getBytes(); + writer.reset(); + } + } finally { + writer.close(); + } + assertThat(alloc.peakOutstanding.get()) + .as("Writer should have allocated buffers during the run") + .isPositive(); + assertThat(alloc.outstanding.get()) + .as("Writer leaked allocator buffers: " + alloc.outstanding.get() + " outstanding after close") + .isZero(); + } + + @Test + public void testLargeScaleDoubleRoundTrip() throws Exception { + // ~2M values across many pages/vectors: verifies correctness at scale and that nothing OOMs. + Random rng = new Random(2); + int perPage = 100_000; + int pages = 20; + for (int p = 0; p < pages; p++) { + double[] values = new double[perPage]; + for (int i = 0; i < perPage; i++) { + values[i] = Math.round(rng.nextDouble() * 1e7) / 100.0; + } + roundTripDoubleStrict(values); + } + } + + private void roundTripFloat(float[] values) throws Exception { + roundTripFloat(values, DEFAULT_VECTOR_SIZE); + } + + private void roundTripFloat(float[] values, int vectorSize) throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + int capacity = Math.max(256, values.length * 8); + writer = new AlpValuesWriter.FloatAlpValuesWriter( + capacity, capacity, new DirectByteBufferAllocator(), vectorSize); + + for (float v : values) { + writer.writeFloat(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (int i = 0; i < values.length; i++) { + float expected = values[i]; + float actual = reader.readFloat(); + + if (Float.isNaN(expected)) { + assertThat(Float.isNaN(actual)) + .as("Expected NaN at index " + i) + .isTrue(); + } else { + assertThat(Float.floatToRawIntBits(actual)) + .as("Value mismatch at index " + i + " for " + expected) + .isEqualTo(Float.floatToRawIntBits(expected)); + } + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + private void roundTripDouble(double[] values) throws Exception { + roundTripDouble(values, DEFAULT_VECTOR_SIZE); + } + + private void roundTripDouble(double[] values, int vectorSize) throws Exception { + AlpValuesWriter.DoubleAlpValuesWriter writer = null; + try { + int capacity = Math.max(512, values.length * 16); + writer = new AlpValuesWriter.DoubleAlpValuesWriter( + capacity, capacity, new DirectByteBufferAllocator(), vectorSize); + + for (double v : values) { + writer.writeDouble(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (int i = 0; i < values.length; i++) { + double expected = values[i]; + double actual = reader.readDouble(); + + if (Double.isNaN(expected)) { + assertThat(Double.isNaN(actual)) + .as("Expected NaN at index " + i) + .isTrue(); + } else { + assertThat(Double.doubleToRawLongBits(actual)) + .as("Value mismatch at index " + i + " for " + expected) + .isEqualTo(Double.doubleToRawLongBits(expected)); + } + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== Float Pipeline Tests ========== + + @Test + public void testFloatSingleValue() throws Exception { + roundTripFloat(new float[] {1.23f}); + } + + @Test + public void testFloatSmallBatch() throws Exception { + roundTripFloat(new float[] {0.0f, 1.0f, -1.0f, 3.14f, 100.5f, 0.001f, 1234567.0f}); + } + + @Test + public void testFloatRandomData() throws Exception { + Random rand = new Random(42); + float[] values = new float[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = rand.nextFloat() * 10000.0f - 5000.0f; + } + roundTripFloat(values); + } + + @Test + public void testFloatWithExceptions() throws Exception { + roundTripFloat(new float[] { + 1.0f, Float.NaN, 2.0f, Float.POSITIVE_INFINITY, 3.0f, Float.NEGATIVE_INFINITY, 4.0f, -0.0f, 5.0f + }); + } + + @Test + public void testFloatAllExceptions() throws Exception { + roundTripFloat(new float[] {Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY, -0.0f}); + } + + @Test + public void testFloatMultipleVectors() throws Exception { + Random rand = new Random(42); + float[] values = new float[3000]; + for (int i = 0; i < values.length; i++) { + values[i] = rand.nextFloat() * 1000.0f; + } + roundTripFloat(values); + } + + @Test + public void testFloatZeroValues() throws Exception { + roundTripFloat(new float[] {0.0f, 0.0f, 0.0f, 0.0f}); + } + + @Test + public void testFloatWideForRange() throws Exception { + // Integer-valued floats spanning the widest signed range float holds losslessly (< 2^23), so the + // frame-of-reference minimum is deeply negative and the FOR delta needs a high bit width. A buggy + // unsigned (max - min) range estimate would misjudge this; the signed range must round-trip cleanly. + float[] values = new float[2048]; + float lo = -8.3e6f, hi = 8.3e6f; + for (int i = 0; i < values.length; i++) { + values[i] = (i % 2 == 0) ? lo + i : hi - i; + } + values[0] = lo; // frame minimum near -2^23 + values[1] = hi; // frame maximum near +2^23 + roundTripFloat(values); + } + + @Test + public void testDoubleWideForRange() throws Exception { + // Integer-valued doubles spanning a wide signed range (< 2^52) so the frame-of-reference minimum + // is deeply negative and the FOR delta needs a near-53-bit width, all with zero exceptions. + double[] values = new double[2048]; + double lo = -4.5e15, hi = 4.5e15; + for (int i = 0; i < values.length; i++) { + values[i] = (i % 2 == 0) ? lo + i : hi - i; + } + values[0] = lo; // frame minimum near -2^52 + values[1] = hi; // frame maximum near +2^52 + roundTripDouble(values); + } + + @Test + public void testDoubleExtremeForBitWidth() throws Exception { + // Extreme values near the ALP double encoding limit (~2^63), all exact integer-valued doubles so + // they encode losslessly with no exceptions, but the frame-of-reference delta spans the full range + // and overflows the signed subtraction, forcing 64-bit FOR packing. Verified with the encoder: + // scale of 1 (e == f), 0 exceptions, FOR bit width 64. Exercises 64-bit packing and the reader's + // modular reconstruction, which cross-language interop must match. + double[] values = new double[2048]; + long lo = -9_000_000_000_000_000_000L, hi = 9_000_000_000_000_000_000L; + for (int i = 0; i < values.length; i++) { + long off = (long) (i % 256) * (1L << 20); + values[i] = (double) ((i % 2 == 0) ? (lo + off) : (hi - off)); + } + roundTripDouble(values); + } + + @Test + public void testDoubleExtremeFor63BitWidth() throws Exception { + // Exact integer-valued doubles near +/- 2^61, so the frame-of-reference delta spans ~2^62.5 and + // needs 63 bits WITHOUT overflowing the signed max - min subtraction (the non-overflow high-bit + // counterpart to the 64-bit case above). Verified with the encoder: 0 exceptions, FOR bit width 63. + double[] values = new double[2048]; + long lo = -3_000_000_000_000_000_000L, hi = 3_000_000_000_000_000_000L; + for (int i = 0; i < values.length; i++) { + long off = (long) (i % 256) * (1L << 20); + values[i] = (double) ((i % 2 == 0) ? (lo + off) : (hi - off)); + } + roundTripDouble(values); + } + + @Test + public void testFloatExtremeForBitWidth() throws Exception { + // Float analogue near the ~2^31 float encoding limit: exact integer-valued floats whose encoded + // ints span ~2^32, forcing the full 32-bit FOR width. Verified: e=0, f=0, 0 exceptions, bit width 32. + float[] values = new float[2048]; + int lo = -2_100_000_000, hi = 2_100_000_000; + for (int i = 0; i < values.length; i++) { + int off = (i % 256) * (1 << 8); + values[i] = (float) ((i % 2 == 0) ? (lo + off) : (hi - off)); + } + roundTripFloat(values); + } + + @Test + public void testFloatAllNullPage() throws Exception { + // Every row on the page is null, so no value reaches the writer and num_elements is 0. + // numVectors then works out to 0 and the reader ends up calling slice(0) on empty offset and + // vector buffers. Initialization must succeed even though the page carries a positive row count. + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter( + 256, 256, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + // no writeFloat calls: all rows are null + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(100, ByteBufferInputStream.wrap(input.toByteBuffer())); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testDoubleAllNullPage() throws Exception { + // Double analogue of testFloatAllNullPage: an all-null page produces num_elements 0. + AlpValuesWriter.DoubleAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.DoubleAlpValuesWriter( + 512, 512, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + // no writeDouble calls: all rows are null + BytesInput input = writer.getBytes(); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(100, ByteBufferInputStream.wrap(input.toByteBuffer())); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testFloatPartialNullPage() throws Exception { + // Some rows are null, so fewer values reach the writer than the page's row count: num_elements + // is less than valueCount. The reader is initialized with the larger page count and must serve + // exactly the num_elements encoded values back. + int numValues = 700; + int pageRowCount = 1000; // 300 nulls + float[] values = new float[numValues]; + for (int i = 0; i < numValues; i++) { + values[i] = (i % 100) / 100.0f + 1.0f; + } + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter( + 16384, 16384, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + for (float v : values) { + writer.writeFloat(v); + } + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(pageRowCount, ByteBufferInputStream.wrap(input.toByteBuffer())); + for (int i = 0; i < numValues; i++) { + assertThat(Float.floatToRawIntBits(reader.readFloat())) + .as("Value mismatch at index " + i) + .isEqualTo(Float.floatToRawIntBits(values[i])); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testDoublePartialNullPage() throws Exception { + // Double analogue of testFloatPartialNullPage: num_elements less than valueCount because some + // rows are null. + int numValues = 700; + int pageRowCount = 1000; // 300 nulls + double[] values = new double[numValues]; + for (int i = 0; i < numValues; i++) { + values[i] = (i % 100) / 100.0 + 1.0; + } + AlpValuesWriter.DoubleAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.DoubleAlpValuesWriter( + 32768, 32768, new DirectByteBufferAllocator(), DEFAULT_VECTOR_SIZE); + for (double v : values) { + writer.writeDouble(v); + } + BytesInput input = writer.getBytes(); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(pageRowCount, ByteBufferInputStream.wrap(input.toByteBuffer())); + for (int i = 0; i < numValues; i++) { + assertThat(Double.doubleToRawLongBits(reader.readDouble())) + .as("Value mismatch at index " + i) + .isEqualTo(Double.doubleToRawLongBits(values[i])); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testFloatIdenticalValues() throws Exception { + float[] values = new float[100]; + for (int i = 0; i < values.length; i++) { + values[i] = 3.14159f; + } + roundTripFloat(values); + } + + // ========== Float: Vector Size / Boundary Tests ========== + + @Test + public void testFloatExactOneVector() throws Exception { + float[] values = new float[DEFAULT_VECTOR_SIZE]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.01f; + } + roundTripFloat(values); + } + + @Test + public void testFloatExactTwoVectors() throws Exception { + float[] values = new float[DEFAULT_VECTOR_SIZE * 2]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.01f; + } + roundTripFloat(values); + } + + @Test + public void testFloatExactThreeVectors() throws Exception { + float[] values = new float[DEFAULT_VECTOR_SIZE * 3]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.01f; + } + roundTripFloat(values); + } + + @Test + public void testFloatPartialLastVectorPlusOne() throws Exception { + float[] values = new float[DEFAULT_VECTOR_SIZE + 1]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.01f; + } + roundTripFloat(values); + } + + @Test + public void testFloatPartialLastVectorMinusOne() throws Exception { + float[] values = new float[DEFAULT_VECTOR_SIZE * 2 - 1]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.01f; + } + roundTripFloat(values); + } + + @Test + public void testFloatCustomVectorSize512() throws Exception { + float[] values = new float[512 + 100]; // partial second vector + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.1f; + } + roundTripFloat(values, 512); + } + + @Test + public void testFloatCustomVectorSize2048() throws Exception { + float[] values = new float[2048 * 2 + 500]; // partial third vector + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.1f; + } + roundTripFloat(values, 2048); + } + + @Test + public void testFloatCustomVectorSizeSmall() throws Exception { + // Minimum vector size = 8 (2^3) + float[] values = new float[25]; // 3 full vectors + 1 partial + for (int i = 0; i < values.length; i++) { + values[i] = i * 1.5f; + } + roundTripFloat(values, 8); + } + + // ========== Float: Large Page ========== + + @Test + public void testFloatLargePage() throws Exception { + Random rand = new Random(42); + float[] values = new float[15000]; + for (int i = 0; i < values.length; i++) { + values[i] = rand.nextFloat() * 100000.0f - 50000.0f; + } + roundTripFloat(values); + } + + // ========== Float: Monetary Data ========== + + @Test + public void testFloatMonetaryData() throws Exception { + float[] values = { + 19.99f, 29.95f, 9.99f, 49.99f, 99.95f, 14.50f, 7.99f, 24.99f, 39.95f, 59.99f, 119.99f, 4.99f, 0.99f, 1.50f, + 2.99f, 3.49f + }; + roundTripFloat(values); + } + + // ========== Float: All Identical (bit_width = 0) ========== + + @Test + public void testFloatAllIdenticalBitWidthZero() throws Exception { + float[] values = new float[2000]; + for (int i = 0; i < values.length; i++) { + values[i] = 42.0f; + } + roundTripFloat(values); + } + + // ========== Float: Mixed Exceptions at Various Positions ========== + + @Test + public void testFloatExceptionsAtBoundaries() throws Exception { + // Exceptions at vector start and end + float[] values = new float[20]; + values[0] = Float.NaN; + for (int i = 1; i < values.length - 1; i++) { + values[i] = i * 1.1f; + } + values[values.length - 1] = Float.POSITIVE_INFINITY; + roundTripFloat(values); + } + + // ========== Float: Skip Tests ========== + + @Test + public void testFloatSkip() throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + + float[] values = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f, 7.0f, 8.0f, 9.0f, 10.0f}; + for (float v : values) { + writer.writeFloat(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(1.0f)); + reader.skip(3); + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(5.0f)); + reader.skip(2); + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(8.0f)); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testFloatSkipAcrossVectorBoundaries() throws Exception { + int vectorSize = 8; // small vector for boundary testing + int totalValues = vectorSize * 3 + 4; // 3 full vectors + partial + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter( + totalValues * 8, totalValues * 8, new DirectByteBufferAllocator(), vectorSize); + + float[] values = new float[totalValues]; + for (int i = 0; i < totalValues; i++) { + values[i] = (i + 1) * 1.0f; + writer.writeFloat(values[i]); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(totalValues, ByteBufferInputStream.wrap(input.toByteBuffer())); + + // Read first value from vector 0 + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(1.0f)); + + // Skip past vector 0 and vector 1, into vector 2 + reader.skip(vectorSize * 2 - 1); // skip to index vectorSize*2 + + // Read first value of vector 2 + float expected = (vectorSize * 2 + 1) * 1.0f; + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(expected)); + + // Skip into the partial last vector + reader.skip(vectorSize - 1); // skip to index vectorSize*3 + + expected = (vectorSize * 3 + 1) * 1.0f; + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(expected)); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== Float: Writer Reset ========== + + @Test + public void testFloatWriterReset() throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + + writer.writeFloat(1.0f); + writer.writeFloat(2.0f); + writer.reset(); + assertThat(writer.getBufferedSize()).isZero(); + + float[] values = {3.0f, 4.0f, 5.0f}; + for (float v : values) { + writer.writeFloat(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (float expected : values) { + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(expected)); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== Double Pipeline Tests ========== + + @Test + public void testDoubleSingleValue() throws Exception { + roundTripDouble(new double[] {1.23456789}); + } + + @Test + public void testDoubleSmallBatch() throws Exception { + roundTripDouble(new double[] {0.0, 1.0, -1.0, 3.14159265358979, 100.5, 0.001, 12345678901234.0}); + } + + @Test + public void testDoubleRandomData() throws Exception { + Random rand = new Random(42); + double[] values = new double[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = rand.nextDouble() * 1000000.0 - 500000.0; + } + roundTripDouble(values); + } + + @Test + public void testDoubleWithExceptions() throws Exception { + roundTripDouble(new double[] { + 1.0, Double.NaN, 2.0, Double.POSITIVE_INFINITY, 3.0, Double.NEGATIVE_INFINITY, 4.0, -0.0, 5.0 + }); + } + + @Test + public void testDoubleAllExceptions() throws Exception { + roundTripDouble(new double[] {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, -0.0}); + } + + @Test + public void testDoubleMultipleVectors() throws Exception { + Random rand = new Random(42); + double[] values = new double[3000]; + for (int i = 0; i < values.length; i++) { + values[i] = rand.nextDouble() * 100000.0; + } + roundTripDouble(values); + } + + @Test + public void testDoubleZeroValues() throws Exception { + roundTripDouble(new double[] {0.0, 0.0, 0.0, 0.0}); + } + + @Test + public void testDoubleIdenticalValues() throws Exception { + double[] values = new double[100]; + for (int i = 0; i < values.length; i++) { + values[i] = 3.14159265358979; + } + roundTripDouble(values); + } + + // ========== Double: Vector Size / Boundary Tests ========== + + @Test + public void testDoubleExactOneVector() throws Exception { + double[] values = new double[DEFAULT_VECTOR_SIZE]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.01; + } + roundTripDouble(values); + } + + @Test + public void testDoubleExactTwoVectors() throws Exception { + double[] values = new double[DEFAULT_VECTOR_SIZE * 2]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.01; + } + roundTripDouble(values); + } + + @Test + public void testDoublePartialLastVectorPlusOne() throws Exception { + double[] values = new double[DEFAULT_VECTOR_SIZE + 1]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.01; + } + roundTripDouble(values); + } + + @Test + public void testDoublePartialLastVectorMinusOne() throws Exception { + double[] values = new double[DEFAULT_VECTOR_SIZE * 2 - 1]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.01; + } + roundTripDouble(values); + } + + @Test + public void testDoubleCustomVectorSize512() throws Exception { + double[] values = new double[512 + 100]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.1; + } + roundTripDouble(values, 512); + } + + @Test + public void testDoubleCustomVectorSize2048() throws Exception { + double[] values = new double[2048 * 2 + 500]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 0.1; + } + roundTripDouble(values, 2048); + } + + @Test + public void testDoubleCustomVectorSizeSmall() throws Exception { + double[] values = new double[25]; + for (int i = 0; i < values.length; i++) { + values[i] = i * 1.5; + } + roundTripDouble(values, 8); + } + + // ========== Double: Large Page ========== + + @Test + public void testDoubleLargePage() throws Exception { + Random rand = new Random(42); + double[] values = new double[15000]; + for (int i = 0; i < values.length; i++) { + values[i] = rand.nextDouble() * 100000.0 - 50000.0; + } + roundTripDouble(values); + } + + // ========== Double: Monetary Data ========== + + @Test + public void testDoubleMonetaryData() throws Exception { + double[] values = { + 19.99, 29.95, 9.99, 49.99, 99.95, 14.50, 7.99, 24.99, 39.95, 59.99, 119.99, 4.99, 0.99, 1.50, 2.99, 3.49 + }; + roundTripDouble(values); + } + + // ========== Double: All Identical (bit_width = 0) ========== + + @Test + public void testDoubleAllIdenticalBitWidthZero() throws Exception { + double[] values = new double[2000]; + for (int i = 0; i < values.length; i++) { + values[i] = 42.0; + } + roundTripDouble(values); + } + + // ========== Double: Mixed Exceptions at Boundaries ========== + + @Test + public void testDoubleExceptionsAtBoundaries() throws Exception { + double[] values = new double[20]; + values[0] = Double.NaN; + for (int i = 1; i < values.length - 1; i++) { + values[i] = i * 1.1; + } + values[values.length - 1] = Double.POSITIVE_INFINITY; + roundTripDouble(values); + } + + // ========== Double: Skip Tests ========== + + @Test + public void testDoubleSkip() throws Exception { + AlpValuesWriter.DoubleAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.DoubleAlpValuesWriter(512, 512, new DirectByteBufferAllocator()); + + double[] values = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0}; + for (double v : values) { + writer.writeDouble(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + assertThat(Double.doubleToRawLongBits(reader.readDouble())).isEqualTo(Double.doubleToRawLongBits(1.0)); + reader.skip(3); + assertThat(Double.doubleToRawLongBits(reader.readDouble())).isEqualTo(Double.doubleToRawLongBits(5.0)); + reader.skip(2); + assertThat(Double.doubleToRawLongBits(reader.readDouble())).isEqualTo(Double.doubleToRawLongBits(8.0)); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testDoubleSkipAcrossVectorBoundaries() throws Exception { + int vectorSize = 8; + int totalValues = vectorSize * 3 + 4; + AlpValuesWriter.DoubleAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.DoubleAlpValuesWriter( + totalValues * 16, totalValues * 16, new DirectByteBufferAllocator(), vectorSize); + + double[] values = new double[totalValues]; + for (int i = 0; i < totalValues; i++) { + values[i] = (i + 1) * 1.0; + writer.writeDouble(values[i]); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(totalValues, ByteBufferInputStream.wrap(input.toByteBuffer())); + + assertThat(Double.doubleToRawLongBits(reader.readDouble())).isEqualTo(Double.doubleToRawLongBits(1.0)); + reader.skip(vectorSize * 2 - 1); + + double expected = (vectorSize * 2 + 1) * 1.0; + assertThat(Double.doubleToRawLongBits(reader.readDouble())).isEqualTo(Double.doubleToRawLongBits(expected)); + + reader.skip(vectorSize - 1); + expected = (vectorSize * 3 + 1) * 1.0; + assertThat(Double.doubleToRawLongBits(reader.readDouble())).isEqualTo(Double.doubleToRawLongBits(expected)); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== Double: Writer Reset ========== + + @Test + public void testDoubleWriterReset() throws Exception { + AlpValuesWriter.DoubleAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.DoubleAlpValuesWriter(512, 512, new DirectByteBufferAllocator()); + + writer.writeDouble(1.0); + writer.writeDouble(2.0); + writer.reset(); + assertThat(writer.getBufferedSize()).isZero(); + + double[] values = {3.0, 4.0, 5.0}; + for (double v : values) { + writer.writeDouble(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (double expected : values) { + assertThat(Double.doubleToRawLongBits(reader.readDouble())) + .isEqualTo(Double.doubleToRawLongBits(expected)); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== Packed Size Verification ========== + + @Test + public void testPackedSizeSpecFormula() throws Exception { + // Verify that the packed data size exactly matches ceil(n * bitWidth / 8) + // by checking total encoded bytes for partial vectors with various remainders. + // Vector size 8 means: 1 full group of 8 = no partial group, + // but with count not divisible by 8 we exercise partial groups. + for (int count : new int[] {1, 2, 3, 5, 7, 9, 11, 13, 15}) { + float[] values = new float[count]; + for (int i = 0; i < count; i++) { + values[i] = (i + 1) * 1.0f; + } + // Use smallest vector size so the partial vector IS the only vector + roundTripFloat(values, 1024); + } + } + + @Test + public void testPartialVectorAllRemaindersMod8() throws Exception { + // Test every possible remainder (1..7) to exercise the partial group path. + // With vectorSize=16: 16 is 2 full groups of 8, no partial group. + // So use vectorSize=16 and write 16+R values where R in {1..7} + // to get a partial last vector of size R. + for (int remainder = 1; remainder <= 7; remainder++) { + int count = 16 + remainder; + float[] values = new float[count]; + for (int i = 0; i < count; i++) { + values[i] = (i + 1) * 0.5f; + } + roundTripFloat(values, 16); + } + // Also test for doubles + for (int remainder = 1; remainder <= 7; remainder++) { + int count = 16 + remainder; + double[] values = new double[count]; + for (int i = 0; i < count; i++) { + values[i] = (i + 1) * 0.5; + } + roundTripDouble(values, 16); + } + } + + // ========== Negative Encoded Values / Wide FOR Range ========== + + @Test + public void testFloatNegativeValues() throws Exception { + // Values that produce negative encoded integers, testing FOR with negative min + float[] values = new float[100]; + for (int i = 0; i < values.length; i++) { + values[i] = -50.0f + i * 1.0f; // range [-50, 49] + } + roundTripFloat(values); + } + + @Test + public void testDoubleNegativeValues() throws Exception { + double[] values = new double[100]; + for (int i = 0; i < values.length; i++) { + values[i] = -50.0 + i * 1.0; + } + roundTripDouble(values); + } + + @Test + public void testFloatWideRange() throws Exception { + // Mix of small and large positive values - exercises higher bit widths + float[] values = {0.01f, 0.02f, 1000.0f, 2000.0f, 0.05f, 50000.0f, 0.99f, 99999.0f}; + roundTripFloat(values); + } + + @Test + public void testDoubleWideRange() throws Exception { + double[] values = {0.01, 0.02, 100000.0, 200000.0, 0.05, 5000000.0, 0.99, 99999999.0}; + roundTripDouble(values); + } + + // ========== Binary Format Verification ========== + + @Test + public void testBinaryFormatExactBytes() throws Exception { + // Write a known small dataset with vectorSize=8, then verify the + // exact binary layout byte-by-byte + int vectorSize = 8; + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator(), vectorSize); + + // Write 10 identical values: 1.0f + // With e=0, f=0: encoded=1, FOR min=1, all deltas=0, bitWidth=0 + for (int i = 0; i < 10; i++) { + writer.writeFloat(1.0f); + } + + BytesInput input = writer.getBytes(); + byte[] bytes = input.toByteArray(); + java.nio.ByteBuffer buf = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.LITTLE_ENDIAN); + + // Header (7 bytes) + assertThat(buf.get() & 0xFF).as("compression_mode").isZero(); + assertThat(buf.get() & 0xFF).as("integer_encoding").isZero(); + assertThat(buf.get() & 0xFF).as("log_vector_size").isEqualTo(3); // log2(8)=3 + assertThat(buf.getInt()).as("num_elements").isEqualTo(10); + + // Offset array (2 vectors * 4 bytes = 8 bytes) + int offset0 = buf.getInt(); // first vector offset + int offset1 = buf.getInt(); // second vector offset + assertThat(offset0).as("offset0 = past offset array").isEqualTo(8); + + // Vector 0: 8 identical values, bitWidth=0, no exceptions + // AlpInfo (4 bytes) + int v0Exp = buf.get() & 0xFF; + int v0Fac = buf.get() & 0xFF; + int v0Exc = buf.getShort() & 0xFFFF; + assertThat(v0Exc).as("v0 exceptions").isZero(); + + // ForInfo (5 bytes) + int v0For = buf.getInt(); + int v0Bw = buf.get() & 0xFF; + assertThat(v0Bw).as("v0 bitWidth").isZero(); + // No packed bytes (bitWidth=0) + // No exceptions + + // Vector 0 should be exactly 4 + 5 = 9 bytes + assertThat(offset1).as("offset1 should be offset0 + 9").isEqualTo(offset0 + 9); + + // Vector 1: 2 identical values, bitWidth=0, no exceptions + int v1Exp = buf.get() & 0xFF; + int v1Fac = buf.get() & 0xFF; + assertThat(v1Exp).as("same exponent both vectors").isEqualTo(v0Exp); + assertThat(v1Fac).as("same factor both vectors").isEqualTo(v0Fac); + int v1Exc = buf.getShort() & 0xFFFF; + assertThat(v1Exc).as("v1 exceptions").isZero(); + int v1For = buf.getInt(); + int v1Bw = buf.get() & 0xFF; + assertThat(v1Bw).as("v1 bitWidth").isZero(); + + // Should have consumed all bytes + assertThat(buf.position()).as("all bytes consumed").isEqualTo(bytes.length); + + // Verify round-trip + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(10, ByteBufferInputStream.wrap(input.toByteBuffer())); + for (int i = 0; i < 10; i++) { + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(1.0f)); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== Exception-Heavy Vectors ========== + + @Test + public void testFloatManyExceptionsInVector() throws Exception { + // Vector with more exceptions than normal values + float[] values = new float[10]; + values[0] = 1.0f; // normal + for (int i = 1; i < 10; i++) { + values[i] = Float.NaN; // all exceptions except first + } + roundTripFloat(values, 16); + } + + @Test + public void testDoubleManyExceptionsInVector() throws Exception { + double[] values = new double[10]; + values[0] = 1.0; + for (int i = 1; i < 10; i++) { + values[i] = Double.NaN; + } + roundTripDouble(values, 16); + } + + @Test + public void testFloatExceptionsAcrossMultipleVectors() throws Exception { + // Exceptions in different vectors + int vectorSize = 8; + float[] values = new float[vectorSize * 3]; + for (int i = 0; i < values.length; i++) { + values[i] = (i + 1) * 0.5f; + } + // Place exceptions at specific positions in different vectors + values[0] = Float.NaN; // vector 0, position 0 + values[vectorSize - 1] = Float.POSITIVE_INFINITY; // vector 0, last position + values[vectorSize] = -0.0f; // vector 1, position 0 + values[vectorSize * 2 + 3] = Float.NaN; // vector 2, position 3 + roundTripFloat(values, vectorSize); + } + + // ========== Read After Skip to End ========== + + @Test + public void testFloatReadPastEnd() throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + writer.writeFloat(1.0f); + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(1, ByteBufferInputStream.wrap(input.toByteBuffer())); + + reader.readFloat(); // read the one value + assertThatThrownBy(reader::readFloat) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("ALP float data was already exhausted"); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + @Test + public void testFloatSkipPastEnd() throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + writer.writeFloat(1.0f); + writer.writeFloat(2.0f); + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(2, ByteBufferInputStream.wrap(input.toByteBuffer())); + + // should throw - only 2 values + assertThatThrownBy(() -> reader.skip(3)) + .isInstanceOf(ParquetDecodingException.class) + .hasMessageContaining("Cannot skip this many elements"); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== Hand-Crafted Binary → Reader (independent of writer) ========== + + /** + * Construct ALP-encoded bytes manually per the spec and verify the reader + * decodes them correctly. This catches symmetric bugs where writer and reader + * both implement the same wrong thing. + * + *

Test vector: 3 float values {100.0f, 200.0f, 300.0f} with vectorSize=8. + * With exponent=0, factor=0: encoded = {100, 200, 300}. + * FOR min = 100, deltas = {0, 100, 200}, maxDelta = 200, bitWidth = 8. + * Packed: 3 values in 1 partial group of 8, padded with zeros. + * Packed size = ceil(3 * 8 / 8) = 3 bytes. + */ + @Test + public void testReaderWithHandCraftedBytes() throws Exception { + // Manually build ALP page for {100.0f, 200.0f, 300.0f}, vectorSize=8 + int vectorSize = 8; + int logVectorSize = 3; + int numElements = 3; + int numVectors = 1; + + // Encoding: e=0, f=0 → multiplier=1.0 + // encoded = {100, 200, 300} + // FOR min = 100 + // deltas = {0, 100, 200} + // maxDelta = 200, bitWidth = 8 + int exponent = 0; + int factor = 0; + int numExceptions = 0; + int frameOfReference = 100; + int bitWidth = 8; + + // Bit-pack deltas using the same BytePacker the reader will use + BytePacker packer = Packer.LITTLE_ENDIAN.newBytePacker(8); + int[] deltas = {0, 100, 200, 0, 0, 0, 0, 0}; // padded to 8 + byte[] packed = new byte[8]; // bitWidth bytes for one group of 8 + packer.pack8Values(deltas, 0, packed, 0); + int packedSize = (3 * 8 + 7) / 8; // = 3 bytes for 3 values at 8 bits + + // Build the page + int vectorDataSize = 4 + 5 + packedSize; // AlpInfo + ForInfo + packed + int offsetArraySize = numVectors * 4; + + ByteBuffer page = + ByteBuffer.allocate(7 + offsetArraySize + vectorDataSize).order(ByteOrder.LITTLE_ENDIAN); + + // Header (7 bytes) + page.put((byte) 0); // compression_mode + page.put((byte) 0); // integer_encoding + page.put((byte) logVectorSize); + page.putInt(numElements); + + // Offset array (1 vector) + page.putInt(offsetArraySize); // offset₀ = past offset array + + // Vector 0: AlpInfo (4 bytes) + page.put((byte) exponent); + page.put((byte) factor); + page.putShort((short) numExceptions); + + // Vector 0: ForInfo (5 bytes) + page.putInt(frameOfReference); + page.put((byte) bitWidth); + + // Vector 0: Packed data (3 bytes) + page.put(packed, 0, packedSize); + + page.flip(); + + // Now read it back + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(numElements, ByteBufferInputStream.wrap(page)); + + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(100.0f)); + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(200.0f)); + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(300.0f)); + } + + /** + * Hand-craft bytes with exceptions to verify reader handles them independently. + * Values: {1.0f, NaN, 3.0f} with vectorSize=8. + * e=0, f=0: encoded={1, placeholder=1, 3}, 1 exception at position 1. + * FOR min=1, deltas={0, 0, 2}, maxDelta=2, bitWidth=2. + */ + @Test + public void testReaderWithHandCraftedExceptions() throws Exception { + int vectorSize = 8; + int logVectorSize = 3; + int numElements = 3; + + int exponent = 0; + int factor = 0; + int numExceptions = 1; + int placeholder = 1; // first non-exception encoded value + int frameOfReference = 1; // min of {1, 1, 3} + int bitWidth = 2; // ceil(log2(2+1)) = 2 + + // deltas = {1-1, 1-1, 3-1} = {0, 0, 2}, padded to 8: {0,0,2,0,0,0,0,0} + BytePacker packer = Packer.LITTLE_ENDIAN.newBytePacker(2); + int[] deltas = {0, 0, 2, 0, 0, 0, 0, 0}; + byte[] packed = new byte[2]; // bitWidth=2 bytes for a group of 8 + packer.pack8Values(deltas, 0, packed, 0); + int packedSize = (3 * 2 + 7) / 8; // = 1 byte + + // Vector data: AlpInfo(4) + ForInfo(5) + packed(1) + excPos(2) + excVal(4) = 16 + int vectorDataSize = 4 + 5 + packedSize + 2 + 4; + int offsetArraySize = 1 * 4; + + ByteBuffer page = + ByteBuffer.allocate(7 + offsetArraySize + vectorDataSize).order(ByteOrder.LITTLE_ENDIAN); + + // Header (7 bytes) + page.put((byte) 0); // compression_mode + page.put((byte) 0); // integer_encoding + page.put((byte) logVectorSize); + page.putInt(numElements); + + // Offset array + page.putInt(offsetArraySize); + + // AlpInfo + page.put((byte) exponent); + page.put((byte) factor); + page.putShort((short) numExceptions); + + // ForInfo + page.putInt(frameOfReference); + page.put((byte) bitWidth); + + // Packed data + page.put(packed, 0, packedSize); + + // Exception positions (uint16 LE) + page.putShort((short) 1); // position 1 + + // Exception values (float32 LE) + page.putFloat(Float.NaN); + + page.flip(); + + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(numElements, ByteBufferInputStream.wrap(page)); + + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(1.0f)); + float val1 = reader.readFloat(); + assertThat(Float.isNaN(val1)).as("Expected NaN at position 1").isTrue(); + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(3.0f)); + } + + /** + * Hand-craft double ALP bytes and verify reader independence. + * Values: {10.0, 20.0, 30.0}, vectorSize=8, e=0, f=0. + * encoded={10,20,30}, FOR min=10, deltas={0,10,20}, maxDelta=20, bitWidth=5. + */ + @Test + public void testDoubleReaderWithHandCraftedBytes() throws Exception { + int numElements = 3; + int exponent = 0; + int factor = 0; + long frameOfReference = 10; + int bitWidth = 5; // ceil(log2(20+1)) = 5 + + org.apache.parquet.column.values.bitpacking.BytePackerForLong packer = + Packer.LITTLE_ENDIAN.newBytePackerForLong(5); + long[] deltas = {0, 10, 20, 0, 0, 0, 0, 0}; + byte[] packed = new byte[5]; // bitWidth bytes for group of 8 + packer.pack8Values(deltas, 0, packed, 0); + int packedSize = (3 * 5 + 7) / 8; // = 2 bytes + + int vectorDataSize = 4 + 9 + packedSize; // AlpInfo + DoubleForInfo + packed + int offsetArraySize = 4; + + ByteBuffer page = + ByteBuffer.allocate(7 + offsetArraySize + vectorDataSize).order(ByteOrder.LITTLE_ENDIAN); + + // Header (7 bytes) + page.put((byte) 0); // compression_mode + page.put((byte) 0); // integer_encoding + page.put((byte) 3); // log2(8) + page.putInt(numElements); + + // Offset array + page.putInt(offsetArraySize); + + // AlpInfo + page.put((byte) exponent); + page.put((byte) factor); + page.putShort((short) 0); // no exceptions + + // ForInfo (9 bytes for double) + page.putLong(frameOfReference); + page.put((byte) bitWidth); + + // Packed data + page.put(packed, 0, packedSize); + + page.flip(); + + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(numElements, ByteBufferInputStream.wrap(page)); + + assertThat(Double.doubleToRawLongBits(reader.readDouble())).isEqualTo(Double.doubleToRawLongBits(10.0)); + assertThat(Double.doubleToRawLongBits(reader.readDouble())).isEqualTo(Double.doubleToRawLongBits(20.0)); + assertThat(Double.doubleToRawLongBits(reader.readDouble())).isEqualTo(Double.doubleToRawLongBits(30.0)); + } + + // ========== Writer Output → Hand-Verified Bytes ========== + + /** + * Write known values, then verify the exact binary output against hand computation. + * This verifies the writer independently of the reader. + * + *

Input: {1.0f, 2.0f, 3.0f} with vectorSize=8. + * Expected: e=0, f=0, encoded={1,2,3}, FOR min=1, deltas={0,1,2}, + * maxDelta=2, bitWidth=2. Packed size = ceil(3*2/8) = 1 byte. + */ + @Test + public void testWriterOutputExactBytes() throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator(), 8); + writer.writeFloat(1.0f); + writer.writeFloat(2.0f); + writer.writeFloat(3.0f); + + byte[] bytes = writer.getBytes().toByteArray(); + ByteBuffer buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); + + // Header (7 bytes) + assertThat(buf.get() & 0xFF).isZero(); // compression_mode + assertThat(buf.get() & 0xFF).isZero(); // integer_encoding + assertThat(buf.get() & 0xFF).isEqualTo(3); // log2(8) = 3 + assertThat(buf.getInt()).isEqualTo(3); // num_elements + + // Offset array (1 vector) + int offset0 = buf.getInt(); + assertThat(offset0).isEqualTo(4); // 1 * 4 = past offset array + + // AlpInfo + int exp = buf.get() & 0xFF; + int fac = buf.get() & 0xFF; + int numExc = buf.getShort() & 0xFFFF; + assertThat(numExc).isZero(); + + // ForInfo + int forRef = buf.getInt(); + int bw = buf.get() & 0xFF; + + // Verify: for e=0, f=0: multiplier=1.0 + // encoded = {1, 2, 3}, min = 1, deltas = {0, 1, 2}, bitWidth = 2 + assertThat(forRef).isEqualTo(1); // frame of reference = 1 + assertThat(bw).isEqualTo(2); // bitWidth for max delta 2 + + // Packed data: ceil(3 * 2 / 8) = 1 byte + // deltas {0, 1, 2} at bitWidth=2: + // Use BytePacker to compute expected packed byte + BytePacker packer = Packer.LITTLE_ENDIAN.newBytePacker(2); + int[] expectedDeltas = {0, 1, 2, 0, 0, 0, 0, 0}; + byte[] expectedPacked = new byte[2]; // bitWidth=2 + packer.pack8Values(expectedDeltas, 0, expectedPacked, 0); + + byte actualPackedByte = buf.get(); + assertThat(actualPackedByte).as("packed byte").isEqualTo(expectedPacked[0]); + + // Should have consumed all bytes + assertThat(buf.position()).as("all bytes consumed").isEqualTo(bytes.length); + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== NaN Bit Pattern Preservation ========== + + /** + * Verify that different NaN bit patterns survive the round-trip exactly. + * Java allows multiple NaN representations. Our exception handling must + * preserve the exact bit pattern, not normalize to Float.NaN. + */ + @Test + public void testNaNBitPatternPreservation() throws Exception { + // Standard NaN + float standardNaN = Float.NaN; // 0x7FC00000 + // Signaling NaN (different bit pattern) + float signalingNaN = Float.intBitsToFloat(0x7F800001); + // Negative NaN + float negativeNaN = Float.intBitsToFloat(0xFFC00000); + // Another NaN variant + float customNaN = Float.intBitsToFloat(0x7FFFFFFF); + + assertThat(Float.isNaN(standardNaN)).isTrue(); + assertThat(Float.isNaN(signalingNaN)).isTrue(); + assertThat(Float.isNaN(negativeNaN)).isTrue(); + assertThat(Float.isNaN(customNaN)).isTrue(); + + float[] values = {1.0f, standardNaN, 2.0f, signalingNaN, 3.0f, negativeNaN, 4.0f, customNaN}; + + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + for (float v : values) { + writer.writeFloat(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (int i = 0; i < values.length; i++) { + float actual = reader.readFloat(); + assertThat(Float.floatToRawIntBits(actual)) + .as("Bit pattern mismatch at index " + i + " (0x" + + Integer.toHexString(Float.floatToRawIntBits(values[i])) + " vs 0x" + + Integer.toHexString(Float.floatToRawIntBits(actual)) + ")") + .isEqualTo(Float.floatToRawIntBits(values[i])); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + /** + * Same test for double NaN bit patterns. + */ + @Test + public void testDoubleNaNBitPatternPreservation() throws Exception { + double standardNaN = Double.NaN; + double signalingNaN = Double.longBitsToDouble(0x7FF0000000000001L); + double negativeNaN = Double.longBitsToDouble(0xFFF8000000000000L); + double customNaN = Double.longBitsToDouble(0x7FFFFFFFFFFFFFFFL); + + double[] values = {1.0, standardNaN, 2.0, signalingNaN, 3.0, negativeNaN, 4.0, customNaN}; + + AlpValuesWriter.DoubleAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.DoubleAlpValuesWriter(512, 512, new DirectByteBufferAllocator()); + for (double v : values) { + writer.writeDouble(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForDouble reader = new AlpValuesReaderForDouble(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (int i = 0; i < values.length; i++) { + double actual = reader.readDouble(); + assertThat(Double.doubleToRawLongBits(actual)) + .as("Bit pattern mismatch at index " + i + " (0x" + + Long.toHexString(Double.doubleToRawLongBits(values[i])) + " vs 0x" + + Long.toHexString(Double.doubleToRawLongBits(actual)) + ")") + .isEqualTo(Double.doubleToRawLongBits(values[i])); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== Negative Zero Bit-Exact Roundtrip ========== + + /** + * Verify that -0.0f roundtrips as -0.0f (bit pattern 0x80000000), + * not as +0.0f (bit pattern 0x00000000). + */ + @Test + public void testNegativeZeroBitExact() throws Exception { + float negZero = -0.0f; + float posZero = 0.0f; + + // Sanity: they are == but have different bit patterns + assertThat(negZero == posZero).isTrue(); + assertThat(Float.floatToRawIntBits(negZero)).isNotEqualTo(Float.floatToRawIntBits(posZero)); + + float[] values = {posZero, negZero, 1.0f, negZero}; + + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator()); + for (float v : values) { + writer.writeFloat(v); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(values.length, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (int i = 0; i < values.length; i++) { + float actual = reader.readFloat(); + assertThat(Float.floatToRawIntBits(actual)) + .as("Bit pattern at index " + i) + .isEqualTo(Float.floatToRawIntBits(values[i])); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== Extreme Float Values ========== + + /** + * Test subnormal floats, Float.MIN_VALUE, Float.MAX_VALUE, Float.MIN_NORMAL. + * These should all either encode losslessly or be stored as exceptions. + */ + @Test + public void testExtremeFloatValues() throws Exception { + float[] values = { + Float.MIN_VALUE, // smallest positive subnormal: 1.4e-45 + Float.MIN_NORMAL, // smallest positive normal: 1.17549435e-38 + Float.MAX_VALUE, // 3.4028235e38 + -Float.MAX_VALUE, // most negative + 1.17549435e-38f, // near MIN_NORMAL + 3.4028234e38f, // near MAX_VALUE + 1.0e-10f, // very small positive + 1.0e10f, // large positive + }; + roundTripFloat(values); + } + + /** + * Test extreme double values. + */ + @Test + public void testExtremeDoubleValues() throws Exception { + double[] values = { + Double.MIN_VALUE, // smallest positive subnormal + Double.MIN_NORMAL, // smallest positive normal + Double.MAX_VALUE, // largest positive + -Double.MAX_VALUE, // most negative + 1.0e-100, // very small + 1.0e100, // very large + }; + roundTripDouble(values); + } + + // ========== Preset Caching Under Distribution Change ========== + + /** + * Write >8 vectors where the optimal (e,f) changes after the sampling phase. + * First 8 vectors: monetary data (e=2, f=0 optimal). + * Remaining vectors: large integers (e=0, f=0 optimal). + * Verify all values survive round-trip despite the distribution change. + */ + @Test + public void testPresetCachingWithDistributionChange() throws Exception { + int vectorSize = 8; // small vectors to get to 8 quickly + int samplingVectors = 8; + int postSamplingVectors = 4; + int totalValues = vectorSize * (samplingVectors + postSamplingVectors); + + float[] values = new float[totalValues]; + + // First 8 vectors: decimal monetary values (best with e=2, f=0) + for (int i = 0; i < vectorSize * samplingVectors; i++) { + values[i] = 10.00f + (i % 100) * 0.01f; // 10.00, 10.01, 10.02, ... + } + + // Last 4 vectors: whole numbers (best with e=0, f=0) + for (int i = vectorSize * samplingVectors; i < totalValues; i++) { + values[i] = (float) (i * 1000); + } + + roundTripFloat(values, vectorSize); + } + + /** + * Same test for doubles. + */ + @Test + public void testDoublePresetCachingWithDistributionChange() throws Exception { + int vectorSize = 8; + int totalValues = vectorSize * 12; // 8 sampling + 4 post + + double[] values = new double[totalValues]; + + for (int i = 0; i < vectorSize * 8; i++) { + values[i] = 10.00 + (i % 100) * 0.01; + } + for (int i = vectorSize * 8; i < totalValues; i++) { + values[i] = (double) (i * 1000); + } + + roundTripDouble(values, vectorSize); + } + + // ========== Multi-Vector Hand-Crafted Binary ========== + + /** + * Hand-craft a 2-vector page to verify offset array navigation works. + * 10 values with vectorSize=8: vector 0 has values 1-8, vector 1 has 9-10. + */ + @Test + public void testMultiVectorHandCrafted() throws Exception { + int vectorSize = 8; + int numElements = 10; + int numVectors = 2; + + // Vector 0: {1,2,3,4,5,6,7,8} → e=0,f=0, encoded={1..8}, FOR min=1, deltas={0..7} + // maxDelta=7, bitWidth=3. Packed: 8 values at 3 bits = 3 bytes exactly. + int v0BitWidth = 3; + BytePacker packer3 = Packer.LITTLE_ENDIAN.newBytePacker(3); + int[] v0Deltas = {0, 1, 2, 3, 4, 5, 6, 7}; + byte[] v0Packed = new byte[3]; + packer3.pack8Values(v0Deltas, 0, v0Packed, 0); + int v0PackedSize = 3; // (8*3+7)/8 = 3 + + // Vector 1: {9, 10} → e=0,f=0, encoded={9,10}, FOR min=9, deltas={0,1} + // maxDelta=1, bitWidth=1. Packed: ceil(2*1/8) = 1 byte. + int v1BitWidth = 1; + BytePacker packer1 = Packer.LITTLE_ENDIAN.newBytePacker(1); + int[] v1Deltas = {0, 1, 0, 0, 0, 0, 0, 0}; + byte[] v1Packed = new byte[1]; + packer1.pack8Values(v1Deltas, 0, v1Packed, 0); + int v1PackedSize = 1; // (2*1+7)/8 = 1 + + int v0DataSize = 4 + 5 + v0PackedSize; // 12 + int v1DataSize = 4 + 5 + v1PackedSize; // 10 + int offsetArraySize = numVectors * 4; // 8 + + ByteBuffer page = ByteBuffer.allocate(7 + offsetArraySize + v0DataSize + v1DataSize) + .order(ByteOrder.LITTLE_ENDIAN); + + // Header (7 bytes) + page.put((byte) 0); // compression_mode + page.put((byte) 0); // integer_encoding + page.put((byte) 3); // log2(8) + page.putInt(numElements); + + // Offset array + page.putInt(offsetArraySize); // vector 0 starts at 8 + page.putInt(offsetArraySize + v0DataSize); // vector 1 starts at 8+12=20 + + // Vector 0 + page.put((byte) 0); // exponent + page.put((byte) 0); // factor + page.putShort((short) 0); // exceptions + page.putInt(1); // FOR = 1 + page.put((byte) v0BitWidth); + page.put(v0Packed, 0, v0PackedSize); + + // Vector 1 + page.put((byte) 0); // exponent + page.put((byte) 0); // factor + page.putShort((short) 0); // exceptions + page.putInt(9); // FOR = 9 + page.put((byte) v1BitWidth); + page.put(v1Packed, 0, v1PackedSize); + + page.flip(); + + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(numElements, ByteBufferInputStream.wrap(page)); + + for (int i = 0; i < numElements; i++) { + float expected = (float) (i + 1); + float actual = reader.readFloat(); + assertThat(Float.floatToRawIntBits(actual)) + .as("Value mismatch at index " + i) + .isEqualTo(Float.floatToRawIntBits(expected)); + } + } + + // ========== Skip Over Entire Vector Without Decoding ========== + + /** + * Verify that skipping an entire vector and reading the next one works. + * Uses hand-crafted bytes to ensure independence from writer. + */ + @Test + public void testSkipEntireVectorHandCrafted() throws Exception { + // Reuse the multi-vector setup: {1..8} in vector 0, {9, 10} in vector 1 + int vectorSize = 8; + int numElements = 10; + int numVectors = 2; + + BytePacker packer3 = Packer.LITTLE_ENDIAN.newBytePacker(3); + int[] v0Deltas = {0, 1, 2, 3, 4, 5, 6, 7}; + byte[] v0Packed = new byte[3]; + packer3.pack8Values(v0Deltas, 0, v0Packed, 0); + + BytePacker packer1 = Packer.LITTLE_ENDIAN.newBytePacker(1); + int[] v1Deltas = {0, 1, 0, 0, 0, 0, 0, 0}; + byte[] v1Packed = new byte[1]; + packer1.pack8Values(v1Deltas, 0, v1Packed, 0); + + int v0DataSize = 4 + 5 + 3; + int v1DataSize = 4 + 5 + 1; + int offsetArraySize = numVectors * 4; + + ByteBuffer page = ByteBuffer.allocate(7 + offsetArraySize + v0DataSize + v1DataSize) + .order(ByteOrder.LITTLE_ENDIAN); + + page.put((byte) 0).put((byte) 0).put((byte) 3); + page.putInt(numElements); + page.putInt(offsetArraySize); + page.putInt(offsetArraySize + v0DataSize); + + // Vector 0 + page.put((byte) 0).put((byte) 0).putShort((short) 0); + page.putInt(1).put((byte) 3); + page.put(v0Packed, 0, 3); + + // Vector 1 + page.put((byte) 0).put((byte) 0).putShort((short) 0); + page.putInt(9).put((byte) 1); + page.put(v1Packed, 0, 1); + + page.flip(); + + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(numElements, ByteBufferInputStream.wrap(page)); + + // Skip entire vector 0 (8 values) + reader.skip(8); + + // Read from vector 1 + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(9.0f)); + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(10.0f)); + } + + // ========== Vector Size Validation ========== + + /** + * Verify that vectorSize=65536 is rejected because num_exceptions (uint16) + * cannot represent 65536, which would cause silent data corruption if all + * values in a vector are exceptions. + */ + @Test + public void testVectorSize65536Rejected() throws Exception { + assertThatThrownBy(() -> + new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator(), 65536)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Vector size log2 must be between"); + } + + /** Integer.MIN_VALUE has a single bit set, so only the positivity check rejects it. */ + @Test + public void testNonPowerOfTwoVectorSizeRejected() { + for (int vectorSize : new int[] {0, 3, 1000, 1025, -1024, Integer.MIN_VALUE}) { + assertThatThrownBy(() -> new AlpConfig(vectorSize)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Vector size must be a power of 2"); + } + assertThat(new AlpConfig(1024).getVectorSize()).isEqualTo(1024); + } + + /** + * Verify that vectorSize=32768 (max allowed) works correctly. + */ + @Test + public void testVectorSize32768Works() throws Exception { + AlpValuesWriter.FloatAlpValuesWriter writer = null; + try { + writer = new AlpValuesWriter.FloatAlpValuesWriter(256, 256, new DirectByteBufferAllocator(), 32768); + // Write a small number of values (partial vector) + for (int i = 0; i < 10; i++) { + writer.writeFloat(i * 1.0f); + } + + BytesInput input = writer.getBytes(); + AlpValuesReaderForFloat reader = new AlpValuesReaderForFloat(); + reader.initFromPage(10, ByteBufferInputStream.wrap(input.toByteBuffer())); + + for (int i = 0; i < 10; i++) { + assertThat(Float.floatToRawIntBits(reader.readFloat())).isEqualTo(Float.floatToRawIntBits(i * 1.0f)); + } + } finally { + if (writer != null) { + writer.reset(); + writer.close(); + } + } + } + + // ========== Factor > 0 Tests ========== + + /** + * Data where the best (e,f) combo has factor > 0. + * Values like 1234.56 have digits on both sides of the decimal point. + * With e=4,f=2: 1234.56 * 10000 / 100 = 123456 — exact integer. + * This exercises the division path in the encode/decode formula. + */ + @Test + public void testDoubleFactorGreaterThanZero() throws Exception { + // These values are designed so the best combo has f > 0 + double[] values = new double[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = 1000.0 + i * 0.01; // 1000.00, 1000.01, ... + } + + // Verify the best params actually have f > 0 or f == 0 with low exceptions + AlpCodec.EncodingParams params = + AlpCodec.findBestDoubleParams(values, 0, values.length, AlpCodec.ALL_VALID_DOUBLE_PAIRS); + // Regardless of the chosen f, the roundtrip must be lossless + roundTripDouble(values); + } + + @Test + public void testFloatFactorGreaterThanZero() throws Exception { + float[] values = new float[1024]; + for (int i = 0; i < values.length; i++) { + values[i] = 1000.0f + i * 0.01f; + } + roundTripFloat(values); + } + + // ========== Overflow Boundary Tests ========== + + /** + * Values near the encoding limits that test the overflow check. + * The old code used Long.MAX_VALUE (which rounds to 2^63 as double), + * the fix uses DOUBLE_ENCODING_UPPER_LIMIT = 9223372036854774784.0. + */ + @Test + public void testDoubleNearOverflowBoundary() throws Exception { + double[] values = { + 9.2e18, // near Long.MAX_VALUE + -9.2e18, // near Long.MIN_VALUE + 1.7e308, // near Double.MAX_VALUE + -1.7e308, // near -Double.MAX_VALUE + 4.9e-324, // Double.MIN_VALUE (subnormal) + 1.0e18, // large but in range + -1.0e18, // large negative but in range + 0.0, // normal + 1.23, // normal + }; + roundTripDouble(values); + } + + @Test + public void testFloatNearOverflowBoundary() throws Exception { + float[] values = { + 2.1e9f, // near Integer.MAX_VALUE + -2.1e9f, // near Integer.MIN_VALUE + 3.4e38f, // near Float.MAX_VALUE + -3.4e38f, // near -Float.MAX_VALUE + 1.4e-45f, // Float.MIN_VALUE (subnormal) + 1.0e9f, // large but in range + 0.0f, // normal + 1.23f, // normal + }; + roundTripFloat(values); + } + + // ========== Large Scale Stress Test ========== + + @Test + public void testDoubleLargeScale100K() throws Exception { + Random rand = new Random(12345); + double[] values = new double[100_000]; + for (int i = 0; i < values.length; i++) { + values[i] = rand.nextDouble() * 10000.0 - 5000.0; + } + roundTripDouble(values); + } + + @Test + public void testFloatLargeScale100K() throws Exception { + Random rand = new Random(12345); + float[] values = new float[100_000]; + for (int i = 0; i < values.length; i++) { + values[i] = rand.nextFloat() * 10000.0f - 5000.0f; + } + roundTripFloat(values); + } + + // ========== All Exceptions in Large Vector ========== + + /** + * A full vector where every value is an exception (NaN/Inf/-0.0). + * This exercises the case where placeholder search finds no valid value. + */ + @Test + public void testFloatEntireVectorAllExceptions() throws Exception { + float[] values = new float[DEFAULT_VECTOR_SIZE + 5]; // partial second vector + for (int i = 0; i < values.length; i++) { + switch (i % 4) { + case 0: + values[i] = Float.NaN; + break; + case 1: + values[i] = Float.POSITIVE_INFINITY; + break; + case 2: + values[i] = Float.NEGATIVE_INFINITY; + break; + case 3: + values[i] = -0.0f; + break; + } + } + roundTripFloat(values); + } + + @Test + public void testDoubleEntireVectorAllExceptions() throws Exception { + double[] values = new double[DEFAULT_VECTOR_SIZE + 5]; + for (int i = 0; i < values.length; i++) { + switch (i % 4) { + case 0: + values[i] = Double.NaN; + break; + case 1: + values[i] = Double.POSITIVE_INFINITY; + break; + case 2: + values[i] = Double.NEGATIVE_INFINITY; + break; + case 3: + values[i] = -0.0; + break; + } + } + roundTripDouble(values); + } + + // ========== Verify Encoder Produces Expected Values ========== + + /** + * Manually verify that the encoder produces the exact integer values + * we expect for known inputs. This is independent of the writer/reader. + */ + @Test + public void testEncoderProducesExpectedValues() { + // 1.23f * 100 = 123 + assertThat(AlpCodec.encodeFloat(1.23f, 2, 0)).isEqualTo(123); + // 19.99f * 100 = 1999 + assertThat(AlpCodec.encodeFloat(19.99f, 2, 0)).isEqualTo(1999); + // -5.0f * 10 = -50 + assertThat(AlpCodec.encodeFloat(-5.0f, 1, 0)).isEqualTo(-50); + // 0.0f * anything = 0 + assertThat(AlpCodec.encodeFloat(0.0f, 5, 0)).isZero(); + // 42.0f * 1 = 42 + assertThat(AlpCodec.encodeFloat(42.0f, 0, 0)).isEqualTo(42); + // 1.5f * 10 = 15 + assertThat(AlpCodec.encodeFloat(1.5f, 1, 0)).isEqualTo(15); + + // Double path + assertThat(AlpCodec.encodeDouble(1.23, 2, 0)).isEqualTo(123L); + assertThat(AlpCodec.encodeDouble(19.99, 2, 0)).isEqualTo(1999L); + assertThat(AlpCodec.encodeDouble(-5.0, 1, 0)).isEqualTo(-50L); + assertThat(AlpCodec.encodeDouble(0.0, 5, 0)).isZero(); + assertThat(AlpCodec.encodeDouble(42.0, 0, 0)).isEqualTo(42L); + } +} diff --git a/parquet-column/src/test/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactoryTest.java b/parquet-column/src/test/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactoryTest.java index 436c13834f..a8a575c82a 100644 --- a/parquet-column/src/test/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactoryTest.java +++ b/parquet-column/src/test/java/org/apache/parquet/column/values/factory/DefaultValuesWriterFactoryTest.java @@ -32,6 +32,7 @@ import org.apache.parquet.column.ParquetProperties; import org.apache.parquet.column.ParquetProperties.WriterVersion; import org.apache.parquet.column.values.ValuesWriter; +import org.apache.parquet.column.values.alp.AlpValuesWriter; import org.apache.parquet.column.values.bytestreamsplit.ByteStreamSplitValuesWriter; import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriter; import org.apache.parquet.column.values.delta.DeltaBinaryPackingValuesWriterForLong; @@ -460,6 +461,30 @@ public void testDouble_V2_WithByteStreamSplitAndDictionary() { testFloatingPoint_WithByteStreamSplitAndDictionary(PrimitiveTypeName.DOUBLE, WriterVersion.PARQUET_2_0); } + @Test + public void testFloat_V1_WithAlp() { + testFloatingPoint_WithAlp( + PrimitiveTypeName.FLOAT, WriterVersion.PARQUET_1_0, AlpValuesWriter.FloatAlpValuesWriter.class); + } + + @Test + public void testDouble_V1_WithAlp() { + testFloatingPoint_WithAlp( + PrimitiveTypeName.DOUBLE, WriterVersion.PARQUET_1_0, AlpValuesWriter.DoubleAlpValuesWriter.class); + } + + @Test + public void testFloat_V2_WithAlp() { + testFloatingPoint_WithAlp( + PrimitiveTypeName.FLOAT, WriterVersion.PARQUET_2_0, AlpValuesWriter.FloatAlpValuesWriter.class); + } + + @Test + public void testDouble_V2_WithAlp() { + testFloatingPoint_WithAlp( + PrimitiveTypeName.DOUBLE, WriterVersion.PARQUET_2_0, AlpValuesWriter.DoubleAlpValuesWriter.class); + } + @Test public void testColumnWiseDictionaryWithFalseDefault() { ValuesWriterFactory factory = getDefaultFactory( @@ -631,6 +656,36 @@ private void testFloatingPoint_WithByteStreamSplitAndDictionary( PlainValuesWriter.class); } + private void testFloatingPoint_WithAlp( + PrimitiveTypeName typeName, WriterVersion writerVersion, Class alpWriterClass) { + // Cross-column ALP without a dictionary: the ALP writer is produced directly. + doTestValueWriter( + createColumnDescriptor(typeName), + ParquetProperties.builder() + .withWriterVersion(writerVersion) + .withDictionaryEncoding(false) + .withAlp() + .build(), + alpWriterClass); + // Cross-column ALP with a dictionary: the ALP writer is the dictionary's fallback. + doTestValueWriter( + createColumnDescriptor(typeName), + ParquetProperties.builder() + .withWriterVersion(writerVersion) + .withAlp() + .build(), + DictionaryValuesWriter.class, + alpWriterClass); + // Per-column ALP: only colA opts in, colB keeps the plain fallback. + ParquetProperties properties = ParquetProperties.builder() + .withWriterVersion(writerVersion) + .withDictionaryEncoding(false) + .withAlp("colA") + .build(); + doTestValueWriter(createColumnDescriptor(typeName, "colA"), properties, alpWriterClass); + doTestValueWriter(createColumnDescriptor(typeName, "colB"), properties, PlainValuesWriter.class); + } + private void validateFactory( ValuesWriterFactory factory, PrimitiveTypeName typeName, diff --git a/parquet-common/src/main/java/org/apache/parquet/bytes/BytesUtils.java b/parquet-common/src/main/java/org/apache/parquet/bytes/BytesUtils.java index 07bcc28903..6abbae18eb 100644 --- a/parquet-common/src/main/java/org/apache/parquet/bytes/BytesUtils.java +++ b/parquet-common/src/main/java/org/apache/parquet/bytes/BytesUtils.java @@ -50,6 +50,16 @@ public static int getWidthFromMaxInt(int bound) { return 32 - Integer.numberOfLeadingZeros(bound); } + /** + * give the number of bits needed to encode a long given the max value + * + * @param bound max long that we want to encode + * @return the number of bits required + */ + public static int getWidthFromMaxLong(long bound) { + return 64 - Long.numberOfLeadingZeros(bound); + } + /** * reads an int in little endian at the given position * @@ -66,6 +76,29 @@ public static int readIntLittleEndian(ByteBuffer in, int offset) throws IOExcept return ((ch1 << 24) + (ch2 << 16) + (ch3 << 8) + (ch4 << 0)); } + /** + * reads an unsigned byte at the given position + * + * @param in a byte buffer + * @param offset an offset into the byte buffer + * @return the byte at position offset, from 0 to 255 + */ + public static int readUnsignedByte(ByteBuffer in, int offset) { + return in.get(offset) & 0xff; + } + + /** + * reads an unsigned short in little endian at the given position, regardless of the buffer's byte + * order + * + * @param in a byte buffer + * @param offset an offset into the byte buffer + * @return the short at position offset read using little endian byte order, from 0 to 65535 + */ + public static int readUnsignedShortLittleEndian(ByteBuffer in, int offset) { + return (in.get(offset) & 0xff) | ((in.get(offset + 1) & 0xff) << 8); + } + /** * reads an int in little endian at the given position * diff --git a/parquet-common/src/test/java/org/apache/parquet/bytes/TestBytesUtil.java b/parquet-common/src/test/java/org/apache/parquet/bytes/TestBytesUtil.java index c18d6f5a9c..e8f9bac2c4 100644 --- a/parquet-common/src/test/java/org/apache/parquet/bytes/TestBytesUtil.java +++ b/parquet-common/src/test/java/org/apache/parquet/bytes/TestBytesUtil.java @@ -19,8 +19,13 @@ package org.apache.parquet.bytes; import static org.apache.parquet.bytes.BytesUtils.getWidthFromMaxInt; +import static org.apache.parquet.bytes.BytesUtils.getWidthFromMaxLong; +import static org.apache.parquet.bytes.BytesUtils.readUnsignedByte; +import static org.apache.parquet.bytes.BytesUtils.readUnsignedShortLittleEndian; import static org.assertj.core.api.Assertions.assertThat; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; import org.junit.jupiter.api.Test; public class TestBytesUtil { @@ -46,4 +51,60 @@ public void testWidth() { assertThat(getWidthFromMaxInt(128)).isEqualTo(8); assertThat(getWidthFromMaxInt(255)).isEqualTo(8); } + + @Test + public void testWidthLong() { + assertThat(getWidthFromMaxLong(0L)).isZero(); + assertThat(getWidthFromMaxLong(1L)).isEqualTo(1); + assertThat(getWidthFromMaxLong(2L)).isEqualTo(2); + assertThat(getWidthFromMaxLong(3L)).isEqualTo(2); + assertThat(getWidthFromMaxLong(4L)).isEqualTo(3); + assertThat(getWidthFromMaxLong(255L)).isEqualTo(8); + assertThat(getWidthFromMaxLong(256L)).isEqualTo(9); + assertThat(getWidthFromMaxLong(65535L)).isEqualTo(16); + assertThat(getWidthFromMaxLong((long) Integer.MAX_VALUE)).isEqualTo(31); + // Beyond the int range, where getWidthFromMaxInt cannot be used + assertThat(getWidthFromMaxLong(1L << 32)).isEqualTo(33); + assertThat(getWidthFromMaxLong(Long.MAX_VALUE)).isEqualTo(63); + // A negative bound has the sign bit set, so it occupies the full 64 bits + assertThat(getWidthFromMaxLong(-1L)).isEqualTo(64); + } + + @Test + public void testReadUnsignedByte() { + ByteBuffer buf = ByteBuffer.wrap(new byte[] {0, 0x7f, (byte) 0x80, (byte) 0xff}); + assertThat(readUnsignedByte(buf, 0)).isZero(); + assertThat(readUnsignedByte(buf, 1)).isEqualTo(127); + assertThat(readUnsignedByte(buf, 2)).isEqualTo(128); + assertThat(readUnsignedByte(buf, 3)).isEqualTo(255); + // The offset is absolute, so the buffer's position doesn't matter + buf.position(3); + assertThat(readUnsignedByte(buf, 2)).isEqualTo(128); + } + + @Test + public void testReadUnsignedShortLittleEndian() { + byte[] bytes = {0, 0, (byte) 0xff, 0x7f, 0, (byte) 0x80, (byte) 0xff, (byte) 0xff, 0x34, 0x12}; + for (ByteOrder order : new ByteOrder[] {ByteOrder.LITTLE_ENDIAN, ByteOrder.BIG_ENDIAN}) { + ByteBuffer buf = ByteBuffer.wrap(bytes).order(order); + assertThat(readUnsignedShortLittleEndian(buf, 0)) + .as("order=%s", order) + .isZero(); + assertThat(readUnsignedShortLittleEndian(buf, 2)) + .as("order=%s", order) + .isEqualTo(32767); + assertThat(readUnsignedShortLittleEndian(buf, 4)) + .as("order=%s", order) + .isEqualTo(32768); + assertThat(readUnsignedShortLittleEndian(buf, 6)) + .as("order=%s", order) + .isEqualTo(65535); + assertThat(readUnsignedShortLittleEndian(buf, 8)) + .as("order=%s", order) + .isEqualTo(0x1234); + assertThat(readUnsignedShortLittleEndian(buf, 3)) + .as("order=%s", order) + .isEqualTo(0x007f); + } + } } diff --git a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetWriter.java b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetWriter.java index 7dcbb3188c..2df44d49be 100644 --- a/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetWriter.java +++ b/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/ParquetWriter.java @@ -28,6 +28,7 @@ import org.apache.parquet.bytes.ByteBufferAllocator; import org.apache.parquet.column.ParquetProperties; import org.apache.parquet.column.ParquetProperties.WriterVersion; +import org.apache.parquet.column.values.alp.AlpConfig; import org.apache.parquet.compression.CompressionCodecFactory; import org.apache.parquet.conf.HadoopParquetConfiguration; import org.apache.parquet.conf.ParquetConfiguration; @@ -737,6 +738,36 @@ public SELF withByteStreamSplitEncoding(String columnPath, boolean enableByteStr return self(); } + public SELF withAlp() { + encodingPropsBuilder.withAlp(); + return self(); + } + + public SELF withAlp(AlpConfig config) { + encodingPropsBuilder.withAlp(config); + return self(); + } + + public SELF withAlp(String columnPath) { + encodingPropsBuilder.withAlp(columnPath); + return self(); + } + + public SELF withAlp(String columnPath, AlpConfig config) { + encodingPropsBuilder.withAlp(columnPath, config); + return self(); + } + + public SELF withoutAlp() { + encodingPropsBuilder.withoutAlp(); + return self(); + } + + public SELF withoutAlp(String columnPath) { + encodingPropsBuilder.withoutAlp(columnPath); + return self(); + } + /** * Enable or disable dictionary encoding of the specified column for the constructed writer. * diff --git a/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java b/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java index 46ffccf7fa..7ec562c878 100644 --- a/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java +++ b/parquet-hadoop/src/test/java/org/apache/parquet/format/converter/TestParquetMetadataConverter.java @@ -589,11 +589,6 @@ public void testEnumEquivalence() { .isEqualTo(encoding); } for (org.apache.parquet.format.Encoding encoding : org.apache.parquet.format.Encoding.values()) { - // ALP is in the format spec but is not implemented on the Java side yet, so it has no - // org.apache.parquet.column.Encoding to round trip through. Remove this once it does. - if (encoding == org.apache.parquet.format.Encoding.ALP) { - continue; - } assertThat(parquetMetadataConverter.getEncoding(parquetMetadataConverter.getEncoding(encoding))) .isEqualTo(encoding); } diff --git a/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestInterOpReadAlp.java b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestInterOpReadAlp.java new file mode 100644 index 0000000000..535037a0af --- /dev/null +++ b/parquet-hadoop/src/test/java/org/apache/parquet/hadoop/TestInterOpReadAlp.java @@ -0,0 +1,685 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.hadoop.conf.Configuration; +import org.apache.parquet.column.Encoding; +import org.apache.parquet.column.ParquetProperties.WriterVersion; +import org.apache.parquet.column.page.PageReadStore; +import org.apache.parquet.column.values.alp.AlpConfig; +import org.apache.parquet.example.data.Group; +import org.apache.parquet.example.data.simple.SimpleGroup; +import org.apache.parquet.example.data.simple.convert.GroupRecordConverter; +import org.apache.parquet.hadoop.example.ExampleParquetWriter; +import org.apache.parquet.hadoop.metadata.CompressionCodecName; +import org.apache.parquet.hadoop.metadata.ParquetMetadata; +import org.apache.parquet.io.ColumnIOFactory; +import org.apache.parquet.io.LocalInputFile; +import org.apache.parquet.io.LocalOutputFile; +import org.apache.parquet.io.MessageColumnIO; +import org.apache.parquet.io.RecordReader; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.MessageTypeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Cross-compatibility tests for ALP (Adaptive Lossless floating-Point) encoding. + * + *

Reads the ALP conformance file from parquet-testing and checks that every ALP column decodes + * bit-identically to its PLAIN counterpart, and round-trips Java-written ALP files. + */ +public class TestInterOpReadAlp { + private static final Logger LOG = LoggerFactory.getLogger(TestInterOpReadAlp.class); + + @TempDir + java.nio.file.Path temp; + + /** Mirrors JUnit 4's {@code TemporaryFolder#newFolder()}: a fresh directory per call. */ + private File newFolder() throws IOException { + return Files.createTempDirectory(temp, "junit").toFile(); + } + + private static final String ALP_EXTENDED_FILE = "alp_extended.zstd.parquet"; + private static final String CHANGESET = "09f3cdb"; + private final InterOpTester interop = new InterOpTester(); + + private static final int EXTENDED_ROWS = 9032; + private static final int EXTENDED_ROW_GROUPS = 5; + private static final int EXTENDED_NULLS = 8; + + /** ALP column -> PLAIN column holding the same values. */ + private static final Map ALP_TO_PLAIN = new LinkedHashMap<>(); + + static { + ALP_TO_PLAIN.put("float_alp_32", "float_plain"); + ALP_TO_PLAIN.put("float_alp_1024", "float_plain"); + ALP_TO_PLAIN.put("float_alp_4096", "float_plain"); + ALP_TO_PLAIN.put("double_alp_32", "double_plain"); + ALP_TO_PLAIN.put("double_alp_1024", "double_plain"); + ALP_TO_PLAIN.put("double_alp_4096", "double_plain"); + } + + private java.nio.file.Path getExtendedFile() throws IOException { + return Paths.get(interop.GetInterOpFile(ALP_EXTENDED_FILE, CHANGESET).toString()); + } + + /** Read all rows from a parquet file using LocalInputFile (no Hadoop FileSystem). */ + private List readAllRows(java.nio.file.Path filePath) throws IOException { + List rows = new ArrayList<>(); + try (ParquetFileReader reader = ParquetFileReader.open(new LocalInputFile(filePath))) { + ParquetMetadata footer = reader.getFooter(); + MessageType schema = footer.getFileMetaData().getSchema(); + MessageColumnIO columnIO = new ColumnIOFactory().getColumnIO(schema); + PageReadStore pages; + while ((pages = reader.readNextRowGroup()) != null) { + long rowCount = pages.getRowCount(); + RecordReader recordReader = columnIO.getRecordReader(pages, new GroupRecordConverter(schema)); + for (long i = 0; i < rowCount; i++) { + rows.add(recordReader.read()); + } + } + } + return rows; + } + + private static boolean isNull(Group row, String field) { + return row.getFieldRepetitionCount(field) == 0; + } + + /** Raw IEEE 754 bits of a float or double field, widened to a long for exact comparison. */ + private static long rawBits(Group row, String field, boolean isFloat) { + return isFloat + ? Float.floatToRawIntBits(row.getFloat(field, 0)) & 0xFFFFFFFFL + : Double.doubleToRawLongBits(row.getDouble(field, 0)); + } + + @Test + public void testAlpColumnsMatchPlainColumns() throws IOException { + List rows = readAllRows(getExtendedFile()); + assertThat(rows).as("row count").hasSize(EXTENDED_ROWS); + + for (Map.Entry entry : ALP_TO_PLAIN.entrySet()) { + String alpCol = entry.getKey(); + String plainCol = entry.getValue(); + boolean isFloat = alpCol.startsWith("float"); + int nulls = 0; + for (int i = 0; i < rows.size(); i++) { + Group row = rows.get(i); + boolean alpNull = isNull(row, alpCol); + assertThat(alpNull).as("nullness of " + alpCol + " at row " + i).isEqualTo(isNull(row, plainCol)); + if (alpNull) { + nulls++; + continue; + } + long alpBits = rawBits(row, alpCol, isFloat); + long plainBits = rawBits(row, plainCol, isFloat); + assertThat(alpBits) + .as(String.format( + "%s row %d: bits 0x%x, %s bits 0x%x", alpCol, i, alpBits, plainCol, plainBits)) + .isEqualTo(plainBits); + } + assertThat(nulls).as(alpCol + " nulls").isEqualTo(EXTENDED_NULLS); + } + } + + @Test + public void testAlpColumnsAreAlpEncoded() throws IOException { + try (ParquetFileReader reader = ParquetFileReader.open(new LocalInputFile(getExtendedFile()))) { + ParquetMetadata footer = reader.getFooter(); + assertThat(footer.getBlocks()).as("row groups").hasSize(EXTENDED_ROW_GROUPS); + int alpChunks = 0; + for (org.apache.parquet.hadoop.metadata.BlockMetaData block : footer.getBlocks()) { + for (org.apache.parquet.hadoop.metadata.ColumnChunkMetaData chunk : block.getColumns()) { + String path = chunk.getPath().toDotString(); + boolean hasAlp = chunk.getEncodings().contains(Encoding.ALP); + assertThat(hasAlp) + .as("ALP on " + path + ": " + chunk.getEncodings()) + .isEqualTo(ALP_TO_PLAIN.containsKey(path)); + if (hasAlp) { + alpChunks++; + } + } + } + assertThat(alpChunks).as("ALP column chunks").isEqualTo(ALP_TO_PLAIN.size() * EXTENDED_ROW_GROUPS); + } + } + + /** + * Checks exact bit patterns at known rows, so a fault that corrupted the ALP and PLAIN paths the + * same way would still be caught. NaN payloads must survive, since exceptions are stored verbatim. + */ + @Test + public void testSpecialValueBitPatterns() throws IOException { + List rows = readAllRows(getExtendedFile()); + assertBits(rows, 1024, 0x7FF8000000000000L, 0x7FC00000L, "canonical NaN"); + assertBits(rows, 1500, 0x7FF800DEADBEEF00L, 0x7FC0DEADL, "NaN with payload"); + assertBits(rows, 2047, 0xFFF8000000000001L, 0xFFC00001L, "negative NaN with payload"); + assertBits(rows, 2000, 0x7FF0000000000000L, 0x7F800000L, "+Inf"); + assertBits(rows, 2001, 0xFFF0000000000000L, 0xFF800000L, "-Inf"); + assertBits(rows, 2002, 0x8000000000000000L, 0x80000000L, "-0.0"); + assertBits(rows, 2003, 0x1L, 0x1L, "smallest subnormal"); + assertBits(rows, 2500, 0x400921FB54442D18L, 0x40490FDBL, "pi"); + assertBits(rows, 7168, 0x401F147AE147AE14L, 0x40F8A3D7L, "constant 7.77"); + assertBits(rows, 9000, 0xC3DBC16D674EC800L, 0xDEDE0B6BL, "-8e18"); + assertBits(rows, 9001, 0x43DBC16D674EC800L, 0x5EDE0B6BL, "8e18"); + for (int row : new int[] {8200, 8900}) { + for (String col : ALP_TO_PLAIN.keySet()) { + assertThat(isNull(rows.get(row), col)) + .as(col + " null at row " + row) + .isTrue(); + } + } + } + + private void assertBits(List rows, int row, long doubleBits, long floatBits, String what) { + for (String col : ALP_TO_PLAIN.keySet()) { + boolean isFloat = col.startsWith("float"); + assertThat(rawBits(rows.get(row), col, isFloat)) + .as(what + " at row " + row + " in " + col) + .isEqualTo(isFloat ? floatBits : doubleBits); + } + } + + private static final String ALP_SCHEMA = + "message alp_interop { " + "required double double_col; " + "required float float_col; " + "}"; + + private static final double[] DOUBLE_VALUES = { + 1.23, 4.56, 7.89, 0.001, 1000.0, -3.14, 2.718281828, 9.99999, 0.123456789, 100.5 + }; + private static final float[] FLOAT_VALUES = { + 1.23f, 4.56f, 7.89f, 0.001f, 1000.0f, -3.14f, 2.718f, 9.999f, 0.1234f, 100.5f + }; + + /** + * Write an ALP-encoded file from Java using the given page version, then read it back and verify + * all double and float values round-trip exactly. + */ + private void writeAndVerifyAlpFile(WriterVersion version) throws IOException { + MessageType schema = MessageTypeParser.parseMessageType(ALP_SCHEMA); + java.nio.file.Path outPath = + newFolder().toPath().resolve("alp_java_" + version.name().toLowerCase() + ".parquet"); + + try (ParquetWriter writer = ExampleParquetWriter.builder(new LocalOutputFile(outPath)) + .withType(schema) + .withCompressionCodec(CompressionCodecName.UNCOMPRESSED) + .withWriterVersion(version) + .withAlp() + .withDictionaryEncoding(false) + .withConf(new Configuration()) + .build()) { + for (int i = 0; i < DOUBLE_VALUES.length; i++) { + SimpleGroup row = new SimpleGroup(schema); + row.add("double_col", DOUBLE_VALUES[i]); + row.add("float_col", FLOAT_VALUES[i]); + writer.write(row); + } + } + + List rows = readAllRows(outPath); + assertThat(rows).as("Row count mismatch for " + version).hasSameSizeAs(DOUBLE_VALUES); + for (int i = 0; i < DOUBLE_VALUES.length; i++) { + assertThat(rows.get(i).getDouble("double_col", 0)) + .as("double_col mismatch at row " + i + " for " + version) + .isEqualTo(DOUBLE_VALUES[i]); + assertThat(rows.get(i).getFloat("float_col", 0)) + .as("float_col mismatch at row " + i + " for " + version) + .isEqualTo(FLOAT_VALUES[i]); + } + LOG.info( + "writeAndVerifyAlpFile [{}]: wrote and read back {} rows from {}", + version, + rows.size(), + outPath.getFileName()); + } + + /** + * Java writes ALP-encoded floats/doubles using V1 (PARQUET_1_0) data pages and reads them back. + * Verifies the Java write path produces a valid file readable by this implementation. + */ + @Test + public void testJavaWriteAlpV1Pages() throws IOException { + writeAndVerifyAlpFile(WriterVersion.PARQUET_1_0); + } + + /** + * Writes >4096 rows with vectorSize=4096 so multiple full vectors are flushed, then verifies + * the file round-trips exactly. The reader pulls log_vector_size from the on-disk header to + * size its unpacking window, so a wrong header byte would surface as decode garbage — + * round-trip equality is sufficient proof that the configured vector size took effect. + */ + @Test + public void testJavaWriteAlpCustomVectorSize() throws IOException { + MessageType schema = MessageTypeParser.parseMessageType(ALP_SCHEMA); + int rowCount = 4500; // crosses one full vector + partial tail at vectorSize=4096 + double[] doubles = new double[rowCount]; + float[] floats = new float[rowCount]; + // 2-decimal sensor-like data — the ALP sweet spot, so few/no exceptions + for (int i = 0; i < rowCount; i++) { + doubles[i] = (i * 13L % 100000) / 100.0; + floats[i] = (float) ((i * 7L % 10000) / 100.0); + } + + java.nio.file.Path outPath = newFolder().toPath().resolve("alp_java_vs4096.parquet"); + + try (ParquetWriter writer = ExampleParquetWriter.builder(new LocalOutputFile(outPath)) + .withType(schema) + .withCompressionCodec(CompressionCodecName.UNCOMPRESSED) + .withWriterVersion(WriterVersion.PARQUET_2_0) + .withAlp(new AlpConfig(4096)) + .withDictionaryEncoding(false) + .withConf(new Configuration()) + .build()) { + for (int i = 0; i < rowCount; i++) { + SimpleGroup row = new SimpleGroup(schema); + row.add("double_col", doubles[i]); + row.add("float_col", floats[i]); + writer.write(row); + } + } + + List rows = readAllRows(outPath); + assertThat(rows).as("Row count mismatch at vectorSize=4096").hasSize(rowCount); + for (int i = 0; i < rowCount; i++) { + assertThat(rows.get(i).getDouble("double_col", 0)) + .as("double_col mismatch at row " + i) + .isEqualTo(doubles[i]); + assertThat(rows.get(i).getFloat("float_col", 0)) + .as("float_col mismatch at row " + i) + .isEqualTo(floats[i]); + } + LOG.info("testJavaWriteAlpCustomVectorSize: {} rows round-tripped at vectorSize=4096", rowCount); + } + + /** + * Java writes ALP-encoded floats/doubles using V2 (PARQUET_2_0) data pages and reads them back. + */ + @Test + public void testJavaWriteAlpV2Pages() throws IOException { + writeAndVerifyAlpFile(WriterVersion.PARQUET_2_0); + } + + // --------------------------------------------------------------------------- + // Statistics correctness for ALP-encoded columns. + // + // Statistics are populated by the column writer wrapper (not the encoder), + // so they should "just work" regardless of which encoding is in use — but + // the assumption is worth pinning. Wrong/missing statistics break parquet + // predicate pushdown: readers either skip row groups they shouldn't or scan + // ones they could have skipped. + // --------------------------------------------------------------------------- + + @Test + public void testAlpColumnStatisticsAreCorrect() throws IOException { + MessageType schema = MessageTypeParser.parseMessageType(ALP_SCHEMA); + // Mix positive / negative / zero so min/max aren't trivially the endpoints + double[] doubleVals = {1.23, -4.56, 7.89, 0.0, 1000.0, -3.14, 9.99, 0.001}; + float[] floatVals = {1.23f, -4.56f, 7.89f, 0.0f, 1000.0f, -3.14f, 9.99f, 0.001f}; + double expectedDoubleMin = -4.56, expectedDoubleMax = 1000.0; + float expectedFloatMin = -4.56f, expectedFloatMax = 1000.0f; + + java.nio.file.Path outPath = newFolder().toPath().resolve("alp_stats.parquet"); + try (ParquetWriter writer = ExampleParquetWriter.builder(new LocalOutputFile(outPath)) + .withType(schema) + .withCompressionCodec(CompressionCodecName.UNCOMPRESSED) + .withWriterVersion(WriterVersion.PARQUET_2_0) + .withAlp() + .withDictionaryEncoding(false) + .withConf(new Configuration()) + .build()) { + for (int i = 0; i < doubleVals.length; i++) { + SimpleGroup row = new SimpleGroup(schema); + row.add("double_col", doubleVals[i]); + row.add("float_col", floatVals[i]); + writer.write(row); + } + } + + try (ParquetFileReader reader = ParquetFileReader.open(new LocalInputFile(outPath))) { + ParquetMetadata footer = reader.getFooter(); + assertThat(footer.getBlocks()).as("expected one row group").hasSize(1); + org.apache.parquet.hadoop.metadata.BlockMetaData block = + footer.getBlocks().get(0); + + org.apache.parquet.hadoop.metadata.ColumnChunkMetaData doubleChunk = null; + org.apache.parquet.hadoop.metadata.ColumnChunkMetaData floatChunk = null; + for (org.apache.parquet.hadoop.metadata.ColumnChunkMetaData c : block.getColumns()) { + String path = c.getPath().toDotString(); + if (path.equals("double_col")) doubleChunk = c; + if (path.equals("float_col")) floatChunk = c; + } + assertThat(doubleChunk.getEncodings()) + .as("double_col must be ALP-encoded") + .contains(Encoding.ALP); + assertThat(floatChunk.getEncodings()) + .as("float_col must be ALP-encoded") + .contains(Encoding.ALP); + + org.apache.parquet.column.statistics.Statistics dStats = doubleChunk.getStatistics(); + org.apache.parquet.column.statistics.Statistics fStats = floatChunk.getStatistics(); + + assertThat(dStats.hasNonNullValue()) + .as("double stats must have min/max set") + .isTrue(); + assertThat(fStats.hasNonNullValue()) + .as("float stats must have min/max set") + .isTrue(); + + // Bit-exact: same encoder path produces same IEEE 754 representation; + // statistics min/max should reflect the input data exactly. + assertThat(Double.doubleToRawLongBits((Double) dStats.genericGetMin())) + .as("double min mismatch") + .isEqualTo(Double.doubleToRawLongBits(expectedDoubleMin)); + assertThat(Double.doubleToRawLongBits((Double) dStats.genericGetMax())) + .as("double max mismatch") + .isEqualTo(Double.doubleToRawLongBits(expectedDoubleMax)); + assertThat(Float.floatToRawIntBits((Float) fStats.genericGetMin())) + .as("float min mismatch") + .isEqualTo(Float.floatToRawIntBits(expectedFloatMin)); + assertThat(Float.floatToRawIntBits((Float) fStats.genericGetMax())) + .as("float max mismatch") + .isEqualTo(Float.floatToRawIntBits(expectedFloatMax)); + assertThat(dStats.getNumNulls()).as("double null count must be 0").isZero(); + assertThat(fStats.getNumNulls()).as("float null count must be 0").isZero(); + } + } + + // --------------------------------------------------------------------------- + // Integration coverage: the production paths normal ALP tests skip (dictionary + // fallback, compression, adversarial statistics, repeated/nested columns). + // --------------------------------------------------------------------------- + + private boolean columnUsesAlp(java.nio.file.Path file, String colDotPath) throws IOException { + try (ParquetFileReader reader = ParquetFileReader.open(new LocalInputFile(file))) { + for (org.apache.parquet.hadoop.metadata.BlockMetaData b : + reader.getFooter().getBlocks()) { + for (org.apache.parquet.hadoop.metadata.ColumnChunkMetaData c : b.getColumns()) { + if ((colDotPath == null || c.getPath().toDotString().equals(colDotPath)) + && c.getEncodings().contains(Encoding.ALP)) { + return true; + } + } + } + } + return false; + } + + @Test + public void testDictionaryOverflowFallsBackToAlp() throws IOException { + // Dictionary ENABLED (the real default) with a small dictionary page, so a high-cardinality + // double/float column overflows the dictionary and FALLS BACK to ALP mid-column. Normal ALP + // tests disable the dictionary, so this exercises the untested production path where the + // FallbackValuesWriter replays buffered values into ALP. + MessageType schema = MessageTypeParser.parseMessageType("message m { required double d; required float f; }"); + int n = 20000; + double[] ds = new double[n]; + float[] fs = new float[n]; + java.util.Random rng = new java.util.Random(3); + for (int i = 0; i < n; i++) { + ds[i] = Math.round(rng.nextDouble() * 1e9) / 100.0; + fs[i] = (float) (Math.round(rng.nextFloat() * 1e6) / 100.0); + } + java.nio.file.Path outPath = newFolder().toPath().resolve("alp_dict_fallback.parquet"); + try (ParquetWriter writer = ExampleParquetWriter.builder(new LocalOutputFile(outPath)) + .withType(schema) + .withCompressionCodec(CompressionCodecName.UNCOMPRESSED) + .withWriterVersion(WriterVersion.PARQUET_2_0) + .withAlp() + .withDictionaryEncoding(true) + .withDictionaryPageSize(4096) + .withConf(new Configuration()) + .build()) { + for (int i = 0; i < n; i++) { + SimpleGroup row = new SimpleGroup(schema); + row.add("d", ds[i]); + row.add("f", fs[i]); + writer.write(row); + } + } + List rows = readAllRows(outPath); + assertThat(rows).hasSize(n); + for (int i = 0; i < n; i++) { + assertThat(Double.doubleToRawLongBits(rows.get(i).getDouble("d", 0))) + .as("d mismatch at " + i) + .isEqualTo(Double.doubleToRawLongBits(ds[i])); + assertThat(Float.floatToRawIntBits(rows.get(i).getFloat("f", 0))) + .as("f mismatch at " + i) + .isEqualTo(Float.floatToRawIntBits(fs[i])); + } + assertThat(columnUsesAlp(outPath, null)) + .as("Expected dictionary overflow to fall back to ALP encoding") + .isTrue(); + } + + @Test + public void testAlpUnderCompressionCodecs() throws IOException { + MessageType schema = MessageTypeParser.parseMessageType("message m { required double d; required float f; }"); + int n = 5000; + double[] ds = new double[n]; + float[] fs = new float[n]; + for (int i = 0; i < n; i++) { + ds[i] = (i % 1000) / 100.0 - 5.0; + fs[i] = (i % 777) / 100.0f - 3.0f; + } + for (CompressionCodecName codec : new CompressionCodecName[] { + CompressionCodecName.SNAPPY, CompressionCodecName.GZIP, CompressionCodecName.ZSTD + }) { + java.nio.file.Path outPath = newFolder().toPath().resolve("alp_" + codec.name() + ".parquet"); + try (ParquetWriter writer = ExampleParquetWriter.builder(new LocalOutputFile(outPath)) + .withType(schema) + .withCompressionCodec(codec) + .withWriterVersion(WriterVersion.PARQUET_2_0) + .withAlp() + .withDictionaryEncoding(false) + .withConf(new Configuration()) + .build()) { + for (int i = 0; i < n; i++) { + SimpleGroup row = new SimpleGroup(schema); + row.add("d", ds[i]); + row.add("f", fs[i]); + writer.write(row); + } + } + List rows = readAllRows(outPath); + assertThat(rows).as("row count for " + codec).hasSize(n); + for (int i = 0; i < n; i++) { + assertThat(Double.doubleToRawLongBits(rows.get(i).getDouble("d", 0))) + .as("d mismatch " + codec + " row " + i) + .isEqualTo(Double.doubleToRawLongBits(ds[i])); + assertThat(Float.floatToRawIntBits(rows.get(i).getFloat("f", 0))) + .as("f mismatch " + codec + " row " + i) + .isEqualTo(Float.floatToRawIntBits(fs[i])); + } + assertThat(columnUsesAlp(outPath, "d")) + .as(codec + ": column should be ALP-encoded") + .isTrue(); + } + } + + @Test + public void testAlpStatisticsExcludeNaNAndCountNulls() throws IOException { + // Optional double column mixing finite values, NaN (must be excluded from min/max), and nulls + // (must be counted). Combines def-level nulls (num_elements < rowCount) with ALP exceptions (NaN) + // and verifies the footer statistics that predicate pushdown relies on are correct. + MessageType schema = MessageTypeParser.parseMessageType("message m { optional double d; }"); + Double[] vals = {1.0, Double.NaN, -2.0, 5.0, null, 3.0, Double.NaN, null, 0.0}; + java.nio.file.Path outPath = newFolder().toPath().resolve("alp_stats_adv.parquet"); + try (ParquetWriter writer = ExampleParquetWriter.builder(new LocalOutputFile(outPath)) + .withType(schema) + .withCompressionCodec(CompressionCodecName.UNCOMPRESSED) + .withWriterVersion(WriterVersion.PARQUET_2_0) + .withAlp() + .withDictionaryEncoding(false) + .withConf(new Configuration()) + .build()) { + for (Double v : vals) { + SimpleGroup row = new SimpleGroup(schema); + if (v != null) { + row.add("d", v.doubleValue()); + } + writer.write(row); + } + } + // Values also round-trip (with NaN payload preserved and nulls in the right places). + List rows = readAllRows(outPath); + assertThat(rows).hasSameSizeAs(vals); + for (int i = 0; i < vals.length; i++) { + int rep = rows.get(i).getFieldRepetitionCount("d"); + if (vals[i] == null) { + assertThat(rep).as("row " + i + " should be null").isZero(); + } else { + assertThat(rep).as("row " + i + " should be present").isEqualTo(1); + assertThat(Double.doubleToRawLongBits(rows.get(i).getDouble("d", 0))) + .as("value row " + i) + .isEqualTo(Double.doubleToRawLongBits(vals[i])); + } + } + try (ParquetFileReader reader = ParquetFileReader.open(new LocalInputFile(outPath))) { + org.apache.parquet.hadoop.metadata.ColumnChunkMetaData chunk = + reader.getFooter().getBlocks().get(0).getColumns().get(0); + org.apache.parquet.column.statistics.Statistics s = chunk.getStatistics(); + assertThat(s.getNumNulls()).as("null count").isEqualTo(2L); + double min = (Double) s.genericGetMin(); + double max = (Double) s.genericGetMax(); + assertThat(!Double.isNaN(min)).as("min must not be NaN").isTrue(); + assertThat(!Double.isNaN(max)).as("max must not be NaN").isTrue(); + assertThat(min).as("min").isEqualTo(-2.0); + assertThat(max).as("max").isEqualTo(5.0); + } + } + + @Test + public void testAlpLargeScaleManyRowGroups() throws IOException { + // Half a million rows with a small row-group size, forcing many row groups, exercised through + // the full write/read pipeline (compression on). Verification is streaming (regenerate the + // expected sequence, no List accumulation) so the read side is memory-safe at scale too. + MessageType schema = MessageTypeParser.parseMessageType("message m { required double d; required float f; }"); + int n = 500_000; + long seed = 5; + java.nio.file.Path outPath = newFolder().toPath().resolve("alp_large.parquet"); + java.util.Random rng = new java.util.Random(seed); + try (ParquetWriter writer = ExampleParquetWriter.builder(new LocalOutputFile(outPath)) + .withType(schema) + .withCompressionCodec(CompressionCodecName.SNAPPY) + .withWriterVersion(WriterVersion.PARQUET_2_0) + .withAlp() + .withDictionaryEncoding(false) + .withRowGroupSize(1L << 19) // 512KB, so 500k rows span many row groups + .withConf(new Configuration()) + .build()) { + for (int i = 0; i < n; i++) { + SimpleGroup row = new SimpleGroup(schema); + row.add("d", Math.round(rng.nextDouble() * 1e7) / 100.0); + row.add("f", (float) (Math.round(rng.nextFloat() * 1e5) / 100.0)); + writer.write(row); + } + } + java.util.Random verify = new java.util.Random(seed); + int total = 0; + int rowGroups = 0; + try (ParquetFileReader reader = ParquetFileReader.open(new LocalInputFile(outPath))) { + MessageType s = reader.getFooter().getFileMetaData().getSchema(); + MessageColumnIO columnIO = new ColumnIOFactory().getColumnIO(s); + PageReadStore pages; + while ((pages = reader.readNextRowGroup()) != null) { + rowGroups++; + long rc = pages.getRowCount(); + RecordReader rr = columnIO.getRecordReader(pages, new GroupRecordConverter(s)); + for (long i = 0; i < rc; i++) { + Group g = rr.read(); + double ed = Math.round(verify.nextDouble() * 1e7) / 100.0; + float ef = (float) (Math.round(verify.nextFloat() * 1e5) / 100.0); + assertThat(Double.doubleToRawLongBits(g.getDouble("d", 0))) + .as("d mismatch at row " + total) + .isEqualTo(Double.doubleToRawLongBits(ed)); + assertThat(Float.floatToRawIntBits(g.getFloat("f", 0))) + .as("f mismatch at row " + total) + .isEqualTo(Float.floatToRawIntBits(ef)); + total++; + } + } + } + assertThat(total).as("row count").isEqualTo(n); + assertThat(rowGroups).as("expected multiple row groups").isGreaterThanOrEqualTo(2); + } + + @Test + public void testAlpOnRepeatedDoubleField() throws IOException { + // ALP on a REPEATED double field, so it must interleave correctly with repetition/definition + // levels (empty rows, varying counts). Nested/repeated columns are otherwise untested. + MessageType schema = + MessageTypeParser.parseMessageType("message m { required int64 id; repeated double vals; }"); + int nRows = 3000; + java.util.Random rng = new java.util.Random(9); + double[][] expected = new double[nRows][]; + for (int r = 0; r < nRows; r++) { + int k = rng.nextInt(5); // 0..4 values per row (exercises empty rows + varying rep levels) + expected[r] = new double[k]; + for (int j = 0; j < k; j++) { + expected[r][j] = Math.round(rng.nextDouble() * 100000) / 100.0; + } + } + java.nio.file.Path outPath = newFolder().toPath().resolve("alp_repeated.parquet"); + try (ParquetWriter writer = ExampleParquetWriter.builder(new LocalOutputFile(outPath)) + .withType(schema) + .withCompressionCodec(CompressionCodecName.UNCOMPRESSED) + .withWriterVersion(WriterVersion.PARQUET_2_0) + .withAlp() + .withDictionaryEncoding(false) + .withConf(new Configuration()) + .build()) { + for (int r = 0; r < nRows; r++) { + SimpleGroup row = new SimpleGroup(schema); + row.add("id", (long) r); + for (double v : expected[r]) { + row.add("vals", v); + } + writer.write(row); + } + } + List rows = readAllRows(outPath); + assertThat(rows).hasSize(nRows); + for (int r = 0; r < nRows; r++) { + Group g = rows.get(r); + int k = g.getFieldRepetitionCount("vals"); + assertThat(k).as("rep count row " + r).isEqualTo(expected[r].length); + for (int j = 0; j < k; j++) { + assertThat(Double.doubleToRawLongBits(g.getDouble("vals", j))) + .as("val row " + r + " idx " + j) + .isEqualTo(Double.doubleToRawLongBits(expected[r][j])); + } + } + assertThat(columnUsesAlp(outPath, "vals")) + .as("repeated double column should be ALP-encoded") + .isTrue(); + } +}