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 53b77402cf [Python] Ignore inactive format options when selecting 
native mutations (#10656)
53b77402cf is described below

commit 53b77402cf8b97211341ff6c8994dbfb46d31cda
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 19 18:49:04 2026 +0800

    [Python] Ignore inactive format options when selecting native mutations 
(#10656)
---
 docs/docs/pypaimon/writing.md                      |   7 +
 .../native_update_inactive_vector_option_test.py   | 172 +++++++++++++++++++++
 .../tests/native_write_inactive_options_test.py    | 150 ++++++++++++++++++
 paimon-python/pypaimon/write/native_update.py      |   5 +-
 paimon-python/pypaimon/write/native_write.py       |   8 +-
 5 files changed, 338 insertions(+), 4 deletions(-)

diff --git a/docs/docs/pypaimon/writing.md b/docs/docs/pypaimon/writing.md
index e84209ef6c..6014a8a3b9 100644
--- a/docs/docs/pypaimon/writing.md
+++ b/docs/docs/pypaimon/writing.md
@@ -104,6 +104,13 @@ Whole-partition predicate deletes can use Native on 
primary-key tables configure
 with input changelogs. Native upserts also accept fully NULL source key columns
 with a different physical type, including selected NULL dictionary values.
 
+Native selection checks the formats used by the operation. An unused
+`changelog-file.format` does not prevent Native Parquet writes when no INPUT
+changelog is produced, including batch overwrite. Likewise, 
`vector.file.format`
+does not prevent Native updates on tables without VECTOR fields. These 
operations
+continue to write Parquet/BLOB files; an active dedicated format must still be
+supported by the Native path.
+
 `write_ray()` uses the shared Python coordinator and lets workers select Native
 writers. For postpone fixed-bucket writes, overwrite plans use the new input
 unless an explicit bucket plan was supplied. A complete primary-key
diff --git 
a/paimon-python/pypaimon/tests/native_update_inactive_vector_option_test.py 
b/paimon-python/pypaimon/tests/native_update_inactive_vector_option_test.py
new file mode 100644
index 0000000000..fe4ae85f7b
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_update_inactive_vector_option_test.py
@@ -0,0 +1,172 @@
+# 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.
+
+"""An unused vector format must not disable Parquet/Blob Native mutations."""
+
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pyarrow.compute as pc
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.table.data_evolution_merge_into import WhenMatched, 
WhenNotMatched, source_col
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.tests import native_plan_rest_test
+from pypaimon.write.native_update import _native_update_formats_supported
+from pypaimon.write.table_delete import TableDeleteByRowId
+from pypaimon.write.table_update import TableUpdate
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+from pypaimon.write.table_upsert_by_key import TableUpsertByKey
+
+
+rest_catalog = native_plan_rest_test.rest_catalog
+pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
+_NATIVE = pytest.mark.skipif(not native_runtime_available(), 
reason='pypaimon-rust not installed')
+_ENGINES = [False, pytest.param(True, marks=_NATIVE)]
+_SCHEMA = pa.schema([('id', pa.int32()), ('pt', pa.string()), ('value', 
pa.int32()),
+                     ('payload', pa.large_binary())])
+_ROWS = [dict(id=i, pt='a', value=i * 10, payload=('blob-%d' % i).encode()) 
for i in range(1, 4)]
+
+
[email protected]('vector,format_,expected', [
+    (False, 'orc', True), (False, 'parquet', True),
+    (True, 'orc', False), (True, 'parquet', True),
+])
+def test_update_format_gate_checks_only_active_vector_fields(tmp_path, vector, 
format_, expected):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+    schema = (_SCHEMA.append(pa.field('embedding', pa.list_(pa.float32(), 2))) 
if vector else _SCHEMA)
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, 
options={
+        'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
+        'file.format': 'parquet', 'vector.file.format': format_,
+    }), False)
+    assert _native_update_formats_supported(catalog.get_table('default.t')) is 
expected
+
+
+def _write(table, rows):
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(pa.Table.from_pylist(rows, schema=_SCHEMA))
+        messages = writer.prepare_commit()
+        assert all(file.file_name.endswith(('.parquet', '.blob'))
+                   and '.vector.' not in file.file_name for message in 
messages for file in message.new_files)
+        commit.commit(messages)
+    finally:
+        writer.close()
+        commit.close()
+
+
+def _read(table):
+    table = table.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'})
+    builder = table.new_read_builder().with_projection(table.field_names + 
['_ROW_ID'])
+    return 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('id').to_pylist()
+
+
+def _native_only(native):
+    stack = ExitStack()
+    if native:
+        for owner, method in [(TableUpdateByRowId, 
'_load_existing_files_info'),
+                              (TableUpsertByKey, '_upsert_partition'),
+                              (TableUpsertByKey, '_upsert_row_partition'),
+                              (TableUpdate, '_build_predicate_update_table'),
+                              (TableDeleteByRowId, '_scan_anchor_ranges')]:
+            stack.enter_context(patch.object(owner, method, 
side_effect=AssertionError('Python mutation fallback')))
+        
stack.enter_context(patch('pypaimon.table.data_evolution_merge_into._build_tables',
+                                  side_effect=AssertionError('Python MERGE 
fallback')))
+    return stack
+
+
[email protected]('native', _ENGINES, ids=['python', 'native'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
[email protected]('operation', ['row-id', 'predicate', 'callable', 
'upsert', 'row-upsert',
+                                       'merge', 'merge-table', 'row-delete', 
'predicate-delete'])
+def test_inactive_vector_option_preserves_parquet_blob_mutations(rest_catalog, 
native, stream, operation):
+    catalog, _ = rest_catalog
+    options = {
+        'data-evolution.enabled': 'true', 'row-tracking.enabled': 'true',
+        'deletion-vectors.enabled': 'true', 'file.format': 'parquet', 
'vector.file.format': 'orc',
+        'write.native.enabled': 'false', 'commit.native.enabled': 'false',
+        'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false',
+    }
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        _SCHEMA, partition_keys=['pt'], options=options), False)
+    table = catalog.get_table('default.t')
+    _write(table, _ROWS)
+    before = _read(table)
+    row_id = before[0]['_ROW_ID']
+    table = table.copy({'write.native.enabled': str(native).lower(), 
'commit.native.enabled': str(native).lower()})
+    source_rows = [dict(_ROWS[0], value=99), dict(id=4, pt='a', value=40, 
payload=b'blob-4')]
+    source = pa.Table.from_pylist(source_rows, schema=_SCHEMA)
+    if operation == 'merge-table':
+        catalog.create_table('default.s', Schema.from_pyarrow_schema(_SCHEMA, 
options=options), False)
+        source = catalog.get_table('default.s')
+        _write(source, source_rows)
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    update = builder.new_update().with_update_type(['value'])
+    predicate = update.new_predicate_builder().equal('id', 1)
+
+    def invoke(method, *args, **kwargs):
+        if stream:
+            kwargs['commit_identifier'] = 7
+        return method(*args, **kwargs)
+
+    with _native_only(native):
+        if operation == 'row-id':
+            messages = invoke(update.update_by_arrow_with_row_id,
+                              pa.table({'_ROW_ID': [row_id], 'value': 
pa.array([99], type=pa.int32())}))
+        elif operation in ('predicate', 'callable'):
+            assignments = {'value': 99 if operation == 'predicate' else lambda 
batch: pc.add(batch['value'], 89)}
+            messages = invoke(update.update_by_predicate, predicate, 
assignments,
+                              read_columns=['value'] if operation == 
'callable' else None)
+        elif operation == 'upsert':
+            messages = invoke(update.upsert_by_arrow_with_key, source, ['id'])
+        elif operation == 'row-upsert':
+            rows = [GenericRow([row[field.name] for field in table.fields], 
table.fields) for row in source_rows]
+            messages = invoke(update.upsert_by_key, rows, ['id'])
+        elif operation in ('merge', 'merge-table'):
+            messages = invoke(update.merge_into, source, on=['id', 'pt'],
+                              when_matched=[WhenMatched.update({'value': 
source_col('value')})],
+                              when_not_matched=[WhenNotMatched('*')])
+        elif operation == 'row-delete':
+            messages = invoke(update.delete_by_row_id, [row_id])
+        else:
+            messages = invoke(update.delete_by_predicate, predicate)
+    files = [file for message in messages for file in message.new_files]
+    assert messages
+    assert all(file.file_name.endswith(('.parquet', '.blob')) and '.vector.' 
not in file.file_name for file in files)
+    commit = builder.new_commit()
+    try:
+        with ExitStack() as stack:
+            if native:
+                stack.enter_context(patch.object(
+                    commit.file_store_commit, 'commit', 
side_effect=AssertionError('Python commit fallback')))
+            commit.commit(messages, 7) if stream else commit.commit(messages)
+    finally:
+        commit.close()
+    actual = [{key: value for key, value in row.items() if key != '_ROW_ID'} 
for row in _read(table)]
+    expected = [dict(row) for row in _ROWS]
+    if operation.endswith('delete'):
+        expected = expected[1:]
+    else:
+        expected[0]['value'] = 99
+        if operation in ('upsert', 'row-upsert', 'merge', 'merge-table'):
+            expected.append(source_rows[1])
+    assert actual == expected
diff --git a/paimon-python/pypaimon/tests/native_write_inactive_options_test.py 
b/paimon-python/pypaimon/tests/native_write_inactive_options_test.py
new file mode 100644
index 0000000000..5bb9eb19d5
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_write_inactive_options_test.py
@@ -0,0 +1,150 @@
+# 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.
+
+"""Unused changelog format options do not prevent native Parquet writes."""
+
+from contextlib import ExitStack
+from unittest.mock import Mock, patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.read.native_plan import native_runtime_available
+from pypaimon.write.native_write import NativeTableWrite, create_native_write
+
+
+pytestmark = [pytest.mark.python_write, pytest.mark.python_commit]
+_NATIVE = pytest.mark.skipif(not native_runtime_available(), 
reason='pypaimon-rust is not installed')
+_ENGINES = [False, pytest.param(True, marks=_NATIVE)]
+_SCHEMA = pa.schema([('id', pa.int32()), ('pt', pa.string()), ('value', 
pa.int32())])
+
+
+def _table(catalog, primary_key, producer='none'):
+    catalog.create_table('default.t', Schema.from_pyarrow_schema(
+        _SCHEMA, partition_keys=['pt'], primary_keys=['id', 'pt'] if 
primary_key else [], options={
+            'file.format': 'parquet', 'bucket': '1' if primary_key else '-1',
+            'changelog-producer': producer, 'changelog-file.format': 'avro',
+            'dynamic-partition-overwrite': 'false',
+            'read.native.enabled': 'false', 'scan.native-plan.enabled': 
'false',
+            'write.native.enabled': 'false', 'commit.native.enabled': 'false',
+        }), False)
+    return catalog.get_table('default.t')
+
+
[email protected]('primary_key,stream,partition,producer', [
+    (False, False, None, 'none'), (False, True, None, 'none'),
+    (True, False, None, 'none'), (True, True, None, 'none'),
+    (True, False, {}, 'input'), (True, False, {'pt': 'a'}, 'input'),
+])
+def test_inactive_changelog_format_qualifies_for_native_write(
+        native_rest_catalog, primary_key, stream, partition, producer):
+    table = _table(native_rest_catalog, primary_key, producer)
+    native_table = Mock()
+    with patch('pypaimon.write.native_write.native_runtime_available', 
return_value=True), \
+            patch('pypaimon.write.native_write.create_native_write_table', 
return_value=native_table) as create:
+        writer = create_native_write(table, 'job', static_partition=partition, 
stream=stream)
+    assert isinstance(writer, NativeTableWrite)
+    create.assert_called_once_with(table)
+    builder = 
(native_table.new_stream_write_builder.return_value.with_commit_user.return_value
 if stream
+               else 
native_table.new_batch_write_builder.return_value._with_commit_user.return_value)
+    if partition is not None:
+        builder.with_overwrite.assert_called_once_with(partition)
+        builder = builder.with_overwrite.return_value
+    assert writer._native_writer is builder.new_write.return_value
+    writer.close()
+
+
[email protected]('stream,partition', [(False, None), (True, None), 
(True, {})])
+def test_active_non_parquet_input_changelog_keeps_existing_fallback(
+        native_rest_catalog, stream, partition):
+    table = _table(native_rest_catalog, True, 'input')
+    with patch('pypaimon.write.native_write.native_runtime_available', 
return_value=True), \
+            patch('pypaimon.write.native_write.create_native_write_table') as 
create:
+        assert create_native_write(table, 'job', static_partition=partition, 
stream=stream) is None
+    create.assert_not_called()
+
+
+def _write(table, rows, stream=False, overwrite=None):
+    builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    if overwrite is not None:
+        builder.overwrite(overwrite)
+    native = table.options.native_write_enabled()
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch.object(
+                NativeTableWrite, '_switch_to_python', 
side_effect=AssertionError('Python write fallback')))
+        writer, commit = builder.new_write(), builder.new_commit()
+        if native:
+            assert isinstance(writer, NativeTableWrite)
+            for method in ('commit', 'overwrite'):
+                stack.enter_context(patch.object(
+                    commit.file_store_commit, method, 
side_effect=AssertionError('Python commit fallback')))
+        try:
+            messages = []
+            # Stream checkpoints exercise reuse after the first prepared batch.
+            for identifier, group in enumerate(([row] for row in rows) if 
stream else [rows], 1):
+                writer.write_arrow(pa.Table.from_pylist(group, schema=_SCHEMA))
+                current = writer.prepare_commit(identifier) if stream else 
writer.prepare_commit()
+                assert current
+                assert all(file.file_name.endswith('.parquet')
+                           for message in current for file in 
message.new_files)
+                assert all(not message.changelog_files for message in current)
+                if stream:
+                    commit.commit(current, identifier)
+                else:
+                    commit.commit(current)
+                messages.extend(current)
+        finally:
+            writer.close()
+            commit.close()
+    assert 
table.snapshot_manager().get_latest_snapshot().changelog_manifest_list is None
+    return messages
+
+
+def _rows(table):
+    builder = table.new_read_builder()
+    return 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('id').to_pylist()
+
+
[email protected]('native', _ENGINES)
[email protected]('primary_key', [False, True], ids=['append', 'pk'])
[email protected]('stream', [False, True], ids=['batch', 'stream'])
+def test_parquet_writes_ignore_unused_non_parquet_changelog_format(
+        native_rest_catalog, native, primary_key, stream):
+    table = _table(native_rest_catalog, primary_key).copy({
+        'write.native.enabled': str(native).lower(), 'commit.native.enabled': 
str(native).lower()})
+    rows = [dict(id=1, pt='a', value=10), dict(id=2, pt='b', value=20)]
+    _write(table, rows, stream)
+    assert _rows(table) == rows
+
+
[email protected]('native', _ENGINES)
[email protected]('primary_key', [False, True], ids=['append', 'pk'])
[email protected]('partition', [{}, {'pt': 'a'}], ids=['all', 
'partition'])
+def test_overwrite_suppresses_inactive_non_parquet_input_changelog(
+        native_rest_catalog, native, primary_key, partition):
+    table = _table(native_rest_catalog, primary_key)
+    seed = [dict(id=1, pt='a', value=10), dict(id=2, pt='b', value=20)]
+    _write(table, seed)
+    table = table.copy({'changelog-producer': 'input' if primary_key else 
'none',
+                        'write.native.enabled': str(native).lower(),
+                        'commit.native.enabled': str(native).lower()})
+    replacement = [dict(id=3, pt='a', value=30)]
+    _write(table, replacement, overwrite=partition)
+    expected = ([seed[1]] if partition else []) + replacement
+    assert _rows(table) == sorted(expected, key=lambda row: row['id'])
diff --git a/paimon-python/pypaimon/write/native_update.py 
b/paimon-python/pypaimon/write/native_update.py
index 4bc293b8bb..a7825c13dd 100644
--- a/paimon-python/pypaimon/write/native_update.py
+++ b/paimon-python/pypaimon/write/native_update.py
@@ -24,7 +24,7 @@ import pyarrow.compute as pc
 
 from pypaimon.read.native_plan import _native_query_auth_supported, 
native_runtime_available
 from pypaimon.schema.arrow_schema import arrow_schemas_compatible
-from pypaimon.schema.data_types import MapType, PyarrowFieldParser, RowType, 
is_blob_file_field
+from pypaimon.schema.data_types import MapType, PyarrowFieldParser, RowType, 
VectorType, is_blob_file_field
 from pypaimon.common.options.core_options import ChangelogProducer, CoreOptions
 from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
 from pypaimon.snapshot.time_travel_util import SCAN_KEYS
@@ -39,7 +39,8 @@ from pypaimon.write.row_utils import _contains_blob_value, 
value_for_arrow
 
 def _native_update_formats_supported(table):
     return (table.options.file_format() == 'parquet'
-            and (not table.options.with_vector_format() or 
table.options.vector_file_format() == 'parquet'))
+            and (not table.options.with_vector_format() or 
table.options.vector_file_format() == 'parquet'
+                 or not any(isinstance(field.type, VectorType) for field in 
table.table_schema.fields)))
 
 
 def _native_update_table(table, *, allow_changelog=False):
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index 267694d695..ce74056e24 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -18,7 +18,7 @@
 
 import pyarrow as pa
 
-from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.options.core_options import ChangelogProducer, CoreOptions
 from pypaimon.read.native_plan import native_runtime_available
 from pypaimon.schema.arrow_schema import (
     arrow_schemas_compatible, normalize_arrow_strings, 
normalize_arrow_write_nullability)
@@ -51,7 +51,11 @@ def create_native_write(table, commit_user, 
static_partition=None, stream=False,
                                            BucketMode.BUCKET_UNAWARE,
                                            BucketMode.CROSS_PARTITION,
                                            BucketMode.POSTPONE_MODE)
-            or table.options.changelog_file_format() not in (None, 'parquet')
+            # The format matters only when this write emits INPUT changelogs.
+            # Batch overwrites suppress them because they cannot be committed.
+            or (table.options.changelog_file_format() not in (None, 'parquet')
+                and table.options.changelog_producer() == 
ChangelogProducer.INPUT
+                and (stream or static_partition is None))
             or table.options.file_format() != 'parquet'
             or not _native_partition_types_supported(
                 schema, table.partition_keys, 
table.options.options.get(CoreOptions.PARTITION_GENERATE_LEGACY_NAME))

Reply via email to