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 b7e9447ca8 [python] Allow distinct global index definitions to coexist 
(#10664)
b7e9447ca8 is described below

commit b7e9447ca897b14512ee65ea21fb8ed4ea22f17d
Author: QuakeWang <[email protected]>
AuthorDate: Tue Oct 20 15:13:30 2026 +0800

    [python] Allow distinct global index definitions to coexist (#10664)
---
 .../pypaimon/manifest/index_manifest_file.py       |   5 +-
 .../tests/index_manifest_definition_test.py        | 224 +++++++++++++++++++++
 2 files changed, 227 insertions(+), 2 deletions(-)

diff --git a/paimon-python/pypaimon/manifest/index_manifest_file.py 
b/paimon-python/pypaimon/manifest/index_manifest_file.py
index a19c196f50..7b587cc553 100644
--- a/paimon-python/pypaimon/manifest/index_manifest_file.py
+++ b/paimon-python/pypaimon/manifest/index_manifest_file.py
@@ -404,9 +404,10 @@ def _is_global_index(index_type: str) -> bool:
 
 
 def _can_keep_existing_global_index(retained_meta, added_meta) -> bool:
+    # The caller has already matched the index type and primary field.
     return (
-        _extra_field_ids(retained_meta) == _extra_field_ids(added_meta)
-        and not _row_ranges_intersect(retained_meta, added_meta)
+        _extra_field_ids(retained_meta) != _extra_field_ids(added_meta)
+        or not _row_ranges_intersect(retained_meta, added_meta)
     )
 
 
diff --git a/paimon-python/pypaimon/tests/index_manifest_definition_test.py 
b/paimon-python/pypaimon/tests/index_manifest_definition_test.py
new file mode 100644
index 0000000000..3b75239fe6
--- /dev/null
+++ b/paimon-python/pypaimon/tests/index_manifest_definition_test.py
@@ -0,0 +1,224 @@
+# 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 os
+import shutil
+import tempfile
+import unittest
+
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
+from pypaimon.index.data_evolution_index_source_meta import 
DataEvolutionIndexSourceMeta
+from pypaimon.index.index_file_meta import IndexFileMeta
+from pypaimon.index.pk.primary_key_index_source_file import 
PrimaryKeyIndexSourceFile
+from pypaimon.index.pk.primary_key_index_source_meta import 
PrimaryKeyIndexSourceMeta
+from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+from pypaimon.manifest.index_manifest_file import IndexManifestFile
+from pypaimon.table.row.generic_row import GenericRow
+
+
+class IndexManifestDefinitionTest(unittest.TestCase):
+
+    def setUp(self):
+        self.tempdir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, self.tempdir, ignore_errors=True)
+        catalog = CatalogFactory.create({'warehouse': 
os.path.join(self.tempdir, 'warehouse')})
+        catalog.create_database('default', True)
+        schema = Schema.from_pyarrow_schema(pa.schema([
+            ('a', pa.int32()), ('b', pa.int32()), ('c', pa.int32()),
+        ]))
+        catalog.create_table('default.index_definitions', schema, False)
+        self.table = catalog.get_table('default.index_definitions')
+        self.manifest = IndexManifestFile(self.table)
+        self.a, self.b, self.c = [self.table.field_dict[name].id for name in 
('a', 'b', 'c')]
+
+    def _entry(self, name, fields, start=0, end=99, index_type='btree', 
empty_extra=False):
+        return IndexManifestEntry(
+            kind=0,
+            partition=GenericRow([], []),
+            bucket=0,
+            index_file=IndexFileMeta(
+                index_type=index_type,
+                file_name=name,
+                file_size=1,
+                row_count=end - start + 1,
+                global_index_meta=GlobalIndexMeta(
+                    row_range_start=start,
+                    row_range_end=end,
+                    index_field_id=fields[0],
+                    extra_field_ids=list(fields[1:]) if len(fields) > 1 or 
empty_extra else None,
+                ),
+            ),
+        )
+
+    def _assert_entries(self, name, expected):
+        entries = self.manifest.read(name)
+        self.assertEqual(len(expected), len(entries))
+        actual = {entry.index_file.file_name: entry for entry in entries}
+        self.assertEqual({entry.index_file.file_name for entry in expected}, 
set(actual))
+        for entry in expected:
+            restored = actual[entry.index_file.file_name]
+            self.assertEqual(entry, restored)
+            self.assertEqual(entry.index_file.global_index_meta,
+                             restored.index_file.global_index_meta)
+
+    def test_different_ordered_definitions_coexist(self):
+        definitions = [
+            ((self.a,), (self.a, self.b)),
+            ((self.a, self.b), (self.a, self.c)),
+            ((self.a, self.b), (self.a, self.b, self.c)),
+            ((self.a, self.b, self.c), (self.a, self.c, self.b)),
+            ((self.a, self.b), (self.b, self.a)),
+        ]
+        for index_type in ('btree', 'test-vector-ann', 'test-fulltext'):
+            for old_fields, new_fields in definitions:
+                with self.subTest(index_type=index_type, old=old_fields, 
new=new_fields):
+                    old = self._entry('old', old_fields, index_type=index_type)
+                    new = self._entry('new', new_fields, index_type=index_type)
+                    previous = self.manifest.write([old])
+                    current = self.manifest.combine_changes(previous, [new], 
[])
+                    self._assert_entries(current, [old, new])
+
+    def test_different_definitions_with_disjoint_ranges_coexist(self):
+        old = self._entry('scalar', (self.a,))
+        new = self._entry('pair', (self.a, self.b), start=100, end=199)
+        previous = self.manifest.write([old])
+        current = self.manifest.combine_changes(previous, [new], [])
+        self._assert_entries(current, [old, new])
+
+    def test_scalar_extension_preserves_existing_composite_definition(self):
+        scalar = self._entry('scalar', (self.a,))
+        composite = self._entry('composite', (self.a, self.b))
+        extension = self._entry('extension', (self.a,), start=100, end=199)
+        # Seed the valid coexistence state produced by the Java manifest 
combiner.
+        previous = self.manifest.write([scalar, composite])
+        self._assert_entries(previous, [scalar, composite])
+        current = self.manifest.combine_changes(previous, [extension], [])
+        self._assert_entries(current, [scalar, composite, extension])
+
+    def test_same_definition_overlap_is_rejected(self):
+        old = self._entry('old', (self.a, self.b))
+        previous = self.manifest.write([old])
+        for start in (50, 99):
+            with self.subTest(start=start):
+                new = self._entry('new', (self.a, self.b), start=start, 
end=149)
+                with self.assertRaisesRegex(RuntimeError, 'Remove the previous 
file first'):
+                    self.manifest.combine_changes(previous, [new], [])
+                self._assert_entries(previous, [old])
+
+    def test_same_definition_disjoint_ranges_coexist(self):
+        old = self._entry('old', (self.a, self.b))
+        new = self._entry('new', (self.a, self.b), start=100, end=199)
+        previous = self.manifest.write([old])
+        current = self.manifest.combine_changes(previous, [new], [])
+        self._assert_entries(current, [old, new])
+
+    def test_delete_before_replacement(self):
+        old = self._entry('old', (self.a, self.b))
+        new = self._entry('new', (self.a, self.b), start=50, end=149)
+        previous = self.manifest.write([old])
+        deleted = IndexManifestEntry(1, old.partition, old.bucket, 
old.index_file)
+        current = self.manifest.combine_changes(previous, [new], [deleted])
+        self._assert_entries(current, [new])
+        self._assert_entries(previous, [old])
+
+    def test_replacing_one_definition_preserves_other_definitions(self):
+        for index_type in ('btree', 'test-vector-ann', 'test-fulltext'):
+            with self.subTest(index_type=index_type):
+                definitions = [
+                    self._entry('single', (self.a,), index_type=index_type),
+                    self._entry('pair', (self.a, self.b), 
index_type=index_type),
+                    self._entry('other-pair', (self.a, self.c), 
index_type=index_type),
+                    self._entry('triple', (self.a, self.b, self.c), 
index_type=index_type),
+                    self._entry('reordered', (self.a, self.c, self.b), 
index_type=index_type),
+                ]
+                previous = None
+                for definition in definitions:
+                    previous = self.manifest.combine_changes(previous, 
[definition], [])
+                self._assert_entries(previous, definitions)
+
+                replacement = self._entry('replacement', (self.a, self.b), 
start=50, end=149,
+                                          index_type=index_type)
+                with self.assertRaisesRegex(RuntimeError, 'previous file pair 
still exists'):
+                    self.manifest.combine_changes(previous, [replacement], [])
+                old = definitions[1]
+                deleted = IndexManifestEntry(1, old.partition, old.bucket, 
old.index_file)
+                current = self.manifest.combine_changes(previous, 
[replacement], [deleted])
+                self._assert_entries(current, [definitions[0], replacement] + 
definitions[2:])
+                self._assert_entries(previous, definitions)
+
+    def 
test_source_metadata_is_preserved_without_disabling_overlap_validation(self):
+        sources = [
+            (DataEvolutionIndexSourceMeta(1).serialize(),
+             DataEvolutionIndexSourceMeta(2).serialize()),
+            (PrimaryKeyIndexSourceMeta(1, 
[PrimaryKeyIndexSourceFile('old.parquet', 100)]).serialize(),
+             PrimaryKeyIndexSourceMeta(1, 
[PrimaryKeyIndexSourceFile('new.parquet', 100)]).serialize()),
+        ]
+        for old_source, new_source in sources:
+            with self.subTest(old_source=old_source):
+                old = self._entry('old', (self.a,))
+                old.index_file.global_index_meta.source_meta = old_source
+                old.index_file.global_index_meta.index_meta = b'old-index-meta'
+                previous = self.manifest.write([old])
+                replacement = self._entry('replacement', (self.a,))
+                replacement.index_file.global_index_meta.source_meta = 
new_source
+                replacement.index_file.global_index_meta.index_meta = 
b'new-index-meta'
+                with self.assertRaisesRegex(RuntimeError, 'Remove the previous 
file first'):
+                    self.manifest.combine_changes(previous, [replacement], [])
+
+                other = self._entry('other', (self.a, self.b))
+                other.index_file.global_index_meta.source_meta = new_source
+                other.index_file.global_index_meta.index_meta = 
b'other-index-meta'
+                current = self.manifest.combine_changes(previous, [other], [])
+                self._assert_entries(current, [old, other])
+                self._assert_entries(previous, [old])
+
+    def test_null_and_empty_extra_fields_are_the_same_definition(self):
+        for empty_extra in (False, True):
+            with self.subTest(empty_extra=empty_extra):
+                old = self._entry('old', (self.a,), empty_extra=empty_extra)
+                previous = self.manifest.write([old])
+                overlap = self._entry('overlap', (self.a,), empty_extra=not 
empty_extra)
+                with self.assertRaisesRegex(RuntimeError, 'Remove the previous 
file first'):
+                    self.manifest.combine_changes(previous, [overlap], [])
+                tail = self._entry('tail', (self.a,), start=100, end=199,
+                                   empty_extra=not empty_extra)
+                current = self.manifest.combine_changes(previous, [tail], [])
+                self._assert_entries(current, [old, tail])
+
+    def test_different_types_coexist(self):
+        old = self._entry('old', (self.a,))
+        new = self._entry('new', (self.a,), index_type='bitmap')
+        previous = self.manifest.write([old])
+        current = self.manifest.combine_changes(previous, [new], [])
+        self._assert_entries(current, [old, new])
+
+    def test_delete_only_and_no_changes_preserve_other_definitions(self):
+        scalar = self._entry('scalar', (self.a,))
+        composite = self._entry('composite', (self.a, self.b))
+        previous = self.manifest.write([scalar, composite])
+        self.assertEqual(previous, self.manifest.combine_changes(previous, [], 
[]))
+        deleted = IndexManifestEntry(1, scalar.partition, scalar.bucket, 
scalar.index_file)
+        current = self.manifest.combine_changes(previous, [], [deleted])
+        self._assert_entries(current, [composite])
+        self._assert_entries(previous, [scalar, composite])
+
+
+if __name__ == '__main__':
+    unittest.main()

Reply via email to