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 84964d2eb8 [python] Use Native updates for raw Blob columns (#10454)
84964d2eb8 is described below
commit 84964d2eb8cc2140a7f4ba62321ce1171007931d
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 9 10:02:10 2026 +0800
[python] Use Native updates for raw Blob columns (#10454)
---
.../pypaimon/tests/native_blob_update_test.py | 359 ++++++++++++++++++++-
paimon-python/pypaimon/write/native_blob_rows.py | 73 +++++
paimon-python/pypaimon/write/native_update.py | 40 ++-
3 files changed, 455 insertions(+), 17 deletions(-)
diff --git a/paimon-python/pypaimon/tests/native_blob_update_test.py
b/paimon-python/pypaimon/tests/native_blob_update_test.py
index d7e9ffdc7b..892f7936df 100644
--- a/paimon-python/pypaimon/tests/native_blob_update_test.py
+++ b/paimon-python/pypaimon/tests/native_blob_update_test.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-"""Native updates of Parquet columns preserve dedicated Blob files."""
+"""Native updates share the Java Blob placeholder and row-id layout."""
from contextlib import ExitStack
from unittest.mock import patch
@@ -26,9 +26,10 @@ import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.common.predicate_builder import PredicateBuilder
-from pypaimon.schema.data_types import AtomicType, DataField,
PyarrowFieldParser
+from pypaimon.schema.data_types import ArrayType, AtomicType, DataField,
MapType, PyarrowFieldParser
from pypaimon.table.row.blob import Blob, BlobDescriptor, BlobViewStruct
from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.table.data_evolution_merge_into import WhenMatched,
WhenNotMatched, source_col
from pypaimon.write.table_delete import TableDeleteByRowId
from pypaimon.write.native_update import NativeTableUpdateByRowId
from pypaimon.write.native_write import NativeTableWrite
@@ -39,13 +40,13 @@ from pypaimon.write.table_upsert_by_key import
TableUpsertByKey
pytestmark = pytest.mark.native_plan
-def _table(tmp_path, extra=None, catalog=None, payloads=None):
+def _table(tmp_path, extra=None, catalog=None, payloads=None,
payload_type=None):
catalog = catalog or CatalogFactory.create({'warehouse': str(tmp_path /
'warehouse')})
catalog.create_database('db', True)
fields = [DataField(0, 'id', AtomicType('INT')),
DataField(1, 'pt', AtomicType('STRING')),
DataField(2, 'value', AtomicType('INT')),
- DataField(3, 'payload', AtomicType('BLOB'))]
+ DataField(3, 'payload', payload_type or AtomicType('BLOB'))]
options = {'row-tracking.enabled': 'true', 'data-evolution.enabled':
'true',
'write.native.enabled': 'true', 'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true', 'deletion-vectors.enabled':
'true',
@@ -277,11 +278,355 @@ def test_native_inline_blob_updates_preserve_references(
def test_raw_blob_incremental_updater_keeps_one_implementation(tmp_path):
- # Later calls choose their columns independently. Keeping this operation
- # on Python permits a scalar update followed by a raw Blob update.
+ # One snapshot/index covers scalar and raw Blob columns chosen per call.
table, _ = _table(tmp_path)
from pypaimon.write.native_update import create_native_update_by_row_id
- assert create_native_update_by_row_id(table, 'test', 7) is None
+ row_ids = {row['id']: row['_ROW_ID']
+ for row in _read(table, projection=['id', '_ROW_ID'])}
+ with _native_only():
+ updater = create_native_update_by_row_id(table, 'test', 7)
+ assert isinstance(updater, NativeTableUpdateByRowId)
+ updater.update_columns(pa.table({
+ '_ROW_ID': [row_ids[1]], 'value': pa.array([111], type=pa.int32()),
+ }), ['value'])
+ updater.update_columns(pa.table({
+ '_ROW_ID': [row_ids[3]],
+ 'payload': pa.array([b'replacement'], type=pa.large_binary()),
+ }), ['payload'])
+ messages = updater.commit_messages
+ assert {file.file_name.rsplit('.', 1)[-1]
+ for message in messages for file in message.new_files} ==
{'parquet', 'blob'}
+ _commit(table.new_stream_write_builder(), messages, True)
+ rows = _read(table)
+ assert [row['value'] for row in rows] == [10, 111, 12, 13]
+ assert [row['payload'] for row in rows] == [b'large' * 10, None, b'',
b'replacement']
+
+
+def _blob_values(kind):
+ if kind == 'scalar':
+ return AtomicType('BLOB'), [b'old', None, b'', b'last'], b'new'
+ if kind == 'array':
+ return (ArrayType(True, AtomicType('BLOB')),
+ [[b'old', None, b''], None, [], [b'last']], [b'new', None,
b''])
+ key = AtomicType('INT') if kind == 'int_map' else AtomicType('STRING')
+ keys = [1, 2, 3] if kind == 'int_map' else ['a', 'null', '']
+ return (MapType(True, key, AtomicType('BLOB')),
+ [[(keys[0], b'old'), (keys[1], None), (keys[2], b'')], None, [],
[(keys[0], b'last')]],
+ [(keys[0], b'new'), (keys[1], None), (keys[2], b'')])
+
+
[email protected]('kind', ['scalar', 'array', 'map', 'int_map'])
[email protected]('stream', [False, True])
[email protected]('operation', ['row_id', 'grouped', 'incremental',
'row', 'rows', 'predicate'])
+def test_native_raw_blob_updates_across_existing_update_apis(tmp_path, kind,
stream, operation):
+ payload_type, original, replacement = _blob_values(kind)
+ table, schema = _table(tmp_path, payloads=original,
payload_type=payload_type)
+ before = _read(table, descriptors=True)
+ ids = {row['id']: row['_ROW_ID'] for row in _read(table, projection=['id',
'_ROW_ID'])}
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ update = builder.new_update().with_update_type(['payload'])
+ data = pa.table({'_ROW_ID': [ids[1], ids[3]],
+ 'payload': pa.array([replacement, None],
type=schema.field('payload').type)})
+ with _native_only():
+ if operation == 'row_id':
+ messages = (update.update_by_arrow_with_row_id(data, 7) if stream
+ else update.update_by_arrow_with_row_id(data))
+ elif operation == 'grouped':
+ batches = iter([data.slice(0, 1), data.slice(1)])
+ # Grouped input is a batch API; stream updates use the shared
+ # incremental updater to transport multiple logical tables.
+ if stream:
+ updater = update.new_update_by_row_id(7)
+ for batch in batches:
+ updater.update_columns(batch, ['payload'])
+ messages = updater.commit_messages
+ else:
+ messages = update.update_by_arrow_batches_with_row_id(batches)
+ elif operation == 'incremental':
+ updater = update.new_update_by_row_id(7) if stream else
update.new_update_by_row_id()
+ assert isinstance(updater, NativeTableUpdateByRowId)
+ updater.update_columns(data.slice(0, 1), ['payload'])
+ updater.update_columns(data.slice(1), ['payload'])
+ messages = updater.commit_messages
+ elif operation in ('row', 'rows'):
+ updater = update.new_update_by_row_id(7) if stream else
update.new_update_by_row_id()
+ assert isinstance(updater, NativeTableUpdateByRowId)
+ rows = [GenericRow([1, 'a', 999, replacement], table.fields),
+ GenericRow([3, 'b', 999, None], table.fields)]
+ if operation == 'row':
+ updater.update_row_columns(rows[0], [ids[1]], ['payload'])
+ updater.update_row_columns(rows[1], [ids[3]], ['payload'])
+ else:
+ updater.update_rows_columns(rows, [[ids[1]], [ids[3]]],
['payload'])
+ messages = updater.commit_messages
+ else:
+ predicate = PredicateBuilder(table.fields).is_in('id', [1, 3])
+
+ def assignment(matched):
+ return pa.array([replacement if key == 1 else None
+ for key in matched['id'].to_pylist()],
type=schema.field('payload').type)
+
+ args = (predicate, {'payload': assignment})
+ messages = (update.update_by_predicate(*args, 7,
read_columns=['id']) if stream
+ else update.update_by_predicate(*args,
read_columns=['id']))
+ assert messages
+ assert all(file.file_name.endswith('.blob') and file.write_cols ==
['payload']
+ and file.min_sequence_number == file.max_sequence_number == 0
+ for message in messages for file in message.new_files)
+ _commit(builder, messages, stream)
+ assert [row['payload'] for row in _read(table)] == [original[0],
replacement, original[2], None]
+ assert [row['value'] for row in _read(table)] == [10, 11, 12, 13]
+ after = _read(table, descriptors=True)
+ assert after[0]['payload'] == before[0]['payload']
+ assert after[2]['payload'] == before[2]['payload']
+
+
[email protected]('kind', ['scalar', 'array', 'map', 'int_map'])
[email protected]('stream', [False, True])
[email protected]('operation', ['upsert_arrow', 'upsert_rows', 'merge'])
+def test_native_raw_blob_matched_updates_and_unmatched_inserts(tmp_path, kind,
stream, operation):
+ payload_type, original, replacement = _blob_values(kind)
+ table, schema = _table(tmp_path, payloads=original,
payload_type=payload_type)
+ source = pa.table({'id': [0, 4], 'pt': ['a', 'b'], 'value': [100, 400],
+ 'payload': [replacement, None]}, schema=schema)
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ update = builder.new_update().with_update_type(['payload'])
+ with _native_only():
+ if operation == 'merge':
+ with
patch('pypaimon.table.data_evolution_merge_into._build_tables',
+ side_effect=AssertionError('Python merge
orchestration')):
+ kwargs = {'commit_identifier': 7} if stream else {}
+ messages = update.merge_into(source, on=['id'], when_matched=[
+ WhenMatched.update({'payload': source_col('payload')})],
+ when_not_matched=[WhenNotMatched('*')], **kwargs)
+ elif operation == 'upsert_rows':
+ source_rows = [GenericRow([row[field.name] for field in
table.fields], table.fields)
+ for row in source.to_pylist()]
+ messages = (update.upsert_by_key(source_rows, ['id'], 7) if stream
+ else update.upsert_by_key(source_rows, ['id']))
+ else:
+ messages = (update.upsert_by_arrow_with_key(source, ['id'], 7) if
stream
+ else update.upsert_by_arrow_with_key(source, ['id']))
+ _commit(builder, messages, stream)
+ rows = _read(table)
+ assert [row['payload'] for row in rows] == [replacement, None,
original[2], original[3], None]
+ assert [row['value'] for row in rows] == [10, 11, 12, 13, 400]
+
+
[email protected]('kind', ['scalar', 'array', 'map'])
+def test_native_blob_no_baseline_emits_null_for_unchanged_rows(tmp_path, kind):
+ payload_type, _, replacement = _blob_values(kind)
+ table, schema = _table(tmp_path, payloads=[None] * 4,
payload_type=payload_type,
+ extra={'target-file-row-num': '100'})
+ # Create a new logical column with no files in the older snapshot.
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
+ from pypaimon.schema.schema_change import SchemaChange
+ catalog.alter_table('db.t', [SchemaChange.add_column('extra',
payload_type)])
+ table = catalog.get_table('db.t')
+ schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+ row_id = {row['id']: row['_ROW_ID'] for row in _read(table,
projection=['id', '_ROW_ID'])}[1]
+ builder = table.new_batch_write_builder()
+ with _native_only():
+ messages = builder.new_update().update_by_arrow_with_row_id(pa.table({
+ '_ROW_ID': [row_id], 'extra': pa.array([replacement],
type=schema.field('extra').type)}))
+ assert all(file.write_cols == ['extra'] and file.row_count == 2
+ for message in messages for file in message.new_files)
+ _commit(builder, messages, False)
+ assert [row['extra'] for row in _read(table)] == [None, replacement, None,
None]
+
+
+def
test_native_blob_drop_and_add_same_name_does_not_reuse_old_field_baseline(tmp_path):
+ from pypaimon.schema.schema_change import SchemaChange
+ table, _ = _table(tmp_path, extra={'target-file-row-num': '100'})
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
+ catalog.alter_table('db.t', [SchemaChange.drop_column('payload')])
+ catalog.alter_table('db.t', [SchemaChange.add_column('payload',
AtomicType('BLOB'))])
+ table = catalog.get_table('db.t')
+ assert table.field_dict['payload'].id != 3
+ row_id = {row['id']: row['_ROW_ID'] for row in _read(table,
projection=['id', '_ROW_ID'])}[1]
+ builder = table.new_batch_write_builder()
+ with _native_only():
+ messages = builder.new_update().update_by_arrow_with_row_id(pa.table({
+ '_ROW_ID': [row_id], 'payload': pa.array([b'new column'],
type=pa.large_binary())}))
+ assert all(file.row_count == 2 for message in messages for file in
message.new_files)
+ _commit(builder, messages, False)
+ assert [row['payload'] for row in _read(table)] == [None, b'new column',
None, None]
+
+
+def
test_native_independent_blob_columns_share_a_pinned_update_snapshot(tmp_path):
+ from pypaimon.schema.schema_change import SchemaChange
+ table, _ = _table(tmp_path)
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
+ catalog.alter_table('db.t', [SchemaChange.add_column('items',
ArrayType(True, AtomicType('BLOB')))])
+ table = catalog.get_table('db.t')
+ row_id = {row['id']: row['_ROW_ID'] for row in _read(table,
projection=['id', '_ROW_ID'])}[1]
+ builder = table.new_batch_write_builder()
+ with _native_only():
+ updater = builder.new_update().new_update_by_row_id()
+ updater.update_columns(pa.table({'_ROW_ID': [row_id],
+ 'payload': pa.array([b'scalar'],
type=pa.large_binary())}), ['payload'])
+ updater.update_columns(pa.table({'_ROW_ID': [row_id],
+ 'items': pa.array([[b'array', None]],
type=pa.list_(pa.large_binary()))}),
+ ['items'])
+ paths = set(tmp_path.rglob('*.blob'))
+ with pytest.raises(ValueError, match='overlapping first_row_ids'):
+ updater.update_columns(pa.table({'_ROW_ID': [row_id],
+ 'payload': pa.array([b'again'],
type=pa.large_binary())}), ['payload'])
+ assert set(tmp_path.rglob('*.blob')) == paths
+ messages = updater.commit_messages
+ assert {tuple(file.write_cols) for message in messages for file in
message.new_files} == {
+ ('payload',), ('items',)}
+ assert {message.check_from_snapshot for message in messages} == {1}
+ _commit(builder, messages, False)
+ rows = _read(table)
+ assert [row['items'] for row in rows] == [None, [b'array', None], None,
None]
+ assert [row['payload'] for row in rows] == [b'large' * 10, b'scalar', b'',
b'last']
+
+
[email protected]('published', [False, True])
+def test_raw_blob_abort_preserves_prepared_files(tmp_path, published):
+ table, _ = _table(tmp_path)
+ row_id = {row['id']: row['_ROW_ID'] for row in _read(table,
projection=['id', '_ROW_ID'])}[0]
+ builder = table.new_batch_write_builder()
+ with _native_only():
+ updater = builder.new_update().new_update_by_row_id()
+ updater.update_columns(pa.table({'_ROW_ID': [row_id],
+ 'payload': pa.array([b'new'],
type=pa.large_binary())}), ['payload'])
+ messages = list(updater.commit_messages)
+ paths = set(tmp_path.rglob('*.blob'))
+ if published:
+ _commit(builder, messages, False)
+ updater.writer._abort()
+ updater.writer._abort()
+ assert set(tmp_path.rglob('*.blob')) == paths
+ if not published:
+ assert _read(table)[0]['payload'] == b'large' * 10
+ _commit(builder, messages, False)
+ assert _read(table)[0]['payload'] == b'new'
+
+
+def
test_raw_blob_updates_keep_previous_prepared_messages_after_generator_failure(tmp_path):
+ table, _ = _table(tmp_path)
+ old_paths = set(tmp_path.rglob('*.blob'))
+
+ def batches():
+ yield pa.table({'_ROW_ID': [0], 'payload': pa.array([b'new'],
type=pa.large_binary())})
+ raise RuntimeError('source generator failed')
+
+ with _native_only(), pytest.raises(RuntimeError, match='source generator
failed'):
+
table.new_batch_write_builder().new_update().update_by_arrow_batches_with_row_id(batches())
+ assert old_paths < set(tmp_path.rglob('*.blob'))
+ assert [row['payload'] for row in _read(table)] == [b'large' * 10, None,
b'', b'last']
+
+
[email protected]('kind', ['scalar', 'array', 'map'])
+def
test_native_row_update_streams_blob_objects_without_materializing(tmp_path,
kind):
+ from pypaimon.tests.blob_table_test import _StreamingOnlyBlob
+ payload_type, original, _ = _blob_values(kind)
+ table, _ = _table(tmp_path, payloads=original, payload_type=payload_type)
+ row_id = {row['id']: row['_ROW_ID'] for row in _read(table,
projection=['id', '_ROW_ID'])}[1]
+ blob = _StreamingOnlyBlob(b'streamed')
+ value = blob if kind == 'scalar' else ([blob, None] if kind == 'array'
else [('a', blob), ('null', None)])
+ builder = table.new_batch_write_builder()
+ with _native_only():
+ updater = builder.new_update().new_update_by_row_id()
+ updater.update_row_columns(GenericRow([1, 'a', 11, value],
table.fields), [row_id], ['payload'])
+ messages = updater.commit_messages
+ assert blob.opened
+ _commit(builder, messages, False)
+ expected = b'streamed' if kind == 'scalar' else (
+ [b'streamed', None] if kind == 'array' else [('a', b'streamed'),
('null', None)])
+ assert [row['payload'] for row in _read(table)] == [original[0], expected,
original[2], original[3]]
+
+
+def
test_native_blob_row_failure_preserves_prepared_columns_and_can_retry(tmp_path):
+ from pypaimon.tests.blob_table_test import _StreamingOnlyBlob
+
+ class FailingBlob(_StreamingOnlyBlob):
+ def new_input_stream(self):
+ raise OSError('row stream failed')
+
+ table, _ = _table(tmp_path)
+ row_id = {row['id']: row['_ROW_ID'] for row in _read(table,
projection=['id', '_ROW_ID'])}[0]
+ builder = table.new_batch_write_builder()
+ with _native_only():
+ updater = builder.new_update().new_update_by_row_id()
+ updater.update_columns(pa.table({'_ROW_ID': [row_id], 'value':
pa.array([99], type=pa.int32())}), ['value'])
+ prepared = list(updater.commit_messages)
+ paths = set(tmp_path.rglob('*.parquet'))
+ with pytest.raises(OSError, match='row stream failed'):
+ updater.update_row_columns(GenericRow([0, 'a', 99,
FailingBlob(b'bad')], table.fields),
+ [row_id], ['payload'])
+ assert [file.file_name for message in updater.commit_messages for file
in message.new_files] == [
+ file.file_name for message in prepared for file in
message.new_files]
+ assert set(tmp_path.rglob('*.parquet')) == paths
+ updater.update_row_columns(GenericRow([0, 'a', 99,
_StreamingOnlyBlob(b'retry')], table.fields),
+ [row_id], ['payload'])
+ messages = updater.commit_messages
+ _commit(builder, messages, False)
+ assert _read(table)[0] == {'id': 0, 'pt': 'a', 'value': 99, 'payload':
b'retry'}
+
+
[email protected]('stream', [False, True])
[email protected]('blob_object', [False, True])
[email protected]('kind', ['map', 'int_map'])
+def test_native_row_blob_maps_write_java_null_key_records(tmp_path, stream,
blob_object, kind):
+ from pypaimon.read.reader.format_blob_reader import FormatBlobReader
+ from pypaimon.tests.blob_table_test import _StreamingOnlyBlob
+ payload_type, original, _ = _blob_values(kind)
+ table, _ = _table(tmp_path, payloads=original, payload_type=payload_type,
+ extra={'blob.target-file-size': '1 MB'})
+ row_id = {row['id']: row['_ROW_ID'] for row in _read(table,
projection=['id', '_ROW_ID'])}[1]
+ value = _StreamingOnlyBlob(b'null key') if blob_object else b'null key'
+ key = 7 if kind == 'int_map' else 'normal'
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ with _native_only():
+ update = builder.new_update()
+ updater = update.new_update_by_row_id(7) if stream else
update.new_update_by_row_id()
+ updater.update_row_columns(GenericRow([1, 'a', 11, [(None, value),
(key, None)]], table.fields),
+ [row_id], ['payload'])
+ messages = updater.commit_messages
+ _commit(builder, messages, stream)
+ file = messages[0].new_files[0]
+ reader = FormatBlobReader(table.file_io, file.file_path, ['payload'],
[table.field_dict['payload']],
+ None, False)
+ try:
+ # Row iteration preserves the nullable keys which Arrow Map cannot
+ # represent. The independent Python decoder verifies Java's -1 tag.
+ row = reader.read_values_at([row_id - file.first_row_id])[0]
+ assert row[None].to_data() == b'null key'
+ assert row[key] is None
+ assert reader.blob_lengths[0] == -2
+ finally:
+ reader.close()
+ if blob_object:
+ assert value.opened
+
+
+def
test_native_row_blob_map_rejects_duplicate_null_keys_before_record_write(tmp_path):
+ table, _ = _table(tmp_path, payloads=[[], None, [], []],
+ payload_type=MapType(True, AtomicType('STRING'),
AtomicType('BLOB')))
+ row_id = {row['id']: row['_ROW_ID'] for row in _read(table,
projection=['id', '_ROW_ID'])}[1]
+ paths = set(tmp_path.rglob('*.blob'))
+ with _native_only(), pytest.raises(ValueError, match='unique'):
+
table.new_batch_write_builder().new_update().new_update_by_row_id().update_row_columns(
+ GenericRow([1, 'a', 11, [(None, b'first'), (None, b'second')]],
table.fields),
+ [row_id], ['payload'])
+ assert set(tmp_path.rglob('*.blob')) == paths
+
+
+def test_raw_blob_row_upsert_keeps_streams_lazy_on_python_path(tmp_path):
+ from pypaimon.tests.blob_table_test import _StreamingOnlyBlob
+ table, _ = _table(tmp_path)
+ first, shadowed, surviving = (_StreamingOnlyBlob(value) for value in
(b'first', b'shadow', b'last'))
+ source = [GenericRow([0, 'a', 100, first], table.fields),
+ GenericRow([4, 'b', 400, shadowed], table.fields),
+ GenericRow([4, 'b', 401, surviving], table.fields)]
+ builder = table.new_batch_write_builder()
+ messages =
builder.new_update().with_update_type(['payload']).upsert_by_key(source, ['id'])
+ _commit(builder, messages, False)
+ assert first.opened and surviving.opened and not shadowed.opened
+ assert [row['payload'] for row in _read(table)] == [b'first', None, b'',
b'last', b'last']
def test_view_prescan_keeps_inline_descriptor_predicate(tmp_path,
native_rest_catalog):
diff --git a/paimon-python/pypaimon/write/native_blob_rows.py
b/paimon-python/pypaimon/write/native_blob_rows.py
new file mode 100644
index 0000000000..463f940789
--- /dev/null
+++ b/paimon-python/pypaimon/write/native_blob_rows.py
@@ -0,0 +1,73 @@
+# 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.
+
+"""Transport row Blob streams into the core URI copy path."""
+
+from collections.abc import Mapping
+from uuid import uuid4
+
+from pypaimon.common.uri_reader import UriReaderFactory
+from pypaimon.schema.data_types import ArrayType, MapType
+from pypaimon.table.row.blob import Blob, BlobDescriptor
+
+
+class _BlobRowReader:
+ def __init__(self, blob):
+ self.blob = blob
+
+ def new_input_stream(self, uri):
+ return self.blob.new_input_stream()
+
+
+class NativeBlobRows:
+ """Expose each Python Blob lazily, without opening or materializing it.
+
+ The synthetic descriptors only transport live objects for this operation.
+ Core copies their payloads into normal .blob records; these URIs must never
+ become stored descriptors. A new reader is kept for each Blob object so
+ the core cannot reuse an unrelated Python stream.
+ """
+
+ def __init__(self, file_io):
+ self.readers = {}
+ self.fallback = UriReaderFactory.from_file_io(file_io)
+
+ def create(self, uri):
+ if uri in self.readers:
+ return self.readers[uri]
+ return self.fallback.create(uri)
+
+ def encode(self, value, data_type):
+ if hasattr(value, 'as_py'):
+ value = value.as_py()
+ if value is None:
+ return None
+ if isinstance(data_type, ArrayType):
+ if isinstance(value, (bytes, bytearray, str)):
+ raise ValueError('ARRAY<BLOB> field value must be a list or
tuple.')
+ return [self.encode(item, data_type.element) for item in value]
+ if isinstance(data_type, MapType):
+ entries = value.items() if isinstance(value, Mapping) else value
+ return [(key, self.encode(item, data_type.value)) for key, item in
entries]
+ if isinstance(value, Blob):
+ if any(value is placeholder for placeholder in (
+ Blob.PLACE_HOLDER, Blob.ARRAY_PLACE_HOLDER,
Blob.MAP_PLACE_HOLDER)):
+ raise ValueError('Blob placeholders are reserved for unchanged
rows.')
+ uri = 'pypaimon-blob-row://' + uuid4().hex
+ self.readers[uri] = _BlobRowReader(value)
+ return BlobDescriptor(uri, 0, -1).serialize()
+ return value
diff --git a/paimon-python/pypaimon/write/native_update.py
b/paimon-python/pypaimon/write/native_update.py
index 3175d30226..3f5231a1b8 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -19,7 +19,7 @@
import pyarrow as pa
-from pypaimon.schema.data_types import PyarrowFieldParser, is_blob_file_field
+from pypaimon.schema.data_types import MapType, PyarrowFieldParser
from pypaimon.common.options.core_options import ChangelogProducer
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.snapshot.time_travel_util import SCAN_KEYS
@@ -29,7 +29,7 @@ from pypaimon.write.native_commit import (
)
from pypaimon.write.native_write import native_write_available,
_native_partition_types_supported
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
-from pypaimon.write.row_utils import value_for_arrow
+from pypaimon.write.row_utils import _contains_blob_value, value_for_arrow
def _native_row_id_table(table):
@@ -68,9 +68,8 @@ def create_native_update(table, commit_user, columns):
def create_native_update_by_row_id(table, commit_user, commit_identifier):
"""Create a core updater sharing one snapshot across incremental calls."""
- # Its columns are selected on each later call. Until core supports raw
- # Blob updates, retain one Python updater for the whole operation on such
- # tables rather than switching after some files have already been staged.
+ # Columns are selected per call; one core updater pins the snapshot and
+ # tracks overlaps across Parquet and dedicated Blob updates.
if not _native_update_columns_supported(table, None):
return None
native_table = _native_row_id_table(table)
@@ -97,13 +96,11 @@ def _supported_upsert_key_type(data_type):
def _native_update_columns_supported(table, columns):
- """Java updatableBlobFields: descriptor and view fields are inline."""
+ """Core writes Parquet columns and Java-compatible sparse Blob deltas."""
if type(table) is not FileStoreTable:
return False
- inline = table.options.blob_descriptor_fields() |
table.options.blob_view_fields()
names = set(table.field_names if columns is None else columns)
- return not any(field.name in names and is_blob_file_field(field)
- and field.name not in inline for field in table.fields)
+ return not names.intersection(table.options.video_frame_fields())
def _upsert_row_batches(rows, fields, schema):
@@ -147,6 +144,10 @@ def create_native_upsert(table, commit_user, data, keys,
columns):
if not isinstance(data, pa.Table):
if not columns or not data:
return None
+ # Row upserts can append custom Blob streams. Select the row-aware
+ # writer before matching or opening any source, including shadowed
rows.
+ if any(_contains_blob_value(value) for row in data for value in
row.values()):
+ return None
data = _upsert_row_batches(data, fields, schema)
elif (len(data.column_names) != len(set(data.column_names))
or not set(data.column_names) <= set(schema.names)
@@ -276,7 +277,26 @@ class NativeTableUpdateByRowId(TableUpdateByRowId):
def _write_row_columns(self, data, column_names, blob_object_columns):
# Row input normalization happens in the public Python API; physical
# field layouts and Blob references are handled by the core updater.
- return self.update_columns(data, column_names)
+ if not blob_object_columns:
+ return self.update_columns(data, column_names)
+ from pypaimon.write.native_blob_rows import NativeBlobRows
+ blobs = NativeBlobRows(self.table.file_io)
+ for name, values in blob_object_columns.items():
+ field = self.table.field_dict[name]
+ arrow_field = PyarrowFieldParser.from_paimon_field(field)
+ if isinstance(field.type, MapType):
+ # Arrow Map disallows NULL keys; Java's Blob map records allow
+ # one. Core accepts row entries as a list of nullable-key
structs.
+ entries = pa.struct([pa.field('key',
arrow_field.type.key_type),
+ pa.field('value',
arrow_field.type.item_type)])
+ arrow_field = pa.field(name, pa.list_(entries),
nullable=arrow_field.nullable)
+ data = data.append_column(arrow_field, pa.array(
+ [blobs.encode(value, field.type) for value in values],
type=arrow_field.type))
+ self.writer._with_blob_uri_reader_factory(blobs)
+ try:
+ return self.update_columns(data, column_names)
+ finally:
+ self.writer._with_blob_uri_reader_factory(None)
class NativeTableUpsert: