This is an automated email from the ASF dual-hosted git repository.

huaxingao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new ad4e824433 Core: Avoid redundant SplitScanTask wrapper for files that 
fit in a single split (#18002)
ad4e824433 is described below

commit ad4e824433e543affb8dd05b6c8e7f4ab1a5fd3c
Author: Yuming Wang <[email protected]>
AuthorDate: Wed Sep 23 07:08:57 2026 +0800

    Core: Avoid redundant SplitScanTask wrapper for files that fit in a single 
split (#18002)
    
    * Core: Avoid redundant split wrapper for files smaller than target split 
size
    
    BaseContentScanTask.split() wraps every splittable file in a SplitScanTask
    even when the file already fits within a single split (length <= 
targetSplitSize).
    For tables with many small files this allocates one redundant 1:1 wrapper
    per data file during scan planning, adding Spark driver heap pressure.
    
    Short-circuit that case: when a file fits in a single split, return the
    task itself instead of constructing an iterator that would only ever
    produce a single, functionally-identical wrapper.
    
    Also make IcebergSourceSplit.splitId() class-agnostic so the split
    identity is stable whether the underlying task is a BaseFileScanTask or
    a SplitScanTask. Previously splitId embedded the concrete task class name
    via MoreObjects.toStringHelper(fileScanTask), so the serialization
    round-trip was not type-preserving (ScanTaskParser.fromJson always
    returns a SplitScanTask), breaking TestIcebergEnumeratorStateSerializer.
    Use a fixed label so splitId no longer depends on the runtime task type.
    
    Co-authored-by: Cursor <[email protected]>
    
    * fix
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 .../org/apache/iceberg/BaseContentScanTask.java    |   2 +
 .../org/apache/iceberg/util/TestTableScanUtil.java | 140 ++++++++++++++++++---
 2 files changed, 123 insertions(+), 19 deletions(-)

diff --git a/core/src/main/java/org/apache/iceberg/BaseContentScanTask.java 
b/core/src/main/java/org/apache/iceberg/BaseContentScanTask.java
index 53c2875a70..f854acffbc 100644
--- a/core/src/main/java/org/apache/iceberg/BaseContentScanTask.java
+++ b/core/src/main/java/org/apache/iceberg/BaseContentScanTask.java
@@ -104,6 +104,8 @@ abstract class BaseContentScanTask<ThisT extends 
ContentScanTask<F>, F extends C
         return () ->
             new OffsetsAwareSplitScanTaskIterator<>(
                 self(), length(), splitOffsets, this::newSplitTask);
+      } else if (length() <= targetSplitSize && length() > 0) {
+        return ImmutableList.of(self());
       } else {
         return () ->
             new FixedSizeSplitScanTaskIterator<>(
diff --git a/core/src/test/java/org/apache/iceberg/util/TestTableScanUtil.java 
b/core/src/test/java/org/apache/iceberg/util/TestTableScanUtil.java
index 63db332d8c..c2caadc294 100644
--- a/core/src/test/java/org/apache/iceberg/util/TestTableScanUtil.java
+++ b/core/src/test/java/org/apache/iceberg/util/TestTableScanUtil.java
@@ -151,26 +151,9 @@ public class TestTableScanUtil {
 
   @Test
   public void testTaskGroupPlanningCorruptedOffset() {
-    DataFile dataFile =
-        DataFiles.builder(TestBase.SPEC)
-            .withPath("/path/to/data-a.parquet")
-            .withFileSizeInBytes(10)
-            .withPartitionPath("data_bucket=0")
-            .withRecordCount(1)
-            .withSplitOffsets(
-                ImmutableList.of(2L, 12L)) // the last offset is beyond the 
end of the file
-            .build();
-
-    ResidualEvaluator residualEvaluator =
-        ResidualEvaluator.of(TestBase.SPEC, Expressions.equal("id", 1), false);
-
+    // the last offset is beyond the end of the file
     BaseFileScanTask baseFileScanTask =
-        new BaseFileScanTask(
-            dataFile,
-            null,
-            SchemaParser.toJson(TestBase.SCHEMA),
-            PartitionSpecParser.toJson(TestBase.SPEC),
-            residualEvaluator);
+        newScanTask(FileFormat.PARQUET, 10L, ImmutableList.of(2L, 12L));
 
     List<BaseFileScanTask> baseFileScanTasks = 
ImmutableList.of(baseFileScanTask);
 
@@ -332,6 +315,125 @@ public class TestTableScanUtil {
         .hasMessageStartingWith("Parallelism must be > 0: 0");
   }
 
+  @Test
+  public void testSplitSkipsWrapWhenFileFitsSingleSplit() {
+    // A small splittable file (no offsets) smaller than the target split size 
must not be wrapped
+    // in a redundant 1:1 SplitScanTask; split() should return the task itself.
+    BaseFileScanTask task = newScanTask(FileFormat.PARQUET, 64L, null);
+
+    List<FileScanTask> splits = ImmutableList.copyOf(task.split(128L));
+
+    assertThat(splits).hasSize(1);
+    assertThat(splits.get(0)).isSameAs(task);
+    // behavior must be identical to the wrapper it replaces: whole file from 
offset 0
+    assertThat(splits.get(0).start()).isEqualTo(0L);
+    assertThat(splits.get(0).length()).isEqualTo(64L);
+    assertThat(splits.get(0).estimatedRowsCount()).isEqualTo(1L);
+  }
+
+  @Test
+  public void testSplitSkipsWrapWhenFileSizeEqualsTargetSplitSize() {
+    // length() == targetSplitSize is still "fits in a single split" and must 
not be wrapped
+    BaseFileScanTask task = newScanTask(FileFormat.PARQUET, 128L, null);
+
+    List<FileScanTask> splits = ImmutableList.copyOf(task.split(128L));
+
+    assertThat(splits).hasSize(1);
+    assertThat(splits.get(0)).isSameAs(task);
+  }
+
+  @Test
+  public void testSplitUsesOffsetsWhenFileFitsSingleSplit() {
+    // A splittable file with valid row-group/stripe offsets is split into one 
task per offset,
+    // even when the whole file fits within the target split size. The 
short-circuit must not
+    // bypass OffsetsAwareSplitScanTaskIterator, which preserves 
row-group-aligned splits.
+    BaseFileScanTask task = newScanTask(FileFormat.PARQUET, 64L, 
ImmutableList.of(0L, 32L));
+
+    List<FileScanTask> splits = ImmutableList.copyOf(task.split(128L));
+
+    assertThat(splits).hasSize(2);
+    assertThat(splits.get(0).start()).isEqualTo(0L);
+    assertThat(splits.get(0).length()).isEqualTo(32L);
+    assertThat(splits.get(1).start()).isEqualTo(32L);
+    assertThat(splits.get(1).length()).isEqualTo(32L);
+  }
+
+  @Test
+  public void testSplitPreservesOldBehaviorForEmptyFile() {
+    // A zero-length file is not short-circuited; it falls through to the 
existing splittable
+    // path. With no split offsets, FixedSizeSplitScanTaskIterator yields zero 
splits, matching
+    // the behavior before this change (hasNext() == remainingLength > 0).
+    BaseFileScanTask task = newScanTask(FileFormat.PARQUET, 0L, null);
+
+    List<FileScanTask> splits = ImmutableList.copyOf(task.split(128L));
+
+    assertThat(splits).isEmpty();
+  }
+
+  @Test
+  public void testSplitFallsThroughWhenFileExceedsTargetSplitSize() {
+    // A splittable file larger than the target split size must not be 
short-circuited; it falls
+    // through to FixedSizeSplitScanTaskIterator and is split into multiple 
fixed-size tasks.
+    BaseFileScanTask task = newScanTask(FileFormat.PARQUET, 256L, null);
+
+    List<FileScanTask> splits = ImmutableList.copyOf(task.split(128L));
+
+    assertThat(splits).hasSize(2);
+    assertThat(splits.get(0).start()).isEqualTo(0L);
+    assertThat(splits.get(0).length()).isEqualTo(128L);
+    assertThat(splits.get(1).start()).isEqualTo(128L);
+    assertThat(splits.get(1).length()).isEqualTo(128L);
+  }
+
+  @Test
+  public void testSplitPreservesOldBehaviorForEmptyFileWithOffsets() {
+    // A zero-length file's split offsets are not "well defined" (the last 
offset 0 is not less
+    // than the file size 0), so they are ignored and the file falls through to
+    // FixedSizeSplitScanTaskIterator, yielding zero splits just like the 
no-offsets empty file.
+    BaseFileScanTask task = newScanTask(FileFormat.PARQUET, 0L, 
ImmutableList.of(0L));
+
+    List<FileScanTask> splits = ImmutableList.copyOf(task.split(128L));
+
+    assertThat(splits).isEmpty();
+  }
+
+  @Test
+  public void testSplitSkipsWrapWhenOffsetsNotStrictlyAscending() {
+    // Offsets that are not strictly ascending are not usable, so the file is 
treated as having no
+    // usable offsets. Since it fits in a single split, split() returns the 
task itself.
+    BaseFileScanTask task = newScanTask(FileFormat.PARQUET, 64L, 
ImmutableList.of(32L, 0L));
+
+    List<FileScanTask> splits = ImmutableList.copyOf(task.split(128L));
+
+    assertThat(splits).hasSize(1);
+    assertThat(splits.get(0)).isSameAs(task);
+  }
+
+  private BaseFileScanTask newScanTask(
+      FileFormat format, long sizeInBytes, List<Long> splitOffsets) {
+    DataFiles.Builder builder =
+        DataFiles.builder(TestBase.SPEC)
+            .withPath("/path/to/" + format.addExtension("data-a"))
+            .withFormat(format)
+            .withFileSizeInBytes(sizeInBytes)
+            .withPartitionPath("data_bucket=0")
+            .withRecordCount(1);
+
+    if (splitOffsets != null) {
+      builder.withSplitOffsets(splitOffsets);
+    }
+
+    ResidualEvaluator residualEvaluator =
+        ResidualEvaluator.of(TestBase.SPEC, Expressions.alwaysTrue(), false);
+
+    return new BaseFileScanTask(
+        builder.build(),
+        null,
+        SchemaParser.toJson(TestBase.SCHEMA),
+        PartitionSpecParser.toJson(TestBase.SPEC),
+        residualEvaluator);
+  }
+
   private PartitionScanTask taskWithPartition(
       PartitionSpec spec, StructLike partition, long sizeBytes) {
     PartitionScanTask task = Mockito.mock(PartitionScanTask.class);

Reply via email to