blob: 4401b6342d1e0f572d7dfc2d9522145f5b002e9e [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 shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import pyarrow as pa
import pyarrow.parquet as pq
from pypaimon import CatalogFactory, Schema
from pypaimon.data.map_shared_shredding import (
is_shared_shredding,
parse_shared_shredding_metadata,
)
class MapSharedShreddingWriteTest(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.catalog = CatalogFactory.create({"warehouse": self.temp_dir})
self.catalog.create_database("default", True)
self.arrow_schema = pa.schema([
pa.field("id", pa.int32()),
pa.field("metrics", pa.map_(pa.string(), pa.int64())),
])
def tearDown(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_write_and_read_parquet(self):
expected = [
[("hot", 10), ("warm", 20), ("overflow", 30)],
[("hot", None), ("new", 40)],
[],
None,
]
data = pa.Table.from_pydict({
"id": [1, 2, 3, 4],
"metrics": expected,
}, schema=self.arrow_schema)
table = self._create_table("parquet", max_columns=2)
messages = self._write(table, data)
physical_field = pq.read_schema(
messages[0].new_files[0].file_path).field("metrics")
self.assertTrue(pa.types.is_struct(physical_field.type))
self.assertTrue(is_shared_shredding(physical_field))
name_by_id, num_columns = \
parse_shared_shredding_metadata(physical_field)
self.assertEqual(2, num_columns)
self.assertEqual(
{"hot", "warm", "overflow", "new"},
set(name_by_id.values()),
)
read_builder = table.new_read_builder()
result = read_builder.new_read().to_arrow(
read_builder.new_scan().plan().splits())
self.assertEqual(expected, result.column("metrics").to_pylist())
selected = table.new_read_builder().with_projection([
"id", "metrics['hot']", "metrics['overflow']",
])
result = selected.new_read().to_arrow(
selected.new_scan().plan().splits())
self.assertEqual(
[10, None, None, None],
result.column("metrics_hot").to_pylist(),
)
self.assertEqual(
[30, None, None, None],
result.column("metrics_overflow").to_pylist(),
)
def test_row_id_update_preserves_shared_shredding(self):
table = self._create_table('parquet', 2, {
'data-evolution.enabled': 'true',
'row-tracking.enabled': 'true',
})
self._write(table, pa.Table.from_pydict({
'id': [1, 2],
'metrics': [[('hot', 1)], [('warm', 2)]],
}, schema=self.arrow_schema))
read_builder = table.new_read_builder().with_projection(['id', '_ROW_ID'])
rows = read_builder.new_read().to_arrow(
read_builder.new_scan().plan().splits())
row_ids = dict(zip(rows.column('id').to_pylist(),
rows.column('_ROW_ID').to_pylist()))
row_id = row_ids[2]
update = pa.Table.from_pydict({
'_ROW_ID': [row_id],
'metrics': [[('hot', 99), ('new', 7)]],
}, schema=pa.schema([
('_ROW_ID', pa.int64()),
self.arrow_schema.field('metrics'),
]))
builder = table.new_batch_write_builder()
messages = builder.new_update().with_update_type(
['metrics']).update_by_arrow_with_row_id(update)
self.assertEqual(1, len(messages[0].new_files))
overlay = messages[0].new_files[0]
self.assertTrue(is_shared_shredding(
pq.read_schema(overlay.file_path).field('metrics')))
commit = builder.new_commit()
commit.commit(messages)
commit.close()
read_builder = table.new_read_builder().with_projection(['id', 'metrics'])
result = read_builder.new_read().to_arrow(
read_builder.new_scan().plan().splits())
self.assertEqual(
{1: [('hot', 1)], 2: [('hot', 99), ('new', 7)]},
dict(zip(result.column('id').to_pylist(),
result.column('metrics').to_pylist())),
)
def test_reject_orc(self):
with self.assertRaisesRegex(
ValueError,
"PyPaimon MAP shared-shredding writes only support parquet"):
writer = self._create_table(
"orc", max_columns=2).new_batch_write_builder().new_write()
writer.write_arrow(pa.Table.from_pydict({
"id": [1],
"metrics": [[("key", 1)]],
}, schema=self.arrow_schema))
def test_non_nullable_values(self):
for value_type, value in [
(pa.int64(), 1),
(pa.struct([pa.field('score', pa.int64(), nullable=False)]),
{'score': 1})]:
with self.subTest(value_type=value_type):
self.arrow_schema = pa.schema([
pa.field('id', pa.int32()),
pa.field('metrics', pa.map_(
pa.string(), pa.field('value', value_type, nullable=False))),
])
expected = [[('a', value)], [], None]
data = pa.Table.from_pydict({
'id': [1, 2, 3], 'metrics': expected,
}, schema=self.arrow_schema)
table = self._create_table('parquet', 2)
messages = self._write(table, data)
field = pq.read_schema(messages[0].new_files[0].file_path).field('metrics')
self.assertTrue(field.type['__col_0'].nullable)
self.assertTrue(field.type['__col_1'].nullable)
reader = table.new_read_builder()
result = reader.new_read().to_arrow(reader.new_scan().plan().splits())
# Arrow 6 cannot convert non-nullable MAP values to scalars.
read_value_type = result.schema.field('metrics').type.item_type
actual = result.column('metrics').cast(pa.map_(pa.string(), read_value_type))
self.assertEqual(expected, actual.to_pylist())
def test_nested_non_nullable_map_values_in_subprocess(self):
# A native Arrow assertion aborts the process, not a Python exception.
result = subprocess.run([
sys.executable, '-c',
'from pypaimon.tests.map_shared_shredding_write_test import '
'MapSharedShreddingWriteTest\n'
'test = MapSharedShreddingWriteTest()\n'
'test.setUp()\n'
'try:\n'
' test.check_nested_non_nullable_map_values()\n'
'finally:\n'
' test.tearDown()\n',
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60)
self.assertEqual(0, result.returncode, result.stderr.decode('utf-8', 'replace'))
def check_nested_non_nullable_map_values(self):
from pypaimon.execution.python.write.map_shared_shredding_writer import _to_python_values
inner = pa.map_(pa.string(), pa.field('value', pa.int64(), nullable=False))
for value_type, value in [
(inner, [('x', 1)]),
(pa.struct([pa.field('nested', inner)]), {'nested': [('x', 1)]}),
(pa.list_(inner), [[('x', 1)], [], None])]:
self.arrow_schema = pa.schema([
pa.field('id', pa.int32()),
pa.field('metrics', pa.map_(pa.string(), value_type)),
])
expected = [[('a', value)], [], None, [('b', value)]]
array = pa.array([[('unused', value)]] + expected + [[]],
type=self.arrow_schema.field('metrics').type)
# Keep nonzero offsets and multiple chunks in the conversion check.
chunks = [array.slice(1, 2), array.slice(3, 2)]
self.assertEqual(expected, [v for chunk in chunks
for v in _to_python_values(chunk)])
data = pa.Table.from_arrays([
pa.array([1, 2, 3, 4], type=pa.int32()),
pa.chunked_array(chunks),
], schema=self.arrow_schema)
table = self._create_table('parquet', 2)
messages = self._write(table, data)
self.assertTrue(is_shared_shredding(pq.read_schema(
messages[0].new_files[0].file_path).field('metrics')))
reader = table.new_read_builder()
result = reader.new_read().to_arrow(reader.new_scan().plan().splits())
self.assertEqual(expected, [v for chunk in result.column('metrics').chunks
for v in _to_python_values(chunk)])
def test_dedicated_columns_preserve_shredding(self):
for blob, vector in [(True, False), (False, True), (True, True)]:
with self.subTest(blob=blob, vector=vector):
fields = [pa.field('id', pa.int32()),
pa.field('metrics', pa.map_(pa.string(), pa.int64()))]
values = {'id': [1, 2], 'metrics': [[('a', 1)], []]}
options = {'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true'}
if blob:
fields.append(pa.field('payload', pa.large_binary()))
values['payload'] = [b'one', b'two']
if vector:
fields.append(pa.field('embedding', pa.list_(pa.float32(), 2)))
values['embedding'] = [[1., 2.], [3., 4.]]
options['vector.file.format'] = 'parquet'
self.arrow_schema = pa.schema(fields)
table = self._create_table('parquet', 2, options)
messages = self._write(table, pa.Table.from_pydict(
values, schema=self.arrow_schema))
normal_files = [f for m in messages for f in m.new_files
if f.file_name.endswith('.parquet')
and '.vector.' not in f.file_name]
self.assertTrue(normal_files)
for file in normal_files:
self.assertTrue(is_shared_shredding(
pq.read_schema(file.file_path).field('metrics')))
reader = table.new_read_builder().with_projection(["metrics['a']"])
result = reader.new_read().to_arrow(reader.new_scan().plan().splits())
self.assertEqual([1, None], result.column('metrics_a').to_pylist())
def test_reject_default_layout_on_non_map(self):
table = self._create_table('parquet', 2, {
'fields.metrics.map.storage-layout': 'default',
'fields.id.map.storage-layout': 'default',
})
with self.assertRaisesRegex(ValueError, 'its type is not MAP'):
self._write(table, pa.Table.from_pydict({
'id': [1], 'metrics': [[('a', 1)]],
}, schema=self.arrow_schema))
def test_reject_char_keys(self):
from pypaimon.schema.data_types import AtomicType, DataField, MapType
from pypaimon.execution.python.write.map_shared_shredding_writer import MapSharedShreddingWriter
with self.assertRaisesRegex(ValueError, 'STRING keys'):
MapSharedShreddingWriter._validate_field(DataField(
0, 'metrics', MapType(True, AtomicType('CHAR(4)', False), AtomicType('BIGINT'))))
def test_streams_physical_batches(self):
from pypaimon.execution.python.write.map_shared_shredding_writer import _MapFieldConverter
table = self._create_table('parquet', 256, {'write.native.enabled': 'false'})
data = pa.Table.from_pydict({
'id': list(range(5000)), 'metrics': [[('a', 1)]] * 4999 + [[('late', 2)]],
}, schema=self.arrow_schema)
convert = _MapFieldConverter.convert
write_table = pq.ParquetWriter.write_table
sizes = []
written = []
def convert_batch(converter, column):
sizes.append(len(column))
return convert(converter, column)
def write_batch(writer, physical, *args, **kwargs):
written.append(physical.num_rows)
return write_table(writer, physical, *args, **kwargs)
with patch.object(_MapFieldConverter, 'convert', convert_batch), \
patch.object(pq.ParquetWriter, 'write_table', write_batch):
messages = self._write(table, data)
self.assertEqual(5000, sum(sizes))
self.assertLessEqual(max(sizes), 1024)
self.assertEqual([5000], written)
self.assertEqual(1, len(messages[0].new_files))
reader = table.new_read_builder().with_projection(["metrics['a']", "metrics['late']"])
result = reader.new_read().to_arrow(reader.new_scan().plan().splits())
self.assertEqual([1] * 4999 + [None], result.column('metrics_a').to_pylist())
self.assertEqual([None] * 4999 + [2], result.column('metrics_late').to_pylist())
def test_failed_stream_removes_partial_file(self):
from pypaimon.execution.python.write.map_shared_shredding_writer import MapSharedShreddingWriter
table = self._create_table('parquet', 2, {'file.block-size': '1 kb'})
converter = MapSharedShreddingWriter(table.fields, table.options, 'parquet', None)
data = pa.Table.from_pydict({
'id': list(range(2048)), 'metrics': [[('a', 1)]] * 2048,
}, schema=self.arrow_schema)
path = self.temp_dir + '/partial.parquet'
write_table = pq.ParquetWriter.write_table
calls = []
def fail_second_batch(writer, physical, *args, **kwargs):
calls.append(physical.num_rows)
if len(calls) == 2:
raise OSError('injected write failure')
return write_table(writer, physical, *args, **kwargs)
with patch.object(pq.ParquetWriter, 'write_table', fail_second_batch):
with self.assertRaisesRegex(OSError, 'injected write failure'):
converter.write_parquet(table.file_io, path, data, 'zstd', 1)
self.assertEqual(2, len(calls))
self.assertFalse(table.file_io.exists(path))
def test_row_groups_do_not_follow_input_calls(self):
from pypaimon.table.row.generic_row import GenericRow
for count in (1000, 2500):
layouts = []
for by_row in (False, True):
table = self._create_table('parquet', 256, {'write.native.enabled': 'false'})
builder = table.new_batch_write_builder()
writer = builder.new_write()
if by_row:
for i in range(count):
writer.write_row(GenericRow([i, [('a', i)]], table.fields))
else:
writer.write_arrow(pa.Table.from_pydict({
'id': list(range(count)),
'metrics': [[('a', i)] for i in range(count)],
}, schema=self.arrow_schema))
messages = writer.prepare_commit()
builder.new_commit().commit(messages)
writer.close()
files = [f for m in messages for f in m.new_files]
self.assertEqual(1, len(files))
metadata = pq.read_metadata(files[0].file_path)
layouts.append([metadata.row_group(i).num_rows
for i in range(metadata.num_row_groups)])
self.assertEqual(1, metadata.num_row_groups)
reader = table.new_read_builder().with_projection(['id', "metrics['a']"])
result = reader.new_read().to_arrow(reader.new_scan().plan().splits())
self.assertEqual(list(range(count)), result.column('id').to_pylist())
self.assertEqual(list(range(count)), result.column('metrics_a').to_pylist())
self.assertEqual(layouts[0], layouts[1])
def test_physical_row_group_byte_and_row_limits(self):
from pypaimon.execution.python.write.map_shared_shredding_writer import _physical_row_groups
data = pa.table({'id': pa.array(range(25), type=pa.int64())})
for chunks in ([data], [data.slice(i, 3) for i in range(0, 25, 3)]):
for target, max_rows, expected in (
(80, 100, [10, 10, 5]), (800, 6, [6, 6, 6, 6, 1])):
groups = list(_physical_row_groups(iter(chunks), target, max_rows))
self.assertEqual(expected, [g.num_rows for g in groups])
self.assertTrue(all(g.nbytes <= target for g in groups))
self.assertTrue(pa.concat_tables(groups).equals(data))
def test_physical_row_groups_empty_nulls_and_oversized_row(self):
from pypaimon.execution.python.write.map_shared_shredding_writer import _physical_row_groups
self.assertEqual([], list(_physical_row_groups(iter([]), 100)))
data = pa.table({'value': [None, 'x' * 300, '', 'tail']})
groups = list(_physical_row_groups(iter([data.slice(0, 0), data]), 64))
self.assertTrue(pa.concat_tables(groups).equals(data))
self.assertTrue(all(g.nbytes <= 64 or g.num_rows == 1 for g in groups))
self.assertEqual([1, 1, 2], [g.num_rows for g in groups])
def test_physical_row_groups_release_input_on_close(self):
from pypaimon.execution.python.write.map_shared_shredding_writer import _physical_row_groups
closed = []
def batches():
try:
while True:
yield pa.table({'id': [1, 2]})
finally:
closed.append(True)
groups = _physical_row_groups(batches(), 16)
next(groups)
groups.close()
self.assertEqual([True], closed)
def test_physical_row_groups_preserve_nested_shredding_metadata(self):
from pypaimon.execution.python.write.map_shared_shredding_writer import _physical_row_groups
table = self._create_table('parquet', 2, {
'write.native.enabled': 'false', 'read.native.enabled': 'false',
'file.block-size': '4 kb',
})
maps = [[('a', i), ('b', None), ('overflow', i + 1)] for i in range(2500)]
data = pa.Table.from_pydict(
{'id': list(range(2500)), 'metrics': maps}, schema=self.arrow_schema)
captured = []
def collect(batches, target_bytes):
for group in _physical_row_groups(batches, target_bytes):
captured.append((group.num_rows, group.nbytes))
self.assertTrue(is_shared_shredding(group.schema.field('metrics')))
yield group
with patch('pypaimon.execution.python.write.map_shared_shredding_writer._physical_row_groups', collect):
messages = self._write(table, data)
self.assertTrue(all(size <= 4096 or rows == 1 for rows, size in captured))
metadata = pq.read_metadata(messages[0].new_files[0].file_path)
self.assertEqual([rows for rows, _ in captured],
[metadata.row_group(i).num_rows for i in range(metadata.num_row_groups)])
reader = table.new_read_builder()
actual = reader.new_read().to_arrow(reader.new_scan().plan().splits())
self.assertEqual(maps, actual['metrics'].to_pylist())
def test_reject_nonpositive_row_group_target(self):
from pypaimon.execution.python.write.map_shared_shredding_writer import MapSharedShreddingWriter
table = self._create_table('parquet', 2, {'file.block-size': '0 b'})
with self.assertRaisesRegex(ValueError, 'file.block-size must be positive'):
MapSharedShreddingWriter(table.fields, table.options, 'parquet', None)
def test_reject_postpone_with_fixed_output_bucket(self):
from pypaimon.execution.python.write.writer.append_only_data_writer import AppendOnlyDataWriter
table = self._create_table('parquet', 2, {'bucket': '-2'})
with self.assertRaisesRegex(ValueError, 'postpone bucket'):
AppendOnlyDataWriter(table, (), 0, 0, table.options)
def test_adapts_physical_column_count_between_files(self):
table = self._create_table(
"parquet", max_columns=4,
extra_options={
"data-evolution.enabled": "true",
"row-tracking.enabled": "true",
"target-file-row-num": "2",
"write.native.enabled": "false",
},
)
data = pa.Table.from_pydict({
"id": [1, 2, 3, 4],
"metrics": [
[("a", 1)],
[("a", 2)],
[("a", 3), ("b", 4)],
[("b", 5)],
],
}, schema=self.arrow_schema)
messages = self._write(table, data)
files = [file for message in messages for file in message.new_files]
self.assertEqual(2, len(files))
counts = []
for file in files:
field = pq.read_schema(file.file_path).field("metrics")
counts.append(parse_shared_shredding_metadata(field)[1])
self.assertEqual([4, 1], counts)
read_builder = table.new_read_builder()
result = read_builder.new_read().to_arrow(
read_builder.new_scan().plan().splits())
self.assertEqual(4, result.num_rows)
def _create_table(
self, file_format, max_columns, extra_options=None):
options = {
"file.format": file_format,
"fields.metrics.map.storage-layout": "shared-shredding",
"fields.metrics.map.shared-shredding.max-columns": str(max_columns),
}
options.update(extra_options or {})
name = "default.map_write_{}_{}".format(
file_format, len(self.catalog.list_tables("default")))
self.catalog.create_table(
name,
Schema.from_pyarrow_schema(self.arrow_schema, options=options),
False,
)
return self.catalog.get_table(name)
@staticmethod
def _write(table, data):
builder = table.new_batch_write_builder()
writer = builder.new_write()
writer.write_arrow(data)
messages = writer.prepare_commit()
builder.new_commit().commit(messages)
writer.close()
return messages
if __name__ == "__main__":
unittest.main()