blob: 9f29670158f83ed6a0c5da9a5592106542a3198a [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.
"""Optional Rust data writer behind PyPaimon's batch and stream builders."""
import pyarrow as pa
from pypaimon.common.options.core_options import ChangelogProducer
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
from pypaimon.execution.native.runtime import native_runtime_available
from pypaimon.schema.arrow_schema import arrow_schemas_compatible
from pypaimon.schema.data_types import PyarrowFieldParser, is_blob_file_field
from pypaimon.table.bucket_mode import BucketMode
from pypaimon.write.commit_identifier import validate_commit_identifier
from pypaimon.execution.native.write.native_commit import (
create_native_write_table, from_native_commit_messages,
)
from pypaimon.write.row_utils import (
require_columns, row_to_named_values, row_values_to_arrow_table, validate_write_columns,
)
def create_native_write(table, commit_user, static_partition=None, stream=False,
*, fixed_bucket=False, bucket_plan=None, restore_snapshot_id=None):
"""Return a native writer if the table can use the filesystem write path."""
# DV tables flush normal level-0 files using the configured merge engine.
# Batch visibility is controlled by deletion-vectors.merge-on-read.
if (not native_runtime_available()
or table.bucket_mode() not in (BucketMode.HASH_FIXED, BucketMode.HASH_DYNAMIC,
BucketMode.BUCKET_UNAWARE,
BucketMode.CROSS_PARTITION,
BucketMode.POSTPONE_MODE)
# The format matters only when this write emits INPUT changelogs.
# Batch overwrites suppress them because they cannot be committed.
or (table.options.changelog_file_format() not in (None, 'parquet')
and table.options.changelog_producer() == ChangelogProducer.INPUT
and (stream or static_partition is None))
or table.options.file_format() != 'parquet'
or table.options.video_frame_fields()):
return None
native_table = create_native_write_table(table)
if native_table is None:
return None
if fixed_bucket:
builder = native_table.new_postpone_fixed_bucket_write_builder().with_commit_user(commit_user)
if bucket_plan is not None:
builder = builder.with_bucket_plan(bucket_plan.to_arrow(table))
if static_partition is not None:
builder = builder.with_overwrite(static_partition)
return NativePostponeFixedBucketTableWrite(
table, commit_user, static_partition, bucket_plan, builder.new_write())
if stream:
builder = native_table.new_stream_write_builder().with_commit_user(commit_user)
else:
builder = native_table.new_batch_write_builder().with_commit_user(commit_user)
if static_partition is not None:
builder = builder.with_overwrite(static_partition)
if restore_snapshot_id is not None:
builder.with_restore_snapshot(restore_snapshot_id)
return NativeTableWrite(table, commit_user, static_partition, stream,
builder.new_write(), restore_snapshot_id)
class NativeTableWrite:
"""Adapt Python inputs and commit messages to one Rust writer."""
def __init__(self, table, commit_user, static_partition, stream, native_writer,
restore_snapshot_id=None):
self.table = table
self.commit_user = commit_user
self.static_partition = static_partition
self.stream = stream
self._native_writer = native_writer
self._written = False
self._batch_committed = False
self._table_schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
self._schema = self._table_schema
self._write_cols = None
self._blob_consumer = None
self._blob_uri_reader_factory = None
self._restore_snapshot_id = restore_snapshot_id
self._blob_rows = None
self._defer_blob_input = False
if any(is_blob_file_field(field) for field in table.table_schema.fields):
from pypaimon.execution.native.write.native_blob_rows import NativeBlobRows
self._blob_rows = NativeBlobRows()
self._native_writer.with_blob_uri_reader_factory(self._blob_rows)
def with_write_type(self, write_cols):
names = validate_write_columns(write_cols)
if self.table.is_primary_key_table:
if len(names) == len(self._table_schema.names) and set(names) == set(self._table_schema.names):
# Python treats a complete PK write type as the unchanged table
# schema. It does not configure a PK column-subset writer.
return self
raise ValueError('Column-subset primary-key writes require the Python executor')
self._configure_native('with_write_type', names)
self._schema = pa.schema([self._table_schema.field(name) for name in names])
self._write_cols = names
return self
def with_blob_consumer(self, blob_consumer):
if blob_consumer is None:
self._configure_native('with_blob_consumer', None)
self._blob_consumer = None
return self
if not callable(blob_consumer):
raise TypeError('blob_consumer must be callable')
from pypaimon.table.row.blob import BlobDescriptor
def consume(field_name, encoded):
descriptor = None if encoded is None else BlobDescriptor.deserialize(encoded)
return blob_consumer(field_name, descriptor)
self._configure_native('with_blob_consumer', consume)
self._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._blob_rows is not None:
# Only core knows whether RowKind filtering accepted any rows or a
# postpone writer buffered data. Validate before changing the live
# bridge's fallback, including when clearing the application factory.
self._configure_native('with_blob_uri_reader_factory', self._blob_rows)
self._blob_rows.fallback = uri_reader_factory
else:
self._configure_native('with_blob_uri_reader_factory', uri_reader_factory)
self._blob_uri_reader_factory = uri_reader_factory
return self
def _configure_native(self, method, value):
try:
getattr(self._native_writer, method)(value)
except ValueError as error:
# Core owns written state: an ignored RowKind can leave it writable
# even after this facade accepted a batch. Preserve the existing
# Python state-error type without changing unrelated input errors.
message = method + ' must be called before any write operation'
if message in str(error):
raise RuntimeError(message) from error
raise
def write_arrow(self, data, bucket=None):
data = self._prepare_native_arrow_data(data)
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, bucket=None):
data = self._prepare_native_arrow_data(data)
self._write_native_batch(data, bucket)
def _write_native_batch(self, data, bucket=None):
if data.num_rows:
# A failed native write may already have produced files. Never
# retry that batch through Python after this point.
self._written = True
self._native_writer.write_arrow(data, bucket)
def _prepare_native_arrow_data(self, data):
# Core resolves row kinds and filters ignored events before inspecting
# payload values. This facade checks only the logical Arrow schema.
compatible = dict(check_top_level_nullability=False, allow_binary_compatibility=True,
check_nested_nullability=False)
names = data.schema.names
if len(set(names)) == len(names) and all(name in self._table_schema.names for name in names):
expected = pa.schema([self._table_schema.field(name) for name in names])
if arrow_schemas_compatible(data.schema, expected, **compatible):
# Core validates the selected write type and routing columns.
# Python checks only the declared types of transported fields.
return data
raise ValueError(
"Input schema isn't consistent with table schema and write cols. "
f"Input schema is: {data.schema} Table schema is: {self._schema} "
f"Write cols is: {self._write_cols}")
def write_pandas(self, dataframe):
from pypaimon.write.input_adapter import _pandas_write_batch
self.write_arrow_batch(_pandas_write_batch(self.table, dataframe, self._schema, self._table_schema))
def roll_before_group_if_needed(self, row_count):
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.')
# Python's group boundary only affects packed video writers. Native
# selection excludes video, so ordinary Parquet/BLOB writes do not roll.
def write_row(self, row):
values = row_to_named_values(row, self.table.table_schema.fields)
names = self._schema.names
full_row = set(values) == set(self._table_schema.names)
require_columns(values, self.table.partition_keys, 'write_row')
from pypaimon.write.row_kind import skip_write_row, with_row_kind
logical_columns = self._write_cols if full_row else list(values)
if skip_write_row(self.table, values, row.get_row_kind(), logical_columns):
return
require_columns(values, names, 'write_row')
if self._write_cols is not None:
routing = list(self.table.partition_keys)
if ((self.table.bucket_mode() == BucketMode.HASH_FIXED and self.table.options.bucket() > 1)
or isinstance(self, NativePostponeFixedBucketTableWrite)):
routing.extend(self.table.table_schema.bucket_keys)
routing = list(dict.fromkeys(routing))
row_kind_field = self.table.options.options.to_map().get('rowkind.field')
if row_kind_field is not None:
routing.append(row_kind_field)
require_columns(values, routing, 'write_row')
# Keep the selected order, followed by routing/event fields. Values
# outside this transport remain untouched, including live Blobs.
names = list(names) + [name for name in self._table_schema.names
if name in routing and name not in names]
try:
data = (self._blob_rows.to_batch(self.table, [values], names)
if self._blob_rows is not None else
self._prepare_native_arrow_data(row_values_to_arrow_table(
values, self.table.table_schema.fields, names).to_batches()[0]))
self._write_native_batch(with_row_kind(self.table, data, row))
finally:
# Core externalizes Blob values during write, before buffering PK
# records. Their live Python objects need not survive the call.
if self._blob_rows is not None:
self._blob_rows.release_consumed()
if not self._defer_blob_input:
self._blob_rows.clear()
def prepare_commit(self, commit_identifier):
commit_identifier = validate_commit_identifier(commit_identifier)
if self.stream:
messages = self._native_writer.prepare_commit(True, commit_identifier)
else:
if commit_identifier != BATCH_COMMIT_IDENTIFIER:
raise ValueError('Batch writer execution requires the batch commit identifier')
self._batch_committed = True
try:
messages = self._native_writer.prepare_commit()
finally:
if self._blob_rows is not None and self._defer_blob_input:
self._blob_rows.clear()
return from_native_commit_messages(self.table, messages)
def close(self):
if self._native_writer is not None:
self._native_writer.close()
self._native_writer = None
if self._blob_rows is not None:
self._blob_rows.clear()
def abort(self):
# Rust cleans only files still owned by its writer. Prepared commit
# messages must survive failures whose publication outcome is unknown.
self.close()
class NativePostponeFixedBucketTableWrite(NativeTableWrite):
"""Fixed-bucket batches retain the standard native ownership contract.
A distributed coordinator reads bucket metadata through Rust and groups
inputs with shared arithmetic before workers use the fixed-bucket writer.
"""
def __init__(self, table, commit_user, static_partition, bucket_plan, native_writer):
super().__init__(table, commit_user, static_partition, False, native_writer)
self._bucket_plan = bucket_plan
# Automatic planning stages input until all partition sizes are known.
self._defer_blob_input = (bucket_plan is None
and table.options.postpone_default_bucket_num() is None)