blob: 5c35016f272749d73ba9693e607720840e19fa10 [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.
"""Select execution implementations before data is read or published.
Only preparation belongs in the fallback boundary. An execution failure must
reach the caller: a writer or committer may already have produced side effects.
The independent plan/read/write/commit options keep their existing meaning.
"""
import logging
import sys
from pypaimon.catalog.catalog_exception import TableNoPermissionException
from pypaimon.write.commit_identifier import validate_commit_identifier
logger = logging.getLogger(__name__)
def propagate_fork_safety_error(error):
"""Recognize an already-loaded SDK error without loading another backend."""
runtime = sys.modules.get('pypaimon_rust')
error_type = getattr(runtime, 'ForkSafetyError', None)
if error_type is None:
return
current, seen = error, set()
while current is not None and id(current) not in seen:
seen.add(id(current))
if isinstance(current, error_type):
raise current
current = current.__cause__ or current.__context__
def try_prepare_native(factory, *args, **kwargs):
"""Return a prepared executor, or None when preparation permits fallback."""
try:
return factory(*args, **kwargs)
except Exception as error:
propagate_fork_safety_error(error)
if isinstance(error, TableNoPermissionException):
raise
logger.debug('Native execution preparation failed; using Python: %s', error)
return None
def create_python_write(table, commit_user, static_partition=None, stream=False,
restore_snapshot_id=None, fixed_bucket=False, bucket_plan=None,
public_writer=None):
if fixed_bucket:
from pypaimon.write.postpone_batch_table_write import PostponeFixedBucketBatchTableWrite
writer_type = PostponeFixedBucketBatchTableWrite
else:
from pypaimon.write.table_write import BatchTableWrite, StreamTableWrite
writer_type = StreamTableWrite if stream else BatchTableWrite
writer = object.__new__(writer_type)
_initialize_python_write(writer, table, commit_user, static_partition, stream,
restore_snapshot_id, fixed_bucket, bucket_plan, public_writer)
return writer
def _initialize_python_write(owner, table, commit_user, static_partition, stream,
restore_snapshot_id, fixed_bucket, bucket_plan, public_writer=None):
if fixed_bucket:
from pypaimon.execution.python.write.postpone_batch_table_write import PythonPostponeFixedBucketWrite
execution = PythonPostponeFixedBucketWrite(public_writer if public_writer is not None else owner)
execution.initialize(table, commit_user, static_partition, bucket_plan)
else:
from pypaimon.execution.python.write.table_write import PythonTableWrite
execution = PythonTableWrite(public_writer if public_writer is not None else owner)
execution.initialize(table, commit_user, static_partition,
restore_snapshot_id=restore_snapshot_id)
owner.table = table
owner.commit_user = commit_user
owner.static_partition = static_partition
owner._execution = execution
from pypaimon.write.table_write import BatchTableWrite
if isinstance(owner, BatchTableWrite):
owner._batch_committed = False
def _prepare_write(table, commit_user, static_partition, stream,
restore_snapshot_id, fixed_bucket, bucket_plan):
from pypaimon.read.merge_engine_support import check_sequence_field_supported
check_sequence_field_supported(table)
if table.options.native_write_enabled():
def prepare():
from pypaimon.execution.native.write.native_write import create_native_write
return create_native_write(
table, commit_user, static_partition, stream,
restore_snapshot_id=restore_snapshot_id, fixed_bucket=fixed_bucket, bucket_plan=bucket_plan)
native = try_prepare_native(prepare)
if native is not None:
from pypaimon.write.write_dispatch import NativeTableWrite
return NativeTableWrite.from_native(native)
def initialize_write(owner, table, commit_user, static_partition=None, stream=False,
restore_snapshot_id=None, fixed_bucket=False, bucket_plan=None):
"""Select once while retaining the caller's public class and batch hooks."""
execution = _prepare_write(table, commit_user, static_partition, stream,
restore_snapshot_id, fixed_bucket, bucket_plan)
if execution is None:
_initialize_python_write(owner, table, commit_user, static_partition, stream,
restore_snapshot_id, fixed_bucket, bucket_plan)
else:
owner.table = table
owner.commit_user = commit_user
owner.static_partition = static_partition
execution._public_writer = owner
owner._execution = execution
def create_write(table, commit_user, static_partition=None, stream=False,
restore_snapshot_id=None, fixed_bucket=False, bucket_plan=None):
execution = _prepare_write(table, commit_user, static_partition, stream,
restore_snapshot_id, fixed_bucket, bucket_plan)
if execution is not None:
return execution
return create_python_write(
table, commit_user, static_partition, stream, restore_snapshot_id, fixed_bucket, bucket_plan)
def prepare_write_commit(owner, commit_identifier):
from pypaimon.write.write_dispatch import NativeTableWrite
execution = owner._execution
if isinstance(execution, NativeTableWrite):
return (execution.prepare_commit(commit_identifier) if execution.stream else
execution.prepare_commit())
return execution.prepare_commit(commit_identifier)
def prepare_batch_write(owner):
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.write.write_dispatch import NativeTableWrite
execution = owner._execution
if isinstance(execution, NativeTableWrite):
return execution.prepare_commit()
if owner._batch_committed:
raise RuntimeError('BatchTableWrite only supports one-time committing.')
owner._batch_committed = True
return owner._prepare_commit(BATCH_COMMIT_IDENTIFIER)
def switch_writer_to_python(owner):
"""Switch only before the native writer has accepted its first mutation."""
if owner._python_writer is not None:
return owner._python_writer
if owner._written:
raise RuntimeError('Cannot switch to the Python writer after native data was written')
owner._native_writer.close()
owner._native_writer = None
owner._python_writer = owner._new_python_writer()
return owner._python_writer
def create_postpone_bucket_planner(table, known_num_buckets=None):
"""Resolve metadata before Ray materializes or writes any input."""
if table.options.native_write_enabled():
def prepare():
from pypaimon.execution.native.write.postpone_bucket import create_native_postpone_bucket_planner
return create_native_postpone_bucket_planner(table, known_num_buckets)
native = try_prepare_native(prepare)
if native is not None:
return native
from pypaimon.execution.python.write.postpone_bucket import PythonPostponeBucketPlanner
return PythonPostponeBucketPlanner(table, known_num_buckets=known_num_buckets)
def create_input_router(table, bucket_plan=None):
"""Select bucket routing before a distributed task receives input rows."""
if table.options.native_write_enabled():
def prepare():
from pypaimon.execution.native.write.input_routing import create_native_input_router
return create_native_input_router(table, bucket_plan)
native = try_prepare_native(prepare)
if native is not None:
return native
if bucket_plan is not None:
from pypaimon.execution.python.write.input_routing import PostponeFixedBucketInputRouter
return PostponeFixedBucketInputRouter(table, bucket_plan)
from pypaimon.execution.python.write.input_routing import FixedBucketInputRouter
return FixedBucketInputRouter(table.table_schema)
def create_dynamic_bucket_assigner_router(table, num_channels, num_assigners):
"""Select routing to dynamic bucket assigners before a task receives rows."""
if table.options.native_write_enabled():
from pypaimon.execution.native.write.input_routing import create_native_dynamic_bucket_assigner_router
try:
native = create_native_dynamic_bucket_assigner_router(table, num_channels, num_assigners)
except NotImplementedError:
native = None
if native is not None:
return native
from pypaimon.execution.python.write.input_routing import PythonDynamicBucketAssignerRouter
return PythonDynamicBucketAssignerRouter(table, num_channels, num_assigners)
def create_dynamic_bucket_assigner(table, num_channels, num_assigners, assign_id,
ignore_existing=False, base_snapshot_id=None):
"""Select restoration before assignment; authorization failures are terminal."""
if table.options.native_write_enabled():
from pypaimon.execution.native.write.input_routing import create_native_dynamic_bucket_assigner
# Resolving the writer snapshot can authorize and read HASH metadata.
# Only an explicitly unsupported environment returns None; never turn
# an authorization/restoration exception into another backend attempt.
native = create_native_dynamic_bucket_assigner(
table, num_channels, num_assigners, assign_id, ignore_existing, base_snapshot_id)
if native is not None:
return native
from pypaimon.execution.python.write.input_routing import PythonDynamicBucketAssigner
return PythonDynamicBucketAssigner(
table, num_channels, num_assigners, assign_id, ignore_existing, base_snapshot_id)
def load_postpone_bucket_metadata(table):
"""Select metadata planning before public bucket arithmetic consumes input."""
if table.options.native_write_enabled():
def prepare():
from pypaimon.execution.native.write.postpone_bucket import create_native_postpone_bucket_planner
planner = create_native_postpone_bucket_planner(table)
return None if planner is None else planner.current_plan().as_dict()
metadata = try_prepare_native(prepare)
if metadata is not None:
return metadata
from pypaimon.execution.python.write.postpone_bucket import PythonPostponeBucketPlanner
return PythonPostponeBucketPlanner._load_bucket_metadata(table)
def prepare_native_commit(owner, messages, *, batch=False):
if owner._python_commit is not None or not owner.table.options.native_commit_enabled():
return None
def prepare():
from pypaimon.execution.native.write.commit import prepare_native_commit as prepare_commit
return prepare_commit(owner, messages, batch=batch)
return try_prepare_native(prepare)
def prepare_native_write_operation(operation, table, commit_user, *args, **kwargs):
if not table.options.native_write_enabled():
return None
names = {
'update': 'create_native_update',
'update_by_row_id': 'create_native_update_by_row_id',
'shard_update': 'create_native_shard_updator',
'upsert': 'create_native_upsert',
'predicate_update': 'create_native_predicate_update',
'predicate_delete': 'create_native_predicate_delete',
'delete': 'create_native_delete',
}
name = names[operation]
def prepare():
from pypaimon.execution.native.write import native_update
native = getattr(native_update, name)(table, commit_user, *args, **kwargs)
if native is not None and operation == 'update_by_row_id':
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
# Preserve the public return type without inheriting Python's file
# rewriting implementation in the Native adapter.
TableUpdateByRowId.register(native_update.NativeTableUpdateByRowId)
return native
return try_prepare_native(prepare)
def prepare_native_merge_into(*args, **kwargs):
# MERGE preparation can consume user input. Preserve its existing boundary:
# only an explicit unsupported result selects Python; failures propagate.
from pypaimon.execution.native.write.native_merge_into import create_native_merge_into
return create_native_merge_into(*args, **kwargs)
def create_merge_batch_transform(operation, *args, native_enabled=False, **kwargs):
"""Select the joined-batch executor before a Ray task consumes input."""
if native_enabled:
from pypaimon.execution.native.runtime import native_runtime_available
native_enabled = native_runtime_available()
if native_enabled:
from pypaimon.execution.native.write import merge_transform
else:
from pypaimon.execution.python.write import merge_transform
builders = {
'update': merge_transform._build_matched_transform,
'delete': merge_transform._build_matched_delete_transform,
'insert': merge_transform._build_insert_transform,
}
return builders[operation](*args, **kwargs)
def create_python_row_id_update(table, commit_user, commit_identifier, precomputed=None):
"""Create an updater after Python execution has already been selected."""
from pypaimon.execution.python.write.table_update_by_row_id import PythonTableUpdateByRowId
return PythonTableUpdateByRowId().initialize(table, commit_user, commit_identifier, precomputed)
def create_python_row_id_delete(table, precomputed=None):
"""Create a deleter without preparing another execution backend."""
from pypaimon.execution.python.write.table_delete import PythonTableDeleteByRowId
return PythonTableDeleteByRowId().initialize(table, precomputed)
def create_python_shard_updator(table, projection, columns, commit_user, shard, shard_count):
"""Initialize the Python shard resources only after backend selection."""
from pypaimon.execution.python.write.table_update import ShardTableUpdator
return ShardTableUpdator(table, projection, columns, commit_user, shard, shard_count)
def create_python_update_operation(context, commit_identifier, predicate=None, *, has_callable=False, has_array=False):
from pypaimon.execution.python.write.table_update import PythonTableUpdate
return PythonTableUpdate(context, commit_identifier, predicate, has_callable=has_callable, has_array=has_array)
def create_python_upsert_operation(context, data, upsert_keys, update_columns, *, rows=False):
from pypaimon.execution.python.write.table_upsert_by_key import PythonTableUpsert
return PythonTableUpsert(context, data, upsert_keys, update_columns, rows=rows)
def create_python_merge_into_operation(
table, source, *, on, when_matched, when_not_matched, commit_user, commit_identifier):
from pypaimon.execution.python.write.merge_into import PythonTableMergeInto
return PythonTableMergeInto(
table, source, on=on, when_matched=when_matched, when_not_matched=when_not_matched,
commit_user=commit_user, commit_identifier=commit_identifier)
def create_shard_update_execution(table, projection, columns, commit_user, shard, shard_count):
"""Select the direct shard constructor's backend before capturing files."""
from pypaimon.write.update_validation import _authorize_update
_authorize_update(table)
native = prepare_native_write_operation(
'shard_update', table, commit_user, projection, columns, shard, shard_count)
if native is not None:
return native
return create_python_shard_updator(table, projection, columns, commit_user, shard, shard_count)
def create_row_id_plan_execution(table, plan):
"""Select pure routing once; execution errors never replay another backend."""
if (table.options.native_write_enabled() or table.options.native_read_enabled()
or table.options.native_plan_enabled()):
from pypaimon.execution.native.write.row_id_plan import create_native_row_id_plan, NativeRowIdPlan
native = create_native_row_id_plan(table, plan)
if native is not None:
return NativeRowIdPlan(table, plan, native=native)
from pypaimon.execution.python.write.row_id_plan import PythonRowIdPlan
return PythonRowIdPlan(plan)
def create_row_id_update_execution(table, commit_user, commit_identifier, precomputed=None):
"""Select one incremental updater before capturing target files."""
from pypaimon.write.update_validation import _authorize_update
_authorize_update(table)
from pypaimon.write.row_id_plan import CapturedRowIdPlan
if isinstance(precomputed, CapturedRowIdPlan):
_validate_captured_row_id_plan(table, precomputed)
if table.options.native_write_enabled():
from pypaimon.execution.native.write.native_update import create_native_update_by_row_id
native = create_native_update_by_row_id(table, commit_user, commit_identifier, precomputed)
if native is not None:
return native
if precomputed is None:
native = prepare_native_write_operation('update_by_row_id', table, commit_user, commit_identifier)
if native is not None:
return native
return create_python_row_id_update(table, commit_user, commit_identifier, precomputed)
def create_row_id_delete_execution(table, precomputed=None):
"""Select one deleter, reusing captured metadata when supplied."""
from pypaimon.write.update_validation import _authorize_update
_authorize_update(table)
from pypaimon.write.row_id_plan import CapturedRowIdPlan
if isinstance(precomputed, CapturedRowIdPlan):
_validate_captured_row_id_plan(table, precomputed)
if table.options.native_write_enabled():
from pypaimon.execution.native.write.native_update import create_native_row_id_delete
native = create_native_row_id_delete(table, precomputed)
if native is not None:
return native
if precomputed is None and table.options.native_write_enabled():
def prepare():
from pypaimon.execution.native.write.native_update import create_native_row_id_delete
return create_native_row_id_delete(table)
native = try_prepare_native(prepare)
if native is not None:
return native
return create_python_row_id_delete(table, precomputed)
def _validate_captured_row_id_plan(table, captured):
if (table.table_path != captured.table.table_path or table.current_branch() != captured.table.current_branch()
or table.table_schema.id != captured.table.table_schema.id
or table.fields != captured.table.fields):
raise ValueError('Captured row-ID plan belongs to a different table, branch or schema')
def native_row_id_update_selected(worker):
native = sys.modules.get('pypaimon.execution.native.write.native_update')
native_type = getattr(native, 'NativeTableUpdateByRowId', None)
execution = vars(worker).get('_execution', worker)
return native_type is not None and isinstance(execution, native_type)
def validate_row_id_update_rows(worker, captured_plan, first_row_id, row_ids):
"""Validate liveness in the selected executor before positional rewriting."""
if native_row_id_update_selected(worker):
# Core validates the captured anchor's DV before staging any output.
return
from pypaimon.execution.python.write.row_id_plan import validate_live_row_ids
validate_live_row_ids(worker.table, captured_plan, first_row_id, row_ids)
def python_commit(owner):
"""Create Python commit resources only after that backend is selected."""
if owner._python_commit is None:
from pypaimon.execution.python.write.table_commit import create_python_commit
owner._python_commit = create_python_commit(owner)
return owner._python_commit
def commit_table_messages(
owner,
commit_messages,
commit_identifier,
snapshot_properties=None):
"""Publish prepared messages while preserving files on any failure."""
commit_identifier = validate_commit_identifier(commit_identifier)
non_empty_messages = [msg for msg in commit_messages if not msg.is_empty()]
# 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 owner.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",
owner.table.identifier, len(non_empty_messages)
)
else:
if (not non_empty_messages
and owner.table.options.snapshot_ignore_empty_commit()):
return
logger.info(
"Committing table %s, %d non-empty messages",
owner.table.identifier, len(non_empty_messages)
)
prepared = owner._prepare_native_commit(non_empty_messages)
if prepared is None:
execution, messages = python_commit(owner), non_empty_messages
else:
execution, messages = prepared
# Mutation is deliberately outside the preparation fallback boundary:
# an exception can mean the snapshot was already published.
return execution.commit(messages, commit_identifier, snapshot_properties=snapshot_properties)
def abort_table_commit(owner, messages):
prepared = owner._prepare_native_commit(messages)
if prepared is not None:
native, wire_messages = prepared
native.abort(wire_messages)
return
python_commit(owner).abort(messages)
def truncate_table_commit(owner, commit_identifier):
prepared = owner._prepare_native_commit([], batch=True)
if prepared is not None:
prepared[0].truncate_table(commit_identifier)
return
python_commit(owner).truncate_table(commit_identifier)
def truncate_partitions_commit(owner, partitions, commit_identifier):
prepared = owner._prepare_native_commit([], batch=True)
if prepared is not None:
prepared[0].truncate_partitions(partitions, commit_identifier)
return
python_commit(owner).truncate_partitions(partitions, commit_identifier)
def close_table_commit(owner):
"""Close only initialized backends and each registered callback once."""
if owner._closed:
return
owner._closed = True
try:
if owner._native_commit is not None:
owner._native_commit.close()
finally:
try:
if owner._python_commit is not None:
owner._python_commit.close()
finally:
for callback in owner._commit_callbacks:
try:
callback.close()
except Exception:
pass
def execute_row_id_conflict_check(table, messages, latest_snapshot_id, check_from_snapshot):
if table.options.native_commit_enabled():
from pypaimon.execution.native.write.native_commit import prepare_native_row_id_conflict_check
# Unsupported configurations return None explicitly. Permission or
# transport failures must never restart the check in another backend.
native = prepare_native_row_id_conflict_check(table, messages)
if native is not None:
commit, native_messages = native
failure = None
try:
commit.check_row_id_conflicts(native_messages, latest_snapshot_id, check_from_snapshot)
except BaseException as error:
failure = error
raise
finally:
try:
commit.close()
except BaseException:
if failure is None:
raise
return
from pypaimon.execution.python.write.commit.row_id_conflict import check_row_id_conflicts as check
check(table, messages, latest_snapshot_id, check_from_snapshot)