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 8dd8549b05 [python] Enable native scalar Blob Arrow writes (#10305)
8dd8549b05 is described below
commit 8dd8549b059a66402b0fd182c3bdf89512e32dc0
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Sep 30 08:35:20 2026 +0800
[python] Enable native scalar Blob Arrow writes (#10305)
---
paimon-python/README.md | 26 +-
paimon-python/pypaimon/tests/blob_table_test.py | 7 +-
paimon-python/pypaimon/tests/blob_test.py | 15 +-
.../tests/data_evolution_row_rolling_test.py | 1 +
.../pypaimon/tests/deferred_blob_resolve_test.py | 10 +-
.../pypaimon/tests/native_blob_write_test.py | 472 +++++++++++++++++++++
.../pypaimon/tests/native_external_write_test.py | 5 +-
paimon-python/pypaimon/tests/native_write_test.py | 49 ++-
paimon-python/pypaimon/tests/ray_sink_test.py | 2 +
paimon-python/pypaimon/write/commit_message.py | 1 +
paimon-python/pypaimon/write/native_write.py | 29 +-
paimon-python/pypaimon/write/table_commit.py | 3 +
.../write/writer/dedicated_format_writer.py | 15 +-
13 files changed, 598 insertions(+), 37 deletions(-)
diff --git a/paimon-python/README.md b/paimon-python/README.md
index a32becf695..246af0693a 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -161,10 +161,11 @@ finally:
The native writer returns ordinary PyPaimon commit messages, so the Python
committer also works when `commit.native.enabled` is false. Batch overwrite and
reusable stream writers retain the builder's commit user and identifier. Native
-write supports Parquet append, primary-key and data-evolution tables without
-BLOB fields or optional data-evolution row sidecars, on the same
filesystem/JDBC
-publication route as native commit. Writer methods requiring Python's
specialized path select the Python
-writer before native data is written. If the runtime or table route is
+write supports Parquet append, primary-key and data-evolution tables, including
+top-level scalar BLOB Arrow columns, on the same filesystem/JDBC publication
+route as native commit. ARRAY/MAP BLOB, video and optional data-evolution row
+sidecars select the Python writer. Writer methods requiring Python's
specialized
+path select the Python writer before native data is written. If the runtime or
table route is
unavailable, write uses Python. Once Rust starts writing a batch, errors
propagate without retrying that batch through Python.
@@ -173,6 +174,23 @@ Native writes honor `data-file.path-directory` and the
configured
when the write destinations change. Python and native readers and committers
can exchange these files, including external data files and their index
sidecars.
+BLOB Arrow values may contain payload bytes or serialized descriptors. Fields
+listed in `blob-descriptor-field` remain inline and require descriptors.
HTTP(S)
+references use decoded response streams, including gzip and deflate. Blob
+files roll by payload size, independently of the normal Parquet files. Python
+`Blob` row objects can provide custom streams or URI readers; `write_row` on a
+BLOB table selects the Python writer before any native data is written.
Switching
+to row writes after native Arrow writes is rejected. Use
`write.native.enabled=false` when
+mixing Arrow batches and Python `Blob` objects in one writer.
+
+An explicit native writer `abort()` also deletes prepared files that have not
+been passed to a PyPaimon committer. Calling `close()` instead releases those
+files to the caller without deleting them; use `commit.abort(messages)` to
+discard them after closing the writer. Once a commit attempt starts, writer
+abort preserves its files even if the attempt raises, because a snapshot may
+already reference them. Stream writers retain cleanup ownership only for
+messages that have not been submitted to a committer.
+
Both native options are disabled by default.
# Native commit
diff --git a/paimon-python/pypaimon/tests/blob_table_test.py
b/paimon-python/pypaimon/tests/blob_table_test.py
index 9a4172f0f9..ae4c5aa032 100755
--- a/paimon-python/pypaimon/tests/blob_table_test.py
+++ b/paimon-python/pypaimon/tests/blob_table_test.py
@@ -295,7 +295,9 @@ class DedicatedFormatWriterTest(unittest.TestCase):
}
)
self.catalog.create_table('test_db.blob_detection_test', schema, False)
- table = self.catalog.get_table('test_db.blob_detection_test')
+ # This test inspects the Python writer's internal column routing.
+ table = self.catalog.get_table('test_db.blob_detection_test').copy(
+ {'write.native.enabled': 'false'})
# Use proper table API to create writer
write_builder = table.new_batch_write_builder()
@@ -2494,7 +2496,8 @@ class DedicatedFormatWriterTest(unittest.TestCase):
table_scan = read_builder.new_scan()
table_read = read_builder.new_read()
splits = table_scan.plan().splits()
- result = table_read.to_arrow(splits)
+ # Scans do not promise an ordering across partitions.
+ result = table_read.to_arrow(splits).sort_by('id')
# Verify the data was read back correctly
self.assertEqual(result.num_rows, 5, "Should have 5 rows")
diff --git a/paimon-python/pypaimon/tests/blob_test.py
b/paimon-python/pypaimon/tests/blob_test.py
index 21f2dc14e7..9beb2829cb 100644
--- a/paimon-python/pypaimon/tests/blob_test.py
+++ b/paimon-python/pypaimon/tests/blob_test.py
@@ -1618,7 +1618,7 @@ class BlobTest(unittest.TestCase):
}))
self.assertEqual(set(), blank_canonical.blob_descriptor_fields())
- def test_dedicated_writer_accepts_exact_v1_descriptor_bytes(self):
+ def test_dedicated_writer_accepts_java_descriptor_prefixes(self):
from pypaimon.write.writer.dedicated_format_writer import (
DedicatedFormatWriter)
@@ -1642,19 +1642,16 @@ class BlobTest(unittest.TestCase):
pa.RecordBatch.from_arrays(
[pa.array([v1], type=pa.large_binary())], names=['payload']))
- padded = pa.RecordBatch.from_arrays(
- [pa.array([v1 + b"x"], type=pa.large_binary())], names=['payload'])
- with self.assertRaisesRegex(ValueError, "trailing bytes"):
- writer._validate_inline_stored_fields_input(padded)
-
+ # Java's known-descriptor parser accepts padding and the older
+ # no-magic layout. Do not apply heuristic exact-length detection here.
v0 = bytes([0]) + v1[1:]
- with self.assertRaisesRegex(ValueError, r"in \[1, 2\], but found 0"):
+ for value in (v0, v1 + b"x", v2 + b"padding"):
writer._validate_inline_stored_fields_input(
pa.RecordBatch.from_arrays(
- [pa.array([v0], type=pa.large_binary())],
names=['payload']))
+ [pa.array([value], type=pa.large_binary())],
names=['payload']))
v3 = bytes([3]) + v2[1:]
- with self.assertRaisesRegex(ValueError, r"in \[1, 2\], but found 3"):
+ with self.assertRaisesRegex(ValueError, "serialized BlobDescriptor"):
writer._validate_inline_stored_fields_input(
pa.RecordBatch.from_arrays(
[pa.array([v3], type=pa.large_binary())],
names=['payload']))
diff --git a/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
index 6f7fb20421..d36da758a4 100644
--- a/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
@@ -208,6 +208,7 @@ class DataEvolutionRowRollingTest(unittest.TestCase):
NotImplementedError, 'row-count based file rolling'):
tw.write_arrow(self._rows(4))
+ @pytest.mark.python_write
def test_blob_writer_supports_target_file_row_num(self):
table = self._create_with_schema(
self.blob_schema,
diff --git a/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
b/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
index ee013017d6..3c8a1a071a 100644
--- a/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
+++ b/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
@@ -285,7 +285,10 @@ class DeferredBlobResolveTest(unittest.TestCase):
read_builder = table.new_read_builder().with_projection(
["sample_id", "payload", "score"]
).with_limit(2)
- splits = read_builder.new_scan().plan().splits()
+ # The reader's remaining-limit behavior needs both partitions, even
+ # when a planner could satisfy the limit with a single partition.
+ splits = sorted(table.new_read_builder().new_scan().plan().splits(),
+ key=lambda split: split.partition.values)
rows = list(read_builder.new_read().to_iterator(splits))
@@ -308,7 +311,10 @@ class DeferredBlobResolveTest(unittest.TestCase):
auth_result = _RejectScoreOneAuthResult()
splits = [
QueryAuthSplit(split, auth_result)
- for split in read_builder.new_scan().plan().splits()
+ # Attach authorization after planning; do not push the reader's
+ # limit into this unauthenticated scan. Order partitions
explicitly.
+ for split in
sorted(table.new_read_builder().new_scan().plan().splits(),
+ key=lambda split: split.partition.values)
]
scores = [
diff --git a/paimon-python/pypaimon/tests/native_blob_write_test.py
b/paimon-python/pypaimon/tests/native_blob_write_test.py
new file mode 100644
index 0000000000..d78167e33a
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_blob_write_test.py
@@ -0,0 +1,472 @@
+# 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 Arrow writes must retain row identity across physical file rolls."""
+
+import io
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.read.native_plan import native_plan
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.table.row.blob import Blob, BlobDescriptor
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.native_write import NativeTableWrite
+
+pytestmark = pytest.mark.native_plan
+_SCHEMA = pa.schema([('id', pa.int32()), ('large', pa.large_binary()),
('small', pa.large_binary())])
+
+
+def _table(tmp_path, options=None):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
+ catalog.create_database('db', True)
+ settings = {'row-tracking.enabled': 'true', 'data-evolution.enabled':
'true',
+ 'write.native.enabled': 'true', 'target-file-row-num': '2',
+ 'blob.target-file-size': '32 B',
'file-index.bloom-filter.columns': 'id',
+ 'file-index.bloom-filter.id.items': '10',
'file-index.in-manifest-threshold': '0 B'}
+ settings.update(options or {})
+ fields = [DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'large', AtomicType('BLOB')), DataField(2, 'small',
AtomicType('BLOB'))]
+ catalog.create_table('db.t', Schema(fields=fields, options=settings),
False)
+ return catalog.get_table('db.t')
+
+
+def _data(start=0):
+ return pa.table({'id': [start, start + 1],
+ 'large': [bytes([start + 1]) * 40, None],
+ 'small': [b'', b'abc']}, schema=_SCHEMA)
+
+
+def _read(table, planner, reader):
+ table = table.copy({'scan.native-plan.enabled': str(planner).lower(),
+ 'read.native.enabled': str(reader).lower()})
+ builder = table.new_read_builder()
+ plan = native_plan(table) if planner else builder.new_scan().plan()
+ read = builder.new_read()
+ with ExitStack() as stack:
+ if reader:
+ stack.enter_context(patch.object(read, '_create_split_read',
+
side_effect=AssertionError('Python read fallback')))
+ return sorted(read.to_arrow(plan.splits()).to_pylist(), key=lambda
row: row['id'])
+
+
+def _physical_files(tmp_path):
+ return {path for path in tmp_path.rglob('*')
+ if path.is_file() and path.suffix in ('.parquet', '.blob',
'.index')}
+
+
[email protected]('external', [False, True])
[email protected]('optimize', [False, True])
[email protected]('native_commit', [False, True])
+def test_native_blob_stream_rolls_and_reuses_writer(tmp_path, external,
optimize, native_commit):
+ options = {'data-evolution.write-cols-optimization.enabled':
str(optimize).lower(),
+ 'commit.native.enabled': str(native_commit).lower()}
+ if external:
+ options.update({'data-file.external-paths': (tmp_path /
'external').as_uri(),
+ 'data-file.external-paths.strategy': 'entropy-inject'})
+ table = _table(tmp_path, options)
+ builder = table.new_stream_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ expected = []
+ try:
+ assert isinstance(writer, NativeTableWrite)
+ for identifier in (1, 2):
+ for start in range((identifier - 1) * 6, identifier * 6, 2):
+ data = _data(start)
+ writer.write_arrow(data)
+ expected.extend(data.to_pylist())
+ messages = writer.prepare_commit(identifier)
+ files = [file for message in messages for file in
message.new_files]
+ normal = [file for file in files if
file.file_name.endswith('.parquet')]
+ assert len(normal) == 3
+ assert all(file.row_count == 2 and file.extra_files for file in
normal)
+ assert all(file.write_cols == (None if optimize else ['id']) for
file in normal)
+ assert all((file.min_sequence_number, file.max_sequence_number) ==
(0, file.row_count - 1)
+ for file in files)
+ assert all(bool(file.external_path) == external for file in files)
+ # Each normal file owns the following Blob files until the next
normal file.
+ groups = []
+ for file in files:
+ if file.file_name.endswith('.parquet'):
+ groups.append({'large': [], 'small': []})
+ else:
+ groups[-1][file.write_cols[0]].append(file.row_count)
+ assert groups == [{'large': [1, 1], 'small': [2]}] * 3
+ commit.commit(messages, identifier)
+ assert writer._python_writer is None
+ for planner in (False, True):
+ for reader in (False, True):
+ assert _read(table, planner, reader) == expected
+ finally:
+ writer.close()
+ commit.close()
+
+
[email protected]('external', [False, True])
[email protected]('action', ['abort', 'close', 'failed-write'])
+def test_native_blob_cleanup_includes_completed_groups(tmp_path, external,
action):
+ options = {}
+ if external:
+ options.update({'data-file.external-paths': (tmp_path /
'external').as_uri(),
+ 'data-file.external-paths.strategy': 'entropy-inject'})
+ table = _table(tmp_path, options)
+ writer = table.new_batch_write_builder().new_write()
+ try:
+ writer.write_arrow(_data())
+ completed = _physical_files(tmp_path)
+ assert {path.suffix for path in completed} == {'.parquet', '.blob',
'.index'}
+ if action == 'failed-write':
+ missing = BlobDescriptor(str(tmp_path / 'missing'), 0,
40).serialize()
+ with pytest.raises(Exception, match='missing'):
+ writer.write_arrow(pa.table({'id': [2], 'large': [missing],
'small': [b'ok']},
+ schema=_SCHEMA))
+ else:
+ # Leave another normal group open when aborting/closing.
+ writer.write_arrow(_data(2).slice(0, 1))
+ getattr(writer, action)()
+ assert not _physical_files(tmp_path)
+ assert table.snapshot_manager().get_latest_snapshot() is None
+ finally:
+ writer.close()
+
+
[email protected]('external', [False, True])
[email protected]('stream', [False, True])
+def test_native_blob_abort_removes_prepared_and_outstanding_files(tmp_path,
external, stream):
+ options = {'data-file.path-directory': 'data/nested'}
+ if external:
+ options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
+ table = _table(tmp_path, options)
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ writer = builder.new_write()
+ try:
+ assert isinstance(writer, NativeTableWrite)
+ for identifier in range(1, 3 if stream else 2):
+ writer.write_arrow(_data(identifier * 2))
+ messages = writer.prepare_commit(identifier) if stream else
writer.prepare_commit()
+ assert messages
+ assert {path.suffix for path in _physical_files(tmp_path)} ==
{'.parquet', '.blob', '.index'}
+ writer.write_arrow(_data(6))
+ writer.abort()
+ writer.abort()
+ assert not _physical_files(tmp_path)
+ assert table.snapshot_manager().get_latest_snapshot() is None
+ finally:
+ writer.close()
+
+
[email protected]('external', [False, True])
+def test_native_blob_close_releases_prepared_files_to_committer(tmp_path,
external):
+ options = {'data-file.external-paths': (tmp_path / 'external').as_uri()}
if external else {}
+ table = _table(tmp_path, options)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(_data())
+ messages = writer.prepare_commit()
+ prepared = _physical_files(tmp_path)
+ assert prepared
+ writer.close()
+ writer.abort()
+ assert _physical_files(tmp_path) == prepared
+ commit.commit(messages)
+ for planner in (False, True):
+ for reader in (False, True):
+ assert _read(table, planner, reader) == _data().to_pylist()
+ finally:
+ writer.close()
+ commit.close()
+
+
[email protected]('external', [False, True])
[email protected]('commit_native_option', [False, True])
+def test_native_blob_stream_abort_preserves_submitted_files(tmp_path,
external, commit_native_option):
+ options = {'commit.native.enabled': str(commit_native_option).lower()}
+ if external:
+ options['data-file.external-paths'] = (tmp_path / 'external').as_uri()
+ table = _table(tmp_path, options)
+ builder = table.new_stream_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(_data())
+ commit.commit(writer.prepare_commit(1), 1)
+ submitted = _physical_files(tmp_path)
+ writer.write_arrow(_data(2))
+ assert writer.prepare_commit(2)
+ writer.write_arrow(_data(4))
+ assert _physical_files(tmp_path) > submitted
+ writer.abort()
+ assert _physical_files(tmp_path) == submitted
+ for planner in (False, True):
+ for reader in (False, True):
+ assert _read(table, planner, reader) == _data().to_pylist()
+ finally:
+ writer.close()
+ commit.close()
+
+
[email protected]('published', [False, True])
+def test_native_blob_abort_preserves_files_after_commit_exception(tmp_path,
published):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(_data())
+ messages = writer.prepare_commit()
+ prepared = _physical_files(tmp_path)
+ original_commit = commit.file_store_commit.commit
+
+ def fail_commit(**kwargs):
+ if published:
+ original_commit(**kwargs)
+ raise RuntimeError('Commit outcome is unknown')
+
+ with patch.object(commit.file_store_commit, 'commit',
side_effect=fail_commit):
+ with pytest.raises(RuntimeError, match='Commit outcome is
unknown'):
+ commit.commit(messages)
+ writer.abort()
+ assert _physical_files(tmp_path) == prepared
+ assert (table.snapshot_manager().get_latest_snapshot() is not None) ==
published
+ if published:
+ assert _read(table, True, True) == _data().to_pylist()
+ finally:
+ writer.close()
+ commit.close()
+
+
+class _StreamingBlob(Blob):
+ def __init__(self, data):
+ self.data = data
+ self.opened = False
+
+ def to_data(self):
+ raise AssertionError('Blob must stay streaming')
+
+ def to_descriptor(self):
+ raise RuntimeError('No descriptor')
+
+ def new_input_stream(self):
+ self.opened = True
+ return io.BytesIO(self.data)
+
+
+def test_blob_object_selects_python_before_writing(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ blob = _StreamingBlob(b'stream')
+ try:
+ assert isinstance(writer, NativeTableWrite)
+ writer.write_row(GenericRow([0, b'bytes', None], table.fields))
+ writer.write_row(GenericRow([1, blob, None], table.fields))
+ writer.write_arrow(_data(2))
+ assert writer._python_writer is not None
+ commit.commit(writer.prepare_commit())
+ assert blob.opened
+ expected = [{'id': 0, 'large': b'bytes', 'small': None},
+ {'id': 1, 'large': b'stream', 'small': None}] +
_data(2).to_pylist()
+ assert _read(table, True, True) == expected
+ finally:
+ writer.close()
+ commit.close()
+
+
+def test_blob_object_cannot_switch_after_native_write(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ blob = _StreamingBlob(b'stream')
+ try:
+ writer.write_arrow(_data())
+ with pytest.raises(RuntimeError, match='after native data was
written'):
+ writer.write_row(GenericRow([2, blob, None], table.fields))
+ assert not blob.opened
+ commit.commit(writer.prepare_commit())
+ assert _read(table, True, True) == _data().to_pylist()
+ finally:
+ writer.close()
+ commit.close()
+
+
[email protected]('native', [False, True])
[email protected]('trailing', [b'', b'padding'])
+def test_inline_descriptors_keep_v1_v2_and_null_semantics(tmp_path, native,
trailing):
+ source = tmp_path / 'source'
+ source.write_bytes(b'prefix-payload-suffix')
+ v2 = BlobDescriptor(str(source), 7, 7).serialize()
+ v1 = bytes([1]) + v2[9:] + trailing
+ v2 += trailing
+ table = _table(tmp_path, {'blob-descriptor-field': 'large',
+ 'write.native.enabled': str(native).lower()})
+ data = pa.table({'id': [0, 1, 2], 'large': [v1, v2, None], 'small': [b'a',
None, b'']}, schema=_SCHEMA)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ assert isinstance(writer, NativeTableWrite) == native
+ writer.write_arrow(data)
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+ expected = data.to_pylist()
+ expected[0]['large'] = expected[1]['large'] = b'payload'
+ for planner in (False, True):
+ for reader in (False, True):
+ assert _read(table, planner, reader) == expected
+ assert _read(table.copy({'blob-as-descriptor': 'true'}), planner,
reader)[0]['large'] == v1
+
+
[email protected]
+def http_blob_uri():
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+ from threading import Thread
+
+ requests = []
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ requests.append(self.path)
+ if self.path == '/large':
+ self.send_response(200)
+ self.send_header('Content-Length', str(7 + 18 * 1024 * 1024))
+ self.end_headers()
+ self.wfile.write(b'prefix-')
+ for _ in range(18):
+ self.wfile.write(b'x' * 1024 * 1024)
+ return
+ if self.path in ('/gzip', '/deflate'):
+ import gzip
+ import zlib
+ payload = b'prefix-payload'
+ body = gzip.compress(payload) if self.path == '/gzip' else
zlib.compress(payload)
+ self.send_response(200)
+ self.send_header('Content-Encoding', self.path[1:])
+ self.send_header('Content-Length', str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+ if self.path == '/missing':
+ self.send_error(404)
+ return
+ # Deliberately ignore Range and omit Content-Length. Native must
+ # stream past the prefix and determine EOF for length=-1.
+ self.send_response(200)
+ self.end_headers()
+ self.wfile.write(b'prefix-payload')
+
+ def log_message(self, *args):
+ pass
+
+ server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+ thread = Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ yield 'http://127.0.0.1:{}'.format(server.server_port), requests
+ finally:
+ server.shutdown()
+ thread.join()
+ server.server_close()
+
+
[email protected]('encoding', ['payload', 'gzip', 'deflate'])
[email protected]('inline', [False, True])
[email protected]('length', [3, -1])
[email protected]('native', [False, True])
+def test_http_descriptors_follow_uri_scheme(tmp_path, http_blob_uri, inline,
length, native, encoding):
+ uri, _ = http_blob_uri
+ options = {'write.native.enabled': str(native).lower()}
+ if inline:
+ options['blob-descriptor-field'] = 'large'
+ table = _table(tmp_path, options)
+ descriptor = BlobDescriptor(uri + '/' + encoding, 7, length).serialize()
+ data = pa.table({'id': [0], 'large': [descriptor], 'small': [None]},
schema=_SCHEMA)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ assert isinstance(writer, NativeTableWrite) == native
+ # Write a preceding local payload to ensure HTTP input works after
+ # native ownership is established, without any late fallback.
+ prior = data if inline else _data(1)
+ writer.write_arrow(prior)
+ writer.write_arrow(data)
+ commit.commit(writer.prepare_commit())
+ if native:
+ assert writer._python_writer is None
+ expected = [{'id': 0, 'large': b'pay' if length == 3 else b'payload',
'small': None}]
+ expected += expected if inline else prior.to_pylist()
+ for planner in (False, True):
+ for reader in (False, True):
+ assert _read(table, planner, reader) == expected
+ finally:
+ writer.close()
+ commit.close()
+
+
+def test_http_descriptor_failure_cleans_native_files(tmp_path, http_blob_uri):
+ uri, _ = http_blob_uri
+ table = _table(tmp_path)
+ writer = table.new_batch_write_builder().new_write()
+ try:
+ writer.write_arrow(_data())
+ descriptor = BlobDescriptor(uri + '/missing', 0, 5).serialize()
+ with pytest.raises(Exception, match='404'):
+ writer.write_arrow(pa.table({'id': [2], 'large': [descriptor],
'small': [None]}, schema=_SCHEMA))
+ assert not _physical_files(tmp_path)
+ finally:
+ writer.close()
+
+
+def test_large_http_descriptor_uses_one_stream_for_all_copy_chunks(tmp_path,
http_blob_uri):
+ uri, requests = http_blob_uri
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ length = 18 * 1024 * 1024 # More than two native 8 MiB copy buffers.
+ descriptor = BlobDescriptor(uri + '/large', 7, length).serialize()
+ try:
+ writer.write_arrow(pa.table({'id': [0], 'large': [descriptor],
'small': [None]}, schema=_SCHEMA))
+ commit.commit(writer.prepare_commit())
+ assert requests == ['/large']
+ assert _read(table, True, True) == [{'id': 0, 'large': b'x' * length,
'small': None}]
+ finally:
+ writer.close()
+ commit.close()
+
+
+def
test_native_blob_row_target_rolls_normal_files_at_batch_boundaries(tmp_path):
+ table = _table(tmp_path, {'target-file-row-num': '3',
'blob.target-file-size': '10 MB'})
+ data = pa.table({'id': list(range(7)), 'large': [b'a'] * 7, 'small':
[None] * 7}, schema=_SCHEMA)
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(data)
+ messages = writer.prepare_commit()
+ files = [file for message in messages for file in message.new_files]
+ assert [file.row_count for file in files if
file.file_name.endswith('.parquet')] == [7]
+ for name in ('large', 'small'):
+ assert [file.row_count for file in files if file.write_cols ==
[name]] == [3, 3, 1]
+ commit.commit(messages)
+ for planner in (False, True):
+ for reader in (False, True):
+ assert _read(table, planner, reader) == data.to_pylist()
+ finally:
+ writer.close()
+ commit.close()
diff --git a/paimon-python/pypaimon/tests/native_external_write_test.py
b/paimon-python/pypaimon/tests/native_external_write_test.py
index f1bed9ddd5..9c45d6fc4c 100644
--- a/paimon-python/pypaimon/tests/native_external_write_test.py
+++ b/paimon-python/pypaimon/tests/native_external_write_test.py
@@ -184,7 +184,7 @@ def
test_external_updates_upserts_deletes_history_and_abort(tmp_path, strategy,
@pytest.mark.parametrize('strategy', ['none', 'round-robin', 'entropy-inject'])
@pytest.mark.parametrize('native', [False, True])
-def test_external_blob_fallback_and_readers(tmp_path, strategy, native):
+def test_external_blob_writer_and_readers(tmp_path, strategy, native):
from pypaimon.schema.data_types import AtomicType, DataField
table = _table(tmp_path, 'evolution', strategy, native, partitioned=False)
catalog = table.catalog_environment.catalog_loader.load()
@@ -196,8 +196,7 @@ def test_external_blob_fallback_and_readers(tmp_path,
strategy, native):
data = pa.table({'id': pa.array([1, 2, 3], pa.int32()),
'first': pa.array([b'hello', None, b''],
pa.large_binary()),
'second': pa.array([None, b'world', b'!'],
pa.large_binary())})
- # Blob row streams still require the Python writer, including when opted
in.
- _write(table, data, False)
+ _write(table, data, native)
for planner in (False, True):
for reader in (False, True):
assert _read(table, planner, reader) == data.to_pylist()
diff --git a/paimon-python/pypaimon/tests/native_write_test.py
b/paimon-python/pypaimon/tests/native_write_test.py
index bfe8b638b4..ea6646e457 100644
--- a/paimon-python/pypaimon/tests/native_write_test.py
+++ b/paimon-python/pypaimon/tests/native_write_test.py
@@ -16,7 +16,7 @@
"""End-to-end coverage of the optional native data writer bridge."""
-from unittest.mock import patch
+from unittest.mock import Mock, patch
import pyarrow as pa
import pytest
@@ -257,6 +257,53 @@ def test_native_overwrite_and_empty_overwrite(tmp_path):
assert _rows(table) == [{'id': 2, 'pt': 'b'}]
+@requires_native
[email protected]('failure', [None, 'before-publication',
'after-publication'])
+def test_writer_abort_preserves_native_commit_files(native_rest_catalog,
failure):
+ native_rest_catalog.create_table('default.abort_handoff',
Schema.from_pyarrow_schema(
+ pa.schema([('id', pa.int64()), ('pt', pa.string())]), options={
+ 'write.native.enabled': 'true', 'commit.native.enabled': 'true',
+ }), False)
+ table = native_rest_catalog.get_table('default.abort_handoff')
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ assert isinstance(writer, NativeTableWrite)
+ writer.write_arrow_batch(_batch([1, 2], ['a', 'b']))
+ messages = writer.prepare_commit()
+ prepared = commit._prepare_native_commit(messages)
+ assert prepared is not None
+ native, native_messages = prepared
+ proxy = Mock(wraps=native)
+
+ def publish(*args):
+ if failure != 'before-publication':
+ native.commit(*args)
+ if failure:
+ raise RuntimeError('Native commit outcome is unknown')
+
+ proxy.commit.side_effect = publish
+ with patch.object(commit, '_prepare_native_commit',
return_value=(proxy, native_messages)), \
+ patch.object(commit.file_store_commit, 'commit',
+ side_effect=AssertionError('Python commit
fallback')):
+ if failure:
+ with pytest.raises(RuntimeError, match='Native commit outcome
is unknown'):
+ commit.commit(messages)
+ else:
+ commit.commit(messages)
+ proxy.commit.assert_called_once()
+ writer.abort()
+ assert all(table.file_io.exists(file.file_path)
+ for message in messages for file in message.new_files)
+ if failure != 'before-publication':
+ assert _rows(table) == [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}]
+ else:
+ assert table.snapshot_manager().get_latest_snapshot() is None
+ finally:
+ writer.close()
+ commit.close()
+
+
@requires_native
def test_advanced_api_switches_before_write_and_rejects_late_switch(tmp_path):
table = _table(tmp_path)
diff --git a/paimon-python/pypaimon/tests/ray_sink_test.py
b/paimon-python/pypaimon/tests/ray_sink_test.py
index 72c565828e..6153b37014 100644
--- a/paimon-python/pypaimon/tests/ray_sink_test.py
+++ b/paimon-python/pypaimon/tests/ray_sink_test.py
@@ -21,6 +21,7 @@ import unittest
from unittest.mock import Mock, patch
import pyarrow as pa
+import pytest
from ray.data._internal.execution.interfaces import TaskContext
from pypaimon import CatalogFactory, Schema
@@ -361,6 +362,7 @@ class RaySinkTest(unittest.TestCase):
self.assertEqual({0, 1}, {message.bucket for message in messages})
self.assertEqual({2}, {message.total_buckets for message in messages})
+ @pytest.mark.python_write
def
test_write_does_not_return_prepared_messages_when_dedicated_close_aborts(self):
from pypaimon.write.writer.dedicated_format_writer import
DedicatedFormatWriter
diff --git a/paimon-python/pypaimon/write/commit_message.py
b/paimon-python/pypaimon/write/commit_message.py
index 1325260cd2..0a25ba5cf4 100644
--- a/paimon-python/pypaimon/write/commit_message.py
+++ b/paimon-python/pypaimon/write/commit_message.py
@@ -41,6 +41,7 @@ class CommitMessage:
compact_changelog_files: List[DataFileMeta] = field(default_factory=list)
compact_index_adds: List['IndexManifestEntry'] =
field(default_factory=list)
compact_index_deletes: List['IndexManifestEntry'] =
field(default_factory=list)
+ _native_write_pending: bool = field(default=False, init=False, repr=False,
compare=False)
def is_empty(self):
return (
diff --git a/paimon-python/pypaimon/write/native_write.py
b/paimon-python/pypaimon/write/native_write.py
index f01c636887..8e00e13171 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -22,8 +22,9 @@ import pyarrow as pa
from pypaimon.common.options.core_options import MergeEngine
from pypaimon.schema.arrow_schema import arrow_schemas_compatible,
normalize_arrow_strings
-from pypaimon.schema.data_types import PyarrowFieldParser, is_blob_file_field
+from pypaimon.schema.data_types import PyarrowFieldParser, is_blob_file_field,
is_blob_type
from pypaimon.table.bucket_mode import BucketMode
+from pypaimon.write.file_store_commit import _abort_commit_messages
from pypaimon.write.native_commit import (
create_native_write_table, from_native_commit_messages,
)
@@ -81,7 +82,10 @@ def create_native_write(table, commit_user,
static_partition=None, stream=False)
or not _native_map_layouts_supported(table, schema)
# Rust cannot encode these partition keys yet.
or not _native_partition_types_supported(schema,
table.partition_keys)
- or any(is_blob_file_field(field) for field in
table.table_schema.fields)):
+ # Native dedicated files currently support top-level scalar Blob
fields.
+ or table.options.video_frame_fields()
+ or any(is_blob_file_field(field) and not is_blob_type(field.type)
+ for field in table.table_schema.fields)):
return None
native_table = create_native_write_table(table)
if native_table is None:
@@ -112,6 +116,7 @@ class NativeTableWrite:
self._native_writer = native_writer
self._python_writer = None
self._written = False
+ self._prepared_messages = []
self._schema =
PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
def _switch_to_python(self):
@@ -169,6 +174,10 @@ class NativeTableWrite:
def write_row(self, row):
if self._python_writer is not None:
return self._python_writer.write_row(row)
+ if any(is_blob_file_field(field) for field in
self.table.table_schema.fields):
+ # Select the row-aware writer from the first row, including byte
+ # values: later rows may provide custom Blob streams or URI
readers.
+ return self._switch_to_python().write_row(row)
values = row_to_named_values(row, self.table.table_schema.fields)
names = list(self.table.field_names)
self.write_arrow_batch(row_values_to_arrow_table(
@@ -187,7 +196,13 @@ class NativeTableWrite:
if commit_identifier is not None:
raise TypeError('BatchTableWrite.prepare_commit accepts no
identifier')
messages = self._native_writer.prepare_commit()
- return from_native_commit_messages(self.table, messages)
+ messages = from_native_commit_messages(self.table, messages)
+ self._prepared_messages = [message for message in
self._prepared_messages
+ if message._native_write_pending]
+ for message in messages:
+ message._native_write_pending = True
+ self._prepared_messages.extend(messages)
+ return messages
def close(self):
if self._python_writer is not None:
@@ -195,9 +210,15 @@ class NativeTableWrite:
elif self._native_writer is not None:
self._native_writer.close()
self._native_writer = None
+ self._prepared_messages.clear()
def abort(self):
if self._python_writer is not None:
self._python_writer.abort()
else:
- self.close()
+ messages = [message for message in self._prepared_messages
+ if message._native_write_pending]
+ try:
+ self.close()
+ finally:
+ _abort_commit_messages(self.table, messages)
diff --git a/paimon-python/pypaimon/write/table_commit.py
b/paimon-python/pypaimon/write/table_commit.py
index ef2595d93c..486dcbcff7 100644
--- a/paimon-python/pypaimon/write/table_commit.py
+++ b/paimon-python/pypaimon/write/table_commit.py
@@ -66,6 +66,9 @@ class TableCommit:
commit_messages: List[CommitMessage],
commit_identifier: int = BATCH_COMMIT_IDENTIFIER,
snapshot_properties: Optional[Dict[str, str]] = None):
+ """Release native-writer cleanup ownership before publication can
start."""
+ for message in commit_messages:
+ message._native_write_pending = False
non_empty_messages = [msg for msg in commit_messages if not
msg.is_empty()]
commit_kwargs = {
"commit_messages": non_empty_messages,
diff --git a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
index 98fc208bef..dd0bb4096a 100644
--- a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
+++ b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
@@ -403,14 +403,9 @@ class DedicatedFormatWriter(CompositeDataWriter):
"BlobDescriptor."
)
descriptor_bytes = bytes(value)
- if descriptor_bytes:
- version = descriptor_bytes[0]
- if version < 1 or version > BlobDescriptor.CURRENT_VERSION:
- raise ValueError(
- f"blob-descriptor-field requires BlobDescriptor
version "
- f"in [1, {BlobDescriptor.CURRENT_VERSION}], but
found "
- f"{version}."
- )
+ # Like Java, schema-declared descriptors use the prefix parser.
+ # Exact wire length is only needed when detecting descriptors
+ # among arbitrary payload bytes.
try:
BlobDescriptor.deserialize(descriptor_bytes)
except Exception as e:
@@ -418,10 +413,6 @@ class DedicatedFormatWriter(CompositeDataWriter):
"blob-descriptor-field requires blob field value to be
a serialized "
"BlobDescriptor."
) from e
- # serialize() always emits CURRENT_VERSION, so a round-trip
- # would reject exact v1 bytes. Check exact wire length instead.
- if BlobDescriptor.parse_if_serialized(descriptor_bytes) is
None:
- raise ValueError("Descriptor payload contains trailing
bytes.")
for field_name in self.blob_view_fields:
if field_name not in data.schema.names: