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 3b889d239f [python] Reduce memory usage for overlapping temporal 
windows (#10000)
3b889d239f is described below

commit 3b889d239fe54a8673d87936c2f3fe00c11474f9
Author: QuakeWang <[email protected]>
AuthorDate: Sun Sep 20 15:58:59 2026 +0800

    [python] Reduce memory usage for overlapping temporal windows (#10000)
---
 paimon-python/pypaimon/multimodal/temporal.py      | 45 ++++++------
 .../pypaimon/tests/multimodal_temporal_test.py     | 79 +++++++++++++++++++---
 2 files changed, 95 insertions(+), 29 deletions(-)

diff --git a/paimon-python/pypaimon/multimodal/temporal.py 
b/paimon-python/pypaimon/multimodal/temporal.py
index 93f118d94b..50af264bfb 100644
--- a/paimon-python/pypaimon/multimodal/temporal.py
+++ b/paimon-python/pypaimon/multimodal/temporal.py
@@ -591,11 +591,11 @@ class _WindowJoinRight(_AsOfJoinRight):
                 metadata=source.metadata))
         return fields
 
-    def match(self, anchor_row):
+    def _match_range(self, anchor_row):
         key = tuple(anchor_row[name] for name in self.by)
         bounds = self._index.get(key)
         if bounds is None:
-            return []
+            return 0, 0
         target = anchor_row[_TIME_KEY]
         start, end = bounds
         left = target - self._preceding_key
@@ -616,7 +616,7 @@ class _WindowJoinRight(_AsOfJoinRight):
             first_key = max(first_key, _python_scalar(self._time_keys[start]))
             last_key = min(last_key, _python_scalar(self._time_keys[end - 1]))
             if first_key > last_key:
-                return []
+                return 0, 0
             first = bisect_left(
                 self._time_keys, first_key, start, end)
             last = bisect_right(
@@ -632,21 +632,29 @@ class _WindowJoinRight(_AsOfJoinRight):
                 if self.closed in ("both", "right")
                 else bisect_left(self._time_keys, right, first, end)
             )
-        return [self._row_ids[index].as_py()
-                for index in range(first, last)]
+        return first, last
 
     def build_arrays(self, anchor_rows, fetcher):
-        matches = [self.match(row) for row in anchor_rows]
-        unique_ids = list(dict.fromkeys(
-            row_id for match in matches for row_id in match))
+        ranges = [self._match_range(row) for row in anchor_rows]
+        slices = [(0, 0)] * len(ranges)
+        unique_ids = []
+        covered_end = 0
+        offset = 0
+        # Ranges refer to sorted metadata positions, not contiguous row IDs.
+        # Fetch their union in that order so each window becomes an Arrow 
slice.
+        for index, (start, end) in sorted(
+                enumerate(ranges), key=lambda item: item[1]):
+            if start == end:
+                continue
+            if start >= covered_end:
+                offset = len(unique_ids) - start
+            unread_start = max(start, covered_end)
+            if end > unread_start:
+                unique_ids.extend(
+                    self._row_ids.slice(unread_start, end - 
unread_start).to_pylist())
+                covered_end = end
+            slices[index] = (start + offset, end - start)
         values = fetcher.fetch(unique_ids)
-        positions = {
-            row_id: index for index, row_id in enumerate(unique_ids)
-        }
-        indices = [
-            [positions[row_id] for row_id in match]
-            for match in matches
-        ]
         aggregations_by_source = {}
         for index, (_, source_name, aggregation) in 
enumerate(self.aggregations):
             aggregations_by_source.setdefault(source_name, []).append(
@@ -661,11 +669,8 @@ class _WindowJoinRight(_AsOfJoinRight):
             ]
             source_values = values[source_name]
             results = [[] for _ in aggregations]
-            for row_indices in indices:
-                selected = (
-                    pc.take(source_values, pa.array(row_indices, 
type=pa.int64()))
-                    if row_indices else source_values.slice(0, 0)
-                )
+            for start, length in slices:
+                selected = source_values.slice(start, length)
                 for (_, aggregation), result in zip(aggregations, results):
                     result.append(_aggregate_values(selected, aggregation))
                 del selected
diff --git a/paimon-python/pypaimon/tests/multimodal_temporal_test.py 
b/paimon-python/pypaimon/tests/multimodal_temporal_test.py
index 50d8e92bcb..6ffbc80368 100644
--- a/paimon-python/pypaimon/tests/multimodal_temporal_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_temporal_test.py
@@ -19,6 +19,7 @@ import os
 import shutil
 import sys
 import tempfile
+import tracemalloc
 import unittest
 from datetime import datetime, timedelta
 from unittest import mock
@@ -376,7 +377,7 @@ class MultimodalTemporalTest(unittest.TestCase):
         self.assertIsNone(rows[2]["average"])
         self.assertEqual(0, rows[2]["valid_count"])
 
-    def test_window_join_reuses_gathered_values(self):
+    def test_window_join_reuses_arrow_slices(self):
         source = temporal._WindowJoinRight.__new__(temporal._WindowJoinRight)
         source.by = ("group",)
         source._index = {(1,): (0, 3), (2,): (3, 4)}
@@ -425,14 +426,74 @@ class MultimodalTemporalTest(unittest.TestCase):
                 for (_, _, operation), array in zip(aggregations, arrays):
                     self.assertEqual(expected[operation].type, array.type)
                     self.assertEqual(expected[operation], array)
-                source_names = {name for _, name, _ in aggregations}
-                self.assertEqual(3 * len(source_names), take.call_count)
-                for name in source_names:
-                    gathered_indices = [
-                        call[0][1].to_pylist() for call in take.call_args_list
-                        if call[0][0].equals(values[name])
-                    ]
-                    self.assertEqual([[0, 1], [1, 2], [3]], gathered_indices)
+                take.assert_not_called()
+
+    def test_window_join_preserves_order_across_disjoint_ranges(self):
+        anchors = self._table("range_anchors", {
+            "episode_id": pa.int32(), "event_time": pa.int64(),
+        })
+        samples = self._table("range_samples", {
+            "episode_id": pa.int32(), "event_time": pa.int64(),
+            "label": pa.string(),
+        })
+        # Physical row IDs are neither contiguous nor ordered within a window.
+        samples.add([
+            {"episode_id": 1, "event_time": 12, "label": "late"},
+            {"episode_id": 2, "event_time": 2, "label": "other"},
+            {"episode_id": 1, "event_time": 2, "label": "first"},
+            {"episode_id": 1, "event_time": 6, "label": "excluded"},
+            {"episode_id": 1, "event_time": 2, "label": None},
+            {"episode_id": 1, "event_time": 2, "label": "last"},
+            {"episode_id": 1, "event_time": 1, "label": "early"},
+        ])
+        anchors.add([
+            {"episode_id": group, "event_time": time}
+            for group, time in [(1, 12), (1, 3), (2, 2), (1, 2),
+                                (1, 2), (3, 2), (1, 20)]
+        ])
+        query = pmm.join_window(
+            anchors.scan(), samples.scan().select("label"),
+            on="event_time", by="episode_id", preceding=1,
+            aggregations={
+                "first": ("label", "first"),
+                "last": ("label", "last"),
+                "count": ("label", "count"),
+            })
+        rows = query.to_list()
+        self.assertEqual([
+            ("late", "late", 1), ("first", "last", 2),
+            ("other", "other", 1), ("early", "last", 3),
+            ("early", "last", 3), (None, None, 0), (None, None, 0),
+        ], [(row["first"], row["last"], row["count"]) for row in rows])
+
+    def test_window_join_bounds_overlapping_window_memory(self):
+        size = 1024
+        source = temporal._WindowJoinRight.__new__(temporal._WindowJoinRight)
+        source.by = ("group",)
+        source._index = {(1,): (0, size)}
+        source._time_keys = np.arange(size, dtype=np.int64)
+        source._row_ids = pa.array(range(10000, 10000 + size), type=pa.int64())
+        source.time_type = pa.int64()
+        source._preceding_key = size - 1
+        source._following_key = 0
+        source.closed = "both"
+        source.aggregations = (("count", "value", "count"),)
+        values = pa.table({"value": pa.array(range(size), type=pa.int64())})
+        fetcher = mock.Mock(schema=values.schema)
+        fetcher.fetch.return_value = values
+        anchors = [{"group": 1, temporal._TIME_KEY: size - 1}] * 256
+
+        tracemalloc.start()
+        try:
+            arrays = source.build_arrays(anchors, fetcher)
+            _, peak = tracemalloc.get_traced_memory()
+        finally:
+            tracemalloc.stop()
+
+        self.assertEqual([size] * len(anchors), arrays[0].to_pylist())
+        fetcher.fetch.assert_called_once_with(list(range(10000, 10000 + size)))
+        # Per-window row-ID and position lists alone exceed this budget.
+        self.assertLess(peak, 4 * 1024 * 1024)
 
     def test_window_join_supports_asymmetric_timestamp_bounds(self):
         anchors = self._table("window_timestamp_anchors", {

Reply via email to