blob: 9cf739cf59eb3116afcedd8b0eb793b42281aa43 [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.
"""Optional native commits using the Java CommitMessage v14 bridge."""
import json
from pypaimon.common.json_util import JSON
from pypaimon.execution.native.runtime import native_runtime_available
from pypaimon.execution.native.table import (
_catalog_context_options, _catalog_metastore, _option_value_to_string,
_resolved_schema_file_io_options)
from pypaimon.utils.file_store_path_factory import canonical_data_file_path
from pypaimon.write.commit_message_serializer import (
deserialize_commit_message, serialize_commit_message,
)
def native_messages_supported(table, messages) -> bool:
for message in messages:
if (message.compact_before or message.compact_after
or message.compact_changelog_files
or message.compact_index_adds or message.compact_index_deletes):
return False
partition = tuple(message.partition)
for file in message.new_files + message.changelog_files:
if file.external_path:
continue
expected = canonical_data_file_path(
table, partition, message.bucket, file.file_name)
if file.file_path:
if str(file.file_path) != expected:
return False
return True
def create_native_commit(table, commit_user, overwrite_partition=None, *, batch=False):
"""Return a native committer only when its publication protocol matches Python."""
if not _rest_catalog_supported(table) or not native_runtime_available():
return None
native_table = create_native_write_table(table)
if native_table is None:
return None
if batch or overwrite_partition is not None:
builder = native_table.new_batch_write_builder().with_commit_user(commit_user)
if overwrite_partition is not None:
builder = builder.with_overwrite(overwrite_partition)
return builder.new_commit()
# Append commits use the stream committer with the Python writer's identity;
# Python enforces each mode's lifecycle and empty-commit rules.
return native_table.new_stream_write_builder().with_commit_user(commit_user).new_commit()
def _rest_catalog_supported(table):
from pypaimon.catalog.catalog_environment import CatalogEnvironment
from pypaimon.catalog.rest.rest_token_file_io import RESTTokenFileIO
from pypaimon.filesystem.caching_file_io import CachingFileIO
from pypaimon.filesystem.local_file_io import FuseLocalFileIO, LocalFileIO
from pypaimon.filesystem.oss_file_io import OssFileIO
from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
from pypaimon.table.file_store_table import FileStoreTable
environment = table.catalog_environment
loader = getattr(environment, 'catalog_loader', None)
context = loader.context() if _catalog_metastore(loader) == 'rest' else None
file_io = table.file_io
if type(file_io) is CachingFileIO:
file_io = file_io._delegate
return (type(table) is FileStoreTable
and type(environment) is CatalogEnvironment
and type(file_io) in (LocalFileIO, FuseLocalFileIO, PyArrowFileIO, OssFileIO,
ResolvingFileIO, RESTTokenFileIO)
and environment.supports_version_management
and _rest_source_uuid(table) is not None
and context is not None
and context.options is not None)
def _rest_source_uuid(table):
"""Identity of the catalog table used to reconstruct a copied FUSE view."""
if table.catalog_environment.uuid is not None:
return table.catalog_environment.uuid
from pypaimon.execution.native.table import _resolved_rest_table_response, _validate_native_fuse_binding
try:
original = _validate_native_fuse_binding(table)
if original is None or original == table.identifier:
return None
response = _resolved_rest_table_response(table, original)
uuid = json.loads(response).get('id') if response is not None else None
return uuid if isinstance(uuid, str) and uuid else None
except (ValueError, RuntimeError):
return None
def _native_rest_table(table, schema_json):
from pypaimon_rust.datafusion import PaimonCatalog as NativeCatalog
from pypaimon.execution.native.table import _native_table, _validate_native_fuse_binding
identifier = _validate_native_fuse_binding(table) or table.identifier
catalog_options = _catalog_context_options(table)
catalog_options['metastore'] = 'rest'
native_table = NativeCatalog(catalog_options, config_required=False).get_table((
identifier.get_database_name(), identifier.get_object_name()))
if (native_table.location() != table.table_path
or native_table.rest_table_uuid() != _rest_source_uuid(table)):
return None
if identifier != table.identifier:
native_table = native_table.copy({'branch': table.current_branch()})
if native_table.location() != table.table_path or native_table.branch() != table.current_branch():
return None
if table.options.query_auth_enabled:
# A catalog-loaded handle can predate ordinary schema evolution without
# selecting a historical snapshot. Reuse the read transport's original
# REST response and captured view instead of attaching its fields to a
# newly loaded schema, which core correctly treats as an assembled table.
# The fresh lookup above still refuses a replaced table or FileIO route.
native_table = _native_table(table)
if (native_table.location() != table.table_path
or native_table.rest_table_uuid() != _rest_source_uuid(table)
or native_table.branch() != table.current_branch()):
return None
return native_table.copy_with_resolved_schema(schema_json)
def create_native_write_table(table):
"""Preserve the resolved schema and the catalog's publication route."""
from pypaimon.catalog.catalog_environment import CatalogEnvironment
from pypaimon.filesystem.local_file_io import LocalFileIO
from pypaimon.filesystem.pyarrow_file_io import PyArrowFileIO
from pypaimon.filesystem.resolving_file_io import ResolvingFileIO
from pypaimon.table.file_store_table import FileStoreTable
environment = table.catalog_environment
if (type(table) is not FileStoreTable
or type(environment) is not CatalogEnvironment):
return None
from pypaimon_rust.datafusion import Table as NativeTable
# Preserve the resolved schema and all effective table options, including
# copy() overrides. Do not inject scan options or reload catalog schemas.
options = {str(key): _option_value_to_string(value)
for key, value in table.table_schema.options.items() if value is not None}
# Python accepts boolean spellings such as "off"; pass the parsed value.
options['dynamic-partition-overwrite'] = _option_value_to_string(
table.options.dynamic_partition_overwrite())
options['snapshot.ignore-empty-commit'] = _option_value_to_string(
table.options.snapshot_ignore_empty_commit())
options['row-tracking.partition-group-on-commit'] = _option_value_to_string(
table.options.row_tracking_partition_group_on_commit())
options['data-evolution.row-sidecar.enabled'] = _option_value_to_string(
table.options.data_evolution_row_sidecar_enabled())
options['query-auth.enabled'] = _option_value_to_string(table.options.query_auth_enabled)
schema_json = JSON.to_json(table.table_schema.copy(new_options=options))
if environment.supports_version_management:
if not _rest_catalog_supported(table):
return None
return _native_rest_table(table, schema_json)
if type(table.file_io) not in (LocalFileIO, PyArrowFileIO, ResolvingFileIO):
return None
file_io_options = _resolved_schema_file_io_options(table)
if file_io_options is None:
return None
return NativeTable.from_resolved_schema(
table.table_path, schema_json,
database=table.identifier.get_database_name(),
table=table.identifier.get_table_name(), branch=table.current_branch(),
options=file_io_options)
def to_native_commit_messages(table, messages):
from pypaimon_rust.datafusion import CommitMessage as NativeCommitMessage
# Convert the whole batch before any native mutation can begin.
return [NativeCommitMessage.deserialize(
serialize_commit_message(message, table.partition_keys_fields), version=14)
for message in messages]
def from_native_commit_messages(table, messages):
"""Decode native v14 messages for PyPaimon's existing commit API."""
decoded = [deserialize_commit_message(
message.serialize(), table.partition_keys_fields,
table.trimmed_primary_keys_fields) for message in messages]
for message in decoded:
for file in message.new_files + message.changelog_files:
file.file_path = file.physical_path() if file.external_path else canonical_data_file_path(
table, message.partition, message.bucket, file.file_name)
return decoded
def native_commit_error(error):
"""Keep retry classification structured; never infer it from error text."""
try:
from pypaimon_rust import RowIdExistenceConflict as NativeRowIdConflict
except ImportError:
return error
from pypaimon.write.row_id_conflict import RowIdExistenceConflict
if not isinstance(error, NativeRowIdConflict):
return error
return RowIdExistenceConflict._from_fields(
file_name=error.file_name, first_row_id=error.first_row_id,
row_count=error.row_count, bucket=error.bucket,
current_first_row_id=error.current_first_row_id,
current_row_count=error.current_row_count)
def prepare_native_row_id_conflict_check(table, messages):
"""Prepare a check without creating Python publication or conflict state."""
from pypaimon.execution.native.write.commit import NativeTableCommit
if not native_messages_supported(table, messages):
return None
native = create_native_commit(table, 'row-id-conflict-check', batch=True)
if native is None:
return None
try:
converted = to_native_commit_messages(table, messages)
except BaseException:
try:
native.close()
except BaseException:
pass
raise
return NativeTableCommit(native, is_batch=True), converted
def native_commit_callback(table, callbacks):
"""Translate the native notification into the existing Python context."""
from pypaimon.manifest.schema.manifest_entry import ManifestEntry
from pypaimon.snapshot.snapshot import Snapshot
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.write.commit_callback import CommitCallbackContext
def notify(native_context):
entries = []
for entry in native_context.commit_entries:
message = from_native_commit_messages(table, [entry._to_commit_message()])[0]
entries.append(ManifestEntry(
entry.kind, GenericRow(list(message.partition), table.partition_keys_fields),
entry.bucket, entry.total_buckets, message.new_files[0]))
context = CommitCallbackContext(
JSON.from_json(native_context._snapshot_json(), Snapshot), entries, native_context.identifier)
for callback in callbacks:
callback.call(context)
return notify