blob: 58f26a51c0398b6da4fd355ed6e6c52fc6d2338d [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 unittest
import threading
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon.execution.python.read.table_read import PythonTableRead
from pypaimon.read.table_read import TableRead
from pypaimon.execution.python.read.search.vector_search_read import BatchVectorSearchReadImpl
from pypaimon.tests.data_evolution_test_helpers import BatchModeMixin, DataEvolutionTestBase
from pypaimon.utils.range import Range
def _scores(result):
getter = result.score_getter()
return {row_id: getter(row_id) for row_id in result.results()}
class BatchVectorRawScanTest(BatchModeMixin, DataEvolutionTestBase, unittest.TestCase):
pa_schema = pa.schema([
('id', pa.int32()), ('embedding', pa.list_(pa.float32())), ('pt', pa.int32()),
])
table_options = {
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'global-index.enabled': 'true', 'bucket': '-1', 'file.format': 'parquet',
'vector-index.search-mode': 'full',
'read.batch-size': '2',
}
def _data(self, vectors, partition=0):
return pa.table({'id': list(range(len(vectors))), 'embedding': vectors,
'pt': [partition] * len(vectors)}, schema=self.pa_schema)
def _reader(self, table, queries, metric='l2', **kwargs):
return BatchVectorSearchReadImpl(
table, limit=2, vector_column=table.field_dict['embedding'],
query_vectors=queries, options={'ivf-flat.metric': metric}, **kwargs)
def test_shared_scan_matches_individual_queries_for_all_metrics(self):
table = self._create_table()
self._write_arrow(table, self._data([
[1, 0], [0, 1], None, [0, 0], [1, 0], [-1, 2], [2, -1],
]))
queries = [[1, 0], [0, 1], [0, 0], [1, 0]]
ranges = [Range(0, 4), Range(3, 6)]
for metric in ('l2', 'cosine', 'inner_product'):
with self.subTest(metric=metric):
reader = self._reader(table, queries, metric)
expected = [_scores(reader._read_raw_search(
ranges, None, query, 'ivf-flat')) for query in queries]
with patch.object(reader, '_plan_raw_read', wraps=reader._plan_raw_read) as plan, \
patch.object(TableRead, 'to_arrow', side_effect=AssertionError(
'Batch fallback must not materialize the full table')):
actual = reader._read_raw_batch_search(ranges, None, 'ivf-flat')
plan.assert_called_once()
self.assertEqual(expected, [_scores(result) for result in actual])
self.assertTrue(all(len(result.results()) <= 2 for result in actual))
def test_filters_and_partition_are_applied_to_the_shared_scan(self):
table = self._create_table(partition_keys=['pt'])
self._write_arrow(table, self._data([[10, 0], [11, 0]], partition=0))
self._write_arrow(table, self._data([[0, 0], [1, 0], [2, 0]], partition=1))
predicates = table.new_read_builder().new_predicate_builder()
reader = self._reader(
table, [[1, 0], [2, 0]],
filter_=predicates.greater_or_equal('id', 1),
partition_filter=predicates.equal('pt', 1))
ranges = [Range(0, 99)]
pre_filter = [Range(1, 99)]
expected = [_scores(reader._read_raw_search(
ranges, pre_filter, q, 'ivf-flat')) for q in reader._query_vectors]
with patch.object(reader, '_plan_raw_read', wraps=reader._plan_raw_read) as plan:
actual = reader._read_raw_batch_search(ranges, pre_filter, 'ivf-flat')
plan.assert_called_once()
self.assertEqual(expected, [_scores(r) for r in actual])
self.assertTrue(all(len(r.results()) == 2 for r in actual))
self.assertTrue(all(max(_scores(r).values()) == 1.0 for r in actual))
def test_empty_prefilter_and_empty_queries_do_not_read(self):
table = self._create_table()
for queries, ranges, pre_filter in (
([[0, 0], [1, 1]], [Range(0, 9)], []),
([[0, 0]], [], None),
([], [Range(0, 9)], None),
):
with self.subTest(queries=queries, ranges=ranges):
reader = self._reader(table, queries)
with patch.object(reader, '_plan_raw_read') as plan:
results = reader._read_raw_batch_search(ranges, pre_filter, 'ivf-flat')
plan.assert_not_called()
self.assertEqual([{} for _ in queries], [_scores(r) for r in results])
def test_empty_and_null_only_data_return_empty_results(self):
for vectors in ([], [None, None, None]):
with self.subTest(vectors=vectors):
table = self._create_table()
if vectors:
self._write_arrow(table, self._data(vectors))
reader = self._reader(table, [[0, 0], [1, 1]])
results = reader._read_raw_batch_search([Range(0, 99)], None, 'ivf-flat')
self.assertEqual([{}, {}], [_scores(r) for r in results])
@pytest.mark.python_read
def test_scoring_finishes_each_batch_before_reading_the_next(self):
table = self._create_table()
self._write_arrow(table, self._data([[1, 0], [0, 1], [2, 0], [0, 2]]))
reader = self._reader(table, [[1, 0], [0, 1]])
original = TableRead._new_arrow_batch_reader
original_generator = PythonTableRead._arrow_batch_generator
generators = []
batch_sizes = []
module = 'pypaimon.execution.python.read.search.vector_search_read'
from pypaimon.execution.python.read.search.vector_search_read import _compute_scores
def batches(table_read, splits):
arrow, generator = original(table_read, splits)
generators.append(generator)
return arrow, generator
def tracked_generator(table_read, *args):
source = original_generator(table_read, *args)
expected_score_blocks = 0
try:
for batch in source:
batch_sizes.append(batch.num_rows)
expected_score_blocks += 2
yield batch
self.assertEqual(expected_score_blocks, score.call_count)
finally:
source.close()
with patch.object(TableRead, '_new_arrow_batch_reader', batches), \
patch.object(PythonTableRead, '_arrow_batch_generator', tracked_generator), \
patch(module + '._compute_scores', wraps=_compute_scores) as score:
reader._read_raw_batch_search([Range(0, 3)], None, 'ivf-flat')
self.assertEqual(4, score.call_count)
self.assertEqual(1, len(generators))
self.assertGreater(len(batch_sizes), 1)
self.assertIsNone(generators[0].gi_frame)
def test_dimension_and_read_failures_close_suspended_iterator(self):
table = self._create_table()
self._write_arrow(table, self._data([[1, 0], [0, 1]]))
schema = pa.schema([('embedding', pa.list_(pa.float32())), ('_ROW_ID', pa.int64())])
for failure in ('dimension', 'read'):
with self.subTest(failure=failure):
closed = []
def generate():
try:
yield pa.RecordBatch.from_arrays([
pa.array([[1, 0]], type=pa.list_(pa.float32())),
pa.array([0], type=pa.int64()),
], schema=schema)
raise RuntimeError('injected read failure')
finally:
closed.append(True)
generator = generate()
arrow = pa.RecordBatchReader.from_batches(schema, generator)
reader = self._reader(table, [[1]] if failure == 'dimension' else [[1, 0]])
exception = ValueError if failure == 'dimension' else RuntimeError
message = 'dimension mismatch' if failure == 'dimension' else 'injected read failure'
with patch.object(TableRead, '_new_arrow_batch_reader', return_value=(arrow, generator)):
with self.assertRaisesRegex(exception, message):
reader._read_raw_batch_search([Range(0, 1)], None, 'ivf-flat')
self.assertEqual([True], closed)
self.assertIsNone(generator.gi_frame)
def test_public_batch_search_uses_planned_snapshot(self):
table = self._create_table()
self._write_arrow(table, self._data([[0, 0]]))
builder = table.new_batch_vector_search_builder().with_vector_column(
'embedding').with_query_vectors([[2, 0], [0, 0]]).with_limit(1)
plan = builder.new_vector_search_scan().scan()
self._write_arrow(table, self._data([[2, 0]]))
reader = builder.new_batch_vector_search_read()
old = reader.read_batch_plan(plan)
self.assertEqual([0.2], list(_scores(old[0]).values()))
self.assertEqual([1.0], list(_scores(old[1]).values()))
current = builder.execute_batch_local()
self.assertEqual([1.0], list(_scores(current[0]).values()))
self.assertNotEqual(list(old[0].results()), list(current[0].results()))
@pytest.mark.python_read
def test_public_batch_search_preserves_split_parallelism(self):
table = self._create_table(partition_keys=['pt'])
for partition in range(4):
self._write_arrow(table, self._data(
[[1, 0], [0, 1], None, [0, 0], [partition, 1]], partition))
original = PythonTableRead._arrow_batch_generator
for parallelism in (1, 2, 4, None):
options = {} if parallelism is None else {'read.parallelism': str(parallelism)}
read_table = table.copy(options)
expected_workers = 4 if parallelism is None else parallelism
for metric in ('l2', 'cosine', 'inner_product'):
for queries in ([[1, 0]], [[1, 0], [0, 1]]):
with self.subTest(parallelism=parallelism, metric=metric, queries=queries):
expected = [_scores(read_table.new_vector_search_builder()
.with_vector_column('embedding').with_query_vector(query)
.with_option('metric', metric).with_limit(2).execute_local())
for query in queries]
barrier = threading.Barrier(expected_workers)
lock = threading.Lock()
state = {'active': 0, 'peak': 0, 'closed': 0}
seen = []
def tracked(table_read, splits, *args):
source = original(table_read, splits, *args)
with lock:
state['active'] += 1
state['peak'] = max(state['peak'], state['active'])
seen.extend(id(split) for split in splits)
try:
barrier.wait(timeout=5)
yield from source
finally:
source.close()
with lock:
state['active'] -= 1
state['closed'] += 1
with patch.object(PythonTableRead, '_arrow_batch_generator', tracked), \
patch('pypaimon.read.read_support.os.cpu_count', return_value=4):
actual = (read_table.new_batch_vector_search_builder()
.with_vector_column('embedding').with_query_vectors(queries)
.with_option('metric', metric).with_limit(2).execute_batch_local())
self.assertEqual(expected, [_scores(result) for result in actual])
self.assertEqual(4, len(seen))
self.assertEqual(4, len(set(seen)))
self.assertEqual({'active': 0, 'peak': expected_workers,
'closed': expected_workers}, state)
@pytest.mark.python_read
def test_parallel_failure_closes_all_started_readers(self):
table = self._create_table(
partition_keys=['pt'], options=dict(self.table_options, **{'read.parallelism': '2'}))
for partition in range(4):
self._write_arrow(table, self._data([[1, 0], [0, 1]], partition))
original = PythonTableRead._arrow_batch_generator
for failure in ('dimension', 'read'):
with self.subTest(failure=failure):
barrier = threading.Barrier(2)
lock = threading.Lock()
started = []
closed = []
def tracked(table_read, *args):
source = original(table_read, *args)
with lock:
worker = len(started)
started.append(worker)
try:
barrier.wait(timeout=5)
for batch in source:
yield batch
if failure == 'read' and worker == 0:
raise RuntimeError('injected parallel read failure')
finally:
source.close()
with lock:
closed.append(worker)
query = [1] if failure == 'dimension' else [1, 0]
exception = ValueError if failure == 'dimension' else RuntimeError
message = 'dimension mismatch' if failure == 'dimension' else 'injected parallel read failure'
with patch.object(PythonTableRead, '_arrow_batch_generator', tracked):
with self.assertRaisesRegex(exception, message):
(table.new_batch_vector_search_builder().with_vector_column('embedding')
.with_query_vectors([query]).with_limit(2).execute_batch_local())
self.assertEqual([0, 1], sorted(closed))
if __name__ == '__main__':
unittest.main()