This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 79a8d165fa [core] Add block-based manifest Avro reader (#9160)
79a8d165fa is described below
commit 79a8d165fade334c71ee85104082f51a1df37de1
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Aug 11 13:49:09 2026 +0800
[core] Add block-based manifest Avro reader (#9160)
Speed up manifest reads by decoding Avro object-container blocks
directly and applying partition, bucket, and projection pruning before
materializing nested `DataFileMeta` rows.
---
.../paimon/manifest/BinaryManifestEntry.java | 5 +-
.../apache/paimon/manifest/ManifestAvroReader.java | 317 ++++++++++++++++++++
.../paimon/manifest/ManifestEntrySerializer.java | 2 +-
.../org/apache/paimon/manifest/ManifestFile.java | 74 ++---
.../paimon/manifest/ManifestSchemaUtils.java | 4 +-
.../java/org/apache/paimon/utils/ObjectsFile.java | 30 +-
.../apache/paimon/manifest/ManifestFileTest.java | 320 ++++++++++++++++++---
.../apache/paimon/format/avro/AvroBlockReader.java | 158 ++++++++++
.../paimon/format/avro/AvroRecordDecoder.java | 167 +++++++++++
.../paimon/format/avro/FieldReaderFactory.java | 12 +-
.../paimon/format/avro/AvroFileFormatTest.java | 149 ++++++++++
11 files changed, 1155 insertions(+), 83 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/manifest/BinaryManifestEntry.java
b/paimon-core/src/main/java/org/apache/paimon/manifest/BinaryManifestEntry.java
index 646f19ae44..f1dd962bba 100644
---
a/paimon-core/src/main/java/org/apache/paimon/manifest/BinaryManifestEntry.java
+++
b/paimon-core/src/main/java/org/apache/paimon/manifest/BinaryManifestEntry.java
@@ -39,8 +39,9 @@ import static
org.apache.paimon.utils.SerializationUtils.deserializeBinaryRow;
*
* <p>This class is intended for streaming manifest algorithms which only need
a subset of {@link
* ManifestEntry}. Unlike {@link PojoManifestEntry}, it does not deserialize
the nested {@code
- * _FILE} row into a POJO. The view is mutable and only valid while its
backing {@link InternalRow}
- * is valid; callers must not retain it across reader iterations or batches.
+ * _FILE} row into a POJO. The view is mutable and remains valid while its
backing {@link
+ * InternalRow} remains valid. Producers such as {@link
ManifestFile#scan(String, Long, Projection)}
+ * provide independently backed entries which can be retained.
*/
public final class BinaryManifestEntry implements ManifestEntry {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestAvroReader.java
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestAvroReader.java
new file mode 100644
index 0000000000..83af2833eb
--- /dev/null
+++
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestAvroReader.java
@@ -0,0 +1,317 @@
+/*
+ * 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.paimon.manifest;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.format.avro.AvroBlockReader;
+import org.apache.paimon.format.avro.AvroBlockReader.BorrowedBlock;
+import org.apache.paimon.format.avro.AvroRecordDecoder;
+import org.apache.paimon.format.avro.AvroRecordDecoder.FieldDecoder;
+import org.apache.paimon.format.avro.AvroRecordDecoder.FieldType;
+import org.apache.paimon.partition.PartitionPredicate;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.CloseableIterator;
+import org.apache.paimon.utils.IOUtils;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.NoSuchElementException;
+
+import static org.apache.paimon.utils.SerializationUtils.deserializeBinaryRow;
+
+/**
+ * Schema-aware Avro reader which projects fields and filters before decoding
data file metadata.
+ */
+final class ManifestAvroReader implements CloseableIterator<InternalRow> {
+
+ private final AvroBlockReader blockReader;
+ private final AvroRecordDecoder decoder;
+ private final ManifestRecordDecoder recordDecoder;
+
+ private long recordsRemaining;
+ private @Nullable InternalRow next;
+ private boolean nextReady;
+ private boolean finished;
+
+ ManifestAvroReader(
+ InputStream input,
+ RowType projectedType,
+ @Nullable PartitionPredicate partitionFilter,
+ @Nullable BucketFilter bucketFilter)
+ throws IOException {
+ AvroBlockReader blockReader = new AvroBlockReader(input);
+ try {
+ AvroRecordDecoder decoder = blockReader.createRecordDecoder();
+ this.recordDecoder =
+ new ManifestRecordDecoder(
+ decoder, projectedType, partitionFilter,
bucketFilter);
+ this.decoder = decoder;
+ this.blockReader = blockReader;
+ } catch (RuntimeException | Error e) {
+ IOUtils.closeQuietly(blockReader);
+ throw e;
+ }
+ }
+
+ @Override
+ public boolean hasNext() {
+ if (nextReady) {
+ return true;
+ }
+ if (finished) {
+ return false;
+ }
+
+ try {
+ while (true) {
+ if (recordsRemaining == 0) {
+ if (decoder.isInitialized() && !decoder.isEnd()) {
+ throw new IOException(
+ "Manifest Avro block contains trailing
undecoded bytes.");
+ }
+ if (!blockReader.hasNextBlock()) {
+ finished = true;
+ return false;
+ }
+ BorrowedBlock block = blockReader.nextBorrowedBlock();
+ decoder.reset(block.bytes(), block.offset(),
block.length());
+ recordsRemaining = block.recordCount();
+ }
+
+ InternalRow candidate = recordDecoder.read(decoder);
+ recordsRemaining--;
+ if (candidate != null) {
+ next = candidate;
+ nextReady = true;
+ return true;
+ }
+ }
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to decode Manifest Avro
block.", e);
+ }
+ }
+
+ @Override
+ public InternalRow next() {
+ if (!hasNext()) {
+ throw new NoSuchElementException();
+ }
+ InternalRow result = next;
+ next = null;
+ nextReady = false;
+ return result;
+ }
+
+ long decodedDataFiles() {
+ return recordDecoder.decodedDataFiles;
+ }
+
+ long skippedDataFiles() {
+ return recordDecoder.skippedDataFiles;
+ }
+
+ @Override
+ public void close() throws IOException {
+ next = null;
+ nextReady = false;
+ finished = true;
+ blockReader.close();
+ }
+
+ private static class ManifestRecordDecoder {
+
+ private static final String[] TOP_LEVEL_FIELDS = {
+ ManifestSchemaUtils.FORMAT_IDENTIFIER_FIELD,
+ ManifestEntry.KIND,
+ ManifestEntry.PARTITION,
+ ManifestEntry.BUCKET,
+ ManifestEntry.TOTAL_BUCKETS,
+ ManifestEntry.FILE
+ };
+
+ private final int projectedFieldCount;
+ private final int versionPosition;
+ private final int kindPosition;
+ private final int partitionPosition;
+ private final int bucketPosition;
+ private final int totalBucketsPosition;
+ private final int filePosition;
+
+ private final FieldDecoder fileReader;
+
+ private final @Nullable PartitionPredicate partitionFilter;
+ private final @Nullable BucketFilter bucketFilter;
+ private final boolean partitionNeededForFilter;
+ private final boolean bucketNeededForFilter;
+
+ private long decodedDataFiles;
+ private long skippedDataFiles;
+
+ private ManifestRecordDecoder(
+ AvroRecordDecoder decoder,
+ RowType projectedType,
+ @Nullable PartitionPredicate partitionFilter,
+ @Nullable BucketFilter bucketFilter) {
+ this.projectedFieldCount = projectedType.getFieldCount();
+ this.versionPosition =
+
projectedType.getFieldIndex(ManifestSchemaUtils.FORMAT_IDENTIFIER_FIELD);
+ this.kindPosition =
projectedType.getFieldIndex(ManifestEntry.KIND);
+ this.partitionPosition =
projectedType.getFieldIndex(ManifestEntry.PARTITION);
+ this.bucketPosition =
projectedType.getFieldIndex(ManifestEntry.BUCKET);
+ this.totalBucketsPosition =
projectedType.getFieldIndex(ManifestEntry.TOTAL_BUCKETS);
+ this.filePosition =
projectedType.getFieldIndex(ManifestEntry.FILE);
+ this.partitionFilter = partitionFilter;
+ this.bucketFilter = bucketFilter;
+ this.partitionNeededForFilter = partitionFilter != null ||
bucketFilter != null;
+ this.bucketNeededForFilter = bucketFilter != null;
+
+ validateTopLevelFields(decoder);
+ // Manifest v2 has a fixed top-level layout, but DataFileMeta has
gained nullable
+ // fields. Build this reader from the writer schema so legacy
files with fewer nested
+ // fields still decode and expose the missing projected fields as
null.
+ fileReader =
+ decoder.createFieldDecoder(
+ 5, filePosition >= 0 ?
projectedType.getTypeAt(filePosition) : null);
+ }
+
+ private @Nullable InternalRow read(AvroRecordDecoder decoder) throws
IOException {
+ if (!decoder.readRecordStart()) {
+ throw new IOException("Unexpected null or non-record Manifest
Avro value.");
+ }
+
+ int version = decoder.readInt();
+ ManifestEntrySerializer.checkFormatIdentifier(version);
+ int kind = decoder.readInt();
+ byte[] partitionBytes;
+ if (partitionPosition >= 0 || partitionNeededForFilter) {
+ partitionBytes = decoder.readBytes();
+ } else {
+ decoder.skipBytes();
+ partitionBytes = null;
+ }
+
+ BinaryRow partition =
+ partitionNeededForFilter ?
deserializeBinaryRow(partitionBytes) : null;
+ if (partitionFilter != null && !partitionFilter.test(partition)) {
+ skipBucketAndFile(decoder);
+ return null;
+ }
+
+ int bucket;
+ if (bucketPosition >= 0 || bucketNeededForFilter) {
+ bucket = decoder.readInt();
+ } else {
+ decoder.readInt();
+ bucket = 0;
+ }
+
+ int totalBuckets;
+ if (totalBucketsPosition >= 0 || bucketNeededForFilter) {
+ totalBuckets = decoder.readInt();
+ } else {
+ decoder.readInt();
+ totalBuckets = 0;
+ }
+
+ if (bucketFilter != null && !bucketFilter.test(partition, bucket,
totalBuckets)) {
+ skipFile(decoder);
+ return null;
+ }
+
+ Object file;
+ if (filePosition >= 0) {
+ file = fileReader.read(decoder, null);
+ decodedDataFiles++;
+ } else {
+ fileReader.skip(decoder);
+ skippedDataFiles++;
+ file = null;
+ }
+
+ GenericRow row = new GenericRow(projectedFieldCount);
+ setProjected(row, versionPosition, version);
+ setProjected(row, kindPosition, (byte) kind);
+ setProjected(row, partitionPosition, partitionBytes);
+ setProjected(row, bucketPosition, bucket);
+ setProjected(row, totalBucketsPosition, totalBuckets);
+ setProjected(row, filePosition, file);
+ return row;
+ }
+
+ private void skipBucketAndFile(AvroRecordDecoder decoder) throws
IOException {
+ decoder.readInt();
+ decoder.readInt();
+ skipFile(decoder);
+ }
+
+ private void skipFile(AvroRecordDecoder decoder) throws IOException {
+ fileReader.skip(decoder);
+ skippedDataFiles++;
+ }
+
+ private static void setProjected(
+ GenericRow row, int outputPosition, @Nullable Object value) {
+ if (outputPosition >= 0) {
+ row.setField(outputPosition, value);
+ }
+ }
+
+ private static void validateTopLevelFields(AvroRecordDecoder decoder) {
+ if (decoder.fieldCount() != TOP_LEVEL_FIELDS.length) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Manifest Avro schema has %s top-level fields,
expected %s.",
+ decoder.fieldCount(),
TOP_LEVEL_FIELDS.length));
+ }
+ for (int i = 0; i < TOP_LEVEL_FIELDS.length; i++) {
+ String actual = decoder.fieldName(i);
+ String expected = TOP_LEVEL_FIELDS[i];
+ if (!expected.equals(actual)) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Unexpected Manifest Avro field at
position %s: expected %s but found %s.",
+ i, expected, actual));
+ }
+ }
+
+ validateFieldType(decoder, 0, FieldType.INT);
+ validateFieldType(decoder, 1, FieldType.INT);
+ validateFieldType(decoder, 2, FieldType.BYTES);
+ validateFieldType(decoder, 3, FieldType.INT);
+ validateFieldType(decoder, 4, FieldType.INT);
+ validateFieldType(decoder, 5, FieldType.RECORD);
+ }
+
+ private static void validateFieldType(
+ AvroRecordDecoder decoder, int position, FieldType
expectedType) {
+ FieldType actualType = decoder.fieldType(position);
+ if (actualType != expectedType) {
+ throw new IllegalArgumentException(
+ String.format(
+ "Unexpected Manifest Avro type for field %s:
expected %s but found %s.",
+ decoder.fieldName(position), expectedType,
actualType));
+ }
+ }
+ }
+}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestEntrySerializer.java
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestEntrySerializer.java
index ec52247eba..9dfef81aeb 100644
---
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestEntrySerializer.java
+++
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestEntrySerializer.java
@@ -67,7 +67,7 @@ public class ManifestEntrySerializer extends
ObjectSerializer<ManifestEntry> {
return fromDataRow(new OffsetRow(row.getFieldCount() - 1,
1).replace(row));
}
- private void checkFormatIdentifier(int formatIdentifier) {
+ static void checkFormatIdentifier(int formatIdentifier) {
if (formatIdentifier != FORMAT_IDENTIFIER) {
if (formatIdentifier == 1) {
throw new IllegalArgumentException(
diff --git
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFile.java
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFile.java
index 4f1aba75ba..45e0a74378 100644
--- a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFile.java
+++ b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestFile.java
@@ -21,7 +21,6 @@ package org.apache.paimon.manifest;
import org.apache.paimon.annotation.VisibleForTesting;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.format.FileFormat;
-import org.apache.paimon.format.FormatReaderFactory;
import org.apache.paimon.format.FormatWriterFactory;
import org.apache.paimon.format.SimpleStatsCollector;
import org.apache.paimon.fs.FileIO;
@@ -50,7 +49,6 @@ import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.ArrayList;
import java.util.Arrays;
-import java.util.Collections;
import java.util.List;
import java.util.function.Function;
@@ -64,8 +62,6 @@ public class ManifestFile extends ObjectsFile<ManifestEntry> {
private final SchemaManager schemaManager;
private final RowType partitionType;
- private final FileFormat fileFormat;
- private final RowType manifestType;
private final FormatWriterFactory writerFactory;
private final long suggestedFileSize;
@@ -73,10 +69,7 @@ public class ManifestFile extends ObjectsFile<ManifestEntry>
{
FileIO fileIO,
SchemaManager schemaManager,
RowType partitionType,
- FileFormat fileFormat,
ManifestEntrySerializer serializer,
- RowType manifestType,
- FormatReaderFactory readerFactory,
FormatWriterFactory writerFactory,
String compression,
PathFactory pathFactory,
@@ -85,16 +78,16 @@ public class ManifestFile extends
ObjectsFile<ManifestEntry> {
super(
fileIO,
serializer,
- manifestType,
- readerFactory,
+ ManifestEntry.MANIFEST_ROW_TYPE,
+ (path, ignoredFileSize) ->
+ createManifestIterator(
+ fileIO, path, ManifestEntry.MANIFEST_ROW_TYPE,
null, null),
writerFactory,
compression,
pathFactory,
cache);
this.schemaManager = schemaManager;
this.partitionType = partitionType;
- this.fileFormat = fileFormat;
- this.manifestType = manifestType;
this.writerFactory = writerFactory;
this.suggestedFileSize = suggestedFileSize;
}
@@ -103,7 +96,7 @@ public class ManifestFile extends ObjectsFile<ManifestEntry>
{
protected ManifestEntryCache createCache(
@Nullable SegmentsCache<Path> cache, RowType formatType) {
return new ManifestEntryCache(
- cache, serializer, formatType, super::fileSize,
super::createIterator);
+ cache, serializer, formatType, super::fileSize,
this::createIterator);
}
@Override
@@ -146,8 +139,14 @@ public class ManifestFile extends
ObjectsFile<ManifestEntry> {
return cache.read(path, fileSize, filters, convertor);
}
- return readFromIterator(
- createIterator(path, fileSize), serializer, readFilter,
readTFilter, convertor);
+ CloseableIterator<InternalRow> iterator =
+ createManifestIterator(
+ fileIO,
+ path,
+ ManifestEntry.MANIFEST_ROW_TYPE,
+ partitionFilter,
+ bucketFilter);
+ return readFromIterator(iterator, serializer, readFilter,
readTFilter, convertor);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
@@ -156,46 +155,36 @@ public class ManifestFile extends
ObjectsFile<ManifestEntry> {
/**
* Scans projected manifest entries without materializing {@link
PojoManifestEntry}s.
*
- * <p>The returned iterator reuses the same mutable {@link
BinaryManifestEntry} for all records.
- * An entry is only valid until the next call to {@link
CloseableIterator#hasNext()}, {@link
- * CloseableIterator#next()}, or {@link CloseableIterator#close()}, and
must not be retained.
- * The caller must close the iterator.
+ * <p>Every returned {@link BinaryManifestEntry} has independent backing
data and can be
+ * retained after the iterator advances or closes. The caller must close
the iterator.
*
* <p>This method intentionally bypasses the manifest cache because cached
entries are
* materialized with the complete manifest schema.
*/
public CloseableIterator<BinaryManifestEntry> scan(
String fileName, @Nullable Long fileSize, Projection projection) {
- BinaryManifestEntry entry = projection.createEntry();
try {
CloseableIterator<InternalRow> rows =
- FileUtils.createFormatReader(
- fileIO,
- fileFormat.createReaderFactory(
- manifestType,
- projection.projectedType(),
- Collections.emptyList()),
- pathFactory.toPath(fileName),
- fileSize)
- .toCloseableIterator();
+ createManifestIterator(
+ fileIO,
+ pathFactory.toPath(fileName),
+ projection.projectedType(),
+ null,
+ null);
return new CloseableIterator<BinaryManifestEntry>() {
@Override
public boolean hasNext() {
- entry.clear();
return rows.hasNext();
}
@Override
public BinaryManifestEntry next() {
- entry.clear();
- InternalRow row = rows.next();
- return row == null ? null : entry.replace(row);
+ return projection.createEntry().replace(rows.next());
}
@Override
public void close() throws Exception {
- entry.clear();
rows.close();
}
};
@@ -204,6 +193,22 @@ public class ManifestFile extends
ObjectsFile<ManifestEntry> {
}
}
+ private static CloseableIterator<InternalRow> createManifestIterator(
+ FileIO fileIO,
+ Path path,
+ RowType projectedType,
+ @Nullable PartitionPredicate partitionFilter,
+ @Nullable BucketFilter bucketFilter)
+ throws IOException {
+ try {
+ return new ManifestAvroReader(
+ fileIO.newInputStream(path), projectedType,
partitionFilter, bucketFilter);
+ } catch (IOException e) {
+ FileUtils.checkExists(fileIO, path);
+ throw e;
+ }
+ }
+
@VisibleForTesting
public long suggestedFileSize() {
return suggestedFileSize;
@@ -412,10 +417,7 @@ public class ManifestFile extends
ObjectsFile<ManifestEntry> {
fileIO,
schemaManager,
partitionType,
- fileFormat,
new ManifestEntrySerializer(),
- entryType,
- fileFormat.createReaderFactory(entryType, entryType, new
ArrayList<>()),
fileFormat.createWriterFactory(entryType),
compression,
pathFactory.manifestFileFactory(),
diff --git
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestSchemaUtils.java
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestSchemaUtils.java
index e58efc0c13..05842e085d 100644
---
a/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestSchemaUtils.java
+++
b/paimon-core/src/main/java/org/apache/paimon/manifest/ManifestSchemaUtils.java
@@ -28,13 +28,15 @@ import java.util.List;
/** Utilities for manifest schemas. */
final class ManifestSchemaUtils {
+ static final String FORMAT_IDENTIFIER_FIELD = "_VERSION";
+
private ManifestSchemaUtils() {}
/** Adds the permanent on-disk format identifier field to a manifest row
type. */
static RowType withFormatIdentifier(RowType rowType) {
List<DataField> fields = new ArrayList<>();
// Keep the historical field name for compatibility with existing
manifest files.
- fields.add(new DataField(-1, "_VERSION", new IntType(false)));
+ fields.add(new DataField(-1, FORMAT_IDENTIFIER_FIELD, new
IntType(false)));
fields.addAll(rowType.getFields());
return new RowType(false, fields);
}
diff --git a/paimon-core/src/main/java/org/apache/paimon/utils/ObjectsFile.java
b/paimon-core/src/main/java/org/apache/paimon/utils/ObjectsFile.java
index deb6c91214..7d1a5d483a 100644
--- a/paimon-core/src/main/java/org/apache/paimon/utils/ObjectsFile.java
+++ b/paimon-core/src/main/java/org/apache/paimon/utils/ObjectsFile.java
@@ -46,11 +46,12 @@ public abstract class ObjectsFile<T> implements
SimpleFileReader<T> {
protected final FileIO fileIO;
protected final ObjectSerializer<T> serializer;
- protected final FormatReaderFactory readerFactory;
protected final FormatWriterFactory writerFactory;
protected final String compression;
protected final PathFactory pathFactory;
+ private final BiFunctionWithIOE<Path, Long,
CloseableIterator<InternalRow>> iteratorFactory;
+
@Nullable protected final ObjectsCache<Path, T, ?> cache;
public ObjectsFile(
@@ -62,9 +63,31 @@ public abstract class ObjectsFile<T> implements
SimpleFileReader<T> {
String compression,
PathFactory pathFactory,
@Nullable SegmentsCache<Path> cache) {
+ this(
+ fileIO,
+ serializer,
+ formatType,
+ (file, fileSize) ->
+ FileUtils.createFormatReader(fileIO, readerFactory,
file, fileSize)
+ .toCloseableIterator(),
+ writerFactory,
+ compression,
+ pathFactory,
+ cache);
+ }
+
+ protected ObjectsFile(
+ FileIO fileIO,
+ ObjectSerializer<T> serializer,
+ RowType formatType,
+ BiFunctionWithIOE<Path, Long, CloseableIterator<InternalRow>>
iteratorFactory,
+ FormatWriterFactory writerFactory,
+ String compression,
+ PathFactory pathFactory,
+ @Nullable SegmentsCache<Path> cache) {
this.fileIO = fileIO;
this.serializer = serializer;
- this.readerFactory = readerFactory;
+ this.iteratorFactory = iteratorFactory;
this.writerFactory = writerFactory;
this.compression = compression;
this.pathFactory = pathFactory;
@@ -200,8 +223,7 @@ public abstract class ObjectsFile<T> implements
SimpleFileReader<T> {
public CloseableIterator<InternalRow> createIterator(Path file, @Nullable
Long fileSize)
throws IOException {
- return FileUtils.createFormatReader(fileIO, readerFactory, file,
fileSize)
- .toCloseableIterator();
+ return iteratorFactory.apply(file, fileSize);
}
public long fileSize(Path file) throws IOException {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
index fef9f7b9ac..c1e1898a36 100644
--- a/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/manifest/ManifestFileTest.java
@@ -19,14 +19,19 @@
package org.apache.paimon.manifest;
import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
import org.apache.paimon.format.FileFormat;
+import org.apache.paimon.format.FormatWriter;
import org.apache.paimon.format.SimpleColStats;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.FileIOFinder;
import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.io.DataFileMeta;
import org.apache.paimon.options.Options;
+import org.apache.paimon.partition.PartitionPredicate;
import org.apache.paimon.schema.SchemaManager;
import org.apache.paimon.stats.StatsTestUtils;
import org.apache.paimon.types.DataField;
@@ -42,12 +47,11 @@ import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
import static org.apache.paimon.TestKeyValueGenerator.DEFAULT_PART_TYPE;
@@ -79,6 +83,264 @@ public class ManifestFileTest {
assertThat(actualEntries).isEqualTo(entries);
}
+ @Test
+ void testReadMissingManifestFile() {
+ ManifestFile manifestFile = createManifestFile(tempDir.toString());
+
+ assertThatThrownBy(
+ () ->
+ manifestFile.read(
+ "missing-manifest",
+ null,
+ null,
+ null,
+ row -> true,
+ entry -> true))
+ .hasMessageContaining("not found");
+ }
+
+ @Test
+ void testAvroReaderSkipsDataFileMetaBeforeMaterialization() throws
Exception {
+ List<ManifestEntry> entries = generateData();
+ ManifestEntry selected = entries.get(0);
+ PartitionPredicate partitionFilter =
+ PartitionPredicate.fromMultiple(
+ DEFAULT_PART_TYPE,
Collections.singletonList(selected.partition()));
+ BucketFilter bucketFilter = new BucketFilter(false, selected.bucket(),
null, null);
+ List<ManifestEntry> expected =
+ entries.stream()
+ .filter(
+ entry ->
+
entry.partition().equals(selected.partition())
+ && entry.bucket() ==
selected.bucket())
+ .collect(Collectors.toList());
+
+ ManifestFile manifestFile = createManifestFile(tempDir.toString(),
Long.MAX_VALUE);
+ ManifestFileMeta manifest = writeSingleManifest(manifestFile, entries);
+ FileIO fileIO = LocalFileIO.create();
+ Path path = new Path(new Path(tempDir.toUri()), "manifest/" +
manifest.fileName());
+ ManifestEntrySerializer serializer = new ManifestEntrySerializer();
+ List<ManifestEntry> actual = new ArrayList<>();
+
+ try (ManifestAvroReader reader =
+ new ManifestAvroReader(
+ fileIO.newInputStream(path),
+ ManifestEntry.MANIFEST_ROW_TYPE,
+ partitionFilter,
+ bucketFilter)) {
+ while (reader.hasNext()) {
+ InternalRow row = reader.next();
+ actual.add(serializer.fromRow(row));
+ }
+ assertThat(reader.decodedDataFiles()).isEqualTo(expected.size());
+ assertThat(reader.skippedDataFiles()).isEqualTo(entries.size() -
expected.size());
+ }
+
+ assertThat(actual).containsExactlyElementsOf(expected);
+ assertThat(
+ manifestFile.read(
+ manifest.fileName(),
+ manifest.fileSize(),
+ partitionFilter,
+ bucketFilter,
+ row -> true,
+ entry -> true))
+ .containsExactlyElementsOf(expected);
+ }
+
+ @Test
+ void testAvroReaderSupportsReorderedNestedProjection() throws Exception {
+ List<ManifestEntry> entries = generateData();
+ ManifestFile manifestFile = createManifestFile(tempDir.toString(),
Long.MAX_VALUE);
+ ManifestFileMeta manifest = writeSingleManifest(manifestFile, entries);
+ Path path = new Path(new Path(tempDir.toUri()), "manifest/" +
manifest.fileName());
+ LocalFileIO fileIO = LocalFileIO.create();
+
+ List<DataField> fields = ManifestEntry.MANIFEST_ROW_TYPE.getFields();
+ RowType projectedFileType =
+ DataFileMeta.SCHEMA.project(DataFileMeta.FILE_NAME,
DataFileMeta.ROW_COUNT);
+ RowType projectedType =
+ new RowType(
+ false,
+ Arrays.asList(
+ fields.get(5).newType(projectedFileType),
+ fields.get(2),
+ fields.get(1)));
+ BinaryManifestEntry projectedEntry =
+
BinaryManifestEntry.Projection.create(projectedType).createEntry();
+
+ try (ManifestAvroReader reader =
+ new ManifestAvroReader(fileIO.newInputStream(path),
projectedType, null, null)) {
+ for (ManifestEntry expected : entries) {
+ assertThat(reader.hasNext()).isTrue();
+ InternalRow row = reader.next();
+ assertThat(row.getFieldCount()).isEqualTo(3);
+ assertThat(row.getRow(0,
projectedFileType.getFieldCount()).getFieldCount())
+ .isEqualTo(2);
+
+ projectedEntry.replace(row);
+
assertThat(projectedEntry.fileName()).isEqualTo(expected.fileName());
+
assertThat(projectedEntry.rowCount()).isEqualTo(expected.rowCount());
+
assertThat(projectedEntry.partition()).isEqualTo(expected.partition());
+ assertThat(projectedEntry.kind()).isEqualTo(expected.kind());
+ }
+ assertThat(reader.hasNext()).isFalse();
+ assertThat(reader.decodedDataFiles()).isEqualTo(entries.size());
+ assertThat(reader.skippedDataFiles()).isZero();
+ }
+ }
+
+ @Test
+ void testAvroReaderSkipsUnprojectedDataFile() throws Exception {
+ List<ManifestEntry> entries = generateData();
+ ManifestFile manifestFile = createManifestFile(tempDir.toString(),
Long.MAX_VALUE);
+ ManifestFileMeta manifest = writeSingleManifest(manifestFile, entries);
+ Path path = new Path(new Path(tempDir.toUri()), "manifest/" +
manifest.fileName());
+ LocalFileIO fileIO = LocalFileIO.create();
+
+ List<DataField> fields = ManifestEntry.MANIFEST_ROW_TYPE.getFields();
+ RowType projectedType = new RowType(false,
Arrays.asList(fields.get(2), fields.get(1)));
+ try (ManifestAvroReader reader =
+ new ManifestAvroReader(fileIO.newInputStream(path),
projectedType, null, null)) {
+ for (ManifestEntry expected : entries) {
+ assertThat(reader.hasNext()).isTrue();
+ InternalRow row = reader.next();
+ assertThat(row.getFieldCount()).isEqualTo(2);
+ assertThat(row.getBinary(0))
+ .containsExactly(
+
org.apache.paimon.utils.SerializationUtils.serializeBinaryRow(
+ expected.partition()));
+
assertThat(FileKind.fromByteValue(row.getByte(1))).isEqualTo(expected.kind());
+ }
+ assertThat(reader.hasNext()).isFalse();
+ assertThat(reader.decodedDataFiles()).isZero();
+ assertThat(reader.skippedDataFiles()).isEqualTo(entries.size());
+ }
+ }
+
+ @Test
+ void testProjectedScanRejectsUnsupportedFormatIdentifier() throws
Exception {
+ ManifestEntry entry = gen.next();
+ ManifestFile manifestFile = createManifestFile(tempDir.toString(),
Long.MAX_VALUE);
+ ManifestFileMeta manifest =
+ writeSingleManifest(manifestFile,
Collections.singletonList(entry));
+ Path path = new Path(new Path(tempDir.toUri()), "manifest/" +
manifest.fileName());
+ LocalFileIO fileIO = LocalFileIO.create();
+ ManifestEntrySerializer serializer = new ManifestEntrySerializer();
+ InternalRow valid = serializer.toRow(entry);
+
+ try (PositionOutputStream out = fileIO.newOutputStream(path, true);
+ FormatWriter writer =
+
avro.createWriterFactory(ManifestEntry.MANIFEST_ROW_TYPE)
+ .create(out, "zstd")) {
+ writer.addElement(
+ GenericRow.of(
+ 1,
+ valid.getByte(1),
+ valid.getBinary(2),
+ valid.getInt(3),
+ valid.getInt(4),
+ valid.getRow(5,
DataFileMeta.SCHEMA.getFieldCount())));
+ }
+
+ try (CloseableIterator<BinaryManifestEntry> entries =
+ manifestFile.scan(
+ manifest.fileName(), null,
BinaryManifestEntry.DELETE_ENTRY_PROJECTION)) {
+ assertThatThrownBy(entries::hasNext)
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("not compatible");
+ }
+ }
+
+ @Test
+ void testAvroReaderReadsLegacyDataFileMetaWithFewerFields() throws
Exception {
+ ManifestEntry generated = gen.next();
+ DataFileMeta sourceFile = generated.file().newFirstRowId(42L);
+ ManifestEntry source =
+ ManifestEntry.create(
+ FileKind.ADD,
+ generated.partition(),
+ generated.bucket(),
+ generated.totalBuckets(),
+ sourceFile);
+ RowType legacyFileType =
+ DataFileMeta.SCHEMA.project(
+ new int[] {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
13, 14, 15, 16, 17});
+ List<DataField> legacyManifestFields =
+ ManifestEntry.MANIFEST_ROW_TYPE.getFields().stream()
+ .map(
+ field ->
+ ManifestEntry.FILE.equals(field.name())
+ ? field.newType(legacyFileType)
+ : field)
+ .collect(Collectors.toList());
+ RowType legacyManifestType = new RowType(false, legacyManifestFields);
+ Path path = new Path(new Path(tempDir.toUri()),
"legacy-manifest.avro");
+ LocalFileIO fileIO = LocalFileIO.create();
+ ManifestEntrySerializer serializer = new ManifestEntrySerializer();
+
+ try (PositionOutputStream out = fileIO.newOutputStream(path, false);
+ FormatWriter writer =
+
avro.createWriterFactory(legacyManifestType).create(out, "zstd")) {
+ writer.addElement(serializer.toRow(source));
+ }
+
+ ManifestEntry actual;
+ try (ManifestAvroReader reader =
+ new ManifestAvroReader(
+ fileIO.newInputStream(path),
ManifestEntry.MANIFEST_ROW_TYPE, null, null)) {
+ assertThat(reader.hasNext()).isTrue();
+ actual = serializer.fromRow(reader.next());
+ assertThat(reader.hasNext()).isFalse();
+ }
+
+ assertThat(actual.fileName()).isEqualTo(source.fileName());
+ assertThat(actual.file().firstRowId()).isNull();
+ assertThat(actual.file().writeCols()).isNull();
+ }
+
+ @Test
+ void testAvroReaderRejectsReorderedTopLevelFields() throws Exception {
+ ManifestEntry entry = gen.next();
+ List<DataField> fields = ManifestEntry.MANIFEST_ROW_TYPE.getFields();
+ RowType reorderedType =
+ new RowType(
+ false,
+ Arrays.asList(
+ fields.get(0),
+ fields.get(5),
+ fields.get(1),
+ fields.get(2),
+ fields.get(3),
+ fields.get(4)));
+ Path path = new Path(new Path(tempDir.toUri()),
"reordered-manifest.avro");
+ LocalFileIO fileIO = LocalFileIO.create();
+ ManifestEntrySerializer serializer = new ManifestEntrySerializer();
+
+ try (PositionOutputStream out = fileIO.newOutputStream(path, false);
+ FormatWriter writer =
avro.createWriterFactory(reorderedType).create(out, "zstd")) {
+ InternalRow row = serializer.toRow(entry);
+ writer.addElement(
+ GenericRow.of(
+ row.getInt(0),
+ row.getRow(5, DataFileMeta.SCHEMA.getFieldCount()),
+ row.getByte(1),
+ row.getBinary(2),
+ row.getInt(3),
+ row.getInt(4)));
+ }
+
+ assertThatThrownBy(
+ () ->
+ new ManifestAvroReader(
+ fileIO.newInputStream(path),
+ ManifestEntry.MANIFEST_ROW_TYPE,
+ null,
+ null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("expected _KIND but found _FILE");
+ }
+
@RepeatedTest(10)
public void testCleanUpForException() throws IOException {
String failingName = UUID.randomUUID().toString();
@@ -227,40 +489,35 @@ public class ManifestFileTest {
}
@Test
- void testScanProjectedManifestEntries() throws Exception {
+ void testScanProjectedManifestEntriesCanBeRetained() throws Exception {
List<ManifestEntry> entries = Arrays.asList(gen.next(), gen.next(),
gen.next());
ManifestFile manifestFile = createManifestFile(tempDir.toString(),
Long.MAX_VALUE);
ManifestFileMeta manifest = writeSingleManifest(manifestFile, entries);
BinaryManifestEntry.Projection projection =
projection(DataFileMeta.FILE_NAME, DataFileMeta.ROW_COUNT);
- List<String> fileNames = new ArrayList<>();
- List<Long> rowCounts = new ArrayList<>();
- AtomicReference<BinaryManifestEntry> retained = new
AtomicReference<>();
+ List<BinaryManifestEntry> actual = new ArrayList<>();
try (CloseableIterator<BinaryManifestEntry> iterator =
manifestFile.scan(manifest.fileName(), manifest.fileSize(),
projection)) {
while (iterator.hasNext()) {
- BinaryManifestEntry entry = iterator.next();
- BinaryManifestEntry previous = retained.getAndSet(entry);
- if (previous != null) {
- assertThat(entry).isSameAs(previous);
- }
- fileNames.add(entry.fileName());
- rowCounts.add(entry.rowCount());
+ actual.add(iterator.next());
}
}
- assertThat(fileNames)
+ assertThat(actual).hasSize(entries.size());
+ for (int i = 1; i < actual.size(); i++) {
+ assertThat(actual.get(i)).isNotSameAs(actual.get(i - 1));
+ }
+
assertThat(actual.stream().map(ManifestEntry::fileName).collect(Collectors.toList()))
.containsExactlyElementsOf(
entries.stream().map(ManifestEntry::fileName).collect(Collectors.toList()));
- assertThat(rowCounts)
+
assertThat(actual.stream().map(ManifestEntry::rowCount).collect(Collectors.toList()))
.containsExactlyElementsOf(
entries.stream().map(ManifestEntry::rowCount).collect(Collectors.toList()));
- assertCleared(retained.get());
}
@Test
- void testScanProjectedManifestInvalidatesEntryWhenAdvancing() throws
Exception {
+ void testScanProjectedManifestKeepsEntryValidWhenAdvancing() throws
Exception {
List<ManifestEntry> entries = Arrays.asList(gen.next(), gen.next());
ManifestFile manifestFile = createManifestFile(tempDir.toString(),
Long.MAX_VALUE);
ManifestFileMeta manifest = writeSingleManifest(manifestFile, entries);
@@ -275,10 +532,10 @@ public class ManifestFileTest {
assertThat(first.fileName()).isEqualTo(entries.get(0).fileName());
assertThat(iterator.hasNext()).isTrue();
- assertCleared(first);
BinaryManifestEntry second = iterator.next();
- assertThat(second).isSameAs(first);
+ assertThat(second).isNotSameAs(first);
assertThat(second.fileName()).isEqualTo(entries.get(1).fileName());
+ assertThat(first.fileName()).isEqualTo(entries.get(0).fileName());
}
}
@@ -287,8 +544,7 @@ public class ManifestFileTest {
List<ManifestEntry> entries = Arrays.asList(gen.next(), gen.next(),
gen.next());
ManifestFile manifestFile = createManifestFile(tempDir.toString(),
Long.MAX_VALUE);
ManifestFileMeta manifest = writeSingleManifest(manifestFile, entries);
- AtomicInteger visited = new AtomicInteger();
- AtomicReference<BinaryManifestEntry> retained = new
AtomicReference<>();
+ List<BinaryManifestEntry> retained = new ArrayList<>();
try (CloseableIterator<BinaryManifestEntry> iterator =
manifestFile.scan(
@@ -297,23 +553,22 @@ public class ManifestFileTest {
projection(DataFileMeta.FILE_NAME))) {
while (iterator.hasNext()) {
BinaryManifestEntry entry = iterator.next();
- retained.set(entry);
- visited.incrementAndGet();
+ retained.add(entry);
break;
}
}
- assertThat(visited).hasValue(1);
- assertCleared(retained.get());
+ assertThat(retained).hasSize(1);
+
assertThat(retained.get(0).fileName()).isEqualTo(entries.get(0).fileName());
}
@Test
- void testScanProjectedManifestClearsEntryWhenProcessingFails() {
+ void testScanProjectedManifestKeepsEntryWhenProcessingFails() {
List<ManifestEntry> entries = Arrays.asList(gen.next(), gen.next());
ManifestFile manifestFile = createManifestFile(tempDir.toString(),
Long.MAX_VALUE);
ManifestFileMeta manifest = writeSingleManifest(manifestFile, entries);
RuntimeException failure = new RuntimeException("Expected processing
failure.");
- AtomicReference<BinaryManifestEntry> retained = new
AtomicReference<>();
+ List<BinaryManifestEntry> retained = new ArrayList<>();
assertThatThrownBy(
() -> {
@@ -324,12 +579,13 @@ public class ManifestFileTest {
projection(DataFileMeta.FILE_NAME))) {
assertThat(iterator.hasNext()).isTrue();
BinaryManifestEntry entry = iterator.next();
- retained.set(entry);
+ retained.add(entry);
throw failure;
}
})
.isSameAs(failure);
- assertCleared(retained.get());
+ assertThat(retained).hasSize(1);
+
assertThat(retained.get(0).fileName()).isEqualTo(entries.get(0).fileName());
}
private List<ManifestEntry> generateData() {
@@ -395,12 +651,6 @@ public class ManifestFileTest {
return BinaryManifestEntry.Projection.create(new RowType(false,
fields));
}
- private static void assertCleared(BinaryManifestEntry entry) {
- assertThatThrownBy(entry::fileName)
- .isInstanceOf(IllegalStateException.class)
- .hasMessageContaining("not backed by a row");
- }
-
private void checkRollingFiles(
ManifestFileMeta expected, List<ManifestFileMeta> actual, long
suggestedFileSize) {
// all but last file should be no smaller than suggestedFileSize
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/avro/AvroBlockReader.java
b/paimon-format/src/main/java/org/apache/paimon/format/avro/AvroBlockReader.java
new file mode 100644
index 0000000000..ef98b15fa0
--- /dev/null
+++
b/paimon-format/src/main/java/org/apache/paimon/format/avro/AvroBlockReader.java
@@ -0,0 +1,158 @@
+/*
+ * 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.paimon.format.avro;
+
+import org.apache.paimon.utils.IOUtils;
+
+import org.apache.avro.AvroRuntimeException;
+import org.apache.avro.Schema;
+import org.apache.avro.file.DataFileStream;
+import org.apache.avro.generic.GenericDatumReader;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+
+/**
+ * Reader which exposes decompressed blocks from an Avro object container file.
+ *
+ * <p>This reader owns the input stream and closes it when construction fails
or {@link #close()} is
+ * called.
+ */
+public final class AvroBlockReader implements Closeable {
+
+ private final DataFileStream<Object> reader;
+
+ private long currentBlockRecordCount = -1;
+
+ public AvroBlockReader(InputStream input) throws IOException {
+ try {
+ this.reader = new DataFileStream<>(input, new
GenericDatumReader<>());
+ } catch (IOException | RuntimeException | Error e) {
+ IOUtils.closeQuietly(input);
+ throw e;
+ }
+ }
+
+ Schema schema() {
+ return reader.getSchema();
+ }
+
+ /** Creates a record decoder from the writer schema stored in the Avro
file header. */
+ public AvroRecordDecoder createRecordDecoder() {
+ return new AvroRecordDecoder(reader.getSchema());
+ }
+
+ /** Returns whether another block is available. */
+ public boolean hasNextBlock() throws IOException {
+ return replaceAvroRuntimeException(reader::hasNext);
+ }
+
+ /**
+ * Returns a decompressed copy of the next block.
+ *
+ * <p>The returned array is owned by the caller and remains valid after
this reader advances or
+ * closes.
+ */
+ public byte[] nextBlock() throws IOException {
+ BorrowedBlock block = nextBorrowedBlock();
+ byte[] bytes = new byte[block.length];
+ System.arraycopy(block.bytes, block.offset, bytes, 0, block.length);
+ return bytes;
+ }
+
+ /**
+ * Returns a borrowed view of the next decompressed block.
+ *
+ * <p>The returned bytes are owned by this reader and may be overwritten
by the next call to
+ * {@link #hasNextBlock()}, {@link #nextBlock()}, or this method, or when
this reader is closed.
+ */
+ public BorrowedBlock nextBorrowedBlock() throws IOException {
+ ByteBuffer block = replaceAvroRuntimeException(reader::nextBlock);
+ currentBlockRecordCount = reader.getBlockCount();
+ return new BorrowedBlock(
+ block.array(),
+ block.arrayOffset() + block.position(),
+ block.remaining(),
+ currentBlockRecordCount);
+ }
+
+ /** Returns the record count of the last block returned by a block-reading
method. */
+ public long currentBlockRecordCount() {
+ if (currentBlockRecordCount < 0) {
+ throw new IllegalStateException("No block has been read.");
+ }
+ return currentBlockRecordCount;
+ }
+
+ @Override
+ public void close() throws IOException {
+ reader.close();
+ }
+
+ private static <T> T replaceAvroRuntimeException(IOSupplier<T> supplier)
throws IOException {
+ try {
+ return supplier.get();
+ } catch (AvroRuntimeException e) {
+ if (e.getCause() instanceof IOException) {
+ throw (IOException) e.getCause();
+ }
+ throw e;
+ }
+ }
+
+ /** Borrowed decompressed block contents and its record count. */
+ public static final class BorrowedBlock {
+
+ private final byte[] bytes;
+ private final int offset;
+ private final int length;
+ private final long recordCount;
+
+ private BorrowedBlock(byte[] bytes, int offset, int length, long
recordCount) {
+ this.bytes = bytes;
+ this.offset = offset;
+ this.length = length;
+ this.recordCount = recordCount;
+ }
+
+ public byte[] bytes() {
+ return bytes;
+ }
+
+ public int offset() {
+ return offset;
+ }
+
+ public int length() {
+ return length;
+ }
+
+ public long recordCount() {
+ return recordCount;
+ }
+ }
+
+ @FunctionalInterface
+ private interface IOSupplier<T> {
+
+ T get() throws IOException;
+ }
+}
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/avro/AvroRecordDecoder.java
b/paimon-format/src/main/java/org/apache/paimon/format/avro/AvroRecordDecoder.java
new file mode 100644
index 0000000000..78b4941897
--- /dev/null
+++
b/paimon-format/src/main/java/org/apache/paimon/format/avro/AvroRecordDecoder.java
@@ -0,0 +1,167 @@
+/*
+ * 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.paimon.format.avro;
+
+import org.apache.paimon.types.DataType;
+
+import org.apache.avro.Schema;
+import org.apache.avro.io.BinaryDecoder;
+import org.apache.avro.io.DecoderFactory;
+
+import javax.annotation.Nullable;
+
+import java.io.IOException;
+
+/**
+ * Decoder for sequentially reading records from Avro blocks without exposing
Avro classes to
+ * callers.
+ */
+public final class AvroRecordDecoder {
+
+ private final Schema recordSchema;
+ private final int recordBranch;
+
+ private @Nullable BinaryDecoder decoder;
+
+ AvroRecordDecoder(Schema writerSchema) {
+ if (writerSchema.getType() == Schema.Type.UNION) {
+ int branchIndex = -1;
+ Schema record = null;
+ for (int i = 0; i < writerSchema.getTypes().size(); i++) {
+ Schema branch = writerSchema.getTypes().get(i);
+ if (branch.getType() == Schema.Type.RECORD) {
+ if (record != null) {
+ throw new IllegalArgumentException(
+ "Avro union contains multiple record
branches.");
+ }
+ record = branch;
+ branchIndex = i;
+ }
+ }
+ if (record == null) {
+ throw new IllegalArgumentException("Avro schema is not a
record or record union.");
+ }
+ this.recordSchema = record;
+ this.recordBranch = branchIndex;
+ } else if (writerSchema.getType() == Schema.Type.RECORD) {
+ this.recordSchema = writerSchema;
+ this.recordBranch = -1;
+ } else {
+ throw new IllegalArgumentException("Avro schema is not a record or
record union.");
+ }
+ }
+
+ /** Returns the number of fields in the writer record. */
+ public int fieldCount() {
+ return recordSchema.getFields().size();
+ }
+
+ /** Returns the writer field name at the given position. */
+ public String fieldName(int position) {
+ return recordSchema.getFields().get(position).name();
+ }
+
+ /** Returns the Avro type of the writer field at the given position. */
+ public FieldType fieldType(int position) {
+ return
FieldType.valueOf(recordSchema.getFields().get(position).schema().getType().name());
+ }
+
+ /** Creates a decoder for one writer field. */
+ public FieldDecoder createFieldDecoder(int position, @Nullable DataType
readType) {
+ FieldReader reader =
+ new FieldReaderFactory()
+
.visit(recordSchema.getFields().get(position).schema(), readType);
+ return new FieldDecoder(reader);
+ }
+
+ /** Reuses this decoder for another block. */
+ public void reset(byte[] bytes, int offset, int length) {
+ decoder = DecoderFactory.get().binaryDecoder(bytes, offset, length,
decoder);
+ }
+
+ /** Returns whether a block has been supplied through {@link
#reset(byte[], int, int)}. */
+ public boolean isInitialized() {
+ return decoder != null;
+ }
+
+ /** Returns whether the current block has been consumed. */
+ public boolean isEnd() throws IOException {
+ return decoder().isEnd();
+ }
+
+ /** Reads the union branch, if present, and returns whether it is the
record branch. */
+ public boolean readRecordStart() throws IOException {
+ return recordBranch < 0 || decoder().readIndex() == recordBranch;
+ }
+
+ public int readInt() throws IOException {
+ return decoder().readInt();
+ }
+
+ public byte[] readBytes() throws IOException {
+ return decoder().readBytes(null).array();
+ }
+
+ public void skipBytes() throws IOException {
+ decoder().skipBytes();
+ }
+
+ private BinaryDecoder decoder() {
+ if (decoder == null) {
+ throw new IllegalStateException("No Avro block has been
supplied.");
+ }
+ return decoder;
+ }
+
+ /** Avro schema types represented without exposing Avro's {@link
Schema.Type}. */
+ public enum FieldType {
+ RECORD,
+ ENUM,
+ ARRAY,
+ MAP,
+ UNION,
+ FIXED,
+ STRING,
+ BYTES,
+ INT,
+ LONG,
+ FLOAT,
+ DOUBLE,
+ BOOLEAN,
+ NULL
+ }
+
+ /** Decoder for one field in the writer record. */
+ public static final class FieldDecoder {
+
+ private final FieldReader reader;
+
+ private FieldDecoder(FieldReader reader) {
+ this.reader = reader;
+ }
+
+ public Object read(AvroRecordDecoder decoder, @Nullable Object reuse)
throws IOException {
+ return reader.read(decoder.decoder(), reuse);
+ }
+
+ public void skip(AvroRecordDecoder decoder) throws IOException {
+ reader.skip(decoder.decoder());
+ }
+ }
+}
diff --git
a/paimon-format/src/main/java/org/apache/paimon/format/avro/FieldReaderFactory.java
b/paimon-format/src/main/java/org/apache/paimon/format/avro/FieldReaderFactory.java
index 4801134507..1369a359eb 100644
---
a/paimon-format/src/main/java/org/apache/paimon/format/avro/FieldReaderFactory.java
+++
b/paimon-format/src/main/java/org/apache/paimon/format/avro/FieldReaderFactory.java
@@ -662,17 +662,21 @@ public class FieldReaderFactory implements
AvroSchemaVisitor<FieldReader> {
row = new GenericRow(mapping.length);
}
- Object[] values = new Object[fieldReaders.length];
for (int i = 0; i < fieldReaders.length; i += 1) {
- if (mappingBack[i] >= 0) {
- values[i] = fieldReaders[i].read(decoder,
row.getField(mappingBack[i]));
+ int outputPosition = mappingBack[i];
+ if (outputPosition >= 0) {
+ row.setField(
+ outputPosition,
+ fieldReaders[i].read(decoder,
row.getField(outputPosition)));
} else {
fieldReaders[i].skip(decoder);
}
}
for (int i = 0; i < mapping.length; i++) {
- row.setField(i, mapping[i] >= 0 ? values[mapping[i]] : null);
+ if (mapping[i] < 0) {
+ row.setField(i, null);
+ }
}
return row;
diff --git
a/paimon-format/src/test/java/org/apache/paimon/format/avro/AvroFileFormatTest.java
b/paimon-format/src/test/java/org/apache/paimon/format/avro/AvroFileFormatTest.java
index cb3d7de27d..b8cb87f1c4 100644
---
a/paimon-format/src/test/java/org/apache/paimon/format/avro/AvroFileFormatTest.java
+++
b/paimon-format/src/test/java/org/apache/paimon/format/avro/AvroFileFormatTest.java
@@ -24,6 +24,7 @@ import org.apache.paimon.format.FileFormat;
import org.apache.paimon.format.FileFormatFactory.FormatContext;
import org.apache.paimon.format.FormatReaderContext;
import org.apache.paimon.format.FormatWriter;
+import org.apache.paimon.format.avro.AvroBlockReader.BorrowedBlock;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.PositionOutputStream;
@@ -35,14 +36,23 @@ import org.apache.paimon.types.DataField;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowType;
+import org.apache.avro.Schema;
+import org.apache.avro.SchemaBuilder;
+import org.apache.avro.io.BinaryDecoder;
+import org.apache.avro.io.BinaryEncoder;
+import org.apache.avro.io.DecoderFactory;
+import org.apache.avro.io.EncoderFactory;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.NoSuchElementException;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
@@ -134,6 +144,145 @@ public class AvroFileFormatTest {
}
}
+ @Test
+ void testReadDecompressedBlocks() throws IOException {
+ RowType rowType = DataTypes.ROW(DataTypes.INT().notNull()).notNull();
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path file = new Path(new Path(tempPath.toUri()),
UUID.randomUUID().toString());
+ int numRecords = 100_000;
+
+ try (PositionOutputStream out = fileIO.newOutputStream(file, false)) {
+ FormatWriter writer =
fileFormat.createWriterFactory(rowType).create(out, "zstd");
+ for (int i = 0; i < numRecords; i++) {
+ writer.addElement(GenericRow.of(i));
+ }
+ writer.close();
+ }
+
+ int nextValue = 0;
+ int numBlocks = 0;
+ byte[] firstBlock = null;
+ byte[] firstBlockCopy = null;
+ try (AvroBlockReader reader = new
AvroBlockReader(fileIO.newInputStream(file))) {
+ assertThat(reader.schema()).isNotNull();
+ assertThatThrownBy(reader::currentBlockRecordCount)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("No block");
+
+ AvroRowDatumReader datumReader = new AvroRowDatumReader(rowType);
+ datumReader.setSchema(reader.schema());
+ BinaryDecoder decoder = null;
+ while (reader.hasNextBlock()) {
+ byte[] block = reader.nextBlock();
+ if (firstBlock == null) {
+ firstBlock = block;
+ firstBlockCopy = block.clone();
+ }
+ numBlocks++;
+ decoder = DecoderFactory.get().binaryDecoder(block, decoder);
+ long blockRecordCount = reader.currentBlockRecordCount();
+ assertThat(blockRecordCount).isPositive();
+ for (long i = 0; i < blockRecordCount; i++) {
+ assertThat(datumReader.read(null,
decoder).getInt(0)).isEqualTo(nextValue++);
+ }
+ assertThat(decoder.isEnd()).isTrue();
+ }
+
+ assertThat(reader.hasNextBlock()).isFalse();
+
assertThatThrownBy(reader::nextBlock).isInstanceOf(NoSuchElementException.class);
+ }
+
+ assertThat(numBlocks).isGreaterThan(1);
+ assertThat(nextValue).isEqualTo(numRecords);
+ assertThat(firstBlock).containsExactly(firstBlockCopy);
+ }
+
+ @Test
+ void testReadBlocksFromEmptyFile() throws IOException {
+ RowType rowType = DataTypes.ROW(DataTypes.INT().notNull()).notNull();
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path file = new Path(new Path(tempPath.toUri()),
UUID.randomUUID().toString());
+
+ try (PositionOutputStream out = fileIO.newOutputStream(file, false)) {
+ fileFormat.createWriterFactory(rowType).create(out,
"zstd").close();
+ }
+
+ try (AvroBlockReader reader = new
AvroBlockReader(fileIO.newInputStream(file))) {
+ assertThat(reader.hasNextBlock()).isFalse();
+
assertThatThrownBy(reader::nextBlock).isInstanceOf(NoSuchElementException.class);
+ }
+ }
+
+ @Test
+ void testReadBorrowedBlocks() throws IOException {
+ RowType rowType = DataTypes.ROW(DataTypes.INT().notNull()).notNull();
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path file = new Path(new Path(tempPath.toUri()),
UUID.randomUUID().toString());
+ int numRecords = 100_000;
+
+ try (PositionOutputStream out = fileIO.newOutputStream(file, false)) {
+ FormatWriter writer =
fileFormat.createWriterFactory(rowType).create(out, "zstd");
+ for (int i = 0; i < numRecords; i++) {
+ writer.addElement(GenericRow.of(i));
+ }
+ writer.close();
+ }
+
+ int nextValue = 0;
+ try (AvroBlockReader reader = new
AvroBlockReader(fileIO.newInputStream(file))) {
+ AvroRecordDecoder decoder = reader.createRecordDecoder();
+ AvroRecordDecoder.FieldDecoder fieldDecoder =
+ decoder.createFieldDecoder(0, rowType.getTypeAt(0));
+ while (reader.hasNextBlock()) {
+ BorrowedBlock block = reader.nextBorrowedBlock();
+ assertThat(block.recordCount()).isPositive();
+
assertThat(reader.currentBlockRecordCount()).isEqualTo(block.recordCount());
+ decoder.reset(block.bytes(), block.offset(), block.length());
+ for (long i = 0; i < block.recordCount(); i++) {
+ assertThat(decoder.readRecordStart()).isTrue();
+ assertThat(fieldDecoder.read(decoder,
null)).isEqualTo(nextValue++);
+ }
+ assertThat(decoder.isEnd()).isTrue();
+ }
+ assertThatThrownBy(reader::nextBorrowedBlock)
+ .isInstanceOf(NoSuchElementException.class);
+ }
+
+ assertThat(nextValue).isEqualTo(numRecords);
+ }
+
+ @Test
+ void testRowReaderProjectsIntoReusedRow() throws IOException {
+ Schema writerSchema =
+ SchemaBuilder.record("record")
+ .fields()
+ .requiredInt("first")
+ .requiredInt("second")
+ .endRecord();
+ RowType projectedType =
+ new RowType(
+ false,
+ Arrays.asList(
+ new DataField(0, "second",
DataTypes.INT().notNull()),
+ new DataField(1, "missing", DataTypes.INT())));
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ BinaryEncoder encoder = EncoderFactory.get().binaryEncoder(output,
null);
+ encoder.writeInt(10);
+ encoder.writeInt(20);
+ encoder.flush();
+
+ AvroRowDatumReader reader = new AvroRowDatumReader(projectedType);
+ reader.setSchema(writerSchema);
+ BinaryDecoder decoder =
DecoderFactory.get().binaryDecoder(output.toByteArray(), null);
+ GenericRow reuse = GenericRow.of(100, 200);
+ InternalRow result = reader.read(reuse, decoder);
+
+ assertThat(result).isSameAs(reuse);
+ assertThat(result.getInt(0)).isEqualTo(20);
+ assertThat(result.isNullAt(1)).isTrue();
+ assertThat(decoder.isEnd()).isTrue();
+ }
+
@Test
void testGetRealIOException() throws IOException {
RowType rowType = DataTypes.ROW(DataTypes.INT().notNull());