blob: 07adeeb25d99b237ffe40ff061aece566dbbcf47 [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.mock import patch
import pyarrow as pa
import pytest
from pypaimon.common.options.core_options import CoreOptions, GlobalIndexSearchMode
from pypaimon.common.options.options import Options
from pypaimon.common.predicate import Predicate
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
from pypaimon.globalindex.global_index_evaluator import GlobalIndexEvaluation
from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.index.index_file_meta import IndexFileMeta
from pypaimon.index.index_file_handler import IndexFileHandler
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.snapshot.snapshot_manager import SnapshotManager
from pypaimon.tests.data_evolution_test_helpers import (
BatchModeMixin,
DataEvolutionTestBase,
)
from pypaimon.utils.roaring_bitmap import RoaringBitmap64
from pypaimon.utils.range import Range
class GlobalIndexTest(unittest.TestCase):
def test_primary_key_sorted_index_columns_preserve_java_split_semantics(self):
options = CoreOptions(Options({
"pk-btree.index.columns": "name, id",
"pk-bitmap.index.columns": "category",
}))
self.assertEqual(["name", "id"], options.primary_key_btree_index_columns())
self.assertEqual(["category"], options.primary_key_bitmap_index_columns())
def test_global_index_meta_preserves_primary_key_source_meta(self):
source_meta = b"\x00\x00\x00\x01source-file"
meta = GlobalIndexMeta(
row_range_start=0,
row_range_end=2,
index_field_id=1,
source_meta=source_meta,
)
self.assertEqual(source_meta, meta.source_meta)
def test_chained_or(self):
result = GlobalIndexResult.create_empty()
for i in range(600):
other = GlobalIndexResult.from_range(Range(i * 10, i * 10 + 5))
result = result.or_(other)
self.assertEqual(result.results().cardinality(), 3600)
def test_chained_and(self):
result = GlobalIndexResult.from_range(Range(0, 10000))
for i in range(600):
other = GlobalIndexResult.from_range(Range(0, 10000))
result = result.and_(other)
self.assertEqual(result.results().cardinality(), 10001)
class _CoverageOptions:
def __init__(self, mode):
self.options = Options({"global-index.search-mode": mode})
def scalar_index_search_mode(self):
return CoreOptions(self.options).scalar_index_search_mode()
class _CoverageTable:
def __init__(self, mode, data_ranges=None):
self.options = _CoverageOptions(mode)
self.fields = [
DataField(0, "id", AtomicType("INT")),
DataField(1, "name", AtomicType("STRING")),
]
self._data_ranges = data_ranges or []
def data_ranges_for_data_evolution_global_index_coverage(self, snapshot, partition_filter):
return self._data_ranges
class _CoverageSnapshot:
def __init__(self, next_row_id):
self.next_row_id = next_row_id
def _coverage_index_file(field_id, start, end, extra_field_ids=None):
return IndexFileMeta(
index_type="btree",
file_name="idx-%s-%s-%s" % (field_id, start, end),
file_size=1,
row_count=end - start + 1,
global_index_meta=GlobalIndexMeta(
row_range_start=start,
row_range_end=end,
index_field_id=field_id,
extra_field_ids=extra_field_ids,
index_meta=b"",
),
)
class DataEvolutionGlobalIndexCoverageTest(unittest.TestCase):
def test_fast_mode_does_not_return_unindexed_ranges(self):
from pypaimon.globalindex.data_evolution_global_index_coverage import DataEvolutionGlobalIndexCoverage
table = _CoverageTable(GlobalIndexSearchMode.FAST)
coverage = DataEvolutionGlobalIndexCoverage(
table,
_CoverageSnapshot(10),
None,
[_coverage_index_file(0, 0, 4)],
)
self.assertEqual([], coverage.unindexed_ranges(0))
def test_full_mode_uses_snapshot_next_row_id(self):
from pypaimon.globalindex.data_evolution_global_index_coverage import DataEvolutionGlobalIndexCoverage
table = _CoverageTable("full")
coverage = DataEvolutionGlobalIndexCoverage(
table,
_CoverageSnapshot(10),
None,
[_coverage_index_file(0, 0, 4)],
)
self.assertEqual([Range(5, 9)], coverage.unindexed_ranges(0))
def test_full_mode_accepts_multiple_field_ids(self):
from pypaimon.globalindex.data_evolution_global_index_coverage import DataEvolutionGlobalIndexCoverage
table = _CoverageTable("full")
coverage = DataEvolutionGlobalIndexCoverage(
table,
_CoverageSnapshot(10),
None,
[
_coverage_index_file(0, 0, 9),
_coverage_index_file(1, 0, 4),
],
)
self.assertEqual([Range(5, 9)], coverage.unindexed_ranges([0, 1]))
def test_full_mode_intersects_coverage_for_all_predicate_fields(self):
from pypaimon.globalindex.data_evolution_global_index_coverage import DataEvolutionGlobalIndexCoverage
table = _CoverageTable("full")
coverage = DataEvolutionGlobalIndexCoverage(
table,
_CoverageSnapshot(10),
None,
[
_coverage_index_file(0, 0, 9),
_coverage_index_file(1, 0, 4),
],
)
predicate = PredicateBuilder.and_predicates(
[
Predicate(method="equal", index=0, field="id", literals=[1]),
Predicate(method="equal", index=1, field="name", literals=["a"]),
]
)
self.assertEqual(
[Range(5, 9)],
coverage.unindexed_ranges(table.fields, predicate),
)
def test_extra_fields_count_as_index_coverage(self):
from pypaimon.globalindex.data_evolution_global_index_coverage import DataEvolutionGlobalIndexCoverage
table = _CoverageTable("full")
coverage = DataEvolutionGlobalIndexCoverage(
table,
_CoverageSnapshot(10),
None,
[_coverage_index_file(0, 0, 9, extra_field_ids=[1])],
)
self.assertEqual([], coverage.unindexed_ranges(1))
def test_detail_mode_uses_table_data_ranges(self):
from pypaimon.globalindex.data_evolution_global_index_coverage import DataEvolutionGlobalIndexCoverage
table = _CoverageTable("detail", data_ranges=[Range(0, 2), Range(7, 9)])
coverage = DataEvolutionGlobalIndexCoverage(
table,
_CoverageSnapshot(10),
None,
[_coverage_index_file(0, 0, 4)],
)
self.assertEqual([Range(7, 9)], coverage.unindexed_ranges(0))
class GlobalIndexScalarFallbackTest(unittest.TestCase):
def test_eval_global_index_keeps_unindexed_ranges_out_of_bitmap(self):
from pypaimon.read.scanner.file_scanner import (
FileScanner,
_GlobalIndexPlanningResult,
)
class _Options:
def global_index_enabled(self):
return True
def scalar_index_search_mode(self):
return GlobalIndexSearchMode.FULL
class _Table:
options = _Options()
predicate = Predicate(method="equal", index=0, field="id", literals=[1])
scanner = FileScanner.__new__(FileScanner)
scanner.predicate = predicate
scanner.partition_key_predicate = None
scanner.table = _Table()
index_result = GlobalIndexResult.from_range(Range(1, 1))
unindexed = [Range(5, 6)]
fake_scanner = unittest.mock.MagicMock()
fake_scanner.scan_with_coverage.return_value = GlobalIndexEvaluation(
index_result, frozenset([0]))
fake_scanner.unindexed_ranges.return_value = unindexed
fake_scanner.__enter__.return_value = fake_scanner
fake_scanner.__exit__.return_value = None
with unittest.mock.patch(
"pypaimon.globalindex.data_evolution_global_index_scanner.DataEvolutionGlobalIndexScanner.create",
return_value=fake_scanner), unittest.mock.patch.object(
GlobalIndexResult,
"from_ranges",
side_effect=AssertionError("fallback ranges entered bitmap")):
result = scanner._eval_global_index(snapshot=object())
self.assertIsInstance(result, _GlobalIndexPlanningResult)
self.assertIs(index_result, result.indexed_result)
self.assertEqual(unindexed, result.unindexed_ranges)
fake_scanner.unindexed_ranges.assert_called_once_with(
predicate,
search_mode=GlobalIndexSearchMode.FULL,
contributing_field_ids=frozenset([0]),
)
def test_split_planning_merges_indexed_and_unindexed_ranges(self):
from pypaimon.read.scanner.file_scanner import (
FileScanner,
_GlobalIndexPlanningResult,
)
scanner = FileScanner.__new__(FileScanner)
scanner.manifest_scanner = unittest.mock.MagicMock(
return_value=([], unittest.mock.Mock(id=3)))
scanner._global_index_result = None
scanner._eval_global_index = unittest.mock.MagicMock(
return_value=_GlobalIndexPlanningResult(
GlobalIndexResult.from_range(Range(1, 1)),
[Range(10, 10 ** 12)],
))
scanner.predicate = Predicate(
method="equal", index=0, field="id", literals=[1])
scanner.read_manifest_entries = unittest.mock.MagicMock(return_value=[])
scanner.table = unittest.mock.Mock()
scanner.target_split_size = 1
scanner.open_file_cost = 1
scanner._deletion_files_map = unittest.mock.MagicMock(return_value={})
with unittest.mock.patch(
"pypaimon.read.scanner.file_scanner.DataEvolutionSplitGenerator"
) as split_generator:
scanner._create_data_evolution_split_generator()
self.assertEqual(
[Range(1, 1), Range(10, 10 ** 12)],
split_generator.call_args[0][4],
)
def test_eval_global_index_keeps_none_as_full_scan(self):
from pypaimon.read.scanner.file_scanner import FileScanner
class _Options:
def global_index_enabled(self):
return True
class _Table:
options = _Options()
scanner = FileScanner.__new__(FileScanner)
scanner.predicate = Predicate(
method="equal", index=0, field="id", literals=[1])
scanner.partition_key_predicate = None
scanner.table = _Table()
fake_scanner = unittest.mock.MagicMock()
fake_scanner.scan_with_coverage.return_value = None
fake_scanner.__enter__.return_value = fake_scanner
fake_scanner.__exit__.return_value = None
with unittest.mock.patch(
"pypaimon.globalindex.data_evolution_global_index_scanner.DataEvolutionGlobalIndexScanner.create",
return_value=fake_scanner):
result = scanner._eval_global_index(snapshot=object())
self.assertIsNone(result)
class PlanSnapshotFetchRegressionTest(
BatchModeMixin, DataEvolutionTestBase, unittest.TestCase):
table_options = {
'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true',
'global-index.enabled': 'true',
'bucket': '-1',
}
@pytest.mark.python_plan
def test_plan_accepts_row_ranges_without_bitmap(self):
table = self._create_table()
self._write_arrow(table, pa.table(
{'id': [1, 2, 3], 'name': ['a', 'b', 'c'],
'age': [10, 20, 30], 'city': ['x', 'y', 'z']},
schema=self.pa_schema))
read_builder = table.new_read_builder()
ranges = [Range(0, 10 ** 12)]
with patch.object(
RoaringBitmap64,
'to_range_list',
side_effect=AssertionError('row ranges entered a bitmap')):
plan = read_builder.new_scan().with_row_ranges(ranges).plan()
result = read_builder.new_read().to_arrow(plan.splits())
self.assertEqual([1, 2, 3], sorted(result.column('id').to_pylist()))
self.assertEqual(
[], read_builder.new_scan().with_row_ranges([]).plan().splits())
@pytest.mark.python_plan
def test_plan_fetches_latest_snapshot_only_once(self):
table = self._create_table()
self._write_arrow(table, pa.table(
{'id': [1, 2, 3], 'name': ['a', 'b', 'c'],
'age': [10, 20, 30], 'city': ['x', 'y', 'z']},
schema=self.pa_schema))
fresh_table = self.catalog.get_table(table.identifier.get_full_name())
rb = fresh_table.new_read_builder()
rb = rb.with_filter(rb.new_predicate_builder().equal('id', 1))
orig_get_latest = SnapshotManager.get_latest_snapshot
call_count = [0]
def counting(self_sm, *args, **kwargs):
call_count[0] += 1
return orig_get_latest(self_sm, *args, **kwargs)
with patch.object(SnapshotManager, 'get_latest_snapshot', counting):
rb.new_scan().plan().splits()
self.assertEqual(
1, call_count[0],
msg=f"Plan fetched latest snapshot {call_count[0]} times — "
"duplicate from #7513: manifest_scanner + "
"DataEvolutionGlobalIndexScanner.create both fetch independently.")
@pytest.mark.python_plan
def test_time_travel_plan(self):
table = self._create_table()
self._write_arrow(table, pa.table(
{'id': [1], 'name': ['a'], 'age': [10], 'city': ['x']},
schema=self.pa_schema))
snapshot_1_id = table.snapshot_manager().get_latest_snapshot().id
self._write_arrow(table, pa.table(
{'id': [2], 'name': ['b'], 'age': [20], 'city': ['y']},
schema=self.pa_schema))
travel_table = self.catalog.get_table(
table.identifier.get_full_name()
).copy({'scan.snapshot-id': str(snapshot_1_id)})
rb = travel_table.new_read_builder()
rb = rb.with_filter(rb.new_predicate_builder().equal('id', 1))
orig_scan = IndexFileHandler.scan
seen_snapshot_ids = []
def spy_scan(self_h, snapshot, entry_filter=None):
seen_snapshot_ids.append(snapshot.id if snapshot else None)
return orig_scan(self_h, snapshot, entry_filter)
with patch.object(IndexFileHandler, 'scan', spy_scan):
rb.new_scan().plan().splits()
self.assertTrue(seen_snapshot_ids,
"IndexFileHandler.scan was never called")
self.assertEqual(
snapshot_1_id, seen_snapshot_ids[0],
msg=f"Global index evaluated against snapshot "
f"{seen_snapshot_ids[0]}, expected time-travel snapshot "
f"{snapshot_1_id}. Before #7513 was fixed, "
"DataEvolutionGlobalIndexScanner.create self-fetched latest snapshot, "
"so global index used latest while manifest used the "
"time-travel snapshot — silent correctness bug.")