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 8393d28ba2 [python] Reject changing immutable options once a table has 
snapshots (#8565)
8393d28ba2 is described below

commit 8393d28ba2a73770bcb0a641affb67666671114e
Author: Jiajia Li <[email protected]>
AuthorDate: Mon Jul 13 10:18:48 2026 +0800

    [python] Reject changing immutable options once a table has snapshots 
(#8565)
---
 .../pypaimon/common/options/core_options.py        |  30 ++++-
 paimon-python/pypaimon/schema/schema_manager.py    |  54 +++++++++
 .../pypaimon/snapshot/snapshot_manager.py          |  70 +++++++++--
 .../pypaimon/tests/filesystem_catalog_test.py      | 132 +++++++++++++++++++++
 .../pypaimon/tests/ray_read_by_row_id_test.py      |  20 ++--
 .../pypaimon/tests/snapshot_manager_test.py        |  63 +++++++++-
 6 files changed, 347 insertions(+), 22 deletions(-)

diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index 32d115a723..aa58e48eb4 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -99,6 +99,34 @@ class GlobalIndexSearchMode(str, Enum):
 
 class CoreOptions:
     """Core options for Paimon tables."""
+
+    # Options that define the table's structure/identity and cannot be changed
+    # once the table has snapshots. Mirrors the @Immutable annotated options in
+    # the Java org.apache.paimon.CoreOptions (IMMUTABLE_OPTIONS).
+    IMMUTABLE_OPTIONS: frozenset = frozenset([
+        "type",
+        "bucket-key",
+        "bucket-function.type",
+        "data-file.path-directory",
+        "merge-engine",
+        "sequence.snapshot-ordering",
+        "aggregation.remove-record-on-delete",
+        "partial-update.remove-record-on-delete",
+        "partial-update.remove-record-on-sequence-group",
+        "rowkind.field",
+        "primary-key",
+        "partition",
+        "dynamic-bucket.initial-buckets",
+        "force-lookup",
+        "row-tracking.enabled",
+        "data-evolution.enabled",
+        "index-file-in-data-file-dir",
+        "blob-field",
+        "blob-descriptor-field",
+        "blob-view-field",
+        "pk-clustering-override",
+    ])
+
     # File format constants
     FILE_FORMAT_ORC: str = "orc"
     FILE_FORMAT_AVRO: str = "avro"
@@ -127,7 +155,7 @@ class CoreOptions:
     TYPE: ConfigOption[str] = (
         ConfigOptions.key("type")
         .string_type()
-        .default_value("primary-key")
+        .default_value("table")
         .with_description("Specify what type of table this is.")
     )
 
diff --git a/paimon-python/pypaimon/schema/schema_manager.py 
b/paimon-python/pypaimon/schema/schema_manager.py
index 3e25c9b242..0f31b55f34 100644
--- a/paimon-python/pypaimon/schema/schema_manager.py
+++ b/paimon-python/pypaimon/schema/schema_manager.py
@@ -294,6 +294,26 @@ def _get_type_root(data_type) -> str:
     return getattr(data_type, 'type', '')
 
 
+def _normalize_key_list(value: str) -> List[str]:
+    return [s.strip() for s in value.split(',') if s.strip()]
+
+
+def _is_unchanged_normalized_key(key, old_value, new_value, old_table_schema) 
-> bool:
+    """Values whose canonical home is outside the options map (or the implicit
+    default) are not a change when replayed. Mirrors the Java SchemaManager's
+    isUnchangedNormalizedKey."""
+    if old_value is not None or new_value is None:
+        return False
+    if key == "primary-key":
+        return _normalize_key_list(new_value) == old_table_schema.primary_keys
+    if key == "partition":
+        return _normalize_key_list(new_value) == 
old_table_schema.partition_keys
+    if key == "type":
+        # a table created without an explicit type is of the default type
+        return new_value == CoreOptions.TYPE.default_value()
+    return False
+
+
 def _assert_not_updating_partition_keys(
         schema: 'TableSchema', field_names: List[str], operation: str):
     if len(field_names) > 1:
@@ -646,10 +666,44 @@ class SchemaManager:
         disable_null_to_not_null = str(old_table_schema.options.get(
             'alter-column-null-to-not-null.disabled', 'true')).lower() != 
'false'
 
+        # Structural options cannot be changed once the table has snapshots
+        # (mirrors the Java SchemaManager checkAlterTableOption /
+        # checkResetTableOption guards). The snapshot check is lazy so that
+        # alters without option changes never pay for it.
+        has_snapshots: Optional[bool] = None
+
+        def _has_snapshots() -> bool:
+            nonlocal has_snapshots
+            if has_snapshots is None:
+                from pypaimon.snapshot.snapshot_manager import SnapshotManager
+                has_snapshots = SnapshotManager(
+                    self.file_io, self.table_path, self.branch
+                ).get_latest_snapshot() is not None
+            return has_snapshots
+
         for change in changes:
             if isinstance(change, SetOption):
+                # compare against the accumulated options so repeated changes 
to
+                # the same key in one batch apply in order
+                old_value = new_options.get(change.key)
+                unchanged = (old_value == change.value
+                             or _is_unchanged_normalized_key(
+                                 change.key, old_value, change.value, 
old_table_schema))
+                if unchanged:
+                    continue
+                # the type decides the table implementation, and table kinds 
like
+                # format tables never create Paimon snapshots but can still 
hold
+                # data -- reject independently of the snapshot check
+                if change.key == "type":
+                    raise ValueError(f"Change '{change.key}' is not supported 
yet.")
+                if change.key in CoreOptions.IMMUTABLE_OPTIONS and 
_has_snapshots():
+                    raise ValueError(f"Change '{change.key}' is not supported 
yet.")
                 new_options[change.key] = change.value
             elif isinstance(change, RemoveOption):
+                if change.key == "type":
+                    raise ValueError(f"Change '{change.key}' is not supported 
yet.")
+                if change.key in CoreOptions.IMMUTABLE_OPTIONS and 
_has_snapshots():
+                    raise ValueError(f"Change '{change.key}' is not supported 
yet.")
                 new_options.pop(change.key, None)
             elif isinstance(change, UpdateComment):
                 new_comment = change.comment
diff --git a/paimon-python/pypaimon/snapshot/snapshot_manager.py 
b/paimon-python/pypaimon/snapshot/snapshot_manager.py
index d404a0dd94..2fc1438f56 100644
--- a/paimon-python/pypaimon/snapshot/snapshot_manager.py
+++ b/paimon-python/pypaimon/snapshot/snapshot_manager.py
@@ -94,23 +94,75 @@ class SnapshotManager:
 
     def _get_latest_snapshot_from_filesystem(self) -> Optional[Snapshot]:
         """
-        Get the latest snapshot from filesystem by reading LATEST file.
-        
+        Get the latest snapshot from the filesystem. The LATEST file is only a
+        hint: when it is missing, stale, or unreadable, reconcile with a scan
+        of the snapshot files, like the Java SnapshotManager.findLatest.
+
+        If the hint indicates the table has snapshots but none can be resolved
+        (e.g. an unreadable LATEST plus a listing that an IO error truncated to
+        empty), fail closed rather than report the table as empty -- callers
+        such as the immutable-option guard must not mistake a populated table
+        for an empty one.
+
         Returns:
-            The latest snapshot, or None if not found
+            The latest snapshot, or None if the table genuinely has none
         """
-        if not self.file_io.exists(self.latest_file):
+        if not self.file_io.exists(self.snapshot_dir):
             return None
 
-        latest_content = self.read_latest_file()
-        latest_snapshot_id = int(latest_content.strip())
-
-        snapshot_file = f"{self.snapshot_dir}/snapshot-{latest_snapshot_id}"
-        if not self.file_io.exists(snapshot_file):
+        hint_id = None
+        hint_indicates_data = False
+        hint_read_error = None
+        if self.file_io.exists(self.latest_file):
+            try:
+                content = self.file_io.read_file_utf8(self.latest_file)
+                if content and content.strip():
+                    hint_indicates_data = True
+                    hint_id = int(content.strip())
+            except IOError as e:
+                # A present-but-unreadable hint most likely guards existing
+                # snapshots; remember the failure so we fail closed below.
+                hint_indicates_data = True
+                hint_read_error = e
+            except ValueError:
+                # Non-empty but corrupt hint; reconcile with a listing.
+                hint_indicates_data = True
+
+        # Trust the hint only when it points to an existing snapshot and no
+        # newer snapshot exists -- a lagging _commit_latest_hint leaves the
+        # hint stale. Otherwise reconcile with a directory listing (mirrors
+        # Java SnapshotManager.findLatest).
+        latest_snapshot_id = hint_id
+        if (latest_snapshot_id is None
+                or not 
self.file_io.exists(self.get_snapshot_path(latest_snapshot_id))
+                or 
self.file_io.exists(self.get_snapshot_path(latest_snapshot_id + 1))):
+            latest_snapshot_id = self._max_snapshot_id_from_listing()
+
+        if latest_snapshot_id is None:
+            if hint_indicates_data:
+                raise RuntimeError(
+                    f"Snapshot hint under {self.snapshot_dir} indicates 
existing "
+                    f"snapshots but none could be resolved"
+                    + (f": {hint_read_error}" if hint_read_error else ""))
             return None
 
+        snapshot_file = self.get_snapshot_path(latest_snapshot_id)
         return JSON.from_json(self.file_io.read_file_utf8(snapshot_file), 
Snapshot)
 
+    def _max_snapshot_id_from_listing(self) -> Optional[int]:
+        """Scan the snapshot directory for the largest snapshot-N file id."""
+        import re
+
+        snapshot_pattern = re.compile(r'^snapshot-(\d+)$')
+        max_snapshot_id = None
+        for file_info in self.file_io.list_status(self.snapshot_dir):
+            match = snapshot_pattern.match(file_info.path.split('/')[-1])
+            if match:
+                snapshot_id = int(match.group(1))
+                if max_snapshot_id is None or snapshot_id > max_snapshot_id:
+                    max_snapshot_id = snapshot_id
+        return max_snapshot_id
+
     def read_latest_file(self, max_retries: int = 5):
         """
         Read the latest snapshot ID from LATEST file with retry mechanism.
diff --git a/paimon-python/pypaimon/tests/filesystem_catalog_test.py 
b/paimon-python/pypaimon/tests/filesystem_catalog_test.py
index 0251e7fd88..c22193ea15 100644
--- a/paimon-python/pypaimon/tests/filesystem_catalog_test.py
+++ b/paimon-python/pypaimon/tests/filesystem_catalog_test.py
@@ -219,6 +219,138 @@ class FileSystemCatalogTest(unittest.TestCase):
         table = catalog.get_table(identifier)
         self.assertEqual(len(table.fields), 2)
 
+    def test_alter_immutable_options(self):
+        catalog = CatalogFactory.create({"warehouse": self.warehouse})
+        catalog.create_database("test_db", False)
+
+        # while a table has no snapshots, structural options may still be 
changed
+        empty_identifier = "test_db.immutable_options_empty"
+        pa_schema = pa.schema([('id', pa.int32()), ('name', pa.string())])
+        catalog.create_table(empty_identifier, 
Schema.from_pyarrow_schema(pa_schema), False)
+        catalog.alter_table(
+            empty_identifier,
+            [SchemaChange.set_option("merge-engine", "first-row")],
+            False
+        )
+        # the type is rejected even without snapshots: table kinds like format
+        # tables never create Paimon snapshots but can still hold data
+        with self.assertRaises(RuntimeError) as ctx:
+            catalog.alter_table(
+                empty_identifier,
+                [SchemaChange.set_option("type", "format-table")], False)
+        self.assertIn("Change 'type' is not supported yet.", 
str(ctx.exception))
+        with self.assertRaises(RuntimeError):
+            catalog.alter_table(
+                empty_identifier, [SchemaChange.remove_option("type")], False)
+
+        # once the table has a snapshot, immutable options are rejected
+        identifier = "test_db.immutable_options_table"
+        catalog.create_table(identifier, 
Schema.from_pyarrow_schema(pa_schema), False)
+        table = catalog.get_table(identifier)
+        write_builder = table.new_batch_write_builder()
+        table_write = write_builder.new_write()
+        table_commit = write_builder.new_commit()
+        table_write.write_arrow(
+            pa.Table.from_pydict({'id': [1, 2], 'name': ['a', 'b']}, 
schema=pa_schema))
+        table_commit.commit(table_write.prepare_commit())
+        table_write.close()
+        table_commit.close()
+
+        # the catalog wraps alter failures in RuntimeError
+        with self.assertRaises(RuntimeError) as ctx:
+            catalog.alter_table(
+                identifier, [SchemaChange.set_option("type", "format-table")], 
False)
+        self.assertIn("Change 'type' is not supported yet.", 
str(ctx.exception))
+
+        with self.assertRaises(RuntimeError) as ctx:
+            catalog.alter_table(
+                identifier, [SchemaChange.set_option("merge-engine", 
"deduplicate")], False)
+        self.assertIn("Change 'merge-engine' is not supported yet.", 
str(ctx.exception))
+
+        with self.assertRaises(RuntimeError) as ctx:
+            catalog.alter_table(
+                identifier, [SchemaChange.remove_option("merge-engine")], 
False)
+        self.assertIn("Change 'merge-engine' is not supported yet.", 
str(ctx.exception))
+
+        # canonical hyphenated keys are guarded too
+        with self.assertRaises(RuntimeError) as ctx:
+            catalog.alter_table(
+                identifier,
+                [SchemaChange.set_option("index-file-in-data-file-dir", 
"true")], False)
+        self.assertIn(
+            "Change 'index-file-in-data-file-dir' is not supported yet.", 
str(ctx.exception))
+
+        # setting the default type explicitly is not a change
+        catalog.alter_table(
+            identifier, [SchemaChange.set_option("type", "table")], False)
+        table = catalog.get_table(identifier)
+        self.assertNotIn("type", table.table_schema.options)
+        # ...and the reloaded effective type resolves to the requested "table"
+        # (the default), not some other configured default
+        from pypaimon.common.options import CoreOptions, Options
+        self.assertEqual(
+            CoreOptions(Options(table.table_schema.options)).type(), "table")
+
+        # mutable options can still be changed
+        catalog.alter_table(
+            identifier, [SchemaChange.set_option("target-file-size", "10mb")], 
False)
+
+        # repeated changes to the same option in one batch apply in order
+        catalog.alter_table(identifier, [
+            SchemaChange.set_option("target-file-size", "20mb"),
+            SchemaChange.set_option("target-file-size", "10mb")], False)
+        table = catalog.get_table(identifier)
+        self.assertEqual(table.table_schema.options.get("target-file-size"), 
"10mb")
+        catalog.alter_table(identifier, [
+            SchemaChange.remove_option("target-file-size"),
+            SchemaChange.set_option("target-file-size", "10mb")], False)
+        table = catalog.get_table(identifier)
+        self.assertEqual(table.table_schema.options.get("target-file-size"), 
"10mb")
+
+        # replaying the actual primary/partition keys is not a change: they are
+        # stored in the schema fields, not the options map
+        pk_identifier = "test_db.immutable_options_pk"
+        catalog.create_table(
+            pk_identifier,
+            Schema.from_pyarrow_schema(
+                pa_schema, primary_keys=['id'], options={'bucket': '1'}),
+            False)
+        pk_table = catalog.get_table(pk_identifier)
+        write_builder = pk_table.new_batch_write_builder()
+        table_write = write_builder.new_write()
+        table_commit = write_builder.new_commit()
+        table_write.write_arrow(
+            pa.Table.from_pydict({'id': [1], 'name': ['a']}, schema=pa_schema))
+        table_commit.commit(table_write.prepare_commit())
+        table_write.close()
+        table_commit.close()
+        catalog.alter_table(
+            pk_identifier, [SchemaChange.set_option("primary-key", "id")], 
False)
+        pk_table = catalog.get_table(pk_identifier)
+        self.assertNotIn("primary-key", pk_table.table_schema.options)
+        with self.assertRaises(RuntimeError):
+            catalog.alter_table(
+                pk_identifier, [SchemaChange.set_option("primary-key", 
"name")], False)
+
+        part_identifier = "test_db.immutable_options_part"
+        self._create_partitioned_table_with_data(
+            catalog, part_identifier, [{'dt': '2026-01-01', 'rows': 1}])
+        catalog.alter_table(
+            part_identifier, [SchemaChange.set_option("partition", "dt")], 
False)
+        with self.assertRaises(RuntimeError):
+            catalog.alter_table(
+                part_identifier, [SchemaChange.set_option("partition", 
"col1")], False)
+
+        # the LATEST file is only a hint: the guard must still see the
+        # snapshots when it is missing
+        os.remove(os.path.join(
+            self.warehouse, "test_db.db", "immutable_options_table",
+            "snapshot", "LATEST"))
+        with self.assertRaises(RuntimeError):
+            catalog.alter_table(
+                identifier,
+                [SchemaChange.set_option("merge-engine", "deduplicate")], 
False)
+
     def test_update_column_type_guards_null_to_not_null(self):
         catalog = CatalogFactory.create({"warehouse": self.warehouse})
         catalog.create_database("test_db_guard", False)
diff --git a/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py 
b/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py
index 2fa6cd00c9..c49d359042 100644
--- a/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py
+++ b/paimon-python/pypaimon/tests/ray_read_by_row_id_test.py
@@ -255,21 +255,19 @@ class RayReadByRowIdTest(unittest.TestCase):
             read_by_row_id(target, src, self.catalog_options, 
projection=["age"],
                            dynamic_options={"scan.snapshot-id": "1", 
"scan.tag-name": "x"})
 
-    def test_time_travel_before_row_tracking_raises(self):
+    def test_row_tracking_cannot_be_enabled_after_data(self):
+        # row-tracking.enabled / data-evolution.enabled are immutable once the
+        # table has snapshots (Java parity), so a "snapshot predates
+        # row-tracking" state can no longer be constructed through ALTER; the
+        # alter itself is rejected instead.
         from pypaimon.schema.schema_change import SchemaChange
         name = self._create(options={})   # plain table: no data-evolution / 
row-tracking
         self._write(name, pa.Table.from_pydict(
             {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
-        self.catalog.alter_table(name, [
-            SchemaChange.set_option("row-tracking.enabled", "true"),
-            SchemaChange.set_option("data-evolution.enabled", "true")])
-        self._write(name, pa.Table.from_pydict(
-            {"id": [2], "name": ["b"], "age": [2]}, schema=self.pa_schema))
-        src = pa.table({"_ROW_ID": [0]}, schema=pa.schema([("_ROW_ID", 
pa.int64())]))
-        # snapshot 1 predates row-tracking -> clear error, not a silent empty 
read
-        with self.assertRaisesRegex(ValueError, "row-tracking|data-evolution"):
-            read_by_row_id(name, src, self.catalog_options, projection=["age"],
-                           dynamic_options={"scan.snapshot-id": "1"})
+        with self.assertRaisesRegex(RuntimeError, "not supported yet"):
+            self.catalog.alter_table(name, [
+                SchemaChange.set_option("row-tracking.enabled", "true"),
+                SchemaChange.set_option("data-evolution.enabled", "true")])
 
     def test_pins_base_snapshot(self):
         import importlib
diff --git a/paimon-python/pypaimon/tests/snapshot_manager_test.py 
b/paimon-python/pypaimon/tests/snapshot_manager_test.py
index c5adcb0537..f09f61fc3a 100644
--- a/paimon-python/pypaimon/tests/snapshot_manager_test.py
+++ b/paimon-python/pypaimon/tests/snapshot_manager_test.py
@@ -20,7 +20,7 @@ Tests for SnapshotManager batch lookahead functionality.
 """
 
 import unittest
-from unittest.mock import Mock
+from unittest.mock import Mock, patch
 
 
 def _create_mock_snapshot(snapshot_id: int, commit_kind: str = "APPEND"):
@@ -162,5 +162,66 @@ class SnapshotManagerTest(unittest.TestCase):
             manager.try_get_earliest_snapshot()
 
 
+def _file_info(path):
+    info = Mock()
+    info.path = path
+    return info
+
+
+class SnapshotManagerFilesystemHintTest(unittest.TestCase):
+    """Tests for _get_latest_snapshot_from_filesystem hint reconciliation."""
+
+    SNAP_DIR = "/tmp/test_table/snapshot"
+    LATEST = "/tmp/test_table/snapshot/LATEST"
+
+    def _snap(self, n):
+        return "{}/snapshot-{}".format(self.SNAP_DIR, n)
+
+    def test_ignores_stale_latest_hint(self):
+        # LATEST still points to snapshot 1 but snapshot 2 was committed (a
+        # lagging _commit_latest_hint); the newer snapshot must win.
+        present = {self.SNAP_DIR, self.LATEST, self._snap(1), self._snap(2)}
+        file_io = Mock()
+        file_io.exists.side_effect = lambda p: p in present
+        file_io.read_file_utf8.side_effect = (
+            lambda p: "1" if p == self.LATEST else "content-" + p.rsplit("-", 
1)[-1])
+        file_io.list_status.return_value = [
+            _file_info(self._snap(1)), _file_info(self._snap(2))]
+        manager = _build_manager(file_io)
+        with patch(
+                'pypaimon.snapshot.snapshot_manager.JSON.from_json',
+                side_effect=lambda content, cls: content):
+            result = manager._get_latest_snapshot_from_filesystem()
+        self.assertEqual(result, "content-2")
+
+    def test_fails_closed_when_hint_unreadable_and_listing_empty(self):
+        # LATEST exists (the table is non-empty) but cannot be read, and the
+        # listing came back empty (e.g. a swallowed PermissionError). Returning
+        # None here would let the immutable-option guard fail open.
+        present = {self.SNAP_DIR, self.LATEST}
+        file_io = Mock()
+        file_io.exists.side_effect = lambda p: p in present
+
+        def _read(p):
+            if p == self.LATEST:
+                raise IOError("permission denied")
+            raise AssertionError("unexpected read of " + p)
+
+        file_io.read_file_utf8.side_effect = _read
+        file_io.list_status.return_value = []
+        manager = _build_manager(file_io)
+        with self.assertRaises(RuntimeError):
+            manager._get_latest_snapshot_from_filesystem()
+
+    def test_returns_none_when_table_genuinely_empty(self):
+        # No LATEST and no snapshot files: a real empty table, not an error.
+        present = {self.SNAP_DIR}
+        file_io = Mock()
+        file_io.exists.side_effect = lambda p: p in present
+        file_io.list_status.return_value = []
+        manager = _build_manager(file_io)
+        self.assertIsNone(manager._get_latest_snapshot_from_filesystem())
+
+
 if __name__ == '__main__':
     unittest.main()

Reply via email to