blob: 5b954fe4e5c221b48a697523bc21003a513ebb2c [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.
"""Paimon writer capabilities shared by Rust core and the Python bridge."""
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.table.row.generic_row import GenericRowDeserializer
from pypaimon.write.write_dispatch import NativeTableWrite
pytestmark = pytest.mark.native_plan
def _table(tmp_path, schema, primary_key=False, options=None, partition_keys=None):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
opts = {'file.format': 'parquet', 'write.native.enabled': 'true',
'bucket': '1' if primary_key else '-1'}
opts.update(options or {})
catalog.create_table('default.t', Schema.from_pyarrow_schema(
schema, primary_keys=['id'] if primary_key else [],
partition_keys=partition_keys or [], options=opts), False)
return catalog.get_table('default.t')
def _rows(table, native_read, all_files=False, chunk_shuffle=False):
table = table.copy({'read.native.enabled': str(native_read).lower(),
'scan.native-plan.enabled': str(native_read).lower()})
builder = table.new_read_builder()
scan = builder.new_scan()
if chunk_shuffle:
scan.with_chunk_shuffle(seed=7, chunk_size=1)
splits = (scan.plan_all() if all_files else scan.plan()).splits()
read = builder.new_read()
if native_read:
with patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python fallback')
):
rows = read.to_arrow(splits).to_pylist()
else:
rows = read.to_arrow(splits).to_pylist()
return sorted(rows, key=lambda row: row['id'])
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('engine,expected', [
('first-row', {'id': 1, 'a': 1, 'b': 'first'}),
('partial-update', {'id': 1, 'a': 3, 'b': 'second'}),
('aggregation', {'id': 1, 'a': 6, 'b': 'second'}),
])
def test_native_merge_engines_within_and_across_commits(tmp_path, stream, engine, expected):
schema = pa.schema([('id', pa.int64()), ('a', pa.int64()), ('b', pa.string())])
options = {'merge-engine': engine}
if engine == 'aggregation':
options['fields.a.aggregate-function'] = 'sum'
table = _table(tmp_path, schema, True, options)
builder = (table.new_stream_write_builder() if stream else table.new_batch_write_builder())
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite)
try:
for row in [{'id': 1, 'a': 1, 'b': 'first'}, {'id': 1, 'a': 2, 'b': 'second'}]:
writer.write_arrow(pa.Table.from_pylist([row], schema=schema))
if stream:
commit.commit(writer.prepare_commit(1), 1)
else:
commit.commit(writer.prepare_commit())
writer.close()
commit.close()
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite)
writer.write_arrow(pa.Table.from_pylist([{'id': 1, 'a': 3, 'b': None}], schema=schema))
if stream:
commit.commit(writer.prepare_commit(2), 2)
else:
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
# Java first-row batch scans hide un-compacted L0 files; write scans expose them.
for native_read in (False, True):
assert _rows(table, native_read, engine == 'first-row') == [expected]
@pytest.mark.parametrize('stream', [False, True])
@pytest.mark.parametrize('data_evolution', [False, True])
def test_native_append_row_rolling_and_checkpoints(tmp_path, stream, data_evolution):
schema = pa.schema([('id', pa.int64()), ('name', pa.string())])
table = _table(tmp_path, schema, options={
'target-file-row-num': '3',
'row-tracking.enabled': str(data_evolution).lower(),
'data-evolution.enabled': str(data_evolution).lower(),
})
builder = (table.new_stream_write_builder() if stream else table.new_batch_write_builder())
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite)
expected = []
try:
for checkpoint in range(2 if stream else 1):
for ids in ([0, 1], [2, 3], [4]):
rows = [{'id': i + checkpoint * 5, 'name': 'value'} for i in ids]
expected.extend(rows)
writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
messages = writer.prepare_commit(checkpoint) if stream else writer.prepare_commit()
counts = sorted(file.row_count for message in messages for file in message.new_files)
# Java bundled writes may cross the limit by one input batch.
assert sum(counts) == 5
assert len(counts) >= 2
assert max(counts) <= 4
if stream:
commit.commit(messages, checkpoint)
else:
commit.commit(messages)
finally:
writer.close()
commit.close()
for native_read in (False, True):
assert _rows(table, native_read) == expected
if data_evolution:
assert table.snapshot_manager().get_latest_snapshot().next_row_id == len(expected)
@pytest.mark.parametrize('primary_key', [False, True])
def test_native_nested_and_fixed_binary_input(tmp_path, primary_key):
schema = pa.schema([
('id', pa.int64()), ('bytes', pa.binary()),
('items', pa.list_(pa.struct([('label', pa.string()), ('value', pa.int64())]))),
('mapping', pa.map_(pa.string(), pa.list_(pa.int64()))),
])
table = _table(tmp_path, schema, primary_key)
input_schema = schema.set(1, pa.field('bytes', pa.binary(3)))
rows = [
{'id': 0, 'bytes': b'xyz', 'items': [], 'mapping': []},
{'id': 1, 'bytes': b'abc', 'items': [{'label': 'a', 'value': None}, None],
'mapping': [('a', [1, None]), ('b', [])]},
{'id': 2, 'bytes': None, 'items': None, 'mapping': None},
{'id': 3, 'bytes': b'def', 'items': [], 'mapping': [('a', None)]},
]
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite)
try:
# A sliced batch checks offsets and null parents in nested Arrow input.
writer.write_arrow(pa.Table.from_pylist(rows, schema=input_schema).slice(1))
assert writer._python_writer is None
# Fixed binary must also work after native data has already been written.
writer.write_arrow(pa.Table.from_pylist(rows[:1], schema=input_schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
for native_read in (False, True):
assert _rows(table, native_read) == rows
@pytest.mark.parametrize('mode', ['full', 'truncate(3)', 'counts'])
def test_native_primary_key_value_stats(tmp_path, mode):
schema = pa.schema([('id', pa.int64()), ('value', pa.string())])
table = _table(tmp_path, schema, True, {'metadata.stats-mode': mode})
rows = [{'id': 1, 'value': 'abcdef'}, {'id': 2, 'value': None}]
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite)
try:
writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
messages = writer.prepare_commit()
files = [file for message in messages for file in message.new_files]
assert len(files) == 1
stats = files[0].value_stats
assert stats.null_counts == [0, 1]
expected_min, expected_max = {
'full': ('abcdef', 'abcdef'), 'truncate(3)': ('abc', 'abd'), 'counts': (None, None),
}[mode]
assert GenericRowDeserializer.from_bytes(
stats.min_values.data, table.fields).values[1] == expected_min
assert GenericRowDeserializer.from_bytes(
stats.max_values.data, table.fields).values[1] == expected_max
commit.commit(messages)
finally:
writer.close()
commit.close()
for native_read in (False, True):
assert _rows(table, native_read) == rows
@pytest.mark.parametrize('separate_commits', [False, True])
def test_native_partial_update_sequence_group_aggregation(tmp_path, separate_commits):
schema = pa.schema([(name, pa.int64()) for name in ('id', 'seq', 'total', 'latest', 'free')])
table = _table(tmp_path, schema, True, {
'merge-engine': 'partial-update', 'fields.seq.sequence-group': 'total,latest',
'fields.total.aggregate-function': 'sum',
})
rows = [
{'id': 1, 'seq': 2, 'total': 10, 'latest': 20, 'free': 1},
{'id': 1, 'seq': 1, 'total': 3, 'latest': 10, 'free': 2},
{'id': 1, 'seq': 3, 'total': 7, 'latest': 30, 'free': None},
]
groups = [[row] for row in rows] if separate_commits else [rows]
for group in groups:
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite)
try:
for row in group:
writer.write_arrow(pa.Table.from_pylist([row], schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
# Java aggregates every input, while non-aggregate fields follow sequence
# groups and ungrouped fields keep the last non-null value in arrival order.
assert _rows(table, True) == [{'id': 1, 'seq': 3, 'total': 20, 'latest': 30, 'free': 2}]
@pytest.mark.parametrize('engine', ['first-row', 'partial-update', 'aggregation'])
def test_native_ignore_delete_filters_retractions(tmp_path, engine):
schema = pa.schema([('id', pa.int64()), ('value', pa.int64()), ('op', pa.string())])
options = {'merge-engine': engine, 'ignore-delete': 'true', 'rowkind.field': 'op'}
if engine == 'aggregation':
options['fields.value.aggregate-function'] = 'sum'
table = _table(tmp_path, schema, True, options)
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite)
try:
writer.write_arrow(pa.Table.from_pylist([
{'id': 1, 'value': 10, 'op': '+I'}, {'id': 1, 'value': 99, 'op': '-D'},
{'id': 2, 'value': 20, 'op': '-U'},
], schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
assert _rows(table, True, engine == 'first-row') == [{'id': 1, 'value': 10, 'op': '+I'}]
@pytest.mark.parametrize('chunk_shuffle', [False, True])
def test_native_data_evolution_partitioned_append_delete_and_time_travel(tmp_path, chunk_shuffle):
schema = pa.schema([('id', pa.int64()), ('p', pa.string())])
table = _table(tmp_path, schema, partition_keys=['p'], options={
'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'deletion-vectors.enabled': 'true', 'index-file-in-data-file-dir': 'true',
'read.native.enabled': 'true',
})
rows = [{'id': 1, 'p': 'a/b'}, {'id': 2, 'p': 'b'}, {'id': 3, 'p': 'a/b'}]
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite)
try:
writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
read = table.new_read_builder().with_projection(['id', '_ROW_ID'])
mapped = read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist()
# Row IDs are allocated by file commit order, not logical ID/partition order.
row_id = next(row['_ROW_ID'] for row in mapped if row['id'] == 1)
builder = table.new_batch_write_builder()
messages = builder.new_update().delete_by_row_id([row_id])
commit = builder.new_commit()
try:
commit.commit(messages)
finally:
commit.close()
for native_read in (False, True):
assert _rows(table, native_read, chunk_shuffle=chunk_shuffle) == rows[1:]
assert _rows(table.copy({'scan.snapshot-id': '1'}), native_read,
chunk_shuffle=chunk_shuffle) == rows