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 ef238ed73f [python] Keep custom Blob URI readers on native writes
(#10417)
ef238ed73f is described below
commit ef238ed73f1a2e46581cc422e46da72c88ab0507
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 7 23:52:54 2026 +0800
[python] Keep custom Blob URI readers on native writes (#10417)
---
.../pypaimon/tests/native_blob_uri_reader_test.py | 350 +++++++++++++++++++++
.../tests/native_postpone_fixed_write_test.py | 30 ++
paimon-python/pypaimon/tests/native_write_test.py | 8 +-
paimon-python/pypaimon/write/native_write.py | 19 +-
4 files changed, 402 insertions(+), 5 deletions(-)
diff --git a/paimon-python/pypaimon/tests/native_blob_uri_reader_test.py
b/paimon-python/pypaimon/tests/native_blob_uri_reader_test.py
new file mode 100644
index 0000000000..9d6ece1f34
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_blob_uri_reader_test.py
@@ -0,0 +1,350 @@
+# 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.
+
+"""Custom URI readers must copy Blob references in Rust core."""
+
+import io
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.schema.data_types import PyarrowFieldParser
+from pypaimon.table.row.blob import BlobDescriptor
+from pypaimon.tests.native_blob_write_test import _table, _read
+from pypaimon.write.native_write import NativeTableWrite
+
+pytestmark = pytest.mark.native_plan
+
+
+class Factory:
+ def __init__(self):
+ self.sources = {'custom://source': b'prefixPAYLOADsuffix'}
+ self.opened = []
+ self.closed = []
+ self.requests = []
+ self.errors = {}
+ self.short_reads = False
+ self.no_seek = False
+
+ def create(self, uri):
+ if 'create' in self.errors:
+ raise self.errors['create']
+ assert uri in self.sources
+ return self
+
+ def new_input_stream(self, uri):
+ if 'open' in self.errors:
+ raise self.errors['open']
+ self.opened.append(uri)
+ owner = self
+
+ class Stream(io.BytesIO):
+ def read(self, length=-1):
+ owner.requests.append(length)
+ if 'read' in owner.errors:
+ raise owner.errors['read']
+ if owner.short_reads:
+ length = min(length, 2)
+ return super().read(length)
+
+ def seek(self, offset, whence=0):
+ if owner.no_seek:
+ raise io.UnsupportedOperation('source cannot seek')
+ if 'seek' in owner.errors:
+ raise owner.errors['seek']
+ return super().seek(offset, whence)
+
+ def close(self):
+ if not self.closed:
+ owner.closed.append(uri)
+ super().close()
+ if 'close' in owner.errors:
+ raise owner.errors['close']
+
+ return Stream(self.sources[uri])
+
+
+def test_custom_uri_reader_keeps_native_writer(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ factory = Factory()
+ try:
+ assert writer.with_blob_uri_reader_factory(factory) is writer
+ assert isinstance(writer, NativeTableWrite)
+ descriptor = BlobDescriptor('custom://source', 6, 7).serialize()
+ writer.write_arrow(pa.Table.from_pylist([
+ {'id': 1, 'large': descriptor, 'small': b'inline'},
+ {'id': 2, 'large': descriptor, 'small': None},
+ ], schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
+ commit.commit(writer.prepare_commit())
+ assert writer._python_writer is None
+ assert _read(table, True, True) == [
+ {'id': 1, 'large': b'PAYLOAD', 'small': b'inline'},
+ {'id': 2, 'large': b'PAYLOAD', 'small': None},
+ ]
+ assert factory.opened == factory.closed
+ finally:
+ writer.close()
+ commit.close()
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
+def test_custom_readers_copy_scalar_and_collection_windows(tmp_path, native,
stream):
+ from pypaimon.tests.native_blob_consumer_test import _collection_table,
_payload_at
+ table = _collection_table(tmp_path, native, options={
+ 'target-file-row-num': '1000', 'blob.target-file-size': '1 MB'})
+ factory = Factory()
+ factory.short_reads = True
+ descriptor = BlobDescriptor('custom://source', 6, 7).serialize()
+ expected = [{'id': 1, 'payload': b'PAYLOAD', 'items': [b'PAYLOAD', None,
b''],
+ 'attrs': [('a', b'PAYLOAD'), ('b', None)]},
+ {'id': 2, 'payload': None, 'items': None, 'attrs': None},
+ {'id': 3, 'payload': b'', 'items': [], 'attrs': []}]
+ source = [dict(expected[0], payload=descriptor, items=[descriptor, None,
b''],
+ attrs=[('a', descriptor), ('b', None)]), expected[1],
expected[2]]
+ received = []
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.with_blob_uri_reader_factory(factory).with_blob_consumer(
+ lambda name, descriptor: (received.append((name, descriptor)) or
True))
+ data = pa.Table.from_pylist(source,
schema=PyarrowFieldParser.from_paimon_schema(table.fields))
+ writer.write_arrow(data)
+ messages = writer.prepare_commit(1) if stream else
writer.prepare_commit()
+ commit.commit(messages, 1) if stream else commit.commit(messages)
+ assert _read(table, True, True) == expected
+ assert factory.opened == ['custom://source'] * 3
+ assert factory.closed == factory.opened
+ if native:
+ assert writer._python_writer is None
+ assert all(length <= 3 for length in factory.requests)
+ assert [_payload_at(descriptor) for name, descriptor in received
+ if name == 'payload' and descriptor is not None] ==
[b'PAYLOAD', b'']
+ finally:
+ writer.close()
+ commit.close()
+
+
[email protected]('unknown', [False, True])
+def
test_non_seekable_source_recovers_rewind_without_leaking_an_exception(tmp_path,
unknown):
+ table = _table(tmp_path, {'blob.target-file-size': '1 MB',
'target-file-row-num': '1000'})
+ factory = Factory()
+ factory.no_seek = True
+ length = -1 if unknown else 3
+ reference = BlobDescriptor('custom://source', 0, length).serialize()
+ writer = table.new_batch_write_builder().new_write()
+ try:
+ writer.with_blob_uri_reader_factory(factory)
+ writer.write_arrow(pa.Table.from_pylist([
+ {'id': 1, 'large': reference, 'small': None},
+ {'id': 2, 'large': reference, 'small': None},
+ ], schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
+
table.new_batch_write_builder().new_commit().commit(writer.prepare_commit())
+ value = factory.sources['custom://source'] if unknown else b'pre'
+ assert [row['large'] for row in _read(table, True, True)] == [value,
value]
+ assert factory.opened == factory.closed == ['custom://source'] * 2
+ finally:
+ writer.close()
+
+
[email protected]('operation', ['create', 'open', 'seek', 'read',
'close'])
+def test_custom_reader_exceptions_are_not_retried_and_streams_close(tmp_path,
operation):
+ table = _table(tmp_path, {'blob.target-file-size': '1 MB',
'target-file-row-num': '1000'})
+ factory = Factory()
+ failure = RuntimeError('custom source failed: ' + operation)
+ factory.errors[operation] = failure
+ writer = table.new_batch_write_builder().new_write()
+ try:
+ writer.with_blob_uri_reader_factory(factory)
+ data = pa.Table.from_pylist([
+ {'id': 1, 'large': BlobDescriptor('custom://source', 6,
7).serialize(), 'small': None},
+ ], schema=PyarrowFieldParser.from_paimon_schema(table.fields))
+ if operation == 'close':
+ writer.write_arrow(data)
+ with pytest.raises(RuntimeError) as caught:
+ writer.prepare_commit()
+ else:
+ with pytest.raises(RuntimeError) as caught:
+ writer.write_arrow(data)
+ assert caught.value is failure
+ assert writer._python_writer is None
+ assert factory.opened == factory.closed
+ expected = (RuntimeError, 'one-time committing') if operation ==
'close' else (
+ ValueError, 'cannot be reused')
+ with pytest.raises(expected[0], match=expected[1]):
+ writer.prepare_commit()
+ finally:
+ writer.close()
+ assert not list(tmp_path.rglob('*.blob'))
+ assert not list(tmp_path.rglob('*.parquet'))
+
+
+def test_reader_factory_replacement_clear_and_row_fallback(tmp_path):
+ from pypaimon.table.row.generic_row import GenericRow
+ table = _table(tmp_path)
+ factory = Factory()
+ factory.errors['create'] = AssertionError('cleared factory invoked')
+ writer = table.new_batch_write_builder().new_write()
+ try:
+ writer.with_blob_uri_reader_factory(factory)
+ writer.with_blob_uri_reader_factory(None)
+ writer.write_arrow(pa.Table.from_pylist(
+ [{'id': 1, 'large': b'inline', 'small': None}],
+ schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
+ assert factory.opened == []
+ with pytest.raises(RuntimeError, match='before any write'):
+ writer.with_blob_uri_reader_factory(factory)
+ finally:
+ writer.close()
+
+ factory = Factory()
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.with_blob_uri_reader_factory(factory)
+ writer.write_row(GenericRow([
+ 2, BlobDescriptor('custom://source', 6, 7).serialize(), None],
table.fields))
+ commit.commit(writer.prepare_commit())
+ assert factory.opened == factory.closed == ['custom://source']
+ assert _read(table, True, True) == [{'id': 2, 'large': b'PAYLOAD',
'small': None}]
+ finally:
+ writer.close()
+ commit.close()
+
+
[email protected]('mode', ['fixed', 'pending', 'postpone-fixed'])
+def test_managed_blob_copies_use_the_configured_reader_factory(tmp_path, mode):
+ from pypaimon.tests.native_managed_blob_write_test import (
+ _table as managed_table, _builder, _input, _read as managed_read)
+ table = managed_table(tmp_path, mode)
+ factory = Factory()
+ reference = BlobDescriptor('custom://source', 6, 7).serialize()
+ builder = _builder(table, mode)
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.with_blob_uri_reader_factory(factory)
+ values = [{'id': 1, 'payload': reference, 'items': [reference, None],
+ 'mapping': [('k', reference)], 'op': '+I'}]
+ writer.write_arrow(_input(table, values))
+ messages = writer.prepare_commit()
+ commit.commit(messages)
+ assert writer._python_writer is None
+ if mode == 'pending':
+ import pyarrow.parquet as pq
+ from pypaimon.table.row.blob import Blob
+ file = messages[0].new_files[0]
+ with table.file_io.new_input_stream(file.file_path) as source:
+ row = pq.ParquetFile(source).read().to_pylist()[0]
+ assert Blob.from_descriptor_bytes(row['payload'],
table.file_io).to_data() == b'PAYLOAD'
+ assert Blob.from_descriptor_bytes(row['items'][0],
table.file_io).to_data() == b'PAYLOAD'
+ assert Blob.from_descriptor_bytes(row['mapping'][0][1],
table.file_io).to_data() == b'PAYLOAD'
+ else:
+ expected = [{'id': 1, 'payload': b'PAYLOAD', 'items': [b'PAYLOAD',
None],
+ 'mapping': [('k', b'PAYLOAD')], 'op': '+I'}]
+ assert managed_read(table) == expected
+ assert factory.opened == factory.closed
+ finally:
+ writer.close()
+ commit.close()
+
+
+def test_same_uri_with_different_reader_identity_reopens_the_source(tmp_path):
+ table = _table(tmp_path, {'blob.target-file-size': '1 MB',
'target-file-row-num': '1000'})
+
+ class RotatingFactory(Factory):
+ def create(self, uri):
+ owner = self
+ payload = b'FIRST__' if not self.opened else b'SECOND_'
+
+ class Reader:
+ def new_input_stream(self, uri):
+ owner.sources[uri] = payload
+ return owner.new_input_stream(uri)
+
+ return Reader()
+
+ factory = RotatingFactory()
+ reference = BlobDescriptor('custom://source', 0, 7).serialize()
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.with_blob_uri_reader_factory(factory)
+ writer.write_arrow(pa.Table.from_pylist([
+ {'id': 1, 'large': reference, 'small': None},
+ {'id': 2, 'large': reference, 'small': None},
+ ], schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
+ commit.commit(writer.prepare_commit())
+ assert [row['large'] for row in _read(table, True, True)] ==
[b'FIRST__', b'SECOND_']
+ assert factory.opened == factory.closed == ['custom://source'] * 2
+ finally:
+ writer.close()
+ commit.close()
+
+
+def test_premature_eof_closes_source_and_leaves_no_uncommitted_files(tmp_path):
+ table = _table(tmp_path)
+ factory = Factory()
+ writer = table.new_batch_write_builder().new_write()
+ try:
+ writer.with_blob_uri_reader_factory(factory)
+ data = pa.Table.from_pylist([
+ {'id': 1, 'large': BlobDescriptor('custom://source', 6,
100).serialize(), 'small': None},
+ ], schema=PyarrowFieldParser.from_paimon_schema(table.fields))
+ with pytest.raises(ValueError, match='Unexpected EOF'):
+ writer.write_arrow(data)
+ assert factory.opened == factory.closed == ['custom://source']
+ finally:
+ writer.close()
+ assert not list(tmp_path.rglob('*.blob'))
+ assert not list(tmp_path.rglob('*.parquet'))
+
+
+def
test_reader_identity_is_preserved_across_interleaved_blob_columns(tmp_path):
+ table = _table(tmp_path, {'blob.target-file-size': '1 MB',
'target-file-row-num': '1000'})
+
+ class InterleavedFactory:
+ def __init__(self):
+ self.readers = {uri: Factory() for uri in ('custom://a',
'custom://b')}
+ for uri, reader in self.readers.items():
+ reader.sources = {uri: b'firstsecond'}
+
+ def create(self, uri):
+ return self.readers[uri]
+
+ factory = InterleavedFactory()
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.with_blob_uri_reader_factory(factory)
+ for identifier, offset, length in ((1, 0, 5), (2, 5, 6)):
+ writer.write_arrow(pa.Table.from_pylist([
+ {'id': identifier,
+ 'large': BlobDescriptor('custom://a', offset,
length).serialize(),
+ 'small': BlobDescriptor('custom://b', offset,
length).serialize()},
+ ], schema=PyarrowFieldParser.from_paimon_schema(table.fields)))
+ commit.commit(writer.prepare_commit())
+ assert _read(table, True, True) == [
+ {'id': 1, 'large': b'first', 'small': b'first'},
+ {'id': 2, 'large': b'second', 'small': b'second'},
+ ]
+ for uri, reader in factory.readers.items():
+ assert reader.opened == reader.closed == [uri]
+ finally:
+ writer.close()
+ commit.close()
diff --git a/paimon-python/pypaimon/tests/native_postpone_fixed_write_test.py
b/paimon-python/pypaimon/tests/native_postpone_fixed_write_test.py
index 7ac8d52b8e..7b1a1350a8 100644
--- a/paimon-python/pypaimon/tests/native_postpone_fixed_write_test.py
+++ b/paimon-python/pypaimon/tests/native_postpone_fixed_write_test.py
@@ -254,6 +254,36 @@ def
test_python_fallback_retains_specialized_writer_and_plan(tmp_path):
writer.abort()
[email protected]('clear_factory', [False, True])
+def test_python_fallback_retains_uri_reader_factory_and_bucket_plan(tmp_path,
clear_factory):
+ from pypaimon.common.uri_reader import UriReaderFactory
+ from pypaimon.write.postpone_batch_table_write import
PostponeFixedBucketBatchTableWrite
+
+ table = _table(tmp_path)
+ factory = UriReaderFactory.from_file_io(table.file_io)
+ plan = PostponeBucketPlan({('a',): 7})
+ builder =
table.new_postpone_fixed_bucket_write_builder().with_bucket_plan(plan)
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ assert isinstance(writer, NativePostponeFixedBucketTableWrite)
+ writer.with_blob_uri_reader_factory(factory)
+ if clear_factory:
+ writer.with_blob_uri_reader_factory(None)
+ # Coordinators select Python planning before receiving data.
+ writer.file_store_write
+ writer.write_row(GenericRow([1, 'a', 10], table.fields))
+ assert isinstance(writer._python_writer,
PostponeFixedBucketBatchTableWrite)
+ assert writer._python_writer.file_store_write.blob_uri_reader_factory
is (
+ None if clear_factory else factory)
+ messages = writer.prepare_commit()
+ assert {message.total_buckets for message in messages} == {7}
+ commit.commit(messages)
+ assert _rows(table) == [dict(id=1, p='a', v=10)]
+ finally:
+ writer.close()
+ commit.close()
+
+
def test_cannot_fallback_after_native_data(tmp_path):
table = _table(tmp_path)
writer = table.new_postpone_fixed_bucket_write_builder().new_write()
diff --git a/paimon-python/pypaimon/tests/native_write_test.py
b/paimon-python/pypaimon/tests/native_write_test.py
index 132d651ed2..dafe004c37 100644
--- a/paimon-python/pypaimon/tests/native_write_test.py
+++ b/paimon-python/pypaimon/tests/native_write_test.py
@@ -453,13 +453,13 @@ def
test_primary_key_full_write_type_selects_writer_before_staging(tmp_path, str
@requires_native
-def test_advanced_api_switches_before_write_and_rejects_late_switch(tmp_path):
+def
test_uri_reader_factory_none_keeps_native_and_rejects_late_change(tmp_path):
table = _table(tmp_path)
builder = table.new_batch_write_builder()
writer = builder.new_write()
assert isinstance(writer, NativeTableWrite)
- assert writer.with_blob_uri_reader_factory(None) is writer._python_writer
- assert writer._native_writer is None
+ assert writer.with_blob_uri_reader_factory(None) is writer
+ assert writer._python_writer is None
writer.write_arrow_batch(_batch([1], ['a']))
builder.new_commit().commit(writer.prepare_commit())
writer.close()
@@ -467,6 +467,8 @@ def
test_advanced_api_switches_before_write_and_rejects_late_switch(tmp_path):
native = table.new_batch_write_builder().new_write()
native.write_arrow_batch(_batch([2], ['a']))
+ with pytest.raises(RuntimeError, match='before any write'):
+ native.with_blob_uri_reader_factory(None)
with pytest.raises(RuntimeError, match='after native data'):
native.with_write_type(['id'])
native.abort()
diff --git a/paimon-python/pypaimon/write/native_write.py
b/paimon-python/pypaimon/write/native_write.py
index de48174194..bdc7a98518 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -110,6 +110,7 @@ class NativeTableWrite:
self._schema = self._table_schema
self._write_cols = None
self._blob_consumer = None
+ self._blob_uri_reader_factory = None
self._restore_snapshot_id = restore_snapshot_id
def _switch_to_python(self):
@@ -131,10 +132,15 @@ class NativeTableWrite:
else:
writer = BatchTableWrite(self.table, self.commit_user,
self.static_partition,
restore_snapshot_id=self._restore_snapshot_id)
+ return self._configure_python_writer(writer)
+
+ def _configure_python_writer(self, writer):
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)
+ if self._blob_uri_reader_factory is not None:
+ writer.with_blob_uri_reader_factory(self._blob_uri_reader_factory)
return writer
def __getattr__(self, name):
@@ -178,6 +184,15 @@ class NativeTableWrite:
self._blob_consumer = blob_consumer
return self
+ def with_blob_uri_reader_factory(self, uri_reader_factory):
+ if self._python_writer is not None:
+ return
self._python_writer.with_blob_uri_reader_factory(uri_reader_factory)
+ if self._written:
+ raise RuntimeError('with_blob_uri_reader_factory must be called
before any write operation.')
+ self._native_writer.with_blob_uri_reader_factory(uri_reader_factory)
+ self._blob_uri_reader_factory = uri_reader_factory
+ return self
+
def write_arrow(self, data, bucket=None):
if self._python_writer is not None:
return self._python_writer.write_arrow(data, bucket)
@@ -276,5 +291,5 @@ class NativePostponeFixedBucketTableWrite(NativeTableWrite):
def _new_python_writer(self):
from pypaimon.write.postpone_batch_table_write import
PostponeFixedBucketBatchTableWrite
- return PostponeFixedBucketBatchTableWrite(
- self.table, self.commit_user, self.static_partition,
self._bucket_plan)
+ return
self._configure_python_writer(PostponeFixedBucketBatchTableWrite(
+ self.table, self.commit_user, self.static_partition,
self._bucket_plan))