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 eed721f963 [Python] Use Native for DV merge engines and fixed postpone 
writes (#10501)
eed721f963 is described below

commit eed721f96376162510004230a0d2445ec5c03db3
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 11 14:40:20 2026 +0800

    [Python] Use Native for DV merge engines and fixed postpone writes (#10501)
---
 .../tests/native_dv_merge_engine_write_test.py     | 230 +++++++++++++++++++++
 paimon-python/pypaimon/write/native_write.py       |   8 +-
 2 files changed, 233 insertions(+), 5 deletions(-)

diff --git a/paimon-python/pypaimon/tests/native_dv_merge_engine_write_test.py 
b/paimon-python/pypaimon/tests/native_dv_merge_engine_write_test.py
new file mode 100644
index 0000000000..507b3933eb
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_dv_merge_engine_write_test.py
@@ -0,0 +1,230 @@
+# 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.
+
+"""DV table writes use the existing Java merge functions before file flush."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pyarrow.parquet as pq
+import pytest
+
+from pypaimon import Schema
+from pypaimon.table.bucket_mode import BucketMode
+from pypaimon.write.native_write import NativeTableWrite
+
+pytestmark = pytest.mark.native_plan
+
+
+def _table(catalog, engine, bucket, native, changelog=False, extra=None):
+    schema = pa.schema([
+        ('p', pa.string()), ('id', pa.int32()), ('value', pa.int32()), 
('text', pa.string()),
+    ])
+    options = {
+        'bucket': str(bucket), 'merge-engine': engine, 
'deletion-vectors.enabled': 'true',
+        'write.native.enabled': str(native).lower(),
+        'source.split.target-size': '1b', 'source.split.open-file-cost': '1b',
+    }
+    if bucket == -2:
+        options['postpone.default-bucket-num'] = '3'
+    if engine == 'aggregation':
+        options.update({'fields.value.aggregate-function': 'sum',
+                        'fields.text.aggregate-function': 
'last_non_null_value'})
+    if changelog:
+        options['changelog-producer'] = 'input'
+    options.update(extra or {})
+    catalog.create_table('default.dv_merge', Schema.from_pyarrow_schema(
+        schema, primary_keys=['p', 'id'], partition_keys=['p'], 
options=options), False)
+    return catalog.get_table('default.dv_merge'), schema
+
+
+def _write(table, schema, rows, native, stream=False, identifier=1, 
overwrite=None, restore=None):
+    if table.bucket_mode() == BucketMode.POSTPONE_MODE:
+        builder = table.new_postpone_fixed_bucket_write_builder()
+    else:
+        builder = table.new_stream_write_builder() if stream else 
table.new_batch_write_builder()
+    if overwrite is not None:
+        builder.overwrite(overwrite)
+    if restore is not None:
+        builder.with_restore_snapshot(restore)
+    writer, commit = builder.new_write(), builder.new_commit()
+    assert isinstance(writer, NativeTableWrite) == native
+    try:
+        for group in rows:
+            writer.write_arrow(pa.Table.from_pylist(group, schema=schema))
+        messages = writer.prepare_commit(identifier) if stream else 
writer.prepare_commit()
+        commit.commit(messages, identifier) if stream else 
commit.commit(messages)
+        return messages
+    finally:
+        writer.close()
+        commit.close()
+
+
+def _rows(table, native=True, merge_on_read=True, predicate=None, 
projection=None):
+    table = table.copy({
+        'scan.native-plan.enabled': str(native).lower(), 
'read.native.enabled': str(native).lower(),
+        'deletion-vectors.merge-on-read': str(merge_on_read).lower(),
+    })
+    builder = table.new_read_builder()
+    if predicate is not None:
+        builder.with_filter(predicate(builder.new_predicate_builder()))
+    if projection is not None:
+        builder.with_projection(projection)
+    read = builder.new_read()
+    if native:
+        with patch.object(read, '_create_split_read', 
side_effect=AssertionError('Python read fallback')):
+            result = read.to_arrow(builder.new_scan().plan().splits())
+    else:
+        result = read.to_arrow(builder.new_scan().plan().splits())
+    return sorted(result.to_pylist(), key=lambda row: (row.get('p', ''), 
row.get('id', 0)))
+
+
+def _first_rows():
+    return [
+        [dict(p='a', id=1, value=10, text='first'), dict(p='b', id=2, 
value=20, text='second')],
+        [dict(p='a', id=1, value=None, text='patched')],
+    ]
+
+
[email protected]('native', [False, True], ids=['python-write', 
'native-write'])
[email protected]('engine', ['partial-update', 'aggregation'])
[email protected]('bucket,stream', [
+    (1, False), (1, True), (-1, False), (-1, True), (-2, False),
+], ids=['fixed-batch', 'fixed-stream', 'dynamic-batch', 'dynamic-stream', 
'postpone-fixed-batch'])
[email protected]('changelog', [False, True], ids=['none', 'input'])
+def test_dv_merge_engines_fold_and_restore_across_commits(
+        native_rest_catalog, native, engine, bucket, stream, changelog):
+    table, schema = _table(native_rest_catalog, engine, bucket, native, 
changelog)
+    first = _write(table, schema, _first_rows(), native, stream)
+    assert sum(file.row_count for message in first for file in 
message.new_files) == 2
+    assert all(file.level == 0 and file.delete_row_count == 0 for message in 
first for file in message.new_files)
+    assert sum(file.row_count for message in first for file in 
message.changelog_files) == (3 if changelog else 0)
+    expected = [dict(p='a', id=1, value=10, text='patched'), dict(p='b', id=2, 
value=20, text='second')]
+    for native_read in (False, True):
+        assert _rows(table, native_read) == expected
+        # Java batchScanSkipLevel0 keeps the default materialized view empty.
+        assert _rows(table, native_read, merge_on_read=False) == []
+    if changelog:
+        inputs = []
+        for message in first:
+            for file in message.changelog_files:
+                for row in pq.ParquetFile(file.file_path).read().to_pylist():
+                    inputs.append((row['p'], row['id'], row['value'], 
row['text'], row['_VALUE_KIND']))
+        assert sorted(inputs, key=lambda row: (row[0], row[1], row[3])) == [
+            ('a', 1, 10, 'first', 0), ('a', 1, None, 'patched', 0), ('b', 2, 
20, 'second', 0),
+        ]
+    second = _write(table, schema, [[
+        dict(p='a', id=1, value=3, text=None), dict(p='c', id=3, value=30, 
text='third'),
+    ]], native, stream, 2)
+    previous_max = max(file.max_sequence_number for message in first if 
message.partition == ('a',)
+                       for file in message.new_files)
+    assert all(file.min_sequence_number > previous_max for message in second 
if message.partition == ('a',)
+               for file in message.new_files)
+    expected[0]['value'] = 13 if engine == 'aggregation' else 3
+    expected.append(dict(p='c', id=3, value=30, text='third'))
+    for native_read in (False, True):
+        assert _rows(table, native_read) == expected
+        assert _rows(table, native_read, merge_on_read=False) == []
+    assert table.snapshot_manager().get_latest_snapshot().id == 2
+
+
[email protected]('engine', ['partial-update', 'aggregation'])
[email protected]('bucket', [1, -1], ids=['fixed', 'dynamic'])
+def 
test_native_dv_merge_engines_overwrite_and_dynamic_restore(native_rest_catalog, 
engine, bucket):
+    table, schema = _table(native_rest_catalog, engine, bucket, True, 
changelog=True)
+    _write(table, schema, _first_rows(), True)
+    _write(table, schema, [[dict(p='a', id=1, value=3, text=None)]], True, 
restore=1 if bucket == -1 else None)
+    assert _rows(table) == [
+        dict(p='a', id=1, value=13 if engine == 'aggregation' else 3, 
text='patched'),
+        dict(p='b', id=2, value=20, text='second'),
+    ]
+    overwritten = _write(table, schema, [[dict(p='a', id=1, value=100, 
text=None)]], True, overwrite={'p': 'a'})
+    # Overwrite resets the partition's merge state and does not publish INPUT 
logs.
+    assert not any(message.changelog_files for message in overwritten)
+    assert _rows(table) == [
+        dict(p='a', id=1, value=100, text=None), dict(p='b', id=2, value=20, 
text='second'),
+    ]
+    _write(table, schema, [[dict(p='a', id=1, value=7, text='after')]], True)
+    expected = [
+        dict(p='a', id=1, value=107 if engine == 'aggregation' else 7, 
text='after'),
+        dict(p='b', id=2, value=20, text='second'),
+    ]
+    for native in (False, True):
+        assert _rows(table, native) == expected
+    assert table.snapshot_manager().get_latest_snapshot().id == 4
+
+
[email protected]('engine', ['partial-update', 'aggregation'])
+def test_native_dv_merge_on_read_filters_the_merged_value(native_rest_catalog, 
engine):
+    table, schema = _table(native_rest_catalog, engine, 1, True)
+    _write(table, schema, [[dict(p='a', id=1, value=5, text='old')]], True)
+    _write(table, schema, [[dict(p='a', id=1, value=6, text=None)]], True)
+    for native in (False, True):
+        # Each file is below this threshold. Aggregation must evaluate the
+        # merged 11 rather than discard both files using value statistics.
+        result = _rows(table, native, predicate=lambda builder: 
builder.greater_than('value', 10),
+                       projection=['p', 'id'])
+        assert result == ([dict(p='a', id=1)] if engine == 'aggregation' else 
[])
+        assert _rows(table, native, predicate=lambda builder: 
builder.equal('text', 'old'),
+                     projection=['p', 'id']) == [dict(p='a', id=1)]
+
+
[email protected]('engine', ['partial-update', 'aggregation'])
+def 
test_native_dv_merge_engines_preserve_values_across_buffer_flushes(native_rest_catalog,
 engine):
+    table, schema = _table(native_rest_catalog, engine, 1, True, 
changelog=True,
+                           extra={'write.parquet-buffer-size': '1b'})
+    messages = _write(table, schema, [
+        [dict(p='a', id=1, value=1, text='first')],
+        [dict(p='a', id=1, value=2, text=None)],
+        [dict(p='a', id=1, value=3, text='last')],
+    ], True)
+    assert len([file for message in messages for file in message.new_files]) 
== 3
+    assert sum(file.row_count for message in messages for file in 
message.changelog_files) == 3
+    for native in (False, True):
+        assert _rows(table, native) == [dict(p='a', id=1, value=6 if engine == 
'aggregation' else 3, text='last')]
+
+
[email protected]('engine', ['deduplicate', 'partial-update', 
'aggregation'])
+def 
test_native_dv_postpone_fixed_bucket_append_and_rescale(native_rest_catalog, 
engine):
+    table, schema = _table(native_rest_catalog, engine, -2, True, 
changelog=True)
+    first = _write(table, schema, [
+        [dict(p='a', id=1, value=10, text='first')],
+        [dict(p='a', id=1, value=20, text=None)],
+    ], True)
+    assert all(message.total_buckets == 3 and 0 <= message.bucket < 3 for 
message in first)
+    assert sum(file.row_count for message in first for file in 
message.new_files) == 1
+    expected_text = None if engine == 'deduplicate' else 'first'
+    expected = [dict(p='a', id=1, value=30 if engine == 'aggregation' else 20, 
text=expected_text)]
+    for native in (False, True):
+        assert _rows(table, native) == expected
+        assert _rows(table, native, merge_on_read=False) == []
+    replacement = table.copy({'postpone.default-bucket-num': '5'})
+    appended = _write(replacement, schema, [[
+        dict(p='a', id=1, value=30, text='after'), dict(p='b', id=2, value=40, 
text='new'),
+    ]], True)
+    assert {message.partition: message.total_buckets for message in appended} 
== {('a',): 3, ('b',): 5}
+    assert _rows(replacement) == [
+        dict(p='a', id=1, value=60 if engine == 'aggregation' else 30, 
text='after'),
+        dict(p='b', id=2, value=40, text='new'),
+    ]
+    messages = _write(replacement, schema, [[dict(p='a', id=1, value=100, 
text=None)]], True, overwrite={'p': 'a'})
+    assert all(message.total_buckets == 5 and 0 <= message.bucket < 5 for 
message in messages)
+    assert not any(message.changelog_files for message in messages)
+    for native in (False, True):
+        assert _rows(replacement, native) == [
+            dict(p='a', id=1, value=100, text=None), dict(p='b', id=2, 
value=40, text='new'),
+        ]
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index 7bbccd3ebf..0897ca8e95 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -20,7 +20,7 @@ from importlib import import_module
 
 import pyarrow as pa
 
-from pypaimon.common.options.core_options import CoreOptions, MergeEngine
+from pypaimon.common.options.core_options import CoreOptions
 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.table.bucket_mode import BucketMode
@@ -53,15 +53,13 @@ def create_native_write(table, commit_user, 
static_partition=None, stream=False,
                         *, fixed_bucket=False, bucket_plan=None, 
restore_snapshot_id=None):
     """Return a native writer if the table can use the filesystem write 
path."""
     schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
+    # DV tables flush normal level-0 files using the configured merge engine.
+    # Batch visibility is controlled by deletion-vectors.merge-on-read.
     if (not native_write_available()
             or table.bucket_mode() not in (BucketMode.HASH_FIXED, 
BucketMode.HASH_DYNAMIC,
                                            BucketMode.BUCKET_UNAWARE,
                                            BucketMode.CROSS_PARTITION,
                                            BucketMode.POSTPONE_MODE)
-            or (fixed_bucket and table.options.deletion_vectors_enabled())
-            or (table.options.deletion_vectors_enabled()
-                and table.options.merge_engine() in 
(MergeEngine.PARTIAL_UPDATE,
-                                                     MergeEngine.AGGREGATE))
             or table.options.changelog_file_format() not in (None, 'parquet')
             or table.options.file_format() != 'parquet'
             or not _native_partition_types_supported(

Reply via email to