|
| 1 | +# Licensed to the Apache Software Foundation (ASF) under one |
| 2 | +# or more contributor license agreements. See the NOTICE file |
| 3 | +# distributed with this work for additional information |
| 4 | +# regarding copyright ownership. The ASF licenses this file |
| 5 | +# to you under the Apache License, Version 2.0 (the |
| 6 | +# "License"); you may not use this file except in compliance |
| 7 | +# with the License. You may obtain a copy of the License at |
| 8 | +# |
| 9 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 10 | +# |
| 11 | +# Unless required by applicable law or agreed to in writing, |
| 12 | +# software distributed under the License is distributed on an |
| 13 | +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 14 | +# KIND, either express or implied. See the License for the |
| 15 | +# specific language governing permissions and limitations |
| 16 | +# under the License. |
| 17 | + |
| 18 | +import os |
| 19 | +import unittest |
| 20 | +from unittest.mock import Mock, patch |
| 21 | + |
| 22 | +import numpy as np |
| 23 | +import pyarrow as pa |
| 24 | + |
| 25 | +from pypaimon.globalindex.create_global_index import GlobalIndexBuilder, _write_vector_batch |
| 26 | +from pypaimon.globalindex.vindex.vindex_vector_index_writer import VindexVectorIndexWriter |
| 27 | +from pypaimon.schema.data_types import ArrayType, AtomicType |
| 28 | +from pypaimon.utils.range import Range |
| 29 | + |
| 30 | + |
| 31 | +class VindexBatchWriteTest(unittest.TestCase): |
| 32 | + |
| 33 | + def _writer(self): |
| 34 | + writer = VindexVectorIndexWriter( |
| 35 | + Mock(), '/unused', ArrayType(True, AtomicType('FLOAT')), |
| 36 | + 'ivf-flat', {'ivf-flat.dimension': '2'}, 'embedding') |
| 37 | + self.addCleanup(writer.close) |
| 38 | + return writer |
| 39 | + |
| 40 | + def _contents(self, writer): |
| 41 | + writer._close_temp_files() |
| 42 | + contents = [] |
| 43 | + for path in (writer._row_id_temp_path, writer._vector_temp_path): |
| 44 | + if path is None: |
| 45 | + contents.append(b'') |
| 46 | + else: |
| 47 | + with open(path, 'rb') as stream: |
| 48 | + contents.append(stream.read()) |
| 49 | + return writer._row_count, writer._vector_count, contents |
| 50 | + |
| 51 | + def _assert_parity(self, vectors, row_ids, fast=True): |
| 52 | + scalar, batch = self._writer(), self._writer() |
| 53 | + for vector, row_id in zip(vectors.to_pylist(), row_ids.to_pylist()): |
| 54 | + scalar.write(vector, row_id) |
| 55 | + with patch.object(batch, 'write', wraps=batch.write) as write: |
| 56 | + batch.write_batch(vectors, row_ids) |
| 57 | + if fast: |
| 58 | + write.assert_not_called() |
| 59 | + self.assertEqual(self._contents(scalar), self._contents(batch)) |
| 60 | + |
| 61 | + def test_sliced_list_large_list_and_fixed_size_list(self): |
| 62 | + for array_type in (pa.list_(pa.float32()), pa.large_list(pa.float32()), |
| 63 | + pa.list_(pa.float32(), 2)): |
| 64 | + with self.subTest(array_type=array_type): |
| 65 | + vectors = pa.array([ |
| 66 | + [99, 99], [1, 2], None, [-0.0, 1e-40], [3, 4], [88, 88], |
| 67 | + ], type=array_type).slice(1, 4) |
| 68 | + row_ids = pa.array([77, 0, 3, 8, 2 ** 62, 99], type=pa.int64()).slice(1, 4) |
| 69 | + self._assert_parity(vectors, row_ids) |
| 70 | + |
| 71 | + def test_null_parent_ignores_invalid_child_values(self): |
| 72 | + values = pa.array([1, 2, float('nan'), None, 3, 4], type=pa.float32()) |
| 73 | + vectors = pa.Array.from_buffers(pa.list_(pa.float32()), 3, [ |
| 74 | + pa.py_buffer(b'\x05'), |
| 75 | + pa.py_buffer(np.array([0, 2, 4, 6], dtype=np.int32)), |
| 76 | + ], children=[values]) |
| 77 | + self._assert_parity(vectors, pa.array([0, 1, 2], type=pa.int64())) |
| 78 | + |
| 79 | + def test_empty_and_all_null_batches_do_not_create_files(self): |
| 80 | + for data in ([], [None, None]): |
| 81 | + with self.subTest(data=data): |
| 82 | + writer = self._writer() |
| 83 | + writer.write_batch(pa.array(data, type=pa.list_(pa.float32())), |
| 84 | + pa.array(range(len(data)), type=pa.int64())) |
| 85 | + self.assertEqual((len(data), 0, [b'', b'']), self._contents(writer)) |
| 86 | + |
| 87 | + def test_multiple_batches_and_scalar_writes_can_be_interleaved(self): |
| 88 | + writer = self._writer() |
| 89 | + writer.write([1, 2], 0) |
| 90 | + writer.write_batch(pa.array([[3, 4], None], type=pa.list_(pa.float32())), |
| 91 | + pa.array([2, 3], type=pa.int64())) |
| 92 | + writer.write([5, 6], 4) |
| 93 | + writer.write_batch(pa.array([[7, 8]], type=pa.list_(pa.float32(), 2)), |
| 94 | + pa.array([6], type=pa.int64())) |
| 95 | + count, valid_count, contents = self._contents(writer) |
| 96 | + self.assertEqual((5, 4), (count, valid_count)) |
| 97 | + self.assertEqual([0, 2, 4, 6], np.frombuffer(contents[0], dtype=np.int64).tolist()) |
| 98 | + self.assertEqual(list(range(1, 9)), np.frombuffer(contents[1], dtype=np.float32).tolist()) |
| 99 | + |
| 100 | + def test_invalid_vectors_preserve_scalar_error_and_written_prefix(self): |
| 101 | + cases = [ |
| 102 | + [[1, 2], [3]], |
| 103 | + [[1, 2], [None, 4]], |
| 104 | + [[1, 2], [float('nan'), 4]], |
| 105 | + [[1, 2], [3, float('inf')]], |
| 106 | + [[1, 2], [3, float('-inf')]], |
| 107 | + [[float('nan'), 2], [3]], |
| 108 | + [[1, 2], [float('nan'), None]], |
| 109 | + [[1, 2], [None, float('nan')]], |
| 110 | + ] |
| 111 | + for data in cases: |
| 112 | + with self.subTest(data=data): |
| 113 | + vectors = pa.array(data, type=pa.list_(pa.float32())) |
| 114 | + ids = pa.array([5, 9], type=pa.int64()) |
| 115 | + scalar, batch = self._writer(), self._writer() |
| 116 | + with self.assertRaises(ValueError) as old_error: |
| 117 | + for vector, row_id in zip(vectors.to_pylist(), ids.to_pylist()): |
| 118 | + scalar.write(vector, row_id) |
| 119 | + with self.assertRaises(ValueError) as new_error: |
| 120 | + batch.write_batch(vectors, ids) |
| 121 | + self.assertEqual(str(old_error.exception), str(new_error.exception)) |
| 122 | + self.assertEqual(self._contents(scalar), self._contents(batch)) |
| 123 | + |
| 124 | + def test_float64_and_non_int64_ids_fall_back_to_scalar(self): |
| 125 | + self._assert_parity( |
| 126 | + pa.array([[1.1, 2.2], None], type=pa.list_(pa.float64())), |
| 127 | + pa.array([0, 1], type=pa.int64()), fast=False) |
| 128 | + self._assert_parity( |
| 129 | + pa.array([[1, 2], [3, 4]], type=pa.list_(pa.float32())), |
| 130 | + pa.array([0, 1], type=pa.int32()), fast=False) |
| 131 | + |
| 132 | + def test_batch_length_and_null_row_id_validation(self): |
| 133 | + vectors = pa.array([[1, 2]], type=pa.list_(pa.float32())) |
| 134 | + writer = self._writer() |
| 135 | + with self.assertRaisesRegex(ValueError, 'batch lengths differ'): |
| 136 | + writer.write_batch(vectors, pa.array([], type=pa.int64())) |
| 137 | + with self.assertRaisesRegex(ValueError, '_ROW_ID is null'): |
| 138 | + writer.write_batch(vectors, pa.array([None], type=pa.int64())) |
| 139 | + self.assertEqual((0, 0, [b'', b'']), self._contents(writer)) |
| 140 | + |
| 141 | + def test_close_removes_batch_files_and_rejects_further_writes(self): |
| 142 | + writer = self._writer() |
| 143 | + vectors = pa.array([[1, 2]], type=pa.list_(pa.float32())) |
| 144 | + ids = pa.array([0], type=pa.int64()) |
| 145 | + writer.write_batch(vectors, ids) |
| 146 | + paths = writer._row_id_temp_path, writer._vector_temp_path |
| 147 | + writer.close() |
| 148 | + self.assertTrue(all(not os.path.exists(path) for path in paths)) |
| 149 | + with self.assertRaisesRegex(RuntimeError, 'already closed'): |
| 150 | + writer.write_batch(vectors, ids) |
| 151 | + |
| 152 | + def test_builder_filters_ranges_before_vector_validation(self): |
| 153 | + vectors = pa.array([[float('nan'), 0], [1, 2], None, [3, 4], [5]], |
| 154 | + type=pa.list_(pa.float32())) |
| 155 | + ids = pa.array([9, 10, 11, 19, 20], type=pa.int64()) |
| 156 | + batch = pa.RecordBatch.from_arrays([vectors, ids], ['embedding', '_ROW_ID']) |
| 157 | + writer = self._writer() |
| 158 | + _write_vector_batch(writer, batch, 'embedding', Range(10, 19)) |
| 159 | + count, valid_count, contents = self._contents(writer) |
| 160 | + self.assertEqual((3, 2), (count, valid_count)) |
| 161 | + self.assertEqual([0, 9], np.frombuffer(contents[0], dtype=np.int64).tolist()) |
| 162 | + self.assertEqual([1, 2, 3, 4], np.frombuffer(contents[1], dtype=np.float32).tolist()) |
| 163 | + batch = pa.RecordBatch.from_arrays([ |
| 164 | + pa.array([[1, 2]], type=pa.list_(pa.float32())), |
| 165 | + pa.array([None], type=pa.int64()), |
| 166 | + ], ['embedding', '_ROW_ID']) |
| 167 | + with self.assertRaisesRegex(ValueError, '_ROW_ID is null'): |
| 168 | + _write_vector_batch(self._writer(), batch, 'embedding', Range(10, 19)) |
| 169 | + |
| 170 | + def test_null_row_ids_are_rejected_before_writing_any_batch(self): |
| 171 | + builder = object.__new__(GlobalIndexBuilder) |
| 172 | + builder._core_options = Mock() |
| 173 | + builder._core_options.global_index_row_count_per_shard.return_value = 10 |
| 174 | + builder._index_type = 'ivf-flat' |
| 175 | + builder._index_columns = ['embedding'] |
| 176 | + writer = Mock() |
| 177 | + builder._create_generic_index_writer = Mock(return_value=writer) |
| 178 | + read = Mock() |
| 179 | + read.to_arrow.return_value = pa.table({ |
| 180 | + 'embedding': pa.array([[1], [3, 4]], type=pa.list_(pa.float32())), |
| 181 | + '_ROW_ID': pa.array([0, None], type=pa.int64()), |
| 182 | + }) |
| 183 | + module = 'pypaimon.globalindex.create_global_index' |
| 184 | + with patch(module + '._split_by_global_index_shard', return_value=[ |
| 185 | + (Mock(), Range(0, 9)), |
| 186 | + ]), patch(module + '.ADD_BATCH_SIZE', 1): |
| 187 | + with self.assertRaisesRegex(ValueError, '_ROW_ID is null'): |
| 188 | + builder._build_generic_index([], [], Mock(), read, '/unused') |
| 189 | + writer.write_batch.assert_not_called() |
| 190 | + writer.finish.assert_not_called() |
| 191 | + writer.close.assert_called_once() |
| 192 | + |
| 193 | + |
| 194 | +if __name__ == '__main__': |
| 195 | + unittest.main() |
0 commit comments