| # 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) |