blob: 894d616fba99d4bd1b4fbbf28ff206b727556063 [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
from typing import Dict, List, Optional
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.write.commit_callback import CommitCallback
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.file_store_commit import FileStoreCommit
logger = logging.getLogger(__name__)
class TableCommit:
"""Common base for batch and stream table commits.
Owns the underlying :class:`FileStoreCommit` and provides the shared
:meth:`_commit` implementation. The concrete subclasses differ only in
their public ``commit`` signature and lifecycle constraints:
* :class:`BatchTableCommit` accepts no ``commit_identifier`` and may be
committed at most once.
* :class:`StreamTableCommit` requires an explicit ``commit_identifier``
on every call and may be reused for many commits.
"""
def __init__(self, table, commit_user: str, static_partition: Optional[dict]):
from pypaimon.table.file_store_table import FileStoreTable
self.table: FileStoreTable = table
self.commit_user = commit_user
self.overwrite_partition = static_partition
# Get SnapshotCommit from table's catalog environment
snapshot_commit = table.new_snapshot_commit()
if snapshot_commit is None:
raise RuntimeError("Table does not provide a SnapshotCommit instance")
self._commit_callbacks: List[CommitCallback] = []
self._native_commit = None
self.file_store_commit = FileStoreCommit(
snapshot_commit, table, commit_user,
commit_callbacks=self._commit_callbacks)
def add_commit_callback(self, callback: CommitCallback) -> None:
"""Register a callback to be invoked after each successful commit."""
self._commit_callbacks.append(callback)
def _commit(
self,
commit_messages: List[CommitMessage],
commit_identifier: int = BATCH_COMMIT_IDENTIFIER,
snapshot_properties: Optional[Dict[str, str]] = None):
"""Publish prepared messages while preserving files on any failure."""
non_empty_messages = [msg for msg in commit_messages if not msg.is_empty()]
commit_kwargs = {
"commit_messages": non_empty_messages,
"commit_identifier": commit_identifier,
}
if snapshot_properties is not None:
commit_kwargs["snapshot_properties"] = snapshot_properties
# Never abort files in response to a commit exception. Preserving
# possible orphan files is safer than deleting files which another
# attempt may still commit or which a snapshot may already reference.
if self.overwrite_partition is not None:
# Always call overwrite() even with empty messages, so that
# FileStoreCommit.overwrite can handle the empty case properly
# (e.g. static overwrite with empty data should delete the partition).
logger.info(
"Committing overwrite to table %s, %d non-empty messages",
self.table.identifier, len(non_empty_messages)
)
if snapshot_properties is None:
prepared = self._prepare_native_commit(non_empty_messages)
if prepared is not None:
native, messages = prepared
# Keep publication failures outside the preparation fallback.
native.commit(messages)
return
self.file_store_commit.overwrite(
overwrite_partition=self.overwrite_partition,
**commit_kwargs)
else:
if (not non_empty_messages
and self.table.options.snapshot_ignore_empty_commit()):
return
logger.info(
"Committing table %s, %d non-empty messages",
self.table.identifier, len(non_empty_messages)
)
if snapshot_properties is None:
prepared = self._prepare_native_commit(non_empty_messages)
if prepared is not None:
native, messages = prepared
# Mutation is deliberately outside the fallback boundary:
# an exception can mean the snapshot was already published.
native.commit(commit_identifier, messages)
return
self.file_store_commit.commit(**commit_kwargs)
def _prepare_native_commit(self, messages):
if (not self.table.options.native_commit_enabled()
or self._commit_callbacks):
return None
try:
from pypaimon.write.native_commit import (
create_native_commit, native_messages_supported,
to_native_commit_messages)
if not native_messages_supported(self.table, messages):
return None
if self._native_commit is None:
self._native_commit = create_native_commit(
self.table, self.commit_user, self.overwrite_partition)
if self._native_commit is None:
return None
return self._native_commit, to_native_commit_messages(self.table, messages)
except Exception as error:
# No native mutation has started. Preserve the normal Python path
# when the optional runtime, FileIO or wire bridge is unavailable.
logger.debug("Native commit preparation failed; using Python: %s", error)
return None
def abort(self, commit_messages: List[CommitMessage]):
"""Delete explicitly abandoned, known-uncommitted files.
Never call after a commit whose outcome is unknown. Publication may
have succeeded before its response failed. Internal failure paths
must preserve prepared files instead of calling this method.
"""
prepared = self._prepare_native_commit(commit_messages)
if prepared is not None:
native, messages = prepared
native.abort(messages)
return
self.file_store_commit.abort(commit_messages)
def close(self):
try:
if self._native_commit is not None:
self._native_commit.close()
finally:
self.file_store_commit.close()
class BatchTableCommit(TableCommit):
"""Batch-mode commit; supports at most one commit per instance."""
def __init__(self, table, commit_user: str, static_partition: Optional[dict]):
super().__init__(table, commit_user, static_partition)
self.batch_committed = False
def commit(
self,
commit_messages: List[CommitMessage],
snapshot_properties: Optional[Dict[str, str]] = None):
"""Commit once, attaching optional properties to the snapshot."""
self._check_committed()
self._commit(
commit_messages,
BATCH_COMMIT_IDENTIFIER,
snapshot_properties=snapshot_properties)
def truncate_table(self) -> None:
"""Truncate the entire table, deleting all data."""
self._check_committed()
self.file_store_commit.truncate_table(BATCH_COMMIT_IDENTIFIER)
def truncate_partitions(self, partitions: List[Dict[str, str]]) -> None:
self._check_committed()
self.file_store_commit.drop_partitions(partitions, BATCH_COMMIT_IDENTIFIER)
def _check_committed(self):
if self.batch_committed:
raise RuntimeError("BatchTableCommit only supports one-time committing.")
self.batch_committed = True
class StreamTableCommit(TableCommit):
"""Stream-mode commit; reusable across many commit rounds.
Each call must be tagged with a monotonically increasing
``commit_identifier`` — analogous to
:meth:`StreamTableWrite.prepare_commit`.
"""
def __init__(self, table, commit_user: str):
super().__init__(table, commit_user, None)
def commit(
self,
commit_messages: List[CommitMessage],
commit_identifier: int,
snapshot_properties: Optional[Dict[str, str]] = None):
"""Commit a stream checkpoint with optional snapshot properties."""
self._commit(
commit_messages,
commit_identifier,
snapshot_properties=snapshot_properties)