| # 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 | |
| from typing import Dict, List, Optional, Tuple | |
| import pyarrow as pa | |
| from pypaimon.common.options.core_options import CoreOptions, ChangelogProducer | |
| from pypaimon.manifest.schema.data_file_meta import DataFileMeta | |
| from pypaimon.schema.arrow_schema import project_arrow_batch | |
| from pypaimon.schema.data_types import ( | |
| PyarrowFieldParser, | |
| VectorType, | |
| is_blob_file_field, | |
| is_blob_type, | |
| ) | |
| from pypaimon.table.row.blob import ( | |
| BlobConsumer, | |
| video_payload_descriptor, | |
| ) | |
| from pypaimon.write.row_utils import ( | |
| inline_blob_value, | |
| require_columns, | |
| row_to_named_values, | |
| row_values_to_arrow_table, | |
| ) | |
| from pypaimon.execution.python.write.writer.composite_data_writer import CompositeDataWriter | |
| from pypaimon.execution.python.write.writer.video_group import VideoGroupRollingPolicy | |
| logger = logging.getLogger(__name__) | |
| class DedicatedFormatWriter(CompositeDataWriter): | |
| """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 or .video 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, …] | |
| """ | |
| 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, | |
| blob_uri_reader_factory=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) | |
| configured_video_fields = CoreOptions.video_frame_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)}" | |
| ) | |
| inline_nested_blob_fields = [ | |
| field.name | |
| for field in self.table.table_schema.fields | |
| if field.name in self.blob_inline_fields and not is_blob_type(field.type) | |
| ] | |
| if inline_nested_blob_fields: | |
| raise ValueError( | |
| "ARRAY<BLOB> and MAP<X, BLOB> are only supported by 'blob-field'. " | |
| f"Invalid inline blob fields: {sorted(inline_nested_blob_fields)}" | |
| ) | |
| # Blob fields that should still be written to dedicated 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 | |
| ] | |
| self.video_frame_columns = [ | |
| column for column in self.blob_file_column_names | |
| if column in configured_video_fields | |
| ] | |
| 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 | |
| ] | |
| all_normal_column_names = [ | |
| col for col in all_column_names if col not in dedicated_set | |
| ] | |
| self.write_cols = ( | |
| None | |
| if options.data_evolution_enabled(False) | |
| and options.data_evolution_write_cols_optimization_enabled(False) | |
| and self.normal_column_names == all_normal_column_names | |
| else self.normal_column_names | |
| ) | |
| self._video_group_policy = ( | |
| VideoGroupRollingPolicy() | |
| if self.video_frame_columns | |
| else None | |
| ) | |
| # Initialize blob writers for each blob-file column. | |
| from pypaimon.execution.python.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, | |
| video=blob_column in configured_video_fields, | |
| uri_reader_factory=blob_uri_reader_factory, | |
| ) | |
| # Initialize vector writer when vector.file.format is configured. | |
| from pypaimon.execution.python.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, | |
| rolling_managed_by_parent=bool(self.video_frame_columns), | |
| ) | |
| logger.info( | |
| "Initialized DedicatedFormatWriter with blob columns: %s, blob file columns: %s, " | |
| "vector columns: %s, descriptor stored columns: %s, view stored columns: %s", | |
| self.blob_column_names, | |
| self.blob_file_column_names, | |
| self.vector_write_columns, | |
| sorted(self.blob_descriptor_fields), | |
| sorted(self.blob_view_fields) | |
| ) | |
| def _get_blob_columns_from_schema(self) -> List[str]: | |
| blob_columns = [ | |
| field.name | |
| for field in self.table.table_schema.fields | |
| if is_blob_file_field(field) | |
| ] | |
| 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): | |
| # Outside the try on purpose: rejecting the write must not abort the | |
| # writer, or the unfinished flush would lose its chance to be retried. | |
| self._require_finished_flush() | |
| try: | |
| if self.video_frame_columns: | |
| self._write_video_batches(data) | |
| return | |
| self._write_bounded_batches(data) | |
| except Exception as e: | |
| logger.error("Exception occurs when writing data. Cleaning up.", exc_info=e) | |
| self.abort() | |
| raise e | |
| def _write_batch(self, data: pa.RecordBatch): | |
| if data.num_rows == 0: | |
| return | |
| # 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: | |
| self._normal_buffer.append(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 | |
| # Defer any active video-group roll to its Episode boundary. | |
| if self._should_roll_active_group(): | |
| self._roll_or_defer_for_video_group() | |
| def write_row(self, row): | |
| self._require_finished_flush() | |
| try: | |
| values_by_name = row_to_named_values( | |
| row, self.table.table_schema.fields) | |
| required_columns = ( | |
| list(self.normal_column_names) | |
| + list(self.blob_file_column_names) | |
| + list(self.vector_write_columns) | |
| ) | |
| require_columns(values_by_name, required_columns, "write_row") | |
| if self.video_frame_columns: | |
| next_group = self._video_group( | |
| values_by_name[column] | |
| for column in self.video_frame_columns | |
| ) | |
| self._roll_before_video_group(next_group) | |
| self._video_group_policy.record(next_group) | |
| if self.normal_column_names: | |
| normal_values = dict(values_by_name) | |
| for field_name in self.normal_column_names: | |
| normal_values[field_name] = ( | |
| self._normal_row_value(field_name, normal_values[field_name]) | |
| ) | |
| normal_data = row_values_to_arrow_table( | |
| normal_values, | |
| self.table.table_schema.fields, | |
| self.normal_column_names, | |
| ).to_batches()[0] | |
| self._validate_inline_stored_fields_input(normal_data) | |
| processed_normal = self._process_normal_data(normal_data) | |
| if processed_normal is not None: | |
| self._normal_buffer.append(processed_normal) | |
| for blob_column in self.blob_file_column_names: | |
| arrow_type = PyarrowFieldParser.from_paimon_type( | |
| self.table.field_dict[blob_column].type) | |
| self.blob_writers[blob_column].write_blob( | |
| values_by_name[blob_column], arrow_type) | |
| if self.vector_writer is not None and self.vector_write_columns: | |
| vector_data = row_values_to_arrow_table( | |
| values_by_name, | |
| self.table.table_schema.fields, | |
| self.vector_write_columns, | |
| ).to_batches()[0] | |
| self.vector_writer.write(vector_data) | |
| self.record_count += 1 | |
| if self._should_roll_active_group(): | |
| self._roll_or_defer_for_video_group() | |
| except Exception as e: | |
| logger.error("Exception occurs when writing row. Cleaning up.", exc_info=e) | |
| self.abort() | |
| raise e | |
| def _normal_row_value(self, field_name: str, value): | |
| return inline_blob_value(value, field_name in self.blob_descriptor_fields, | |
| field_name in self.blob_view_fields) | |
| 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() | |
| super().abort() | |
| def _split_data(self, data: pa.RecordBatch) -> Tuple[ | |
| Optional[pa.RecordBatch], Dict[str, pa.RecordBatch], Optional[pa.RecordBatch]]: | |
| """Split data into normal, blob, and vector parts based on column names.""" | |
| normal_data = ( | |
| self._project_columns(data, self.normal_column_names) | |
| if self.normal_column_names else None | |
| ) | |
| blob_data_map = { | |
| blob_column: self._project_columns(data, [blob_column]) | |
| for blob_column in self.blob_file_column_names | |
| } | |
| vector_data = ( | |
| self._project_columns(data, self.vector_write_columns) | |
| if self.vector_write_columns else None | |
| ) | |
| return normal_data, blob_data_map, vector_data | |
| @staticmethod | |
| def _project_columns(data: pa.RecordBatch, column_names: List[str]) -> pa.RecordBatch: | |
| return project_arrow_batch(data, column_names) | |
| @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 roll_before_group_if_needed(self, row_count: int): | |
| """Roll current files before the next logical write group if needed.""" | |
| self._require_finished_flush() | |
| if self._video_group_policy is None: | |
| return | |
| pending_rows = self.pending_row_count | |
| should_roll = pending_rows > 0 and ( | |
| self._video_group_policy.pending_roll | |
| or pending_rows + row_count > self.target_file_row_num | |
| or ( | |
| not self._normal_buffer.is_empty | |
| and self._normal_buffer.nbytes > self.target_file_size | |
| ) | |
| ) | |
| should_roll = should_roll or any( | |
| self.blob_writers[column].should_roll_before_group(row_count) | |
| for column in self.video_frame_columns | |
| ) | |
| should_roll = should_roll or ( | |
| self.vector_writer is not None | |
| and self.vector_writer.should_roll_before_group(row_count) | |
| ) | |
| if should_roll: | |
| self._close_current_writers() | |
| def _should_roll_active_group(self) -> bool: | |
| return self._should_roll_normal() or ( | |
| self._video_group_policy is not None | |
| and self.vector_writer is not None | |
| and self.vector_writer.rolling_file() | |
| ) | |
| def _roll_or_defer_for_video_group(self): | |
| if self._video_group_policy is not None and self._video_group_policy.defer_roll(): | |
| return | |
| self._close_current_writers() | |
| def _roll_before_video_group(self, next_group): | |
| if ( | |
| self._video_group_policy is not None | |
| and self._video_group_policy.should_roll_before(next_group) | |
| ): | |
| self._close_current_writers() | |
| def _write_video_batches(self, data: pa.RecordBatch): | |
| for batch, group in self._video_group_runs(data): | |
| self._roll_before_video_group(group) | |
| self._video_group_policy.record(group) | |
| if group is None: | |
| self._write_bounded_batches(batch) | |
| else: | |
| self._write_batch(batch) | |
| def _write_bounded_batches(self, data: pa.RecordBatch): | |
| offset = 0 | |
| # _write_batch keeps normal/blob/vector pending rows in lockstep | |
| # and closes all writers when the common row limit is reached. | |
| while offset < data.num_rows: | |
| capacity = self.target_file_row_num - self.pending_row_count | |
| if capacity <= 0: | |
| self._close_current_writers() | |
| capacity = self.target_file_row_num | |
| length = min(capacity, data.num_rows - offset) | |
| self._write_batch(data.slice(offset, length)) | |
| offset += length | |
| def _video_group_runs(self, data: pa.RecordBatch): | |
| column_indices = [ | |
| data.schema.get_field_index(column) | |
| for column in self.video_frame_columns | |
| ] | |
| missing = [ | |
| column for column, index | |
| in zip(self.video_frame_columns, column_indices) | |
| if index < 0 | |
| ] | |
| if missing: | |
| raise KeyError(f"Video columns were not found in the record batch: {missing}") | |
| if data.num_rows == 0: | |
| return | |
| columns = [data.column(index) for index in column_indices] | |
| start = 0 | |
| current_group = self._video_group(column[0] for column in columns) | |
| for index in range(1, data.num_rows): | |
| next_group = self._video_group(column[index] for column in columns) | |
| if next_group != current_group: | |
| yield data.slice(start, index - start), current_group | |
| start = index | |
| current_group = next_group | |
| yield data.slice(start, data.num_rows - start), current_group | |
| @staticmethod | |
| def _video_group(values): | |
| group = tuple(video_payload_descriptor(value) for value in values) | |
| return group if any(value is not None for value in group) else None | |
| @property | |
| def pending_row_count(self) -> int: | |
| # Overrides the base property, which reads a buffer this writer never | |
| # fills. Normal, blob and vector rows are kept in lockstep, so any half | |
| # answers for all of them; the sidecars are asked only when the table | |
| # has no normal columns at all. | |
| if not self._normal_buffer.is_empty: | |
| return self._normal_buffer.num_rows | |
| for blob_writer in self.blob_writers.values(): | |
| if blob_writer.current_writer is not None: | |
| return blob_writer.current_writer.row_count | |
| if self.vector_writer is not None: | |
| # Running count, not a folded buffer: this runs on every write. | |
| return self.vector_writer.pending_row_count | |
| return 0 | |
| def _close_current_writers(self): | |
| super()._close_current_writers() | |
| if self._video_group_policy is not None: | |
| self._video_group_policy.reset() | |
| def _prepare_sidecar_commits(self, normal_meta): | |
| prepared = [] | |
| for blob_column in self.blob_file_column_names: | |
| blob_writer = self.blob_writers[blob_column] | |
| writer_metas = blob_writer.prepare_commit() | |
| if normal_meta is not None: | |
| self._validate_consistency(normal_meta, writer_metas, blob_column) | |
| prepared.append((blob_writer, writer_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') | |
| prepared.append((self.vector_writer, vector_metas)) | |
| return prepared | |
| 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})" | |
| ) |