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 df3196146e [core][python] Reuse PK sorted indexes with retired source 
files (#9460)
df3196146e is described below

commit df3196146e709c4899fade2492eb92e5f3edf8f5
Author: wangyong9999 <[email protected]>
AuthorDate: Sun Aug 30 10:11:06 2026 +0800

    [core][python] Reuse PK sorted indexes with retired source files (#9460)
---
 .../index/pksorted/PkSortedBucketIndexState.java   | 111 ++++++++++++++++-----
 .../paimon/index/pksorted/PkSortedIndexGroup.java  |   5 +-
 .../table/source/PrimaryKeySortedIndexScan.java    |  17 +++-
 .../pksorted/PkSortedBucketIndexStateTest.java     |  95 +++++++++++++++++-
 .../source/PrimaryKeySortedIndexScanTest.java      |  99 +++++++++++++++++-
 .../index/pk/primary_key_index_source_policy.py    |  21 ++++
 .../index/pksorted/pk_sorted_bucket_index_state.py |  63 +++++++++---
 .../table/source/primary_key_full_text_scan.py     |   7 +-
 .../table/source/primary_key_sorted_index_scan.py  |  14 ++-
 .../table/source/primary_key_vector_scan.py        |   7 +-
 .../tests/primary_key_sorted_index_scan_test.py    | 105 +++++++++++++++++++
 11 files changed, 481 insertions(+), 63 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedBucketIndexState.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedBucketIndexState.java
index fa6a5dd10b..1933a28778 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedBucketIndexState.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedBucketIndexState.java
@@ -37,6 +37,22 @@ import java.util.TreeMap;
 /** Immutable sorted-index state for one field and bucket. */
 public final class PkSortedBucketIndexState {
 
+    private static final class PayloadCandidate {
+
+        private final IndexFileMeta payload;
+        private final PkSortedIndexGroup group;
+        private final List<PrimaryKeyIndexSourceFile> activeSources;
+
+        private PayloadCandidate(
+                IndexFileMeta payload,
+                PkSortedIndexGroup group,
+                List<PrimaryKeyIndexSourceFile> activeSources) {
+            this.payload = payload;
+            this.group = group;
+            this.activeSources = activeSources;
+        }
+    }
+
     private final List<PkSortedIndexGroup> groups;
     private final List<PrimaryKeyIndexSourceFile> coveredSourceFiles;
     private final List<PrimaryKeyIndexSourceFile> uncoveredSourceFiles;
@@ -72,55 +88,102 @@ public final class PkSortedBucketIndexState {
             
sources.sort(Comparator.comparing(PrimaryKeyIndexSourceFile::fileName));
         }
 
-        Map<Integer, List<IndexFileMeta>> payloadsByLevel = new TreeMap<>();
+        Map<Integer, List<PayloadCandidate>> candidatesByLevel = new 
TreeMap<>();
         List<IndexFileMeta> rejected = new ArrayList<>();
         for (IndexFileMeta payload : activePayloads) {
             try {
                 PrimaryKeyIndexSourceMeta sourceMeta =
                         PrimaryKeyIndexSourceMeta.fromIndexFile(payload);
-                List<PrimaryKeyIndexSourceFile> desired =
+                List<PrimaryKeyIndexSourceFile> activeLevelSources =
                         sourcesByLevel.get(sourceMeta.dataLevel());
-                if (desired == null || 
!desired.equals(sourceMeta.sourceFiles())) {
+                if (activeLevelSources == null) {
+                    rejected.add(payload);
+                    continue;
+                }
+
+                List<PrimaryKeyIndexSourceFile> payloadSources = 
sourceMeta.sourceFiles();
+                List<PrimaryKeyIndexSourceFile> activeIntersection =
+                        activeIntersection(activeLevelSources, payloadSources);
+                if (activeIntersection == null || 
activeIntersection.isEmpty()) {
+                    rejected.add(payload);
+                    continue;
+                }
+
+                Optional<PkSortedIndexGroup> group =
+                        PkSortedIndexGroup.create(
+                                fieldId,
+                                indexType,
+                                payloadSources,
+                                Collections.singletonList(payload));
+                if (!group.isPresent()) {
                     rejected.add(payload);
-                } else {
-                    payloadsByLevel
-                            .computeIfAbsent(sourceMeta.dataLevel(), ignored 
-> new ArrayList<>())
-                            .add(payload);
+                    continue;
                 }
+                candidatesByLevel
+                        .computeIfAbsent(sourceMeta.dataLevel(), ignored -> 
new ArrayList<>())
+                        .add(new PayloadCandidate(payload, group.get(), 
activeIntersection));
             } catch (RuntimeException ignored) {
                 rejected.add(payload);
             }
         }
 
         List<PkSortedIndexGroup> groups = new ArrayList<>();
-        Set<Integer> coveredLevels = new HashSet<>();
-        for (Map.Entry<Integer, List<IndexFileMeta>> entry : 
payloadsByLevel.entrySet()) {
-            List<IndexFileMeta> levelPayloads = entry.getValue();
-            Optional<PkSortedIndexGroup> group =
-                    levelPayloads.size() == 1
-                            ? PkSortedIndexGroup.create(
-                                    fieldId,
-                                    indexType,
-                                    sourcesByLevel.get(entry.getKey()),
-                                    levelPayloads)
-                            : Optional.empty();
-            if (group.isPresent()) {
-                groups.add(group.get());
-                coveredLevels.add(entry.getKey());
-            } else {
-                rejected.addAll(levelPayloads);
+        Map<Integer, Set<PrimaryKeyIndexSourceFile>> coveredSourcesByLevel = 
new TreeMap<>();
+        for (Map.Entry<Integer, List<PayloadCandidate>> entry : 
candidatesByLevel.entrySet()) {
+            List<PayloadCandidate> levelCandidates = entry.getValue();
+            if (levelCandidates.size() != 1) {
+                for (PayloadCandidate candidate : levelCandidates) {
+                    rejected.add(candidate.payload);
+                }
+                continue;
             }
+            PayloadCandidate candidate = levelCandidates.get(0);
+            groups.add(candidate.group);
+            coveredSourcesByLevel
+                    .computeIfAbsent(entry.getKey(), ignored -> new 
HashSet<>())
+                    .addAll(candidate.activeSources);
         }
 
         List<PrimaryKeyIndexSourceFile> covered = new ArrayList<>();
         List<PrimaryKeyIndexSourceFile> uncovered = new ArrayList<>();
         for (Map.Entry<Integer, List<PrimaryKeyIndexSourceFile>> entry :
                 sourcesByLevel.entrySet()) {
-            (coveredLevels.contains(entry.getKey()) ? covered : 
uncovered).addAll(entry.getValue());
+            Set<PrimaryKeyIndexSourceFile> coveredSources =
+                    coveredSourcesByLevel.getOrDefault(entry.getKey(), 
Collections.emptySet());
+            for (PrimaryKeyIndexSourceFile source : entry.getValue()) {
+                (coveredSources.contains(source) ? covered : 
uncovered).add(source);
+            }
         }
         return new PkSortedBucketIndexState(groups, covered, uncovered, 
rejected);
     }
 
+    private static List<PrimaryKeyIndexSourceFile> activeIntersection(
+            List<PrimaryKeyIndexSourceFile> activeSources,
+            List<PrimaryKeyIndexSourceFile> payloadSources) {
+        List<PrimaryKeyIndexSourceFile> intersection = new ArrayList<>();
+        int activeSourceIndex = 0;
+        for (int i = 0; i < payloadSources.size(); i++) {
+            PrimaryKeyIndexSourceFile source = payloadSources.get(i);
+            if (i > 0 && payloadSources.get(i - 
1).fileName().compareTo(source.fileName()) >= 0) {
+                return null;
+            }
+            while (activeSourceIndex < activeSources.size()
+                    && 
activeSources.get(activeSourceIndex).fileName().compareTo(source.fileName())
+                            < 0) {
+                activeSourceIndex++;
+            }
+            if (activeSourceIndex == activeSources.size()
+                    || 
!activeSources.get(activeSourceIndex).fileName().equals(source.fileName())) {
+                continue;
+            }
+            if (activeSources.get(activeSourceIndex).rowCount() != 
source.rowCount()) {
+                return null;
+            }
+            intersection.add(source);
+        }
+        return intersection;
+    }
+
     public List<PkSortedIndexGroup> groups() {
         return groups;
     }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexGroup.java
 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexGroup.java
index 54999c2162..f30047a681 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexGroup.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/index/pksorted/PkSortedIndexGroup.java
@@ -30,7 +30,10 @@ import java.util.List;
 import java.util.Optional;
 import java.util.Set;
 
-/** The single payload which indexes one complete data level. */
+/**
+ * One validated payload which indexes an immutable source group at one data 
level.
+ * Snapshot-specific active coverage is validated by {@link 
PkSortedBucketIndexState}.
+ */
 public final class PkSortedIndexGroup {
 
     private final int dataLevel;
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySortedIndexScan.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySortedIndexScan.java
index 2bda216ce0..5003c9dbfd 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySortedIndexScan.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/source/PrimaryKeySortedIndexScan.java
@@ -32,6 +32,7 @@ import org.apache.paimon.index.IndexFileMeta;
 import org.apache.paimon.index.IndexPathFactory;
 import org.apache.paimon.index.pk.PrimaryKeyIndexDefinition;
 import org.apache.paimon.index.pk.PrimaryKeyIndexSourceFile;
+import org.apache.paimon.index.pk.PrimaryKeyIndexSourcePolicy;
 import org.apache.paimon.index.pksorted.PkSortedBucketIndexState;
 import org.apache.paimon.index.pksorted.PkSortedIndexGroup;
 import org.apache.paimon.io.DataFileMeta;
@@ -169,10 +170,15 @@ public final class PrimaryKeySortedIndexScan {
             Pair<BinaryRow, Integer> bucket = bucketEntry.getKey();
             List<IndexFileMeta> bucketPayloads =
                     payloadsByBucket.getOrDefault(bucket, 
Collections.emptyList());
-            Set<PrimaryKeyIndexSourceFile> activeSourceFiles = new HashSet<>();
+            Map<Integer, Set<PrimaryKeyIndexSourceFile>> 
activeSourceFilesByLevel = new HashMap<>();
             for (DataFileMeta dataFile : bucketEntry.getValue()) {
-                activeSourceFiles.add(
-                        new PrimaryKeyIndexSourceFile(dataFile.fileName(), 
dataFile.rowCount()));
+                if (PrimaryKeyIndexSourcePolicy.shouldRead(dataFile)) {
+                    activeSourceFilesByLevel
+                            .computeIfAbsent(dataFile.level(), ignored -> new 
HashSet<>())
+                            .add(
+                                    new PrimaryKeyIndexSourceFile(
+                                            dataFile.fileName(), 
dataFile.rowCount()));
+                }
             }
             Map<String, Map<Integer, PkSortedIndexGroup>> groupsBySource = new 
LinkedHashMap<>();
             for (PrimaryKeyIndexDefinition definition : scalarDefinitions) {
@@ -193,8 +199,11 @@ public final class PrimaryKeySortedIndexScan {
                                     bucketEntry.getValue(),
                                     definitionPayloads);
                     for (PkSortedIndexGroup group : state.groups()) {
+                        Set<PrimaryKeyIndexSourceFile> activeGroupSources =
+                                activeSourceFilesByLevel.getOrDefault(
+                                        group.dataLevel(), 
Collections.emptySet());
                         for (PrimaryKeyIndexSourceFile sourceFile : 
group.sourceFiles()) {
-                            if (!activeSourceFiles.contains(sourceFile)) {
+                            if (!activeGroupSources.contains(sourceFile)) {
                                 continue;
                             }
                             groupsBySource
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedBucketIndexStateTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedBucketIndexStateTest.java
index f7a9a60d1d..e666ee5ea0 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedBucketIndexStateTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/index/pksorted/PkSortedBucketIndexStateTest.java
@@ -61,7 +61,7 @@ class PkSortedBucketIndexStateTest {
     }
 
     @Test
-    void testRejectsPartialLevelPayload() {
+    void testAcceptsPayloadSubsetAndLeavesNewSourceUncovered() {
         DataFileMeta first = dataFile("data-a", 3, 2);
         DataFileMeta second = dataFile("data-b", 7, 2);
         IndexFileMeta partial = payload("partial", 2, first);
@@ -73,9 +73,89 @@ class PkSortedBucketIndexStateTest {
                         Arrays.asList(first, second),
                         Collections.singletonList(partial));
 
+        assertThat(state.groups()).hasSize(1);
+        
assertThat(state.coveredSourceFiles()).containsExactly(sourceFile(first));
+        
assertThat(state.uncoveredSourceFiles()).containsExactly(sourceFile(second));
+        assertThat(state.rejectedPayloads()).isEmpty();
+    }
+
+    @Test
+    void testRetainsRetiredSourcesAndCoversOnlyActiveIntersection() {
+        DataFileMeta retired = dataFile("data-a", 3, 2);
+        DataFileMeta active = dataFile("data-b", 7, 2);
+        DataFileMeta newlyActive = dataFile("data-c", 5, 2);
+        IndexFileMeta payload = payload("index", 2, retired, active);
+
+        PkSortedBucketIndexState state =
+                PkSortedBucketIndexState.fromActiveDataFiles(
+                        7,
+                        "btree",
+                        Arrays.asList(active, newlyActive),
+                        Collections.singletonList(payload));
+
+        assertThat(state.groups()).hasSize(1);
+        assertThat(state.groups().get(0).sourceFiles())
+                .containsExactly(sourceFile(retired), sourceFile(active));
+        
assertThat(state.coveredSourceFiles()).containsExactly(sourceFile(active));
+        
assertThat(state.uncoveredSourceFiles()).containsExactly(sourceFile(newlyActive));
+        assertThat(state.rejectedPayloads()).isEmpty();
+    }
+
+    @Test
+    void testRejectsPayloadWithoutActiveSource() {
+        DataFileMeta retired = dataFile("data-a", 3, 2);
+        DataFileMeta active = dataFile("data-b", 7, 2);
+        IndexFileMeta payload = payload("index", 2, retired);
+
+        PkSortedBucketIndexState state =
+                PkSortedBucketIndexState.fromActiveDataFiles(
+                        7,
+                        "btree",
+                        Collections.singletonList(active),
+                        Collections.singletonList(payload));
+
+        assertThat(state.groups()).isEmpty();
+        assertThat(state.coveredSourceFiles()).isEmpty();
+        
assertThat(state.uncoveredSourceFiles()).containsExactly(sourceFile(active));
+        assertThat(state.rejectedPayloads()).containsExactly(payload);
+    }
+
+    @Test
+    void testRejectsMismatchedActiveSourceRowCount() {
+        DataFileMeta active = dataFile("data", 3, 2);
+        DataFileMeta stale = dataFile("data", 4, 2);
+        IndexFileMeta payload = payload("index", 2, stale);
+
+        PkSortedBucketIndexState state =
+                PkSortedBucketIndexState.fromActiveDataFiles(
+                        7,
+                        "btree",
+                        Collections.singletonList(active),
+                        Collections.singletonList(payload));
+
         assertThat(state.groups()).isEmpty();
-        assertThat(state.uncoveredSourceFiles()).hasSize(2);
-        assertThat(state.rejectedPayloads()).containsExactly(partial);
+        
assertThat(state.uncoveredSourceFiles()).containsExactly(sourceFile(active));
+        assertThat(state.rejectedPayloads()).containsExactly(payload);
+    }
+
+    @Test
+    void testRejectsMisorderedPayloadSources() {
+        DataFileMeta first = dataFile("data-a", 3, 2);
+        DataFileMeta second = dataFile("data-b", 7, 2);
+        IndexFileMeta payload =
+                payload("index", 2, Arrays.asList(sourceFile(second), 
sourceFile(first)));
+
+        PkSortedBucketIndexState state =
+                PkSortedBucketIndexState.fromActiveDataFiles(
+                        7,
+                        "btree",
+                        Arrays.asList(first, second),
+                        Collections.singletonList(payload));
+
+        assertThat(state.groups()).isEmpty();
+        assertThat(state.uncoveredSourceFiles())
+                .containsExactly(sourceFile(first), sourceFile(second));
+        assertThat(state.rejectedPayloads()).containsExactly(payload);
     }
 
     @Test
@@ -159,6 +239,11 @@ class PkSortedBucketIndexStateTest {
                                         new PrimaryKeyIndexSourceFile(
                                                 file.fileName(), 
file.rowCount()))
                         .collect(java.util.stream.Collectors.toList());
+        return payload(name, level, sources);
+    }
+
+    private static IndexFileMeta payload(
+            String name, int level, List<PrimaryKeyIndexSourceFile> sources) {
         long rowCount = 0;
         for (PrimaryKeyIndexSourceFile source : sources) {
             rowCount += source.rowCount();
@@ -177,4 +262,8 @@ class PkSortedBucketIndexStateTest {
                         new PrimaryKeyIndexSourceMeta(level, 
sources).serialize()),
                 null);
     }
+
+    private static PrimaryKeyIndexSourceFile sourceFile(DataFileMeta file) {
+        return new PrimaryKeyIndexSourceFile(file.fileName(), file.rowCount());
+    }
 }
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeySortedIndexScanTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeySortedIndexScanTest.java
index c9c09ea7d4..25edb47eca 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeySortedIndexScanTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/source/PrimaryKeySortedIndexScanTest.java
@@ -363,6 +363,99 @@ class PrimaryKeySortedIndexScanTest {
         assertThat(secondSplit.rowRanges()).containsExactly(new Range(0, 0), 
new Range(2, 2));
     }
 
+    @Test
+    void testRetiredSourceOffsetsAndNewSourceFallback() throws IOException {
+        DataFileMeta retired = dataFile("data-1", 2);
+        DataFileMeta active = dataFile("data-2", 3);
+        DataFileMeta newlyActive = dataFile("data-3", 4);
+        DataSplit split = dataSplit(11, 0, active, newlyActive);
+        PrimaryKeyIndexDefinition definition =
+                definition(
+                        7,
+                        BTreeGlobalIndexerFactory.IDENTIFIER,
+                        PrimaryKeyIndexDefinition.Family.BTREE);
+        IndexFileMeta payload =
+                payload(
+                        "btree-retired-source",
+                        Arrays.asList(
+                                new PrimaryKeyIndexSourceFile(
+                                        retired.fileName(), 
retired.rowCount()),
+                                new PrimaryKeyIndexSourceFile(
+                                        active.fileName(), active.rowCount())),
+                        "btree",
+                        7,
+                        5);
+        PrimaryKeySortedIndexScan.Plan plan =
+                PrimaryKeySortedIndexScan.plan(
+                        11,
+                        Collections.singletonList(split),
+                        Collections.singletonList(definition),
+                        Collections.singletonList(payloadEntry(0, payload)));
+
+        assertThat(plan.files()).hasSize(2);
+        assertThat(plan.files().get(0).group(7)).isPresent();
+        assertThat(plan.files().get(0).group(7).get().sourceFiles())
+                .extracting(PrimaryKeyIndexSourceFile::fileName)
+                .containsExactly("data-1", "data-2");
+        assertThat(plan.files().get(1).group(7)).isEmpty();
+
+        RowType rowType = RowType.of(new DataField(7, "f7", DataTypes.INT()));
+        Predicate predicate = new PredicateBuilder(rowType).equal(0, 42);
+        GlobalIndexReader reader = readerWithPositions(2, 4);
+        AtomicInteger readersCreated = new AtomicInteger();
+
+        PrimaryKeySortedIndexScan.EvaluatedPlan evaluated =
+                PrimaryKeySortedIndexScan.evaluate(
+                        plan,
+                        rowType,
+                        predicate,
+                        Collections.singletonList(definition),
+                        (ignoredFile, ignoredDefinition, payloads, 
totalRowCount) -> {
+                            readersCreated.incrementAndGet();
+                            assertThat(payloads).containsExactly(payload);
+                            assertThat(totalRowCount).isEqualTo(5);
+                            return reader;
+                        });
+
+        assertThat(readersCreated).hasValue(1);
+        assertThat(evaluated.files().get(0).result()).isPresent();
+        
assertThat(evaluated.files().get(0).result().get().results()).containsExactly(0L,
 2L);
+        assertThat(evaluated.files().get(1).result()).isEmpty();
+        verify(reader).close();
+    }
+
+    @Test
+    void testRetiredSourceAtAnotherLevelDoesNotInheritGroup() {
+        DataFileMeta active = dataFile("data-1", 2, 1);
+        DataFileMeta movedToAnotherLevel = dataFile("data-2", 3, 2);
+        DataSplit split = dataSplit(11, 0, active, movedToAnotherLevel);
+        PrimaryKeyIndexDefinition definition =
+                definition(
+                        7,
+                        BTreeGlobalIndexerFactory.IDENTIFIER,
+                        PrimaryKeyIndexDefinition.Family.BTREE);
+        IndexFileMeta payload =
+                payload(
+                        "btree-level-1",
+                        Arrays.asList(
+                                new PrimaryKeyIndexSourceFile("data-1", 2),
+                                new PrimaryKeyIndexSourceFile("data-2", 3)),
+                        "btree",
+                        7,
+                        5);
+
+        PrimaryKeySortedIndexScan.Plan plan =
+                PrimaryKeySortedIndexScan.plan(
+                        11,
+                        Collections.singletonList(split),
+                        Collections.singletonList(definition),
+                        Collections.singletonList(payloadEntry(0, payload)));
+
+        assertThat(plan.files()).hasSize(2);
+        assertThat(plan.files().get(0).group(7)).isPresent();
+        assertThat(plan.files().get(1).group(7)).isEmpty();
+    }
+
     @Test
     void testArrayContainsIsCachedAndLocalized() throws IOException {
         DataFileMeta first = dataFile("data-1", 2);
@@ -645,6 +738,10 @@ class PrimaryKeySortedIndexScanTest {
     }
 
     private static DataFileMeta dataFile(String fileName, long rowCount) {
+        return dataFile(fileName, rowCount, 1);
+    }
+
+    private static DataFileMeta dataFile(String fileName, long rowCount, int 
level) {
         return DataFileMeta.forAppend(
                         fileName,
                         100,
@@ -660,7 +757,7 @@ class PrimaryKeySortedIndexScanTest {
                         null,
                         null,
                         null)
-                .upgrade(1);
+                .upgrade(level);
     }
 
     private static IndexManifestEntry payloadEntry(int bucket, IndexFileMeta 
payload) {
diff --git a/paimon-python/pypaimon/index/pk/primary_key_index_source_policy.py 
b/paimon-python/pypaimon/index/pk/primary_key_index_source_policy.py
new file mode 100644
index 0000000000..66ffa1e4ab
--- /dev/null
+++ b/paimon-python/pypaimon/index/pk/primary_key_index_source_policy.py
@@ -0,0 +1,21 @@
+# 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.
+
+
+def should_read(data_file):
+    """Match Java PrimaryKeyIndexSourcePolicy.shouldRead."""
+    # FileSource.COMPACT = 1.
+    return data_file.file_source == 1 and data_file.level > 0
diff --git 
a/paimon-python/pypaimon/index/pksorted/pk_sorted_bucket_index_state.py 
b/paimon-python/pypaimon/index/pksorted/pk_sorted_bucket_index_state.py
index 490957149e..c541c9d798 100644
--- a/paimon-python/pypaimon/index/pksorted/pk_sorted_bucket_index_state.py
+++ b/paimon-python/pypaimon/index/pksorted/pk_sorted_bucket_index_state.py
@@ -18,6 +18,7 @@ from dataclasses import dataclass
 
 from pypaimon.index.pk.primary_key_index_source_file import 
PrimaryKeyIndexSourceFile
 from pypaimon.index.pk.primary_key_index_source_meta import 
PrimaryKeyIndexSourceMeta
+from pypaimon.index.pk.primary_key_index_source_policy import should_read
 from pypaimon.index.pksorted.pk_sorted_index_group import PkSortedIndexGroup
 
 
@@ -32,40 +33,70 @@ class PkSortedBucketIndexState:
     def from_active_data_files(field_id, index_type, active_data_files, 
active_payloads):
         sources_by_level = {}
         for data_file in active_data_files:
-            if data_file.file_source != 1 or data_file.level <= 0:
+            if not should_read(data_file):
                 continue
             sources_by_level.setdefault(data_file.level, []).append(
                 PrimaryKeyIndexSourceFile(data_file.file_name, 
data_file.row_count))
         for sources in sources_by_level.values():
             sources.sort(key=lambda source: source.file_name)
 
-        payloads_by_level = {}
+        candidates_by_level = {}
         rejected = []
         for payload in active_payloads:
             try:
                 source_meta = 
PrimaryKeyIndexSourceMeta.from_index_file(payload)
-                desired = sources_by_level.get(source_meta.data_level)
-                if desired is None or tuple(desired) != 
tuple(source_meta.source_files):
+                active_level_sources = 
sources_by_level.get(source_meta.data_level)
+                if active_level_sources is None:
                     rejected.append(payload)
-                else:
-                    payloads_by_level.setdefault(source_meta.data_level, 
[]).append(payload)
+                    continue
+                active_intersection = _active_intersection(
+                    active_level_sources, source_meta.source_files)
+                if not active_intersection:
+                    rejected.append(payload)
+                    continue
+                group = PkSortedIndexGroup.create(
+                    field_id, index_type, source_meta.source_files, [payload])
+                if group is None:
+                    rejected.append(payload)
+                    continue
+                candidates_by_level.setdefault(source_meta.data_level, 
[]).append(
+                    (payload, group, active_intersection))
             except (TypeError, ValueError):
                 rejected.append(payload)
 
         groups = []
-        covered_levels = set()
-        for level, payloads in sorted(payloads_by_level.items()):
-            group = PkSortedIndexGroup.create(
-                field_id, index_type, sources_by_level[level], payloads)
-            if group is None:
-                rejected.extend(payloads)
-            else:
-                groups.append(group)
-                covered_levels.add(level)
+        covered_sources_by_level = {}
+        for level, candidates in sorted(candidates_by_level.items()):
+            if len(candidates) != 1:
+                rejected.extend(candidate[0] for candidate in candidates)
+                continue
+            _, group, active_intersection = candidates[0]
+            groups.append(group)
+            covered_sources_by_level[level] = set(active_intersection)
 
         covered = []
         uncovered = []
         for level, sources in sorted(sources_by_level.items()):
-            (covered if level in covered_levels else uncovered).extend(sources)
+            covered_sources = covered_sources_by_level.get(level, set())
+            for source in sources:
+                (covered if source in covered_sources else 
uncovered).append(source)
         return PkSortedBucketIndexState(
             tuple(groups), tuple(covered), tuple(uncovered), tuple(rejected))
+
+
+def _active_intersection(active_sources, payload_sources):
+    intersection = []
+    active_source_index = 0
+    for index, source in enumerate(payload_sources):
+        if index > 0 and payload_sources[index - 1].file_name >= 
source.file_name:
+            return None
+        while (active_source_index < len(active_sources)
+               and active_sources[active_source_index].file_name < 
source.file_name):
+            active_source_index += 1
+        if (active_source_index == len(active_sources)
+                or active_sources[active_source_index].file_name != 
source.file_name):
+            continue
+        if active_sources[active_source_index].row_count != source.row_count:
+            return None
+        intersection.append(source)
+    return intersection
diff --git a/paimon-python/pypaimon/table/source/primary_key_full_text_scan.py 
b/paimon-python/pypaimon/table/source/primary_key_full_text_scan.py
index afa3a02dfc..dab5f0e66e 100644
--- a/paimon-python/pypaimon/table/source/primary_key_full_text_scan.py
+++ b/paimon-python/pypaimon/table/source/primary_key_full_text_scan.py
@@ -23,6 +23,8 @@ from pypaimon.globalindex.indexed_split import IndexedSplit
 from pypaimon.index.index_file_handler import IndexFileHandler
 from pypaimon.index.pk.primary_key_index_source_meta import (
     PrimaryKeyIndexSourceMeta)
+from pypaimon.index.pk.primary_key_index_source_policy import (
+    should_read as _should_read_source)
 from pypaimon.read.query_auth_split import QueryAuthSplit
 from pypaimon.read.split import DataSplit
 from pypaimon.snapshot.time_travel_util import TimeTravelUtil
@@ -184,11 +186,6 @@ def _current_payloads(active_files, active_payloads):
     return current, covered
 
 
-def _should_read_source(data_file):
-    # FileSource.COMPACT = 1. Match Java PrimaryKeyIndexSourcePolicy.
-    return data_file.file_source == 1 and data_file.level > 0
-
-
 class PrimaryKeyFullTextScanPlan(FullTextScanPlan):
     def __init__(self, snapshot_id, splits):
         super().__init__(splits)
diff --git 
a/paimon-python/pypaimon/table/source/primary_key_sorted_index_scan.py 
b/paimon-python/pypaimon/table/source/primary_key_sorted_index_scan.py
index 5a2c91ad6a..865a52eebf 100644
--- a/paimon-python/pypaimon/table/source/primary_key_sorted_index_scan.py
+++ b/paimon-python/pypaimon/table/source/primary_key_sorted_index_scan.py
@@ -24,6 +24,7 @@ from pypaimon.globalindex.global_index_result import 
GlobalIndexResult
 from pypaimon.globalindex.data_evolution_global_index_scanner import 
_create_inner_readers
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.index.pk.primary_key_index_source_file import 
PrimaryKeyIndexSourceFile
+from pypaimon.index.pk.primary_key_index_source_policy import should_read
 from pypaimon.index.pksorted.pk_sorted_bucket_index_state import 
PkSortedBucketIndexState
 from pypaimon.utils.roaring_bitmap import RoaringBitmap64
 
@@ -77,9 +78,12 @@ def plan(snapshot_id, data_splits, definitions, 
index_entries):
     groups_by_bucket = {}
     for bucket, data_files in data_files_by_bucket.items():
         payloads = payloads_by_bucket.get(bucket, [])
-        active_sources = {
-            PrimaryKeyIndexSourceFile(f.file_name, f.row_count) for f in 
data_files
-        }
+        active_sources_by_level = {}
+        for data_file in data_files:
+            if should_read(data_file):
+                active_sources_by_level.setdefault(data_file.level, set()).add(
+                    PrimaryKeyIndexSourceFile(
+                        data_file.file_name, data_file.row_count))
         by_source = {}
         for definition in definitions:
             definition_payloads = [
@@ -92,8 +96,10 @@ def plan(snapshot_id, data_splits, definitions, 
index_entries):
                     definition.field_id, definition.index_type,
                     data_files, definition_payloads)
                 for group in state.groups:
+                    active_group_sources = active_sources_by_level.get(
+                        group.data_level, set())
                     for source in group.source_files:
-                        if source in active_sources:
+                        if source in active_group_sources:
                             by_source.setdefault(source.file_name, 
{})[definition.field_id] = group
             except Exception as exc:
                 LOG.warning("Failed to plan primary-key sorted index for field 
%s: %s",
diff --git a/paimon-python/pypaimon/table/source/primary_key_vector_scan.py 
b/paimon-python/pypaimon/table/source/primary_key_vector_scan.py
index ccd2535783..99156528f0 100644
--- a/paimon-python/pypaimon/table/source/primary_key_vector_scan.py
+++ b/paimon-python/pypaimon/table/source/primary_key_vector_scan.py
@@ -21,6 +21,8 @@ from pypaimon.common.options.options import Options
 from pypaimon.common.options.core_options import CoreOptions
 from pypaimon.index.index_file_handler import IndexFileHandler
 from pypaimon.index.pk.primary_key_index_source_meta import 
PrimaryKeyIndexSourceMeta
+from pypaimon.index.pk.primary_key_index_source_policy import (
+    should_read as _should_read_source)
 from pypaimon.read.query_auth_split import QueryAuthSplit
 from pypaimon.read.split import DataSplit
 from pypaimon.globalindex.indexed_split import IndexedSplit
@@ -187,11 +189,6 @@ def _bucket_splits(source_splits, entries):
     return result
 
 
-def _should_read_source(data_file):
-    # FileSource.COMPACT = 1. Match Java PrimaryKeyIndexSourcePolicy.
-    return data_file.file_source == 1 and data_file.level > 0
-
-
 def _residual_row_ranges(table, predicate, split, candidate_ranges):
     """Evaluate the residual predicate on physical rows before ANN search."""
     from pypaimon.read.push_down_utils import (
diff --git a/paimon-python/pypaimon/tests/primary_key_sorted_index_scan_test.py 
b/paimon-python/pypaimon/tests/primary_key_sorted_index_scan_test.py
index 791e319120..1eab053efd 100644
--- a/paimon-python/pypaimon/tests/primary_key_sorted_index_scan_test.py
+++ b/paimon-python/pypaimon/tests/primary_key_sorted_index_scan_test.py
@@ -28,6 +28,7 @@ from pypaimon.index.pk.primary_key_index_definition import (
     PrimaryKeyIndexDefinition, PrimaryKeyIndexFamily)
 from pypaimon.index.pk.primary_key_index_source_file import 
PrimaryKeyIndexSourceFile
 from pypaimon.index.pk.primary_key_index_source_meta import 
PrimaryKeyIndexSourceMeta
+from pypaimon.index.pksorted.pk_sorted_bucket_index_state import 
PkSortedBucketIndexState
 from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
 from pypaimon.read.split import DataSplit
 from pypaimon.schema.data_types import AtomicType, DataField
@@ -86,6 +87,110 @@ class PrimaryKeySortedIndexScanTest(unittest.TestCase):
         self.assertEqual([1], evaluated.files[0].result.results().to_list())
         self.assertEqual([1], evaluated.files[1].result.results().to_list())
 
+    def test_retired_source_offsets_and_new_source_falls_back(self):
+        field = DataField(3, "value", AtomicType("INT"))
+        definition = PrimaryKeyIndexDefinition(
+            "value", 3, "btree", Options.from_none(), 
PrimaryKeyIndexFamily.BTREE)
+        files = [
+            SimpleNamespace(file_name="b", row_count=3, level=1, 
file_source=1),
+            SimpleNamespace(file_name="c", row_count=4, level=1, 
file_source=1),
+        ]
+        partition = GenericRow([], [])
+        split = DataSplit(files, partition, 0, raw_convertible=True)
+        source_meta = PrimaryKeyIndexSourceMeta(
+            1, [PrimaryKeyIndexSourceFile("a", 2),
+                PrimaryKeyIndexSourceFile("b", 3)]).serialize()
+        payload = IndexFileMeta(
+            "btree", "index", 1, 5,
+            global_index_meta=GlobalIndexMeta(0, 4, 3, 
source_meta=source_meta))
+        planned = scan.plan(
+            9, [split], [definition],
+            [IndexManifestEntry(0, partition, 0, payload)])
+
+        self.assertEqual((PrimaryKeyIndexSourceFile("a", 2),
+                          PrimaryKeyIndexSourceFile("b", 3)),
+                         planned.files[0].groups[3].source_files)
+        self.assertNotIn(3, planned.files[1].groups)
+
+        created = []
+
+        def reader_factory(*ignored):
+            created.append(_Reader([Range(2, 2), Range(4, 4)]))
+            return created[-1]
+
+        evaluated = scan.evaluate(
+            planned, [field], PredicateBuilder([field]).equal("value", 1),
+            [definition], reader_factory)
+
+        self.assertEqual(1, len(created))
+        self.assertEqual([0, 2], evaluated.files[0].result.results().to_list())
+        self.assertIsNone(evaluated.files[1].result)
+
+    def test_source_at_another_level_does_not_inherit_group(self):
+        definition = PrimaryKeyIndexDefinition(
+            "value", 3, "btree", Options.from_none(), 
PrimaryKeyIndexFamily.BTREE)
+        files = [
+            SimpleNamespace(file_name="a", row_count=2, level=1, 
file_source=1),
+            SimpleNamespace(file_name="b", row_count=3, level=2, 
file_source=1),
+        ]
+        partition = GenericRow([], [])
+        split = DataSplit(files, partition, 0, raw_convertible=True)
+        source_meta = PrimaryKeyIndexSourceMeta(
+            1, [PrimaryKeyIndexSourceFile("a", 2),
+                PrimaryKeyIndexSourceFile("b", 3)]).serialize()
+        payload = IndexFileMeta(
+            "btree", "index", 1, 5,
+            global_index_meta=GlobalIndexMeta(0, 4, 3, 
source_meta=source_meta))
+
+        planned = scan.plan(
+            9, [split], [definition],
+            [IndexManifestEntry(0, partition, 0, payload)])
+
+        self.assertIn(3, planned.files[0].groups)
+        self.assertNotIn(3, planned.files[1].groups)
+
+    def test_bucket_state_keeps_invalid_payloads_uncovered(self):
+        active = SimpleNamespace(
+            file_name="a", row_count=2, level=1, file_source=1)
+
+        def payload(name, level, sources, source_meta=None):
+            row_count = sum(source.row_count for source in sources)
+            serialized = source_meta
+            if serialized is None:
+                serialized = PrimaryKeyIndexSourceMeta(level, 
sources).serialize()
+            return IndexFileMeta(
+                "btree", name, 1, row_count,
+                global_index_meta=GlobalIndexMeta(
+                    0, row_count - 1, 3, source_meta=serialized))
+
+        invalid_payloads = [
+            payload("wrong-level", 2, [PrimaryKeyIndexSourceFile("a", 2)]),
+            payload("no-active", 1, [PrimaryKeyIndexSourceFile("b", 2)]),
+            payload("wrong-row-count", 1, [PrimaryKeyIndexSourceFile("a", 3)]),
+            payload(
+                "misordered", 1,
+                [PrimaryKeyIndexSourceFile("b", 1),
+                 PrimaryKeyIndexSourceFile("a", 2)]),
+            payload(
+                "malformed", 1, [PrimaryKeyIndexSourceFile("a", 2)], b"\x00"),
+        ]
+        for invalid in invalid_payloads:
+            with self.subTest(payload=invalid.file_name):
+                state = PkSortedBucketIndexState.from_active_data_files(
+                    3, "btree", [active], [invalid])
+                self.assertFalse(state.groups)
+                self.assertEqual(
+                    (PrimaryKeyIndexSourceFile("a", 2),),
+                    state.uncovered_source_files)
+                self.assertEqual((invalid,), state.rejected_payloads)
+
+        first = payload("first", 1, [PrimaryKeyIndexSourceFile("a", 2)])
+        second = payload("second", 1, [PrimaryKeyIndexSourceFile("a", 2)])
+        duplicate_state = PkSortedBucketIndexState.from_active_data_files(
+            3, "btree", [active], [first, second])
+        self.assertFalse(duplicate_state.groups)
+        self.assertEqual((first, second), duplicate_state.rejected_payloads)
+
     def test_shared_result_is_partitioned_only_once(self):
         class CountingResult(GlobalIndexResult):
             def __init__(self):

Reply via email to