blob: 684cda64814a598135b78ca3cc57798d10ffd23a [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.
"""Direct public writer construction shares the builder execution boundary."""
from contextlib import ExitStack, contextmanager
import inspect
from pathlib import Path
import subprocess
import sys
from unittest.mock import Mock, call, patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.write.bucket_plan import PostponeBucketPlan
from pypaimon.write.postpone_batch_table_write import PostponeFixedBucketBatchTableWrite
from pypaimon.write.table_commit import BatchTableCommit, StreamTableCommit
from pypaimon.write.table_write import BatchTableWrite, StreamTableWrite, TableWrite
WRITERS = [TableWrite, BatchTableWrite, StreamTableWrite, PostponeFixedBucketBatchTableWrite]
pytestmark = [pytest.mark.python_plan, pytest.mark.python_read, pytest.mark.python_commit]
def _table(tmp_path, writer_class, primary_key=False):
postpone = issubclass(writer_class, PostponeFixedBucketBatchTableWrite)
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([('id', pa.int32()), ('value', pa.int32())])
catalog.create_table('default.direct', Schema.from_pyarrow_schema(
schema, primary_keys=['id'] if postpone or primary_key else [], options={
'bucket': '-2' if postpone else '1' if primary_key else '-1', 'postpone.default-bucket-num': '2',
'write.native.enabled': 'true', 'commit.native.enabled': 'false',
'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false',
}), False)
return catalog.get_table('default.direct'), schema
def _construct(writer_class, table):
if issubclass(writer_class, PostponeFixedBucketBatchTableWrite):
return writer_class(table, 'direct-writer', bucket_plan=PostponeBucketPlan({(): 2}))
return writer_class(table, 'direct-writer')
def _prepare(writer):
if isinstance(writer, StreamTableWrite):
return writer.prepare_commit(7)
if isinstance(writer, BatchTableWrite):
return writer.prepare_commit()
return writer._prepare_commit(BATCH_COMMIT_IDENTIFIER)
@contextmanager
def _native_backend(forbid_python=True):
"""Use the real thin Native adapter without loading or building the SDK."""
native_table, builder, core = Mock(), Mock(), Mock()
for name in ('new_batch_write_builder', 'new_stream_write_builder',
'new_postpone_fixed_bucket_write_builder'):
getattr(native_table, name).return_value = builder
for name in ('with_commit_user', 'with_bucket_plan', 'with_overwrite'):
getattr(builder, name).return_value = builder
builder.new_write.return_value = core
core.prepare_commit.return_value = ['wire-message']
with ExitStack() as stack:
stack.enter_context(patch(
'pypaimon.execution.native.write.native_write.native_runtime_available', return_value=True))
create = stack.enter_context(patch(
'pypaimon.execution.native.write.native_write.create_native_write_table', return_value=native_table))
stack.enter_context(patch(
'pypaimon.execution.native.write.native_write.from_native_commit_messages', return_value=['message']))
if forbid_python:
stack.enter_context(patch(
'pypaimon.execution.python.write.table_write.PythonTableWrite.initialize',
side_effect=AssertionError('Native initialized Python execution')))
yield core, create
@pytest.mark.parametrize('writer_class', WRITERS)
def test_direct_writer_selects_native_without_python_initialization(tmp_path, writer_class):
table, schema = _table(tmp_path, writer_class)
batch = pa.record_batch([[1], [10]], schema=schema)
with _native_backend() as (core, create):
writer = _construct(writer_class, table)
try:
assert type(writer) is writer_class
writer.write_arrow_batch(batch)
assert _prepare(writer) == ['message']
finally:
writer.close()
create.assert_called_once_with(table)
core.write_arrow.assert_called_once_with(batch, None)
core.prepare_commit.assert_called_once_with(*((True, 7) if writer_class is StreamTableWrite else ()))
core.close.assert_called_once_with()
@pytest.mark.parametrize('writer_class', WRITERS)
def test_direct_writer_metadata_inspection_does_not_select_python(tmp_path, writer_class):
table, _ = _table(tmp_path, writer_class)
with _native_backend() as (core, create):
writer = _construct(writer_class, table)
try:
assert writer.table is table
assert writer.commit_user == 'direct-writer'
assert writer.static_partition is None
for name in ('file_store_write', 'row_key_extractor', 'table_pyarrow_schema',
'restore_snapshot_id', 'batch_committed'):
assert not hasattr(writer, name)
with pytest.raises(AttributeError):
getattr(writer, name)
assert not hasattr(writer, 'missing_writer_attribute')
with pytest.raises(AttributeError):
getattr(writer, 'missing_writer_attribute')
create.assert_called_once_with(table)
core.close.assert_not_called()
finally:
writer.close()
@pytest.mark.parametrize('writer_class', WRITERS)
@pytest.mark.parametrize('native', [False, True])
def test_direct_writer_public_api_is_backend_independent(tmp_path, writer_class, native):
table, _ = _table(tmp_path, writer_class)
with ExitStack() as stack:
if native:
stack.enter_context(_native_backend())
else:
stack.enter_context(patch(
'pypaimon.execution.native.write.native_write.create_native_write', return_value=None))
writer = _construct(writer_class, table)
try:
assert type(writer) is writer_class
assert writer.table is table
assert writer.commit_user == 'direct-writer'
assert writer.static_partition is None
for name in ('file_store_write', 'row_key_extractor', 'table_pyarrow_schema',
'restore_snapshot_id', 'batch_committed'):
assert not hasattr(writer, name)
with pytest.raises(AttributeError):
getattr(writer, name)
if writer_class is TableWrite:
# The base API intentionally leaves preparation to its subclass.
# Discovering optional methods must not leak the selected executor.
assert not hasattr(writer, 'prepare_commit')
else:
parameters = list(inspect.signature(writer.prepare_commit).parameters.values())
if writer_class is StreamTableWrite:
assert [parameter.name for parameter in parameters] == ['commit_identifier']
assert parameters[0].default is inspect.Parameter.empty
else:
assert not parameters
finally:
writer.close()
@pytest.mark.parametrize('writer_class', WRITERS)
def test_direct_writer_native_write_failure_is_not_replayed(tmp_path, writer_class):
table, schema = _table(tmp_path, writer_class)
failure = OSError('file write may already have succeeded')
with _native_backend() as (core, create):
core.write_arrow.side_effect = failure
writer = _construct(writer_class, table)
try:
with pytest.raises(OSError) as caught:
writer.write_arrow_batch(pa.record_batch([[1], [10]], schema=schema))
assert caught.value is failure
create.assert_called_once_with(table)
finally:
writer.close()
core.write_arrow.assert_called_once()
@pytest.mark.parametrize('writer_class', WRITERS)
def test_direct_native_writer_configuration_keeps_public_receiver(tmp_path, writer_class):
table, _ = _table(tmp_path, writer_class)
with _native_backend() as (core, _):
writer = _construct(writer_class, table)
try:
assert writer.with_write_type(['id', 'value']) is writer
assert writer.with_blob_consumer(None) is writer
assert writer.with_blob_uri_reader_factory(None) is writer
core.with_blob_consumer.assert_called_once_with(None)
core.with_blob_uri_reader_factory.assert_called_once_with(None)
finally:
writer.close()
@pytest.mark.parametrize('writer_class', WRITERS)
def test_direct_writer_python_selection_is_not_revisited(tmp_path, writer_class):
table, schema = _table(tmp_path, writer_class)
data = pa.table({'id': [1], 'value': [10]}, schema=schema)
with patch('pypaimon.execution.native.write.native_write.create_native_write', return_value=None) as prepare:
writer = _construct(writer_class, table)
try:
assert type(writer) is writer_class
writer.write_arrow(data)
messages = _prepare(writer)
finally:
writer.close()
prepare.assert_called_once()
commit = (StreamTableCommit(table, 'direct-writer') if writer_class is StreamTableWrite else
BatchTableCommit(table, 'direct-writer', None))
try:
commit.commit(messages, 7) if writer_class is StreamTableWrite else commit.commit(messages)
finally:
commit.close()
read = table.new_read_builder()
assert read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist() == data.to_pylist()
@pytest.mark.parametrize('writer_class', WRITERS)
@pytest.mark.parametrize('pandas', [False, True])
def test_direct_native_writer_preserves_public_batch_override(tmp_path, writer_class, pandas):
table, schema = _table(tmp_path, writer_class)
class ObservingWriter(writer_class):
def write_arrow_batch(self, data, bucket=None):
self.observed.append(data)
return super().write_arrow_batch(data, bucket)
with _native_backend() as (core, _):
writer = _construct(ObservingWriter, table)
writer.observed = []
data = pa.table({'id': [1, 2], 'value': [10, 20]}, schema=schema)
try:
writer.write_pandas(data.to_pandas()) if pandas else writer.write_arrow(data)
assert isinstance(writer, ObservingWriter)
assert len(writer.observed) == 1
assert writer.observed[0].to_pydict() == data.to_pydict()
core.write_arrow.assert_called_once()
finally:
writer.close()
@pytest.mark.parametrize('writer_class', WRITERS)
@pytest.mark.parametrize('pandas', [False, True])
def test_direct_writer_pk_subset_fallback_preserves_outer_batch_override(tmp_path, writer_class, pandas):
table, schema = _table(tmp_path, writer_class, primary_key=True)
class ObservingWriter(writer_class):
def write_arrow_batch(self, data, bucket=None):
self.observed.append(data)
return super().write_arrow_batch(data, bucket)
with _native_backend(forbid_python=False) as (core, create):
writer = _construct(ObservingWriter, table)
writer.observed = []
try:
# PK partial writes select Python before mutation; the public
# subclass must continue observing its batch override.
assert writer.with_write_type(['id']) is writer
core.close.assert_called_once_with()
# Restoring a supported full PK schema keeps the selected Python
# executor and must preserve the outer public batch override.
assert writer.with_write_type(['id', 'value']) is writer
data = pa.table({'id': [1], 'value': [10]}, schema=schema)
writer.write_pandas(data.to_pandas()) if pandas else writer.write_arrow(data)
assert len(writer.observed) == 1
assert writer.observed[0].to_pydict() == data.to_pydict()
assert _prepare(writer)
create.assert_called_once_with(table)
core.write_arrow.assert_not_called()
core.prepare_commit.assert_not_called()
finally:
writer.close()
core.close.assert_called_once_with()
@pytest.mark.parametrize('overwrite', [False, True])
def test_selected_python_postpone_reuses_its_distributed_planner(tmp_path, overwrite):
table, _ = _table(tmp_path, PostponeFixedBucketBatchTableWrite)
with patch('pypaimon.execution.native.write.native_write.create_native_write', return_value=None):
writer = _construct(PostponeFixedBucketBatchTableWrite, table)
try:
planner = writer._execution._planner
with patch('pypaimon.write.backend_dispatch.create_postpone_bucket_planner',
side_effect=AssertionError('distributed write selected a second planner')):
options = writer._distributed_write_options(overwrite=overwrite)
assert options['postpone_bucket_planner'] is planner
finally:
writer.close()
@pytest.mark.parametrize('writer_class', [BatchTableWrite, PostponeFixedBucketBatchTableWrite])
def test_direct_native_batch_preparation_failure_consumes_one_attempt(tmp_path, writer_class):
table, _ = _table(tmp_path, writer_class)
failure = OSError('preparation may already have succeeded')
with _native_backend() as (core, _):
core.prepare_commit.side_effect = failure
writer = _construct(writer_class, table)
try:
with pytest.raises(TypeError):
writer.prepare_commit(1)
core.prepare_commit.assert_not_called()
with pytest.raises(OSError) as caught:
writer.prepare_commit()
assert caught.value is failure
with pytest.raises(RuntimeError, match='one-time committing'):
writer.prepare_commit()
core.prepare_commit.assert_called_once_with()
finally:
writer.close()
def test_direct_native_stream_preparation_validates_before_io_and_supports_multiple_rounds(tmp_path):
table, _ = _table(tmp_path, StreamTableWrite)
with _native_backend() as (core, _):
writer = StreamTableWrite(table, 'direct-writer')
try:
with pytest.raises(TypeError):
writer.prepare_commit()
for identifier in (None, 7.5, '7'):
with pytest.raises(TypeError):
writer.prepare_commit(identifier)
core.prepare_commit.assert_not_called()
assert writer.prepare_commit(7) == ['message']
assert writer.prepare_commit(8) == ['message']
assert core.prepare_commit.call_args_list == [call(True, 7), call(True, 8)]
finally:
writer.close()
@pytest.mark.parametrize('writer_class', WRITERS)
def test_direct_native_writer_never_imports_python_execution(tmp_path, writer_class):
source = r'''
import importlib.abc
import sys
from unittest.mock import Mock, patch
class BlockPythonExecution(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname == 'pypaimon.execution.python' or fullname.startswith('pypaimon.execution.python.'):
raise AssertionError('Native imported Python execution: ' + fullname)
sys.meta_path.insert(0, BlockPythonExecution())
import pyarrow as pa
from pypaimon import CatalogFactory, Schema
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.write.bucket_plan import PostponeBucketPlan
from pypaimon.write.postpone_batch_table_write import PostponeFixedBucketBatchTableWrite
from pypaimon.write.table_write import BatchTableWrite, StreamTableWrite, TableWrite
writer_class = globals()[sys.argv[1]]
postpone = writer_class is PostponeFixedBucketBatchTableWrite
catalog = CatalogFactory.create({'warehouse': sys.argv[2]})
catalog.create_database('default', True)
schema = pa.schema([('id', pa.int32()), ('value', pa.int32())])
catalog.create_table('default.direct', Schema.from_pyarrow_schema(
schema, primary_keys=['id'] if postpone else [], options={
'bucket': '-2' if postpone else '-1', 'postpone.default-bucket-num': '2',
'write.native.enabled': 'true', 'commit.native.enabled': 'false',
'scan.native-plan.enabled': 'false', 'read.native.enabled': 'false',
}), False)
table = catalog.get_table('default.direct')
native_table, builder, core = Mock(), Mock(), Mock()
for name in ('new_batch_write_builder', 'new_stream_write_builder', 'new_postpone_fixed_bucket_write_builder'):
getattr(native_table, name).return_value = builder
for name in ('with_commit_user', 'with_bucket_plan'):
getattr(builder, name).return_value = builder
builder.new_write.return_value = core
core.prepare_commit.return_value = []
with patch('pypaimon.execution.native.write.native_write.native_runtime_available', return_value=True), \
patch('pypaimon.execution.native.write.native_write.create_native_write_table', return_value=native_table), \
patch('pypaimon.execution.native.write.native_write.from_native_commit_messages', return_value=[]):
kwargs = {'bucket_plan': PostponeBucketPlan({(): 2})} if postpone else {}
writer = writer_class(table, 'native-direct', **kwargs)
try:
assert type(writer) is writer_class
writer.write_arrow(pa.table({'id': [1], 'value': [10]}, schema=schema))
if writer_class is StreamTableWrite:
assert writer.prepare_commit(7) == []
elif writer_class is TableWrite:
assert writer._prepare_commit(BATCH_COMMIT_IDENTIFIER) == []
else:
assert writer.prepare_commit() == []
finally:
writer.close()
core.write_arrow.assert_called_once()
assert core.write_arrow.call_args[0][0].to_pydict() == {'id': [1], 'value': [10]}
core.close.assert_called_once_with()
assert not any(name.startswith('pypaimon.execution.python') for name in sys.modules)
'''
result = subprocess.run(
[sys.executable, '-c', source, writer_class.__name__, str(tmp_path / 'warehouse')],
cwd=str(Path(__file__).resolve().parents[3]),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=30)
assert result.returncode == 0, result.stdout + result.stderr