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 5d902e8e04 [Python] Use the common native global index builder (#10487)
5d902e8e04 is described below

commit 5d902e8e047f4e2e8fb1341647d52e21bb12761b
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 10 16:12:54 2026 +0800

    [Python] Use the common native global index builder (#10487)
---
 .../pypaimon/globalindex/create_global_index.py    |   6 +-
 .../pypaimon/globalindex/native_index_build.py     |  26 ++-
 .../tests/native_generic_index_build_test.py       | 207 +++++++++++++++++++++
 .../tests/native_sorted_index_build_test.py        |   8 +-
 4 files changed, 232 insertions(+), 15 deletions(-)

diff --git a/paimon-python/pypaimon/globalindex/create_global_index.py 
b/paimon-python/pypaimon/globalindex/create_global_index.py
index ec7fc24488..4ff50f8507 100644
--- a/paimon-python/pypaimon/globalindex/create_global_index.py
+++ b/paimon-python/pypaimon/globalindex/create_global_index.py
@@ -174,9 +174,9 @@ class GlobalIndexBuilder:
             concurrency, ray_remote_args = validate_build_options(
                 self, concurrency, ray_remote_args)
         partition_filter = self._resolve_partition_filter()
-        if execution == "local" and self._index_type in 
_SORTED_INDEX_IDENTIFIERS:
-            from pypaimon.globalindex.native_index_build import 
build_native_sorted_index
-            messages = build_native_sorted_index(self, partition_filter)
+        if execution == "local":
+            from pypaimon.globalindex.native_index_build import 
build_native_global_index
+            messages = build_native_global_index(self, partition_filter)
             if messages is not None:
                 return messages
         read_builder = self._table.new_read_builder()
diff --git a/paimon-python/pypaimon/globalindex/native_index_build.py 
b/paimon-python/pypaimon/globalindex/native_index_build.py
index 266ebac6ee..48ecf421e3 100644
--- a/paimon-python/pypaimon/globalindex/native_index_build.py
+++ b/paimon-python/pypaimon/globalindex/native_index_build.py
@@ -15,21 +15,23 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"""Local sorted-index builds delegated to Rust, retaining Python's commit 
API."""
+"""Local global-index builds delegated to Rust, retaining Python's commit 
API."""
 
 from pypaimon.read.native_plan import (
     _native_table, _option_value_to_string, _predicate_to_native, 
native_method_available)
 from pypaimon.write.native_commit import _rest_catalog_supported, 
from_native_commit_messages
 
 
-def build_native_sorted_index(builder, partition_filter):
+def build_native_global_index(builder, partition_filter):
     """Return unpublished messages, or None before building for an ineligible 
table.
 
-    Rust owns snapshot selection, range planning, sorting and file generation.
+    Rust owns snapshot selection, range planning and index file generation.
     Build errors propagate: a failed native build must not start a second 
build.
     """
     table = builder._table
-    if (builder._index_type not in ('btree', 'bitmap')
+    # The Rust vector pipeline requires dense, non-null vectors. Keep Python
+    # vector construction until it supports Java's sparse relative row IDs.
+    if (builder._index_type not in ('btree', 'bitmap', 'full-text')
             or not table.options.native_write_enabled()
             or not table.options.data_evolution_enabled()
             or not table.options.global_index_enabled()
@@ -37,13 +39,21 @@ def build_native_sorted_index(builder, partition_filter):
             or table.options.query_auth_enabled
             or table.is_primary_key_table
             or table.options.file_format() != 'parquet'
-            or not native_method_available('Table', 
'new_sorted_global_index_build_builder')
+            or not native_method_available('Table', 
'new_global_index_build_builder')
             or not _rest_catalog_supported(table)):
         return None
-    native = _native_table(table).new_sorted_global_index_build_builder()
+    from pypaimon_rust.datafusion import GlobalIndexBuildBuilder
+    if not GlobalIndexBuildBuilder.supports_index_type(builder._index_type):
+        return None
+    native = _native_table(table).new_global_index_build_builder()
     
native.with_index_column(builder._index_columns[0]).with_index_type(builder._index_type)
-    native.with_options({str(key): _option_value_to_string(value)
-                         for key, value in builder._user_options.items() if 
value is not None})
+    options = {str(key): _option_value_to_string(value)
+               for key, value in builder._user_options.items() if value is not 
None}
+    if builder._index_type == 'full-text':
+        from 
pypaimon.globalindex.full_text.native_full_text_global_index_reader import 
NativeFullTextIndexOptions
+        options.update({'full-text.' + key: value for key, value in
+                        
NativeFullTextIndexOptions.from_options(builder._options.to_map()).to_native_options().items()})
+    native.with_options(options)
     if partition_filter is not None:
         native.with_partition_filter(_predicate_to_native(partition_filter))
     return from_native_commit_messages(table, native.build())
diff --git a/paimon-python/pypaimon/tests/native_generic_index_build_test.py 
b/paimon-python/pypaimon/tests/native_generic_index_build_test.py
new file mode 100644
index 0000000000..b700ae53c5
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_generic_index_build_test.py
@@ -0,0 +1,207 @@
+# 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.
+
+"""The common Rust index builder prepares full-text and vector indexes over 
REST."""
+
+import struct
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.globalindex.create_global_index import GlobalIndexBuilder
+from pypaimon.read.native_plan import _native_table, _predicate_to_native, 
native_method_available
+from pypaimon.tests import native_sorted_index_build_test as index_tests
+from pypaimon.write.native_commit import from_native_commit_messages
+
+rest_catalog = index_tests.rest_catalog
+pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
+    not native_method_available('Table', 'new_global_index_build_builder'),
+    reason='Common Rust global index builder required')]
+
+KINDS = ['full-text', 'ivf-flat', 'ivf-pq', 'ivf-sq', 'ivf-rq', 'diskann']
+SCHEMA = pa.schema([('id', pa.int32()), ('name', pa.string()),
+                    ('embedding', pa.list_(pa.float32(), 2)), ('pt', 
pa.int32())])
+ROWS = [{'id': i, 'name': 'paimon lake' if i % 2 == 0 else None,
+         'embedding': [float(i % 4), float(i // 4)], 'pt': i // 16} for i in 
range(32)]
+
+
+def _options(kind, rows_per_shard=100):
+    options = {'global-index.row-count-per-shard': str(rows_per_shard)}
+    if kind != 'full-text':
+        options[kind + '.dimension'] = '2'
+        if kind != 'diskann':
+            options[kind + '.nlist'] = '1'
+        if kind == 'ivf-pq':
+            options['ivf-pq.pq.m'] = '1'
+        if kind == 'diskann':
+            options['diskann.pq.bits'] = '4'
+    return options
+
+
+def _table(catalog, options=None):
+    settings = {'vector-index.search-mode': 'fast', 
'full-text-index.search-mode': 'fast'}
+    settings.update(options or {})
+    table = index_tests._create(catalog, schema=SCHEMA, partitioned=True, 
options=settings)
+    index_tests._append(table, ROWS, schema=SCHEMA)
+    return table
+
+
+def _builder(table, kind, rows_per_shard=100):
+    return (_native_table(table).new_global_index_build_builder()
+            .with_index_column('name' if kind == 'full-text' else 'embedding')
+            .with_index_type(' ' + kind.upper() + ' ')
+            .with_options(_options(kind, rows_per_shard)))
+
+
+def _build(table, builder):
+    return from_native_commit_messages(table, builder.build())
+
+
[email protected]('kind', KINDS)
[email protected]('native_commit', [False, True])
+def test_prepare_partition_then_commit_and_query(rest_catalog, kind, 
native_commit):
+    catalog, _ = rest_catalog
+    table = _table(catalog, {'commit.native.enabled': 
str(native_commit).lower()})
+    predicate = table.new_read_builder().new_predicate_builder()
+    builder = _builder(table, kind, rows_per_shard=4)
+    
builder.with_partition_filter(_predicate_to_native(predicate.greater_or_equal('pt',
 0)))
+    builder.with_partition_filter(_predicate_to_native(predicate.equal('pt', 
1)))
+    before = table.snapshot_manager().get_latest_snapshot().id
+    messages = _build(table, builder)
+    files = index_tests._message_files(messages)
+    assert table.snapshot_manager().get_latest_snapshot().id == before
+    assert not index_tests._files(table)
+    assert len(files) == 4
+    assert {message.partition for message in messages} == {(1,)}
+    assert {file.index_type for file in files} == {kind}
+    assert sum(file.row_count for file in files) == 16
+    assert all(struct.unpack('>iiq', file.global_index_meta.source_meta)
+               == (0x44454958, 1, before) for file in files)
+    index_tests._commit(table, messages)
+    assert table.snapshot_manager().get_latest_snapshot().id == before + 1
+    assert _build(table, builder) == []
+    classic = table.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'})
+    if kind == 'full-text':
+        result = (classic.new_full_text_search_builder().with_query('name', 
'{"match":{"query":"paimon"}}')
+                  .with_partition_filter(predicate.equal('pt', 
1)).with_limit(64).execute_local())
+        assert sorted(result.results()) == list(range(16, 32, 2))
+    else:
+        result = 
(classic.new_vector_search_builder().with_vector_column('embedding')
+                  .with_query_vector(ROWS[16]['embedding']).with_limit(1)
+                  .with_partition_filter(predicate.equal('pt', 
1)).execute_local())
+        assert len(list(result.results())) == 1
+        assert 16 <= list(result.results())[0] < 32
+
+
[email protected]('kind', KINDS)
+def test_all_families_use_external_index_placement(rest_catalog, kind, 
tmp_path):
+    catalog, _ = rest_catalog
+    external = (tmp_path / 'indexes').as_uri()
+    table = _table(catalog, {'global-index.external-path': external})
+    messages = _build(table, _builder(table, kind))
+    assert messages
+    for file in index_tests._message_files(messages):
+        assert file.external_path.startswith(external + '/')
+        assert table.file_io.exists(file.external_path)
+    index_tests._commit(table, messages)
+    assert len(index_tests._files(table)) == 2
+
+
[email protected]('kind', ['full-text', 'ivf-flat'])
[email protected]('native_commit', [False, True])
+def 
test_indexed_column_changes_reject_messages_and_preserve_files(rest_catalog, 
kind, native_commit):
+    catalog, _ = rest_catalog
+    table = _table(catalog, {'commit.native.enabled': 
str(native_commit).lower()})
+    messages = _build(table, _builder(table, kind))
+    column = 'name' if kind == 'full-text' else 'embedding'
+    changed = 'changed' if kind == 'full-text' else [99., 99.]
+    data = pa.table({'_ROW_ID': pa.array([0], pa.int64()),
+                     column: pa.array([changed], SCHEMA.field(column).type)})
+    update = 
table.new_batch_write_builder().new_update().update_by_arrow_with_row_id(data)
+    index_tests._commit(table, update)
+    before = table.snapshot_manager().get_latest_snapshot().id
+    with pytest.raises(Exception, match='Global index source conflict'):
+        index_tests._commit(table, messages)
+    assert table.snapshot_manager().get_latest_snapshot().id == before
+    assert all(table.file_io.exists(index_tests._path(table, file))
+               for file in index_tests._message_files(messages))
+
+
+def test_python_full_text_build_delegates_to_common_native_entry(rest_catalog):
+    catalog, _ = rest_catalog
+    table = _table(catalog)
+    predicate = table.new_read_builder().new_predicate_builder().equal('pt', 1)
+    with patch.object(GlobalIndexBuilder, '_build_generic_index',
+                      side_effect=AssertionError('Python index build ran')):
+        messages = GlobalIndexBuilder(table, 'name', index_type='full-text',
+                                      partition_filter=predicate, 
options=_options('full-text', 4)).build()
+    assert len(index_tests._message_files(messages)) == 4
+    assert {message.partition for message in messages} == {(1,)}
+    index_tests._commit(table, messages)
+
+
+def test_python_nullable_vector_build_keeps_sparse_row_ids(rest_catalog):
+    catalog, _ = rest_catalog
+    schema = pa.schema([('id', pa.int32()), ('name', pa.string()),
+                        ('embedding', pa.list_(pa.float32())), ('pt', 
pa.int32())])
+    table = index_tests._create(catalog, schema=schema)
+    rows = [dict(ROWS[0]), dict(ROWS[1]), dict(ROWS[2])]
+    rows[1]['embedding'] = None
+    index_tests._append(table, rows, schema=schema)
+    with patch.object(GlobalIndexBuilder, '_build_generic_index',
+                      wraps=GlobalIndexBuilder(table, 'embedding', 
index_type='ivf-flat',
+                                               
options=_options('ivf-flat'))._build_generic_index) as build:
+        messages = GlobalIndexBuilder(table, 'embedding', 
index_type='ivf-flat',
+                                      options=_options('ivf-flat')).build()
+    assert build.call_count == 1
+    assert sum(file.row_count for file in 
index_tests._message_files(messages)) == 3
+    index_tests._commit(table, messages)
+
+
[email protected]('option_source', ['build', 'table'])
[email protected]('stop_words', [['paimon', 'apache'], 'paimon;apache'])
+def test_native_full_text_options_match_python_list_encoding(rest_catalog, 
option_source, stop_words):
+    import json
+    from pypaimon.globalindex.full_text.native_full_text_global_index_reader 
import NativeFullTextIndexOptions
+
+    catalog, _ = rest_catalog
+    options = {'full-text.stop-words': stop_words, 'full-text.with-position': 
True}
+    table_options = {'full-text.' + key: value for key, value in
+                     
NativeFullTextIndexOptions.from_options(options).to_native_options().items()}
+    table = _table(catalog, table_options if option_source == 'table' else 
None)
+    messages = GlobalIndexBuilder(table, 'name', index_type='full-text',
+                                  options=options if option_source == 'build' 
else {}).build()
+    native_meta = 
[json.loads(file.global_index_meta.index_meta.decode('utf-8'))
+                   for file in index_tests._message_files(messages)]
+    assert all(meta['stop-words'] == 'paimon;apache' and meta['with-position'] 
== 'true'
+               for meta in native_meta)
+    index_tests._commit(table, messages)
+    query = '{"match":{"query":"paimon"}}'
+    result = table.new_full_text_search_builder().with_query('name', 
query).with_limit(64).execute_local()
+    assert not list(result.results())
+    # Rebuild through Python's original writer and compare serialized options 
and results.
+    classic = table.copy({'write.native.enabled': 'false'})
+    classic.drop_global_index('name', index_type='full-text')
+    messages = GlobalIndexBuilder(classic, 'name', index_type='full-text',
+                                  options=options if option_source == 'build' 
else {}).build()
+    classic_meta = 
[NativeFullTextIndexOptions.deserialize(file.global_index_meta.index_meta).to_native_options()
+                    for file in index_tests._message_files(messages)]
+    assert classic_meta == native_meta
+    index_tests._commit(classic, messages)
+    result = classic.new_full_text_search_builder().with_query('name', 
query).with_limit(64).execute_local()
+    assert not list(result.results())
diff --git a/paimon-python/pypaimon/tests/native_sorted_index_build_test.py 
b/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
index b56ec60992..b2849ee60e 100644
--- a/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
+++ b/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
@@ -38,7 +38,7 @@ from pypaimon.write.table_commit import BatchTableCommit
 
 rest_catalog = native_plan_rest_test.rest_catalog
 pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
-    not native_method_available('Table', 
'new_sorted_global_index_build_builder'),
+    not native_method_available('Table', 'new_global_index_build_builder'),
     reason='Rust index builder required')]
 
 SCHEMA = pa.schema([('id', pa.int32()), ('name', pa.string()), ('pt', 
pa.int32())])
@@ -258,7 +258,7 @@ def 
test_binding_partition_conjunction_and_rejection_are_core_semantics(rest_cat
     table = _create(catalog, partitioned=True)
     _append(table, ROWS)
     predicates = table.new_read_builder().new_predicate_builder()
-    native = 
_native_table(table).new_sorted_global_index_build_builder().with_index_column('id').with_index_type(kind)
+    native = 
_native_table(table).new_global_index_build_builder().with_index_column('id').with_index_type(kind)
     # Python partition predicates carry partition-row indices; bindings 
resolve by field name.
     native.with_partition_filter(_predicate_to_native(predicates.equal('pt', 
0)))
     native.with_partition_filter(_predicate_to_native(predicates.equal('pt', 
1)))
@@ -275,7 +275,7 @@ def test_empty_table_build_is_a_noop(rest_catalog, kind):
     table = _create(catalog)
     assert _build(table, kind) == []
     assert table.create_global_index('name', index_type=kind) == 0
-    assert 
_native_table(table).new_sorted_global_index_build_builder().with_index_column('name').execute()
 == 0
+    assert 
_native_table(table).new_global_index_build_builder().with_index_column('name').execute()
 == 0
     assert table.snapshot_manager().get_latest_snapshot() is None
 
 
@@ -284,7 +284,7 @@ def 
test_native_binding_can_build_and_execute_separate_indexes(rest_catalog):
     table = _create(catalog)
     _append(table, ROWS)
     native = _native_table(table)
-    builder = 
native.new_sorted_global_index_build_builder().with_index_column('name')
+    builder = native.new_global_index_build_builder().with_index_column('name')
     prepared = builder.build()
     assert all(message.serialize() for message in prepared)
     _commit(table, from_native_commit_messages(table, prepared))

Reply via email to