blob: ca8ce192e99a7e12909c29ab1542e19b358394da [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
from typing import List, Optional, Tuple
import pyarrow as pa
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.schema.data_types import VectorType
from pypaimon.execution.python.write.writer.composite_data_writer import CompositeDataWriter
logger = logging.getLogger(__name__)
class DataVectorWriter(CompositeDataWriter):
"""A rolling file writer that stores vector columns separately from normal columns.
All vector columns are written to a single `.vector.<format>` file (matching
Java behavior), while normal columns go to standard data files.
Metadata organization:
committed_files = [
normal_file_meta, # data.parquet (id, label columns)
vector_file_meta, # data.vector.parquet (all vector columns)
...
]
"""
def __init__(self, table, partition: Tuple, bucket: int, max_seq_number: int,
options: CoreOptions = None, write_cols: Optional[List[str]] = None):
super().__init__(table, partition, bucket, max_seq_number, options, write_cols=write_cols)
self.vector_column_names = self._get_vector_columns_from_schema()
self.vector_file_format = options.vector_file_format()
all_column_names = self.table.field_names
vector_set = set(self.vector_column_names)
if write_cols is not None:
write_col_set = set(write_cols)
self.vector_write_columns = [
col for col in self.vector_column_names if col in write_col_set
]
self.normal_column_names = [
col for col in write_cols if col not in vector_set
]
else:
self.vector_write_columns = list(self.vector_column_names)
self.normal_column_names = [
col for col in all_column_names if col not in vector_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
]
all_normal_column_names = [
col for col in all_column_names if col not in vector_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
)
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=self.vector_file_format,
options=options,
)
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 pa.concat_tables([existing_data, new_data])
def write(self, data: pa.RecordBatch):
self._require_finished_flush()
try:
offset = 0
# _write_batch keeps normal and vector pending rows in lockstep
# and closes both writers when the shared 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
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
normal_data, vector_data = self._split_data(data)
if normal_data is not None:
self._normal_buffer.append(pa.Table.from_batches([normal_data]))
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
if self._should_roll_normal():
self._close_current_writers()
def abort(self):
if self.vector_writer is not None:
self.vector_writer.abort()
super().abort()
def _split_data(self, data: pa.RecordBatch) -> Tuple[pa.RecordBatch, pa.RecordBatch]:
normal_data = (
pa.RecordBatch.from_arrays(
[data.column(name) for name in self.normal_column_names],
names=self.normal_column_names,
)
if self.normal_column_names else None
)
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, vector_data
@property
def pending_row_count(self) -> int:
# Overrides the base property, which reads a buffer this writer never
# fills. Normal and vector rows are kept in lockstep, so either half
# answers for the pair; the vector writer is asked only when the table
# has no normal columns at all.
if not self._normal_buffer.is_empty:
return self._normal_buffer.num_rows
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 _prepare_sidecar_commits(self, normal_meta):
if self.vector_writer is None:
return []
vector_metas = self.vector_writer.prepare_commit()
if vector_metas and normal_meta is not None:
self._validate_consistency(normal_meta, vector_metas)
return [(self.vector_writer, vector_metas)]
def _validate_consistency(
self, normal_meta: DataFileMeta, vector_metas: List[DataFileMeta]):
if normal_meta is None:
return
normal_row_count = normal_meta.row_count
vector_row_count = sum(meta.row_count for meta in vector_metas)
if normal_row_count != vector_row_count:
raise RuntimeError(
f"Row count mismatch between main file and vector files. "
f"Main file: {normal_meta.file_name} (rows: {normal_row_count}), "
f"vector files: {[m.file_name for m in vector_metas]} (rows: {vector_row_count})"
)