| # 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. | |
| from typing import TYPE_CHECKING, Any, Dict, List, Optional | |
| import pyarrow as pa | |
| from pypaimon.common.options.core_options import ChangelogProducer | |
| from pypaimon.schema.arrow_schema import arrow_schemas_compatible, normalize_arrow_strings | |
| from pypaimon.schema.data_types import PyarrowFieldParser | |
| from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER | |
| from pypaimon.table.row.blob import BlobConsumer | |
| from pypaimon.write.row_utils import ( | |
| require_columns, | |
| row_to_named_values, | |
| ) | |
| from pypaimon.write.commit_message import CommitMessage | |
| from pypaimon.write.file_store_write import FileStoreWrite | |
| if TYPE_CHECKING: | |
| from ray.data import Dataset | |
| class TableWrite: | |
| def __init__(self, table, commit_user, static_partition: Optional[dict] = None, | |
| *, restore_snapshot_id=None): | |
| from pypaimon.table.file_store_table import FileStoreTable | |
| self.table: FileStoreTable = table | |
| self.table_pyarrow_schema = PyarrowFieldParser.from_paimon_schema(self.table.table_schema.fields) | |
| self.commit_user = commit_user | |
| self.static_partition = static_partition | |
| self.restore_snapshot_id = restore_snapshot_id | |
| self.file_store_write = self._create_file_store_write(commit_user) | |
| self.file_store_write.restore_snapshot_id = ( | |
| 0 if static_partition is not None and restore_snapshot_id is not None | |
| else restore_snapshot_id) | |
| if static_partition is not None: | |
| # An overwrite replaces state, not an input changelog. Java's | |
| # overwrite commit does not publish changelog manifests; avoid | |
| # writing unreferenced changelog files in the first place. | |
| self.file_store_write.changelog_producer = ChangelogProducer.NONE | |
| self.row_key_extractor = self._create_row_key_extractor(static_partition) | |
| def _create_file_store_write(self, commit_user): | |
| return FileStoreWrite(self.table, commit_user) | |
| def _create_row_key_extractor(self, static_partition): | |
| if self.restore_snapshot_id is not None: | |
| from pypaimon.write.row_key_extractor import DynamicBucketRowKeyExtractor | |
| return DynamicBucketRowKeyExtractor( | |
| self.table.table_schema, table=self.table, | |
| ignore_existing=static_partition is not None, | |
| base_snapshot_id=self.restore_snapshot_id) | |
| return self.table.create_row_key_extractor( | |
| ignore_existing=static_partition is not None | |
| ) | |
| def write_arrow(self, data, bucket: Optional[int] = None): | |
| """Write Arrow data, optionally to a bucket assigned upstream.""" | |
| self._validate_pyarrow_schema(data.schema) | |
| if isinstance(data, pa.RecordBatch): | |
| return self.write_arrow_batch(data, bucket) | |
| for batch in data.to_batches(): | |
| self.write_arrow_batch(batch, bucket) | |
| def write_arrow_batch(self, data: pa.RecordBatch, bucket: Optional[int] = None): | |
| if bucket is not None: | |
| return self._write_precomputed_bucket(data, bucket) | |
| data = self._prepare_arrow_data(data) | |
| for partition, bucket, row_indices in \ | |
| self.row_key_extractor.extract_partition_bucket_groups(data): | |
| if row_indices is None: | |
| # Every input row belongs to the same partition/bucket. Passing the | |
| # original batch through avoids copying large BLOB values through | |
| # Arrow take before the dedicated BLOB writer consumes them. | |
| sub_table = data | |
| else: | |
| # row_indices is an int64 array of this group's rows in | |
| # ascending input order (the extractor sorts grouped indices so | |
| # sequence-number assignment stays latest-wins correct), so the | |
| # span is just first..last. | |
| lo = row_indices[0].as_py() | |
| hi = row_indices[-1].as_py() | |
| count = len(row_indices) | |
| if hi - lo + 1 == count: | |
| # Distinct row indices spanning exactly `count` values are | |
| # contiguous, so share the original Arrow buffers instead of | |
| # gathering their rows into newly allocated buffers with take. | |
| sub_table = data.slice(lo, count) | |
| else: | |
| sub_table = pa.compute.take(data, row_indices) | |
| self._write_partition_bucket_batch(partition, bucket, sub_table) | |
| def roll_before_group_if_needed(self, row_count: int): | |
| """Roll current files before the next logical write group if needed.""" | |
| if isinstance(row_count, bool) or not isinstance(row_count, int) \ | |
| or row_count <= 0: | |
| raise ValueError("Group row count must be a positive integer.") | |
| self.file_store_write.roll_before_group_if_needed(row_count) | |
| def _write_partition_bucket_batch(self, partition, bucket, data): | |
| self.file_store_write.write(partition, bucket, data) | |
| def _write_precomputed_bucket(self, data, bucket): | |
| """Write one partition/bucket group using normal index maintenance.""" | |
| from pypaimon.table.bucket_mode import BucketMode | |
| from pypaimon.write.row_key_extractor import DynamicBucketRowKeyExtractor | |
| bucket_mode = self.table.bucket_mode() | |
| if bucket_mode not in (BucketMode.HASH_FIXED, BucketMode.HASH_DYNAMIC): | |
| raise ValueError( | |
| "Precomputed bucket writes are only valid for HASH_FIXED or " | |
| "HASH_DYNAMIC tables" | |
| ) | |
| if not isinstance(self.row_key_extractor, DynamicBucketRowKeyExtractor): | |
| if bucket_mode == BucketMode.HASH_DYNAMIC: | |
| raise RuntimeError("Dynamic bucket extractor is not configured") | |
| from pypaimon.index.dynamic_bucket import MAX_DYNAMIC_BUCKETS | |
| upper_bound = MAX_DYNAMIC_BUCKETS if bucket_mode == BucketMode.HASH_DYNAMIC else self.table.options.bucket() | |
| if not 0 <= bucket < upper_bound: | |
| raise ValueError('Bucket id must be between 0 and {}'.format(upper_bound - 1)) | |
| data = self._prepare_arrow_data(data) | |
| if bucket_mode == BucketMode.HASH_DYNAMIC: | |
| partition = self.row_key_extractor.notify_precomputed_bucket_batch(data, bucket) | |
| else: | |
| partitions = self.row_key_extractor.extract_partitions_batch(data) | |
| if not partitions: | |
| return | |
| partition = tuple(partitions[0]) | |
| if any(tuple(actual) != partition for actual in partitions): | |
| raise ValueError('A precomputed bucket group contained multiple partitions') | |
| if partition is None: | |
| return | |
| self._write_partition_bucket_batch(partition, bucket, data) | |
| def write_row(self, row): | |
| values_by_name = row_to_named_values(row, self.table.table_schema.fields) | |
| column_names = ( | |
| self.file_store_write.write_cols | |
| if self.file_store_write.write_cols is not None | |
| else list(self.table.field_names) | |
| ) | |
| require_columns(values_by_name, column_names, "write_row") | |
| require_columns(values_by_name, self.table.partition_keys, "write_row") | |
| from pypaimon.write.row_kind import skip_write_row | |
| if skip_write_row(self.table, values_by_name): | |
| return | |
| partition, bucket = ( | |
| self.row_key_extractor.extract_partition_bucket_row(values_by_name) | |
| ) | |
| self._write_partition_bucket_row( | |
| partition, bucket, row, values_by_name | |
| ) | |
| def _write_partition_bucket_row( | |
| self, partition, bucket, row, values_by_name | |
| ): | |
| self.file_store_write.write_row( | |
| partition, bucket, row, values_by_name | |
| ) | |
| def write_pandas(self, dataframe): | |
| write_cols = self.file_store_write.write_cols | |
| if write_cols is not None: | |
| # Column-subset write (append-only ``with_write_type``): build the | |
| # RecordBatch against the subset schema so the input only needs the | |
| # written columns, mirroring the ``write_arrow`` path. | |
| pa_schema = self._write_cols_pyarrow_schema(write_cols) | |
| else: | |
| pa_schema = self.table_pyarrow_schema | |
| record_batch = pa.RecordBatch.from_pandas(dataframe, schema=pa_schema) | |
| return self.write_arrow_batch(record_batch) | |
| def with_write_type(self, write_cols: List[str]): | |
| for col in write_cols: | |
| if col not in self.table_pyarrow_schema.names: | |
| raise ValueError(f"Column {col} is not in table schema.") | |
| if len(write_cols) == len(self.table_pyarrow_schema.names): | |
| write_cols = None | |
| self.file_store_write.write_cols = write_cols | |
| return self | |
| def with_blob_consumer(self, blob_consumer: BlobConsumer): | |
| if self.file_store_write.data_writers: | |
| raise RuntimeError( | |
| "with_blob_consumer must be called before any write operation." | |
| ) | |
| self.file_store_write.blob_consumer = blob_consumer | |
| return self | |
| def with_blob_uri_reader_factory(self, uri_reader_factory): | |
| if self.file_store_write.data_writers: | |
| raise RuntimeError( | |
| "with_blob_uri_reader_factory must be called before any " | |
| "write operation." | |
| ) | |
| self.file_store_write.blob_uri_reader_factory = uri_reader_factory | |
| return self | |
| def write_ray( | |
| self, | |
| dataset: "Dataset", | |
| overwrite: bool = False, | |
| concurrency: Optional[int] = None, | |
| ray_remote_args: Optional[Dict[str, Any]] = None, | |
| hash_fixed_precluster: str = "auto", | |
| static_partition: Optional[dict] = None, | |
| ) -> None: | |
| """ | |
| Write a Ray Dataset to Paimon table. | |
| Args: | |
| dataset: Ray Dataset to write. This is a distributed data collection | |
| from Ray Data (ray.data.Dataset). | |
| overwrite: Whether to overwrite existing data. Defaults to False. | |
| Builder-level or static_partition overwrite mode takes precedence. | |
| concurrency: Optional max number of Ray tasks to run concurrently. | |
| By default, dynamically decided based on available resources. | |
| ray_remote_args: Optional kwargs passed to :func:`ray.remote` in write tasks. | |
| For example, ``{"num_cpus": 2, "max_retries": 3}``. | |
| hash_fixed_precluster: Pre-clustering mode. ``"auto"`` follows | |
| table options, ``"off"`` disables it, and ``"map_groups"`` | |
| explicitly enables HASH_FIXED grouping. | |
| static_partition: Optional partition spec to overwrite. When set, | |
| the Ray write runs in overwrite mode for this partition and | |
| overrides any builder-level partition spec. | |
| """ | |
| from pypaimon.write.ray_datasink import write_paimon_dataset | |
| overwrite_partition = self.static_partition | |
| if static_partition is not None: | |
| overwrite_partition = static_partition | |
| write_paimon_dataset( | |
| dataset, | |
| self.table, | |
| overwrite=overwrite, | |
| static_partition=overwrite_partition, | |
| concurrency=concurrency, | |
| ray_remote_args=ray_remote_args, | |
| hash_fixed_precluster=hash_fixed_precluster, | |
| **self._distributed_write_options(), | |
| ) | |
| def _distributed_write_options(self) -> Dict[str, Any]: | |
| """Return options forwarded by ``write_ray`` to the Ray writer.""" | |
| return {} | |
| def close(self): | |
| try: | |
| self.file_store_write.close() | |
| finally: | |
| self._release_prepared_indexes() | |
| def abort(self): | |
| try: | |
| self.file_store_write.abort() | |
| finally: | |
| abort = getattr(self.row_key_extractor, "abort", None) | |
| if abort is not None: | |
| abort() | |
| def _prepare_commit(self, commit_identifier) -> List[CommitMessage]: | |
| commit_messages = self.file_store_write._prepare_commit_messages(commit_identifier) | |
| prepare_indexes = getattr(self.row_key_extractor, "prepare_commit", None) | |
| index_changes = prepare_indexes() if prepare_indexes is not None else {} | |
| messages_by_bucket = { | |
| (tuple(message.partition), message.bucket): message | |
| for message in commit_messages | |
| } | |
| for (partition, bucket), changes in index_changes.items(): | |
| message = messages_by_bucket.get((partition, bucket)) | |
| if message is None: | |
| message = CommitMessage( | |
| partition=partition, | |
| bucket=bucket, | |
| new_files=[], | |
| ) | |
| commit_messages.append(message) | |
| messages_by_bucket[(partition, bucket)] = message | |
| message.index_adds.extend(changes.additions) | |
| message.index_deletes.extend(changes.deletions) | |
| # Hand off data, changelog and HASH indexes together. Until every | |
| # preparation succeeds, retain the metadata for retry and abort. | |
| self.file_store_write._release_prepared_files() | |
| self._release_prepared_indexes() | |
| return commit_messages | |
| def _release_prepared_indexes(self) -> None: | |
| release = getattr(self.row_key_extractor, "release_prepared", None) | |
| if release is not None: | |
| release() | |
| def _prepare_arrow_data(self, data): | |
| self._validate_pyarrow_schema(data.schema) | |
| data = normalize_arrow_strings(data) | |
| # Schema-only writers can normalize input without table options. | |
| table = getattr(self, 'table', None) | |
| if table is None: | |
| return data | |
| from pypaimon.write.row_kind import filter_write_batch | |
| return filter_write_batch(table, data) | |
| def _validate_pyarrow_schema(self, data_schema: pa.Schema): | |
| if self._is_compatible_pyarrow_schema(data_schema, self.table_pyarrow_schema): | |
| return | |
| write_cols = self.file_store_write.write_cols | |
| if write_cols is not None: | |
| write_cols_schema = self._write_cols_pyarrow_schema(write_cols) | |
| if self._is_compatible_pyarrow_schema(data_schema, write_cols_schema): | |
| return | |
| self._raise_inconsistent_schema(data_schema) | |
| def _is_compatible_pyarrow_schema( | |
| self, data_schema: pa.Schema, expected_schema: pa.Schema) -> bool: | |
| return arrow_schemas_compatible( | |
| data_schema, expected_schema, check_top_level_nullability=False, allow_binary_compatibility=True) | |
| def _write_cols_pyarrow_schema(self, write_cols: List[str]) -> pa.Schema: | |
| table_fields = { | |
| field.name: field for field in self.table_pyarrow_schema | |
| } | |
| return pa.schema([table_fields[col] for col in write_cols]) | |
| def _raise_inconsistent_schema(self, data_schema: pa.Schema): | |
| raise ValueError(f"Input schema isn't consistent with table schema and write cols. " | |
| f"Input schema is: {data_schema} " | |
| f"Table schema is: {self.table_pyarrow_schema} " | |
| f"Write cols is: {self.file_store_write.write_cols}") | |
| class BatchTableWrite(TableWrite): | |
| def __init__(self, table, commit_user, static_partition: Optional[dict] = None, | |
| *, restore_snapshot_id=None): | |
| super().__init__(table, commit_user, static_partition, | |
| restore_snapshot_id=restore_snapshot_id) | |
| self.batch_committed = False | |
| def prepare_commit(self) -> List[CommitMessage]: | |
| if self.batch_committed: | |
| raise RuntimeError("BatchTableWrite only supports one-time committing.") | |
| self.batch_committed = True | |
| return self._prepare_commit(BATCH_COMMIT_IDENTIFIER) | |
| class StreamTableWrite(TableWrite): | |
| def __init__(self, table, commit_user, *, restore_snapshot_id=None): | |
| super().__init__(table, commit_user, None, | |
| restore_snapshot_id=restore_snapshot_id) | |
| def prepare_commit(self, commit_identifier) -> List[CommitMessage]: | |
| return self._prepare_commit(commit_identifier) |