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 4cf5e17f1d [core][python] Prune data evolution groups by merged stats
(#9114)
4cf5e17f1d is described below
commit 4cf5e17f1d39adb9d26618477d91a47fb1da9cda
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Aug 9 17:00:50 2026 +0800
[core][python] Prune data evolution groups by merged stats (#9114)
---
.../operation/DataEvolutionFileStoreScan.java | 141 ++++---
.../operation/DataEvolutionFileStoreScanTest.java | 235 ++++++++++--
.../read/scanner/data_evolution_split_generator.py | 13 +-
.../pypaimon/read/scanner/data_evolution_stats.py | 259 +++++++++++++
.../pypaimon/read/scanner/file_scanner.py | 34 +-
.../tests/data_evolution_group_stats_test.py | 420 +++++++++++++++++++++
paimon-python/pypaimon/tests/global_index_test.py | 82 ++++
7 files changed, 1104 insertions(+), 80 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
index 5ae7eb07c4..88053b03e3 100644
---
a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
+++
b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java
@@ -47,7 +47,6 @@ import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
-import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
@@ -56,7 +55,6 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Function;
-import java.util.function.ToLongFunction;
import java.util.stream.Collectors;
import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile;
@@ -64,6 +62,8 @@ import static
org.apache.paimon.manifest.ManifestFileMeta.allContainsRowId;
import static org.apache.paimon.types.VectorType.isVectorStoreFile;
import static org.apache.paimon.utils.DataEvolutionUtils.fileFieldIds;
import static org.apache.paimon.utils.DataEvolutionUtils.retrieveAnchorFile;
+import static org.apache.paimon.utils.InternalRowUtils.compare;
+import static org.apache.paimon.utils.InternalRowUtils.get;
/** {@link FileStoreScan} for data-evolution enabled table. */
public class DataEvolutionFileStoreScan extends AppendOnlyFileStoreScan {
@@ -266,6 +266,21 @@ public class DataEvolutionFileStoreScan extends
AppendOnlyFileStoreScan {
Function<Long, TableSchema> scanTableSchema,
List<ManifestEntry> metas,
EvolutionStatsCache evolutionStatsCache) {
+ long groupStart =
+ metas.stream()
+ .map(ManifestEntry::file)
+ .map(DataFileMeta::nonNullRowIdRange)
+ .mapToLong(range -> range.from)
+ .min()
+ .orElseThrow(() -> new IllegalArgumentException("Empty
evolution group."));
+ long groupEnd =
+ metas.stream()
+ .map(ManifestEntry::file)
+ .map(DataFileMeta::nonNullRowIdRange)
+ .mapToLong(range -> range.to)
+ .max()
+ .orElseThrow(() -> new IllegalArgumentException("Empty
evolution group."));
+ long groupRowCount = groupEnd - groupStart + 1;
Set<Integer> excludedFileFieldIds =
metas.stream()
.filter(
@@ -279,87 +294,91 @@ public class DataEvolutionFileStoreScan extends
AppendOnlyFileStoreScan {
.map(DataField::id))
.collect(Collectors.toSet());
// exclude blob and vector-store files, useless for predicate eval
- metas =
+ List<ManifestEntry> normalMetas =
metas.stream()
.filter(entry -> !isBlobFile(entry.file().fileName()))
.filter(entry ->
!isVectorStoreFile(entry.file().fileName()))
.collect(Collectors.toList());
- ToLongFunction<ManifestEntry> maxSeqFunc = e ->
e.file().maxSequenceNumber();
- metas.sort(Comparator.comparingLong(maxSeqFunc).reversed());
-
int[] allFields =
schema.fields().stream().mapToInt(DataField::id).toArray();
DataType[] targetTypes =
schema.fields().stream().map(DataField::type).toArray(DataType[]::new);
int fieldsCount = schema.fields().size();
int[] rowOffsets = new int[fieldsCount];
int[] fieldOffsets = new int[fieldsCount];
+ long[] latestSequences = new long[fieldsCount];
+ boolean[] tiedLatestProviders = new boolean[fieldsCount];
Arrays.fill(rowOffsets, -1);
Arrays.fill(fieldOffsets, -1);
- Set<Integer> typeMismatchedFieldIds = new HashSet<>();
+ Arrays.fill(latestSequences, Long.MIN_VALUE);
- InternalRow[] min = new InternalRow[metas.size()];
- InternalRow[] max = new InternalRow[metas.size()];
- BinaryArray[] nullCounts = new BinaryArray[metas.size()];
+ InternalRow[] min = new InternalRow[normalMetas.size()];
+ InternalRow[] max = new InternalRow[normalMetas.size()];
+ BinaryArray[] nullCounts = new BinaryArray[normalMetas.size()];
+ EvolutionStatsCache.ProjectedFileSchema[] projectedSchemas =
+ new
EvolutionStatsCache.ProjectedFileSchema[normalMetas.size()];
- for (int i = 0; i < metas.size(); i++) {
- SimpleStats stats = metas.get(i).file().valueStats();
+ for (int i = 0; i < normalMetas.size(); i++) {
+ DataFileMeta file = normalMetas.get(i).file();
+ SimpleStats stats = file.valueStats();
min[i] = stats.minValues();
max[i] = stats.maxValues();
nullCounts[i] = stats.nullCounts();
- }
-
- int unresolvedFields = fieldsCount;
- for (int i = 0; i < metas.size(); i++) {
- DataFileMeta fileMeta = metas.get(i).file();
- EvolutionStatsCache.ProjectedFileSchema projectedFileSchema =
- evolutionStatsCache.get(scanTableSchema, fileMeta);
-
+ EvolutionStatsCache.ProjectedFileSchema projected =
+ evolutionStatsCache.get(scanTableSchema, file);
+ projectedSchemas[i] = projected;
for (int j = 0; j < fieldsCount; j++) {
- if (rowOffsets[j] != -1) {
+ if (projected.fieldStats(allFields[j]) == null) {
continue;
}
- int targetFieldId = allFields[j];
- EvolutionStatsCache.FileFieldStats fileFieldStats =
- projectedFileSchema.fieldStats(targetFieldId);
- if (fileFieldStats == null) {
- continue;
- }
- if (!fileFieldStats.hasStats()) {
- rowOffsets[j] = -2;
- unresolvedFields--;
- continue;
- }
- DataType fileType = fileFieldStats.type();
- if (!fileType.equalsIgnoreFieldId(targetTypes[j])) {
- typeMismatchedFieldIds.add(targetFieldId);
- continue;
+ long sequence = file.maxSequenceNumber();
+ if (sequence > latestSequences[j]) {
+ latestSequences[j] = sequence;
+ rowOffsets[j] = i;
+ tiedLatestProviders[j] = false;
+ } else if (sequence == latestSequences[j]) {
+ tiedLatestProviders[j] = true;
}
- rowOffsets[j] = i;
- fieldOffsets[j] = fileFieldStats.index();
- unresolvedFields--;
- }
- if (unresolvedFields == 0) {
- break;
}
}
- long groupRowCount = metas.get(0).file().rowCount();
for (int j = 0; j < fieldsCount; j++) {
- if (rowOffsets[j] == -1
- && (excludedFileFieldIds.contains(allFields[j])
- || typeMismatchedFieldIds.contains(allFields[j])))
{
+ if (rowOffsets[j] == -1) {
+ if (excludedFileFieldIds.contains(allFields[j])) {
+ rowOffsets[j] = -2;
+ }
+ continue;
+ }
+ int provider = rowOffsets[j];
+ DataFileMeta file = normalMetas.get(provider).file();
+ EvolutionStatsCache.FileFieldStats fileStats =
+ projectedSchemas[provider].fieldStats(allFields[j]);
+ Range fileRange = file.nonNullRowIdRange();
+ if (tiedLatestProviders[j]
+ || !fileStats.hasStats()
+ || !fileStats.type().equalsIgnoreFieldId(targetTypes[j])
+ || fileRange.from != groupStart
+ || fileRange.to != groupEnd) {
+ rowOffsets[j] = -2;
+ continue;
+ }
+ int fieldOffset = fileStats.index();
+ if (!isValidStats(file.valueStats(), fieldOffset, targetTypes[j],
groupRowCount)) {
rowOffsets[j] = -2;
+ continue;
}
+ fieldOffsets[j] = fieldOffset;
}
- DataEvolutionRow finalMin = new DataEvolutionRow(metas.size(),
rowOffsets, fieldOffsets);
- DataEvolutionRow finalMax = new DataEvolutionRow(metas.size(),
rowOffsets, fieldOffsets);
+ DataEvolutionRow finalMin =
+ new DataEvolutionRow(normalMetas.size(), rowOffsets,
fieldOffsets);
+ DataEvolutionRow finalMax =
+ new DataEvolutionRow(normalMetas.size(), rowOffsets,
fieldOffsets);
// For null-count specifically, a field absent from every file in the
group means every
// logical row is null for that field — encode as groupRowCount so
stats predicates can
// prune non-null comparisons (e.g. `extra2 = 'x'`) instead of falling
back to
// "unknown stats -> keep" in LeafPredicate.test.
DataEvolutionArray finalNullCounts =
- new DataEvolutionArray(metas.size(), rowOffsets, fieldOffsets,
groupRowCount);
+ new DataEvolutionArray(normalMetas.size(), rowOffsets,
fieldOffsets, groupRowCount);
finalMin.setRows(min);
finalMax.setRows(max);
@@ -367,6 +386,30 @@ public class DataEvolutionFileStoreScan extends
AppendOnlyFileStoreScan {
return new EvolutionStats(groupRowCount, finalMin, finalMax,
finalNullCounts);
}
+ private static boolean isValidStats(
+ SimpleStats stats, int fieldOffset, DataType type, long rowCount) {
+ try {
+ Object min = get(stats.minValues(), fieldOffset, type);
+ Object max = get(stats.maxValues(), fieldOffset, type);
+ BinaryArray nullCounts = stats.nullCounts();
+ Long nullCount =
+ nullCounts.isNullAt(fieldOffset) ? null :
nullCounts.getLong(fieldOffset);
+ if (nullCount != null && (nullCount < 0 || nullCount > rowCount)) {
+ return false;
+ }
+ if ((min == null) != (max == null)) {
+ return false;
+ }
+ if (min == null) {
+ return true;
+ }
+ return (nullCount == null || nullCount != rowCount)
+ && compare(min, max, type.getTypeRoot()) <= 0;
+ } catch (RuntimeException e) {
+ return false;
+ }
+ }
+
/** Note: Keep this thread-safe. */
@Override
protected boolean filterByStats(ManifestEntry entry) {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionFileStoreScanTest.java
b/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionFileStoreScanTest.java
index 647e6a2c8f..1d2c9b654c 100644
---
a/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionFileStoreScanTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/operation/DataEvolutionFileStoreScanTest.java
@@ -107,8 +107,8 @@ public class DataEvolutionFileStoreScanTest {
assertThat(minRow.getString(1).toString()).isEqualTo("a");
assertThat(maxRow.getString(1).toString()).isEqualTo("z");
- assertThat(nullCounts.getInt(0)).isEqualTo(0);
- assertThat(nullCounts.getInt(1)).isEqualTo(1);
+ assertThat(nullCounts.getLong(0)).isEqualTo(0L);
+ assertThat(nullCounts.getLong(1)).isEqualTo(1L);
assertThat(minRow.getFieldCount()).isEqualTo(2);
assertThat(maxRow.getFieldCount()).isEqualTo(2);
@@ -218,7 +218,11 @@ public class DataEvolutionFileStoreScanTest {
GenericRow.of(2, 20),
GenericRow.of(4, 40),
createBinaryArray(new int[] {1, 2}),
- new int[] {0, 2}));
+ new int[] {0, 2}),
+ "newer.parquet",
+ 1L,
+ 0L,
+ 100L);
List<ManifestEntry> entries = Arrays.asList(entry2, entry1);
@@ -237,9 +241,9 @@ public class DataEvolutionFileStoreScanTest {
assertThat(maxRow.getInt(2)).isEqualTo(40);
assertThat(minRow.getString(1).toString()).isEqualTo("a");
assertThat(maxRow.getString(1).toString()).isEqualTo("c");
- assertThat(nullCounts.getInt(0)).isEqualTo(1);
- assertThat(nullCounts.getInt(1)).isEqualTo(1);
- assertThat(nullCounts.getInt(2)).isEqualTo(2);
+ assertThat(nullCounts.getLong(0)).isEqualTo(1L);
+ assertThat(nullCounts.getLong(1)).isEqualTo(1L);
+ assertThat(nullCounts.getLong(2)).isEqualTo(2L);
}
@Test
@@ -259,7 +263,11 @@ public class DataEvolutionFileStoreScanTest {
GenericRow.of(1, BinaryString.fromString("a")),
GenericRow.of(3, BinaryString.fromString("c")),
createBinaryArray(new int[] {0, 1}),
- new int[] {0, 1}));
+ new int[] {0, 1}),
+ "base-newer.parquet",
+ 1L,
+ 0L,
+ 100L);
ManifestEntry entry2 =
createManifestEntry(
@@ -290,9 +298,9 @@ public class DataEvolutionFileStoreScanTest {
assertThat(minRow.getInt(2)).isEqualTo(20);
assertThat(maxRow.getInt(2)).isEqualTo(40);
- assertThat(nullCounts.getInt(0)).isEqualTo(0);
- assertThat(nullCounts.getInt(1)).isEqualTo(1);
- assertThat(nullCounts.getInt(2)).isEqualTo(1);
+ assertThat(nullCounts.getLong(0)).isEqualTo(0L);
+ assertThat(nullCounts.getLong(1)).isEqualTo(1L);
+ assertThat(nullCounts.getLong(2)).isEqualTo(1L);
}
@Test
@@ -311,7 +319,8 @@ public class DataEvolutionFileStoreScanTest {
GenericRow.of(1, BinaryString.fromString("a")),
GenericRow.of(3, BinaryString.fromString("c")),
createBinaryArray(new int[] {0, 1}),
- new int[] {0, 1}));
+ new int[] {0, 1}),
+ 1L);
ManifestEntry entry2 =
createManifestEntryWithDifferentCols(
@@ -344,8 +353,8 @@ public class DataEvolutionFileStoreScanTest {
assertThat(minRow.isNullAt(2)).isTrue();
assertThat(maxRow.isNullAt(2)).isTrue();
- assertThat(nullCounts.getInt(0)).isEqualTo(0);
- assertThat(nullCounts.getInt(1)).isEqualTo(1);
+ assertThat(nullCounts.getLong(0)).isEqualTo(0L);
+ assertThat(nullCounts.getLong(1)).isEqualTo(1L);
assertThat(nullCounts.isNullAt(2)).isTrue();
}
@@ -384,7 +393,8 @@ public class DataEvolutionFileStoreScanTest {
newTypeMaxWriter.complete();
SimpleStats newTypeStats =
new SimpleStats(newTypeMin, newTypeMax, createBinaryArray(new
int[] {0, 0}));
- ManifestEntry newTypeEntry = createManifestEntry(1L, newTypeStats);
+ ManifestEntry newTypeEntry =
+ createManifestEntry(1L, newTypeStats, "new-type.parquet", 1L,
0L, 100L);
EvolutionStats result =
DataEvolutionFileStoreScan.evolutionStats(
@@ -494,6 +504,151 @@ public class DataEvolutionFileStoreScanTest {
.isTrue();
}
+ @Test
+ public void testNewestIncompatibleProviderIsUnknown() {
+ Schema oldSchema = createSchema("f0");
+ schemas.put(0L, TableSchema.create(0L, oldSchema));
+ TableSchema currentSchema =
+ TableSchema.create(
+ 1L, Schema.newBuilder().column("f0",
DataTypes.BIGINT()).build());
+ schemas.put(1L, currentSchema);
+
+ ManifestEntry compatible =
+ createManifestEntry(
+ 1L,
+ createSimpleStats(
+ GenericRow.of(10),
+ GenericRow.of(20),
+ createBinaryArray(new int[] {0}),
+ new int[] {0}),
+ "compatible.parquet",
+ 1L,
+ 0L,
+ 10L);
+ ManifestEntry newerIncompatible =
+ createManifestEntry(
+ 0L,
+ createSimpleStats(
+ GenericRow.of(100),
+ GenericRow.of(200),
+ createBinaryArray(new int[] {0}),
+ new int[] {0}),
+ "incompatible.parquet",
+ 2L,
+ 0L,
+ 10L);
+
+ EvolutionStats result =
+ DataEvolutionFileStoreScan.evolutionStats(
+ currentSchema,
+ scanTableSchema,
+ Arrays.asList(compatible, newerIncompatible),
+ new EvolutionStatsCache());
+
+ assertThat(result.minValues().isNullAt(0)).isTrue();
+ assertThat(result.nullCounts().isNullAt(0)).isTrue();
+ }
+
+ @Test
+ public void testPartialLatestProviderIsUnknown() {
+ Schema schema = createSchema("f0");
+ TableSchema tableSchema = TableSchema.create(0L, schema);
+ schemas.put(0L, tableSchema);
+ SimpleStats baseStats =
+ createSimpleStats(
+ GenericRow.of(0),
+ GenericRow.of(9),
+ createBinaryArray(new int[] {0}),
+ new int[] {0});
+ SimpleStats partialStats =
+ createSimpleStats(
+ GenericRow.of(100),
+ GenericRow.of(104),
+ createBinaryArray(new int[] {0}),
+ new int[] {0});
+
+ EvolutionStats result =
+ DataEvolutionFileStoreScan.evolutionStats(
+ tableSchema,
+ scanTableSchema,
+ Arrays.asList(
+ createManifestEntry(0L, baseStats,
"base.parquet", 0L, 0L, 10L),
+ createManifestEntry(
+ 0L, partialStats, "partial.parquet",
1L, 5L, 5L)),
+ new EvolutionStatsCache());
+
+ assertThat(result.minValues().isNullAt(0)).isTrue();
+ assertThat(result.nullCounts().isNullAt(0)).isTrue();
+ }
+
+ @Test
+ public void testTiedLatestProvidersAreUnknown() {
+ Schema schema = createSchema("f0");
+ TableSchema tableSchema = TableSchema.create(0L, schema);
+ schemas.put(0L, tableSchema);
+ SimpleStats stats =
+ createSimpleStats(
+ GenericRow.of(0),
+ GenericRow.of(9),
+ createBinaryArray(new int[] {0}),
+ new int[] {0});
+
+ EvolutionStats result =
+ DataEvolutionFileStoreScan.evolutionStats(
+ tableSchema,
+ scanTableSchema,
+ Arrays.asList(
+ createManifestEntry(0L, stats,
"first.parquet", 1L, 0L, 10L),
+ createManifestEntry(0L, stats,
"second.parquet", 1L, 0L, 10L)),
+ new EvolutionStatsCache());
+
+ assertThat(result.minValues().isNullAt(0)).isTrue();
+ assertThat(result.nullCounts().isNullAt(0)).isTrue();
+ }
+
+ @Test
+ public void testInvalidProviderStatsAreUnknown() {
+ Schema schema = createSchema("f0");
+ TableSchema tableSchema = TableSchema.create(0L, schema);
+ schemas.put(0L, tableSchema);
+
+ assertInvalidProviderStatsAreUnknown(tableSchema, 10, 10, -1);
+ assertInvalidProviderStatsAreUnknown(tableSchema, 10, 10, 11);
+ assertInvalidProviderStatsAreUnknown(tableSchema, null, 10, 0);
+ assertInvalidProviderStatsAreUnknown(tableSchema, 10, null, 0);
+ assertInvalidProviderStatsAreUnknown(tableSchema, 10, 1, 0);
+ assertInvalidProviderStatsAreUnknown(tableSchema, 10, 10, 10);
+ }
+
+ private void assertInvalidProviderStatsAreUnknown(
+ TableSchema tableSchema, Object min, Object max, int nullCount) {
+ SimpleStats stats =
+ createSimpleStats(
+ GenericRow.of(min),
+ GenericRow.of(max),
+ createBinaryArray(new int[] {nullCount}),
+ new int[] {0});
+ EvolutionStats result =
+ DataEvolutionFileStoreScan.evolutionStats(
+ tableSchema,
+ scanTableSchema,
+ Collections.singletonList(
+ createManifestEntry(0L, stats,
"invalid.parquet", 0L, 0L, 10L)),
+ new EvolutionStatsCache());
+
+ assertThat(result.minValues().isNullAt(0)).isTrue();
+ assertThat(result.maxValues().isNullAt(0)).isTrue();
+ assertThat(result.nullCounts().isNullAt(0)).isTrue();
+ Predicate predicate = new
PredicateBuilder(tableSchema.logicalRowType()).equal(0, 5);
+ assertThat(
+ predicate.test(
+ result.rowCount(),
+ result.minValues(),
+ result.maxValues(),
+ result.nullCounts()))
+ .isTrue();
+ }
+
@Test
public void testIntersectsRowRanges() {
List<Range> rowRanges =
@@ -523,17 +678,27 @@ public class DataEvolutionFileStoreScanTest {
}
private ManifestEntry createManifestEntry(Long schemaId, SimpleStats
stats) {
+ return createManifestEntry(schemaId, stats, "test-file.parquet", 0L,
0L, 100L);
+ }
+
+ private ManifestEntry createManifestEntry(
+ Long schemaId,
+ SimpleStats stats,
+ String fileName,
+ long sequence,
+ long firstRowId,
+ long rowCount) {
DataFileMeta fileMeta =
DataFileMeta.create(
- "test-file.parquet",
- 100L,
+ fileName,
100L,
+ rowCount,
createBinaryRow(1),
createBinaryRow(100),
stats,
stats,
- 0L,
- 0L,
+ sequence,
+ sequence,
schemaId,
0,
Collections.emptyList(),
@@ -542,7 +707,7 @@ public class DataEvolutionFileStoreScanTest {
FileSource.APPEND,
null,
null,
- null,
+ firstRowId,
null);
return ManifestEntry.create(FileKind.ADD, createBinaryRow(0), 0, 0,
fileMeta);
@@ -550,8 +715,17 @@ public class DataEvolutionFileStoreScanTest {
private ManifestEntry createManifestEntryWithDifferentCols(
Long schemaId, String[] writeCols, String[] valueStatsCols,
SimpleStats stats) {
+ return createManifestEntryWithDifferentCols(schemaId, writeCols,
valueStatsCols, stats, 0L);
+ }
+
+ private ManifestEntry createManifestEntryWithDifferentCols(
+ Long schemaId,
+ String[] writeCols,
+ String[] valueStatsCols,
+ SimpleStats stats,
+ long sequence) {
return createManifestEntryWithDifferentColsAndFileName(
- "test-file.parquet", schemaId, writeCols, valueStatsCols,
stats);
+ "test-file.parquet", schemaId, writeCols, valueStatsCols,
stats, sequence);
}
private ManifestEntry createManifestEntryWithDifferentColsAndFileName(
@@ -560,6 +734,17 @@ public class DataEvolutionFileStoreScanTest {
String[] writeCols,
String[] valueStatsCols,
SimpleStats stats) {
+ return createManifestEntryWithDifferentColsAndFileName(
+ fileName, schemaId, writeCols, valueStatsCols, stats, 0L);
+ }
+
+ private ManifestEntry createManifestEntryWithDifferentColsAndFileName(
+ String fileName,
+ Long schemaId,
+ String[] writeCols,
+ String[] valueStatsCols,
+ SimpleStats stats,
+ long sequence) {
DataFileMeta fileMeta =
DataFileMeta.create(
fileName,
@@ -569,8 +754,8 @@ public class DataEvolutionFileStoreScanTest {
createBinaryRow(100),
stats,
stats,
- 0L,
- 0L,
+ sequence,
+ sequence,
schemaId,
0,
Collections.emptyList(),
@@ -579,7 +764,7 @@ public class DataEvolutionFileStoreScanTest {
FileSource.APPEND,
Arrays.stream(valueStatsCols).collect(Collectors.toList()),
null,
- null,
+ 0L,
Arrays.stream(writeCols).collect(Collectors.toList()));
return ManifestEntry.create(FileKind.ADD, createBinaryRow(0), 0, 0,
fileMeta);
@@ -595,9 +780,9 @@ public class DataEvolutionFileStoreScanTest {
private BinaryArray createBinaryArray(int[] values) {
BinaryArray array = new BinaryArray();
- BinaryArrayWriter writer = new BinaryArrayWriter(array, values.length,
4);
+ BinaryArrayWriter writer = new BinaryArrayWriter(array, values.length,
8);
for (int i = 0; i < values.length; i++) {
- writer.writeInt(i, values[i]);
+ writer.writeLong(i, values[i]);
}
writer.complete();
return array;
diff --git
a/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py
b/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py
index 60a8d9c700..fea6ab8687 100644
--- a/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py
+++ b/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py
@@ -39,11 +39,13 @@ class DataEvolutionSplitGenerator(AbstractSplitGenerator):
open_file_cost: int,
deletion_files_map=None,
row_ranges: Optional[List] = None,
- score_getter=None
+ score_getter=None,
+ group_stats_filter=None,
):
super().__init__(table, target_split_size, open_file_cost,
deletion_files_map)
self.row_ranges = row_ranges
self.score_getter = score_getter
+ self.group_stats_filter = group_stats_filter
def create_splits(self, file_entries: List[ManifestEntry]) -> List[Split]:
"""
@@ -85,6 +87,15 @@ class DataEvolutionSplitGenerator(AbstractSplitGenerator):
# Split files by firstRowId for data evolution
split_by_row_id = self._split_by_row_id(data_files)
+ if self.group_stats_filter is not None:
+ split_by_row_id = [
+ group for group in split_by_row_id
+ if self.group_stats_filter.may_match(group)
+ ]
+ split_by_row_id = [
+ [file.copy_without_stats() for file in group]
+ for group in split_by_row_id
+ ]
# Pack the split groups for optimal split sizes
packed_files = self._pack_for_ordered(
diff --git a/paimon-python/pypaimon/read/scanner/data_evolution_stats.py
b/paimon-python/pypaimon/read/scanner/data_evolution_stats.py
new file mode 100644
index 0000000000..c9de93763d
--- /dev/null
+++ b/paimon-python/pypaimon/read/scanner/data_evolution_stats.py
@@ -0,0 +1,259 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from typing import Callable, Dict, List, Tuple
+
+from pypaimon.common.predicate import Predicate
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.manifest.schema.simple_stats import SimpleStats
+from pypaimon.read.push_down_utils import rewrite_predicate_indices
+from pypaimon.schema.data_types import DataField
+from pypaimon.table.special_fields import SpecialFields
+from pypaimon.table.row.generic_row import GenericRow
+
+
+_KNOWN = 0
+_MISSING = 1
+_UNKNOWN = 2
+
+
+class _FileLayout:
+
+ def __init__(self, data_fields, stats_offsets):
+ self.data_fields = {field.id: field for field in data_fields}
+ self.stats_offsets = stats_offsets
+
+
+class _StatsProvider:
+
+ def __init__(self, file, layout):
+ self.file = file
+ self.layout = layout
+ self.tied = False
+
+
+class DataEvolutionGroupStatsFilter:
+ """Conservatively filters logical row-id groups by merged column stats."""
+
+ def __init__(
+ self,
+ predicate: Predicate,
+ table_fields: List[DataField],
+ schema_fields: Callable[[int], List[DataField]],
+ ):
+ self.predicate = rewrite_predicate_indices(predicate, table_fields)
+ self.table_fields = table_fields
+ self.schema_fields = schema_fields
+ self._layout_cache: Dict[Tuple, _FileLayout] = {}
+
+ def may_match(self, files: List[DataFileMeta]) -> bool:
+ if not files:
+ return True
+ try:
+ stats, states, row_count = self._group_stats(files)
+ return self._predicate_may_match(
+ self.predicate, stats, states, row_count)
+ except Exception:
+ # Stats pruning is optional. Unknown schemas, corrupt stats, and
+ # incompatible types must retain the complete logical group.
+ return True
+
+ def _group_stats(self, files):
+ group_start = min(file.non_null_row_id_range().from_ for file in files)
+ group_end = max(file.non_null_row_id_range().to for file in files)
+ row_count = group_end - group_start + 1
+
+ normal_files = []
+ special_field_ids = set()
+ for file in files:
+ layout = self._layout(file)
+ if DataFileMeta.is_blob_file(file.file_name) \
+ or DataFileMeta.is_vector_file(file.file_name):
+ special_field_ids.update(layout.data_fields)
+ else:
+ normal_files.append((file, layout))
+ providers = {}
+ for file, layout in normal_files:
+ for field_id in layout.data_fields:
+ current = providers.get(field_id)
+ if (current is None
+ or file.max_sequence_number
+ > current.file.max_sequence_number):
+ providers[field_id] = _StatsProvider(file, layout)
+ elif (file.max_sequence_number
+ == current.file.max_sequence_number):
+ current.tied = True
+
+ min_values = []
+ max_values = []
+ null_counts = []
+ states = []
+ for field_index, field in enumerate(self.table_fields):
+ provider = providers.get(field.id)
+ if provider is None:
+ if field.id in special_field_ids:
+ self._append_unknown(
+ min_values, max_values, null_counts, states)
+ else:
+ min_values.append(None)
+ max_values.append(None)
+ null_counts.append(row_count)
+ states.append(_MISSING)
+ continue
+
+ if provider.tied:
+ self._append_unknown(
+ min_values, max_values, null_counts, states)
+ continue
+
+ file = provider.file
+ layout = provider.layout
+ file_range = file.non_null_row_id_range()
+ source_field = layout.data_fields[field.id]
+ # Partial-file stats do not describe the complete logical group.
+ if (file_range.from_ != group_start
+ or file_range.to != group_end
+ or source_field.type != field.type
+ or field.id not in layout.stats_offsets):
+ self._append_unknown(
+ min_values, max_values, null_counts, states)
+ continue
+
+ stats = file.value_stats
+ stats_offset = layout.stats_offsets[field.id]
+ min_value = stats.min_values.get_field(stats_offset)
+ max_value = stats.max_values.get_field(stats_offset)
+ null_count = (
+ stats.null_counts[stats_offset]
+ if (stats.null_counts is not None
+ and stats_offset < len(stats.null_counts))
+ else None
+ )
+ self._validate_stats(
+ min_value, max_value, null_count, row_count)
+ min_values.append(min_value)
+ max_values.append(max_value)
+ null_counts.append(null_count)
+ states.append(_KNOWN)
+
+ return (
+ SimpleStats(
+ GenericRow(min_values, self.table_fields),
+ GenericRow(max_values, self.table_fields),
+ null_counts,
+ ),
+ states,
+ row_count,
+ )
+
+ @staticmethod
+ def _append_unknown(min_values, max_values, null_counts, states):
+ min_values.append(None)
+ max_values.append(None)
+ null_counts.append(None)
+ states.append(_UNKNOWN)
+
+ def _layout(self, file):
+ key = (
+ file.schema_id,
+ tuple(file.write_cols) if file.write_cols is not None else None,
+ (tuple(file.value_stats_cols)
+ if file.value_stats_cols is not None else None),
+ )
+ layout = self._layout_cache.get(key)
+ if layout is not None:
+ return layout
+
+ schema_fields = self.schema_fields(file.schema_id)
+ fields_by_name = {field.name: field for field in schema_fields}
+ data_fields = self._project_fields(
+ schema_fields, fields_by_name, file.write_cols)
+ stats_fields = self._project_fields(
+ data_fields,
+ {field.name: field for field in data_fields},
+ file.value_stats_cols,
+ )
+ layout = _FileLayout(
+ data_fields,
+ {field.id: index for index, field in enumerate(stats_fields)},
+ )
+ self._layout_cache[key] = layout
+ return layout
+
+ @staticmethod
+ def _project_fields(default_fields, fields_by_name, names):
+ if names is None:
+ return default_fields
+ if len(names) != len(set(names)):
+ raise ValueError("Duplicate fields in file stats metadata.")
+ unknown = [
+ name for name in names
+ if name not in fields_by_name
+ and not SpecialFields.is_system_field(name)
+ ]
+ if unknown:
+ raise ValueError("Unknown fields in file stats metadata: %s" %
unknown)
+ return [fields_by_name[name] for name in names if name in
fields_by_name]
+
+ @staticmethod
+ def _validate_stats(min_value, max_value, null_count, row_count):
+ if (null_count is not None
+ and (isinstance(null_count, bool)
+ or not isinstance(null_count, int)
+ or null_count < 0
+ or null_count > row_count)):
+ raise ValueError("Invalid null count in file stats.")
+ if (min_value is None) != (max_value is None):
+ raise ValueError("Incomplete min/max values in file stats.")
+ if min_value is not None:
+ try:
+ ordered = min_value <= max_value
+ except TypeError as exc:
+ raise ValueError("Incomparable min/max values in file stats.")
from exc
+ if not ordered:
+ raise ValueError("Invalid min/max order in file stats.")
+ if null_count == row_count:
+ raise ValueError("All-null stats contain non-null bounds.")
+
+ def _predicate_may_match(self, predicate, stats, states, row_count):
+ if predicate.method == 'and':
+ return all(
+ self._predicate_may_match(child, stats, states, row_count)
+ for child in predicate.literals
+ )
+ if predicate.method == 'or':
+ return any(
+ self._predicate_may_match(child, stats, states, row_count)
+ for child in predicate.literals
+ )
+
+ index = predicate.index
+ if index is None or index < 0 or index >= len(states):
+ return True
+ if states[index] == _UNKNOWN:
+ return True
+ if states[index] == _MISSING:
+ tester = Predicate.testers.get(predicate.method)
+ return True if tester is None else tester.test_by_value(
+ None, predicate.literals)
+ field_type = getattr(self.table_fields[index].type, 'type', None)
+ if (field_type in ('FLOAT', 'DOUBLE')
+ and predicate.method in ('notEqual', 'notIn')):
+ # PyArrow min/max can omit NaN, which still matches negative
+ # predicates.
+ return True
+ return predicate.test_by_simple_stats(stats, row_count)
diff --git a/paimon-python/pypaimon/read/scanner/file_scanner.py
b/paimon-python/pypaimon/read/scanner/file_scanner.py
index e92205405d..7714e7e925 100755
--- a/paimon-python/pypaimon/read/scanner/file_scanner.py
+++ b/paimon-python/pypaimon/read/scanner/file_scanner.py
@@ -50,6 +50,8 @@ from pypaimon.read.scanner.chunk_shuffle_split_generator
import (
)
from pypaimon.read.scanner.data_evolution_split_generator import \
DataEvolutionSplitGenerator
+from pypaimon.read.scanner.data_evolution_stats import \
+ DataEvolutionGroupStatsFilter
from pypaimon.read.scanner.primary_key_table_split_generator import \
PrimaryKeyTableSplitGenerator
from pypaimon.read.split import DataSplit
@@ -234,7 +236,7 @@ class FileScanner:
else:
self.predicate_for_stats = predicate
self.predicate_for_stats = exclude_predicate_with_fields(
- self.predicate_for_stats, {SpecialFields.ROW_ID.name})
+ self.predicate_for_stats, SpecialFields.SYSTEM_FIELD_NAMES)
# Partition columns aren't in data files, so skip them for value-stats
pruning.
self.predicate_for_stats = exclude_predicate_with_fields(
self.predicate_for_stats, set(self.table.partition_keys))
@@ -383,6 +385,12 @@ class FileScanner:
# Generate splits
splits = split_generator.create_splits(entries)
+ if self.data_evolution and self.scan_stats is not None:
+ # Data-evolution stats pruning happens on complete row-id groups
+ # inside the split generator, not in _filter_manifest_entry.
+ self.scan_stats.entries_after_stats = sum(
+ len(split.files) for split in splits)
+
if self.table.is_primary_key_table:
splits = self._apply_primary_key_sorted_indexes(splits)
@@ -469,18 +477,33 @@ class FileScanner:
{},
row_ranges,
score_getter,
+ None,
)
# Filter manifest files by row ranges if available
if row_ranges is not None:
manifest_files =
_filter_manifest_files_by_row_ranges(manifest_files, row_ranges)
- entries = self.read_manifest_entries(manifest_files,
row_ranges=row_ranges)
+ stats_predicate = getattr(self, 'predicate_for_stats', None)
+ group_stats_enabled = stats_predicate is not None and score_getter is
None
+ entries = self.read_manifest_entries(
+ manifest_files,
+ row_ranges=row_ranges,
+ keep_stats=group_stats_enabled,
+ )
# Redundant when early_record_filter ran; kept for explain mode and as
safety net.
if row_ranges is not None:
entries = _filter_manifest_entries_by_row_ranges(entries,
row_ranges)
+ group_stats_filter = None
+ if group_stats_enabled:
+ group_stats_filter = DataEvolutionGroupStatsFilter(
+ stats_predicate,
+ self.table.fields,
+ self._schema_fields,
+ )
+
return entries, DataEvolutionSplitGenerator(
self.table,
self.target_split_size,
@@ -488,6 +511,7 @@ class FileScanner:
self._deletion_files_map(entries),
row_ranges,
score_getter,
+ group_stats_filter,
)
def plan_files(self) -> List[ManifestEntry]:
@@ -536,7 +560,8 @@ class FileScanner:
return None
def read_manifest_entries(self, manifest_files: List[ManifestFileMeta],
- row_ranges=None) -> List[ManifestEntry]:
+ row_ranges=None,
+ keep_stats=False) -> List[ManifestEntry]:
max_workers =
self.table.options.scan_manifest_parallelism(os.cpu_count() or 8)
if self.scan_stats is not None:
self.scan_stats.manifest_files_total += len(manifest_files)
@@ -561,6 +586,7 @@ class FileScanner:
return self.manifest_file_manager.read_entries_parallel(
manifest_files,
self._filter_manifest_entry,
+ drop_stats=not keep_stats,
max_workers=max_workers,
early_entry_filter=self._build_early_bucket_filter(),
early_record_filter=early_row_filter,
@@ -854,8 +880,6 @@ class FileScanner:
return True
# Data evolution: file stats may be from another schema, skip
stats filter and filter in reader.
if self.data_evolution:
- if stats is not None:
- stats.entries_after_stats += 1
return True
if entry.file.value_stats_cols is None and entry.file.write_cols
is not None:
stats_fields = entry.file.write_cols
diff --git a/paimon-python/pypaimon/tests/data_evolution_group_stats_test.py
b/paimon-python/pypaimon/tests/data_evolution_group_stats_test.py
new file mode 100644
index 0000000000..4b9fbd0495
--- /dev/null
+++ b/paimon-python/pypaimon/tests/data_evolution_group_stats_test.py
@@ -0,0 +1,420 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import tempfile
+import unittest
+
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.manifest.schema.data_file_meta import DataFileMeta
+from pypaimon.manifest.schema.manifest_entry import ManifestEntry
+from pypaimon.manifest.schema.simple_stats import SimpleStats
+from pypaimon.read.scanner.data_evolution_split_generator import \
+ DataEvolutionSplitGenerator
+from pypaimon.read.scanner.data_evolution_stats import \
+ DataEvolutionGroupStatsFilter
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.special_fields import SpecialFields
+
+
+def _empty_stats():
+ return SimpleStats(GenericRow([], []), GenericRow([], []), [])
+
+
+def _file(
+ name,
+ first_row_id,
+ row_count,
+ fields,
+ min_values,
+ max_values,
+ null_counts=None,
+ sequence=0,
+ schema_id=0,
+ write_cols=None,
+ value_stats_cols=None,
+):
+ stats_fields = fields
+ if value_stats_cols is not None:
+ by_name = {field.name: field for field in fields}
+ stats_fields = [by_name[name] for name in value_stats_cols]
+ elif write_cols is not None:
+ by_name = {field.name: field for field in fields}
+ stats_fields = [by_name[name] for name in write_cols
+ if name in by_name]
+ return DataFileMeta.create(
+ file_name=name,
+ file_size=100,
+ row_count=row_count,
+ min_key=GenericRow([], []),
+ max_key=GenericRow([], []),
+ key_stats=_empty_stats(),
+ value_stats=SimpleStats(
+ GenericRow(min_values, stats_fields),
+ GenericRow(max_values, stats_fields),
+ ([0] * len(stats_fields)
+ if null_counts is None else null_counts),
+ ),
+ min_sequence_number=sequence,
+ max_sequence_number=sequence,
+ schema_id=schema_id,
+ level=0,
+ extra_files=[],
+ first_row_id=first_row_id,
+ write_cols=write_cols,
+ value_stats_cols=value_stats_cols,
+ )
+
+
+class DataEvolutionGroupStatsFilterTest(unittest.TestCase):
+
+ @staticmethod
+ def _filter(predicate, schemas, current_schema_id):
+ current_fields = schemas[current_schema_id]
+ return DataEvolutionGroupStatsFilter(
+ predicate,
+ current_fields,
+ lambda schema_id: schemas[schema_id],
+ )
+
+ def test_merges_stats_from_latest_file_for_each_column(self):
+ fields = [
+ DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'left_value', AtomicType('INT')),
+ DataField(2, 'right_value', AtomicType('INT')),
+ ]
+ base = _file(
+ 'base.parquet', 0, 10, fields, [0, 10], [9, 19],
+ write_cols=['id', 'left_value'])
+ delta = _file(
+ 'delta.parquet', 0, 10, fields, [100], [109], sequence=1,
+ write_cols=['right_value'])
+ builder = PredicateBuilder(fields)
+
+ self.assertTrue(self._filter(
+ builder.and_predicates([
+ builder.equal('left_value', 15),
+ builder.equal('right_value', 105),
+ ]), {0: fields}, 0).may_match([base, delta]))
+ self.assertFalse(self._filter(
+ builder.equal('right_value', 500),
+ {0: fields}, 0).may_match([base, delta]))
+
+ def test_missing_or_corrupt_stats_fail_open(self):
+ fields = [DataField(0, 'value', AtomicType('INT'))]
+ builder = PredicateBuilder(fields)
+ without_stats = _file(
+ 'no-stats.parquet', 0, 10, fields, [], [],
+ write_cols=['value'], value_stats_cols=[])
+ corrupt = _file(
+ 'corrupt.parquet', 10, 10, fields, [10], [20])
+ corrupt.value_stats = _empty_stats()
+
+ for file in [without_stats, corrupt]:
+ with self.subTest(file=file.file_name):
+ self.assertTrue(self._filter(
+ builder.equal('value', 1000),
+ {0: fields}, 0).may_match([file]))
+
+ def test_invalid_stats_metadata_fails_open(self):
+ fields = [DataField(0, 'value', AtomicType('INT'))]
+ builder = PredicateBuilder(fields)
+ unknown_write_col = _file(
+ 'unknown-write.parquet', 0, 10, fields, [], [],
+ write_cols=['unknown'])
+ unknown_stats_col = _file(
+ 'unknown-stats.parquet', 0, 10, fields, [0], [9])
+ unknown_stats_col.value_stats_cols = ['unknown']
+ bad_null_count = _file(
+ 'bad-null-count.parquet', 0, 10, fields, [0], [9],
+ null_counts=[11])
+ reversed_min_max = _file(
+ 'reversed-min-max.parquet', 0, 10, fields, [100], [0])
+ contradictory_all_null = _file(
+ 'all-null-with-bounds.parquet', 0, 10, fields, [5], [5],
+ null_counts=[10])
+
+ cases = [
+ (unknown_write_col, builder.is_not_null('value')),
+ (unknown_stats_col, builder.equal('value', 50)),
+ (bad_null_count, builder.is_not_null('value')),
+ (reversed_min_max, builder.equal('value', 50)),
+ (contradictory_all_null, builder.not_equal('value', 5)),
+ ]
+ for file, predicate in cases:
+ with self.subTest(file=file.file_name):
+ self.assertTrue(self._filter(
+ predicate, {0: fields}, 0).may_match([file]))
+
+ def test_projected_predicate_is_rebound_by_name(self):
+ fields = [
+ DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'b', AtomicType('INT')),
+ DataField(2, 'c', AtomicType('INT')),
+ ]
+ projected = [fields[0], fields[2]]
+ file = _file(
+ 'data.parquet', 0, 1, fields, [1, 0, 200], [1, 0, 200])
+ predicate = PredicateBuilder(projected).greater_than('c', 150)
+
+ self.assertTrue(self._filter(
+ predicate, {0: fields}, 0).may_match([file]))
+
+ def test_negative_float_predicates_fail_open_for_nan(self):
+ for type_name in ('FLOAT', 'DOUBLE'):
+ fields = [DataField(0, 'value', AtomicType(type_name))]
+ file = _file('data.parquet', 0, 2, fields, [5.0], [5.0])
+ builder = PredicateBuilder(fields)
+ for predicate in (
+ builder.not_equal('value', 5.0),
+ builder.is_not_in('value', [5.0])):
+ with self.subTest(type=type_name, method=predicate.method):
+ self.assertTrue(self._filter(
+ predicate, {0: fields}, 0).may_match([file]))
+
+ def test_projected_layout_is_cached(self):
+ fields = [DataField(0, 'value', AtomicType('INT'))]
+ schema_loads = []
+
+ def load_schema(schema_id):
+ schema_loads.append(schema_id)
+ return fields
+
+ stats_filter = DataEvolutionGroupStatsFilter(
+ PredicateBuilder(fields).equal('value', 5),
+ fields,
+ load_schema,
+ )
+ stats_filter.may_match([_file(
+ 'first.parquet', 0, 1, fields, [5], [5])])
+ stats_filter.may_match([_file(
+ 'second.parquet', 1, 1, fields, [5], [5])])
+
+ self.assertEqual([0], schema_loads)
+
+ def test_value_stats_cols_controls_covered_fields(self):
+ fields = [
+ DataField(0, 'without_stats', AtomicType('INT')),
+ DataField(1, 'with_stats', AtomicType('INT')),
+ ]
+ file = _file(
+ 'data.parquet', 0, 10, fields, [20], [29],
+ write_cols=['without_stats', 'with_stats'],
+ value_stats_cols=['with_stats'])
+ builder = PredicateBuilder(fields)
+
+ self.assertFalse(self._filter(
+ builder.equal('with_stats', 1000),
+ {0: fields}, 0).may_match([file]))
+ self.assertTrue(self._filter(
+ builder.equal('without_stats', 1000),
+ {0: fields}, 0).may_match([file]))
+
+ def test_add_column_uses_implicit_null_stats(self):
+ old_fields = [DataField(0, 'id', AtomicType('INT'))]
+ current_fields = old_fields + [
+ DataField(1, 'added', AtomicType('STRING'))]
+ old_file = _file(
+ 'old.parquet', 0, 10, old_fields, [0], [9], schema_id=0)
+ builder = PredicateBuilder(current_fields)
+ schemas = {0: old_fields, 1: current_fields}
+
+ self.assertTrue(self._filter(
+ builder.is_null('added'), schemas, 1).may_match([old_file]))
+ self.assertFalse(self._filter(
+ builder.is_not_null('added'), schemas, 1).may_match([old_file]))
+ self.assertFalse(self._filter(
+ builder.equal('added', 'x'), schemas, 1).may_match([old_file]))
+
+ def test_schema_rename_uses_field_id_and_type_change_fails_open(self):
+ old_fields = [DataField(0, 'old_name', AtomicType('INT'))]
+ renamed_fields = [DataField(0, 'new_name', AtomicType('INT'))]
+ changed_fields = [DataField(0, 'new_name', AtomicType('BIGINT'))]
+ old_file = _file(
+ 'old.parquet', 0, 10, old_fields, [0], [9], schema_id=0)
+
+ renamed_builder = PredicateBuilder(renamed_fields)
+ self.assertFalse(self._filter(
+ renamed_builder.equal('new_name', 50),
+ {0: old_fields, 1: renamed_fields}, 1).may_match([old_file]))
+
+ changed_builder = PredicateBuilder(changed_fields)
+ self.assertTrue(self._filter(
+ changed_builder.equal('new_name', 50),
+ {0: old_fields, 2: changed_fields}, 2).may_match([old_file]))
+
+ def test_blob_and_vector_files_do_not_supply_predicate_stats(self):
+ fields = [
+ DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'payload', AtomicType('BYTES')),
+ ]
+ base = _file(
+ 'base.parquet', 0, 10, fields, [0], [9],
+ write_cols=['id'])
+ builder = PredicateBuilder(fields)
+ for name in ['payload.blob', 'payload.vector.parquet']:
+ special = _file(
+ name, 0, 10, fields, [b'a'], [b'z'], sequence=1,
+ write_cols=['payload'])
+ with self.subTest(name=name):
+ stats_filter = self._filter(
+ builder.equal('id', 50), {0: fields}, 0)
+ self.assertFalse(stats_filter.may_match([base, special]))
+ stats_filter = self._filter(
+ builder.equal('payload', b'not-present'),
+ {0: fields}, 0)
+ self.assertTrue(stats_filter.may_match([base, special]))
+
+ def test_special_file_does_not_hide_normal_file_stats(self):
+ fields = [
+ DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'payload', AtomicType('BYTES')),
+ ]
+ base = _file(
+ 'base.parquet', 0, 10, fields, [0], [9],
+ write_cols=['id'])
+ vector = _file(
+ 'data.vector.parquet', 0, 10, fields,
+ [100, b'a'], [109, b'z'], sequence=1)
+ builder = PredicateBuilder(fields)
+
+ self.assertFalse(self._filter(
+ builder.equal('id', 50), {0: fields}, 0
+ ).may_match([base, vector]))
+ self.assertTrue(self._filter(
+ builder.equal('payload', b'not-present'), {0: fields}, 0
+ ).may_match([base, vector]))
+
+ def test_partial_newer_file_fails_open(self):
+ fields = [DataField(0, 'value', AtomicType('INT'))]
+ base = _file('base.parquet', 0, 10, fields, [0], [9])
+ partial_delta = _file(
+ 'delta.parquet', 3, 3, fields, [100], [102], sequence=1,
+ write_cols=['value'])
+ stats_filter = self._filter(
+ PredicateBuilder(fields).equal('value', 5), {0: fields}, 0)
+
+ self.assertTrue(stats_filter.may_match([base, partial_delta]))
+
+ def test_tied_latest_providers_fail_open(self):
+ fields = [DataField(0, 'value', AtomicType('INT'))]
+ first = _file(
+ 'first.parquet', 0, 10, fields, [0], [9], sequence=1)
+ second = _file(
+ 'second.parquet', 0, 10, fields, [100], [109], sequence=1)
+ stats_filter = self._filter(
+ PredicateBuilder(fields).equal('value', 50), {0: fields}, 0)
+
+ self.assertTrue(stats_filter.may_match([first, second]))
+
+
+class DataEvolutionGroupStatsPlanningTest(unittest.TestCase):
+
+ def test_system_field_predicate_skips_group_stats(self):
+ arrow_schema = pa.schema([
+ ('id', pa.int64()),
+ ('value', pa.int32()),
+ ])
+ with tempfile.TemporaryDirectory() as warehouse:
+ catalog = CatalogFactory.create({'warehouse': warehouse})
+ catalog.create_database('default', False)
+ catalog.create_table(
+ 'default.t',
+ Schema.from_pyarrow_schema(arrow_schema, options={
+ 'metadata.stats-mode': 'full',
+ 'data-evolution.enabled': 'true',
+ 'row-tracking.enabled': 'true',
+ }),
+ False,
+ )
+ table = catalog.get_table('default.t')
+
+ batch_write = table.new_batch_write_builder()
+ writer = batch_write.new_write()
+ commit = batch_write.new_commit()
+ try:
+ writer.write_arrow(pa.table({
+ 'id': [1, 2],
+ 'value': [10, 20],
+ }, schema=arrow_schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+ read_builder = table.new_read_builder().with_projection([
+ 'id',
+ SpecialFields.SEQUENCE_NUMBER.name,
+ ])
+ predicate = read_builder.new_predicate_builder().greater_than(
+ SpecialFields.SEQUENCE_NUMBER.name, -1)
+ read_builder.with_filter(predicate)
+
+ plan = read_builder.new_scan().plan()
+ result = read_builder.new_read().to_arrow(plan.splits())
+ self.assertEqual({
+ 'id': [1, 2],
+ SpecialFields.SEQUENCE_NUMBER.name: [1, 1],
+ }, result.to_pydict())
+
+ def test_prunes_groups_before_split_packing(self):
+ fields = [DataField(0, 'id', AtomicType('INT'))]
+ files = [
+ _file('match.parquet', 0, 10, fields, [0], [9]),
+ _file('fallback-1.parquet', 10, 10, fields, [20], [29]),
+ _file('fallback-2.parquet', 20, 10, fields, [40], [49]),
+ ]
+ entries = [ManifestEntry(
+ kind=0,
+ partition=GenericRow([], []),
+ bucket=0,
+ total_buckets=1,
+ file=file,
+ ) for file in files]
+
+ class _Options:
+ options = {}
+
+ class _Table:
+ table_path = '/tmp/table'
+ options = _Options()
+
+ predicate = PredicateBuilder(fields).equal('id', 5)
+ group_filter = DataEvolutionGroupStatsFilter(
+ predicate,
+ fields,
+ lambda schema_id: fields,
+ )
+ without_pruning = DataEvolutionSplitGenerator(
+ _Table(), 1024 * 1024, 0).create_splits(entries)
+ with_pruning = DataEvolutionSplitGenerator(
+ _Table(), 1024 * 1024, 0,
+ group_stats_filter=group_filter).create_splits(entries)
+
+ self.assertEqual(3, sum(len(split.files) for split in without_pruning))
+ self.assertEqual(1, sum(len(split.files) for split in with_pruning))
+ self.assertEqual(
+ ['match.parquet'],
+ [file.file_name for split in with_pruning for file in split.files],
+ )
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/global_index_test.py
b/paimon-python/pypaimon/tests/global_index_test.py
index 07adeeb25d..3e6ab7485a 100644
--- a/paimon-python/pypaimon/tests/global_index_test.py
+++ b/paimon-python/pypaimon/tests/global_index_test.py
@@ -15,12 +15,14 @@
# specific language governing permissions and limitations
# under the License.
+import tempfile
import unittest
from unittest.mock import patch
import pyarrow as pa
import pytest
+from pypaimon import CatalogFactory, Schema
from pypaimon.common.options.core_options import CoreOptions,
GlobalIndexSearchMode
from pypaimon.common.options.options import Options
from pypaimon.common.predicate import Predicate
@@ -219,6 +221,86 @@ class
DataEvolutionGlobalIndexCoverageTest(unittest.TestCase):
class GlobalIndexScalarFallbackTest(unittest.TestCase):
+ def test_full_fallback_groups_are_pruned_before_split_packing(self):
+ from pypaimon.read.scanner.file_scanner import (
+ _GlobalIndexPlanningResult,
+ )
+
+ with tempfile.TemporaryDirectory() as warehouse:
+ catalog = CatalogFactory.create({'warehouse': warehouse})
+ catalog.create_database('default', False)
+ schema = Schema.from_pyarrow_schema(
+ pa.schema([('key', pa.int64()), ('value', pa.string())]),
+ options={
+ 'data-evolution.enabled': 'true',
+ 'row-tracking.enabled': 'true',
+ 'metadata.stats-mode': 'full',
+ 'target-file-row-num': '10',
+ 'source.split.target-size': '1kb',
+ },
+ )
+ catalog.create_table('default.fallback_stats', schema, False)
+ table = catalog.get_table('default.fallback_stats')
+ for start in range(0, 100, 10):
+ write_builder = table.new_batch_write_builder()
+ writer = write_builder.new_write()
+ commit = write_builder.new_commit()
+ writer.write_arrow(pa.table({
+ 'key': list(range(start, start + 10)),
+ 'value': ['v{}'.format(i)
+ for i in range(start, start + 10)],
+ }, schema=pa.schema([
+ ('key', pa.int64()),
+ ('value', pa.string()),
+ ])))
+ commit.commit(writer.prepare_commit())
+ writer.close()
+ commit.close()
+
+ read_builder = table.new_read_builder()
+ read_builder.with_filter(
+ read_builder.new_predicate_builder().equal('key', 5))
+ index_plan = (
+ _GlobalIndexPlanningResult(
+ GlobalIndexResult.from_range(Range(5, 5)),
+ [Range(10, 99)],
+ )
+ )
+
+ baseline_scan = read_builder.new_scan()
+ baseline_scan.file_scanner._global_index_result = index_plan
+ baseline_scan.file_scanner.predicate_for_stats = None
+ baseline_plan = baseline_scan.plan()
+
+ scan = read_builder.new_scan()
+ scan.file_scanner._global_index_result = index_plan
+ plan = scan.plan()
+
+ stats_scan = read_builder.new_scan()
+ stats_scan.file_scanner._global_index_result = index_plan
+ stats_plan, stats = stats_scan.file_scanner.scan_with_stats()
+
+ self.assertEqual(
+ 10,
+ sum(len(split.files) for split in baseline_plan.splits()),
+ )
+ self.assertEqual(1, len(plan.splits()))
+ self.assertEqual(
+ 1, sum(len(split.files) for split in plan.splits()))
+ self.assertEqual(10, stats.entries_after_bucket)
+ self.assertEqual(1, stats.entries_after_stats)
+ self.assertEqual(
+ 1, sum(len(split.files) for split in stats_plan.splits()))
+ baseline_result = read_builder.new_read().to_arrow(
+ baseline_plan.splits()).to_pydict()
+ pruned_result = read_builder.new_read().to_arrow(
+ plan.splits()).to_pydict()
+ self.assertEqual(
+ {'key': [5], 'value': ['v5']},
+ baseline_result,
+ )
+ self.assertEqual(baseline_result, pruned_result)
+
def test_eval_global_index_keeps_unindexed_ranges_out_of_bitmap(self):
from pypaimon.read.scanner.file_scanner import (
FileScanner,