blob: 3dae61095e729695ebd92a5ef6a7644f6c576c22 [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
import pytest
from pypaimon.write.backend_dispatch import python_commit
from pypaimon import CatalogFactory, Schema
from pypaimon.index.dynamic_bucket import to_signed_int32, validate_bucket_id
from pypaimon.execution.python.write.dynamic_bucket import (
DynamicBucketAssigner, _PartitionIndex, _iter_hashes, compute_assigner,
)
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.execution.python.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, bucket=None):
builder = table.new_batch_write_builder()
writer = builder.new_write()
batch = pa.RecordBatch.from_pydict({
'id': ids,
'value': [f'v-{value}' for value in ids],
})
if bucket is None:
writer.write_arrow_batch(batch)
else:
writer.write_arrow(batch, bucket=bucket)
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)
# Java int addition wraps before the final remainder.
self.assertEqual(compute_assigner(max_int - 1, max_int - 1, max_int, max_int), -4)
def test_global_maximum_does_not_hide_an_unused_partition_bucket(self):
index = _PartitionIndex({10: 0}, {0: 1}, 1)
# A different partition already restored the global maximum bucket.
# Java PartitionIndex still allocates the unused local bucket 1.
self.assertEqual(index.assign(20, lambda _: True, 2, 1), 1)
self.assertEqual(index.assign(20, lambda _: True, 2, 1), 1)
def test_reached_partition_bucket_limit_is_cached_and_hash_assignments_stay_stable(self):
index = _PartitionIndex({10: 1}, {1: 1}, 1)
owns_odd = Mock(side_effect=lambda bucket: bucket % 2 == 1)
self.assertEqual(index.assign(20, owns_odd, 4, 3), 3)
first_overflow = index.assign(30, owns_odd, 4, 3)
self.assertIn(first_overflow, (1, 3))
owns_odd.reset_mock()
for key in range(31, 50):
bucket = index.assign(key, owns_odd, 4, 3)
self.assertIn(bucket, (1, 3))
self.assertEqual(index.assign(key, owns_odd, 4, 3), bucket)
owns_odd.assert_not_called()
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 == 32767,
max_buckets_num=-1,
max_bucket_id=32766,
)
self.assertEqual(bucket, 32767)
def test_rejects_bucket_count_above_java_short_range(self):
index = _PartitionIndex({}, {}, 1)
with self.assertRaisesRegex(
ValueError,
"'dynamic-bucket.max-buckets' must be -1 or between 1 and 32768",
):
index.assign(
key_hash=1,
bucket_filter=lambda candidate: candidate >= 32768,
max_buckets_num=40000,
max_bucket_id=32767,
)
def test_rejects_bucket_id_above_java_short_range(self):
with self.assertRaisesRegex(
ValueError,
"Dynamic bucket id must be between 0 and 32767, but was 32768",
):
validate_bucket_id(32768)
def test_restore_rejects_bucket_id_above_java_short_range(self):
assigner = DynamicBucketAssigner(
table=Mock(),
num_channels=1,
num_assigners=1,
assign_id=0,
target_bucket_row_number=1,
max_buckets_num=-1,
snapshot=Mock(),
)
entry = Mock()
entry.bucket = 32768
with patch(
'pypaimon.execution.python.write.dynamic_bucket.IndexFileHandler'
) as handler:
handler.return_value.scan.return_value = [entry]
with patch.object(
assigner, '_active_data_buckets', return_value=set()
):
with self.assertRaisesRegex(
ValueError,
"Dynamic bucket id must be between 0 and 32767",
):
assigner._load_partition((), {1})
with self.assertRaisesRegex(
ValueError,
"Dynamic bucket id must be between 0 and 32767",
):
assigner._restore_requested_hashes((), {1}, {})
def test_assigner_rejects_record_owned_by_another_writer(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'wrong_assigner')
assigner = DynamicBucketAssigner(
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=32767,
)
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))
@pytest.mark.python_write
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._execution.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)
@pytest.mark.python_write
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._execution.row_key_extractor._assigner._partition_indexes[()]
self.assertEqual(1, len(partition_index.hash_to_bucket))
self.assertEqual({}, writer._execution.row_key_extractor._index_maintainer._states)
@pytest.mark.python_write
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._execution.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']}))
@pytest.mark.python_write
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_preserves_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.assertTrue(table.file_io.exists(index_path))
@pytest.mark.python_write
def test_failed_hash_index_prepare_retries_the_complete_increment(self):
for failed_call in (1, 2):
with self.subTest(failed_call=failed_call), tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'retry_prepare', target_row_num=1).copy({
'write.native.enabled': 'false', 'commit.native.enabled': 'false',
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
'changelog-producer': 'input'})
builder = table.new_stream_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
data = pa.table({'id': [1, 2, 3], 'value': ['v-1', 'v-2', 'v-3']})
try:
writer.write_arrow(data)
maintainer = writer._execution.row_key_extractor._index_maintainer
bucket_count = len(maintainer._states)
self.assertGreaterEqual(bucket_count, 2)
write_index = maintainer._write_index
calls = 0
def fail_index(*args):
nonlocal calls
calls += 1
if calls == failed_call:
raise OSError('HASH index prepare failed')
return write_index(*args)
with patch.object(maintainer, '_write_index', side_effect=fail_index):
with self.assertRaisesRegex(OSError, 'HASH index prepare failed'):
writer.prepare_commit(1)
self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
messages = writer.prepare_commit(1)
self.assertEqual(3, sum(file.row_count for message in messages
for file in message.new_files))
self.assertEqual(3, sum(file.row_count for message in messages
for file in message.changelog_files))
self.assertEqual(bucket_count, sum(len(message.index_adds) for message in messages))
self.assertEqual([], writer.prepare_commit(2))
commit.commit(messages, 1)
writer.abort()
self.assertEqual(data.to_pydict(), self._read_arrow(table).sort_by('id').to_pydict())
self.assertEqual(bucket_count, len(self._hash_indexes(table)))
finally:
writer.close()
commit.close()
@pytest.mark.python_write
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._execution.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_hash_add_replaces_previous_index_without_explicit_delete(self):
# Java DynamicBucketIndexMaintainer sends only the complete new HASH
# file. Its bucket-owner protocol does not require an old-file DELETE.
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'implicit_hash_replace')
self._commit_arrow(table, [1], ['one'])
writer, commit, messages = self._prepare_indexed_write(table, [2])
self.assertEqual([], messages[0].index_deletes)
commit.commit(messages)
indexes = self._hash_indexes(table)
self.assertEqual(1, len(indexes))
self.assertEqual(2, indexes[0].index_file.row_count)
writer.close()
commit.close()
def test_sequential_hash_index_replacements_keep_one_complete_index(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]
)
commit1.commit(messages1)
writer2, commit2, messages2 = self._prepare_indexed_write(
table, [3]
)
self.assertEqual([], messages1[0].index_deletes)
self.assertEqual([], messages2[0].index_deletes)
commit2.commit(messages2)
indexes = self._hash_indexes(table)
self.assertEqual(1, len(indexes))
self.assertEqual(3, indexes[0].index_file.row_count)
writer1.close()
writer2.close()
commit1.close()
commit2.close()
def test_concurrent_disjoint_bucket_replacements_succeed(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()
writer1, commit1, messages1 = self._prepare_indexed_write(
table, [3], bucket=0
)
writer2, commit2, messages2 = self._prepare_indexed_write(
table, [4], bucket=1
)
self.assertNotEqual(messages1[0].bucket, messages2[0].bucket)
commit1.commit(messages1)
commit2.commit(messages2)
self.assertEqual(2, len(self._hash_indexes(table)))
self.assertEqual(
{'id': [1, 2, 3, 4],
'value': ['v-1', 'v-2', 'v-3', 'v-4']},
self._read_arrow(table).sort_by('id').to_pydict(),
)
writer1.close()
writer2.close()
commit1.close()
commit2.close()
def test_data_only_upsert_succeeds_after_concurrent_index_change(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(
root, 'data_only_concurrent_append', 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
))
concurrent_messages = self._commit_arrow(table, [3], ['three'])
self.assertTrue(any(
message.index_adds or message.index_deletes
for message in concurrent_messages
))
stale_commit.commit(stale_messages)
self.assertEqual(
{'id': [1, 2, 3],
'value': ['one', 'stale-upsert', 'three']},
self._read_arrow(table).sort_by('id').to_pydict(),
)
stale_writer.close()
stale_commit.close()
def test_retry_after_disjoint_hash_index_commit_preserves_prepared_files(self):
with tempfile.TemporaryDirectory() as root:
table = self._create_table(root, 'retry_hash_commit', target_row_num=1)
self._commit_arrow(table, [0], ['seed'])
# The pending upsert owns bucket 0. A new key is assigned bucket 1,
# so the competing commit obeys Java's single owner per bucket.
writer, commit, messages = self._prepare_indexed_write(table, [0])
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 = python_commit(commit).file_store_commit.snapshot_commit
snapshot_commit = original_snapshot_commit.commit
calls = 0
def lose_first_compare_and_set(
base_snapshot_uuid, snapshot, statistics):
nonlocal calls
calls += 1
if 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
return snapshot_commit(base_snapshot_uuid, snapshot, statistics)
with patch.object(
original_snapshot_commit,
'commit',
side_effect=lose_first_compare_and_set,
), patch.object(
python_commit(commit).file_store_commit,
'_commit_retry_wait',
):
commit.commit(messages)
self.assertEqual(2, calls)
self.assertTrue(all(
table.file_io.exists(path) for path in prepared_paths
))
self.assertEqual({'id': [0, 2], 'value': ['v-0', 'v-2']},
self._read_arrow(table).sort_by('id').to_pydict())
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, "Cannot define 'bucket-key' in dynamic bucket mode"
):
DynamicBucketRowKeyExtractor(invalid_schema)
if __name__ == '__main__':
unittest.main()