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 8b589d42d0 [core] Pack multiple files into one split for format table
reads (#8317)
8b589d42d0 is described below
commit 8b589d42d0697448cf08996ecbdb14b7cc168464
Author: Zouxxyy <[email protected]>
AuthorDate: Mon Jun 22 17:23:51 2026 +0800
[core] Pack multiple files into one split for format table reads (#8317)
Format tables (`format-table.implementation = paimon`) currently
generate one split per data file, which produces a large number of
splits/tasks when a directory contains many small files.
This change lets a single split carry multiple files, packed in the core
scan by `source.split.target-size` (with `source.split.open-file-cost`
as a per-file weight floor, mirroring `AppendOnlySplitGenerator`).
Because packing happens in `FormatTableScan`, it is engine-agnostic; the
reader concatenates a split's files via `ConcatRecordReader`.
---
.../paimon/table/format/FormatDataSplit.java | 134 ++++++++++++++-------
.../paimon/table/format/FormatReadBuilder.java | 34 ++++--
.../paimon/table/format/FormatTableScan.java | 36 ++++--
.../paimon/table/format/FormatDataSplitTest.java | 53 ++++----
.../paimon/table/format/FormatReadBuilderTest.java | 9 +-
.../paimon/table/format/FormatTableScanTest.java | 5 +-
.../paimon/spark/read/BinPackingSplits.scala | 3 +-
.../org/apache/paimon/spark/util/SplitUtils.scala | 4 +-
.../paimon/spark/table/PaimonFormatTableTest.scala | 49 ++++++++
9 files changed, 225 insertions(+), 102 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatDataSplit.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatDataSplit.java
index f90d8c9ed3..78f2f9532e 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatDataSplit.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatDataSplit.java
@@ -24,61 +24,44 @@ import org.apache.paimon.table.source.Split;
import javax.annotation.Nullable;
+import java.io.Serializable;
+import java.util.List;
import java.util.Objects;
import java.util.OptionalLong;
-/** {@link FormatDataSplit} for format table. */
+/**
+ * {@link Split} for format table. A split may contain multiple files packed
by {@code
+ * source.split.target-size}, so a single reader task can read several files
sequentially.
+ */
public class FormatDataSplit implements Split {
- private static final long serialVersionUID = 2L;
+ private static final long serialVersionUID = 3L;
- private final Path filePath;
- private final long fileSize;
- private final long offset;
- // If null, means reading the whole file.
- @Nullable private final Long length;
+ private final List<FileMeta> files;
@Nullable private final BinaryRow partition;
- public FormatDataSplit(
- Path filePath,
- long fileSize,
- long offset,
- @Nullable Long length,
- @Nullable BinaryRow partition) {
- this.filePath = filePath;
- this.fileSize = fileSize;
- this.offset = offset;
- this.length = length;
+ public FormatDataSplit(List<FileMeta> files, @Nullable BinaryRow
partition) {
+ this.files = files;
this.partition = partition;
}
- public FormatDataSplit(Path filePath, long fileSize, @Nullable BinaryRow
partition) {
- this(filePath, fileSize, 0L, null, partition);
- }
-
- public Path filePath() {
- return this.filePath;
- }
-
- public Path dataPath() {
- return this.filePath;
+ public List<FileMeta> files() {
+ return files;
}
- public long fileSize() {
- return this.fileSize;
- }
-
- public long offset() {
- return offset;
+ @Nullable
+ public BinaryRow partition() {
+ return partition;
}
- @Nullable
- public Long length() {
- return length;
+ /** Total bytes to read for this split, i.e. the sum of {@link
FileMeta#readSize()}. */
+ public long totalSize() {
+ return files.stream().mapToLong(FileMeta::readSize).sum();
}
- public BinaryRow partition() {
- return partition;
+ /** Number of files (or file ranges) in this split. */
+ public int fileCount() {
+ return files.size();
}
@Override
@@ -100,15 +83,78 @@ public class FormatDataSplit implements Split {
return false;
}
FormatDataSplit that = (FormatDataSplit) o;
- return offset == that.offset
- && fileSize == that.fileSize
- && Objects.equals(length, that.length)
- && Objects.equals(filePath, that.filePath)
- && Objects.equals(partition, that.partition);
+ return Objects.equals(files, that.files) && Objects.equals(partition,
that.partition);
}
@Override
public int hashCode() {
- return Objects.hash(filePath, fileSize, offset, length, partition);
+ return Objects.hash(files, partition);
+ }
+
+ /**
+ * A single file (or one offset range of a splittable file) inside a
{@link FormatDataSplit}.
+ */
+ public static class FileMeta implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final Path filePath;
+ private final long fileSize;
+ private final long offset;
+ // If null, means reading the whole file.
+ @Nullable private final Long length;
+
+ public FileMeta(Path filePath, long fileSize, long offset, @Nullable
Long length) {
+ this.filePath = filePath;
+ this.fileSize = fileSize;
+ this.offset = offset;
+ this.length = length;
+ }
+
+ public FileMeta(Path filePath, long fileSize) {
+ this(filePath, fileSize, 0L, null);
+ }
+
+ public Path filePath() {
+ return filePath;
+ }
+
+ public long fileSize() {
+ return fileSize;
+ }
+
+ public long offset() {
+ return offset;
+ }
+
+ @Nullable
+ public Long length() {
+ return length;
+ }
+
+ /** Bytes this segment actually reads: range length when sliced,
otherwise whole file. */
+ public long readSize() {
+ return length != null ? length : fileSize;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ FileMeta that = (FileMeta) o;
+ return fileSize == that.fileSize
+ && offset == that.offset
+ && Objects.equals(length, that.length)
+ && Objects.equals(filePath, that.filePath);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(filePath, fileSize, offset, length);
+ }
}
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
index 6c394f3b8f..05ad36da72 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatReadBuilder.java
@@ -19,18 +19,20 @@
package org.apache.paimon.table.format;
import org.apache.paimon.CoreOptions;
+import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.InternalRow;
import org.apache.paimon.format.FileFormatDiscover;
import org.apache.paimon.format.FormatReaderContext;
import org.apache.paimon.format.FormatReaderFactory;
-import org.apache.paimon.fs.Path;
import org.apache.paimon.io.DataFileRecordReader;
+import org.apache.paimon.mergetree.compact.ConcatRecordReader;
import org.apache.paimon.partition.PartitionPredicate;
import org.apache.paimon.partition.PartitionUtils;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
import org.apache.paimon.predicate.TopN;
import org.apache.paimon.reader.FileRecordReader;
+import org.apache.paimon.reader.ReaderSupplier;
import org.apache.paimon.reader.RecordReader;
import org.apache.paimon.table.FormatTable;
import org.apache.paimon.table.source.ReadBuilder;
@@ -47,6 +49,7 @@ import org.apache.paimon.utils.RowRangeIndex;
import javax.annotation.Nullable;
import java.io.IOException;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
@@ -159,9 +162,6 @@ public class FormatReadBuilder implements ReadBuilder {
}
protected RecordReader<InternalRow> createReader(FormatDataSplit
dataSplit) throws IOException {
- Path filePath = dataSplit.dataPath();
- FormatReaderContext formatReaderContext =
- new FormatReaderContext(table.fileIO(), filePath,
dataSplit.fileSize(), null);
// Skip pushing down partition filters to reader.
List<Predicate> readFilters =
excludePredicateWithFields(
@@ -176,14 +176,30 @@ public class FormatReadBuilder implements ReadBuilder {
Pair<int[], RowType> partitionMapping =
PartitionUtils.getPartitionMapping(
table.partitionKeys(), readType().getFields(),
table.partitionType());
+
+ BinaryRow partition = dataSplit.partition();
+ List<ReaderSupplier<InternalRow>> suppliers = new ArrayList<>();
+ for (FormatDataSplit.FileMeta file : dataSplit.files()) {
+ suppliers.add(() -> createFileReader(file, partition,
readerFactory, partitionMapping));
+ }
+ return ConcatRecordReader.create(suppliers);
+ }
+
+ private RecordReader<InternalRow> createFileReader(
+ FormatDataSplit.FileMeta file,
+ @Nullable BinaryRow partition,
+ FormatReaderFactory readerFactory,
+ Pair<int[], RowType> partitionMapping)
+ throws IOException {
+ FormatReaderContext formatReaderContext =
+ new FormatReaderContext(table.fileIO(), file.filePath(),
file.fileSize(), null);
try {
FileRecordReader<InternalRow> reader;
- Long length = dataSplit.length();
+ Long length = file.length();
if (length != null) {
- reader =
- readerFactory.createReader(formatReaderContext,
dataSplit.offset(), length);
+ reader = readerFactory.createReader(formatReaderContext,
file.offset(), length);
} else {
- checkArgument(dataSplit.offset() == 0, "Offset must be 0.");
+ checkArgument(file.offset() == 0, "Offset must be 0.");
reader = readerFactory.createReader(formatReaderContext);
}
return new DataFileRecordReader(
@@ -193,7 +209,7 @@ public class FormatReadBuilder implements ReadBuilder {
options.scanIgnoreLostFile(),
null,
null,
- PartitionUtils.create(partitionMapping,
dataSplit.partition()),
+ PartitionUtils.create(partitionMapping, partition),
false,
null,
0,
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
index 9bbd64ccdf..0b63e71318 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
@@ -49,6 +49,7 @@ import org.apache.paimon.table.source.TableScan;
import org.apache.paimon.types.DataType;
import org.apache.paimon.types.RowType;
import org.apache.paimon.types.VarCharType;
+import org.apache.paimon.utils.BinPacking;
import org.apache.paimon.utils.InternalRowPartitionComputer;
import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.PartitionPathUtils;
@@ -57,7 +58,9 @@ import javax.annotation.Nullable;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
+import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
@@ -78,6 +81,7 @@ public class FormatTableScan implements InnerTableScan {
@Nullable private PartitionPredicate partitionFilter;
@Nullable private final Integer limit;
private final long targetSplitSize;
+ private final long openFileCost;
private final FormatTable.Format format;
public FormatTableScan(
@@ -89,6 +93,7 @@ public class FormatTableScan implements InnerTableScan {
this.partitionFilter = partitionFilter;
this.limit = limit;
this.targetSplitSize = coreOptions.splitTargetSize();
+ this.openFileCost = coreOptions.splitOpenFileCost();
this.format = table.format();
}
@@ -267,37 +272,44 @@ public class FormatTableScan implements InnerTableScan {
private List<Split> createSplits(FileIO fileIO, Path path, BinaryRow
partition)
throws IOException {
- List<Split> splits = new ArrayList<>();
+ List<FormatDataSplit.FileMeta> segments = new ArrayList<>();
FileStatus[] files = fileIO.listFiles(path, true);
+ Arrays.sort(files, Comparator.comparing(file ->
file.getPath().toString()));
for (FileStatus file : files) {
if (isDataFileName(file.getPath().getName())) {
- List<FormatDataSplit> fileSplits = tryToSplitLargeFile(file,
partition);
- splits.addAll(fileSplits);
+ segments.addAll(toSegments(file));
}
}
+
+ List<Split> splits = new ArrayList<>();
+ for (List<FormatDataSplit.FileMeta> bin :
+ BinPacking.packForOrdered(
+ segments,
+ file -> Math.max(file.readSize(), openFileCost),
+ targetSplitSize)) {
+ splits.add(new FormatDataSplit(bin, partition));
+ }
return splits;
}
- private List<FormatDataSplit> tryToSplitLargeFile(FileStatus file,
BinaryRow partition) {
+ private List<FormatDataSplit.FileMeta> toSegments(FileStatus file) {
if (!preferToSplitFile(file)) {
return Collections.singletonList(
- new FormatDataSplit(file.getPath(), file.getLen(),
partition));
+ new FormatDataSplit.FileMeta(file.getPath(),
file.getLen()));
}
- List<FormatDataSplit> splits = new ArrayList<>();
+ List<FormatDataSplit.FileMeta> segments = new ArrayList<>();
long remainingBytes = file.getLen();
long currentStart = 0;
while (remainingBytes > 0) {
long splitSize = Math.min(targetSplitSize, remainingBytes);
-
- FormatDataSplit split =
- new FormatDataSplit(
- file.getPath(), file.getLen(), currentStart,
splitSize, partition);
- splits.add(split);
+ segments.add(
+ new FormatDataSplit.FileMeta(
+ file.getPath(), file.getLen(), currentStart,
splitSize));
currentStart += splitSize;
remainingBytes -= splitSize;
}
- return splits;
+ return segments;
}
private boolean preferToSplitFile(FileStatus file) {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatDataSplitTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatDataSplitTest.java
index 601941063d..73a1bf6f22 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatDataSplitTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatDataSplitTest.java
@@ -19,15 +19,13 @@
package org.apache.paimon.table.format;
import org.apache.paimon.fs.Path;
-import org.apache.paimon.predicate.Predicate;
-import org.apache.paimon.predicate.PredicateBuilder;
-import org.apache.paimon.types.IntType;
-import org.apache.paimon.types.RowType;
+import org.apache.paimon.table.format.FormatDataSplit.FileMeta;
import org.apache.paimon.utils.InstantiationUtil;
import org.junit.jupiter.api.Test;
import java.io.IOException;
+import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
@@ -36,38 +34,33 @@ public class FormatDataSplitTest {
@Test
public void testSerializeAndDeserialize() throws IOException,
ClassNotFoundException {
- // Create test data
- Path filePath = new Path("/test/path/file.parquet");
- RowType rowType = RowType.builder().field("id", new IntType()).build();
- long modificationTime = System.currentTimeMillis();
+ // A split packing one whole file and one offset range of another file.
+ FileMeta wholeFile = new FileMeta(new
Path("/test/path/file1.parquet"), 1024L);
+ FileMeta rangeFile = new FileMeta(new Path("/test/path/file2.csv"),
2048L, 100L, 512L);
+ FormatDataSplit split = new FormatDataSplit(Arrays.asList(wholeFile,
rangeFile), null);
- // Create a predicate for testing
- PredicateBuilder builder = new PredicateBuilder(rowType);
- Predicate predicate = builder.equal(0, 5);
-
- // Create FormatDataSplit
- FormatDataSplit split = new FormatDataSplit(filePath, 1024L, null);
-
- // Test Java serialization
byte[] serialized = InstantiationUtil.serializeObject(split);
FormatDataSplit deserialized =
InstantiationUtil.deserializeObject(serialized,
getClass().getClassLoader());
- // Verify the deserialized object
- assertThat(deserialized.filePath()).isEqualTo(split.filePath());
- assertThat(deserialized.offset()).isEqualTo(split.offset());
- assertThat(deserialized.fileSize()).isEqualTo(split.fileSize());
- assertThat(deserialized.length()).isEqualTo(split.length());
-
- split = new FormatDataSplit(filePath, 1024L, 100L, 512L, null);
+ assertThat(deserialized).isEqualTo(split);
+ assertThat(deserialized.files()).isEqualTo(split.files());
+ assertThat(deserialized.partition()).isEqualTo(split.partition());
+ assertThat(deserialized.fileCount()).isEqualTo(2);
+ // readSize: whole file -> fileSize (1024), range -> length (512).
+ assertThat(deserialized.totalSize()).isEqualTo(1024L + 512L);
- serialized = InstantiationUtil.serializeObject(split);
- deserialized = InstantiationUtil.deserializeObject(serialized,
getClass().getClassLoader());
+ FileMeta f0 = deserialized.files().get(0);
+ assertThat(f0.filePath()).isEqualTo(wholeFile.filePath());
+ assertThat(f0.fileSize()).isEqualTo(1024L);
+ assertThat(f0.offset()).isEqualTo(0L);
+ assertThat(f0.length()).isNull();
+ assertThat(f0.readSize()).isEqualTo(1024L);
- // Verify the deserialized object
- assertThat(deserialized.filePath()).isEqualTo(split.filePath());
- assertThat(deserialized.offset()).isEqualTo(split.offset());
- assertThat(deserialized.fileSize()).isEqualTo(split.fileSize());
- assertThat(deserialized.length()).isEqualTo(split.length());
+ FileMeta f1 = deserialized.files().get(1);
+ assertThat(f1.filePath()).isEqualTo(rangeFile.filePath());
+ assertThat(f1.offset()).isEqualTo(100L);
+ assertThat(f1.length()).isEqualTo(512L);
+ assertThat(f1.readSize()).isEqualTo(512L);
}
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatReadBuilderTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatReadBuilderTest.java
index 7b441e691d..19bc1d74fc 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatReadBuilderTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatReadBuilderTest.java
@@ -185,7 +185,9 @@ public class FormatReadBuilderTest {
long fileSize = fileIO.getFileSize(csvFile);
// Test 1: Read entire CSV file (offset = 0, length = fileSize)
- FormatDataSplit fullSplit = new FormatDataSplit(csvFile, fileSize,
null);
+ FormatDataSplit fullSplit =
+ new FormatDataSplit(
+ Arrays.asList(new FormatDataSplit.FileMeta(csvFile,
fileSize)), null);
RecordReader<InternalRow> fullReader =
readBuilder.createReader(fullSplit);
List<InternalRow> fullResult = readAllRows(fullReader, rowType);
@@ -204,7 +206,10 @@ public class FormatReadBuilderTest {
// Read from offset 0 with a limited length (first 2 lines
approximately)
long partialLength = fileSize / 2;
FormatDataSplit partialSplit =
- new FormatDataSplit(csvFile, fileSize, 0, partialLength, null);
+ new FormatDataSplit(
+ Arrays.asList(
+ new FormatDataSplit.FileMeta(csvFile,
fileSize, 0, partialLength)),
+ null);
RecordReader<InternalRow> partialReader =
readBuilder.createReader(partialSplit);
List<InternalRow> partialResult = readAllRows(partialReader, rowType);
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
index cda08b61e7..936b3d6bd1 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
@@ -785,8 +785,9 @@ public class FormatTableScanTest {
// Parquet files should NOT be split, should be a single split
assertThat(splits).hasSize(1);
FormatDataSplit split = (FormatDataSplit) splits.get(0);
- assertThat(split.filePath()).isEqualTo(parquetFile);
- assertThat(split.offset()).isEqualTo(0);
+ assertThat(split.files()).hasSize(1);
+ assertThat(split.files().get(0).filePath()).isEqualTo(parquetFile);
+ assertThat(split.files().get(0).offset()).isEqualTo(0);
}
@TestTemplate
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BinPackingSplits.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BinPackingSplits.scala
index f2d06b45ea..f27af93cf6 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BinPackingSplits.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/BinPackingSplits.scala
@@ -79,7 +79,8 @@ case class BinPackingSplits(coreOptions: CoreOptions,
readRowSizeRatio: Double =
val (toReshuffle, reserved) = splits.partition {
case _: FallbackSplit => false
case split: DataSplit => split.rawConvertible() ||
coreOptions.dataEvolutionEnabled()
- // Currently, format table reader only supports reading one file.
+ // FormatDataSplit is already packed with multiple files by target size
in the core scan,
+ // so each split maps directly to one input partition here.
case _: FormatDataSplit => false
case _ => false
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/SplitUtils.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/SplitUtils.scala
index 038f3ae307..c485fe4da3 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/SplitUtils.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/util/SplitUtils.scala
@@ -32,7 +32,7 @@ object SplitUtils {
case ds: DataSplit =>
ds.dataFiles().asScala.map(_.fileSize).sum
case fs: FormatDataSplit =>
- if (fs.length() == null) fs.fileSize() else fs.length().longValue()
+ fs.totalSize()
case _ => 0
}
}
@@ -42,7 +42,7 @@ object SplitUtils {
def dataFileCount(split: Split): Long = {
split match {
case ds: DataSplit => ds.dataFiles().size()
- case _: FormatDataSplit => 1
+ case fs: FormatDataSplit => fs.fileCount()
case _ => 0
}
}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/table/PaimonFormatTableTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/table/PaimonFormatTableTest.scala
index bbd39ae70c..adac70df2e 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/table/PaimonFormatTableTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/table/PaimonFormatTableTest.scala
@@ -22,6 +22,7 @@ import org.apache.paimon.catalog.Identifier
import org.apache.paimon.fs.Path
import org.apache.paimon.spark.PaimonSparkTestWithRestCatalogBase
import org.apache.paimon.table.FormatTable
+import org.apache.paimon.table.format.FormatDataSplit
import org.apache.spark.sql.Row
import org.apache.spark.sql.connector.catalog.TableCapability
@@ -447,4 +448,52 @@ class PaimonFormatTableTest extends
PaimonSparkTestWithRestCatalogBase {
checkAnswer(sql("SHOW PARTITIONS t PARTITION (p1=2, p2='2')"),
Seq(Row("p1=2/p2=2")))
}
}
+
+ test("PaimonFormatTable: pack multiple files into one split by
source.split.target-size") {
+ val tableName = "paimon_format_multifile_split"
+ withTable(tableName) {
+ sql(
+ s"CREATE TABLE $tableName (f0 INT, f1 STRING) USING CSV TBLPROPERTIES
(" +
+ "'seq'='|', 'lineSep'='\n', 'file.compression'='none', " +
+ "'format-table.implementation'='paimon')")
+ val table =
+ paimonCatalog.getTable(Identifier.create("test_db",
tableName)).asInstanceOf[FormatTable]
+
+ // Three data files of equal byte size, each with two distinct rows.
+ val contents = Seq("1|aaa\n2|bbb", "3|ccc\n4|ddd", "5|eee\n6|fff")
+ contents.zipWithIndex.foreach {
+ case (content, i) =>
+ table.fileIO().writeFile(new Path(table.location(),
s"part-0000$i.csv"), content, false)
+ }
+ val fileSize = contents.head.getBytes("UTF-8").length
+
+ val expected = Seq(
+ Row(1, "aaa"),
+ Row(2, "bbb"),
+ Row(3, "ccc"),
+ Row(4, "ddd"),
+ Row(5, "eee"),
+ Row(6, "fff"))
+
+ // Default target size (128MB): all three files are packed into a single
split.
+ val combined = getFormatTableScan(s"SELECT * FROM
$tableName").inputSplits
+ assert(combined.length == 1, s"Expected 1 packed split but got
${combined.length}")
+ assert(
+ combined.head.asInstanceOf[FormatDataSplit].files().size() == 3,
+ "The single split should contain all 3 files")
+ checkAnswer(sql(s"SELECT * FROM $tableName ORDER BY f0"), expected)
+
+ // Target size = one file size: each file becomes its own split (no
slicing since len <= target).
+ withSparkSQLConf("spark.paimon.source.split.target-size" ->
s"${fileSize}b") {
+ val perFile = getFormatTableScan(s"SELECT * FROM
$tableName").inputSplits
+ assert(perFile.length == 3, s"Expected 3 splits but got
${perFile.length}")
+ perFile.foreach(
+ s =>
+ assert(
+ s.asInstanceOf[FormatDataSplit].files().size() == 1,
+ "Each split should contain exactly 1 file"))
+ checkAnswer(sql(s"SELECT * FROM $tableName ORDER BY f0"), expected)
+ }
+ }
+ }
}