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 d0214e51a7 [python] Use native builders for local sorted global
indexes (#10482)
d0214e51a7 is described below
commit d0214e51a7fda788c26755727e0dd982d1d5260c
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 10 15:05:26 2026 +0800
[python] Use native builders for local sorted global indexes (#10482)
---
.../pypaimon/globalindex/create_global_index.py | 7 +-
.../pypaimon/globalindex/native_index_build.py | 49 +++
.../index/data_evolution_index_source_meta.py | 48 ++
paimon-python/pypaimon/read/table_read.py | 3 +-
paimon-python/pypaimon/tests/act_runner_test.py | 28 +-
.../pypaimon/tests/batch_vector_lookup_test.py | 17 +-
.../tests/data_evolution_row_rolling_test.py | 8 +-
paimon-python/pypaimon/tests/native_read_test.py | 9 +-
.../tests/native_sorted_index_build_test.py | 488 +++++++++++++++++++++
paimon-python/pypaimon/tests/native_write_test.py | 58 +++
.../pypaimon/write/commit/conflict_detection.py | 6 +
.../write/commit/global_index_source_check.py | 82 ++++
12 files changed, 775 insertions(+), 28 deletions(-)
diff --git a/paimon-python/pypaimon/globalindex/create_global_index.py
b/paimon-python/pypaimon/globalindex/create_global_index.py
index 569f2f8861..ec7fc24488 100644
--- a/paimon-python/pypaimon/globalindex/create_global_index.py
+++ b/paimon-python/pypaimon/globalindex/create_global_index.py
@@ -173,8 +173,13 @@ class GlobalIndexBuilder:
from pypaimon.ray.vector_index_build import validate_build_options
concurrency, ray_remote_args = validate_build_options(
self, concurrency, ray_remote_args)
- read_builder = self._table.new_read_builder()
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 messages is not None:
+ return messages
+ read_builder = self._table.new_read_builder()
if partition_filter is not None:
read_builder = read_builder.with_partition_filter(partition_filter)
diff --git a/paimon-python/pypaimon/globalindex/native_index_build.py
b/paimon-python/pypaimon/globalindex/native_index_build.py
new file mode 100644
index 0000000000..266ebac6ee
--- /dev/null
+++ b/paimon-python/pypaimon/globalindex/native_index_build.py
@@ -0,0 +1,49 @@
+# 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.
+
+"""Local sorted-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):
+ """Return unpublished messages, or None before building for an ineligible
table.
+
+ Rust owns snapshot selection, range planning, sorting and 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')
+ or not table.options.native_write_enabled()
+ or not table.options.data_evolution_enabled()
+ or not table.options.global_index_enabled()
+ or table.options.deletion_vectors_enabled()
+ 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 _rest_catalog_supported(table)):
+ return None
+ native = _native_table(table).new_sorted_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})
+ 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/index/data_evolution_index_source_meta.py
b/paimon-python/pypaimon/index/data_evolution_index_source_meta.py
new file mode 100644
index 0000000000..5bdd7d77f0
--- /dev/null
+++ b/paimon-python/pypaimon/index/data_evolution_index_source_meta.py
@@ -0,0 +1,48 @@
+# 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.
+
+"""Java DataEvolutionIndexSourceMeta stored in GlobalIndexMeta.source_meta."""
+
+import struct
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class DataEvolutionIndexSourceMeta:
+ scan_snapshot_id: int
+
+ MAGIC = 0x44454958
+ VERSION = 1
+
+ def __post_init__(self):
+ if self.scan_snapshot_id <= 0:
+ raise ValueError('Scan snapshot id must be positive.')
+
+ def serialize(self):
+ return struct.pack('>iiq', self.MAGIC, self.VERSION,
self.scan_snapshot_id)
+
+ @classmethod
+ def is_data_evolution_meta(cls, data):
+ return data is not None and len(data) >= 4 and data[:4] == b'DEIX'
+
+ @classmethod
+ def deserialize(cls, data):
+ if len(data) != 16 or not cls.is_data_evolution_meta(data):
+ raise ValueError('Invalid data-evolution index source metadata.')
+ _, version, snapshot_id = struct.unpack('>iiq', data)
+ if version != cls.VERSION:
+ raise ValueError('Unsupported data-evolution index source version:
{}.'.format(version))
+ return cls(snapshot_id)
diff --git a/paimon-python/pypaimon/read/table_read.py
b/paimon-python/pypaimon/read/table_read.py
index e0e683d973..b0b103be4a 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -791,8 +791,7 @@ class TableRead:
def _native_split_files_supported(split):
for data_file in split.files:
file_name = data_file.file_name.lower()
- if ('.vector.' not in file_name
- and not file_name.endswith(_NATIVE_READ_FILE_SUFFIXES)
+ if (not file_name.endswith(_NATIVE_READ_FILE_SUFFIXES)
and not file_name.endswith(_NATIVE_BLOB_FILE_SUFFIXES)):
return False
return True
diff --git a/paimon-python/pypaimon/tests/act_runner_test.py
b/paimon-python/pypaimon/tests/act_runner_test.py
index 6e85e51690..e0f3a5c5b5 100644
--- a/paimon-python/pypaimon/tests/act_runner_test.py
+++ b/paimon-python/pypaimon/tests/act_runner_test.py
@@ -258,6 +258,15 @@ def benchmark_input(tmp_path, monkeypatch):
return root, warehouse
+def _frame_for_append(frames, predicate):
+ # A full-row append must retain all required Blob fields, including depth
+ # images outside the RGB-only ACT input projection.
+ scalar, blobs = frames.scan().where(predicate).read_blobs()
+ row = scalar.to_pylist()[0]
+ row.update({name: values[0] for name, values in blobs.items()})
+ return row
+
+
class _Policy(torch.nn.Module):
def __init__(self):
@@ -630,15 +639,10 @@ def
test_paimon_windows_are_lazy_snapshot_pinned_and_vortex_independent(
for name in IMAGE_COLUMNS
} == {name: 1 for name in IMAGE_COLUMNS}
- scalar, blobs = frames.scan().where(
- "episode_id = 'train-a' AND frame_index = 5"
- ).read_blobs(IMAGE_COLUMNS)
- appended = scalar.to_pylist()[0]
+ appended = _frame_for_append(frames, "episode_id = 'train-a' AND
frame_index = 5")
appended["frame_index"] = 6
appended["index"] = max(
row["index"] for row in frames.scan().select(["index"]).to_list()) + 1
- for name in IMAGE_COLUMNS:
- appended[name] = blobs[name][0]
frames.add([appended])
assert train.snapshot_id == snapshot_id
@@ -749,12 +753,8 @@ def
test_preparation_uses_published_group_after_new_episode_commit(
changed["split"] = "test"
episodes.add([changed])
frames = connection.get_table(agilex.FRAMES_TABLE)
- scalar, blobs = frames.scan().where(
- "episode_id = 'train-a' AND frame_index = 0").read_blobs(IMAGE_COLUMNS)
- changed_frame = scalar.to_pylist()[0]
+ changed_frame = _frame_for_append(frames, "episode_id = 'train-a' AND
frame_index = 0")
changed_frame["action"] = [0.0] * 14
- for name in IMAGE_COLUMNS:
- changed_frame[name] = blobs[name][0]
frames.add([changed_frame])
assert latest_snapshot_id(frames) != before["paimon"]["frames_snapshot_id"]
@@ -810,12 +810,8 @@ def
test_dense_comparison_rejects_selected_episode_with_invalid_frames(
options={"warehouse": str(warehouse)})
frames = connection.get_table(agilex.FRAMES_TABLE)
indices = episode_indices(connection, "act-test@1")
- scalar, blobs = frames.scan().where(
- "episode_id = 'val-a' AND frame_index = 0").read_blobs(IMAGE_COLUMNS)
- invalid = scalar.to_pylist()[0]
+ invalid = _frame_for_append(frames, "episode_id = 'val-a' AND frame_index
= 0")
invalid["quality_status"] = 2
- for name in IMAGE_COLUMNS:
- invalid[name] = blobs[name][0]
frames.add([invalid])
frames.raw_table.create_tag("invalid-frames")
diff --git a/paimon-python/pypaimon/tests/batch_vector_lookup_test.py
b/paimon-python/pypaimon/tests/batch_vector_lookup_test.py
index b849fa1a64..229c9cf3a5 100644
--- a/paimon-python/pypaimon/tests/batch_vector_lookup_test.py
+++ b/paimon-python/pypaimon/tests/batch_vector_lookup_test.py
@@ -77,7 +77,9 @@ def test_batch_lookup_matches_individual_queries(docs,
projection, with_row_id):
assert len(actual) == len(expected)
for left, right in zip(expected, actual):
assert left.schema == right.schema
- assert left.to_pylist() == right.to_pylist()
+ # Unordered lookups may visit partition files in different orders.
+ # Keep duplicates and compare all projected values, including Blobs.
+ assert sorted(map(repr, left.to_pylist())) == sorted(map(repr,
right.to_pylist()))
if projection == [] and not with_row_id:
assert right.num_columns == 0
assert right.num_rows == 3
@@ -158,11 +160,20 @@ def test_single_query_keeps_single_lookup(docs):
assert result[0].equals(expected)
-def test_unsupported_nested_row_projection_still_raises(docs):
[email protected]_plan
[email protected]_read
+def test_python_nested_row_projection_still_raises(docs):
with pytest.raises(NotImplementedError, match="ROW nested-field
projection"):
docs.search_vectors([[0.0, 0.0], [1.0,
0.0]]).select(["info.label"]).to_arrow()
[email protected]_plan
+def test_native_nested_row_projection(docs):
+ docs.raw_table = docs.raw_table.copy({'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true'})
+ results = docs.search_vectors([[0.0, 0.0], [1.0,
0.0]]).select(["info.label"]).limit(1).to_arrow()
+ assert [result.to_pylist() for result in results] == [[{'info_label':
'0'}], [{'info_label': '1'}]]
+
+
def test_native_index_batch_uses_shared_lookup(docs):
pytest.importorskip("paimon_vindex")
docs.raw_table.copy({"deletion-vectors.enabled":
"false"}).create_global_index(
@@ -180,5 +191,5 @@ def test_native_index_batch_uses_shared_lookup(docs):
with patch.object(ScanQuery, "_read_global_index_result", read):
actual = query.to_list()
- assert actual == expected
+ assert [sorted(map(repr, rows)) for rows in actual] == [sorted(map(repr,
rows)) for rows in expected]
assert calls == [6]
diff --git a/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
index e37dac3d00..e38d05cffa 100644
--- a/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
@@ -646,7 +646,9 @@ class DataEvolutionRowRollingTest(unittest.TestCase):
'vector.file.format': 'parquet',
})
- files = self._write_files(table, self._vector_rows(7))
+ # Native writers check the row limit at Arrow batch boundaries.
+ data =
pa.Table.from_batches(self._vector_rows(7).to_batches(max_chunksize=3))
+ files = self._write_files(table, data)
data_rows = sorted(
f.row_count for f in files
@@ -667,7 +669,9 @@ class DataEvolutionRowRollingTest(unittest.TestCase):
'vector.file.format': 'parquet',
})
- files = self._write_files(table, self._blob_vector_rows(7))
+ # Keep all dedicated writers aligned while rolling between batches.
+ data =
pa.Table.from_batches(self._blob_vector_rows(7).to_batches(max_chunksize=3))
+ files = self._write_files(table, data)
data_rows = sorted(
f.row_count for f in files
diff --git a/paimon-python/pypaimon/tests/native_read_test.py
b/paimon-python/pypaimon/tests/native_read_test.py
index 3e117cb0a0..094eb89aaf 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -951,10 +951,11 @@ def test_native_avro_read_uses_native_path():
native.assert_called_once()
-def test_native_read_falls_back_for_unsupported_dedicated_file():
[email protected]('file_name', ['camera.unsupported',
'data.vector.lance', 'data.vector.vortex'])
+def test_native_read_falls_back_for_unsupported_dedicated_file(file_name):
read = _table_read()
schema = pa.schema([('id', pa.int32())])
- split = _Split('camera.unsupported')
+ split = _Split(file_name)
split._native_split = object()
with patch('pypaimon.read.native_plan.native_read') as native:
@@ -963,8 +964,8 @@ def
test_native_read_falls_back_for_unsupported_dedicated_file():
native.assert_not_called()
[email protected]('file_name', ['picture.blob', 'camera.video'])
-def
test_native_read_supports_blob_and_video_files_and_forwards_parallelism(file_name):
[email protected]('file_name', ['picture.blob', 'camera.video',
'data.vector.parquet'])
+def
test_native_read_supports_dedicated_files_and_forwards_parallelism(file_name):
read = _table_read()
schema = pa.schema([('id', pa.int32())])
split = _Split(file_name)
diff --git a/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
b/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
new file mode 100644
index 0000000000..b56ec60992
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_sorted_index_build_test.py
@@ -0,0 +1,488 @@
+# 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.
+
+"""Build Rust index files on REST tables and consume them through Python
APIs."""
+
+from datetime import date, datetime, time, timezone
+from decimal import Decimal
+import struct
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.api.api_response import ErrorResponse, GetTableSnapshotResponse
+from pypaimon.globalindex.create_global_index import GlobalIndexBuilder
+from pypaimon.globalindex.data_evolution_global_index_scanner import
DataEvolutionGlobalIndexScanner
+from pypaimon.index.index_file_handler import IndexFileHandler
+from pypaimon.read.native_plan import _native_table, _predicate_to_native,
native_method_available
+from pypaimon.snapshot.table_snapshot import TableSnapshot
+from pypaimon.tests import native_plan_rest_test
+from pypaimon.write.native_commit import from_native_commit_messages
+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'),
+ reason='Rust index builder required')]
+
+SCHEMA = pa.schema([('id', pa.int32()), ('name', pa.string()), ('pt',
pa.int32())])
+ROWS = [
+ {'id': 3, 'name': 'c', 'pt': 0}, {'id': 1, 'name': 'a', 'pt': 1},
+ {'id': 2, 'name': None, 'pt': None}, {'id': 4, 'name': 'b', 'pt': 0},
+ {'id': 5, 'name': 'a', 'pt': 1},
+]
+
+
+def _create(catalog, *, schema=SCHEMA, partitioned=False, options=None):
+ settings = {
+ 'file.format': 'parquet', 'bucket': '-1', 'row-tracking.enabled':
'true',
+ 'data-evolution.enabled': 'true', 'global-index.enabled': 'true',
+ 'write.native.enabled': 'true', 'commit.native.enabled': 'false',
+ 'read.native.enabled': 'false', 'scan.native-plan.enabled': 'true',
+ 'sorted-index.records-per-range': '2', 'read.batch-size': '1',
+ 'global-index.search-mode': 'full',
+ }
+ settings.update(options or {})
+ catalog.create_table('default.indexes', Schema.from_pyarrow_schema(
+ schema, partition_keys=['pt'] if partitioned else [],
options=settings), False)
+ return catalog.get_table('default.indexes')
+
+
+def _append(table, rows, schema=SCHEMA):
+ builder = table.copy({'write.native.enabled':
'false'}).new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+
+def _commit(table, messages):
+ commit = table.new_batch_write_builder().new_commit()
+ try:
+ commit.commit(messages)
+ finally:
+ commit.close()
+
+
+def _files(table):
+ return [entry.index_file for entry in IndexFileHandler(table).scan(None)]
+
+
+def _message_files(messages):
+ return [entry.index_file for message in messages for entry in
message.index_adds]
+
+
+def _path(table, file):
+ return file.external_path or
table.path_factory().global_index_path_factory().to_path(file.file_name)
+
+
+def _ids(table, predicate=None):
+ builder = table.new_read_builder().with_projection(['id'])
+ if predicate is not None:
+ builder.with_filter(predicate)
+ rows = builder.new_read().to_arrow(builder.new_scan().plan().splits())
+ return sorted(rows.column('id').to_pylist())
+
+
+def _build(table, kind, column='name', **kwargs):
+ # A native build must never materialize or sort rows in Python.
+ with patch.object(GlobalIndexBuilder, '_build_sorted_index',
side_effect=AssertionError('Python index build ran')):
+ return GlobalIndexBuilder(table, column, index_type=kind,
**kwargs).build()
+
+
[email protected]('kind', ['btree', 'bitmap'])
[email protected]('partitioned', [False, True])
[email protected]('native_commit', [False, True])
+def
test_build_is_unpublished_and_messages_work_with_either_committer(rest_catalog,
kind, partitioned, native_commit):
+ catalog, _ = rest_catalog
+ table = _create(catalog, partitioned=partitioned,
options={'commit.native.enabled': str(native_commit).lower()})
+ _append(table, ROWS)
+ before = table.snapshot_manager().get_latest_snapshot()
+ messages = _build(table, kind)
+ assert messages and all(not message.new_files and message.index_adds for
message in messages)
+ assert table.snapshot_manager().get_latest_snapshot().id == before.id
+ assert _files(table) == []
+ for file in _message_files(messages):
+ assert file.index_type == kind
+ assert file.global_index_meta.index_field_id ==
table.field_dict['name'].id
+ assert table.file_io.exists(_path(table, file))
+ _commit(table, messages)
+ assert table.snapshot_manager().get_latest_snapshot().id == before.id + 1
+ predicate = table.new_read_builder().new_predicate_builder().equal('name',
'a')
+ with DataEvolutionGlobalIndexScanner.create(table, predicate=predicate) as
scanner:
+ result = scanner.scan(predicate)
+ assert result is not None and result.is_exact()
+ assert result.results().cardinality() == 2
+ assert _ids(table, predicate) == [1, 5]
+ nulls = table.new_read_builder().new_predicate_builder().is_null('name')
+ assert _ids(table, nulls) == [2]
+ after = table.snapshot_manager().get_latest_snapshot().id
+ assert _build(table, kind) == []
+ assert table.create_global_index('name', index_type=kind) == 0
+ assert table.snapshot_manager().get_latest_snapshot().id == after
+
+
[email protected]('kind', ['btree', 'bitmap'])
[email protected]('selection', ['spec', 'predicate', 'null', 'union'])
+def test_partition_selection_and_incremental_coverage(rest_catalog, kind,
selection):
+ catalog, _ = rest_catalog
+ table = _create(catalog, partitioned=True)
+ _append(table, ROWS)
+ predicates = table.new_read_builder().new_predicate_builder()
+ kwargs = ({'partition_filter': predicates.equal('pt', 1)} if selection ==
'predicate' else
+ {'partitions': {'pt': None}} if selection == 'null' else
+ {'partitions': [{'pt': 0}, {'pt': 1}]} if selection == 'union'
else {'partitions': {'pt': 1}})
+ first = _build(table, kind, column='id', **kwargs)
+ expected_partitions = {(None,)} if selection == 'null' else {(0,), (1,)}
if selection == 'union' else {(1,)}
+ assert {message.partition for message in first} == expected_partitions
+ _commit(table, first)
+ assert _build(table, kind, column='id', **kwargs) == []
+ _append(table, [{'id': 6, 'name': 'a', 'pt': 1}, {'id': 7, 'name': 'z',
'pt': 0}])
+ added = _build(table, kind, column='id', **kwargs)
+ assert sum(file.row_count for file in _message_files(added)) == (
+ 0 if selection == 'null' else 2 if selection == 'union' else 1)
+ if added:
+ _commit(table, added)
+ remaining = _build(table, kind, column='id')
+ assert sum(file.row_count for file in _message_files(remaining)) == (
+ 6 if selection == 'null' else 1 if selection == 'union' else 4)
+ _commit(table, remaining)
+ assert _build(table, kind, column='id') == []
+ assert _ids(table, predicates.greater_or_equal('id', 5)) == [5, 6, 7]
+
+
[email protected]('kind', ['btree', 'bitmap'])
+def
test_external_paths_remain_readable_and_explicit_abort_removes_only_private_files(rest_catalog,
tmp_path, kind):
+ catalog, _ = rest_catalog
+ root = (tmp_path / 'external-indexes').as_uri()
+ table = _create(catalog, options={'global-index.external-path': root})
+ _append(table, ROWS)
+ messages = _build(table, kind)
+ private = _message_files(messages)
+ assert all(file.external_path.startswith(root + '/') for file in private)
+ assert all(table.file_io.exists(file.external_path) for file in private)
+ commit = table.new_batch_write_builder().new_commit()
+ try:
+ commit.abort(messages)
+ finally:
+ commit.close()
+ assert all(not table.file_io.exists(file.external_path) for file in
private)
+ assert table.snapshot_manager().get_latest_snapshot().id == 1
+ replacement = _build(table, kind)
+ _commit(table, replacement)
+ assert {file.file_name for file in private}.isdisjoint(file.file_name for
file in _files(table))
+ table = table.copy({'global-index.external-path': (tmp_path /
'changed-root').as_uri()})
+ assert _ids(table,
table.new_read_builder().new_predicate_builder().equal('name', 'a')) == [1, 5]
+
+
[email protected]('kind', ['btree', 'bitmap'])
[email protected]('response', ['first', 'empty'])
+def
test_rest_snapshot_response_controls_build_instead_of_newer_disk_snapshot(rest_catalog,
kind, response):
+ catalog, server = rest_catalog
+ table = _create(catalog)
+ _append(table, ROWS[:2])
+ first = table.snapshot_manager().get_latest_snapshot()
+ _append(table, ROWS[2:])
+ reply = (GetTableSnapshotResponse(TableSnapshot(first, 1, 0, 1,
first.time_millis)) if response == 'first'
+ else GetTableSnapshotResponse())
+ with patch.object(server, '_table_snapshot_handle',
return_value=server._mock_response(reply, 200)):
+ messages = _build(table, kind)
+ count = sum(file.row_count for file in _message_files(messages))
+ assert count == (2 if response == 'first' else 0)
+ assert table.snapshot_manager().get_latest_snapshot().id == 2
+
+
[email protected]('code', [403, 500, 503])
+def
test_rest_snapshot_errors_propagate_without_starting_python_build(rest_catalog,
code):
+ catalog, server = rest_catalog
+ table = _create(catalog)
+ _append(table, ROWS)
+ reply = ErrorResponse('TABLE', 'indexes', 'snapshot unavailable', code)
+ with patch.object(server, '_table_snapshot_handle',
return_value=server._mock_response(reply, code)):
+ with pytest.raises(Exception, match='snapshot unavailable|permission'):
+ _build(table, 'btree')
+ assert table.snapshot_manager().get_latest_snapshot().id == 1
+ assert _files(table) == []
+ index_directory = table.path_factory().index_path()
+ assert not table.file_io.exists(index_directory) or not
table.file_io.list_status(index_directory)
+
+
[email protected]('kind', ['btree', 'bitmap'])
[email protected]('column, data_type, values, literal', [
+ ('value', pa.int64(), [-2, 3, None, -2], -2),
+ ('value', pa.bool_(), [True, False, None, True], True),
+ ('value', pa.date32(), [date(2025, 1, 1), date(2025, 1, 2), None,
date(2025, 1, 1)], date(2025, 1, 1)),
+ ('value', pa.decimal128(12, 2), [Decimal('1.25'), Decimal('2.50'), None,
Decimal('1.25')], Decimal('1.25')),
+ ('value', pa.time32('ms'), [time(1, 2, 3), time(2, 3, 4), None, time(1, 2,
3)], time(1, 2, 3)),
+ ('value', pa.timestamp('us'), [datetime(2025, 1, 1), datetime(2025, 1, 2),
None, datetime(2025, 1, 1)],
+ datetime(2025, 1, 1)),
+ ('value', pa.timestamp('us', tz='UTC'),
+ [datetime(2025, 1, 1, tzinfo=timezone.utc), datetime(2025, 1, 2,
tzinfo=timezone.utc), None,
+ datetime(2025, 1, 1, tzinfo=timezone.utc)], datetime(2025, 1, 1,
tzinfo=timezone.utc)),
+])
+def test_typed_keys_match_python_scalar_readers(rest_catalog, kind, column,
data_type, values, literal):
+ catalog, _ = rest_catalog
+ schema = pa.schema([('id', pa.int32()), (column, data_type)])
+ table = _create(catalog, schema=schema)
+ _append(table, [{'id': i, column: value} for i, value in
enumerate(values)], schema)
+ _commit(table, _build(table, kind, column))
+ predicate = table.new_read_builder().new_predicate_builder().equal(column,
literal)
+ with DataEvolutionGlobalIndexScanner.create(table, predicate=predicate) as
scanner:
+ result = scanner.scan(predicate)
+ assert list(result.results()) == [0, 3]
+ assert _ids(table, predicate) == [0, 3]
+
+
[email protected]('kind', ['btree', 'bitmap'])
+def
test_binding_partition_conjunction_and_rejection_are_core_semantics(rest_catalog,
kind):
+ catalog, _ = rest_catalog
+ 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)
+ # 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)))
+ assert native.build() == []
+ assert native.execute() == 0
+ with pytest.raises(Exception, match='only partition keys'):
+
native.with_partition_filter(_predicate_to_native(predicates.equal('id', 1)))
+ assert table.snapshot_manager().get_latest_snapshot().id == 1
+
+
[email protected]('kind', ['btree', 'bitmap'])
+def test_empty_table_build_is_a_noop(rest_catalog, kind):
+ catalog, _ = rest_catalog
+ 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 table.snapshot_manager().get_latest_snapshot() is None
+
+
+def test_native_binding_can_build_and_execute_separate_indexes(rest_catalog):
+ catalog, _ = rest_catalog
+ table = _create(catalog)
+ _append(table, ROWS)
+ native = _native_table(table)
+ builder =
native.new_sorted_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))
+ assert builder.execute() == 0
+
builder.with_index_type('bitmap').with_options({'sorted-index.records-per-range':
'100'})
+ assert builder.execute() == 1
+ assert {file.index_type for file in _files(table)} == {'btree', 'bitmap'}
+ assert table.snapshot_manager().get_latest_snapshot().id == 3
+
+
+def test_both_index_types_can_share_one_explicit_commit(rest_catalog):
+ catalog, _ = rest_catalog
+ table = _create(catalog)
+ _append(table, ROWS)
+ messages = _build(table, 'btree') + _build(table, 'bitmap')
+ assert table.snapshot_manager().get_latest_snapshot().id == 1
+ _commit(table, messages)
+ assert table.snapshot_manager().get_latest_snapshot().id == 2
+ assert {file.index_type for file in _files(table)} == {'btree', 'bitmap'}
+ assert _ids(table,
table.new_read_builder().new_predicate_builder().equal('name', 'a')) == [1, 5]
+
+
[email protected]('kind', ['btree', 'bitmap'])
+def test_commit_response_failure_keeps_published_index_files(rest_catalog,
kind):
+ catalog, _ = rest_catalog
+ table = _create(catalog)
+ _append(table, ROWS)
+ original = BatchTableCommit.commit
+ published = []
+
+ def publish_then_raise(commit, messages, *args, **kwargs):
+ published.extend(_message_files(messages))
+ original(commit, messages, *args, **kwargs)
+ raise OSError('commit response lost')
+
+ with patch.object(BatchTableCommit, 'commit', publish_then_raise):
+ with pytest.raises(OSError, match='commit response lost'):
+ table.create_global_index('name', index_type=kind)
+ assert published and all(table.file_io.exists(_path(table, file)) for file
in published)
+ assert table.snapshot_manager().get_latest_snapshot().id == 2
+ assert _ids(table,
table.new_read_builder().new_predicate_builder().equal('name', 'a')) == [1, 5]
+
+
[email protected]('kind', ['btree', 'bitmap'])
+def
test_invalid_native_options_do_not_fall_back_or_publish_files(rest_catalog,
kind):
+ catalog, _ = rest_catalog
+ table = _create(catalog)
+ _append(table, ROWS)
+ with pytest.raises(Exception, match='greater than 0'):
+ _build(table, kind, options={'sorted-index.records-per-range': 0})
+ assert table.snapshot_manager().get_latest_snapshot().id == 1
+ index_directory = table.path_factory().index_path()
+ assert not table.file_io.exists(index_directory) or not
table.file_io.list_status(index_directory)
+
+
[email protected]('kind', ['btree', 'bitmap'])
+def test_java_records_per_file_option_overrides_table_alias(rest_catalog,
kind):
+ catalog, _ = rest_catalog
+ table = _create(catalog)
+ _append(table, ROWS)
+ messages = _build(table, kind, options={'sorted-index.records-per-file':
100})
+ files = _message_files(messages)
+ assert len(files) == 1
+ assert files[0].row_count == len(ROWS)
+ _commit(table, messages)
+ assert _ids(table,
table.new_read_builder().new_predicate_builder().equal('name', 'a')) == [1, 5]
+
+
[email protected]('options', [{'write.native.enabled': 'false'},
{'data-evolution.enabled': 'false'}])
+def test_ineligible_tables_choose_python_before_building(rest_catalog,
options):
+ catalog, _ = rest_catalog
+ table = _create(catalog, options=options)
+ _append(table, ROWS)
+ original = GlobalIndexBuilder._build_sorted_index
+ with patch.object(GlobalIndexBuilder, '_build_sorted_index',
autospec=True, side_effect=original) as build:
+ assert table.create_global_index('name') > 0
+ build.assert_called_once()
+ assert _ids(table,
table.new_read_builder().new_predicate_builder().equal('name', 'a')) == [1, 5]
+
+
[email protected]('kind', ['btree', 'bitmap'])
[email protected]('native_commit', [False, True])
[email protected]('same_commit', [False, True])
+def test_target_column_changes_reject_staged_indexes_without_deleting_files(
+ rest_catalog, kind, native_commit, same_commit):
+ catalog, _ = rest_catalog
+ table = _create(catalog, options={'commit.native.enabled':
str(native_commit).lower()})
+ _append(table, ROWS)
+ messages = _build(table, kind)
+ private = _message_files(messages)
+ updater = table.new_batch_write_builder().new_update()
+ updated = updater.update_by_arrow_with_row_id(pa.table({'_ROW_ID': [0],
'name': ['changed']}))
+ if not same_commit:
+ _commit(table, updated)
+ before = table.snapshot_manager().get_latest_snapshot().id
+ with pytest.raises(Exception, match='Global index source conflict'):
+ _commit(table, messages + updated if same_commit else messages)
+ assert table.snapshot_manager().get_latest_snapshot().id == before
+ assert all(table.file_io.exists(_path(table, file)) for file in private)
+ if not same_commit:
+ assert _ids(table,
table.new_read_builder().new_predicate_builder().equal('name', 'changed')) ==
[3]
+
+
[email protected]('kind', ['btree', 'bitmap'])
[email protected]('native_commit', [False, True])
+def
test_append_and_unrelated_column_changes_do_not_invalidate_staged_index(rest_catalog,
kind, native_commit):
+ catalog, _ = rest_catalog
+ table = _create(catalog, options={'commit.native.enabled':
str(native_commit).lower()})
+ _append(table, ROWS)
+ messages = _build(table, kind)
+ assert all(struct.unpack('>iiq', file.global_index_meta.source_meta) ==
(0x44454958, 1, 1)
+ for file in _message_files(messages))
+ updater = table.new_batch_write_builder().new_update()
+ _commit(table, updater.update_by_arrow_with_row_id(pa.table({'_ROW_ID':
[0], 'id': pa.array([10], pa.int32())})))
+ _append(table, [{'id': 6, 'name': 'a', 'pt': 0}])
+ _commit(table, messages)
+ assert _ids(table,
table.new_read_builder().new_predicate_builder().equal('name', 'a')) == [1, 5,
6]
+
+
[email protected]('kind', ['btree', 'bitmap'])
[email protected]('method, literals, partitions', [
+ ('in', [], set()), ('notIn', [], {(0,), (1,)}),
+ ('in', [None, 0], {(0,)}), ('notIn', [None, 0], set()),
+])
+def test_partition_set_predicates_follow_java_null_semantics(rest_catalog,
kind, method, literals, partitions):
+ catalog, _ = rest_catalog
+ table = _create(catalog, partitioned=True)
+ _append(table, ROWS)
+ from pypaimon.common.predicate import Predicate
+ predicate = Predicate(method, 2, 'pt', literals)
+ messages = _build(table, kind, partition_filter=predicate)
+ assert {message.partition for message in messages} == partitions
+
+
[email protected]('restricted', [False, True])
+def test_query_authorization_remains_on_python_build_path(rest_catalog,
restricted):
+ from pypaimon.catalog.rest.rest_catalog import RESTCatalog
+ from pypaimon.catalog.table_query_auth import TableQueryAuthResult
+ from pypaimon.catalog.catalog_exception import TableNoPermissionException
+
+ catalog, _ = rest_catalog
+ table = _create(catalog)
+ _append(table, ROWS)
+ table = table.copy({'query-auth.enabled': 'true'})
+ auth = TableQueryAuthResult(None, {'name': '{"name":"NULL"}'} if
restricted else None)
+ original = GlobalIndexBuilder._build_sorted_index
+ with patch.object(RESTCatalog, 'auth_table_query', return_value=auth), \
+ patch('pypaimon.globalindex.native_index_build._native_table',
+ side_effect=AssertionError('native auth build')), \
+ patch.object(GlobalIndexBuilder, '_build_sorted_index',
autospec=True, side_effect=original) as build:
+ if restricted:
+ with pytest.raises(TableNoPermissionException):
+ GlobalIndexBuilder(table, 'name').build()
+ build.assert_not_called()
+ else:
+ assert GlobalIndexBuilder(table, 'name').build()
+ build.assert_called_once()
+
+
[email protected]('kind', ['btree', 'bitmap'])
[email protected]('native_commit', [False, True])
[email protected]('remove_all', [False, True])
+def test_deleted_column_deltas_and_removed_source_ranges_reject_staged_index(
+ rest_catalog, kind, native_commit, remove_all):
+ from pypaimon.write.commit_message import CommitMessage
+
+ catalog, _ = rest_catalog
+ table = _create(catalog, options={'commit.native.enabled':
str(native_commit).lower()})
+ _append(table, ROWS)
+ updates =
table.new_batch_write_builder().new_update().update_by_arrow_with_row_id(
+ pa.table({'_ROW_ID': [0], 'name': ['changed']}))
+ _commit(table, updates)
+ messages = _build(table, kind)
+ if remove_all:
+ commit = table.new_batch_write_builder().new_commit()
+ try:
+ commit.truncate_table()
+ finally:
+ commit.close()
+ else:
+ deletions = [CommitMessage(partition=message.partition,
bucket=message.bucket, new_files=[],
+ deleted_files=message.new_files) for
message in updates]
+ _commit(table, deletions)
+ with pytest.raises(Exception, match='Global index (source|row ID
existence) conflict'):
+ _commit(table, messages)
+ assert all(table.file_io.exists(_path(table, file)) for file in
_message_files(messages))
+
+
[email protected]('native_commit', [False, True])
[email protected]('source', [b'DEIX', struct.pack('>iiq', 0x44454958,
1, 99)])
+def test_malformed_or_future_index_source_is_rejected(rest_catalog,
native_commit, source):
+ catalog, _ = rest_catalog
+ table = _create(catalog, options={'commit.native.enabled':
str(native_commit).lower()})
+ _append(table, ROWS)
+ messages = _build(table, 'btree')
+ for file in _message_files(messages):
+ file.global_index_meta.source_meta = source
+ with pytest.raises(Exception, match='source metadata|source snapshot'):
+ _commit(table, messages)
+ assert table.snapshot_manager().get_latest_snapshot().id == 1
+ assert all(table.file_io.exists(_path(table, file)) for file in
_message_files(messages))
diff --git a/paimon-python/pypaimon/tests/native_write_test.py
b/paimon-python/pypaimon/tests/native_write_test.py
index dafe004c37..1d1f0bbc36 100644
--- a/paimon-python/pypaimon/tests/native_write_test.py
+++ b/paimon-python/pypaimon/tests/native_write_test.py
@@ -129,6 +129,64 @@ def
test_batch_native_write_commits_through_both_committers(
assert table.snapshot_manager().get_latest_snapshot().commit_user ==
builder.commit_user
+@requires_native
[email protected]('evolution', [False, True])
[email protected]('streaming', [False, True])
[email protected]('element_type', [pa.float32(), pa.float64()])
[email protected]('child_name', ['item', 'element', ''])
+def test_native_vector_write_preserves_slices_and_nulls(
+ native_rest_catalog, evolution, streaming, element_type, child_name):
+ schema = pa.schema([
+ ('id', pa.int32()), ('embedding', pa.list_(pa.field(child_name,
element_type), 2)),
+ ])
+ options = {
+ 'file.format': 'parquet',
+ 'write.native.enabled': 'true', 'commit.native.enabled': 'true',
+ 'row-tracking.enabled': str(evolution).lower(),
+ 'data-evolution.enabled': str(evolution).lower(),
+ }
+ if evolution:
+ options['vector.file.format'] = 'parquet'
+ native_rest_catalog.create_table('default.vectors',
Schema.from_pyarrow_schema(schema, options=options), False)
+ table = native_rest_catalog.get_table('default.vectors')
+ builder = table.new_stream_write_builder() if streaming else
table.new_batch_write_builder()
+ writer = builder.new_write()
+ commit = builder.new_commit()
+ data = pa.Table.from_pylist([
+ {'id': 0, 'embedding': [0.0, 1.0]}, {'id': 1, 'embedding': [2.0, 3.0]},
+ {'id': 2, 'embedding': None}, {'id': 3, 'embedding': [4.0, None]},
+ ], schema=schema).slice(1)
+ try:
+ assert isinstance(writer, NativeTableWrite)
+ assert writer._python_writer is None
+ with patch.object(NativeTableWrite, '_switch_to_python',
side_effect=AssertionError('Vector fallback')):
+ writer.write_arrow(data)
+ messages = writer.prepare_commit(7) if streaming else
writer.prepare_commit()
+ commit.commit(messages, 7) if streaming else commit.commit(messages)
+ finally:
+ writer.close()
+ commit.close()
+ assert _rows(table) == data.to_pylist()
+
+
+@requires_native
[email protected]('streaming', [False, True])
+def
test_unavailable_vector_format_falls_back_before_accepting_data(native_rest_catalog,
streaming):
+ schema = pa.schema([('id', pa.int32()), ('embedding',
pa.list_(pa.float32(), 2))])
+ native_rest_catalog.create_table('default.vectors',
Schema.from_pyarrow_schema(schema, options={
+ 'write.native.enabled': 'true', 'row-tracking.enabled': 'true',
+ 'data-evolution.enabled': 'true', 'vector.file.format': 'lance',
+ }), False)
+ table = native_rest_catalog.get_table('default.vectors')
+ builder = table.new_stream_write_builder() if streaming else
table.new_batch_write_builder()
+ writer = builder.new_write()
+ try:
+ assert not isinstance(writer, NativeTableWrite)
+ assert table.snapshot_manager().get_latest_snapshot() is None
+ finally:
+ writer.close()
+
+
@requires_native
@pytest.mark.parametrize('directory', [None, 'relative', 'absolute', 'uri'])
def test_escaped_partition_file_path_and_abort(tmp_path, native_rest_catalog,
directory):
diff --git a/paimon-python/pypaimon/write/commit/conflict_detection.py
b/paimon-python/pypaimon/write/commit/conflict_detection.py
index 19d831cf60..179e21ddf7 100644
--- a/paimon-python/pypaimon/write/commit/conflict_detection.py
+++ b/paimon-python/pypaimon/write/commit/conflict_detection.py
@@ -287,6 +287,12 @@ class ConflictDetection:
if conflict is not None:
return conflict
+ from pypaimon.write.commit.global_index_source_check import
check_global_index_sources
+ conflict = check_global_index_sources(
+ self, latest_snapshot, merged_entries, delta_entries,
delta_index_entries)
+ if conflict is not None:
+ return conflict
+
return self.check_row_id_from_snapshot(latest_snapshot, delta_entries)
@staticmethod
diff --git a/paimon-python/pypaimon/write/commit/global_index_source_check.py
b/paimon-python/pypaimon/write/commit/global_index_source_check.py
new file mode 100644
index 0000000000..c4d9bb52ea
--- /dev/null
+++ b/paimon-python/pypaimon/write/commit/global_index_source_check.py
@@ -0,0 +1,82 @@
+# 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.
+
+"""Revalidate unpublished indexes against the data scanned to build them."""
+
+from pypaimon.index.data_evolution_index_source_meta import
DataEvolutionIndexSourceMeta
+from pypaimon.utils.range import Range
+
+
+def check_global_index_sources(detection, latest_snapshot, merged_entries,
delta_entries, index_entries):
+ if not detection.data_evolution_enabled:
+ return None
+ sources = []
+ for entry in detection.global_index_file_additions(index_entries):
+ meta = entry.index_file.global_index_meta
+ if not
DataEvolutionIndexSourceMeta.is_data_evolution_meta(meta.source_meta):
+ continue
+ source_id =
DataEvolutionIndexSourceMeta.deserialize(meta.source_meta).scan_snapshot_id
+ if latest_snapshot is None or source_id > latest_snapshot.id:
+ return RuntimeError('Global index source conflict: source snapshot
is not available.')
+ if detection.snapshot_manager.get_snapshot_by_id(source_id) is None:
+ return RuntimeError('Global index source conflict: source snapshot
{} is missing.'.format(source_id))
+ sources.append((source_id, entry))
+ if not sources:
+ return None
+ conflict = detection.check_global_index_row_id_existence(merged_entries,
[entry for _, entry in sources])
+ if conflict is not None:
+ return conflict
+ conflict = _check_column_changes(detection, sources, delta_entries)
+ if conflict is not None:
+ return conflict
+ earliest = min(source_id for source_id, _ in sources)
+ for snapshot_id in range(earliest + 1, latest_snapshot.id + 1):
+ snapshot = detection.snapshot_manager.get_snapshot_by_id(snapshot_id)
+ if snapshot is None:
+ return RuntimeError('Global index source conflict: snapshot {} is
missing.'.format(snapshot_id))
+ if snapshot.commit_kind == 'COMPACT':
+ continue
+ changes =
detection.commit_scanner.read_incremental_raw_entries_from_changed_partitions(
+ snapshot, [], index_entries=[entry for _, entry in sources])
+ conflict = _check_column_changes(detection, sources, changes,
snapshot_id)
+ if conflict is not None:
+ return conflict
+ return None
+
+
+def _check_column_changes(detection, sources, changes, snapshot_id=None):
+ from pypaimon.write.commit.conflict_detection import
RowIdColumnConflictChecker
+
+ field_checker = RowIdColumnConflictChecker([],
detection.table.schema_manager)
+ for change in changes:
+ row_range = change.file.row_id_range()
+ if row_range is None:
+ continue
+ for source_id, index in sources:
+ if snapshot_id is not None and snapshot_id <= source_id:
+ continue
+ if tuple(index.partition.values) != tuple(change.partition.values)
or index.bucket != change.bucket:
+ continue
+ meta = index.index_file.global_index_meta
+ if not row_range.overlaps(Range(meta.row_range_start,
meta.row_range_end)):
+ continue
+ fields = {meta.index_field_id}
+ fields.update(meta.extra_field_ids or [])
+ if field_checker._contains_any_write_field(fields, change.file):
+ return RuntimeError(
+ "Global index source conflict: indexed values changed
after building "
+ "index file '{}'.".format(index.index_file.file_name))
+ return None