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 5e5f2f4c77 [python] Support incremental-between with snapshot ids and 
tags (#10857)
5e5f2f4c77 is described below

commit 5e5f2f4c7773af394a6d7832a26f049574dfd86e
Author: Jiajia Li <[email protected]>
AuthorDate: Thu Oct 29 10:10:03 2026 +0800

    [python] Support incremental-between with snapshot ids and tags (#10857)
---
 .../pypaimon/common/options/core_options.py        |  62 ++++++++-
 .../pypaimon/execution/native/read/plan.py         |   5 +
 .../pypaimon/execution/python/read/table_scan.py   |  61 ++++++++-
 paimon-python/pypaimon/multimodal/temporal.py      |   3 +-
 .../pypaimon/multimodal/window_dataset.py          |   3 +-
 paimon-python/pypaimon/ray/join_common.py          |   3 +-
 paimon-python/pypaimon/read/file_scanner.py        |   4 +-
 paimon-python/pypaimon/read/scan_config.py         |  17 ++-
 paimon-python/pypaimon/table/file_store_table.py   |   8 +-
 .../pypaimon/tests/multimodal_temporal_test.py     |  35 +++--
 .../pypaimon/tests/native_plan_incremental_test.py | 151 +++++++++++++++++++++
 paimon-python/pypaimon/tests/native_plan_test.py   |  13 ++
 .../pypaimon/tests/table/file_store_table_test.py  |  19 +++
 .../pypaimon/tests/table_scan_mode_test.py         |  29 ++++
 .../pypaimon/tests/write/table_write_test.py       |  22 +++
 15 files changed, 391 insertions(+), 44 deletions(-)

diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index 93f04872c0..91ff535c2b 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -87,6 +87,13 @@ class StartupMode(str, Enum):
     INCREMENTAL = "incremental"
 
 
+class IncrementalBetweenScanMode(str, Enum):
+    AUTO = "auto"
+    DELTA = "delta"
+    CHANGELOG = "changelog"
+    DIFF = "diff"
+
+
 class GlobalIndexColumnUpdateAction(str, Enum):
     THROW_ERROR = "THROW_ERROR"
     DROP_PARTITION_INDEX = "DROP_PARTITION_INDEX"
@@ -559,6 +566,20 @@ class CoreOptions:
         .with_description("The fallback branch for scanning.")
     )
 
+    INCREMENTAL_BETWEEN: ConfigOption[str] = (
+        ConfigOptions.key("incremental-between")
+        .string_type()
+        .no_default_value()
+        .with_description("Read changes between start snapshot (exclusive) and 
end snapshot (inclusive).")
+    )
+
+    INCREMENTAL_BETWEEN_SCAN_MODE: ConfigOption[IncrementalBetweenScanMode] = (
+        ConfigOptions.key("incremental-between-scan-mode")
+        .enum_type(IncrementalBetweenScanMode)
+        .default_value(IncrementalBetweenScanMode.AUTO)
+        .with_description("Scan mode for incremental changes between 
snapshots.")
+    )
+
     INCREMENTAL_BETWEEN_TIMESTAMP: ConfigOption[str] = (
         ConfigOptions.key("incremental-between-timestamp")
         .string_type()
@@ -566,6 +587,26 @@ class CoreOptions:
         .with_description("The timestamp range for incremental reading.")
     )
 
+    INCREMENTAL_TO_AUTO_TAG: ConfigOption[str] = (
+        ConfigOptions.key("incremental-to-auto-tag")
+        .string_type()
+        .no_default_value()
+        .with_description("Read changes between an earlier auto-created tag 
and the specified end tag (inclusive).")
+    )
+
+    INCREMENTAL_SCAN_KEYS = [
+        INCREMENTAL_BETWEEN.key(),
+        INCREMENTAL_BETWEEN_TIMESTAMP.key(),
+        INCREMENTAL_TO_AUTO_TAG.key(),
+    ]
+
+    INCREMENTAL_BETWEEN_TAG_TO_SNAPSHOT: ConfigOption[bool] = (
+        ConfigOptions.key("incremental-between-tag-to-snapshot")
+        .boolean_type()
+        .default_value(False)
+        .with_description("Read incremental changes between the snapshots 
corresponding to the tags.")
+    )
+
     SCAN_TAG_NAME: ConfigOption[str] = (
         ConfigOptions.key("scan.tag-name")
         .string_type()
@@ -1541,12 +1582,12 @@ class CoreOptions:
                   or self.options.contains(CoreOptions.SCAN_WATERMARK)
                   or self.options.contains(CoreOptions.SCAN_VERSION)):
                 return StartupMode.FROM_SNAPSHOT
-            elif 
self.options.contains(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP):
-                return StartupMode.INCREMENTAL
             elif 
self.options.contains(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS):
                 return StartupMode.FROM_FILE_CREATION_TIME
             elif self.options.contains(CoreOptions.SCAN_CREATION_TIME_MILLIS):
                 return StartupMode.FROM_CREATION_TIMESTAMP
+            elif any(self.options.contains_key(key) for key in 
CoreOptions.INCREMENTAL_SCAN_KEYS):
+                return StartupMode.INCREMENTAL
             else:
                 return StartupMode.LATEST_FULL
         elif mode == StartupMode.FULL:
@@ -1565,6 +1606,23 @@ class CoreOptions:
     def incremental_between_timestamp(self, default=None):
         return self.options.get(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP, 
default)
 
+    def incremental_between(self):
+        value = self.options.get(CoreOptions.INCREMENTAL_BETWEEN)
+        # Java String.split discards trailing empty strings.
+        parts = value.rstrip(',').split(',')
+        if len(parts) != 2:
+            raise ValueError(
+                "The incremental-between must specific start(exclusive) and 
end snapshot,"
+                " for example, 'incremental-between'='5,10' means changes 
between snapshot 5 and snapshot 10. But is: "
+                + value)
+        return parts[0], parts[1]
+
+    def incremental_between_scan_mode(self):
+        return self.options.get(CoreOptions.INCREMENTAL_BETWEEN_SCAN_MODE)
+
+    def incremental_between_tag_to_snapshot(self):
+        return 
self.options.get(CoreOptions.INCREMENTAL_BETWEEN_TAG_TO_SNAPSHOT)
+
     def scan_tag_name(self, default=None):
         return self.options.get(CoreOptions.SCAN_TAG_NAME, default)
 
diff --git a/paimon-python/pypaimon/execution/native/read/plan.py 
b/paimon-python/pypaimon/execution/native/read/plan.py
index 9525a21a61..357de9d4f2 100644
--- a/paimon-python/pypaimon/execution/native/read/plan.py
+++ b/paimon-python/pypaimon/execution/native/read/plan.py
@@ -258,6 +258,11 @@ class NativeTableScan:
         options = scan.table.options.options
         from pypaimon.snapshot.time_travel_util import SCAN_KEYS
         unsupported_scan_keys = set(SCAN_KEYS) - _NATIVE_TIME_TRAVEL_OPTIONS
+        # Snapshot/tag ranges and explicit incremental modes use the Python 
scanner.
+        unsupported_scan_keys.update((
+            CoreOptions.INCREMENTAL_BETWEEN.key(), 
CoreOptions.INCREMENTAL_BETWEEN_SCAN_MODE.key(),
+            CoreOptions.INCREMENTAL_TO_AUTO_TAG.key(),
+        ))
         return not any(options.contains_key(k) for k in unsupported_scan_keys)
 
     def _native_global_index_result_supported(self, request: 
Optional[ScanRequest] = None) -> bool:
diff --git a/paimon-python/pypaimon/execution/python/read/table_scan.py 
b/paimon-python/pypaimon/execution/python/read/table_scan.py
index 22e1dacdd9..20e16ae530 100644
--- a/paimon-python/pypaimon/execution/python/read/table_scan.py
+++ b/paimon-python/pypaimon/execution/python/read/table_scan.py
@@ -17,7 +17,9 @@
 
 """Python file scanner construction for the public TableScan."""
 
-from pypaimon.common.options.core_options import CoreOptions, ChangelogProducer
+import re
+
+from pypaimon.common.options.core_options import CoreOptions, 
ChangelogProducer, IncrementalBetweenScanMode, StartupMode
 from pypaimon.catalog.catalog_exception import TableNoPermissionException
 from pypaimon.manifest.manifest_list_manager import ManifestListManager
 from pypaimon.execution.python.read.auth_support import prune_scanner_by_auth
@@ -32,6 +34,8 @@ from pypaimon.utils.range import Range
 def _resolve_incremental_snapshot_range(table):
     """Resolve Python scan boundaries only after selecting this backend."""
     window = validate_scan_options(table)
+    if table.options.options.contains(CoreOptions.INCREMENTAL_BETWEEN):
+        return _resolve_incremental_ids(table)
     if window is None:
         return None
     start_timestamp, end_timestamp = window
@@ -60,6 +64,50 @@ def _resolve_incremental_snapshot_range(table):
     return start_id, end_id
 
 
+def _resolve_incremental_ids(table):
+    start, end = table.options.incremental_between()
+    tag_manager = table.tag_manager()
+    start_tag, end_tag = tag_manager.get(start), tag_manager.get(end)
+    if start_tag is not None and end_tag is not None:
+        start_id, end_id = start_tag.id, end_tag.id
+        if not table.options.incremental_between_tag_to_snapshot():
+            if end_id < start_id:
+                raise ValueError(
+                    "Tag end %s with snapshot id %s should be >= tag start %s 
with snapshot id %s"
+                    % (end, end_id, start, start_id))
+            if start_id == end_id:
+                return None
+            raise NotImplementedError(
+                "Incremental diff between tags is not supported in pypaimon. "
+                "Set incremental-between-tag-to-snapshot=true to scan snapshot 
deltas or changelogs.")
+        if table.options.incremental_between_scan_mode() == 
IncrementalBetweenScanMode.DIFF:
+            raise NotImplementedError("Unsupported incremental scan mode DIFF")
+    else:
+        try:
+            if any(re.fullmatch(r'[+-]?[0-9]+', value) is None for value in 
(start, end)):
+                raise ValueError()
+            start_id, end_id = int(start), int(end)
+            if not all(-(1 << 63) <= value < (1 << 63) for value in (start_id, 
end_id)):
+                raise ValueError()
+        except ValueError:
+            raise ValueError(
+                "Didn't find two tags for start '%s' and end '%s', and they 
are not two snapshot Ids. "
+                "Please set two tags or two snapshot Ids." % (start, end))
+        if end_id < start_id:
+            raise ValueError("Ending snapshotId should >= starting snapshotId 
%s. [%s]" % (end_id, start_id))
+        if table.snapshot_manager().get_latest_snapshot() is None or start_id 
== end_id:
+            return None
+
+    snapshot_manager = table.snapshot_manager()
+    latest_id = snapshot_manager.get_latest_snapshot().id
+    earliest_id = snapshot_manager.try_get_earliest_snapshot(latest_id).id
+    if start_id < earliest_id - 1 or end_id > latest_id:
+        raise ValueError(
+            "The specified scan snapshotId range [%s, %s] is out of available 
snapshotId range [%s, %s]."
+            % (start_id, end_id, earliest_id, latest_id))
+    return start_id, end_id
+
+
 class PythonTableScan:
     """Plan with Python's file scanner and the public scan configuration."""
 
@@ -78,7 +126,7 @@ class PythonTableScan:
 
         from pypaimon.snapshot.time_travel_util import TimeTravelUtil, 
SCAN_KEYS
         has_time_travel = any(options.contains_key(key) for key in SCAN_KEYS)
-        has_incremental = 
options.contains(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP)
+        has_incremental = scan.table.options.startup_mode() == 
StartupMode.INCREMENTAL
 
         if has_incremental:
             snapshot_range = _resolve_incremental_snapshot_range(scan.table)
@@ -89,9 +137,12 @@ class PythonTableScan:
                     partition_predicate=scan.partition_predicate,
                 )
             start_id, end_id = snapshot_range
-            # Java's AUTO mode uses physical changelogs when a producer is
-            # configured, and APPEND deltas otherwise.
-            changelog = scan.table.options.changelog_producer() != 
ChangelogProducer.NONE
+            mode = scan.table.options.incremental_between_scan_mode()
+            if mode == IncrementalBetweenScanMode.DIFF:
+                raise NotImplementedError("incremental-between-scan-mode 
'diff' is not supported in pypaimon.")
+            changelog = (mode == IncrementalBetweenScanMode.CHANGELOG
+                         or (mode == IncrementalBetweenScanMode.AUTO
+                             and scan.table.options.changelog_producer() != 
ChangelogProducer.NONE))
 
             def incremental_manifest():
                 snapshots_in_range = []
diff --git a/paimon-python/pypaimon/multimodal/temporal.py 
b/paimon-python/pypaimon/multimodal/temporal.py
index 1affc09c5c..7b11127f6a 100644
--- a/paimon-python/pypaimon/multimodal/temporal.py
+++ b/paimon-python/pypaimon/multimodal/temporal.py
@@ -942,8 +942,7 @@ def _pin_scan_to_snapshot(query):
         raise ValueError(
             "Temporal alignment requires 'row-tracking.enabled' = 'true'.")
     if (options.scan_mode() == StartupMode.INCREMENTAL
-            or options.options.contains(
-                CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP)):
+            or any(options.options.contains_key(key) for key in 
CoreOptions.INCREMENTAL_SCAN_KEYS)):
         raise ValueError(
             "Temporal alignment does not support incremental scans; "
             "inputs must "
diff --git a/paimon-python/pypaimon/multimodal/window_dataset.py 
b/paimon-python/pypaimon/multimodal/window_dataset.py
index f2240557e3..c697d6eac9 100644
--- a/paimon-python/pypaimon/multimodal/window_dataset.py
+++ b/paimon-python/pypaimon/multimodal/window_dataset.py
@@ -818,10 +818,9 @@ def _pin_table(table, snapshot_id):
     tag_name = table.options.scan_tag_name()
     if tag_name is not None:
         _require_tag_snapshot(table, tag_name, snapshot_id)
-    scan_keys = set(SCAN_KEYS)
+    scan_keys = set(SCAN_KEYS + CoreOptions.INCREMENTAL_SCAN_KEYS)
     scan_keys.update(option.key() for option in (
         CoreOptions.SCAN_MODE,
-        CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP,
         CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS,
         CoreOptions.SCAN_CREATION_TIME_MILLIS,
     ))
diff --git a/paimon-python/pypaimon/ray/join_common.py 
b/paimon-python/pypaimon/ray/join_common.py
index 0892ea9c08..118d4aa5f9 100644
--- a/paimon-python/pypaimon/ray/join_common.py
+++ b/paimon-python/pypaimon/ray/join_common.py
@@ -84,9 +84,10 @@ def pin_latest_snapshot(table) -> Optional[int]:
     for key in (CoreOptions.SCAN_MODE, CoreOptions.SCAN_SNAPSHOT_ID,
                 CoreOptions.SCAN_TAG_NAME, CoreOptions.SCAN_WATERMARK,
                 CoreOptions.SCAN_TIMESTAMP, CoreOptions.SCAN_TIMESTAMP_MILLIS,
-                CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP,
                 CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS,
                 CoreOptions.SCAN_CREATION_TIME_MILLIS):
         opts.data.pop(key.key(), None)
+    for key in CoreOptions.INCREMENTAL_SCAN_KEYS:
+        opts.data.pop(key, None)
     opts.set(CoreOptions.SCAN_SNAPSHOT_ID, snapshot.id)
     return snapshot.id
diff --git a/paimon-python/pypaimon/read/file_scanner.py 
b/paimon-python/pypaimon/read/file_scanner.py
index 2b958c8b41..d2f5f6458b 100644
--- a/paimon-python/pypaimon/read/file_scanner.py
+++ b/paimon-python/pypaimon/read/file_scanner.py
@@ -16,7 +16,7 @@
 
 """File-level configuration and dispatch for the public table scanner."""
 
-from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.options.core_options import StartupMode
 from pypaimon.read.scan_request import scan_partition_predicate
 from pypaimon.table.bucket_mode import BucketMode
 from pypaimon.utils.range import Range
@@ -60,7 +60,7 @@ class FileScanner:
         self._global_index_result = request.global_index_result
         self._row_ranges = (None if request.row_ranges is None else
                             [Range(start, end) for start, end in 
request.row_ranges])
-        self.is_streaming = 
self.table.options.options.contains(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP)
+        self.is_streaming = self.table.options.startup_mode() == 
StartupMode.INCREMENTAL
         self.skip_level0 = False if self.is_streaming else 
self.table.options.batch_scan_skip_level0()
         self.only_read_real_buckets = self.table.options.bucket() == 
BucketMode.POSTPONE_BUCKET.value
         self.auth_partition_predicate = None
diff --git a/paimon-python/pypaimon/read/scan_config.py 
b/paimon-python/pypaimon/read/scan_config.py
index e31106625c..e2c2cf1761 100644
--- a/paimon-python/pypaimon/read/scan_config.py
+++ b/paimon-python/pypaimon/read/scan_config.py
@@ -41,7 +41,7 @@ def _validate_scan_mode(table):
     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)
+    incremental_keys = [key for key in CoreOptions.INCREMENTAL_SCAN_KEYS if 
options.contains_key(key)]
     has_file_creation_time = 
options.contains(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS)
     has_creation_time = options.contains(CoreOptions.SCAN_CREATION_TIME_MILLIS)
 
@@ -58,8 +58,7 @@ def _validate_scan_mode(table):
         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())
+    present_keys.extend(incremental_keys)
     if has_file_creation_time:
         present_keys.append(CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key())
     if has_creation_time:
@@ -101,11 +100,11 @@ def _validate_scan_mode(table):
                 "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:
+        allowed = set(CoreOptions.INCREMENTAL_SCAN_KEYS)
+        if len(incremental_keys) != 1:
             raise ValueError(
-                "scan.mode is 'incremental' but "
-                "incremental-between-timestamp is not set."
+                "must set only one key in 
[incremental-between,incremental-between-timestamp,incremental-to-auto-tag] "
+                "when you use incremental for scan.mode"
             )
     elif mode in (StartupMode.LATEST_FULL, StartupMode.LATEST):
         allowed = set()
@@ -146,6 +145,10 @@ def validate_scan_options(table):
 
     _validate_scan_mode(table)
     options = table.options.options
+    if options.contains(CoreOptions.INCREMENTAL_TO_AUTO_TAG):
+        raise NotImplementedError("incremental-to-auto-tag is not supported in 
pypaimon.")
+    if options.contains(CoreOptions.INCREMENTAL_BETWEEN):
+        table.options.incremental_between()
     has_time_travel = any(options.contains_key(key) for key in SCAN_KEYS)
     if not options.contains(CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP):
         return None
diff --git a/paimon-python/pypaimon/table/file_store_table.py 
b/paimon-python/pypaimon/table/file_store_table.py
index 5bf2886dea..30111c91e9 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -493,8 +493,7 @@ class FileStoreTable(Table):
     def _copy_with_snapshot(self, snapshot):
         """Keep one resolved read view, including tag metadata and empty 
tables."""
         from pypaimon.snapshot.time_travel_util import SCAN_KEYS
-        selectors = SCAN_KEYS + [
-            CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP.key(),
+        selectors = SCAN_KEYS + CoreOptions.INCREMENTAL_SCAN_KEYS + [
             CoreOptions.SCAN_FILE_CREATION_TIME_MILLIS.key(),
             CoreOptions.SCAN_CREATION_TIME_MILLIS.key(),
         ]
@@ -529,9 +528,8 @@ class FileStoreTable(Table):
 
         from pypaimon.snapshot.time_travel_util import SCAN_KEYS
         preserve_snapshot = hasattr(self, "_read_snapshot") and not any(
-            key in options for key in SCAN_KEYS + [
-                CoreOptions.SCAN_MODE.key(), CoreOptions.BRANCH.key(),
-                CoreOptions.INCREMENTAL_BETWEEN_TIMESTAMP.key()])
+            key in options for key in SCAN_KEYS + 
CoreOptions.INCREMENTAL_SCAN_KEYS + [
+                CoreOptions.SCAN_MODE.key(), CoreOptions.BRANCH.key()])
 
         # Re-encode the branch into the identifier when the option changes, so
         # current_branch() and any catalog-routed snapshot commit see the
diff --git a/paimon-python/pypaimon/tests/multimodal_temporal_test.py 
b/paimon-python/pypaimon/tests/multimodal_temporal_test.py
index b65f5f7309..b8b5f30867 100644
--- a/paimon-python/pypaimon/tests/multimodal_temporal_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_temporal_test.py
@@ -2241,17 +2241,10 @@ class MultimodalTemporalTest(unittest.TestCase):
             ).to_list()
 
     def test_alignment_rejects_incremental_scans(self):
-        anchors = self.conn.create_table(
-            "incremental_anchors",
-            schema=pa.schema([
-                pa.field("episode_id", pa.int32()),
-                pa.field("event_time", pa.int64()),
-            ]),
-            options={
-                "scan.mode": "incremental",
-                "incremental-between-timestamp": "0,9999999999999",
-            },
-        )
+        anchors = self._table("incremental_anchors", {
+            "episode_id": pa.int32(),
+            "event_time": pa.int64(),
+        })
         source = self._table("incremental_source", {
             "episode_id": pa.int32(),
             "event_time": pa.int64(),
@@ -2262,13 +2255,19 @@ class MultimodalTemporalTest(unittest.TestCase):
             "episode_id": 1, "event_time": 100, "value": 7,
         }])
 
-        with self.assertRaisesRegex(
-                ValueError, "Temporal alignment.*incremental"):
-            pmm.join_asof(
-                anchors.scan(), source.scan().select("value"),
-                on="event_time", by="episode_id",
-                direction="nearest", tolerance=0,
-            ).to_list()
+        for mode in ("default", "incremental"):
+            for selector, value in (
+                    ("incremental-between", "0,1"),
+                    ("incremental-between-timestamp", "0,9999999999999"),
+                    ("incremental-to-auto-tag", "end")):
+                with self.subTest(mode=mode, selector=selector):
+                    query = 
temporal.ScanQuery(anchors.raw_table.copy({"scan.mode": mode, selector: value}))
+                    with self.assertRaisesRegex(ValueError, "Temporal 
alignment.*incremental"):
+                        pmm.join_asof(
+                            query, source.scan().select("value"),
+                            on="event_time", by="episode_id",
+                            direction="nearest", tolerance=0,
+                        ).to_list()
 
     def test_alignment_supports_zero_output_columns(self):
         anchors = self._table("zero_output_anchors", {
diff --git a/paimon-python/pypaimon/tests/native_plan_incremental_test.py 
b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
index 423fa8c899..62d90acf57 100644
--- a/paimon-python/pypaimon/tests/native_plan_incremental_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_incremental_test.py
@@ -634,3 +634,154 @@ def test_timestamp_auto_reads_physical_changelog(native, 
catalog):
     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')]
+
+
+def _read_incremental_options(table, options, configure_scanner=False):
+    builder = table.copy(options).new_read_builder()
+    scan = builder.new_scan()
+    if configure_scanner:
+        assert scan.file_scanner.is_streaming
+        assert not scan.file_scanner.skip_level0
+    plan = scan.plan()
+    return plan, builder.new_read().to_arrow(plan.splits()).to_pylist()
+
+
[email protected]('primary_key', [False, True])
[email protected]('window,expected', [
+    ('1,2', [2]), ('0,1', [1]), ('+1,02', [2]), ('1,2,', [2]),
+    ('1,3', [2, 3]), ('2,2', []), ('99,99', []),
+])
+def test_snapshot_id_window(catalog, primary_key, window, expected):
+    table = _table(catalog, 'snapshot_window', primary_key, {'bucket': '1'} if 
primary_key else None)
+    for key in range(1, 4):
+        _write(table, key * 100, [{'k': key, 'v': str(key)}])
+    plan, rows = _read_incremental_options(table, {'incremental-between': 
window}, configure_scanner=True)
+    assert sorted(row['k'] for row in rows) == expected
+    assert plan.snapshot_id == (max(expected) if expected else None)
+
+
[email protected]('window,message', [
+    ('3,1', 'Ending snapshotId should >= starting snapshotId 1. [3]'),
+    ('-1,2', 'The specified scan snapshotId range [-1, 2] is out of available 
snapshotId range [1, 5].'),
+    ('1,6', 'The specified scan snapshotId range [1, 6] is out of available 
snapshotId range [1, 5].'),
+    (',2', "Tag name shouldn't be blank."),
+])
+def test_snapshot_id_window_validation(history, window, message):
+    with pytest.raises(ValueError) as error:
+        _read_incremental_options(history, {'incremental-between': window})
+    assert str(error.value) == message
+
+
[email protected]('window', [
+    'missing,end', '1,end', ' 1,2', '1,2.0', '1_0,20',
+    '1,9223372036854775808', '-9223372036854775809,2',
+])
+def test_snapshot_id_window_invalid_ids(catalog, window):
+    table = _table(catalog, 'invalid_ids')
+    with pytest.raises(ValueError) as error:
+        _read_incremental_options(table, {'incremental-between': window})
+    start, end = window.split(',')
+    assert str(error.value) == (
+        "Didn't find two tags for start '%s' and end '%s', and they are not 
two snapshot Ids. "
+        "Please set two tags or two snapshot Ids." % (start, end))
+
+
+def test_snapshot_id_window_empty_table(catalog):
+    table = _table(catalog, 'empty_ids')
+    assert _read_incremental_options(table, {'incremental-between': 
'0,99'})[1] == []
+    with pytest.raises(ValueError, match='Ending snapshotId'):
+        _read_incremental_options(table, {'incremental-between': '2,1'})
+
+
+def test_snapshot_id_window_expired_start(history):
+    manager = history.snapshot_manager()
+    history.file_io.delete_quietly(manager.get_snapshot_path(1))
+    assert _read_incremental_options(history, {'incremental-between': 
'1,2'})[0].snapshot_id == 2
+    with pytest.raises(ValueError, match=r'available snapshotId range \[2, 
5\]'):
+        _read_incremental_options(history, {'incremental-between': '0,2'})
+
+
+def test_snapshot_id_window_earliest_beyond_default_probe(history):
+    manager = history.snapshot_manager()
+    for snapshot_id in (1, 2):
+        history.file_io.delete_quietly(manager.get_snapshot_path(snapshot_id))
+    with 
patch('pypaimon.snapshot.snapshot_manager.EARLIEST_SNAPSHOT_DEFAULT_RETRY_NUM', 
1):
+        assert _read_incremental_options(history, {'incremental-between': 
'2,3'})[0].snapshot_id == 3
+
+
[email protected]('names', [('tag1', 'tag2'), ('1', '2')])
+def test_tag_window_resolves_tags_before_snapshot_ids(history, names):
+    manager = history.snapshot_manager()
+    for name, snapshot_id in zip(names, (2, 3)):
+        
history.tag_manager().create_tag(manager.get_snapshot_by_id(snapshot_id), name)
+    plan, rows = _read_incremental_options(history, {
+        'incremental-between': ','.join(names), 
'incremental-between-tag-to-snapshot': 'true'})
+    assert plan.snapshot_id == 3
+    assert sorted((row['k'], row['v']) for row in rows) == [(1, 'latest'), (4, 
'fourth')]
+
+
+def test_tag_window_default_diff_and_ordering(history):
+    manager = history.snapshot_manager()
+    for name, snapshot_id in [('start', 1), ('end', 3)]:
+        
history.tag_manager().create_tag(manager.get_snapshot_by_id(snapshot_id), name)
+    with pytest.raises(NotImplementedError, match='diff'):
+        _read_incremental_options(history, {'incremental-between': 
'start,end'})
+    with pytest.raises(ValueError) as error:
+        _read_incremental_options(history, {'incremental-between': 
'end,start'})
+    assert str(error.value) == 'Tag end start with snapshot id 1 should be >= 
tag start end with snapshot id 3'
+    assert _read_incremental_options(history, {'incremental-between': 
'start,start'})[1] == []
+    with pytest.raises(ValueError, match='Please set two tags or two snapshot 
Ids'):
+        _read_incremental_options(history, {'incremental-between': 'start,3'})
+
+
+def test_tag_window_uses_branch_history(catalog):
+    table = _table(catalog, 'tag_branch')
+    _write(table, 100, [{'k': 1, 'v': 'base'}])
+    table.create_tag('base')
+    catalog.create_branch(table.identifier, 'test', tag_name='base')
+    branch = catalog.get_table(Identifier('default', 'tag_branch', 
branch='test'))
+    for current, value in ((table, 'main'), (branch, 'branch')):
+        _write(current, 200, [{'k': 2, 'v': value}])
+        current.create_tag('end')
+    _, rows = _read_incremental_options(branch, {
+        'incremental-between': 'base,end', 
'incremental-between-tag-to-snapshot': 'true'})
+    assert rows == [{'k': 2, 'v': 'branch'}]
+
+
[email protected]('selector,window', [
+    ('incremental-between', '1,3'), ('incremental-between-timestamp', 
'100,300'),
+])
[email protected]('producer', ['none', 'input'])
[email protected]('mode', ['auto', 'delta', 'changelog', 'diff', 
'invalid'])
+def test_incremental_scan_mode(catalog, selector, window, producer, mode):
+    table = _table(catalog, 'scan_modes', True, {'bucket': '1', 
'changelog-producer': producer})
+    for timestamp, value in ((100, '1'), (200, '2'), (300, '1')):
+        _write(table, timestamp, [{'k': 1, 'v': value}])
+    options = {selector: window, 'incremental-between-scan-mode': mode}
+    if mode in ('diff', 'invalid'):
+        with pytest.raises(NotImplementedError if mode == 'diff' else 
ValueError, match=mode):
+            _read_incremental_options(table, options)
+        return
+    plan, rows = _read_incremental_options(table, options)
+    assert sorted(row['v'] for row in rows) == ([] if mode == 'changelog' and 
producer == 'none' else ['1', '2'])
+    changelog = mode == 'changelog' or (mode == 'auto' and producer != 'none')
+    assert all(file.file_name.startswith('changelog-') == changelog
+               for split in plan.splits() for file in split.files)
+
+
+def test_incremental_scan_mode_skips_compact_and_overwrite(history):
+    manager = history.snapshot_manager()
+    path = manager.get_snapshot_path(3)
+    snapshot = json.loads(history.file_io.read_file_utf8(path))
+    snapshot['commitKind'] = 'COMPACT'
+    history.file_io.overwrite_file_utf8(path, json.dumps(snapshot))
+    plan, rows = _read_incremental_options(history, {'incremental-between': 
'1,5'})
+    assert plan.snapshot_id == 5
+    assert sorted((row['k'], row['v']) for row in rows) == [(1, 
'intermediate'), (3, 'third'), (5, 'fifth')]
+
+
[email protected]('mode', ['default', 'incremental'])
+def test_incremental_to_auto_tag_is_explicitly_unsupported(catalog, mode):
+    table = _table(catalog, 'auto_tag')
+    with pytest.raises(NotImplementedError, match='incremental-to-auto-tag'):
+        _read_incremental_options(table, {'scan.mode': mode, 
'incremental-to-auto-tag': '2026-10-27'})
diff --git a/paimon-python/pypaimon/tests/native_plan_test.py 
b/paimon-python/pypaimon/tests/native_plan_test.py
index f8a35522bf..321fcfc7fa 100644
--- a/paimon-python/pypaimon/tests/native_plan_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_test.py
@@ -1359,6 +1359,19 @@ class NativePlanTest(unittest.TestCase):
         self.assertNotIn('incremental_range', native.call_args[1])
         fs.scan.assert_not_called()
 
+    def test_incremental_selectors_use_python_planning(self):
+        for options in ({'incremental-between': '1,2'},
+                        {'incremental-between-timestamp': '100,200', 
'incremental-between-scan-mode': 'changelog'},
+                        {'incremental-to-auto-tag': 'tag2'}):
+            with self.subTest(options=options):
+                fs = Mock(partition_key_predicate=None)
+                scan = _scan(True, fs)
+                scan.table.options.options = Options(options)
+                with patch('pypaimon.execution.native.read.plan.native_plan') 
as native:
+                    scan.plan()
+                native.assert_not_called()
+                fs.scan.assert_called_once()
+
     def test_native_plan_forwards_explicit_incremental_mode(self):
         table = _scan(True, Mock()).table
         table.table_schema = Mock(fields=[], partition_keys=[])
diff --git a/paimon-python/pypaimon/tests/table/file_store_table_test.py 
b/paimon-python/pypaimon/tests/table/file_store_table_test.py
index 0786984af8..1bac930648 100644
--- a/paimon-python/pypaimon/tests/table/file_store_table_test.py
+++ b/paimon-python/pypaimon/tests/table/file_store_table_test.py
@@ -104,6 +104,25 @@ class FileStoreTableTest(unittest.TestCase):
                 CoreOptions.SCAN_SNAPSHOT_ID.key()],
         )
 
+    def test_snapshot_pin_clears_incremental_selectors(self):
+        for selector in ('incremental-between', 
'incremental-between-timestamp', 'incremental-to-auto-tag'):
+            with self.subTest(selector=selector):
+                selected = self.table.copy({selector: '1,2'})
+                pinned = selected._copy_with_snapshot(None)
+                self.assertNotIn(selector, pinned.table_schema.options)
+                self.assertIsNone(pinned._read_snapshot)
+                self.assertEqual([], 
pinned.new_read_builder().new_scan().plan().splits())
+                self.assertIn(selector, selected.table_schema.options)
+
+    def test_incremental_selector_copy_invalidates_pinned_snapshot(self):
+        pinned = self.table._copy_with_snapshot(None)
+        for selector in ('incremental-between', 
'incremental-between-timestamp', 'incremental-to-auto-tag'):
+            for copy in (pinned.copy, pinned.copy_without_time_travel):
+                with self.subTest(selector=selector, copy=copy.__name__):
+                    selected = copy({selector: '1,2'})
+                    self.assertFalse(hasattr(selected, '_read_snapshot'))
+        self.assertIsNone(pinned.copy({'read.batch-size': '1'})._read_snapshot)
+
     def test_consumer_manager(self):
         """Test that FileStoreTable has consumer_manager method."""
         # Get consumer_manager
diff --git a/paimon-python/pypaimon/tests/table_scan_mode_test.py 
b/paimon-python/pypaimon/tests/table_scan_mode_test.py
index c33fdf9beb..0b66785e2f 100644
--- a/paimon-python/pypaimon/tests/table_scan_mode_test.py
+++ b/paimon-python/pypaimon/tests/table_scan_mode_test.py
@@ -69,6 +69,35 @@ class TableScanModeTest(unittest.TestCase):
         self.assertEqual(options.startup_mode(), StartupMode.FROM_SNAPSHOT)
         _scan(options.options.to_map())._validate_scan_mode()
 
+    def test_incremental_selector_startup_mode(self):
+        for key in ('incremental-between', 'incremental-between-timestamp', 
'incremental-to-auto-tag'):
+            with self.subTest(key=key):
+                scan = _scan({key: '1,2'})
+                self.assertEqual(scan.table.options.startup_mode(), 
StartupMode.INCREMENTAL)
+                scan._validate_scan_mode()
+
+    def test_incremental_selector_requires_exactly_one_range(self):
+        for ranges in ({}, {'incremental-between': '1,2', 
'incremental-between-timestamp': '100,200'},
+                       {'incremental-between': '1,2', 
'incremental-to-auto-tag': 'tag2'}):
+            with self.subTest(ranges=ranges):
+                scan = _scan(dict(ranges, **{'scan.mode': 'incremental'}))
+                with self.assertRaisesRegex(ValueError, 'must set only one 
key'):
+                    scan._validate_scan_mode()
+
+    def test_incremental_selector_conflicts_with_time_travel(self):
+        for key in ('incremental-between', 'incremental-to-auto-tag'):
+            for scan_options in ({'scan.snapshot-id': '1'}, {'scan.mode': 
'latest-full'}):
+                with self.subTest(key=key, scan_options=scan_options):
+                    with self.assertRaisesRegex(ValueError, key):
+                        _scan(dict(scan_options, **{key: 
'1,2'}))._validate_scan_mode()
+
+    def test_incremental_between_format(self):
+        for value in ('', '1', '1,', ',', '1,2,3'):
+            with self.subTest(value=value):
+                with self.assertRaisesRegex(ValueError, 'The 
incremental-between must specific'):
+                    CoreOptions.from_dict({'incremental-between': 
value}).incremental_between()
+        self.assertEqual(CoreOptions.from_dict({'incremental-between': 
'1,2,'}).incremental_between(), ('1', '2'))
+
     def test_unsupported_scan_modes_raise_value_error(self):
         scan = _scan({
             CoreOptions.SCAN_MODE.key(): StartupMode.COMPACTED_FULL.value,
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py 
b/paimon-python/pypaimon/tests/write/table_write_test.py
index 85609c222b..dabfa5898a 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -522,6 +522,28 @@ class TableWriteTest(unittest.TestCase):
         self.assertEqual(True, snapshot_json.__contains__("writerVersion"))
         self.assertEqual(False, snapshot_json.__contains__("nextRowId"))
 
+    @parameterized.expand([
+        ('incremental-between', '1,2'),
+        ('incremental-between-timestamp', '0,9999999999999'),
+        ('incremental-to-auto-tag', 'end'),
+    ])
+    def test_write_through_incremental_copy_restores_latest_sequence(self, 
selector, value):
+        name = 'default.write_' + selector.replace('-', '_')
+        schema = pa.schema([pa.field('id', pa.int64(), nullable=False), 
('value', pa.int64())])
+        self.catalog.create_table(name, Schema.from_pyarrow_schema(
+            schema, primary_keys=['id'], options={'bucket': '1'}), False)
+        table = self.catalog.get_table(name)
+        for number in range(3):
+            self._commit_arrow(table, pa.Table.from_pydict({'id': [1], 
'value': [number]}, schema=schema))
+
+        selected = table.copy({selector: value})
+        self._commit_arrow(selected, pa.Table.from_pydict({'id': [1], 'value': 
[3]}, schema=schema))
+
+        self.assertEqual([{'id': 1, 'value': 3}], self._read_sorted(table, 
'id').to_pylist())
+        splits = table.new_read_builder().new_scan().plan().splits()
+        self.assertEqual(3, max(file.max_sequence_number for split in splits 
for file in split.files))
+        self.assertEqual(value, selected.table_schema.options[selector])
+
     def test_write_row_append_only_partitioned_table(self):
         from pypaimon.table.row.generic_row import GenericRow
 

Reply via email to