blob: d799e4879b02a9613b6ce5a20b498dfd568684d9 [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.
"""Existing writer APIs stay native without changing query-read policies."""
import json
from contextlib import ExitStack
from types import ModuleType
from unittest.mock import Mock, patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.api.api_response import AuthTableQueryResponse, ErrorResponse
from pypaimon.catalog.catalog_exception import TableNoPermissionException
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.tests import native_plan_rest_test
from pypaimon.execution.native.write.native_commit import create_native_write_table
from pypaimon.write.write_dispatch import NativePostponeFixedBucketTableWrite, NativeTableWrite
from pypaimon.write.postpone_bucket import PostponeBucketPlan
rest_catalog = native_plan_rest_test.rest_catalog
pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
native = pytest.mark.skipif(not native_runtime_available(), reason='pypaimon-rust not installed')
SCHEMA = pa.schema([('id', pa.int64()), ('pt', pa.string()), ('value', pa.string())])
def _table(tmp_path, mode='append', catalog=None, use_native=False, guarded=False):
catalog = catalog or CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
options = {
'write.native.enabled': str(use_native).lower(), 'commit.native.enabled': str(use_native).lower(),
'query-auth.enabled': str(guarded).lower(), 'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false',
}
if mode == 'data-evolution':
options.update({'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'})
if mode == 'postpone':
options.update({'bucket': '-2', 'postpone.default-bucket-num': '2'})
elif mode in ('fixed', 'dynamic'):
options['bucket'] = '1' if mode == 'fixed' else '-1'
catalog.create_table('default.t', Schema.from_pyarrow_schema(
SCHEMA, partition_keys=['pt'], primary_keys=['pt', 'id']
if mode in ('fixed', 'dynamic', 'postpone') else [], options=options), False)
return catalog.get_table('default.t')
def _writer(table, native_writer, partition=None):
return NativeTableWrite(table, 'job', partition, False, native_writer)
@pytest.mark.parametrize('mode', ['append', 'fixed'])
@pytest.mark.parametrize('staged', [False, True])
@pytest.mark.parametrize('partition', [None, {'pt': 'a'}, {}])
def test_write_ray_reuses_coordination_without_switching_local_writer(tmp_path, mode, staged, partition):
table = _table(tmp_path, mode)
underlying = Mock()
writer = _writer(table, underlying, {'pt': 'builder'})
if staged:
writer.write_arrow_batch(pa.RecordBatch.from_pylist(
[{'id': 1, 'pt': 'a', 'value': 'local'}], schema=SCHEMA))
module = ModuleType('pypaimon.write.ray_datasink')
module.write_paimon_dataset = Mock()
dataset = object()
with patch.dict('sys.modules', {'pypaimon.write.ray_datasink': module}), \
patch.object(writer, '_new_python_writer', side_effect=AssertionError('Python writer')):
assert writer.write_ray(dataset, overwrite=True, concurrency=3,
ray_remote_args={'num_cpus': 2}, hash_fixed_precluster='map_groups',
static_partition=partition) is None
module.write_paimon_dataset.assert_called_once_with(
dataset, table, overwrite=True, concurrency=3, ray_remote_args={'num_cpus': 2},
hash_fixed_precluster='map_groups', static_partition={'pt': 'builder'} if partition is None else partition)
assert writer._python_writer is None
underlying.close.assert_not_called()
@pytest.mark.parametrize('plan', [None, PostponeBucketPlan({('a',): 4})])
@pytest.mark.parametrize('overwrite', [False, True])
def test_native_postpone_ray_preserves_fixed_bucket_planner(tmp_path, plan, overwrite):
table = _table(tmp_path, 'postpone')
writer = NativePostponeFixedBucketTableWrite(table, 'job', {} if overwrite else None, plan, Mock())
module = ModuleType('pypaimon.write.ray_datasink')
module.write_paimon_dataset = Mock()
with patch.dict('sys.modules', {'pypaimon.write.ray_datasink': module}):
writer.write_ray(object(), hash_fixed_precluster='off')
options = module.write_paimon_dataset.call_args[1]
planner = options['postpone_bucket_planner']
assert planner.current_plan().as_dict() == ({} if plan is None else {('a',): 4})
assert options['static_partition'] == ({} if overwrite else None)
assert writer._python_writer is None
@pytest.mark.parametrize('native_writer', [False, True])
@pytest.mark.parametrize('mode', ['append', 'builder-overwrite', 'call-overwrite', 'call-partition', 'call-empty'])
@pytest.mark.parametrize('explicit_plan', [False, True])
def test_ray_postpone_plans_use_effective_overwrite(tmp_path, native_writer, mode, explicit_plan):
from pypaimon.write.postpone_batch_table_write import PostponeFixedBucketBatchTableWrite
from pypaimon.execution.python.write.postpone_bucket import PythonPostponeBucketPlanner as PostponeBucketPlanner
table = _table(tmp_path, 'postpone')
plan = PostponeBucketPlan({('a',): 8}) if explicit_plan else None
partition = {} if mode == 'builder-overwrite' else None
module = ModuleType('pypaimon.write.ray_datasink')
module.write_paimon_dataset = Mock()
with patch.object(PostponeBucketPlanner, '_load_bucket_metadata', return_value={('a',): 4}), \
patch.dict('sys.modules', {'pypaimon.write.ray_datasink': module}):
writer = (NativePostponeFixedBucketTableWrite(table, 'job', partition, plan, Mock()) if native_writer
else PostponeFixedBucketBatchTableWrite(table, 'job', partition, plan))
try:
writer.write_ray(object(), overwrite=mode == 'call-overwrite', static_partition=(
{'pt': 'a'} if mode == 'call-partition' else {} if mode == 'call-empty' else None))
finally:
writer.close()
actual = module.write_paimon_dataset.call_args[1]['postpone_bucket_planner'].current_plan().as_dict()
assert actual == ({('a',): 8} if explicit_plan else {('a',): 4} if mode == 'append' else {})
@pytest.mark.parametrize('mode', ['append', 'fixed', 'data-evolution'])
def test_non_video_group_boundaries_validate_without_switching(tmp_path, mode):
underlying = Mock()
writer = _writer(_table(tmp_path, mode), underlying)
for staged in (False, True):
if staged:
writer.write_arrow_batch(pa.RecordBatch.from_pylist(
[{'id': 1, 'pt': 'a', 'value': 'x'}], schema=SCHEMA))
for count in (1, 1000):
assert writer.roll_before_group_if_needed(count) is None
for count in (True, False, 0, -1, 1.0, '1', None):
with pytest.raises(ValueError, match='positive integer'):
writer.roll_before_group_if_needed(count)
assert writer._python_writer is None
underlying.close.assert_not_called()
@pytest.mark.parametrize('names', [SCHEMA.names, list(reversed(SCHEMA.names))])
def test_pk_complete_write_type_keeps_table_order_and_native_writer(tmp_path, names):
underlying = Mock()
writer = _writer(_table(tmp_path, 'fixed'), underlying)
assert writer.with_write_type(iter(names)) is writer
writer.write_arrow_batch(pa.RecordBatch.from_pylist(
[{'id': 1, 'pt': 'a', 'value': 'x'}], schema=SCHEMA))
assert underlying.write_arrow.call_args[0][0].schema.names == SCHEMA.names
underlying.with_write_type.assert_not_called()
assert writer._python_writer is None
@pytest.mark.parametrize('full_input', [False, True])
def test_append_write_type_preserves_input_routing_columns_for_core(tmp_path, full_input):
underlying = Mock()
writer = _writer(_table(tmp_path), underlying).with_write_type(['value'])
data = pa.Table.from_pylist([{'id': 1, 'pt': 'a', 'value': 'x'}], schema=SCHEMA)
if not full_input:
data = data.select(['value'])
writer.write_arrow(data)
actual, bucket = underlying.write_arrow.call_args[0]
assert actual.schema.names == data.schema.names
assert actual.to_pylist() == data.to_pylist()
assert bucket is None
assert writer._python_writer is None
@pytest.mark.parametrize('use_native', [False, pytest.param(True, marks=native)])
def test_stream_new_bucket_restores_sequences_after_a_checkpoint(rest_catalog, use_native):
catalog, _ = rest_catalog
catalog.create_table('default.t', Schema.from_pyarrow_schema(
SCHEMA, primary_keys=['id'], options={
'bucket': '2', 'write.native.enabled': str(use_native).lower(),
'commit.native.enabled': 'false', 'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false'}), False)
table = catalog.get_table('default.t')
builder = table.new_stream_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
assert isinstance(writer, NativeTableWrite) == use_native
writer.write_arrow(pa.Table.from_pylist([dict(id=1, pt='a', value='first')], schema=SCHEMA), bucket=0)
commit.commit(writer.prepare_commit(1), 1)
# A new bucket is first seen after the long-lived writer's checkpoint.
other_builder = table.new_batch_write_builder()
other_writer, other_commit = other_builder.new_write(), other_builder.new_commit()
try:
other_writer.write_arrow(pa.Table.from_pylist([
dict(id=2, pt='a', value=str(value)) for value in (20, 21, 22)
], schema=SCHEMA), bucket=1)
other_commit.commit(other_writer.prepare_commit())
finally:
other_writer.close()
other_commit.close()
writer.write_arrow(pa.Table.from_pylist([dict(id=2, pt='a', value='latest')], schema=SCHEMA), bucket=1)
messages = writer.prepare_commit(2)
new_bucket = [file for message in messages if message.bucket == 1 for file in message.new_files]
assert len(new_bucket) == 1
assert new_bucket[0].min_sequence_number == 3
commit.commit(messages, 2)
assert _rows(table) == [dict(id=1, pt='a', value='first'), dict(id=2, pt='a', value='latest')]
# Refreshing restore state must not reset an already-open bucket's counter.
writer.write_arrow(pa.Table.from_pylist([dict(id=1, pt='a', value='retained')], schema=SCHEMA), bucket=0)
messages = writer.prepare_commit(3)
assert messages[0].new_files[0].min_sequence_number == 1
commit.commit(messages, 3)
assert _rows(table) == [dict(id=1, pt='a', value='retained'), dict(id=2, pt='a', value='latest')]
finally:
writer.close()
commit.close()
@pytest.mark.parametrize('query_auth', ['true', 'on'])
@pytest.mark.parametrize('invalid', [None, 'fresh-uuid', 'captured-uuid', 'captured-path', 'captured-branch'])
def test_standard_rest_query_auth_write_table_keeps_native_publication(rest_catalog, query_auth, invalid):
from pypaimon.schema.data_types import AtomicType
from pypaimon.schema.schema_change import SchemaChange
catalog, _ = rest_catalog
table = _table(None, catalog=catalog, guarded=True).copy({'query-auth.enabled': query_auth})
catalog.alter_table('default.t', [SchemaChange.add_column('extra', AtomicType('INT'))])
assert table.table_schema.id != catalog.get_table('default.t').table_schema.id
module = ModuleType('pypaimon_rust.datafusion')
module.Table = Mock()
module.PaimonCatalog = Mock()
resolved = Mock(spec=['location', 'rest_table_uuid', 'branch', 'copy_with_resolved_schema'])
module.PaimonCatalog.return_value.get_table.return_value = resolved
resolved.location.return_value = table.table_path
resolved.rest_table_uuid.return_value = (
'replacement-uuid' if invalid == 'fresh-uuid' else table.catalog_environment.uuid)
resolved.branch.return_value = 'main'
resolved.copy_with_resolved_schema.return_value = resolved
captured = Mock(spec=['location', 'rest_table_uuid', 'branch', 'copy_with_resolved_schema'])
captured.location.return_value = '/different' if invalid == 'captured-path' else table.table_path
captured.rest_table_uuid.return_value = (
'replacement-uuid' if invalid == 'captured-uuid' else table.catalog_environment.uuid)
captured.branch.return_value = 'dev' if invalid == 'captured-branch' else 'main'
captured.copy_with_resolved_schema.return_value = captured
with patch.dict('sys.modules', {'pypaimon_rust.datafusion': module}), \
patch('pypaimon.execution.native.table._native_table', return_value=captured) as restore:
result = create_native_write_table(table)
resolved.copy_with_resolved_schema.assert_not_called()
if invalid is not None:
assert result is None
captured.copy_with_resolved_schema.assert_not_called()
if invalid == 'fresh-uuid':
restore.assert_not_called()
else:
restore.assert_called_once_with(table)
return
assert result is captured
restore.assert_called_once_with(table)
captured.copy_with_resolved_schema.assert_called_once()
schema = json.loads(captured.copy_with_resolved_schema.call_args[0][0])
assert schema['options']['query-auth.enabled'] == 'true'
def test_read_based_updates_delegate_standard_rest_authorization_to_core(rest_catalog):
from pypaimon.execution.native.write.native_update import _native_row_id_table
catalog, _ = rest_catalog
table = _table(None, 'data-evolution', catalog, use_native=True, guarded=True)
with patch('pypaimon.execution.native.write.native_update.native_runtime_available', return_value=True), \
patch('pypaimon.execution.native.write.native_update.create_native_write_table') as create:
assert _native_row_id_table(table) is create.return_value
create.assert_called_once_with(table)
def _rows(table):
builder = table.new_read_builder()
return builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by(
[('id', 'ascending'), ('value', 'ascending')]).to_pylist()
@pytest.mark.parametrize('use_native,mode', [
(False, 'append'), (False, 'fixed'), (False, 'dynamic'), (False, 'postpone'),
pytest.param(True, 'append', marks=native), pytest.param(True, 'fixed', marks=native),
pytest.param(True, 'dynamic', marks=native), pytest.param(True, 'data-evolution', marks=native),
pytest.param(True, 'postpone', marks=native),
])
def test_query_auth_writes_do_not_require_select_and_reads_still_authorize(rest_catalog, use_native, mode):
catalog, server = rest_catalog
table = _table(None, mode, catalog, use_native, guarded=True)
denied = server._mock_response(ErrorResponse('TABLE', 't', 'SELECT denied', 403), 403)
with patch.object(server, '_table_query_auth_handle', return_value=denied) as query:
for rows in ([{'id': 1, 'pt': 'a', 'value': 'before'}, {'id': 2, 'pt': 'b', 'value': 'hidden'}],
[{'id': 1, 'pt': 'a', 'value': 'after'}]):
builder = (table.new_postpone_fixed_bucket_write_builder()
if mode == 'postpone' else table.new_batch_write_builder())
writer, commit = builder.new_write(), builder.new_commit()
with ExitStack() as stack:
if use_native:
assert isinstance(writer, NativeTableWrite)
stack.enter_context(patch('pypaimon.execution.python.write.file_store_commit.'
'FileStoreCommit.commit',
side_effect=AssertionError('Python commit')))
try:
writer.write_arrow(pa.Table.from_pylist(rows, schema=SCHEMA))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
query.assert_not_called()
with pytest.raises(TableNoPermissionException):
_rows(table)
# Row policies apply only when the newly stored rows are read, including
# masking stored values; hidden rows were still written normally.
field = {'name': 'id', 'type': 'BIGINT', 'index': 0}
server.table_query_auth_store[table.identifier.get_full_name()] = AuthTableQueryResponse(
filter=[json.dumps({'kind': 'LEAF', 'transform': {'name': 'FIELD_REF', 'fieldRef': field},
'function': 'EQUAL', 'literals': [1]})],
column_masking={'value': json.dumps({'name': 'CONCAT', 'inputs': ['masked']})})
visible = _rows(table)
count = 2 if mode in ('append', 'data-evolution') else 1
assert visible == [{'id': 1, 'pt': 'a', 'value': 'masked'}] * count
@pytest.mark.parametrize('restore_id', [0, 1])
@pytest.mark.parametrize('use_native', [False, pytest.param(True, marks=native)])
def test_guarded_dynamic_restore_keeps_explicit_snapshot_baseline(rest_catalog, restore_id, use_native):
catalog, server = rest_catalog
table = _table(None, 'dynamic', catalog, guarded=True).copy({'dynamic-bucket.target-row-num': '1'})
denied = server._mock_response(ErrorResponse('TABLE', 't', 'SELECT denied', 403), 403)
with patch.object(server, '_table_query_auth_handle', return_value=denied) as query:
for rows in ([{'id': 1, 'pt': 'a', 'value': 'base'}], [
{'id': 1, 'pt': 'a', 'value': 'future-1'}, {'id': 1, 'pt': 'a', 'value': 'future-2'},
{'id': 2, 'pt': 'a', 'value': 'new-bucket'}]):
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(pa.Table.from_pylist(rows, schema=SCHEMA))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert table.snapshot_manager().get_latest_snapshot().id == 2
restored = table.copy({'write.native.enabled': str(use_native).lower()})
builder = restored.new_batch_write_builder().with_restore_snapshot(restore_id)
writer = builder.new_write()
assert isinstance(writer, NativeTableWrite) == use_native
try:
writer.write_arrow(pa.Table.from_pylist([
{'id': 1, 'pt': 'a', 'value': 'restored'}, {'id': 3, 'pt': 'a', 'value': 'another'}], schema=SCHEMA))
messages = writer.prepare_commit()
assert {message.bucket for message in messages} == {0, 1}
files = {message.bucket: message.new_files[0] for message in messages}
assert files[0].min_sequence_number == restore_id
assert files[1].min_sequence_number == 0
# These messages are explicitly abandoned, never submitted for
# publication. The old snapshots and their HASH files stay intact.
commit = builder.new_commit()
try:
commit.abort(messages)
finally:
commit.close()
finally:
writer.close()
query.assert_not_called()
assert table.snapshot_manager().get_latest_snapshot().id == 2
@pytest.mark.parametrize('use_native', [False, pytest.param(True, marks=native)])
def test_guarded_overwrite_and_truncate_keep_rest_publication(rest_catalog, use_native):
catalog, server = rest_catalog
table = _table(None, catalog=catalog, use_native=use_native, guarded=True)
denied = server._mock_response(ErrorResponse('TABLE', 't', 'SELECT denied', 403), 403)
with patch.object(server, '_table_query_auth_handle', return_value=denied) as query:
for partition, rows in [(None, [
{'id': 1, 'pt': 'a', 'value': 'old'}, {'id': 2, 'pt': 'b', 'value': 'keep'}]),
({'pt': 'a'}, [{'id': 3, 'pt': 'a', 'value': 'replace'}])]:
builder = table.new_batch_write_builder()
if partition is not None:
builder.overwrite(partition)
writer, commit = builder.new_write(), builder.new_commit()
with ExitStack() as stack:
if use_native:
assert isinstance(writer, NativeTableWrite)
stack.enter_context(patch('pypaimon.execution.python.write.file_store_commit.'
'FileStoreCommit.commit',
side_effect=AssertionError('Python commit')))
stack.enter_context(patch('pypaimon.execution.python.write.file_store_commit.'
'FileStoreCommit.overwrite',
side_effect=AssertionError('Python overwrite')))
try:
writer.write_arrow(pa.Table.from_pylist(rows, schema=SCHEMA))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert table.snapshot_manager().get_latest_snapshot().total_record_count == 2
commit = table.new_batch_write_builder().new_commit()
with ExitStack() as stack:
if use_native:
stack.enter_context(patch('pypaimon.execution.python.write.file_store_commit.'
'FileStoreCommit.drop_partitions',
side_effect=AssertionError('Python drop')))
stack.enter_context(patch('pypaimon.execution.python.write.file_store_commit.'
'FileStoreCommit.truncate_table',
side_effect=AssertionError('Python truncate')))
try:
commit.truncate_partitions([{'pt': 'b'}])
assert table.snapshot_manager().get_latest_snapshot().total_record_count == 1
commit.truncate_table()
assert table.snapshot_manager().get_latest_snapshot().total_record_count == 0
finally:
commit.close()
query.assert_not_called()