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 bf319e753e [python] Keep BlobConsumer writes native (#10416)
bf319e753e is described below
commit bf319e753ec85c65882c36ebbc1f6429613af694
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 21:18:24 2026 +0800
[python] Keep BlobConsumer writes native (#10416)
---
paimon-python/pypaimon/tests/blob_table_test.py | 8 +-
.../pypaimon/tests/native_blob_consumer_test.py | 287 +++++++++++++++++++++
paimon-python/pypaimon/write/native_write.py | 24 ++
3 files changed, 314 insertions(+), 5 deletions(-)
diff --git a/paimon-python/pypaimon/tests/blob_table_test.py
b/paimon-python/pypaimon/tests/blob_table_test.py
index 6ba1a7d0af..58e2484a60 100755
--- a/paimon-python/pypaimon/tests/blob_table_test.py
+++ b/paimon-python/pypaimon/tests/blob_table_test.py
@@ -6302,11 +6302,9 @@ class BlobConsumerTest(unittest.TestCase):
self.assertGreater(len(received), 0)
- # Capture data writers before close() clears them, then abort each one.
- data_writers = list(writer.file_store_write.data_writers.values())
- self.assertGreater(len(data_writers), 0)
- for dw in data_writers:
- dw.abort()
+ # Public abort covers both Python and Native writers. Consumer-held
+ # descriptors keep their Blob files regardless of the writer backend.
+ writer.abort()
# Every descriptor returned to the consumer must still be readable.
uri_reader = FileUriReader(table.file_io)
diff --git a/paimon-python/pypaimon/tests/native_blob_consumer_test.py
b/paimon-python/pypaimon/tests/native_blob_consumer_test.py
new file mode 100644
index 0000000000..c460ed81f3
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_blob_consumer_test.py
@@ -0,0 +1,287 @@
+# 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.
+
+"""Blob callbacks must retain Native writes and report Java payload ranges."""
+
+import pyarrow as pa
+import pytest
+from urllib.parse import urlparse
+from unittest.mock import patch
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.schema.data_types import AtomicType, ArrayType, DataField,
MapType, PyarrowFieldParser
+from pypaimon.table.row.blob import Blob, BlobDescriptor
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.tests.native_blob_write_test import _table, _data, _read
+from pypaimon.write.native_write import NativeTableWrite
+
+pytestmark = pytest.mark.native_plan
+
+
+def test_blob_consumer_keeps_native_writer(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ received = []
+
+ def consumer(field_name, descriptor):
+ received.append((field_name, descriptor))
+ return True
+
+ try:
+ assert writer.with_blob_consumer(consumer) is writer
+ assert isinstance(writer, NativeTableWrite)
+ writer.write_arrow(_data())
+ messages = writer.prepare_commit()
+ commit.commit(messages)
+ assert writer._python_writer is None
+ assert [descriptor.length if descriptor is not None else None
+ for name, descriptor in received if name == 'large'] == [40,
None]
+ assert [descriptor.length for name, descriptor in received if name ==
'small'] == [0, 3]
+ assert _read(table, True, True) == _data().to_pylist()
+ finally:
+ writer.close()
+ commit.close()
+
+
+def _collection_table(tmp_path, native, external=False, options=None):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
+ catalog.create_database('db', True)
+ blob = AtomicType('BLOB')
+ fields = [DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'payload', blob),
+ DataField(2, 'items', ArrayType(True, blob)),
+ DataField(3, 'attrs', MapType(True, AtomicType('STRING'), blob))]
+ settings = {'row-tracking.enabled': 'true', 'data-evolution.enabled':
'true',
+ 'write.native.enabled': str(native).lower(),
'target-file-row-num': '2',
+ 'blob.target-file-size': '32 B', 'blob.copy-buffer-size': '3
B',
+ 'data-file.path-directory': 'data'}
+ if external:
+ settings['data-file.external-paths'] = (tmp_path / 'external').as_uri()
+ settings['data-file.external-paths.strategy'] = 'round-robin'
+ settings.update(options or {})
+ catalog.create_table('db.t', Schema(fields=fields, options=settings),
False)
+ return catalog.get_table('db.t')
+
+
+def _payload_at(descriptor):
+ path = urlparse(descriptor.uri).path if descriptor.uri.startswith('file:')
else descriptor.uri
+ with open(path, 'rb') as source:
+ source.seek(descriptor.offset)
+ return source.read(descriptor.length)
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
[email protected]('external', [False, True])
[email protected]('reference', [False, True])
+def test_blob_consumer_ranges_nulls_and_collection_elements(tmp_path, native,
stream, external, reference):
+ table = _collection_table(tmp_path, native, external)
+ received = []
+
+ def consumer(name, descriptor):
+ received.append((name, descriptor))
+ return len(received) % 2 == 1
+
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ expected = []
+ try:
+ assert writer.with_blob_consumer(consumer) is writer
+ for identifier in range(1, 3 if stream else 2):
+ received.clear()
+ value = b'payload-with-many-copy-chunks'
+ if reference:
+ path = tmp_path / 'source'
+ path.write_bytes(b'prefix' + value + b'suffix')
+ value_input = BlobDescriptor(str(path), 6,
len(value)).serialize()
+ else:
+ value_input = value
+ rows = [{'id': identifier * 3, 'payload': value_input,
+ 'items': [b'a', None, b''], 'attrs': [('key',
value_input), ('null', None)]},
+ {'id': identifier * 3 + 1, 'payload': None, 'items': None,
'attrs': None},
+ {'id': identifier * 3 + 2, 'payload': b'', 'items': [],
'attrs': []}]
+ expected.extend([dict(rows[0], payload=value, attrs=[('key',
value), ('null', None)]),
+ rows[1], rows[2]])
+ writer.write_arrow(pa.Table.from_pylist(
+ rows,
schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
+ messages = writer.prepare_commit(identifier) if stream else
writer.prepare_commit()
+ for name, values in [('payload', [value, None, b'']),
+ ('items', [b'a', b'', None]), ('attrs',
[value, None])]:
+ descriptors = [descriptor for field, descriptor in received if
field == name]
+ assert len(descriptors) == len(values)
+ for descriptor, payload in zip(descriptors, values):
+ if payload is None:
+ assert descriptor is None
+ else:
+ assert isinstance(descriptor, BlobDescriptor)
+ assert descriptor.length == len(payload)
+ assert _payload_at(descriptor) == payload
+ assert ('/external/' in descriptor.uri) == external
+ commit.commit(messages, identifier) if stream else
commit.commit(messages)
+ for planner in (False, True):
+ for reader in (False, True):
+ assert _read(table, planner, reader) == expected
+ if native:
+ assert writer._python_writer is None
+ finally:
+ writer.close()
+ commit.close()
+
+
+def
test_blob_consumer_failure_preserves_exception_and_does_not_retry(tmp_path):
+ table = _collection_table(tmp_path, True)
+ writer = table.new_batch_write_builder().new_write()
+ calls = []
+ failure = RuntimeError('consumer stopped the write')
+
+ def consumer(name, descriptor):
+ calls.append((name, descriptor))
+ raise failure
+
+ try:
+ writer.with_blob_consumer(consumer)
+ schema = PyarrowFieldParser.from_paimon_schema(table.fields)
+ data = pa.Table.from_pylist([{'id': 1, 'payload': b'a'}, {'id': 2,
'payload': b'b'}], schema=schema)
+ with pytest.raises(RuntimeError) as caught:
+ writer.write_arrow(data)
+ assert caught.value is failure
+ assert len(calls) == 1
+ assert writer._python_writer is None
+ assert table.snapshot_manager().get_latest_snapshot() is None
+ # Java relinquishes deletion rights as soon as a consumer is installed.
+ # The descriptor may already have been handed to another application.
+ assert list(tmp_path.rglob('*.blob'))
+ assert _payload_at(calls[0][1]) == b'a'
+ assert not list(tmp_path.rglob('*.parquet'))
+ with pytest.raises(Exception, match='write failure'):
+ writer.write_arrow(data)
+ assert len(calls) == 1
+ finally:
+ writer.close()
+
+
[email protected]('native', [False, True])
[email protected]('action', ['close', 'abort', 'callback-failure'])
+def test_blob_consumer_preserves_uncommitted_exposed_files(tmp_path, native,
action):
+ table = _table(tmp_path, {'write.native.enabled': str(native).lower(),
+ 'target-file-row-num': '1'})
+ writer = table.new_batch_write_builder().new_write()
+ received = []
+
+ def consumer(name, descriptor):
+ received.append((name, descriptor))
+ if action == 'callback-failure' and descriptor is None:
+ raise RuntimeError('after exposing a payload')
+ return True
+
+ try:
+ writer.with_blob_consumer(consumer)
+ if action == 'callback-failure':
+ with pytest.raises(RuntimeError, match='after exposing'):
+ writer.write_arrow(_data())
+ else:
+ writer.write_arrow(_data())
+ getattr(writer, action)()
+ assert table.snapshot_manager().get_latest_snapshot() is None
+ for _, descriptor in received:
+ if descriptor is not None:
+ assert _payload_at(descriptor) in (b'\x01' * 40, b'', b'abc')
+ assert list(tmp_path.rglob('*.blob'))
+ if native:
+ assert not list(tmp_path.rglob('*.parquet'))
+ assert writer._python_writer is None
+ finally:
+ writer.close()
+
+
+def
test_blob_consumer_flush_makes_local_payload_visible_before_prepare(tmp_path):
+ table = _table(tmp_path, {'target-file-row-num': '1000',
'blob.target-file-size': '1 MB'})
+ writer = table.new_batch_write_builder().new_write()
+ received = []
+ try:
+ writer.with_blob_consumer(lambda name, descriptor:
(received.append((name, descriptor)) or True))
+ writer.write_arrow(_data().slice(0, 1))
+ # No roll or prepare can hide a no-op flush by closing the output.
+ assert [_payload_at(descriptor) for _, descriptor in received] ==
[b'\x01' * 40, b'']
+ assert table.snapshot_manager().get_latest_snapshot() is None
+ assert writer._python_writer is None
+ finally:
+ writer.close()
+
+
+def
test_blob_consumer_can_be_replaced_cleared_and_cannot_change_after_write(tmp_path):
+ table = _table(tmp_path)
+ writer = table.new_batch_write_builder().new_write()
+ try:
+ writer.with_blob_consumer(lambda name, descriptor:
pytest.fail('replaced consumer invoked'))
+ with pytest.raises(TypeError, match='callable'):
+ writer.with_blob_consumer(123)
+ writer.with_blob_consumer(None)
+ writer.write_arrow(_data())
+ with pytest.raises(RuntimeError, match='before any write'):
+ writer.with_blob_consumer(None)
+ assert writer._python_writer is None
+ finally:
+ writer.close()
+
+
+def test_blob_consumer_survives_switch_to_row_writer(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ received = []
+ try:
+ writer.with_blob_consumer(lambda name, descriptor:
received.append((name, descriptor)))
+ row = GenericRow([1, Blob.from_data(b'row-payload'), None],
table.fields)
+ writer.write_row(row)
+ messages = writer.prepare_commit()
+ commit.commit(messages)
+ assert [(name, None if descriptor is None else _payload_at(descriptor))
+ for name, descriptor in received] == [('large',
b'row-payload'), ('small', None)]
+ finally:
+ writer.close()
+ commit.close()
+
+
+def
test_blob_consumer_reentrant_native_read_and_write_type_projection(tmp_path):
+ table = _table(tmp_path)
+ first = table.new_batch_write_builder()
+ initial = first.new_write()
+ initial.write_arrow(_data())
+ first.new_commit().commit(initial.prepare_commit())
+ initial.close()
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ received = []
+
+ def consumer(name, descriptor):
+ assert _read(table, True, True) == _data().to_pylist()
+ received.append((name, descriptor))
+ return False
+
+ try:
+ writer.with_blob_consumer(consumer).with_write_type(['id', 'small'])
+ with patch.object(writer, '_switch_to_python',
side_effect=AssertionError('Python writer fallback')):
+ writer.write_arrow(_data(2))
+ commit.commit(writer.prepare_commit())
+ assert [name for name, _ in received] == ['small', 'small']
+ assert [_payload_at(descriptor) for _, descriptor in received] ==
[b'', b'abc']
+ assert writer._python_writer is None
+ finally:
+ writer.close()
+ commit.close()
diff --git a/paimon-python/pypaimon/write/native_write.py
b/paimon-python/pypaimon/write/native_write.py
index 1a53d25f6e..de48174194 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -109,6 +109,7 @@ class NativeTableWrite:
self._table_schema =
PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
self._schema = self._table_schema
self._write_cols = None
+ self._blob_consumer = None
self._restore_snapshot_id = restore_snapshot_id
def _switch_to_python(self):
@@ -132,6 +133,8 @@ class NativeTableWrite:
restore_snapshot_id=self._restore_snapshot_id)
if self._write_cols is not None:
writer.with_write_type(self._write_cols)
+ if self._blob_consumer is not None:
+ writer.with_blob_consumer(self._blob_consumer)
return writer
def __getattr__(self, name):
@@ -154,6 +157,27 @@ class NativeTableWrite:
self._write_cols = names
return self
+ def with_blob_consumer(self, blob_consumer):
+ if self._python_writer is not None:
+ return self._python_writer.with_blob_consumer(blob_consumer)
+ if self._written:
+ raise RuntimeError('with_blob_consumer must be called before any
write operation.')
+ if blob_consumer is None:
+ self._native_writer.with_blob_consumer(None)
+ self._blob_consumer = None
+ return self
+ if not callable(blob_consumer):
+ raise TypeError('blob_consumer must be callable')
+ from pypaimon.table.row.blob import BlobDescriptor
+
+ def consume(field_name, encoded):
+ descriptor = None if encoded is None else
BlobDescriptor.deserialize(encoded)
+ return blob_consumer(field_name, descriptor)
+
+ self._native_writer.with_blob_consumer(consume)
+ self._blob_consumer = blob_consumer
+ return self
+
def write_arrow(self, data, bucket=None):
if self._python_writer is not None:
return self._python_writer.write_arrow(data, bucket)