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 761df49c20 [iceberg] Fix partition pruning that skips manifests
holding removed files (#10649)
761df49c20 is described below
commit 761df49c2021622a183322e3c6c63ae353e7304d
Author: Jiajia Li <[email protected]>
AuthorDate: Tue Oct 20 14:49:55 2026 +0800
[iceberg] Fix partition pruning that skips manifests holding removed files
(#10649)
---
.../paimon/iceberg/IcebergCommitCallback.java | 37 ++++-
.../paimon/iceberg/IcebergCompatibilityTest.java | 173 ++++++++++++++++++++-
2 files changed, 202 insertions(+), 8 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
b/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
index abfc1dd31e..cf89a26b7a 100644
---
a/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
+++
b/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
@@ -1405,28 +1405,37 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
RowType partitionType = table.schema().logicalPartitionType();
PartitionPredicate predicate =
PartitionPredicate.fromMultiple(partitionType,
modifiedPartitions);
+ // the fields where a modified partition is NaN, which manifest bounds
never cover
+ boolean[] modifiedNan = new boolean[partitionType.getFieldCount()];
+ for (BinaryRow partition : modifiedPartitions) {
+ for (int i = 0; i < modifiedNan.length; i++) {
+ modifiedNan[i] |= isNaN(partition, i,
partitionType.getTypeAt(i));
+ }
+ }
for (IcebergManifestFileMeta fileMeta : baseManifestFileMetas) {
// use partition predicate to only check modified partitions
int numFields = partitionType.getFieldCount();
GenericRow minValues = new GenericRow(numFields);
GenericRow maxValues = new GenericRow(numFields);
- long[] nullCounts = new long[numFields];
+ Long[] nullCounts = new Long[numFields];
for (int i = 0; i < numFields; i++) {
IcebergPartitionSummary summary = fileMeta.partitions().get(i);
DataType fieldType = partitionType.getTypeAt(i);
- // an omitted bound means the value is unknown; keep the slot
null
- byte[] lowerBound = summary.lowerBound();
- byte[] upperBound = summary.upperBound();
+ // an omitted bound means the value is unknown; keep the slot
null. NaN is never
+ // in the bounds, so they cannot rule out a NaN partition of a
summary that saw NaN
+ boolean boundsMissNan = modifiedNan[i] &&
summary.containsNan();
+ byte[] lowerBound = boundsMissNan ? null :
summary.lowerBound();
+ byte[] upperBound = boundsMissNan ? null :
summary.upperBound();
if (lowerBound != null) {
minValues.setField(i,
IcebergConversions.toPaimonObject(fieldType, lowerBound));
}
if (upperBound != null) {
maxValues.setField(i,
IcebergConversions.toPaimonObject(fieldType, upperBound));
}
- // IcebergPartitionSummary only has `containsNull` field and
does not have the
- // exact number of nulls.
- nullCounts[i] = summary.containsNull() ? 1 : 0;
+ // `contains_null` does not give the number of nulls, so the
count is unknown;
+ // a count of 1 would make a manifest with one live row look
all null
+ nullCounts[i] = summary.containsNull() ? null : 0L;
}
if (predicate == null
@@ -1498,6 +1507,20 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
return Pair.of(newManifestFileMetas, operation);
}
+ private static boolean isNaN(BinaryRow row, int pos, DataType type) {
+ if (row.isNullAt(pos)) {
+ return false;
+ }
+ switch (type.getTypeRoot()) {
+ case FLOAT:
+ return Float.isNaN(row.getFloat(pos));
+ case DOUBLE:
+ return Double.isNaN(row.getDouble(pos));
+ default:
+ return false;
+ }
+ }
+
//
-------------------------------------------------------------------------------------
// Compact
//
-------------------------------------------------------------------------------------
diff --git
a/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
b/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
index 07ab14c501..8364c4f6a5 100644
---
a/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
@@ -34,6 +34,7 @@ import org.apache.paimon.data.Timestamp;
import org.apache.paimon.disk.IOManagerImpl;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.SeekableInputStream;
import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.iceberg.manifest.IcebergManifestEntry;
import org.apache.paimon.iceberg.manifest.IcebergManifestFile;
@@ -93,6 +94,7 @@ import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.File;
+import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
@@ -110,7 +112,9 @@ import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -1575,6 +1579,110 @@ public class IcebergCompatibilityTest {
});
}
+ @Test
+ public void testDropNanPartitionInManifestWithOtherPartitions() throws
Exception {
+ RowType rowType =
+ RowType.of(
+ new DataType[] {DataTypes.DOUBLE(), DataTypes.INT()},
+ new String[] {"pt", "v"});
+ FileStoreTable table =
+ createPaimonTable(
+ rowType, Collections.singletonList("pt"),
Collections.emptyList(), -1);
+
+ String commitUser = UUID.randomUUID().toString();
+ TableWriteImpl<?> write = table.newWrite(commitUser);
+ TableCommitImpl commit = table.newCommit(commitUser);
+
+ // one manifest holds all partitions, and its bounds [1.0, 2.0] leave
out NaN
+ write.write(GenericRow.of(1.0, 1));
+ write.write(GenericRow.of(Double.NaN, 2));
+ write.write(GenericRow.of(2.0, 3));
+ commit.commit(1, write.prepareCommit(false, 1));
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(1.0, 1)", "Record(NaN, 2)",
"Record(2.0, 3)");
+
+ // the bounds still cover a finite partition
+
commit.truncatePartitions(Collections.singletonList(Collections.singletonMap("pt",
"2.0")));
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(1.0, 1)", "Record(NaN, 2)");
+
+
commit.truncatePartitions(Collections.singletonList(Collections.singletonMap("pt",
"NaN")));
+ assertThat(getIcebergResult()).containsExactly("Record(1.0, 1)");
+
+ write.close();
+ commit.close();
+ }
+
+ @Test
+ public void testPartitionDropSkipsManifestsWhoseBoundsExcludeIt() throws
Exception {
+ RowType rowType =
+ RowType.of(
+ new DataType[] {DataTypes.DOUBLE(), DataTypes.INT()},
+ new String[] {"pt", "v"});
+ ReadCountingFileIO fileIO = new ReadCountingFileIO();
+ FileStoreTable table =
+ createPaimonTable(
+ fileIO,
+ rowType,
+ Collections.singletonList("pt"),
+ Collections.emptyList(),
+ -1,
+ new HashMap<>());
+
+ String commitUser = UUID.randomUUID().toString();
+ TableWriteImpl<?> write = table.newWrite(commitUser);
+ TableCommitImpl commit = table.newCommit(commitUser);
+
+ // this manifest saw NaN, and its bounds [5.0, 5.0] rule out pt=1.0
+ write.write(GenericRow.of(5.0, 1));
+ write.write(GenericRow.of(Double.NaN, 2));
+ commit.commit(1, write.prepareCommit(false, 1));
+ Set<String> nanManifests = currentManifestNames(table);
+ assertThat(nanManifests).hasSize(1);
+
+ write.write(GenericRow.of(1.0, 3));
+ commit.commit(2, write.prepareCommit(false, 2));
+ Set<String> finiteManifests = currentManifestNames(table);
+
+ // this manifest has no NaN, and its bounds [2.0, 2.0] rule out pt=NaN
+ write.write(GenericRow.of(2.0, 4));
+ commit.commit(3, write.prepareCommit(false, 3));
+ Set<String> finiteOnlyManifests = currentManifestNames(table);
+ finiteOnlyManifests.removeAll(finiteManifests);
+ assertThat(finiteOnlyManifests).hasSize(1);
+
+ fileIO.readCounts.clear();
+
commit.truncatePartitions(Collections.singletonList(Collections.singletonMap("pt",
"1.0")));
+
assertThat(fileIO.readCounts.keySet()).doesNotContainAnyElementsOf(nanManifests);
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(5.0, 1)", "Record(NaN, 2)",
"Record(2.0, 4)");
+
+ fileIO.readCounts.clear();
+
commit.truncatePartitions(Collections.singletonList(Collections.singletonMap("pt",
"NaN")));
+
assertThat(fileIO.readCounts.keySet()).doesNotContainAnyElementsOf(finiteOnlyManifests);
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(5.0, 1)", "Record(2.0, 4)");
+
+ write.close();
+ commit.close();
+ }
+
+ private Set<String> currentManifestNames(FileStoreTable table) {
+ long latestSnapshotId = table.snapshotManager().latestSnapshotId();
+ IcebergMetadata metadata =
+ IcebergMetadata.fromPath(
+ table.fileIO(),
+ new Path(
+ table.location(),
+ "metadata/v" + latestSnapshotId +
".metadata.json"));
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+ return IcebergManifestList.create(table, pathFactory)
+ .read(new
Path(metadata.currentSnapshot().manifestList()).getName()).stream()
+ .map(meta -> new Path(meta.manifestPath()).getName())
+ .collect(Collectors.toCollection(HashSet::new));
+ }
+
@Test
public void testNullPartitionValue() throws Exception {
RowType rowType =
@@ -1642,6 +1750,39 @@ public class IcebergCompatibilityTest {
commit.close();
}
+ @Test
+ public void testDropPartitionInManifestWithOneLiveRowAndNullPartition()
throws Exception {
+ RowType rowType =
+ RowType.of(
+ new DataType[] {DataTypes.INT(), DataTypes.INT()},
+ new String[] {"pt", "v"});
+ FileStoreTable table =
+ createPaimonTable(
+ rowType, Collections.singletonList("pt"),
Collections.emptyList(), -1);
+
+ String commitUser = UUID.randomUUID().toString();
+ TableWriteImpl<?> write = table.newWrite(commitUser);
+ TableCommitImpl commit = table.newCommit(commitUser);
+
+ write.write(GenericRow.of(null, 1));
+ write.write(GenericRow.of(1, 2));
+ commit.commit(1, write.prepareCommit(false, 1));
+
+ // the rewritten manifest still reports contains_null for the deleted
entry, and has a
+ // single live row left
+ Map<String, String> partition = new HashMap<>();
+ partition.put("pt", table.coreOptions().partitionDefaultName());
+ commit.truncatePartitions(Collections.singletonList(partition));
+ assertThat(getIcebergResult()).containsExactly("Record(1, 2)");
+
+ partition.put("pt", "1");
+ commit.truncatePartitions(Collections.singletonList(partition));
+ assertThat(getIcebergResult()).isEmpty();
+
+ write.close();
+ commit.close();
+ }
+
@Test
public void testStringPartitionNullPadding() throws Exception {
RowType rowType =
@@ -3183,7 +3324,23 @@ public class IcebergCompatibilityTest {
int numBuckets,
Map<String, String> customOptions)
throws Exception {
- LocalFileIO fileIO = LocalFileIO.create();
+ return createPaimonTable(
+ LocalFileIO.create(),
+ rowType,
+ partitionKeys,
+ primaryKeys,
+ numBuckets,
+ customOptions);
+ }
+
+ private FileStoreTable createPaimonTable(
+ FileIO fileIO,
+ RowType rowType,
+ List<String> partitionKeys,
+ List<String> primaryKeys,
+ int numBuckets,
+ Map<String, String> customOptions)
+ throws Exception {
Path path = new Path(tempDir.toString());
Options options = new Options(customOptions);
@@ -3373,4 +3530,18 @@ public class IcebergCompatibilityTest {
IcebergCommitCallback.checkVariantNotPublishable(
RowType.of(new DataType[] {DataTypes.INT()}, new String[]
{"k"}));
}
+
+ /** A {@link LocalFileIO} that counts the reads of each file name. */
+ private static class ReadCountingFileIO extends LocalFileIO {
+
+ private final Map<String, AtomicInteger> readCounts = new
ConcurrentHashMap<>();
+
+ @Override
+ public SeekableInputStream newInputStream(Path path) throws
IOException {
+ readCounts
+ .computeIfAbsent(path.getName(), ignored -> new
AtomicInteger())
+ .incrementAndGet();
+ return super.newInputStream(path);
+ }
+ }
}