blob: 54bba8d9f043fd4a91b52ee7acab02183b756c1d [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.
"""Dynamic bucket writer and HASH index interoperability with Rust core."""
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.index.index_file_handler import IndexFileHandler
from pypaimon.execution.native.write.native_commit import from_native_commit_messages
from pypaimon.write.write_dispatch import NativeTableWrite
pytestmark = pytest.mark.native_plan
SCHEMA = pa.schema([('id', pa.int64()), ('p', pa.string()), ('v', pa.string())])
def _table(tmp_path, options=None, catalog=None):
catalog = catalog or CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
opts = {'bucket': '-1', 'file.format': 'parquet',
'dynamic-bucket.target-row-num': '2', 'write.native.enabled': 'true'}
opts.update(options or {})
catalog.create_table('default.t', Schema.from_pyarrow_schema(
SCHEMA, primary_keys=['id', 'p'], partition_keys=['p'], options=opts), False)
return catalog.get_table('default.t')
def _batch(ids, p='a', value='v'):
return pa.RecordBatch.from_pylist(
[{'id': i, 'p': p, 'v': value} for i in ids], schema=SCHEMA)
def _indexes(table):
return IndexFileHandler(table).scan(table.snapshot_manager().get_latest_snapshot())
def _write(table, batch, native=True):
table = table.copy({'write.native.enabled': str(native).lower()})
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite) == native
try:
writer.write_arrow_batch(batch)
if native:
assert writer._python_writer is None
messages = writer.prepare_commit()
commit.commit(messages)
return messages
finally:
writer.close()
commit.close()
def _rows(table, native):
builder = table.copy({'scan.native-plan.enabled': str(native).lower(),
'read.native.enabled': str(native).lower()}).new_read_builder()
splits = builder.new_scan().plan().splits()
reader = builder.new_read()
if native:
with patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('fallback')
):
rows = reader.to_arrow(splits).to_pylist()
else:
rows = reader.to_arrow(splits).to_pylist()
return sorted(rows, key=lambda row: (row['p'], row['id']))
def test_python_commit_accepts_java_hash_replacement(tmp_path):
from pypaimon_rust.datafusion import PaimonCatalog
table = _table(tmp_path, {'dynamic-bucket.target-row-num': '100'})
for ids in ([1], [2]):
native = PaimonCatalog({'warehouse': str(tmp_path)}).get_table('default.t')
builder = native.new_batch_write_builder()
writer = builder.new_write()
writer.write_arrow(_batch(ids))
messages = from_native_commit_messages(table, writer.prepare_commit())
assert len(messages) == 1
assert len(messages[0].index_adds) == 1
assert messages[0].index_deletes == []
table.new_batch_write_builder().new_commit().commit(messages)
writer.close()
assert len(_indexes(table)) == 1
assert _indexes(table)[0].index_file.row_count == 2
for native in (False, True):
assert _rows(table, native) == _batch([1, 2]).to_pylist()
@pytest.mark.parametrize('bucket_local', [False, True])
@pytest.mark.parametrize('first_native', [False, True])
def test_dynamic_cross_backend_restart(tmp_path, bucket_local, first_native):
table = _table(tmp_path, {
'index-file-in-data-file-dir': str(bucket_local).lower(),
'data-file.path-directory': 'data',
'dynamic-bucket.max-buckets': '1',
})
for native, ids, value in [(first_native, [1, 2, 3], 'old'),
(not first_native, [2, 4], 'new'),
(first_native, [1, 5], 'last')]:
messages = _write(table, _batch(ids, 'a/b', value), native)
assert {m.bucket for m in messages} == {0}
indexes = _indexes(table)
assert len(indexes) == 1
assert indexes[0].index_file.external_path is None
path = table.path_factory().bucket_index_path(
tuple(indexes[0].partition.values), 0, indexes[0].index_file)
assert table.file_io.exists(path)
assert _indexes(table)[0].index_file.row_count == 5
expected = [{'id': i, 'p': 'a/b', 'v': v} for i, v in
[(1, 'last'), (2, 'new'), (3, 'old'), (4, 'new'), (5, 'last')]]
for native in (False, True):
assert _rows(table, native) == expected
@pytest.mark.parametrize('native_commit', [False, True])
def test_stream_dynamic_index_checkpoints(tmp_path, native_rest_catalog, native_commit):
table = _table(tmp_path, {'commit.native.enabled': str(native_commit).lower(),
'dynamic-bucket.max-buckets': '1'}, native_rest_catalog)
builder = table.new_stream_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
assert isinstance(writer, NativeTableWrite)
try:
for checkpoint, ids in enumerate(([1, 2], [2], [3]), 1):
writer.write_arrow_batch(_batch(ids, value=str(checkpoint)))
messages = writer.prepare_commit(checkpoint)
adds = [entry for m in messages for entry in m.index_adds]
assert len(adds) == (0 if checkpoint == 2 else 1)
if native_commit:
with patch.object(commit.file_store_commit, 'commit',
side_effect=AssertionError('Python commit fallback')):
commit.commit(messages, checkpoint)
else:
commit.commit(messages, checkpoint)
assert writer.prepare_commit(4) == []
assert _indexes(table)[0].index_file.row_count == 3
finally:
writer.close()
commit.close()
for native in (False, True):
assert _rows(table, native) == [
{'id': 1, 'p': 'a', 'v': '1'}, {'id': 2, 'p': 'a', 'v': '2'},
{'id': 3, 'p': 'a', 'v': '3'}]
@pytest.mark.parametrize('bucket_local', [False, True])
@pytest.mark.parametrize('commit_first', [False, True])
def test_dynamic_abort_index_ownership(tmp_path, bucket_local, commit_first):
table = _table(tmp_path, {'index-file-in-data-file-dir': str(bucket_local).lower()})
builder = table.new_batch_write_builder()
writer = builder.new_write()
assert isinstance(writer, NativeTableWrite)
writer.write_arrow_batch(_batch([1]))
messages = writer.prepare_commit()
entry = messages[0].index_adds[0]
path = table.path_factory().bucket_index_path(('a',), entry.bucket, entry.index_file)
assert table.file_io.exists(path)
if commit_first:
builder.new_commit().commit(messages)
writer.abort()
assert table.file_io.exists(path)
assert all(table.file_io.exists(f.file_path)
for m in messages for f in m.new_files)
@pytest.mark.parametrize('native', [False, True])
def test_hash_index_requires_configured_directory(tmp_path, native):
# A file in table/index cannot replace a missing bucket-local index.
table = _table(tmp_path, {'index-file-in-data-file-dir': 'false'})
_write(table, _batch([1]), False)
old = _indexes(table)[0]
assert old.index_file.external_path is None
table = table.copy({'index-file-in-data-file-dir': 'true'})
with pytest.raises((FileNotFoundError, ValueError)) as error:
_write(table, _batch([2]), native)
assert old.index_file.file_name in str(error.value)
assert table.snapshot_manager().get_latest_snapshot().id == 1
assert _indexes(table) == [old]
@pytest.mark.parametrize('missing_all', [False, True])
def test_native_rejects_incomplete_hash_index(tmp_path, missing_all):
table = _table(tmp_path, {'dynamic-bucket.target-row-num': '1'})
builder = table.copy({'write.native.enabled': 'false'}).new_batch_write_builder()
writer = builder.new_write()
writer.write_arrow_batch(_batch([1, 2]))
messages = writer.prepare_commit()
for message in messages:
if missing_all or message.bucket == 1:
message.index_adds.clear()
builder.new_commit().commit(messages)
writer.close()
writer = table.new_batch_write_builder().new_write()
assert isinstance(writer, NativeTableWrite)
try:
with pytest.raises(Exception, match='complete HASH index'):
writer.write_arrow_batch(_batch([2], value='update'))
with pytest.raises(Exception, match='cannot be reused'):
writer.prepare_commit()
finally:
writer.close()
assert table.snapshot_manager().get_latest_snapshot().id == 1
@pytest.mark.parametrize('native_commit', [False, True])
@pytest.mark.parametrize('dynamic', [False, True])
def test_dynamic_overwrite_resets_only_replaced_indexes(
tmp_path, native_rest_catalog, native_commit, dynamic):
table = _table(
tmp_path, {'commit.native.enabled': str(native_commit).lower(),
'dynamic-partition-overwrite': str(dynamic).lower()}, native_rest_catalog)
_write(table, _batch([1, 2], 'a'))
_write(table, _batch([3], 'b'))
previous_b = next(e.index_file.file_name for e in _indexes(table) if e.partition.values == ['b'])
builder = table.new_batch_write_builder().overwrite({'p': 'a'})
writer = builder.new_write()
assert isinstance(writer, NativeTableWrite)
writer.write_arrow_batch(_batch([4], 'a'))
builder.new_commit().commit(writer.prepare_commit())
writer.close()
counts = sorted((tuple(e.partition.values), e.index_file.row_count) for e in _indexes(table))
assert counts == [(('a',), 1), (('b',), 1)]
assert next(e.index_file.file_name for e in _indexes(table) if e.partition.values == ['b']) == previous_b
_write(table, _batch([4, 5], 'a', 'next'))
for native in (False, True):
assert _rows(table, native) == _batch([4, 5], 'a', 'next').to_pylist() + _batch([3], 'b').to_pylist()
# Static empty overwrite removes the old partition's HASH as well as data.
builder = table.copy({'dynamic-partition-overwrite': 'false'}).new_batch_write_builder().overwrite({'p': 'a'})
writer = builder.new_write()
builder.new_commit().commit(writer.prepare_commit())
writer.close()
assert [e.partition.values for e in _indexes(table)] == [['b']]
@pytest.mark.parametrize('native', [False, True])
@pytest.mark.parametrize('bucket_key', ['id', ''])
def test_dynamic_writer_rejects_custom_bucket_key(tmp_path, native, bucket_key):
table = _table(tmp_path).copy({'write.native.enabled': str(native).lower(),
'bucket-key': bucket_key})
with pytest.raises(Exception, match="Cannot define 'bucket-key'"):
table.new_batch_write_builder().new_write()
@pytest.mark.parametrize('bucket_local', [False, True])
def test_dynamic_external_indexes_cross_backends(tmp_path, bucket_local):
external = tmp_path / 'external-index'
options = {'index-file-in-data-file-dir': str(bucket_local).lower(),
'data-file.path-directory': 'data',
'dynamic-bucket.max-buckets': '1'}
if bucket_local:
options.update({'data-file.external-paths': external.as_uri(),
'data-file.external-paths.strategy': 'round-robin'})
else:
options['global-index.external-path'] = external.as_uri()
table = _table(tmp_path, options)
for native, ids in [(True, [1]), (False, [2]), (True, [3])]:
_write(table, _batch(ids), native)
indexes = _indexes(table)
assert len(indexes) == 1
path = indexes[0].index_file.external_path
assert path is not None
assert '/external-index/' in path
assert ('/data/p=a/bucket-0/' in path) == bucket_local
assert table.file_io.exists(path)
for native in (False, True):
assert _rows(table, native) == _batch([1, 2, 3]).to_pylist()