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 547c720e4a [python] Add opt-in native writes to PyPaimon (#10154)
547c720e4a is described below
commit 547c720e4a04574ec60db369b4177e53dbb8e2be
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Sep 24 11:00:52 2026 +0800
[python] Add opt-in native writes to PyPaimon (#10154)
---
paimon-python/README.md | 40 ++++-
.../pypaimon/common/options/core_options.py | 12 ++
paimon-python/pypaimon/tests/native_write_test.py | 164 +++++++++++++++++++++
paimon-python/pypaimon/write/native_commit.py | 27 ++--
paimon-python/pypaimon/write/native_write.py | 162 ++++++++++++++++++++
paimon-python/pypaimon/write/write_builder.py | 21 ++-
6 files changed, 409 insertions(+), 17 deletions(-)
diff --git a/paimon-python/README.md b/paimon-python/README.md
index 3295d70df3..96f7ba0d9f 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -119,6 +119,36 @@ table = table.copy({"parquet.filter.columnindex.enabled":
"false"})
Unsupported reads use the normal path. Reading fewer bytes may require more
object-store requests.
+# Native write and commit
+
+PyPaimon can write Arrow batches through the optional `pypaimon-rust` runtime.
+Enable it on a table independently of native commit:
+
+```python
+native_table = table.copy({"write.native.enabled": "true",
+ "commit.native.enabled": "true"})
+builder = native_table.new_batch_write_builder()
+writer, commit = builder.new_write(), builder.new_commit()
+try:
+ writer.write_arrow(data)
+ commit.commit(writer.prepare_commit())
+finally:
+ writer.close()
+ commit.close()
+```
+
+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 is currently limited to Parquet tables without BLOB fields or
+data-evolution mode, 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
+unavailable, write uses Python. Once Rust starts writing a batch, errors
+propagate without retrying that batch through Python.
+
+Both native options are disabled by default.
+
# Native commit
PyPaimon can submit append and batch overwrite commits through the optional
@@ -136,11 +166,11 @@ finally:
commit.close()
```
-The Python writer still produces files. Its commit messages cross the Java v14
-wire format into `CommitMessage.deserialize()` and are committed by Rust. Batch
-and stream append commits retain the Python builder's commit user, identifier,
-empty-commit option, and batch one-shot lifecycle. Explicit abort also supports
-native cleanup of uncommitted files.
+When only native commit is enabled, the Python writer produces files. Its
commit
+messages cross the Java v14 wire format into `CommitMessage.deserialize()` and
+are committed by Rust. Batch and stream append commits retain the Python
+builder's commit user, identifier, empty-commit option, and batch one-shot
+lifecycle. Explicit abort also supports native cleanup of uncommitted files.
For batch overwrite, configure the Python builder as usual:
diff --git a/paimon-python/pypaimon/common/options/core_options.py
b/paimon-python/pypaimon/common/options/core_options.py
index ee7cb43d7c..84fd675349 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -685,6 +685,15 @@ class CoreOptions:
"operations use Python before any native commit is
attempted.")
)
+ WRITE_NATIVE_ENABLED: ConfigOption[bool] = (
+ ConfigOptions.key("write.native.enabled")
+ .boolean_type()
+ .default_value(False)
+ .with_description("Write Arrow data via pypaimon_rust when the table
and "
+ "writer API are supported. Unsupported routes use
Python "
+ "before any native data is written.")
+ )
+
CHANGELOG_PRODUCER: ConfigOption[ChangelogProducer] = (
ConfigOptions.key("changelog-producer")
.enum_type(ChangelogProducer)
@@ -1603,6 +1612,9 @@ class CoreOptions:
def native_commit_enabled(self, default=None):
return self.options.get(CoreOptions.COMMIT_NATIVE_ENABLED, default)
+ def native_write_enabled(self, default=None):
+ return self.options.get(CoreOptions.WRITE_NATIVE_ENABLED, default)
+
def changelog_producer(self, default=None):
return self.options.get(CoreOptions.CHANGELOG_PRODUCER, default)
diff --git a/paimon-python/pypaimon/tests/native_write_test.py
b/paimon-python/pypaimon/tests/native_write_test.py
new file mode 100644
index 0000000000..7224dbe09a
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_write_test.py
@@ -0,0 +1,164 @@
+# 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.
+
+"""End-to-end coverage of the optional native data writer bridge."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.options.options import Options
+from pypaimon.write.native_write import NativeTableWrite,
native_write_available
+
+
+requires_native = pytest.mark.skipif(
+ not native_write_available(), reason='pypaimon-rust writer required')
+
+
+def _table(tmp_path, primary_key=False, commit_native=True):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('default', True)
+ options = {'file.format': 'parquet', 'write.native.enabled': 'true',
+ 'commit.native.enabled': str(commit_native).lower()}
+ if primary_key:
+ options['bucket'] = '1'
+ catalog.create_table('default.t', Schema.from_pyarrow_schema(
+ pa.schema([('id', pa.int64()), ('pt', pa.string())]),
+ options=options, primary_keys=['id'] if primary_key else [],
+ partition_keys=[] if primary_key else ['pt']), False)
+ return catalog.get_table('default.t')
+
+
+def _batch(ids, partitions):
+ return pa.record_batch(
+ [pa.array(ids, pa.int64()), pa.array(partitions, pa.string())],
+ names=['id', 'pt'])
+
+
+def _rows(table):
+ builder = table.new_read_builder()
+ return sorted(builder.new_read().to_arrow(
+ builder.new_scan().plan().splits()).to_pylist(), key=lambda row:
row['id'])
+
+
+def test_native_write_is_opt_in():
+ assert not CoreOptions(Options({})).native_write_enabled()
+ assert CoreOptions(Options({'write.native.enabled':
'true'})).native_write_enabled()
+
+
+@requires_native
[email protected]('primary_key', [False, True])
[email protected]('commit_native', [False, True])
+def test_batch_native_write_commits_through_both_committers(
+ tmp_path, primary_key, commit_native):
+ table = _table(tmp_path, primary_key, commit_native)
+ builder = table.new_batch_write_builder()
+ writer = builder.new_write()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ writer.write_arrow_batch(_batch([1], ['a']))
+ writer.write_arrow(pa.Table.from_batches([_batch([2], ['b'])]))
+ messages = writer.prepare_commit()
+ assert messages and sum(file.row_count for msg in messages
+ for file in msg.new_files) == 2
+ commit = builder.new_commit()
+ try:
+ commit.commit(messages)
+ finally:
+ commit.close()
+ finally:
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'pt': 'a'}, {'id': 2, 'pt': 'b'}]
+ assert table.snapshot_manager().get_latest_snapshot().commit_user ==
builder.commit_user
+
+
+@requires_native
+def test_stream_native_write_reuses_writer_across_checkpoints(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_stream_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ assert isinstance(writer, NativeTableWrite)
+ try:
+ for identifier in (7, 8):
+ writer.write_arrow_batch(_batch([identifier], ['a']))
+ commit.commit(writer.prepare_commit(identifier), identifier)
+ assert _rows(table) == [{'id': 7, 'pt': 'a'}, {'id': 8, 'pt': 'a'}]
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ assert snapshot.commit_user == builder.commit_user
+ assert snapshot.commit_identifier == 8
+ finally:
+ writer.close()
+ commit.close()
+
+
+@requires_native
+def test_native_overwrite_and_empty_overwrite(tmp_path):
+ table = _table(tmp_path)
+ seed = table.new_batch_write_builder()
+ seed_writer = seed.new_write()
+ seed_writer.write_arrow_batch(_batch([1, 2], ['a', 'b']))
+ seed.new_commit().commit(seed_writer.prepare_commit())
+ seed_writer.close()
+
+ overwrite = table.new_batch_write_builder().overwrite({'pt': 'a'})
+ writer = overwrite.new_write()
+ assert isinstance(writer, NativeTableWrite)
+ writer.write_arrow_batch(_batch([3], ['a']))
+ overwrite.new_commit().commit(writer.prepare_commit())
+ writer.close()
+ assert _rows(table) == [{'id': 2, 'pt': 'b'}, {'id': 3, 'pt': 'a'}]
+
+ static_table = table.copy({'dynamic-partition-overwrite': 'false'})
+ empty = static_table.new_batch_write_builder().overwrite({'pt': 'a'})
+ empty_writer = empty.new_write()
+ assert isinstance(empty_writer, NativeTableWrite)
+ empty.new_commit().commit(empty_writer.prepare_commit())
+ empty_writer.close()
+ assert _rows(table) == [{'id': 2, 'pt': 'b'}]
+
+
+@requires_native
+def test_advanced_api_switches_before_write_and_rejects_late_switch(tmp_path):
+ table = _table(tmp_path)
+ builder = table.new_batch_write_builder()
+ writer = builder.new_write()
+ assert isinstance(writer, NativeTableWrite)
+ assert writer.with_write_type(['id', 'pt']) is writer._python_writer
+ assert writer._native_writer is None
+ writer.write_arrow_batch(_batch([1], ['a']))
+ builder.new_commit().commit(writer.prepare_commit())
+ writer.close()
+ assert _rows(table) == [{'id': 1, 'pt': 'a'}]
+
+ native = table.new_batch_write_builder().new_write()
+ native.write_arrow_batch(_batch([2], ['a']))
+ with pytest.raises(RuntimeError, match='after native data'):
+ native.with_write_type(['id'])
+ native.abort()
+
+
+@requires_native
+def
test_unavailable_native_writer_falls_back_before_table_reconstruction(tmp_path):
+ table = _table(tmp_path)
+ with patch('pypaimon.write.native_write.native_write_available',
return_value=False), \
+ patch('pypaimon.write.native_write.create_native_write_table',
+ side_effect=AssertionError('must not reconstruct')):
+ writer = table.new_batch_write_builder().new_write()
+ assert not isinstance(writer, NativeTableWrite)
+ writer.close()
diff --git a/paimon-python/pypaimon/write/native_commit.py
b/paimon-python/pypaimon/write/native_commit.py
index 97d5316582..7c54ccb851 100644
--- a/paimon-python/pypaimon/write/native_commit.py
+++ b/paimon-python/pypaimon/write/native_commit.py
@@ -52,14 +52,28 @@ def native_messages_supported(table, messages) -> bool:
def create_native_commit(table, commit_user, overwrite_partition=None):
"""Return a native committer only when its publication protocol matches
Python."""
+ if not native_commit_available():
+ return None
+ native_table = create_native_write_table(table)
+ if native_table is None:
+ return None
+ if overwrite_partition is not None:
+ return (native_table.new_batch_write_builder()
+ ._with_commit_user(commit_user)
+ .with_overwrite(overwrite_partition).new_commit())
+ # Append commits use the stream committer with the Python writer's
identity;
+ # Python enforces each mode's lifecycle and empty-commit rules.
+ return
native_table.new_stream_write_builder().with_commit_user(commit_user).new_commit()
+
+
+def create_native_write_table(table):
+ """Reconstruct a resolved table only for the filesystem publication
route."""
from pypaimon.catalog.catalog_environment import CatalogEnvironment
from pypaimon.filesystem.local_file_io import LocalFileIO
from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
from pypaimon.table.file_store_table import FileStoreTable
- if not native_commit_available():
- return None
# Native branch writes are not supported. Catalog-backed publication (REST
# or custom version management) must continue through Python's environment.
environment = table.catalog_environment
@@ -84,18 +98,11 @@ def create_native_commit(table, commit_user,
overwrite_partition=None):
table.options.dynamic_partition_overwrite())
options['snapshot.ignore-empty-commit'] = _option_value_to_string(
table.options.snapshot_ignore_empty_commit())
- native_table = NativeTable.from_resolved_schema(
+ return NativeTable.from_resolved_schema(
table.table_path,
JSON.to_json(table.table_schema.copy(new_options=options)),
database=table.identifier.get_database_name(),
table=table.identifier.get_table_name(),
options=file_io_options)
- if overwrite_partition is not None:
- return (native_table.new_batch_write_builder()
- ._with_commit_user(commit_user)
- .with_overwrite(overwrite_partition).new_commit())
- # Append commits use the stream committer with the Python writer's
identity;
- # Python enforces each mode's lifecycle and empty-commit rules.
- return
native_table.new_stream_write_builder().with_commit_user(commit_user).new_commit()
def to_native_commit_messages(table, messages):
diff --git a/paimon-python/pypaimon/write/native_write.py
b/paimon-python/pypaimon/write/native_write.py
new file mode 100644
index 0000000000..fe2952ed0c
--- /dev/null
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -0,0 +1,162 @@
+# 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.
+
+"""Optional Rust data writer behind PyPaimon's batch and stream builders."""
+
+import pyarrow as pa
+
+from pypaimon.read.native_plan import native_method_available
+from pypaimon.schema.arrow_schema import normalize_arrow_strings
+from pypaimon.schema.data_types import PyarrowFieldParser, is_blob_file_field
+from pypaimon.write.commit_message_serializer import deserialize_commit_message
+from pypaimon.write.native_commit import create_native_write_table
+from pypaimon.write.row_utils import row_to_named_values,
row_values_to_arrow_table
+
+
+def native_write_available() -> bool:
+ """Check every binding entry point used by the writer bridge."""
+ return all(native_method_available(type_name, method) for type_name,
method in (
+ ('Table', 'from_resolved_schema'),
+ ('BatchWriteBuilder', '_with_commit_user'),
+ ('BatchWriteBuilder', 'with_overwrite'),
+ ('BatchTableWrite', 'write_arrow'),
+ ('BatchTableWrite', 'prepare_commit'),
+ ('StreamWriteBuilder', 'with_commit_user'),
+ ('StreamTableWrite', 'write_arrow'),
+ ('StreamTableWrite', 'prepare_commit'),
+ ('CommitMessage', 'serialize'),
+ ))
+
+
+def create_native_write(table, commit_user, static_partition=None,
stream=False):
+ """Return a native writer if the table can use the filesystem write
path."""
+ if (not native_write_available()
+ or table.options.data_evolution_enabled()
+ or table.options.file_format() != 'parquet'
+ or any(is_blob_file_field(field) for field in
table.table_schema.fields)):
+ return None
+ native_table = create_native_write_table(table)
+ if native_table is None:
+ return None
+ if stream:
+ builder =
native_table.new_stream_write_builder().with_commit_user(commit_user)
+ else:
+ builder =
native_table.new_batch_write_builder()._with_commit_user(commit_user)
+ if static_partition is not None:
+ builder = builder.with_overwrite(static_partition)
+ return NativeTableWrite(table, commit_user, static_partition, stream,
+ builder.new_write())
+
+
+class NativeTableWrite:
+ """Use Rust for Arrow batches while retaining PyPaimon's commit-message
API.
+
+ Advanced Python writer methods switch to the Python writer before the first
+ native data write. Once Rust has written data, switching would split one
+ logical write across two writers, so it is rejected.
+ """
+
+ def __init__(self, table, commit_user, static_partition, stream,
native_writer):
+ self.table = table
+ self.commit_user = commit_user
+ self.static_partition = static_partition
+ self.stream = stream
+ self._native_writer = native_writer
+ self._python_writer = None
+ self._written = False
+
+ def _switch_to_python(self):
+ if self._python_writer is not None:
+ return self._python_writer
+ if self._written:
+ raise RuntimeError(
+ 'Cannot switch to the Python writer after native data was
written')
+ self._native_writer.close()
+ self._native_writer = None
+ from pypaimon.write.table_write import BatchTableWrite,
StreamTableWrite
+ if self.stream:
+ self._python_writer = StreamTableWrite(self.table,
self.commit_user)
+ else:
+ self._python_writer = BatchTableWrite(
+ self.table, self.commit_user, self.static_partition)
+ return self._python_writer
+
+ def __getattr__(self, name):
+ if name.startswith('_'):
+ raise AttributeError(name)
+ return getattr(self._switch_to_python(), name)
+
+ def write_arrow(self, data):
+ if self._python_writer is not None:
+ return self._python_writer.write_arrow(data)
+ if isinstance(data, pa.RecordBatch):
+ return self.write_arrow_batch(data)
+ for batch in data.to_batches():
+ self.write_arrow_batch(batch)
+
+ def write_arrow_batch(self, data):
+ if self._python_writer is not None:
+ return self._python_writer.write_arrow_batch(data)
+ data = normalize_arrow_strings(data)
+ if data.num_rows:
+ # A failed native write may already have produced files. Never
+ # retry that batch through Python after this point.
+ self._written = True
+ self._native_writer.write_arrow(data)
+
+ def write_pandas(self, dataframe):
+ if self._python_writer is not None:
+ return self._python_writer.write_pandas(dataframe)
+ schema =
PyarrowFieldParser.from_paimon_schema(self.table.table_schema.fields)
+ self.write_arrow_batch(pa.RecordBatch.from_pandas(dataframe,
schema=schema))
+
+ def write_row(self, row):
+ if self._python_writer is not None:
+ return self._python_writer.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(
+ values, self.table.table_schema.fields, names).to_batches()[0])
+
+ def prepare_commit(self, commit_identifier=None):
+ if self._python_writer is not None:
+ if self.stream:
+ return self._python_writer.prepare_commit(commit_identifier)
+ return self._python_writer.prepare_commit()
+ if self.stream:
+ if commit_identifier is None:
+ raise TypeError('StreamTableWrite.prepare_commit requires an
identifier')
+ messages = self._native_writer.prepare_commit(True,
commit_identifier)
+ else:
+ if commit_identifier is not None:
+ raise TypeError('BatchTableWrite.prepare_commit accepts no
identifier')
+ messages = self._native_writer.prepare_commit()
+ return [deserialize_commit_message(
+ message.serialize(), self.table.partition_keys_fields,
+ self.table.trimmed_primary_keys_fields) for message in messages]
+
+ def close(self):
+ if self._python_writer is not None:
+ self._python_writer.close()
+ elif self._native_writer is not None:
+ self._native_writer.close()
+ self._native_writer = None
+
+ def abort(self):
+ if self._python_writer is not None:
+ self._python_writer.abort()
+ else:
+ self.close()
diff --git a/paimon-python/pypaimon/write/write_builder.py
b/paimon-python/pypaimon/write/write_builder.py
index 1c64d616e2..5180b8b08f 100644
--- a/paimon-python/pypaimon/write/write_builder.py
+++ b/paimon-python/pypaimon/write/write_builder.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+import logging
import uuid
from abc import ABC
from typing import Optional
@@ -26,6 +27,8 @@ from pypaimon.write.table_update import (BatchTableUpdate,
StreamTableUpdate,
from pypaimon.write.table_write import (BatchTableWrite, StreamTableWrite,
TableWrite)
+logger = logging.getLogger(__name__)
+
class WriteBuilder(ABC):
def __init__(self, table):
@@ -50,6 +53,18 @@ class WriteBuilder(ABC):
else:
return str(uuid.uuid4())
+ def _native_write(self, static_partition=None, stream=False):
+ if not self.table.options.native_write_enabled():
+ return None
+ try:
+ from pypaimon.write.native_write import create_native_write
+ return create_native_write(self.table, self.commit_user,
+ static_partition, stream)
+ except Exception as error:
+ # Construction has not written any data; the normal writer is safe.
+ logger.debug('Native writer preparation failed; using Python: %s',
error)
+ return None
+
class BatchWriteBuilder(WriteBuilder):
@@ -62,7 +77,8 @@ class BatchWriteBuilder(WriteBuilder):
return self
def new_write(self) -> BatchTableWrite:
- return BatchTableWrite(self.table, self.commit_user,
self.static_partition)
+ return (self._native_write(self.static_partition)
+ or BatchTableWrite(self.table, self.commit_user,
self.static_partition))
def new_update(self) -> BatchTableUpdate:
return BatchTableUpdate(self.table, self.commit_user)
@@ -75,7 +91,8 @@ class BatchWriteBuilder(WriteBuilder):
class StreamWriteBuilder(WriteBuilder):
def new_write(self) -> StreamTableWrite:
- return StreamTableWrite(self.table, self.commit_user)
+ return (self._native_write(stream=True)
+ or StreamTableWrite(self.table, self.commit_user))
def new_update(self) -> StreamTableUpdate:
return StreamTableUpdate(self.table, self.commit_user)