blob: 04e7b4df01b94ec16a7062f6ee6c7ff939ffd821 [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.
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)