blob: 220413a822a6b3690395c2a5327b2aeabff17f0a [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.
"""Java-compatible row tracking metadata through the native REST commit bridge."""
from contextlib import ExitStack
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import Schema
from pypaimon.manifest.manifest_file_manager import ManifestFileManager
from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.manifest.manifest_sidecar import read_sidecar, SUFFIX
from pypaimon.utils.range import Range
from pypaimon.execution.native.write.native_commit import native_messages_supported
from pypaimon.write.write_dispatch import NativeTableWrite
pytestmark = pytest.mark.native_plan
_SCHEMA = pa.schema([('id', pa.int64()), ('v', pa.int64()), ('w', pa.int64()), ('p', pa.string())])
def _table(catalog, options=None):
settings = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'commit.native.enabled': 'true', 'write.native.enabled': 'true',
'manifest.sidecar.enabled': 'true'}
settings.update(options or {})
catalog.create_table('default.t', Schema.from_pyarrow_schema(
_SCHEMA, partition_keys=['p'], options=settings), False)
return catalog.get_table('default.t')
def _prepare(builder, ids, partition='a', identifier=None):
writer = builder.new_write()
try:
assert isinstance(writer, NativeTableWrite) == builder.table.options.native_write_enabled()
writer.write_arrow(pa.table({'id': ids, 'v': ids, 'w': ids,
'p': [partition] * len(ids)}, schema=_SCHEMA))
return writer.prepare_commit() if identifier is None else writer.prepare_commit(identifier)
finally:
writer.close()
def _commit(builder, messages, identifier=None, overwrite=False):
commit = builder.new_commit()
try:
with patch.object(commit.file_store_commit, 'overwrite' if overwrite else 'commit',
side_effect=AssertionError('Python commit fallback')):
if identifier is None:
commit.commit(messages)
else:
commit.commit(messages, identifier)
finally:
commit.close()
def _read(table, native=False, metadata=False):
read_table = table.copy({'read.native.enabled': str(native).lower(),
'scan.native-plan.enabled': str(native).lower()})
builder = read_table.new_read_builder()
if metadata:
builder.with_projection(['id', 'v', 'w', 'p', '_ROW_ID', '_SEQUENCE_NUMBER'])
read = builder.new_read()
with ExitStack() as stack:
if native:
stack.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python read fallback')
))
return read.to_arrow(builder.new_scan().plan().splits()).sort_by('id').to_pydict()
def _delta(table):
snapshot = table.snapshot_manager().get_latest_snapshot()
metas = ManifestListManager(table).read(snapshot.delta_manifest_list)
manager = ManifestFileManager(table)
entries = [entry for meta in metas for entry in manager.read(meta.file_name)]
return snapshot, metas, entries
@pytest.mark.parametrize('native_write', [False, True])
@pytest.mark.parametrize('group', [False, True])
def test_append_groups_partitions_assigns_versions_and_publishes_sidecars(
native_rest_catalog, native_write, group):
table = _table(native_rest_catalog, {
'write.native.enabled': str(native_write).lower(),
'row-tracking.partition-group-on-commit': str(group).lower()})
builder = table.new_batch_write_builder()
messages = (_prepare(builder, [10, 11], 'a') + _prepare(builder, [20], 'b')
+ _prepare(builder, [12], 'a'))
assert native_messages_supported(table, messages)
_commit(builder, messages)
snapshot, metas, entries = _delta(table)
assert snapshot.next_row_id == 4
assert [(e.file.first_row_id, e.file.row_count) for e in entries] == [
(0, 2), (2, 1), (3, 1)]
assert [e.partition.values for e in entries] == (
[['a'], ['a'], ['b']] if group else [['a'], ['b'], ['a']])
assert all((e.file.min_sequence_number, e.file.max_sequence_number) == (1, 1)
for e in entries)
for meta in metas:
assert meta.extra_files == [meta.file_name + SUFFIX]
path = table.table_path + '/manifest/' + meta.file_name
assert read_sidecar(table.file_io, path, meta, [Range(0, 0)]).blocks
assert not read_sidecar(table.file_io, path, meta, [Range(4, 4)]).blocks
for native in (False, True):
rows = _read(table, native, metadata=True)
assert rows['id'] == [10, 11, 12, 20]
assert rows['_ROW_ID'] == ([0, 1, 2, 3] if group else [0, 1, 3, 2])
if not native:
assert rows['_SEQUENCE_NUMBER'] == [1, 1, 1, 1]
@pytest.mark.parametrize('stream', [False, True])
def test_partial_column_update_keeps_row_ids_and_checks_conflicts(native_rest_catalog, stream):
table = _table(native_rest_catalog)
seed = table.new_batch_write_builder()
_commit(seed, _prepare(seed, [1, 2]))
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
def prepare(column, value):
update = builder.new_update()
updater = update.new_update_by_row_id(7) if stream else update.new_update_by_row_id()
return updater.update_columns(pa.table({'_ROW_ID': [0], column: [value]}), [column])
first = prepare('v', 10)
conflicting = prepare('v', 20)
disjoint = prepare('w', 30)
assert all(m.check_from_snapshot == 1 for m in first + conflicting + disjoint)
_commit(builder, first, 7 if stream else None)
# Give each logical transaction a separate commit identity.
other = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
with pytest.raises(Exception, match='multiple MERGE INTO.*conflicts'):
_commit(other, conflicting, 8 if stream else None)
assert table.snapshot_manager().get_latest_snapshot().id == 2
other = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
_commit(other, disjoint, 9 if stream else None)
snapshot, _, entries = _delta(table)
assert snapshot.next_row_id == 2
assert all(e.file.first_row_id == 0 for e in entries)
for native in (False, True):
rows = _read(table, native, metadata=True)
assert rows['v'] == [10, 2]
assert rows['w'] == [30, 2]
assert rows['_ROW_ID'] == [0, 1]
@pytest.mark.parametrize('native_write', [False, True])
def test_overwrite_keeps_deleted_versions_and_allocates_fresh_row_ids(native_rest_catalog, native_write):
table = _table(native_rest_catalog, {'write.native.enabled': str(native_write).lower()})
seed = table.new_batch_write_builder()
_commit(seed, _prepare(seed, [1, 2], 'a') + _prepare(seed, [3], 'b'))
builder = table.new_batch_write_builder().overwrite({'p': 'a'})
_commit(builder, _prepare(builder, [4], 'a'), overwrite=True)
snapshot, _, entries = _delta(table)
assert snapshot.next_row_id == 4
deleted = [e.file for e in entries if e.kind == 1]
added = [e.file for e in entries if e.kind == 0]
assert [(f.first_row_id, f.min_sequence_number, f.max_sequence_number)
for f in deleted] == [(0, 1, 1)]
assert [(f.first_row_id, f.min_sequence_number, f.max_sequence_number)
for f in added] == [(3, 2, 2)]
for native in (False, True):
rows = _read(table, native, metadata=True)
assert rows['id'] == [3, 4]
assert rows['_ROW_ID'] == [2, 3]
if not native:
assert rows['_SEQUENCE_NUMBER'] == [1, 2]
def test_deletion_vector_commit_preserves_row_id_high_watermark(native_rest_catalog):
table = _table(native_rest_catalog, {'deletion-vectors.enabled': 'true'})
seed = table.new_batch_write_builder()
_commit(seed, _prepare(seed, [1, 2, 3]))
builder = table.new_batch_write_builder()
messages = builder.new_update().delete_by_row_id([0, 2])
_commit(builder, messages)
snapshot = table.snapshot_manager().get_latest_snapshot()
assert snapshot.next_row_id == 3
assert snapshot.index_manifest
for native in (False, True):
rows = _read(table, native, metadata=True)
assert rows['id'] == [2]
assert rows['_ROW_ID'] == [1]
@pytest.mark.parametrize('rewrite_size', [None, '0 B', '256 MB'])
def test_preassigned_row_id_updates_use_native_java_validation(native_rest_catalog, rewrite_size):
options = {} if rewrite_size is None else {
'data-evolution.row-id-conflict-rewrite.max-size': rewrite_size}
table = _table(native_rest_catalog, options)
seed = table.new_batch_write_builder()
_commit(seed, _prepare(seed, [1, 2]))
builder = table.new_batch_write_builder()
messages = builder.new_update().new_update_by_row_id().update_columns(
pa.table({'_ROW_ID': [0], 'v': [99]}), ['v'])
assert native_messages_supported(table, messages)
_commit(builder, messages)
snapshot = table.snapshot_manager().get_latest_snapshot()
assert snapshot.id == 2
assert snapshot.next_row_id == 2
for native in (False, True):
assert _read(table, native)['v'] == [99, 2]
@pytest.mark.parametrize('group_option', ['off', '0', ' true '])
def test_native_partition_group_option_uses_python_boolean_normalization(native_rest_catalog, group_option):
table = _table(native_rest_catalog, {'row-tracking.partition-group-on-commit': group_option})
builder = table.new_batch_write_builder()
_commit(builder, _prepare(builder, [1], 'a') + _prepare(builder, [2], 'b')
+ _prepare(builder, [3], 'a'))
_, _, entries = _delta(table)
expected = [['a'], ['a'], ['b']] if table.options.row_tracking_partition_group_on_commit() else [
['a'], ['b'], ['a']]
assert [entry.partition.values for entry in entries] == expected
def test_stream_abort_preserves_published_row_ids(native_rest_catalog):
table = _table(native_rest_catalog)
builder = table.new_stream_write_builder()
messages = _prepare(builder, [1, 2], identifier=7)
_commit(builder, messages, 7)
assert table.snapshot_manager().get_latest_snapshot().next_row_id == 2
pending = _prepare(builder, [3], identifier=8)
pending_paths = [file.file_path for message in pending for file in message.new_files]
assert pending_paths and all(table.file_io.exists(path) for path in pending_paths)
commit = builder.new_commit()
try:
with patch.object(commit.file_store_commit, 'abort',
side_effect=AssertionError('Python abort fallback')):
commit.abort(pending)
finally:
commit.close()
assert all(not table.file_io.exists(path) for path in pending_paths)
assert _read(table, True, metadata=True)['_ROW_ID'] == [0, 1]
_commit(builder, _prepare(builder, [4], identifier=9), 9)
assert table.snapshot_manager().get_latest_snapshot().next_row_id == 3
assert _read(table, True, metadata=True)['_ROW_ID'] == [0, 1, 2]
@pytest.mark.parametrize('external', [False, True])
def test_blob_files_align_with_normal_files_after_native_rest_commit(
tmp_path, native_rest_catalog, external):
from pypaimon.schema.data_types import AtomicType, DataField
options = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'write.native.enabled': 'true', 'commit.native.enabled': 'true',
'blob.target-file-size': '1 B', 'manifest.sidecar.enabled': 'true',
'manifest.target-file-size': '1 B'}
if external:
options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
fields = [DataField(0, 'id', AtomicType('BIGINT')),
DataField(1, 'p', AtomicType('STRING')),
DataField(2, 'left', AtomicType('BLOB')),
DataField(3, 'right', AtomicType('BLOB'))]
native_rest_catalog.create_table('default.blobs', Schema(
fields=fields, partition_keys=['p'], options=options), False)
table = native_rest_catalog.get_table('default.blobs')
schema = pa.schema([('id', pa.int64()), ('p', pa.string()),
('left', pa.large_binary()), ('right', pa.large_binary())])
builder = table.new_stream_write_builder()
expected = []
for identifier, partition in [(1, 'a/b'), (2, 'c')]:
writer = builder.new_write()
assert isinstance(writer, NativeTableWrite)
rows = [{'id': identifier * 10, 'p': partition, 'left': b'x' * 80, 'right': b''},
{'id': identifier * 10 + 1, 'p': partition, 'left': None, 'right': b'y' * 16}]
try:
writer.write_arrow(pa.Table.from_pylist(rows, schema=schema))
messages = writer.prepare_commit(identifier)
_commit(builder, messages, identifier)
finally:
writer.close()
expected.extend(rows)
snapshot, metas, entries = _delta(table)
assert snapshot.next_row_id == identifier * 2
normal = [entry.file for entry in entries if entry.file.file_name.endswith('.parquet')]
blobs = [entry.file for entry in entries if entry.file.file_name.endswith('.blob')]
assert len(normal) == 1 and blobs
start = (identifier - 1) * 2
assert normal[0].first_row_id == start
for column in ('left', 'right'):
column_files = [file for file in blobs if file.write_cols == [column]]
assert column_files
assert min(file.first_row_id for file in column_files) == start
assert sum(file.row_count for file in column_files) == 2
assert all(meta.extra_files == [meta.file_name + SUFFIX] for meta in metas)
for native in (False, True):
readable = table.copy({'read.native.enabled': str(native).lower(),
'scan.native-plan.enabled': str(native).lower()})
read = readable.new_read_builder()
actual = read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
assert actual.to_pylist() == expected
@pytest.mark.parametrize('rewrite_size', [None, '256 MB'])
def test_native_blob_update_rejects_stale_range_before_publication(native_rest_catalog, rewrite_size):
from dataclasses import replace
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.write.commit_message import CommitMessage
fields = [DataField(0, 'id', AtomicType('BIGINT')), DataField(1, 'payload', AtomicType('BLOB'))]
options = {'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
'write.native.enabled': 'true', 'commit.native.enabled': 'true'}
if rewrite_size is not None:
options['data-evolution.row-id-conflict-rewrite.max-size'] = rewrite_size
native_rest_catalog.create_table('default.blobs', Schema(fields=fields, options=options), False)
table = native_rest_catalog.get_table('default.blobs')
schema = pa.schema([('id', pa.int64()), ('payload', pa.large_binary())])
builder = table.new_batch_write_builder()
def prepare(ids):
writer = builder.new_write()
try:
writer.write_arrow(pa.table({'id': ids, 'payload': [b'value'] * len(ids)}, schema=schema))
return writer.prepare_commit()
finally:
writer.close()
# Simulate a current layout with two adjacent normal ranges, as can result
# from another engine replacing a former [0, 9] normal file.
_commit(builder, prepare(list(range(5))) + prepare(list(range(5, 10))))
staged = prepare(list(range(10)))
source = staged[0]
blob = next(file for file in source.new_files if file.file_name.endswith('.blob'))
assert blob.row_count == 10
blob = replace(blob, first_row_id=0, min_sequence_number=0, max_sequence_number=0)
stale = [CommitMessage(source.partition, source.bucket, [blob], check_from_snapshot=1)]
assert native_messages_supported(table, stale)
before = table.snapshot_manager().get_latest_snapshot()
with pytest.raises(Exception, match='Row ID existence conflict|not covered by one data file range'):
_commit(table.new_batch_write_builder(), stale)
assert table.snapshot_manager().get_latest_snapshot().id == before.id
assert table.snapshot_manager().get_latest_snapshot().next_row_id == 10
# Both engines must still be able to read the original, valid snapshot.
for native in (False, True):
read = table.copy({'read.native.enabled': str(native).lower(),
'scan.native-plan.enabled': str(native).lower()}).new_read_builder()
actual = read.new_read().to_arrow(read.new_scan().plan().splits()).sort_by('id')
assert actual['id'].to_pylist() == list(range(10))
assert actual['payload'].to_pylist() == [b'value'] * 10
commit = builder.new_commit()
try:
commit.abort(staged)
finally:
commit.close()
@pytest.mark.parametrize('action', ['IGNORE', 'DROP_PARTITION_INDEX', 'THROW_ERROR'])
def test_native_update_honors_java_global_index_policy(native_rest_catalog, action):
from pypaimon.tests.global_index_update_action_test import _index_entry
from pypaimon.write.commit_message import CommitMessage
from pypaimon.execution.python.write.global_index_update_checker import scan_global_index_entries
table = _table(native_rest_catalog, {'global-index.column-update-action': action})
seed = table.new_batch_write_builder()
_commit(seed, _prepare(seed, [1, 2]))
# Commit policy only needs manifest metadata. This fixture intentionally
# does not execute an index query or claim to refresh IGNORE's stale index.
index = _index_entry('indexed-v', ('a',), 1)
_commit(table.new_batch_write_builder(), [CommitMessage(('a',), 0, [], index_adds=[index])])
builder = table.new_batch_write_builder()
# Prepare without a Python index policy precheck, so the committer itself
# must enforce the resolved table policy for externally produced messages.
update_table = table.copy({'global-index.column-update-action': 'IGNORE'})
messages = update_table.new_batch_write_builder().new_update().new_update_by_row_id().update_columns(
pa.table({'_ROW_ID': [0], 'v': [99]}), ['v'])
if action == 'THROW_ERROR':
with pytest.raises(Exception, match='globally indexed columns'):
_commit(builder, messages)
else:
_commit(builder, messages)
snapshot = table.snapshot_manager().get_latest_snapshot()
assert snapshot.id == (2 if action == 'THROW_ERROR' else 3)
assert snapshot.next_row_id == 2
indexes = scan_global_index_entries(table, snapshot)
assert [entry.index_file.file_name for entry in indexes] == (
[] if action == 'DROP_PARTITION_INDEX' else ['indexed-v'])
assert _read(table, True)['v'] == ([1, 2] if action == 'THROW_ERROR' else [99, 2])