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 6f13b1bbb3 [python] Support distributed vector index construction with
Ray (#10426)
6f13b1bbb3 is described below
commit 6f13b1bbb3c7640800adcfba7b8549a8735b7602
Author: chaoyang <[email protected]>
AuthorDate: Fri Oct 9 21:58:54 2026 +0800
[python] Support distributed vector index construction with Ray (#10426)
---
docs/docs/pypaimon/multimodal-search.md | 26 +++
.../filesystem/jindo_file_system_handler.py | 8 +
.../pypaimon/globalindex/create_global_index.py | 26 ++-
paimon-python/pypaimon/multimodal/table.py | 6 +-
paimon-python/pypaimon/ray/vector_index_build.py | 81 +++++++++
paimon-python/pypaimon/table/file_store_table.py | 4 +-
.../pypaimon/tests/jindo_file_system_test.py | 36 ++++
.../pypaimon/tests/multimodal_table_test.py | 4 +-
.../pypaimon/tests/ray_vector_index_build_test.py | 191 +++++++++++++++++++++
9 files changed, 374 insertions(+), 8 deletions(-)
diff --git a/docs/docs/pypaimon/multimodal-search.md
b/docs/docs/pypaimon/multimodal-search.md
index c63dc59a99..4470bf3d4e 100644
--- a/docs/docs/pypaimon/multimodal-search.md
+++ b/docs/docs/pypaimon/multimodal-search.md
@@ -50,6 +50,32 @@ docs.create_index("embedding", index_type="ivf-pq")
docs.create_index("content", index_type="full-text")
```
+### Build vector indexes with Ray
+
+Pass `execution="ray"` to distribute native vector-index shards across Ray
+workers. BTree, Bitmap and full-text builds still use local execution.
+
+```python
+docs.create_index(
+ "embedding", index_type="ivf-flat",
+ options={"global-index.row-count-per-shard": "1000000"},
+ execution="ray", concurrency=4, ray_remote_args={"num_cpus": 1},
+)
+```
+
+The driver plans uncovered row ranges from one snapshot. Workers build files;
+the driver publishes their index entries in one commit after all shards
succeed.
+`concurrency` bounds running shards; `global-index.build.parallelism` controls
+only local thread-based builds. Install the same `pypaimon[ray,vindex]`
+dependencies on every worker and use storage accessible to the entire cluster.
+
+Write tasks require `max_retries=0` and `retry_exceptions=False` (set by
default).
+On a task failure, the driver stops submissions, waits for dispatched tasks,
+and removes their uncommitted output files. Rerun the build after the failure;
+already committed index coverage is skipped. Driver/cluster termination can
+still leave orphan files for normal orphan-file maintenance. Retain the source
+snapshot's files until the build and its commit finish.
+
## Search
Use `search` for one vector query or one full-text query.
diff --git a/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
b/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
index 467ba03927..5a242df8de 100644
--- a/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
+++ b/paimon-python/pypaimon/filesystem/jindo_file_system_handler.py
@@ -235,6 +235,14 @@ class JindoFileSystemHandler(FileSystemHandler):
config = build_jindo_config(catalog_options)
self._jindo_fs = jfs.connect(self.root_path, "root", config)
+ def __getstate__(self):
+ # Native clients and thread locks belong to this process. Transport
only
+ # connection configuration; immutable-file size hints can be refilled.
+ return {"root_path": self.root_path, "properties": self.properties}
+
+ def __setstate__(self, state):
+ self.__init__(state["root_path"], state["properties"])
+
def register_file_size(self, path: str, file_size: int):
"""Register an immutable file's size supplied by Paimon metadata."""
normalized = self._normalize_path(path)
diff --git a/paimon-python/pypaimon/globalindex/create_global_index.py
b/paimon-python/pypaimon/globalindex/create_global_index.py
index 5926101148..569f2f8861 100644
--- a/paimon-python/pypaimon/globalindex/create_global_index.py
+++ b/paimon-python/pypaimon/globalindex/create_global_index.py
@@ -73,6 +73,10 @@ def create_global_index(
partition_filter: Optional[Predicate] = None,
partitions: Optional[Union[Dict[str, object], Sequence[Dict[str,
object]]]] = None,
options: Optional[Dict[str, object]] = None,
+ *,
+ execution="local",
+ concurrency=None,
+ ray_remote_args=None,
) -> int:
"""Build and commit global index files for a table.
@@ -87,7 +91,8 @@ def create_global_index(
partitions=partitions,
options=options,
)
- messages = builder.build()
+ messages = builder.build(
+ execution=execution, concurrency=concurrency,
ray_remote_args=ray_remote_args)
if not messages:
return 0
@@ -159,7 +164,15 @@ class GlobalIndexBuilder:
if self._index_type in _GENERIC_INDEX_IDENTIFIERS:
self._validate_generic_index_table()
- def build(self) -> List[CommitMessage]:
+ def build(self, *, execution="local", concurrency=None,
ray_remote_args=None) -> List[CommitMessage]:
+ if execution not in ("local", "ray"):
+ raise ValueError("execution must be 'local' or 'ray'.")
+ if execution == "local" and (concurrency is not None or
ray_remote_args is not None):
+ raise ValueError("Ray options require execution='ray'.")
+ if execution == "ray":
+ 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 partition_filter is not None:
@@ -203,6 +216,11 @@ class GlobalIndexBuilder:
if self._index_type in _SORTED_INDEX_IDENTIFIERS:
return self._build_sorted_index(
splits, unindexed_ranges, index_field, table_read, index_path)
+ if execution == "ray":
+ from pypaimon.ray.vector_index_build import build_vector_index
+ return build_vector_index(
+ self, splits, unindexed_ranges, index_field, table_read,
+ index_path, concurrency, ray_remote_args)
return self._build_generic_index(
splits, unindexed_ranges, index_field, table_read, index_path)
@@ -380,7 +398,7 @@ class GlobalIndexBuilder:
raise
def _build_generic_shard(
- self, index_split, index_range, index_field, table_read, index_path:
str
+ self, index_split, index_range, index_field, table_read, index_path:
str, file_name=None
) -> Optional[CommitMessage]:
from pypaimon.read.table_read import _ClosableArrowBatchReader
@@ -396,6 +414,8 @@ class GlobalIndexBuilder:
if writer is None:
writer = self._create_generic_index_writer(
index_path, index_field)
+ if file_name is not None:
+ writer.file_name = file_name
if self._index_type in VINDEX_IDENTIFIERS:
if batch.column(SpecialFields.ROW_ID.name).null_count:
raise ValueError(
diff --git a/paimon-python/pypaimon/multimodal/table.py
b/paimon-python/pypaimon/multimodal/table.py
index 6c0ff50ff8..00f1c03c4e 100644
--- a/paimon-python/pypaimon/multimodal/table.py
+++ b/paimon-python/pypaimon/multimodal/table.py
@@ -534,9 +534,11 @@ class MultimodalTable:
pre_filter=pre_filter,
)
- def create_index(self, column, index_type, options=None):
+ def create_index(self, column, index_type, options=None, *,
execution="local",
+ concurrency=None, ray_remote_args=None):
return self.raw_table.create_global_index(
- column, index_type=_normalize_index_type(index_type),
options=options)
+ column, index_type=_normalize_index_type(index_type),
options=options,
+ execution=execution, concurrency=concurrency,
ray_remote_args=ray_remote_args)
def _merge(
self,
diff --git a/paimon-python/pypaimon/ray/vector_index_build.py
b/paimon-python/pypaimon/ray/vector_index_build.py
new file mode 100644
index 0000000000..15575aed19
--- /dev/null
+++ b/paimon-python/pypaimon/ray/vector_index_build.py
@@ -0,0 +1,81 @@
+# 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.
+
+"""Ray shard construction with one driver-side global-index commit."""
+
+from pypaimon.globalindex.build_plan import split_by_global_index_shard
+from pypaimon.globalindex.index_file_utils import new_global_index_file_name
+from pypaimon.globalindex.vindex.vindex_vector_global_index_reader import
VINDEX_IDENTIFIERS
+from pypaimon.ray.vector_search import _execution_options
+
+
+def validate_build_options(builder, concurrency, ray_remote_args):
+ if builder._index_type not in VINDEX_IDENTIFIERS:
+ raise ValueError("Ray index building supports only native vector
indexes.")
+ concurrency, options = _execution_options(concurrency, ray_remote_args)
+ # Retrying a write task can race with a lost worker writing the same file.
+ # A failed build can instead be rerun after its uncommitted outputs close.
+ if options.get("max_retries", 0) != 0 or options.get("retry_exceptions",
False):
+ raise ValueError("Ray index building requires max_retries=0 and
retry_exceptions=False.")
+ options.update(max_retries=0, retry_exceptions=False)
+ if builder._core_options.global_index_row_count_per_shard() <= 0:
+ raise ValueError("Option 'global-index.row-count-per-shard' must be
greater than 0.")
+ return concurrency, options
+
+
+def build_vector_index(builder, splits, unindexed_ranges, index_field,
table_read,
+ index_path, concurrency, remote_args):
+ import ray
+
+ shards = split_by_global_index_shard(
+ splits, builder._core_options.global_index_row_count_per_shard(),
unindexed_ranges)
+ # Allocate names before dispatch, so failed/lost result delivery cannot
hide
+ # files from cleanup. Workers never publish index manifests or snapshots.
+ names = [new_global_index_file_name("vindex-" + builder._index_type) for _
in shards]
+ context = ray.put((builder, index_field, table_read, index_path))
+ remote = ray.remote(_build_shard).options(**remote_args)
+ pending = {}
+ results = {}
+ next_shard = 0
+ try:
+ while pending or next_shard < len(shards):
+ while len(pending) < concurrency and next_shard < len(shards):
+ ref = remote.remote(context, shards[next_shard],
names[next_shard])
+ pending[ref] = next_shard
+ next_shard += 1
+ ready, _ = ray.wait(list(pending), num_returns=1)
+ ref = ready[0]
+ results[pending.pop(ref)] = ray.get(ref)
+ return [results[i] for i in range(len(shards)) if results[i] is not
None]
+ except BaseException:
+ # Do not delete a file while another worker may still be writing it.
+ # Stop submissions and drain every dispatched task before cleanup.
+ for ref in pending:
+ try:
+ ray.get(ref)
+ except BaseException:
+ pass
+ for name in names:
+ builder._table.file_io.delete_quietly(index_path.rstrip("/") + "/"
+ name)
+ raise
+
+
+def _build_shard(context, shard, file_name):
+ builder, index_field, table_read, index_path = context
+ index_split, index_range = shard
+ return builder._build_generic_shard(
+ index_split, index_range, index_field, table_read, index_path,
file_name=file_name)
diff --git a/paimon-python/pypaimon/table/file_store_table.py
b/paimon-python/pypaimon/table/file_store_table.py
index 76f2d019a1..8dea054608 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -461,7 +461,8 @@ class FileStoreTable(Table):
def create_global_index(self, index_column, index_type: str = "btree",
partition_filter=None, partitions=None,
- options: Optional[dict] = None) -> int:
+ options: Optional[dict] = None, *,
execution="local",
+ concurrency=None, ray_remote_args=None) -> int:
from pypaimon.globalindex.create_global_index import \
create_global_index
return create_global_index(
@@ -471,6 +472,7 @@ class FileStoreTable(Table):
partition_filter=partition_filter,
partitions=partitions,
options=options,
+ execution=execution, concurrency=concurrency,
ray_remote_args=ray_remote_args,
)
def drop_global_index(self, index_column, index_type: str = "btree",
diff --git a/paimon-python/pypaimon/tests/jindo_file_system_test.py
b/paimon-python/pypaimon/tests/jindo_file_system_test.py
index a3d7bd1194..0c95c9228b 100644
--- a/paimon-python/pypaimon/tests/jindo_file_system_test.py
+++ b/paimon-python/pypaimon/tests/jindo_file_system_test.py
@@ -17,6 +17,8 @@
import io
import os
+import pickle
+import threading
import types
import unittest
import uuid
@@ -102,6 +104,40 @@ class JindoConfigTest(unittest.TestCase):
stream = JindoInputFile(JindoStream())
self.assertEqual(5, stream.seek(0, io.SEEK_END))
+ def test_pickle_reconnects_without_native_client_lock_or_cache(self):
+ options = Options({
+ "fs.oss.impl": "jindo", "fs.oss.endpoint":
"https://oss-cn-hangzhou.aliyuncs.com",
+ "fs.oss.accessKeyId": "test-ak", "fs.oss.accessKeySecret":
"test-sk",
+ "fs.oss.securityToken": "test-token",
"fs.jindocache.client.metrics.enable": True,
+ })
+ # A native client is not serializable even after the handler lock is
+ # removed. Give this stand-in its own lock to enforce reconstruction.
+ clients = [types.SimpleNamespace(lock=threading.Lock()) for _ in
range(2)]
+ connect = mock.Mock(side_effect=clients)
+ with mock.patch.object(jindo_module, "JINDO_AVAILABLE", True), \
+ mock.patch.object(jindo_module, "jfs",
types.SimpleNamespace(connect=connect)), \
+ mock.patch.object(jindo_module, "jutil",
types.SimpleNamespace(Config=_RecordingConfig)):
+ original = JindoFileSystemHandler("oss://bucket/", options)
+ original.register_file_size("object", 123)
+ # Exercise PyArrow's enclosing filesystem serialization as well.
+ encoded = pickle.dumps(PyFileSystem(original))
+ with original._known_file_sizes_lock:
+ restored = pickle.loads(encoded).handler
+ self.assertIs(original._jindo_fs, clients[0])
+ self.assertIs(restored._jindo_fs, clients[1])
+ self.assertEqual(restored.root_path, original.root_path)
+ self.assertEqual(restored.properties.to_map(), options.to_map())
+ self.assertIsNot(restored._known_file_sizes_lock,
original._known_file_sizes_lock)
+ self.assertIsNone(restored._known_file_size("oss://bucket/object"))
+ restored.register_file_size("object", 456)
+ self.assertEqual(restored._known_file_size("oss://bucket/object"),
456)
+ self.assertEqual(original._known_file_size("oss://bucket/object"),
123)
+ self.assertEqual(connect.call_count, 2)
+ for call in connect.call_args_list:
+ self.assertEqual(call[0][:2], ("oss://bucket/", "root"))
+ self.assertEqual(call[0][2].values["fs.oss.securityToken"],
"test-token")
+
self.assertEqual(call[0][2].values["fs.jindocache.client.metrics.enable"],
"true")
+
def test_forwards_native_options_to_connect(self):
created_config = _RecordingConfig()
config_factory = mock.Mock(return_value=created_config)
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py
b/paimon-python/pypaimon/tests/multimodal_table_test.py
index 7f5028ed47..00bd51e7d2 100644
--- a/paimon-python/pypaimon/tests/multimodal_table_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -2833,7 +2833,7 @@ class MultimodalTableTest(unittest.TestCase):
calls = []
- def create_global_index(column, index_type, options=None):
+ def create_global_index(column, index_type, options=None, **kwargs):
calls.append((column, index_type, options))
return index_type
@@ -2863,7 +2863,7 @@ class MultimodalTableTest(unittest.TestCase):
options=_PARQUET_OPTIONS,
)
- def create_global_index(column, index_type, options=None):
+ def create_global_index(column, index_type, options=None, **kwargs):
return index_type
docs.raw_table.create_global_index = create_global_index
diff --git a/paimon-python/pypaimon/tests/ray_vector_index_build_test.py
b/paimon-python/pypaimon/tests/ray_vector_index_build_test.py
new file mode 100644
index 0000000000..af5f6bb621
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_vector_index_build_test.py
@@ -0,0 +1,191 @@
+# 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.
+
+"""Exercise Ray vector-index construction with real worker processes."""
+
+import os
+import threading
+import time
+from contextlib import contextmanager
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import pytest
+
+ray = pytest.importorskip("ray")
+pytest.importorskip("paimon_vindex")
+
+from pypaimon.globalindex.create_global_index import GlobalIndexBuilder
+from pypaimon.index.index_file_handler import IndexFileHandler
+from pypaimon.ray import vector_index_build
+from pypaimon.tests import ray_vector_search_test as fixtures
+from pypaimon.tests.ray_vector_search_test import add_rows, VECTORS
+
+ray_cluster = fixtures.ray_cluster
+table = fixtures.table
+
+
+OPTIONS = {"global-index.row-count-per-shard": "3", "ivf-flat.nlist": "1"}
+
+
+def index_files(table):
+ return [entry.index_file for entry in
IndexFileHandler(table.raw_table).scan(
+ table.raw_table.snapshot_manager().get_latest_snapshot())
+ if entry.index_file.index_type == "ivf-flat"]
+
+
[email protected]("concurrency", [1, 2])
+def test_build_and_incremental_coverage(table, ray_cluster, concurrency):
+ add_rows(table, VECTORS)
+ table.delete("id = 0")
+ before = table.raw_table.snapshot_manager().get_latest_snapshot().id
+ assert table.create_index("embedding", "ivf-flat", options=OPTIONS,
+ execution="ray", concurrency=concurrency) == 2
+ assert table.raw_table.snapshot_manager().get_latest_snapshot().id ==
before + 1
+ files = sorted(index_files(table), key=lambda f:
f.global_index_meta.row_range_start)
+ assert [(f.global_index_meta.row_range_start,
f.global_index_meta.row_range_end) for f in files] == [(0, 2), (3, 5)]
+ query = table.search([1., 1.], column="embedding", options={"ivf.nprobe":
"1"}).select(["id"]).limit(2)
+ assert query.to_list() == query.to_list(execution="ray")
+ assert all(row["id"] != 0 for row in query.to_list())
+ assert table.create_index("embedding", "ivf-flat", options=OPTIONS,
execution="ray") == 0
+ add_rows(table, [[20., 1.]], 6)
+ assert table.create_index("embedding", "ivf-flat", options=OPTIONS,
execution="ray") == 1
+ assert len(index_files(table)) == 3
+
+
+def test_build_messages_do_not_publish_or_include_later_appends(table,
ray_cluster):
+ add_rows(table, VECTORS[:3])
+ builder = GlobalIndexBuilder(table.raw_table, "embedding", "ivf-flat",
options=OPTIONS)
+ snapshot = table.raw_table.snapshot_manager().get_latest_snapshot().id
+ messages = builder.build(execution="ray")
+ assert table.raw_table.snapshot_manager().get_latest_snapshot().id ==
snapshot
+ assert not index_files(table)
+ add_rows(table, VECTORS[3:], 3)
+ commit = table.raw_table.new_batch_write_builder().new_commit()
+ try:
+ commit.commit(messages)
+ finally:
+ commit.close()
+ assert [(f.global_index_meta.row_range_start,
f.global_index_meta.row_range_end)
+ for f in index_files(table)] == [(0, 2)]
+ assert table.create_index("embedding", "ivf-flat", options=OPTIONS,
execution="ray") == 1
+
+
+def _write_then_fail(context, shard, name):
+ if shard[1].from_:
+ time.sleep(0.3)
+ message = vector_index_build._build_shard(context, shard, name)
+ if not shard[1].from_:
+ raise RuntimeError("failed after writing an index file")
+ return message
+
+
+def test_worker_failure_drains_and_cleans_all_outputs(table, ray_cluster):
+ add_rows(table, VECTORS)
+ before = table.raw_table.snapshot_manager().get_latest_snapshot().id
+ # Pass a real serialized worker function; a failure after finish() must
also
+ # clean files whose commit messages were never delivered to the driver.
+ original_remote = ray.remote
+
+ def remote(worker):
+ return original_remote(_write_then_fail)
+
+ with patch.object(ray, "remote", remote):
+ with pytest.raises(Exception, match="failed after writing"):
+ table.create_index("embedding", "ivf-flat", options=OPTIONS,
+ execution="ray", concurrency=2)
+ assert table.raw_table.snapshot_manager().get_latest_snapshot().id ==
before
+ assert not index_files(table)
+ root =
table.raw_table.path_factory().global_index_path_factory().global_index_root_path()
+ from pathlib import Path
+ assert not list(Path(root).glob("*.index"))
+ assert table.create_index("embedding", "ivf-flat", options=OPTIONS,
execution="ray") == 2
+
+
[email protected]("kwargs,match", [
+ ({"execution": "other"}, "execution"),
+ ({"concurrency": 1}, "execution='ray'"),
+ ({"execution": "ray", "concurrency": 0}, "concurrency"),
+ ({"execution": "ray", "ray_remote_args": {"max_retries": 1}},
"max_retries"),
+ ({"execution": "ray", "ray_remote_args": {"retry_exceptions": True}},
"max_retries"),
+])
+def test_validate_before_empty_plan(table, kwargs, match):
+ with pytest.raises(ValueError, match=match):
+ table.create_index("embedding", "ivf-flat", options=OPTIONS, **kwargs)
+
+
+def test_reject_non_vector_ray_build(table):
+ with pytest.raises(ValueError, match="only native vector"):
+ table.create_index("id", "btree", execution="ray")
+
+
+@contextmanager
+def _jindo_sdk_stub():
+ from pypaimon.filesystem import jindo_file_system_handler as jindo_module
+ from pypaimon.filesystem import pyarrow_file_io as arrow_module
+ from pypaimon.tests.jindo_file_system_test import _RecordingConfig
+
+ def connect(root, user, config):
+ return SimpleNamespace(pid=os.getpid(), lock=threading.Lock(),
config=config.values)
+
+ with patch.object(jindo_module, "JINDO_AVAILABLE", True), \
+ patch.object(arrow_module, "JINDO_AVAILABLE", True), \
+ patch.object(jindo_module, "jfs",
SimpleNamespace(connect=connect)), \
+ patch.object(jindo_module, "jutil",
SimpleNamespace(Config=_RecordingConfig)):
+ yield
+
+
+def _inspect_jindo_context(context_refs):
+ # Nested object refs postpone deserialization until the worker has
installed
+ # the SDK stand-in. Only Config/connect are replaced, not serialization.
+ with _jindo_sdk_stub():
+ builder, field, table_read, index_path = ray.get(context_refs[0])
+ handler = builder._table.file_io.filesystem.handler
+ assert table_read.table.file_io is builder._table.file_io
+ assert field.name == "embedding"
+ assert handler._known_file_size("oss://bucket/object") is None
+ handler.register_file_size("object", 456)
+ return (os.getpid(), handler._jindo_fs.pid,
handler._known_file_size("oss://bucket/object"),
+ handler.root_path,
handler._jindo_fs.config["fs.oss.securityToken"], index_path)
+
+
+def test_jindo_build_context_reconstructs_on_worker(table, ray_cluster):
+ from pypaimon.common.options import Options
+ from pypaimon.filesystem.oss_file_io import OssFileIO
+ from pypaimon.read.table_read import TableRead
+ from pypaimon.table.special_fields import SpecialFields
+
+ builder = GlobalIndexBuilder(table.raw_table, "embedding", "ivf-flat",
options=OPTIONS)
+ field = table.raw_table.field_dict["embedding"]
+ reader = TableRead(table.raw_table, None, [field, SpecialFields.ROW_ID])
+ with _jindo_sdk_stub():
+ file_io = OssFileIO("oss://bucket/table", Options({
+ "fs.oss.impl": "jindo", "fs.oss.endpoint":
"oss-cn-hangzhou.aliyuncs.com",
+ "fs.oss.accessKeyId": "test-ak", "fs.oss.accessKeySecret":
"test-sk",
+ "fs.oss.securityToken": "test-token",
+ }))
+ original = file_io.filesystem.handler
+ original.register_file_size("object", 123)
+ with patch.object(table.raw_table, "file_io", file_io):
+ # This is the exact tuple broadcast by build_vector_index.
+ context = ray.put((builder, field, reader, "oss://bucket/index"))
+ result = ray.get(ray.remote(_inspect_jindo_context).remote([context]))
+ worker_pid, client_pid, size, root, token, index_path = result
+ assert worker_pid != os.getpid()
+ assert client_pid == worker_pid
+ assert (size, root, token, index_path) == (456, "oss://bucket/",
"test-token", "oss://bucket/index")
+ assert original._known_file_size("oss://bucket/object") == 123