blob: b1f212283d75eb57b8514c49c241f8268e3bf6ef [file]
# 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 logging
import random
import time
import uuid
from typing import Dict, List, Optional
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.manifest.manifest_file_manager import ManifestFileManager
from pypaimon.manifest.manifest_file_merger import ManifestFileMerger
from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.file_entry import FileEntry
from pypaimon.manifest.schema.manifest_entry import ManifestEntry
from pypaimon.manifest.schema.manifest_file_meta import ManifestFileMeta
from pypaimon.snapshot.snapshot import Snapshot
from pypaimon.snapshot.snapshot_commit import (PartitionStatistics,
SnapshotCommit)
from pypaimon.table.row.generic_row import GenericRow, GenericRowSerializer
from pypaimon.table.row.offset_row import OffsetRow
from pypaimon.write.commit.commit_rollback import CommitRollback
from pypaimon.write.commit.commit_scanner import CommitScanner
from pypaimon.write.commit.conflict_detection import (
CommitConflictError,
ConflictDetection,
RowIdExistenceConflict,
)
from pypaimon.write.commit.row_id_conflict_rewriter import (
RowIdConflictRewriter,
RowIdRewriteResult,
)
from pypaimon.write.commit.overwrite_changes_provider import OverwriteChangesProvider
from pypaimon.table.special_fields import SpecialFields
from pypaimon.write.commit_callback import CommitCallback, CommitCallbackContext
from pypaimon.write.commit_message import CommitMessage
logger = logging.getLogger(__name__)
class CommitResult:
"""Base class for commit results."""
def is_success(self) -> bool:
"""Returns True if commit was successful."""
raise NotImplementedError
class SuccessResult(CommitResult):
"""Result indicating successful commit."""
def is_success(self) -> bool:
return True
def _manifest_file_key(manifest: ManifestFileMeta):
stats = manifest.partition_stats
return (
manifest.file_name,
manifest.file_size,
manifest.num_added_files,
manifest.num_deleted_files,
GenericRowSerializer.to_bytes(stats.min_values),
GenericRowSerializer.to_bytes(stats.max_values),
tuple(stats.null_counts) if stats.null_counts is not None else None,
manifest.schema_id,
manifest.min_row_id,
manifest.max_row_id,
)
def _try_replace_manifest_files(current, replaced, replacement):
"""Replace the first contiguous occurrence while preserving list order."""
current = list(current)
replaced = list(replaced)
replacement = list(replacement)
if not replaced:
return replacement if not current else None
current_keys = [_manifest_file_key(manifest) for manifest in current]
replaced_keys = [_manifest_file_key(manifest) for manifest in replaced]
for start in range(len(current) - len(replaced) + 1):
if current_keys[start:start + len(replaced)] == replaced_keys:
return (
current[:start]
+ replacement
+ current[start + len(replaced):]
)
return None
class ManifestMergeResult:
"""Manifest merge input and output retained for a deterministic retry."""
def __init__(self, merge_before_manifests, merge_after_manifests):
self.merge_before_manifests = tuple(merge_before_manifests)
self.merge_after_manifests = tuple(merge_after_manifests)
def _try_reuse_manifest_merge_result(retry_result, current_manifests):
if (retry_result is None
or retry_result.commit_result_may_be_uncertain
or retry_result.manifest_merge_result is None):
return None
previous = retry_result.manifest_merge_result
return _try_replace_manifest_files(
current_manifests,
previous.merge_before_manifests,
previous.merge_after_manifests,
)
class RetryResult(CommitResult):
def __init__(self, latest_snapshot, exception: Optional[Exception] = None,
base_data_files: Optional[List[ManifestEntry]] = None,
commit_result_may_be_uncertain: bool = False,
manifest_merge_result: Optional[ManifestMergeResult] = None):
self.latest_snapshot = latest_snapshot
self.exception = exception
self.commit_result_may_be_uncertain = commit_result_may_be_uncertain
# Base entries as of latest_snapshot, carried so the next attempt reuses
# them and reads only the incremental changes.
self.base_data_files = base_data_files
self.manifest_merge_result = manifest_merge_result
def is_success(self) -> bool:
return False
class RewriteResult(CommitResult):
def __init__(self, rewrite: RowIdRewriteResult):
self.rewrite = rewrite
def is_success(self) -> bool:
return False
class FileStoreCommit:
"""
Core commit logic for file store operations.
This class provides atomic commit functionality similar to
org.apache.paimon.operation.FileStoreCommitImpl in Java.
"""
def __init__(self, snapshot_commit: SnapshotCommit, table, commit_user: str,
commit_callbacks: Optional[List[CommitCallback]] = None):
from pypaimon.table.file_store_table import FileStoreTable
self.snapshot_commit = snapshot_commit
self.table: FileStoreTable = table
self.commit_user = commit_user
self.commit_callbacks: List[CommitCallback] = commit_callbacks if commit_callbacks is not None else []
self.snapshot_manager = table.snapshot_manager()
self.manifest_file_manager = ManifestFileManager(table)
self.manifest_list_manager = ManifestListManager(table)
self.manifest_target_size = table.options.manifest_target_size()
self.manifest_merge_min_count = table.options.manifest_merge_min_count()
self.manifest_file_merger = ManifestFileMerger(
self.manifest_file_manager,
self.manifest_target_size,
self.manifest_merge_min_count,
)
self.commit_max_retries = table.options.commit_max_retries()
self.commit_timeout = table.options.commit_timeout()
self.commit_min_retry_wait = table.options.commit_min_retry_wait()
self.commit_max_retry_wait = table.options.commit_max_retry_wait()
self.row_id_conflict_rewrite_max_size = (
table.options.data_evolution_row_id_conflict_rewrite_max_size()
)
self.commit_scanner = CommitScanner(table, self.manifest_list_manager)
self.conflict_detection = ConflictDetection(
data_evolution_enabled=table.options.data_evolution_enabled(),
snapshot_manager=self.snapshot_manager,
manifest_list_manager=self.manifest_list_manager,
table=table,
commit_scanner=self.commit_scanner
)
table_rollback = table.catalog_environment.catalog_table_rollback()
self.rollback = CommitRollback(table_rollback) if table_rollback is not None else None
def commit(self, commit_messages: List[CommitMessage], commit_identifier: int):
"""Commit the given commit messages in normal append mode."""
if not commit_messages:
return
# Extract the minimum check_from_snapshot from commit messages
valid_snapshots = [msg.check_from_snapshot for msg in commit_messages
if msg.check_from_snapshot != -1]
if valid_snapshots:
self.conflict_detection._row_id_check_from_snapshot = min(valid_snapshots)
logger.info(
"Ready to commit to table %s, number of commit messages: %d",
self.table.identifier,
len(commit_messages),
)
commit_entries = self._collect_manifest_entries(commit_messages)
changelog_entries = self._collect_changelog_entries(commit_messages)
logger.info("Finished collecting changes, including: %d entries, %d changelog entries",
len(commit_entries), len(changelog_entries))
index_deletes = []
index_adds = []
for msg in commit_messages:
index_deletes.extend(msg.index_deletes)
index_adds.extend(msg.index_adds)
hash_index_base_snapshot = self._hash_index_base_snapshot(
commit_messages
)
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
if self.conflict_detection.should_be_overwrite_commit(
commit_entries, index_adds + index_deletes):
commit_kind = "OVERWRITE"
detect_conflicts = True
allow_rollback = True
if self.conflict_detection.has_row_id_check_from_snapshot():
detect_conflicts = True
allow_rollback = True
if self.conflict_detection.has_global_index_additions(index_adds):
detect_conflicts = True
if self.conflict_detection.has_hash_index_changes(
index_adds + index_deletes):
detect_conflicts = True
if any(message.total_buckets is not None
for message in commit_messages):
# Detect concurrent bucket-count changes in postpone APPENDs.
detect_conflicts = True
self._try_commit(commit_kind=commit_kind,
commit_identifier=commit_identifier,
commit_entries_plan=lambda snapshot: commit_entries,
changelog_entries=changelog_entries,
detect_conflicts=detect_conflicts,
allow_rollback=allow_rollback,
index_deletes=index_deletes,
index_adds=index_adds,
hash_index_base_snapshot=hash_index_base_snapshot)
def overwrite(self, overwrite_partition, commit_messages: List[CommitMessage], commit_identifier: int):
"""Commit the given commit messages in overwrite mode."""
logger.info(
"Ready to overwrite to table %s, number of commit messages: %d",
self.table.identifier,
len(commit_messages),
)
skip_overwrite = False
partition_filter = None
# Partition filter is built from dynamic or static partition according to options.
if len(self.table.partition_keys) > 0 and self.table.options.dynamic_partition_overwrite():
if not commit_messages:
# In dynamic mode, if there are no changes to commit, no data will be deleted
skip_overwrite = True
else:
partition_filter = self._create_dynamic_partition_filter(commit_messages)
else:
partition_filter = self._create_static_partition_filter(overwrite_partition, commit_messages)
changelog_entries = self._collect_changelog_entries(commit_messages)
index_adds = [
entry for message in commit_messages for entry in message.index_adds
]
index_deletes = [
entry for message in commit_messages for entry in message.index_deletes
]
hash_index_base_snapshot = self._hash_index_base_snapshot(
commit_messages
)
if not skip_overwrite:
index_deletes = self._overwrite_hash_index_deletes(
partition_filter, index_deletes
)
provider = self._overwrite_changes_provider(partition_filter, commit_messages)
self._try_commit(
commit_kind="OVERWRITE",
commit_identifier=commit_identifier,
commit_entries_plan=provider.provide,
changelog_entries=changelog_entries,
detect_conflicts=True,
allow_rollback=False,
index_deletes=index_deletes,
index_adds=index_adds,
hash_index_base_snapshot=hash_index_base_snapshot,
)
@staticmethod
def _hash_index_base_snapshot(
commit_messages: List[CommitMessage],
) -> Optional[int]:
# Include data-only dynamic-bucket upserts. Their existing mappings
# are stable across append commits, but a concurrent overwrite may
# rebuild the HASH index and move a key to another bucket.
base_snapshots = [
getattr(message, "hash_index_base_snapshot", None)
for message in commit_messages
if getattr(message, "hash_index_base_snapshot", None) is not None
]
return min(base_snapshots) if base_snapshots else None
def _overwrite_hash_index_deletes(self, partition_filter, deletes):
"""Delete HASH indexes for every partition replaced by overwrite."""
from pypaimon.index.dynamic_bucket import HASH_INDEX
from pypaimon.index.index_file_handler import IndexFileHandler
from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
from pypaimon.table.bucket_mode import BucketMode
if self.table.bucket_mode() != BucketMode.HASH_DYNAMIC:
return deletes
by_file_name = {entry.index_file.file_name: entry for entry in deletes}
snapshot = self.snapshot_manager.get_latest_snapshot()
for entry in IndexFileHandler(self.table).scan(snapshot):
if entry.index_file.index_type != HASH_INDEX:
continue
if partition_filter is not None and not partition_filter.test(
entry.partition
):
continue
by_file_name[entry.index_file.file_name] = IndexManifestEntry(
kind=1,
partition=entry.partition,
bucket=entry.bucket,
index_file=entry.index_file,
)
return list(by_file_name.values())
def drop_partitions(self, partitions: List[Dict[str, str]], commit_identifier: int) -> None:
if not partitions:
raise ValueError("Partitions list cannot be empty.")
partition_keys_set = set(self.table.partition_keys)
for part in partitions:
for key in part:
if key not in partition_keys_set:
raise ValueError(
f"Partition spec key '{key}' is not a partition column. "
f"Partition keys are: {list(self.table.partition_keys)}."
)
predicate_builder = PredicateBuilder(self.table.partition_keys_fields)
default_part_value = self.table.options.options.get(
CoreOptions.PARTITION_DEFAULT_NAME, "__DEFAULT_PARTITION__")
partition_predicates = []
for part in partitions:
sub_predicates = []
for key, value in part.items():
if value is None or (isinstance(value, str) and value == default_part_value):
sub_predicates.append(predicate_builder.is_null(key))
else:
sub_predicates.append(predicate_builder.equal(key, value))
if sub_predicates:
pred = predicate_builder.and_predicates(sub_predicates)
if pred is not None:
partition_predicates.append(pred)
if not partition_predicates:
raise RuntimeError("Failed to build partition filter for drop_partitions.")
partition_filter = predicate_builder.or_predicates(partition_predicates)
self.drop_by_partition_filter(partition_filter, commit_identifier)
def drop_by_partition_filter(self, partition_filter, commit_identifier: int) -> None:
if partition_filter is None:
raise RuntimeError("Failed to build partition filter.")
provider = self._overwrite_changes_provider(partition_filter, [])
self._try_commit(
commit_kind="OVERWRITE",
commit_identifier=commit_identifier,
commit_entries_plan=provider.provide,
detect_conflicts=True,
allow_rollback=False,
)
def truncate_table(self, commit_identifier: int) -> None:
"""Truncate the entire table, deleting all data."""
provider = self._overwrite_changes_provider(None, [])
self._try_commit(
commit_kind="OVERWRITE",
commit_identifier=commit_identifier,
commit_entries_plan=provider.provide,
detect_conflicts=True,
allow_rollback=False,
)
def _try_commit(self, commit_kind, commit_identifier, commit_entries_plan,
detect_conflicts=False, allow_rollback=False, index_deletes=None,
index_adds=None, changelog_entries=None,
hash_index_base_snapshot=None):
retry_count = 0
retry_result = None
commit_result_may_be_uncertain = False
uncertain_commit_exception = None
rewritten_commit_entries = None
start_time_ms = int(time.time() * 1000)
while True:
latest_snapshot = self.snapshot_manager.get_latest_snapshot()
commit_entries = (
rewritten_commit_entries
if rewritten_commit_entries is not None
else commit_entries_plan(latest_snapshot)
)
# 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 and not index_deletes and not index_adds:
break
result = self._try_commit_once(
retry_result=retry_result,
commit_kind=commit_kind,
commit_entries=commit_entries,
changelog_entries=changelog_entries or [],
commit_identifier=commit_identifier,
latest_snapshot=latest_snapshot,
detect_conflicts=detect_conflicts,
allow_rollback=allow_rollback,
index_deletes=index_deletes,
index_adds=index_adds,
hash_index_base_snapshot=hash_index_base_snapshot,
commit_result_may_be_uncertain=commit_result_may_be_uncertain,
)
if isinstance(result, RewriteResult):
rewritten_commit_entries = result.rewrite.commit_entries
self.conflict_detection._row_id_check_from_snapshot = (
latest_snapshot.id
)
# No snapshot commit was attempted for the conflicting files,
# so the rewritten attempt is still deterministic.
retry_result = None
logger.info(
"Rewrote %d stale row-id file(s) against snapshot %d "
"before retrying commit to table %s.",
result.rewrite.rewritten_file_count,
latest_snapshot.id,
self.table.identifier,
)
elif result.is_success():
commit_duration_ms = int(time.time() * 1000) - start_time_ms
if commit_kind == "OVERWRITE":
logger.info(
"Finished overwrite to table %s, duration %d ms",
self.table.identifier,
commit_duration_ms,
)
else:
logger.info(
"Finished commit to table %s, duration %d ms",
self.table.identifier,
commit_duration_ms,
)
break
else:
retry_result = result
if result.commit_result_may_be_uncertain:
commit_result_may_be_uncertain = True
if uncertain_commit_exception is None:
uncertain_commit_exception = result.exception
elapsed_ms = int(time.time() * 1000) - start_time_ms
if elapsed_ms > self.commit_timeout or retry_count >= self.commit_max_retries:
if commit_kind == "OVERWRITE":
logger.info(
"Finished (Uncertain of success) overwrite to table %s, duration %d ms",
self.table.identifier,
elapsed_ms,
)
else:
logger.info(
"Finished (Uncertain of success) commit to table %s, duration %d ms",
self.table.identifier,
elapsed_ms,
)
error_msg = (
f"Commit failed {latest_snapshot.id + 1 if latest_snapshot else 1} "
f"after {elapsed_ms} millis with {retry_count} retries, "
f"there maybe exist commit conflicts between multiple jobs."
)
if commit_result_may_be_uncertain:
raise RuntimeError(error_msg) from uncertain_commit_exception
if retry_result is not None and retry_result.exception is None:
raise CommitConflictError(error_msg)
if retry_result is not None and retry_result.exception:
raise RuntimeError(error_msg) from retry_result.exception
else:
raise RuntimeError(error_msg)
self._commit_retry_wait(retry_count)
retry_count += 1
def _try_commit_once(self, retry_result: Optional[RetryResult], commit_kind: str,
commit_entries: List[ManifestEntry],
changelog_entries: List[ManifestEntry],
commit_identifier: int,
latest_snapshot: Optional[Snapshot],
detect_conflicts: bool = False,
allow_rollback: bool = False,
index_deletes=None,
index_adds=None,
hash_index_base_snapshot=None,
commit_result_may_be_uncertain: bool = False) -> CommitResult:
start_millis = int(time.time() * 1000)
if self._is_duplicate_commit(retry_result, latest_snapshot, commit_identifier, commit_kind):
return SuccessResult()
latest_snapshot_id = latest_snapshot.id if latest_snapshot else 0
if (
hash_index_base_snapshot is not None
and latest_snapshot_id != hash_index_base_snapshot
):
conflict = RuntimeError(
"HASH index assignment conflict detected: assigned from "
"snapshot {}, but the latest snapshot is {}.".format(
hash_index_base_snapshot, latest_snapshot_id
)
)
if not commit_result_may_be_uncertain:
raise CommitConflictError(str(conflict)) from conflict
raise conflict
unique_id = uuid.uuid4()
base_manifest_list = f"manifest-list-{unique_id}-0"
delta_manifest_list = f"manifest-list-{unique_id}-1"
# process new_manifest
new_manifest_file = f"manifest-{str(uuid.uuid4())}"
new_index_manifest = None
# process snapshot
new_snapshot_id = latest_snapshot.id + 1 if latest_snapshot else 1
index_entries = (index_deletes or []) + (index_adds or [])
# Base entries for conflict detection. On retry, reuse the previous
# attempt's base + read only the incremental changes (mirrors Java).
base_data_files = None
if detect_conflicts:
incremental = None
if (latest_snapshot is not None
and retry_result is not None
and retry_result.latest_snapshot is not None
and retry_result.base_data_files is not None):
incremental = self.commit_scanner.read_incremental_changes(
retry_result.latest_snapshot,
latest_snapshot,
commit_entries,
index_entries)
if incremental is not None:
base_data_files = list(retry_result.base_data_files)
if incremental:
base_data_files.extend(incremental)
base_data_files = FileEntry.merge_entries(base_data_files)
elif latest_snapshot is not None:
# First attempt, or incremental could not be built (missing
# snapshot): scan the changed partitions in full.
base_data_files = self.commit_scanner.read_all_entries_from_changed_partitions(
latest_snapshot, commit_entries, index_entries)
else:
base_data_files = []
conflict_exception = self.conflict_detection.check_conflicts(
latest_snapshot,
base_data_files,
commit_entries,
commit_kind,
index_entries,
)
if conflict_exception is not None:
rewrite_result = self._try_rewrite_row_id_conflict(
commit_result_may_be_uncertain,
conflict_exception,
latest_snapshot,
base_data_files,
commit_entries,
commit_kind,
commit_identifier,
changelog_entries,
)
if rewrite_result is not None:
return RewriteResult(rewrite_result)
if allow_rollback and self.rollback is not None:
if self.rollback.try_to_rollback(latest_snapshot):
# Rolled back: base/snapshot no longer valid; next attempt
# re-scans from scratch (matches Java RollbackRetryResult).
return RetryResult(None, conflict_exception)
if not commit_result_may_be_uncertain:
raise CommitConflictError(
str(conflict_exception)
) from conflict_exception
# A previous attempt may have committed despite returning an
# error. Preserve the generic, uncertain-result semantics so
# callers do not delete files which a snapshot may reference.
raise conflict_exception
# Apply row tracking logic after conflict detection (matches Java ordering)
row_tracking_enabled = self.table.options.row_tracking_enabled()
next_row_id = None
if row_tracking_enabled:
commit_entries = self._assign_snapshot_id(new_snapshot_id, commit_entries)
first_row_id_start = self._get_next_row_id_start(latest_snapshot)
commit_entries, next_row_id = self._assign_row_tracking_meta(first_row_id_start, commit_entries)
changelog_manifest_list_name = None
changelog_manifest_list_size = None
changelog_record_count = None
merge_before_manifests = []
merge_after_manifests = []
merge_new_files = []
skip_manifest_merge_on_retry = False
try:
new_manifest_file_metas = self._write_manifest_files(commit_entries, new_manifest_file)
self.manifest_list_manager.write(delta_manifest_list, new_manifest_file_metas)
# Write changelog manifest if changelog entries exist
if changelog_entries:
changelog_manifest_file = f"manifest-{str(uuid.uuid4())}-changelog"
changelog_manifest_file_metas = self._write_manifest_files(
changelog_entries, changelog_manifest_file)
changelog_manifest_list_name = f"manifest-list-{unique_id}-changelog"
self.manifest_list_manager.write(
changelog_manifest_list_name, changelog_manifest_file_metas)
manifest_path = self.manifest_list_manager.manifest_path
changelog_manifest_list_size = self.table.file_io.get_file_size(
f"{manifest_path}/{changelog_manifest_list_name}")
# kind==0 means ADD; pypaimon producers only support additions currently
changelog_record_count = sum(
entry.file.row_count for entry in changelog_entries if entry.kind == 0)
# process existing_manifest
total_record_count = 0
if latest_snapshot:
merge_before_manifests = self.manifest_list_manager.read_all(
latest_snapshot)
previous_record_count = latest_snapshot.total_record_count
if previous_record_count:
total_record_count += previous_record_count
reused_manifests = _try_reuse_manifest_merge_result(
retry_result, merge_before_manifests)
skip_manifest_merge_on_retry = (
reused_manifests is None and retry_result is not None)
if reused_manifests is not None:
merge_after_manifests = reused_manifests
old_names = {
manifest.file_name for manifest in merge_before_manifests
}
merge_new_files = [
manifest for manifest in merge_after_manifests
if manifest.file_name not in old_names
]
elif skip_manifest_merge_on_retry:
merge_after_manifests = merge_before_manifests
else:
merge_after_manifests, merge_new_files = (
self.manifest_file_merger.merge(
merge_before_manifests))
self.manifest_list_manager.write(
base_manifest_list, merge_after_manifests)
delta_record_count = 0
for entry in commit_entries:
if entry.kind == 0:
delta_record_count += entry.file.row_count
else:
delta_record_count -= entry.file.row_count
total_record_count += delta_record_count
index_manifest = latest_snapshot.index_manifest if latest_snapshot else None
if index_deletes or index_adds:
from pypaimon.manifest.index_manifest_file import IndexManifestFile
previous_index_manifest = index_manifest
index_manifest = IndexManifestFile(self.table).combine_changes(
previous_index_manifest, index_adds or [], index_deletes or [])
if index_manifest != previous_index_manifest:
new_index_manifest = index_manifest
snapshot_data = Snapshot(
version=3,
id=new_snapshot_id,
schema_id=self.table.table_schema.id,
base_manifest_list=base_manifest_list,
delta_manifest_list=delta_manifest_list,
changelog_manifest_list=changelog_manifest_list_name,
changelog_manifest_list_size=changelog_manifest_list_size,
changelog_record_count=changelog_record_count,
total_record_count=total_record_count,
delta_record_count=delta_record_count,
commit_user=self.commit_user,
commit_identifier=commit_identifier,
commit_kind=commit_kind,
time_millis=int(time.time() * 1000),
watermark=(
latest_snapshot.watermark if latest_snapshot else None),
next_row_id=next_row_id,
index_manifest=index_manifest,
)
# Generate partition statistics for the commit
statistics = self._generate_partition_statistics(commit_entries)
except Exception as e:
try:
self._clean_up_reuse_tmp_manifests(
delta_manifest_list, changelog_manifest_list_name, new_index_manifest)
self._clean_up_no_reuse_tmp_manifests(
base_manifest_list, merge_new_files)
except Exception as cleanup_err:
logger.warning(f"Failed to clean up temporary files: {cleanup_err}",
exc_info=True)
logger.warning(f"Exception occurs when preparing snapshot: {e}", exc_info=True)
raise RuntimeError(f"Failed to prepare snapshot: {e}")
# Use SnapshotCommit for atomic commit
try:
with self.snapshot_commit:
success = self.snapshot_commit.commit(
latest_snapshot.uuid if latest_snapshot else None,
snapshot_data,
statistics,
)
if not success:
commit_time_s = (int(time.time() * 1000) - start_millis) / 1000
logger.warning(
"Atomic commit failed for snapshot #%d by user %s "
"with identifier %s and kind %s after %.0f seconds. Try again.",
new_snapshot_id,
self.commit_user,
commit_identifier,
commit_kind,
commit_time_s,
)
manifest_merge_result = (
None
if skip_manifest_merge_on_retry
else ManifestMergeResult(
merge_before_manifests,
merge_after_manifests,
)
)
return RetryResult(
latest_snapshot,
None,
base_data_files=base_data_files,
manifest_merge_result=manifest_merge_result,
)
except Exception as e:
# Commit exception, not sure about the situation and should not clean up the files
logger.warning("Retry commit for exception.", exc_info=True)
return RetryResult(
latest_snapshot,
e,
base_data_files=base_data_files,
commit_result_may_be_uncertain=True,
manifest_merge_result=None,
)
logger.info(
"Successfully commit snapshot %d to table %s by user %s "
"with identifier %s and kind %s.",
new_snapshot_id,
self.table.identifier,
self.commit_user,
commit_identifier,
commit_kind,
)
if self.commit_callbacks:
context = CommitCallbackContext(
snapshot=snapshot_data,
commit_entries=commit_entries,
identifier=commit_identifier,
)
for callback in self.commit_callbacks:
callback.call(context)
return SuccessResult()
def _try_rewrite_row_id_conflict(
self,
commit_result_may_be_uncertain,
conflict_exception,
latest_snapshot,
base_data_files,
commit_entries,
commit_kind,
commit_identifier,
changelog_entries):
if not isinstance(conflict_exception, RowIdExistenceConflict):
return None
if commit_kind != "APPEND" or changelog_entries:
return None
if commit_result_may_be_uncertain:
return None
non_compaction_conflict = (
self.conflict_detection.check_row_id_from_snapshot(
latest_snapshot,
commit_entries,
check_compaction=False,
)
)
if non_compaction_conflict is not None:
raise CommitConflictError(
str(non_compaction_conflict)
) from non_compaction_conflict
try:
return RowIdConflictRewriter(
self.table,
self.commit_user,
commit_identifier,
self.row_id_conflict_rewrite_max_size,
).rewrite(
latest_snapshot,
base_data_files,
commit_entries,
)
except RuntimeError as rewrite_error:
raise CommitConflictError(
"{} {}".format(conflict_exception, rewrite_error)
) from conflict_exception
def _write_manifest_files(self, commit_entries, base_name):
return self.manifest_file_manager.rolling_write(
commit_entries, self.manifest_target_size, base_name)
def _is_duplicate_commit(self, retry_result, latest_snapshot, commit_identifier, commit_kind) -> bool:
if retry_result is not None and latest_snapshot is not None:
start_check_snapshot_id = 1 # Snapshot.FIRST_SNAPSHOT_ID
if retry_result.latest_snapshot is not None:
start_check_snapshot_id = retry_result.latest_snapshot.id + 1
for snapshot_id in range(start_check_snapshot_id, latest_snapshot.id + 1):
snapshot = self.snapshot_manager.get_snapshot_by_id(snapshot_id)
if (snapshot and snapshot.commit_user == self.commit_user and
snapshot.commit_identifier == commit_identifier and
snapshot.commit_kind == commit_kind):
logger.info(
f"Commit already completed (snapshot {snapshot_id}), "
f"user: {self.commit_user}, identifier: {commit_identifier}"
)
return True
return False
def _create_dynamic_partition_filter(self, commit_messages: List[CommitMessage]):
"""Build a partition filter from the unique partitions present in commit_messages."""
predicate_builder = PredicateBuilder(self.table.partition_keys_fields)
predicates = []
seen_partitions = set()
for msg in commit_messages:
partition_values = tuple(msg.partition)
if partition_values not in seen_partitions:
seen_partitions.add(partition_values)
equalities = []
for name, value in zip(self.table.partition_keys, msg.partition):
if value is None:
equalities.append(predicate_builder.is_null(name))
else:
equalities.append(predicate_builder.equal(name, value))
predicates.append(predicate_builder.and_predicates(equalities))
return predicate_builder.or_predicates(predicates)
def _create_static_partition_filter(self, overwrite_partition, commit_messages: List[CommitMessage]):
"""Build a partition filter from the explicit overwrite_partition spec."""
if not overwrite_partition:
return None
predicate_builder = PredicateBuilder(self.table.partition_keys_fields)
default_part_value = self.table.options.options.get(
CoreOptions.PARTITION_DEFAULT_NAME, "__DEFAULT_PARTITION__")
equalities = []
for key, value in overwrite_partition.items():
if value is None or (isinstance(value, str) and value == default_part_value):
equalities.append(predicate_builder.is_null(key))
else:
equalities.append(predicate_builder.equal(key, value))
partition_filter = predicate_builder.and_predicates(equalities)
for msg in commit_messages:
row = OffsetRow(msg.partition, 0, len(msg.partition))
if not partition_filter.test(row):
raise RuntimeError(f"Trying to overwrite partition {overwrite_partition}, but the changes "
f"in {msg.partition} does not belong to this partition")
return partition_filter
def _overwrite_changes_provider(self, partition_filter, commit_messages):
"""Build a stateful provider of OVERWRITE commit entries that caches the
existing files of the target partitions across retries (see
OverwriteChangesProvider). One instance per overwrite operation."""
return OverwriteChangesProvider(
self.table,
self.manifest_list_manager,
self.snapshot_manager,
partition_filter,
commit_messages,
)
def _commit_retry_wait(self, retry_count: int):
retry_wait_ms = min(
self.commit_min_retry_wait * (2 ** retry_count),
self.commit_max_retry_wait
)
jitter_ms = random.randint(0, max(1, int(retry_wait_ms * 0.2)))
total_wait_ms = retry_wait_ms + jitter_ms
time.sleep(total_wait_ms / 1000.0)
def _collect_changelog_entries(self, commit_messages: List[CommitMessage]) -> List[ManifestEntry]:
changelog_entries = []
for msg in commit_messages:
partition = GenericRow(list(msg.partition), self.table.partition_keys_fields)
total_buckets = (
msg.total_buckets
if msg.total_buckets is not None
else self.table.total_buckets
)
for file in msg.changelog_files:
changelog_entries.append(ManifestEntry(
kind=0,
partition=partition,
bucket=msg.bucket,
total_buckets=total_buckets,
file=file
))
return changelog_entries
def _collect_manifest_entries(self, commit_messages: List[CommitMessage]) -> List[ManifestEntry]:
commit_entries = []
for msg in commit_messages:
partition = GenericRow(list(msg.partition), self.table.partition_keys_fields)
total_buckets = (
msg.total_buckets
if msg.total_buckets is not None
else self.table.total_buckets
)
for file in msg.new_files:
commit_entries.append(ManifestEntry(
kind=0,
partition=partition,
bucket=msg.bucket,
total_buckets=total_buckets,
file=file,
))
for file in msg.deleted_files:
commit_entries.append(ManifestEntry(
kind=1,
partition=partition,
bucket=msg.bucket,
total_buckets=total_buckets,
file=file,
))
return commit_entries
def _clean_up_reuse_tmp_manifests(
self,
delta_manifest_list: Optional[str],
changelog_manifest_list: Optional[str],
index_manifest: Optional[str] = None):
"""Clean up delta/changelog manifests and index manifest.
Mirrors Java CommitCleaner.cleanUpReuseTmpManifests.
"""
manifest_path = self.manifest_list_manager.manifest_path
for ml_name in (delta_manifest_list, changelog_manifest_list):
if ml_name:
try:
for meta in self.manifest_list_manager.read(ml_name):
self.table.file_io.delete_quietly(
f"{self.manifest_file_manager.manifest_path}/{meta.file_name}")
except Exception:
pass
self.table.file_io.delete_quietly(f"{manifest_path}/{ml_name}")
if index_manifest:
self.table.file_io.delete_quietly(f"{manifest_path}/{index_manifest}")
def _clean_up_no_reuse_tmp_manifests(
self,
base_manifest_list: Optional[str],
merge_new_files: List[ManifestFileMeta]):
"""Clean up base manifest list and newly created merge manifests.
Mirrors Java CommitCleaner.cleanUpNoReuseTmpManifests.
"""
manifest_path = self.manifest_list_manager.manifest_path
if base_manifest_list:
self.table.file_io.delete_quietly(f"{manifest_path}/{base_manifest_list}")
for meta in merge_new_files:
self.table.file_io.delete_quietly(
f"{self.manifest_file_manager.manifest_path}/{meta.file_name}")
def abort(self, commit_messages: List[CommitMessage]):
"""Abort commit and delete files. Uses external_path if available to ensure proper scheme handling."""
for message in commit_messages:
for file in list(message.new_files) + list(message.changelog_files):
try:
path_to_delete = file.external_path if file.external_path else file.file_path
if path_to_delete:
path_str = str(path_to_delete)
self.table.file_io.delete_quietly(path_str)
except Exception as e:
path_to_delete = file.external_path if file.external_path else file.file_path
logger.warning(f"Failed to clean up file {path_to_delete} during abort: {e}")
for entry in message.index_adds:
try:
file_name = entry.index_file.file_name
index_path = (
entry.index_file.external_path
or self.table.path_factory()
.global_index_path_factory()
.to_path(file_name)
)
self.table.file_io.delete_quietly(index_path)
except Exception as e:
logger.warning(
f"Failed to clean up index file {entry.index_file.file_name} during abort: {e}")
def close(self):
"""Close the FileStoreCommit and release resources."""
for callback in self.commit_callbacks:
try:
callback.close()
except Exception:
pass
if hasattr(self.snapshot_commit, 'close'):
self.snapshot_commit.close()
def _generate_partition_statistics(self, commit_entries: List[ManifestEntry]) -> List[PartitionStatistics]:
"""
Generate partition statistics from commit entries.
This method follows the Java implementation pattern from
org.apache.paimon.manifest.PartitionEntry.fromManifestEntry() and
PartitionEntry.merge() methods.
Args:
commit_entries: List of commit entries to analyze
Returns:
List of PartitionStatistics for each unique partition
"""
partition_stats = {}
for entry in commit_entries:
# Convert partition tuple to dictionary for PartitionStatistics
partition_value = tuple(entry.partition.values) # Call the method to get partition value
if partition_value:
# Assuming partition is a tuple and we need to convert it to a dict
# This may need adjustment based on actual partition format
if isinstance(partition_value, tuple):
# Create partition spec from partition tuple and table partition keys
partition_spec = {}
if len(partition_value) == len(self.table.partition_keys):
for i, key in enumerate(self.table.partition_keys):
partition_spec[key] = str(partition_value[i])
else:
# Fallback: use indices as keys
for i, value in enumerate(partition_value):
partition_spec[f"partition_{i}"] = str(value)
else:
# If partition is already a dict or other format
partition_spec = dict(partition_value) if partition_value else {}
else:
# Default partition for unpartitioned tables
partition_spec = {}
partition_key = tuple(sorted(partition_spec.items()))
if partition_key not in partition_stats:
partition_stats[partition_key] = {
'partition_spec': partition_spec,
'record_count': 0,
'file_count': 0,
'file_size_in_bytes': 0,
'last_file_creation_time': 0,
'total_buckets': entry.total_buckets
}
partition_stats[partition_key]['total_buckets'] = (
entry.total_buckets)
# Following Java implementation: PartitionEntry.fromDataFile()
file_meta = entry.file
# Extract actual file metadata (following Java DataFileMeta pattern)
record_count = file_meta.row_count if entry.kind == 0 else file_meta.row_count * -1
file_size_in_bytes = file_meta.file_size if entry.kind == 0 else file_meta.file_size * -1
file_count = 1 if entry.kind == 0 else -1
# Use epoch millis
if file_meta.creation_time:
file_creation_time = file_meta.creation_time_epoch_millis()
else:
file_creation_time = int(time.time() * 1000)
# Accumulate statistics (following Java PartitionEntry.merge() logic)
partition_stats[partition_key]['record_count'] += record_count
partition_stats[partition_key]['file_size_in_bytes'] += file_size_in_bytes
partition_stats[partition_key]['file_count'] += file_count
# Keep the latest creation time
partition_stats[partition_key]['last_file_creation_time'] = max(
partition_stats[partition_key]['last_file_creation_time'],
file_creation_time
)
# Convert to PartitionStatistics objects
# Following Java PartitionEntry.toPartitionStatistics() pattern
return [
PartitionStatistics.create(
partition_spec=stats['partition_spec'],
record_count=stats['record_count'],
file_count=stats['file_count'],
file_size_in_bytes=stats['file_size_in_bytes'],
last_file_creation_time=stats['last_file_creation_time'],
total_buckets=stats['total_buckets']
)
for stats in partition_stats.values()
]
def _assign_snapshot_id(self, snapshot_id: int, commit_entries: List[ManifestEntry]) -> List[ManifestEntry]:
"""Assign snapshot ID to delta entries whose minSequenceNumber is 0."""
result = []
for entry in commit_entries:
if entry.file.min_sequence_number == 0:
result.append(entry.assign_sequence_number(snapshot_id, snapshot_id))
else:
result.append(entry)
return result
def _get_next_row_id_start(self, latest_snapshot) -> int:
"""Get the next row ID start from the latest snapshot."""
if latest_snapshot and hasattr(latest_snapshot, 'next_row_id') and latest_snapshot.next_row_id is not None:
return latest_snapshot.next_row_id
return 0
def _assign_row_tracking_meta(self, first_row_id_start: int, commit_entries: List[ManifestEntry]):
"""Assign row tracking metadata (first_row_id) to new files.
Aligned with Java RowTrackingCommitUtils.assignRowTrackingMeta.
"""
if not commit_entries:
return commit_entries, first_row_id_start
row_id_assigned = []
start = first_row_id_start
blob_start_default = first_row_id_start
blob_starts = {}
vector_store_start = first_row_id_start
for entry in commit_entries:
assert entry.file.file_source is not None, \
f"file_source must be present for row-tracking table, file={entry.file.file_name}"
write_cols = entry.file.write_cols
contains_row_id = (
write_cols is not None
and SpecialFields.ROW_ID.name in write_cols
)
if (entry.file.file_source == 0
and entry.file.first_row_id is None
and not contains_row_id):
row_count = entry.file.row_count
if DataFileMeta.is_blob_file(entry.file.file_name):
blob_field_name = entry.file.write_cols[0]
blob_start = blob_starts.get(blob_field_name, blob_start_default)
if blob_start >= start:
raise RuntimeError(
f"This is a bug, blobStart {blob_start} should be less than "
f"start {start} when assigning a blob entry file."
)
row_id_assigned.append(entry.assign_first_row_id(blob_start))
blob_starts[blob_field_name] = blob_start + row_count
elif DataFileMeta.is_vector_file(entry.file.file_name):
if vector_store_start >= start:
raise RuntimeError(
f"This is a bug, vectorStoreStart {vector_store_start} should be "
f"less than start {start} when assigning a vector-store entry file."
)
row_id_assigned.append(entry.assign_first_row_id(vector_store_start))
vector_store_start += row_count
else:
row_id_assigned.append(entry.assign_first_row_id(start))
blob_start_default = start
blob_starts.clear()
start += row_count
else:
row_id_assigned.append(entry)
return row_id_assigned, start