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

szehon-ho 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 98041f0ecb Core: Fix incorrect delete manifest pruning in entries 
metadata tables (#17440)
98041f0ecb is described below

commit 98041f0ecb9f01934a4e92a82b35ecd834b05aff
Author: yangshangqing <[email protected]>
AuthorDate: Fri Jul 31 23:00:27 2026 -0400

    Core: Fix incorrect delete manifest pruning in entries metadata tables 
(#17440)
---
 .../java/org/apache/iceberg/BaseEntriesTable.java  | 51 +++++++-----
 .../apache/iceberg/TestEntriesMetadataTable.java   | 29 +++++++
 .../org/apache/iceberg/TestMetadataTableScans.java | 90 ++++++++++++++++++----
 3 files changed, 137 insertions(+), 33 deletions(-)

diff --git a/core/src/main/java/org/apache/iceberg/BaseEntriesTable.java 
b/core/src/main/java/org/apache/iceberg/BaseEntriesTable.java
index a4ee6eec32..d51a419dfe 100644
--- a/core/src/main/java/org/apache/iceberg/BaseEntriesTable.java
+++ b/core/src/main/java/org/apache/iceberg/BaseEntriesTable.java
@@ -117,13 +117,13 @@ abstract class BaseEntriesTable extends BaseMetadataTable 
{
 
     private class ManifestEvalVisitor extends 
ExpressionVisitors.BoundExpressionVisitor<Boolean> {
 
-      private int manifestContentId;
+      private ManifestContent manifestContent;
 
       private static final boolean ROWS_MIGHT_MATCH = true;
       private static final boolean ROWS_CANNOT_MATCH = false;
 
       private boolean eval(ManifestFile manifestFile) {
-        this.manifestContentId = manifestFile.content().id();
+        this.manifestContent = manifestFile.content();
         return ExpressionVisitors.visitEvaluator(boundExpr, this);
       }
 
@@ -204,7 +204,7 @@ abstract class BaseEntriesTable extends BaseMetadataTable {
       public <T> Boolean eq(BoundReference<T> ref, Literal<T> lit) {
         if (fileContent(ref)) {
           Literal<Integer> intLit = lit.to(Types.IntegerType.get());
-          if (!contentMatch(intLit.value())) {
+          if (!mayContainFileContent(intLit.value())) {
             return ROWS_CANNOT_MATCH;
           }
         }
@@ -215,7 +215,7 @@ abstract class BaseEntriesTable extends BaseMetadataTable {
       public <T> Boolean notEq(BoundReference<T> ref, Literal<T> lit) {
         if (fileContent(ref)) {
           Literal<Integer> intLit = lit.to(Types.IntegerType.get());
-          if (contentMatch(intLit.value())) {
+          if (containsOnlyFileContent(intLit.value())) {
             return ROWS_CANNOT_MATCH;
           }
         }
@@ -225,7 +225,7 @@ abstract class BaseEntriesTable extends BaseMetadataTable {
       @Override
       public <T> Boolean in(BoundReference<T> ref, Set<T> literalSet) {
         if (fileContent(ref)) {
-          if (literalSet.stream().noneMatch(lit -> contentMatch((Integer) 
lit))) {
+          if (literalSet.stream().noneMatch(lit -> 
mayContainFileContent((Integer) lit))) {
             return ROWS_CANNOT_MATCH;
           }
         }
@@ -234,11 +234,10 @@ abstract class BaseEntriesTable extends BaseMetadataTable 
{
 
       @Override
       public <T> Boolean notIn(BoundReference<T> ref, Set<T> literalSet) {
-        if (fileContent(ref)) {
-          if (literalSet.stream().anyMatch(lit -> contentMatch((Integer) 
lit))) {
-            return ROWS_CANNOT_MATCH;
-          }
+        if (fileContent(ref) && containsAllPossibleFileContents(literalSet)) {
+          return ROWS_CANNOT_MATCH;
         }
+
         return ROWS_MIGHT_MATCH;
       }
 
@@ -256,15 +255,31 @@ abstract class BaseEntriesTable extends BaseMetadataTable 
{
         return ref.fieldId() == DataFile.CONTENT.fieldId();
       }
 
-      private boolean contentMatch(Integer fileContentId) {
-        if (FileContent.DATA.id() == fileContentId) {
-          return ManifestContent.DATA.id() == manifestContentId;
-        } else if (FileContent.EQUALITY_DELETES.id() == fileContentId
-            || FileContent.POSITION_DELETES.id() == fileContentId) {
-          return ManifestContent.DELETES.id() == manifestContentId;
-        } else {
-          return false;
-        }
+      private boolean mayContainFileContent(int fileContentId) {
+        return switch (manifestContent) {
+          case DATA -> FileContent.DATA.id() == fileContentId;
+          case DELETES ->
+              FileContent.POSITION_DELETES.id() == fileContentId
+                  || FileContent.EQUALITY_DELETES.id() == fileContentId;
+        };
+      }
+
+      private boolean containsOnlyFileContent(int fileContentId) {
+        // A data manifest contains only data files(content=0), while a delete 
manifest may contain
+        // both position(content=1) and equality(content=2) deletes.
+        return switch (manifestContent) {
+          case DATA -> FileContent.DATA.id() == fileContentId;
+          case DELETES -> false;
+        };
+      }
+
+      private <T> boolean containsAllPossibleFileContents(Set<T> 
fileContentIds) {
+        return switch (manifestContent) {
+          case DATA -> fileContentIds.contains(FileContent.DATA.id());
+          case DELETES ->
+              fileContentIds.contains(FileContent.POSITION_DELETES.id())
+                  && 
fileContentIds.contains(FileContent.EQUALITY_DELETES.id());
+        };
       }
     }
   }
diff --git 
a/core/src/test/java/org/apache/iceberg/TestEntriesMetadataTable.java 
b/core/src/test/java/org/apache/iceberg/TestEntriesMetadataTable.java
index f80f29256d..132c2ef678 100644
--- a/core/src/test/java/org/apache/iceberg/TestEntriesMetadataTable.java
+++ b/core/src/test/java/org/apache/iceberg/TestEntriesMetadataTable.java
@@ -21,8 +21,11 @@ package org.apache.iceberg;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assumptions.assumeThat;
 
+import java.io.IOException;
 import java.util.List;
+import org.apache.iceberg.expressions.Expression;
 import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.io.CloseableIterable;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
 import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
 import org.apache.iceberg.types.TypeUtil;
@@ -187,4 +190,30 @@ public class TestEntriesMetadataTable extends TestBase {
           .allSatisfy(task -> 
assertThat(task.residual()).isEqualTo(Expressions.alwaysTrue()));
     }
   }
+
+  @TestTemplate
+  public void testInvalidContentPredicatesPruneAllManifests() throws 
IOException {
+    assumeThat(formatVersion).as("Only V2+ tables support delete 
files").isGreaterThanOrEqualTo(2);
+
+    table.newAppend().appendFile(FILE_A).commit();
+    table.newRowDelta().addDeletes(fileADeletes()).commit();
+
+    List<Table> metadataTables =
+        ImmutableList.of(new ManifestEntriesTable(table), new 
AllEntriesTable(table));
+
+    List<Expression> filters =
+        ImmutableList.of(
+            Expressions.equal("data_file.content", 3), 
Expressions.in("data_file.content", 3, 4));
+
+    for (Table metadataTable : metadataTables) {
+      for (Expression filter : filters) {
+        try (CloseableIterable<FileScanTask> tasks =
+            metadataTable.newScan().filter(filter).planFiles()) {
+          assertThat(tasks)
+              .as("Should not plan manifests for %s with filter %s", 
metadataTable.name(), filter)
+              .isEmpty();
+        }
+      }
+    }
+  }
 }
diff --git a/core/src/test/java/org/apache/iceberg/TestMetadataTableScans.java 
b/core/src/test/java/org/apache/iceberg/TestMetadataTableScans.java
index d5a0402a7a..75edd882d5 100644
--- a/core/src/test/java/org/apache/iceberg/TestMetadataTableScans.java
+++ b/core/src/test/java/org/apache/iceberg/TestMetadataTableScans.java
@@ -33,6 +33,7 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
 import java.util.stream.StreamSupport;
+import org.apache.iceberg.expressions.Evaluator;
 import org.apache.iceberg.expressions.Expression;
 import org.apache.iceberg.expressions.Expressions;
 import org.apache.iceberg.expressions.Literal;
@@ -251,23 +252,23 @@ public class TestMetadataTableScans extends 
MetadataTableScanTestBase {
   }
 
   @TestTemplate
-  public void testEntriesTableDateFileContentNotEq() {
-    preparePartitionedTable();
+  public void testEntriesTableDataFileContentNotEq() {
+    // Write position and equality deletes into the same delete manifest.
+    preparePartitionedTable(true);
 
     Table entriesTable = new ManifestEntriesTable(table);
 
     Expression notData = Expressions.notEqual("data_file.content", 0);
-    TableScan entriesTableScan = entriesTable.newScan().filter(notData);
-    Set<String> expected =
+    Set<String> expectedDeleteManifestPaths =
         table.currentSnapshot().deleteManifests(table.io()).stream()
             .map(ManifestFile::path)
             .collect(Collectors.toSet());
 
-    assertThat(scannedPaths(entriesTableScan))
+    assertThat(scannedPaths(entriesTable.newScan().filter(notData)))
         .as("Expected manifest filter by data file content does not match")
-        .isEqualTo(expected);
+        .isEqualTo(expectedDeleteManifestPaths);
 
-    Set<String> allManifests =
+    Set<String> allManifestPaths =
         table.currentSnapshot().allManifests(table.io()).stream()
             .map(ManifestFile::path)
             .collect(Collectors.toSet());
@@ -275,7 +276,17 @@ public class TestMetadataTableScans extends 
MetadataTableScanTestBase {
             scannedPaths(
                 
entriesTable.newScan().filter(Expressions.notEqual("data_file.content", 3))))
         .as("Expected manifest filter by data file content does not match")
-        .isEqualTo(allManifests);
+        .isEqualTo(allManifestPaths);
+
+    Expression notPositionDeletes = Expressions.notEqual("data_file.content", 
1);
+    assertThat(scannedPaths(entriesTable.newScan().filter(notPositionDeletes)))
+        .as("Expected mixed delete manifest to be retained for content != 
POSITION_DELETES")
+        .isEqualTo(allManifestPaths);
+
+    Expression notEqualityDeletes = Expressions.notEqual("data_file.content", 
2);
+    assertThat(scannedPaths(entriesTable.newScan().filter(notEqualityDeletes)))
+        .as("Expected mixed delete manifest to be retained for content != 
EQUALITY_DELETES")
+        .isEqualTo(allManifestPaths);
   }
 
   @TestTemplate
@@ -317,39 +328,51 @@ public class TestMetadataTableScans extends 
MetadataTableScanTestBase {
 
   @TestTemplate
   public void testEntriesTableDataFileContentNotIn() {
-    preparePartitionedTable();
+    // Write position and equality deletes into the same delete manifest.
+    preparePartitionedTable(true);
     Table entriesTable = new ManifestEntriesTable(table);
 
     Expression notIn0 = Expressions.notIn("data_file.content", 0);
     TableScan scan1 = entriesTable.newScan().filter(notIn0);
-    Set<String> expectedDeleteManifestPath =
+    Set<String> expectedDeleteManifestPaths =
         table.currentSnapshot().deleteManifests(table.io()).stream()
             .map(ManifestFile::path)
             .collect(Collectors.toSet());
     assertThat(scannedPaths(scan1))
         .as("Expected manifest filter by data file content does not match")
-        .isEqualTo(expectedDeleteManifestPath);
+        .isEqualTo(expectedDeleteManifestPaths);
 
     Expression notIn12 = Expressions.notIn("data_file.content", 1, 2);
     TableScan scan2 = entriesTable.newScan().filter(notIn12);
-    Set<String> expectedDataManifestPath =
+    Set<String> expectedDataManifestPaths =
         table.currentSnapshot().dataManifests(table.io()).stream()
             .map(ManifestFile::path)
             .collect(Collectors.toSet());
     assertThat(scannedPaths(scan2))
         .as("Expected manifest filter by data file content does not match")
-        .isEqualTo(expectedDataManifestPath);
+        .isEqualTo(expectedDataManifestPaths);
 
     Expression notInNeither = Expressions.notIn("data_file.content", 3);
-    Set<String> allManifests = Sets.union(expectedDataManifestPath, 
expectedDeleteManifestPath);
+    Set<String> allManifestPaths =
+        Sets.union(expectedDataManifestPaths, expectedDeleteManifestPaths);
     assertThat(scannedPaths(entriesTable.newScan().filter(notInNeither)))
         .as("Expected manifest filter by data file content does not match")
-        .isEqualTo(allManifests);
+        .isEqualTo(allManifestPaths);
 
     Expression notInAll = Expressions.notIn("data_file.content", 0, 1, 2);
     assertThat(scannedPaths(entriesTable.newScan().filter(notInAll)))
         .as("Expected manifest filter by data file content does not match")
         .isEmpty();
+
+    Expression notInPositionDeleteAndInvalid = 
Expressions.notIn("data_file.content", 1, 3);
+    
assertThat(scannedPaths(entriesTable.newScan().filter(notInPositionDeleteAndInvalid)))
+        .as("Expected mixed delete manifest to be retained")
+        .isEqualTo(allManifestPaths);
+
+    Expression notInEqualityDeleteAndInvalid = 
Expressions.notIn("data_file.content", 2, 3);
+    
assertThat(scannedPaths(entriesTable.newScan().filter(notInEqualityDeleteAndInvalid)))
+        .as("Expected mixed delete manifest to be retained")
+        .isEqualTo(allManifestPaths);
   }
 
   @TestTemplate
@@ -1833,6 +1856,43 @@ public class TestMetadataTableScans extends 
MetadataTableScanTestBase {
         .isEqualTo(2);
   }
 
+  @TestTemplate
+  public void testNotEqualReturnsEqualityDeletesFromMixedDeleteManifest() 
throws IOException {
+    assumeThat(formatVersion).as("Only V2+ tables support 
deletes").isGreaterThanOrEqualTo(2);
+
+    // Creates one delete manifest containing both position and equality 
deletes.
+    preparePartitionedTable(true);
+
+    assertThat(table.currentSnapshot().deleteManifests(table.io())).hasSize(1);
+
+    Table entriesTable = new ManifestEntriesTable(table);
+    TableScan scan =
+        entriesTable
+            .newScan()
+            .select("data_file.content")
+            .filter(Expressions.notEqual("data_file.content", 1));
+
+    Accessor<StructLike> contentAccessor =
+        scan.schema().accessorForField(DataFile.CONTENT.fieldId());
+
+    List<Integer> actualContents = Lists.newArrayList();
+
+    try (CloseableIterable<FileScanTask> tasks = scan.planFiles()) {
+      for (FileScanTask task : tasks) {
+        Evaluator residualEvaluator = new Evaluator(scan.schema().asStruct(), 
task.residual());
+        try (CloseableIterable<StructLike> rows = task.asDataTask().rows()) {
+          for (StructLike row : rows) {
+            if (residualEvaluator.eval(row)) {
+              actualContents.add((Integer) contentAccessor.get(row));
+            }
+          }
+        }
+      }
+    }
+
+    assertThat(actualContents).containsExactlyInAnyOrder(0, 0, 0, 0, 2, 2);
+  }
+
   private int rowCount(TableScan scan) throws IOException {
     int count = 0;
     try (CloseableIterable<FileScanTask> tasks = scan.planFiles()) {

Reply via email to