blob: 30d2dcaa85806d6c66683083be59db93873cdb87 [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.
################################################################################
import datetime
import tempfile
import unittest
from decimal import Decimal
from unittest.mock import Mock, patch
import pyarrow as pa
from pypaimon import CatalogFactory, Schema
from pypaimon.index.dynamic_bucket import (
HashBucketAssigner,
_PartitionIndex,
_iter_hashes,
compute_assigner,
to_signed_int32,
)
from pypaimon.index.index_file_handler import IndexFileHandler
from pypaimon.index.index_file_meta import IndexFileMeta
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.write.row_key_extractor import DynamicBucketRowKeyExtractor
class DynamicBucketTest(unittest.TestCase):
@staticmethod
def _create_table(
root, name, target_row_num=100, max_buckets=None
):
catalog = CatalogFactory.create({'warehouse': root})
catalog.create_database('default', True)
options = {
'bucket': '-1',
'dynamic-bucket.target-row-num': str(target_row_num),
'file.format': 'parquet',
}
if max_buckets is not None:
options['dynamic-bucket.max-buckets'] = str(max_buckets)
schema = Schema.from_pyarrow_schema(
pa.schema([
pa.field('id', pa.int64()),
pa.field('value', pa.string()),
]),
primary_keys=['id'],
options=options,
)
catalog.create_table(f'default.{name}', schema, False)
return catalog.get_table(f'default.{name}')
@staticmethod
def _prepare_indexed_write(table, ids):
builder = table.new_batch_write_builder()
writer = builder.new_write().with_dynamic_bucket_index()
writer.write_arrow_batch(pa.RecordBatch.from_pydict({
'id': ids,
'value': [f'v-{value}' for value in ids],
}))
return writer, builder.new_commit(), writer.prepare_commit()
@staticmethod
def _hash_indexes(table):
snapshot = table.snapshot_manager().get_latest_snapshot()
return [
entry for entry in IndexFileHandler(table).scan(snapshot)
if entry.index_file.index_type == 'HASH'
]
@staticmethod
def _commit_arrow(table, ids, values):
builder = table.new_batch_write_builder()
writer = builder.new_write()
writer.write_arrow(pa.table({'id': ids, 'value': values}))
messages = writer.prepare_commit()
commit = builder.new_commit()
commit.commit(messages)
writer.close()
commit.close()
return messages
@staticmethod
def _read_arrow(table):
builder = table.new_read_builder()
return builder.new_read().to_arrow(builder.new_scan().plan().splits())
def test_compute_assigner_matches_java(self):
max_int = 2 ** 31 - 1
self.assertEqual(compute_assigner(max_int, 0, 5, 5), 2)
self.assertEqual(compute_assigner(max_int, 1, 5, 5), 3)
self.assertEqual(compute_assigner(max_int, 2, 5, 5), 4)
self.assertEqual(compute_assigner(max_int, 3, 5, 5), 0)
self.assertEqual(compute_assigner(2, 0, 5, 3), 2)
self.assertEqual(compute_assigner(2, 1, 5, 3), 3)
self.assertEqual(compute_assigner(2, 2, 5, 3), 4)
self.assertEqual(compute_assigner(2, 3, 5, 3), 2)
self.assertEqual(compute_assigner(3, 1, 5, 1), 3)
self.assertEqual(compute_assigner(3, 2, 5, 1), 3)
min_int = -(2 ** 31)
self.assertEqual(compute_assigner(min_int, 0, 5, 5), 3)
self.assertEqual(compute_assigner(2, min_int, 5, 5), 0)
def test_binary_row_hash_matches_java_for_bucket_key_types(self):
# Generated with Java InternalRowSerializer.toBinaryRow(...).hashCode().
cases = [
(
'inline string',
('hello',),
[DataField(0, 'key', AtomicType('STRING'))],
243722546,
),
(
'variable string',
('hello-java',),
[DataField(0, 'key', AtomicType('STRING'))],
-201703277,
),
(
'composite bucket key',
('hello-java', 42),
[
DataField(0, 'key1', AtomicType('STRING')),
DataField(1, 'key2', AtomicType('BIGINT')),
],
-2066620165,
),
(
'compact decimal',
(Decimal('12345.67'),),
[DataField(0, 'key', AtomicType('DECIMAL(10, 2)'))],
754928256,
),
(
'variable decimal',
(Decimal('12345678901234567890123.45'),),
[DataField(0, 'key', AtomicType('DECIMAL(25, 2)'))],
1388205002,
),
(
'compact timestamp',
(datetime.datetime(2026, 1, 2, 3, 4, 5, 123000),),
[DataField(0, 'key', AtomicType('TIMESTAMP(3)'))],
-1766746798,
),
(
'variable timestamp',
(datetime.datetime(2026, 1, 2, 3, 4, 5, 123456),),
[DataField(0, 'key', AtomicType('TIMESTAMP(6)'))],
1245041971,
),
(
'inline binary',
(bytes([0, 1, 255, 127]),),
[DataField(0, 'key', AtomicType('BYTES'))],
586821318,
),
(
'variable binary',
(bytes(range(10)),),
[DataField(0, 'key', AtomicType('BYTES'))],
1822312655,
),
]
for name, values, fields, java_hash in cases:
with self.subTest(name=name):
actual = DynamicBucketRowKeyExtractor._binary_row_hash_code(
values, fields
)
self.assertEqual(java_hash, to_signed_int32(actual))
def test_unbounded_bucket_id_matches_java_short_limit(self):
index = _PartitionIndex({}, {}, 1)
bucket = index.assign(
key_hash=1,
bucket_filter=lambda candidate: candidate == 32766,
max_buckets_num=-1,
max_bucket_id=32765,
)
self.assertEqual(bucket, 32766)
def test_assigner_rejects_record_owned_by_another_writer(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'wrong_assigner')
assigner = HashBucketAssigner(
table=table,
num_channels=2,
num_assigners=2,
assign_id=0,
target_bucket_row_number=100,
max_buckets_num=-1,
)
with self.assertRaisesRegex(
ValueError, 'Record assigner 1 does not match writer assigner 0'
):
assigner.assign((), partition_hash=0, key_hash=1)
def test_max_buckets_rejects_assigner_without_bucket(self):
index = _PartitionIndex({}, {}, 1)
with self.assertRaisesRegex(
RuntimeError, 'No dynamic bucket is available for this assigner'
):
index.assign(
key_hash=1,
bucket_filter=lambda _: False,
max_buckets_num=1,
max_bucket_id=0,
)
def test_unbounded_buckets_rejects_java_short_id_exhaustion(self):
index = _PartitionIndex({}, {}, 1)
with self.assertRaisesRegex(
RuntimeError, 'No dynamic bucket id remains below Java Short.MAX_VALUE'
):
index.assign(
key_hash=1,
bucket_filter=lambda _: False,
max_buckets_num=-1,
max_bucket_id=32766,
)
def test_corrupt_hash_index_rejects_trailing_bytes(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'corrupt_index')
path = f'{root}/corrupt-hash-index'
with table.file_io.new_output_stream(path) as stream:
stream.write(b'\x00\x00\x00')
entry = IndexManifestEntry(
kind=0,
partition=GenericRow([], []),
bucket=0,
index_file=IndexFileMeta(
index_type='HASH',
file_name='corrupt-hash-index',
file_size=3,
row_count=1,
external_path=path,
),
)
with self.assertRaisesRegex(
RuntimeError, 'expected a multiple of 4 bytes'
):
list(_iter_hashes(table, entry))
def test_regular_dynamic_writer_uses_persistent_index(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'regular_dynamic')
writer = table.new_batch_write_builder().new_write()
self.assertIs(table, writer.row_key_extractor._table)
writer.write_arrow_batch(pa.RecordBatch.from_pydict({
'id': [1],
'value': ['v-1'],
}))
messages = writer.prepare_commit()
self.assertTrue(any(message.index_adds for message in messages))
self.assertFalse(any(message.index_deletes for message in messages))
def test_regular_dynamic_writer_restores_mapping_across_commits(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'regular_upsert', target_row_num=1)
self._commit_arrow(table, [1], ['old'])
self._commit_arrow(table, [2, 1], ['other', 'new'])
result = self._read_arrow(table).sort_by('id').to_pydict()
self.assertEqual({'id': [1, 2], 'value': ['new', 'other']}, result)
def test_regular_dynamic_writer_retains_only_requested_index_hashes(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'bounded_restore')
self._commit_arrow(
table, list(range(10)), [f'old-{value}' for value in range(10)]
)
writer = table.new_batch_write_builder().new_write()
writer.write_arrow(pa.table({'id': [3], 'value': ['new-3']}))
partition_index = writer.row_key_extractor._assigner._partition_indexes[()]
self.assertEqual(1, len(partition_index.hash_to_bucket))
self.assertEqual({}, writer.row_key_extractor._index_maintainer._states)
def test_legacy_dynamic_data_without_hash_index_fails_fast(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'legacy_no_index')
builder = table.new_batch_write_builder()
writer = builder.new_write()
writer.row_key_extractor = DynamicBucketRowKeyExtractor(
table.table_schema
)
writer.write_arrow(pa.table({'id': [1], 'value': ['old']}))
messages = writer.prepare_commit()
builder.new_commit().commit(messages)
writer.close()
new_writer = table.new_batch_write_builder().new_write()
with self.assertRaisesRegex(
RuntimeError, 'has data files but no complete HASH index'
):
new_writer.write_arrow(pa.table({'id': [1], 'value': ['new']}))
def test_cross_partition_write_requires_global_index(self):
with tempfile.TemporaryDirectory() as root:
catalog = CatalogFactory.create({'warehouse': root})
catalog.create_database('default', True)
schema = Schema.from_pyarrow_schema(
pa.schema([
pa.field('id', pa.int64()),
pa.field('value', pa.string()),
pa.field('dt', pa.string()),
]),
partition_keys=['dt'],
primary_keys=['id'],
options={'bucket': '-1'},
)
catalog.create_table('default.cross_partition', schema, False)
table = catalog.get_table('default.cross_partition')
with self.assertRaisesRegex(
ValueError, 'CROSS_PARTITION.*global primary-key index'
):
table.new_batch_write_builder().new_write()
def test_batch_writer_abort_after_prepare_deletes_hash_index(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'abort_prepared')
writer = table.new_batch_write_builder().new_write()
writer.write_arrow(pa.table({'id': [1], 'value': ['v-1']}))
messages = writer.prepare_commit()
index_path = messages[0].index_adds[0].index_file.external_path
if index_path is None:
index_path = (
table.path_factory().global_index_path_factory()
.to_path(messages[0].index_adds[0].index_file.file_name)
)
self.assertTrue(table.file_io.exists(index_path))
writer.abort()
self.assertFalse(table.file_io.exists(index_path))
def test_stream_writer_releases_prepared_hash_index_ownership(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'stream_prepared')
builder = table.new_stream_write_builder()
writer = builder.new_write()
commit = builder.new_commit()
writer.write_arrow(pa.table({'id': [1], 'value': ['v-1']}))
messages = writer.prepare_commit(1)
index_path = messages[0].index_adds[0].index_file.external_path
if index_path is None:
index_path = (
table.path_factory().global_index_path_factory()
.to_path(messages[0].index_adds[0].index_file.file_name)
)
self.assertEqual(
[], writer.row_key_extractor._index_maintainer._new_paths
)
commit.commit(messages, 1)
writer.close()
self.assertTrue(table.file_io.exists(index_path))
self.assertEqual(
{'id': [1], 'value': ['v-1']},
self._read_arrow(table).to_pydict(),
)
commit.close()
def test_regular_dynamic_extractor_skips_partition_hash(self):
with tempfile.TemporaryDirectory() as root:
catalog = CatalogFactory.create({'warehouse': root})
catalog.create_database('default', True)
schema = Schema.from_pyarrow_schema(
pa.schema([
pa.field('id', pa.int64()),
pa.field('value', pa.string()),
pa.field('dt', pa.string()),
]),
partition_keys=['dt'],
primary_keys=['id', 'dt'],
options={
'bucket': '-1',
'dynamic-bucket.target-row-num': '100',
},
)
catalog.create_table('default.partitioned', schema, False)
table = catalog.get_table('default.partitioned')
extractor = DynamicBucketRowKeyExtractor(table.table_schema)
hash_code = Mock(wraps=extractor._binary_row_hash_code)
extractor._binary_row_hash_code = hash_code
extractor.extract_partition_bucket_batch(
pa.RecordBatch.from_pydict({
'id': [1, 2],
'value': ['a', 'b'],
'dt': ['p', 'p'],
})
)
self.assertEqual(2, hash_code.call_count)
self.assertNotIn(
('p',),
[call.args[0] for call in hash_code.call_args_list],
)
def test_concurrent_initial_hash_index_add_conflicts(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'concurrent_initial')
writer1, commit1, messages1 = self._prepare_indexed_write(
table, [1]
)
writer2, commit2, messages2 = self._prepare_indexed_write(
table, [2]
)
commit1.commit(messages1)
stale_data_paths = [
file.file_path for message in messages2 for file in message.new_files
]
stale_index_paths = [
entry.index_file.external_path
or table.path_factory().global_index_path_factory().to_path(
entry.index_file.file_name
)
for message in messages2 for entry in message.index_adds
]
with self.assertRaisesRegex(
RuntimeError, 'HASH index assignment conflict'
):
commit2.commit(messages2)
self.assertTrue(all(
not table.file_io.exists(path)
for path in stale_data_paths + stale_index_paths
))
indexes = self._hash_indexes(table)
self.assertEqual(1, len(indexes))
self.assertEqual(1, indexes[0].index_file.row_count)
writer1.close()
writer2.close()
commit1.close()
commit2.close()
def test_concurrent_hash_index_replacement_conflicts(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'concurrent_replace')
seed_writer, seed_commit, seed_messages = (
self._prepare_indexed_write(table, [1])
)
seed_commit.commit(seed_messages)
seed_writer.close()
seed_commit.close()
writer1, commit1, messages1 = self._prepare_indexed_write(
table, [2]
)
writer2, commit2, messages2 = self._prepare_indexed_write(
table, [3]
)
old_index = messages1[0].index_deletes[0].index_file.file_name
self.assertEqual(
old_index,
messages2[0].index_deletes[0].index_file.file_name,
)
commit1.commit(messages1)
with self.assertRaisesRegex(
RuntimeError, 'HASH index assignment conflict'
):
commit2.commit(messages2)
indexes = self._hash_indexes(table)
self.assertEqual(1, len(indexes))
self.assertEqual(2, indexes[0].index_file.row_count)
writer1.close()
writer2.close()
commit1.close()
commit2.close()
def test_concurrent_disjoint_bucket_replacements_conflict(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(
root,
'concurrent_disjoint',
target_row_num=1,
max_buckets=2,
)
seed_writer, seed_commit, seed_messages = (
self._prepare_indexed_write(table, [1, 2])
)
seed_commit.commit(seed_messages)
seed_writer.close()
seed_commit.close()
with patch(
'pypaimon.index.dynamic_bucket.random.choice',
return_value=0,
):
writer1, commit1, messages1 = self._prepare_indexed_write(
table, [3]
)
with patch(
'pypaimon.index.dynamic_bucket.random.choice',
return_value=1,
):
writer2, commit2, messages2 = self._prepare_indexed_write(
table, [3]
)
self.assertNotEqual(messages1[0].bucket, messages2[0].bucket)
commit1.commit(messages1)
stale_paths = [
file.file_path
for message in messages2
for file in message.new_files
] + [
entry.index_file.external_path
or table.path_factory().global_index_path_factory().to_path(
entry.index_file.file_name
)
for message in messages2
for entry in message.index_adds
]
with self.assertRaisesRegex(
RuntimeError, 'assigned from snapshot.*latest snapshot'
):
commit2.commit(messages2)
self.assertTrue(all(
not table.file_io.exists(path) for path in stale_paths
))
writer1.close()
writer2.close()
commit1.close()
commit2.close()
def test_data_only_upsert_conflicts_after_overwrite_remaps_key(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(
root, 'data_only_overwrite_conflict', target_row_num=1
)
self._commit_arrow(table, [1, 2], ['one', 'two'])
stale_builder = table.new_batch_write_builder()
stale_writer = stale_builder.new_write()
stale_writer.write_arrow(
pa.table({'id': [2], 'value': ['stale-upsert']})
)
stale_messages = stale_writer.prepare_commit()
stale_commit = stale_builder.new_commit()
self.assertFalse(any(
message.index_adds or message.index_deletes
for message in stale_messages
))
overwrite_builder = table.new_batch_write_builder().overwrite({})
overwrite_writer = overwrite_builder.new_write()
overwrite_writer.write_arrow(
pa.table({'id': [2], 'value': ['overwrite']})
)
overwrite_messages = overwrite_writer.prepare_commit()
overwrite_commit = overwrite_builder.new_commit()
overwrite_commit.commit(overwrite_messages)
overwrite_writer.close()
overwrite_commit.close()
with self.assertRaisesRegex(
RuntimeError, 'HASH index assignment conflict'
):
stale_commit.commit(stale_messages)
self.assertEqual(
{'id': [2], 'value': ['overwrite']},
self._read_arrow(table).to_pydict(),
)
stale_writer.close()
stale_commit.close()
def test_retry_then_hash_index_conflict_preserves_prepared_files(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'retry_hash_conflict')
writer, commit, messages = self._prepare_indexed_write(table, [1])
prepared_paths = [
file.file_path
for message in messages
for file in message.new_files
] + [
entry.index_file.external_path
or table.path_factory().global_index_path_factory().to_path(
entry.index_file.file_name
)
for message in messages
for entry in message.index_adds
]
original_snapshot_commit = commit.file_store_commit.snapshot_commit
calls = 0
def lose_first_compare_and_set(snapshot, statistics):
nonlocal calls
calls += 1
concurrent_writer, concurrent_commit, concurrent_messages = (
self._prepare_indexed_write(table, [2])
)
concurrent_commit.commit(concurrent_messages)
concurrent_writer.close()
concurrent_commit.close()
return False
with patch.object(
original_snapshot_commit,
'commit',
side_effect=lose_first_compare_and_set,
), patch.object(
commit.file_store_commit,
'_commit_retry_wait',
):
with self.assertRaisesRegex(
RuntimeError, 'HASH index assignment conflict'
):
commit.commit(messages)
self.assertEqual(1, calls)
self.assertTrue(all(
table.file_io.exists(path) for path in prepared_paths
))
writer.close()
commit.close()
def test_invalid_dynamic_bucket_key_reports_schema_error(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'invalid_bucket_key')
options = dict(table.table_schema.options)
options['bucket-key'] = 'missing_column'
invalid_schema = table.table_schema.copy(options)
with self.assertRaisesRegex(
ValueError, 'bucket-key references unknown columns'
):
DynamicBucketRowKeyExtractor(invalid_schema)
if __name__ == '__main__':
unittest.main()