blob: b6e1c74f5ca0b5492e9d5f60e33341000f4e6610 [file]
# 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
from contextlib import closing
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
import pyarrow as pa
import pytest
ray = pytest.importorskip("ray")
from pypaimon.ray.search_execution import RaySearchTaskExecutor
from pypaimon.tests.search_execution_test_helpers import execute_tasks, task_parts
import pypaimon.multimodal as pm
from pypaimon.multimodal.query import ScanQuery
from pypaimon.execution.python.read.search import distributed_vector_search as search_module
from pypaimon.snapshot.time_travel_util import TimeTravelUtil
from pypaimon.execution.python.read.search.vector_search_scan import DataEvolutionVectorScan
SCHEMA = pa.schema([
("id", pa.int64()), ("category", pa.string()), ("embedding", pa.list_(pa.float32(), 2))])
VECTORS = [[0., 1.], [2., 1.], [1., 3.], [-2., 2.], [4., 1.], [1., -3.]]
@pytest.fixture(scope="module")
def ray_cluster():
started = not ray.is_initialized()
if started:
ray.init(address="local", num_cpus=2, include_dashboard=False,
object_store_memory=100 * 1024 * 1024)
yield
if started:
ray.shutdown()
@pytest.fixture
def table(tmp_path):
return pm.connect(options={"warehouse": str(tmp_path)}).create_table(
"vectors", schema=SCHEMA,
options={"file.format": "parquet", "vector.file.format": "parquet",
"vector-index.search-mode": "full", "read.batch-size": "2",
"source.split.target-size": "1 b", "source.split.open-file-cost": "1 b"})
def add_rows(table, vectors, start=0):
ids = list(range(start, start + len(vectors)))
table.add(pa.table({"id": ids, "category": ["yes" if i % 2 == 0 else "no" for i in ids],
"embedding": vectors}, schema=SCHEMA))
def build_index(table, metric="l2"):
pytest.importorskip("paimon_vindex")
table.raw_table.create_global_index(
"embedding", "ivf-flat", options={
"global-index.row-count-per-shard": "3",
"ivf-flat.nlist": "1", "ivf-flat.distance.metric": metric})
def ids(result):
return sorted(result.column("id").to_pylist())
@pytest.mark.parametrize("metric", ["l2", "cosine", "inner_product"])
@pytest.mark.parametrize("concurrency", [1, 2])
def test_raw_search_matches_exact_top_k(table, ray_cluster, metric, concurrency):
add_rows(table, VECTORS[:3])
add_rows(table, VECTORS[3:], 3)
query_vector = [1., 1.]
query = table.search(query_vector, options={"metric": metric}).select(["id"]).limit(3)
vectors = np.array(VECTORS)
if metric == "l2":
scores = -np.sum((vectors - query_vector) ** 2, axis=1)
elif metric == "cosine":
scores = vectors.dot(query_vector) / (np.linalg.norm(vectors, axis=1) * np.sqrt(2.))
else:
scores = vectors.dot(query_vector)
expected = sorted(sorted(range(len(VECTORS)), key=lambda i: (-scores[i], i))[:3])
assert ids(query.to_arrow()) == expected
original = RaySearchTaskExecutor.map
dispatched = []
def record(executor, tasks, ordered=False):
worker, context, items = task_parts(tasks)
dispatched.append(len(items))
return original(executor, tasks, ordered=ordered)
with patch.object(RaySearchTaskExecutor, "map", record):
assert ids(query.to_arrow(execution="ray", concurrency=concurrency)) == expected
assert dispatched == [2]
@pytest.mark.parametrize("metric", ["l2", "cosine", "inner_product"])
@pytest.mark.parametrize("refine", [False, True])
def test_indexed_search_and_raw_fallback_use_persisted_metric(table, ray_cluster, metric, refine):
add_rows(table, VECTORS)
build_index(table, metric)
add_rows(table, [[1., 1.], [20., 1.]], 6)
# No metric override: raw workers must inherit it from the actual indexes.
options = {"ivf.nprobe": "1"}
if refine:
options["refine_factor"] = "2"
query = table.search([1., 1.], options=options).select(["id"]).limit(3)
expected = ids(query.to_arrow())
observed = []
original = RaySearchTaskExecutor.map
def record(executor, tasks, ordered=False):
worker, context, items = task_parts(tasks)
observed.append((worker.__name__, len(items)))
return original(executor, tasks, ordered=ordered)
with patch.object(RaySearchTaskExecutor, "map", record):
assert ids(query.to_arrow(execution="ray", concurrency=2)) == expected
assert ("_search_index_split", 2) in observed
assert any(name == "_search_raw_split" and count > 0 for name, count in observed)
def test_filters_deletions_and_column_updates(table, ray_cluster):
add_rows(table, VECTORS)
build_index(table)
add_rows(table, [[1., 1.], [0., 1.]], 6)
table.update("id = 2", {"category": "no"})
table.delete("id = 0")
# The scalar filter has no index, so fallback overlaps vector-index coverage.
query = table.search([1., 1.], pre_filter="category = 'yes'").select(["id"]).limit(3)
assert ids(query.to_arrow()) == [4, 6]
assert ids(query.to_arrow(execution="ray", concurrency=2)) == [4, 6]
# Post-filtering keeps its existing meaning: it need not fill the requested K.
query = table.search([1., 1.]).where("id = 4").select(["id"]).limit(1)
assert query.to_arrow().num_rows == 0
assert query.to_arrow(execution="ray").num_rows == 0
assert table.search([1., 1.], pre_filter="category = 'missing'").to_arrow(execution="ray").num_rows == 0
def test_partition_filter(tmp_path, ray_cluster):
table = pm.connect(options={"warehouse": str(tmp_path)}).create_table(
"partitioned", schema=SCHEMA, partitioned=["category"],
options={"file.format": "parquet", "vector.file.format": "parquet",
"vector-index.search-mode": "full"})
add_rows(table, VECTORS)
query = table.search([1., 1.], column="embedding", pre_filter="category = 'yes'").select(["id"]).limit(10)
assert ids(query.to_arrow()) == [0, 2, 4]
assert ids(query.to_arrow(execution="ray")) == [0, 2, 4]
def test_fast_mode_does_not_scan_unindexed_rows(table, ray_cluster):
add_rows(table, VECTORS)
build_index(table)
add_rows(table, [[1., 1.]], 6)
table.raw_table = table.raw_table.copy({"vector-index.search-mode": "fast"})
query = table.search([1., 1.]).select(["id"]).limit(2)
with patch.object(search_module, "_search_raw_split", side_effect=AssertionError("raw scan")):
result = query.to_arrow(execution="ray")
assert ids(result) == ids(query.to_arrow())
assert 6 not in ids(result)
@pytest.mark.parametrize("change", ["update", "delete"])
def test_query_pins_worker_reads_and_lookup_but_can_be_reused(table, ray_cluster, change):
add_rows(table, [[1., 1.], [10., 1.]])
query = table.search([1., 1.]).select(["id", "category"]).limit(1)
expected = query.to_arrow().to_pylist()
original = RaySearchTaskExecutor.map
changed = []
def change_before_dispatch(executor, tasks, ordered=False):
if not changed:
if change == "delete":
table.delete("id = 0")
else:
table.update("id = 0", {"category": "changed"})
changed.append(True)
return original(executor, tasks, ordered=ordered)
with patch.object(RaySearchTaskExecutor, "map", change_before_dispatch):
assert query.to_arrow(execution="ray").to_pylist() == expected
latest = query.to_arrow().to_pylist()
assert latest != expected
assert query.to_arrow(execution="ray").to_pylist() == latest
def test_empty_snapshot_does_not_follow_first_commit(table, ray_cluster):
query = table.search([1., 1.]).select(["id"])
scan = DataEvolutionVectorScan.scan
def append_before_scan(search_scan):
add_rows(table, [[1., 1.]])
return scan(search_scan)
with patch.object(DataEvolutionVectorScan, "scan", append_before_scan), \
patch.object(RaySearchTaskExecutor, "map", side_effect=AssertionError("empty task")):
result = query.to_arrow(execution="ray")
assert result.schema.names == ["id"]
assert result.num_rows == 0
assert ids(query.to_arrow(execution="ray")) == [0]
@pytest.mark.parametrize("selector", ["snapshot", "tag"])
def test_snapshot_and_retained_tag_metadata_reach_workers(table, ray_cluster, selector):
add_rows(table, VECTORS)
snapshot = table.raw_table.snapshot_manager().get_latest_snapshot()
table.raw_table.create_tag("saved", snapshot_id=snapshot.id)
add_rows(table, [[1., 1.]], 6)
if selector == "tag":
table.raw_table.file_io.delete(table.raw_table.snapshot_manager().get_snapshot_path(snapshot.id))
query = table.search([1., 1.], tag_name="saved")
else:
query = table.search([1., 1.], snapshot_id=snapshot.id)
query.select(["id"]).limit(2)
assert ids(query.to_arrow(execution="ray")) == ids(query.to_arrow()) == [0, 1]
def test_lookup_is_pinned_after_worker_completion(table, ray_cluster):
add_rows(table, [[1., 1.], [10., 1.]])
original = ScanQuery._read_global_index_result
def delete_before_lookup(query, result):
table.delete("id = 0")
return original(query, result)
with patch.object(ScanQuery, "_read_global_index_result", delete_before_lookup):
assert ids(table.search([1., 1.]).select(["id"]).limit(1).to_arrow(execution="ray")) == [0]
def test_warmed_read_context_and_distinct_worker_processes(table, ray_cluster):
add_rows(table, VECTORS[:3])
add_rows(table, VECTORS[3:], 3)
query = table.search([1., 1.]).select(["id"]).limit(2)
query.to_arrow() # Warm filesystem and metadata state before serialization.
pinned = query._for_execution()._table
def inspect_context(table, ordinal):
import time
time.sleep(0.2)
return os.getpid(), TimeTravelUtil.resolve_snapshot(table).id
results = list(execute_tasks(inspect_context, pinned, [0, 1], 2, {"num_cpus": 1}))
assert len({result[0] for _, result in results}) == 2
assert all(pid != os.getpid() and snapshot == 2 for _, (pid, snapshot) in results)
assert ids(query.to_arrow(execution="ray")) == [0, 1]
@pytest.mark.parametrize("ordered", [False, True])
def test_task_retry_keeps_original_snapshot(ordered, table, ray_cluster, tmp_path):
add_rows(table, [[1., 1.]])
pinned = table.search([1., 1.])._for_execution()._table
add_rows(table, [[2., 1.]], 1)
marker = str(tmp_path / "retry-marker")
def retry_once(table, marker):
try:
fd = os.open(marker, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
builder = table.new_read_builder().with_projection(["id"])
data = builder.new_read().to_arrow(builder.new_scan().plan().splits())
return TimeTravelUtil.resolve_snapshot(table).id, data.column("id").to_pylist()
os.close(fd)
raise RuntimeError("retry this read")
assert list(execute_tasks(
retry_once, pinned, [marker], 1,
{"max_retries": 1, "retry_exceptions": True}, ordered=ordered)) == [(0, (1, [0]))]
@pytest.mark.parametrize("ordered", [False, True])
def test_failure_cancels_outstanding_tasks(ordered, ray_cluster):
def fail(context, item):
if item == 0:
raise ValueError("injected search failure")
import time
time.sleep(30)
with patch.object(ray, "cancel", wraps=ray.cancel) as cancel:
with pytest.raises(ValueError, match="injected search failure"):
list(execute_tasks(fail, None, [0, 1, 2], 2, {}, ordered=ordered))
assert cancel.call_count == 2
@pytest.mark.parametrize("ordered", [False, True])
def test_map_tasks_bounds_submissions_and_cancels_on_early_close(ordered, ray_cluster):
def echo(context, item):
return item
wait = ray.wait
counts = []
def record_wait(refs, **kwargs):
counts.append(len(refs))
return wait(refs, **kwargs)
with patch.object(ray, "wait", record_wait), patch.object(ray, "cancel", wraps=ray.cancel) as cancel:
with closing(execute_tasks(echo, None, list(range(10)), 2, {}, ordered=ordered)) as tasks:
next(tasks)
assert counts == [2] or (ordered and counts == [2, 1])
assert cancel.call_count == 2 - len(counts)
def test_refinement_uses_global_candidates(table):
from pypaimon.table.source.vector_search_split import IndexVectorSearchSplit
from pypaimon.index.index_file_meta import IndexFileMeta
add_rows(table, [[0., 1.], [10., 1.], [20., 1.], [1., 1.]])
query = table.search([1., 1.], options={"refine_factor": "2"}).limit(1)._for_execution()
reader = query._vector_search_builder(query).new_vector_search_read()
distributed = search_module.DistributedVectorSearchRead(reader, RaySearchTaskExecutor(2, {}))
index_file = IndexFileMeta("ivf-flat", "fake", 1, 2, None)
splits = [IndexVectorSearchSplit(0, 1, [index_file]), IndexVectorSearchSplit(2, 3, [index_file])]
def completed(executor, tasks, ordered=False):
worker, context, items = task_parts(tasks)
if worker is search_module._search_raw_split:
for ordinal, split in enumerate(items):
yield ordinal, worker(context, split)
return
# Row 3 is the true nearest, but is outside the GLOBAL approximate top-2.
# Refining independently per shard would incorrectly bring it back in.
assert ordered is True # Index tasks request plan-ordered results.
yield 0, ("l2", {0: 10., 1: 9.})
yield 1, ("l2", {2: 8., 3: 7.})
with patch.object(RaySearchTaskExecutor, "map", completed):
result = distributed._read_indexed(splits, [1., 1.], query._table._read_snapshot)
assert list(result.results()) == [0]
assert result.score_getter()(0) == 0.5 # L2 score is 1 / (1 + squared distance).
def test_duplicate_scores_keep_plan_order(table):
from pypaimon.table.source.vector_search_split import IndexVectorSearchSplit
from pypaimon.index.index_file_meta import IndexFileMeta
query = table.search([1., 1.]).limit(2)._for_execution()
reader = query._vector_search_builder(query).new_vector_search_read()
distributed = search_module.DistributedVectorSearchRead(reader, RaySearchTaskExecutor(2, {}))
index_file = IndexFileMeta("ivf-flat", "fake", 1, 2)
splits = [IndexVectorSearchSplit(0, 1, [index_file]), IndexVectorSearchSplit(0, 3, [index_file])]
def completed(executor, tasks, ordered=False):
assert ordered is True
yield 0, ("l2", {0: 1., 1: 2.})
yield 1, ("l2", {0: 100.})
with patch.object(RaySearchTaskExecutor, "map", completed):
result = distributed._read_indexed(splits, [1., 1.], None)
assert result.score_getter()(0) == 1.
assert list(result.top_k(1).results()) == [1]
def test_metric_mismatch_fails_even_when_one_shard_has_no_hits(table):
from pypaimon.table.source.vector_search_split import IndexVectorSearchSplit
query = table.search([1., 1.])
reader = query._vector_search_builder(query).new_vector_search_read()
distributed = search_module.DistributedVectorSearchRead(reader, RaySearchTaskExecutor(2, {}))
closed = []
def completed(executor, tasks, ordered=False):
try:
yield 0, ("l2", {})
yield 1, ("inner_product", {1: 10.})
finally:
closed.append(True)
with patch.object(RaySearchTaskExecutor, "map", completed), \
pytest.raises(ValueError, match="different metrics"):
list(distributed._search_index_splits(
[IndexVectorSearchSplit(0, 1, []), IndexVectorSearchSplit(2, 3, [])], [1., 1.], 1, None))
assert closed == [True]
@pytest.mark.parametrize("kwargs, message", [
({"execution": "unknown"}, "execution must"),
({"concurrency": 2}, "require execution"),
({"execution": "ray", "concurrency": 0}, "positive integer"),
({"execution": "ray", "concurrency": True}, "positive integer"),
({"execution": "ray", "concurrency": 1.5}, "positive integer"),
({"execution": "ray", "ray_remote_args": {"num_returns": 2}}, "num_returns"),
])
def test_invalid_execution_options(table, kwargs, message):
with pytest.raises(ValueError, match=message):
table.search([1., 1.]).to_arrow(**kwargs)
def test_rejects_non_data_evolution_table(table):
query = table.search([1., 1.])
with patch.object(query._table.options.__class__, "data_evolution_enabled", return_value=False), \
pytest.raises(ValueError, match="only data-evolution tables"):
query.to_arrow(execution="ray")
@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf")])
def test_rejects_nonfinite_query_before_dispatch(table, value):
with pytest.raises(ValueError, match="finite query vector"):
table.search([value, 1.]).to_arrow(execution="ray")
def test_nan_scores_fail_before_worker_top_k(table, ray_cluster):
add_rows(table, [[1., 1.], [float("nan"), 1.], [10., 1.]])
# NaN may be discarded by a local heap. Validate before that truncation,
# otherwise a different split layout can silently change the answer.
with pytest.raises(ValueError, match="cannot rank NaN"):
table.search([1., 1.]).limit(1).to_arrow(execution="ray")
@pytest.mark.parametrize("ordered", [False, True])
@pytest.mark.parametrize("early_close", [False, True])
def test_slow_first_task_bounds_completed_results(ordered, early_close):
submitted, completed, consumed, cancelled = [], [], [], []
def submit(operation, context, item, *dependencies):
# A slow prefix must not allow all later tasks/results to accumulate.
assert len(submitted) - len(consumed) < 3
submitted.append(item)
return item
def wait(refs, **kwargs):
# Complete the newest task first, delaying the first submitted task.
ref = max(refs)
completed.append(ref)
return [ref], [item for item in refs if item != ref]
remote = SimpleNamespace(remote=submit)
remote.options = lambda **kwargs: remote
with patch.object(ray, "put", return_value=None), \
patch.object(ray, "remote", return_value=remote), \
patch.object(ray, "wait", side_effect=wait), \
patch.object(ray, "get", side_effect=lambda ref: ref), \
patch.object(ray, "cancel", side_effect=cancelled.append):
with closing(execute_tasks(None, None, list(range(10)), 3, {}, ordered)) as tasks:
for ordinal, result in tasks:
assert ordinal == result
consumed.append(ordinal)
if early_close:
break
if ordered:
assert completed[:3] == [2, 1, 0]
assert consumed == ([0] if early_close else list(range(10)))
else:
assert consumed[0] == 2
if early_close:
assert submitted == [0, 1, 2]
assert cancelled == ([] if ordered else [0, 1])
else:
assert sorted(consumed) == list(range(10))
assert cancelled == []