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

findepi 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 339be73d22 Core: Reduce DeleteFileIndex memory for file-scoped 
position deletes (#18359)
339be73d22 is described below

commit 339be73d22dbb444b1ea3ad37ea5a9e38d08532d
Author: Raunaq Morarka <[email protected]>
AuthorDate: Thu Oct 29 01:01:41 2026 +0530

    Core: Reduce DeleteFileIndex memory for file-scoped position deletes 
(#18359)
    
    * Core: Copy file-scoped position deletes without stats in DeleteFileIndex
    
    A position delete whose file_path bounds name a single data file is
    matched by location, so the copy records the data file in
    referencedDataFile and drops column stats.
    
    JOL retained bytes per delete, 100k v2 position deletes, 150-char paths:
    
    | data files | before   | after   |
    |------------|----------|---------|
    | 100k (1:1) | 2122.6 B | 810.6 B |
    | 37k (2.7:1)| 1908.1 B | 717.0 B |
    
    * Core: Share referenced data file locations in DeleteFileIndex
    
    Position deletes of the same data file hold one location String.
    
    JOL retained bytes per delete, 100k v2 position deletes, 150-char paths,
    2.7 deletes per data file: 717.0 B before, 596.1 B after.
---
 .../src/main/java/org/apache/iceberg/BaseFile.java |  10 +-
 .../java/org/apache/iceberg/DeleteFileIndex.java   |  37 ++++++--
 .../java/org/apache/iceberg/GenericDeleteFile.java |   8 ++
 .../apache/iceberg/DeleteFileIndexTestBase.java    |  45 +--------
 .../apache/iceberg/TestLocalDeleteFileIndex.java   | 104 +++++++++++++++++++++
 5 files changed, 153 insertions(+), 51 deletions(-)

diff --git a/core/src/main/java/org/apache/iceberg/BaseFile.java 
b/core/src/main/java/org/apache/iceberg/BaseFile.java
index 8d94fd89be..cc6184e7aa 100644
--- a/core/src/main/java/org/apache/iceberg/BaseFile.java
+++ b/core/src/main/java/org/apache/iceberg/BaseFile.java
@@ -202,6 +202,14 @@ abstract class BaseFile<F> extends SupportsIndexProjection
    *     column stat is kept.
    */
   BaseFile(BaseFile<F> toCopy, boolean copyStats, Set<Integer> 
requestedColumnIds) {
+    this(toCopy, copyStats, requestedColumnIds, toCopy.referencedDataFile);
+  }
+
+  BaseFile(
+      BaseFile<F> toCopy,
+      boolean copyStats,
+      Set<Integer> requestedColumnIds,
+      String referencedDataFile) {
     super(toCopy);
     this.fileOrdinal = toCopy.fileOrdinal;
     this.manifestLocation = toCopy.manifestLocation;
@@ -246,7 +254,7 @@ abstract class BaseFile<F> extends SupportsIndexProjection
     this.dataSequenceNumber = toCopy.dataSequenceNumber;
     this.fileSequenceNumber = toCopy.fileSequenceNumber;
     this.firstRowId = toCopy.firstRowId;
-    this.referencedDataFile = toCopy.referencedDataFile;
+    this.referencedDataFile = referencedDataFile;
     this.contentOffset = toCopy.contentOffset;
     this.contentSizeInBytes = toCopy.contentSizeInBytes;
   }
diff --git a/core/src/main/java/org/apache/iceberg/DeleteFileIndex.java 
b/core/src/main/java/org/apache/iceberg/DeleteFileIndex.java
index b15114b1f0..8bf029985e 100644
--- a/core/src/main/java/org/apache/iceberg/DeleteFileIndex.java
+++ b/core/src/main/java/org/apache/iceberg/DeleteFileIndex.java
@@ -69,6 +69,8 @@ import org.apache.iceberg.util.Tasks;
  */
 class DeleteFileIndex {
   private static final DeleteFile[] EMPTY_DELETES = new DeleteFile[0];
+  private static final Set<Integer> DELETE_FILE_PATH_COLUMN =
+      Set.of(MetadataColumns.DELETE_FILE_PATH.fieldId());
 
   private final EqualityDeletes globalDeletes;
   private final PartitionMap<EqualityDeletes> eqDeletesByPartition;
@@ -493,6 +495,8 @@ class DeleteFileIndex {
       // read all of the matching delete manifests in parallel and accumulate 
the matching files in
       // a queue
       Queue<DeleteFile> files = new ConcurrentLinkedQueue<>();
+      // share one location instance across deletes that reference the same 
data file
+      Map<String, String> referencedDataFiles = Maps.newConcurrentMap();
       Tasks.foreach(deleteManifestReaders())
           .stopOnFailure()
           .throwFailureWhenFinished()
@@ -502,14 +506,7 @@ class DeleteFileIndex {
                 try (CloseableIterable<ManifestEntry<DeleteFile>> reader = 
deleteFile) {
                   for (ManifestEntry<DeleteFile> entry : reader) {
                     if (entry.dataSequenceNumber() > minSequenceNumber) {
-                      DeleteFile file = entry.file();
-                      // keep minimum stats to avoid memory pressure
-                      Set<Integer> columns =
-                          file.content() == FileContent.POSITION_DELETES
-                              ? 
Set.of(MetadataColumns.DELETE_FILE_PATH.fieldId())
-                              : Set.copyOf(file.equalityFieldIds());
-                      // copy with stats for better filtering against data 
file stats
-                      files.add(ContentFileUtil.copy(file, true, columns));
+                      files.add(copyWithMinStats(entry.file(), 
referencedDataFiles));
                     }
                   }
                 } catch (IOException e) {
@@ -519,6 +516,30 @@ class DeleteFileIndex {
       return files;
     }
 
+    private static DeleteFile copyWithMinStats(
+        DeleteFile file, Map<String, String> referencedDataFiles) {
+      if (file.content() == FileContent.EQUALITY_DELETES) {
+        // copy with stats for better filtering against data file stats
+        return file.copyWithStats(Set.copyOf(file.equalityFieldIds()));
+      }
+
+      if (ContentFileUtil.isDV(file)) {
+        // a DV already records its referenced data file
+        return file.copyWithoutStats();
+      }
+
+      String referencedDataFile = 
ContentFileUtil.referencedDataFileLocation(file);
+      if (referencedDataFile != null && file instanceof GenericDeleteFile 
genericFile) {
+        // a file-scoped position delete is matched by location and needs no 
stats
+        String location = 
referencedDataFiles.computeIfAbsent(referencedDataFile, key -> key);
+        return genericFile.copyAsFileScoped(location);
+      }
+
+      // keep the file_path bounds for position deletes that span multiple 
data files and for
+      // other DeleteFile implementations
+      return file.copyWithStats(DELETE_FILE_PATH_COLUMN);
+    }
+
     private Collection<Schema> schemas() {
       if (schemasById != null) {
         return schemasById.values();
diff --git a/core/src/main/java/org/apache/iceberg/GenericDeleteFile.java 
b/core/src/main/java/org/apache/iceberg/GenericDeleteFile.java
index d3aafc2922..2a0995eaaf 100644
--- a/core/src/main/java/org/apache/iceberg/GenericDeleteFile.java
+++ b/core/src/main/java/org/apache/iceberg/GenericDeleteFile.java
@@ -90,6 +90,10 @@ class GenericDeleteFile extends BaseFile<DeleteFile> 
implements DeleteFile {
     super(toCopy, copyStats, requestedColumnIds);
   }
 
+  private GenericDeleteFile(GenericDeleteFile toCopy, String 
referencedDataFile) {
+    super(toCopy, false /* drop stats */, null, referencedDataFile);
+  }
+
   /** Constructor for Java serialization. */
   GenericDeleteFile() {}
 
@@ -98,6 +102,10 @@ class GenericDeleteFile extends BaseFile<DeleteFile> 
implements DeleteFile {
     return new GenericDeleteFile(this, false /* drop stats */, null);
   }
 
+  DeleteFile copyAsFileScoped(String referencedDataFile) {
+    return new GenericDeleteFile(this, referencedDataFile);
+  }
+
   @Override
   public DeleteFile copyWithStats(Set<Integer> requestedColumnIds) {
     return new GenericDeleteFile(this, true, requestedColumnIds);
diff --git a/core/src/test/java/org/apache/iceberg/DeleteFileIndexTestBase.java 
b/core/src/test/java/org/apache/iceberg/DeleteFileIndexTestBase.java
index 08849fe351..9a66c98cc6 100644
--- a/core/src/test/java/org/apache/iceberg/DeleteFileIndexTestBase.java
+++ b/core/src/test/java/org/apache/iceberg/DeleteFileIndexTestBase.java
@@ -92,7 +92,7 @@ public abstract class DeleteFileIndexTestBase<
         .build();
   }
 
-  private static DeleteFile posDeletesWithMetrics(PartitionSpec spec, String 
path) {
+  static DeleteFile posDeletesWithMetrics(PartitionSpec spec, String 
lowerPath, String upperPath) {
     return FileMetadata.deleteFileBuilder(spec)
         .ofPositionDeletes()
         .withPath(UUID.randomUUID() + 
"/path/to/data-partitioned-pos-deletes.parquet")
@@ -109,12 +109,12 @@ public abstract class DeleteFileIndexTestBase<
                     1,
                     ByteBuffer.wrap(new byte[10]),
                     MetadataColumns.DELETE_FILE_PATH.fieldId(),
-                    ByteBuffer.wrap(path.getBytes(StandardCharsets.UTF_8))),
+                    
ByteBuffer.wrap(lowerPath.getBytes(StandardCharsets.UTF_8))),
                 ImmutableMap.of(
                     1,
                     ByteBuffer.wrap(new byte[10]),
                     MetadataColumns.DELETE_FILE_PATH.fieldId(),
-                    ByteBuffer.wrap(path.getBytes(StandardCharsets.UTF_8)))))
+                    
ByteBuffer.wrap(upperPath.getBytes(StandardCharsets.UTF_8)))))
         .build();
   }
 
@@ -603,45 +603,6 @@ public abstract class DeleteFileIndexTestBase<
         .isEqualTo(Sets.newHashSet(FILE_A_EQ_1.location(), 
fileADeletes().location()));
   }
 
-  @TestTemplate
-  public void testPositionDeleteDiscardMetrics() {
-    assumeThat(formatVersion).isEqualTo(2);
-
-    Table table =
-        TestTables.create(tableDir, "partitioned", SCHEMA, 
PartitionSpec.unpartitioned(), 2);
-
-    table.newAppend().appendFile(FILE_A).commit();
-
-    // add a delete file
-    DeleteFile posDeletesWithMetrics = posDeletesWithMetrics(table.spec(), 
FILE_A.location());
-    table.newRowDelta().addDeletes(posDeletesWithMetrics).commit();
-
-    List<T> tasks = Lists.newArrayList(newScan(table).planFiles().iterator());
-    assertThat(tasks).as("Should have one task").hasSize(1);
-
-    FileScanTask task = (FileScanTask) tasks.get(0);
-    assertThat(task.file().location())
-        .as("Should have the correct data file path")
-        .isEqualTo(FILE_A.location());
-    assertThat(task.deletes()).as("Should have one associated delete 
file").hasSize(1);
-
-    // verify scanned delete file only contains metrics for 'file_path' field
-    int fieldId = MetadataColumns.DELETE_FILE_PATH.fieldId();
-    DeleteFile deleteFile = task.deletes().get(0);
-    assertThat(deleteFile.columnSizes()).containsExactly(Map.entry(fieldId, 
2L));
-    assertThat(deleteFile.valueCounts()).containsExactly(Map.entry(fieldId, 
2L));
-    
assertThat(deleteFile.nullValueCounts()).containsExactly(Map.entry(fieldId, 
2L));
-    assertThat(deleteFile.nanValueCounts()).containsExactly(Map.entry(fieldId, 
2L));
-    assertThat(deleteFile.lowerBounds())
-        .containsExactly(
-            Map.entry(
-                fieldId, 
ByteBuffer.wrap(FILE_A.location().getBytes(StandardCharsets.UTF_8))));
-    assertThat(deleteFile.upperBounds())
-        .containsExactly(
-            Map.entry(
-                fieldId, 
ByteBuffer.wrap(FILE_A.location().getBytes(StandardCharsets.UTF_8))));
-  }
-
   @TestTemplate
   public void testEqualityDeleteDiscardMetrics() {
     Table table = TestTables.create(tableDir, "partitioned", SCHEMA, SPEC, 
formatVersion);
diff --git 
a/core/src/test/java/org/apache/iceberg/TestLocalDeleteFileIndex.java 
b/core/src/test/java/org/apache/iceberg/TestLocalDeleteFileIndex.java
index d01e6253d4..adee7f2465 100644
--- a/core/src/test/java/org/apache/iceberg/TestLocalDeleteFileIndex.java
+++ b/core/src/test/java/org/apache/iceberg/TestLocalDeleteFileIndex.java
@@ -18,6 +18,17 @@
  */
 package org.apache.iceberg;
 
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.junit.jupiter.api.TestTemplate;
+
 public class TestLocalDeleteFileIndex
     extends DeleteFileIndexTestBase<TableScan, FileScanTask, CombinedScanTask> 
{
 
@@ -25,4 +36,97 @@ public class TestLocalDeleteFileIndex
   protected TableScan newScan(Table table) {
     return table.newScan();
   }
+
+  @TestTemplate
+  void fileScopedPositionDeleteDiscardMetrics() {
+    Table table =
+        TestTables.create(tableDir, "partitioned", SCHEMA, 
PartitionSpec.unpartitioned(), 2);
+
+    table.newAppend().appendFile(FILE_A).commit();
+
+    // add a delete file whose file_path bounds both equal FILE_A
+    table
+        .newRowDelta()
+        .addDeletes(posDeletesWithMetrics(table.spec(), FILE_A.location(), 
FILE_A.location()))
+        .commit();
+
+    // upgrade to the tested format version with the position deletes in place
+    table
+        .updateProperties()
+        .set(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion))
+        .commit();
+
+    FileScanTask task = Iterables.getOnlyElement(table.newScan().planFiles());
+    DeleteFile deleteFile = Iterables.getOnlyElement(task.deletes());
+    assertThat(deleteFile.referencedDataFile()).isEqualTo(FILE_A.location());
+    assertThat(deleteFile.lowerBounds()).isNull();
+    assertThat(deleteFile.upperBounds()).isNull();
+  }
+
+  @TestTemplate
+  void positionDeleteWithReferencedDataFileDiscardMetrics() {
+    Table table =
+        TestTables.create(tableDir, "partitioned", SCHEMA, 
PartitionSpec.unpartitioned(), 2);
+
+    table.newAppend().appendFile(FILE_A).commit();
+
+    // add a delete file with referenced_data_file set and no file_path bounds
+    DeleteFile posDeletes =
+        FileMetadata.deleteFileBuilder(table.spec())
+            .ofPositionDeletes()
+            .withPath(UUID.randomUUID() + 
"/path/to/data-unpartitioned-pos-deletes.parquet")
+            .withFileSizeInBytes(10)
+            .withRecordCount(1)
+            .withReferencedDataFile(FILE_A.location())
+            .build();
+    table.newRowDelta().addDeletes(posDeletes).commit();
+
+    // upgrade to the tested format version with the position deletes in place
+    table
+        .updateProperties()
+        .set(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion))
+        .commit();
+
+    FileScanTask task = Iterables.getOnlyElement(table.newScan().planFiles());
+    DeleteFile deleteFile = Iterables.getOnlyElement(task.deletes());
+    assertThat(deleteFile.referencedDataFile()).isEqualTo(FILE_A.location());
+  }
+
+  @TestTemplate
+  void multiFilePositionDeleteDiscardMetrics() {
+    Table table =
+        TestTables.create(tableDir, "partitioned", SCHEMA, 
PartitionSpec.unpartitioned(), 2);
+
+    table.newAppend().appendFile(FILE_A).appendFile(FILE_B).commit();
+
+    // add a delete file whose file_path bounds span FILE_A and FILE_B
+    table
+        .newRowDelta()
+        .addDeletes(posDeletesWithMetrics(table.spec(), FILE_A.location(), 
FILE_B.location()))
+        .commit();
+
+    // upgrade to the tested format version with the position deletes in place
+    table
+        .updateProperties()
+        .set(TableProperties.FORMAT_VERSION, String.valueOf(formatVersion))
+        .commit();
+
+    List<FileScanTask> tasks = Lists.newArrayList(table.newScan().planFiles());
+    assertThat(tasks).hasSize(2);
+
+    // verify scanned delete files only keep the file_path bounds
+    int fieldId = MetadataColumns.DELETE_FILE_PATH.fieldId();
+    for (FileScanTask task : tasks) {
+      DeleteFile deleteFile = Iterables.getOnlyElement(task.deletes());
+      assertThat(deleteFile.referencedDataFile()).isNull();
+      assertThat(deleteFile.lowerBounds())
+          .containsExactly(
+              Map.entry(
+                  fieldId, 
ByteBuffer.wrap(FILE_A.location().getBytes(StandardCharsets.UTF_8))));
+      assertThat(deleteFile.upperBounds())
+          .containsExactly(
+              Map.entry(
+                  fieldId, 
ByteBuffer.wrap(FILE_B.location().getBytes(StandardCharsets.UTF_8))));
+    }
+  }
 }

Reply via email to