blob: 4a563413bf6155b6ba99d55720f2139e786e33fb [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 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, normalize_arrow_write_nullability, project_arrow_batch)
from pypaimon.schema.data_types import PyarrowFieldParser
from pypaimon.table.row.blob import BlobConsumer
from pypaimon.write.row_utils import (
require_columns,
row_to_named_values,
validate_write_columns,
)
from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.commit_identifier import validate_commit_identifier
from pypaimon.execution.python.write.file_store_write import FileStoreWrite
from pypaimon.write.input_adapter import _pandas_write_batch
class PythonTableWrite:
"""Bound Python writer implementing the common write execution protocol."""
def __init__(self, public_writer):
self.public_writer = public_writer
def initialize(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)
# Java overwrite writers ignore previous files even without an explicit
# restore snapshot. HASH restoration is separately disabled by the row
# key extractor; sequence restoration must also start from empty state.
self.file_store_write.restore_snapshot_id = (
0 if static_partition 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.execution.python.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)
from pypaimon.execution.python.write.row_key_extractor import create_row_key_extractor
return create_row_key_extractor(self.table, 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."""
data = self._prepare_arrow_schema(data)
# Public batch overrides remain observable for Arrow and pandas input.
# Schema, routing and preparation helpers stay within this executor.
if isinstance(data, pa.RecordBatch):
return self.public_writer.write_arrow_batch(data, bucket)
for batch in data.to_batches():
self.public_writer.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)
if data.num_rows == 0:
return
groups = self.row_key_extractor.extract_partition_bucket_groups(data)
for partition, bucket, row_indices in groups:
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.execution.python.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)
require_columns(values_by_name, self.table.partition_keys, "write_row")
from pypaimon.write.row_kind import skip_write_row
write_cols = (self.file_store_write.write_cols
if set(values_by_name) == set(self.table.field_names) else list(values_by_name))
if skip_write_row(self.table, values_by_name, row.get_row_kind(), write_cols):
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 = _pandas_write_batch(
getattr(self, 'table', None), dataframe, pa_schema, self.table_pyarrow_schema)
return self.public_writer.write_arrow_batch(record_batch)
def with_write_type(self, write_cols: List[str]):
write_cols = validate_write_columns(write_cols)
for col in write_cols:
if col not in self.table_pyarrow_schema.names:
raise ValueError(f"Column {col} is not in table schema.")
full_names = self.table_pyarrow_schema.names
# Java preserves the declared append column order in writeCols. PK
# writes still require the complete table layout in the Python buffer.
full_pk_type = (self.table.is_primary_key_table and len(write_cols) == len(full_names)
and set(write_cols) == set(full_names))
if write_cols == full_names or full_pk_type:
write_cols = None
if self.file_store_write._ever_written:
if write_cols == self.file_store_write.write_cols:
return self
raise RuntimeError('with_write_type must be called before any write operation')
self.file_store_write.write_cols = write_cols
return self
def with_blob_consumer(self, blob_consumer: BlobConsumer):
if blob_consumer is not None and not callable(blob_consumer):
raise TypeError('blob_consumer must be callable')
self.file_store_write.blob_consumer = blob_consumer
return self
def with_blob_uri_reader_factory(self, uri_reader_factory):
if uri_reader_factory is not None and not callable(getattr(uri_reader_factory, 'create', None)):
raise TypeError('URI reader factory must provide callable create')
if self.file_store_write._ever_written:
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 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_identifier = validate_commit_identifier(commit_identifier)
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):
data = self._prepare_arrow_schema(data)
# Schema-only writers can normalize input without table options.
table = getattr(self, 'table', None)
if table is None:
return data
data = self._normalize_arrow_write_schema(data)
from pypaimon.write.row_kind import filter_write_batch
selected = (self.file_store_write.write_cols if not table.is_primary_key_table else None)
write_cols = selected
if selected is None:
write_cols = (self.file_store_write.write_cols
if data.schema.names == self.table_pyarrow_schema.names else data.schema.names)
data = filter_write_batch(table, data, write_cols)
if not data.num_rows:
return data
if selected is not None:
routing = self._write_routing_columns()
columns = selected + [name for name in data.schema.names
if name in routing and name not in selected]
data = self._project_write_input(data, columns)
# Routing fields are not written values. Arrow's whole-table
# string cast must not reapply their declared top-level NOT NULL.
schema = pa.schema([field if field.name in selected else field.with_nullable(True)
for field in data.schema], metadata=data.schema.metadata)
data = (pa.RecordBatch.from_arrays(data.columns, schema=schema) if isinstance(data, pa.RecordBatch)
else pa.Table.from_arrays(data.columns, schema=schema))
data = normalize_arrow_strings(data)
physical = self._project_write_input(data, selected) if selected is not None else data
target = self.table_pyarrow_schema
if physical.schema.names != target.names:
target = self._write_cols_pyarrow_schema(physical.schema.names)
# Validate only surviving values. Keep the input transport metadata:
# PyArrow otherwise rejects hidden NULL children of NULL ROW parents.
validated = normalize_arrow_write_nullability(physical, target)
self._validate_pyarrow_schema(validated.schema)
return data
@staticmethod
def _project_write_input(data, columns):
return (project_arrow_batch(data, columns) if isinstance(data, pa.RecordBatch)
else data.select(columns))
def _write_routing_columns(self):
table = self.table
routing = set(table.partition_keys)
if table.options.bucket() > 0:
routing.update(table.table_schema.bucket_keys)
row_kind = table.options.options.to_map().get('rowkind.field')
if row_kind is not None:
routing.add(row_kind)
return routing
def _prepare_arrow_schema(self, data):
# Legacy schema-only callers have no row-kind options to evaluate.
# A real writer must defer casts until ignored events have been removed.
if getattr(self, 'table', None) is None:
data = normalize_arrow_strings(data)
self._validate_pyarrow_schema(
data.schema, check_nested_nullability=getattr(self, 'table', None) is None)
return data
def _normalize_arrow_write_schema(self, data):
# Compatible input batches may differ in top-level nullability or
# metadata. Buffered writes and row-to-Arrow conversion must use the
# same declared fields, while keeping normalized Arrow array types.
fields = []
for field in data.schema:
declared = self.table_pyarrow_schema.field(field.name)
fields.append(pa.field(field.name, field.type, nullable=declared.nullable,
metadata=declared.metadata))
schema = pa.schema(fields, metadata=self.table_pyarrow_schema.metadata)
from pypaimon.write.row_kind import write_value_schema
schema = write_value_schema(self.table, schema)
if data.schema.equals(schema, check_metadata=True):
return data
if isinstance(data, pa.RecordBatch):
return pa.RecordBatch.from_arrays(data.columns, schema=schema)
return pa.Table.from_arrays(data.columns, schema=schema)
def _validate_pyarrow_schema(self, data_schema: pa.Schema, *, check_nested_nullability=True):
if self._is_compatible_pyarrow_schema(
data_schema, self.table_pyarrow_schema, check_nested_nullability=check_nested_nullability):
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, check_nested_nullability=check_nested_nullability):
return
table = getattr(self, 'table', None)
if table is not None and not table.is_primary_key_table:
names = data_schema.names
selected = set(write_cols)
allowed = selected | self._write_routing_columns()
if (len(set(names)) == len(names) and set(names).issubset(allowed)
and [name for name in names if name in selected] == write_cols):
transport_schema = self._write_cols_pyarrow_schema(names)
if self._is_compatible_pyarrow_schema(
data_schema, transport_schema, check_nested_nullability=check_nested_nullability):
return
self._raise_inconsistent_schema(data_schema)
def _is_compatible_pyarrow_schema(
self, data_schema: pa.Schema, expected_schema: pa.Schema, *, check_nested_nullability=True) -> bool:
return arrow_schemas_compatible(
data_schema, expected_schema, check_top_level_nullability=False,
allow_binary_compatibility=True, check_nested_nullability=check_nested_nullability)
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}")