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 f978e8bd5f [python] Run timestamp-window MERGE sources in native core 
(#10428)
f978e8bd5f is described below

commit f978e8bd5fc776d44732031ef8f2f02ebdf75aa0
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 8 14:47:15 2026 +0800

    [python] Run timestamp-window MERGE sources in native core (#10428)
---
 paimon-python/README.md                            |  10 +-
 paimon-python/pypaimon/read/table_scan.py          | 243 +++++++++++----------
 .../tests/native_merge_incremental_test.py         | 192 ++++++++++++++++
 .../pypaimon/tests/native_plan_incremental_test.py |  35 ++-
 paimon-python/pypaimon/write/native_merge_into.py  |  11 +-
 5 files changed, 370 insertions(+), 121 deletions(-)

diff --git a/paimon-python/README.md b/paimon-python/README.md
index dc2459e3c0..f4dd3c7fe8 100644
--- a/paimon-python/README.md
+++ b/paimon-python/README.md
@@ -184,8 +184,9 @@ except for a sole unconditional DELETE, following Paimon 
Spark MERGE.
 For table sources, core independently pins the selected source branch/snapshot
 and reads only keys and referenced columns. Append and primary-key sources do
 not require data evolution. Source scan options select the same point in time
-on Python and native paths. Incremental source scans, non-REST Blob views
-requiring catalog resolution, and packed-video inserts use the Python path
+on Python and native paths. Numeric `incremental-between-timestamp` source
+windows are resolved and read by core. Non-REST Blob views
+requiring catalog resolution and packed-video inserts use the Python path
 selected before native execution starts. Native failures propagate without
 Python retry or deleting files from earlier prepared actions.
 
@@ -435,7 +436,10 @@ counts can differ between shards. Limits are applied after 
shard/slice selection
 Timestamp incremental scans require `ReadBuilder.new_incremental_scan()` and
 stream-aware splits exposing `Split.is_streaming()`. Python resolves
 `(start_timestamp, end_timestamp]` to snapshot IDs; Rust packs the selected 
APPEND
-deltas into one plan. Continuous streaming uses the same native path for 
initial
+deltas into one plan. Equal timestamp bounds produce an empty result, as in 
Java.
+AUTO selects physical changelog files when a changelog producer is configured;
+each split carries the selected ending snapshot ID. Numeric bounds use signed
+64-bit milliseconds and Java's integer syntax. Continuous streaming uses the 
same native path for initial
 and delta frames. When `changelog-producer` is enabled, follow-up frames 
request
 Rust's explicit `changelog` mode and read the physical changelog manifests.
 OVERWRITE changelog frames retain per-snapshot Python planning because Java
diff --git a/paimon-python/pypaimon/read/table_scan.py 
b/paimon-python/pypaimon/read/table_scan.py
index 44a09ee5ac..879072a12f 100755
--- a/paimon-python/pypaimon/read/table_scan.py
+++ b/paimon-python/pypaimon/read/table_scan.py
@@ -17,11 +17,12 @@
 
 import json as _json
 import logging
+import re
 from typing import List, Optional, Tuple
 
 from pypaimon.catalog.catalog_exception import TableNoPermissionException
 from pypaimon.common.identifier import UNKNOWN_DATABASE
-from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.options.core_options import CoreOptions, ChangelogProducer
 from pypaimon.common.predicate import Predicate
 from pypaimon.common.predicate_builder import PredicateBuilder
 from pypaimon.manifest.manifest_list_manager import ManifestListManager
@@ -183,6 +184,8 @@ class TableScan:
                 if self._incremental_snapshot_range is None:
                     return Plan([])
                 extra_options['incremental_range'] = 
self._incremental_snapshot_range
+                if self.table.options.changelog_producer() != 
ChangelogProducer.NONE:
+                    extra_options['incremental_mode'] = 'changelog'
             chunk_shuffle = fs.chunk_shuffle
             if chunk_shuffle is not None:
                 extra_options['chunk_shuffle'] = chunk_shuffle
@@ -317,26 +320,21 @@ class TableScan:
             )
 
         if has_incremental:
-            ts = 
options.get(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP).split(",")
-            if len(ts) != 2:
-                raise ValueError(
-                    "The incremental-between-timestamp must specific 
start(exclusive) and end timestamp. But is: " +
-                    options.get(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP))
-            start_timestamp = int(ts[0])
-            end_timestamp = int(ts[1])
-            if start_timestamp >= end_timestamp:
-                raise ValueError(
-                    "Ending timestamp %s must be greater than starting 
timestamp %s."
-                    % (end_timestamp, start_timestamp))
-            earliest_snapshot = snapshot_manager.try_get_earliest_snapshot()
+            start_timestamp, end_timestamp = 
_parse_incremental_timestamp_window(
+                options.get(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP))
             latest_snapshot = snapshot_manager.get_latest_snapshot()
+            earliest_snapshot = snapshot_manager.try_get_earliest_snapshot() 
if latest_snapshot is not None else None
             if earliest_snapshot is None or latest_snapshot is None:
                 return FileScanner(
                     self.table,
                     lambda: ([], None),
                     partition_predicate=self.partition_predicate,
                 )
-            if (start_timestamp > latest_snapshot.time_millis
+            if start_timestamp > end_timestamp:
+                raise ValueError(
+                    "Ending timestamp %s should be >= starting timestamp %s."
+                    % (end_timestamp, start_timestamp))
+            if (start_timestamp == end_timestamp or start_timestamp > 
latest_snapshot.time_millis
                     or end_timestamp < earliest_snapshot.time_millis):
                 return FileScanner(
                     self.table,
@@ -357,6 +355,9 @@ class TableScan:
             latest_snapshot = snapshot_manager.get_latest_snapshot()
             end_id = end_snapshot.id if end_snapshot else (latest_snapshot.id 
if latest_snapshot else -1)
             self._incremental_snapshot_range = (start_id, end_id)
+            # Java's AUTO mode uses physical changelogs when a producer is
+            # configured, and APPEND deltas otherwise.
+            changelog = self.table.options.changelog_producer() != 
ChangelogProducer.NONE
 
             def incremental_manifest():
                 snapshots_in_range = []
@@ -364,13 +365,15 @@ class TableScan:
                 for snapshot_id in range(start_id + 1, end_id + 1):
                     snapshot = snapshot_manager.get_snapshot_by_id(snapshot_id)
                     end_snapshot = snapshot
-                    if snapshot.commit_kind == "APPEND":
+                    if (snapshot.commit_kind != "OVERWRITE" and 
snapshot.changelog_manifest_list
+                            if changelog else snapshot.commit_kind == 
"APPEND"):
                         snapshots_in_range.append(snapshot)
 
                 manifests = []
 
                 for snapshot in snapshots_in_range:
-                    manifest_files = manifest_list_manager.read_delta(snapshot)
+                    manifest_files = 
(manifest_list_manager.read_changelog(snapshot) if changelog else
+                                      
manifest_list_manager.read_delta(snapshot))
                     manifests.extend(manifest_files)
                 return manifests, end_snapshot
 
@@ -436,109 +439,125 @@ class TableScan:
         return self
 
     def _validate_scan_mode(self):
-        """Validate scan.mode against companion options using a whitelist 
approach.
+        _validate_scan_mode(self.table)
 
-        Each StartupMode declares exactly which scan keys are allowed. Any
-        scan key present but not in the whitelist for the resolved mode is
-        rejected. This matches Java's SchemaValidation mutual-exclusion matrix.
-        """
-        from pypaimon.common.options.core_options import StartupMode
-
-        core_options = self.table.options
-        mode = core_options.startup_mode()
-        options = core_options.options
-
-        has_snapshot_id = options.contains(CoreOptions.SCAN_SNAPSHOT_ID)
-        has_tag_name = options.contains(CoreOptions.SCAN_TAG_NAME)
-        has_version = options.contains(CoreOptions.SCAN_VERSION)
-        has_watermark = options.contains(CoreOptions.SCAN_WATERMARK)
-        has_timestamp_millis = 
options.contains(CoreOptions.SCAN_TIMESTAMP_MILLIS)
-        has_timestamp = options.contains(CoreOptions.SCAN_TIMESTAMP)
-        has_incremental = 
options.contains(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP)
-        has_file_creation_time = 
options.contains(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS)
-        has_creation_time = 
options.contains(CoreOptions.SCAN_CREATION_TIME_MILLIS)
-
-        present_keys = []
-        if has_version:
-            present_keys.append(CoreOptions.SCAN_VERSION.key())
-        if has_snapshot_id:
-            present_keys.append(CoreOptions.SCAN_SNAPSHOT_ID.key())
-        if has_tag_name:
-            present_keys.append(CoreOptions.SCAN_TAG_NAME.key())
-        if has_watermark:
-            present_keys.append(CoreOptions.SCAN_WATERMARK.key())
-        if has_timestamp_millis:
-            present_keys.append(CoreOptions.SCAN_TIMESTAMP_MILLIS.key())
-        if has_timestamp:
-            present_keys.append(CoreOptions.SCAN_TIMESTAMP.key())
-        if has_incremental:
-            
present_keys.append(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP.key())
-        if has_file_creation_time:
-            
present_keys.append(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key())
-        if has_creation_time:
-            present_keys.append(CoreOptions.SCAN_CREATION_TIME_MILLIS.key())
-
-        # scan.timestamp-millis and scan.timestamp are mutually exclusive
-        if has_timestamp_millis and has_timestamp:
+
+def _validate_scan_mode(table):
+    """Validate scan.mode against companion options using a whitelist approach.
+
+    Each StartupMode declares exactly which scan keys are allowed. Any
+    scan key present but not in the whitelist for the resolved mode is
+    rejected. This matches Java's SchemaValidation mutual-exclusion matrix.
+    """
+    from pypaimon.common.options.core_options import StartupMode
+
+    core_options = table.options
+    mode = core_options.startup_mode()
+    options = core_options.options
+
+    has_snapshot_id = options.contains(CoreOptions.SCAN_SNAPSHOT_ID)
+    has_tag_name = options.contains(CoreOptions.SCAN_TAG_NAME)
+    has_version = options.contains(CoreOptions.SCAN_VERSION)
+    has_watermark = options.contains(CoreOptions.SCAN_WATERMARK)
+    has_timestamp_millis = options.contains(CoreOptions.SCAN_TIMESTAMP_MILLIS)
+    has_timestamp = options.contains(CoreOptions.SCAN_TIMESTAMP)
+    has_incremental = 
options.contains(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP)
+    has_file_creation_time = 
options.contains(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS)
+    has_creation_time = options.contains(CoreOptions.SCAN_CREATION_TIME_MILLIS)
+
+    present_keys = []
+    if has_version:
+        present_keys.append(CoreOptions.SCAN_VERSION.key())
+    if has_snapshot_id:
+        present_keys.append(CoreOptions.SCAN_SNAPSHOT_ID.key())
+    if has_tag_name:
+        present_keys.append(CoreOptions.SCAN_TAG_NAME.key())
+    if has_watermark:
+        present_keys.append(CoreOptions.SCAN_WATERMARK.key())
+    if has_timestamp_millis:
+        present_keys.append(CoreOptions.SCAN_TIMESTAMP_MILLIS.key())
+    if has_timestamp:
+        present_keys.append(CoreOptions.SCAN_TIMESTAMP.key())
+    if has_incremental:
+        present_keys.append(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP.key())
+    if has_file_creation_time:
+        present_keys.append(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key())
+    if has_creation_time:
+        present_keys.append(CoreOptions.SCAN_CREATION_TIME_MILLIS.key())
+
+    # scan.timestamp-millis and scan.timestamp are mutually exclusive
+    if has_timestamp_millis and has_timestamp:
+        raise ValueError(
+            "scan.timestamp-millis and scan.timestamp cannot both be set."
+        )
+
+    # Define allowed companion keys per mode
+    if mode == StartupMode.FROM_TIMESTAMP:
+        allowed = {
+            CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+            CoreOptions.SCAN_TIMESTAMP.key(),
+        }
+        if not (has_timestamp_millis or has_timestamp):
             raise ValueError(
-                "scan.timestamp-millis and scan.timestamp cannot both be set."
+                "scan.mode is 'from-timestamp' but neither "
+                "scan.timestamp-millis nor scan.timestamp is set."
             )
-
-        # Define allowed companion keys per mode
-        if mode == StartupMode.FROM_TIMESTAMP:
-            allowed = {
-                CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
-                CoreOptions.SCAN_TIMESTAMP.key(),
-            }
-            if not (has_timestamp_millis or has_timestamp):
-                raise ValueError(
-                    "scan.mode is 'from-timestamp' but neither "
-                    "scan.timestamp-millis nor scan.timestamp is set."
-                )
-        elif mode == StartupMode.FROM_SNAPSHOT_FULL:
-            allowed = {CoreOptions.SCAN_SNAPSHOT_ID.key()}
-            if not has_snapshot_id:
-                raise ValueError(
-                    "scan.mode is 'from-snapshot-full' but scan.snapshot-id is 
not set."
-                )
-        elif mode == StartupMode.FROM_SNAPSHOT:
-            allowed = {
-                CoreOptions.SCAN_SNAPSHOT_ID.key(),
-                CoreOptions.SCAN_TAG_NAME.key(),
-                CoreOptions.SCAN_WATERMARK.key(),
-                CoreOptions.SCAN_VERSION.key(),
-            }
-            if not (has_snapshot_id or has_tag_name or has_watermark or 
has_version):
-                raise ValueError(
-                    "scan.mode is 'from-snapshot' but none of "
-                    "scan.version, scan.snapshot-id, scan.tag-name, or 
scan.watermark is set."
-                )
-        elif mode == StartupMode.INCREMENTAL:
-            allowed = {CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP.key()}
-            if not has_incremental:
-                raise ValueError(
-                    "scan.mode is 'incremental' but "
-                    "incremental-between-timestamp is not set."
-                )
-        elif mode in (StartupMode.LATEST_FULL, StartupMode.LATEST):
-            allowed = set()
-        elif mode in (StartupMode.COMPACTED_FULL,
-                      StartupMode.FROM_CREATION_TIMESTAMP,
-                      StartupMode.FROM_FILE_CREATION_TIME):
+    elif mode == StartupMode.FROM_SNAPSHOT_FULL:
+        allowed = {CoreOptions.SCAN_SNAPSHOT_ID.key()}
+        if not has_snapshot_id:
             raise ValueError(
-                f"scan.mode '{mode.value}' is not yet supported in pypaimon."
+                "scan.mode is 'from-snapshot-full' but scan.snapshot-id is not 
set."
             )
-        else:
-            allowed = set()
-
-        # Reject any scan key that's not in the whitelist for this mode
-        disallowed = [k for k in present_keys if k not in allowed]
-        if disallowed:
+    elif mode == StartupMode.FROM_SNAPSHOT:
+        allowed = {
+            CoreOptions.SCAN_SNAPSHOT_ID.key(),
+            CoreOptions.SCAN_TAG_NAME.key(),
+            CoreOptions.SCAN_WATERMARK.key(),
+            CoreOptions.SCAN_VERSION.key(),
+        }
+        if not (has_snapshot_id or has_tag_name or has_watermark or 
has_version):
+            raise ValueError(
+                "scan.mode is 'from-snapshot' but none of "
+                "scan.version, scan.snapshot-id, scan.tag-name, or 
scan.watermark is set."
+            )
+    elif mode == StartupMode.INCREMENTAL:
+        allowed = {CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP.key()}
+        if not has_incremental:
             raise ValueError(
-                f"scan.mode '{mode.value}' conflicts with: {disallowed}. "
-                f"Only {sorted(allowed) if allowed else 'no scan keys'} "
-                f"are allowed for this mode."
+                "scan.mode is 'incremental' but "
+                "incremental-between-timestamp is not set."
             )
+    elif mode in (StartupMode.LATEST_FULL, StartupMode.LATEST):
+        allowed = set()
+    elif mode in (StartupMode.COMPACTED_FULL,
+                  StartupMode.FROM_CREATION_TIMESTAMP,
+                  StartupMode.FROM_FILE_CREATION_TIME):
+        raise ValueError(
+            f"scan.mode '{mode.value}' is not yet supported in pypaimon."
+        )
+    else:
+        allowed = set()
+
+    # Reject any scan key that's not in the whitelist for this mode
+    disallowed = [k for k in present_keys if k not in allowed]
+    if disallowed:
+        raise ValueError(
+            f"scan.mode '{mode.value}' conflicts with: {disallowed}. "
+            f"Only {sorted(allowed) if allowed else 'no scan keys'} "
+            f"are allowed for this mode."
+        )
+
+
+def _parse_incremental_timestamp_window(value):
+    """Numeric milliseconds use Java Long parsing, including its range."""
+    parts = value.split(',')
+    if len(parts) != 2 or any(re.fullmatch(r'[+-]?[0-9]+', part) is None for 
part in parts):
+        raise ValueError('incremental-between-timestamp requires 
start(exclusive),end(inclusive) '
+                         'in milliseconds, got: ' + value)
+    start, end = map(int, parts)
+    if not all(-(1 << 63) <= timestamp < (1 << 63) for timestamp in (start, 
end)):
+        raise ValueError('incremental-between-timestamp milliseconds must be 
signed 64-bit integers')
+    return start, end
 
 
 def latest_auth_fields(table):
diff --git a/paimon-python/pypaimon/tests/native_merge_incremental_test.py 
b/paimon-python/pypaimon/tests/native_merge_incremental_test.py
new file mode 100644
index 0000000000..8f73835463
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_merge_incremental_test.py
@@ -0,0 +1,192 @@
+# 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.
+
+"""Timestamp-window table sources must be planned and read by Rust core."""
+
+import json
+from contextlib import ExitStack
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.table.data_evolution_merge_into import WhenMatched, 
WhenNotMatched, source_col
+from pypaimon.tests.native_merge_into_test import _SCHEMA, _table, _rows, 
_write_rows
+
+pytestmark = pytest.mark.native_plan
+
+
+def _set_time(table, timestamp):
+    snapshot = table.snapshot_manager().get_latest_snapshot()
+    path = table.snapshot_manager().get_snapshot_path(snapshot.id)
+    with open(path) as reader:
+        data = json.load(reader)
+    data['timeMillis'] = timestamp
+    with open(path, 'w') as writer:
+        json.dump(data, writer)
+
+
+def _source(tmp_path, mode='append', options=None, schema=None):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('db', True)
+    config = {'write.native.enabled': 'false', 'read.native.enabled': 'false',
+              'scan.native-plan.enabled': 'false'}
+    if mode == 'pk':
+        config['bucket'] = '1'
+    if mode == 'evolution':
+        config.update({'data-evolution.enabled': 'true', 
'row-tracking.enabled': 'true'})
+    config.update(options or {})
+    catalog.create_table('db.source', Schema.from_pyarrow_schema(
+        schema or _SCHEMA, primary_keys=['id'] if mode == 'pk' else [], 
options=config), False)
+    return catalog.get_table('db.source')
+
+
+def _merge(target, source, stream, native, value_column='value'):
+    target = target.copy({'write.native.enabled': str(native).lower()})
+    builder = target.new_stream_write_builder() if stream else 
target.new_batch_write_builder()
+    kwargs = {'commit_identifier': 57} if stream else {}
+    with ExitStack() as stack:
+        if native:
+            stack.enter_context(patch.object(source, 'snapshot_manager',
+                                             
side_effect=AssertionError('Python source snapshot resolution')))
+            
stack.enter_context(patch('pypaimon.table.data_evolution_merge_into._normalize_source',
+                                      side_effect=AssertionError('Python 
source materialization')))
+            
stack.enter_context(patch('pypaimon.table.data_evolution_merge_into._build_tables',
+                                      side_effect=AssertionError('Python MERGE 
matching')))
+        messages = builder.new_update().merge_into(
+            source, on=['id'], when_matched=[WhenMatched.update({'value': 
source_col(value_column)})],
+            when_not_matched=[WhenNotMatched({
+                'id': source_col('id'), 'value': source_col(value_column), 
'name': source_col('name')})], **kwargs)
+    commit = builder.new_commit()
+    commit.commit(messages, 57) if stream else commit.commit(messages)
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
[email protected]('mode', ['append', 'pk', 'evolution'])
[email protected]('window,expected', [
+    ('100,200', [dict(id=1, value=11, name='old'), dict(id=2, value=22, 
name='insert')]),
+    ('0,100', [dict(id=1, value=-1, name='old')]),
+    ('200,300', [dict(id=1, value=10, name='old'), dict(id=3, value=33, 
name='later')]),
+    ('100,150', [dict(id=1, value=10, name='old')]),
+    ('100,100', [dict(id=1, value=10, name='old')]),
+    ('350,500', [dict(id=1, value=10, name='old')]),
+    ('0,50', [dict(id=1, value=10, name='old')]),
+])
+def test_timestamp_window_source_merge(tmp_path, native, stream, mode, window, 
expected):
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    source = _source(tmp_path / 'source', mode)
+    for timestamp, values in [
+            (100, [dict(id=1, value=-1, name='base')]),
+            (200, [dict(id=1, value=11, name='new'), dict(id=2, value=22, 
name='insert')]),
+            (300, [dict(id=3, value=33, name='later')])]:
+        _write_rows(source, _SCHEMA, values)
+        _set_time(source, timestamp)
+    source = source.copy({'scan.mode': 'incremental', 
'incremental-between-timestamp': window})
+    _merge(target, source, stream, native)
+    assert _rows(target) == expected
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
+def test_incremental_source_uses_selected_branch(tmp_path, native, stream):
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    source = _source(tmp_path / 'source')
+    _write_rows(source, _SCHEMA, [dict(id=1, value=-1, name='base')])
+    _set_time(source, 100)
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'source')})
+    catalog.create_tag(source.identifier, 'base')
+    catalog.create_branch(source.identifier, 'dev', tag_name='base')
+    branch = catalog.get_table('db.source$branch_dev')
+    _write_rows(branch, _SCHEMA, [dict(id=1, value=11, name='branch'), 
dict(id=2, value=22, name='branch-insert')])
+    _set_time(branch, 200)
+    _write_rows(source, _SCHEMA, [dict(id=1, value=99, name='main'), 
dict(id=3, value=33, name='main-insert')])
+    _set_time(source, 200)
+    _merge(target, branch.copy({'incremental-between-timestamp': '100,200'}), 
stream, native)
+    assert _rows(target) == [dict(id=1, value=11, name='old'), dict(id=2, 
value=22, name='branch-insert')]
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
[email protected]('mode', ['append', 'pk'])
+def test_incremental_source_projects_evolved_field_ids(tmp_path, native, 
stream, mode):
+    from pypaimon.schema.schema_change import SchemaChange
+    from pypaimon.schema.data_types import AtomicType
+
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    old_schema = pa.schema([_SCHEMA.field('id'), _SCHEMA.field('value')])
+    source = _source(tmp_path / 'source', mode, schema=old_schema)
+    _write_rows(source, old_schema, [dict(id=1, value=11)])
+    _set_time(source, 100)
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'source')})
+    catalog.alter_table('db.source', [
+        SchemaChange.rename_column('value', 'incoming'), 
SchemaChange.add_column('name', AtomicType('STRING'))], False)
+    source = catalog.get_table('db.source')
+    evolved_schema = pa.schema([_SCHEMA.field('id'), pa.field('incoming', 
pa.int32()), _SCHEMA.field('name')])
+    _write_rows(source, evolved_schema, [dict(id=2, incoming=22, 
name='insert')])
+    _set_time(source, 200)
+    _merge(target, source.copy({'incremental-between-timestamp': '0,200'}), 
stream, native, 'incoming')
+    assert _rows(target) == [dict(id=1, value=11, name='old'), dict(id=2, 
value=22, name='insert')]
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
[email protected]('window', ['200,100', '100', 'one,200', '100, 200', 
'1_00,200',
+                                    '0,9223372036854775808'])
+def test_invalid_incremental_source_never_stages_target(tmp_path, native, 
stream, window):
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    source = _source(tmp_path / 'source')
+    _write_rows(source, _SCHEMA, [dict(id=1, value=11, name='new')])
+    _set_time(source, 100)
+    before_snapshot = target.snapshot_manager().get_latest_snapshot().id
+    before_files = {path for path in (tmp_path / 'target').rglob('*') if 
path.is_file()}
+    with pytest.raises(ValueError):
+        _merge(target, source.copy({'incremental-between-timestamp': window}), 
stream, native)
+    assert target.snapshot_manager().get_latest_snapshot().id == 
before_snapshot
+    assert {path for path in (tmp_path / 'target').rglob('*') if 
path.is_file()} == before_files
+    assert _rows(target) == [dict(id=1, value=10, name='old')]
+
+
[email protected]('native', [False, True])
[email protected]('stream', [False, True])
+def test_incremental_source_auto_uses_changelog(tmp_path, native, stream):
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    source = _source(tmp_path / 'source', 'pk', options={'changelog-producer': 
'input'})
+    for time, values in [(100, [dict(id=1, value=-1, name='base')]),
+                         (200, [dict(id=1, value=11, name='new')]),
+                         (300, [dict(id=2, value=22, name='insert')])]:
+        _write_rows(source, _SCHEMA, values)
+        _set_time(source, time)
+    _merge(target, source.copy({'incremental-between-timestamp': '100,300'}), 
stream, native)
+    assert _rows(target) == [dict(id=1, value=11, name='old'), dict(id=2, 
value=22, name='insert')]
+
+
[email protected]('stream', [False, True])
+def test_missing_source_snapshot_does_not_stage_target(tmp_path, stream):
+    target = _table(tmp_path / 'target', [dict(id=1, value=10, name='old')])
+    source = _source(tmp_path / 'source')
+    for time in (100, 200, 300):
+        _write_rows(source, _SCHEMA, [dict(id=time, value=time, 
name='source')])
+        _set_time(source, time)
+    source.file_io.delete(source.snapshot_manager().get_snapshot_path(2), 
False)
+    before_snapshot = target.snapshot_manager().get_latest_snapshot().id
+    before_files = {path for path in (tmp_path / 'target').rglob('*') if 
path.is_file()}
+    with pytest.raises(ValueError, match='Snapshot 2 does not exist'):
+        _merge(target, source.copy({'incremental-between-timestamp': 
'0,300'}), stream, True)
+    assert target.snapshot_manager().get_latest_snapshot().id == 
before_snapshot
+    assert {path for path in (tmp_path / 'target').rglob('*') if 
path.is_file()} == before_files
diff --git a/paimon-python/pypaimon/tests/native_plan_incremental_test.py 
b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
index 1bffc16240..31c54ea997 100644
--- a/paimon-python/pypaimon/tests/native_plan_incremental_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
@@ -286,7 +286,7 @@ def 
test_incremental_ignores_deletion_vectors_from_window_end(native, catalog):
     assert actual == [rows[4]]
 
 
[email protected]('window', ['100,100', '200,100', '100', 'one,200'])
[email protected]('window', ['100', 'one,200', '0,100,200'])
 def test_invalid_timestamp_window_is_rejected_even_for_empty_tables(catalog, 
window):
     table = _table(catalog, 'invalid')
     with pytest.raises(ValueError):
@@ -590,3 +590,36 @@ def 
test_streaming_reader_honors_explicit_split_deletion_vector(catalog, native,
                 side_effect=AssertionError('explicit DV native read fell 
back')))
         result = 
read_table.new_read_builder().new_read().to_arrow([dv_split]).to_pylist()
     assert result == [{'k': 1, 'v': '1'}, {'k': 3, 'v': '3'}]
+
+
[email protected]('window', [(100, 100), (200, 100)])
+def test_empty_table_timestamp_windows_follow_java(catalog, native, window):
+    table = _table(catalog, 'empty_window')
+    plan, rows = _read(table, native, window)
+    assert rows == []
+    assert plan.snapshot_id is None
+
+
+def test_reverse_window_is_rejected_when_snapshots_exist(catalog):
+    table = _table(catalog, 'reverse_window')
+    _write(table, 100, [{'k': 1, 'v': 'base'}])
+    with pytest.raises(ValueError, match='Ending timestamp'):
+        table.copy({'incremental-between-timestamp': 
'200,100'}).new_read_builder().new_scan()
+
+
+def test_equal_window_is_empty_for_populated_tables(native, history):
+    plan, rows = _read(history, native, (100, 100))
+    assert rows == []
+    assert plan.snapshot_id is None
+
+
+def test_timestamp_auto_reads_physical_changelog(native, catalog):
+    table = _table(catalog, 'batch_changelog', True, {'bucket': '1', 
'changelog-producer': 'input'})
+    _write(table, 100, [{'k': 1, 'v': 'old'}])
+    _write(table, 200, [{'k': 1, 'v': 'new'}, {'k': 2, 'v': 'insert'}])
+    _write(table, 300, [{'k': 9, 'v': 'overwrite'}], overwrite=True)
+    plan, rows = _read(table, native, (100, 300))
+    assert plan.snapshot_id == 3
+    assert all(split.snapshot_id == 3 for split in plan.splits())
+    assert all(file.file_name.startswith('changelog-') for split in 
plan.splits() for file in split.files)
+    assert sorted((row['k'], row['v']) for row in rows) == [(1, 'new'), (2, 
'insert')]
diff --git a/paimon-python/pypaimon/write/native_merge_into.py 
b/paimon-python/pypaimon/write/native_merge_into.py
index 7d535ce1dd..8652079f15 100644
--- a/paimon-python/pypaimon/write/native_merge_into.py
+++ b/paimon-python/pypaimon/write/native_merge_into.py
@@ -51,13 +51,14 @@ def create_native_merge_into(table, source, on, matched, 
not_matched, commit_use
                 or source.options.video_frame_fields() or 
source.options.with_vector_format()
                 or (source.is_primary_key_table and not 
source.trimmed_primary_keys)):
             return None
-        source.new_read_builder().new_scan()._validate_scan_mode()
+        from pypaimon.read.table_scan import _validate_scan_mode
+        _validate_scan_mode(source)
         if not _native_blob_view_supported(source, source.field_names):
             return None
-        # This operation reads one full source snapshot. Incremental scans stay
-        # on the Python path, whose scanner applies the requested delta range.
-        if (source.options.scan_mode() not in ('default', 'from-snapshot', 
'from-timestamp')
-                or any(str(key).startswith('incremental-') for key in 
source.table_schema.options)):
+        # Core resolves a full snapshot or PyPaimon's existing timestamp 
window.
+        if (source.options.scan_mode() not in ('default', 'from-snapshot', 
'from-timestamp', 'incremental')
+                or any(str(key).startswith('incremental-') and key != 
'incremental-between-timestamp'
+                       for key in source.table_schema.options)):
             return None
         from pypaimon.schema.data_types import PyarrowFieldParser
         source_schema = 
PyarrowFieldParser.from_paimon_schema(source.table_schema.fields)

Reply via email to