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 17bc76450f [python] Fix row-id updates for Arrow columns over 2 GiB 
(#9146)
17bc76450f is described below

commit 17bc76450ff3d36990ff258c15d311cdff9ac58e
Author: umi <[email protected]>
AuthorDate: Mon Aug 10 23:06:40 2026 +0800

    [python] Fix row-id updates for Arrow columns over 2 GiB (#9146)
---
 .../tests/table_update_by_row_id_chunked_test.py   | 206 ++++++++++++++++++++
 .../pypaimon/write/table_update_by_row_id.py       | 210 ++++++++++++++++++---
 2 files changed, 393 insertions(+), 23 deletions(-)

diff --git 
a/paimon-python/pypaimon/tests/table_update_by_row_id_chunked_test.py 
b/paimon-python/pypaimon/tests/table_update_by_row_id_chunked_test.py
new file mode 100644
index 0000000000..c0902c5421
--- /dev/null
+++ b/paimon-python/pypaimon/tests/table_update_by_row_id_chunked_test.py
@@ -0,0 +1,206 @@
+################################################################################
+#  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 unittest
+from unittest import mock
+
+import pyarrow as pa
+import pyarrow.compute as pc
+
+from pypaimon.table.special_fields import SpecialFields
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+
+class TableUpdateByRowIdChunkedTest(unittest.TestCase):
+
+    @staticmethod
+    def _updater():
+        updater = TableUpdateByRowId.__new__(TableUpdateByRowId)
+        updater._is_blob_column = lambda _: False
+        return updater
+
+    def test_variant_update_preserves_chunks_and_non_nullable_fields(self):
+        variant_type = pa.struct([
+            pa.field("value", pa.binary(), nullable=False),
+            pa.field("metadata", pa.binary(), nullable=False),
+        ])
+        original_payload = pa.chunked_array([
+            pa.array([
+                {"value": b"v0", "metadata": b"m0"},
+                {"value": b"v1", "metadata": b"m1"},
+            ], type=variant_type),
+            pa.array([
+                {"value": b"v2", "metadata": b"m2"},
+                {"value": b"v3", "metadata": b"m3"},
+            ], type=variant_type),
+        ])
+        update_payload = pa.chunked_array([
+            pa.array([
+                {"value": b"updated-1", "metadata": b"new-1"},
+            ], type=variant_type),
+            pa.array([
+                {"value": b"updated-3", "metadata": b"new-3"},
+            ], type=variant_type),
+        ])
+        original = pa.table({"payload": original_payload})
+        updates = pa.table({
+            SpecialFields.ROW_ID.name: pa.array([101, 103], type=pa.int64()),
+            "payload": update_payload,
+        })
+
+        merged, _ = self._updater()._merge_update_with_original(
+            original, updates, ["payload"], first_row_id=100)
+
+        self.assertEqual(merged.schema.field("payload").type, variant_type)
+        self.assertEqual(merged["payload"].num_chunks, 2)
+        self.assertEqual(merged["payload"].to_pylist(), [
+            {"value": b"v0", "metadata": b"m0"},
+            {"value": b"updated-1", "metadata": b"new-1"},
+            {"value": b"v2", "metadata": b"m2"},
+            {"value": b"updated-3", "metadata": b"new-3"},
+        ])
+
+    def test_update_positions_are_sorted_once_for_all_columns(self):
+        original = pa.table({
+            "left": pa.chunked_array([[1], [2]]),
+            "right": pa.chunked_array([[10], [20]]),
+        })
+        updates = pa.table({
+            SpecialFields.ROW_ID.name: pa.array([1], type=pa.int64()),
+            "left": pa.array([3]),
+            "right": pa.array([30]),
+        })
+
+        with mock.patch("builtins.sorted", wraps=sorted) as sorted_mock:
+            merged, _ = self._updater()._merge_update_with_original(
+                original, updates, ["left", "right"], first_row_id=0)
+
+        self.assertEqual(sorted_mock.call_count, 1)
+        self.assertEqual(merged["left"].to_pylist(), [1, 3])
+        self.assertEqual(merged["right"].to_pylist(), [10, 30])
+
+    def test_update_position_outside_column_range_raises(self):
+        original = pa.table({"payload": pa.array([1, 2])})
+        updates = pa.table({
+            SpecialFields.ROW_ID.name: pa.array([2], type=pa.int64()),
+            "payload": pa.array([3]),
+        })
+
+        with self.assertRaisesRegex(IndexError, "outside column range"):
+            self._updater()._merge_update_with_original(
+                original, updates, ["payload"], first_row_id=0)
+
+    def test_total_offsets_over_int32_remain_in_separate_chunks(self):
+        child_length = 1_100_000_000
+        large_chunk = self._list_chunk([0, child_length])
+        original = pa.table({
+            "payload": pa.chunked_array([large_chunk, large_chunk]),
+        })
+        updates = pa.table({
+            SpecialFields.ROW_ID.name: pa.array([1], type=pa.int64()),
+            "payload": pa.chunked_array([large_chunk]),
+        })
+
+        merged, _ = self._updater()._merge_update_with_original(
+            original, updates, ["payload"], first_row_id=0)
+
+        payload = merged["payload"]
+        self.assertEqual(payload.num_chunks, 2)
+        self.assertEqual(
+            sum(len(chunk.values) for chunk in payload.chunks),
+            2 * child_length,
+        )
+
+    def test_fallback_splits_before_temporary_concat_overflows(self):
+        original_value_length = 600_000_000
+        replacement_value_length = 1_100_000_000
+        original_chunk = self._list_chunk([
+            0,
+            original_value_length,
+            2 * original_value_length,
+        ])
+        replacement_chunk = self._list_chunk([0, replacement_value_length])
+        original = pa.table({
+            "payload": pa.chunked_array([original_chunk]),
+        })
+        updates = pa.table({
+            SpecialFields.ROW_ID.name: pa.array([1], type=pa.int64()),
+            "payload": pa.chunked_array([replacement_chunk]),
+        })
+
+        with mock.patch.object(
+                TableUpdateByRowId,
+                "_chunk_offsets",
+                wraps=TableUpdateByRowId._chunk_offsets,
+        ) as chunk_offsets:
+            merged, _ = self._updater()._merge_update_with_original(
+                original, updates, ["payload"], first_row_id=0)
+
+        payload = merged["payload"]
+        self.assertEqual(chunk_offsets.call_count, 1)
+        self.assertEqual(payload.num_chunks, 2)
+        self.assertEqual(
+            [len(chunk[0].values) for chunk in payload.chunks],
+            [original_value_length, replacement_value_length],
+        )
+
+    def test_replace_with_mask_capacity_error_splits_chunk(self):
+        original = pa.table({
+            "payload": pa.array(
+                [b"a", b"b", b"c", b"d"], type=pa.binary()),
+        })
+        updates = pa.table({
+            SpecialFields.ROW_ID.name:
+                pa.array([1, 3], type=pa.int64()),
+            "payload": pa.array([b"B", b"D"], type=pa.binary()),
+        })
+        real_replace_with_mask = pc.replace_with_mask
+        attempted_lengths = []
+
+        def replace_with_capacity_limit(values, mask, replacements):
+            attempted_lengths.append(len(values))
+            if len(values) > 2:
+                raise pa.lib.ArrowCapacityError(
+                    "array cannot contain more than 2147483647 bytes")
+            return real_replace_with_mask(values, mask, replacements)
+
+        with mock.patch.object(
+                pc,
+                "replace_with_mask",
+                side_effect=replace_with_capacity_limit,
+        ):
+            merged, _ = self._updater()._merge_update_with_original(
+                original, updates, ["payload"], first_row_id=0)
+
+        self.assertEqual(attempted_lengths, [4, 2, 2])
+        self.assertEqual(merged["payload"].num_chunks, 2)
+        self.assertEqual(
+            merged["payload"].to_pylist(),
+            [b"a", b"B", b"c", b"D"],
+        )
+
+    @staticmethod
+    def _list_chunk(offsets):
+        return pa.ListArray.from_arrays(
+            pa.array(offsets, type=pa.int32()),
+            pa.nulls(offsets[-1]),
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/paimon-python/pypaimon/write/table_update_by_row_id.py 
b/paimon-python/pypaimon/write/table_update_by_row_id.py
index c74fbc54fa..508a74b5d0 100644
--- a/paimon-python/pypaimon/write/table_update_by_row_id.py
+++ b/paimon-python/pypaimon/write/table_update_by_row_id.py
@@ -461,15 +461,11 @@ class TableUpdateByRowId:
             pa.scalar(first_row_id, type=pa.int64())
         ).cast(pa.int64())
 
-        # Build a boolean mask: True at positions that need to be updated
-        all_indices = pa.array(range(original_data.num_rows), type=pa.int64())
-        mask = pc.is_in(all_indices, value_set=relative_indices)
-
         # Build the merged table column by column
         merged_columns = {}
         blob_columns: Dict[str, List[object]] = {}
         update_by_col = {
-            col_name: update_data[col_name].combine_chunks()
+            col_name: update_data[col_name]
             for col_name in column_names
             if col_name in update_data.column_names
         }
@@ -477,6 +473,7 @@ class TableUpdateByRowId:
             int(relative_index.as_py()): idx
             for idx, relative_index in enumerate(relative_indices)
         }
+        sorted_updates = None
         # Caller (_write_by_first_row_id) only enters this method with a
         # non-empty group, so update_positions is non-empty here.
         blob_row_count = max(update_positions) + 1
@@ -502,34 +499,201 @@ class TableUpdateByRowId:
                 ]
                 continue
             update_col = update_by_col[col_name]
-            original_col = original_data[col_name].combine_chunks()
-            if update_col.type != original_col.type:
-                update_col = self._coerce_column(
-                    update_col, original_col.type)
-            try:
-                merged_columns[col_name] = pc.replace_with_mask(
-                    original_col, mask, update_col)
-            except pa.lib.ArrowNotImplementedError:
-                n = original_data.num_rows
-                combined = pa.concat_arrays(
-                    [original_col, update_col])
-                offset = len(original_col)
-                indices = np.arange(n, dtype=np.int64)
-                for orig_pos, upd_idx in update_positions.items():
-                    indices[orig_pos] = offset + upd_idx
-                merged_columns[col_name] = combined.take(
-                    pa.array(indices))
+            original_col = original_data[col_name]
+            if sorted_updates is None:
+                sorted_updates = sorted(update_positions.items())
+                row_count = len(original_col)
+                for position, _ in sorted_updates:
+                    if position < 0 or position >= row_count:
+                        raise IndexError(
+                            f"Update position {position} is outside column "
+                            f"range [0, {row_count})")
+            merged_columns[col_name] = self._merge_chunked_column(
+                original_col, update_col, sorted_updates)
 
         merged_table = pa.table(merged_columns) if merged_columns else None
 
         return merged_table, blob_columns
 
+    @classmethod
+    def _merge_chunked_column(
+            cls,
+            original_col: pa.ChunkedArray,
+            update_col: pa.ChunkedArray,
+            sorted_updates: List[Tuple[int, int]],
+    ) -> pa.ChunkedArray:
+        """Merge updates without flattening a column into one Arrow Array.
+
+        ``binary``, ``string`` and ``list`` use signed 32-bit offsets, so a
+        valid multi-chunk column can exceed 2 GiB while each individual Array
+        remains below the limit. Keep those chunks independent. If merging an
+        individual chunk still overflows (for example, the struct/list
+        fallback temporarily concatenates original and replacement values),
+        split that row range and retry.
+        """
+        update_chunk_offsets = cls._chunk_offsets(update_col)
+        sorted_updates_idx = 0
+        chunk_start_row = 0
+        merged_chunks: List[pa.Array] = []
+
+        for original_chunk in original_col.chunks:
+            chunk_end = chunk_start_row + len(original_chunk)
+            chunk_updates: List[Tuple[int, int]] = []
+            while (
+                    sorted_updates_idx < len(sorted_updates)
+                    and sorted_updates[sorted_updates_idx][0] < chunk_end
+            ):
+                position, update_index = sorted_updates[sorted_updates_idx]
+                chunk_updates.append((
+                    position - chunk_start_row, update_index))
+                sorted_updates_idx += 1
+
+            merged_chunks.extend(cls._merge_chunk_with_updates(
+                original_chunk,
+                update_col,
+                update_chunk_offsets,
+                chunk_updates,
+            ))
+            chunk_start_row = chunk_end
+
+        return pa.chunked_array(merged_chunks, type=original_col.type)
+
+    @classmethod
+    def _merge_chunk_with_updates(
+            cls,
+            original: pa.Array,
+            update_col: pa.ChunkedArray,
+            update_chunk_offsets: List[int],
+            updates: List[Tuple[int, int]],
+    ) -> List[pa.Array]:
+        if not updates:
+            return [original]
+
+        try:
+            replacements = cls._take_from_chunked_array(
+                update_col,
+                update_chunk_offsets,
+                [update_index for _, update_index in updates],
+            )
+            if replacements.type != original.type:
+                replacements = cls._coerce_column(
+                    replacements, original.type)
+
+            if len(updates) == len(original):
+                return [replacements]
+
+            mask_values = np.zeros(len(original), dtype=np.bool_)
+            for position, _ in updates:
+                mask_values[position] = True
+            mask = pa.array(mask_values)
+
+            try:
+                return [pc.replace_with_mask(original, mask, replacements)]
+            except pa.lib.ArrowNotImplementedError:
+                combined = pa.concat_arrays([original, replacements])
+                indices = np.arange(len(original), dtype=np.int64)
+                replacement_offset = len(original)
+                for replacement_index, (position, _) in enumerate(updates):
+                    indices[position] = replacement_offset + replacement_index
+                return [combined.take(pa.array(indices))]
+        except (pa.lib.ArrowInvalid,
+                pa.lib.ArrowCapacityError) as error:
+            if not cls._is_offset_overflow(error) or len(original) <= 1:
+                raise
+
+            split_at = len(original) // 2
+            left_updates = [
+                update for update in updates if update[0] < split_at
+            ]
+            right_updates = [
+                (position - split_at, update_index)
+                for position, update_index in updates
+                if position >= split_at
+            ]
+            return (
+                cls._merge_chunk_with_updates(
+                    original.slice(0, split_at),
+                    update_col,
+                    update_chunk_offsets,
+                    left_updates,
+                )
+                + cls._merge_chunk_with_updates(
+                    original.slice(split_at),
+                    update_col,
+                    update_chunk_offsets,
+                    right_updates,
+                )
+            )
+
+    @staticmethod
+    def _chunk_offsets(column: pa.ChunkedArray) -> List[int]:
+        offsets = [0]
+        for chunk in column.chunks:
+            offsets.append(offsets[-1] + len(chunk))
+        return offsets
+
+    @staticmethod
+    def _take_from_chunked_array(
+            column: pa.ChunkedArray,
+            chunk_offsets: List[int],
+            indices: List[int],
+    ) -> pa.Array:
+        """Take values without asking Arrow to combine unrelated chunks."""
+        if not indices:
+            return pa.array([], type=column.type)
+
+        pieces: List[pa.Array] = []
+        current_chunk_index = None
+        current_local_indices: List[int] = []
+
+        def append_piece(chunk_index: int, local_indices: List[int]):
+            chunk = column.chunk(chunk_index)
+            start = local_indices[0]
+            if all(value == start + offset
+                   for offset, value in enumerate(local_indices)):
+                pieces.append(chunk.slice(start, len(local_indices)))
+            else:
+                pieces.append(chunk.take(pa.array(
+                    local_indices, type=pa.int64())))
+
+        for index in indices:
+            if index < 0 or index >= len(column):
+                raise IndexError(
+                    f"Update index {index} is outside column range "
+                    f"[0, {len(column)})")
+            chunk_index = bisect.bisect_right(chunk_offsets, index) - 1
+            local_index = index - chunk_offsets[chunk_index]
+            if current_chunk_index is None:
+                current_chunk_index = chunk_index
+            elif chunk_index != current_chunk_index:
+                append_piece(current_chunk_index, current_local_indices)
+                current_chunk_index = chunk_index
+                current_local_indices = []
+            current_local_indices.append(local_index)
+
+        append_piece(current_chunk_index, current_local_indices)
+        if len(pieces) == 1:
+            return pieces[0]
+        return pa.concat_arrays(pieces)
+
+    @staticmethod
+    def _is_offset_overflow(error: pa.lib.ArrowException) -> bool:
+        if isinstance(error, pa.lib.ArrowCapacityError):
+            return True
+        message = str(error).lower()
+        return (
+            "offset overflow" in message
+            or "too large to convert" in message
+        )
+
     @staticmethod
     def _coerce_column(col: pa.Array, target_type: pa.DataType) -> pa.Array:
         try:
             return col.cast(target_type)
+        except pa.lib.ArrowInvalid as error:
+            if TableUpdateByRowId._is_offset_overflow(error):
+                raise
         except (pa.lib.ArrowNotImplementedError,
-                pa.lib.ArrowInvalid,
                 pa.lib.ArrowTypeError):
             pass
         pylist = col.to_pylist()

Reply via email to