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 36caecf49e [python] Support global index column update action (#8270)
36caecf49e is described below

commit 36caecf49e0d1690005df5db5d20f68b94afda62
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Jun 17 23:02:36 2026 +0800

    [python] Support global index column update action (#8270)
    
    Support Python API usage of `global-index.column-update-action` for
    global index column updates. This keeps the Python option behavior
    aligned with the Java enum-style configuration and documents the option
    in Python core options.
---
 .../pypaimon/common/options/core_options.py        |   4 +
 .../pypaimon/common/options/options_utils.py       |   2 +
 .../tests/global_index_update_action_test.py       | 191 +++++++++++++++++++++
 .../pypaimon/write/global_index_update_checker.py  |  32 +++-
 4 files changed, 220 insertions(+), 9 deletions(-)

diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index e2df232193..78c8db22e5 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -632,6 +632,10 @@ class CoreOptions:
         ConfigOptions.key("global-index.column-update-action")
         .enum_type(GlobalIndexColumnUpdateAction)
         .default_value(GlobalIndexColumnUpdateAction.THROW_ERROR)
+        .with_description(
+            "Defines the action to take when an update modifies columns that "
+            "are covered by a global index."
+        )
     )
 
     LOCAL_CACHE_ENABLED: ConfigOption[bool] = (
diff --git a/paimon-python/pypaimon/common/options/options_utils.py 
b/paimon-python/pypaimon/common/options/options_utils.py
index 7933abe056..4c86e7898d 100644
--- a/paimon-python/pypaimon/common/options/options_utils.py
+++ b/paimon-python/pypaimon/common/options/options_utils.py
@@ -72,6 +72,8 @@ class OptionsUtils:
     @staticmethod
     def convert_to_string(value: Any) -> str:
         """Convert value to string."""
+        if isinstance(value, Enum):
+            return str(value.value)
         return str(value)
 
     @staticmethod
diff --git a/paimon-python/pypaimon/tests/global_index_update_action_test.py 
b/paimon-python/pypaimon/tests/global_index_update_action_test.py
new file mode 100644
index 0000000000..8cd92e253e
--- /dev/null
+++ b/paimon-python/pypaimon/tests/global_index_update_action_test.py
@@ -0,0 +1,191 @@
+# 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
+
+from pypaimon.common.options.core_options import (
+    CoreOptions,
+    GlobalIndexColumnUpdateAction,
+)
+from pypaimon.common.options.options import Options
+from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
+from pypaimon.index.index_file_meta import IndexFileMeta
+from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.global_index_update_checker import (
+    apply_global_index_update_action,
+)
+
+
+def _field(field_id, name, field_type="STRING"):
+    return DataField(field_id, name, AtomicType(field_type))
+
+
+def _partition_row(values):
+    fields = [_field(i, "p{}".format(i)) for i in range(len(values))]
+    return GenericRow(list(values), fields)
+
+
+def _index_entry(file_name, partition, field_id, extra_field_ids=None):
+    index_file = IndexFileMeta(
+        index_type="BTREE",
+        file_name=file_name,
+        file_size=1,
+        row_count=1,
+        global_index_meta=GlobalIndexMeta(
+            row_range_start=0,
+            row_range_end=0,
+            index_field_id=field_id,
+            extra_field_ids=extra_field_ids,
+            index_meta=b"",
+        ),
+    )
+    return IndexManifestEntry(
+        kind=0,
+        partition=_partition_row(partition),
+        bucket=0,
+        index_file=index_file,
+    )
+
+
+class _Table:
+    fields = [_field(1, "name"), _field(2, "age", "INT")]
+
+    def __init__(self, options):
+        self.options = options
+
+
+class GlobalIndexUpdateActionTest(unittest.TestCase):
+
+    def test_column_update_action_reads_java_style_string(self):
+        options = CoreOptions.from_dict({
+            CoreOptions.GLOBAL_INDEX_COLUMN_UPDATE_ACTION.key(): 
"DROP_PARTITION_INDEX",
+        })
+
+        self.assertEqual(
+            GlobalIndexColumnUpdateAction.DROP_PARTITION_INDEX,
+            options.global_index_column_update_action(),
+        )
+
+    def test_column_update_action_round_trips_python_enum(self):
+        options = CoreOptions(Options({}))
+        options.set(
+            CoreOptions.GLOBAL_INDEX_COLUMN_UPDATE_ACTION,
+            GlobalIndexColumnUpdateAction.DROP_PARTITION_INDEX,
+        )
+
+        self.assertEqual(
+            "DROP_PARTITION_INDEX",
+            options.options.to_map()[
+                CoreOptions.GLOBAL_INDEX_COLUMN_UPDATE_ACTION.key()
+            ],
+        )
+        self.assertEqual(
+            GlobalIndexColumnUpdateAction.DROP_PARTITION_INDEX,
+            options.global_index_column_update_action(),
+        )
+
+    def test_default_action_rejects_updates_to_global_index_columns(self):
+        table = _Table(CoreOptions.from_dict({}))
+        entries = [_index_entry("idx-name", (), 1)]
+
+        with mock.patch(
+                "pypaimon.write.global_index_update_checker."
+                "scan_global_index_entries",
+                return_value=entries):
+            with self.assertRaisesRegex(RuntimeError, "Conflicted columns"):
+                apply_global_index_update_action(table, object(), ["name"], 
{()})
+
+    def test_default_action_rejects_extra_global_index_columns(self):
+        table = _Table(CoreOptions.from_dict({}))
+        entries = [_index_entry("idx-name-age", (), 1, extra_field_ids=[2])]
+
+        with mock.patch(
+                "pypaimon.write.global_index_update_checker."
+                "scan_global_index_entries",
+                return_value=entries):
+            with self.assertRaises(RuntimeError) as ctx:
+                apply_global_index_update_action(table, object(), ["age"], 
{()})
+
+        self.assertIn("'age'", str(ctx.exception))
+        self.assertIn("Conflicted columns: ['age']", str(ctx.exception))
+
+    def test_drop_partition_index_builds_deletes_for_affected_partition(self):
+        options = CoreOptions.from_dict({
+            CoreOptions.GLOBAL_INDEX_COLUMN_UPDATE_ACTION.key(): 
"DROP_PARTITION_INDEX",
+        })
+        table = _Table(options)
+        entries = [
+            _index_entry("idx-name-p0", ("2026-06-17",), 1),
+            _index_entry("idx-age-p0", ("2026-06-17",), 2),
+            _index_entry("idx-name-p1", ("2026-06-18",), 1),
+        ]
+
+        with mock.patch(
+                "pypaimon.write.global_index_update_checker."
+                "scan_global_index_entries",
+                return_value=entries):
+            messages = apply_global_index_update_action(
+                table,
+                object(),
+                ["name"],
+                {("2026-06-17",)},
+            )
+
+        self.assertEqual(1, len(messages))
+        self.assertEqual(("2026-06-17",), messages[0].partition)
+        self.assertEqual(
+            ["idx-name-p0"],
+            [d.index_file.file_name for d in messages[0].index_deletes],
+        )
+        self.assertEqual([1], [d.kind for d in messages[0].index_deletes])
+
+    def test_drop_partition_index_builds_deletes_for_extra_column_update(self):
+        options = CoreOptions.from_dict({
+            CoreOptions.GLOBAL_INDEX_COLUMN_UPDATE_ACTION.key(): 
"DROP_PARTITION_INDEX",
+        })
+        table = _Table(options)
+        entries = [
+            _index_entry(
+                "idx-name-age-p0",
+                ("2026-06-17",),
+                1,
+                extra_field_ids=[2],
+            ),
+        ]
+
+        with mock.patch(
+                "pypaimon.write.global_index_update_checker."
+                "scan_global_index_entries",
+                return_value=entries):
+            messages = apply_global_index_update_action(
+                table,
+                object(),
+                ["age"],
+                {("2026-06-17",)},
+            )
+
+        self.assertEqual(
+            ["idx-name-age-p0"],
+            [d.index_file.file_name for d in messages[0].index_deletes],
+        )
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/paimon-python/pypaimon/write/global_index_update_checker.py 
b/paimon-python/pypaimon/write/global_index_update_checker.py
index ac405fc4bc..db44e5e4af 100644
--- a/paimon-python/pypaimon/write/global_index_update_checker.py
+++ b/paimon-python/pypaimon/write/global_index_update_checker.py
@@ -47,6 +47,17 @@ def build_index_delete_msgs(entries) -> list:
     ]
 
 
+def indexed_field_names(global_index_meta, field_by_id) -> set:
+    field_ids = [global_index_meta.index_field_id]
+    if global_index_meta.extra_field_ids:
+        field_ids.extend(global_index_meta.extra_field_ids)
+    return {
+        field_by_id[field_id]
+        for field_id in field_ids
+        if field_id in field_by_id
+    }
+
+
 def apply_global_index_update_action(
     table,
     snapshot,
@@ -60,11 +71,17 @@ def apply_global_index_update_action(
         return []
     field_by_id = {f.id: f.name for f in table.fields}
     update_set = set(updated_cols)
-    affected = [
-        e for e in entries
-        if field_by_id.get(e.index_file.global_index_meta.index_field_id) in 
update_set
-        and tuple(e.partition.values) in written_partitions
-    ]
+    affected = []
+    conflicted = set()
+    for e in entries:
+        if tuple(e.partition.values) not in written_partitions:
+            continue
+        matched = indexed_field_names(
+            e.index_file.global_index_meta, field_by_id
+        ).intersection(update_set)
+        if matched:
+            affected.append(e)
+            conflicted.update(matched)
     if not affected:
         return []
     action = table.options.global_index_column_update_action()
@@ -72,11 +89,8 @@ def apply_global_index_update_action(
         action = GlobalIndexColumnUpdateAction.THROW_ERROR
     if action == GlobalIndexColumnUpdateAction.DROP_PARTITION_INDEX:
         return build_index_delete_msgs(affected)
-    conflicted = sorted(
-        {field_by_id.get(e.index_file.global_index_meta.index_field_id) for e 
in affected}
-    )
     raise RuntimeError(
         f"Update columns contain globally indexed columns, not supported 
now.\n"
         f"Updated columns: {sorted(update_set)}\n"
-        f"Conflicted columns: {conflicted}"
+        f"Conflicted columns: {sorted(conflicted)}"
     )

Reply via email to