blob: 4f1236e7bcb281ea092c8f1c05e6500d59e3bd1d [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.
"""Read entry points load only the selected execution backend."""
import subprocess
import sys
import textwrap
from unittest.mock import Mock, patch
import pytest
def _run(script):
result = subprocess.run(
[sys.executable, '-c', textwrap.dedent(script)],
capture_output=True, text=True,
)
assert result.returncode == 0, result.stdout + result.stderr
def test_native_read_scan_and_stream_preparation_do_not_import_python_execution():
_run("""
import importlib.abc
import sys
import tempfile
from types import SimpleNamespace
from unittest.mock import patch
class RejectPythonRead(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname.startswith('pypaimon.execution.python'):
raise AssertionError('Python execution imported: ' + fullname)
sys.meta_path.insert(0, RejectPythonRead())
import pyarrow as pa
from pypaimon import CatalogFactory, Schema
from pypaimon.read.plan import Plan
from pypaimon.read.table_read import TableRead
from pypaimon.read.table_scan import TableScan
from pypaimon.read.stream_read_builder import StreamReadBuilder
assert TableRead.__bases__ == (object,)
assert TableScan.__bases__ == (object,)
with tempfile.TemporaryDirectory() as path:
catalog = CatalogFactory.create({'warehouse': path})
catalog.create_database('default', True)
catalog.create_table('default.lazy', Schema.from_pyarrow_schema(
pa.schema([('id', pa.int32())]), options={
'read.native.enabled': 'true', 'scan.native-plan.enabled': 'true',
}), False)
table = catalog.get_table('default.lazy')
builder = table.new_read_builder()
with patch('pypaimon.execution.native.runtime.native_runtime_available', return_value=True), \
patch('pypaimon.execution.native.read.plan.native_plan', return_value=Plan([], 7)):
assert builder.new_scan().plan().snapshot_id == 7
assert builder.new_scan().plan_all().snapshot_id == 7
assert builder.new_scan().scan_with_stats()[0].snapshot_id == 7
reader = builder.new_read()
assert reader.to_arrow([]).num_rows == 0
batches = reader.to_arrow_batch_reader([])
batches.close()
assert list(reader.to_iterator([])) == []
native = SimpleNamespace(checkpoint=lambda: 8)
with patch('pypaimon.execution.native.read.streaming_table_scan.native_stream_scan', return_value=native):
stream = StreamReadBuilder(table).new_streaming_scan()
assert stream.checkpoint() == 8
assert not any(name.startswith('pypaimon.execution.python') for name in sys.modules)
""")
@pytest.mark.parametrize('failure', [False, True])
def test_native_stream_failure_does_not_create_a_python_reader(failure):
from types import SimpleNamespace
import pyarrow as pa
from pypaimon.read.table_read import TableRead
schema = pa.schema([('id', pa.int32())])
closed = []
def batches():
try:
yield pa.record_batch([[1]], schema=schema)
if failure:
raise OSError('native stream failed')
finally:
closed.append(True)
read = TableRead.__new__(TableRead)
read.table = SimpleNamespace(options=SimpleNamespace(native_read_enabled=lambda: True))
read._read_parallelism = 1
read.limit = None
read.include_row_kind = False
read.predicate = None
read._deferred_blob_fields = set()
read._output_arrow_schema = lambda splits: schema
read._try_native_batches = Mock(return_value=batches())
with patch.dict(sys.modules, {'pypaimon.execution.python.read.table_read': None}):
reader = read.to_arrow_batch_reader([], parallelism=1)
assert reader.read_next_batch().to_pydict() == {'id': [1]}
if failure:
with pytest.raises(OSError, match='native stream failed'):
reader.read_next_batch()
reader.close()
assert closed == [True]
def test_python_vector_executor_never_selects_the_native_vindex_facade():
from pypaimon.execution.python.read.search.vector_search_read import _create_vector_reader
expected = object()
with patch('pypaimon.execution.python.globalindex.vindex.vindex_vector_global_index_reader.'
'PythonVindexVectorGlobalIndexReader', return_value=expected) as create, \
patch.dict(sys.modules, {'pypaimon.globalindex.vindex_reader': None}):
assert _create_vector_reader('ivf-flat', 'io', 'path', ['meta'], {'nprobe': '2'}) is expected
create.assert_called_once_with('io', 'path', ['meta'], {'nprobe': '2'})
@pytest.mark.parametrize('outcome', ['success', 'schema-error', 'stream-error'])
@pytest.mark.parametrize('close_error', [False, True])
def test_native_materialization_always_closes_a_retained_batch_source(outcome, close_error):
import pyarrow as pa
from pypaimon.read.table_read import TableRead
schema = pa.schema([('id', pa.int32())])
events = []
primary = OSError('native stream failed')
cleanup = RuntimeError('native close failed')
def batches():
try:
batch_type = pa.int64() if outcome == 'schema-error' else pa.int32()
yield pa.record_batch([pa.array([1], type=batch_type)], names=['id'])
if outcome == 'stream-error':
raise primary
finally:
events.append('generator-closed')
class Source:
def __init__(self):
self.stream = batches()
self.close_count = 0
def __iter__(self):
return self
def __next__(self):
return next(self.stream)
def close(self):
self.close_count += 1
self.stream.close()
if close_error:
raise cleanup
source = Source()
read = TableRead.__new__(TableRead)
read._read_parallelism = 1
read.include_row_kind = False
read._output_arrow_schema = lambda splits: schema
read._try_native_batches = Mock(return_value=source)
with patch.dict(sys.modules, {'pypaimon.execution.python.read.table_read': None}):
if outcome == 'schema-error':
with pytest.raises(TypeError, match="has type int64, expected int32"):
read.to_arrow([], parallelism=1)
elif outcome == 'stream-error':
with pytest.raises(OSError) as caught:
read.to_arrow([], parallelism=1)
assert caught.value is primary
elif close_error:
with pytest.raises(RuntimeError) as caught:
read.to_arrow([], parallelism=1)
assert caught.value is cleanup
else:
assert read.to_arrow([], parallelism=1).to_pydict() == {'id': [1]}
# Keep source and its generator reachable: cleanup must not depend on GC.
assert source.close_count == 1
assert source.stream.gi_frame is None
assert events == ['generator-closed']
@pytest.mark.parametrize('failure', ['schema', 'stream'])
def test_native_converter_preserves_primary_failure_when_retained_source_close_fails(failure):
import pyarrow as pa
from pypaimon.read.table_read import TableRead
schema = pa.schema([('id', pa.int32())])
primary = OSError('primary native stream failure')
closed = []
class Source:
close_count = 0
def __init__(self):
self.stream = self.batches()
def batches(self):
try:
if failure == 'stream':
raise primary
yield pa.record_batch([pa.array([1], type=pa.int64())], names=['id'])
finally:
closed.append(True)
def __iter__(self):
return self
def __next__(self):
return next(self.stream)
def close(self):
self.close_count += 1
self.stream.close()
raise RuntimeError('secondary close failure')
source = Source()
read = TableRead.__new__(TableRead)
read._prepare_native_batch = lambda batch, schema: batch
read._project_batch_to_output = lambda batch: batch
converted = read._convert_native_batches(source, schema)
with patch.dict(sys.modules, {'pypaimon.execution.python.read.table_read': None}):
if failure == 'schema':
with pytest.raises(TypeError, match='has type int64, expected int32'):
read._batches_to_arrow(converted, schema)
else:
with pytest.raises(OSError) as caught:
read._batches_to_arrow(converted, schema)
assert caught.value is primary
assert source.close_count == 1
assert source.stream.gi_frame is None
assert closed == [True]
def test_native_row_conversion_keeps_its_failure_when_closing_batches_also_fails():
from types import SimpleNamespace
import pyarrow as pa
from pypaimon.read.table_read import TableRead
from pypaimon.schema.data_types import AtomicType, DataField
primary = ValueError('invalid native row batch')
source = Mock(close=Mock(side_effect=OSError('secondary close failure')))
read = TableRead.__new__(TableRead)
read.table = SimpleNamespace(options=SimpleNamespace(native_read_enabled=lambda: True))
read.read_type = [DataField(0, 'id', AtomicType('INT'))]
read.output_projection = None
read._output_arrow_schema = lambda splits: pa.schema([('id', pa.int32())])
read._try_native_batches = Mock(return_value=source)
with patch('pypaimon.execution.native.read.row_iterator.native_rows', side_effect=primary), \
patch.dict(sys.modules, {'pypaimon.execution.python.read.table_read': None}):
rows = read._try_native_rows([])
with pytest.raises(ValueError) as caught:
next(rows)
assert caught.value is primary
source.close.assert_called_once_with()
def _python_read_source(primary=None, cleanup=None):
from types import SimpleNamespace
import pyarrow as pa
from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.table.row.offset_row import OffsetRow
schema = pa.schema([('id', pa.int32())])
class Source(RecordBatchReader):
close_count = 0
read_count = 0
def read_arrow_batch(self):
self.read_count += 1
if primary is not None:
raise primary
if self.read_count == 1:
return pa.record_batch([pa.array([1], type=pa.int32())], schema=schema)
return None
def read_batch(self):
self.read_count += 1
if primary is not None:
raise primary
if self.read_count != 1:
return None
rows = iter([OffsetRow((1,), 0, 1)])
return SimpleNamespace(next=lambda: next(rows, None))
def close(self):
self.close_count += 1
if cleanup is not None:
raise cleanup
source = Source()
read = SimpleNamespace(
table=SimpleNamespace(is_primary_key_table=False),
_begin_auth_read=lambda splits: None,
_blob_as_descriptor=False,
_blob_view_output_indices=(),
_project_batch_to_output=lambda batch: batch,
output_projection=None,
_adapter_read_type=[DataField(0, 'id', AtomicType('INT'))],
include_row_kind=False,
limit=None,
)
from pypaimon.execution.python.read.table_read import PythonTableRead
execution = PythonTableRead(read)
execution._create_reader_for_split = lambda *args, **kwargs: source
return execution, source, schema
def _consume_python_read_path(path, read, schema):
from concurrent.futures import ThreadPoolExecutor
from pypaimon.execution.python.read.table_read import _RemainingRows
if path == 'rows':
return list(read.read_rows([object()]))
if path == 'serial':
return list(read._arrow_batch_generator([object()], schema))
with ThreadPoolExecutor(max_workers=1) as executor:
return executor.submit(
read._read_one_split_to_batches,
object(), schema, _RemainingRows(None),
).result(timeout=5)
@pytest.mark.parametrize('path', ['rows', 'serial', 'parallel-worker'])
@pytest.mark.parametrize('read_error', [False, True])
@pytest.mark.parametrize('close_error', [False, True])
def test_python_reader_preserves_primary_failure_and_closes_on_eof(path, read_error, close_error):
primary = ValueError('primary Python read failure') if read_error else None
cleanup = OSError('secondary Python close failure') if close_error else None
read, source, schema = _python_read_source(primary, cleanup)
expected = primary if primary is not None else cleanup
if expected is not None:
with pytest.raises(type(expected)) as caught:
_consume_python_read_path(path, read, schema)
assert caught.value is expected
else:
result = _consume_python_read_path(path, read, schema)
assert len(result) == 1
if path == 'rows':
assert result[0].get_field(0) == 1
else:
assert result[0].column('id').to_pylist() == [1]
# The source remains reachable, so close must be deterministic, not GC-driven.
assert source.close_count == 1
@pytest.mark.parametrize('path', ['serial', 'parallel-worker'])
def test_python_batch_conversion_keeps_primary_failure_when_close_also_fails(path):
primary = ValueError('invalid Python batch conversion')
read, source, schema = _python_read_source(cleanup=OSError('secondary close failure'))
read._serialize_blob_views_as_descriptors = Mock(side_effect=primary)
with pytest.raises(ValueError) as caught:
_consume_python_read_path(path, read, schema)
assert caught.value is primary
assert source.close_count == 1
@pytest.mark.parametrize('path', ['rows', 'serial'])
@pytest.mark.parametrize('close_error', [False, True])
def test_python_read_explicit_generator_close_keeps_cleanup_error_visible(path, close_error):
cleanup = OSError('explicit close failure') if close_error else None
read, source, schema = _python_read_source(cleanup=cleanup)
if path == 'rows':
iterator = read.read_rows([object()])
else:
iterator = read._arrow_batch_generator([object()], schema)
next(iterator)
if close_error:
with pytest.raises(OSError) as caught:
iterator.close()
assert caught.value is cleanup
else:
iterator.close()
assert source.close_count == 1
assert iterator.gi_frame is None