Skip to content

Commit 8393d28

Browse files
[python] Reject changing immutable options once a table has snapshots (#8565)
1 parent 6509987 commit 8393d28

6 files changed

Lines changed: 347 additions & 22 deletions

File tree

paimon-python/pypaimon/common/options/core_options.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,34 @@ class GlobalIndexSearchMode(str, Enum):
9999

100100
class CoreOptions:
101101
"""Core options for Paimon tables."""
102+
103+
# Options that define the table's structure/identity and cannot be changed
104+
# once the table has snapshots. Mirrors the @Immutable annotated options in
105+
# the Java org.apache.paimon.CoreOptions (IMMUTABLE_OPTIONS).
106+
IMMUTABLE_OPTIONS: frozenset = frozenset([
107+
"type",
108+
"bucket-key",
109+
"bucket-function.type",
110+
"data-file.path-directory",
111+
"merge-engine",
112+
"sequence.snapshot-ordering",
113+
"aggregation.remove-record-on-delete",
114+
"partial-update.remove-record-on-delete",
115+
"partial-update.remove-record-on-sequence-group",
116+
"rowkind.field",
117+
"primary-key",
118+
"partition",
119+
"dynamic-bucket.initial-buckets",
120+
"force-lookup",
121+
"row-tracking.enabled",
122+
"data-evolution.enabled",
123+
"index-file-in-data-file-dir",
124+
"blob-field",
125+
"blob-descriptor-field",
126+
"blob-view-field",
127+
"pk-clustering-override",
128+
])
129+
102130
# File format constants
103131
FILE_FORMAT_ORC: str = "orc"
104132
FILE_FORMAT_AVRO: str = "avro"
@@ -127,7 +155,7 @@ class CoreOptions:
127155
TYPE: ConfigOption[str] = (
128156
ConfigOptions.key("type")
129157
.string_type()
130-
.default_value("primary-key")
158+
.default_value("table")
131159
.with_description("Specify what type of table this is.")
132160
)
133161

paimon-python/pypaimon/schema/schema_manager.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,26 @@ def _get_type_root(data_type) -> str:
294294
return getattr(data_type, 'type', '')
295295

296296

297+
def _normalize_key_list(value: str) -> List[str]:
298+
return [s.strip() for s in value.split(',') if s.strip()]
299+
300+
301+
def _is_unchanged_normalized_key(key, old_value, new_value, old_table_schema) -> bool:
302+
"""Values whose canonical home is outside the options map (or the implicit
303+
default) are not a change when replayed. Mirrors the Java SchemaManager's
304+
isUnchangedNormalizedKey."""
305+
if old_value is not None or new_value is None:
306+
return False
307+
if key == "primary-key":
308+
return _normalize_key_list(new_value) == old_table_schema.primary_keys
309+
if key == "partition":
310+
return _normalize_key_list(new_value) == old_table_schema.partition_keys
311+
if key == "type":
312+
# a table created without an explicit type is of the default type
313+
return new_value == CoreOptions.TYPE.default_value()
314+
return False
315+
316+
297317
def _assert_not_updating_partition_keys(
298318
schema: 'TableSchema', field_names: List[str], operation: str):
299319
if len(field_names) > 1:
@@ -646,10 +666,44 @@ def _generate_table_schema(
646666
disable_null_to_not_null = str(old_table_schema.options.get(
647667
'alter-column-null-to-not-null.disabled', 'true')).lower() != 'false'
648668

669+
# Structural options cannot be changed once the table has snapshots
670+
# (mirrors the Java SchemaManager checkAlterTableOption /
671+
# checkResetTableOption guards). The snapshot check is lazy so that
672+
# alters without option changes never pay for it.
673+
has_snapshots: Optional[bool] = None
674+
675+
def _has_snapshots() -> bool:
676+
nonlocal has_snapshots
677+
if has_snapshots is None:
678+
from pypaimon.snapshot.snapshot_manager import SnapshotManager
679+
has_snapshots = SnapshotManager(
680+
self.file_io, self.table_path, self.branch
681+
).get_latest_snapshot() is not None
682+
return has_snapshots
683+
649684
for change in changes:
650685
if isinstance(change, SetOption):
686+
# compare against the accumulated options so repeated changes to
687+
# the same key in one batch apply in order
688+
old_value = new_options.get(change.key)
689+
unchanged = (old_value == change.value
690+
or _is_unchanged_normalized_key(
691+
change.key, old_value, change.value, old_table_schema))
692+
if unchanged:
693+
continue
694+
# the type decides the table implementation, and table kinds like
695+
# format tables never create Paimon snapshots but can still hold
696+
# data -- reject independently of the snapshot check
697+
if change.key == "type":
698+
raise ValueError(f"Change '{change.key}' is not supported yet.")
699+
if change.key in CoreOptions.IMMUTABLE_OPTIONS and _has_snapshots():
700+
raise ValueError(f"Change '{change.key}' is not supported yet.")
651701
new_options[change.key] = change.value
652702
elif isinstance(change, RemoveOption):
703+
if change.key == "type":
704+
raise ValueError(f"Change '{change.key}' is not supported yet.")
705+
if change.key in CoreOptions.IMMUTABLE_OPTIONS and _has_snapshots():
706+
raise ValueError(f"Change '{change.key}' is not supported yet.")
653707
new_options.pop(change.key, None)
654708
elif isinstance(change, UpdateComment):
655709
new_comment = change.comment

paimon-python/pypaimon/snapshot/snapshot_manager.py

Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,23 +94,75 @@ def get_latest_snapshot(self) -> Optional[Snapshot]:
9494

9595
def _get_latest_snapshot_from_filesystem(self) -> Optional[Snapshot]:
9696
"""
97-
Get the latest snapshot from filesystem by reading LATEST file.
98-
97+
Get the latest snapshot from the filesystem. The LATEST file is only a
98+
hint: when it is missing, stale, or unreadable, reconcile with a scan
99+
of the snapshot files, like the Java SnapshotManager.findLatest.
100+
101+
If the hint indicates the table has snapshots but none can be resolved
102+
(e.g. an unreadable LATEST plus a listing that an IO error truncated to
103+
empty), fail closed rather than report the table as empty -- callers
104+
such as the immutable-option guard must not mistake a populated table
105+
for an empty one.
106+
99107
Returns:
100-
The latest snapshot, or None if not found
108+
The latest snapshot, or None if the table genuinely has none
101109
"""
102-
if not self.file_io.exists(self.latest_file):
110+
if not self.file_io.exists(self.snapshot_dir):
103111
return None
104112

105-
latest_content = self.read_latest_file()
106-
latest_snapshot_id = int(latest_content.strip())
107-
108-
snapshot_file = f"{self.snapshot_dir}/snapshot-{latest_snapshot_id}"
109-
if not self.file_io.exists(snapshot_file):
113+
hint_id = None
114+
hint_indicates_data = False
115+
hint_read_error = None
116+
if self.file_io.exists(self.latest_file):
117+
try:
118+
content = self.file_io.read_file_utf8(self.latest_file)
119+
if content and content.strip():
120+
hint_indicates_data = True
121+
hint_id = int(content.strip())
122+
except IOError as e:
123+
# A present-but-unreadable hint most likely guards existing
124+
# snapshots; remember the failure so we fail closed below.
125+
hint_indicates_data = True
126+
hint_read_error = e
127+
except ValueError:
128+
# Non-empty but corrupt hint; reconcile with a listing.
129+
hint_indicates_data = True
130+
131+
# Trust the hint only when it points to an existing snapshot and no
132+
# newer snapshot exists -- a lagging _commit_latest_hint leaves the
133+
# hint stale. Otherwise reconcile with a directory listing (mirrors
134+
# Java SnapshotManager.findLatest).
135+
latest_snapshot_id = hint_id
136+
if (latest_snapshot_id is None
137+
or not self.file_io.exists(self.get_snapshot_path(latest_snapshot_id))
138+
or self.file_io.exists(self.get_snapshot_path(latest_snapshot_id + 1))):
139+
latest_snapshot_id = self._max_snapshot_id_from_listing()
140+
141+
if latest_snapshot_id is None:
142+
if hint_indicates_data:
143+
raise RuntimeError(
144+
f"Snapshot hint under {self.snapshot_dir} indicates existing "
145+
f"snapshots but none could be resolved"
146+
+ (f": {hint_read_error}" if hint_read_error else ""))
110147
return None
111148

149+
snapshot_file = self.get_snapshot_path(latest_snapshot_id)
112150
return JSON.from_json(self.file_io.read_file_utf8(snapshot_file), Snapshot)
113151

152+
def _max_snapshot_id_from_listing(self) -> Optional[int]:
153+
"""Scan the snapshot directory for the largest snapshot-N file id."""
154+
import re
155+
156+
snapshot_pattern = re.compile(r'^snapshot-(\d+)$')
157+
max_snapshot_id = None
158+
for file_info in self.file_io.list_status(self.snapshot_dir):
159+
match = snapshot_pattern.match(file_info.path.split('/')[-1])
160+
if match:
161+
snapshot_id = int(match.group(1))
162+
if max_snapshot_id is None or snapshot_id > max_snapshot_id:
163+
max_snapshot_id = snapshot_id
164+
return max_snapshot_id
165+
114166
def read_latest_file(self, max_retries: int = 5):
115167
"""
116168
Read the latest snapshot ID from LATEST file with retry mechanism.

paimon-python/pypaimon/tests/filesystem_catalog_test.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,138 @@ def test_alter_table(self):
219219
table = catalog.get_table(identifier)
220220
self.assertEqual(len(table.fields), 2)
221221

222+
def test_alter_immutable_options(self):
223+
catalog = CatalogFactory.create({"warehouse": self.warehouse})
224+
catalog.create_database("test_db", False)
225+
226+
# while a table has no snapshots, structural options may still be changed
227+
empty_identifier = "test_db.immutable_options_empty"
228+
pa_schema = pa.schema([('id', pa.int32()), ('name', pa.string())])
229+
catalog.create_table(empty_identifier, Schema.from_pyarrow_schema(pa_schema), False)
230+
catalog.alter_table(
231+
empty_identifier,
232+
[SchemaChange.set_option("merge-engine", "first-row")],
233+
False
234+
)
235+
# the type is rejected even without snapshots: table kinds like format
236+
# tables never create Paimon snapshots but can still hold data
237+
with self.assertRaises(RuntimeError) as ctx:
238+
catalog.alter_table(
239+
empty_identifier,
240+
[SchemaChange.set_option("type", "format-table")], False)
241+
self.assertIn("Change 'type' is not supported yet.", str(ctx.exception))
242+
with self.assertRaises(RuntimeError):
243+
catalog.alter_table(
244+
empty_identifier, [SchemaChange.remove_option("type")], False)
245+
246+
# once the table has a snapshot, immutable options are rejected
247+
identifier = "test_db.immutable_options_table"
248+
catalog.create_table(identifier, Schema.from_pyarrow_schema(pa_schema), False)
249+
table = catalog.get_table(identifier)
250+
write_builder = table.new_batch_write_builder()
251+
table_write = write_builder.new_write()
252+
table_commit = write_builder.new_commit()
253+
table_write.write_arrow(
254+
pa.Table.from_pydict({'id': [1, 2], 'name': ['a', 'b']}, schema=pa_schema))
255+
table_commit.commit(table_write.prepare_commit())
256+
table_write.close()
257+
table_commit.close()
258+
259+
# the catalog wraps alter failures in RuntimeError
260+
with self.assertRaises(RuntimeError) as ctx:
261+
catalog.alter_table(
262+
identifier, [SchemaChange.set_option("type", "format-table")], False)
263+
self.assertIn("Change 'type' is not supported yet.", str(ctx.exception))
264+
265+
with self.assertRaises(RuntimeError) as ctx:
266+
catalog.alter_table(
267+
identifier, [SchemaChange.set_option("merge-engine", "deduplicate")], False)
268+
self.assertIn("Change 'merge-engine' is not supported yet.", str(ctx.exception))
269+
270+
with self.assertRaises(RuntimeError) as ctx:
271+
catalog.alter_table(
272+
identifier, [SchemaChange.remove_option("merge-engine")], False)
273+
self.assertIn("Change 'merge-engine' is not supported yet.", str(ctx.exception))
274+
275+
# canonical hyphenated keys are guarded too
276+
with self.assertRaises(RuntimeError) as ctx:
277+
catalog.alter_table(
278+
identifier,
279+
[SchemaChange.set_option("index-file-in-data-file-dir", "true")], False)
280+
self.assertIn(
281+
"Change 'index-file-in-data-file-dir' is not supported yet.", str(ctx.exception))
282+
283+
# setting the default type explicitly is not a change
284+
catalog.alter_table(
285+
identifier, [SchemaChange.set_option("type", "table")], False)
286+
table = catalog.get_table(identifier)
287+
self.assertNotIn("type", table.table_schema.options)
288+
# ...and the reloaded effective type resolves to the requested "table"
289+
# (the default), not some other configured default
290+
from pypaimon.common.options import CoreOptions, Options
291+
self.assertEqual(
292+
CoreOptions(Options(table.table_schema.options)).type(), "table")
293+
294+
# mutable options can still be changed
295+
catalog.alter_table(
296+
identifier, [SchemaChange.set_option("target-file-size", "10mb")], False)
297+
298+
# repeated changes to the same option in one batch apply in order
299+
catalog.alter_table(identifier, [
300+
SchemaChange.set_option("target-file-size", "20mb"),
301+
SchemaChange.set_option("target-file-size", "10mb")], False)
302+
table = catalog.get_table(identifier)
303+
self.assertEqual(table.table_schema.options.get("target-file-size"), "10mb")
304+
catalog.alter_table(identifier, [
305+
SchemaChange.remove_option("target-file-size"),
306+
SchemaChange.set_option("target-file-size", "10mb")], False)
307+
table = catalog.get_table(identifier)
308+
self.assertEqual(table.table_schema.options.get("target-file-size"), "10mb")
309+
310+
# replaying the actual primary/partition keys is not a change: they are
311+
# stored in the schema fields, not the options map
312+
pk_identifier = "test_db.immutable_options_pk"
313+
catalog.create_table(
314+
pk_identifier,
315+
Schema.from_pyarrow_schema(
316+
pa_schema, primary_keys=['id'], options={'bucket': '1'}),
317+
False)
318+
pk_table = catalog.get_table(pk_identifier)
319+
write_builder = pk_table.new_batch_write_builder()
320+
table_write = write_builder.new_write()
321+
table_commit = write_builder.new_commit()
322+
table_write.write_arrow(
323+
pa.Table.from_pydict({'id': [1], 'name': ['a']}, schema=pa_schema))
324+
table_commit.commit(table_write.prepare_commit())
325+
table_write.close()
326+
table_commit.close()
327+
catalog.alter_table(
328+
pk_identifier, [SchemaChange.set_option("primary-key", "id")], False)
329+
pk_table = catalog.get_table(pk_identifier)
330+
self.assertNotIn("primary-key", pk_table.table_schema.options)
331+
with self.assertRaises(RuntimeError):
332+
catalog.alter_table(
333+
pk_identifier, [SchemaChange.set_option("primary-key", "name")], False)
334+
335+
part_identifier = "test_db.immutable_options_part"
336+
self._create_partitioned_table_with_data(
337+
catalog, part_identifier, [{'dt': '2026-01-01', 'rows': 1}])
338+
catalog.alter_table(
339+
part_identifier, [SchemaChange.set_option("partition", "dt")], False)
340+
with self.assertRaises(RuntimeError):
341+
catalog.alter_table(
342+
part_identifier, [SchemaChange.set_option("partition", "col1")], False)
343+
344+
# the LATEST file is only a hint: the guard must still see the
345+
# snapshots when it is missing
346+
os.remove(os.path.join(
347+
self.warehouse, "test_db.db", "immutable_options_table",
348+
"snapshot", "LATEST"))
349+
with self.assertRaises(RuntimeError):
350+
catalog.alter_table(
351+
identifier,
352+
[SchemaChange.set_option("merge-engine", "deduplicate")], False)
353+
222354
def test_update_column_type_guards_null_to_not_null(self):
223355
catalog = CatalogFactory.create({"warehouse": self.warehouse})
224356
catalog.create_database("test_db_guard", False)

paimon-python/pypaimon/tests/ray_read_by_row_id_test.py

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -255,21 +255,19 @@ def test_rejects_multiple_time_travel_keys(self):
255255
read_by_row_id(target, src, self.catalog_options, projection=["age"],
256256
dynamic_options={"scan.snapshot-id": "1", "scan.tag-name": "x"})
257257

258-
def test_time_travel_before_row_tracking_raises(self):
258+
def test_row_tracking_cannot_be_enabled_after_data(self):
259+
# row-tracking.enabled / data-evolution.enabled are immutable once the
260+
# table has snapshots (Java parity), so a "snapshot predates
261+
# row-tracking" state can no longer be constructed through ALTER; the
262+
# alter itself is rejected instead.
259263
from pypaimon.schema.schema_change import SchemaChange
260264
name = self._create(options={}) # plain table: no data-evolution / row-tracking
261265
self._write(name, pa.Table.from_pydict(
262266
{"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
263-
self.catalog.alter_table(name, [
264-
SchemaChange.set_option("row-tracking.enabled", "true"),
265-
SchemaChange.set_option("data-evolution.enabled", "true")])
266-
self._write(name, pa.Table.from_pydict(
267-
{"id": [2], "name": ["b"], "age": [2]}, schema=self.pa_schema))
268-
src = pa.table({"_ROW_ID": [0]}, schema=pa.schema([("_ROW_ID", pa.int64())]))
269-
# snapshot 1 predates row-tracking -> clear error, not a silent empty read
270-
with self.assertRaisesRegex(ValueError, "row-tracking|data-evolution"):
271-
read_by_row_id(name, src, self.catalog_options, projection=["age"],
272-
dynamic_options={"scan.snapshot-id": "1"})
267+
with self.assertRaisesRegex(RuntimeError, "not supported yet"):
268+
self.catalog.alter_table(name, [
269+
SchemaChange.set_option("row-tracking.enabled", "true"),
270+
SchemaChange.set_option("data-evolution.enabled", "true")])
273271

274272
def test_pins_base_snapshot(self):
275273
import importlib

0 commit comments

Comments
 (0)