blob: fb5d75b2a8894ff4576e48772f1dbb4b66ab8a71 [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.
import logging
import uuid
from typing import Dict, List, Optional, Tuple
import pyarrow as pa
from pypaimon.common.options.core_options import CoreOptions, ChangelogProducer
from pypaimon.data.timestamp import Timestamp
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.simple_stats import SimpleStats
from pypaimon.schema.data_types import VectorType
from pypaimon.table.row.blob import BlobConsumer
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.write.writer.data_writer import DataWriter
logger = logging.getLogger(__name__)
class DedicatedFormatWriter(DataWriter):
"""A rolling file writer that writes normal, blob, and vector columns to dedicated files.
Splits incoming data three ways:
- Normal columns → standard data files (.parquet / .orc / .vortex / …)
- Blob columns (large_binary) → .blob files
- Vector columns (when vector.file.format is configured) → .vector.<format> files
This mirrors Java's DedicatedFormatRollingFileWriter.
Metadata order in committed_files:
[normal_meta, blob_meta1, …, vector_meta1, …]
"""
# Constant for checking rolling condition periodically
CHECK_ROLLING_RECORD_CNT = 1000
def __init__(self, table, partition: Tuple, bucket: int, max_seq_number: int, options: CoreOptions = None,
write_cols: Optional[List[str]] = None, blob_consumer: Optional[BlobConsumer] = None,
changelog_producer: ChangelogProducer = ChangelogProducer.NONE):
super().__init__(table, partition, bucket, max_seq_number, options, write_cols=write_cols,
changelog_producer=changelog_producer)
# Determine blob columns from table schema
self.blob_column_names = self._get_blob_columns_from_schema()
self.blob_descriptor_fields = CoreOptions.blob_descriptor_fields(self.options)
self.blob_view_fields = CoreOptions.blob_view_fields(self.options)
self.blob_inline_fields = self.blob_descriptor_fields.union(self.blob_view_fields)
unknown_descriptor_fields = self.blob_descriptor_fields.difference(
set(self.blob_column_names)
)
if unknown_descriptor_fields:
raise ValueError(
"Fields in 'blob-descriptor-field' must be blob fields in schema. "
f"Unknown fields: {sorted(unknown_descriptor_fields)}"
)
# Blob fields that should still be written to `.blob` files.
self.blob_file_column_names = [
col for col in self.blob_column_names if col not in self.blob_inline_fields
]
full_blob_file_set = set(self.blob_file_column_names)
all_column_names = self.table.field_names
# Detect vector columns that should be written to dedicated files.
full_vector_column_names = self._get_vector_columns_from_schema()
full_vector_set = set(full_vector_column_names)
# Only split vector columns when vector.file.format is configured.
has_dedicated_vector = bool(full_vector_column_names) and options.with_vector_format()
dedicated_set = full_blob_file_set | (full_vector_set if has_dedicated_vector else set())
# Narrow columns when TableWrite.with_write_type(...) supplies a partial column list.
# Incoming RecordBatches only contain those columns; selecting full normal/blob lists
# would raise KeyError.
if write_cols is not None:
write_col_set = set(write_cols)
self.blob_file_column_names = [
col for col in self.blob_file_column_names if col in write_col_set
]
self.vector_write_columns = [
col for col in full_vector_column_names if col in write_col_set
] if has_dedicated_vector else []
self.normal_column_names = [
col for col in write_cols if col not in dedicated_set
]
else:
self.vector_write_columns = list(full_vector_column_names) if has_dedicated_vector else []
self.normal_column_names = [
col for col in all_column_names if col not in dedicated_set
]
normal_name_set = set(self.normal_column_names)
self.normal_columns = [
field for field in self.table.table_schema.fields if field.name in normal_name_set
]
self.write_cols = self.normal_column_names
# State management for blob writer
self.record_count = 0
self.closed = False
# Track pending data for normal data only
self.pending_normal_data: Optional[pa.Table] = None
# Initialize blob writers for each blob-file column.
from pypaimon.write.writer.blob_writer import BlobWriter
self.blob_writers: Dict[str, BlobWriter] = {}
for blob_column in self.blob_file_column_names:
self.blob_writers[blob_column] = BlobWriter(
table=self.table,
partition=self.partition,
bucket=self.bucket,
max_seq_number=max_seq_number,
blob_column=blob_column,
options=options,
blob_consumer=blob_consumer,
)
# Initialize vector writer when vector.file.format is configured.
from pypaimon.write.writer.vector_writer import VectorWriter
self.vector_writer: Optional[VectorWriter] = None
if self.vector_write_columns:
self.vector_writer = VectorWriter(
table=self.table,
partition=self.partition,
bucket=self.bucket,
max_seq_number=max_seq_number,
vector_columns=self.vector_write_columns,
vector_file_format=options.vector_file_format(),
options=options,
)
# Initialize ExternalStorageBlobWriter if configured
self._external_storage_writer = None
external_storage_fields = self.options.blob_external_storage_fields()
external_storage_path = self.options.blob_external_storage_path()
if external_storage_fields and external_storage_path:
from pypaimon.write.writer.external_storage_blob_writer import \
ExternalStorageBlobWriter
self._external_storage_writer = ExternalStorageBlobWriter(
file_io=self.file_io,
external_storage_path=external_storage_path,
external_storage_fields=external_storage_fields,
blob_target_file_size=self.options.blob_target_file_size(),
data_file_prefix=CoreOptions.data_file_prefix(self.options),
)
logger.info(
"Initialized DedicatedFormatWriter with blob columns: %s, blob file columns: %s, "
"vector columns: %s, descriptor stored columns: %s, external storage fields: %s, view stored columns: %s",
self.blob_column_names,
self.blob_file_column_names,
self.vector_write_columns,
sorted(self.blob_descriptor_fields),
sorted(external_storage_fields) if external_storage_fields else [],
sorted(self.blob_view_fields)
)
def _get_blob_columns_from_schema(self) -> List[str]:
blob_columns = []
for field in self.table.table_schema.fields:
type_str = str(field.type).lower()
if 'blob' in type_str:
blob_columns.append(field.name)
if len(blob_columns) == 0:
raise ValueError("No blob field found in table schema.")
return blob_columns
def _get_vector_columns_from_schema(self) -> List[str]:
return [
field.name for field in self.table.table_schema.fields
if isinstance(field.type, VectorType)
]
def _process_data(self, data: pa.RecordBatch) -> pa.RecordBatch:
normal_data, _, _ = self._split_data(data)
return normal_data
def _merge_data(self, existing_data: pa.Table, new_data: pa.Table) -> pa.Table:
return self._merge_normal_data(existing_data, new_data)
def write(self, data: pa.RecordBatch):
try:
# Transform external-storage fields: write raw blob to external storage,
# replace with serialized BlobDescriptor
if self._external_storage_writer:
data = self._external_storage_writer.transform_batch(data)
# Split data into normal, blob, and vector parts
normal_data, blob_data_map, vector_data = self._split_data(data)
self._validate_inline_stored_fields_input(data)
# Process and accumulate normal data (may be None for partial writes)
processed_normal = self._process_normal_data(normal_data)
if processed_normal is not None:
if self.pending_normal_data is None:
self.pending_normal_data = processed_normal
else:
self.pending_normal_data = self._merge_normal_data(self.pending_normal_data, processed_normal)
# Write blob-file columns to dedicated blob writers.
for blob_column, blob_data in blob_data_map.items():
if blob_data is not None and blob_data.num_rows > 0:
self.blob_writers[blob_column].write(blob_data)
# Write vector columns to dedicated vector writer.
if self.vector_writer is not None and vector_data is not None and vector_data.num_rows > 0:
self.vector_writer.write(vector_data)
self.record_count += data.num_rows
# Check if normal data rolling is needed
if self._should_roll_normal():
# When normal data rolls, close both writers and fetch blob metadata
self._close_current_writers()
except Exception as e:
logger.error("Exception occurs when writing data. Cleaning up.", exc_info=e)
self.abort()
raise e
def prepare_commit(self) -> List[DataFileMeta]:
# Close any remaining data
self._close_current_writers()
return self.committed_files.copy()
def close(self):
if self.closed:
return
try:
self._close_current_writers()
if self._external_storage_writer:
self._external_storage_writer.close()
except Exception as e:
logger.error("Exception occurs when closing writer. Cleaning up.", exc_info=e)
self.abort()
raise
finally:
self.closed = True
self.pending_normal_data = None
def abort(self):
"""Abort all writers and clean up resources."""
for blob_writer in self.blob_writers.values():
blob_writer.abort()
if self.vector_writer is not None:
self.vector_writer.abort()
if self._external_storage_writer:
self._external_storage_writer.abort()
committed_non_blob_files = [
file_meta for file_meta in self.committed_files
if not DataFileMeta.is_blob_file(file_meta.file_name)
]
self._delete_committed_files(committed_non_blob_files)
self.pending_normal_data = None
self.pending_data = None
self.committed_files.clear()
def _split_data(self, data: pa.RecordBatch) -> Tuple[
pa.RecordBatch, Dict[str, pa.RecordBatch], Optional[pa.RecordBatch]]:
"""Split data into normal, blob, and vector parts based on column names."""
normal_data = data.select(self.normal_column_names) if self.normal_column_names else None
blob_data_map = {
blob_column: data.select([blob_column]) for blob_column in self.blob_file_column_names
}
vector_data = (
pa.RecordBatch.from_arrays(
[data.column(name) for name in self.vector_write_columns],
names=self.vector_write_columns,
) if self.vector_write_columns else None
)
return normal_data, blob_data_map, vector_data
def _validate_inline_stored_fields_input(self, data: pa.RecordBatch):
if not self.blob_inline_fields:
return
from pypaimon.table.row.blob import BlobDescriptor, BlobViewStruct
for field_name in self.blob_descriptor_fields:
if field_name not in data.schema.names:
continue
values = data.column(data.schema.get_field_index(field_name)).to_pylist()
for value in values:
if value is None:
continue
if hasattr(value, 'as_py'):
value = value.as_py()
if isinstance(value, str):
value = value.encode('utf-8')
if not isinstance(value, (bytes, bytearray)):
raise ValueError(
"blob-descriptor-field requires blob field value to be a serialized "
"BlobDescriptor."
)
try:
descriptor_bytes = bytes(value)
descriptor = BlobDescriptor.deserialize(descriptor_bytes)
if descriptor.serialize() != descriptor_bytes:
raise ValueError("Descriptor payload contains trailing bytes.")
except Exception as e:
raise ValueError(
"blob-descriptor-field requires blob field value to be a serialized "
"BlobDescriptor."
) from e
for field_name in self.blob_view_fields:
if field_name not in data.schema.names:
continue
values = data.column(data.schema.get_field_index(field_name)).to_pylist()
for value in values:
if value is None:
continue
if hasattr(value, 'as_py'):
value = value.as_py()
if isinstance(value, str):
value = value.encode('utf-8')
if not isinstance(value, (bytes, bytearray)):
raise ValueError(
"blob-view-field requires blob field value to be a serialized "
"BlobViewStruct."
)
try:
view_bytes = bytes(value)
view_struct = BlobViewStruct.deserialize(view_bytes)
if view_struct.serialize() != view_bytes:
raise ValueError("BlobViewStruct payload contains trailing bytes.")
except Exception as e:
raise ValueError(
"blob-view-field requires blob field value to be a serialized "
"BlobViewStruct."
) from e
@staticmethod
def _process_normal_data(data: pa.RecordBatch) -> Optional[pa.Table]:
"""Process normal data (similar to base DataWriter)."""
if data is None or data.num_rows == 0:
return None
return pa.Table.from_batches([data])
@staticmethod
def _merge_normal_data(existing_data: pa.Table, new_data: pa.Table) -> pa.Table:
return pa.concat_tables([existing_data, new_data])
def _should_roll_normal(self) -> bool:
if self.pending_normal_data is None:
return False
# Check rolling condition periodically (every CHECK_ROLLING_RECORD_CNT records)
if self.record_count % self.CHECK_ROLLING_RECORD_CNT != 0:
return False
# Check if normal data exceeds target size
current_size = self.pending_normal_data.nbytes
return current_size > self.target_file_size
def _close_current_writers(self):
"""Close normal, blob, and vector writers; add metadata in order: normal, blob, vector."""
normal_meta = None
if self.pending_normal_data is not None and self.pending_normal_data.num_rows > 0:
normal_meta = self._write_normal_data_to_file(self.pending_normal_data)
self.committed_files.append(normal_meta)
blob_metas = []
for blob_column in self.blob_file_column_names:
writer_metas = self.blob_writers[blob_column].prepare_commit()
if normal_meta is not None:
self._validate_consistency(normal_meta, writer_metas, blob_column)
blob_metas.extend(writer_metas)
self.committed_files.extend(blob_metas)
vector_metas = []
if self.vector_writer is not None:
vector_metas = self.vector_writer.prepare_commit()
if vector_metas and normal_meta is not None:
self._validate_consistency(normal_meta, vector_metas, 'vector')
self.committed_files.extend(vector_metas)
self.vector_writer.committed_files.clear()
self.pending_normal_data = None
if normal_meta is not None or blob_metas or vector_metas:
normal_name = normal_meta.file_name if normal_meta is not None else '<none>'
logger.info(f"Closed writers - normal: {normal_name}, "
f"{len(blob_metas)} blob metas, {len(vector_metas)} vector metas")
def _write_normal_data_to_file(self, data: pa.Table) -> Optional[DataFileMeta]:
if data.num_rows == 0:
return None
file_name = f"{CoreOptions.data_file_prefix(self.options)}{uuid.uuid4()}-0.{self.file_format}"
file_path = self._generate_file_path(file_name)
# Write file based on format
if self.file_format == CoreOptions.FILE_FORMAT_PARQUET:
self.file_io.write_parquet(file_path, data, compression=self.compression, zstd_level=self.zstd_level)
elif self.file_format == CoreOptions.FILE_FORMAT_ORC:
self.file_io.write_orc(file_path, data, compression=self.compression, zstd_level=self.zstd_level)
elif self.file_format == CoreOptions.FILE_FORMAT_AVRO:
self.file_io.write_avro(file_path, data, compression=self.compression, zstd_level=self.zstd_level)
elif self.file_format == CoreOptions.FILE_FORMAT_LANCE:
self.file_io.write_lance(file_path, data)
elif self.file_format == CoreOptions.FILE_FORMAT_VORTEX:
self.file_io.write_vortex(file_path, data)
elif self.file_format == CoreOptions.FILE_FORMAT_MOSAIC:
self.file_io.write_mosaic(file_path, data)
elif self.file_format == CoreOptions.FILE_FORMAT_ROW:
self.file_io.write_row(file_path, data, zstd_level=self.zstd_level)
else:
raise ValueError(f"Unsupported file format: {self.file_format}")
# Determine if this is an external path
is_external_path = self.external_path_provider is not None
external_path_str = file_path if is_external_path else None
return self._create_data_file_meta(file_name, file_path, data, external_path_str)
def _create_data_file_meta(self, file_name: str, file_path: str, data: pa.Table,
external_path: Optional[str] = None) -> DataFileMeta:
# Column stats (only for normal columns)
metadata_stats_enabled = self.options.metadata_stats_enabled()
stats_columns = self.normal_columns if metadata_stats_enabled else []
value_stats = self._collect_value_stats(data, stats_columns)
min_seq, max_seq = self._append_file_sequence_range(data.num_rows)
return DataFileMeta.create(
file_name=file_name,
file_size=self.file_io.get_file_size(file_path),
row_count=data.num_rows,
min_key=GenericRow([], []),
max_key=GenericRow([], []),
key_stats=SimpleStats.empty_stats(),
value_stats=value_stats,
min_sequence_number=min_seq,
max_sequence_number=max_seq,
schema_id=self.table.table_schema.id,
level=0,
extra_files=[],
creation_time=Timestamp.now(),
delete_row_count=0,
file_source=0,
value_stats_cols=[column.name for column in stats_columns],
external_path=external_path,
file_path=file_path,
write_cols=self.write_cols)
def _validate_consistency(
self, normal_meta: DataFileMeta, blob_metas: List[DataFileMeta], blob_column: str):
if normal_meta is None:
return
normal_row_count = normal_meta.row_count
blob_row_count = sum(meta.row_count for meta in blob_metas)
if normal_row_count != blob_row_count:
raise RuntimeError(
f"This is a bug: The row count of main file and blob files does not match. "
f"Main file: {normal_meta.file_name} (row count: {normal_row_count}), "
f"blob field: {blob_column}, "
f"blob files: {[meta.file_name for meta in blob_metas]} (total row count: {blob_row_count})"
)