blob: 171fdb1ab1a85dcccf13559cdfe8debe433d2d7a [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.
"""Captured row-ID execution preserves row order, ownership and read grants."""
from dataclasses import replace
import pickle
import sys
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pyarrow as pa
import pytest
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
from pypaimon.execution.native.write.row_id_plan import NativeRowIdPlan
from pypaimon.execution.python.write.row_id_plan import PythonRowIdPlan
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.simple_stats import SimpleStats
from pypaimon.read.plan import Plan
from pypaimon.read.query_auth_split import QueryAuthSplit
from pypaimon.read.scan_stats import ScanStats, _NATIVE_COUNTER_FIELDS
from pypaimon.read.split import DataSplit
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.source.deletion_file import DeletionFile
from pypaimon.utils.range import Range
from pypaimon.write.row_id_plan import CapturedRowIdPlan
pytestmark = [pytest.mark.python_plan, pytest.mark.python_read,
pytest.mark.python_write, pytest.mark.python_commit]
def _file(name, first, count):
empty = GenericRow([], [])
return DataFileMeta.create(
name, 100, count, empty, empty, SimpleStats.empty_stats(), SimpleStats.empty_stats(),
0, 0, 0, 0, [], first_row_id=first, external_path='memory:/external/' + name,
file_path='memory:/external/' + name)
def _split(first, count, partition, bucket=0, dedicated=False):
files = [_file('data-{}.parquet'.format(first), first, count)]
if dedicated:
files += [replace(files[0], file_name='delta.parquet', max_sequence_number=1,
write_cols=['value']), _file('payload.blob', first, count)]
field = DataField(0, 'p', AtomicType('STRING'))
return DataSplit(files, GenericRow([partition], [field]), bucket,
data_deletion_files=[DeletionFile('memory:/dv', 0, 8, 1)] * len(files),
snapshot_id=7, bucket_path='memory:/captured/bucket-{}'.format(bucket),
total_buckets=4)
@pytest.fixture
def captured():
grant = TableQueryAuthResult(['value > 0'], {'value': 'value + 1'})
splits = [_split(50, 2, 'west', 1), _split(20, 3, 'west'),
QueryAuthSplit(_split(10, 3, 'west', dedicated=True), grant),
_split(30, 2, 'east'), _split((1 << 63) - 1, 1, 'west')]
plan = Plan(splits, 7, _scan_stats=ScanStats(entries_total=17), _stats_limit=3)
execution = PythonRowIdPlan(plan)
return CapturedRowIdPlan(None, plan, execution.groups, execution)
def test_python_routing_keeps_unsorted_duplicates_and_inclusive_ends(captured):
row_ids = pa.array([22, 10, 31, 11, 20, 51, 22, (1 << 63) - 1], type=pa.int64())
assert captured.route(row_ids).to_pylist() == [20, 10, 30, 10, 20, 50, 20, (1 << 63) - 1]
# Two independent anchors in one partition/bucket share the oldest owner.
# The same bucket in a different partition and a different bucket do not.
assert captured.route_by_bucket(row_ids).to_pylist() == [10, 10, 30, 10, 10, 50, 10, 10]
@pytest.mark.parametrize('operation', ['route', 'route_by_bucket', 'select'])
@pytest.mark.parametrize('row_id', [-1, 9, 13, 19, 23, 32, 49, 52])
def test_python_routing_rejects_foreign_ids_and_gaps(captured, operation, row_id):
with pytest.raises(ValueError, match='valid range'):
getattr(captured, operation)(pa.array([10, row_id], type=pa.int64()))
@pytest.mark.parametrize('operation', ['route', 'route_by_bucket', 'select'])
@pytest.mark.parametrize('row_ids,error', [
(pa.array([10.5]), TypeError), (pa.array([True]), TypeError), (pa.array(['10']), TypeError),
(pa.array([10, None], type=pa.int64()), ValueError),
(pa.array([1 << 63], type=pa.uint64()), ValueError), ([10], TypeError),
])
def test_public_routing_validates_ids_before_backend_execution(captured, operation, row_ids, error):
execution = Mock()
captured = replace(captured, _execution=execution)
with pytest.raises(error):
getattr(captured, operation)(row_ids)
getattr(execution, operation).assert_not_called()
def test_chunked_integer_input_is_normalized_without_float_coercion(captured):
row_ids = pa.chunked_array([pa.array([10], type=pa.int32()), pa.array([22, 31], type=pa.int32())])
assert captured.route(row_ids).to_pylist() == [10, 20, 30]
def test_selection_deduplicates_absolute_ids_and_preserves_complete_groups(captured):
original_splits = captured.plan.splits()[:]
selected = captured.select(pa.array([21, 12, 10, 11, 21, (1 << 63) - 1], type=pa.int64()))
assert selected.snapshot_id == 7
assert selected._scan_stats is not captured.plan._scan_stats
assert selected._scan_stats == captured.plan._scan_stats
assert selected._stats_limit == 3
assert [split.row_ranges() for split in selected.splits()] == [
[Range(10, 12)], [Range(21, 21)], [Range((1 << 63) - 1, (1 << 63) - 1)]]
authorized = selected.splits()[0]
original = next(split for split in original_splits if isinstance(split, QueryAuthSplit))
assert isinstance(authorized, QueryAuthSplit)
assert authorized.auth_result is original.auth_result
assert authorized.files == original.files
assert len(authorized.files) == 3
assert authorized.data_deletion_files == original.data_deletion_files
assert authorized.split.data_split().bucket_path == original.bucket_path
assert authorized.split.data_split().total_buckets == original.total_buckets
assert authorized.snapshot_id == original.snapshot_id
assert captured.plan.splits() == original_splits
assert captured.groups[0][2].bucket_path == original.bucket_path
def test_empty_routing_and_selection_keep_snapshot(captured):
ids = pa.array([], type=pa.int64())
for view in (captured, CapturedRowIdPlan(None, Plan([], None), [], PythonRowIdPlan(Plan([], None)))):
assert view.route(ids).to_pylist() == []
assert view.route_by_bucket(ids).to_pylist() == []
assert view.select(ids).splits() == []
assert view.select(ids).snapshot_id == view.plan.snapshot_id
def test_python_captured_routing_pickle_and_complete_group_scope(captured):
restored = pickle.loads(pickle.dumps(captured))
assert restored.snapshot_id == 7
assert restored.route(pa.array([22, 10])).to_pylist() == [20, 10]
single = restored.for_split(restored.groups[1][2])
assert single.route_by_bucket(pa.array([20, 22])).to_pylist() == [20, 20]
with pytest.raises(ValueError, match='valid range'):
single.route(pa.array([10]))
@pytest.mark.parametrize('operation', ['route', 'route_by_bucket', 'select'])
def test_native_routing_is_arrow_transport_and_never_replays_python(captured, operation):
core = Mock()
expected = (Plan([], 7) if operation == 'select' else pa.array([10, 20], type=pa.int64()))
getattr(core, operation).return_value = expected
execution = NativeRowIdPlan(None, captured.plan, native=core, groups=captured.groups)
captured = replace(captured, _execution=execution)
source = pa.chunked_array([pa.array([12], type=pa.int32()), pa.array([20], type=pa.int32())])
with patch.object(PythonRowIdPlan, '__init__', side_effect=AssertionError('Python replay')), \
patch('pypaimon.execution.native.read.plan._from_native_plan', return_value=expected) as decode:
actual = getattr(captured, operation)(source)
if operation == 'select':
assert actual == expected
assert actual._scan_stats is not captured.plan._scan_stats
assert actual._scan_stats == captured.plan._scan_stats
assert actual._stats_limit == 3
else:
assert actual is expected
transported = getattr(core, operation).call_args.args[0]
assert transported.type == pa.int64()
assert transported.to_pylist() == [12, 20]
assert decode.call_count == int(operation == 'select')
failure = PermissionError('injected Core routing error')
getattr(core, operation).side_effect = failure
with patch.object(PythonRowIdPlan, '__init__', side_effect=AssertionError('Python replay')):
with pytest.raises(PermissionError) as raised:
getattr(captured, operation)(source)
assert raised.value is failure
def test_native_routing_pickle_rebuilds_only_the_captured_plan(captured, monkeypatch):
sdk = SimpleNamespace(RowIdPlan=Mock())
monkeypatch.setitem(sys.modules, 'pypaimon_rust.datafusion', sdk)
sdk.RowIdPlan.return_value.route.return_value = pa.array([20, 10], type=pa.int64())
execution = NativeRowIdPlan(None, captured.plan, native=lambda: None, groups=captured.groups)
restored = pickle.loads(pickle.dumps(replace(captured, _execution=execution)))
assert restored._execution._native is None
assert restored.snapshot_id == 7
wire = object()
with patch('pypaimon.execution.native.write.row_id_plan.native_plan', return_value=wire) as transport, \
patch('pypaimon.execution.native.write.row_id_plan.create_native_row_id_plan',
side_effect=AssertionError('backend reselected')), \
patch('pypaimon.read.table_scan.TableScan.plan_all', side_effect=AssertionError('worker replanned')), \
patch.object(PythonRowIdPlan, '__init__', side_effect=AssertionError('Python replay')):
assert restored.route(pa.array([22, 10])).to_pylist() == [20, 10]
assert restored.route(pa.array([22, 10])).to_pylist() == [20, 10]
sdk.RowIdPlan.assert_called_once_with(wire)
assert transport.call_args.args[1].snapshot_id == 7
assert transport.call_args.args[1].splits() == restored.plan.splits()
scoped = restored.for_split(restored.groups[0][2])
assert isinstance(scoped._execution, NativeRowIdPlan)
assert len(scoped.plan.splits()) == 1
@pytest.mark.parametrize('dedicated_name', ['payload.blob', 'embedding.vector.parquet'])
def test_dedicated_files_do_not_merge_normal_owners_or_fill_row_id_gaps(dedicated_name):
original = _split(10, 3, 'west')
other = _file('data-20.parquet', 20, 3)
dedicated = _file(dedicated_name, 10, 13)
files = original.files + [other, dedicated]
deletion_files = [DeletionFile('memory:/dv-' + str(index), 0, 8, 1) for index in range(3)]
split = DataSplit(files, original.partition, 0, data_deletion_files=deletion_files,
snapshot_id=7, bucket_path=original.bucket_path, total_buckets=4)
grant = TableQueryAuthResult(['value > 0'], {})
plan = Plan([QueryAuthSplit(split, grant)], 7)
execution = PythonRowIdPlan(plan)
captured = CapturedRowIdPlan(None, plan, execution.groups, execution)
assert [(first, last) for first, last, _ in captured.groups] == [(10, 12), (20, 22)]
assert captured.route(pa.array([10, 21])).to_pylist() == [10, 20]
assert captured.route_by_bucket(pa.array([10, 21])).to_pylist() == [10, 10]
for first, _, group in captured.groups:
assert isinstance(group, QueryAuthSplit)
assert group.auth_result is grant
assert group.bucket_path == split.bucket_path
assert group.total_buckets == 4
assert {file.file_name for file in group.files} == {'data-{}.parquet'.format(first), dedicated_name}
assert group.data_deletion_files == [deletion_files[0 if first == 10 else 1], deletion_files[2]]
with pytest.raises(ValueError, match='valid range'):
captured.route(pa.array([15]))
selected = captured.select(pa.array([21, 11]))
assert [group.row_ranges() for group in selected.splits()] == [[Range(11, 11)], [Range(21, 21)]]
assert all(dedicated in group.files for group in selected.splits())
def test_overlapping_normal_anchors_with_different_ranges_are_rejected():
original = _split(10, 3, 'west')
overlap = _file('overlap.parquet', 11, 3)
split = DataSplit(original.files + [overlap], original.partition, 0, snapshot_id=7)
with pytest.raises(ValueError, match='identical ranges'):
PythonRowIdPlan(Plan([split], 7))
@pytest.mark.parametrize('state', ['lazy', 'materialized', 'pickled'])
@pytest.mark.parametrize('selected_ids', [[], [10, 21]])
def test_selected_plan_stats_keep_work_and_update_output_without_mutating_original(captured, state, selected_ids):
counters = {field: 0 for field in _NATIVE_COUNTER_FIELDS}
counters.update(manifest_entries_read=99, manifest_entries_after_merge=27)
captured.plan._scan_stats = None
captured.plan._native_stats = SimpleNamespace(**counters)
if state == 'materialized':
captured.plan.stats()
elif state == 'pickled':
captured = pickle.loads(pickle.dumps(captured))
selected = captured.select(pa.array(selected_ids, type=pa.int64()))
source = dict(captured.plan.stats().planning_counters)
selected_stats = selected.stats().planning_counters
assert selected_stats['manifest_entries_read'] == 99
assert selected_stats['manifest_entries_after_merge'] == 27
assert selected_stats['limit'] == 3
assert selected_stats['final_splits'] == selected_stats['splits_after_limit'] == len(selected_ids)
assert selected_stats['final_files'] == (4 if selected_ids else 0)
assert selected_stats['planned_data_file_bytes'] == (400 if selected_ids else 0)
assert captured.plan.stats().planning_counters == source
assert selected.stats() is not captured.plan.stats()
def test_selecting_python_stats_does_not_invent_native_counters(captured):
assert captured.plan.stats().planning_counters is None
selected = captured.select(pa.array([10]))
assert selected.stats().planning_counters is None
assert selected.stats() is not captured.plan.stats()