This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 1d23d3d23a [python] Fix lossy VARIANT shredding in Python writer
(#10633)
1d23d3d23a is described below
commit 1d23d3d23a2e379e8722e301a7cd2414c867330a
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Oct 18 08:59:52 2026 +0800
[python] Fix lossy VARIANT shredding in Python writer (#10633)
---
.../pypaimon/benchmark/variant_shredding.py | 109 ++++++++++++++++
paimon-python/pypaimon/data/variant_shredding.py | 51 +++++++-
paimon-python/pypaimon/tests/variant_test.py | 144 +++++++++++++++++++++
3 files changed, 299 insertions(+), 5 deletions(-)
diff --git a/paimon-python/pypaimon/benchmark/variant_shredding.py
b/paimon-python/pypaimon/benchmark/variant_shredding.py
new file mode 100644
index 0000000000..9302f007be
--- /dev/null
+++ b/paimon-python/pypaimon/benchmark/variant_shredding.py
@@ -0,0 +1,109 @@
+# 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.
+
+"""Benchmark matching DOUBLE shredding and Parquet serialization in memory.
+
+Example: python -m pypaimon.benchmark.variant_shredding --rows 20000 --fields
10
+Use --reference label=/path/to/variant_shredding.py to compare revisions.
+"""
+
+import argparse
+import hashlib
+import importlib.util
+import json
+import platform
+import statistics
+import sys
+import time
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+
+from pypaimon.data.generic_variant import GenericVariant
+from pypaimon.data import variant_shredding
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--rows', type=int, default=20000)
+ parser.add_argument('--fields', type=int, default=10)
+ parser.add_argument('--repeats', type=int, default=5)
+ parser.add_argument('--warmup', type=int, default=1)
+ parser.add_argument('--reference', action='append', default=[],
metavar='LABEL=FILE')
+ args = parser.parse_args()
+ if min(args.rows, args.fields, args.repeats) < 1 or args.warmup < 0:
+ parser.error('rows, fields and repeats must be positive; warmup must
be non-negative')
+
+ modules = {'current': variant_shredding}
+ sources = {}
+ for reference in args.reference:
+ label, path = reference.split('=', 1)
+ if label in modules:
+ parser.error('duplicate label: ' + label)
+ spec = importlib.util.spec_from_file_location('_benchmark_' + label,
path)
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ modules[label] = module
+ for label, module in modules.items():
+ with open(module.__file__, 'rb') as source:
+ sources[label] = hashlib.sha256(source.read()).hexdigest()
+
+ names = ['f{}'.format(i) for i in range(args.fields)]
+ column = GenericVariant.to_arrow_array([
+ GenericVariant.from_python({name: float(row + i) + 0.25
+ for i, name in enumerate(names)})
+ for row in range(args.rows)
+ ])
+ option = json.dumps({'type': 'ROW', 'fields': [{'name': 'payload', 'type':
{
+ 'type': 'ROW', 'fields': [{'name': name, 'type': 'DOUBLE'} for name in
names]
+ }}]})
+ schemas = {label: module.parse_shredding_schema_option(option)['payload']
+ for label, module in modules.items()}
+ types = {label: module.shredding_schema_to_arrow_type(schemas[label])
+ for label, module in modules.items()}
+ times = {label: [] for label in modules}
+ expected = None
+ labels = list(modules)
+ for repeat in range(args.warmup + args.repeats):
+ # Rotate execution order to reduce order bias.
+ order = labels[repeat % len(labels):] + labels[:repeat % len(labels)]
+ for label in order:
+ start = time.perf_counter()
+ shredded = modules[label].shred_variant_column(column,
schemas[label], types[label])
+ sink = pa.BufferOutputStream()
+ pq.write_table(pa.table({'payload': shredded}), sink,
compression='zstd')
+ output = sink.getvalue()
+ elapsed = time.perf_counter() - start
+ fingerprint = (output.size, hashlib.sha256(output).hexdigest())
+ if expected is None:
+ expected = fingerprint
+ if fingerprint != expected:
+ raise AssertionError('Parquet output differs: ' + label)
+ if repeat >= args.warmup:
+ times[label].append(elapsed)
+ print(json.dumps({
+ 'python': platform.python_version(), 'pyarrow': pa.__version__,
+ 'platform': platform.platform(), 'parameters': vars(args),
+ 'source_sha256': sources, 'parquet_bytes': expected[0],
+ 'parquet_sha256': expected[1], 'seconds': times,
+ 'median_seconds': {label: statistics.median(values) for label, values
in times.items()},
+ }, indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/paimon-python/pypaimon/data/variant_shredding.py
b/paimon-python/pypaimon/data/variant_shredding.py
index ae95d5f800..8a033a6e71 100644
--- a/paimon-python/pypaimon/data/variant_shredding.py
+++ b/paimon-python/pypaimon/data/variant_shredding.py
@@ -52,6 +52,8 @@ from typing import Dict, List, Optional, Tuple
import pyarrow as pa
+from pypaimon.data.generic_variant import GenericVariant, _Type,
_variant_get_type
+
from pypaimon.data._variant_binary import (
_OBJECT,
_U8_MAX, _U32_SIZE,
@@ -856,6 +858,47 @@ def shredding_schema_to_arrow_type(obj_fields:
List[ObjectField]) -> pa.StructTy
])
+def _try_shred_scalar(field_bytes: bytes, metadata: bytes, arrow_type:
pa.DataType):
+ kind = _variant_get_type(field_bytes, 0)
+ if kind == _Type.DOUBLE:
+ compatible = pa.types.is_float64(arrow_type)
+ elif kind == _Type.FLOAT:
+ compatible = pa.types.is_float32(arrow_type)
+ elif kind == _Type.LONG:
+ compatible = pa.types.is_integer(arrow_type)
+ elif kind == _Type.BOOLEAN:
+ compatible = pa.types.is_boolean(arrow_type)
+ elif kind == _Type.STRING:
+ compatible = pa.types.is_string(arrow_type) or
pa.types.is_large_string(arrow_type)
+ elif kind == _Type.BINARY:
+ compatible = pa.types.is_binary(arrow_type) or
pa.types.is_large_binary(arrow_type)
+ elif kind == _Type.DATE:
+ compatible = pa.types.is_date32(arrow_type)
+ elif kind == _Type.DECIMAL:
+ compatible = pa.types.is_decimal(arrow_type)
+ elif kind in (_Type.TIMESTAMP, _Type.TIMESTAMP_NTZ):
+ compatible = (pa.types.is_timestamp(arrow_type)
+ and bool(arrow_type.tz) == (kind == _Type.TIMESTAMP))
+ else:
+ # JSON null stays in residual; two null columns mean absent.
+ return None
+ if not compatible:
+ return None
+
+ value = GenericVariant(field_bytes, metadata).to_python()
+ if kind == _Type.LONG:
+ bits = arrow_type.bit_width
+ lower = 0 if pa.types.is_unsigned_integer(arrow_type) else -(1 <<
(bits - 1))
+ if not lower <= value < lower + (1 << bits):
+ return None
+ elif kind in (_Type.DECIMAL, _Type.TIMESTAMP, _Type.TIMESTAMP_NTZ):
+ # Keep Arrow validation for scale, timestamp precision and overflow.
+ value = pa.scalar(value, type=arrow_type).as_py()
+ if _encode_scalar_to_value_bytes(value, arrow_type) != field_bytes:
+ return None
+ return value
+
+
def _decompose_field_bytes(
field_bytes: bytes,
schema: VariantSchema,
@@ -875,14 +918,12 @@ def _decompose_field_bytes(
A Python dict ``{'value': bytes_or_none, 'typed_value':
val_or_dict_or_none}``
suitable for building a PyArrow struct array row.
"""
- from pypaimon.data.generic_variant import GenericVariant
-
if schema.scalar_arrow_type is not None:
try:
- py_val = GenericVariant(field_bytes, metadata).to_python()
+ typed_val = _try_shred_scalar(field_bytes, metadata,
schema.scalar_arrow_type)
except Exception:
- return {'value': field_bytes, 'typed_value': None}
- return {'value': None, 'typed_value': py_val}
+ typed_val = None
+ return {'value': field_bytes if typed_val is None else None,
'typed_value': typed_val}
if schema.object_fields is not None:
if not field_bytes or (field_bytes[0] & 0x3) != _OBJECT:
diff --git a/paimon-python/pypaimon/tests/variant_test.py
b/paimon-python/pypaimon/tests/variant_test.py
index 4a27543fe0..658d766dd7 100644
--- a/paimon-python/pypaimon/tests/variant_test.py
+++ b/paimon-python/pypaimon/tests/variant_test.py
@@ -51,6 +51,7 @@ import unittest
import datetime
import uuid
from decimal import Decimal
+from unittest.mock import patch
import pyarrow as pa
import pyarrow.parquet as pq
@@ -60,6 +61,7 @@ from pypaimon.data.generic_variant import GenericVariant
from pypaimon.data.variant_shredding import (
VARIANT_ARROW_TYPE,
VariantSchema,
+ _decompose_field_bytes,
_NULL_VALUE_BYTES,
_build_array_value,
_build_object_value,
@@ -1161,6 +1163,57 @@ class TestShreddingWrite(unittest.TestCase):
overflow_py = GenericVariant(overflow,
result['metadata']).to_python()
self.assertIn('extra', overflow_py)
+ def test_scalar_shredding_preserves_types_and_values(self):
+ residual_cases = [
+ (0, pa.float64()), (2.5, pa.int64()), (2.0, pa.int64()),
+ (True, pa.int64()), (False, pa.float64()), (1, pa.bool_()),
+ (None, pa.int64()), ('12', pa.int64()), ([], pa.string()),
+ ({'x': 1}, pa.string()), (128, pa.int8()), (-129, pa.int8()),
+ (2 ** 31, pa.int32()), (2 ** 63 - 1, pa.float64()),
+ (1.25, pa.float32()), (Decimal('1.234'), pa.decimal128(4, 2)),
+ (Decimal('123.45'), pa.decimal128(4, 2)),
+ (datetime.datetime(2024, 1, 1, microsecond=1), pa.timestamp('ms')),
+ (datetime.datetime(2500, 1, 1), pa.timestamp('ns')),
+ (datetime.datetime(2024, 1, 1), pa.timestamp('us', tz='UTC')),
+ (datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc),
pa.timestamp('us')),
+ (True, pa.decimal128(4, 0)), (1, pa.decimal128(4, 0)),
+ (Decimal('1'), pa.int64()),
+ ]
+ typed_cases = [
+ (True, pa.bool_()), (-128, pa.int8()), (127, pa.int8()),
+ (2 ** 63 - 1, pa.int64()), (1.25, pa.float64()),
+ (-0.0, pa.float64()), ('hello', pa.string()), (b'abc',
pa.binary()),
+ (Decimal('12.34'), pa.decimal128(4, 2)),
+ (datetime.date(2024, 1, 1), pa.date32()),
+ (datetime.datetime(2024, 1, 1, microsecond=1), pa.timestamp('us')),
+ ]
+ for residual, cases in ((True, residual_cases), (False, typed_cases)):
+ for value, arrow_type in cases:
+ with self.subTest(value=value, arrow_type=arrow_type):
+ gv = GenericVariant.from_python(value)
+ schema = VariantSchema(value_idx=0, typed_idx=1,
+ scalar_arrow_type=arrow_type)
+ with patch('pypaimon.data.variant_shredding.pa.scalar',
wraps=pa.scalar) as scalar:
+ row = _decompose_field_bytes(gv.value(), schema,
gv.metadata())
+ if not (pa.types.is_decimal(arrow_type) or
pa.types.is_timestamp(arrow_type)):
+ scalar.assert_not_called()
+ self.assertEqual(row['value'], gv.value() if residual else
None)
+ if residual:
+ self.assertIsNone(row['typed_value'])
+ self.assertEqual(rebuild_value(row, schema, {}),
gv.value())
+
+ def test_float_wire_type_is_preserved(self):
+ metadata = GenericVariant.from_python(0).metadata()
+ for source in (pa.float32(), pa.float64()):
+ for target in (pa.float32(), pa.float64()):
+ with self.subTest(source=source, target=target):
+ raw = _encode_scalar_to_value_bytes(1.25, source)
+ schema = VariantSchema(value_idx=0, typed_idx=1,
+ scalar_arrow_type=target)
+ row = _decompose_field_bytes(raw, schema, metadata)
+ self.assertEqual(row['value'], None if source == target
else raw)
+ self.assertEqual(rebuild_value(row, schema, {}), raw)
+
# ===========================================================================
# 8. Full-chain via Paimon API
@@ -1424,6 +1477,97 @@ class TestVariantPaimonTable(unittest.TestCase):
with self.assertRaisesRegex(Exception, 'Cannot cast Variant'):
strict.new_read().to_arrow(strict.new_scan().plan().splits())
+ def _check_shredding_type_preservation(self, native):
+ def typed(value):
+ if isinstance(value, dict):
+ return ('object', sorted((k, typed(v)) for k, v in
value.items()))
+ if isinstance(value, list):
+ return ('array', [typed(v) for v in value])
+ return (type(value).__name__, value)
+
+ values = [
+ {'score': 1.5, 'count': 2, 'small': 127, 'nested': {'x': 3.25}},
+ {'score': 0, 'count': 2.5, 'small': 128, 'nested': {'x': 0}},
+ {'score': True, 'count': False, 'small': -129, 'nested': {'x':
False}},
+ {'score': None, 'count': None, 'nested': {'x': None}},
+ {}, {'nested': {}}, {'nested': None}, {'nested': [1, None]},
+ [1, None], 42, None, {'count': 2 ** 63 - 1},
+ ]
+ gvs = [GenericVariant.from_python(value) for value in values] + [None]
+ expected = {i: ('sql-null',) if gv is None else typed(gv.to_python())
+ for i, gv in enumerate(gvs)}
+ shredding = _schema_json('payload', [
+ ('score', 'DOUBLE'), ('count', 'BIGINT'), ('small', 'TINYINT'),
+ ('nested', {'type': 'ROW', 'fields': [{'name': 'x', 'type':
'DOUBLE'}]}),
+ ])
+ for shredded in (False, True):
+ name = 'default.type_preservation_{}_{}'.format(native, shredded)
+ options = {'bucket': '-1', 'file.format': 'parquet',
+ 'write.native.enabled': str(native).lower(),
+ 'read.native.enabled': 'false'}
+ if shredded:
+ options['parquet.variant.shreddingSchema'] = shredding
+ schema = Schema.from_pyarrow_schema(self._pa_schema())
+ schema.options.update(options)
+ self.catalog.create_table(name, schema, False)
+ table = self.catalog.get_table(name)
+ builder = table.new_batch_write_builder()
+ writer = builder.new_write()
+ self.assertEqual(type(writer).__name__,
+ 'NativeTableWrite' if native else
'BatchTableWrite')
+ commit = builder.new_commit()
+ try:
+ for start in range(0, len(gvs), 3):
+ batch = pa.RecordBatch.from_arrays([
+ pa.array(range(start, min(start + 3, len(gvs))),
type=pa.int64()),
+ GenericVariant.to_arrow_array(gvs[start:start + 3]),
+ ], schema=self._pa_schema())
+ writer.write_arrow_batch(batch)
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+ reader = table.new_read_builder()
+ rows =
reader.new_read().to_arrow(reader.new_scan().plan().splits()).to_pylist()
+ actual = {row['id']: ('sql-null',) if row['payload'] is None else
+
typed(GenericVariant.from_arrow_struct(row['payload']).to_python())
+ for row in rows}
+ self.assertEqual(actual, expected)
+ if not shredded:
+ continue
+ physical = {}
+ for root, _, files in os.walk(table.table_path):
+ for filename in files:
+ if filename.endswith('.parquet'):
+ for row in pq.read_table(os.path.join(root,
filename)).to_pylist():
+ physical[row['id']] = row['payload']
+ self.assertEqual(len(physical), len(gvs))
+ for index, field in ((1, 'score'), (1, 'count'), (1, 'small'),
+ (2, 'score'), (2, 'count'), (2, 'small'), (3,
'score')):
+ leaf = physical[index]['typed_value'][field]
+ self.assertIsNone(leaf['typed_value'])
+ self.assertEqual(leaf['value'],
GenericVariant.from_python(values[index][field]).value())
+ missing = physical[4]['typed_value']['score']
+ self.assertEqual(missing, {'value': None, 'typed_value': None})
+
self.assertEqual(physical[3]['typed_value']['nested']['typed_value']['x']['value'],
+ _NULL_VALUE_BYTES)
+ self.assertEqual(physical[10]['value'], _NULL_VALUE_BYTES)
+ self.assertIsNone(physical[12])
+
self.assertEqual(physical[0]['typed_value']['score']['typed_value'], 1.5)
+ self.assertIsNone(physical[0]['typed_value']['score']['value'])
+
+ def test_python_writer_preserves_shredded_variant_types(self):
+ self._check_shredding_type_preservation(native=False)
+
+ def test_native_writer_preserves_shredded_variant_types(self):
+ try:
+ import pypaimon_rust # noqa: F401
+ from pypaimon.write.native_write import NativeTableWrite # noqa:
F401
+ except ImportError:
+ self.skipTest('native writer is not available')
+ self._check_shredding_type_preservation(native=True)
+
def test_plain_variant_write_and_read(self):
"""Plain VARIANT: GenericVariant → write_arrow → to_arrow →
GenericVariant."""
schema = Schema.from_pyarrow_schema(self._pa_schema())