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 85096ccc5d [python] data-evolution: speed up split planning from 
O(n^2) to O(n log n) (#8477)
85096ccc5d is described below

commit 85096ccc5d962ce65b77f79cc70a48c151747ab0
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Jul 6 17:51:11 2026 +0800

    [python] data-evolution: speed up split planning from O(n^2) to O(n log n) 
(#8477)
    
    DataEvolutionSplitGenerator._split_by_row_id (split planning) grouped a
    DE table's files by scanning every merged range per file -> O(n^2), the
    only quadratic step in planning; planning is single-threaded, so every
    DE read that plans splits pays it.
---
 .../pypaimon/manifest/schema/data_file_meta.py     |   6 ++
 .../read/scanner/chunk_shuffle_split_generator.py  |  26 ++---
 .../read/scanner/data_evolution_split_generator.py |  24 +----
 .../tests/data_evolution_split_generator_test.py   | 116 +++++++++++++++++++++
 .../scanner/chunk_shuffle_split_generator_test.py  |  21 ++++
 5 files changed, 156 insertions(+), 37 deletions(-)

diff --git a/paimon-python/pypaimon/manifest/schema/data_file_meta.py 
b/paimon-python/pypaimon/manifest/schema/data_file_meta.py
index e01e097ce0..4cc9a3a935 100644
--- a/paimon-python/pypaimon/manifest/schema/data_file_meta.py
+++ b/paimon-python/pypaimon/manifest/schema/data_file_meta.py
@@ -60,6 +60,12 @@ class DataFileMeta:
             return None
         return Range(self.first_row_id, self.first_row_id + self.row_count - 1)
 
+    def non_null_row_id_range(self) -> Range:
+        """Row-id range, failing fast if first_row_id is null (mirrors Java 
nonNullRowIdRange)."""
+        if self.first_row_id is None:
+            raise ValueError(f"First row id of '{self.file_name}' should not 
be null.")
+        return Range(self.first_row_id, self.first_row_id + self.row_count - 1)
+
     def get_creation_time(self) -> Optional[Timestamp]:
         return self.creation_time
 
diff --git 
a/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py 
b/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py
index 504236e500..6493b2e6fb 100644
--- a/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py
+++ b/paimon-python/pypaimon/read/scanner/chunk_shuffle_split_generator.py
@@ -29,6 +29,7 @@ from pypaimon.read.sliced_split import SlicedSplit
 from pypaimon.read.split import DataSplit, Split
 from pypaimon.table.row.generic_row import GenericRow
 from pypaimon.utils.range import Range
+from pypaimon.utils.range_helper import RangeHelper
 
 
 def _null_safe_partition_key(partition_values) -> tuple:
@@ -353,25 +354,16 @@ class 
DataEvolutionChunkShuffleSplitGenerator(ChunkShuffleSplitGeneratorBase):
         also returns the merged row_id range per group, which the chunk
         slicer needs to drive row-count accumulation.
         """
-        list_ranges = []
         for f in files:
-            file_range = f.row_id_range()
-            if file_range is None:
+            if f.row_id_range() is None:
                 raise ValueError(
                     "chunk_shuffle for data evolution tables requires row 
tracking; "
                     f"file {f.file_name} is missing first_row_id"
                 )
-            list_ranges.append(file_range)
-        if not list_ranges:
-            return []
-        sorted_ranges = Range.sort_and_merge_overlap(list_ranges, True, False)
-
-        range_to_files: "dict[Range, List[DataFileMeta]]" = {}
-        for f in files:
-            file_range = f.row_id_range()
-            for r in sorted_ranges:
-                if r.overlaps(file_range):
-                    range_to_files.setdefault(r, []).append(f)
-                    break
-
-        return sorted(range_to_files.items(), key=lambda kv: kv[0].from_)
+        groups = RangeHelper(lambda f: 
f.row_id_range()).merge_overlapping_ranges(files)
+        result = []
+        for group in groups:
+            ranges = [f.row_id_range() for f in group]
+            merged = Range(min(r.from_ for r in ranges), max(r.to for r in 
ranges))
+            result.append((merged, group))
+        return sorted(result, key=lambda kv: kv[0].from_)
diff --git 
a/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py 
b/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py
index 7886e93234..60a8d9c700 100644
--- a/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py
+++ b/paimon-python/pypaimon/read/scanner/data_evolution_split_generator.py
@@ -20,6 +20,7 @@ from typing import List, Optional, Tuple
 
 from pypaimon.globalindex.indexed_split import IndexedSplit
 from pypaimon.utils.range import Range
+from pypaimon.utils.range_helper import RangeHelper
 from pypaimon.manifest.schema.data_file_meta import DataFileMeta
 from pypaimon.manifest.schema.manifest_entry import ManifestEntry
 from pypaimon.read.scanner.split_generator import AbstractSplitGenerator
@@ -296,26 +297,9 @@ class DataEvolutionSplitGenerator(AbstractSplitGenerator):
 
     @staticmethod
     def _split_by_row_id(files: List[DataFileMeta]) -> 
List[List[DataFileMeta]]:
-        """
-        Split files by row ID for data evolution tables.
-        Files are grouped by their overlapping row ID ranges.
-        """
-        list_ranges = [file.row_id_range() for file in files]
-
-        if not list_ranges:
-            return []
-
-        sorted_ranges = Range.sort_and_merge_overlap(list_ranges, True, False)
-
-        range_to_files = {}
-        for file in files:
-            file_range = file.row_id_range()
-            for r in sorted_ranges:
-                if r.overlaps(file_range):
-                    range_to_files.setdefault(r, []).append(file)
-                    break
-
-        return list(range_to_files.values())
+        """Group data-evolution files with overlapping row-id ranges (an 
original file and its
+        column deltas) via the shared RangeHelper; fails fast on a file 
missing first_row_id."""
+        return RangeHelper(lambda f: 
f.non_null_row_id_range()).merge_overlapping_ranges(files)
 
     def _wrap_to_indexed_splits(self, splits: List[Split], row_ranges: 
List[Range]) -> List[Split]:
         """
diff --git 
a/paimon-python/pypaimon/tests/data_evolution_split_generator_test.py 
b/paimon-python/pypaimon/tests/data_evolution_split_generator_test.py
new file mode 100644
index 0000000000..dbb0a756cc
--- /dev/null
+++ b/paimon-python/pypaimon/tests/data_evolution_split_generator_test.py
@@ -0,0 +1,116 @@
+#  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.
+
+import random
+import unittest
+
+from pypaimon.read.scanner.data_evolution_split_generator import 
DataEvolutionSplitGenerator
+from pypaimon.utils.range import Range
+
+
+class _F:
+    def __init__(self, tag: int, from_: int = None, to: int = None):
+        self.tag = tag
+        self.file_name = f"f{tag}"
+        self._range = Range(from_, to) if from_ is not None else None
+
+    def row_id_range(self):
+        return self._range
+
+    def non_null_row_id_range(self) -> Range:
+        if self._range is None:
+            raise ValueError(f"First row id of '{self.file_name}' should not 
be null.")
+        return self._range
+
+
+def _reference_split(files):
+    """The original O(n^2) linear scan, kept to lock equivalence."""
+    list_ranges = [f.row_id_range() for f in files]
+    if not list_ranges:
+        return []
+    sorted_ranges = Range.sort_and_merge_overlap(list_ranges, True, False)
+    range_to_files = {}
+    for f in files:
+        file_range = f.row_id_range()
+        for r in sorted_ranges:
+            if r.overlaps(file_range):
+                range_to_files.setdefault(r, []).append(f)
+                break
+    return list(range_to_files.values())
+
+
+def _shape(groups):
+    """Group structure by file tag: locks grouping + order."""
+    return [[f.tag for f in g] for g in groups]
+
+
+def _grouping(groups):
+    """Files grouped together, ignoring order -- the functional invariant."""
+    return {frozenset(f.tag for f in g) for g in groups}
+
+
+class SplitByRowIdEquivalenceTest(unittest.TestCase):
+    def test_empty(self):
+        self.assertEqual(DataEvolutionSplitGenerator._split_by_row_id([]), [])
+
+    def test_raises_on_file_missing_first_row_id(self):
+        # A file without first_row_id must fail fast with a readable error.
+        with self.assertRaisesRegex(ValueError, "should not be null"):
+            DataEvolutionSplitGenerator._split_by_row_id([_F(0, 0, 4), _F(1)])
+
+    def test_disjoint_files_each_its_own_group(self):
+        files = [_F(0, 0, 4), _F(1, 5, 9), _F(2, 10, 14)]
+        
self.assertEqual(_shape(DataEvolutionSplitGenerator._split_by_row_id(files)),
+                         [[0], [1], [2]])
+
+    def test_evolution_delta_grouped_with_original(self):
+        original = _F(0, 0, 9)
+        delta = _F(1, 3, 5)  # sub-range of original -> same merged range
+        groups = DataEvolutionSplitGenerator._split_by_row_id([original, 
delta])
+        self.assertEqual(_shape(groups), [[0, 1]])
+
+    def test_groups_ordered_by_range_start(self):
+        files = [_F(0, 10, 14), _F(1, 0, 4), _F(2, 5, 9)]  # unsorted input
+        
self.assertEqual(_shape(DataEvolutionSplitGenerator._split_by_row_id(files)),
+                         [[1], [2], [0]])  # groups come out ordered by range 
start
+
+    def test_matches_reference_grouping_on_random_inputs(self):
+        rng = random.Random(1234)
+        for _ in range(1000):
+            n = rng.randint(0, 50)
+            files, cursor = [], 0
+            for tag in range(n):
+                roll = rng.random()
+                if roll < 0.6:            # disjoint
+                    from_ = cursor + rng.randint(1, 5)
+                    to = from_ + rng.randint(0, 10)
+                elif roll < 0.85:         # overlapping (evolution-like)
+                    from_ = rng.randint(max(0, cursor - 8), max(0, cursor))
+                    to = from_ + rng.randint(0, 6)
+                else:                     # duplicate / same start
+                    from_ = rng.randint(0, cursor + 1)
+                    to = from_ + rng.randint(0, 12)
+                cursor = max(cursor, to)
+                files.append(_F(tag, from_, to))
+            rng.shuffle(files)
+            self.assertEqual(
+                _grouping(DataEvolutionSplitGenerator._split_by_row_id(files)),
+                _grouping(_reference_split(files)))
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git 
a/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py 
b/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
index be50cfa637..ce69096d7c 100644
--- a/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
+++ b/paimon-python/pypaimon/tests/scanner/chunk_shuffle_split_generator_test.py
@@ -107,6 +107,15 @@ def _mock_de_entry(partition_values, bucket, file_name, 
first_row_id, row_count,
     return entry
 
 
+def _de_file(file_name, first_row_id, row_count):
+    """A DE file mock; row_id_range() is a real Range, or None when 
first_row_id is None."""
+    file = Mock(spec=DataFileMeta)
+    file.file_name = file_name
+    rng = Range(first_row_id, first_row_id + row_count - 1) if first_row_id is 
not None else None
+    file.row_id_range = lambda r=rng: r
+    return file
+
+
 def _split_signature(split):
     """A stable, comparable identity for a split — what the worker would 
actually read."""
     if isinstance(split, SlicedSplit):
@@ -532,6 +541,18 @@ class DataEvolutionChunkShuffleAlgoTest(unittest.TestCase):
         gen = _make_de_generator(seed=1, chunk_size=100)
         self.assertEqual(gen.create_splits([]), [])
 
+    def test_split_by_row_id_with_range_groups_and_merges(self):
+        # a[0,4] & c[2,3] overlap -> one merged group [0,4]; b[10,14] -> 
another. Input unsorted.
+        files = [_de_file("a", 0, 5), _de_file("b", 10, 5), _de_file("c", 2, 
2)]
+        out = 
DataEvolutionChunkShuffleSplitGenerator._split_by_row_id_with_range(files)
+        self.assertEqual([(r.from_, r.to) for r, _ in out], [(0, 4), (10, 14)])
+        self.assertEqual([[f.file_name for f in fs] for _, fs in out], [["a", 
"c"], ["b"]])
+
+    def test_split_by_row_id_with_range_raises_on_missing_first_row_id(self):
+        files = [_de_file("a", 0, 5), _de_file("b", None, 5)]
+        with self.assertRaisesRegex(ValueError, "requires row tracking"):
+            
DataEvolutionChunkShuffleSplitGenerator._split_by_row_id_with_range(files)
+
     def test_full_aligned_groups_one_per_chunk(self):
         # Three commits of 100 rows each → three aligned groups.
         # chunk_size = 100 → 3 chunks, each holding one group whole.

Reply via email to