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 b277ab0e3a [python] Avoid full-window conversion for first and last 
aggregates (#10016)
b277ab0e3a is described below

commit b277ab0e3af6224a4752c94abf088f2aa6c92ba7
Author: QuakeWang <[email protected]>
AuthorDate: Mon Sep 21 10:40:34 2026 +0800

    [python] Avoid full-window conversion for first and last aggregates (#10016)
---
 paimon-python/pypaimon/multimodal/temporal.py      | 21 ++++++--
 .../pypaimon/tests/multimodal_temporal_test.py     | 61 ++++++++++++++++++++++
 2 files changed, 78 insertions(+), 4 deletions(-)

diff --git a/paimon-python/pypaimon/multimodal/temporal.py 
b/paimon-python/pypaimon/multimodal/temporal.py
index 9f8a2d5141..deac1f125a 100644
--- a/paimon-python/pypaimon/multimodal/temporal.py
+++ b/paimon-python/pypaimon/multimodal/temporal.py
@@ -755,10 +755,23 @@ def _aggregate_values(selected, aggregation):
         return pc.min(selected).as_py()
     if aggregation == "max":
         return pc.max(selected).as_py()
-    items = selected.to_pylist()
-    if aggregation == "first":
-        return next((item for item in items if item is not None), None)
-    return next((item for item in reversed(items) if item is not None), None)
+    chunks = selected.chunks if isinstance(selected, pa.ChunkedArray) else 
(selected,)
+    if aggregation == "last":
+        chunks = reversed(chunks)
+    for chunk in chunks:
+        if chunk.null_count == len(chunk):
+            continue
+        indices = range(len(chunk))
+        if aggregation == "last":
+            indices = reversed(indices)
+        for index in indices:
+            scalar = chunk[index]
+            if scalar.is_valid:
+                value = scalar.as_py()
+                # A valid dictionary index can still reference a null value.
+                if value is not None:
+                    return value
+    return None
 
 
 def _window_bound_key(name, value, data_type):
diff --git a/paimon-python/pypaimon/tests/multimodal_temporal_test.py 
b/paimon-python/pypaimon/tests/multimodal_temporal_test.py
index c34acf0197..74eb806885 100644
--- a/paimon-python/pypaimon/tests/multimodal_temporal_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_temporal_test.py
@@ -495,6 +495,67 @@ class MultimodalTemporalTest(unittest.TestCase):
         # Per-window row-ID and position lists alone exceed this budget.
         self.assertLess(peak, 4 * 1024 * 1024)
 
+    def test_window_endpoints_preserve_values_across_chunks(self):
+        cases = [
+            (pa.string(), [None, "", "last", None], "", "last"),
+            (pa.binary(), [None, b"", b"last", None], b"", b"last"),
+            (pa.bool_(), [None, False, True, None], False, True),
+            (pa.int64(), [None, 0, -1, None], 0, -1),
+            (pa.float64(), [None, float("nan"), 1.0, None], float("nan"), 1.0),
+            (pa.list_(pa.int64()), [None, [], [1, None], None], [], [1, None]),
+            (pa.struct([pa.field("a", pa.int64())]),
+             [None, {"a": None}, {"a": 0}, None], {"a": None}, {"a": 0}),
+            (pa.string(), [None, None], None, None),
+            (pa.string(), [], None, None),
+        ]
+        for data_type, values, first, last in cases:
+            array = pa.array(values, type=data_type)
+            empty = pa.array([], type=data_type)
+            padded = pa.concat_arrays([
+                pa.nulls(1, type=data_type), array,
+                pa.nulls(1, type=data_type)])
+            chunks = pa.chunked_array([
+                empty, array.slice(0, 1), empty, array.slice(1), empty])
+            sliced_chunks = pa.chunked_array([
+                padded.slice(0, 2), empty, padded.slice(2), empty,
+            ]).slice(1, len(array))
+            for selected in (array, padded.slice(1, len(array)),
+                             chunks, sliced_chunks):
+                for operation, expected in (("first", first), ("last", last)):
+                    with self.subTest(data_type=data_type, operation=operation,
+                                      selected=selected):
+                        result = temporal._aggregate_values(selected, 
operation)
+                        if isinstance(expected, float) and np.isnan(expected):
+                            self.assertTrue(np.isnan(result))
+                        else:
+                            self.assertEqual(expected, result)
+
+    def test_window_endpoints_skip_null_dictionary_values(self):
+        array = pa.DictionaryArray.from_arrays(
+            pa.array([0, 1, 0], type=pa.int8()),
+            pa.array([None, "value"], type=pa.string()))
+        for selected in (array, pa.chunked_array([array.slice(0, 1), 
array.slice(1)])):
+            for operation in ("first", "last"):
+                with self.subTest(operation=operation, selected=selected):
+                    self.assertEqual(
+                        "value", temporal._aggregate_values(selected, 
operation))
+
+    def test_window_endpoints_do_not_materialize_full_payload(self):
+        payload = b"x" * 1024
+        selected = pa.chunked_array([
+            pa.array([payload] * 8192, type=pa.binary())])
+        for operation in ("first", "last"):
+            with self.subTest(operation=operation):
+                tracemalloc.start()
+                try:
+                    result = temporal._aggregate_values(selected, operation)
+                    _, peak = tracemalloc.get_traced_memory()
+                finally:
+                    tracemalloc.stop()
+                self.assertEqual(payload, result)
+                # Converting the entire window creates over 8 MiB of Python 
bytes.
+                self.assertLess(peak, 256 * 1024)
+
     def test_window_join_supports_asymmetric_timestamp_bounds(self):
         anchors = self._table("window_timestamp_anchors", {
             "episode_id": pa.int32(),

Reply via email to