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 d50becf97d [python] Reject or drop stale index when updating globally 
indexed columns (#8045)
d50becf97d is described below

commit d50becf97d0ef1e9633978cdf4cae2c5c1387120
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun May 31 21:23:47 2026 +0800

    [python] Reject or drop stale index when updating globally indexed columns 
(#8045)
---
 .../pypaimon/common/options/core_options.py        |  14 +++
 .../pypaimon/manifest/index_manifest_file.py       | 119 +++++++++++++++++++-
 .../pypaimon/tests/e2e/java_py_read_write_test.py  |  70 +++++++++++-
 .../pypaimon/tests/index_manifest_write_test.py    | 121 +++++++++++++++++++++
 paimon-python/pypaimon/write/commit_message.py     |  10 +-
 paimon-python/pypaimon/write/file_store_commit.py  |  56 ++++++++--
 .../pypaimon/write/global_index_update_checker.py  |  82 ++++++++++++++
 paimon-python/pypaimon/write/table_update.py       |   6 -
 8 files changed, 459 insertions(+), 19 deletions(-)

diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index 1cb9e831d3..bb157bb6e3 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -56,6 +56,11 @@ class MergeEngine(str, Enum):
     FIRST_ROW = "first-row"
 
 
+class GlobalIndexColumnUpdateAction(str, Enum):
+    THROW_ERROR = "THROW_ERROR"
+    DROP_PARTITION_INDEX = "DROP_PARTITION_INDEX"
+
+
 class CoreOptions:
     """Core options for Paimon tables."""
     # File format constants
@@ -495,6 +500,12 @@ class CoreOptions:
         )
     )
 
+    GLOBAL_INDEX_COLUMN_UPDATE_ACTION: 
ConfigOption[GlobalIndexColumnUpdateAction] = (
+        ConfigOptions.key("global-index.column-update-action")
+        .enum_type(GlobalIndexColumnUpdateAction)
+        .default_value(GlobalIndexColumnUpdateAction.THROW_ERROR)
+    )
+
     LOCAL_CACHE_ENABLED: ConfigOption[bool] = (
         ConfigOptions.key("local-cache.enabled")
         .boolean_type()
@@ -785,6 +796,9 @@ class CoreOptions:
     def data_evolution_enabled(self, default=None):
         return self.options.get(CoreOptions.DATA_EVOLUTION_ENABLED, default)
 
+    def global_index_column_update_action(self, default=None):
+        return self.options.get(CoreOptions.GLOBAL_INDEX_COLUMN_UPDATE_ACTION, 
default)
+
     def deletion_vectors_enabled(self, default=None):
         return self.options.get(CoreOptions.DELETION_VECTORS_ENABLED, default)
 
diff --git a/paimon-python/pypaimon/manifest/index_manifest_file.py 
b/paimon-python/pypaimon/manifest/index_manifest_file.py
index 4e65e95e0c..a7d82d4b12 100644
--- a/paimon-python/pypaimon/manifest/index_manifest_file.py
+++ b/paimon-python/pypaimon/manifest/index_manifest_file.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import uuid
 from io import BytesIO
 from typing import List, Optional
 
@@ -24,11 +25,61 @@ from pypaimon.globalindex.global_index_meta import 
GlobalIndexMeta
 from pypaimon.index.deletion_vector_meta import DeletionVectorMeta
 from pypaimon.index.index_file_meta import IndexFileMeta
 from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
-from pypaimon.table.row.generic_row import GenericRowDeserializer
+from pypaimon.table.row.generic_row import (GenericRowDeserializer,
+                                            GenericRowSerializer)
+from pypaimon.utils.file_store_path_factory import FileStorePathFactory
+
+# DV and global-index sub-schemas required by INDEX_MANIFEST_ENTRY_SCHEMA for
+# Avro compatibility with Java; values are always null in data-evolution 
tables.
+_DELETION_VECTOR_META_SCHEMA = {
+    "type": "record",
+    "name": "DeletionVectorMeta",
+    "fields": [
+        {"name": "f0", "type": "string"},
+        {"name": "f1", "type": "long"},
+        {"name": "f2", "type": "int"},
+        {"name": "_CARDINALITY", "type": ["null", "long"], "default": None},
+    ],
+}
+
+_GLOBAL_INDEX_META_SCHEMA = {
+    "type": "record",
+    "name": "GlobalIndexMeta",
+    "fields": [
+        {"name": "_ROW_RANGE_START", "type": "long"},
+        {"name": "_ROW_RANGE_END", "type": "long"},
+        {"name": "_INDEX_FIELD_ID", "type": "int"},
+        {"name": "_EXTRA_FIELD_IDS",
+         "type": ["null", {"type": "array", "items": "int"}], "default": None},
+        {"name": "_INDEX_META", "type": ["null", "bytes"], "default": None},
+    ],
+}
+
+INDEX_MANIFEST_ENTRY_SCHEMA = {
+    "type": "record",
+    "name": "IndexManifestEntry",
+    "fields": [
+        {"name": "_VERSION", "type": "int"},
+        {"name": "_KIND", "type": "int"},
+        {"name": "_PARTITION", "type": "bytes"},
+        {"name": "_BUCKET", "type": "int"},
+        {"name": "_INDEX_TYPE", "type": "string"},
+        {"name": "_FILE_NAME", "type": "string"},
+        {"name": "_FILE_SIZE", "type": "long"},
+        {"name": "_ROW_COUNT", "type": "long"},
+        {"name": "_DELETIONS_VECTORS_RANGES",
+         "type": ["null", {"type": "array", "items": 
_DELETION_VECTOR_META_SCHEMA}],
+         "default": None},
+        {"name": "_EXTERNAL_PATH", "type": ["null", "string"], "default": 
None},
+        {"name": "_GLOBAL_INDEX",
+         "type": ["null", _GLOBAL_INDEX_META_SCHEMA], "default": None},
+    ],
+}
+
+_INDEX_ENTRY_VERSION = 1
 
 
 class IndexManifestFile:
-    """Index manifest file reader for reading index manifest entries."""
 
     DELETION_VECTORS_INDEX = "DELETION_VECTORS"
 
@@ -172,5 +223,69 @@ class IndexManifestFile:
             row_range_start=global_index_record.get('_ROW_RANGE_START', 0),
             row_range_end=global_index_record.get('_ROW_RANGE_END', 0),
             index_field_id=global_index_record.get('_INDEX_FIELD_ID', 0),
+            extra_field_ids=global_index_record.get('_EXTRA_FIELD_IDS'),
             index_meta=global_index_record.get('_INDEX_META')
         )
+
+    def combine_deletes(
+        self,
+        previous_name: Optional[str],
+        deletes: List[IndexManifestEntry],
+    ) -> Optional[str]:
+        if not deletes:
+            return previous_name
+        previous = self.read(previous_name) if previous_name else []
+        delete_names = {e.index_file.file_name for e in deletes}
+        survivors = [e for e in previous if e.index_file.file_name not in 
delete_names]
+        if not survivors:
+            return None
+        return self.write(survivors)
+
+    def write(self, entries: List[IndexManifestEntry]) -> str:
+        file_name = 
f"{FileStorePathFactory.INDEX_MANIFEST_PREFIX}{uuid.uuid4()}"
+        path = f"{self.manifest_path}/{file_name}"
+        records = [self._to_avro_record(e) for e in entries]
+        try:
+            buffer = BytesIO()
+            fastavro.writer(buffer, INDEX_MANIFEST_ENTRY_SCHEMA, records)
+            with self.file_io.new_output_stream(path) as output_stream:
+                output_stream.write(buffer.getvalue())
+        except Exception as e:
+            self.file_io.delete_quietly(path)
+            raise RuntimeError(
+                f"Exception occurs when writing records to {path}. Clean up."
+            ) from e
+        return file_name
+
+    def _to_avro_record(self, entry: IndexManifestEntry) -> dict:
+        index_file = entry.index_file
+        dv_ranges = None
+        if index_file.dv_ranges:
+            dv_ranges = [
+                {"f0": dv.data_file_name, "f1": dv.offset, "f2": dv.length,
+                 "_CARDINALITY": dv.cardinality}
+                for dv in index_file.dv_ranges.values()
+            ]
+        global_index = None
+        if index_file.global_index_meta is not None:
+            gim = index_file.global_index_meta
+            global_index = {
+                "_ROW_RANGE_START": gim.row_range_start,
+                "_ROW_RANGE_END": gim.row_range_end,
+                "_INDEX_FIELD_ID": gim.index_field_id,
+                "_EXTRA_FIELD_IDS": gim.extra_field_ids,
+                "_INDEX_META": gim.index_meta,
+            }
+        return {
+            "_VERSION": _INDEX_ENTRY_VERSION,
+            "_KIND": entry.kind,
+            "_PARTITION": GenericRowSerializer.to_bytes(entry.partition),
+            "_BUCKET": entry.bucket,
+            "_INDEX_TYPE": index_file.index_type,
+            "_FILE_NAME": index_file.file_name,
+            "_FILE_SIZE": index_file.file_size,
+            "_ROW_COUNT": index_file.row_count,
+            "_DELETIONS_VECTORS_RANGES": dv_ranges,
+            "_EXTERNAL_PATH": index_file.external_path,
+            "_GLOBAL_INDEX": global_index,
+        }
diff --git a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py 
b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
index d01adb26ba..1016f8135b 100644
--- a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
+++ b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
@@ -443,7 +443,9 @@ class JavaPyReadWriteTest(unittest.TestCase):
         self._test_read_btree_index_generic("test_btree_index_bigint", 2000, 
pa.int64())
         self._test_read_btree_index_large()
         self._test_read_btree_index_null()
-        self._test_index_manifest_inherited_after_write()
+        self._test_partial_append_does_not_trigger_index_action()
+        if sys.version_info[:2] >= (3, 7):
+            self._test_index_manifest_inherited_after_write()
 
     def _test_read_btree_index_generic(self, table_name: str, k, k_type):
         table = self.catalog.get_table('default.' + table_name)
@@ -565,6 +567,72 @@ class JavaPyReadWriteTest(unittest.TestCase):
             "index_manifest lost after Python data write - indexes become 
invisible"
         )
 
+        read_builder = table.new_read_builder()
+        predicate_builder = read_builder.new_predicate_builder()
+        read_builder.with_filter(predicate_builder.equal('k', 'k2'))
+        read_builder.with_projection(['k', '_ROW_ID'])
+        splits = read_builder.new_scan().plan().splits()
+        row_ids = 
read_builder.new_read().to_arrow(splits)['_ROW_ID'].to_pylist()
+        self.assertTrue(len(row_ids) > 0, "k2 should exist before update")
+
+        wb = table.new_batch_write_builder()
+        tu = wb.new_update().with_update_type(['k'])
+        update_data = pa.table({
+            '_ROW_ID': pa.array(row_ids, type=pa.int64()),
+            'k': ['k_updated'] * len(row_ids),
+        })
+        msgs = tu.update_by_arrow_with_row_id(update_data)
+        with self.assertRaises(RuntimeError) as cm:
+            wb.new_commit().commit(msgs)
+        self.assertIn("'k'", str(cm.exception))
+        self.assertIn("Conflicted columns", str(cm.exception))
+
+        table_drop = table.copy(
+            {'global-index.column-update-action': 'DROP_PARTITION_INDEX'}
+        )
+        wb_drop = table_drop.new_batch_write_builder()
+        tu_drop = wb_drop.new_update().with_update_type(['k'])
+        
wb_drop.new_commit().commit(tu_drop.update_by_arrow_with_row_id(update_data))
+
+        table_after = self.catalog.get_table('default.test_btree_index_string')
+        rb = table_after.new_read_builder()
+        rb.with_filter(rb.new_predicate_builder().equal('k', 'k_updated'))
+        rows_new = rb.new_read().to_arrow(rb.new_scan().plan().splits())
+        self.assertGreater(len(rows_new), 0,
+                           "after DROP_PARTITION_INDEX, new value should read")
+
+        from pypaimon.manifest.index_manifest_file import IndexManifestFile
+        snap = table_after.snapshot_manager().get_latest_snapshot()
+        entries = (IndexManifestFile(table_after).read(snap.index_manifest)
+                   if snap.index_manifest else [])
+        field_by_id = {f.id: f.name for f in table_after.fields}
+        remaining = [e for e in entries
+                     if e.index_file.global_index_meta is not None
+                     and field_by_id.get(
+                         e.index_file.global_index_meta.index_field_id) == 'k']
+        self.assertEqual(remaining, [],
+                         "btree index entries for 'k' should be dropped")
+
+    def _test_partial_append_does_not_trigger_index_action(self):
+        table = self.catalog.get_table('default.test_btree_index_string')
+        snap_before = table.snapshot_manager().get_latest_snapshot()
+
+        wb = table.new_batch_write_builder()
+        tw = wb.new_write()
+        tw.with_write_type(['k'])
+        tw.write_arrow(pa.table({'k': ['k_new']}))
+        tc = wb.new_commit()
+        tc.commit(tw.prepare_commit())
+        tw.close()
+        tc.close()
+
+        snap_after = table.snapshot_manager().get_latest_snapshot()
+        self.assertGreater(snap_after.id, snap_before.id)
+        self.assertIsNotNone(
+            snap_after.index_manifest,
+            "partial append should not drop index manifest"
+        )
+
     @parameterized.expand([('json',), ('csv',)])
     def test_read_compressed_text_append_table(self, file_format):
         table = self.catalog.get_table(
diff --git a/paimon-python/pypaimon/tests/index_manifest_write_test.py 
b/paimon-python/pypaimon/tests/index_manifest_write_test.py
new file mode 100644
index 0000000000..ff61750407
--- /dev/null
+++ b/paimon-python/pypaimon/tests/index_manifest_write_test.py
@@ -0,0 +1,121 @@
+# 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 os
+import shutil
+import tempfile
+import unittest
+import uuid
+
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
+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.manifest.index_manifest_file import IndexManifestFile
+from pypaimon.table.row.generic_row import GenericRow
+
+
+class IndexManifestWriteTest(unittest.TestCase):
+
+    pa_schema = pa.schema([
+        ('id', pa.int32()),
+        ('vec', pa.string()),
+    ])
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tempdir = tempfile.mkdtemp()
+        cls.warehouse = os.path.join(cls.tempdir, 'warehouse')
+        cls.catalog = CatalogFactory.create({'warehouse': cls.warehouse})
+        cls.catalog.create_database('default', True)
+
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(cls.tempdir, ignore_errors=True)
+
+    def _table(self):
+        name = f'default.idx_{uuid.uuid4().hex[:8]}'
+        s = Schema.from_pyarrow_schema(self.pa_schema)
+        self.catalog.create_table(name, s, False)
+        return self.catalog.get_table(name)
+
+    def _entry(self, file_name, field_id, meta=b'm'):
+        partition = GenericRow([], [])
+        index_file = IndexFileMeta(
+            index_type='BTREE',
+            file_name=file_name,
+            file_size=123,
+            row_count=10,
+            global_index_meta=GlobalIndexMeta(
+                row_range_start=0,
+                row_range_end=10,
+                index_field_id=field_id,
+                extra_field_ids=[field_id + 1],
+                index_meta=meta,
+            ),
+        )
+        return IndexManifestEntry(kind=0, partition=partition, bucket=0, 
index_file=index_file)
+
+    def test_write_read_roundtrip(self):
+        imf = IndexManifestFile(self._table())
+        name = imf.write([self._entry('idx-a', 1), self._entry('idx-b', 2)])
+        out = imf.read(name)
+        self.assertEqual(2, len(out))
+        by_name = {e.index_file.file_name: e for e in out}
+        a = by_name['idx-a']
+        self.assertEqual('BTREE', a.index_file.index_type)
+        self.assertEqual(123, a.index_file.file_size)
+        self.assertEqual(10, a.index_file.row_count)
+        self.assertEqual(0, a.kind)
+        gim = a.index_file.global_index_meta
+        self.assertEqual(1, gim.index_field_id)
+        self.assertEqual(0, gim.row_range_start)
+        self.assertEqual(10, gim.row_range_end)
+        self.assertEqual([2], gim.extra_field_ids)
+        self.assertEqual(b'm', bytes(gim.index_meta))
+
+    def test_combine_drops_named_files(self):
+        imf = IndexManifestFile(self._table())
+        previous = imf.write([self._entry('idx-a', 1), self._entry('idx-b', 
2)])
+        deletes = [self._entry('idx-a', 1)]
+        new_name = imf.combine_deletes(previous, deletes)
+        self.assertNotEqual(previous, new_name)
+        survivors = {e.index_file.file_name for e in imf.read(new_name)}
+        self.assertEqual({'idx-b'}, survivors)
+
+    def test_combine_unknown_delete_is_noop_on_content(self):
+        imf = IndexManifestFile(self._table())
+        previous = imf.write([self._entry('idx-a', 1)])
+        new_name = imf.combine_deletes(previous, [self._entry('idx-zzz', 9)])
+        survivors = {e.index_file.file_name for e in imf.read(new_name)}
+        self.assertEqual({'idx-a'}, survivors)
+
+    def test_combine_empty_deletes_returns_previous(self):
+        imf = IndexManifestFile(self._table())
+        previous = imf.write([self._entry('idx-a', 1)])
+        self.assertEqual(previous, imf.combine_deletes(previous, []))
+
+    def test_combine_all_deleted_returns_none(self):
+        imf = IndexManifestFile(self._table())
+        previous = imf.write([self._entry('idx-a', 1)])
+        self.assertIsNone(imf.combine_deletes(previous, [self._entry('idx-a', 
1)]))
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/paimon-python/pypaimon/write/commit_message.py 
b/paimon-python/pypaimon/write/commit_message.py
index 7bce06d8ab..552df0000f 100644
--- a/paimon-python/pypaimon/write/commit_message.py
+++ b/paimon-python/pypaimon/write/commit_message.py
@@ -15,11 +15,14 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from dataclasses import dataclass
-from typing import List, Tuple, Optional
+from dataclasses import dataclass, field
+from typing import List, Tuple, Optional, TYPE_CHECKING
 
 from pypaimon.manifest.schema.data_file_meta import DataFileMeta
 
+if TYPE_CHECKING:
+    from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+
 
 @dataclass
 class CommitMessage:
@@ -27,6 +30,7 @@ class CommitMessage:
     bucket: int
     new_files: List[DataFileMeta]
     check_from_snapshot: Optional[int] = -1
+    index_deletes: List['IndexManifestEntry'] = field(default_factory=list)
 
     def is_empty(self):
-        return not self.new_files
+        return not self.new_files and not self.index_deletes
diff --git a/paimon-python/pypaimon/write/file_store_commit.py 
b/paimon-python/pypaimon/write/file_store_commit.py
index b5c9976aad..41440ae862 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -143,6 +143,31 @@ class FileStoreCommit:
 
         logger.info("Finished collecting changes, including: %d entries", 
len(commit_entries))
 
+        index_deletes = []
+        for msg in commit_messages:
+            index_deletes.extend(msg.index_deletes)
+
+        if not index_deletes:
+            from pypaimon.write.global_index_update_checker import (
+                apply_global_index_update_action,
+            )
+            updated_cols = set()
+            written_partitions = set()
+            for msg in commit_messages:
+                if msg.check_from_snapshot == -1:
+                    continue
+                for f in msg.new_files:
+                    if f.write_cols:
+                        updated_cols.update(f.write_cols)
+                        written_partitions.add(msg.partition)
+            if updated_cols:
+                snapshot = self.snapshot_manager.get_latest_snapshot()
+                index_msgs = apply_global_index_update_action(
+                    self.table, snapshot, list(updated_cols), 
written_partitions,
+                )
+                for m in index_msgs:
+                    index_deletes.extend(m.index_deletes)
+
         commit_kind = "APPEND"
         detect_conflicts = False
         allow_rollback = False
@@ -158,7 +183,8 @@ class FileStoreCommit:
                          commit_identifier=commit_identifier,
                          commit_entries_plan=lambda snapshot: commit_entries,
                          detect_conflicts=detect_conflicts,
-                         allow_rollback=allow_rollback)
+                         allow_rollback=allow_rollback,
+                         index_deletes=index_deletes)
 
     def overwrite(self, overwrite_partition, commit_messages: 
List[CommitMessage], commit_identifier: int):
         """Commit the given commit messages in overwrite mode."""
@@ -244,7 +270,7 @@ class FileStoreCommit:
         )
 
     def _try_commit(self, commit_kind, commit_identifier, commit_entries_plan,
-                    detect_conflicts=False, allow_rollback=False):
+                    detect_conflicts=False, allow_rollback=False, 
index_deletes=None):
 
         retry_count = 0
         retry_result = None
@@ -255,7 +281,7 @@ class FileStoreCommit:
 
             # No entries to commit (e.g. drop_partitions with no matching 
data): skip commit
             # to avoid creating manifest/snapshot with empty partition_stats 
(causes read errors).
-            if not commit_entries:
+            if not commit_entries and not index_deletes:
                 break
 
             result = self._try_commit_once(
@@ -266,6 +292,7 @@ class FileStoreCommit:
                 latest_snapshot=latest_snapshot,
                 detect_conflicts=detect_conflicts,
                 allow_rollback=allow_rollback,
+                index_deletes=index_deletes,
             )
 
             if result.is_success():
@@ -317,7 +344,8 @@ class FileStoreCommit:
                          commit_entries: List[ManifestEntry], 
commit_identifier: int,
                          latest_snapshot: Optional[Snapshot],
                          detect_conflicts: bool = False,
-                         allow_rollback: bool = False) -> CommitResult:
+                         allow_rollback: bool = False,
+                         index_deletes=None) -> CommitResult:
         start_millis = int(time.time() * 1000)
         if self._is_duplicate_commit(retry_result, latest_snapshot, 
commit_identifier, commit_kind):
             return SuccessResult()
@@ -328,6 +356,7 @@ class FileStoreCommit:
 
         # process new_manifest
         new_manifest_file = f"manifest-{str(uuid.uuid4())}-0"
+        new_index_manifest = None
         # process snapshot
         new_snapshot_id = latest_snapshot.id + 1 if latest_snapshot else 1
 
@@ -378,6 +407,13 @@ class FileStoreCommit:
             index_manifest = None
             if latest_snapshot and commit_kind == "APPEND":
                 index_manifest = latest_snapshot.index_manifest
+            if index_deletes:
+                from pypaimon.manifest.index_manifest_file import 
IndexManifestFile
+                previous_index_manifest = index_manifest
+                index_manifest = IndexManifestFile(self.table).combine_deletes(
+                    previous_index_manifest, index_deletes)
+                if index_manifest != previous_index_manifest:
+                    new_index_manifest = index_manifest
 
             snapshot_data = Snapshot(
                 version=3,
@@ -397,7 +433,8 @@ class FileStoreCommit:
             # Generate partition statistics for the commit
             statistics = self._generate_partition_statistics(commit_entries)
         except Exception as e:
-            self._cleanup_preparation_failure(delta_manifest_list, 
base_manifest_list)
+            self._cleanup_preparation_failure(delta_manifest_list, 
base_manifest_list,
+                                              new_index_manifest)
             logger.warning(f"Exception occurs when preparing snapshot: {e}", 
exc_info=True)
             raise RuntimeError(f"Failed to prepare snapshot: {e}")
 
@@ -417,7 +454,8 @@ class FileStoreCommit:
                         commit_kind,
                         commit_time_s,
                     )
-                    self._cleanup_preparation_failure(delta_manifest_list, 
base_manifest_list)
+                    self._cleanup_preparation_failure(delta_manifest_list, 
base_manifest_list,
+                                                      new_index_manifest)
                     return RetryResult(latest_snapshot, None)
         except Exception as e:
             # Commit exception, not sure about the situation and should not 
clean up the files
@@ -598,10 +636,14 @@ class FileStoreCommit:
 
     def _cleanup_preparation_failure(self,
                                      delta_manifest_list: Optional[str],
-                                     base_manifest_list: Optional[str]):
+                                     base_manifest_list: Optional[str],
+                                     index_manifest: Optional[str] = None):
         try:
             manifest_path = self.manifest_list_manager.manifest_path
 
+            if index_manifest:
+                
self.table.file_io.delete_quietly(f"{manifest_path}/{index_manifest}")
+
             if delta_manifest_list:
                 manifest_files = 
self.manifest_list_manager.read(delta_manifest_list)
                 for manifest_meta in manifest_files:
diff --git a/paimon-python/pypaimon/write/global_index_update_checker.py 
b/paimon-python/pypaimon/write/global_index_update_checker.py
new file mode 100644
index 0000000000..ac405fc4bc
--- /dev/null
+++ b/paimon-python/pypaimon/write/global_index_update_checker.py
@@ -0,0 +1,82 @@
+# 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.
+
+from typing import Sequence, Set, Tuple
+
+from pypaimon.common.options.core_options import GlobalIndexColumnUpdateAction
+
+
+def scan_global_index_entries(table, snapshot):
+    from pypaimon.index.index_file_handler import IndexFileHandler
+
+    handler = IndexFileHandler(table=table)
+    return handler.scan(
+        snapshot, lambda e: e.index_file.global_index_meta is not None
+    )
+
+
+def build_index_delete_msgs(entries) -> list:
+    from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
+    from pypaimon.write.commit_message import CommitMessage
+
+    by_partition = {}
+    for e in entries:
+        key = tuple(e.partition.values)
+        by_partition.setdefault(key, []).append(
+            IndexManifestEntry(
+                kind=1, partition=e.partition, bucket=e.bucket, 
index_file=e.index_file
+            )
+        )
+    return [
+        CommitMessage(partition=key, bucket=0, new_files=[], 
index_deletes=dels)
+        for key, dels in by_partition.items()
+    ]
+
+
+def apply_global_index_update_action(
+    table,
+    snapshot,
+    updated_cols: Sequence[str],
+    written_partitions: Set[Tuple],
+) -> list:
+    if snapshot is None or not updated_cols or not written_partitions:
+        return []
+    entries = scan_global_index_entries(table, snapshot)
+    if not entries:
+        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
+    ]
+    if not affected:
+        return []
+    action = table.options.global_index_column_update_action()
+    if action is None:
+        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}"
+    )
diff --git a/paimon-python/pypaimon/write/table_update.py 
b/paimon-python/pypaimon/write/table_update.py
index fe2fb9a64b..0882bda8c6 100644
--- a/paimon-python/pypaimon/write/table_update.py
+++ b/paimon-python/pypaimon/write/table_update.py
@@ -136,12 +136,6 @@ class TableUpdate:
     def _update_by_arrow_with_row_id(
             self, table: pa.Table, commit_identifier: int
     ) -> List[CommitMessage]:
-        """Shared implementation for ``update_by_arrow_with_row_id``.
-
-        The public method lives on the concrete subclasses so each can
-        expose the signature appropriate to its mode (batch vs stream).
-        Produced commit messages are tagged with ``commit_identifier``.
-        """
         return TableUpdateByRowId(
             self.table, self.commit_user, commit_identifier,
         ).update_columns(table, self.update_cols)

Reply via email to