blob: 5a599f2699e18f6d6e2f6520dc99ae94a058496b [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
from unittest import mock
from types import SimpleNamespace
from pypaimon.common.options.core_options import GlobalIndexSearchMode
from pypaimon.index.pk.primary_key_index_definitions import PrimaryKeyIndexDefinitions
from pypaimon.index.pk.primary_key_index_source_file import PrimaryKeyIndexSourceFile
from pypaimon.index.pk.primary_key_index_source_meta import PrimaryKeyIndexSourceMeta
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.schema.table_schema import TableSchema
from pypaimon.table.source.full_text_search_builder import FullTextSearchBuilderImpl
from pypaimon.table.source.primary_key_full_text_read import PrimaryKeyFullTextRead
from pypaimon.table.source.primary_key_full_text_scan import PrimaryKeyFullTextScan
from pypaimon.table.source.primary_key_full_text_scan import (
_current_payloads, _should_read_source as _full_text_should_read_source)
from pypaimon.table.source.primary_key_vector_read import (
PrimaryKeyVectorRead, _allowed, _include_row_ids)
from pypaimon.table.source.primary_key_vector_scan import (
PrimaryKeyVectorScan, _bucket_splits, _should_read_source)
from pypaimon.table.source.primary_key_scored_result import (
PrimaryKeyScoredResult, PrimaryKeySearchPosition)
from pypaimon.table.source.vector_search_builder import VectorSearchBuilderImpl
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.read.split import DataSplit
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.utils.range import Range
class PrimaryKeyIndexDefinitionsTest(unittest.TestCase):
def test_definitions_follow_schema_order(self):
schema = TableSchema(
fields=[DataField(0, "id", AtomicType("INT")),
DataField(1, "name", AtomicType("STRING"))],
options={"pk-btree.index.columns": "name",
"pk-bitmap.index.columns": "id",
"fields.name.pk-btree.index.options":
'{"block-size": "4096"}'},
)
definitions = PrimaryKeyIndexDefinitions.create(schema).definitions
self.assertEqual(["id", "name"], [d.column for d in definitions])
self.assertEqual(["bitmap", "btree"], [d.index_type for d in definitions])
self.assertEqual(
"4096", definitions[1].options.to_map()["btree-index.block-size"])
def test_source_meta_uses_java_binary_layout(self):
meta = PrimaryKeyIndexSourceMeta(
2, [PrimaryKeyIndexSourceFile("数据-\0-😀.parquet", 7)])
restored = PrimaryKeyIndexSourceMeta.deserialize(meta.serialize())
self.assertEqual(meta, restored)
def test_vector_and_full_text_definitions(self):
schema = TableSchema(
fields=[DataField(0, "embedding", AtomicType("VECTOR(3)")),
DataField(1, "content", AtomicType("STRING"))],
options={
"pk-vector.index.columns": "embedding",
"fields.embedding.pk-vector.index.type": "vindex",
"fields.embedding.pk-vector.index.options": '{"m": "16"}',
"pk-full-text.index.columns": "content",
"fields.content.pk-full-text.index.options": '{"analyzer": "english"}',
})
definitions = PrimaryKeyIndexDefinitions.create(schema).definitions
self.assertEqual(["vindex", "full-text"], [d.index_type for d in definitions])
self.assertEqual("16", definitions[0].options.to_map()["vindex.m"])
self.assertEqual(
"inner_product", definitions[0].options.to_map()["vindex.metric"])
self.assertEqual(
"english", definitions[1].options.to_map()["full-text.analyzer"])
def test_builders_route_primary_key_searches(self):
schema = TableSchema(
fields=[DataField(0, "embedding", AtomicType("VECTOR(3)")),
DataField(1, "content", AtomicType("STRING"))],
options={
"pk-vector.index.columns": "embedding",
"fields.embedding.pk-vector.index.type": "vindex",
"pk-full-text.index.columns": "content",
})
table = SimpleNamespace(
fields=schema.fields, table_schema=schema, partition_keys=[])
vector = (VectorSearchBuilderImpl(table)
.with_vector_column("embedding")
.with_query_vector([1.0, 2.0, 3.0])
.with_limit(2))
full_text = (FullTextSearchBuilderImpl(table)
.with_query("content", "paimon")
.with_limit(2))
self.assertIsInstance(vector.new_vector_search_scan(), PrimaryKeyVectorScan)
self.assertIsInstance(vector.new_vector_search_read(), PrimaryKeyVectorRead)
self.assertIsInstance(full_text.new_full_text_scan(), PrimaryKeyFullTextScan)
self.assertIsInstance(full_text.new_full_text_read(), PrimaryKeyFullTextRead)
def test_primary_key_full_text_only_supports_fast_mode(self):
fields = [DataField(0, "id", AtomicType("INT")),
DataField(1, "content", AtomicType("STRING"))]
def builder(mode):
schema = TableSchema(
fields=fields,
options={"pk-full-text.index.columns": "content",
"global-index.search-mode": mode})
table = SimpleNamespace(
fields=fields, table_schema=schema, partition_keys=[])
return (FullTextSearchBuilderImpl(table)
.with_query("content", "paimon").with_limit(2))
self.assertIsInstance(
builder("fast").new_full_text_read(), PrimaryKeyFullTextRead)
schema = TableSchema(
fields=fields,
options={
"pk-full-text.index.columns": "content",
"global-index.search-mode": "full",
"full-text-index.search-mode": "fast",
})
table = SimpleNamespace(
fields=fields, table_schema=schema, partition_keys=[])
self.assertIsInstance(
FullTextSearchBuilderImpl(table)
.with_query("content", "paimon")
.with_limit(2)
.new_full_text_read(),
PrimaryKeyFullTextRead,
)
for mode in ("full", "detail"):
with self.subTest(mode=mode), self.assertRaisesRegex(
NotImplementedError, "only supports the FAST"):
builder(mode).new_full_text_read()
def test_primary_key_full_text_reader_cannot_bypass_fast_mode(self):
field = DataField(1, "content", AtomicType("STRING"))
for mode in ("full", "detail"):
schema = TableSchema(
fields=[field],
options={"global-index.search-mode": mode})
table = SimpleNamespace(table_schema=schema)
with self.subTest(mode=mode), self.assertRaisesRegex(
NotImplementedError, "only supports the FAST"):
PrimaryKeyFullTextRead(
table, 1, field, "paimon", definition=None)
def test_scored_result_uses_file_local_positions(self):
partition = GenericRow([], [])
data_file = DataFileMeta(
file_name="data.parquet", file_size=10, row_count=5,
min_key=None, max_key=None, key_stats=None, value_stats=None,
min_sequence_number=0, max_sequence_number=0,
schema_id=0, level=1, extra_files=[])
source = DataSplit([data_file], partition, 3)
partition_bytes = repr(tuple(partition.values)).encode("utf-8")
result = PrimaryKeyScoredResult(7, [source], [
PrimaryKeySearchPosition(partition_bytes, 3, "data.parquet", 1, 0.9),
PrimaryKeySearchPosition(partition_bytes, 3, "data.parquet", 2, 0.8),
PrimaryKeySearchPosition(partition_bytes, 3, "data.parquet", 4, 0.7),
])
self.assertEqual(7, result.snapshot_id)
self.assertEqual([(1, 2), (4, 4)],
[(r.from_, r.to) for r in result.splits[0].row_ranges()])
self.assertEqual([0.9, 0.8, 0.7], result.splits[0].scores())
def test_pk_vector_filter_is_applied_before_top_k(self):
split = SimpleNamespace(row_ranges_by_file={
"first.parquet": (Range(1, 2),),
"second.parquet": (Range(0, 1),),
})
sources = [
PrimaryKeyIndexSourceFile("first.parquet", 4),
PrimaryKeyIndexSourceFile("second.parquet", 3),
]
deleted = {(id(split), "first.parquet"): {1}}
include = _include_row_ids(split, sources, deleted)
# first[2] maps to payload row 2; second[0,1] map to rows 4 and 5.
self.assertEqual([2, 4, 5], list(include))
def test_empty_pk_vector_ranges_mean_no_candidates(self):
split = SimpleNamespace(row_ranges_by_file={"data.parquet": tuple()})
source = PrimaryKeyIndexSourceFile("data.parquet", 3)
include = _include_row_ids(split, [source], {})
self.assertEqual([], list(include))
self.assertFalse(_allowed(split, "data.parquet", 0))
def test_pk_vector_refine_factor_overflow_is_rejected_before_search(self):
table = SimpleNamespace(options=SimpleNamespace(
primary_key_vector_index_type=lambda column: "ivf-pq",
vector_index_search_mode=lambda: GlobalIndexSearchMode.FAST))
reader = PrimaryKeyVectorRead(
table, 2147483647, DataField(1, "embedding", AtomicType("VECTOR(1)")),
[0.0], options={"ivf.refine_factor": "2"})
from pypaimon.table.source.primary_key_vector_scan import (
PrimaryKeyVectorScanPlan)
plan = PrimaryKeyVectorScanPlan(1, [])
with self.assertRaisesRegex(ValueError, "limit overflow"):
reader.read_plan(plan)
def test_pk_vector_raw_fallback_uses_vector_search_mode(self):
from pypaimon.table.source.primary_key_vector_scan import (
PrimaryKeyVectorScanPlan)
field = DataField(1, "embedding", AtomicType("VECTOR(1)"))
plan = PrimaryKeyVectorScanPlan(1, [])
for mode, expected_calls in (
(GlobalIndexSearchMode.FAST, 0),
(GlobalIndexSearchMode.FULL, 1),
(GlobalIndexSearchMode.DETAIL, 1)):
table = SimpleNamespace(options=SimpleNamespace(
primary_key_vector_index_type=lambda column: "ivf-pq",
vector_index_search_mode=lambda mode=mode: mode))
reader = PrimaryKeyVectorRead(table, 1, field, [0.0])
with self.subTest(mode=mode), mock.patch.object(
reader, "_raw_candidates", return_value=[]) as raw_candidates:
reader.read_plan(plan)
self.assertEqual(expected_calls, raw_candidates.call_count)
def test_full_text_payload_planner_rejects_duplicate_and_stale_levels(self):
files = [SimpleNamespace(file_name="a.parquet", row_count=2, level=1)]
source_meta = PrimaryKeyIndexSourceMeta(
1, [PrimaryKeyIndexSourceFile("a.parquet", 2)])
def payload(name, row_count=2, start=0, end=1):
return SimpleNamespace(
file_name=name, row_count=row_count,
global_index_meta=SimpleNamespace(
row_range_start=start, row_range_end=end))
current = payload("current")
duplicate = payload("duplicate")
stale = payload("stale", end=0)
with mock.patch.object(
PrimaryKeyIndexSourceMeta, "from_index_file",
return_value=source_meta):
selected, covered = _current_payloads(
files, [current, duplicate, stale])
self.assertEqual([], selected)
self.assertEqual(set(), covered)
def test_vector_payload_planner_rejects_duplicate_level(self):
partition = GenericRow([], [])
data_file = DataFileMeta(
file_name="a.parquet", file_size=10, row_count=2,
min_key=None, max_key=None, key_stats=None, value_stats=None,
min_sequence_number=0, max_sequence_number=0,
schema_id=0, level=1, extra_files=[], file_source=1)
split = DataSplit([data_file], partition, 0)
source_meta = PrimaryKeyIndexSourceMeta(
1, [PrimaryKeyIndexSourceFile("a.parquet", 2)])
def payload(name):
return SimpleNamespace(
file_name=name, row_count=2,
global_index_meta=SimpleNamespace(
row_range_start=0, row_range_end=1))
entries = [
SimpleNamespace(partition=partition, bucket=0,
index_file=payload("first")),
SimpleNamespace(partition=partition, bucket=0,
index_file=payload("duplicate")),
]
with mock.patch.object(
PrimaryKeyIndexSourceMeta, "from_index_file",
return_value=source_meta):
planned = _bucket_splits([(split, {})], entries)
self.assertEqual(tuple(), planned[0].payloads)
self.assertEqual(("a.parquet",), planned[0].uncovered_data_files)
def test_full_text_source_policy_matches_java(self):
self.assertTrue(_full_text_should_read_source(
SimpleNamespace(file_source=1, level=1)))
self.assertFalse(_full_text_should_read_source(
SimpleNamespace(file_source=1, level=0)))
self.assertFalse(_full_text_should_read_source(
SimpleNamespace(file_source=0, level=2)))
def test_pk_vector_scan_pins_source_and_index_to_same_snapshot(self):
schema = TableSchema(
fields=[DataField(1, "embedding", AtomicType("VECTOR(1)"))],
options={"pk-vector.index.columns": "embedding",
"scan.tag-name": "old-tag"})
snapshot = SimpleNamespace(id=17)
copied_options = []
index_snapshots = []
class _ScanTable:
table_schema = schema
fields = schema.fields
def new_read_builder(self):
plan = SimpleNamespace(splits=lambda: [])
scan = SimpleNamespace(plan=lambda: plan)
return SimpleNamespace(new_scan=lambda: scan)
class _Table:
table_schema = schema
fields = schema.fields
def tag_manager(self):
return None
def snapshot_manager(self):
return SimpleNamespace(get_latest_snapshot=lambda: snapshot)
def copy(self, options):
copied_options.append(options)
return _ScanTable()
class _IndexHandler:
def __init__(self, table):
self.table = table
def scan(self, selected_snapshot, entry_filter):
index_snapshots.append(selected_snapshot)
return []
with mock.patch(
"pypaimon.table.source.primary_key_vector_scan."
"TimeTravelUtil.try_travel_to_snapshot",
return_value=snapshot), mock.patch(
"pypaimon.table.source.primary_key_vector_scan.IndexFileHandler",
_IndexHandler):
plan = PrimaryKeyVectorScan(
_Table(), schema.fields[0], index_type="vindex").scan()
self.assertEqual(17, plan.snapshot_id)
self.assertEqual([snapshot], index_snapshots)
self.assertEqual("from-snapshot", copied_options[0]["scan.mode"])
self.assertEqual("17", copied_options[0]["scan.snapshot-id"])
self.assertIsNone(copied_options[0]["scan.tag-name"])
def test_pk_index_source_policy_matches_java(self):
compact = SimpleNamespace(file_source=1, level=1)
append = SimpleNamespace(file_source=0, level=1)
level_zero = SimpleNamespace(file_source=1, level=0)
legacy = SimpleNamespace(file_source=None, level=1)
self.assertTrue(_should_read_source(compact))
self.assertFalse(_should_read_source(append))
self.assertFalse(_should_read_source(level_zero))
self.assertFalse(_should_read_source(legacy))
if __name__ == "__main__":
unittest.main()